Merge remote-tracking branch 'origin/develop' into gsouquet/fix-backdrop-filter
* origin/develop: (1278 commits) Add a little padding Keep number field in focus when pressing dialpad buttons (#6520) Remove old version Fix video call persisting when widget removed Update link to matrix-js-sdk CONTRIBUTING file (#6557) $toast-bg-color -> $system $system-... -> $system Iterate PR based on feedback Remove unnecessary code Use AccessibleTooltipButton Just upload the PR object itself Edit PR Description instead of commenting publish the right directory Fix Netflify builds from fork PRs This doesn't need to be here as it was moved into CallViewButtons Make scrollbar dot transparent Iterate PR based on feedback Don't set hidden RRs labs setting at account level Add a comment for weirdly placed div Add full class names to animations.scss ...
This commit is contained in:
commit
5f9b55eaa9
690 changed files with 26605 additions and 12563 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;
|
||||
|
|
24
src/@types/global.d.ts
vendored
24
src/@types/global.d.ts
vendored
|
@ -15,6 +15,8 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
|
||||
// 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";
|
||||
|
@ -48,6 +50,8 @@ import UIStore from "../stores/UIStore";
|
|||
import { SetupEncryptionStore } from "../stores/SetupEncryptionStore";
|
||||
import { RoomScrollStateStore } from "../stores/RoomScrollStateStore";
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
matrixChat: ReturnType<Renderer>;
|
||||
|
@ -88,6 +92,7 @@ declare global {
|
|||
mxUIStore: UIStore;
|
||||
mxSetupEncryptionStore?: SetupEncryptionStore;
|
||||
mxRoomScrollStateStore?: RoomScrollStateStore;
|
||||
mxOnRecaptchaLoaded?: () => void;
|
||||
}
|
||||
|
||||
interface Document {
|
||||
|
@ -112,7 +117,7 @@ declare global {
|
|||
}
|
||||
|
||||
interface StorageEstimate {
|
||||
usageDetails?: {[key: string]: number};
|
||||
usageDetails?: { [key: string]: number };
|
||||
}
|
||||
|
||||
interface HTMLAudioElement {
|
||||
|
@ -183,4 +188,21 @@ 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-enable @typescript-eslint/naming-convention */
|
||||
|
|
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;
|
||||
}
|
23
src/@types/worker-loader.d.ts
vendored
Normal file
23
src/@types/worker-loader.d.ts
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
declare module "*.worker.ts" {
|
||||
class WebpackWorker extends Worker {
|
||||
constructor();
|
||||
}
|
||||
|
||||
export default WebpackWorker;
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -248,7 +248,7 @@ export default class AddThreepid {
|
|||
|
||||
/**
|
||||
* Takes a phone number verification code as entered by the user and validates
|
||||
* it with the ID server, then if successful, adds the phone number.
|
||||
* it with the identity server, then if successful, adds the phone number.
|
||||
* @param {string} msisdnToken phone number verification code as entered by the user
|
||||
* @return {Promise} Resolves if the phone number was added. Rejects with an object
|
||||
* with a "message" property which contains a human-readable message detailing why
|
||||
|
|
|
@ -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,
|
||||
|
@ -395,17 +395,17 @@ export class Analytics {
|
|||
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) =>
|
||||
|
|
|
@ -18,10 +18,11 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
|||
import { User } from "matrix-js-sdk/src/models/user";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
|
||||
import { split } from "lodash";
|
||||
|
||||
import DMRoomMap from './utils/DMRoomMap';
|
||||
import { mediaFromMxc } from "./customisations/Media";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import SpaceStore from "./stores/SpaceStore";
|
||||
|
||||
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already
|
||||
export function avatarUrlForMember(
|
||||
|
@ -122,27 +123,13 @@ export function getInitialLetter(name: string): string {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
let idx = 0;
|
||||
const initial = name[0];
|
||||
if ((initial === '@' || initial === '#' || initial === '+') && name[1]) {
|
||||
idx++;
|
||||
name = name.substring(1);
|
||||
}
|
||||
|
||||
// string.codePointAt(0) would do this, but that isn't supported by
|
||||
// some browsers (notably PhantomJS).
|
||||
let chars = 1;
|
||||
const first = name.charCodeAt(idx);
|
||||
|
||||
// check if it’s the start of a surrogate pair
|
||||
if (first >= 0xD800 && first <= 0xDBFF && name[idx+1]) {
|
||||
const second = name.charCodeAt(idx+1);
|
||||
if (second >= 0xDC00 && second <= 0xDFFF) {
|
||||
chars++;
|
||||
}
|
||||
}
|
||||
|
||||
const firstChar = name.substring(idx, idx+chars);
|
||||
return firstChar.toUpperCase();
|
||||
// rely on the grapheme cluster splitter in lodash so that we don't break apart compound emojis
|
||||
return split(name, "", 1)[0].toUpperCase();
|
||||
}
|
||||
|
||||
export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) {
|
||||
|
@ -153,7 +140,7 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi
|
|||
}
|
||||
|
||||
// space rooms cannot be DMs so skip the rest
|
||||
if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return null;
|
||||
if (SpaceStore.spacesEnabled && room.isSpaceRoom()) return null;
|
||||
|
||||
let otherMember = null;
|
||||
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
|
||||
|
|
60
src/BlurhashEncoder.ts
Normal file
60
src/BlurhashEncoder.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { defer, IDeferred } from "matrix-js-sdk/src/utils";
|
||||
|
||||
// @ts-ignore - `.ts` is needed here to make TS happy
|
||||
import BlurhashWorker from "./workers/blurhash.worker.ts";
|
||||
|
||||
interface IBlurhashWorkerResponse {
|
||||
seq: number;
|
||||
blurhash: string;
|
||||
}
|
||||
|
||||
export class BlurhashEncoder {
|
||||
private static internalInstance = new BlurhashEncoder();
|
||||
|
||||
public static get instance(): BlurhashEncoder {
|
||||
return BlurhashEncoder.internalInstance;
|
||||
}
|
||||
|
||||
private readonly worker: Worker;
|
||||
private seq = 0;
|
||||
private pendingDeferredMap = new Map<number, IDeferred<string>>();
|
||||
|
||||
constructor() {
|
||||
this.worker = new BlurhashWorker();
|
||||
this.worker.onmessage = this.onMessage;
|
||||
}
|
||||
|
||||
private onMessage = (ev: MessageEvent<IBlurhashWorkerResponse>) => {
|
||||
const { seq, blurhash } = ev.data;
|
||||
const deferred = this.pendingDeferredMap.get(seq);
|
||||
if (deferred) {
|
||||
this.pendingDeferredMap.delete(seq);
|
||||
deferred.resolve(blurhash);
|
||||
}
|
||||
};
|
||||
|
||||
public getBlurhash(imageData: ImageData): Promise<string> {
|
||||
const seq = this.seq++;
|
||||
const deferred = defer<string>();
|
||||
this.pendingDeferredMap.set(seq, deferred);
|
||||
this.worker.postMessage({ seq, imageData });
|
||||
return deferred.promise;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 { IPushRule, 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,33 @@ 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 {
|
||||
return this.calls.size > this.silencedCalls.size;
|
||||
}
|
||||
|
||||
private async checkProtocols(maxTries) {
|
||||
try {
|
||||
const protocols = await MatrixClientPeg.get().getThirdpartyProtocols();
|
||||
|
@ -301,6 +330,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;
|
||||
}
|
||||
|
@ -394,7 +430,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;
|
||||
|
@ -441,36 +477,48 @@ export default class CallHandler extends EventEmitter {
|
|||
break;
|
||||
}
|
||||
|
||||
if (newState !== CallState.Ringing) {
|
||||
this.silencedCalls.delete(call.callId);
|
||||
}
|
||||
|
||||
switch (newState) {
|
||||
case CallState.Ringing:
|
||||
this.play(AudioID.Ring);
|
||||
case CallState.Ringing: {
|
||||
const incomingCallPushRule = (
|
||||
new PushProcessor(MatrixClientPeg.get()).getPushRuleById(RuleId.IncomingCall) as IPushRule
|
||||
);
|
||||
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:
|
||||
}
|
||||
case CallState.InviteSent: {
|
||||
this.play(AudioID.Ringback);
|
||||
break;
|
||||
case CallState.Ended:
|
||||
{
|
||||
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', call.hangupReason);
|
||||
}
|
||||
case CallState.Ended: {
|
||||
const hangupReason = call.hangupReason;
|
||||
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', hangupReason);
|
||||
this.removeCallForRoom(mappedRoomId);
|
||||
if (oldState === CallState.InviteSent && (
|
||||
call.hangupParty === CallParty.Remote ||
|
||||
(call.hangupParty === CallParty.Local && call.hangupReason === CallErrorCode.InviteTimeout)
|
||||
)) {
|
||||
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;
|
||||
if (call.hangupReason === CallErrorCode.UserHangup) {
|
||||
title = _t("Call Declined");
|
||||
description = _t("The other party declined the call.");
|
||||
} else if (call.hangupReason === CallErrorCode.UserBusy) {
|
||||
// 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 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");
|
||||
|
@ -480,7 +528,7 @@ export default class CallHandler extends EventEmitter {
|
|||
title, description,
|
||||
});
|
||||
} else if (
|
||||
call.hangupReason === CallErrorCode.AnsweredElsewhere && oldState === CallState.Connecting
|
||||
hangupReason === CallErrorCode.AnsweredElsewhere && oldState === CallState.Connecting
|
||||
) {
|
||||
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
|
||||
title: _t("Answered Elsewhere"),
|
||||
|
@ -600,6 +648,19 @@ export default class CallHandler extends EventEmitter {
|
|||
`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,
|
||||
|
@ -615,23 +676,23 @@ export default class CallHandler extends EventEmitter {
|
|||
|
||||
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 +710,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>;
|
||||
}
|
||||
|
@ -697,25 +758,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);
|
||||
}
|
||||
|
@ -871,6 +913,12 @@ export default class CallHandler extends EventEmitter {
|
|||
case Action.DialNumber:
|
||||
this.dialNumber(payload.number);
|
||||
break;
|
||||
case Action.TransferCallToMatrixID:
|
||||
this.startTransferToMatrixID(payload.call, payload.destination, payload.consultFirst);
|
||||
break;
|
||||
case Action.TransferCallToPhoneNumber:
|
||||
this.startTransferToPhoneNumber(payload.call, payload.destination, payload.consultFirst);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -903,6 +951,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) {
|
||||
console.log("Failed to transfer call", e);
|
||||
Modal.createTrackedDialog('Failed to transfer call', '', ErrorDialog, {
|
||||
title: _t('Transfer Failed'),
|
||||
description: _t('Failed to transfer call'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setActiveCallRoomId(activeCallRoomId: string) {
|
||||
|
@ -940,14 +1032,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;
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { encode } from "blurhash";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
|
||||
import dis from './dispatcher/dispatcher';
|
||||
|
@ -28,7 +27,6 @@ import RoomViewStore from './stores/RoomViewStore';
|
|||
import encrypt from "browser-encrypt-attachment";
|
||||
import extractPngChunks from "png-chunks-extract";
|
||||
import Spinner from "./components/views/elements/Spinner";
|
||||
|
||||
import { Action } from "./dispatcher/actions";
|
||||
import CountlyAnalytics from "./CountlyAnalytics";
|
||||
import {
|
||||
|
@ -39,7 +37,8 @@ import {
|
|||
UploadStartedPayload,
|
||||
} from "./dispatcher/payloads/UploadPayload";
|
||||
import { IUpload } from "./models/IUpload";
|
||||
import { IImageInfo } from "matrix-js-sdk/src/@types/partials";
|
||||
import { IAbortablePromise, IImageInfo } from "matrix-js-sdk/src/@types/partials";
|
||||
import { BlurhashEncoder } from "./BlurhashEncoder";
|
||||
|
||||
const MAX_WIDTH = 800;
|
||||
const MAX_HEIGHT = 600;
|
||||
|
@ -85,10 +84,6 @@ interface IThumbnail {
|
|||
thumbnail: Blob;
|
||||
}
|
||||
|
||||
interface IAbortablePromise<T> extends Promise<T> {
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a thumbnail for a image DOM element.
|
||||
* The image will be smaller than MAX_WIDTH and MAX_HEIGHT.
|
||||
|
@ -107,55 +102,62 @@ interface IAbortablePromise<T> extends Promise<T> {
|
|||
* @return {Promise} A promise that resolves with an object with an info key
|
||||
* and a thumbnail key.
|
||||
*/
|
||||
function createThumbnail(
|
||||
async function createThumbnail(
|
||||
element: ThumbnailableElement,
|
||||
inputWidth: number,
|
||||
inputHeight: number,
|
||||
mimeType: string,
|
||||
): Promise<IThumbnail> {
|
||||
return new Promise((resolve) => {
|
||||
let targetWidth = inputWidth;
|
||||
let targetHeight = inputHeight;
|
||||
if (targetHeight > MAX_HEIGHT) {
|
||||
targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
|
||||
targetHeight = MAX_HEIGHT;
|
||||
}
|
||||
if (targetWidth > MAX_WIDTH) {
|
||||
targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
|
||||
targetWidth = MAX_WIDTH;
|
||||
}
|
||||
let targetWidth = inputWidth;
|
||||
let targetHeight = inputHeight;
|
||||
if (targetHeight > MAX_HEIGHT) {
|
||||
targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
|
||||
targetHeight = MAX_HEIGHT;
|
||||
}
|
||||
if (targetWidth > MAX_WIDTH) {
|
||||
targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
|
||||
targetWidth = MAX_WIDTH;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
let canvas: HTMLCanvasElement | OffscreenCanvas;
|
||||
if (window.OffscreenCanvas) {
|
||||
canvas = new window.OffscreenCanvas(targetWidth, targetHeight);
|
||||
} else {
|
||||
canvas = document.createElement("canvas");
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
const context = canvas.getContext("2d");
|
||||
context.drawImage(element, 0, 0, targetWidth, targetHeight);
|
||||
const imageData = context.getImageData(0, 0, targetWidth, targetHeight);
|
||||
const blurhash = encode(
|
||||
imageData.data,
|
||||
imageData.width,
|
||||
imageData.height,
|
||||
// use 4 components on the longer dimension, if square then both
|
||||
imageData.width >= imageData.height ? 4 : 3,
|
||||
imageData.height >= imageData.width ? 4 : 3,
|
||||
);
|
||||
canvas.toBlob(function(thumbnail) {
|
||||
resolve({
|
||||
info: {
|
||||
thumbnail_info: {
|
||||
w: targetWidth,
|
||||
h: targetHeight,
|
||||
mimetype: thumbnail.type,
|
||||
size: thumbnail.size,
|
||||
},
|
||||
w: inputWidth,
|
||||
h: inputHeight,
|
||||
[BLURHASH_FIELD]: blurhash,
|
||||
},
|
||||
thumbnail,
|
||||
});
|
||||
}, mimeType);
|
||||
});
|
||||
}
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
context.drawImage(element, 0, 0, targetWidth, targetHeight);
|
||||
|
||||
let thumbnailPromise: Promise<Blob>;
|
||||
|
||||
if (window.OffscreenCanvas) {
|
||||
thumbnailPromise = (canvas as OffscreenCanvas).convertToBlob({ type: mimeType });
|
||||
} else {
|
||||
thumbnailPromise = new Promise<Blob>(resolve => (canvas as HTMLCanvasElement).toBlob(resolve, mimeType));
|
||||
}
|
||||
|
||||
const imageData = context.getImageData(0, 0, targetWidth, targetHeight);
|
||||
// thumbnailPromise and blurhash promise are being awaited concurrently
|
||||
const blurhash = await BlurhashEncoder.instance.getBlurhash(imageData);
|
||||
const thumbnail = await thumbnailPromise;
|
||||
|
||||
return {
|
||||
info: {
|
||||
thumbnail_info: {
|
||||
w: targetWidth,
|
||||
h: targetHeight,
|
||||
mimetype: thumbnail.type,
|
||||
size: thumbnail.size,
|
||||
},
|
||||
w: inputWidth,
|
||||
h: inputHeight,
|
||||
[BLURHASH_FIELD]: blurhash,
|
||||
},
|
||||
thumbnail,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -207,6 +209,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.
|
||||
*
|
||||
|
@ -215,23 +225,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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -333,7 +353,7 @@ export function uploadFile(
|
|||
roomId: string,
|
||||
file: File | Blob,
|
||||
progressHandler?: any, // TODO: Types
|
||||
): Promise<{url?: string, file?: any}> { // TODO: Types
|
||||
): IAbortablePromise<{url?: string, file?: any}> { // TODO: Types
|
||||
let canceled = false;
|
||||
if (matrixClient.isRoomEncrypted(roomId)) {
|
||||
// If the room is encrypted then encrypt the file before uploading it.
|
||||
|
@ -365,8 +385,8 @@ export function uploadFile(
|
|||
encryptInfo.mimetype = file.type;
|
||||
}
|
||||
return { "file": encryptInfo };
|
||||
});
|
||||
(prom as IAbortablePromise<any>).abort = () => {
|
||||
}) as IAbortablePromise<{ file: any }>;
|
||||
prom.abort = () => {
|
||||
canceled = true;
|
||||
if (uploadPromise) matrixClient.cancelUpload(uploadPromise);
|
||||
};
|
||||
|
@ -379,8 +399,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);
|
||||
};
|
||||
|
@ -423,10 +443,10 @@ export default class ContentMessages {
|
|||
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"),
|
||||
|
@ -551,10 +571,10 @@ export default class ContentMessages {
|
|||
content.msgtype = 'm.file';
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}) as IAbortablePromise<void>;
|
||||
|
||||
// create temporary abort handler for before the actual upload gets passed off to js-sdk
|
||||
(prom as IAbortablePromise<any>).abort = () => {
|
||||
prom.abort = () => {
|
||||
upload.canceled = true;
|
||||
};
|
||||
|
||||
|
@ -583,9 +603,7 @@ export default class ContentMessages {
|
|||
// XXX: upload.promise must be the promise that
|
||||
// is returned by uploadFile as it has an abort()
|
||||
// method hacked onto it.
|
||||
upload.promise = uploadFile(
|
||||
matrixClient, roomId, file, onProgress,
|
||||
);
|
||||
upload.promise = uploadFile(matrixClient, roomId, file, onProgress);
|
||||
return upload.promise.then(function(result) {
|
||||
content.file = result.file;
|
||||
content.url = result.url;
|
||||
|
|
|
@ -364,8 +364,8 @@ export default class CountlyAnalytics {
|
|||
|
||||
private initTime = CountlyAnalytics.getTimestamp();
|
||||
private firstPage = true;
|
||||
private heartbeatIntervalId: NodeJS.Timeout;
|
||||
private activityIntervalId: NodeJS.Timeout;
|
||||
private heartbeatIntervalId: number;
|
||||
private activityIntervalId: number;
|
||||
private trackTime = true;
|
||||
private lastBeat: number;
|
||||
private storedDuration = 0;
|
||||
|
|
|
@ -123,6 +123,19 @@ 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;
|
||||
}
|
||||
|
||||
const MILLIS_IN_DAY = 86400000;
|
||||
export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean {
|
||||
if (!nextEventDate || !prevEventDate) {
|
||||
|
|
|
@ -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,7 @@ 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";
|
||||
|
||||
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
|
||||
|
||||
|
@ -58,28 +59,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);
|
||||
|
@ -103,15 +104,15 @@ export default class DeviceListener {
|
|||
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 +121,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
|
||||
|
@ -163,32 +164,32 @@ export default class DeviceListener {
|
|||
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();
|
||||
|
@ -206,7 +207,7 @@ 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;
|
||||
|
@ -235,7 +236,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);
|
||||
|
@ -256,7 +257,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
|
||||
|
|
|
@ -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,33 @@ 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)\/(.+?)\/(.+?)(?:[?/]|$)/;
|
||||
|
||||
|
@ -80,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 {
|
||||
|
@ -153,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;
|
||||
}
|
||||
|
|
|
@ -127,7 +127,7 @@ export default class IdentityAuthClient {
|
|||
await this._matrixClient.getIdentityAccount(token);
|
||||
} catch (e) {
|
||||
if (e.errcode === "M_TERMS_NOT_SIGNED") {
|
||||
console.log("Identity Server requires new terms to be agreed to");
|
||||
console.log("Identity server requires new terms to be agreed to");
|
||||
await startTermsFlow([new Service(
|
||||
SERVICE_TYPES.IS,
|
||||
identityServerUrl,
|
||||
|
@ -146,23 +146,23 @@ export default class IdentityAuthClient {
|
|||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
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) {
|
||||
|
|
|
@ -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";
|
||||
|
@ -47,6 +48,7 @@ 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";
|
||||
|
@ -65,7 +67,7 @@ interface ILoadSessionOpts {
|
|||
guestIsUrl?: string;
|
||||
ignoreGuest?: boolean;
|
||||
defaultDeviceDisplayName?: string;
|
||||
fragmentQueryParams?: Record<string, string>;
|
||||
fragmentQueryParams?: QueryDict;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -118,8 +120,8 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise<boolean>
|
|||
) {
|
||||
console.log("Using guest access credentials");
|
||||
return doSetLoggedIn({
|
||||
userId: fragmentQueryParams.guest_user_id,
|
||||
accessToken: fragmentQueryParams.guest_access_token,
|
||||
userId: fragmentQueryParams.guest_user_id as string,
|
||||
accessToken: fragmentQueryParams.guest_access_token as string,
|
||||
homeserverUrl: guestHsUrl,
|
||||
identityServerUrl: guestIsUrl,
|
||||
guest: true,
|
||||
|
@ -173,7 +175,7 @@ export async function getStoredSessionOwner(): Promise<[string, boolean]> {
|
|||
* login, else false
|
||||
*/
|
||||
export function attemptTokenLogin(
|
||||
queryParams: Record<string, string>,
|
||||
queryParams: QueryDict,
|
||||
defaultDeviceDisplayName?: string,
|
||||
fragmentAfterLogin?: string,
|
||||
): Promise<boolean> {
|
||||
|
@ -198,7 +200,7 @@ export function attemptTokenLogin(
|
|||
homeserver,
|
||||
identityServer,
|
||||
"m.login.token", {
|
||||
token: queryParams.loginToken,
|
||||
token: queryParams.loginToken as string,
|
||||
initial_device_display_name: defaultDeviceDisplayName,
|
||||
},
|
||||
).then(function(creds) {
|
||||
|
@ -572,6 +574,8 @@ async function doSetLoggedIn(
|
|||
await abortLogin();
|
||||
}
|
||||
|
||||
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
|
||||
|
||||
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
|
||||
|
||||
MatrixClientPeg.replaceUsingCreds(credentials);
|
||||
|
@ -699,6 +703,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.
|
||||
|
|
|
@ -17,8 +17,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ICreateClientOpts } from 'matrix-js-sdk/src/matrix';
|
||||
import { MatrixClient } from 'matrix-js-sdk/src/client';
|
||||
import { ICreateClientOpts, PendingEventOrdering } from 'matrix-js-sdk/src/matrix';
|
||||
import { IStartClientOpts, MatrixClient } from 'matrix-js-sdk/src/client';
|
||||
import { MemoryStore } from 'matrix-js-sdk/src/store/memory';
|
||||
import * as utils from 'matrix-js-sdk/src/utils';
|
||||
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
|
||||
|
@ -47,25 +47,8 @@ export interface IMatrixClientCreds {
|
|||
freshLogin?: boolean;
|
||||
}
|
||||
|
||||
// TODO: Move this to the js-sdk
|
||||
export interface IOpts {
|
||||
initialSyncLimit?: number;
|
||||
pendingEventOrdering?: "detached" | "chronological";
|
||||
lazyLoadMembers?: boolean;
|
||||
clientWellKnownPollPeriod?: number;
|
||||
}
|
||||
|
||||
export interface IMatrixClientPeg {
|
||||
opts: IOpts;
|
||||
|
||||
/**
|
||||
* Sets the script href passed to the IndexedDB web worker
|
||||
* If set, a separate web worker will be started to run the IndexedDB
|
||||
* queries on.
|
||||
*
|
||||
* @param {string} script href to the script to be passed to the web worker
|
||||
*/
|
||||
setIndexedDbWorkerScript(script: string): void;
|
||||
opts: IStartClientOpts;
|
||||
|
||||
/**
|
||||
* Return the server name of the user's homeserver
|
||||
|
@ -122,12 +105,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 +124,6 @@ class _MatrixClientPeg implements IMatrixClientPeg {
|
|||
constructor() {
|
||||
}
|
||||
|
||||
public setIndexedDbWorkerScript(script: string): void {
|
||||
createMatrixClient.indexedDbWorkerScript = script;
|
||||
}
|
||||
|
||||
public get(): MatrixClient {
|
||||
return this.matrixClient;
|
||||
}
|
||||
|
@ -231,7 +210,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
|
|||
|
||||
const opts = utils.deepCopy(this.opts);
|
||||
// the react sdk doesn't work without this, so don't allow
|
||||
opts.pendingEventOrdering = "detached";
|
||||
opts.pendingEventOrdering = PendingEventOrdering.Detached;
|
||||
opts.lazyLoadMembers = true;
|
||||
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
|
||||
|
||||
|
@ -321,7 +300,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
|
|||
}
|
||||
|
||||
if (!window.mxMatrixClientPeg) {
|
||||
window.mxMatrixClientPeg = new _MatrixClientPeg();
|
||||
window.mxMatrixClientPeg = new MatrixClientPegClass();
|
||||
}
|
||||
|
||||
export const MatrixClientPeg = window.mxMatrixClientPeg;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -328,7 +328,7 @@ export const Notifier = {
|
|||
|
||||
onEvent: function(ev: MatrixEvent) {
|
||||
if (!this.isSyncing) return; // don't alert for any messages initially
|
||||
if (ev.sender && ev.sender.userId === MatrixClientPeg.get().credentials.userId) return;
|
||||
if (ev.getSender() === MatrixClientPeg.get().credentials.userId) return;
|
||||
|
||||
MatrixClientPeg.get().decryptEventIfNeeded(ev);
|
||||
|
||||
|
|
355
src/PosthogAnalytics.ts
Normal file
355
src/PosthogAnalytics.ts
Normal file
|
@ -0,0 +1,355 @@
|
|||
/*
|
||||
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';
|
||||
|
||||
/* 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, i.e.
|
||||
* hash all matrix identifiers in tracking events (user IDs, room IDs etc) using SHA-256.
|
||||
* - Otherwise, if the existing `analyticsOptIn` flag is `true`, track anonymously, i.e.
|
||||
* redact all matrix identifiers in tracking events.
|
||||
* - 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 hashHex = async (input: string): Promise<string> => {
|
||||
const buf = new TextEncoder().encode(input);
|
||||
const digestBuf = await window.crypto.subtle.digest("sha-256", buf);
|
||||
return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
|
||||
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.
|
||||
// If anonymous is true, redact entirely, if false, substitute it with a hash.
|
||||
// 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, ...parts] = hash.split("/");
|
||||
|
||||
if (!whitelistedScreens.has(screen)) {
|
||||
screen = "<redacted_screen_name>";
|
||||
}
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
parts[i] = anonymity === Anonymity.Anonymous ? `<redacted>` : await hashHex(parts[i]);
|
||||
}
|
||||
|
||||
hashStr = `${beforeFirstSlash}/${screen}/${parts.join("/")}`;
|
||||
}
|
||||
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 all identifers will be redacted before being passed to posthog
|
||||
* - Anonymity.Pseudonymous means all identifiers will be hashed via SHA-256 before being 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, IPseudonymousEvent or IRoomEvent.
|
||||
* 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 = {};
|
||||
|
||||
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']) {
|
||||
console.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;
|
||||
}
|
||||
|
||||
public async identifyUser(userId: string): Promise<void> {
|
||||
if (this.anonymity == Anonymity.Pseudonymous) {
|
||||
this.posthog.identify(await hashHex(userId));
|
||||
}
|
||||
}
|
||||
|
||||
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 trackRoomEvent<E extends IRoomEvent>(
|
||||
eventName: E["eventName"],
|
||||
roomId: string,
|
||||
properties: Omit<E["properties"], "roomId">,
|
||||
): Promise<void> {
|
||||
const updatedProperties = {
|
||||
...properties,
|
||||
hashedRoomId: roomId ? await hashHex(roomId) : null,
|
||||
};
|
||||
await this.trackPseudonymousEvent(eventName, updatedProperties);
|
||||
}
|
||||
|
||||
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(userId);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -51,10 +51,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) {
|
||||
|
|
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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);
|
|
@ -34,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";
|
||||
|
@ -49,6 +48,7 @@ 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 ErrorDialog from './components/views/dialogs/ErrorDialog';
|
||||
import DevtoolsDialog from './components/views/dialogs/DevtoolsDialog';
|
||||
|
@ -277,50 +277,8 @@ export const Commands = [
|
|||
/*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);
|
||||
|
||||
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());
|
||||
|
@ -480,14 +438,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"),
|
||||
},
|
||||
);
|
||||
|
@ -522,7 +480,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
|
||||
|
@ -1069,7 +1027,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);
|
||||
|
|
|
@ -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,7 @@ function textForTopicEvent(ev): () => string | null {
|
|||
});
|
||||
}
|
||||
|
||||
function textForRoomNameEvent(ev): () => string | null {
|
||||
function textForRoomNameEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
|
||||
if (!ev.getContent().name || ev.getContent().name.trim().length === 0) {
|
||||
|
@ -154,12 +185,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 +210,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 +226,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 +256,7 @@ function textForRelatedGroupsEvent(ev): () => string | null {
|
|||
}
|
||||
}
|
||||
|
||||
function textForServerACLEvent(ev): () => string | null {
|
||||
function textForServerACLEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
const prevContent = ev.getPrevContent();
|
||||
const current = ev.getContent();
|
||||
|
@ -255,7 +286,7 @@ function textForServerACLEvent(ev): () => string | null {
|
|||
return getText;
|
||||
}
|
||||
|
||||
function textForMessageEvent(ev): () => string | null {
|
||||
function textForMessageEvent(ev: MatrixEvent): () => string | null {
|
||||
return () => {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
let message = senderDisplayName + ': ' + ev.getContent().body;
|
||||
|
@ -268,7 +299,7 @@ function textForMessageEvent(ev): () => string | null {
|
|||
};
|
||||
}
|
||||
|
||||
function textForCanonicalAliasEvent(ev): () => string | null {
|
||||
function textForCanonicalAliasEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
const oldAlias = ev.getPrevContent().alias;
|
||||
const oldAltAliases = ev.getPrevContent().alt_aliases || [];
|
||||
|
@ -319,91 +350,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 +366,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 +388,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 +411,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,8 +434,8 @@ 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(", "),
|
||||
});
|
||||
|
@ -515,7 +470,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 +500,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 +593,14 @@ 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.call.invite': textForCallInviteEvent,
|
||||
'm.call.answer': textForCallAnswerEvent,
|
||||
'm.call.hangup': textForCallHangupEvent,
|
||||
'm.call.reject': textForCallRejectEvent,
|
||||
};
|
||||
|
||||
const stateHandlers: IHandlers = {
|
||||
|
@ -674,14 +628,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)) {
|
||||
|
|
|
@ -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,8 +370,8 @@ 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>;
|
||||
});
|
||||
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
||||
|
|
|
@ -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}
|
|
@ -134,8 +134,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,
|
||||
);
|
||||
};
|
||||
|
@ -161,19 +162,19 @@ 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'
|
||||
|
@ -188,7 +189,7 @@ export default class ManageEventIndexDialog extends React.Component<IProps, ISta
|
|||
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>
|
||||
);
|
||||
|
|
|
@ -474,10 +474,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 +493,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 +507,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 +536,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 +548,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 +571,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 +579,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 +609,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 +637,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 +660,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
autoComplete="new-password"
|
||||
/>
|
||||
<div className="mx_CreateSecretStorageDialog_passPhraseMatch">
|
||||
{passPhraseMatch}
|
||||
{ passPhraseMatch }
|
||||
</div>
|
||||
</div>
|
||||
<DialogButtons
|
||||
|
@ -672,7 +672,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 +691,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 +733,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 +746,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 +788,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 +858,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.playOnly(this);
|
||||
return super.play();
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.manager.destroyPlaybackInstance(this);
|
||||
super.destroy();
|
||||
}
|
||||
}
|
|
@ -31,30 +31,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 +72,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);
|
||||
}
|
||||
|
@ -121,36 +122,64 @@ export class Playback extends EventEmitter implements IDestroyable {
|
|||
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
|
||||
console.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;
|
||||
}
|
||||
|
||||
private onPlaybackEnd = async () => {
|
||||
|
@ -163,7 +192,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 +207,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 +274,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*
|
|
@ -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,6 +132,10 @@ 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) {
|
54
src/audio/PlaybackManager.ts
Normal file
54
src/audio/PlaybackManager.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
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 { 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops all other playback instances. If no playback is provided, all instances
|
||||
* are stopped.
|
||||
* @param playback Optional. The playback to leave untouched.
|
||||
*/
|
||||
public playOnly(playback?: Playback) {
|
||||
this.instances.filter(p => p !== playback).forEach(p => p.stop());
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -22,14 +22,29 @@ 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);
|
||||
const currentSecond = roundTimeToTargetFreq(currentTime);
|
||||
if (currentSecond === this.nextAmplitudeSecond) {
|
||||
// We're expecting exactly one mono input source, so just grab the very first frame of
|
||||
// samples for the analysis.
|
||||
|
@ -47,9 +62,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;
|
||||
}
|
||||
}
|
|
@ -32,6 +32,6 @@ export interface ITimingPayload extends IPayload {
|
|||
|
||||
export interface IAmplitudePayload extends IPayload {
|
||||
ev: PayloadEvent.AmplitudeMark;
|
||||
forSecond: number;
|
||||
forIndex: number;
|
||||
amplitude: number;
|
||||
}
|
|
@ -27,8 +27,8 @@ import EmojiProvider from './EmojiProvider';
|
|||
import NotifProvider from './NotifProvider';
|
||||
import { timeout } from "../utils/promise";
|
||||
import AutocompleteProvider, { ICommand } from "./AutocompleteProvider";
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import SpaceProvider from "./SpaceProvider";
|
||||
import SpaceStore from "../stores/SpaceStore";
|
||||
|
||||
export interface ISelectionRange {
|
||||
beginning?: boolean; // whether the selection is in the first block of the editor or not
|
||||
|
@ -58,8 +58,7 @@ const PROVIDERS = [
|
|||
DuckDuckGoProvider,
|
||||
];
|
||||
|
||||
// as the spaces feature is device configurable only, and toggling it refreshes the page, we can do this here
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
PROVIDERS.push(SpaceProvider);
|
||||
} else {
|
||||
PROVIDERS.push(CommunityProvider);
|
||||
|
|
|
@ -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: ['emoji.emoticon'],
|
||||
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,
|
||||
|
@ -105,34 +100,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;
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
@ -109,7 +109,7 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
limit = -1,
|
||||
): Promise<ICompletion[]> {
|
||||
// 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);
|
||||
|
@ -147,7 +147,7 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
return _t('Users');
|
||||
}
|
||||
|
||||
_makeUsers() {
|
||||
private makeUsers() {
|
||||
const events = this.room.getLiveTimeline().getEvents();
|
||||
const lastSpoken = {};
|
||||
|
||||
|
|
|
@ -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>);
|
||||
|
|
170
src/components/structures/CallEventGrouper.ts
Normal file
170
src/components/structures/CallEventGrouper.ts
Normal file
|
@ -0,0 +1,170 @@
|
|||
/*
|
||||
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",
|
||||
}
|
||||
|
||||
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 {
|
||||
return [...this.events][0].getContent().call_id;
|
||||
}
|
||||
|
||||
private onSilencedCallsChanged = () => {
|
||||
const newState = CallHandler.sharedInstance().isCallSilenced(this.callId);
|
||||
this.emit(CallEventGrouperEvent.SilencedChanged, newState);
|
||||
};
|
||||
|
||||
public answerCall = () => {
|
||||
this.call?.answer();
|
||||
};
|
||||
|
||||
public rejectCall = () => {
|
||||
this.call?.reject();
|
||||
};
|
||||
|
||||
public callBack = () => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: 'place_call',
|
||||
type: this.isVoice ? CallType.Voice : CallType.Video,
|
||||
room_id: [...this.events][0]?.getRoomId(),
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
|
@ -80,6 +80,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
|
||||
|
@ -390,7 +394,13 @@ 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -461,10 +471,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 (
|
||||
|
|
|
@ -120,16 +120,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>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@ 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;
|
||||
|
@ -241,8 +242,8 @@ class FilePanel extends React.Component<IProps, IState> {
|
|||
// wrap a TimelinePanel with the jump-to-event bits turned off.
|
||||
|
||||
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);
|
||||
|
@ -262,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={TileShape.FileGrid}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
empty={emptyState}
|
||||
layout={Layout.Group}
|
||||
/>
|
||||
</BaseCard>
|
||||
);
|
||||
|
|
|
@ -28,8 +28,8 @@ export default class GenericErrorPage extends React.PureComponent {
|
|||
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>;
|
||||
}
|
||||
|
|
|
@ -41,6 +41,9 @@ 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>,
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -54,7 +54,7 @@ export default class InteractiveAuthComponent extends React.Component {
|
|||
// * emailSid {string} If email auth was performed, the sid of
|
||||
// the auth session.
|
||||
// * clientSecret {string} The client secret used in auth
|
||||
// sessions with the ID server.
|
||||
// sessions with the identity server.
|
||||
onAuthFinished: PropTypes.func.isRequired,
|
||||
|
||||
// Inputs provided by the user to the auth process
|
||||
|
|
|
@ -369,9 +369,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>
|
||||
|
@ -406,7 +403,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
onSelectRoom={this.selectRoom}
|
||||
/>
|
||||
|
||||
{dialPadButton}
|
||||
{ dialPadButton }
|
||||
|
||||
<AccessibleTooltipButton
|
||||
className={classNames("mx_LeftPanel_exploreButton", {
|
||||
|
@ -425,7 +422,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
leftLeftPanel = (
|
||||
<div className="mx_LeftPanel_GroupFilterPanelContainer">
|
||||
<GroupFilterPanel />
|
||||
{SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null}
|
||||
{ SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -453,11 +450,11 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
|
||||
return (
|
||||
<div className={containerClasses} ref={this.ref}>
|
||||
{leftLeftPanel}
|
||||
{ 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
|
||||
|
@ -467,7 +464,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 /> }
|
||||
|
|
|
@ -125,15 +125,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>
|
||||
|
||||
|
|
|
@ -17,8 +17,8 @@ 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';
|
||||
|
@ -66,6 +66,7 @@ import MyGroups from "./MyGroups";
|
|||
import UserView from "./UserView";
|
||||
import GroupView from "./GroupView";
|
||||
import BackdropPanel from "./BackdropPanel";
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
|
||||
// We need to fetch each pinned message individually (if we don't already have it)
|
||||
// so each pinned message may trigger a request. Limit the number per room for sanity.
|
||||
|
@ -81,6 +82,8 @@ 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;
|
||||
|
@ -143,18 +146,6 @@ 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.
|
||||
};
|
||||
|
||||
protected readonly _matrixClient: MatrixClient;
|
||||
protected readonly _roomView: React.RefObject<any>;
|
||||
protected readonly _resizeContainer: React.RefObject<ResizeHandle>;
|
||||
|
@ -184,10 +175,10 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('keydown', this._onNativeKeyDown, false);
|
||||
document.addEventListener('keydown', this.onNativeKeyDown, false);
|
||||
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
|
||||
|
||||
this._updateServerNoticeEvents();
|
||||
this.updateServerNoticeEvents();
|
||||
|
||||
this._matrixClient.on("accountData", this.onAccountData);
|
||||
this._matrixClient.on("sync", this.onSync);
|
||||
|
@ -203,15 +194,15 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
"useCompactLayout", null, this.onCompactLayoutChanged,
|
||||
);
|
||||
|
||||
this.resizer = this._createResizer();
|
||||
this.resizer = this.createResizer();
|
||||
this.resizer.attach();
|
||||
this._loadResizerPreferences();
|
||||
|
||||
OwnProfileStore.instance.on(UPDATE_EVENT, this.refreshBackgroundImage);
|
||||
this.loadResizerPreferences();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('keydown', this._onNativeKeyDown, false);
|
||||
document.removeEventListener('keydown', this.onNativeKeyDown, false);
|
||||
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
|
||||
this._matrixClient.removeListener("accountData", this.onAccountData);
|
||||
this._matrixClient.removeListener("sync", this.onSync);
|
||||
|
@ -233,37 +224,37 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
canResetTimelineInRoom = (roomId) => {
|
||||
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 => {
|
||||
|
@ -279,7 +270,7 @@ 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;
|
||||
|
@ -287,7 +278,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
this.resizer.forHandleAt(0).resize(lhsSize);
|
||||
}
|
||||
|
||||
onAccountData = (event) => {
|
||||
private onAccountData = (event: MatrixEvent) => {
|
||||
if (event.getType() === "m.ignored_user_list") {
|
||||
dis.dispatch({ action: "ignore_state_changed" });
|
||||
}
|
||||
|
@ -319,16 +310,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();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -338,7 +329,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;
|
||||
|
@ -358,7 +349,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
_updateServerNoticeEvents = async () => {
|
||||
private updateServerNoticeEvents = async () => {
|
||||
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
|
||||
if (!serverNoticeList) return [];
|
||||
|
||||
|
@ -390,7 +381,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,
|
||||
|
@ -399,7 +390,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
|
||||
|
@ -437,22 +428,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);
|
||||
|
@ -462,7 +453,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:
|
||||
|
@ -577,7 +568,7 @@ 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);
|
||||
}
|
||||
|
@ -637,8 +628,8 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
return (
|
||||
<MatrixClientContext.Provider value={this._matrixClient}>
|
||||
<div
|
||||
onPaste={this._onPaste}
|
||||
onKeyDown={this._onReactKeyDown}
|
||||
onPaste={this.onPaste}
|
||||
onKeyDown={this.onReactKeyDown}
|
||||
className='mx_MatrixChat_wrapper'
|
||||
aria-hidden={this.props.hideToSRUsers}
|
||||
>
|
||||
|
@ -647,9 +638,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
<BackdropPanel
|
||||
backgroundImage={this.state.backgroundImage}
|
||||
/>
|
||||
{ SettingsStore.getValue("feature_spaces")
|
||||
? <SpacePanel />
|
||||
: null }
|
||||
{ SpaceStore.spacesEnabled ? <SpacePanel /> : null }
|
||||
<LeftPanel
|
||||
isMinimized={this.props.collapseLhs || false}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
|
@ -661,7 +650,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
<CallContainer />
|
||||
<NonUrgentToastContainer />
|
||||
<HostSignupContainer />
|
||||
{audioFeedArraysForCalls}
|
||||
{ audioFeedArraysForCalls }
|
||||
</MatrixClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ import { createClient } from "matrix-js-sdk/src/matrix";
|
|||
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { sleep, defer, IDeferred } from "matrix-js-sdk/src/utils";
|
||||
import { sleep, defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils";
|
||||
|
||||
// focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss
|
||||
import 'focus-visible';
|
||||
|
@ -105,6 +105,9 @@ 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';
|
||||
|
||||
/** constants for MatrixChat.state.view */
|
||||
export enum Views {
|
||||
|
@ -153,7 +156,7 @@ const ONBOARDING_FLOW_STARTERS = [
|
|||
|
||||
interface IScreen {
|
||||
screen: string;
|
||||
params?: object;
|
||||
params?: QueryDict;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
|
@ -183,9 +186,9 @@ interface IProps { // TODO type things better
|
|||
onNewScreen: (screen: string, replaceLast: boolean) => void;
|
||||
enableGuest?: boolean;
|
||||
// the queryParams extracted from the [real] query-string of the URI
|
||||
realQueryParams?: Record<string, string>;
|
||||
realQueryParams?: QueryDict;
|
||||
// the initial queryParams extracted from the hash-fragment of the URI
|
||||
startingFragmentQueryParams?: Record<string, string>;
|
||||
startingFragmentQueryParams?: QueryDict;
|
||||
// called when we have completed a token login
|
||||
onTokenLoginCompleted?: () => void;
|
||||
// Represents the screen to display as a result of parsing the initial window.location
|
||||
|
@ -193,7 +196,7 @@ interface IProps { // TODO type things better
|
|||
// displayname, if any, to set on the device when logging in/registering.
|
||||
defaultDeviceDisplayName?: string;
|
||||
// A function that makes a registration URL
|
||||
makeRegistrationUrl: (object) => string;
|
||||
makeRegistrationUrl: (params: QueryDict) => string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -251,7 +254,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
private pageChanging: boolean;
|
||||
private tokenLogin?: boolean;
|
||||
private accountPassword?: string;
|
||||
private accountPasswordTimer?: NodeJS.Timeout;
|
||||
private accountPasswordTimer?: number;
|
||||
private focusComposer: boolean;
|
||||
private subTitleStatus: string;
|
||||
private prevWindowWidth: number;
|
||||
|
@ -296,7 +299,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -385,6 +388,10 @@ 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);
|
||||
}
|
||||
|
||||
|
@ -429,7 +436,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();
|
||||
|
@ -441,6 +448,7 @@ 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.FocusSendMessageComposer);
|
||||
|
@ -561,7 +569,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
switch (payload.action) {
|
||||
case 'MatrixActions.accountData':
|
||||
// XXX: This is a collection of several hacks to solve a minor problem. We want to
|
||||
// update our local state when the ID server changes, but don't want to put that in
|
||||
// update our local state when the identity server changes, but don't want to put that in
|
||||
// the js-sdk as we'd be then dictating how all consumers need to behave. However,
|
||||
// this component is already bloated and we probably don't want this tiny logic in
|
||||
// here, but there's no better place in the react-sdk for it. Additionally, we're
|
||||
|
@ -627,6 +635,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
case 'forget_room':
|
||||
this.forgetRoom(payload.room_id);
|
||||
break;
|
||||
case 'copy_room':
|
||||
this.copyRoom(payload.room_id);
|
||||
break;
|
||||
case 'reject_invite':
|
||||
Modal.createTrackedDialog('Reject invitation', '', QuestionDialog, {
|
||||
title: _t('Reject invitation'),
|
||||
|
@ -1099,7 +1110,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 = [];
|
||||
|
||||
|
@ -1107,7 +1118,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>
|
||||
|
@ -1122,7 +1133,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.") }
|
||||
|
@ -1137,7 +1148,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
|
||||
const warnings = this.leaveRoomWarnings(roomId);
|
||||
|
||||
const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom();
|
||||
const isSpace = SpaceStore.spacesEnabled && roomToLeave?.isSpaceRoom();
|
||||
Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, {
|
||||
title: isSpace ? _t("Leave space") : _t("Leave room"),
|
||||
description: (
|
||||
|
@ -1150,7 +1161,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>
|
||||
),
|
||||
|
@ -1193,6 +1204,17 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
private async copyRoom(roomId: string) {
|
||||
const roomLink = makeRoomPermalink(roomId);
|
||||
const success = await copyPlaintext(roomLink);
|
||||
if (!success) {
|
||||
Modal.createTrackedDialog("Unable to copy room link", "", ErrorDialog, {
|
||||
title: _t("Unable to copy room link"),
|
||||
description: _t("Unable to copy a link to the room to the clipboard."),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a chat with the welcome user, if the user doesn't already have one
|
||||
* @returns {string} The room ID of the new room, or null if no room was created
|
||||
|
@ -1687,7 +1709,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
const type = screen === "start_sso" ? "sso" : "cas";
|
||||
PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin());
|
||||
} else if (screen === 'groups') {
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
dis.dispatch({ action: "view_home_page" });
|
||||
return;
|
||||
}
|
||||
|
@ -1774,7 +1796,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
subAction: params.action,
|
||||
});
|
||||
} else if (screen.indexOf('group/') === 0) {
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
dis.dispatch({ action: "view_home_page" });
|
||||
return;
|
||||
}
|
||||
|
@ -1848,13 +1870,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");
|
||||
};
|
||||
|
@ -1936,7 +1951,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;
|
||||
}
|
||||
|
@ -2027,7 +2042,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
{...this.state}
|
||||
ref={this.loggedInView}
|
||||
matrixClient={MatrixClientPeg.get()}
|
||||
onRoomCreated={this.onRoomCreated}
|
||||
onRegistered={this.onRegistered}
|
||||
currentRoomId={this.state.currentRoomId}
|
||||
/>
|
||||
|
@ -2037,15 +2051,15 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
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>
|
||||
);
|
||||
|
@ -2091,7 +2105,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
onForgotPasswordClick={showPasswordReset ? this.onForgotPasswordClick : undefined}
|
||||
onServerConfigChange={this.onServerConfigChange}
|
||||
fragmentAfterLogin={fragmentAfterLogin}
|
||||
defaultUsername={this.props.startingFragmentQueryParams.defaultUsername}
|
||||
defaultUsername={this.props.startingFragmentQueryParams.defaultUsername as string}
|
||||
{...this.getServerProperties()}
|
||||
/>
|
||||
);
|
||||
|
@ -2108,7 +2122,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
|
||||
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';
|
||||
|
@ -50,11 +51,20 @@ import EditorStateTransfer from "../../utils/EditorStateTransfer";
|
|||
|
||||
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 {
|
||||
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 +84,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;
|
||||
}
|
||||
|
@ -228,6 +238,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 +254,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 +263,14 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.calculateRoomMembersCount();
|
||||
this.props.room?.on("RoomState.members", this.calculateRoomMembersCount);
|
||||
this.isMounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.isMounted = false;
|
||||
this.props.room?.off("RoomState.members", this.calculateRoomMembersCount);
|
||||
SettingsStore.unwatchSetting(this.showTypingNotificationsWatcherRef);
|
||||
}
|
||||
|
||||
|
@ -265,6 +284,10 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
private calculateRoomMembersCount = (): void => {
|
||||
this.membersCount = this.props.room?.getMembers().length || 0;
|
||||
};
|
||||
|
||||
private onShowTypingNotificationsChange = (): void => {
|
||||
this.setState({
|
||||
showTypingNotifications: SettingsStore.getValue("showTypingNotifications"),
|
||||
|
@ -399,17 +422,21 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
return !this.isMounted;
|
||||
};
|
||||
|
||||
private get showHiddenEvents(): boolean {
|
||||
return this.context?.showHiddenEventsInTimeline ?? this.showHiddenEventsInTimeline;
|
||||
}
|
||||
|
||||
// TODO: Implement granular (per-room) hide options
|
||||
public shouldShowEvent(mxEv: MatrixEvent): boolean {
|
||||
if (mxEv.sender && MatrixClientPeg.get().isUserIgnored(mxEv.sender.userId)) {
|
||||
if (MatrixClientPeg.get().isUserIgnored(mxEv.getSender())) {
|
||||
return false; // ignored = no show (only happens if the ignore happens after an event was received)
|
||||
}
|
||||
|
||||
if (this.showHiddenEventsInTimeline) {
|
||||
if (this.showHiddenEvents) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!haveTileForEvent(mxEv)) {
|
||||
if (!haveTileForEvent(mxEv, this.showHiddenEvents)) {
|
||||
return false; // no tile = no show
|
||||
}
|
||||
|
||||
|
@ -567,9 +594,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 +623,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) {
|
||||
|
@ -644,12 +693,15 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
let willWantDateSeparator = false;
|
||||
let lastInSection = true;
|
||||
if (nextEvent) {
|
||||
willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEvent.getDate() || new Date());
|
||||
lastInSection = willWantDateSeparator || mxEv.getSender() !== nextEvent.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 +732,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 +755,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 +763,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 +994,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 +1002,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 +1121,7 @@ class CreationGrouper extends BaseGrouper {
|
|||
onToggle={panel.onHeightChanged} // Update scroll state
|
||||
summaryMembers={[ev.sender]}
|
||||
summaryText={summaryText}
|
||||
layout={this.layout}
|
||||
>
|
||||
{ eventTiles }
|
||||
</EventListSummary>,
|
||||
|
@ -1092,10 +1149,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 +1218,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 +1239,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 +1247,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 +1257,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 +1324,7 @@ class MemberGrouper extends BaseGrouper {
|
|||
events={this.events}
|
||||
onToggle={panel.onHeightChanged} // Update scroll state
|
||||
startExpanded={highlightInMels}
|
||||
layout={this.layout}
|
||||
>
|
||||
{ eventTiles }
|
||||
</MemberEventListSummary>,
|
||||
|
|
|
@ -109,8 +109,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 +120,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,7 +136,7 @@ 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">
|
||||
|
|
|
@ -51,14 +51,14 @@ export default class NonUrgentToastContainer extends React.PureComponent<IProps,
|
|||
const toasts = this.state.toasts.map((t, i) => {
|
||||
return (
|
||||
<div className="mx_NonUrgentToastContainer_toast" key={`toast-${i}`}>
|
||||
{React.createElement(t, {})}
|
||||
{ React.createElement(t, {}) }
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx_NonUrgentToastContainer" role="alert">
|
||||
{toasts}
|
||||
{ toasts }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ import { replaceableComponent } from "../../utils/replaceableComponent";
|
|||
import TimelinePanel from "./TimelinePanel";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import { TileShape } from "../views/rooms/EventTile";
|
||||
import { Layout } from "../../settings/Layout";
|
||||
|
||||
interface IProps {
|
||||
onClose(): void;
|
||||
|
@ -35,8 +36,8 @@ interface IProps {
|
|||
export default class NotificationPanel extends React.PureComponent<IProps> {
|
||||
render() {
|
||||
const emptyState = (<div className="mx_RightPanel_empty mx_NotificationPanel_empty">
|
||||
<h2>{_t('You’re all caught up')}</h2>
|
||||
<p>{_t('You have no visible notifications.')}</p>
|
||||
<h2>{ _t('You’re all caught up') }</h2>
|
||||
<p>{ _t('You have no visible notifications.') }</p>
|
||||
</div>);
|
||||
|
||||
let content;
|
||||
|
@ -52,6 +53,7 @@ export default class NotificationPanel extends React.PureComponent<IProps> {
|
|||
tileShape={TileShape.Notif}
|
||||
empty={emptyState}
|
||||
alwaysShowTimestamps={true}
|
||||
layout={Layout.Group}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { RoomState } from "matrix-js-sdk/src/models/room-state";
|
||||
import { User } from "matrix-js-sdk/src/models/user";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
|
@ -48,6 +49,7 @@ import NotificationPanel from "./NotificationPanel";
|
|||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
|
||||
import { throttle } from 'lodash';
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
|
||||
interface IProps {
|
||||
room?: Room; // if showing panels for a given room, this is set
|
||||
|
@ -107,7 +109,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
|
|||
return RightPanelPhases.GroupMemberList;
|
||||
}
|
||||
return rps.groupPanelPhase;
|
||||
} else if (SettingsStore.getValue("feature_spaces") && this.props.room?.isSpaceRoom()
|
||||
} else if (SpaceStore.spacesEnabled && this.props.room?.isSpaceRoom()
|
||||
&& !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)
|
||||
) {
|
||||
return RightPanelPhases.SpaceMemberList;
|
||||
|
@ -151,7 +153,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase
|
||||
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line
|
||||
if (newProps.groupId !== this.props.groupId) {
|
||||
this.unregisterGroupStore();
|
||||
this.initGroupStore(newProps.groupId);
|
||||
|
@ -173,7 +175,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
private onRoomStateMember = (ev: MatrixEvent, _, member: RoomMember) => {
|
||||
private onRoomStateMember = (ev: MatrixEvent, state: RoomState, member: RoomMember) => {
|
||||
if (!this.props.room || member.roomId !== this.props.room.roomId) {
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -16,6 +16,9 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { IFieldType, IInstance, IProtocol, IPublicRoomsChunkRoom } from "matrix-js-sdk/src/client";
|
||||
import { Visibility } from "matrix-js-sdk/src/@types/partials";
|
||||
import { IRoomDirectoryOptions } from "matrix-js-sdk/src/@types/requests";
|
||||
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
|
@ -25,7 +28,7 @@ import { _t } from '../../languageHandler';
|
|||
import SdkConfig from '../../SdkConfig';
|
||||
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
|
||||
import Analytics from '../../Analytics';
|
||||
import { ALL_ROOMS, IFieldType, IInstance, IProtocol, Protocols } from "../views/directory/NetworkDropdown";
|
||||
import NetworkDropdown, { ALL_ROOMS, Protocols } from "../views/directory/NetworkDropdown";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
|
||||
import GroupStore from "../../stores/GroupStore";
|
||||
|
@ -40,10 +43,10 @@ import ErrorDialog from "../views/dialogs/ErrorDialog";
|
|||
import QuestionDialog from "../views/dialogs/QuestionDialog";
|
||||
import BaseDialog from "../views/dialogs/BaseDialog";
|
||||
import DirectorySearchBox from "../views/elements/DirectorySearchBox";
|
||||
import NetworkDropdown from "../views/directory/NetworkDropdown";
|
||||
import ScrollPanel from "./ScrollPanel";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import { getDisplayAliasForAliasSet } from "../../Rooms";
|
||||
|
||||
const MAX_NAME_LENGTH = 80;
|
||||
const MAX_TOPIC_LENGTH = 800;
|
||||
|
@ -60,7 +63,7 @@ interface IProps extends IDialogProps {
|
|||
}
|
||||
|
||||
interface IState {
|
||||
publicRooms: IRoom[];
|
||||
publicRooms: IPublicRoomsChunkRoom[];
|
||||
loading: boolean;
|
||||
protocolsLoading: boolean;
|
||||
error?: string;
|
||||
|
@ -71,35 +74,12 @@ interface IState {
|
|||
communityName?: string;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
interface IRoom {
|
||||
room_id: string;
|
||||
name?: string;
|
||||
avatar_url?: string;
|
||||
topic?: string;
|
||||
canonical_alias?: string;
|
||||
aliases?: string[];
|
||||
world_readable: boolean;
|
||||
guest_can_join: boolean;
|
||||
num_joined_members: number;
|
||||
}
|
||||
|
||||
interface IPublicRoomsRequest {
|
||||
limit?: number;
|
||||
since?: string;
|
||||
server?: string;
|
||||
filter?: object;
|
||||
include_all_networks?: boolean;
|
||||
third_party_instance_id?: string;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
@replaceableComponent("structures.RoomDirectory")
|
||||
export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
private readonly startTime: number;
|
||||
private unmounted = false;
|
||||
private nextBatch: string = null;
|
||||
private filterTimeout: NodeJS.Timeout;
|
||||
private filterTimeout: number;
|
||||
private protocols: Protocols;
|
||||
|
||||
constructor(props) {
|
||||
|
@ -252,7 +232,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
// remember the next batch token when we sent the request
|
||||
// too. If it's changed, appending to the list will corrupt it.
|
||||
const nextBatch = this.nextBatch;
|
||||
const opts: IPublicRoomsRequest = { limit: 20 };
|
||||
const opts: IRoomDirectoryOptions = { limit: 20 };
|
||||
if (roomServer != MatrixClientPeg.getHomeserverName()) {
|
||||
opts.server = roomServer;
|
||||
}
|
||||
|
@ -325,7 +305,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
* HS admins to do this through the RoomSettings interface, but
|
||||
* this needs SPEC-417.
|
||||
*/
|
||||
private removeFromDirectory(room: IRoom) {
|
||||
private removeFromDirectory(room: IPublicRoomsChunkRoom) {
|
||||
const alias = getDisplayAliasForRoom(room);
|
||||
const name = room.name || alias || _t('Unnamed room');
|
||||
|
||||
|
@ -345,7 +325,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
const modal = Modal.createDialog(Spinner);
|
||||
let step = _t('remove %(name)s from the directory.', { name: name });
|
||||
|
||||
MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => {
|
||||
MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, Visibility.Private).then(() => {
|
||||
if (!alias) return;
|
||||
step = _t('delete the address.');
|
||||
return MatrixClientPeg.get().deleteAlias(alias);
|
||||
|
@ -367,7 +347,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
private onRoomClicked = (room: IRoom, ev: ButtonEvent) => {
|
||||
private onRoomClicked = (room: IPublicRoomsChunkRoom, ev: ButtonEvent) => {
|
||||
// If room was shift-clicked, remove it from the room directory
|
||||
if (ev.shiftKey && !this.state.selectedCommunityId) {
|
||||
ev.preventDefault();
|
||||
|
@ -480,17 +460,17 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onPreviewClick = (ev: ButtonEvent, room: IRoom) => {
|
||||
private onPreviewClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => {
|
||||
this.showRoom(room, null, false, true);
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
private onViewClick = (ev: ButtonEvent, room: IRoom) => {
|
||||
private onViewClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => {
|
||||
this.showRoom(room);
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
private onJoinClick = (ev: ButtonEvent, room: IRoom) => {
|
||||
private onJoinClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => {
|
||||
this.showRoom(room, null, true);
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
@ -508,7 +488,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
this.showRoom(null, alias, autoJoin);
|
||||
}
|
||||
|
||||
private showRoom(room: IRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) {
|
||||
private showRoom(room: IPublicRoomsChunkRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) {
|
||||
this.onFinished();
|
||||
const payload: ActionPayload = {
|
||||
action: 'view_room',
|
||||
|
@ -557,7 +537,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
dis.dispatch(payload);
|
||||
}
|
||||
|
||||
private createRoomCells(room: IRoom) {
|
||||
private createRoomCells(room: IPublicRoomsChunkRoom) {
|
||||
const client = MatrixClientPeg.get();
|
||||
const clientRoom = client.getRoom(room.room_id);
|
||||
const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join";
|
||||
|
@ -609,7 +589,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
// We use onMouseDown instead of onClick, so that we can avoid text getting selected
|
||||
return [
|
||||
<div
|
||||
key={ `${room.room_id}_avatar` }
|
||||
key={`${room.room_id}_avatar`}
|
||||
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
|
||||
className="mx_RoomDirectory_roomAvatar"
|
||||
>
|
||||
|
@ -623,7 +603,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
/>
|
||||
</div>,
|
||||
<div
|
||||
key={ `${room.room_id}_description` }
|
||||
key={`${room.room_id}_description`}
|
||||
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
|
||||
className="mx_RoomDirectory_roomDescription"
|
||||
>
|
||||
|
@ -646,14 +626,14 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
</div>
|
||||
</div>,
|
||||
<div
|
||||
key={ `${room.room_id}_memberCount` }
|
||||
key={`${room.room_id}_memberCount`}
|
||||
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
|
||||
className="mx_RoomDirectory_roomMemberCount"
|
||||
>
|
||||
{ room.num_joined_members }
|
||||
</div>,
|
||||
<div
|
||||
key={ `${room.room_id}_preview` }
|
||||
key={`${room.room_id}_preview`}
|
||||
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
|
||||
// cancel onMouseDown otherwise shift-clicking highlights text
|
||||
className="mx_RoomDirectory_preview"
|
||||
|
@ -661,7 +641,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
{ previewButton }
|
||||
</div>,
|
||||
<div
|
||||
key={ `${room.room_id}_join` }
|
||||
key={`${room.room_id}_join`}
|
||||
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
|
||||
className="mx_RoomDirectory_join"
|
||||
>
|
||||
|
@ -816,7 +796,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
showJoinButton={showJoinButton}
|
||||
initialText={this.props.initialText}
|
||||
/>
|
||||
{dropdown}
|
||||
{ dropdown }
|
||||
</div>;
|
||||
}
|
||||
const explanation =
|
||||
|
@ -834,16 +814,16 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
}) : _t("Explore rooms");
|
||||
return (
|
||||
<BaseDialog
|
||||
className={'mx_RoomDirectory_dialog'}
|
||||
className="mx_RoomDirectory_dialog"
|
||||
hasCancel={true}
|
||||
onFinished={this.onFinished}
|
||||
title={title}
|
||||
>
|
||||
<div className="mx_RoomDirectory">
|
||||
{explanation}
|
||||
{ explanation }
|
||||
<div className="mx_RoomDirectory_list">
|
||||
{listHeader}
|
||||
{content}
|
||||
{ listHeader }
|
||||
{ content }
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
@ -853,6 +833,6 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
|
||||
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
||||
// but works with the objects we get from the public room list
|
||||
function getDisplayAliasForRoom(room: IRoom) {
|
||||
return room.canonical_alias || room.aliases?.[0] || "";
|
||||
function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) {
|
||||
return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
|
||||
}
|
||||
|
|
|
@ -209,9 +209,9 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
|
||||
return (
|
||||
<div className={classes}>
|
||||
{icon}
|
||||
{input}
|
||||
{clearButton}
|
||||
{ icon }
|
||||
{ input }
|
||||
{ clearButton }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -222,17 +222,17 @@ export default class RoomStatusBar extends React.PureComponent {
|
|||
|
||||
let buttonRow = <>
|
||||
<AccessibleButton onClick={this._onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn">
|
||||
{_t("Delete all")}
|
||||
{ _t("Delete all") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton onClick={this._onResendAllClick} className="mx_RoomStatusBar_unsentResendAllBtn">
|
||||
{_t("Retry all")}
|
||||
{ _t("Retry all") }
|
||||
</AccessibleButton>
|
||||
</>;
|
||||
if (this.state.isResending) {
|
||||
buttonRow = <>
|
||||
<InlineSpinner w={20} h={20} />
|
||||
{/* span for css */}
|
||||
<span>{_t("Sending")}</span>
|
||||
{ /* span for css */ }
|
||||
<span>{ _t("Sending") }</span>
|
||||
</>;
|
||||
}
|
||||
|
||||
|
@ -253,7 +253,7 @@ export default class RoomStatusBar extends React.PureComponent {
|
|||
</div>
|
||||
</div>
|
||||
<div className="mx_RoomStatusBar_unsentButtonBar">
|
||||
{buttonRow}
|
||||
{ buttonRow }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -266,14 +266,18 @@ export default class RoomStatusBar extends React.PureComponent {
|
|||
<div className="mx_RoomStatusBar">
|
||||
<div role="alert">
|
||||
<div className="mx_RoomStatusBar_connectionLostBar">
|
||||
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24"
|
||||
height="24" title="/!\ " alt="/!\ " />
|
||||
<img
|
||||
src={require("../../../res/img/feather-customised/warning-triangle.svg")}
|
||||
width="24"
|
||||
height="24"
|
||||
title="/!\ "
|
||||
alt="/!\ " />
|
||||
<div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_title">
|
||||
{_t('Connectivity to the server has been lost.')}
|
||||
{ _t('Connectivity to the server has been lost.') }
|
||||
</div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_desc">
|
||||
{_t('Sent messages will be stored until your connection has returned.')}
|
||||
{ _t('Sent messages will be stored until your connection has returned.') }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -25,8 +25,8 @@ import React, { createRef } from 'react';
|
|||
import classNames from 'classnames';
|
||||
import { IRecommendedVersion, NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { SearchResult } from "matrix-js-sdk/src/models/search-result";
|
||||
import { EventSubscription } from "fbemitter";
|
||||
import { ISearchResults } from 'matrix-js-sdk/src/@types/search';
|
||||
|
||||
import shouldHideEvent from '../../shouldHideEvent';
|
||||
import { _t } from '../../languageHandler';
|
||||
|
@ -89,6 +89,7 @@ import RoomStatusBar from "./RoomStatusBar";
|
|||
import MessageComposer from '../views/rooms/MessageComposer';
|
||||
import JumpToBottomButton from "../views/rooms/JumpToBottomButton";
|
||||
import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar";
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
|
||||
const DEBUG = false;
|
||||
let debuglog = function(msg: string) {};
|
||||
|
@ -133,12 +134,7 @@ export interface IState {
|
|||
searching: boolean;
|
||||
searchTerm?: string;
|
||||
searchScope?: SearchScope;
|
||||
searchResults?: XOR<{}, {
|
||||
count: number;
|
||||
highlights: string[];
|
||||
results: SearchResult[];
|
||||
next_batch: string; // eslint-disable-line camelcase
|
||||
}>;
|
||||
searchResults?: XOR<{}, ISearchResults>;
|
||||
searchHighlights?: string[];
|
||||
searchInProgress?: boolean;
|
||||
callState?: CallState;
|
||||
|
@ -170,6 +166,11 @@ export interface IState {
|
|||
canReply: boolean;
|
||||
layout: Layout;
|
||||
lowBandwidth: boolean;
|
||||
alwaysShowTimestamps: boolean;
|
||||
showTwelveHourTimestamps: boolean;
|
||||
readMarkerInViewThresholdMs: number;
|
||||
readMarkerOutOfViewThresholdMs: number;
|
||||
showHiddenEventsInTimeline: boolean;
|
||||
showReadReceipts: boolean;
|
||||
showRedactions: boolean;
|
||||
showJoinLeaves: boolean;
|
||||
|
@ -234,6 +235,11 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
canReply: false,
|
||||
layout: SettingsStore.getValue("layout"),
|
||||
lowBandwidth: SettingsStore.getValue("lowBandwidth"),
|
||||
alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"),
|
||||
showTwelveHourTimestamps: SettingsStore.getValue("showTwelveHourTimestamps"),
|
||||
readMarkerInViewThresholdMs: SettingsStore.getValue("readMarkerInViewThresholdMs"),
|
||||
readMarkerOutOfViewThresholdMs: SettingsStore.getValue("readMarkerOutOfViewThresholdMs"),
|
||||
showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline"),
|
||||
showReadReceipts: true,
|
||||
showRedactions: true,
|
||||
showJoinLeaves: true,
|
||||
|
@ -257,7 +263,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
this.context.on("userTrustStatusChanged", this.onUserVerificationChanged);
|
||||
this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
|
||||
this.context.on("Event.decrypted", this.onEventDecrypted);
|
||||
this.context.on("event", this.onEvent);
|
||||
// Start listening for RoomViewStore updates
|
||||
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
|
||||
this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate);
|
||||
|
@ -266,11 +271,26 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate);
|
||||
|
||||
this.settingWatchers = [
|
||||
SettingsStore.watchSetting("layout", null, () =>
|
||||
this.setState({ layout: SettingsStore.getValue("layout") }),
|
||||
SettingsStore.watchSetting("layout", null, (...[,,, value]) =>
|
||||
this.setState({ layout: value as Layout }),
|
||||
),
|
||||
SettingsStore.watchSetting("lowBandwidth", null, () =>
|
||||
this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }),
|
||||
SettingsStore.watchSetting("lowBandwidth", null, (...[,,, value]) =>
|
||||
this.setState({ lowBandwidth: value as boolean }),
|
||||
),
|
||||
SettingsStore.watchSetting("alwaysShowTimestamps", null, (...[,,, value]) =>
|
||||
this.setState({ alwaysShowTimestamps: value as boolean }),
|
||||
),
|
||||
SettingsStore.watchSetting("showTwelveHourTimestamps", null, (...[,,, value]) =>
|
||||
this.setState({ showTwelveHourTimestamps: value as boolean }),
|
||||
),
|
||||
SettingsStore.watchSetting("readMarkerInViewThresholdMs", null, (...[,,, value]) =>
|
||||
this.setState({ readMarkerInViewThresholdMs: value as number }),
|
||||
),
|
||||
SettingsStore.watchSetting("readMarkerOutOfViewThresholdMs", null, (...[,,, value]) =>
|
||||
this.setState({ readMarkerOutOfViewThresholdMs: value as number }),
|
||||
),
|
||||
SettingsStore.watchSetting("showHiddenEventsInTimeline", null, (...[,,, value]) =>
|
||||
this.setState({ showHiddenEventsInTimeline: value as boolean }),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
@ -337,30 +357,20 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
|
||||
// Add watchers for each of the settings we just looked up
|
||||
this.settingWatchers = this.settingWatchers.concat([
|
||||
SettingsStore.watchSetting("showReadReceipts", null, () =>
|
||||
this.setState({
|
||||
showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId),
|
||||
}),
|
||||
SettingsStore.watchSetting("showReadReceipts", roomId, (...[,,, value]) =>
|
||||
this.setState({ showReadReceipts: value as boolean }),
|
||||
),
|
||||
SettingsStore.watchSetting("showRedactions", null, () =>
|
||||
this.setState({
|
||||
showRedactions: SettingsStore.getValue("showRedactions", roomId),
|
||||
}),
|
||||
SettingsStore.watchSetting("showRedactions", roomId, (...[,,, value]) =>
|
||||
this.setState({ showRedactions: value as boolean }),
|
||||
),
|
||||
SettingsStore.watchSetting("showJoinLeaves", null, () =>
|
||||
this.setState({
|
||||
showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId),
|
||||
}),
|
||||
SettingsStore.watchSetting("showJoinLeaves", roomId, (...[,,, value]) =>
|
||||
this.setState({ showJoinLeaves: value as boolean }),
|
||||
),
|
||||
SettingsStore.watchSetting("showAvatarChanges", null, () =>
|
||||
this.setState({
|
||||
showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId),
|
||||
}),
|
||||
SettingsStore.watchSetting("showAvatarChanges", roomId, (...[,,, value]) =>
|
||||
this.setState({ showAvatarChanges: value as boolean }),
|
||||
),
|
||||
SettingsStore.watchSetting("showDisplaynameChanges", null, () =>
|
||||
this.setState({
|
||||
showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId),
|
||||
}),
|
||||
SettingsStore.watchSetting("showDisplaynameChanges", roomId, (...[,,, value]) =>
|
||||
this.setState({ showDisplaynameChanges: value as boolean }),
|
||||
),
|
||||
]);
|
||||
|
||||
|
@ -641,7 +651,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
|
||||
this.context.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
|
||||
this.context.removeListener("Event.decrypted", this.onEventDecrypted);
|
||||
this.context.removeListener("event", this.onEvent);
|
||||
}
|
||||
|
||||
window.removeEventListener('beforeunload', this.onPageUnload);
|
||||
|
@ -841,8 +850,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
if (this.unmounted) return;
|
||||
|
||||
// ignore events for other rooms
|
||||
if (!room) return;
|
||||
if (!this.state.room || room.roomId != this.state.room.roomId) return;
|
||||
if (!room || room.roomId !== this.state.room?.roomId) return;
|
||||
|
||||
// ignore events from filtered timelines
|
||||
if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return;
|
||||
|
@ -863,6 +871,10 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
// we'll only be showing a spinner.
|
||||
if (this.state.joining) return;
|
||||
|
||||
if (!ev.isBeingDecrypted() && !ev.isDecryptionFailure()) {
|
||||
this.handleEffects(ev);
|
||||
}
|
||||
|
||||
if (ev.getSender() !== this.context.credentials.userId) {
|
||||
// update unread count when scrolled up
|
||||
if (!this.state.searchResults && this.state.atEndOfLiveTimeline) {
|
||||
|
@ -875,20 +887,14 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onEventDecrypted = (ev) => {
|
||||
private onEventDecrypted = (ev: MatrixEvent) => {
|
||||
if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all
|
||||
if (ev.getRoomId() !== this.state.room.roomId) return; // not for us
|
||||
if (ev.isDecryptionFailure()) return;
|
||||
this.handleEffects(ev);
|
||||
};
|
||||
|
||||
private onEvent = (ev) => {
|
||||
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
|
||||
this.handleEffects(ev);
|
||||
};
|
||||
|
||||
private handleEffects = (ev) => {
|
||||
if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all
|
||||
if (ev.getRoomId() !== this.state.room.roomId) return; // not for us
|
||||
|
||||
private handleEffects = (ev: MatrixEvent) => {
|
||||
const notifState = RoomNotificationStateStore.instance.getRoomState(this.state.room);
|
||||
if (!notifState.isUnread) return;
|
||||
|
||||
|
@ -921,6 +927,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
// called when state.room is first initialised (either at initial load,
|
||||
// after a successful peek, or after we join the room).
|
||||
private onRoomLoaded = (room: Room) => {
|
||||
if (this.unmounted) return;
|
||||
// Attach a widget store listener only when we get a room
|
||||
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange);
|
||||
this.onWidgetLayoutChange(); // provoke an update
|
||||
|
@ -935,9 +942,9 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private async calculateRecommendedVersion(room: Room) {
|
||||
this.setState({
|
||||
upgradeRecommendation: await room.getRecommendedVersion(),
|
||||
});
|
||||
const upgradeRecommendation = await room.getRecommendedVersion();
|
||||
if (this.unmounted) return;
|
||||
this.setState({ upgradeRecommendation });
|
||||
}
|
||||
|
||||
private async loadMembersIfJoined(room: Room) {
|
||||
|
@ -1027,23 +1034,19 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private async updateE2EStatus(room: Room) {
|
||||
if (!this.context.isRoomEncrypted(room.roomId)) {
|
||||
return;
|
||||
}
|
||||
if (!this.context.isCryptoEnabled()) {
|
||||
// If crypto is not currently enabled, we aren't tracking devices at all,
|
||||
// so we don't know what the answer is. Let's error on the safe side and show
|
||||
// a warning for this case.
|
||||
this.setState({
|
||||
e2eStatus: E2EStatus.Warning,
|
||||
});
|
||||
return;
|
||||
if (!this.context.isRoomEncrypted(room.roomId)) return;
|
||||
|
||||
// If crypto is not currently enabled, we aren't tracking devices at all,
|
||||
// so we don't know what the answer is. Let's error on the safe side and show
|
||||
// a warning for this case.
|
||||
let e2eStatus = E2EStatus.Warning;
|
||||
if (this.context.isCryptoEnabled()) {
|
||||
/* At this point, the user has encryption on and cross-signing on */
|
||||
e2eStatus = await shieldStatusForRoom(this.context, room);
|
||||
}
|
||||
|
||||
/* At this point, the user has encryption on and cross-signing on */
|
||||
this.setState({
|
||||
e2eStatus: await shieldStatusForRoom(this.context, room),
|
||||
});
|
||||
if (this.unmounted) return;
|
||||
this.setState({ e2eStatus });
|
||||
}
|
||||
|
||||
private onAccountData = (event: MatrixEvent) => {
|
||||
|
@ -1137,7 +1140,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
|
||||
if (this.state.searchResults.next_batch) {
|
||||
debuglog("requesting more search results");
|
||||
const searchPromise = searchPagination(this.state.searchResults);
|
||||
const searchPromise = searchPagination(this.state.searchResults as ISearchResults);
|
||||
return this.handleSearchResult(searchPromise);
|
||||
} else {
|
||||
debuglog("no more search results");
|
||||
|
@ -1400,7 +1403,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (!haveTileForEvent(mxEv)) {
|
||||
if (!haveTileForEvent(mxEv, this.state.showHiddenEventsInTimeline)) {
|
||||
// XXX: can this ever happen? It will make the result count
|
||||
// not match the displayed count.
|
||||
continue;
|
||||
|
@ -1737,7 +1740,8 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
onJoinClick={this.onJoinButtonClicked}
|
||||
onForgetClick={this.onForgetClick}
|
||||
onRejectClick={this.onRejectThreepidInviteButtonClicked}
|
||||
canPreview={false} error={this.state.roomLoadError}
|
||||
canPreview={false}
|
||||
error={this.state.roomLoadError}
|
||||
roomAlias={roomAlias}
|
||||
joining={this.state.joining}
|
||||
inviterName={inviterName}
|
||||
|
@ -1753,10 +1757,8 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
const myMembership = this.state.room.getMyMembership();
|
||||
if (myMembership === "invite"
|
||||
// SpaceRoomView handles invites itself
|
||||
&& (!SettingsStore.getValue("feature_spaces") || !this.state.room.isSpaceRoom())
|
||||
) {
|
||||
// SpaceRoomView handles invites itself
|
||||
if (myMembership === "invite" && (!SpaceStore.spacesEnabled || !this.state.room.isSpaceRoom())) {
|
||||
if (this.state.joining || this.state.rejecting) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
|
@ -1887,7 +1889,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
room={this.state.room}
|
||||
/>
|
||||
);
|
||||
if (!this.state.canPeek && (!SettingsStore.getValue("feature_spaces") || !this.state.room?.isSpaceRoom())) {
|
||||
if (!this.state.canPeek && (!SpaceStore.spacesEnabled || !this.state.room?.isSpaceRoom())) {
|
||||
return (
|
||||
<div className="mx_RoomView">
|
||||
{ previewBar }
|
||||
|
@ -1901,10 +1903,10 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
className="mx_RoomView_auxPanel_hiddenHighlights"
|
||||
onClick={this.onHiddenHighlightsClick}
|
||||
>
|
||||
{_t(
|
||||
{ _t(
|
||||
"You have %(count)s unread notifications in a prior version of this room.",
|
||||
{ count: hiddenHighlightCount },
|
||||
)}
|
||||
) }
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
@ -2016,7 +2018,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
onScroll={this.onMessageListScroll}
|
||||
onUserScroll={this.onUserScroll}
|
||||
onReadMarkerUpdated={this.updateTopUnreadMessagesBar}
|
||||
showUrlPreview = {this.state.showUrlPreview}
|
||||
showUrlPreview={this.state.showUrlPreview}
|
||||
className={messagePanelClassNames}
|
||||
membersLoaded={this.state.membersLoaded}
|
||||
permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)}
|
||||
|
@ -2066,7 +2068,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
return (
|
||||
<RoomContext.Provider value={this.state}>
|
||||
<main className={mainClasses} ref={this.roomView} onKeyDown={this.onReactKeyDown}>
|
||||
{showChatEffects && this.roomView.current &&
|
||||
{ showChatEffects && this.roomView.current &&
|
||||
<EffectsOverlay roomWidth={this.roomView.current.offsetWidth} />
|
||||
}
|
||||
<ErrorBoundary>
|
||||
|
@ -2085,22 +2087,22 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
/>
|
||||
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
|
||||
<div className="mx_RoomView_body">
|
||||
{auxPanel}
|
||||
{ auxPanel }
|
||||
<div className={timelineClasses}>
|
||||
{fileDropTarget}
|
||||
{topUnreadMessagesBar}
|
||||
{jumpToBottom}
|
||||
{messagePanel}
|
||||
{searchResultsPanel}
|
||||
{ fileDropTarget }
|
||||
{ topUnreadMessagesBar }
|
||||
{ jumpToBottom }
|
||||
{ messagePanel }
|
||||
{ searchResultsPanel }
|
||||
</div>
|
||||
<div className={statusBarAreaClass}>
|
||||
<div className="mx_RoomView_statusAreaBox">
|
||||
<div className="mx_RoomView_statusAreaBox_line" />
|
||||
{statusBar}
|
||||
{ statusBar }
|
||||
</div>
|
||||
</div>
|
||||
{previewBar}
|
||||
{messageComposer}
|
||||
{ previewBar }
|
||||
{ messageComposer }
|
||||
</div>
|
||||
</MainSplit>
|
||||
</ErrorBoundary>
|
||||
|
|
|
@ -183,11 +183,17 @@ export default class ScrollPanel extends React.Component<IProps> {
|
|||
private readonly itemlist = createRef<HTMLOListElement>();
|
||||
private unmounted = false;
|
||||
private scrollTimeout: Timer;
|
||||
// Are we currently trying to backfill?
|
||||
private isFilling: boolean;
|
||||
// Is the current fill request caused by a props update?
|
||||
private isFillingDueToPropsUpdate = false;
|
||||
// Did another request to check the fill state arrive while we were trying to backfill?
|
||||
private fillRequestWhileRunning: boolean;
|
||||
// Is that next fill request scheduled because of a props update?
|
||||
private pendingFillDueToPropsUpdate: boolean;
|
||||
private scrollState: IScrollState;
|
||||
private preventShrinkingState: IPreventShrinkingState;
|
||||
private unfillDebouncer: NodeJS.Timeout;
|
||||
private unfillDebouncer: number;
|
||||
private bottomGrowth: number;
|
||||
private pages: number;
|
||||
private heightUpdateInProgress: boolean;
|
||||
|
@ -213,7 +219,7 @@ export default class ScrollPanel extends React.Component<IProps> {
|
|||
// adding events to the top).
|
||||
//
|
||||
// This will also re-check the fill state, in case the paginate was inadequate
|
||||
this.checkScroll();
|
||||
this.checkScroll(true);
|
||||
this.updatePreventShrinking();
|
||||
}
|
||||
|
||||
|
@ -251,12 +257,12 @@ export default class ScrollPanel extends React.Component<IProps> {
|
|||
|
||||
// after an update to the contents of the panel, check that the scroll is
|
||||
// where it ought to be, and set off pagination requests if necessary.
|
||||
public checkScroll = () => {
|
||||
public checkScroll = (isFromPropsUpdate = false) => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
this.restoreSavedScrollState();
|
||||
this.checkFillState();
|
||||
this.checkFillState(0, isFromPropsUpdate);
|
||||
};
|
||||
|
||||
// return true if the content is fully scrolled down right now; else false.
|
||||
|
@ -319,7 +325,7 @@ export default class ScrollPanel extends React.Component<IProps> {
|
|||
}
|
||||
|
||||
// check the scroll state and send out backfill requests if necessary.
|
||||
public checkFillState = async (depth = 0): Promise<void> => {
|
||||
public checkFillState = async (depth = 0, isFromPropsUpdate = false): Promise<void> => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
|
@ -355,14 +361,20 @@ export default class ScrollPanel extends React.Component<IProps> {
|
|||
// don't allow more than 1 chain of calls concurrently
|
||||
// do make a note when a new request comes in while already running one,
|
||||
// so we can trigger a new chain of calls once done.
|
||||
// However, we make an exception for when we're already filling due to a
|
||||
// props (or children) update, because very often the children include
|
||||
// spinners to say whether we're paginating or not, so this would cause
|
||||
// infinite paginating.
|
||||
if (isFirstCall) {
|
||||
if (this.isFilling) {
|
||||
if (this.isFilling && !this.isFillingDueToPropsUpdate) {
|
||||
debuglog("isFilling: not entering while request is ongoing, marking for a subsequent request");
|
||||
this.fillRequestWhileRunning = true;
|
||||
this.pendingFillDueToPropsUpdate = isFromPropsUpdate;
|
||||
return;
|
||||
}
|
||||
debuglog("isFilling: setting");
|
||||
this.isFilling = true;
|
||||
this.isFillingDueToPropsUpdate = isFromPropsUpdate;
|
||||
}
|
||||
|
||||
const itemlist = this.itemlist.current;
|
||||
|
@ -393,11 +405,14 @@ export default class ScrollPanel extends React.Component<IProps> {
|
|||
if (isFirstCall) {
|
||||
debuglog("isFilling: clearing");
|
||||
this.isFilling = false;
|
||||
this.isFillingDueToPropsUpdate = false;
|
||||
}
|
||||
|
||||
if (this.fillRequestWhileRunning) {
|
||||
const refillDueToPropsUpdate = this.pendingFillDueToPropsUpdate;
|
||||
this.fillRequestWhileRunning = false;
|
||||
this.checkFillState();
|
||||
this.pendingFillDueToPropsUpdate = false;
|
||||
this.checkFillState(0, refillDueToPropsUpdate);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -136,8 +136,8 @@ export default class SearchBox extends React.Component {
|
|||
key="button"
|
||||
tabIndex={-1}
|
||||
className="mx_SearchBox_closeButton"
|
||||
onClick={ () => {this._clearSearch("button"); } }>
|
||||
</AccessibleButton>) : undefined;
|
||||
onClick={() => {this._clearSearch("button"); }}
|
||||
/>) : undefined;
|
||||
|
||||
// show a shorter placeholder when blurred, if requested
|
||||
// this is used for the room filter field that has
|
||||
|
@ -153,12 +153,12 @@ export default class SearchBox extends React.Component {
|
|||
type="text"
|
||||
ref={this._search}
|
||||
className={"mx_textinput_icon mx_textinput_search " + className}
|
||||
value={ this.state.searchTerm }
|
||||
onFocus={ this._onFocus }
|
||||
onChange={ this.onChange }
|
||||
onKeyDown={ this._onKeyDown }
|
||||
value={this.state.searchTerm}
|
||||
onFocus={this._onFocus}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this._onKeyDown}
|
||||
onBlur={this._onBlur}
|
||||
placeholder={ placeholder }
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
autoFocus={this.props.autoFocus}
|
||||
/>
|
||||
|
|
|
@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ReactNode, useMemo, useState } from "react";
|
||||
import React, { ReactNode, KeyboardEvent, useMemo, useState, KeyboardEventHandler } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
|
||||
import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces";
|
||||
import classNames from "classnames";
|
||||
import { sortBy } from "lodash";
|
||||
|
||||
|
@ -42,45 +42,20 @@ import { useStateToggle } from "../../hooks/useStateToggle";
|
|||
import { getChildOrder } from "../../stores/SpaceStore";
|
||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import { linkifyElement } from "../../HtmlUtils";
|
||||
import { getDisplayAliasForAliasSet } from "../../Rooms";
|
||||
import { useDispatcher } from "../../hooks/useDispatcher";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { Key } from "../../Keyboard";
|
||||
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
|
||||
|
||||
interface IHierarchyProps {
|
||||
space: Room;
|
||||
initialText?: string;
|
||||
refreshToken?: any;
|
||||
additionalButtons?: ReactNode;
|
||||
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
export interface ISpaceSummaryRoom {
|
||||
canonical_alias?: string;
|
||||
aliases: string[];
|
||||
avatar_url?: string;
|
||||
guest_can_join: boolean;
|
||||
name?: string;
|
||||
num_joined_members: number;
|
||||
room_id: string;
|
||||
topic?: string;
|
||||
world_readable: boolean;
|
||||
num_refs: number;
|
||||
room_type: string;
|
||||
}
|
||||
|
||||
export interface ISpaceSummaryEvent {
|
||||
room_id: string;
|
||||
event_id: string;
|
||||
origin_server_ts: number;
|
||||
type: string;
|
||||
state_key: string;
|
||||
content: {
|
||||
order?: string;
|
||||
suggested?: boolean;
|
||||
auto_join?: boolean;
|
||||
via?: string[];
|
||||
};
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
interface ITileProps {
|
||||
room: ISpaceSummaryRoom;
|
||||
suggested?: boolean;
|
||||
|
@ -107,6 +82,7 @@ const Tile: React.FC<ITileProps> = ({
|
|||
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
|
||||
|
||||
const [showChildren, toggleShowChildren] = useStateToggle(true);
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex();
|
||||
|
||||
const onPreviewClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
|
@ -121,11 +97,21 @@ const Tile: React.FC<ITileProps> = ({
|
|||
|
||||
let button;
|
||||
if (joinedRoom) {
|
||||
button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline">
|
||||
button = <AccessibleButton
|
||||
onClick={onPreviewClick}
|
||||
kind="primary_outline"
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
{ _t("View") }
|
||||
</AccessibleButton>;
|
||||
} else if (onJoinClick) {
|
||||
button = <AccessibleButton onClick={onJoinClick} kind="primary">
|
||||
button = <AccessibleButton
|
||||
onClick={onJoinClick}
|
||||
kind="primary"
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
{ _t("Join") }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
@ -133,13 +119,13 @@ const Tile: React.FC<ITileProps> = ({
|
|||
let checkbox;
|
||||
if (onToggleClick) {
|
||||
if (hasPermissions) {
|
||||
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} />;
|
||||
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
|
||||
} else {
|
||||
checkbox = <TextWithTooltip
|
||||
tooltip={_t("You don't have permission")}
|
||||
onClick={ev => { ev.stopPropagation(); }}
|
||||
>
|
||||
<StyledCheckbox disabled={true} />
|
||||
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
|
||||
</TextWithTooltip>;
|
||||
}
|
||||
}
|
||||
|
@ -199,8 +185,9 @@ const Tile: React.FC<ITileProps> = ({
|
|||
</div>
|
||||
</React.Fragment>;
|
||||
|
||||
let childToggle;
|
||||
let childSection;
|
||||
let childToggle: JSX.Element;
|
||||
let childSection: JSX.Element;
|
||||
let onKeyDown: KeyboardEventHandler;
|
||||
if (children) {
|
||||
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
|
||||
childToggle = <div
|
||||
|
@ -212,25 +199,74 @@ const Tile: React.FC<ITileProps> = ({
|
|||
toggleShowChildren();
|
||||
}}
|
||||
/>;
|
||||
|
||||
if (showChildren) {
|
||||
childSection = <div className="mx_SpaceRoomDirectory_subspace_children">
|
||||
const onChildrenKeyDown = (e) => {
|
||||
if (e.key === Key.ARROW_LEFT) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ref.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
childSection = <div
|
||||
className="mx_SpaceRoomDirectory_subspace_children"
|
||||
onKeyDown={onChildrenKeyDown}
|
||||
role="group"
|
||||
>
|
||||
{ children }
|
||||
</div>;
|
||||
}
|
||||
|
||||
onKeyDown = (e) => {
|
||||
let handled = false;
|
||||
|
||||
switch (e.key) {
|
||||
case Key.ARROW_LEFT:
|
||||
if (showChildren) {
|
||||
handled = true;
|
||||
toggleShowChildren();
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.ARROW_RIGHT:
|
||||
handled = true;
|
||||
if (showChildren) {
|
||||
const childSection = ref.current?.nextElementSibling;
|
||||
childSection?.querySelector<HTMLDivElement>(".mx_SpaceRoomDirectory_roomTile")?.focus();
|
||||
} else {
|
||||
toggleShowChildren();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return <>
|
||||
return <li
|
||||
className="mx_SpaceRoomDirectory_roomTileWrapper"
|
||||
role="treeitem"
|
||||
aria-expanded={children ? showChildren : undefined}
|
||||
>
|
||||
<AccessibleButton
|
||||
className={classNames("mx_SpaceRoomDirectory_roomTile", {
|
||||
mx_SpaceRoomDirectory_subspace: room.room_type === RoomType.Space,
|
||||
})}
|
||||
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
|
||||
onKeyDown={onKeyDown}
|
||||
inputRef={ref}
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
{ content }
|
||||
{ childToggle }
|
||||
</AccessibleButton>
|
||||
{ childSection }
|
||||
</>;
|
||||
</li>;
|
||||
};
|
||||
|
||||
export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
|
||||
|
@ -343,18 +379,25 @@ export const HierarchyLevel = ({
|
|||
</React.Fragment>;
|
||||
};
|
||||
|
||||
// mutate argument refreshToken to force a reload
|
||||
export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [
|
||||
export const useSpaceSummary = (space: Room): [
|
||||
null,
|
||||
ISpaceSummaryRoom[],
|
||||
Map<string, Map<string, ISpaceSummaryEvent>>?,
|
||||
Map<string, Set<string>>?,
|
||||
Map<string, Set<string>>?,
|
||||
] | [Error] => {
|
||||
// crude temporary refresh token approach until we have pagination and rework the data flow here
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
useDispatcher(defaultDispatcher, (payload => {
|
||||
if (payload.action === Action.UpdateSpaceHierarchy) {
|
||||
setRefreshToken(t => t + 1);
|
||||
}
|
||||
}));
|
||||
|
||||
// TODO pagination
|
||||
return useAsyncMemo(async () => {
|
||||
try {
|
||||
const data = await cli.getSpaceSummary(space.roomId);
|
||||
const data = await space.client.getSpaceSummary(space.roomId);
|
||||
|
||||
const parentChildRelations = new EnhancedMap<string, Map<string, ISpaceSummaryEvent>>();
|
||||
const childParentRelations = new EnhancedMap<string, Set<string>>();
|
||||
|
@ -382,7 +425,6 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
|||
space,
|
||||
initialText = "",
|
||||
showRoom,
|
||||
refreshToken,
|
||||
additionalButtons,
|
||||
children,
|
||||
}) => {
|
||||
|
@ -392,7 +434,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
|||
|
||||
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
|
||||
|
||||
const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken);
|
||||
const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(space);
|
||||
|
||||
const roomsMap = useMemo(() => {
|
||||
if (!rooms) return null;
|
||||
|
@ -432,179 +474,199 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
|||
const [saving, setSaving] = useState(false);
|
||||
|
||||
if (summaryError) {
|
||||
return <p>{_t("Your server does not support showing space hierarchies.")}</p>;
|
||||
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
|
||||
}
|
||||
|
||||
let content;
|
||||
if (roomsMap) {
|
||||
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
|
||||
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
|
||||
|
||||
let countsStr;
|
||||
if (numSpaces > 1) {
|
||||
countsStr = _t("%(count)s rooms and %(numSpaces)s spaces", { count: numRooms, numSpaces });
|
||||
} else if (numSpaces > 0) {
|
||||
countsStr = _t("%(count)s rooms and 1 space", { count: numRooms, numSpaces });
|
||||
} else {
|
||||
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
|
||||
const onKeyDown = (ev: KeyboardEvent, state: IState) => {
|
||||
if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceRoomDirectory_search")) {
|
||||
state.refs[0]?.current?.focus();
|
||||
}
|
||||
|
||||
let manageButtons;
|
||||
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
|
||||
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
|
||||
return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][];
|
||||
});
|
||||
|
||||
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
|
||||
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
|
||||
});
|
||||
|
||||
const disabled = !selectedRelations.length || removing || saving;
|
||||
|
||||
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
|
||||
let props = {};
|
||||
if (!selectedRelations.length) {
|
||||
Button = AccessibleTooltipButton;
|
||||
props = {
|
||||
tooltip: _t("Select a room below first"),
|
||||
yOffset: -40,
|
||||
};
|
||||
}
|
||||
|
||||
manageButtons = <>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={async () => {
|
||||
setRemoving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
|
||||
parentChildMap.get(parentId).delete(childId);
|
||||
if (parentChildMap.get(parentId).size > 0) {
|
||||
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
|
||||
} else {
|
||||
parentChildMap.delete(parentId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(_t("Failed to remove some rooms. Try again later"));
|
||||
}
|
||||
setRemoving(false);
|
||||
}}
|
||||
kind="danger_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ removing ? _t("Removing...") : _t("Remove") }
|
||||
</Button>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
const suggested = !selectionAllSuggested;
|
||||
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
|
||||
if (!existingContent || existingContent.suggested === suggested) continue;
|
||||
|
||||
const content = {
|
||||
...existingContent,
|
||||
suggested: !selectionAllSuggested,
|
||||
};
|
||||
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
|
||||
|
||||
parentChildMap.get(parentId).get(childId).content = content;
|
||||
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
|
||||
}
|
||||
} catch (e) {
|
||||
setError("Failed to update some suggestions. Try again later");
|
||||
}
|
||||
setSaving(false);
|
||||
setSelected(new Map());
|
||||
}}
|
||||
kind="primary_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ saving
|
||||
? _t("Saving...")
|
||||
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
|
||||
}
|
||||
</Button>
|
||||
</>;
|
||||
}
|
||||
|
||||
let results;
|
||||
if (roomsMap.size) {
|
||||
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
|
||||
|
||||
results = <>
|
||||
<HierarchyLevel
|
||||
spaceId={space.roomId}
|
||||
rooms={roomsMap}
|
||||
relations={parentChildMap}
|
||||
parents={new Set()}
|
||||
selectedMap={selected}
|
||||
onToggleClick={hasPermissions ? (parentId, childId) => {
|
||||
setError("");
|
||||
if (!selected.has(parentId)) {
|
||||
setSelected(new Map(selected.set(parentId, new Set([childId]))));
|
||||
return;
|
||||
}
|
||||
|
||||
const parentSet = selected.get(parentId);
|
||||
if (!parentSet.has(childId)) {
|
||||
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
|
||||
return;
|
||||
}
|
||||
|
||||
parentSet.delete(childId);
|
||||
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
|
||||
} : undefined}
|
||||
onViewRoomClick={(roomId, autoJoin) => {
|
||||
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin);
|
||||
}}
|
||||
/>
|
||||
{ children && <hr /> }
|
||||
</>;
|
||||
} else {
|
||||
results = <div className="mx_SpaceRoomDirectory_noResults">
|
||||
<h3>{ _t("No results found") }</h3>
|
||||
<div>{ _t("You may want to try a different search or check for typos.") }</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
content = <>
|
||||
<div className="mx_SpaceRoomDirectory_listHeader">
|
||||
{ countsStr }
|
||||
<span>
|
||||
{ additionalButtons }
|
||||
{ manageButtons }
|
||||
</span>
|
||||
</div>
|
||||
{ error && <div className="mx_SpaceRoomDirectory_error">
|
||||
{ error }
|
||||
</div> }
|
||||
<AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
|
||||
{ results }
|
||||
{ children }
|
||||
</AutoHideScrollbar>
|
||||
</>;
|
||||
} else {
|
||||
content = <Spinner />;
|
||||
}
|
||||
};
|
||||
|
||||
// TODO loading state/error state
|
||||
return <>
|
||||
<SearchBox
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
placeholder={ _t("Search names and descriptions") }
|
||||
onSearch={setQuery}
|
||||
autoFocus={true}
|
||||
initialValue={initialText}
|
||||
/>
|
||||
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
|
||||
{ ({ onKeyDownHandler }) => {
|
||||
let content;
|
||||
if (roomsMap) {
|
||||
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
|
||||
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
|
||||
|
||||
{ content }
|
||||
</>;
|
||||
let countsStr;
|
||||
if (numSpaces > 1) {
|
||||
countsStr = _t("%(count)s rooms and %(numSpaces)s spaces", { count: numRooms, numSpaces });
|
||||
} else if (numSpaces > 0) {
|
||||
countsStr = _t("%(count)s rooms and 1 space", { count: numRooms, numSpaces });
|
||||
} else {
|
||||
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
|
||||
}
|
||||
|
||||
let manageButtons;
|
||||
if (space.getMyMembership() === "join" &&
|
||||
space.currentState.maySendStateEvent(EventType.SpaceChild, userId)
|
||||
) {
|
||||
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
|
||||
return [
|
||||
...selected.get(parentId).values(),
|
||||
].map(childId => [parentId, childId]) as [string, string][];
|
||||
});
|
||||
|
||||
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
|
||||
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
|
||||
});
|
||||
|
||||
const disabled = !selectedRelations.length || removing || saving;
|
||||
|
||||
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
|
||||
let props = {};
|
||||
if (!selectedRelations.length) {
|
||||
Button = AccessibleTooltipButton;
|
||||
props = {
|
||||
tooltip: _t("Select a room below first"),
|
||||
yOffset: -40,
|
||||
};
|
||||
}
|
||||
|
||||
manageButtons = <>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={async () => {
|
||||
setRemoving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
|
||||
parentChildMap.get(parentId).delete(childId);
|
||||
if (parentChildMap.get(parentId).size > 0) {
|
||||
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
|
||||
} else {
|
||||
parentChildMap.delete(parentId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(_t("Failed to remove some rooms. Try again later"));
|
||||
}
|
||||
setRemoving(false);
|
||||
}}
|
||||
kind="danger_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ removing ? _t("Removing...") : _t("Remove") }
|
||||
</Button>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
const suggested = !selectionAllSuggested;
|
||||
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
|
||||
if (!existingContent || existingContent.suggested === suggested) continue;
|
||||
|
||||
const content = {
|
||||
...existingContent,
|
||||
suggested: !selectionAllSuggested,
|
||||
};
|
||||
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
|
||||
|
||||
parentChildMap.get(parentId).get(childId).content = content;
|
||||
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
|
||||
}
|
||||
} catch (e) {
|
||||
setError("Failed to update some suggestions. Try again later");
|
||||
}
|
||||
setSaving(false);
|
||||
setSelected(new Map());
|
||||
}}
|
||||
kind="primary_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ saving
|
||||
? _t("Saving...")
|
||||
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
|
||||
}
|
||||
</Button>
|
||||
</>;
|
||||
}
|
||||
|
||||
let results;
|
||||
if (roomsMap.size) {
|
||||
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
|
||||
|
||||
results = <>
|
||||
<HierarchyLevel
|
||||
spaceId={space.roomId}
|
||||
rooms={roomsMap}
|
||||
relations={parentChildMap}
|
||||
parents={new Set()}
|
||||
selectedMap={selected}
|
||||
onToggleClick={hasPermissions ? (parentId, childId) => {
|
||||
setError("");
|
||||
if (!selected.has(parentId)) {
|
||||
setSelected(new Map(selected.set(parentId, new Set([childId]))));
|
||||
return;
|
||||
}
|
||||
|
||||
const parentSet = selected.get(parentId);
|
||||
if (!parentSet.has(childId)) {
|
||||
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
|
||||
return;
|
||||
}
|
||||
|
||||
parentSet.delete(childId);
|
||||
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
|
||||
} : undefined}
|
||||
onViewRoomClick={(roomId, autoJoin) => {
|
||||
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin);
|
||||
}}
|
||||
/>
|
||||
{ children && <hr /> }
|
||||
</>;
|
||||
} else {
|
||||
results = <div className="mx_SpaceRoomDirectory_noResults">
|
||||
<h3>{ _t("No results found") }</h3>
|
||||
<div>{ _t("You may want to try a different search or check for typos.") }</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
content = <>
|
||||
<div className="mx_SpaceRoomDirectory_listHeader">
|
||||
{ countsStr }
|
||||
<span>
|
||||
{ additionalButtons }
|
||||
{ manageButtons }
|
||||
</span>
|
||||
</div>
|
||||
{ error && <div className="mx_SpaceRoomDirectory_error">
|
||||
{ error }
|
||||
</div> }
|
||||
<AutoHideScrollbar
|
||||
className="mx_SpaceRoomDirectory_list"
|
||||
onKeyDown={onKeyDownHandler}
|
||||
role="tree"
|
||||
aria-label={_t("Space")}
|
||||
>
|
||||
{ results }
|
||||
{ children }
|
||||
</AutoHideScrollbar>
|
||||
</>;
|
||||
} else {
|
||||
content = <Spinner />;
|
||||
}
|
||||
|
||||
return <>
|
||||
<SearchBox
|
||||
className="mx_SpaceRoomDirectory_search mx_textinput_icon mx_textinput_search"
|
||||
placeholder={_t("Search names and descriptions")}
|
||||
onSearch={setQuery}
|
||||
autoFocus={true}
|
||||
initialValue={initialText}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
/>
|
||||
|
||||
{ content }
|
||||
</>;
|
||||
} }
|
||||
</RovingTabIndexProvider>;
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
|
@ -636,7 +698,7 @@ const SpaceRoomDirectory: React.FC<IProps> = ({ space, onFinished, initialText }
|
|||
{ _t("If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.",
|
||||
null,
|
||||
{ a: sub => {
|
||||
return <AccessibleButton kind="link" onClick={onCreateRoomClick}>{sub}</AccessibleButton>;
|
||||
return <AccessibleButton kind="link" onClick={onCreateRoomClick}>{ sub }</AccessibleButton>;
|
||||
} },
|
||||
) }
|
||||
|
||||
|
@ -666,5 +728,5 @@ export default SpaceRoomDirectory;
|
|||
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
||||
// but works with the objects we get from the public room list
|
||||
function getDisplayAliasForRoom(room: ISpaceSummaryRoom) {
|
||||
return room.canonical_alias || (room.aliases ? room.aliases[0] : "");
|
||||
return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||
|
||||
import React, { RefObject, useContext, useRef, useState } from "react";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import { Preset } from "matrix-js-sdk/src/@types/partials";
|
||||
import { Preset, JoinRule } from "matrix-js-sdk/src/@types/partials";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { EventSubscription } from "fbemitter";
|
||||
|
||||
|
@ -47,13 +47,23 @@ import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
|
|||
import { SetRightPanelPhasePayload } from "../../dispatcher/payloads/SetRightPanelPhasePayload";
|
||||
import { useStateArray } from "../../hooks/useStateArray";
|
||||
import SpacePublicShare from "../views/spaces/SpacePublicShare";
|
||||
import { shouldShowSpaceSettings, showAddExistingRooms, showCreateNewRoom, showSpaceSettings } from "../../utils/space";
|
||||
import {
|
||||
shouldShowSpaceSettings,
|
||||
showAddExistingRooms,
|
||||
showCreateNewRoom,
|
||||
showCreateNewSubspace,
|
||||
showSpaceSettings,
|
||||
} from "../../utils/space";
|
||||
import { showRoom, SpaceHierarchy } from "./SpaceRoomDirectory";
|
||||
import MemberAvatar from "../views/avatars/MemberAvatar";
|
||||
import { useStateToggle } from "../../hooks/useStateToggle";
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
import FacePile from "../views/elements/FacePile";
|
||||
import { AddExistingToSpace } from "../views/dialogs/AddExistingToSpaceDialog";
|
||||
import {
|
||||
AddExistingToSpace,
|
||||
defaultDmsRenderer,
|
||||
defaultRoomsRenderer,
|
||||
defaultSpacesRenderer,
|
||||
} from "../views/dialogs/AddExistingToSpaceDialog";
|
||||
import { ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuOption,
|
||||
|
@ -62,12 +72,12 @@ import IconizedContextMenu, {
|
|||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import { BetaPill } from "../views/beta/BetaCard";
|
||||
import { UserTab } from "../views/dialogs/UserSettingsDialog";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import Modal from "../../Modal";
|
||||
import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
|
||||
import { JoinRule } from "../views/settings/tabs/room/SecurityRoomSettingsTab";
|
||||
import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu";
|
||||
import { CreateEventField, IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog";
|
||||
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import GroupAvatar from "../views/avatars/GroupAvatar";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
|
@ -94,26 +104,6 @@ enum Phase {
|
|||
PrivateExistingRooms,
|
||||
}
|
||||
|
||||
// XXX: Temporary for the Spaces Beta only
|
||||
export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
|
||||
if (!SdkConfig.get().bug_report_endpoint_url) return null;
|
||||
|
||||
return <div className="mx_SpaceFeedbackPrompt">
|
||||
<hr />
|
||||
<div>
|
||||
<span className="mx_SpaceFeedbackPrompt_text">{ _t("Spaces are a beta feature.") }</span>
|
||||
<AccessibleButton kind="link" onClick={() => {
|
||||
if (onClick) onClick();
|
||||
Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, {
|
||||
featureId: "feature_spaces",
|
||||
});
|
||||
}}>
|
||||
{ _t("Feedback") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const RoomMemberCount = ({ room, children }) => {
|
||||
const members = useRoomMembers(room);
|
||||
const count = members.length;
|
||||
|
@ -147,7 +137,7 @@ const SpaceInfo = ({ space }) => {
|
|||
return <div className="mx_SpaceRoomView_info">
|
||||
{ visibilitySection }
|
||||
{ joinRule === "public" && <RoomMemberCount room={space}>
|
||||
{(count) => count > 0 ? (
|
||||
{ (count) => count > 0 ? (
|
||||
<AccessibleButton
|
||||
kind="link"
|
||||
onClick={() => {
|
||||
|
@ -160,7 +150,7 @@ const SpaceInfo = ({ space }) => {
|
|||
>
|
||||
{ _t("%(count)s members", { count }) }
|
||||
</AccessibleButton>
|
||||
) : null}
|
||||
) : null }
|
||||
</RoomMemberCount> }
|
||||
</div>;
|
||||
};
|
||||
|
@ -172,13 +162,39 @@ const onBetaClick = () => {
|
|||
});
|
||||
};
|
||||
|
||||
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
|
||||
// XXX: temporary community migration component
|
||||
const GroupTile = ({ groupId }: { groupId: string }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const groupSummary = useAsyncMemo<IGroupSummary>(() => cli.getGroupSummary(groupId), [cli, groupId]);
|
||||
|
||||
if (!groupSummary) return <Spinner />;
|
||||
|
||||
return <>
|
||||
<GroupAvatar
|
||||
groupId={groupId}
|
||||
groupName={groupSummary.profile.name}
|
||||
groupAvatarUrl={groupSummary.profile.avatar_url}
|
||||
width={16}
|
||||
height={16}
|
||||
resizeMethod='crop'
|
||||
/>
|
||||
{ groupSummary.profile.name }
|
||||
</>;
|
||||
};
|
||||
|
||||
interface ISpacePreviewProps {
|
||||
space: Room;
|
||||
onJoinButtonClicked(): void;
|
||||
onRejectButtonClicked(): void;
|
||||
}
|
||||
|
||||
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const myMembership = useMyRoomMembership(space);
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const spacesEnabled = SettingsStore.getValue("feature_spaces");
|
||||
const spacesEnabled = SpaceStore.spacesEnabled;
|
||||
|
||||
const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave
|
||||
&& space.getJoinRule() !== JoinRule.Public;
|
||||
|
@ -206,11 +222,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
|
||||
if (inviteSender) {
|
||||
inviterSection = <div className="mx_SpaceRoomView_preview_inviter">
|
||||
<MemberAvatar member={inviter} width={32} height={32} />
|
||||
<MemberAvatar member={inviter} fallbackUserId={inviteSender} width={32} height={32} />
|
||||
<div>
|
||||
<div className="mx_SpaceRoomView_preview_inviter_name">
|
||||
{ _t("<inviter/> invites you", {}, {
|
||||
inviter: () => <b>{ inviter.name || inviteSender }</b>,
|
||||
inviter: () => <b>{ inviter?.name || inviteSender }</b>,
|
||||
}) }
|
||||
</div>
|
||||
{ inviter ? <div className="mx_SpaceRoomView_preview_inviter_mxid">
|
||||
|
@ -284,8 +300,18 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
</div>;
|
||||
}
|
||||
|
||||
let migratedCommunitySection: JSX.Element;
|
||||
const createContent = space.currentState.getStateEvents(EventType.RoomCreate, "")?.getContent();
|
||||
if (createContent[CreateEventField]) {
|
||||
migratedCommunitySection = <div className="mx_SpaceRoomView_preview_migratedCommunity">
|
||||
{ _t("Created from <Community />", {}, {
|
||||
Community: () => <GroupTile groupId={createContent[CreateEventField]} />,
|
||||
}) }
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div className="mx_SpaceRoomView_preview">
|
||||
<BetaPill onClick={onBetaClick} />
|
||||
{ migratedCommunitySection }
|
||||
{ inviterSection }
|
||||
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
|
||||
<h1 className="mx_SpaceRoomView_preview_name">
|
||||
|
@ -293,7 +319,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
</h1>
|
||||
<SpaceInfo space={space} />
|
||||
<RoomTopic room={space}>
|
||||
{(topic, ref) =>
|
||||
{ (topic, ref) =>
|
||||
<div className="mx_SpaceRoomView_preview_topic" ref={ref}>
|
||||
{ topic }
|
||||
</div>
|
||||
|
@ -307,8 +333,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
</div>;
|
||||
};
|
||||
|
||||
const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const SpaceLandingAddButton = ({ space }) => {
|
||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
|
||||
|
||||
let contextMenu;
|
||||
|
@ -331,25 +356,33 @@ const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
|
|||
e.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
if (await showCreateNewRoom(cli, space)) {
|
||||
onNewRoomAdded();
|
||||
if (await showCreateNewRoom(space)) {
|
||||
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
label={_t("Add existing room")}
|
||||
iconClassName="mx_RoomList_iconHash"
|
||||
onClick={async (e) => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
const [added] = await showAddExistingRooms(cli, space);
|
||||
if (added) {
|
||||
onNewRoomAdded();
|
||||
}
|
||||
showAddExistingRooms(space);
|
||||
}}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
label={_t("Add space")}
|
||||
iconClassName="mx_RoomList_iconPlus"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
showCreateNewSubspace(space);
|
||||
}}
|
||||
>
|
||||
<BetaPill />
|
||||
</IconizedContextMenuOption>
|
||||
</IconizedContextMenuOptionList>
|
||||
</IconizedContextMenu>;
|
||||
}
|
||||
|
@ -390,19 +423,17 @@ const SpaceLanding = ({ space }) => {
|
|||
|
||||
const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
|
||||
|
||||
const [refreshToken, forceUpdate] = useStateToggle(false);
|
||||
|
||||
let addRoomButton;
|
||||
if (canAddRooms) {
|
||||
addRoomButton = <SpaceLandingAddButton space={space} onNewRoomAdded={forceUpdate} />;
|
||||
addRoomButton = <SpaceLandingAddButton space={space} />;
|
||||
}
|
||||
|
||||
let settingsButton;
|
||||
if (shouldShowSpaceSettings(cli, space)) {
|
||||
if (shouldShowSpaceSettings(space)) {
|
||||
settingsButton = <AccessibleTooltipButton
|
||||
className="mx_SpaceRoomView_landing_settingsButton"
|
||||
onClick={() => {
|
||||
showSpaceSettings(cli, space);
|
||||
showSpaceSettings(space);
|
||||
}}
|
||||
title={_t("Settings")}
|
||||
/>;
|
||||
|
@ -417,15 +448,16 @@ const SpaceLanding = ({ space }) => {
|
|||
};
|
||||
|
||||
return <div className="mx_SpaceRoomView_landing">
|
||||
<SpaceFeedbackPrompt />
|
||||
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
|
||||
<div className="mx_SpaceRoomView_landing_name">
|
||||
<RoomName room={space}>
|
||||
{(name) => {
|
||||
{ (name) => {
|
||||
const tags = { name: () => <div className="mx_SpaceRoomView_landing_nameRow">
|
||||
<h1>{ name }</h1>
|
||||
</div> };
|
||||
return _t("Welcome to <name/>", {}, tags) as JSX.Element;
|
||||
}}
|
||||
} }
|
||||
</RoomName>
|
||||
</div>
|
||||
<div className="mx_SpaceRoomView_landing_info">
|
||||
|
@ -435,21 +467,14 @@ const SpaceLanding = ({ space }) => {
|
|||
{ settingsButton }
|
||||
</div>
|
||||
<RoomTopic room={space}>
|
||||
{(topic, ref) => (
|
||||
{ (topic, ref) => (
|
||||
<div className="mx_SpaceRoomView_landing_topic" ref={ref}>
|
||||
{ topic }
|
||||
</div>
|
||||
)}
|
||||
) }
|
||||
</RoomTopic>
|
||||
<SpaceFeedbackPrompt />
|
||||
<hr />
|
||||
|
||||
<SpaceHierarchy
|
||||
space={space}
|
||||
showRoom={showRoom}
|
||||
refreshToken={refreshToken}
|
||||
additionalButtons={addRoomButton}
|
||||
/>
|
||||
<SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -459,7 +484,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
const numFields = 3;
|
||||
const placeholders = [_t("General"), _t("Random"), _t("Support")];
|
||||
const [roomNames, setRoomName] = useStateArray(numFields, [_t("General"), _t("Random"), ""]);
|
||||
const fields = new Array(numFields).fill(0).map((_, i) => {
|
||||
const fields = new Array(numFields).fill(0).map((x, i) => {
|
||||
const name = "roomName" + i;
|
||||
return <Field
|
||||
key={name}
|
||||
|
@ -532,7 +557,6 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
value={buttonLabel}
|
||||
/>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -551,13 +575,12 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => {
|
|||
{ _t("Skip for now") }
|
||||
</AccessibleButton>
|
||||
}
|
||||
filterPlaceholder={_t("Search for rooms or spaces")}
|
||||
onFinished={onFinished}
|
||||
roomsRenderer={defaultRoomsRenderer}
|
||||
spacesRenderer={defaultSpacesRenderer}
|
||||
dmsRenderer={defaultDmsRenderer}
|
||||
/>
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -577,7 +600,6 @@ const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRoom
|
|||
{ createdRooms ? _t("Go to my first room") : _t("Go to my space") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -606,9 +628,8 @@ const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => {
|
|||
</AccessibleButton>
|
||||
<div className="mx_SpaceRoomView_betaWarning">
|
||||
<h3>{ _t("Teammates might not be able to view or join any private rooms you make.") }</h3>
|
||||
<p>{ _t("We're working on this as part of the beta, but just want to let you know.") }</p>
|
||||
<p>{ _t("We're working on this, but just want to let you know.") }</p>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -626,7 +647,7 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
|
|||
const numFields = 3;
|
||||
const fieldRefs: RefObject<Field>[] = [useRef(), useRef(), useRef()];
|
||||
const [emailAddresses, setEmailAddress] = useStateArray(numFields, "");
|
||||
const fields = new Array(numFields).fill(0).map((_, i) => {
|
||||
const fields = new Array(numFields).fill(0).map((x, i) => {
|
||||
const name = "emailAddress" + i;
|
||||
return <Field
|
||||
key={name}
|
||||
|
@ -731,7 +752,6 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
|
|||
value={buttonLabel}
|
||||
/>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -854,7 +874,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
|||
private renderBody() {
|
||||
switch (this.state.phase) {
|
||||
case Phase.Landing:
|
||||
if (this.state.myMembership === "join" && SettingsStore.getValue("feature_spaces")) {
|
||||
if (this.state.myMembership === "join" && SpaceStore.spacesEnabled) {
|
||||
return <SpaceLanding space={this.props.space} />;
|
||||
} else {
|
||||
return <SpacePreview
|
||||
|
|
|
@ -20,6 +20,7 @@ import * as React from "react";
|
|||
import { _t } from '../../languageHandler';
|
||||
import AutoHideScrollbar from './AutoHideScrollbar';
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import classNames from "classnames";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
|
||||
/**
|
||||
|
@ -37,9 +38,16 @@ export class Tab {
|
|||
}
|
||||
}
|
||||
|
||||
export enum TabLocation {
|
||||
LEFT = 'left',
|
||||
TOP = 'top',
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
tabs: Tab[];
|
||||
initialTabId?: string;
|
||||
tabLocation: TabLocation;
|
||||
onChange?: (tabId: string) => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -62,7 +70,11 @@ export default class TabbedView extends React.Component<IProps, IState> {
|
|||
};
|
||||
}
|
||||
|
||||
private _getActiveTabIndex() {
|
||||
static defaultProps = {
|
||||
tabLocation: TabLocation.LEFT,
|
||||
};
|
||||
|
||||
private getActiveTabIndex() {
|
||||
if (!this.state || !this.state.activeTabIndex) return 0;
|
||||
return this.state.activeTabIndex;
|
||||
}
|
||||
|
@ -72,32 +84,33 @@ export default class TabbedView extends React.Component<IProps, IState> {
|
|||
* @param {Tab} tab the tab to show
|
||||
* @private
|
||||
*/
|
||||
private _setActiveTab(tab: Tab) {
|
||||
private setActiveTab(tab: Tab) {
|
||||
const idx = this.props.tabs.indexOf(tab);
|
||||
if (idx !== -1) {
|
||||
if (this.props.onChange) this.props.onChange(tab.id);
|
||||
this.setState({ activeTabIndex: idx });
|
||||
} else {
|
||||
console.error("Could not find tab " + tab.label + " in tabs");
|
||||
}
|
||||
}
|
||||
|
||||
private _renderTabLabel(tab: Tab) {
|
||||
private renderTabLabel(tab: Tab) {
|
||||
let classes = "mx_TabbedView_tabLabel ";
|
||||
|
||||
const idx = this.props.tabs.indexOf(tab);
|
||||
if (idx === this._getActiveTabIndex()) classes += "mx_TabbedView_tabLabel_active";
|
||||
if (idx === this.getActiveTabIndex()) classes += "mx_TabbedView_tabLabel_active";
|
||||
|
||||
let tabIcon = null;
|
||||
if (tab.icon) {
|
||||
tabIcon = <span className={`mx_TabbedView_maskedIcon ${tab.icon}`} />;
|
||||
}
|
||||
|
||||
const onClickHandler = () => this._setActiveTab(tab);
|
||||
const onClickHandler = () => this.setActiveTab(tab);
|
||||
|
||||
const label = _t(tab.label);
|
||||
return (
|
||||
<AccessibleButton className={classes} key={"tab_label_" + tab.label} onClick={onClickHandler}>
|
||||
{tabIcon}
|
||||
{ tabIcon }
|
||||
<span className="mx_TabbedView_tabLabel_text">
|
||||
{ label }
|
||||
</span>
|
||||
|
@ -105,26 +118,32 @@ export default class TabbedView extends React.Component<IProps, IState> {
|
|||
);
|
||||
}
|
||||
|
||||
private _renderTabPanel(tab: Tab): React.ReactNode {
|
||||
private renderTabPanel(tab: Tab): React.ReactNode {
|
||||
return (
|
||||
<div className="mx_TabbedView_tabPanel" key={"mx_tabpanel_" + tab.label}>
|
||||
<AutoHideScrollbar className='mx_TabbedView_tabPanelContent'>
|
||||
{tab.body}
|
||||
{ tab.body }
|
||||
</AutoHideScrollbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const labels = this.props.tabs.map(tab => this._renderTabLabel(tab));
|
||||
const panel = this._renderTabPanel(this.props.tabs[this._getActiveTabIndex()]);
|
||||
const labels = this.props.tabs.map(tab => this.renderTabLabel(tab));
|
||||
const panel = this.renderTabPanel(this.props.tabs[this.getActiveTabIndex()]);
|
||||
|
||||
const tabbedViewClasses = classNames({
|
||||
'mx_TabbedView': true,
|
||||
'mx_TabbedView_tabsOnLeft': this.props.tabLocation == TabLocation.LEFT,
|
||||
'mx_TabbedView_tabsOnTop': this.props.tabLocation == TabLocation.TOP,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx_TabbedView">
|
||||
<div className={tabbedViewClasses}>
|
||||
<div className="mx_TabbedView_tabLabels">
|
||||
{labels}
|
||||
{ labels }
|
||||
</div>
|
||||
{panel}
|
||||
{ panel }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -277,7 +277,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Move into constructor
|
||||
// eslint-disable-next-line camelcase
|
||||
// eslint-disable-next-line
|
||||
UNSAFE_componentWillMount() {
|
||||
if (this.props.manageReadReceipts) {
|
||||
this.updateReadReceiptOnUserActivity();
|
||||
|
@ -290,7 +290,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
// eslint-disable-next-line camelcase
|
||||
// eslint-disable-next-line
|
||||
UNSAFE_componentWillReceiveProps(newProps) {
|
||||
if (newProps.timelineSet !== this.props.timelineSet) {
|
||||
// throw new Error("changing timelineSet on a TimelinePanel is not supported");
|
||||
|
@ -555,9 +555,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
// more than the timeout on userActiveRecently.
|
||||
//
|
||||
const myUserId = MatrixClientPeg.get().credentials.userId;
|
||||
const sender = ev.sender ? ev.sender.userId : null;
|
||||
callRMUpdated = false;
|
||||
if (sender != myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
|
||||
if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
|
||||
updatedState.readMarkerVisible = true;
|
||||
} else if (lastLiveEvent && this.getReadMarkerPosition() === 0) {
|
||||
// we know we're stuckAtBottom, so we can advance the RM
|
||||
|
@ -666,8 +665,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
|
||||
private readMarkerTimeout(readMarkerPosition: number): number {
|
||||
return readMarkerPosition === 0 ?
|
||||
this.state.readMarkerInViewThresholdMs :
|
||||
this.state.readMarkerOutOfViewThresholdMs;
|
||||
this.context?.readMarkerInViewThresholdMs ?? this.state.readMarkerInViewThresholdMs :
|
||||
this.context?.readMarkerOutOfViewThresholdMs ?? this.state.readMarkerOutOfViewThresholdMs;
|
||||
}
|
||||
|
||||
private async updateReadMarkerOnUserActivity(): Promise<void> {
|
||||
|
@ -758,16 +757,20 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
this.lastRMSentEventId = this.state.readMarkerEventId;
|
||||
|
||||
const roomId = this.props.timelineSet.room.roomId;
|
||||
const hiddenRR = SettingsStore.getValue("feature_hidden_read_receipts", roomId);
|
||||
|
||||
debuglog('TimelinePanel: Sending Read Markers for ',
|
||||
this.props.timelineSet.room.roomId,
|
||||
'rm', this.state.readMarkerEventId,
|
||||
lastReadEvent ? 'rr ' + lastReadEvent.getId() : '',
|
||||
' hidden:' + hiddenRR,
|
||||
);
|
||||
MatrixClientPeg.get().setRoomReadMarkers(
|
||||
this.props.timelineSet.room.roomId,
|
||||
roomId,
|
||||
this.state.readMarkerEventId,
|
||||
lastReadEvent, // Could be null, in which case no RR is sent
|
||||
{},
|
||||
{ hidden: hiddenRR },
|
||||
).catch((e) => {
|
||||
// /read_markers API is not implemented on this HS, fallback to just RR
|
||||
if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) {
|
||||
|
@ -863,7 +866,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
const myUserId = MatrixClientPeg.get().credentials.userId;
|
||||
for (i++; i < events.length; i++) {
|
||||
const ev = events[i];
|
||||
if (!ev.sender || ev.sender.userId != myUserId) {
|
||||
if (ev.getSender() !== myUserId) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -1051,6 +1054,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
{ windowLimit: this.props.timelineCap });
|
||||
|
||||
const onLoaded = () => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
// clear the timeline min-height when
|
||||
// (re)loading the timeline
|
||||
if (this.messagePanel.current) {
|
||||
|
@ -1092,6 +1097,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
const onError = (error) => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
this.setState({ timelineLoading: false });
|
||||
console.error(
|
||||
`Error loading timeline panel at ${eventId}: ${error}`,
|
||||
|
@ -1333,8 +1340,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
const shouldIgnore = !!ev.status || // local echo
|
||||
(ignoreOwn && ev.sender && ev.sender.userId == myUserId); // own message
|
||||
const isWithoutTile = !haveTileForEvent(ev) || shouldHideEvent(ev, this.context);
|
||||
(ignoreOwn && ev.getSender() === myUserId); // own message
|
||||
const isWithoutTile = !haveTileForEvent(ev, this.context?.showHiddenEventsInTimeline) ||
|
||||
shouldHideEvent(ev, this.context);
|
||||
|
||||
if (isWithoutTile || !node) {
|
||||
// don't start counting if the event should be ignored,
|
||||
|
@ -1444,7 +1452,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
if (this.state.events.length == 0 && !this.state.canBackPaginate && this.props.empty) {
|
||||
return (
|
||||
<div className={this.props.className + " mx_RoomView_messageListWrapper"}>
|
||||
<div className="mx_RoomView_empty">{this.props.empty}</div>
|
||||
<div className="mx_RoomView_empty">{ this.props.empty }</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1489,8 +1497,12 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
onUserScroll={this.props.onUserScroll}
|
||||
onFillRequest={this.onMessageListFillRequest}
|
||||
onUnfillRequest={this.onMessageListUnfillRequest}
|
||||
isTwelveHour={this.state.isTwelveHour}
|
||||
alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.alwaysShowTimestamps}
|
||||
isTwelveHour={this.context?.showTwelveHourTimestamps ?? this.state.isTwelveHour}
|
||||
alwaysShowTimestamps={
|
||||
this.props.alwaysShowTimestamps ??
|
||||
this.context?.alwaysShowTimestamps ??
|
||||
this.state.alwaysShowTimestamps
|
||||
}
|
||||
className={this.props.className}
|
||||
tileShape={this.props.tileShape}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
|
|
|
@ -37,14 +37,14 @@ export default class ToastContainer extends React.Component<{}, IState> {
|
|||
// toasts may dismiss themselves in their didMount if they find
|
||||
// they're already irrelevant by the time they're mounted, and
|
||||
// our own componentDidMount is too late.
|
||||
ToastStore.sharedInstance().on('update', this._onToastStoreUpdate);
|
||||
ToastStore.sharedInstance().on('update', this.onToastStoreUpdate);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
ToastStore.sharedInstance().removeListener('update', this._onToastStoreUpdate);
|
||||
ToastStore.sharedInstance().removeListener('update', this.onToastStoreUpdate);
|
||||
}
|
||||
|
||||
_onToastStoreUpdate = () => {
|
||||
private onToastStoreUpdate = () => {
|
||||
this.setState({
|
||||
toasts: ToastStore.sharedInstance().getToasts(),
|
||||
countSeen: ToastStore.sharedInstance().getCountSeen(),
|
||||
|
@ -58,28 +58,39 @@ export default class ToastContainer extends React.Component<{}, IState> {
|
|||
let containerClasses;
|
||||
if (totalCount !== 0) {
|
||||
const topToast = this.state.toasts[0];
|
||||
const { title, icon, key, component, className, props } = topToast;
|
||||
const toastClasses = classNames("mx_Toast_toast", {
|
||||
const { title, icon, key, component, className, bodyClassName, props } = topToast;
|
||||
const bodyClasses = classNames("mx_Toast_body", bodyClassName);
|
||||
const toastClasses = classNames("mx_Toast_toast", className, {
|
||||
"mx_Toast_hasIcon": icon,
|
||||
[`mx_Toast_icon_${icon}`]: icon,
|
||||
}, className);
|
||||
|
||||
let countIndicator;
|
||||
if (isStacked || this.state.countSeen > 0) {
|
||||
countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`;
|
||||
}
|
||||
|
||||
});
|
||||
const toastProps = Object.assign({}, props, {
|
||||
key,
|
||||
toastKey: key,
|
||||
});
|
||||
toast = (<div className={toastClasses}>
|
||||
<div className="mx_Toast_title">
|
||||
<h2>{title}</h2>
|
||||
<span>{countIndicator}</span>
|
||||
const content = React.createElement(component, toastProps);
|
||||
|
||||
let countIndicator;
|
||||
if (title && isStacked || this.state.countSeen > 0) {
|
||||
countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`;
|
||||
}
|
||||
|
||||
let titleElement;
|
||||
if (title) {
|
||||
titleElement = (
|
||||
<div className="mx_Toast_title">
|
||||
<h2>{ title }</h2>
|
||||
<span>{ countIndicator }</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
toast = (
|
||||
<div className={toastClasses}>
|
||||
{ titleElement }
|
||||
<div className={bodyClasses}>{ content }</div>
|
||||
</div>
|
||||
<div className="mx_Toast_body">{React.createElement(component, toastProps)}</div>
|
||||
</div>);
|
||||
);
|
||||
|
||||
containerClasses = classNames("mx_ToastContainer", {
|
||||
"mx_ToastContainer_stacked": isStacked,
|
||||
|
@ -88,7 +99,7 @@ export default class ToastContainer extends React.Component<{}, IState> {
|
|||
return toast
|
||||
? (
|
||||
<div className={containerClasses} role="alert">
|
||||
{toast}
|
||||
{ toast }
|
||||
</div>
|
||||
)
|
||||
: null;
|
||||
|
|
|
@ -104,7 +104,7 @@ export default class UploadBar extends React.Component<IProps, IState> {
|
|||
const uploadSize = filesize(this.state.currentUpload.total);
|
||||
return (
|
||||
<div className="mx_UploadBar">
|
||||
<div className="mx_UploadBar_filename">{uploadText} ({uploadSize})</div>
|
||||
<div className="mx_UploadBar_filename">{ uploadText } ({ uploadSize })</div>
|
||||
<AccessibleButton onClick={this.onCancelClick} className='mx_UploadBar_cancel' />
|
||||
<ProgressBar value={this.state.currentUpload.loaded} max={this.state.currentUpload.total} />
|
||||
</div>
|
||||
|
|
|
@ -90,7 +90,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
|
||||
}
|
||||
|
||||
|
@ -115,7 +115,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
|
||||
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
|
||||
this.tagStoreRef.remove();
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
|
||||
}
|
||||
MatrixClientPeg.get().removeListener("Room", this.onRoom);
|
||||
|
@ -342,20 +342,20 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
if (MatrixClientPeg.get().isGuest()) {
|
||||
topSection = (
|
||||
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_guestPrompts">
|
||||
{_t("Got an account? <a>Sign in</a>", {}, {
|
||||
{ _t("Got an account? <a>Sign in</a>", {}, {
|
||||
a: sub => (
|
||||
<AccessibleButton kind="link" onClick={this.onSignInClick}>
|
||||
{sub}
|
||||
{ sub }
|
||||
</AccessibleButton>
|
||||
),
|
||||
})}
|
||||
{_t("New here? <a>Create an account</a>", {}, {
|
||||
}) }
|
||||
{ _t("New here? <a>Create an account</a>", {}, {
|
||||
a: sub => (
|
||||
<AccessibleButton kind="link" onClick={this.onRegisterClick}>
|
||||
{sub}
|
||||
{ sub }
|
||||
</AccessibleButton>
|
||||
),
|
||||
})}
|
||||
}) }
|
||||
</div>
|
||||
);
|
||||
} else if (hostSignupConfig) {
|
||||
|
@ -394,17 +394,17 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
let primaryHeader = (
|
||||
<div className="mx_UserMenu_contextMenu_name">
|
||||
<span className="mx_UserMenu_contextMenu_displayName">
|
||||
{OwnProfileStore.instance.displayName}
|
||||
{ OwnProfileStore.instance.displayName }
|
||||
</span>
|
||||
<span className="mx_UserMenu_contextMenu_userId">
|
||||
{MatrixClientPeg.get().getUserId()}
|
||||
{ MatrixClientPeg.get().getUserId() }
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
let primaryOptionList = (
|
||||
<React.Fragment>
|
||||
<IconizedContextMenuOptionList>
|
||||
{homeButton}
|
||||
{ homeButton }
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconBell"
|
||||
label={_t("Notification settings")}
|
||||
|
@ -420,11 +420,11 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
label={_t("All settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e, null)}
|
||||
/>
|
||||
{/* <IconizedContextMenuOption
|
||||
{ /* <IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconArchive"
|
||||
label={_t("Archived rooms")}
|
||||
onClick={this.onShowArchived}
|
||||
/> */}
|
||||
/> */ }
|
||||
{ feedbackButton }
|
||||
</IconizedContextMenuOptionList>
|
||||
<IconizedContextMenuOptionList red>
|
||||
|
@ -443,7 +443,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
primaryHeader = (
|
||||
<div className="mx_UserMenu_contextMenu_name">
|
||||
<span className="mx_UserMenu_contextMenu_displayName">
|
||||
{prototypeCommunityName}
|
||||
{ prototypeCommunityName }
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
@ -470,13 +470,13 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
}
|
||||
primaryOptionList = (
|
||||
<IconizedContextMenuOptionList>
|
||||
{settingsOption}
|
||||
{ settingsOption }
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconMembers"
|
||||
label={_t("Members")}
|
||||
onClick={this.onCommunityMembersClick}
|
||||
/>
|
||||
{inviteOption}
|
||||
{ inviteOption }
|
||||
</IconizedContextMenuOptionList>
|
||||
);
|
||||
secondarySection = (
|
||||
|
@ -485,10 +485,10 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
<div className="mx_UserMenu_contextMenu_header">
|
||||
<div className="mx_UserMenu_contextMenu_name">
|
||||
<span className="mx_UserMenu_contextMenu_displayName">
|
||||
{OwnProfileStore.instance.displayName}
|
||||
{ OwnProfileStore.instance.displayName }
|
||||
</span>
|
||||
<span className="mx_UserMenu_contextMenu_userId">
|
||||
{MatrixClientPeg.get().getUserId()}
|
||||
{ MatrixClientPeg.get().getUserId() }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -540,7 +540,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
className={classes}
|
||||
>
|
||||
<div className="mx_UserMenu_contextMenu_header">
|
||||
{primaryHeader}
|
||||
{ primaryHeader }
|
||||
<AccessibleTooltipButton
|
||||
className="mx_UserMenu_contextMenu_themeButton"
|
||||
onClick={this.onSwitchThemeClick}
|
||||
|
@ -553,9 +553,9 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
/>
|
||||
</AccessibleTooltipButton>
|
||||
</div>
|
||||
{topSection}
|
||||
{primaryOptionList}
|
||||
{secondarySection}
|
||||
{ topSection }
|
||||
{ primaryOptionList }
|
||||
{ secondarySection }
|
||||
</IconizedContextMenu>;
|
||||
};
|
||||
|
||||
|
@ -570,27 +570,27 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
|
||||
let isPrototype = false;
|
||||
let menuName = _t("User menu");
|
||||
let name = <span className="mx_UserMenu_userName">{displayName}</span>;
|
||||
let name = <span className="mx_UserMenu_userName">{ displayName }</span>;
|
||||
let buttons = (
|
||||
<span className="mx_UserMenu_headerButtons">
|
||||
{/* masked image in CSS */}
|
||||
{ /* masked image in CSS */ }
|
||||
</span>
|
||||
);
|
||||
let dnd;
|
||||
if (this.state.selectedSpace) {
|
||||
name = (
|
||||
<div className="mx_UserMenu_doubleName">
|
||||
<span className="mx_UserMenu_userName">{displayName}</span>
|
||||
<span className="mx_UserMenu_userName">{ displayName }</span>
|
||||
<RoomName room={this.state.selectedSpace}>
|
||||
{(roomName) => <span className="mx_UserMenu_subUserName">{roomName}</span>}
|
||||
{ (roomName) => <span className="mx_UserMenu_subUserName">{ roomName }</span> }
|
||||
</RoomName>
|
||||
</div>
|
||||
);
|
||||
} else if (prototypeCommunityName) {
|
||||
name = (
|
||||
<div className="mx_UserMenu_doubleName">
|
||||
<span className="mx_UserMenu_userName">{prototypeCommunityName}</span>
|
||||
<span className="mx_UserMenu_subUserName">{displayName}</span>
|
||||
<span className="mx_UserMenu_userName">{ prototypeCommunityName }</span>
|
||||
<span className="mx_UserMenu_subUserName">{ displayName }</span>
|
||||
</div>
|
||||
);
|
||||
menuName = _t("Community and user menu");
|
||||
|
@ -598,8 +598,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
} else if (SettingsStore.getValue("feature_communities_v2_prototypes")) {
|
||||
name = (
|
||||
<div className="mx_UserMenu_doubleName">
|
||||
<span className="mx_UserMenu_userName">{_t("Home")}</span>
|
||||
<span className="mx_UserMenu_subUserName">{displayName}</span>
|
||||
<span className="mx_UserMenu_userName">{ _t("Home") }</span>
|
||||
<span className="mx_UserMenu_subUserName">{ displayName }</span>
|
||||
</div>
|
||||
);
|
||||
isPrototype = true;
|
||||
|
@ -647,20 +647,20 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
className="mx_UserMenu_userAvatar"
|
||||
/>
|
||||
</span>
|
||||
{name}
|
||||
{this.state.pendingRoomJoin.size > 0 && (
|
||||
{ name }
|
||||
{ this.state.pendingRoomJoin.size > 0 && (
|
||||
<InlineSpinner>
|
||||
<TooltipButton helpText={_t(
|
||||
"Currently joining %(count)s rooms",
|
||||
{ count: this.state.pendingRoomJoin.size },
|
||||
)} />
|
||||
</InlineSpinner>
|
||||
)}
|
||||
{dnd}
|
||||
{buttons}
|
||||
) }
|
||||
{ dnd }
|
||||
{ buttons }
|
||||
</div>
|
||||
</ContextMenuButton>
|
||||
{this.renderContextMenu()}
|
||||
{ this.renderContextMenu() }
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -63,23 +63,23 @@ export default class ViewSource extends React.Component {
|
|||
<>
|
||||
<details open className="mx_ViewSource_details">
|
||||
<summary>
|
||||
<span className="mx_ViewSource_heading">{_t("Decrypted event source")}</span>
|
||||
<span className="mx_ViewSource_heading">{ _t("Decrypted event source") }</span>
|
||||
</summary>
|
||||
<SyntaxHighlight className="json">{JSON.stringify(decryptedEventSource, null, 2)}</SyntaxHighlight>
|
||||
<SyntaxHighlight className="json">{ JSON.stringify(decryptedEventSource, null, 2) }</SyntaxHighlight>
|
||||
</details>
|
||||
<details className="mx_ViewSource_details">
|
||||
<summary>
|
||||
<span className="mx_ViewSource_heading">{_t("Original event source")}</span>
|
||||
<span className="mx_ViewSource_heading">{ _t("Original event source") }</span>
|
||||
</summary>
|
||||
<SyntaxHighlight className="json">{JSON.stringify(originalEventSource, null, 2)}</SyntaxHighlight>
|
||||
<SyntaxHighlight className="json">{ JSON.stringify(originalEventSource, null, 2) }</SyntaxHighlight>
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<div className="mx_ViewSource_heading">{_t("Original event source")}</div>
|
||||
<SyntaxHighlight className="json">{JSON.stringify(originalEventSource, null, 2)}</SyntaxHighlight>
|
||||
<div className="mx_ViewSource_heading">{ _t("Original event source") }</div>
|
||||
<SyntaxHighlight className="json">{ JSON.stringify(originalEventSource, null, 2) }</SyntaxHighlight>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -110,7 +110,7 @@ export default class ViewSource extends React.Component {
|
|||
if (isStateEvent) {
|
||||
return (
|
||||
<MatrixClientContext.Consumer>
|
||||
{(cli) => (
|
||||
{ (cli) => (
|
||||
<SendCustomEvent
|
||||
room={cli.getRoom(roomId)}
|
||||
forceStateEvent={true}
|
||||
|
@ -121,7 +121,7 @@ export default class ViewSource extends React.Component {
|
|||
stateKey: mxEvent.getStateKey(),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
) }
|
||||
</MatrixClientContext.Consumer>
|
||||
);
|
||||
} else {
|
||||
|
@ -142,7 +142,7 @@ export default class ViewSource extends React.Component {
|
|||
};
|
||||
return (
|
||||
<MatrixClientContext.Consumer>
|
||||
{(cli) => (
|
||||
{ (cli) => (
|
||||
<SendCustomEvent
|
||||
room={cli.getRoom(roomId)}
|
||||
forceStateEvent={false}
|
||||
|
@ -153,7 +153,7 @@ export default class ViewSource extends React.Component {
|
|||
evContent: JSON.stringify(newContent, null, "\t"),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
) }
|
||||
</MatrixClientContext.Consumer>
|
||||
);
|
||||
}
|
||||
|
@ -176,16 +176,16 @@ export default class ViewSource extends React.Component {
|
|||
return (
|
||||
<BaseDialog className="mx_ViewSource" onFinished={this.props.onFinished} title={_t("View Source")}>
|
||||
<div>
|
||||
<div>Room ID: {roomId}</div>
|
||||
<div>Event ID: {eventId}</div>
|
||||
<div>Room ID: { roomId }</div>
|
||||
<div>Event ID: { eventId }</div>
|
||||
<div className="mx_ViewSource_separator" />
|
||||
{isEditing ? this.editSourceContent() : this.viewSourceContent()}
|
||||
{ isEditing ? this.editSourceContent() : this.viewSourceContent() }
|
||||
</div>
|
||||
{!isEditing && canEdit && (
|
||||
{ !isEditing && canEdit && (
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={() => this.onEdit()}>{_t("Edit")}</button>
|
||||
<button onClick={() => this.onEdit()}>{ _t("Edit") }</button>
|
||||
</div>
|
||||
)}
|
||||
) }
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -79,8 +79,8 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
|
|||
<AuthPage>
|
||||
<CompleteSecurityBody>
|
||||
<h2 className="mx_CompleteSecurity_header">
|
||||
{icon}
|
||||
{title}
|
||||
{ icon }
|
||||
{ title }
|
||||
</h2>
|
||||
<div className="mx_CompleteSecurity_body">
|
||||
<SetupEncryptionBody onFinished={this.props.onFinished} />
|
||||
|
|
|
@ -101,7 +101,7 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
// eslint-disable-next-line camelcase
|
||||
// eslint-disable-next-line
|
||||
public UNSAFE_componentWillReceiveProps(newProps: IProps): void {
|
||||
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
|
||||
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
|
||||
|
@ -239,14 +239,14 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
|
|||
});
|
||||
serverDeadSection = (
|
||||
<div className={classes}>
|
||||
{this.state.serverDeadError}
|
||||
{ this.state.serverDeadError }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div>
|
||||
{errorText}
|
||||
{serverDeadSection}
|
||||
{ errorText }
|
||||
{ serverDeadSection }
|
||||
<ServerPicker
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
|
@ -289,10 +289,10 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
|
|||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<span>{_t(
|
||||
<span>{ _t(
|
||||
'A verification email will be sent to your inbox to confirm ' +
|
||||
'setting your new password.',
|
||||
)}</span>
|
||||
) }</span>
|
||||
<input
|
||||
className="mx_Login_submit"
|
||||
type="submit"
|
||||
|
@ -300,7 +300,7 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
|
|||
/>
|
||||
</form>
|
||||
<a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
|
||||
{_t('Sign in instead')}
|
||||
{ _t('Sign in instead') }
|
||||
</a>
|
||||
</div>;
|
||||
}
|
||||
|
@ -312,23 +312,29 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
|
|||
|
||||
renderEmailSent() {
|
||||
return <div>
|
||||
{_t("An email has been sent to %(emailAddress)s. Once you've followed the " +
|
||||
"link it contains, click below.", { emailAddress: this.state.email })}
|
||||
{ _t("An email has been sent to %(emailAddress)s. Once you've followed the " +
|
||||
"link it contains, click below.", { emailAddress: this.state.email }) }
|
||||
<br />
|
||||
<input className="mx_Login_submit" type="button" onClick={this.onVerify}
|
||||
<input
|
||||
className="mx_Login_submit"
|
||||
type="button"
|
||||
onClick={this.onVerify}
|
||||
value={_t('I have verified my email address')} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
renderDone() {
|
||||
return <div>
|
||||
<p>{_t("Your password has been reset.")}</p>
|
||||
<p>{_t(
|
||||
<p>{ _t("Your password has been reset.") }</p>
|
||||
<p>{ _t(
|
||||
"You have been logged out of all sessions and will no longer receive " +
|
||||
"push notifications. To re-enable notifications, sign in again on each " +
|
||||
"device.",
|
||||
)}</p>
|
||||
<input className="mx_Login_submit" type="button" onClick={this.props.onComplete}
|
||||
) }</p>
|
||||
<input
|
||||
className="mx_Login_submit"
|
||||
type="button"
|
||||
onClick={this.props.onComplete}
|
||||
value={_t('Return to login screen')} />
|
||||
</div>;
|
||||
}
|
||||
|
@ -358,7 +364,7 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
|
|||
<AuthHeader />
|
||||
<AuthBody>
|
||||
<h2> { _t('Set a new password') } </h2>
|
||||
{resetPasswordJsx}
|
||||
{ resetPasswordJsx }
|
||||
</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
|
|
|
@ -144,7 +144,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
|
|||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
// eslint-disable-next-line camelcase
|
||||
// eslint-disable-next-line
|
||||
UNSAFE_componentWillMount() {
|
||||
this.initLoginLogic(this.props.serverConfig);
|
||||
}
|
||||
|
@ -154,7 +154,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
|
|||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
// eslint-disable-next-line camelcase
|
||||
// eslint-disable-next-line
|
||||
UNSAFE_componentWillReceiveProps(newProps) {
|
||||
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
|
||||
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
|
||||
|
@ -239,8 +239,8 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
|
|||
);
|
||||
errorText = (
|
||||
<div>
|
||||
<div>{errorTop}</div>
|
||||
<div className="mx_Login_smallError">{errorDetail}</div>
|
||||
<div>{ errorTop }</div>
|
||||
<div className="mx_Login_smallError">{ errorDetail }</div>
|
||||
</div>
|
||||
);
|
||||
} else if (error.httpStatus === 401 || error.httpStatus === 403) {
|
||||
|
@ -251,10 +251,10 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
|
|||
<div>
|
||||
<div>{ _t('Incorrect username and/or password.') }</div>
|
||||
<div className="mx_Login_smallError">
|
||||
{_t(
|
||||
{ _t(
|
||||
'Please note you are logging into the %(hs)s server, not matrix.org.',
|
||||
{ hs: this.props.serverConfig.hsName },
|
||||
)}
|
||||
) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -463,7 +463,9 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
|
|||
"Either use HTTPS or <a>enable unsafe scripts</a>.", {},
|
||||
{
|
||||
'a': (sub) => {
|
||||
return <a target="_blank" rel="noreferrer noopener"
|
||||
return <a
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
|
||||
>
|
||||
{ sub }
|
||||
|
@ -565,7 +567,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
|
|||
});
|
||||
serverDeadSection = (
|
||||
<div className={classes}>
|
||||
{this.state.serverDeadError}
|
||||
{ this.state.serverDeadError }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -578,15 +580,15 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
|
|||
{ this.props.isSyncing ? _t("Syncing...") : _t("Signing In...") }
|
||||
</div>
|
||||
{ this.props.isSyncing && <div className="mx_AuthBody_paddedFooter_subtitle">
|
||||
{_t("If you've joined lots of rooms, this might take a while")}
|
||||
{ _t("If you've joined lots of rooms, this might take a while") }
|
||||
</div> }
|
||||
</div>;
|
||||
} else if (SettingsStore.getValue(UIFeature.Registration)) {
|
||||
footer = (
|
||||
<span className="mx_AuthBody_changeFlow">
|
||||
{_t("New? <a>Create account</a>", {}, {
|
||||
{ _t("New? <a>Create account</a>", {}, {
|
||||
a: sub => <a onClick={this.onTryRegisterClick} href="#">{ sub }</a>,
|
||||
})}
|
||||
}) }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
@ -596,8 +598,8 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
|
|||
<AuthHeader disableLanguageSelector={this.props.isSyncing || this.state.busyLoggingIn} />
|
||||
<AuthBody>
|
||||
<h2>
|
||||
{_t('Sign in')}
|
||||
{loader}
|
||||
{ _t('Sign in') }
|
||||
{ loader }
|
||||
</h2>
|
||||
{ errorTextSection }
|
||||
{ serverDeadSection }
|
||||
|
|
|
@ -141,7 +141,7 @@ export default class Registration extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
// eslint-disable-next-line camelcase
|
||||
// eslint-disable-next-line
|
||||
UNSAFE_componentWillReceiveProps(newProps) {
|
||||
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
|
||||
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
|
||||
|
@ -290,8 +290,8 @@ export default class Registration extends React.Component<IProps, IState> {
|
|||
},
|
||||
);
|
||||
msg = <div>
|
||||
<p>{errorTop}</p>
|
||||
<p>{errorDetail}</p>
|
||||
<p>{ errorTop }</p>
|
||||
<p>{ errorDetail }</p>
|
||||
</div>;
|
||||
} else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
|
||||
let msisdnAvailable = false;
|
||||
|
@ -482,13 +482,13 @@ export default class Registration extends React.Component<IProps, IState> {
|
|||
fragmentAfterLogin={this.props.fragmentAfterLogin}
|
||||
/>
|
||||
<h3 className="mx_AuthBody_centered">
|
||||
{_t(
|
||||
{ _t(
|
||||
"%(ssoButtons)s Or %(usernamePassword)s",
|
||||
{
|
||||
ssoButtons: "",
|
||||
usernamePassword: "",
|
||||
},
|
||||
).trim()}
|
||||
).trim() }
|
||||
</h3>
|
||||
</React.Fragment>;
|
||||
}
|
||||
|
@ -526,15 +526,15 @@ export default class Registration extends React.Component<IProps, IState> {
|
|||
});
|
||||
serverDeadSection = (
|
||||
<div className={classes}>
|
||||
{this.state.serverDeadError}
|
||||
{ this.state.serverDeadError }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const signIn = <span className="mx_AuthBody_changeFlow">
|
||||
{_t("Already have an account? <a>Sign in here</a>", {}, {
|
||||
{ _t("Already have an account? <a>Sign in here</a>", {}, {
|
||||
a: sub => <a onClick={this.onLoginClick} href="#">{ sub }</a>,
|
||||
})}
|
||||
}) }
|
||||
</span>;
|
||||
|
||||
// Only show the 'go back' button if you're not looking at the form
|
||||
|
@ -550,43 +550,47 @@ export default class Registration extends React.Component<IProps, IState> {
|
|||
let regDoneText;
|
||||
if (this.state.differentLoggedInUserId) {
|
||||
regDoneText = <div>
|
||||
<p>{_t(
|
||||
<p>{ _t(
|
||||
"Your new account (%(newAccountId)s) is registered, but you're already " +
|
||||
"logged into a different account (%(loggedInUserId)s).", {
|
||||
newAccountId: this.state.registeredUsername,
|
||||
loggedInUserId: this.state.differentLoggedInUserId,
|
||||
},
|
||||
)}</p>
|
||||
<p><AccessibleButton element="span" className="mx_linkButton" onClick={async event => {
|
||||
const sessionLoaded = await this.onLoginClickWithCheck(event);
|
||||
if (sessionLoaded) {
|
||||
dis.dispatch({ action: "view_welcome_page" });
|
||||
}
|
||||
}}>
|
||||
{_t("Continue with previous account")}
|
||||
) }</p>
|
||||
<p><AccessibleButton
|
||||
element="span"
|
||||
className="mx_linkButton"
|
||||
onClick={async event => {
|
||||
const sessionLoaded = await this.onLoginClickWithCheck(event);
|
||||
if (sessionLoaded) {
|
||||
dis.dispatch({ action: "view_welcome_page" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{ _t("Continue with previous account") }
|
||||
</AccessibleButton></p>
|
||||
</div>;
|
||||
} else if (this.state.formVals.password) {
|
||||
// We're the client that started the registration
|
||||
regDoneText = <h3>{_t(
|
||||
regDoneText = <h3>{ _t(
|
||||
"<a>Log in</a> to your new account.", {},
|
||||
{
|
||||
a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{sub}</a>,
|
||||
a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{ sub }</a>,
|
||||
},
|
||||
)}</h3>;
|
||||
) }</h3>;
|
||||
} else {
|
||||
// We're not the original client: the user probably got to us by clicking the
|
||||
// email validation link. We can't offer a 'go straight to your account' link
|
||||
// as we don't have the original creds.
|
||||
regDoneText = <h3>{_t(
|
||||
regDoneText = <h3>{ _t(
|
||||
"You can now close this window or <a>log in</a> to your new account.", {},
|
||||
{
|
||||
a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{sub}</a>,
|
||||
a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{ sub }</a>,
|
||||
},
|
||||
)}</h3>;
|
||||
) }</h3>;
|
||||
}
|
||||
body = <div>
|
||||
<h2>{_t("Registration Successful")}</h2>
|
||||
<h2>{ _t("Registration Successful") }</h2>
|
||||
{ regDoneText }
|
||||
</div>;
|
||||
} else {
|
||||
|
|
|
@ -152,7 +152,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
|||
let useRecoveryKeyButton;
|
||||
if (recoveryKeyPrompt) {
|
||||
useRecoveryKeyButton = <AccessibleButton kind="link" onClick={this.onUsePassphraseClick}>
|
||||
{recoveryKeyPrompt}
|
||||
{ recoveryKeyPrompt }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
|
@ -165,15 +165,15 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
|||
|
||||
return (
|
||||
<div>
|
||||
<p>{_t(
|
||||
<p>{ _t(
|
||||
"Verify your identity to access encrypted messages and prove your identity to others.",
|
||||
)}</p>
|
||||
) }</p>
|
||||
|
||||
<div className="mx_CompleteSecurity_actionRow">
|
||||
{verifyButton}
|
||||
{useRecoveryKeyButton}
|
||||
{ verifyButton }
|
||||
{ useRecoveryKeyButton }
|
||||
<AccessibleButton kind="danger" onClick={this.onSkipClick}>
|
||||
{_t("Skip")}
|
||||
{ _t("Skip") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -181,25 +181,25 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
|||
} else if (phase === Phase.Done) {
|
||||
let message;
|
||||
if (this.state.backupInfo) {
|
||||
message = <p>{_t(
|
||||
message = <p>{ _t(
|
||||
"Your new session is now verified. It has access to your " +
|
||||
"encrypted messages, and other users will see it as trusted.",
|
||||
)}</p>;
|
||||
) }</p>;
|
||||
} else {
|
||||
message = <p>{_t(
|
||||
message = <p>{ _t(
|
||||
"Your new session is now verified. Other users will see it as trusted.",
|
||||
)}</p>;
|
||||
) }</p>;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="mx_CompleteSecurity_heroIcon mx_E2EIcon_verified" />
|
||||
{message}
|
||||
{ message }
|
||||
<div className="mx_CompleteSecurity_actionRow">
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
onClick={this.onDoneClick}
|
||||
>
|
||||
{_t("Done")}
|
||||
{ _t("Done") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -207,23 +207,23 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
|||
} else if (phase === Phase.ConfirmSkip) {
|
||||
return (
|
||||
<div>
|
||||
<p>{_t(
|
||||
<p>{ _t(
|
||||
"Without verifying, you won’t have access to all your messages " +
|
||||
"and may appear as untrusted to others.",
|
||||
)}</p>
|
||||
) }</p>
|
||||
<div className="mx_CompleteSecurity_actionRow">
|
||||
<AccessibleButton
|
||||
className="warning"
|
||||
kind="secondary"
|
||||
onClick={this.onSkipConfirmClick}
|
||||
>
|
||||
{_t("Skip")}
|
||||
{ _t("Skip") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
kind="danger"
|
||||
onClick={this.onSkipBackClick}
|
||||
>
|
||||
{_t("Go Back")}
|
||||
{ _t("Go Back") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -219,7 +219,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
|
|||
if (this.state.loginView === LOGIN_VIEW.PASSWORD) {
|
||||
let error = null;
|
||||
if (this.state.errorText) {
|
||||
error = <span className='mx_Login_error'>{this.state.errorText}</span>;
|
||||
error = <span className='mx_Login_error'>{ this.state.errorText }</span>;
|
||||
}
|
||||
|
||||
if (!introText) {
|
||||
|
@ -228,8 +228,8 @@ export default class SoftLogout extends React.Component<IProps, IState> {
|
|||
|
||||
return (
|
||||
<form onSubmit={this.onPasswordLogin}>
|
||||
<p>{introText}</p>
|
||||
{error}
|
||||
<p>{ introText }</p>
|
||||
{ error }
|
||||
<Field
|
||||
type="password"
|
||||
label={_t("Password")}
|
||||
|
@ -243,10 +243,10 @@ export default class SoftLogout extends React.Component<IProps, IState> {
|
|||
type="submit"
|
||||
disabled={this.state.busy}
|
||||
>
|
||||
{_t("Sign In")}
|
||||
{ _t("Sign In") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton onClick={this.onForgotPassword} kind="link">
|
||||
{_t("Forgotten your password?")}
|
||||
{ _t("Forgotten your password?") }
|
||||
</AccessibleButton>
|
||||
</form>
|
||||
);
|
||||
|
@ -262,7 +262,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<p>{introText}</p>
|
||||
<p>{ introText }</p>
|
||||
<SSOButtons
|
||||
matrixClient={MatrixClientPeg.get()}
|
||||
flow={flow}
|
||||
|
@ -277,10 +277,10 @@ export default class SoftLogout extends React.Component<IProps, IState> {
|
|||
// Default: assume unsupported/error
|
||||
return (
|
||||
<p>
|
||||
{_t(
|
||||
{ _t(
|
||||
"You cannot sign in to your account. Please contact your " +
|
||||
"homeserver admin for more information.",
|
||||
)}
|
||||
) }
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
@ -291,25 +291,25 @@ export default class SoftLogout extends React.Component<IProps, IState> {
|
|||
<AuthHeader />
|
||||
<AuthBody>
|
||||
<h2>
|
||||
{_t("You're signed out")}
|
||||
{ _t("You're signed out") }
|
||||
</h2>
|
||||
|
||||
<h3>{_t("Sign in")}</h3>
|
||||
<h3>{ _t("Sign in") }</h3>
|
||||
<div>
|
||||
{this.renderSignInSection()}
|
||||
{ this.renderSignInSection() }
|
||||
</div>
|
||||
|
||||
<h3>{_t("Clear personal data")}</h3>
|
||||
<h3>{ _t("Clear personal data") }</h3>
|
||||
<p>
|
||||
{_t(
|
||||
{ _t(
|
||||
"Warning: Your personal data (including encryption keys) is still stored " +
|
||||
"in this session. Clear it if you're finished using this session, or want to sign " +
|
||||
"in to another account.",
|
||||
)}
|
||||
) }
|
||||
</p>
|
||||
<div>
|
||||
<AccessibleButton onClick={this.onClearAll} kind="danger">
|
||||
{_t("Clear all data")}
|
||||
{ _t("Clear all data") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</AuthBody>
|
||||
|
|
|
@ -14,9 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import React, { createRef, ReactNode, RefObject } from "react";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import PlayPauseButton from "./PlayPauseButton";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { formatBytes } from "../../../utils/FormattingUtils";
|
||||
|
@ -25,44 +23,13 @@ import { Key } from "../../../Keyboard";
|
|||
import { _t } from "../../../languageHandler";
|
||||
import SeekBar from "./SeekBar";
|
||||
import PlaybackClock from "./PlaybackClock";
|
||||
|
||||
interface IProps {
|
||||
// Playback instance to render. Cannot change during component lifecycle: create
|
||||
// an all-new component instead.
|
||||
playback: Playback;
|
||||
|
||||
mediaName: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
playbackPhase: PlaybackState;
|
||||
}
|
||||
import AudioPlayerBase from "./AudioPlayerBase";
|
||||
|
||||
@replaceableComponent("views.audio_messages.AudioPlayer")
|
||||
export default class AudioPlayer extends React.PureComponent<IProps, IState> {
|
||||
export default class AudioPlayer extends AudioPlayerBase {
|
||||
private playPauseRef: RefObject<PlayPauseButton> = createRef();
|
||||
private seekRef: RefObject<SeekBar> = createRef();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
playbackPhase: PlaybackState.Decoding, // default assumption
|
||||
};
|
||||
|
||||
// We don't need to de-register: the class handles this for us internally
|
||||
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
|
||||
|
||||
// Don't wait for the promise to complete - it will emit a progress update when it
|
||||
// is done, and it's not meant to take long anyhow.
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.props.playback.prepare();
|
||||
}
|
||||
|
||||
private onPlaybackUpdate = (ev: PlaybackState) => {
|
||||
this.setState({ playbackPhase: ev });
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
// stopPropagation() prevents the FocusComposer catch-all from triggering,
|
||||
// but we need to do it on key down instead of press (even though the user
|
||||
|
@ -88,37 +55,39 @@ export default class AudioPlayer extends React.PureComponent<IProps, IState> {
|
|||
return `(${formatBytes(bytes)})`;
|
||||
}
|
||||
|
||||
public render(): ReactNode {
|
||||
protected renderComponent(): ReactNode {
|
||||
// tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard
|
||||
// events for accessibility
|
||||
return <div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}>
|
||||
<div className='mx_AudioPlayer_primaryContainer'>
|
||||
<PlayPauseButton
|
||||
playback={this.props.playback}
|
||||
playbackPhase={this.state.playbackPhase}
|
||||
tabIndex={-1} // prevent tabbing into the button
|
||||
ref={this.playPauseRef}
|
||||
/>
|
||||
<div className='mx_AudioPlayer_mediaInfo'>
|
||||
<span className='mx_AudioPlayer_mediaName'>
|
||||
{this.props.mediaName || _t("Unnamed audio")}
|
||||
</span>
|
||||
<div className='mx_AudioPlayer_byline'>
|
||||
<DurationClock playback={this.props.playback} />
|
||||
{/* easiest way to introduce a gap between the components */}
|
||||
{ this.renderFileSize() }
|
||||
return (
|
||||
<div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}>
|
||||
<div className='mx_AudioPlayer_primaryContainer'>
|
||||
<PlayPauseButton
|
||||
playback={this.props.playback}
|
||||
playbackPhase={this.state.playbackPhase}
|
||||
tabIndex={-1} // prevent tabbing into the button
|
||||
ref={this.playPauseRef}
|
||||
/>
|
||||
<div className='mx_AudioPlayer_mediaInfo'>
|
||||
<span className='mx_AudioPlayer_mediaName'>
|
||||
{ this.props.mediaName || _t("Unnamed audio") }
|
||||
</span>
|
||||
<div className='mx_AudioPlayer_byline'>
|
||||
<DurationClock playback={this.props.playback} />
|
||||
{ /* easiest way to introduce a gap between the components */ }
|
||||
{ this.renderFileSize() }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mx_AudioPlayer_seek'>
|
||||
<SeekBar
|
||||
playback={this.props.playback}
|
||||
tabIndex={-1} // prevent tabbing into the bar
|
||||
playbackPhase={this.state.playbackPhase}
|
||||
ref={this.seekRef}
|
||||
/>
|
||||
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='mx_AudioPlayer_seek'>
|
||||
<SeekBar
|
||||
playback={this.props.playback}
|
||||
tabIndex={-1} // prevent tabbing into the bar
|
||||
playbackPhase={this.state.playbackPhase}
|
||||
ref={this.seekRef}
|
||||
/>
|
||||
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />
|
||||
</div>
|
||||
</div>;
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
70
src/components/views/audio_messages/AudioPlayerBase.tsx
Normal file
70
src/components/views/audio_messages/AudioPlayerBase.tsx
Normal file
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
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 { Playback, PlaybackState } from "../../../audio/Playback";
|
||||
import { TileShape } from "../rooms/EventTile";
|
||||
import React, { ReactNode } from "react";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { _t } from "../../../languageHandler";
|
||||
|
||||
interface IProps {
|
||||
// Playback instance to render. Cannot change during component lifecycle: create
|
||||
// an all-new component instead.
|
||||
playback: Playback;
|
||||
|
||||
mediaName?: string;
|
||||
tileShape?: TileShape;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
playbackPhase: PlaybackState;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.audio_messages.AudioPlayerBase")
|
||||
export default abstract class AudioPlayerBase extends React.PureComponent<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
playbackPhase: PlaybackState.Decoding, // default assumption
|
||||
};
|
||||
|
||||
// We don't need to de-register: the class handles this for us internally
|
||||
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
|
||||
|
||||
// Don't wait for the promise to complete - it will emit a progress update when it
|
||||
// is done, and it's not meant to take long anyhow.
|
||||
this.props.playback.prepare().catch(e => {
|
||||
console.error("Error processing audio file:", e);
|
||||
this.setState({ error: true });
|
||||
});
|
||||
}
|
||||
|
||||
private onPlaybackUpdate = (ev: PlaybackState) => {
|
||||
this.setState({ playbackPhase: ev });
|
||||
};
|
||||
|
||||
protected abstract renderComponent(): ReactNode;
|
||||
|
||||
public render(): ReactNode {
|
||||
return <>
|
||||
{ this.renderComponent() }
|
||||
{ this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
|
||||
</>;
|
||||
}
|
||||
}
|
|
@ -43,6 +43,6 @@ export default class Clock extends React.Component<IProps, IState> {
|
|||
public render() {
|
||||
const minutes = Math.floor(this.props.seconds / 60).toFixed(0).padStart(2, '0');
|
||||
const seconds = Math.floor(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis
|
||||
return <span className='mx_Clock'>{minutes}:{seconds}</span>;
|
||||
return <span className='mx_Clock'>{ minutes }:{ seconds }</span>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
import React from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Clock from "./Clock";
|
||||
import { Playback } from "../../../voice/Playback";
|
||||
import { Playback } from "../../../audio/Playback";
|
||||
|
||||
interface IProps {
|
||||
playback: Playback;
|
||||
|
|
|
@ -15,7 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { IRecordingUpdate, VoiceRecording } from "../../../voice/VoiceRecording";
|
||||
import { IRecordingUpdate, VoiceRecording } from "../../../audio/VoiceRecording";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Clock from "./Clock";
|
||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||
|
|
|
@ -15,10 +15,9 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../voice/VoiceRecording";
|
||||
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { arrayFastResample } from "../../../utils/arrays";
|
||||
import { percentageOf } from "../../../utils/numbers";
|
||||
import { arrayFastResample, arraySeed } from "../../../utils/arrays";
|
||||
import Waveform from "./Waveform";
|
||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||
|
||||
|
@ -48,18 +47,14 @@ export default class LiveRecordingWaveform extends React.PureComponent<IProps, I
|
|||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
waveform: [],
|
||||
waveform: arraySeed(0, RECORDING_PLAYBACK_SAMPLES),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => {
|
||||
const bars = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
|
||||
// The incoming data is between zero and one, but typically even screaming into a
|
||||
// microphone won't send you over 0.6, so we artificially adjust the gain for the
|
||||
// waveform. This results in a slightly more cinematic/animated waveform for the
|
||||
// user.
|
||||
this.waveform = bars.map(b => percentageOf(b, 0, 0.50));
|
||||
// The incoming data is between zero and one, so we don't need to clamp/rescale it.
|
||||
this.waveform = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
|
||||
this.scheduledUpdate.mark();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ import React, { ReactNode } from "react";
|
|||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import { Playback, PlaybackState } from "../../../audio/Playback";
|
||||
import classNames from "classnames";
|
||||
|
||||
// omitted props are handled by render function
|
||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
import React from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Clock from "./Clock";
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import { Playback, PlaybackState } from "../../../audio/Playback";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
|
||||
interface IProps {
|
||||
|
|
|
@ -18,7 +18,7 @@ import React from "react";
|
|||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { arraySeed, arrayTrimFill } from "../../../utils/arrays";
|
||||
import Waveform from "./Waveform";
|
||||
import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../voice/Playback";
|
||||
import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../audio/Playback";
|
||||
import { percentageOf } from "../../../utils/numbers";
|
||||
|
||||
interface IProps {
|
||||
|
|
|
@ -14,51 +14,30 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import React, { ReactNode } from "react";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import PlaybackWaveform from "./PlaybackWaveform";
|
||||
import PlayPauseButton from "./PlayPauseButton";
|
||||
import PlaybackClock from "./PlaybackClock";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
interface IProps {
|
||||
// Playback instance to render. Cannot change during component lifecycle: create
|
||||
// an all-new component instead.
|
||||
playback: Playback;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
playbackPhase: PlaybackState;
|
||||
}
|
||||
import { TileShape } from "../rooms/EventTile";
|
||||
import PlaybackWaveform from "./PlaybackWaveform";
|
||||
import AudioPlayerBase from "./AudioPlayerBase";
|
||||
|
||||
@replaceableComponent("views.audio_messages.RecordingPlayback")
|
||||
export default class RecordingPlayback extends React.PureComponent<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
playbackPhase: PlaybackState.Decoding, // default assumption
|
||||
};
|
||||
|
||||
// We don't need to de-register: the class handles this for us internally
|
||||
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
|
||||
|
||||
// Don't wait for the promise to complete - it will emit a progress update when it
|
||||
// is done, and it's not meant to take long anyhow.
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.props.playback.prepare();
|
||||
export default class RecordingPlayback extends AudioPlayerBase {
|
||||
private get isWaveformable(): boolean {
|
||||
return this.props.tileShape !== TileShape.Notif
|
||||
&& this.props.tileShape !== TileShape.FileGrid
|
||||
&& this.props.tileShape !== TileShape.Pinned;
|
||||
}
|
||||
|
||||
private onPlaybackUpdate = (ev: PlaybackState) => {
|
||||
this.setState({ playbackPhase: ev });
|
||||
};
|
||||
|
||||
public render(): ReactNode {
|
||||
return <div className='mx_MediaBody mx_VoiceMessagePrimaryContainer'>
|
||||
<PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
|
||||
<PlaybackClock playback={this.props.playback} />
|
||||
<PlaybackWaveform playback={this.props.playback} />
|
||||
</div>;
|
||||
protected renderComponent(): ReactNode {
|
||||
const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : '';
|
||||
return (
|
||||
<div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}>
|
||||
<PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
|
||||
<PlaybackClock playback={this.props.playback} />
|
||||
{ this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue