Merge remote-tracking branch 'upstream/develop' into feature/copy-version/17603

This commit is contained in:
Šimon Brandner 2021-08-02 11:32:57 +02:00
commit 44258fd278
No known key found for this signature in database
GPG key ID: CC823428E9B582FB
348 changed files with 11721 additions and 5853 deletions

View file

@ -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;

View file

@ -92,6 +92,7 @@ declare global {
mxUIStore: UIStore;
mxSetupEncryptionStore?: SetupEncryptionStore;
mxRoomScrollStateStore?: RoomScrollStateStore;
mxOnRecaptchaLoaded?: () => void;
}
interface Document {
@ -116,7 +117,7 @@ declare global {
}
interface StorageEstimate {
usageDetails?: {[key: string]: number};
usageDetails?: { [key: string]: number };
}
interface HTMLAudioElement {
@ -187,6 +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
View 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;
}

View file

@ -56,7 +56,6 @@ 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';
@ -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';
@ -99,7 +97,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 +127,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 {
@ -164,6 +158,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 +220,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 +324,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;
}
@ -441,6 +471,10 @@ 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);
@ -450,28 +484,18 @@ export default class CallHandler extends EventEmitter {
break;
case CallState.Ended:
{
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', call.hangupReason);
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);
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 {
} else if (hangupReason && ![CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) {
title = _t("Call Failed");
description = _t("The call could not be established");
}
@ -480,7 +504,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"),
@ -697,25 +721,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);
}

View file

@ -33,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";
@ -57,7 +57,7 @@ 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 = ['http', 'https', 'ftp', 'mailto', 'magnet', 'matrix'];
const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/;
@ -79,20 +79,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 {

View file

@ -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) {

View file

@ -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());

View file

@ -14,7 +14,6 @@ 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";
@ -318,90 +317,6 @@ function textForCanonicalAliasEvent(ev: MatrixEvent): () => string | null {
});
}
function textForCallAnswerEvent(event: MatrixEvent): () => string | null {
return () => {
const senderName = event.sender ? event.sender.name : _t('Someone');
const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)');
return _t('%(senderName)s answered the call.', { senderName }) + ' ' + supported;
};
}
function textForCallHangupEvent(event: MatrixEvent): () => 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: MatrixEvent): () => string | null {
return () => {
const senderName = event.sender ? event.sender.name : _t('Someone');
return _t('%(senderName)s declined the call.', { senderName });
};
}
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 textForThreePidInviteEvent(event: MatrixEvent): () => string | null {
const senderName = event.sender ? event.sender.name : event.getSender();
@ -652,10 +567,6 @@ interface IHandlers {
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 = {

View file

@ -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)) {

View file

@ -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 /> }
<DialogButtons
primaryButton={_t('Disable')}
onPrimaryButtonClick={this._onDisable}
onPrimaryButtonClick={this.onDisable}
primaryButtonClass="danger"
cancelButtonClass="warning"
onCancel={this.props.onFinished}

View file

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

View file

@ -269,7 +269,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
<details>
<summary>{ _t("Advanced") }</summary>
<AccessibleButton kind='primary' onClick={this._onSkipPassPhraseClick} >
<AccessibleButton kind='primary' onClick={this._onSkipPassPhraseClick}>
{ _t("Set up with a Security Key") }
</AccessibleButton>
</details>

View file

@ -474,7 +474,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
outlined
>
<div className="mx_CreateSecretStorageDialog_optionTitle">
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_secureBackup"></span>
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_secureBackup" />
{ _t("Generate a Security Key") }
</div>
<div>{ _t("Well generate a Security Key for you to store somewhere safe, like a password manager or a safe.") }</div>
@ -493,7 +493,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
outlined
>
<div className="mx_CreateSecretStorageDialog_optionTitle">
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_securePhrase"></span>
<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>
@ -701,7 +701,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
<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}
>

View file

@ -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>

View file

@ -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}>

View 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();
}
}

View file

@ -32,7 +32,7 @@ export enum PlaybackState {
export const PLAYBACK_WAVEFORM_SAMPLES = 39;
const THUMBNAIL_WAVEFORM_SAMPLES = 100; // arbitrary: [30,120]
const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);
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".
@ -59,9 +59,10 @@ export class Playback extends EventEmitter implements IDestroyable {
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;
@ -129,36 +130,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 () => {
@ -171,7 +200,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
@ -182,13 +215,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);
}
@ -241,7 +282,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*

View file

@ -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) {

View 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;
}
}

View file

@ -333,12 +333,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;
}
}

View file

@ -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;
}

View file

@ -0,0 +1,153 @@
/*
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 SUPPORTED_STATES = [
CallState.Connected,
CallState.Connecting,
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);
}
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);
}
/**
* 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 (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();
}
}

View file

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

View file

@ -120,8 +120,7 @@ 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}>

View file

@ -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;
@ -267,6 +268,7 @@ class FilePanel extends React.Component<IProps, IState> {
tileShape={TileShape.FileGrid}
resizeNotifier={this.props.resizeNotifier}
empty={emptyState}
layout={Layout.Group}
/>
</BaseCard>
);

View file

@ -222,7 +222,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>;
}
@ -1185,10 +1185,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 +1202,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 +1244,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 +1276,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>,
/>,
);
}

View file

@ -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';
@ -232,6 +233,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);
@ -252,11 +258,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);
}
@ -270,6 +279,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"),
@ -576,6 +589,20 @@ 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, this.showHiddenEvents);
@ -591,7 +618,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) {
@ -692,6 +727,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}>
@ -722,7 +758,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
layout={this.props.layout}
enableFlair={this.props.enableFlair}
showReadReceipts={this.props.showReadReceipts}
hideSender={this.props.room.getMembers().length <= 2 && this.props.layout === Layout.Bubble}
callEventGrouper={callEventGrouper}
hideSender={this.membersCount <= 2 && this.props.layout === Layout.Bubble}
/>
</TileErrorBoundary>,
);
@ -952,6 +989,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,
) {
@ -1078,6 +1116,7 @@ class CreationGrouper extends BaseGrouper {
onToggle={panel.onHeightChanged} // Update scroll state
summaryMembers={[ev.sender]}
summaryText={summaryText}
layout={this.layout}
>
{ eventTiles }
</EventListSummary>,
@ -1105,10 +1144,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];
}
@ -1173,6 +1213,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>,
@ -1201,8 +1242,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];
}
@ -1277,6 +1319,7 @@ class MemberGrouper extends BaseGrouper {
events={this.events}
onToggle={panel.onHeightChanged} // Update scroll state
startExpanded={highlightInMels}
layout={this.layout}
>
{ eventTiles }
</MemberEventListSummary>,

View file

@ -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') }

View file

@ -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;
@ -52,6 +53,7 @@ export default class NotificationPanel extends React.PureComponent<IProps> {
tileShape={TileShape.Notif}
empty={emptyState}
alwaysShowTimestamps={true}
layout={Layout.Group}
/>
);
} else {

View file

@ -266,8 +266,12 @@ 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.') }

View file

@ -166,6 +166,10 @@ export interface IState {
canReply: boolean;
layout: Layout;
lowBandwidth: boolean;
alwaysShowTimestamps: boolean;
showTwelveHourTimestamps: boolean;
readMarkerInViewThresholdMs: number;
readMarkerOutOfViewThresholdMs: number;
showHiddenEventsInTimeline: boolean;
showReadReceipts: boolean;
showRedactions: boolean;
@ -231,6 +235,10 @@ 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,
@ -263,14 +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("showHiddenEventsInTimeline", null, () =>
this.setState({ showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline") }),
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 }),
),
]);
@ -1730,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}

View file

@ -183,8 +183,14 @@ 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: number;
@ -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);
}
};

View file

@ -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

View file

@ -16,7 +16,6 @@ limitations under the License.
import React, { ReactNode, useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces";
import classNames from "classnames";
@ -44,11 +43,13 @@ 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";
interface IHierarchyProps {
space: Room;
initialText?: string;
refreshToken?: any;
additionalButtons?: ReactNode;
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
}
@ -315,18 +316,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>>();
@ -354,7 +362,6 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
space,
initialText = "",
showRoom,
refreshToken,
additionalButtons,
children,
}) => {
@ -364,7 +371,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;

View file

@ -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,11 +72,8 @@ import IconizedContextMenu, {
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { BetaPill } from "../views/beta/BetaCard";
import { UserTab } from "../views/dialogs/UserSettingsDialog";
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";
interface IProps {
space: Room;
@ -93,26 +100,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;
@ -306,8 +293,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;
@ -330,25 +316,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>;
}
@ -389,19 +383,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")}
/>;
@ -416,6 +408,7 @@ 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}>
@ -440,15 +433,8 @@ const SpaceLanding = ({ space }) => {
</div>
) }
</RoomTopic>
<SpaceFeedbackPrompt />
<hr />
<SpaceHierarchy
space={space}
showRoom={showRoom}
refreshToken={refreshToken}
additionalButtons={addRoomButton}
/>
<SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} />
</div>;
};
@ -531,7 +517,6 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
value={buttonLabel}
/>
</div>
<SpaceFeedbackPrompt />
</div>;
};
@ -550,13 +535,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>;
};
@ -576,7 +560,6 @@ const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRoom
{ createdRooms ? _t("Go to my first room") : _t("Go to my space") }
</AccessibleButton>
</div>
<SpaceFeedbackPrompt />
</div>;
};
@ -605,9 +588,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>;
};
@ -730,7 +712,6 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
value={buttonLabel}
/>
</div>
<SpaceFeedbackPrompt />
</div>;
};

View file

@ -665,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> {
@ -1493,8 +1493,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}

View file

@ -315,7 +315,10 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
{ _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>;
}
@ -328,7 +331,10 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
"push notifications. To re-enable notifications, sign in again on each " +
"device.",
) }</p>
<input className="mx_Login_submit" type="button" onClick={this.props.onComplete}
<input
className="mx_Login_submit"
type="button"
onClick={this.props.onComplete}
value={_t('Return to login screen')} />
</div>;
}

View file

@ -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 }

View file

@ -557,12 +557,16 @@ export default class Registration extends React.Component<IProps, IState> {
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" });
}
}}>
<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>;

View file

@ -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} />
&nbsp; { /* 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} />
&nbsp; { /* 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>;
);
}
}

View 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> }
</>;
}
}

View file

@ -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;

View file

@ -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";

View file

@ -15,7 +15,7 @@ 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";

View file

@ -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

View file

@ -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 {

View file

@ -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 {

View file

@ -14,61 +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 PlayPauseButton from "./PlayPauseButton";
import PlaybackClock from "./PlaybackClock";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { TileShape } from "../rooms/EventTile";
import PlaybackWaveform from "./PlaybackWaveform";
interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create
// an all-new component instead.
playback: Playback;
tileShape?: TileShape;
}
interface IState {
playbackPhase: PlaybackState;
}
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 {
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>;
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>
);
}
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Playback, PlaybackState } from "../../../voice/Playback";
import { Playback, PlaybackState } from "../../../audio/Playback";
import React, { ChangeEvent, CSSProperties, ReactNode } from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { MarkedExecution } from "../../../utils/MarkedExecution";

View file

@ -54,9 +54,13 @@ export default class Waveform extends React.PureComponent<IProps, IState> {
'mx_Waveform_bar': true,
'mx_Waveform_bar_100pct': isCompleteBar,
});
return <span key={i} style={{
"--barHeight": h,
} as WaveformCSSProperties} className={classes} />;
return <span
key={i}
style={{
"--barHeight": h,
} as WaveformCSSProperties}
className={classes}
/>;
}) }
</div>;
}

View file

@ -19,7 +19,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.auth.AuthBody")
export default class AuthBody extends React.PureComponent {
render() {
public render(): React.ReactNode {
return <div className="mx_AuthBody">
{ this.props.children }
</div>;

View file

@ -22,7 +22,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.auth.AuthFooter")
export default class AuthFooter extends React.Component {
render() {
public render(): React.ReactNode {
return (
<div className="mx_AuthFooter">
<a href="https://matrix.org" target="_blank" rel="noreferrer noopener">{ _t("powered by Matrix") }</a>

View file

@ -16,20 +16,17 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import AuthHeaderLogo from "./AuthHeaderLogo";
import LanguageSelector from "./LanguageSelector";
interface IProps {
disableLanguageSelector?: boolean;
}
@replaceableComponent("views.auth.AuthHeader")
export default class AuthHeader extends React.Component {
static propTypes = {
disableLanguageSelector: PropTypes.bool,
};
render() {
const AuthHeaderLogo = sdk.getComponent('auth.AuthHeaderLogo');
const LanguageSelector = sdk.getComponent('views.auth.LanguageSelector');
export default class AuthHeader extends React.Component<IProps> {
public render(): React.ReactNode {
return (
<div className="mx_AuthHeader">
<AuthHeaderLogo />

View file

@ -19,7 +19,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.auth.AuthHeaderLogo")
export default class AuthHeaderLogo extends React.PureComponent {
render() {
public render(): React.ReactNode {
return <div className="mx_AuthHeaderLogo">
Matrix
</div>;

View file

@ -17,14 +17,12 @@ limitations under the License.
*/
import React from 'react';
import * as sdk from '../../../index';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import AuthFooter from "./AuthFooter";
@replaceableComponent("views.auth.AuthPage")
export default class AuthPage extends React.PureComponent {
render() {
const AuthFooter = sdk.getComponent('auth.AuthFooter');
public render(): React.ReactNode {
return (
<div className="mx_AuthPage">
<div className="mx_AuthPage_modal">

View file

@ -15,66 +15,74 @@ limitations under the License.
*/
import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import CountlyAnalytics from "../../../CountlyAnalytics";
import { replaceableComponent } from "../../../utils/replaceableComponent";
const DIV_ID = 'mx_recaptcha';
interface ICaptchaFormProps {
sitePublicKey: string;
onCaptchaResponse: (response: string) => void;
}
interface ICaptchaFormState {
errorText?: string;
}
/**
* A pure UI component which displays a captcha form.
*/
@replaceableComponent("views.auth.CaptchaForm")
export default class CaptchaForm extends React.Component {
static propTypes = {
sitePublicKey: PropTypes.string,
// called with the captcha response
onCaptchaResponse: PropTypes.func,
};
export default class CaptchaForm extends React.Component<ICaptchaFormProps, ICaptchaFormState> {
static defaultProps = {
onCaptchaResponse: () => {},
};
constructor(props) {
private captchaWidgetId?: string;
private recaptchaContainer = createRef<HTMLDivElement>();
constructor(props: ICaptchaFormProps) {
super(props);
this.state = {
errorText: null,
errorText: undefined,
};
this._captchaWidgetId = null;
this._recaptchaContainer = createRef();
CountlyAnalytics.instance.track("onboarding_grecaptcha_begin");
}
componentDidMount() {
// Just putting a script tag into the returned jsx doesn't work, annoyingly,
// so we do this instead.
if (global.grecaptcha) {
if (this.isRecaptchaReady()) {
// already loaded
this._onCaptchaLoaded();
this.onCaptchaLoaded();
} else {
console.log("Loading recaptcha script...");
window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded();};
window.mxOnRecaptchaLoaded = () => { this.onCaptchaLoaded(); };
const scriptTag = document.createElement('script');
scriptTag.setAttribute(
'src', `https://www.recaptcha.net/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit`,
'src', `https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&render=explicit`,
);
this._recaptchaContainer.current.appendChild(scriptTag);
this.recaptchaContainer.current.appendChild(scriptTag);
}
}
componentWillUnmount() {
this._resetRecaptcha();
this.resetRecaptcha();
}
_renderRecaptcha(divId) {
if (!global.grecaptcha) {
// Borrowed directly from: https://github.com/codeep/react-recaptcha-google/commit/e118fa5670fa268426969323b2e7fe77698376ba
private isRecaptchaReady(): boolean {
return typeof window !== "undefined" &&
typeof global.grecaptcha !== "undefined" &&
typeof global.grecaptcha.render === 'function';
}
private renderRecaptcha(divId: string) {
if (!this.isRecaptchaReady()) {
console.error("grecaptcha not loaded!");
throw new Error("Recaptcha did not load successfully");
}
@ -84,26 +92,26 @@ export default class CaptchaForm extends React.Component {
console.error("No public key for recaptcha!");
throw new Error(
"This server has not supplied enough information for Recaptcha "
+ "authentication");
+ "authentication");
}
console.info("Rendering to %s", divId);
this._captchaWidgetId = global.grecaptcha.render(divId, {
this.captchaWidgetId = global.grecaptcha.render(divId, {
sitekey: publicKey,
callback: this.props.onCaptchaResponse,
});
}
_resetRecaptcha() {
if (this._captchaWidgetId !== null) {
global.grecaptcha.reset(this._captchaWidgetId);
private resetRecaptcha() {
if (this.captchaWidgetId) {
global?.grecaptcha?.reset(this.captchaWidgetId);
}
}
_onCaptchaLoaded() {
private onCaptchaLoaded() {
console.log("Loaded recaptcha script.");
try {
this._renderRecaptcha(DIV_ID);
this.renderRecaptcha(DIV_ID);
// clear error if re-rendered
this.setState({
errorText: null,
@ -128,7 +136,7 @@ export default class CaptchaForm extends React.Component {
}
return (
<div ref={this._recaptchaContainer}>
<div ref={this.recaptchaContainer}>
<p>{ _t(
"This homeserver would like to make sure you are not a robot.",
) }</p>

View file

@ -19,7 +19,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.auth.CompleteSecurityBody")
export default class CompleteSecurityBody extends React.PureComponent {
render() {
public render(): React.ReactNode {
return <div className="mx_CompleteSecurityBody">
{ this.props.children }
</div>;

View file

@ -15,21 +15,19 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { COUNTRIES, getEmojiFlag } from '../../../phonenumber';
import { COUNTRIES, getEmojiFlag, PhoneNumberCountryDefinition } from '../../../phonenumber';
import SdkConfig from "../../../SdkConfig";
import { _t } from "../../../languageHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Dropdown from "../elements/Dropdown";
const COUNTRIES_BY_ISO2 = {};
for (const c of COUNTRIES) {
COUNTRIES_BY_ISO2[c.iso2] = c;
}
function countryMatchesSearchQuery(query, country) {
function countryMatchesSearchQuery(query: string, country: PhoneNumberCountryDefinition): boolean {
// Remove '+' if present (when searching for a prefix)
if (query[0] === '+') {
query = query.slice(1);
@ -41,15 +39,26 @@ function countryMatchesSearchQuery(query, country) {
return false;
}
@replaceableComponent("views.auth.CountryDropdown")
export default class CountryDropdown extends React.Component {
constructor(props) {
super(props);
this._onSearchChange = this._onSearchChange.bind(this);
this._onOptionChange = this._onOptionChange.bind(this);
this._getShortOption = this._getShortOption.bind(this);
interface IProps {
value?: string;
onOptionChange: (country: PhoneNumberCountryDefinition) => void;
isSmall: boolean; // if isSmall, show +44 in the selected value
showPrefix: boolean;
className?: string;
disabled?: boolean;
}
let defaultCountry = COUNTRIES[0];
interface IState {
searchQuery: string;
defaultCountry: PhoneNumberCountryDefinition;
}
@replaceableComponent("views.auth.CountryDropdown")
export default class CountryDropdown extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
let defaultCountry: PhoneNumberCountryDefinition = COUNTRIES[0];
const defaultCountryCode = SdkConfig.get()["defaultCountryCode"];
if (defaultCountryCode) {
const country = COUNTRIES.find(c => c.iso2 === defaultCountryCode.toUpperCase());
@ -62,7 +71,7 @@ export default class CountryDropdown extends React.Component {
};
}
componentDidMount() {
public componentDidMount(): void {
if (!this.props.value) {
// If no value is given, we start with the default
// country selected, but our parent component
@ -71,21 +80,21 @@ export default class CountryDropdown extends React.Component {
}
}
_onSearchChange(search) {
private onSearchChange = (search: string): void => {
this.setState({
searchQuery: search,
});
}
};
_onOptionChange(iso2) {
private onOptionChange = (iso2: string): void => {
this.props.onOptionChange(COUNTRIES_BY_ISO2[iso2]);
}
};
_flagImgForIso2(iso2) {
private flagImgForIso2(iso2: string): React.ReactNode {
return <div className="mx_Dropdown_option_emoji">{ getEmojiFlag(iso2) }</div>;
}
_getShortOption(iso2) {
private getShortOption = (iso2: string): React.ReactNode => {
if (!this.props.isSmall) {
return undefined;
}
@ -94,14 +103,12 @@ export default class CountryDropdown extends React.Component {
countryPrefix = '+' + COUNTRIES_BY_ISO2[iso2].prefix;
}
return <span className="mx_CountryDropdown_shortOption">
{ this._flagImgForIso2(iso2) }
{ this.flagImgForIso2(iso2) }
{ countryPrefix }
</span>;
}
render() {
const Dropdown = sdk.getComponent('elements.Dropdown');
};
public render(): React.ReactNode {
let displayedCountries;
if (this.state.searchQuery) {
displayedCountries = COUNTRIES.filter(
@ -124,7 +131,7 @@ export default class CountryDropdown extends React.Component {
const options = displayedCountries.map((country) => {
return <div className="mx_CountryDropdown_option" key={country.iso2}>
{ this._flagImgForIso2(country.iso2) }
{ this.flagImgForIso2(country.iso2) }
{ _t(country.name) } (+{ country.prefix })
</div>;
});
@ -136,10 +143,10 @@ export default class CountryDropdown extends React.Component {
return <Dropdown
id="mx_CountryDropdown"
className={this.props.className + " mx_CountryDropdown"}
onOptionChange={this._onOptionChange}
onSearchChange={this._onSearchChange}
onOptionChange={this.onOptionChange}
onSearchChange={this.onSearchChange}
menuWidth={298}
getShortOption={this._getShortOption}
getShortOption={this.getShortOption}
value={value}
searchEnabled={true}
disabled={this.props.disabled}
@ -149,13 +156,3 @@ export default class CountryDropdown extends React.Component {
</Dropdown>;
}
}
CountryDropdown.propTypes = {
className: PropTypes.string,
isSmall: PropTypes.bool,
// if isSmall, show +44 in the selected value
showPrefix: PropTypes.bool,
onOptionChange: PropTypes.func.isRequired,
value: PropTypes.string,
disabled: PropTypes.bool,
};

View file

@ -416,8 +416,10 @@ export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITerms
let submitButton;
if (this.props.showContinue !== false) {
// XXX: button classes
submitButton = <button className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
onClick={this.trySubmit} disabled={!allChecked}>{ _t("Accept") }</button>;
submitButton = <button
className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
onClick={this.trySubmit}
disabled={!allChecked}>{ _t("Accept") }</button>;
}
return (
@ -616,7 +618,9 @@ export class MsisdnAuthEntry extends React.Component<IMsisdnAuthEntryProps, IMsi
aria-label={_t("Code")}
/>
<br />
<input type="submit" value={_t("Submit")}
<input
type="submit"
value={_t("Submit")}
className={submitClasses}
disabled={!enableSubmit}
/>

View file

@ -18,21 +18,23 @@ import SdkConfig from "../../../SdkConfig";
import { getCurrentLanguage } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import PlatformPeg from "../../../PlatformPeg";
import * as sdk from '../../../index';
import React from 'react';
import { SettingLevel } from "../../../settings/SettingLevel";
import LanguageDropdown from "../elements/LanguageDropdown";
function onChange(newLang) {
function onChange(newLang: string): void {
if (getCurrentLanguage() !== newLang) {
SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLang);
PlatformPeg.get().reload();
}
}
export default function LanguageSelector({ disabled }) {
if (SdkConfig.get()['disable_login_language_selector']) return <div />;
interface IProps {
disabled?: boolean;
}
const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown');
export default function LanguageSelector({ disabled }: IProps): JSX.Element {
if (SdkConfig.get()['disable_login_language_selector']) return <div />;
return <LanguageDropdown
className="mx_AuthBody_language"
onOptionChange={onChange}

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from 'react';
import classNames from "classnames";
import * as sdk from '../../../index';
import * as sdk from "../../../index";
import SdkConfig from '../../../SdkConfig';
import AuthPage from "./AuthPage";
import { _td } from "../../../languageHandler";
@ -25,21 +25,26 @@ import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import CountlyAnalytics from "../../../CountlyAnalytics";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import LanguageSelector from "./LanguageSelector";
// translatable strings for Welcome pages
_td("Sign in with SSO");
interface IProps {
}
@replaceableComponent("views.auth.Welcome")
export default class Welcome extends React.PureComponent {
constructor(props) {
export default class Welcome extends React.PureComponent<IProps> {
constructor(props: IProps) {
super(props);
CountlyAnalytics.instance.track("onboarding_welcome");
}
render() {
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
const LanguageSelector = sdk.getComponent('auth.LanguageSelector');
public render(): React.ReactNode {
// FIXME: Using an import will result in wrench-element-tests failures
const EmbeddedPage = sdk.getComponent("structures.EmbeddedPage");
const pagesConfig = SdkConfig.get().embeddedPages;
let pageUrl = null;

View file

@ -187,7 +187,8 @@ const BaseAvatar = (props: IProps) => {
width: toPx(width),
height: toPx(height),
}}
title={title} alt={_t("Avatar")}
title={title}
alt={_t("Avatar")}
inputRef={inputRef}
{...otherProps} />
);
@ -201,7 +202,8 @@ const BaseAvatar = (props: IProps) => {
width: toPx(width),
height: toPx(height),
}}
title={title} alt=""
title={title}
alt=""
ref={inputRef}
{...otherProps} />
);

View file

@ -102,8 +102,12 @@ export default class MemberAvatar extends React.Component<IProps, IState> {
}
return (
<BaseAvatar {...otherProps} name={this.state.name} title={this.state.title}
idName={userId} url={this.state.imageUrl} onClick={onClick} />
<BaseAvatar {...otherProps}
name={this.state.name}
title={this.state.title}
idName={userId}
url={this.state.imageUrl}
onClick={onClick} />
);
}
}

View file

@ -13,9 +13,11 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ComponentProps } from 'react';
import { Room } from 'matrix-js-sdk/src/models/room';
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import classNames from "classnames";
import BaseAvatar from './BaseAvatar';
import ImageView from '../elements/ImageView';
@ -32,11 +34,14 @@ interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idNam
// oobData.avatarUrl should be set (else there
// would be nowhere to get the avatar from)
room?: Room;
oobData?: IOOBData;
oobData?: IOOBData & {
roomId?: string;
};
width?: number;
height?: number;
resizeMethod?: ResizeMethod;
viewAvatarOnClick?: boolean;
className?: string;
onClick?(): void;
}
@ -129,15 +134,19 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
};
public render() {
const { room, oobData, viewAvatarOnClick, onClick, ...otherProps } = this.props;
const { room, oobData, viewAvatarOnClick, onClick, className, ...otherProps } = this.props;
const roomName = room ? room.name : oobData.name;
// If the room is a DM, we use the other user's ID for the color hash
// in order to match the room avatar with their avatar
const idName = room ? (DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId) : null;
const idName = room ? (DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId) : oobData.roomId;
return (
<BaseAvatar {...otherProps}
<BaseAvatar
{...otherProps}
className={classNames(className, {
mx_RoomAvatar_isSpaceRoom: room?.isSpaceRoom(),
})}
name={roomName}
idName={idName}
urls={this.state.urls}

View file

@ -27,6 +27,8 @@ import BetaFeedbackDialog from "../dialogs/BetaFeedbackDialog";
import SdkConfig from "../../../SdkConfig";
import SettingsFlag from "../elements/SettingsFlag";
// XXX: Keep this around for re-use in future Betas
interface IProps {
title?: string;
featureId: string;

View file

@ -60,8 +60,10 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
<AccessibleButton className="mx_DialPadContextMenu_cancel" onClick={this.onCancelClick} />
</div>
<div className="mx_DialPadContextMenu_header">
<Field className="mx_DialPadContextMenu_dialled"
value={this.state.value} autoFocus={true}
<Field
className="mx_DialPadContextMenu_dialled"
value={this.state.value}
autoFocus={true}
onChange={this.onChange}
/>
</div>

View file

@ -86,14 +86,18 @@ export const IconizedContextMenuCheckbox: React.FC<ICheckboxProps> = ({
>
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
<span className="mx_IconizedContextMenu_label">{ label }</span>
{ active && <span className="mx_IconizedContextMenu_icon mx_IconizedContextMenu_checked" /> }
<span className={classNames("mx_IconizedContextMenu_icon", {
mx_IconizedContextMenu_checked: active,
mx_IconizedContextMenu_unchecked: !active,
})} />
</MenuItemCheckbox>;
};
export const IconizedContextMenuOption: React.FC<IOptionProps> = ({ label, iconClassName, ...props }) => {
export const IconizedContextMenuOption: React.FC<IOptionProps> = ({ label, iconClassName, children, ...props }) => {
return <MenuItem {...props} label={label}>
{ iconClassName && <span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} /> }
<span className="mx_IconizedContextMenu_label">{ label }</span>
{ children }
</MenuItem>;
};

View file

@ -43,11 +43,15 @@ export function canCancel(eventStatus: EventStatus): boolean {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
}
interface IEventTileOps {
export interface IEventTileOps {
isWidgetHidden(): boolean;
unhideWidget(): void;
}
export interface IOperableEventTile {
getEventTileOps(): IEventTileOps;
}
interface IProps {
/* the MatrixEvent associated with the context menu */
mxEvent: MatrixEvent;

View file

@ -0,0 +1,216 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventType } from "matrix-js-sdk/src/@types/event";
import {
IProps as IContextMenuProps,
} from "../../structures/ContextMenu";
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
import { _t } from "../../../languageHandler";
import {
leaveSpace,
shouldShowSpaceSettings,
showAddExistingRooms,
showCreateNewRoom,
showCreateNewSubspace,
showSpaceInvite,
showSpaceSettings,
} from "../../../utils/space";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { ButtonEvent } from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import RoomViewStore from "../../../stores/RoomViewStore";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import { Action } from "../../../dispatcher/actions";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import { BetaPill } from "../beta/BetaCard";
interface IProps extends IContextMenuProps {
space: Room;
}
const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
const cli = useContext(MatrixClientContext);
const userId = cli.getUserId();
let inviteOption;
if (space.getJoinRule() === "public" || space.canInvite(userId)) {
const onInviteClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showSpaceInvite(space);
onFinished();
};
inviteOption = (
<IconizedContextMenuOption
className="mx_SpacePanel_contextMenu_inviteButton"
iconClassName="mx_SpacePanel_iconInvite"
label={_t("Invite people")}
onClick={onInviteClick}
/>
);
}
let settingsOption;
let leaveSection;
if (shouldShowSpaceSettings(space)) {
const onSettingsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showSpaceSettings(space);
onFinished();
};
settingsOption = (
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconSettings"
label={_t("Settings")}
onClick={onSettingsClick}
/>
);
} else {
const onLeaveClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
leaveSpace(space);
onFinished();
};
leaveSection = <IconizedContextMenuOptionList red first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconLeave"
label={_t("Leave space")}
onClick={onLeaveClick}
/>
</IconizedContextMenuOptionList>;
}
const canAddRooms = space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
let newRoomSection;
if (space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
const onNewRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCreateNewRoom(space);
onFinished();
};
const onAddExistingRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showAddExistingRooms(space);
onFinished();
};
const onNewSubspaceClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCreateNewSubspace(space);
onFinished();
};
newRoomSection = <IconizedContextMenuOptionList first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Create new room")}
onClick={onNewRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconHash"
label={_t("Add existing room")}
onClick={onAddExistingRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Add space")}
onClick={onNewSubspaceClick}
>
<BetaPill />
</IconizedContextMenuOption>
</IconizedContextMenuOptionList>;
}
const onMembersClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
if (!RoomViewStore.getRoomId()) {
defaultDispatcher.dispatch({
action: "view_room",
room_id: space.roomId,
}, true);
}
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.SpaceMemberList,
refireParams: { space: space },
});
onFinished();
};
const onExploreRoomsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: "view_room",
room_id: space.roomId,
});
onFinished();
};
return <IconizedContextMenu
{...props}
onFinished={onFinished}
className="mx_SpacePanel_contextMenu"
compact
>
<div className="mx_SpacePanel_contextMenu_header">
{ space.name }
</div>
<IconizedContextMenuOptionList first>
{ inviteOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconMembers"
label={_t("Members")}
onClick={onMembersClick}
/>
{ settingsOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconExplore"
label={canAddRooms ? _t("Manage & explore rooms") : _t("Explore rooms")}
onClick={onExploreRoomsClick}
/>
</IconizedContextMenuOptionList>
{ newRoomSection }
{ leaveSection }
</IconizedContextMenu>;
};
export default SpaceContextMenu;

View file

@ -109,8 +109,10 @@ export default class StatusMessageContextMenu extends React.Component {
</AccessibleButton>;
}
} else {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
disabled={!this.state.message} onClick={this._onSubmit}
actionButton = <AccessibleButton
className="mx_StatusMessageContextMenu_submit"
disabled={!this.state.message}
onClick={this._onSubmit}
>
<span>{ _t("Set status") }</span>
</AccessibleButton>;
@ -121,12 +123,19 @@ export default class StatusMessageContextMenu extends React.Component {
spinner = <Spinner w="24" h="24" />;
}
const form = <form className="mx_StatusMessageContextMenu_form"
autoComplete="off" onSubmit={this._onSubmit}
const form = <form
className="mx_StatusMessageContextMenu_form"
autoComplete="off"
onSubmit={this._onSubmit}
>
<input type="text" className="mx_StatusMessageContextMenu_message"
key="message" placeholder={_t("Set a new status...")}
autoFocus={true} maxLength="60" value={this.state.message}
<input
type="text"
className="mx_StatusMessageContextMenu_message"
key="message"
placeholder={_t("Set a new status...")}
autoFocus={true}
maxLength="60"
value={this.state.message}
onChange={this._onStatusChange}
/>
<div className="mx_StatusMessageContextMenu_actionContainer">

View file

@ -76,7 +76,8 @@ const WidgetContextMenu: React.FC<IProps> = ({
onFinished();
};
streamAudioStreamButton = <IconizedContextMenuOption
onClick={onStreamAudioClick} label={_t("Start audio stream")}
onClick={onStreamAudioClick}
label={_t("Start audio stream")}
/>;
}

View file

@ -0,0 +1,67 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { _t } from '../../../languageHandler';
import BaseDialog from "./BaseDialog";
import AccessibleButton from "../elements/AccessibleButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { AddExistingToSpace, defaultSpacesRenderer, SubspaceSelector } from "./AddExistingToSpaceDialog";
interface IProps {
space: Room;
onCreateSubspaceClick(): void;
onFinished(added?: boolean): void;
}
const AddExistingSubspaceDialog: React.FC<IProps> = ({ space, onCreateSubspaceClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space);
return <BaseDialog
title={(
<SubspaceSelector
title={_t("Add existing space")}
space={space}
value={selectedSpace}
onChange={setSelectedSpace}
/>
)}
className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpace"
onFinished={onFinished}
fixedWidth={false}
>
<MatrixClientContext.Provider value={space.client}>
<AddExistingToSpace
space={space}
onFinished={onFinished}
footerPrompt={<>
<div>{ _t("Want to add a new space instead?") }</div>
<AccessibleButton onClick={onCreateSubspaceClick} kind="link">
{ _t("Create a new space") }
</AccessibleButton>
</>}
filterPlaceholder={_t("Search for spaces")}
spacesRenderer={defaultSpacesRenderer}
/>
</MatrixClientContext.Provider>
</BaseDialog>;
};
export default AddExistingSubspaceDialog;

View file

@ -17,11 +17,10 @@ limitations under the License.
import React, { ReactNode, useContext, useMemo, useState } from "react";
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { sleep } from "matrix-js-sdk/src/utils";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { _t } from '../../../languageHandler';
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import Dropdown from "../elements/Dropdown";
import SearchBox from "../../structures/SearchBox";
@ -36,20 +35,20 @@ import StyledCheckbox from "../elements/StyledCheckbox";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import ProgressBar from "../elements/ProgressBar";
import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar";
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
interface IProps {
space: Room;
onCreateRoomClick(cli: MatrixClient, space: Room): void;
onCreateRoomClick(): void;
onAddSubspaceClick(): void;
onFinished(added?: boolean): void;
}
const Entry = ({ room, checked, onChange }) => {
export const Entry = ({ room, checked, onChange }) => {
return <label className="mx_AddExistingToSpace_entry">
{ room?.isSpaceRoom()
? <RoomAvatar room={room} height={32} width={32} />
@ -67,14 +66,36 @@ const Entry = ({ room, checked, onChange }) => {
interface IAddExistingToSpaceProps {
space: Room;
footerPrompt?: ReactNode;
filterPlaceholder: string;
emptySelectionButton?: ReactNode;
onFinished(added: boolean): void;
roomsRenderer?(
rooms: Room[],
selectedToAdd: Set<Room>,
onChange: undefined | ((checked: boolean, room: Room) => void),
truncateAt: number,
overflowTile: (overflowCount: number, totalCount: number) => JSX.Element,
): ReactNode;
spacesRenderer?(
spaces: Room[],
selectedToAdd: Set<Room>,
onChange?: (checked: boolean, room: Room) => void,
): ReactNode;
dmsRenderer?(
dms: Room[],
selectedToAdd: Set<Room>,
onChange?: (checked: boolean, room: Room) => void,
): ReactNode;
}
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
space,
footerPrompt,
emptySelectionButton,
filterPlaceholder,
roomsRenderer,
dmsRenderer,
spacesRenderer,
onFinished,
}) => {
const cli = useContext(MatrixClientContext);
@ -198,7 +219,7 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
</>;
}
const onChange = !busy && !error ? (checked, room) => {
const onChange = !busy && !error ? (checked: boolean, room: Room) => {
if (checked) {
selectedToAdd.add(room);
} else {
@ -208,83 +229,52 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
} : null;
const [truncateAt, setTruncateAt] = useState(20);
function overflowTile(overflowCount, totalCount) {
function overflowTile(overflowCount: number, totalCount: number): JSX.Element {
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
} name={text} presenceState="online" suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)} />
<EntityTile
className="mx_EntityTile_ellipsis"
avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
}
name={text}
presenceState="online"
suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)}
/>
);
}
let noResults = true;
if ((roomsRenderer && rooms.length > 0) ||
(dmsRenderer && dms.length > 0) ||
(!roomsRenderer && !dmsRenderer && spacesRenderer && spaces.length > 0) // only count spaces when alone
) {
noResults = false;
}
return <div className="mx_AddExistingToSpace">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Filter your rooms and spaces")}
placeholder={filterPlaceholder}
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_AddExistingToSpace_content">
{ rooms.length > 0 ? (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3>
<TruncatedList
truncateAt={truncateAt}
createOverflowElement={overflowTile}
getChildren={(start, end) => rooms.slice(start, end).map(room =>
<Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked) => {
onChange(checked, room);
} : null}
/>,
)}
getChildCount={() => rooms.length}
/>
</div>
{ rooms.length > 0 && roomsRenderer ? (
roomsRenderer(rooms, selectedToAdd, onChange, truncateAt, overflowTile)
) : undefined }
{ spaces.length > 0 ? (
<div className="mx_AddExistingToSpace_section mx_AddExistingToSpace_section_spaces">
<h3>{ _t("Spaces") }</h3>
<div className="mx_AddExistingToSpace_section_experimental">
<div>{ _t("Feeling experimental?") }</div>
<div>{ _t("You can add existing spaces to a space.") }</div>
</div>
{ spaces.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selectedToAdd.has(space)}
onChange={onChange ? (checked) => {
onChange(checked, space);
} : null}
/>;
}) }
</div>
{ spaces.length > 0 && spacesRenderer ? (
spacesRenderer(spaces, selectedToAdd, onChange)
) : null }
{ dms.length > 0 ? (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Direct Messages") }</h3>
{ dms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked) => {
onChange(checked, room);
} : null}
/>;
}) }
</div>
{ dms.length > 0 && dmsRenderer ? (
dmsRenderer(dms, selectedToAdd, onChange)
) : null }
{ spaces.length + rooms.length + dms.length < 1 ? <span className="mx_AddExistingToSpace_noResults">
{ noResults ? <span className="mx_AddExistingToSpace_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
@ -295,69 +285,166 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
</div>;
};
const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space);
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
export const defaultRoomsRenderer: IAddExistingToSpaceProps["roomsRenderer"] = (
rooms, selectedToAdd, onChange, truncateAt, overflowTile,
) => (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3>
<TruncatedList
truncateAt={truncateAt}
createOverflowElement={overflowTile}
getChildren={(start, end) => rooms.slice(start, end).map(room =>
<Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked: boolean) => {
onChange(checked, room);
} : null}
/>,
)}
getChildCount={() => rooms.length}
/>
</div>
);
let spaceOptionSection;
if (existingSubspaces.length > 0) {
const options = [space, ...existingSubspaces].map((space) => {
const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", {
mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace,
});
return <div key={space.roomId} className={classes}>
<RoomAvatar room={space} width={24} height={24} />
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>;
});
export const defaultSpacesRenderer: IAddExistingToSpaceProps["spacesRenderer"] = (spaces, selectedToAdd, onChange) => (
<div className="mx_AddExistingToSpace_section">
{ spaces.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selectedToAdd.has(space)}
onChange={onChange ? (checked) => {
onChange(checked, space);
} : null}
/>;
}) }
</div>
);
spaceOptionSection = (
export const defaultDmsRenderer: IAddExistingToSpaceProps["dmsRenderer"] = (dms, selectedToAdd, onChange) => (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Direct Messages") }</h3>
{ dms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked: boolean) => {
onChange(checked, room);
} : null}
/>;
}) }
</div>
);
interface ISubspaceSelectorProps {
title: string;
space: Room;
value: Room;
onChange(space: Room): void;
}
export const SubspaceSelector = ({ title, space, value, onChange }: ISubspaceSelectorProps) => {
const options = useMemo(() => {
return [space, ...SpaceStore.instance.getChildSpaces(space.roomId).filter(space => {
return space.currentState.maySendStateEvent(EventType.SpaceChild, space.client.credentials.userId);
})];
}, [space]);
let body;
if (options.length > 1) {
body = (
<Dropdown
id="mx_SpaceSelectDropdown"
className="mx_SpaceSelectDropdown"
onOptionChange={(key: string) => {
setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space);
onChange(options.find(space => space.roomId === key) || space);
}}
value={selectedSpace.roomId}
value={value.roomId}
label={_t("Space selection")}
>
{ options }
{ options.map((space) => {
const classes = classNames({
mx_SubspaceSelector_dropdownOptionActive: space === value,
});
return <div key={space.roomId} className={classes}>
<RoomAvatar room={space} width={24} height={24} />
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>;
}) }
</Dropdown>
);
} else {
spaceOptionSection = <div className="mx_AddExistingToSpaceDialog_onlySpace">
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>;
body = (
<div className="mx_SubspaceSelector_onlySpace">
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>
);
}
const title = <React.Fragment>
<RoomAvatar room={selectedSpace} height={40} width={40} />
return <div className="mx_SubspaceSelector">
<RoomAvatar room={value} height={40} width={40} />
<div>
<h1>{ _t("Add existing rooms") }</h1>
{ spaceOptionSection }
<h1>{ title }</h1>
{ body }
</div>
</React.Fragment>;
</div>;
};
const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick, onAddSubspaceClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space);
return <BaseDialog
title={title}
title={(
<SubspaceSelector
title={_t("Add existing rooms")}
space={space}
value={selectedSpace}
onChange={setSelectedSpace}
/>
)}
className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpace"
onFinished={onFinished}
fixedWidth={false}
>
<MatrixClientContext.Provider value={cli}>
<MatrixClientContext.Provider value={space.client}>
<AddExistingToSpace
space={space}
onFinished={onFinished}
footerPrompt={<>
<div>{ _t("Want to add a new room instead?") }</div>
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
<AccessibleButton
kind="link"
onClick={() => {
onCreateRoomClick();
onFinished();
}}
>
{ _t("Create a new room") }
</AccessibleButton>
</>}
filterPlaceholder={_t("Search for rooms")}
roomsRenderer={defaultRoomsRenderer}
spacesRenderer={() => (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Spaces") }</h3>
<AccessibleButton
kind="link"
onClick={() => {
onAddSubspaceClick();
onFinished();
}}
>
{ _t("Adding spaces has moved.") }
</AccessibleButton>
</div>
)}
dmsRenderer={defaultDmsRenderer}
/>
</MatrixClientContext.Provider>
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
</BaseDialog>;
};

View file

@ -18,14 +18,12 @@ limitations under the License.
*/
import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { sleep } from "matrix-js-sdk/src/utils";
import { _t, _td } from '../../../languageHandler';
import * as sdk from '../../../index';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher';
import { addressTypes, getAddressType } from '../../../UserAddress';
import { AddressType, addressTypes, getAddressType, IUserAddress } from '../../../UserAddress';
import GroupStore from '../../../stores/GroupStore';
import * as Email from '../../../email';
import IdentityAuthClient from '../../../IdentityAuthClient';
@ -34,6 +32,10 @@ import { abbreviateUrl } from '../../../utils/UrlUtils';
import { Key } from "../../../Keyboard";
import { Action } from "../../../dispatcher/actions";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import AddressSelector from '../elements/AddressSelector';
import AddressTile from '../elements/AddressTile';
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
const TRUNCATE_QUERY_LIST = 40;
const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200;
@ -44,29 +46,64 @@ const addressTypeName = {
'email': _td("email address"),
};
@replaceableComponent("views.dialogs.AddressPickerDialog")
export default class AddressPickerDialog extends React.Component {
static propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.node,
// Extra node inserted after picker input, dropdown and errors
extraNode: PropTypes.node,
value: PropTypes.string,
placeholder: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
roomId: PropTypes.string,
button: PropTypes.string,
focus: PropTypes.bool,
validAddressTypes: PropTypes.arrayOf(PropTypes.oneOf(addressTypes)),
onFinished: PropTypes.func.isRequired,
groupId: PropTypes.string,
// The type of entity to search for. Default: 'user'.
pickerType: PropTypes.oneOf(['user', 'room']),
// Whether the current user should be included in the addresses returned. Only
// applicable when pickerType is `user`. Default: false.
includeSelf: PropTypes.bool,
};
interface IResult {
user_id: string; // eslint-disable-line camelcase
room_id?: string; // eslint-disable-line camelcase
name?: string;
display_name?: string; // eslint-disable-line camelcase
avatar_url?: string;// eslint-disable-line camelcase
}
static defaultProps = {
interface IProps {
title: string;
description?: JSX.Element;
// Extra node inserted after picker input, dropdown and errors
extraNode?: JSX.Element;
value?: string;
placeholder?: ((validAddressTypes: any) => string) | string;
roomId?: string;
button?: string;
focus?: boolean;
validAddressTypes?: AddressType[];
onFinished: (success: boolean, list?: IUserAddress[]) => void;
groupId?: string;
// The type of entity to search for. Default: 'user'.
pickerType?: 'user' | 'room';
// Whether the current user should be included in the addresses returned. Only
// applicable when pickerType is `user`. Default: false.
includeSelf?: boolean;
}
interface IState {
// Whether to show an error message because of an invalid address
invalidAddressError: boolean;
// List of UserAddressType objects representing
// the list of addresses we're going to invite
selectedList: IUserAddress[];
// Whether a search is ongoing
busy: boolean;
// An error message generated during the user directory search
searchError: string;
// Whether the server supports the user_directory API
serverSupportsUserDirectory: boolean;
// The query being searched for
query: string;
// List of UserAddressType objects representing the set of
// auto-completion results for the current search query.
suggestedList: IUserAddress[];
// List of address types initialised from props, but may change while the
// dialog is open and represents the supported list of address types at this time.
validAddressTypes: AddressType[];
}
@replaceableComponent("views.dialogs.AddressPickerDialog")
export default class AddressPickerDialog extends React.Component<IProps, IState> {
private textinput = createRef<HTMLTextAreaElement>();
private addressSelector = createRef<AddressSelector>();
private queryChangedDebouncer: number;
private cancelThreepidLookup: () => void;
static defaultProps: Partial<IProps> = {
value: "",
focus: true,
validAddressTypes: addressTypes,
@ -74,36 +111,23 @@ export default class AddressPickerDialog extends React.Component {
includeSelf: false,
};
constructor(props) {
constructor(props: IProps) {
super(props);
this._textinput = createRef();
let validAddressTypes = this.props.validAddressTypes;
// Remove email from validAddressTypes if no IS is configured. It may be added at a later stage by the user
if (!MatrixClientPeg.get().getIdentityServerUrl() && validAddressTypes.includes("email")) {
validAddressTypes = validAddressTypes.filter(type => type !== "email");
if (!MatrixClientPeg.get().getIdentityServerUrl() && validAddressTypes.includes(AddressType.Email)) {
validAddressTypes = validAddressTypes.filter(type => type !== AddressType.Email);
}
this.state = {
// Whether to show an error message because of an invalid address
invalidAddressError: false,
// List of UserAddressType objects representing
// the list of addresses we're going to invite
selectedList: [],
// Whether a search is ongoing
busy: false,
// An error message generated during the user directory search
searchError: null,
// Whether the server supports the user_directory API
serverSupportsUserDirectory: true,
// The query being searched for
query: "",
// List of UserAddressType objects representing the set of
// auto-completion results for the current search query.
suggestedList: [],
// List of address types initialised from props, but may change while the
// dialog is open and represents the supported list of address types at this time.
validAddressTypes,
};
}
@ -111,11 +135,11 @@ export default class AddressPickerDialog extends React.Component {
componentDidMount() {
if (this.props.focus) {
// Set the cursor at the end of the text input
this._textinput.current.value = this.props.value;
this.textinput.current.value = this.props.value;
}
}
getPlaceholder() {
private getPlaceholder(): string {
const { placeholder } = this.props;
if (typeof placeholder === "string") {
return placeholder;
@ -124,23 +148,23 @@ export default class AddressPickerDialog extends React.Component {
return placeholder(this.state.validAddressTypes);
}
onButtonClick = () => {
private onButtonClick = (): void => {
let selectedList = this.state.selectedList.slice();
// Check the text input field to see if user has an unconverted address
// If there is and it's valid add it to the local selectedList
if (this._textinput.current.value !== '') {
selectedList = this._addAddressesToList([this._textinput.current.value]);
if (this.textinput.current.value !== '') {
selectedList = this.addAddressesToList([this.textinput.current.value]);
if (selectedList === null) return;
}
this.props.onFinished(true, selectedList);
};
onCancel = () => {
private onCancel = (): void => {
this.props.onFinished(false);
};
onKeyDown = e => {
const textInput = this._textinput.current ? this._textinput.current.value : undefined;
private onKeyDown = (e: React.KeyboardEvent): void => {
const textInput = this.textinput.current ? this.textinput.current.value : undefined;
if (e.key === Key.ESCAPE) {
e.stopPropagation();
@ -149,15 +173,15 @@ export default class AddressPickerDialog extends React.Component {
} else if (e.key === Key.ARROW_UP) {
e.stopPropagation();
e.preventDefault();
if (this.addressSelector) this.addressSelector.moveSelectionUp();
if (this.addressSelector.current) this.addressSelector.current.moveSelectionUp();
} else if (e.key === Key.ARROW_DOWN) {
e.stopPropagation();
e.preventDefault();
if (this.addressSelector) this.addressSelector.moveSelectionDown();
if (this.addressSelector.current) this.addressSelector.current.moveSelectionDown();
} else if (this.state.suggestedList.length > 0 && [Key.COMMA, Key.ENTER, Key.TAB].includes(e.key)) {
e.stopPropagation();
e.preventDefault();
if (this.addressSelector) this.addressSelector.chooseSelection();
if (this.addressSelector.current) this.addressSelector.current.chooseSelection();
} else if (textInput.length === 0 && this.state.selectedList.length && e.key === Key.BACKSPACE) {
e.stopPropagation();
e.preventDefault();
@ -169,17 +193,17 @@ export default class AddressPickerDialog extends React.Component {
// if there's nothing in the input box, submit the form
this.onButtonClick();
} else {
this._addAddressesToList([textInput]);
this.addAddressesToList([textInput]);
}
} else if (textInput && (e.key === Key.COMMA || e.key === Key.TAB)) {
e.stopPropagation();
e.preventDefault();
this._addAddressesToList([textInput]);
this.addAddressesToList([textInput]);
}
};
onQueryChanged = ev => {
const query = ev.target.value;
private onQueryChanged = (ev: React.ChangeEvent): void => {
const query = (ev.target as HTMLTextAreaElement).value;
if (this.queryChangedDebouncer) {
clearTimeout(this.queryChangedDebouncer);
}
@ -188,17 +212,17 @@ export default class AddressPickerDialog extends React.Component {
this.queryChangedDebouncer = setTimeout(() => {
if (this.props.pickerType === 'user') {
if (this.props.groupId) {
this._doNaiveGroupSearch(query);
this.doNaiveGroupSearch(query);
} else if (this.state.serverSupportsUserDirectory) {
this._doUserDirectorySearch(query);
this.doUserDirectorySearch(query);
} else {
this._doLocalSearch(query);
this.doLocalSearch(query);
}
} else if (this.props.pickerType === 'room') {
if (this.props.groupId) {
this._doNaiveGroupRoomSearch(query);
this.doNaiveGroupRoomSearch(query);
} else {
this._doRoomSearch(query);
this.doRoomSearch(query);
}
} else {
console.error('Unknown pickerType', this.props.pickerType);
@ -213,7 +237,7 @@ export default class AddressPickerDialog extends React.Component {
}
};
onDismissed = index => () => {
private onDismissed = (index: number) => () => {
const selectedList = this.state.selectedList.slice();
selectedList.splice(index, 1);
this.setState({
@ -221,25 +245,21 @@ export default class AddressPickerDialog extends React.Component {
suggestedList: [],
query: "",
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
if (this.cancelThreepidLookup) this.cancelThreepidLookup();
};
onClick = index => () => {
this.onSelected(index);
};
onSelected = index => {
private onSelected = (index: number): void => {
const selectedList = this.state.selectedList.slice();
selectedList.push(this._getFilteredSuggestions()[index]);
selectedList.push(this.getFilteredSuggestions()[index]);
this.setState({
selectedList,
suggestedList: [],
query: "",
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
if (this.cancelThreepidLookup) this.cancelThreepidLookup();
};
_doNaiveGroupSearch(query) {
private doNaiveGroupSearch(query: string): void {
const lowerCaseQuery = query.toLowerCase();
this.setState({
busy: true,
@ -260,7 +280,7 @@ export default class AddressPickerDialog extends React.Component {
display_name: u.displayname,
});
});
this._processResults(results, query);
this.processResults(results, query);
}).catch((err) => {
console.error('Error whilst searching group rooms: ', err);
this.setState({
@ -273,7 +293,7 @@ export default class AddressPickerDialog extends React.Component {
});
}
_doNaiveGroupRoomSearch(query) {
private doNaiveGroupRoomSearch(query: string): void {
const lowerCaseQuery = query.toLowerCase();
const results = [];
GroupStore.getGroupRooms(this.props.groupId).forEach((r) => {
@ -289,13 +309,13 @@ export default class AddressPickerDialog extends React.Component {
name: r.name || r.canonical_alias,
});
});
this._processResults(results, query);
this.processResults(results, query);
this.setState({
busy: false,
});
}
_doRoomSearch(query) {
private doRoomSearch(query: string): void {
const lowerCaseQuery = query.toLowerCase();
const rooms = MatrixClientPeg.get().getRooms();
const results = [];
@ -346,13 +366,13 @@ export default class AddressPickerDialog extends React.Component {
return a.rank - b.rank;
});
this._processResults(sortedResults, query);
this.processResults(sortedResults, query);
this.setState({
busy: false,
});
}
_doUserDirectorySearch(query) {
private doUserDirectorySearch(query: string): void {
this.setState({
busy: true,
query,
@ -366,7 +386,7 @@ export default class AddressPickerDialog extends React.Component {
if (this.state.query !== query) {
return;
}
this._processResults(resp.results, query);
this.processResults(resp.results, query);
}).catch((err) => {
console.error('Error whilst searching user directory: ', err);
this.setState({
@ -377,7 +397,7 @@ export default class AddressPickerDialog extends React.Component {
serverSupportsUserDirectory: false,
});
// Do a local search immediately
this._doLocalSearch(query);
this.doLocalSearch(query);
}
}).then(() => {
this.setState({
@ -386,7 +406,7 @@ export default class AddressPickerDialog extends React.Component {
});
}
_doLocalSearch(query) {
private doLocalSearch(query: string): void {
this.setState({
query,
searchError: null,
@ -407,10 +427,10 @@ export default class AddressPickerDialog extends React.Component {
avatar_url: user.avatarUrl,
});
});
this._processResults(results, query);
this.processResults(results, query);
}
_processResults(results, query) {
private processResults(results: IResult[], query: string): void {
const suggestedList = [];
results.forEach((result) => {
if (result.room_id) {
@ -465,27 +485,27 @@ export default class AddressPickerDialog extends React.Component {
address: query,
isKnown: false,
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
if (this.cancelThreepidLookup) this.cancelThreepidLookup();
if (addrType === 'email') {
this._lookupThreepid(addrType, query);
this.lookupThreepid(addrType, query);
}
}
this.setState({
suggestedList,
invalidAddressError: false,
}, () => {
if (this.addressSelector) this.addressSelector.moveSelectionTop();
if (this.addressSelector.current) this.addressSelector.current.moveSelectionTop();
});
}
_addAddressesToList(addressTexts) {
private addAddressesToList(addressTexts: string[]): IUserAddress[] {
const selectedList = this.state.selectedList.slice();
let hasError = false;
addressTexts.forEach((addressText) => {
addressText = addressText.trim();
const addrType = getAddressType(addressText);
const addrObj = {
const addrObj: IUserAddress = {
addressType: addrType,
address: addressText,
isKnown: false,
@ -504,7 +524,6 @@ export default class AddressPickerDialog extends React.Component {
const room = MatrixClientPeg.get().getRoom(addrObj.address);
if (room) {
addrObj.displayName = room.name;
addrObj.avatarMxc = room.avatarUrl;
addrObj.isKnown = true;
}
}
@ -518,17 +537,17 @@ export default class AddressPickerDialog extends React.Component {
query: "",
invalidAddressError: hasError ? true : this.state.invalidAddressError,
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
if (this.cancelThreepidLookup) this.cancelThreepidLookup();
return hasError ? null : selectedList;
}
async _lookupThreepid(medium, address) {
private async lookupThreepid(medium: AddressType, address: string): Promise<string> {
let cancelled = false;
// Note that we can't safely remove this after we're done
// because we don't know that it's the same one, so we just
// leave it: it's replacing the old one each time so it's
// not like they leak.
this._cancelThreepidLookup = function() {
this.cancelThreepidLookup = function() {
cancelled = true;
};
@ -570,7 +589,7 @@ export default class AddressPickerDialog extends React.Component {
}
}
_getFilteredSuggestions() {
private getFilteredSuggestions(): IUserAddress[] {
// map addressType => set of addresses to avoid O(n*m) operation
const selectedAddresses = {};
this.state.selectedList.forEach(({ address, addressType }) => {
@ -584,15 +603,15 @@ export default class AddressPickerDialog extends React.Component {
});
}
_onPaste = e => {
private onPaste = (e: React.ClipboardEvent): void => {
// Prevent the text being pasted into the textarea
e.preventDefault();
const text = e.clipboardData.getData("text");
// Process it as a list of addresses to add instead
this._addAddressesToList(text.split(/[\s,]+/));
this.addAddressesToList(text.split(/[\s,]+/));
};
onUseDefaultIdentityServerClick = e => {
private onUseDefaultIdentityServerClick = (e: React.MouseEvent): void => {
e.preventDefault();
// Update the IS in account data. Actually using it may trigger terms.
@ -601,22 +620,17 @@ export default class AddressPickerDialog extends React.Component {
// Add email as a valid address type.
const { validAddressTypes } = this.state;
validAddressTypes.push('email');
validAddressTypes.push(AddressType.Email);
this.setState({ validAddressTypes });
};
onManageSettingsClick = e => {
private onManageSettingsClick = (e: React.MouseEvent): void => {
e.preventDefault();
dis.fire(Action.ViewUserSettings);
this.onCancel();
};
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const AddressSelector = sdk.getComponent("elements.AddressSelector");
this.scrollElement = null;
let inputLabel;
if (this.props.description) {
inputLabel = <div className="mx_AddressPickerDialog_label">
@ -627,7 +641,6 @@ export default class AddressPickerDialog extends React.Component {
const query = [];
// create the invite list
if (this.state.selectedList.length > 0) {
const AddressTile = sdk.getComponent("elements.AddressTile");
for (let i = 0; i < this.state.selectedList.length; i++) {
query.push(
<AddressTile
@ -644,19 +657,19 @@ export default class AddressPickerDialog extends React.Component {
query.push(
<textarea
key={this.state.selectedList.length}
onPaste={this._onPaste}
rows="1"
onPaste={this.onPaste}
rows={1}
id="textinput"
ref={this._textinput}
ref={this.textinput}
className="mx_AddressPickerDialog_input"
onChange={this.onQueryChanged}
placeholder={this.getPlaceholder()}
defaultValue={this.props.value}
autoFocus={this.props.focus}>
</textarea>,
autoFocus={this.props.focus}
/>,
);
const filteredSuggestedList = this._getFilteredSuggestions();
const filteredSuggestedList = this.getFilteredSuggestions();
let error;
let addressSelector;
@ -675,7 +688,7 @@ export default class AddressPickerDialog extends React.Component {
error = <div className="mx_AddressPickerDialog_error">{ _t("No results") }</div>;
} else {
addressSelector = (
<AddressSelector ref={(ref) => {this.addressSelector = ref;}}
<AddressSelector ref={this.addressSelector}
addressList={filteredSuggestedList}
showAddress={this.props.pickerType === 'user'}
onSelected={this.onSelected}
@ -686,8 +699,8 @@ export default class AddressPickerDialog extends React.Component {
let identityServer;
// If picker cannot currently accept e-mail but should be able to
if (this.props.pickerType === 'user' && !this.state.validAddressTypes.includes('email')
&& this.props.validAddressTypes.includes('email')) {
if (this.props.pickerType === 'user' && !this.state.validAddressTypes.includes(AddressType.Email)
&& this.props.validAddressTypes.includes(AddressType.Email)) {
const defaultIdentityServerUrl = getDefaultIdentityServerUrl();
if (defaultIdentityServerUrl) {
identityServer = <div className="mx_AddressPickerDialog_identityServer">{ _t(
@ -714,8 +727,12 @@ export default class AddressPickerDialog extends React.Component {
}
return (
<BaseDialog className="mx_AddressPickerDialog" onKeyDown={this.onKeyDown}
onFinished={this.props.onFinished} title={this.props.title}>
<BaseDialog
className="mx_AddressPickerDialog"
onKeyDown={this.onKeyDown}
onFinished={this.props.onFinished}
title={this.props.title}
>
{ inputLabel }
<div className="mx_Dialog_content">
<div className="mx_AddressPickerDialog_inputContainer">{ query }</div>

View file

@ -118,9 +118,7 @@ export default class BaseDialog extends React.Component {
let headerImage;
if (this.props.headerImage) {
headerImage = <img className="mx_Dialog_titleImage" src={this.props.headerImage}
alt=""
/>;
headerImage = <img className="mx_Dialog_titleImage" src={this.props.headerImage} alt="" />;
}
return (

View file

@ -14,22 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useState } from "react";
import React from "react";
import QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler';
import Field from "../elements/Field";
import SdkConfig from "../../../SdkConfig";
import { IDialogProps } from "./IDialogProps";
import SettingsStore from "../../../settings/SettingsStore";
import { submitFeedback } from "../../../rageshake/submit-rageshake";
import StyledCheckbox from "../elements/StyledCheckbox";
import Modal from "../../../Modal";
import InfoDialog from "./InfoDialog";
import AccessibleButton from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "./UserSettingsDialog";
import GenericFeatureFeedbackDialog from "./GenericFeatureFeedbackDialog";
// XXX: Keep this around for re-use in future Betas
interface IProps extends IDialogProps {
featureId: string;
@ -38,74 +34,28 @@ interface IProps extends IDialogProps {
const BetaFeedbackDialog: React.FC<IProps> = ({ featureId, onFinished }) => {
const info = SettingsStore.getBetaInfo(featureId);
const [comment, setComment] = useState("");
const [canContact, setCanContact] = useState(false);
const sendFeedback = async (ok: boolean) => {
if (!ok) return onFinished(false);
const extraData = SettingsStore.getBetaInfo(featureId)?.extraSettings.reduce((o, k) => {
o[k] = SettingsStore.getValue(k);
return o;
}, {});
submitFeedback(SdkConfig.get().bug_report_endpoint_url, info.feedbackLabel, comment, canContact, extraData);
onFinished(true);
Modal.createTrackedDialog("Beta Dialog Sent", featureId, InfoDialog, {
title: _t("Beta feedback"),
description: _t("Thank you for your feedback, we really appreciate it."),
button: _t("Done"),
hasCloseButton: false,
fixedWidth: false,
});
};
return (<QuestionDialog
className="mx_BetaFeedbackDialog"
hasCancelButton={true}
return <GenericFeatureFeedbackDialog
title={_t("%(featureName)s beta feedback", { featureName: info.title })}
description={<React.Fragment>
<div className="mx_BetaFeedbackDialog_subheading">
{ _t(info.feedbackSubheading) }
&nbsp;
{ _t("Your platform and username will be noted to help us use your feedback as much as we can.") }
<AccessibleButton kind="link" onClick={() => {
onFinished(false);
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,
});
}}>
{ _t("To leave the beta, visit your settings.") }
</AccessibleButton>
</div>
<Field
id="feedbackComment"
label={_t("Feedback")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
autoFocus={true}
/>
<StyledCheckbox
checked={canContact}
onClick={e => setCanContact((e.target as HTMLInputElement).checked)}
>
{ _t("You may contact me if you have any follow up questions") }
</StyledCheckbox>
</React.Fragment>}
button={_t("Send feedback")}
buttonDisabled={!comment}
onFinished={sendFeedback}
/>);
subheading={_t(info.feedbackSubheading)}
onFinished={onFinished}
rageshakeLabel={info.feedbackLabel}
rageshakeData={Object.fromEntries((SettingsStore.getBetaInfo(featureId)?.extraSettings || []).map(k => {
return SettingsStore.getValue(k);
}))}
>
<AccessibleButton
kind="link"
onClick={() => {
onFinished(false);
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,
});
}}
>
{ _t("To leave the beta, visit your settings.") }
</AccessibleButton>
</GenericFeatureFeedbackDialog>;
};
export default BetaFeedbackDialog;

View file

@ -188,7 +188,9 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
}
return (
<BaseDialog className="mx_BugReportDialog" onFinished={this.onCancel}
<BaseDialog
className="mx_BugReportDialog"
onFinished={this.onCancel}
title={_t('Submit debug logs')}
contentId='mx_Dialog_content'
>

View file

@ -205,9 +205,12 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent<
people.push((
<AccessibleButton
onClick={this.onShowMorePeople}
kind="link" key="more"
kind="link"
key="more"
className="mx_CommunityPrototypeInviteDialog_morePeople"
>{ _t("Show more") }</AccessibleButton>
>
{ _t("Show more") }
</AccessibleButton>
));
}
}
@ -240,10 +243,13 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent<
{ peopleIntro }
{ people }
<AccessibleButton
kind="primary" onClick={this.onSubmit}
kind="primary"
onClick={this.onSubmit}
disabled={this.state.busy}
className="mx_CommunityPrototypeInviteDialog_primaryButton"
>{ buttonText }</AccessibleButton>
>
{ buttonText }
</AccessibleButton>
</div>
</form>
</BaseDialog>

View file

@ -37,8 +37,8 @@ export default class ConfirmRedactDialog extends React.Component<IProps> {
"Note that if you delete a room name or topic change, it could undo the change.")}
placeholder={_t("Reason (optional)")}
focus
button={_t("Remove")}>
</TextInputDialog>
button={_t("Remove")}
/>
);
}
}

View file

@ -104,7 +104,9 @@ export default class ConfirmUserActionDialog extends React.Component<IProps> {
}
return (
<BaseDialog className="mx_ConfirmUserActionDialog" onFinished={this.props.onFinished}
<BaseDialog
className="mx_ConfirmUserActionDialog"
onFinished={this.props.onFinished}
title={this.props.title}
contentId='mx_Dialog_content'
>

View file

@ -204,8 +204,10 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent<
</div>
<div className="mx_CreateCommunityPrototypeDialog_colAvatar">
<input
type="file" style={{ display: "none" }}
ref={this.avatarUploadRef} accept="image/*"
type="file"
style={{ display: "none" }}
ref={this.avatarUploadRef}
accept="image/*"
onChange={this.onAvatarChanged}
/>
<AccessibleButton

View file

@ -123,7 +123,9 @@ export default class CreateGroupDialog extends React.Component<IProps, IState> {
}
return (
<BaseDialog className="mx_CreateGroupDialog" onFinished={this.props.onFinished}
<BaseDialog
className="mx_CreateGroupDialog"
onFinished={this.props.onFinished}
title={_t('Create Community')}
>
<form onSubmit={this.onFormSubmit}>
@ -133,8 +135,11 @@ export default class CreateGroupDialog extends React.Component<IProps, IState> {
<label htmlFor="groupname">{ _t('Community Name') }</label>
</div>
<div>
<input id="groupname" className="mx_CreateGroupDialog_input"
autoFocus={true} size={64}
<input
id="groupname"
className="mx_CreateGroupDialog_input"
autoFocus={true}
size={64}
placeholder={_t('Example')}
onChange={this.onGroupNameChange}
value={this.state.groupName}

View file

@ -17,6 +17,7 @@ limitations under the License.
import React, { ChangeEvent, createRef, KeyboardEvent, SyntheticEvent } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { JoinRule, Preset, Visibility } from "matrix-js-sdk/src/@types/partials";
import SdkConfig from '../../../SdkConfig';
import withValidation, { IFieldState } from '../elements/Validation';
@ -31,7 +32,8 @@ import RoomAliasField from "../elements/RoomAliasField";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog";
import { Preset, Visibility } from "matrix-js-sdk/src/@types/partials";
import SpaceStore from "../../../stores/SpaceStore";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
interface IProps {
defaultPublic?: boolean;
@ -41,7 +43,7 @@ interface IProps {
}
interface IState {
isPublic: boolean;
joinRule: JoinRule;
isEncrypted: boolean;
name: string;
topic: string;
@ -54,15 +56,25 @@ interface IState {
@replaceableComponent("views.dialogs.CreateRoomDialog")
export default class CreateRoomDialog extends React.Component<IProps, IState> {
private readonly supportsRestricted: boolean;
private nameField = createRef<Field>();
private aliasField = createRef<RoomAliasField>();
constructor(props) {
super(props);
this.supportsRestricted = this.props.parentSpace && !!SpaceStore.instance.restrictedJoinRuleSupport?.preferred;
let joinRule = JoinRule.Invite;
if (this.props.defaultPublic) {
joinRule = JoinRule.Public;
} else if (this.supportsRestricted) {
joinRule = JoinRule.Restricted;
}
const config = SdkConfig.get();
this.state = {
isPublic: this.props.defaultPublic || false,
joinRule,
isEncrypted: privateShouldBeEncrypted(),
name: this.props.defaultName || "",
topic: "",
@ -81,13 +93,18 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
const opts: IOpts = {};
const createOpts: IOpts["createOpts"] = opts.createOpts = {};
createOpts.name = this.state.name;
if (this.state.isPublic) {
if (this.state.joinRule === JoinRule.Public) {
createOpts.visibility = Visibility.Public;
createOpts.preset = Preset.PublicChat;
opts.guestAccess = false;
const { alias } = this.state;
createOpts.room_alias_name = alias.substr(1, alias.indexOf(":") - 1);
} else {
// If we cannot change encryption we pass `true` for safety, the server should automatically do this for us.
opts.encryption = this.state.canChangeEncryption ? this.state.isEncrypted : true;
}
if (this.state.topic) {
createOpts.topic = this.state.topic;
}
@ -95,22 +112,13 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
createOpts.creation_content = { 'm.federate': false };
}
if (!this.state.isPublic) {
if (this.state.canChangeEncryption) {
opts.encryption = this.state.isEncrypted;
} else {
// the server should automatically do this for us, but for safety
// we'll demand it too.
opts.encryption = true;
}
}
if (CommunityPrototypeStore.instance.getSelectedCommunityId()) {
opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId();
}
if (this.props.parentSpace) {
if (this.props.parentSpace && this.state.joinRule === JoinRule.Restricted) {
opts.parentSpace = this.props.parentSpace;
opts.joinRule = JoinRule.Restricted;
}
return opts;
@ -172,8 +180,8 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
this.setState({ topic: ev.target.value });
};
private onPublicChange = (isPublic: boolean) => {
this.setState({ isPublic });
private onJoinRuleChange = (joinRule: JoinRule) => {
this.setState({ joinRule });
};
private onEncryptedChange = (isEncrypted: boolean) => {
@ -210,7 +218,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
render() {
let aliasField;
if (this.state.isPublic) {
if (this.state.joinRule === JoinRule.Public) {
const domain = MatrixClientPeg.get().getDomain();
aliasField = (
<div className="mx_CreateRoomDialog_aliasContainer">
@ -224,19 +232,52 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
);
}
let publicPrivateLabel = <p>{ _t(
"Private rooms can be found and joined by invitation only. Public rooms can be " +
"found and joined by anyone.",
) }</p>;
let publicPrivateLabel: JSX.Element;
if (CommunityPrototypeStore.instance.getSelectedCommunityId()) {
publicPrivateLabel = <p>{ _t(
"Private rooms can be found and joined by invitation only. Public rooms can be " +
"found and joined by anyone in this community.",
) }</p>;
publicPrivateLabel = <p>
{ _t(
"Private rooms can be found and joined by invitation only. Public rooms can be " +
"found and joined by anyone in this community.",
) }
</p>;
} else if (this.state.joinRule === JoinRule.Restricted) {
publicPrivateLabel = <p>
{ _t(
"Everyone in <SpaceName/> will be able to find and join this room.", {}, {
SpaceName: () => <b>{ this.props.parentSpace.name }</b>,
},
) }
&nbsp;
{ _t("You can change this at any time from room settings.") }
</p>;
} else if (this.state.joinRule === JoinRule.Public && this.props.parentSpace) {
publicPrivateLabel = <p>
{ _t(
"Anyone will be able to find and join this room, not just members of <SpaceName/>.", {}, {
SpaceName: () => <b>{ this.props.parentSpace.name }</b>,
},
) }
&nbsp;
{ _t("You can change this at any time from room settings.") }
</p>;
} else if (this.state.joinRule === JoinRule.Public) {
publicPrivateLabel = <p>
{ _t("Anyone will be able to find and join this room.") }
&nbsp;
{ _t("You can change this at any time from room settings.") }
</p>;
} else if (this.state.joinRule === JoinRule.Invite) {
publicPrivateLabel = <p>
{ _t(
"Only people invited will be able to find and join this room.",
) }
&nbsp;
{ _t("You can change this at any time from room settings.") }
</p>;
}
let e2eeSection;
if (!this.state.isPublic) {
if (this.state.joinRule !== JoinRule.Public) {
let microcopy;
if (privateShouldBeEncrypted()) {
if (this.state.canChangeEncryption) {
@ -273,15 +314,16 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
);
}
let title = this.state.isPublic ? _t('Create a public room') : _t('Create a private room');
let title = _t("Create a room");
if (CommunityPrototypeStore.instance.getSelectedCommunityId()) {
const name = CommunityPrototypeStore.instance.getSelectedCommunityName();
title = _t("Create a room in %(communityName)s", { communityName: name });
} else if (!this.props.parentSpace) {
title = this.state.joinRule === JoinRule.Public ? _t('Create a public room') : _t('Create a private room');
}
return (
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished}
title={title}
>
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished} title={title}>
<form onSubmit={this.onOk} onKeyDown={this.onKeyDown}>
<div className="mx_Dialog_content">
<Field
@ -298,11 +340,16 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
value={this.state.topic}
className="mx_CreateRoomDialog_topic"
/>
<LabelledToggleSwitch
label={_t("Make this room public")}
onChange={this.onPublicChange}
value={this.state.isPublic}
<JoinRuleDropdown
label={_t("Room visibility")}
labelInvite={_t("Private room (invite only)")}
labelPublic={_t("Public room")}
labelRestricted={this.supportsRestricted ? _t("Visible to space members") : undefined}
value={this.state.joinRule}
onChange={this.onJoinRuleChange}
/>
{ publicPrivateLabel }
{ e2eeSection }
{ aliasField }

View file

@ -0,0 +1,210 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useRef, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { JoinRule, Preset } from "matrix-js-sdk/src/@types/partials";
import { RoomType } from "matrix-js-sdk/src/@types/event";
import { _t } from '../../../languageHandler';
import BaseDialog from "./BaseDialog";
import AccessibleButton from "../elements/AccessibleButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { BetaPill } from "../beta/BetaCard";
import Field from "../elements/Field";
import RoomAliasField from "../elements/RoomAliasField";
import SpaceStore from "../../../stores/SpaceStore";
import { SpaceCreateForm } from "../spaces/SpaceCreateMenu";
import createRoom from "../../../createRoom";
import { SubspaceSelector } from "./AddExistingToSpaceDialog";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
interface IProps {
space: Room;
onAddExistingSpaceClick(): void;
onFinished(added?: boolean): void;
}
const CreateSubspaceDialog: React.FC<IProps> = ({ space, onAddExistingSpaceClick, onFinished }) => {
const [parentSpace, setParentSpace] = useState(space);
const [busy, setBusy] = useState<boolean>(false);
const [name, setName] = useState("");
const spaceNameField = useRef<Field>();
const [alias, setAlias] = useState("");
const spaceAliasField = useRef<RoomAliasField>();
const [avatar, setAvatar] = useState<File>(null);
const [topic, setTopic] = useState<string>("");
const supportsRestricted = !!SpaceStore.instance.restrictedJoinRuleSupport?.preferred;
const spaceJoinRule = space.getJoinRule();
let defaultJoinRule = JoinRule.Invite;
if (spaceJoinRule === JoinRule.Public) {
defaultJoinRule = JoinRule.Public;
} else if (supportsRestricted) {
defaultJoinRule = JoinRule.Restricted;
}
const [joinRule, setJoinRule] = useState<JoinRule>(defaultJoinRule);
const onCreateSubspaceClick = async (e) => {
e.preventDefault();
if (busy) return;
setBusy(true);
// require & validate the space name field
if (!await spaceNameField.current.validate({ allowEmpty: false })) {
spaceNameField.current.focus();
spaceNameField.current.validate({ allowEmpty: false, focused: true });
setBusy(false);
return;
}
// validate the space name alias field but do not require it
if (joinRule === JoinRule.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) {
spaceAliasField.current.focus();
spaceAliasField.current.validate({ allowEmpty: true, focused: true });
setBusy(false);
return;
}
try {
await createRoom({
createOpts: {
preset: joinRule === JoinRule.Public ? Preset.PublicChat : Preset.PrivateChat,
name,
power_level_content_override: {
// Only allow Admins to write to the timeline to prevent hidden sync spam
events_default: 100,
...joinRule === JoinRule.Public ? { invite: 0 } : {},
},
room_alias_name: joinRule === JoinRule.Public && alias
? alias.substr(1, alias.indexOf(":") - 1)
: undefined,
topic,
},
avatar,
roomType: RoomType.Space,
parentSpace,
spinner: false,
encryption: false,
andView: true,
inlineErrors: true,
});
onFinished(true);
} catch (e) {
console.error(e);
}
};
let joinRuleMicrocopy: JSX.Element;
if (joinRule === JoinRule.Restricted) {
joinRuleMicrocopy = <p>
{ _t(
"Anyone in <SpaceName/> will be able to find and join.", {}, {
SpaceName: () => <b>{ parentSpace.name }</b>,
},
) }
</p>;
} else if (joinRule === JoinRule.Public) {
joinRuleMicrocopy = <p>
{ _t(
"Anyone will be able to find and join this space, not just members of <SpaceName/>.", {}, {
SpaceName: () => <b>{ parentSpace.name }</b>,
},
) }
</p>;
} else if (joinRule === JoinRule.Invite) {
joinRuleMicrocopy = <p>
{ _t("Only people invited will be able to find and join this space.") }
</p>;
}
return <BaseDialog
title={(
<SubspaceSelector
title={_t("Create a space")}
space={space}
value={parentSpace}
onChange={setParentSpace}
/>
)}
className="mx_CreateSubspaceDialog"
contentId="mx_CreateSubspaceDialog"
onFinished={onFinished}
fixedWidth={false}
>
<MatrixClientContext.Provider value={space.client}>
<div className="mx_CreateSubspaceDialog_content">
<div className="mx_CreateSubspaceDialog_betaNotice">
<BetaPill />
{ _t("Add a space to a space you manage.") }
</div>
<SpaceCreateForm
busy={busy}
onSubmit={onCreateSubspaceClick}
setAvatar={setAvatar}
name={name}
setName={setName}
nameFieldRef={spaceNameField}
topic={topic}
setTopic={setTopic}
alias={alias}
setAlias={setAlias}
showAliasField={joinRule === JoinRule.Public}
aliasFieldRef={spaceAliasField}
>
<JoinRuleDropdown
label={_t("Space visibility")}
labelInvite={_t("Private space (invite only)")}
labelPublic={_t("Public space")}
labelRestricted={supportsRestricted ? _t("Visible to space members") : undefined}
width={478}
value={joinRule}
onChange={setJoinRule}
/>
{ joinRuleMicrocopy }
</SpaceCreateForm>
</div>
<div className="mx_CreateSubspaceDialog_footer">
<div className="mx_CreateSubspaceDialog_footer_prompt">
<div>{ _t("Want to add an existing space instead?") }</div>
<AccessibleButton
kind="link"
onClick={() => {
onAddExistingSpaceClick();
onFinished();
}}
>
{ _t("Add existing space") }
</AccessibleButton>
</div>
<AccessibleButton kind="primary_outline" disabled={busy} onClick={() => onFinished(false)}>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton kind="primary" disabled={busy} onClick={onCreateSubspaceClick}>
{ busy ? _t("Adding...") : _t("Add") }
</AccessibleButton>
</div>
</MatrixClientContext.Provider>
</BaseDialog>;
};
export default CreateSubspaceDialog;

View file

@ -72,7 +72,7 @@ const CryptoStoreTooNewDialog: React.FC<IProps> = (props: IProps) => {
hasCancel={false}
onPrimaryButtonClick={props.onFinished}
>
<button onClick={_onLogoutClicked} >
<button onClick={_onLogoutClicked}>
{ _t('Sign out') }
</button>
</DialogButtons>

View file

@ -182,14 +182,23 @@ export class SendCustomEvent extends GenericEditor<ISendCustomEventProps, ISendC
<br />
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" />
<Field
id="evContent"
label={_t("Event Content")}
type="text"
className="mx_DevTools_textarea"
autoComplete="off"
value={this.state.evContent}
onChange={this.onChange}
element="textarea" />
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> }
{ showTglFlip && <div style={{ float: "right" }}>
<input id="isStateEvent" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
<input
id="isStateEvent"
className="mx_DevTools_tgl mx_DevTools_tgl-flip"
type="checkbox"
checked={this.state.isStateEvent}
onChange={this.onChange}
@ -282,14 +291,24 @@ class SendAccountData extends GenericEditor<ISendAccountDataProps, ISendAccountD
{ this.textInput('eventType', _t('Event Type')) }
<br />
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" />
<Field
id="evContent"
label={_t("Event Content")}
type="text"
className="mx_DevTools_textarea"
autoComplete="off"
value={this.state.evContent}
onChange={this.onChange}
element="textarea"
/>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> }
{ !this.state.message && <div style={{ float: "right" }}>
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
<input
id="isRoomAccountData"
className="mx_DevTools_tgl mx_DevTools_tgl-flip"
type="checkbox"
checked={this.state.isRoomAccountData}
disabled={this.props.forceMode}
@ -371,11 +390,18 @@ class FilteredList extends React.PureComponent<IFilteredListProps, IFilteredList
render() {
return <div>
<Field label={_t('Filter results')} autoFocus={true} size={64}
type="text" autoComplete="off" value={this.props.query} onChange={this.onQuery}
<Field
label={_t('Filter results')}
autoFocus={true}
size={64}
type="text"
autoComplete="off"
value={this.props.query}
onChange={this.onQuery}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
// force re-render so that autoFocus is applied when this component is re-used
key={this.props.children[0] ? this.props.children[0].key : ''} />
key={this.props.children[0] ? this.props.children[0].key : ''}
/>
<TruncatedList getChildren={this.getChildren}
getChildCount={this.getChildCount}
@ -459,11 +485,16 @@ class RoomStateExplorer extends React.PureComponent<IExplorerProps, IRoomStateEx
render() {
if (this.state.event) {
if (this.state.editing) {
return <SendCustomEvent room={this.props.room} forceStateEvent={true} onBack={this.onBack} inputs={{
return <SendCustomEvent
room={this.props.room}
forceStateEvent={true}
onBack={this.onBack}
inputs={{
eventType: this.state.event.getType(),
evContent: JSON.stringify(this.state.event.getContent(), null, '\t'),
stateKey: this.state.event.getStateKey(),
}} />;
}}
/>;
}
return <div className="mx_ViewSource">
@ -594,7 +625,9 @@ class AccountDataExplorer extends React.PureComponent<IExplorerProps, IAccountDa
inputs={{
eventType: this.state.event.getType(),
evContent: JSON.stringify(this.state.event.getContent(), null, '\t'),
}} forceMode={true} />;
}}
forceMode={true}
/>;
}
return <div className="mx_ViewSource">
@ -631,7 +664,9 @@ class AccountDataExplorer extends React.PureComponent<IExplorerProps, IAccountDa
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
<div style={{ float: "right" }}>
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
<input
id="isRoomAccountData"
className="mx_DevTools_tgl mx_DevTools_tgl-flip"
type="checkbox"
checked={this.state.isRoomAccountData}
onChange={this.onChange}
@ -1021,8 +1056,13 @@ class SettingsExplorer extends React.PureComponent<IExplorerProps, ISettingsExpl
<div>
<div className="mx_Dialog_content mx_DevTools_SettingsExplorer">
<Field
label={_t('Filter results')} autoFocus={true} size={64}
type="text" autoComplete="off" value={this.state.query} onChange={this.onQueryChange}
label={_t('Filter results')}
autoFocus={true}
size={64}
type="text"
autoComplete="off"
value={this.state.query}
onChange={this.onQueryChange}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
/>
<table>
@ -1040,7 +1080,9 @@ class SettingsExplorer extends React.PureComponent<IExplorerProps, ISettingsExpl
<a href="" onClick={(e) => this.onViewClick(e, i)}>
<code>{ i }</code>
</a>
<a href="" onClick={(e) => this.onEditClick(e, i)}
<a
href=""
onClick={(e) => this.onEditClick(e, i)}
className='mx_DevTools_SettingsExplorer_edit'
>
@ -1104,18 +1146,26 @@ class SettingsExplorer extends React.PureComponent<IExplorerProps, ISettingsExpl
<div>
<Field
id="valExpl" label={_t("Values at explicit levels")} type="text"
className="mx_DevTools_textarea" element="textarea"
autoComplete="off" value={this.state.explicitValues}
id="valExpl"
label={_t("Values at explicit levels")}
type="text"
className="mx_DevTools_textarea"
element="textarea"
autoComplete="off"
value={this.state.explicitValues}
onChange={this.onExplValuesEdit}
/>
</div>
<div>
<Field
id="valExpl" label={_t("Values at explicit levels in this room")} type="text"
className="mx_DevTools_textarea" element="textarea"
autoComplete="off" value={this.state.explicitRoomValues}
id="valExpl"
label={_t("Values at explicit levels in this room")}
type="text"
className="mx_DevTools_textarea"
element="textarea"
autoComplete="off"
value={this.state.explicitRoomValues}
onChange={this.onExplRoomValuesEdit}
/>
</div>

View file

@ -144,8 +144,10 @@ export default class EditCommunityPrototypeDialog extends React.PureComponent<IP
</div>
<div className="mx_EditCommunityPrototypeDialog_rowAvatar">
<input
type="file" style={{ display: "none" }}
ref={this.avatarUploadRef} accept="image/*"
type="file"
style={{ display: "none" }}
ref={this.avatarUploadRef}
accept="image/*"
onChange={this.onAvatarChanged}
/>
<AccessibleButton

View file

@ -106,12 +106,12 @@ const Entry: React.FC<IEntryProps> = ({ room, event, matrixClient: cli, onFinish
className = "mx_ForwardList_sending";
disabled = true;
title = _t("Sending");
icon = <div className="mx_ForwardList_sendIcon" aria-label={title}></div>;
icon = <div className="mx_ForwardList_sendIcon" aria-label={title} />;
} else if (sendState === SendState.Sent) {
className = "mx_ForwardList_sent";
disabled = true;
title = _t("Sent");
icon = <div className="mx_ForwardList_sendIcon" aria-label={title}></div>;
icon = <div className="mx_ForwardList_sendIcon" aria-label={title} />;
} else {
className = "mx_ForwardList_sendFailed";
disabled = true;
@ -204,10 +204,16 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
function overflowTile(overflowCount, totalCount) {
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
} name={text} presenceState="online" suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)} />
<EntityTile
className="mx_EntityTile_ellipsis"
avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
}
name={text}
presenceState="online"
suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)}
/>
);
}

View file

@ -0,0 +1,101 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useState } from "react";
import QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler';
import Field from "../elements/Field";
import SdkConfig from "../../../SdkConfig";
import { IDialogProps } from "./IDialogProps";
import { submitFeedback } from "../../../rageshake/submit-rageshake";
import StyledCheckbox from "../elements/StyledCheckbox";
import Modal from "../../../Modal";
import InfoDialog from "./InfoDialog";
interface IProps extends IDialogProps {
title: string;
subheading: string;
rageshakeLabel: string;
rageshakeData?: Record<string, string>;
}
const GenericFeatureFeedbackDialog: React.FC<IProps> = ({
title,
subheading,
children,
rageshakeLabel,
rageshakeData = {},
onFinished,
}) => {
const [comment, setComment] = useState("");
const [canContact, setCanContact] = useState(false);
const sendFeedback = async (ok: boolean) => {
if (!ok) return onFinished(false);
submitFeedback(SdkConfig.get().bug_report_endpoint_url, rageshakeLabel, comment, canContact, rageshakeData);
onFinished(true);
Modal.createTrackedDialog("Feedback Sent", rageshakeLabel, InfoDialog, {
title,
description: _t("Thank you for your feedback, we really appreciate it."),
button: _t("Done"),
hasCloseButton: false,
fixedWidth: false,
});
};
return (<QuestionDialog
className="mx_GenericFeatureFeedbackDialog"
hasCancelButton={true}
title={title}
description={<React.Fragment>
<div className="mx_GenericFeatureFeedbackDialog_subheading">
{ subheading }
&nbsp;
{ _t("Your platform and username will be noted to help us use your feedback as much as we can.") }
{ children }
</div>
<Field
id="feedbackComment"
label={_t("Feedback")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
autoFocus={true}
/>
<StyledCheckbox
checked={canContact}
onChange={e => setCanContact((e.target as HTMLInputElement).checked)}
>
{ _t("You may contact me if you have any follow up questions") }
</StyledCheckbox>
</React.Fragment>}
button={_t("Send feedback")}
buttonDisabled={!comment}
onFinished={sendFeedback}
/>);
};
export default GenericFeatureFeedbackDialog;

View file

@ -133,18 +133,23 @@ export default class IncomingSasDialog extends React.Component {
? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(48)
: null;
profile = <div className="mx_IncomingSasDialog_opponentProfile">
<BaseAvatar name={oppProfile.displayname}
<BaseAvatar
name={oppProfile.displayname}
idName={this.props.verifier.userId}
url={url}
width={48} height={48} resizeMethod='crop'
width={48}
height={48}
resizeMethod='crop'
/>
<h2>{ oppProfile.displayname }</h2>
</div>;
} else if (this.state.opponentProfileError) {
profile = <div>
<BaseAvatar name={this.props.verifier.userId.slice(1)}
<BaseAvatar
name={this.props.verifier.userId.slice(1)}
idName={this.props.verifier.userId}
width={48} height={48}
width={48}
height={48}
/>
<h2>{ this.props.verifier.userId }</h2>
</div>;

View file

@ -1,7 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd.
Copyright 2019 Bastian Masanek, Noxware IT <matrix@noxware.de>
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,31 +15,31 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import React, { ReactNode, KeyboardEvent } from 'react';
import classNames from "classnames";
export default class InfoDialog extends React.Component {
static propTypes = {
className: PropTypes.string,
title: PropTypes.string,
description: PropTypes.node,
button: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
onFinished: PropTypes.func,
hasCloseButton: PropTypes.bool,
onKeyDown: PropTypes.func,
fixedWidth: PropTypes.bool,
};
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import { IDialogProps } from "./IDialogProps";
interface IProps extends IDialogProps {
title?: string;
description?: ReactNode;
className?: string;
button?: boolean | string;
hasCloseButton?: boolean;
fixedWidth?: boolean;
onKeyDown?(event: KeyboardEvent): void;
}
export default class InfoDialog extends React.Component<IProps> {
static defaultProps = {
title: '',
description: '',
hasCloseButton: false,
};
onFinished = () => {
private onFinished = () => {
this.props.onFinished();
};
@ -63,8 +62,7 @@ export default class InfoDialog extends React.Component {
{ this.props.button !== false && <DialogButtons primaryButton={this.props.button || _t('OK')}
onPrimaryButtonClick={this.onFinished}
hasCancel={false}
>
</DialogButtons> }
/> }
</BaseDialog>
);
}

View file

@ -196,7 +196,9 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
? <img
className='mx_InviteDialog_userTile_avatar mx_InviteDialog_userTile_threepidAvatar'
src={require("../../../../res/img/icon-email-pill-avatar.svg")}
width={avatarSize} height={avatarSize} />
width={avatarSize}
height={avatarSize}
/>
: <BaseAvatar
className='mx_InviteDialog_userTile_avatar'
url={this.props.member.getMxcAvatarUrl()
@ -214,8 +216,11 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
className='mx_InviteDialog_userTile_remove'
onClick={this.onRemove}
>
<img src={require("../../../../res/img/icon-pill-remove.svg")}
alt={_t('Remove')} width={8} height={8}
<img
src={require("../../../../res/img/icon-pill-remove.svg")}
alt={_t('Remove')}
width={8}
height={8}
/>
</AccessibleButton>
);
@ -297,7 +302,9 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
const avatar = (this.props.member as ThreepidMember).isEmail
? <img
src={require("../../../../res/img/icon-email-pill-avatar.svg")}
width={avatarSize} height={avatarSize} />
width={avatarSize}
height={avatarSize}
/>
: <BaseAvatar
url={this.props.member.getMxcAvatarUrl()
? mediaFromMxc(this.props.member.getMxcAvatarUrl()).getSquareThumbnailHttp(avatarSize)
@ -1458,7 +1465,8 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
<p className='mx_InviteDialog_helpText'>
<img
src={require("../../../../res/img/element-icons/info.svg")}
width={14} height={14} />
width={14}
height={14} />
{ " " + _t("Invited people will be able to read old messages.") }
</p>;
}
@ -1534,14 +1542,18 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
// Only show the backspace button if the field has content
let dialPadField;
if (this.state.dialPadValue.length !== 0) {
dialPadField = <Field className="mx_InviteDialog_dialPadField" id="dialpad_number"
dialPadField = <Field
className="mx_InviteDialog_dialPadField"
id="dialpad_number"
value={this.state.dialPadValue}
autoFocus={true}
onChange={this.onDialChange}
postfixComponent={backspaceButton}
/>;
} else {
dialPadField = <Field className="mx_InviteDialog_dialPadField" id="dialpad_number"
dialPadField = <Field
className="mx_InviteDialog_dialPadField"
id="dialpad_number"
value={this.state.dialPadValue}
autoFocus={true}
onChange={this.onDialChange}
@ -1552,14 +1564,19 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
<form onSubmit={this.onDialFormSubmit}>
{ dialPadField }
</form>
<Dialpad hasDial={false}
onDigitPress={this.onDigitPress} onDeletePress={this.onDeletePress}
<Dialpad
hasDial={false}
onDigitPress={this.onDigitPress}
onDeletePress={this.onDeletePress}
/>
</div>;
tabs.push(new Tab(TabId.DialPad, _td("Dial pad"), 'mx_InviteDialog_dialPadIcon', dialPadSection));
dialogContent = <React.Fragment>
<TabbedView tabs={tabs} initialTabId={this.state.currentTabId}
tabLocation={TabLocation.TOP} onChange={this.onTabChange}
<TabbedView
tabs={tabs}
initialTabId={this.state.currentTabId}
tabLocation={TabLocation.TOP}
onChange={this.onTabChange}
/>
{ consultConnectSection }
</React.Fragment>;

View file

@ -0,0 +1,197 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect, useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { _t } from '../../../languageHandler';
import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog";
import SpaceStore from "../../../stores/SpaceStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import { Entry } from "./AddExistingToSpaceDialog";
import SearchBox from "../../structures/SearchBox";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
import StyledRadioGroup from "../elements/StyledRadioGroup";
enum RoomsToLeave {
All = "All",
Specific = "Specific",
None = "None",
}
const SpaceChildPicker = ({ filterPlaceholder, rooms, selected, onChange }) => {
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase().trim();
const filteredRooms = useMemo(() => {
if (!lcQuery) {
return rooms;
}
const matcher = new QueryMatcher<Room>(rooms, {
keys: ["name"],
funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)],
shouldMatchWordsOnly: false,
});
return matcher.match(lcQuery);
}, [rooms, lcQuery]);
return <div className="mx_LeaveSpaceDialog_section">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={filterPlaceholder}
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_LeaveSpaceDialog_content">
{ filteredRooms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selected.has(room)}
onChange={(checked) => {
onChange(checked, room);
}}
/>;
}) }
{ filteredRooms.length < 1 ? <span className="mx_LeaveSpaceDialog_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
</div>;
};
const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave }) => {
const selected = useMemo(() => new Set(roomsToLeave), [roomsToLeave]);
const [state, setState] = useState<string>(RoomsToLeave.All);
useEffect(() => {
if (state === RoomsToLeave.All) {
setRoomsToLeave(spaceChildren);
} else {
setRoomsToLeave([]);
}
}, [setRoomsToLeave, state, spaceChildren]);
return <div className="mx_LeaveSpaceDialog_section">
<StyledRadioGroup
name="roomsToLeave"
value={state}
onChange={setState}
definitions={[
{
value: RoomsToLeave.All,
label: _t("Leave all rooms and spaces"),
}, {
value: RoomsToLeave.None,
label: _t("Don't leave any"),
}, {
value: RoomsToLeave.Specific,
label: _t("Leave specific rooms and spaces"),
},
]}
/>
{ state === RoomsToLeave.Specific && (
<SpaceChildPicker
filterPlaceholder={_t("Search %(spaceName)s", { spaceName: space.name })}
rooms={spaceChildren}
selected={selected}
onChange={(selected: boolean, room: Room) => {
if (selected) {
setRoomsToLeave([room, ...roomsToLeave]);
} else {
setRoomsToLeave(roomsToLeave.filter(r => r !== room));
}
}}
/>
) }
</div>;
};
interface IProps {
space: Room;
onFinished(leave: boolean, rooms?: Room[]): void;
}
const isOnlyAdmin = (room: Room): boolean => {
return !room.getJoinedMembers().some(member => {
return member.userId !== room.client.credentials.userId && member.powerLevelNorm === 100;
});
};
const LeaveSpaceDialog: React.FC<IProps> = ({ space, onFinished }) => {
const spaceChildren = useMemo(() => SpaceStore.instance.getChildren(space.roomId), [space.roomId]);
const [roomsToLeave, setRoomsToLeave] = useState<Room[]>([]);
let rejoinWarning;
if (space.getJoinRule() !== JoinRule.Public) {
rejoinWarning = _t("You won't be able to rejoin unless you are re-invited.");
}
let onlyAdminWarning;
if (isOnlyAdmin(space)) {
onlyAdminWarning = _t("You're the only admin of this space. " +
"Leaving it will mean no one has control over it.");
} else {
const numChildrenOnlyAdminIn = roomsToLeave.filter(isOnlyAdmin).length;
if (numChildrenOnlyAdminIn > 0) {
onlyAdminWarning = _t("You're the only admin of some of the rooms or spaces you wish to leave. " +
"Leaving them will leave them without any admins.");
}
}
return <BaseDialog
title={_t("Leave %(spaceName)s", { spaceName: space.name })}
className="mx_LeaveSpaceDialog"
contentId="mx_LeaveSpaceDialog"
onFinished={() => onFinished(false)}
fixedWidth={false}
>
<div className="mx_Dialog_content" id="mx_LeaveSpaceDialog">
<p>
{ _t("Are you sure you want to leave <spaceName/>?", {}, {
spaceName: () => <b>{ space.name }</b>,
}) }
&nbsp;
{ rejoinWarning }
</p>
{ spaceChildren.length > 0 && <LeaveRoomsPicker
space={space}
spaceChildren={spaceChildren}
roomsToLeave={roomsToLeave}
setRoomsToLeave={setRoomsToLeave}
/> }
{ onlyAdminWarning && <div className="mx_LeaveSpaceDialog_section_warning">
{ onlyAdminWarning }
</div> }
</div>
<DialogButtons
primaryButton={_t("Leave space")}
onPrimaryButtonClick={() => onFinished(true, roomsToLeave)}
hasCancel={true}
onCancel={onFinished}
/>
</BaseDialog>;
};
export default LeaveSpaceDialog;

View file

@ -0,0 +1,192 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { _t } from '../../../languageHandler';
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import SearchBox from "../../structures/SearchBox";
import SpaceStore from "../../../stores/SpaceStore";
import RoomAvatar from "../avatars/RoomAvatar";
import AccessibleButton from "../elements/AccessibleButton";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import StyledCheckbox from "../elements/StyledCheckbox";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
interface IProps extends IDialogProps {
room: Room;
selected?: string[];
}
const Entry = ({ room, checked, onChange }) => {
const localRoom = room instanceof Room;
let description;
if (localRoom) {
description = _t("%(count)s members", { count: room.getJoinedMemberCount() });
const numChildRooms = SpaceStore.instance.getChildRooms(room.roomId).length;
if (numChildRooms > 0) {
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
}
}
return <label className="mx_ManageRestrictedJoinRuleDialog_entry">
<div>
<div>
{ localRoom
? <RoomAvatar room={room} height={20} width={20} />
: <RoomAvatar oobData={room} height={20} width={20} />
}
<span className="mx_ManageRestrictedJoinRuleDialog_entry_name">{ room.name }</span>
</div>
{ description && <div className="mx_ManageRestrictedJoinRuleDialog_entry_description">
{ description }
</div> }
</div>
<StyledCheckbox
onChange={onChange ? (e) => onChange(e.target.checked) : null}
checked={checked}
disabled={!onChange}
/>
</label>;
};
const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [], onFinished }) => {
const cli = room.client;
const [newSelected, setNewSelected] = useState(new Set<string>(selected));
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase().trim();
const [spacesContainingRoom, otherEntries] = useMemo(() => {
const spaces = cli.getVisibleRooms().filter(r => r.getMyMembership() === "join" && r.isSpaceRoom());
return [
spaces.filter(r => SpaceStore.instance.getSpaceFilteredRoomIds(r).has(room.roomId)),
selected.map(roomId => {
const room = cli.getRoom(roomId);
if (!room) {
return { roomId, name: roomId } as Room;
}
if (room.getMyMembership() !== "join" || !room.isSpaceRoom()) {
return room;
}
}).filter(Boolean),
];
}, [cli, selected, room.roomId]);
const [filteredSpacesContainingRooms, filteredOtherEntries] = useMemo(() => [
spacesContainingRoom.filter(r => r.name.toLowerCase().includes(lcQuery)),
otherEntries.filter(r => r.name.toLowerCase().includes(lcQuery)),
], [spacesContainingRoom, otherEntries, lcQuery]);
const onChange = (checked: boolean, room: Room): void => {
if (checked) {
newSelected.add(room.roomId);
} else {
newSelected.delete(room.roomId);
}
setNewSelected(new Set(newSelected));
};
let inviteOnlyWarning;
if (newSelected.size < 1) {
inviteOnlyWarning = <div className="mx_ManageRestrictedJoinRuleDialog_section_info">
{ _t("You're removing all spaces. Access will default to invite only") }
</div>;
}
return <BaseDialog
title={_t("Select spaces")}
className="mx_ManageRestrictedJoinRuleDialog"
onFinished={onFinished}
fixedWidth={false}
>
<p>
{ _t("Decide which spaces can access this room. " +
"If a space is selected, its members can find and join <RoomName/>.", {}, {
RoomName: () => <b>{ room.name }</b>,
}) }
</p>
<MatrixClientContext.Provider value={cli}>
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Search spaces")}
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_ManageRestrictedJoinRuleDialog_content">
{ filteredSpacesContainingRooms.length > 0 ? (
<div className="mx_ManageRestrictedJoinRuleDialog_section">
<h3>{ _t("Spaces you know that contain this room") }</h3>
{ filteredSpacesContainingRooms.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={newSelected.has(space.roomId)}
onChange={(checked: boolean) => {
onChange(checked, space);
}}
/>;
}) }
</div>
) : undefined }
{ filteredOtherEntries.length > 0 ? (
<div className="mx_ManageRestrictedJoinRuleDialog_section">
<h3>{ _t("Other spaces or rooms you might not know") }</h3>
<div className="mx_ManageRestrictedJoinRuleDialog_section_info">
<div>{ _t("These are likely ones other room admins are a part of.") }</div>
</div>
{ filteredOtherEntries.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={newSelected.has(space.roomId)}
onChange={(checked: boolean) => {
onChange(checked, space);
}}
/>;
}) }
</div>
) : null }
{ filteredSpacesContainingRooms.length + filteredOtherEntries.length < 1
? <span className="mx_ManageRestrictedJoinRuleDialog_noResults">
{ _t("No results") }
</span>
: undefined
}
</AutoHideScrollbar>
<div className="mx_ManageRestrictedJoinRuleDialog_footer">
{ inviteOnlyWarning }
<div className="mx_ManageRestrictedJoinRuleDialog_footer_buttons">
<AccessibleButton kind="primary_outline" onClick={() => onFinished()}>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton kind="primary" onClick={() => onFinished(Array.from(newSelected))}>
{ _t("Confirm") }
</AccessibleButton>
</div>
</div>
</MatrixClientContext.Provider>
</BaseDialog>;
};
export default ManageRestrictedJoinRuleDialog;

View file

@ -1,5 +1,5 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2018 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,19 +15,29 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { Room } from "matrix-js-sdk/src/models/room";
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { upgradeRoom } from "../../../utils/RoomUpgrade";
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import ErrorDialog from './ErrorDialog';
import DialogButtons from '../elements/DialogButtons';
import Spinner from "../elements/Spinner";
interface IProps extends IDialogProps {
room: Room;
}
interface IState {
busy: boolean;
}
@replaceableComponent("views.dialogs.RoomUpgradeDialog")
export default class RoomUpgradeDialog extends React.Component {
static propTypes = {
room: PropTypes.object.isRequired,
onFinished: PropTypes.func.isRequired,
};
export default class RoomUpgradeDialog extends React.Component<IProps, IState> {
private targetVersion: string;
state = {
busy: true,
@ -35,20 +45,19 @@ export default class RoomUpgradeDialog extends React.Component {
async componentDidMount() {
const recommended = await this.props.room.getRecommendedVersion();
this._targetVersion = recommended.version;
this.targetVersion = recommended.version;
this.setState({ busy: false });
}
_onCancelClick = () => {
private onCancelClick = (): void => {
this.props.onFinished(false);
};
_onUpgradeClick = () => {
private onUpgradeClick = (): void => {
this.setState({ busy: true });
MatrixClientPeg.get().upgradeRoom(this.props.room.roomId, this._targetVersion).then(() => {
upgradeRoom(this.props.room, this.targetVersion, false, false).then(() => {
this.props.onFinished(true);
}).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to upgrade room', '', ErrorDialog, {
title: _t("Failed to upgrade room"),
description: ((err && err.message) ? err.message : _t("The room upgrade could not be completed")),
@ -59,29 +68,22 @@ export default class RoomUpgradeDialog extends React.Component {
};
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Spinner = sdk.getComponent('views.elements.Spinner');
let buttons;
if (this.state.busy) {
buttons = <Spinner />;
} else {
buttons = <DialogButtons
primaryButton={_t(
'Upgrade this room to version %(version)s',
{ version: this._targetVersion },
)}
primaryButton={_t('Upgrade this room to version %(version)s', { version: this.targetVersion })}
primaryButtonClass="danger"
hasCancel={true}
onPrimaryButtonClick={this._onUpgradeClick}
focus={this.props.focus}
onCancel={this._onCancelClick}
onPrimaryButtonClick={this.onUpgradeClick}
onCancel={this.onCancelClick}
/>;
}
return (
<BaseDialog className="mx_RoomUpgradeDialog"
<BaseDialog
className="mx_RoomUpgradeDialog"
onFinished={this.props.onFinished}
title={_t("Upgrade Room Version")}
contentId='mx_Dialog_content'
@ -97,8 +99,10 @@ export default class RoomUpgradeDialog extends React.Component {
<ol>
<li>{ _t("Create a new room with the same name, description and avatar") }</li>
<li>{ _t("Update any local room aliases to point to the new room") }</li>
<li>{ _t("Stop users from speaking in the old version of the room, and post a message advising users to move to the new room") }</li>
<li>{ _t("Put a link back to the old room at the start of the new room so people can see old messages") }</li>
<li>{ _t("Stop users from speaking in the old version of the room, " +
"and post a message advising users to move to the new room") }</li>
<li>{ _t("Put a link back to the old room at the start of the new room " +
"so people can see old messages") }</li>
</ol>
{ buttons }
</BaseDialog>

View file

@ -1,5 +1,5 @@
/*
Copyright 2019, 2020 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,73 +14,82 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import React, { ReactNode } from 'react';
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { JoinRule } from 'matrix-js-sdk/src/@types/partials';
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import * as sdk from "../../../index";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { IDialogProps } from "./IDialogProps";
import BugReportDialog from './BugReportDialog';
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
interface IProps extends IDialogProps {
roomId: string;
targetVersion: string;
description?: ReactNode;
}
interface IState {
inviteUsersToNewRoom: boolean;
}
@replaceableComponent("views.dialogs.RoomUpgradeWarningDialog")
export default class RoomUpgradeWarningDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
roomId: PropTypes.string.isRequired,
targetVersion: PropTypes.string.isRequired,
};
export default class RoomUpgradeWarningDialog extends React.Component<IProps, IState> {
private readonly isPrivate: boolean;
private readonly currentVersion: string;
constructor(props) {
super(props);
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
const joinRules = room ? room.currentState.getStateEvents("m.room.join_rules", "") : null;
const isPrivate = joinRules ? joinRules.getContent()['join_rule'] !== 'public' : true;
const joinRules = room?.currentState.getStateEvents(EventType.RoomJoinRules, "");
this.isPrivate = joinRules?.getContent()['join_rule'] !== JoinRule.Public ?? true;
this.currentVersion = room?.getVersion() || "1";
this.state = {
currentVersion: room ? room.getVersion() : "1",
isPrivate,
inviteUsersToNewRoom: true,
};
}
_onContinue = () => {
this.props.onFinished({ continue: true, invite: this.state.isPrivate && this.state.inviteUsersToNewRoom });
private onContinue = () => {
this.props.onFinished({ continue: true, invite: this.isPrivate && this.state.inviteUsersToNewRoom });
};
_onCancel = () => {
private onCancel = () => {
this.props.onFinished({ continue: false, invite: false });
};
_onInviteUsersToggle = (newVal) => {
this.setState({ inviteUsersToNewRoom: newVal });
private onInviteUsersToggle = (inviteUsersToNewRoom: boolean) => {
this.setState({ inviteUsersToNewRoom });
};
_openBugReportDialog = (e) => {
private openBugReportDialog = (e) => {
e.preventDefault();
e.stopPropagation();
const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {});
};
render() {
const brand = SdkConfig.get().brand;
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
let inviteToggle = null;
if (this.state.isPrivate) {
if (this.isPrivate) {
inviteToggle = (
<LabelledToggleSwitch
value={this.state.inviteUsersToNewRoom}
onChange={this._onInviteUsersToggle}
label={_t("Automatically invite users")} />
onChange={this.onInviteUsersToggle}
label={_t("Automatically invite members from this room to the new one")} />
);
}
const title = this.state.isPrivate ? _t("Upgrade private room") : _t("Upgrade public room");
const title = this.isPrivate ? _t("Upgrade private room") : _t("Upgrade public room");
let bugReports = (
<p>
@ -101,7 +110,7 @@ export default class RoomUpgradeWarningDialog extends React.Component {
},
{
"a": (sub) => {
return <a href='#' onClick={this._openBugReportDialog}>{ sub }</a>;
return <a href='#' onClick={this.openBugReportDialog}>{ sub }</a>;
},
},
) }
@ -119,18 +128,26 @@ export default class RoomUpgradeWarningDialog extends React.Component {
>
<div>
<p>
{ _t(
{ this.props.description || _t(
"Upgrading a room is an advanced action and is usually recommended when a room " +
"is unstable due to bugs, missing features or security vulnerabilities.",
) }
</p>
<p>
{ _t(
"<b>Please note upgrading will make a new version of the room</b>. " +
"All current messages will stay in this archived room.", {}, {
b: sub => <b>{ sub }</b>,
},
) }
</p>
{ bugReports }
<p>
{ _t(
"You'll upgrade this room from <oldVersion /> to <newVersion />.",
{},
{
oldVersion: () => <code>{ this.state.currentVersion }</code>,
oldVersion: () => <code>{ this.currentVersion }</code>,
newVersion: () => <code>{ this.props.targetVersion }</code>,
},
) }
@ -139,9 +156,9 @@ export default class RoomUpgradeWarningDialog extends React.Component {
</div>
<DialogButtons
primaryButton={_t("Upgrade")}
onPrimaryButtonClick={this._onContinue}
onPrimaryButtonClick={this.onContinue}
cancelButton={_t("Cancel")}
onCancel={this._onCancel}
onCancel={this.onCancel}
/>
</BaseDialog>
);

View file

@ -85,7 +85,9 @@ export default class SessionRestoreErrorDialog extends React.Component {
}
return (
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
<BaseDialog
className="mx_ErrorDialog"
onFinished={this.props.onFinished}
title={_t('Unable to restore session')}
contentId='mx_Dialog_content'
hasCancel={false}

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