Merge remote-tracking branch 'upstream/develop' into feature-surround-with
This commit is contained in:
commit
b99e39e011
383 changed files with 13499 additions and 6309 deletions
|
@ -14,9 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import React, { createRef, ReactNode, RefObject } from "react";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import PlayPauseButton from "./PlayPauseButton";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { formatBytes } from "../../../utils/FormattingUtils";
|
||||
|
@ -25,44 +23,13 @@ import { Key } from "../../../Keyboard";
|
|||
import { _t } from "../../../languageHandler";
|
||||
import SeekBar from "./SeekBar";
|
||||
import PlaybackClock from "./PlaybackClock";
|
||||
|
||||
interface IProps {
|
||||
// Playback instance to render. Cannot change during component lifecycle: create
|
||||
// an all-new component instead.
|
||||
playback: Playback;
|
||||
|
||||
mediaName: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
playbackPhase: PlaybackState;
|
||||
}
|
||||
import AudioPlayerBase from "./AudioPlayerBase";
|
||||
|
||||
@replaceableComponent("views.audio_messages.AudioPlayer")
|
||||
export default class AudioPlayer extends React.PureComponent<IProps, IState> {
|
||||
export default class AudioPlayer extends AudioPlayerBase {
|
||||
private playPauseRef: RefObject<PlayPauseButton> = createRef();
|
||||
private seekRef: RefObject<SeekBar> = createRef();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
playbackPhase: PlaybackState.Decoding, // default assumption
|
||||
};
|
||||
|
||||
// We don't need to de-register: the class handles this for us internally
|
||||
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
|
||||
|
||||
// Don't wait for the promise to complete - it will emit a progress update when it
|
||||
// is done, and it's not meant to take long anyhow.
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.props.playback.prepare();
|
||||
}
|
||||
|
||||
private onPlaybackUpdate = (ev: PlaybackState) => {
|
||||
this.setState({ playbackPhase: ev });
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
// stopPropagation() prevents the FocusComposer catch-all from triggering,
|
||||
// but we need to do it on key down instead of press (even though the user
|
||||
|
@ -88,37 +55,39 @@ export default class AudioPlayer extends React.PureComponent<IProps, IState> {
|
|||
return `(${formatBytes(bytes)})`;
|
||||
}
|
||||
|
||||
public render(): ReactNode {
|
||||
protected renderComponent(): ReactNode {
|
||||
// tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard
|
||||
// events for accessibility
|
||||
return <div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}>
|
||||
<div className='mx_AudioPlayer_primaryContainer'>
|
||||
<PlayPauseButton
|
||||
playback={this.props.playback}
|
||||
playbackPhase={this.state.playbackPhase}
|
||||
tabIndex={-1} // prevent tabbing into the button
|
||||
ref={this.playPauseRef}
|
||||
/>
|
||||
<div className='mx_AudioPlayer_mediaInfo'>
|
||||
<span className='mx_AudioPlayer_mediaName'>
|
||||
{ this.props.mediaName || _t("Unnamed audio") }
|
||||
</span>
|
||||
<div className='mx_AudioPlayer_byline'>
|
||||
<DurationClock playback={this.props.playback} />
|
||||
{ /* easiest way to introduce a gap between the components */ }
|
||||
{ this.renderFileSize() }
|
||||
return (
|
||||
<div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}>
|
||||
<div className='mx_AudioPlayer_primaryContainer'>
|
||||
<PlayPauseButton
|
||||
playback={this.props.playback}
|
||||
playbackPhase={this.state.playbackPhase}
|
||||
tabIndex={-1} // prevent tabbing into the button
|
||||
ref={this.playPauseRef}
|
||||
/>
|
||||
<div className='mx_AudioPlayer_mediaInfo'>
|
||||
<span className='mx_AudioPlayer_mediaName'>
|
||||
{ this.props.mediaName || _t("Unnamed audio") }
|
||||
</span>
|
||||
<div className='mx_AudioPlayer_byline'>
|
||||
<DurationClock playback={this.props.playback} />
|
||||
{ /* easiest way to introduce a gap between the components */ }
|
||||
{ this.renderFileSize() }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mx_AudioPlayer_seek'>
|
||||
<SeekBar
|
||||
playback={this.props.playback}
|
||||
tabIndex={-1} // prevent tabbing into the bar
|
||||
playbackPhase={this.state.playbackPhase}
|
||||
ref={this.seekRef}
|
||||
/>
|
||||
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='mx_AudioPlayer_seek'>
|
||||
<SeekBar
|
||||
playback={this.props.playback}
|
||||
tabIndex={-1} // prevent tabbing into the bar
|
||||
playbackPhase={this.state.playbackPhase}
|
||||
ref={this.seekRef}
|
||||
/>
|
||||
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />
|
||||
</div>
|
||||
</div>;
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
70
src/components/views/audio_messages/AudioPlayerBase.tsx
Normal file
70
src/components/views/audio_messages/AudioPlayerBase.tsx
Normal file
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Playback, PlaybackState } from "../../../audio/Playback";
|
||||
import { TileShape } from "../rooms/EventTile";
|
||||
import React, { ReactNode } from "react";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { _t } from "../../../languageHandler";
|
||||
|
||||
interface IProps {
|
||||
// Playback instance to render. Cannot change during component lifecycle: create
|
||||
// an all-new component instead.
|
||||
playback: Playback;
|
||||
|
||||
mediaName?: string;
|
||||
tileShape?: TileShape;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
playbackPhase: PlaybackState;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.audio_messages.AudioPlayerBase")
|
||||
export default abstract class AudioPlayerBase extends React.PureComponent<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
playbackPhase: PlaybackState.Decoding, // default assumption
|
||||
};
|
||||
|
||||
// We don't need to de-register: the class handles this for us internally
|
||||
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
|
||||
|
||||
// Don't wait for the promise to complete - it will emit a progress update when it
|
||||
// is done, and it's not meant to take long anyhow.
|
||||
this.props.playback.prepare().catch(e => {
|
||||
console.error("Error processing audio file:", e);
|
||||
this.setState({ error: true });
|
||||
});
|
||||
}
|
||||
|
||||
private onPlaybackUpdate = (ev: PlaybackState) => {
|
||||
this.setState({ playbackPhase: ev });
|
||||
};
|
||||
|
||||
protected abstract renderComponent(): ReactNode;
|
||||
|
||||
public render(): ReactNode {
|
||||
return <>
|
||||
{ this.renderComponent() }
|
||||
{ this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
|
||||
</>;
|
||||
}
|
||||
}
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
import React from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Clock from "./Clock";
|
||||
import { Playback } from "../../../voice/Playback";
|
||||
import { Playback } from "../../../audio/Playback";
|
||||
|
||||
interface IProps {
|
||||
playback: Playback;
|
||||
|
|
|
@ -15,7 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { IRecordingUpdate, VoiceRecording } from "../../../voice/VoiceRecording";
|
||||
import { IRecordingUpdate, VoiceRecording } from "../../../audio/VoiceRecording";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Clock from "./Clock";
|
||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||
|
|
|
@ -15,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";
|
||||
|
|
|
@ -18,7 +18,7 @@ import React, { ReactNode } from "react";
|
|||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import { Playback, PlaybackState } from "../../../audio/Playback";
|
||||
import classNames from "classnames";
|
||||
|
||||
// omitted props are handled by render function
|
||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
import React from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Clock from "./Clock";
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import { Playback, PlaybackState } from "../../../audio/Playback";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
|
||||
interface IProps {
|
||||
|
|
|
@ -18,7 +18,7 @@ import React from "react";
|
|||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { arraySeed, arrayTrimFill } from "../../../utils/arrays";
|
||||
import Waveform from "./Waveform";
|
||||
import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../voice/Playback";
|
||||
import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../audio/Playback";
|
||||
import { percentageOf } from "../../../utils/numbers";
|
||||
|
||||
interface IProps {
|
||||
|
|
|
@ -14,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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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>;
|
|
@ -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>
|
|
@ -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 />
|
|
@ -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>;
|
|
@ -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">
|
|
@ -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>
|
|
@ -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>;
|
|
@ -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,
|
||||
};
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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}
|
|
@ -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;
|
|
@ -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} />
|
||||
);
|
||||
|
|
|
@ -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} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -49,6 +49,13 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
|
|||
this.props.onFinished();
|
||||
};
|
||||
|
||||
onKeyDown = (ev) => {
|
||||
// Prevent Backspace and Delete keys from functioning in the entry field
|
||||
if (ev.code === "Backspace" || ev.code === "Delete") {
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
onChange = (ev) => {
|
||||
this.setState({ value: ev.target.value });
|
||||
};
|
||||
|
@ -60,8 +67,11 @@ 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}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
216
src/components/views/context_menus/SpaceContextMenu.tsx
Normal file
216
src/components/views/context_menus/SpaceContextMenu.tsx
Normal 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;
|
||||
|
|
@ -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">
|
||||
|
|
|
@ -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")}
|
||||
/>;
|
||||
}
|
||||
|
||||
|
|
67
src/components/views/dialogs/AddExistingSubspaceDialog.tsx
Normal file
67
src/components/views/dialogs/AddExistingSubspaceDialog.tsx
Normal 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;
|
||||
|
|
@ -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>;
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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 (
|
||||
|
|
|
@ -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) }
|
||||
|
||||
{ _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;
|
||||
|
|
|
@ -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'
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>,
|
||||
},
|
||||
) }
|
||||
|
||||
{ _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>,
|
||||
},
|
||||
) }
|
||||
|
||||
{ _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.") }
|
||||
|
||||
{ _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.",
|
||||
) }
|
||||
|
||||
{ _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 }
|
||||
|
|
210
src/components/views/dialogs/CreateSubspaceDialog.tsx
Normal file
210
src/components/views/dialogs/CreateSubspaceDialog.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
@ -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={{
|
||||
eventType: this.state.event.getType(),
|
||||
evContent: JSON.stringify(this.state.event.getContent(), null, '\t'),
|
||||
stateKey: this.state.event.getStateKey(),
|
||||
}} />;
|
||||
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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
101
src/components/views/dialogs/GenericFeatureFeedbackDialog.tsx
Normal file
101
src/components/views/dialogs/GenericFeatureFeedbackDialog.tsx
Normal 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 }
|
||||
|
||||
{ _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;
|
|
@ -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>;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>;
|
||||
|
|
|
@ -20,10 +20,10 @@ import { _t } from '../../../languageHandler';
|
|||
import SdkConfig from '../../../SdkConfig';
|
||||
|
||||
export default function KeySignatureUploadFailedDialog({
|
||||
failures,
|
||||
source,
|
||||
continuation,
|
||||
onFinished,
|
||||
failures,
|
||||
source,
|
||||
continuation,
|
||||
onFinished,
|
||||
}) {
|
||||
const RETRIES = 2;
|
||||
const BaseDialog = sdk.getComponent('dialogs.BaseDialog');
|
||||
|
|
197
src/components/views/dialogs/LeaveSpaceDialog.tsx
Normal file
197
src/components/views/dialogs/LeaveSpaceDialog.tsx
Normal 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>,
|
||||
}) }
|
||||
|
||||
{ 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;
|
|
@ -16,6 +16,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
|
||||
import Modal from '../../../Modal';
|
||||
import * as sdk from '../../../index';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
|
@ -24,19 +25,25 @@ import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
|||
import RestoreKeyBackupDialog from './security/RestoreKeyBackupDialog';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
interface IProps {
|
||||
onFinished: (success: boolean) => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
shouldLoadBackupStatus: boolean;
|
||||
loading: boolean;
|
||||
backupInfo: IKeyBackupInfo;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.dialogs.LogoutDialog")
|
||||
export default class LogoutDialog extends React.Component {
|
||||
defaultProps = {
|
||||
export default class LogoutDialog extends React.Component<IProps, IState> {
|
||||
static defaultProps = {
|
||||
onFinished: function() {},
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._onSettingsLinkClick = this._onSettingsLinkClick.bind(this);
|
||||
this._onExportE2eKeysClicked = this._onExportE2eKeysClicked.bind(this);
|
||||
this._onFinished = this._onFinished.bind(this);
|
||||
this._onSetRecoveryMethodClick = this._onSetRecoveryMethodClick.bind(this);
|
||||
this._onLogoutConfirm = this._onLogoutConfirm.bind(this);
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
const shouldLoadBackupStatus = cli.isCryptoEnabled() && !cli.getKeyBackupEnabled();
|
||||
|
@ -49,11 +56,11 @@ export default class LogoutDialog extends React.Component {
|
|||
};
|
||||
|
||||
if (shouldLoadBackupStatus) {
|
||||
this._loadBackupStatus();
|
||||
this.loadBackupStatus();
|
||||
}
|
||||
}
|
||||
|
||||
async _loadBackupStatus() {
|
||||
private async loadBackupStatus() {
|
||||
try {
|
||||
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
|
||||
this.setState({
|
||||
|
@ -69,29 +76,29 @@ export default class LogoutDialog extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
_onSettingsLinkClick() {
|
||||
private onSettingsLinkClick = (): void => {
|
||||
// close dialog
|
||||
this.props.onFinished();
|
||||
}
|
||||
this.props.onFinished(true);
|
||||
};
|
||||
|
||||
_onExportE2eKeysClicked() {
|
||||
private onExportE2eKeysClicked = (): void => {
|
||||
Modal.createTrackedDialogAsync('Export E2E Keys', '',
|
||||
import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'),
|
||||
{
|
||||
matrixClient: MatrixClientPeg.get(),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
_onFinished(confirmed) {
|
||||
private onFinished = (confirmed: boolean): void => {
|
||||
if (confirmed) {
|
||||
dis.dispatch({ action: 'logout' });
|
||||
}
|
||||
// close dialog
|
||||
this.props.onFinished();
|
||||
}
|
||||
this.props.onFinished(confirmed);
|
||||
};
|
||||
|
||||
_onSetRecoveryMethodClick() {
|
||||
private onSetRecoveryMethodClick = (): void => {
|
||||
if (this.state.backupInfo) {
|
||||
// A key backup exists for this account, but the creating device is not
|
||||
// verified, so restore the backup which will give us the keys from it and
|
||||
|
@ -108,15 +115,15 @@ export default class LogoutDialog extends React.Component {
|
|||
}
|
||||
|
||||
// close dialog
|
||||
this.props.onFinished();
|
||||
}
|
||||
this.props.onFinished(true);
|
||||
};
|
||||
|
||||
_onLogoutConfirm() {
|
||||
private onLogoutConfirm = (): void => {
|
||||
dis.dispatch({ action: 'logout' });
|
||||
|
||||
// close dialog
|
||||
this.props.onFinished();
|
||||
}
|
||||
this.props.onFinished(true);
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.shouldLoadBackupStatus) {
|
||||
|
@ -152,16 +159,16 @@ export default class LogoutDialog extends React.Component {
|
|||
</div>
|
||||
<DialogButtons primaryButton={setupButtonCaption}
|
||||
hasCancel={false}
|
||||
onPrimaryButtonClick={this._onSetRecoveryMethodClick}
|
||||
onPrimaryButtonClick={this.onSetRecoveryMethodClick}
|
||||
focus={true}
|
||||
>
|
||||
<button onClick={this._onLogoutConfirm}>
|
||||
<button onClick={this.onLogoutConfirm}>
|
||||
{ _t("I don't want my encrypted messages") }
|
||||
</button>
|
||||
</DialogButtons>
|
||||
<details>
|
||||
<summary>{ _t("Advanced") }</summary>
|
||||
<p><button onClick={this._onExportE2eKeysClicked}>
|
||||
<p><button onClick={this.onExportE2eKeysClicked}>
|
||||
{ _t("Manually export keys") }
|
||||
</button></p>
|
||||
</details>
|
||||
|
@ -174,7 +181,7 @@ export default class LogoutDialog extends React.Component {
|
|||
title={_t("You'll lose access to your encrypted messages")}
|
||||
contentId='mx_Dialog_content'
|
||||
hasCancel={true}
|
||||
onFinished={this._onFinished}
|
||||
onFinished={this.onFinished}
|
||||
>
|
||||
{ dialogContent }
|
||||
</BaseDialog>);
|
||||
|
@ -187,7 +194,7 @@ export default class LogoutDialog extends React.Component {
|
|||
"Are you sure you want to sign out?",
|
||||
)}
|
||||
button={_t("Sign out")}
|
||||
onFinished={this._onFinished}
|
||||
onFinished={this.onFinished}
|
||||
/>);
|
||||
}
|
||||
}
|
192
src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx
Normal file
192
src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx
Normal 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;
|
||||
|
|
@ -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>
|
|
@ -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>
|
||||
);
|
|
@ -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}
|
||||
|
|
|
@ -54,7 +54,9 @@ export default class StorageEvictedDialog extends React.Component {
|
|||
}
|
||||
|
||||
return (
|
||||
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
|
||||
<BaseDialog
|
||||
className="mx_ErrorDialog"
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t('Missing session data')}
|
||||
contentId='mx_Dialog_content'
|
||||
hasCancel={false}
|
||||
|
|
|
@ -287,7 +287,8 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp
|
|||
<div className="mx_AccessSecretStorageDialog_reset">
|
||||
{ _t("Forgotten or lost all recovery methods? <a>Reset all</a>", null, {
|
||||
a: (sub) => <a
|
||||
href="" onClick={this.onResetAllClick}
|
||||
href=""
|
||||
onClick={this.onResetAllClick}
|
||||
className="mx_AccessSecretStorageDialog_reset_link">{ sub }</a>,
|
||||
}) }
|
||||
</div>
|
||||
|
|
|
@ -399,7 +399,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
|
|||
|
||||
let keyStatus;
|
||||
if (this.state.recoveryKey.length === 0) {
|
||||
keyStatus = <div className="mx_RestoreKeyBackupDialog_keyStatus"></div>;
|
||||
keyStatus = <div className="mx_RestoreKeyBackupDialog_keyStatus" />;
|
||||
} else if (this.state.recoveryKeyValid) {
|
||||
keyStatus = <div className="mx_RestoreKeyBackupDialog_keyStatus">
|
||||
{ "\uD83D\uDC4D " }{ _t("This looks like a valid Security Key!") }
|
||||
|
|
|
@ -15,56 +15,62 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import AccessibleButton from './AccessibleButton';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import * as sdk from '../../../index';
|
||||
import Analytics from '../../../Analytics';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
interface IProps {
|
||||
size?: string;
|
||||
tooltip?: boolean;
|
||||
action: string;
|
||||
mouseOverAction?: string;
|
||||
label: string;
|
||||
iconPath?: string;
|
||||
className?: string;
|
||||
children?: JSX.Element;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
showTooltip: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.ActionButton")
|
||||
export default class ActionButton extends React.Component {
|
||||
static propTypes = {
|
||||
size: PropTypes.string,
|
||||
tooltip: PropTypes.bool,
|
||||
action: PropTypes.string.isRequired,
|
||||
mouseOverAction: PropTypes.string,
|
||||
label: PropTypes.string.isRequired,
|
||||
iconPath: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
export default class ActionButton extends React.Component<IProps, IState> {
|
||||
static defaultProps: Partial<IProps> = {
|
||||
size: "25",
|
||||
tooltip: false,
|
||||
};
|
||||
|
||||
state = {
|
||||
showTooltip: false,
|
||||
};
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
_onClick = (ev) => {
|
||||
this.state = {
|
||||
showTooltip: false,
|
||||
};
|
||||
}
|
||||
|
||||
private onClick = (ev: React.MouseEvent): void => {
|
||||
ev.stopPropagation();
|
||||
Analytics.trackEvent('Action Button', 'click', this.props.action);
|
||||
dis.dispatch({ action: this.props.action });
|
||||
};
|
||||
|
||||
_onMouseEnter = () => {
|
||||
private onMouseEnter = (): void => {
|
||||
if (this.props.tooltip) this.setState({ showTooltip: true });
|
||||
if (this.props.mouseOverAction) {
|
||||
dis.dispatch({ action: this.props.mouseOverAction });
|
||||
}
|
||||
};
|
||||
|
||||
_onMouseLeave = () => {
|
||||
private onMouseLeave = (): void => {
|
||||
this.setState({ showTooltip: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
let tooltip;
|
||||
if (this.state.showTooltip) {
|
||||
const Tooltip = sdk.getComponent("elements.Tooltip");
|
||||
tooltip = <Tooltip className="mx_RoleButton_tooltip" label={this.props.label} />;
|
||||
}
|
||||
|
||||
|
@ -80,9 +86,9 @@ export default class ActionButton extends React.Component {
|
|||
return (
|
||||
<AccessibleButton
|
||||
className={classNames.join(" ")}
|
||||
onClick={this._onClick}
|
||||
onMouseEnter={this._onMouseEnter}
|
||||
onMouseLeave={this._onMouseLeave}
|
||||
onClick={this.onClick}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
aria-label={this.props.label}
|
||||
>
|
||||
{ icon }
|
|
@ -15,30 +15,37 @@ 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 React, { createRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { UserAddressType } from '../../../UserAddress';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { IUserAddress } from '../../../UserAddress';
|
||||
import AddressTile from './AddressTile';
|
||||
|
||||
interface IProps {
|
||||
onSelected: (index: number) => void;
|
||||
|
||||
// List of the addresses to display
|
||||
addressList: IUserAddress[];
|
||||
// Whether to show the address on the address tiles
|
||||
showAddress?: boolean;
|
||||
truncateAt: number;
|
||||
selected?: number;
|
||||
|
||||
// Element to put as a header on top of the list
|
||||
header?: JSX.Element;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
selected: number;
|
||||
hover: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.AddressSelector")
|
||||
export default class AddressSelector extends React.Component {
|
||||
static propTypes = {
|
||||
onSelected: PropTypes.func.isRequired,
|
||||
export default class AddressSelector extends React.Component<IProps, IState> {
|
||||
private scrollElement = createRef<HTMLDivElement>();
|
||||
private addressListElement = createRef<HTMLDivElement>();
|
||||
|
||||
// List of the addresses to display
|
||||
addressList: PropTypes.arrayOf(UserAddressType).isRequired,
|
||||
// Whether to show the address on the address tiles
|
||||
showAddress: PropTypes.bool,
|
||||
truncateAt: PropTypes.number.isRequired,
|
||||
selected: PropTypes.number,
|
||||
|
||||
// Element to put as a header on top of the list
|
||||
header: PropTypes.node,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
|
@ -48,10 +55,10 @@ export default class AddressSelector extends React.Component {
|
|||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps(props) { // eslint-disable-line camelcase
|
||||
UNSAFE_componentWillReceiveProps(props: IProps) { // eslint-disable-line
|
||||
// Make sure the selected item isn't outside the list bounds
|
||||
const selected = this.state.selected;
|
||||
const maxSelected = this._maxSelected(props.addressList);
|
||||
const maxSelected = this.maxSelected(props.addressList);
|
||||
if (selected > maxSelected) {
|
||||
this.setState({ selected: maxSelected });
|
||||
}
|
||||
|
@ -60,13 +67,13 @@ export default class AddressSelector extends React.Component {
|
|||
componentDidUpdate() {
|
||||
// As the user scrolls with the arrow keys keep the selected item
|
||||
// at the top of the window.
|
||||
if (this.scrollElement && this.props.addressList.length > 0 && !this.state.hover) {
|
||||
const elementHeight = this.addressListElement.getBoundingClientRect().height;
|
||||
this.scrollElement.scrollTop = (this.state.selected * elementHeight) - elementHeight;
|
||||
if (this.scrollElement.current && this.props.addressList.length > 0 && !this.state.hover) {
|
||||
const elementHeight = this.addressListElement.current.getBoundingClientRect().height;
|
||||
this.scrollElement.current.scrollTop = (this.state.selected * elementHeight) - elementHeight;
|
||||
}
|
||||
}
|
||||
|
||||
moveSelectionTop = () => {
|
||||
public moveSelectionTop = (): void => {
|
||||
if (this.state.selected > 0) {
|
||||
this.setState({
|
||||
selected: 0,
|
||||
|
@ -75,7 +82,7 @@ export default class AddressSelector extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
moveSelectionUp = () => {
|
||||
public moveSelectionUp = (): void => {
|
||||
if (this.state.selected > 0) {
|
||||
this.setState({
|
||||
selected: this.state.selected - 1,
|
||||
|
@ -84,8 +91,8 @@ export default class AddressSelector extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
moveSelectionDown = () => {
|
||||
if (this.state.selected < this._maxSelected(this.props.addressList)) {
|
||||
public moveSelectionDown = (): void => {
|
||||
if (this.state.selected < this.maxSelected(this.props.addressList)) {
|
||||
this.setState({
|
||||
selected: this.state.selected + 1,
|
||||
hover: false,
|
||||
|
@ -93,26 +100,26 @@ export default class AddressSelector extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
chooseSelection = () => {
|
||||
public chooseSelection = (): void => {
|
||||
this.selectAddress(this.state.selected);
|
||||
};
|
||||
|
||||
onClick = index => {
|
||||
private onClick = (index: number): void => {
|
||||
this.selectAddress(index);
|
||||
};
|
||||
|
||||
onMouseEnter = index => {
|
||||
private onMouseEnter = (index: number): void => {
|
||||
this.setState({
|
||||
selected: index,
|
||||
hover: true,
|
||||
});
|
||||
};
|
||||
|
||||
onMouseLeave = () => {
|
||||
private onMouseLeave = (): void => {
|
||||
this.setState({ hover: false });
|
||||
};
|
||||
|
||||
selectAddress = index => {
|
||||
private selectAddress = (index: number): void => {
|
||||
// Only try to select an address if one exists
|
||||
if (this.props.addressList.length !== 0) {
|
||||
this.props.onSelected(index);
|
||||
|
@ -120,9 +127,8 @@ export default class AddressSelector extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
createAddressListTiles() {
|
||||
const AddressTile = sdk.getComponent("elements.AddressTile");
|
||||
const maxSelected = this._maxSelected(this.props.addressList);
|
||||
private createAddressListTiles(): JSX.Element[] {
|
||||
const maxSelected = this.maxSelected(this.props.addressList);
|
||||
const addressList = [];
|
||||
|
||||
// Only create the address elements if there are address
|
||||
|
@ -143,14 +149,12 @@ export default class AddressSelector extends React.Component {
|
|||
onMouseEnter={this.onMouseEnter.bind(this, i)}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
key={this.props.addressList[i].addressType + "/" + this.props.addressList[i].address}
|
||||
ref={(ref) => { this.addressListElement = ref; }}
|
||||
ref={this.addressListElement}
|
||||
>
|
||||
<AddressTile
|
||||
address={this.props.addressList[i]}
|
||||
showAddress={this.props.showAddress}
|
||||
justified={true}
|
||||
networkName="vector"
|
||||
networkUrl={require("../../../../res/img/search-icon-vector.svg")}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
@ -159,7 +163,7 @@ export default class AddressSelector extends React.Component {
|
|||
return addressList;
|
||||
}
|
||||
|
||||
_maxSelected(list) {
|
||||
private maxSelected(list: IUserAddress[]): number {
|
||||
const listSize = list.length === 0 ? 0 : list.length - 1;
|
||||
const maxSelected = listSize > (this.props.truncateAt - 1) ? (this.props.truncateAt - 1) : listSize;
|
||||
return maxSelected;
|
||||
|
@ -172,7 +176,7 @@ export default class AddressSelector extends React.Component {
|
|||
});
|
||||
|
||||
return (
|
||||
<div className={classes} ref={(ref) => {this.scrollElement = ref;}}>
|
||||
<div className={classes} ref={this.scrollElement}>
|
||||
{ this.props.header }
|
||||
{ this.createAddressListTiles() }
|
||||
</div>
|
|
@ -16,24 +16,25 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import * as sdk from "../../../index";
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { UserAddressType } from '../../../UserAddress';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import { IUserAddress } from '../../../UserAddress';
|
||||
import BaseAvatar from '../avatars/BaseAvatar';
|
||||
import EmailUserIcon from "../../../../res/img/icon-email-user.svg";
|
||||
|
||||
interface IProps {
|
||||
address: IUserAddress;
|
||||
canDismiss?: boolean;
|
||||
onDismissed?: () => void;
|
||||
justified?: boolean;
|
||||
showAddress?: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.AddressTile")
|
||||
export default class AddressTile extends React.Component {
|
||||
static propTypes = {
|
||||
address: UserAddressType.isRequired,
|
||||
canDismiss: PropTypes.bool,
|
||||
onDismissed: PropTypes.func,
|
||||
justified: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
export default class AddressTile extends React.Component<IProps> {
|
||||
static defaultProps: Partial<IProps> = {
|
||||
canDismiss: false,
|
||||
onDismissed: function() {}, // NOP
|
||||
justified: false,
|
||||
|
@ -49,11 +50,9 @@ export default class AddressTile extends React.Component {
|
|||
if (isMatrixAddress && address.avatarMxc) {
|
||||
imgUrls.push(mediaFromMxc(address.avatarMxc).getSquareThumbnailHttp(25));
|
||||
} else if (address.addressType === 'email') {
|
||||
imgUrls.push(require("../../../../res/img/icon-email-user.svg"));
|
||||
imgUrls.push(EmailUserIcon);
|
||||
}
|
||||
|
||||
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
|
||||
|
||||
const nameClasses = classNames({
|
||||
"mx_AddressTile_name": true,
|
||||
"mx_AddressTile_justified": this.props.justified,
|
||||
|
@ -70,9 +69,10 @@ export default class AddressTile extends React.Component {
|
|||
info = (
|
||||
<div className="mx_AddressTile_mx">
|
||||
<div className={nameClasses}>{ name }</div>
|
||||
{ this.props.showAddress ?
|
||||
<div className={idClasses}>{ address.address }</div> :
|
||||
<div />
|
||||
{
|
||||
this.props.showAddress
|
||||
? <div className={idClasses}>{ address.address }</div>
|
||||
: <div />
|
||||
}
|
||||
</div>
|
||||
);
|
||||
|
@ -122,7 +122,7 @@ export default class AddressTile extends React.Component {
|
|||
let dismiss;
|
||||
if (this.props.canDismiss) {
|
||||
dismiss = (
|
||||
<div className="mx_AddressTile_dismiss" onClick={this.props.onDismissed} >
|
||||
<div className="mx_AddressTile_dismiss" onClick={this.props.onDismissed}>
|
||||
<img src={require("../../../../res/img/icon-address-delete.svg")} width="9" height="9" />
|
||||
</div>
|
||||
);
|
|
@ -17,30 +17,39 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import url from 'url';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import WidgetUtils from "../../../utils/WidgetUtils";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
|
||||
import MemberAvatar from '../avatars/MemberAvatar';
|
||||
import BaseAvatar from '../avatars/BaseAvatar';
|
||||
import AccessibleButton from './AccessibleButton';
|
||||
import TextWithTooltip from "./TextWithTooltip";
|
||||
|
||||
interface IProps {
|
||||
url: string;
|
||||
creatorUserId: string;
|
||||
roomId: string;
|
||||
onPermissionGranted: () => void;
|
||||
isRoomEncrypted?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
roomMember: RoomMember;
|
||||
isWrapped: boolean;
|
||||
widgetDomain: string;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.AppPermission")
|
||||
export default class AppPermission extends React.Component {
|
||||
static propTypes = {
|
||||
url: PropTypes.string.isRequired,
|
||||
creatorUserId: PropTypes.string.isRequired,
|
||||
roomId: PropTypes.string.isRequired,
|
||||
onPermissionGranted: PropTypes.func.isRequired,
|
||||
isRoomEncrypted: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
export default class AppPermission extends React.Component<IProps, IState> {
|
||||
static defaultProps: Partial<IProps> = {
|
||||
onPermissionGranted: () => {},
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// The first step is to pick apart the widget so we can render information about it
|
||||
|
@ -55,16 +64,18 @@ export default class AppPermission extends React.Component {
|
|||
this.state = {
|
||||
...urlInfo,
|
||||
roomMember,
|
||||
isWrapped: null,
|
||||
widgetDomain: null,
|
||||
};
|
||||
}
|
||||
|
||||
parseWidgetUrl() {
|
||||
private parseWidgetUrl(): { isWrapped: boolean, widgetDomain: string } {
|
||||
const widgetUrl = url.parse(this.props.url);
|
||||
const params = new URLSearchParams(widgetUrl.search);
|
||||
|
||||
// HACK: We're relying on the query params when we should be relying on the widget's `data`.
|
||||
// This is a workaround for Scalar.
|
||||
if (WidgetUtils.isScalarUrl(widgetUrl) && params && params.get('url')) {
|
||||
if (WidgetUtils.isScalarUrl(this.props.url) && params && params.get('url')) {
|
||||
const unwrappedUrl = url.parse(params.get('url'));
|
||||
return {
|
||||
widgetDomain: unwrappedUrl.host || unwrappedUrl.hostname,
|
||||
|
@ -80,10 +91,6 @@ export default class AppPermission extends React.Component {
|
|||
|
||||
render() {
|
||||
const brand = SdkConfig.get().brand;
|
||||
const AccessibleButton = sdk.getComponent("views.elements.AccessibleButton");
|
||||
const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar");
|
||||
const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar");
|
||||
const TextWithTooltip = sdk.getComponent("views.elements.TextWithTooltip");
|
||||
|
||||
const displayName = this.state.roomMember ? this.state.roomMember.name : this.props.creatorUserId;
|
||||
const userId = displayName === this.props.creatorUserId ? null : this.props.creatorUserId;
|
|
@ -17,9 +17,12 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import BaseDialog from "..//dialogs/BaseDialog";
|
||||
import DialogButtons from "./DialogButtons";
|
||||
import classNames from 'classnames';
|
||||
import AccessibleButton from './AccessibleButton';
|
||||
import { getDesktopCapturerSources } from "matrix-js-sdk/src/webrtc/call";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import TabbedView, { Tab, TabLocation } from '../../structures/TabbedView';
|
||||
|
||||
export interface DesktopCapturerSource {
|
||||
id: string;
|
||||
|
@ -28,61 +31,70 @@ export interface DesktopCapturerSource {
|
|||
}
|
||||
|
||||
export enum Tabs {
|
||||
Screens = "screens",
|
||||
Windows = "windows",
|
||||
Screens = "screen",
|
||||
Windows = "window",
|
||||
}
|
||||
|
||||
export interface DesktopCapturerSourceIProps {
|
||||
export interface ExistingSourceIProps {
|
||||
source: DesktopCapturerSource;
|
||||
onSelect(source: DesktopCapturerSource): void;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export class ExistingSource extends React.Component<DesktopCapturerSourceIProps> {
|
||||
constructor(props) {
|
||||
export class ExistingSource extends React.Component<ExistingSourceIProps> {
|
||||
constructor(props: ExistingSourceIProps) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
onClick = (ev) => {
|
||||
private onClick = (): void => {
|
||||
this.props.onSelect(this.props.source);
|
||||
};
|
||||
|
||||
render() {
|
||||
const thumbnailClasses = classNames({
|
||||
mx_desktopCapturerSourcePicker_source_thumbnail: true,
|
||||
mx_desktopCapturerSourcePicker_source_thumbnail_selected: this.props.selected,
|
||||
});
|
||||
|
||||
return (
|
||||
<AccessibleButton
|
||||
className="mx_desktopCapturerSourcePicker_stream_button"
|
||||
className="mx_desktopCapturerSourcePicker_source"
|
||||
title={this.props.source.name}
|
||||
onClick={this.onClick} >
|
||||
onClick={this.onClick}
|
||||
>
|
||||
<img
|
||||
className="mx_desktopCapturerSourcePicker_stream_thumbnail"
|
||||
className={thumbnailClasses}
|
||||
src={this.props.source.thumbnailURL}
|
||||
/>
|
||||
<span className="mx_desktopCapturerSourcePicker_stream_name">{ this.props.source.name }</span>
|
||||
<span className="mx_desktopCapturerSourcePicker_source_name">{ this.props.source.name }</span>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface DesktopCapturerSourcePickerIState {
|
||||
export interface PickerIState {
|
||||
selectedTab: Tabs;
|
||||
sources: Array<DesktopCapturerSource>;
|
||||
selectedSource: DesktopCapturerSource | null;
|
||||
}
|
||||
export interface DesktopCapturerSourcePickerIProps {
|
||||
export interface PickerIProps {
|
||||
onFinished(source: DesktopCapturerSource): void;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.DesktopCapturerSourcePicker")
|
||||
export default class DesktopCapturerSourcePicker extends React.Component<
|
||||
DesktopCapturerSourcePickerIProps,
|
||||
DesktopCapturerSourcePickerIState
|
||||
> {
|
||||
interval;
|
||||
PickerIProps,
|
||||
PickerIState
|
||||
> {
|
||||
interval: number;
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: PickerIProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
selectedTab: Tabs.Screens,
|
||||
sources: [],
|
||||
selectedSource: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -106,69 +118,61 @@ export default class DesktopCapturerSourcePicker extends React.Component<
|
|||
clearInterval(this.interval);
|
||||
}
|
||||
|
||||
onSelect = (source) => {
|
||||
this.props.onFinished(source);
|
||||
private onSelect = (source: DesktopCapturerSource): void => {
|
||||
this.setState({ selectedSource: source });
|
||||
};
|
||||
|
||||
onScreensClick = (ev) => {
|
||||
this.setState({ selectedTab: Tabs.Screens });
|
||||
private onShare = (): void => {
|
||||
this.props.onFinished(this.state.selectedSource);
|
||||
};
|
||||
|
||||
onWindowsClick = (ev) => {
|
||||
this.setState({ selectedTab: Tabs.Windows });
|
||||
private onTabChange = (): void => {
|
||||
this.setState({ selectedSource: null });
|
||||
};
|
||||
|
||||
onCloseClick = (ev) => {
|
||||
private onCloseClick = (): void => {
|
||||
this.props.onFinished(null);
|
||||
};
|
||||
|
||||
render() {
|
||||
let sources;
|
||||
if (this.state.selectedTab === Tabs.Screens) {
|
||||
sources = this.state.sources
|
||||
.filter((source) => {
|
||||
return source.id.startsWith("screen");
|
||||
})
|
||||
.map((source) => {
|
||||
return <ExistingSource source={source} onSelect={this.onSelect} key={source.id} />;
|
||||
});
|
||||
} else {
|
||||
sources = this.state.sources
|
||||
.filter((source) => {
|
||||
return source.id.startsWith("window");
|
||||
})
|
||||
.map((source) => {
|
||||
return <ExistingSource source={source} onSelect={this.onSelect} key={source.id} />;
|
||||
});
|
||||
}
|
||||
private getTab(type: "screen" | "window", label: string): Tab {
|
||||
const sources = this.state.sources.filter((source) => source.id.startsWith(type)).map((source) => {
|
||||
return (
|
||||
<ExistingSource
|
||||
selected={this.state.selectedSource?.id === source.id}
|
||||
source={source}
|
||||
onSelect={this.onSelect}
|
||||
key={source.id}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const buttonStyle = "mx_desktopCapturerSourcePicker_tabLabel";
|
||||
const screensButtonStyle = buttonStyle + ((this.state.selectedTab === Tabs.Screens) ? "_selected" : "");
|
||||
const windowsButtonStyle = buttonStyle + ((this.state.selectedTab === Tabs.Windows) ? "_selected" : "");
|
||||
return new Tab(type, label, null, (
|
||||
<div className="mx_desktopCapturerSourcePicker_tab">
|
||||
{ sources }
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
render() {
|
||||
const tabs = [
|
||||
this.getTab("screen", _t("Share entire screen")),
|
||||
this.getTab("window", _t("Application window")),
|
||||
];
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
className="mx_desktopCapturerSourcePicker"
|
||||
onFinished={this.onCloseClick}
|
||||
title={_t("Share your screen")}
|
||||
title={_t("Share content")}
|
||||
>
|
||||
<div className="mx_desktopCapturerSourcePicker_tabLabels">
|
||||
<AccessibleButton
|
||||
className={screensButtonStyle}
|
||||
onClick={this.onScreensClick}
|
||||
>
|
||||
{ _t("Screens") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
className={windowsButtonStyle}
|
||||
onClick={this.onWindowsClick}
|
||||
>
|
||||
{ _t("Windows") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
<div className="mx_desktopCapturerSourcePicker_panel">
|
||||
{ sources }
|
||||
</div>
|
||||
<TabbedView tabs={tabs} tabLocation={TabLocation.TOP} onChange={this.onTabChange} />
|
||||
<DialogButtons
|
||||
primaryButton={_t("Share")}
|
||||
hasCancel={true}
|
||||
onCancel={this.onCloseClick}
|
||||
onPrimaryButtonClick={this.onShare}
|
||||
primaryDisabled={!this.state.selectedSource}
|
||||
/>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -16,34 +15,38 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { ChangeEvent, createRef, CSSProperties, ReactElement, ReactNode, Ref } from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import AccessibleButton from './AccessibleButton';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { Key } from "../../../Keyboard";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
class MenuOption extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this._onMouseEnter = this._onMouseEnter.bind(this);
|
||||
this._onClick = this._onClick.bind(this);
|
||||
}
|
||||
interface IMenuOptionProps {
|
||||
children: ReactElement;
|
||||
highlighted?: boolean;
|
||||
dropdownKey: string;
|
||||
id?: string;
|
||||
inputRef?: Ref<HTMLDivElement>;
|
||||
onClick(dropdownKey: string): void;
|
||||
onMouseEnter(dropdownKey: string): void;
|
||||
}
|
||||
|
||||
class MenuOption extends React.Component<IMenuOptionProps> {
|
||||
static defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
_onMouseEnter() {
|
||||
private onMouseEnter = () => {
|
||||
this.props.onMouseEnter(this.props.dropdownKey);
|
||||
}
|
||||
};
|
||||
|
||||
_onClick(e) {
|
||||
private onClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.onClick(this.props.dropdownKey);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const optClasses = classnames({
|
||||
|
@ -54,8 +57,8 @@ class MenuOption extends React.Component {
|
|||
return <div
|
||||
id={this.props.id}
|
||||
className={optClasses}
|
||||
onClick={this._onClick}
|
||||
onMouseEnter={this._onMouseEnter}
|
||||
onClick={this.onClick}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
role="option"
|
||||
aria-selected={this.props.highlighted}
|
||||
ref={this.props.inputRef}
|
||||
|
@ -65,91 +68,97 @@ class MenuOption extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
MenuOption.propTypes = {
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node,
|
||||
]),
|
||||
highlighted: PropTypes.bool,
|
||||
dropdownKey: PropTypes.string,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
onMouseEnter: PropTypes.func.isRequired,
|
||||
inputRef: PropTypes.any,
|
||||
};
|
||||
interface IProps {
|
||||
id: string;
|
||||
// ARIA label
|
||||
label: string;
|
||||
value?: string;
|
||||
className?: string;
|
||||
children: ReactElement[];
|
||||
// negative for consistency with HTML
|
||||
disabled?: boolean;
|
||||
// The width that the dropdown should be. If specified,
|
||||
// the dropped-down part of the menu will be set to this
|
||||
// width.
|
||||
menuWidth?: number;
|
||||
searchEnabled?: boolean;
|
||||
// Called when the selected option changes
|
||||
onOptionChange(dropdownKey: string): void;
|
||||
// Called when the value of the search field changes
|
||||
onSearchChange?(query: string): void;
|
||||
// Function that, given the key of an option, returns
|
||||
// a node representing that option to be displayed in the
|
||||
// box itself as the currently-selected option (ie. as
|
||||
// opposed to in the actual dropped-down part). If
|
||||
// unspecified, the appropriate child element is used as
|
||||
// in the dropped-down menu.
|
||||
getShortOption?(value: string): ReactNode;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
expanded: boolean;
|
||||
highlightedOption: string | null;
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* Reusable dropdown select control, akin to react-select,
|
||||
* but somewhat simpler as react-select is 79KB of minified
|
||||
* javascript.
|
||||
*
|
||||
* TODO: Port NetworkDropdown to use this.
|
||||
*/
|
||||
@replaceableComponent("views.elements.Dropdown")
|
||||
export default class Dropdown extends React.Component {
|
||||
constructor(props) {
|
||||
export default class Dropdown extends React.Component<IProps, IState> {
|
||||
private readonly buttonRef = createRef<HTMLDivElement>();
|
||||
private dropdownRootElement: HTMLDivElement = null;
|
||||
private ignoreEvent: MouseEvent = null;
|
||||
private childrenByKey: Record<string, ReactNode> = {};
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.dropdownRootElement = null;
|
||||
this.ignoreEvent = null;
|
||||
this.reindexChildren(this.props.children);
|
||||
|
||||
this._onInputClick = this._onInputClick.bind(this);
|
||||
this._onRootClick = this._onRootClick.bind(this);
|
||||
this._onDocumentClick = this._onDocumentClick.bind(this);
|
||||
this._onMenuOptionClick = this._onMenuOptionClick.bind(this);
|
||||
this._onInputChange = this._onInputChange.bind(this);
|
||||
this._collectRoot = this._collectRoot.bind(this);
|
||||
this._collectInputTextBox = this._collectInputTextBox.bind(this);
|
||||
this._setHighlightedOption = this._setHighlightedOption.bind(this);
|
||||
|
||||
this.inputTextBox = null;
|
||||
|
||||
this._reindexChildren(this.props.children);
|
||||
|
||||
const firstChild = React.Children.toArray(props.children)[0];
|
||||
const firstChild = React.Children.toArray(props.children)[0] as ReactElement;
|
||||
|
||||
this.state = {
|
||||
// True if the menu is dropped-down
|
||||
expanded: false,
|
||||
// The key of the highlighted option
|
||||
// (the option that would become selected if you pressed enter)
|
||||
highlightedOption: firstChild ? firstChild.key : null,
|
||||
highlightedOption: firstChild ? firstChild.key as string : null,
|
||||
// the current search query
|
||||
searchQuery: '',
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
|
||||
this._button = createRef();
|
||||
// Listen for all clicks on the document so we can close the
|
||||
// menu when the user clicks somewhere else
|
||||
document.addEventListener('click', this._onDocumentClick, false);
|
||||
document.addEventListener('click', this.onDocumentClick, false);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('click', this._onDocumentClick, false);
|
||||
document.removeEventListener('click', this.onDocumentClick, false);
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase
|
||||
UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line
|
||||
if (!nextProps.children || nextProps.children.length === 0) {
|
||||
return;
|
||||
}
|
||||
this._reindexChildren(nextProps.children);
|
||||
this.reindexChildren(nextProps.children);
|
||||
const firstChild = nextProps.children[0];
|
||||
this.setState({
|
||||
highlightedOption: firstChild ? firstChild.key : null,
|
||||
});
|
||||
}
|
||||
|
||||
_reindexChildren(children) {
|
||||
private reindexChildren(children: ReactElement[]): void {
|
||||
this.childrenByKey = {};
|
||||
React.Children.forEach(children, (child) => {
|
||||
this.childrenByKey[child.key] = child;
|
||||
});
|
||||
}
|
||||
|
||||
_onDocumentClick(ev) {
|
||||
private onDocumentClick = (ev: MouseEvent) => {
|
||||
// Close the dropdown if the user clicks anywhere that isn't
|
||||
// within our root element
|
||||
if (ev !== this.ignoreEvent) {
|
||||
|
@ -157,9 +166,9 @@ export default class Dropdown extends React.Component {
|
|||
expanded: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_onRootClick(ev) {
|
||||
private onRootClick = (ev: MouseEvent) => {
|
||||
// This captures any clicks that happen within our elements,
|
||||
// such that we can then ignore them when they're seen by the
|
||||
// click listener on the document handler, ie. not close the
|
||||
|
@ -167,9 +176,9 @@ export default class Dropdown extends React.Component {
|
|||
// NB. We can't just stopPropagation() because then the event
|
||||
// doesn't reach the React onClick().
|
||||
this.ignoreEvent = ev;
|
||||
}
|
||||
};
|
||||
|
||||
_onInputClick(ev) {
|
||||
private onInputClick = (ev: React.MouseEvent) => {
|
||||
if (this.props.disabled) return;
|
||||
|
||||
if (!this.state.expanded) {
|
||||
|
@ -178,24 +187,24 @@ export default class Dropdown extends React.Component {
|
|||
});
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_close() {
|
||||
private close() {
|
||||
this.setState({
|
||||
expanded: false,
|
||||
});
|
||||
// their focus was on the input, its getting unmounted, move it to the button
|
||||
if (this._button.current) {
|
||||
this._button.current.focus();
|
||||
if (this.buttonRef.current) {
|
||||
this.buttonRef.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
_onMenuOptionClick(dropdownKey) {
|
||||
this._close();
|
||||
private onMenuOptionClick = (dropdownKey: string) => {
|
||||
this.close();
|
||||
this.props.onOptionChange(dropdownKey);
|
||||
}
|
||||
};
|
||||
|
||||
_onInputKeyDown = (e) => {
|
||||
private onInputKeyDown = (e: React.KeyboardEvent) => {
|
||||
let handled = true;
|
||||
|
||||
// These keys don't generate keypress events and so needs to be on keyup
|
||||
|
@ -204,16 +213,16 @@ export default class Dropdown extends React.Component {
|
|||
this.props.onOptionChange(this.state.highlightedOption);
|
||||
// fallthrough
|
||||
case Key.ESCAPE:
|
||||
this._close();
|
||||
this.close();
|
||||
break;
|
||||
case Key.ARROW_DOWN:
|
||||
this.setState({
|
||||
highlightedOption: this._nextOption(this.state.highlightedOption),
|
||||
highlightedOption: this.nextOption(this.state.highlightedOption),
|
||||
});
|
||||
break;
|
||||
case Key.ARROW_UP:
|
||||
this.setState({
|
||||
highlightedOption: this._prevOption(this.state.highlightedOption),
|
||||
highlightedOption: this.prevOption(this.state.highlightedOption),
|
||||
});
|
||||
break;
|
||||
default:
|
||||
|
@ -224,53 +233,46 @@ export default class Dropdown extends React.Component {
|
|||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_onInputChange(e) {
|
||||
private onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({
|
||||
searchQuery: e.target.value,
|
||||
searchQuery: e.currentTarget.value,
|
||||
});
|
||||
if (this.props.onSearchChange) {
|
||||
this.props.onSearchChange(e.target.value);
|
||||
this.props.onSearchChange(e.currentTarget.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_collectRoot(e) {
|
||||
private collectRoot = (e: HTMLDivElement) => {
|
||||
if (this.dropdownRootElement) {
|
||||
this.dropdownRootElement.removeEventListener(
|
||||
'click', this._onRootClick, false,
|
||||
);
|
||||
this.dropdownRootElement.removeEventListener('click', this.onRootClick, false);
|
||||
}
|
||||
if (e) {
|
||||
e.addEventListener('click', this._onRootClick, false);
|
||||
e.addEventListener('click', this.onRootClick, false);
|
||||
}
|
||||
this.dropdownRootElement = e;
|
||||
}
|
||||
};
|
||||
|
||||
_collectInputTextBox(e) {
|
||||
this.inputTextBox = e;
|
||||
if (e) e.focus();
|
||||
}
|
||||
|
||||
_setHighlightedOption(optionKey) {
|
||||
private setHighlightedOption = (optionKey: string) => {
|
||||
this.setState({
|
||||
highlightedOption: optionKey,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_nextOption(optionKey) {
|
||||
private nextOption(optionKey: string): string {
|
||||
const keys = Object.keys(this.childrenByKey);
|
||||
const index = keys.indexOf(optionKey);
|
||||
return keys[(index + 1) % keys.length];
|
||||
}
|
||||
|
||||
_prevOption(optionKey) {
|
||||
private prevOption(optionKey: string): string {
|
||||
const keys = Object.keys(this.childrenByKey);
|
||||
const index = keys.indexOf(optionKey);
|
||||
return keys[(index - 1) % keys.length];
|
||||
}
|
||||
|
||||
_scrollIntoView(node) {
|
||||
private scrollIntoView(node: Element) {
|
||||
if (node) {
|
||||
node.scrollIntoView({
|
||||
block: "nearest",
|
||||
|
@ -279,18 +281,18 @@ export default class Dropdown extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
_getMenuOptions() {
|
||||
private getMenuOptions() {
|
||||
const options = React.Children.map(this.props.children, (child) => {
|
||||
const highlighted = this.state.highlightedOption === child.key;
|
||||
return (
|
||||
<MenuOption
|
||||
id={`${this.props.id}__${child.key}`}
|
||||
key={child.key}
|
||||
dropdownKey={child.key}
|
||||
dropdownKey={child.key as string}
|
||||
highlighted={highlighted}
|
||||
onMouseEnter={this._setHighlightedOption}
|
||||
onClick={this._onMenuOptionClick}
|
||||
inputRef={highlighted ? this._scrollIntoView : undefined}
|
||||
onMouseEnter={this.setHighlightedOption}
|
||||
onClick={this.onMenuOptionClick}
|
||||
inputRef={highlighted ? this.scrollIntoView : undefined}
|
||||
>
|
||||
{ child }
|
||||
</MenuOption>
|
||||
|
@ -307,7 +309,7 @@ export default class Dropdown extends React.Component {
|
|||
render() {
|
||||
let currentValue;
|
||||
|
||||
const menuStyle = {};
|
||||
const menuStyle: CSSProperties = {};
|
||||
if (this.props.menuWidth) menuStyle.width = this.props.menuWidth;
|
||||
|
||||
let menu;
|
||||
|
@ -316,10 +318,10 @@ export default class Dropdown extends React.Component {
|
|||
currentValue = (
|
||||
<input
|
||||
type="text"
|
||||
autoFocus={true}
|
||||
className="mx_Dropdown_option"
|
||||
ref={this._collectInputTextBox}
|
||||
onKeyDown={this._onInputKeyDown}
|
||||
onChange={this._onInputChange}
|
||||
onKeyDown={this.onInputKeyDown}
|
||||
onChange={this.onInputChange}
|
||||
value={this.state.searchQuery}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
|
@ -332,7 +334,7 @@ export default class Dropdown extends React.Component {
|
|||
}
|
||||
menu = (
|
||||
<div className="mx_Dropdown_menu" style={menuStyle} role="listbox" id={`${this.props.id}_listbox`}>
|
||||
{ this._getMenuOptions() }
|
||||
{ this.getMenuOptions() }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -356,14 +358,14 @@ export default class Dropdown extends React.Component {
|
|||
|
||||
// Note the menu sits inside the AccessibleButton div so it's anchored
|
||||
// to the input, but overflows below it. The root contains both.
|
||||
return <div className={classnames(dropdownClasses)} ref={this._collectRoot}>
|
||||
return <div className={classnames(dropdownClasses)} ref={this.collectRoot}>
|
||||
<AccessibleButton
|
||||
className="mx_Dropdown_input mx_no_textinput"
|
||||
onClick={this._onInputClick}
|
||||
onClick={this.onInputClick}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={this.state.expanded}
|
||||
disabled={this.props.disabled}
|
||||
inputRef={this._button}
|
||||
inputRef={this.buttonRef}
|
||||
aria-label={this.props.label}
|
||||
aria-describedby={`${this.props.id}_value`}
|
||||
>
|
||||
|
@ -374,28 +376,3 @@ export default class Dropdown extends React.Component {
|
|||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
Dropdown.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
// The width that the dropdown should be. If specified,
|
||||
// the dropped-down part of the menu will be set to this
|
||||
// width.
|
||||
menuWidth: PropTypes.number,
|
||||
// Called when the selected option changes
|
||||
onOptionChange: PropTypes.func.isRequired,
|
||||
// Called when the value of the search field changes
|
||||
onSearchChange: PropTypes.func,
|
||||
searchEnabled: PropTypes.bool,
|
||||
// Function that, given the key of an option, returns
|
||||
// a node representing that option to be displayed in the
|
||||
// box itself as the currently-selected option (ie. as
|
||||
// opposed to in the actual dropped-down part). If
|
||||
// unspecified, the appropriate child element is used as
|
||||
// in the dropped-down menu.
|
||||
getShortOption: PropTypes.func,
|
||||
value: PropTypes.string,
|
||||
// negative for consistency with HTML
|
||||
disabled: PropTypes.bool,
|
||||
// ARIA label
|
||||
label: PropTypes.string.isRequired,
|
||||
};
|
|
@ -22,6 +22,7 @@ import MemberAvatar from '../avatars/MemberAvatar';
|
|||
import { _t } from '../../../languageHandler';
|
||||
import { useStateToggle } from "../../../hooks/useStateToggle";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import { Layout } from '../../../settings/Layout';
|
||||
|
||||
interface IProps {
|
||||
// An array of member events to summarise
|
||||
|
@ -38,6 +39,8 @@ interface IProps {
|
|||
children: ReactNode[];
|
||||
// Called when the event list expansion is toggled
|
||||
onToggle?(): void;
|
||||
// The layout currently used
|
||||
layout?: Layout;
|
||||
}
|
||||
|
||||
const EventListSummary: React.FC<IProps> = ({
|
||||
|
@ -48,6 +51,7 @@ const EventListSummary: React.FC<IProps> = ({
|
|||
startExpanded,
|
||||
summaryMembers = [],
|
||||
summaryText,
|
||||
layout,
|
||||
}) => {
|
||||
const [expanded, toggleExpanded] = useStateToggle(startExpanded);
|
||||
|
||||
|
@ -63,7 +67,7 @@ const EventListSummary: React.FC<IProps> = ({
|
|||
// If we are only given few events then just pass them through
|
||||
if (events.length < threshold) {
|
||||
return (
|
||||
<li className="mx_EventListSummary" data-scroll-tokens={eventIds} data-expanded={true}>
|
||||
<li className="mx_EventListSummary" data-scroll-tokens={eventIds} data-expanded={true} data-layout={layout}>
|
||||
{ children }
|
||||
</li>
|
||||
);
|
||||
|
@ -92,7 +96,7 @@ const EventListSummary: React.FC<IProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<li className="mx_EventListSummary" data-scroll-tokens={eventIds} data-expanded={expanded + ""}>
|
||||
<li className="mx_EventListSummary" data-scroll-tokens={eventIds} data-expanded={expanded + ""} data-layout={layout}>
|
||||
<AccessibleButton className="mx_EventListSummary_toggle" onClick={toggleExpanded} aria-expanded={expanded}>
|
||||
{ expanded ? _t('collapse') : _t('expand') }
|
||||
</AccessibleButton>
|
||||
|
@ -103,6 +107,7 @@ const EventListSummary: React.FC<IProps> = ({
|
|||
|
||||
EventListSummary.defaultProps = {
|
||||
startExpanded: false,
|
||||
layout: Layout.Group,
|
||||
};
|
||||
|
||||
export default EventListSummary;
|
||||
|
|
|
@ -160,41 +160,80 @@ export default class ImageView extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
private zoom(delta: number) {
|
||||
const newZoom = this.state.zoom + delta;
|
||||
private zoomDelta(delta: number, anchorX?: number, anchorY?: number) {
|
||||
this.zoom(this.state.zoom + delta, anchorX, anchorY);
|
||||
}
|
||||
|
||||
private zoom(zoomLevel: number, anchorX?: number, anchorY?: number) {
|
||||
const oldZoom = this.state.zoom;
|
||||
const newZoom = Math.min(zoomLevel, this.state.maxZoom);
|
||||
|
||||
if (newZoom <= this.state.minZoom) {
|
||||
// Zoom out fully
|
||||
this.setState({
|
||||
zoom: this.state.minZoom,
|
||||
translationX: 0,
|
||||
translationY: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (newZoom >= this.state.maxZoom) {
|
||||
this.setState({ zoom: this.state.maxZoom });
|
||||
return;
|
||||
}
|
||||
} else if (typeof anchorX !== "number" && typeof anchorY !== "number") {
|
||||
// Zoom relative to the center of the view
|
||||
this.setState({
|
||||
zoom: newZoom,
|
||||
translationX: this.state.translationX * newZoom / oldZoom,
|
||||
translationY: this.state.translationY * newZoom / oldZoom,
|
||||
});
|
||||
} else {
|
||||
// Zoom relative to the given point on the image.
|
||||
// First we need to figure out the offset of the anchor point
|
||||
// relative to the center of the image, accounting for rotation.
|
||||
let offsetX;
|
||||
let offsetY;
|
||||
// The modulo operator can return negative values for some
|
||||
// rotations, so we have to do some extra work to normalize it
|
||||
switch (((this.state.rotation % 360) + 360) % 360) {
|
||||
case 0:
|
||||
offsetX = this.image.current.clientWidth / 2 - anchorX;
|
||||
offsetY = this.image.current.clientHeight / 2 - anchorY;
|
||||
break;
|
||||
case 90:
|
||||
offsetX = anchorY - this.image.current.clientHeight / 2;
|
||||
offsetY = this.image.current.clientWidth / 2 - anchorX;
|
||||
break;
|
||||
case 180:
|
||||
offsetX = anchorX - this.image.current.clientWidth / 2;
|
||||
offsetY = anchorY - this.image.current.clientHeight / 2;
|
||||
break;
|
||||
case 270:
|
||||
offsetX = this.image.current.clientHeight / 2 - anchorY;
|
||||
offsetY = anchorX - this.image.current.clientWidth / 2;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
zoom: newZoom,
|
||||
});
|
||||
// Apply the zoom and offset
|
||||
this.setState({
|
||||
zoom: newZoom,
|
||||
translationX: this.state.translationX + (newZoom - oldZoom) * offsetX,
|
||||
translationY: this.state.translationY + (newZoom - oldZoom) * offsetY,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private onWheel = (ev: WheelEvent) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (ev.target === this.image.current) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
const { deltaY } = normalizeWheelEvent(ev);
|
||||
this.zoom(-(deltaY * ZOOM_COEFFICIENT));
|
||||
const { deltaY } = normalizeWheelEvent(ev);
|
||||
// Zoom in on the point on the image targeted by the cursor
|
||||
this.zoomDelta(-deltaY * ZOOM_COEFFICIENT, ev.offsetX, ev.offsetY);
|
||||
}
|
||||
};
|
||||
|
||||
private onZoomInClick = () => {
|
||||
this.zoom(ZOOM_STEP);
|
||||
this.zoomDelta(ZOOM_STEP);
|
||||
};
|
||||
|
||||
private onZoomOutClick = () => {
|
||||
this.zoom(-ZOOM_STEP);
|
||||
this.zoomDelta(-ZOOM_STEP);
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: KeyboardEvent) => {
|
||||
|
@ -259,7 +298,7 @@ export default class ImageView extends React.Component<IProps, IState> {
|
|||
|
||||
// Zoom in if we are completely zoomed out
|
||||
if (this.state.zoom === this.state.minZoom) {
|
||||
this.setState({ zoom: this.state.maxZoom });
|
||||
this.zoom(this.state.maxZoom, ev.nativeEvent.offsetX, ev.nativeEvent.offsetY);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -289,11 +328,7 @@ export default class ImageView extends React.Component<IProps, IState> {
|
|||
Math.abs(this.state.translationX - this.previousX) < ZOOM_DISTANCE &&
|
||||
Math.abs(this.state.translationY - this.previousY) < ZOOM_DISTANCE
|
||||
) {
|
||||
this.setState({
|
||||
zoom: this.state.minZoom,
|
||||
translationX: 0,
|
||||
translationY: 0,
|
||||
});
|
||||
this.zoom(this.state.minZoom);
|
||||
this.initX = 0;
|
||||
this.initY = 0;
|
||||
}
|
||||
|
@ -384,7 +419,8 @@ export default class ImageView extends React.Component<IProps, IState> {
|
|||
const avatar = (
|
||||
<MemberAvatar
|
||||
member={mxEvent.sender}
|
||||
width={32} height={32}
|
||||
width={32}
|
||||
height={32}
|
||||
viewUserOnClick={true}
|
||||
/>
|
||||
);
|
||||
|
@ -403,7 +439,7 @@ export default class ImageView extends React.Component<IProps, IState> {
|
|||
// an empty div here, since the panel uses space-between
|
||||
// and we want the same placement of elements
|
||||
info = (
|
||||
<div></div>
|
||||
<div />
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -427,15 +463,15 @@ export default class ImageView extends React.Component<IProps, IState> {
|
|||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_zoomOut"
|
||||
title={_t("Zoom out")}
|
||||
onClick={this.onZoomOutClick}>
|
||||
</AccessibleTooltipButton>
|
||||
onClick={this.onZoomOutClick}
|
||||
/>
|
||||
);
|
||||
zoomInButton = (
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_zoomIn"
|
||||
title={_t("Zoom in")}
|
||||
onClick={this.onZoomInClick}>
|
||||
</AccessibleTooltipButton>
|
||||
onClick={this.onZoomInClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -457,24 +493,24 @@ export default class ImageView extends React.Component<IProps, IState> {
|
|||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_rotateCCW"
|
||||
title={_t("Rotate Left")}
|
||||
onClick={this.onRotateCounterClockwiseClick}>
|
||||
</AccessibleTooltipButton>
|
||||
onClick={this.onRotateCounterClockwiseClick}
|
||||
/>
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_rotateCW"
|
||||
title={_t("Rotate Right")}
|
||||
onClick={this.onRotateClockwiseClick}>
|
||||
</AccessibleTooltipButton>
|
||||
onClick={this.onRotateClockwiseClick}
|
||||
/>
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_download"
|
||||
title={_t("Download")}
|
||||
onClick={this.onDownloadClick}>
|
||||
</AccessibleTooltipButton>
|
||||
onClick={this.onDownloadClick}
|
||||
/>
|
||||
{ contextMenuButton }
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_close"
|
||||
title={_t("Close")}
|
||||
onClick={this.props.onFinished}>
|
||||
</AccessibleTooltipButton>
|
||||
onClick={this.props.onFinished}
|
||||
/>
|
||||
{ this.renderContextMenu() }
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -22,9 +22,16 @@ import Tooltip, { Alignment } from './Tooltip';
|
|||
import { _t } from "../../../languageHandler";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
export enum InfoTooltipKind {
|
||||
Info = "info",
|
||||
Warning = "warning",
|
||||
}
|
||||
|
||||
interface ITooltipProps {
|
||||
tooltip?: React.ReactNode;
|
||||
className?: string;
|
||||
tooltipClassName?: string;
|
||||
kind?: InfoTooltipKind;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -53,8 +60,12 @@ export default class InfoTooltip extends React.PureComponent<ITooltipProps, ISta
|
|||
};
|
||||
|
||||
render() {
|
||||
const { tooltip, children, tooltipClassName } = this.props;
|
||||
const { tooltip, children, tooltipClassName, className, kind } = this.props;
|
||||
const title = _t("Information");
|
||||
const iconClassName = (
|
||||
(kind !== InfoTooltipKind.Warning) ?
|
||||
"mx_InfoTooltip_icon_info" : "mx_InfoTooltip_icon_warning"
|
||||
);
|
||||
|
||||
// Tooltip are forced on the right for a more natural feel to them on info icons
|
||||
const tip = this.state.hover ? <Tooltip
|
||||
|
@ -64,8 +75,12 @@ export default class InfoTooltip extends React.PureComponent<ITooltipProps, ISta
|
|||
alignment={Alignment.Right}
|
||||
/> : <div />;
|
||||
return (
|
||||
<div onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} className="mx_InfoTooltip">
|
||||
<span className="mx_InfoTooltip_icon" aria-label={title} />
|
||||
<div
|
||||
onMouseOver={this.onMouseOver}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
className={classNames("mx_InfoTooltip", className)}
|
||||
>
|
||||
<span className={classNames("mx_InfoTooltip_icon", iconClassName)} aria-label={title} />
|
||||
{ children }
|
||||
{ tip }
|
||||
</div>
|
||||
|
|
68
src/components/views/elements/JoinRuleDropdown.tsx
Normal file
68
src/components/views/elements/JoinRuleDropdown.tsx
Normal file
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
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 from 'react';
|
||||
import { JoinRule } from 'matrix-js-sdk/src/@types/partials';
|
||||
|
||||
import Dropdown from "./Dropdown";
|
||||
|
||||
interface IProps {
|
||||
value: JoinRule;
|
||||
label: string;
|
||||
width?: number;
|
||||
labelInvite: string;
|
||||
labelPublic: string;
|
||||
labelRestricted?: string; // if omitted then this option will be hidden, e.g if unsupported
|
||||
onChange(value: JoinRule): void;
|
||||
}
|
||||
|
||||
const JoinRuleDropdown = ({
|
||||
label,
|
||||
labelInvite,
|
||||
labelPublic,
|
||||
labelRestricted,
|
||||
value,
|
||||
width = 448,
|
||||
onChange,
|
||||
}: IProps) => {
|
||||
const options = [
|
||||
<div key={JoinRule.Invite} className="mx_JoinRuleDropdown_invite">
|
||||
{ labelInvite }
|
||||
</div>,
|
||||
<div key={JoinRule.Public} className="mx_JoinRuleDropdown_public">
|
||||
{ labelPublic }
|
||||
</div>,
|
||||
];
|
||||
|
||||
if (labelRestricted) {
|
||||
options.unshift(<div key={JoinRule.Restricted} className="mx_JoinRuleDropdown_restricted">
|
||||
{ labelRestricted }
|
||||
</div>);
|
||||
}
|
||||
|
||||
return <Dropdown
|
||||
id="mx_JoinRuleDropdown"
|
||||
className="mx_JoinRuleDropdown"
|
||||
onOptionChange={onChange}
|
||||
menuWidth={width}
|
||||
value={value}
|
||||
label={label}
|
||||
>
|
||||
{ options }
|
||||
</Dropdown>;
|
||||
};
|
||||
|
||||
export default JoinRuleDropdown;
|
|
@ -25,12 +25,15 @@ import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
|
|||
import { isValid3pidInvite } from "../../../RoomInvite";
|
||||
import EventListSummary from "./EventListSummary";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { Layout } from '../../../settings/Layout';
|
||||
|
||||
interface IProps extends Omit<ComponentProps<typeof EventListSummary>, "summaryText" | "summaryMembers"> {
|
||||
// The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
|
||||
summaryLength?: number;
|
||||
// The maximum number of avatars to display in the summary
|
||||
avatarsMaxLength?: number;
|
||||
// The currently selected layout
|
||||
layout: Layout;
|
||||
}
|
||||
|
||||
interface IUserEvents {
|
||||
|
@ -67,6 +70,7 @@ export default class MemberEventListSummary extends React.Component<IProps> {
|
|||
summaryLength: 1,
|
||||
threshold: 3,
|
||||
avatarsMaxLength: 5,
|
||||
layout: Layout.Group,
|
||||
};
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
|
@ -453,6 +457,7 @@ export default class MemberEventListSummary extends React.Component<IProps> {
|
|||
startExpanded={this.props.startExpanded}
|
||||
children={this.props.children}
|
||||
summaryMembers={[...latestUserAvatarMember.values()]}
|
||||
layout={this.props.layout}
|
||||
summaryText={this.generateSummary(aggregate.names, orderedTransitionSequences)} />;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -92,7 +92,7 @@ const MiniAvatarUploader: React.FC<IProps> = ({ hasAvatar, hasAvatarLabel, noAva
|
|||
<div className="mx_MiniAvatarUploader_indicator">
|
||||
{ busy ?
|
||||
<Spinner w={20} h={20} /> :
|
||||
<div className="mx_MiniAvatarUploader_cameraIcon"></div> }
|
||||
<div className="mx_MiniAvatarUploader_cameraIcon" /> }
|
||||
</div>
|
||||
|
||||
<div className={classNames("mx_Tooltip", {
|
||||
|
|
|
@ -83,7 +83,7 @@ export default class PersistedElement extends React.Component {
|
|||
// for this, so we bodge it by listening for document resize and
|
||||
// the timeline_resize action.
|
||||
window.addEventListener('resize', this._repositionChild);
|
||||
this.dispatcherRef = dis.register(this._onAction);
|
||||
this._dispatcherRef = dis.register(this._onAction);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -192,7 +192,8 @@ class Pill extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
onUserPillClicked = () => {
|
||||
onUserPillClicked = (e) => {
|
||||
e.preventDefault();
|
||||
dis.dispatch({
|
||||
action: Action.ViewUser,
|
||||
member: this.state.member,
|
||||
|
@ -258,7 +259,10 @@ class Pill extends React.Component {
|
|||
linkText = groupId;
|
||||
if (this.props.shouldShowPillAvatar) {
|
||||
avatar = <BaseAvatar
|
||||
name={name || groupId} width={16} height={16} aria-hidden="true"
|
||||
name={name || groupId}
|
||||
width={16}
|
||||
height={16}
|
||||
aria-hidden="true"
|
||||
url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(16) : null} />;
|
||||
}
|
||||
pillClass = 'mx_GroupPill';
|
||||
|
|
|
@ -134,8 +134,10 @@ export default class PowerSelector extends React.Component {
|
|||
const label = typeof this.props.label === "undefined" ? _t("Power level") : this.props.label;
|
||||
if (this.state.custom) {
|
||||
picker = (
|
||||
<Field type="number"
|
||||
label={label} max={this.props.maxValue}
|
||||
<Field
|
||||
type="number"
|
||||
label={label}
|
||||
max={this.props.maxValue}
|
||||
onBlur={this.onCustomBlur}
|
||||
onKeyDown={this.onCustomKeyDown}
|
||||
onChange={this.onCustomChange}
|
||||
|
@ -157,9 +159,12 @@ export default class PowerSelector extends React.Component {
|
|||
});
|
||||
|
||||
picker = (
|
||||
<Field element="select"
|
||||
label={label} onChange={this.onSelectChange}
|
||||
value={String(this.state.selectValue)} disabled={this.props.disabled}
|
||||
<Field
|
||||
element="select"
|
||||
label={label}
|
||||
onChange={this.onSelectChange}
|
||||
value={String(this.state.selectValue)}
|
||||
disabled={this.props.disabled}
|
||||
>
|
||||
{ options }
|
||||
</Field>
|
||||
|
|
|
@ -14,17 +14,17 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import React, { ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import StyledRadioButton from "./StyledRadioButton";
|
||||
|
||||
interface IDefinition<T extends string> {
|
||||
export interface IDefinition<T extends string> {
|
||||
value: T;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
label: React.ReactChild;
|
||||
description?: React.ReactChild;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
checked?: boolean; // If provided it will override the value comparison done in the group
|
||||
}
|
||||
|
||||
|
@ -59,7 +59,7 @@ function StyledRadioGroup<T extends string>({
|
|||
checked={d.checked !== undefined ? d.checked : d.value === value}
|
||||
name={name}
|
||||
value={d.value}
|
||||
disabled={disabled || d.disabled}
|
||||
disabled={d.disabled ?? disabled}
|
||||
outlined={outlined}
|
||||
>
|
||||
{ d.label }
|
||||
|
|
|
@ -166,8 +166,7 @@ export default class Tooltip extends React.Component<IProps> {
|
|||
public render() {
|
||||
// Render a placeholder
|
||||
return (
|
||||
<div className={this.props.className}>
|
||||
</div>
|
||||
<div className={this.props.className} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -101,14 +101,16 @@ class Category extends React.PureComponent<IProps> {
|
|||
{ name }
|
||||
</h2>
|
||||
<LazyRenderList
|
||||
element="ul" className="mx_EmojiPicker_list"
|
||||
itemHeight={EMOJI_HEIGHT} items={rows}
|
||||
element="ul"
|
||||
className="mx_EmojiPicker_list"
|
||||
itemHeight={EMOJI_HEIGHT}
|
||||
items={rows}
|
||||
scrollTop={localScrollTop}
|
||||
height={localHeight}
|
||||
overflowItems={OVERFLOW_ROWS}
|
||||
overflowMargin={0}
|
||||
renderItem={this.renderEmojiRow}>
|
||||
</LazyRenderList>
|
||||
renderItem={this.renderEmojiRow}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -32,6 +32,8 @@ export const CATEGORY_HEADER_HEIGHT = 22;
|
|||
export const EMOJI_HEIGHT = 37;
|
||||
export const EMOJIS_PER_ROW = 8;
|
||||
|
||||
const ZERO_WIDTH_JOINER = "\u200D";
|
||||
|
||||
interface IProps {
|
||||
selectedEmojis?: Set<string>;
|
||||
showQuickReactions?: boolean;
|
||||
|
@ -180,7 +182,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
|||
} else {
|
||||
emojis = cat.id === "recent" ? this.recentlyUsed : DATA_BY_CATEGORY[cat.id];
|
||||
}
|
||||
emojis = emojis.filter(emoji => emoji.filterString.includes(filter));
|
||||
emojis = emojis.filter(emoji => this.emojiMatchesFilter(emoji, filter));
|
||||
this.memoizedDataByCategory[cat.id] = emojis;
|
||||
cat.enabled = emojis.length > 0;
|
||||
// The setState below doesn't re-render the header and we already have the refs for updateVisibility, so...
|
||||
|
@ -192,6 +194,10 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
|||
setTimeout(this.updateVisibility, 0);
|
||||
};
|
||||
|
||||
private emojiMatchesFilter = (emoji: IEmoji, filter: string): boolean =>
|
||||
[emoji.annotation, ...emoji.shortcodes, emoji.emoticon, ...emoji.unicode.split(ZERO_WIDTH_JOINER)]
|
||||
.some(x => x?.includes(filter));
|
||||
|
||||
private onEnterFilter = () => {
|
||||
const btn = this.bodyRef.current.querySelector<HTMLButtonElement>(".mx_EmojiPicker_item");
|
||||
if (btn) {
|
||||
|
|
|
@ -27,11 +27,7 @@ interface IProps {
|
|||
@replaceableComponent("views.emojipicker.Preview")
|
||||
class Preview extends React.PureComponent<IProps> {
|
||||
render() {
|
||||
const {
|
||||
unicode = "",
|
||||
annotation = "",
|
||||
shortcodes: [shortcode = ""],
|
||||
} = this.props.emoji || {};
|
||||
const { unicode, annotation, shortcodes: [shortcode] } = this.props.emoji;
|
||||
|
||||
return (
|
||||
<div className="mx_EmojiPicker_footer mx_EmojiPicker_preview">
|
||||
|
|
|
@ -86,10 +86,16 @@ export default class GroupMemberList extends React.Component {
|
|||
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
|
||||
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={this._showFullMemberList} />
|
||||
<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={this._showFullMemberList}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -152,7 +158,9 @@ export default class GroupMemberList extends React.Component {
|
|||
);
|
||||
});
|
||||
|
||||
return <TruncatedList className="mx_MemberList_wrapper" truncateAt={this.state.truncateAt}
|
||||
return <TruncatedList
|
||||
className="mx_MemberList_wrapper"
|
||||
truncateAt={this.state.truncateAt}
|
||||
createOverflowElement={this._createOverflowTile}
|
||||
>
|
||||
{ memberTiles }
|
||||
|
|
|
@ -56,14 +56,19 @@ export default class GroupMemberTile extends React.Component {
|
|||
aria-hidden="true"
|
||||
name={this.props.member.displayname || this.props.member.userId}
|
||||
idName={this.props.member.userId}
|
||||
width={36} height={36}
|
||||
width={36}
|
||||
height={36}
|
||||
url={avatarUrl}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<EntityTile name={name} avatarJsx={av} onClick={this.onClick}
|
||||
suppressOnHover={true} presenceState="online"
|
||||
<EntityTile
|
||||
name={name}
|
||||
avatarJsx={av}
|
||||
onClick={this.onClick}
|
||||
suppressOnHover={true}
|
||||
presenceState="online"
|
||||
powerStatus={this.props.member.isPrivileged ? EntityTile.POWER_STATUS_ADMIN : null}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -76,10 +76,16 @@ export default class GroupRoomList extends React.Component {
|
|||
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
|
||||
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={this._showFullRoomList} />
|
||||
<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={this._showFullRoomList}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -142,7 +148,8 @@ export default class GroupRoomList extends React.Component {
|
|||
}
|
||||
const inputBox = (
|
||||
<input
|
||||
className="mx_GroupRoomList_query mx_textinput" id="mx_GroupRoomList_query"
|
||||
className="mx_GroupRoomList_query mx_textinput"
|
||||
id="mx_GroupRoomList_query"
|
||||
type="text"
|
||||
onChange={this.onSearchQueryChanged}
|
||||
value={this.state.searchQuery}
|
||||
|
@ -156,8 +163,11 @@ export default class GroupRoomList extends React.Component {
|
|||
<div className="mx_GroupRoomList" role="tabpanel">
|
||||
{ inviteButton }
|
||||
<AutoHideScrollbar className="mx_GroupRoomList_joined mx_GroupRoomList_outerWrapper">
|
||||
<TruncatedList className="mx_GroupRoomList_wrapper" truncateAt={this.state.truncateAt}
|
||||
createOverflowElement={this._createOverflowTile}>
|
||||
<TruncatedList
|
||||
className="mx_GroupRoomList_wrapper"
|
||||
truncateAt={this.state.truncateAt}
|
||||
createOverflowElement={this._createOverflowTile}
|
||||
>
|
||||
{ this.makeGroupRoomTiles(this.state.searchQuery) }
|
||||
</TruncatedList>
|
||||
</AutoHideScrollbar>
|
||||
|
|
|
@ -48,8 +48,10 @@ class GroupRoomTile extends React.Component {
|
|||
: null;
|
||||
|
||||
const av = (
|
||||
<BaseAvatar name={this.props.groupRoom.displayname}
|
||||
width={36} height={36}
|
||||
<BaseAvatar
|
||||
name={this.props.groupRoom.displayname}
|
||||
width={36}
|
||||
height={36}
|
||||
url={avatarUrl}
|
||||
/>
|
||||
);
|
||||
|
|
244
src/components/views/messages/CallEvent.tsx
Normal file
244
src/components/views/messages/CallEvent.tsx
Normal file
|
@ -0,0 +1,244 @@
|
|||
/*
|
||||
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 React from 'react';
|
||||
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { _t, _td } from '../../../languageHandler';
|
||||
import MemberAvatar from '../avatars/MemberAvatar';
|
||||
import CallEventGrouper, { CallEventGrouperEvent, CustomCallState } from '../../structures/CallEventGrouper';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import { CallErrorCode, CallState } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip';
|
||||
import classNames from 'classnames';
|
||||
import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
callEventGrouper: CallEventGrouper;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
callState: CallState | CustomCallState;
|
||||
silenced: boolean;
|
||||
}
|
||||
|
||||
const TEXTUAL_STATES: Map<CallState | CustomCallState, string> = new Map([
|
||||
[CallState.Connected, _td("Connected")],
|
||||
[CallState.Connecting, _td("Connecting")],
|
||||
]);
|
||||
|
||||
export default class CallEvent extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
callState: this.props.callEventGrouper.state,
|
||||
silenced: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.callEventGrouper.addListener(CallEventGrouperEvent.StateChanged, this.onStateChanged);
|
||||
this.props.callEventGrouper.addListener(CallEventGrouperEvent.SilencedChanged, this.onSilencedChanged);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.callEventGrouper.removeListener(CallEventGrouperEvent.StateChanged, this.onStateChanged);
|
||||
this.props.callEventGrouper.removeListener(CallEventGrouperEvent.SilencedChanged, this.onSilencedChanged);
|
||||
}
|
||||
|
||||
private onSilencedChanged = (newState) => {
|
||||
this.setState({ silenced: newState });
|
||||
};
|
||||
|
||||
private onStateChanged = (newState: CallState) => {
|
||||
this.setState({ callState: newState });
|
||||
};
|
||||
|
||||
private renderCallBackButton(text: string): JSX.Element {
|
||||
return (
|
||||
<AccessibleButton
|
||||
className="mx_CallEvent_content_button mx_CallEvent_content_button_callBack"
|
||||
onClick={this.props.callEventGrouper.callBack}
|
||||
kind="primary"
|
||||
>
|
||||
<span> { text } </span>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
private renderContent(state: CallState | CustomCallState): JSX.Element {
|
||||
if (state === CallState.Ringing) {
|
||||
const silenceClass = classNames({
|
||||
"mx_CallEvent_iconButton": true,
|
||||
"mx_CallEvent_unSilence": this.state.silenced,
|
||||
"mx_CallEvent_silence": !this.state.silenced,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
<AccessibleTooltipButton
|
||||
className={silenceClass}
|
||||
onClick={this.props.callEventGrouper.toggleSilenced}
|
||||
title={this.state.silenced ? _t("Sound on"): _t("Silence call")}
|
||||
/>
|
||||
<AccessibleButton
|
||||
className="mx_CallEvent_content_button mx_CallEvent_content_button_reject"
|
||||
onClick={this.props.callEventGrouper.rejectCall}
|
||||
kind="danger"
|
||||
>
|
||||
<span> { _t("Decline") } </span>
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
className="mx_CallEvent_content_button mx_CallEvent_content_button_answer"
|
||||
onClick={this.props.callEventGrouper.answerCall}
|
||||
kind="primary"
|
||||
>
|
||||
<span> { _t("Accept") } </span>
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state === CallState.Ended) {
|
||||
const hangupReason = this.props.callEventGrouper.hangupReason;
|
||||
const gotRejected = this.props.callEventGrouper.gotRejected;
|
||||
|
||||
if (gotRejected) {
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
{ _t("Call declined") }
|
||||
{ this.renderCallBackButton(_t("Call back")) }
|
||||
</div>
|
||||
);
|
||||
} else if (([CallErrorCode.UserHangup, "user hangup"].includes(hangupReason) || !hangupReason)) {
|
||||
// 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)
|
||||
// Also, if we don't have a reason
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
{ _t("Call ended") }
|
||||
</div>
|
||||
);
|
||||
} else if (hangupReason === CallErrorCode.InviteTimeout) {
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
{ _t("Missed call") }
|
||||
{ this.renderCallBackButton(_t("Call back")) }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let reason;
|
||||
if (hangupReason === CallErrorCode.IceFailed) {
|
||||
// We couldn't establish a connection at all
|
||||
reason = _t("Could not connect media");
|
||||
} else if (hangupReason === "ice_timeout") {
|
||||
// We established a connection but it died
|
||||
reason = _t("Connection failed");
|
||||
} else if (hangupReason === CallErrorCode.NoUserMedia) {
|
||||
// The other side couldn't open capture devices
|
||||
reason = _t("Their device couldn't start the camera or microphone");
|
||||
} else if (hangupReason === "unknown_error") {
|
||||
// An error code the other side doesn't have a way to express
|
||||
// (as opposed to an error code they gave but we don't know about,
|
||||
// in which case we show the error code)
|
||||
reason = _t("An unknown error occurred");
|
||||
} else if (hangupReason === CallErrorCode.UserBusy) {
|
||||
reason = _t("The user you called is busy.");
|
||||
} else {
|
||||
reason = _t('Unknown failure: %(reason)s)', { reason: hangupReason });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
<InfoTooltip
|
||||
tooltip={reason}
|
||||
className="mx_CallEvent_content_tooltip"
|
||||
kind={InfoTooltipKind.Warning}
|
||||
/>
|
||||
{ _t("Connection failed") }
|
||||
{ this.renderCallBackButton(_t("Retry")) }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (Array.from(TEXTUAL_STATES.keys()).includes(state)) {
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
{ TEXTUAL_STATES.get(state) }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state === CustomCallState.Missed) {
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
{ _t("Missed call") }
|
||||
{ this.renderCallBackButton(_t("Call back")) }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
{ _t("The call is in an unknown state!") }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const event = this.props.mxEvent;
|
||||
const sender = event.sender ? event.sender.name : event.getSender();
|
||||
const isVoice = this.props.callEventGrouper.isVoice;
|
||||
const callType = isVoice ? _t("Voice call") : _t("Video call");
|
||||
const callState = this.state.callState;
|
||||
const hangupReason = this.props.callEventGrouper.hangupReason;
|
||||
const content = this.renderContent(callState);
|
||||
const className = classNames({
|
||||
mx_CallEvent: true,
|
||||
mx_CallEvent_voice: isVoice,
|
||||
mx_CallEvent_video: !isVoice,
|
||||
mx_CallEvent_missed: (
|
||||
callState === CustomCallState.Missed ||
|
||||
(callState === CallState.Ended && hangupReason === CallErrorCode.InviteTimeout)
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="mx_CallEvent_info">
|
||||
<MemberAvatar
|
||||
member={event.sender}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<div className="mx_CallEvent_info_basic">
|
||||
<div className="mx_CallEvent_sender">
|
||||
{ sender }
|
||||
</div>
|
||||
<div className="mx_CallEvent_type">
|
||||
<div className="mx_CallEvent_type_icon" />
|
||||
{ callType }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{ content }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
97
src/components/views/messages/DownloadActionButton.tsx
Normal file
97
src/components/views/messages/DownloadActionButton.tsx
Normal file
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
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 { MatrixEvent } from "matrix-js-sdk/src";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import React from "react";
|
||||
import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import classNames from "classnames";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { FileDownloader } from "../../../utils/FileDownloader";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
|
||||
// XXX: It can take a cycle or two for the MessageActionBar to have all the props/setup
|
||||
// required to get us a MediaEventHelper, so we use a getter function instead to prod for
|
||||
// one.
|
||||
mediaEventHelperGet: () => MediaEventHelper;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
loading: boolean;
|
||||
blob?: Blob;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.DownloadActionButton")
|
||||
export default class DownloadActionButton extends React.PureComponent<IProps, IState> {
|
||||
private downloader = new FileDownloader();
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
|
||||
private onDownloadClick = async () => {
|
||||
if (this.state.loading) return;
|
||||
|
||||
this.setState({ loading: true });
|
||||
|
||||
if (this.state.blob) {
|
||||
// Cheat and trigger a download, again.
|
||||
return this.doDownload();
|
||||
}
|
||||
|
||||
const blob = await this.props.mediaEventHelperGet().sourceBlob.value;
|
||||
this.setState({ blob });
|
||||
await this.doDownload();
|
||||
};
|
||||
|
||||
private async doDownload() {
|
||||
await this.downloader.download({
|
||||
blob: this.state.blob,
|
||||
name: this.props.mediaEventHelperGet().fileName,
|
||||
});
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
|
||||
public render() {
|
||||
let spinner: JSX.Element;
|
||||
if (this.state.loading) {
|
||||
spinner = <Spinner w={18} h={18} />;
|
||||
}
|
||||
|
||||
const classes = classNames({
|
||||
'mx_MessageActionBar_maskButton': true,
|
||||
'mx_MessageActionBar_downloadButton': true,
|
||||
'mx_MessageActionBar_downloadSpinnerButton': !!spinner,
|
||||
});
|
||||
|
||||
return <RovingAccessibleTooltipButton
|
||||
className={classes}
|
||||
title={spinner ? _t("Decrypting") : _t("Download")}
|
||||
onClick={this.onDownloadClick}
|
||||
disabled={!!spinner}
|
||||
>
|
||||
{ spinner }
|
||||
</RovingAccessibleTooltipButton>;
|
||||
}
|
||||
}
|
43
src/components/views/messages/IBodyProps.ts
Normal file
43
src/components/views/messages/IBodyProps.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
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 { MatrixEvent } from "matrix-js-sdk/src";
|
||||
import { TileShape } from "../rooms/EventTile";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import EditorStateTransfer from "../../../utils/EditorStateTransfer";
|
||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
|
||||
export interface IBodyProps {
|
||||
mxEvent: MatrixEvent;
|
||||
|
||||
/* a list of words to highlight */
|
||||
highlights: string[];
|
||||
|
||||
/* link URL for the highlights */
|
||||
highlightLink: string;
|
||||
|
||||
/* callback called when dynamic content in events are loaded */
|
||||
onHeightChanged: () => void;
|
||||
|
||||
showUrlPreview?: boolean;
|
||||
tileShape: TileShape;
|
||||
maxImageHeight?: number;
|
||||
replacingEventId?: string;
|
||||
editState?: EditorStateTransfer;
|
||||
onMessageAllowed: () => void; // TODO: Docs
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
mediaEventHelper: MediaEventHelper;
|
||||
}
|
21
src/components/views/messages/IMediaBody.ts
Normal file
21
src/components/views/messages/IMediaBody.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
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 { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
|
||||
export interface IMediaBody {
|
||||
getMediaHelper(): MediaEventHelper;
|
||||
}
|
|
@ -15,30 +15,24 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { Playback } from "../../../voice/Playback";
|
||||
import MFileBody from "./MFileBody";
|
||||
import { Playback } from "../../../audio/Playback";
|
||||
import InlineSpinner from '../elements/InlineSpinner';
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { mediaFromContent } from "../../../customisations/Media";
|
||||
import { decryptFile } from "../../../utils/DecryptFile";
|
||||
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
||||
import AudioPlayer from "../audio_messages/AudioPlayer";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
}
|
||||
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
||||
import MFileBody from "./MFileBody";
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
import { PlaybackManager } from "../../../audio/PlaybackManager";
|
||||
|
||||
interface IState {
|
||||
error?: Error;
|
||||
playback?: Playback;
|
||||
decryptedBlob?: Blob;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MAudioBody")
|
||||
export default class MAudioBody extends React.PureComponent<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
export default class MAudioBody extends React.PureComponent<IBodyProps, IState> {
|
||||
constructor(props: IBodyProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {};
|
||||
|
@ -46,33 +40,34 @@ export default class MAudioBody extends React.PureComponent<IProps, IState> {
|
|||
|
||||
public async componentDidMount() {
|
||||
let buffer: ArrayBuffer;
|
||||
const content: IMediaEventContent = this.props.mxEvent.getContent();
|
||||
const media = mediaFromContent(content);
|
||||
if (media.isEncrypted) {
|
||||
|
||||
try {
|
||||
try {
|
||||
const blob = await decryptFile(content.file);
|
||||
const blob = await this.props.mediaEventHelper.sourceBlob.value;
|
||||
buffer = await blob.arrayBuffer();
|
||||
this.setState({ decryptedBlob: blob });
|
||||
} catch (e) {
|
||||
this.setState({ error: e });
|
||||
console.warn("Unable to decrypt audio message", e);
|
||||
return; // stop processing the audio file
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer());
|
||||
} catch (e) {
|
||||
this.setState({ error: e });
|
||||
console.warn("Unable to download audio message", e);
|
||||
return; // stop processing the audio file
|
||||
}
|
||||
} catch (e) {
|
||||
this.setState({ error: e });
|
||||
console.warn("Unable to decrypt/download audio message", e);
|
||||
return; // stop processing the audio file
|
||||
}
|
||||
|
||||
// We should have a buffer to work with now: let's set it up
|
||||
const playback = new Playback(buffer);
|
||||
|
||||
// Note: we don't actually need a waveform to render an audio event, but voice messages do.
|
||||
const content = this.props.mxEvent.getContent<IMediaEventContent>();
|
||||
const waveform = content?.["org.matrix.msc1767.audio"]?.waveform?.map(p => p / 1024);
|
||||
|
||||
// We should have a buffer to work with now: let's set it up
|
||||
const playback = PlaybackManager.instance.createPlaybackInstance(buffer, waveform);
|
||||
playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent);
|
||||
this.setState({ playback });
|
||||
// Note: the RecordingPlayback component will handle preparing the Playback class for us.
|
||||
|
||||
// Note: the components later on will handle preparing the Playback class for us.
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
|
@ -81,7 +76,6 @@ export default class MAudioBody extends React.PureComponent<IProps, IState> {
|
|||
|
||||
public render() {
|
||||
if (this.state.error) {
|
||||
// TODO: @@TR: Verify error state
|
||||
return (
|
||||
<span className="mx_MAudioBody">
|
||||
<img src={require("../../../../res/img/warning.svg")} width="16" height="16" />
|
||||
|
@ -91,7 +85,6 @@ export default class MAudioBody extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
|
||||
if (!this.state.playback) {
|
||||
// TODO: @@TR: Verify loading/decrypting state
|
||||
return (
|
||||
<span className="mx_MAudioBody">
|
||||
<InlineSpinner />
|
||||
|
@ -103,7 +96,7 @@ export default class MAudioBody extends React.PureComponent<IProps, IState> {
|
|||
return (
|
||||
<span className="mx_MAudioBody">
|
||||
<AudioPlayer playback={this.state.playback} mediaName={this.props.mxEvent.getContent().body} />
|
||||
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
|
||||
{ this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -15,26 +15,31 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import filesize from 'filesize';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { decryptFile } from '../../../utils/DecryptFile';
|
||||
import Modal from '../../../Modal';
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { mediaFromContent } from "../../../customisations/Media";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import { TileShape } from "../rooms/EventTile";
|
||||
import { presentableTextForFile } from "../../../utils/FileUtils";
|
||||
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
import { FileDownloader } from "../../../utils/FileDownloader";
|
||||
import TextWithTooltip from "../elements/TextWithTooltip";
|
||||
|
||||
let downloadIconUrl; // cached copy of the download.svg asset for the sandboxed iframe later on
|
||||
export let DOWNLOAD_ICON_URL; // cached copy of the download.svg asset for the sandboxed iframe later on
|
||||
|
||||
async function cacheDownloadIcon() {
|
||||
if (downloadIconUrl) return; // cached already
|
||||
if (DOWNLOAD_ICON_URL) return; // cached already
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const svg = await fetch(require("../../../../res/img/download.svg")).then(r => r.text());
|
||||
downloadIconUrl = "data:image/svg+xml;base64," + window.btoa(svg);
|
||||
DOWNLOAD_ICON_URL = "data:image/svg+xml;base64," + window.btoa(svg);
|
||||
}
|
||||
|
||||
// Cache the asset immediately
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
cacheDownloadIcon();
|
||||
|
||||
// User supplied content can contain scripts, we have to be careful that
|
||||
|
@ -72,7 +77,7 @@ cacheDownloadIcon();
|
|||
* @param {HTMLElement} element The element to get the current style of.
|
||||
* @return {string} The CSS style encoded as a string.
|
||||
*/
|
||||
function computedStyle(element) {
|
||||
export function computedStyle(element: HTMLElement) {
|
||||
if (!element) {
|
||||
return "";
|
||||
}
|
||||
|
@ -90,175 +95,172 @@ function computedStyle(element) {
|
|||
return cssText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a human readable label for the file attachment to use as
|
||||
* link text.
|
||||
*
|
||||
* @param {Object} content The "content" key of the matrix event.
|
||||
* @param {boolean} withSize Whether to include size information. Default true.
|
||||
* @return {string} the human readable link text for the attachment.
|
||||
*/
|
||||
export function presentableTextForFile(content, withSize = true) {
|
||||
let linkText = _t("Attachment");
|
||||
if (content.body && content.body.length > 0) {
|
||||
// The content body should be the name of the file including a
|
||||
// file extension.
|
||||
linkText = content.body;
|
||||
}
|
||||
interface IProps extends IBodyProps {
|
||||
/* whether or not to show the default placeholder for the file. Defaults to true. */
|
||||
showGenericPlaceholder: boolean;
|
||||
}
|
||||
|
||||
if (content.info && content.info.size && withSize) {
|
||||
// If we know the size of the file then add it as human readable
|
||||
// string to the end of the link text so that the user knows how
|
||||
// big a file they are downloading.
|
||||
// The content.info also contains a MIME-type but we don't display
|
||||
// it since it is "ugly", users generally aren't aware what it
|
||||
// means and the type of the attachment can usually be inferrered
|
||||
// from the file extension.
|
||||
linkText += ' (' + filesize(content.info.size) + ')';
|
||||
}
|
||||
return linkText;
|
||||
interface IState {
|
||||
decryptedBlob?: Blob;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MFileBody")
|
||||
export default class MFileBody extends React.Component {
|
||||
static propTypes = {
|
||||
/* the MatrixEvent to show */
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
/* already decrypted blob */
|
||||
decryptedBlob: PropTypes.object,
|
||||
/* called when the download link iframe is shown */
|
||||
onHeightChanged: PropTypes.func,
|
||||
/* the shape of the tile, used */
|
||||
tileShape: PropTypes.string,
|
||||
/* whether or not to show the default placeholder for the file. Defaults to true. */
|
||||
showGenericPlaceholder: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default class MFileBody extends React.Component<IProps, IState> {
|
||||
static defaultProps = {
|
||||
showGenericPlaceholder: true,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
private iframe: React.RefObject<HTMLIFrameElement> = createRef();
|
||||
private dummyLink: React.RefObject<HTMLAnchorElement> = createRef();
|
||||
private userDidClick = false;
|
||||
private fileDownloader: FileDownloader = new FileDownloader(() => this.iframe.current);
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
decryptedBlob: (this.props.decryptedBlob ? this.props.decryptedBlob : null),
|
||||
};
|
||||
|
||||
this._iframe = createRef();
|
||||
this._dummyLink = createRef();
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
_getContentUrl() {
|
||||
private get content(): IMediaEventContent {
|
||||
return this.props.mxEvent.getContent<IMediaEventContent>();
|
||||
}
|
||||
|
||||
private get fileName(): string {
|
||||
return this.content.body && this.content.body.length > 0 ? this.content.body : _t("Attachment");
|
||||
}
|
||||
|
||||
private get linkText(): string {
|
||||
return presentableTextForFile(this.content);
|
||||
}
|
||||
|
||||
private downloadFile(fileName: string, text: string) {
|
||||
this.fileDownloader.download({
|
||||
blob: this.state.decryptedBlob,
|
||||
name: fileName,
|
||||
autoDownload: this.userDidClick,
|
||||
opts: {
|
||||
imgSrc: DOWNLOAD_ICON_URL,
|
||||
imgStyle: null,
|
||||
style: computedStyle(this.dummyLink.current),
|
||||
textContent: _t("Download %(text)s", { text }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private getContentUrl(): string {
|
||||
const media = mediaFromContent(this.props.mxEvent.getContent());
|
||||
return media.srcHttp;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
public componentDidUpdate(prevProps, prevState) {
|
||||
if (this.props.onHeightChanged && !prevState.decryptedBlob && this.state.decryptedBlob) {
|
||||
this.props.onHeightChanged();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
const text = presentableTextForFile(content);
|
||||
const isEncrypted = content.file !== undefined;
|
||||
const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment");
|
||||
const contentUrl = this._getContentUrl();
|
||||
const fileSize = content.info ? content.info.size : null;
|
||||
const fileType = content.info ? content.info.mimetype : "application/octet-stream";
|
||||
private decryptFile = async (): Promise<void> => {
|
||||
if (this.state.decryptedBlob) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.userDidClick = true;
|
||||
this.setState({
|
||||
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Unable to decrypt attachment: ", err);
|
||||
Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
|
||||
title: _t("Error"),
|
||||
description: _t("Error decrypting attachment"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let placeholder = null;
|
||||
private onPlaceholderClick = async () => {
|
||||
const mediaHelper = this.props.mediaEventHelper;
|
||||
if (mediaHelper.media.isEncrypted) {
|
||||
await this.decryptFile();
|
||||
this.downloadFile(this.fileName, this.linkText);
|
||||
} else {
|
||||
// As a button we're missing the `download` attribute for styling reasons, so
|
||||
// download with the file downloader.
|
||||
this.fileDownloader.download({
|
||||
blob: await mediaHelper.sourceBlob.value,
|
||||
name: this.fileName,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
public render() {
|
||||
const isEncrypted = this.props.mediaEventHelper.media.isEncrypted;
|
||||
const contentUrl = this.getContentUrl();
|
||||
const fileSize = this.content.info ? this.content.info.size : null;
|
||||
const fileType = this.content.info ? this.content.info.mimetype : "application/octet-stream";
|
||||
|
||||
let placeholder: React.ReactNode = null;
|
||||
if (this.props.showGenericPlaceholder) {
|
||||
placeholder = (
|
||||
<div className="mx_MFileBody_info">
|
||||
<AccessibleButton className="mx_MediaBody mx_MFileBody_info" onClick={this.onPlaceholderClick}>
|
||||
<span className="mx_MFileBody_info_icon" />
|
||||
<span className="mx_MFileBody_info_filename">
|
||||
{ presentableTextForFile(content, false) }
|
||||
</span>
|
||||
</div>
|
||||
<TextWithTooltip tooltip={presentableTextForFile(this.content, _t("Attachment"), true)}>
|
||||
<span className="mx_MFileBody_info_filename">
|
||||
{ presentableTextForFile(this.content, _t("Attachment"), true, true) }
|
||||
</span>
|
||||
</TextWithTooltip>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
const showDownloadLink = this.props.tileShape || !this.props.showGenericPlaceholder;
|
||||
|
||||
if (isEncrypted) {
|
||||
if (this.state.decryptedBlob === null) {
|
||||
if (!this.state.decryptedBlob) {
|
||||
// Need to decrypt the attachment
|
||||
// Wait for the user to click on the link before downloading
|
||||
// and decrypting the attachment.
|
||||
let decrypting = false;
|
||||
const decrypt = (e) => {
|
||||
if (decrypting) {
|
||||
return false;
|
||||
}
|
||||
decrypting = true;
|
||||
decryptFile(content.file).then((blob) => {
|
||||
this.setState({
|
||||
decryptedBlob: blob,
|
||||
});
|
||||
}).catch((err) => {
|
||||
console.warn("Unable to decrypt attachment: ", err);
|
||||
Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
|
||||
title: _t("Error"),
|
||||
description: _t("Error decrypting attachment"),
|
||||
});
|
||||
}).finally(() => {
|
||||
decrypting = false;
|
||||
});
|
||||
};
|
||||
|
||||
// This button should actually Download because usercontent/ will try to click itself
|
||||
// but it is not guaranteed between various browsers' settings.
|
||||
return (
|
||||
<span className="mx_MFileBody">
|
||||
{ placeholder }
|
||||
<div className="mx_MFileBody_download">
|
||||
<AccessibleButton onClick={decrypt}>
|
||||
{ _t("Decrypt %(text)s", { text: text }) }
|
||||
{ showDownloadLink && <div className="mx_MFileBody_download">
|
||||
<AccessibleButton onClick={this.decryptFile}>
|
||||
{ _t("Decrypt %(text)s", { text: this.linkText }) }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div> }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// When the iframe loads we tell it to render a download link
|
||||
const onIframeLoad = (ev) => {
|
||||
ev.target.contentWindow.postMessage({
|
||||
imgSrc: downloadIconUrl,
|
||||
imgStyle: null, // it handles this internally for us. Useful if a downstream changes the icon.
|
||||
style: computedStyle(this._dummyLink.current),
|
||||
blob: this.state.decryptedBlob,
|
||||
// Set a download attribute for encrypted files so that the file
|
||||
// will have the correct name when the user tries to download it.
|
||||
// We can't provide a Content-Disposition header like we would for HTTP.
|
||||
download: fileName,
|
||||
textContent: _t("Download %(text)s", { text: text }),
|
||||
// only auto-download if a user triggered this iframe explicitly
|
||||
auto: !this.props.decryptedBlob,
|
||||
}, "*");
|
||||
};
|
||||
|
||||
const url = "usercontent/"; // XXX: this path should probably be passed from the skin
|
||||
|
||||
// If the attachment is encrypted then put the link inside an iframe.
|
||||
return (
|
||||
<span className="mx_MFileBody">
|
||||
{ placeholder }
|
||||
<div className="mx_MFileBody_download">
|
||||
{ showDownloadLink && <div className="mx_MFileBody_download">
|
||||
<div style={{ display: "none" }}>
|
||||
{ /*
|
||||
* Add dummy copy of the "a" tag
|
||||
* We'll use it to learn how the download link
|
||||
* would have been styled if it was rendered inline.
|
||||
*/ }
|
||||
<a ref={this._dummyLink} />
|
||||
<a ref={this.dummyLink} />
|
||||
</div>
|
||||
{ /*
|
||||
TODO: Move iframe (and dummy link) into FileDownloader.
|
||||
We currently have it set up this way because of styles applied to the iframe
|
||||
itself which cannot be easily handled/overridden by the FileDownloader. In
|
||||
future, the download link may disappear entirely at which point it could also
|
||||
be suitable to just remove this bit of code.
|
||||
*/ }
|
||||
<iframe
|
||||
src={url}
|
||||
onLoad={onIframeLoad}
|
||||
ref={this._iframe}
|
||||
onLoad={() => this.downloadFile(this.fileName, this.linkText)}
|
||||
ref={this.iframe}
|
||||
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />
|
||||
</div>
|
||||
</div> }
|
||||
</span>
|
||||
);
|
||||
} else if (contentUrl) {
|
||||
|
@ -289,12 +291,12 @@ export default class MFileBody extends React.Component {
|
|||
|
||||
// Start a fetch for the download
|
||||
// Based upon https://stackoverflow.com/a/49500465
|
||||
fetch(contentUrl).then((response) => response.blob()).then((blob) => {
|
||||
this.props.mediaEventHelper.sourceBlob.value.then((blob) => {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// We have to create an anchor to download the file
|
||||
const tempAnchor = document.createElement('a');
|
||||
tempAnchor.download = fileName;
|
||||
tempAnchor.download = this.fileName;
|
||||
tempAnchor.href = blobUrl;
|
||||
document.body.appendChild(tempAnchor); // for firefox: https://stackoverflow.com/a/32226068
|
||||
tempAnchor.click();
|
||||
|
@ -303,41 +305,25 @@ export default class MFileBody extends React.Component {
|
|||
};
|
||||
} else {
|
||||
// Else we are hoping the browser will do the right thing
|
||||
downloadProps["download"] = fileName;
|
||||
downloadProps["download"] = this.fileName;
|
||||
}
|
||||
|
||||
// If the attachment is not encrypted then we check whether we
|
||||
// are being displayed in the room timeline or in a list of
|
||||
// files in the right hand side of the screen.
|
||||
if (this.props.tileShape === TileShape.FileGrid) {
|
||||
return (
|
||||
<span className="mx_MFileBody">
|
||||
{ placeholder }
|
||||
<div className="mx_MFileBody_download">
|
||||
<a className="mx_MFileBody_downloadLink" {...downloadProps}>
|
||||
{ fileName }
|
||||
</a>
|
||||
<div className="mx_MImageBody_size">
|
||||
{ content.info && content.info.size ? filesize(content.info.size) : "" }
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="mx_MFileBody">
|
||||
{ placeholder }
|
||||
<div className="mx_MFileBody_download">
|
||||
<a {...downloadProps}>
|
||||
<span className="mx_MFileBody_download_icon" />
|
||||
{ _t("Download %(text)s", { text: text }) }
|
||||
</a>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="mx_MFileBody">
|
||||
{ placeholder }
|
||||
{ showDownloadLink && <div className="mx_MFileBody_download">
|
||||
<a {...downloadProps}>
|
||||
<span className="mx_MFileBody_download_icon" />
|
||||
{ _t("Download %(text)s", { text: this.linkText }) }
|
||||
</a>
|
||||
{ this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size">
|
||||
{ this.content.info && this.content.info.size ? filesize(this.content.info.size) : "" }
|
||||
</div> }
|
||||
</div> }
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
const extra = text ? (': ' + text) : '';
|
||||
const extra = this.linkText ? (': ' + this.linkText) : '';
|
||||
return <span className="mx_MFileBody">
|
||||
{ placeholder }
|
||||
{ _t("Invalid file%(extra)s", { extra: extra }) }
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018, 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
@ -21,7 +20,6 @@ import { Blurhash } from "react-blurhash";
|
|||
|
||||
import MFileBody from './MFileBody';
|
||||
import Modal from '../../../Modal';
|
||||
import { decryptFile } from '../../../utils/DecryptFile';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
@ -29,24 +27,10 @@ import InlineSpinner from '../elements/InlineSpinner';
|
|||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { mediaFromContent } from "../../../customisations/Media";
|
||||
import { BLURHASH_FIELD } from "../../../ContentMessages";
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
|
||||
import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent';
|
||||
import ImageView from '../elements/ImageView';
|
||||
import { SyncState } from 'matrix-js-sdk/src/sync.api';
|
||||
|
||||
export interface IProps {
|
||||
/* the MatrixEvent to show */
|
||||
mxEvent: MatrixEvent;
|
||||
/* called when the image has loaded */
|
||||
onHeightChanged(): void;
|
||||
|
||||
/* the maximum image height to use */
|
||||
maxImageHeight?: number;
|
||||
|
||||
/* the permalinkCreator */
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
}
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
|
||||
interface IState {
|
||||
decryptedUrl?: string;
|
||||
|
@ -64,12 +48,12 @@ interface IState {
|
|||
}
|
||||
|
||||
@replaceableComponent("views.messages.MImageBody")
|
||||
export default class MImageBody extends React.Component<IProps, IState> {
|
||||
export default class MImageBody extends React.Component<IBodyProps, IState> {
|
||||
static contextType = MatrixClientContext;
|
||||
private unmounted = true;
|
||||
private image = createRef<HTMLImageElement>();
|
||||
|
||||
constructor(props: IProps) {
|
||||
constructor(props: IBodyProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
|
@ -257,38 +241,23 @@ export default class MImageBody extends React.Component<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
private downloadImage(): void {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
if (content.file !== undefined && this.state.decryptedUrl === null) {
|
||||
let thumbnailPromise = Promise.resolve(null);
|
||||
if (content.info && content.info.thumbnail_file) {
|
||||
thumbnailPromise = decryptFile(
|
||||
content.info.thumbnail_file,
|
||||
).then(function(blob) {
|
||||
return URL.createObjectURL(blob);
|
||||
private async downloadImage() {
|
||||
if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) {
|
||||
try {
|
||||
const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value;
|
||||
this.setState({
|
||||
decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value,
|
||||
decryptedThumbnailUrl: thumbnailUrl,
|
||||
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
|
||||
});
|
||||
}
|
||||
let decryptedBlob;
|
||||
thumbnailPromise.then((thumbnailUrl) => {
|
||||
return decryptFile(content.file).then(function(blob) {
|
||||
decryptedBlob = blob;
|
||||
return URL.createObjectURL(blob);
|
||||
}).then((contentUrl) => {
|
||||
if (this.unmounted) return;
|
||||
this.setState({
|
||||
decryptedUrl: contentUrl,
|
||||
decryptedThumbnailUrl: thumbnailUrl,
|
||||
decryptedBlob: decryptedBlob,
|
||||
});
|
||||
});
|
||||
}).catch((err) => {
|
||||
} catch (err) {
|
||||
if (this.unmounted) return;
|
||||
console.warn("Unable to decrypt attachment: ", err);
|
||||
// Set a placeholder image when we can't decrypt the image.
|
||||
this.setState({
|
||||
error: err,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -300,22 +269,15 @@ export default class MImageBody extends React.Component<IProps, IState> {
|
|||
localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true";
|
||||
|
||||
if (showImage) {
|
||||
// Don't download anything becaue we don't want to display anything.
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.downloadImage();
|
||||
this.setState({ showImage: true });
|
||||
}
|
||||
} // else don't download anything because we don't want to display anything.
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
this.context.removeListener('sync', this.onClientSync);
|
||||
|
||||
if (this.state.decryptedUrl) {
|
||||
URL.revokeObjectURL(this.state.decryptedUrl);
|
||||
}
|
||||
if (this.state.decryptedThumbnailUrl) {
|
||||
URL.revokeObjectURL(this.state.decryptedThumbnailUrl);
|
||||
}
|
||||
}
|
||||
|
||||
protected messageContent(
|
||||
|
@ -344,7 +306,10 @@ export default class MImageBody extends React.Component<IProps, IState> {
|
|||
imageElement = <HiddenImagePlaceholder />;
|
||||
} else {
|
||||
imageElement = (
|
||||
<img style={{ display: 'none' }} src={thumbUrl} ref={this.image}
|
||||
<img
|
||||
style={{ display: 'none' }}
|
||||
src={thumbUrl}
|
||||
ref={this.image}
|
||||
alt={content.body}
|
||||
onError={this.onImageError}
|
||||
onLoad={this.onImageLoad}
|
||||
|
@ -378,8 +343,11 @@ export default class MImageBody extends React.Component<IProps, IState> {
|
|||
// which has the same width as the timeline
|
||||
// mx_MImageBody_thumbnail resizes img to exactly container size
|
||||
img = (
|
||||
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref={this.image}
|
||||
style={{ maxWidth: maxWidth + "px" }}
|
||||
<img
|
||||
className="mx_MImageBody_thumbnail"
|
||||
src={thumbUrl}
|
||||
ref={this.image}
|
||||
style={{ maxWidth: `min(100%, ${maxWidth}px)` }}
|
||||
alt={content.body}
|
||||
onError={this.onImageError}
|
||||
onLoad={this.onImageLoad}
|
||||
|
@ -398,14 +366,15 @@ export default class MImageBody extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
const thumbnail = (
|
||||
<div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight + "px", maxWidth: maxWidth + "px" }} >
|
||||
{ /* Calculate aspect ratio, using %padding will size _container correctly */ }
|
||||
<div style={{ paddingBottom: forcedHeight ? (forcedHeight + "px") : ((100 * infoHeight / infoWidth) + '%') }} />
|
||||
<div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight, maxWidth: maxWidth, aspectRatio: `${infoWidth}/${infoHeight}` }}>
|
||||
{ showPlaceholder &&
|
||||
<div className="mx_MImageBody_thumbnail" style={{
|
||||
// Constrain width here so that spinner appears central to the loaded thumbnail
|
||||
maxWidth: infoWidth + "px",
|
||||
}}>
|
||||
<div
|
||||
className="mx_MImageBody_thumbnail"
|
||||
style={{
|
||||
// Constrain width here so that spinner appears central to the loaded thumbnail
|
||||
maxWidth: `min(100%, ${infoWidth}px)`,
|
||||
}}
|
||||
>
|
||||
{ placeholder }
|
||||
</div>
|
||||
}
|
||||
|
@ -444,8 +413,11 @@ export default class MImageBody extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
// Overidden by MStickerBody
|
||||
protected getFileBody(): JSX.Element {
|
||||
return <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />;
|
||||
protected getFileBody(): string | JSX.Element {
|
||||
// We only ever need the download bar if we're appearing outside of the timeline
|
||||
if (this.props.tileShape) {
|
||||
return <MFileBody {...this.props} showGenericPlaceholder={false} />;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
|
@ -453,10 +425,10 @@ export default class MImageBody extends React.Component<IProps, IState> {
|
|||
|
||||
if (this.state.error !== null) {
|
||||
return (
|
||||
<span className="mx_MImageBody">
|
||||
<div className="mx_MImageBody">
|
||||
<img src={require("../../../../res/img/warning.svg")} width="16" height="16" />
|
||||
{ _t("Error decrypting image") }
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -471,10 +443,10 @@ export default class MImageBody extends React.Component<IProps, IState> {
|
|||
const thumbnail = this.messageContent(contentUrl, thumbUrl, content);
|
||||
const fileBody = this.getFileBody();
|
||||
|
||||
return <span className="mx_MImageBody">
|
||||
return <div className="mx_MImageBody">
|
||||
{ thumbnail }
|
||||
{ fileBody }
|
||||
</span>;
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -489,7 +461,7 @@ export class HiddenImagePlaceholder extends React.PureComponent<PlaceholderIProp
|
|||
let className = 'mx_HiddenImagePlaceholder';
|
||||
if (this.props.hover) className += ' mx_HiddenImagePlaceholder_hover';
|
||||
return (
|
||||
<div className={className} style={{ maxWidth: maxWidth }}>
|
||||
<div className={className} style={{ maxWidth: `min(100%, ${maxWidth}px)` }}>
|
||||
<div className='mx_HiddenImagePlaceholder_button'>
|
||||
<span className='mx_HiddenImagePlaceholder_eye' />
|
||||
<span>{ _t("Show image") }</span>
|
||||
|
|
|
@ -16,9 +16,11 @@ limitations under the License.
|
|||
|
||||
import React from "react";
|
||||
import MImageBody from "./MImageBody";
|
||||
import { presentableTextForFile } from "./MFileBody";
|
||||
import { presentableTextForFile } from "../../../utils/FileUtils";
|
||||
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
||||
import SenderProfile from "./SenderProfile";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import { _t } from "../../../languageHandler";
|
||||
|
||||
const FORCED_IMAGE_HEIGHT = 44;
|
||||
|
||||
|
@ -32,8 +34,9 @@ export default class MImageReplyBody extends MImageBody {
|
|||
}
|
||||
|
||||
// Don't show "Download this_file.png ..."
|
||||
public getFileBody(): JSX.Element {
|
||||
return presentableTextForFile(this.props.mxEvent.getContent());
|
||||
public getFileBody(): string {
|
||||
const sticker = this.props.mxEvent.getType() === EventType.Sticker;
|
||||
return presentableTextForFile(this.props.mxEvent.getContent(), sticker ? _t("Sticker") : _t("Image"), !sticker);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
|
@ -23,16 +23,16 @@ import { BLURHASH_FIELD } from "../../../ContentMessages";
|
|||
@replaceableComponent("views.messages.MStickerBody")
|
||||
export default class MStickerBody extends MImageBody {
|
||||
// Mostly empty to prevent default behaviour of MImageBody
|
||||
onClick(ev) {
|
||||
protected onClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!this.state.showImage) {
|
||||
this.showImage();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// MStickerBody doesn't need a wrapping `<a href=...>`, but it does need extra padding
|
||||
// which is added by mx_MStickerBody_wrapper
|
||||
wrapImage(contentUrl, children) {
|
||||
protected wrapImage(contentUrl: string, children: React.ReactNode): JSX.Element {
|
||||
let onClick = null;
|
||||
if (!this.state.showImage) {
|
||||
onClick = this.onClick;
|
||||
|
@ -42,13 +42,13 @@ export default class MStickerBody extends MImageBody {
|
|||
|
||||
// Placeholder to show in place of the sticker image if
|
||||
// img onLoad hasn't fired yet.
|
||||
getPlaceholder(width, height) {
|
||||
protected getPlaceholder(width: number, height: number): JSX.Element {
|
||||
if (this.props.mxEvent.getContent().info[BLURHASH_FIELD]) return super.getPlaceholder(width, height);
|
||||
return <img src={require("../../../../res/img/icons-show-stickers.svg")} width="75" height="75" />;
|
||||
}
|
||||
|
||||
// Tooltip to show on mouse over
|
||||
getTooltip() {
|
||||
protected getTooltip(): JSX.Element {
|
||||
const content = this.props.mxEvent && this.props.mxEvent.getContent();
|
||||
|
||||
if (!content || !content.body || !content.info || !content.info.w) return null;
|
||||
|
@ -60,7 +60,7 @@ export default class MStickerBody extends MImageBody {
|
|||
}
|
||||
|
||||
// Don't show "Download this_file.png ..."
|
||||
getFileBody() {
|
||||
protected getFileBody() {
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -18,21 +17,15 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import { decode } from "blurhash";
|
||||
|
||||
import MFileBody from './MFileBody';
|
||||
import { decryptFile } from '../../../utils/DecryptFile';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import InlineSpinner from '../elements/InlineSpinner';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { mediaFromContent } from "../../../customisations/Media";
|
||||
import { BLURHASH_FIELD } from "../../../ContentMessages";
|
||||
|
||||
interface IProps {
|
||||
/* the MatrixEvent to show */
|
||||
mxEvent: any;
|
||||
/* called when the video has loaded */
|
||||
onHeightChanged: () => void;
|
||||
}
|
||||
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
import MFileBody from "./MFileBody";
|
||||
|
||||
interface IState {
|
||||
decryptedUrl?: string;
|
||||
|
@ -45,11 +38,12 @@ interface IState {
|
|||
}
|
||||
|
||||
@replaceableComponent("views.messages.MVideoBody")
|
||||
export default class MVideoBody extends React.PureComponent<IProps, IState> {
|
||||
export default class MVideoBody extends React.PureComponent<IBodyProps, IState> {
|
||||
private videoRef = React.createRef<HTMLVideoElement>();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
fetchingData: false,
|
||||
decryptedUrl: null,
|
||||
|
@ -97,7 +91,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
|
||||
private getThumbUrl(): string|null {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
const content = this.props.mxEvent.getContent<IMediaEventContent>();
|
||||
const media = mediaFromContent(content);
|
||||
|
||||
if (media.isEncrypted && this.state.decryptedThumbnailUrl) {
|
||||
|
@ -139,7 +133,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
|
|||
posterLoading: true,
|
||||
});
|
||||
|
||||
const content = this.props.mxEvent.getContent();
|
||||
const content = this.props.mxEvent.getContent<IMediaEventContent>();
|
||||
const media = mediaFromContent(content);
|
||||
if (media.hasThumbnail) {
|
||||
const image = new Image();
|
||||
|
@ -152,30 +146,22 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
|
|||
|
||||
async componentDidMount() {
|
||||
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean;
|
||||
const content = this.props.mxEvent.getContent();
|
||||
this.loadBlurhash();
|
||||
|
||||
if (content.file !== undefined && this.state.decryptedUrl === null) {
|
||||
let thumbnailPromise = Promise.resolve(null);
|
||||
if (content?.info?.thumbnail_file) {
|
||||
thumbnailPromise = decryptFile(content.info.thumbnail_file)
|
||||
.then(blob => URL.createObjectURL(blob));
|
||||
}
|
||||
|
||||
if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) {
|
||||
try {
|
||||
const thumbnailUrl = await thumbnailPromise;
|
||||
const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value;
|
||||
if (autoplay) {
|
||||
console.log("Preloading video");
|
||||
const decryptedBlob = await decryptFile(content.file);
|
||||
const contentUrl = URL.createObjectURL(decryptedBlob);
|
||||
this.setState({
|
||||
decryptedUrl: contentUrl,
|
||||
decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value,
|
||||
decryptedThumbnailUrl: thumbnailUrl,
|
||||
decryptedBlob: decryptedBlob,
|
||||
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
|
||||
});
|
||||
this.props.onHeightChanged();
|
||||
} else {
|
||||
console.log("NOT preloading video");
|
||||
const content = this.props.mxEvent.getContent<IMediaEventContent>();
|
||||
this.setState({
|
||||
// For Chrome and Electron, we need to set some non-empty `src` to
|
||||
// enable the play button. Firefox does not seem to care either
|
||||
|
@ -195,15 +181,6 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.state.decryptedUrl) {
|
||||
URL.revokeObjectURL(this.state.decryptedUrl);
|
||||
}
|
||||
if (this.state.decryptedThumbnailUrl) {
|
||||
URL.revokeObjectURL(this.state.decryptedThumbnailUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private videoOnPlay = async () => {
|
||||
if (this.hasContentUrl() || this.state.fetchingData || this.state.error) {
|
||||
// We have the file, we are fetching the file, or there is an error.
|
||||
|
@ -213,18 +190,15 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
|
|||
// To stop subsequent download attempts
|
||||
fetchingData: true,
|
||||
});
|
||||
const content = this.props.mxEvent.getContent();
|
||||
if (!content.file) {
|
||||
if (!this.props.mediaEventHelper.media.isEncrypted) {
|
||||
this.setState({
|
||||
error: "No file given in content",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const decryptedBlob = await decryptFile(content.file);
|
||||
const contentUrl = URL.createObjectURL(decryptedBlob);
|
||||
this.setState({
|
||||
decryptedUrl: contentUrl,
|
||||
decryptedBlob: decryptedBlob,
|
||||
decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value,
|
||||
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
|
||||
fetchingData: false,
|
||||
}, () => {
|
||||
if (!this.videoRef.current) return;
|
||||
|
@ -293,9 +267,8 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
|
|||
width={width}
|
||||
poster={poster}
|
||||
onPlay={this.videoOnPlay}
|
||||
>
|
||||
</video>
|
||||
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
|
||||
/>
|
||||
{ this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -15,76 +15,18 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { Playback } from "../../../voice/Playback";
|
||||
import MFileBody from "./MFileBody";
|
||||
import InlineSpinner from '../elements/InlineSpinner';
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { mediaFromContent } from "../../../customisations/Media";
|
||||
import { decryptFile } from "../../../utils/DecryptFile";
|
||||
import RecordingPlayback from "../audio_messages/RecordingPlayback";
|
||||
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
||||
import { TileShape } from "../rooms/EventTile";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
tileShape?: TileShape;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
error?: Error;
|
||||
playback?: Playback;
|
||||
decryptedBlob?: Blob;
|
||||
}
|
||||
import MAudioBody from "./MAudioBody";
|
||||
import MFileBody from "./MFileBody";
|
||||
|
||||
@replaceableComponent("views.messages.MVoiceMessageBody")
|
||||
export default class MVoiceMessageBody extends React.PureComponent<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
public async componentDidMount() {
|
||||
let buffer: ArrayBuffer;
|
||||
const content: IMediaEventContent = this.props.mxEvent.getContent();
|
||||
const media = mediaFromContent(content);
|
||||
if (media.isEncrypted) {
|
||||
try {
|
||||
const blob = await decryptFile(content.file);
|
||||
buffer = await blob.arrayBuffer();
|
||||
this.setState({ decryptedBlob: blob });
|
||||
} catch (e) {
|
||||
this.setState({ error: e });
|
||||
console.warn("Unable to decrypt voice message", e);
|
||||
return; // stop processing the audio file
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer());
|
||||
} catch (e) {
|
||||
this.setState({ error: e });
|
||||
console.warn("Unable to download voice message", e);
|
||||
return; // stop processing the audio file
|
||||
}
|
||||
}
|
||||
|
||||
const waveform = content?.["org.matrix.msc1767.audio"]?.waveform?.map(p => p / 1024);
|
||||
|
||||
// We should have a buffer to work with now: let's set it up
|
||||
const playback = new Playback(buffer, waveform);
|
||||
this.setState({ playback });
|
||||
// Note: the RecordingPlayback component will handle preparing the Playback class for us.
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.state.playback?.destroy();
|
||||
}
|
||||
|
||||
export default class MVoiceMessageBody extends MAudioBody {
|
||||
// A voice message is an audio file but rendered in a special way.
|
||||
public render() {
|
||||
if (this.state.error) {
|
||||
// TODO: @@TR: Verify error state
|
||||
return (
|
||||
<span className="mx_MVoiceMessageBody">
|
||||
<img src={require("../../../../res/img/warning.svg")} width="16" height="16" />
|
||||
|
@ -94,7 +36,6 @@ export default class MVoiceMessageBody extends React.PureComponent<IProps, IStat
|
|||
}
|
||||
|
||||
if (!this.state.playback) {
|
||||
// TODO: @@TR: Verify loading/decrypting state
|
||||
return (
|
||||
<span className="mx_MVoiceMessageBody">
|
||||
<InlineSpinner />
|
||||
|
@ -106,7 +47,7 @@ export default class MVoiceMessageBody extends React.PureComponent<IProps, IStat
|
|||
return (
|
||||
<span className="mx_MVoiceMessageBody">
|
||||
<RecordingPlayback playback={this.state.playback} tileShape={this.props.tileShape} />
|
||||
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
|
||||
{ this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -15,24 +15,18 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import MAudioBody from "./MAudioBody";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import MVoiceMessageBody from "./MVoiceMessageBody";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
}
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
|
||||
@replaceableComponent("views.messages.MVoiceOrAudioBody")
|
||||
export default class MVoiceOrAudioBody extends React.PureComponent<IProps> {
|
||||
export default class MVoiceOrAudioBody extends React.PureComponent<IBodyProps> {
|
||||
public render() {
|
||||
// MSC2516 is a legacy identifier. See https://github.com/matrix-org/matrix-doc/pull/3245
|
||||
const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice']
|
||||
|| !!this.props.mxEvent.getContent()['org.matrix.msc3245.voice'];
|
||||
const voiceMessagesEnabled = SettingsStore.getValue("feature_voice_messages");
|
||||
if (isVoiceMessage && voiceMessagesEnabled) {
|
||||
if (isVoiceMessage) {
|
||||
return <MVoiceMessageBody {...this.props} />;
|
||||
} else {
|
||||
return <MAudioBody {...this.props} />;
|
||||
|
|
|
@ -32,6 +32,8 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
|
|||
import { canCancel } from "../context_menus/MessageContextMenu";
|
||||
import Resend from "../../../Resend";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import DownloadActionButton from "./DownloadActionButton";
|
||||
|
||||
const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => {
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||
|
@ -267,6 +269,15 @@ export default class MessageActionBar extends React.PureComponent {
|
|||
key="react"
|
||||
/>);
|
||||
}
|
||||
|
||||
// XXX: Assuming that the underlying tile will be a media event if it is eligible media.
|
||||
if (MediaEventHelper.isEligible(this.props.mxEvent)) {
|
||||
toolbarOpts.splice(0, 0, <DownloadActionButton
|
||||
mxEvent={this.props.mxEvent}
|
||||
mediaEventHelperGet={() => this.props.getTile?.().getMediaHelper?.()}
|
||||
key="download"
|
||||
/>);
|
||||
}
|
||||
}
|
||||
|
||||
if (allowCancel) {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue