Merge remote-tracking branch 'upstream/develop' into feature-surround-with

This commit is contained in:
Šimon Brandner 2021-08-04 18:14:38 +02:00
commit b99e39e011
No known key found for this signature in database
GPG key ID: CC823428E9B582FB
383 changed files with 13499 additions and 6309 deletions

View file

@ -14,9 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Playback, PlaybackState } from "../../../voice/Playback";
import React, { createRef, ReactNode, RefObject } from "react";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import PlayPauseButton from "./PlayPauseButton";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { formatBytes } from "../../../utils/FormattingUtils";
@ -25,44 +23,13 @@ import { Key } from "../../../Keyboard";
import { _t } from "../../../languageHandler";
import SeekBar from "./SeekBar";
import PlaybackClock from "./PlaybackClock";
interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create
// an all-new component instead.
playback: Playback;
mediaName: string;
}
interface IState {
playbackPhase: PlaybackState;
}
import AudioPlayerBase from "./AudioPlayerBase";
@replaceableComponent("views.audio_messages.AudioPlayer")
export default class AudioPlayer extends React.PureComponent<IProps, IState> {
export default class AudioPlayer extends AudioPlayerBase {
private playPauseRef: RefObject<PlayPauseButton> = createRef();
private seekRef: RefObject<SeekBar> = createRef();
constructor(props: IProps) {
super(props);
this.state = {
playbackPhase: PlaybackState.Decoding, // default assumption
};
// We don't need to de-register: the class handles this for us internally
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
// Don't wait for the promise to complete - it will emit a progress update when it
// is done, and it's not meant to take long anyhow.
// noinspection JSIgnoredPromiseFromCall
this.props.playback.prepare();
}
private onPlaybackUpdate = (ev: PlaybackState) => {
this.setState({ playbackPhase: ev });
};
private onKeyDown = (ev: React.KeyboardEvent) => {
// stopPropagation() prevents the FocusComposer catch-all from triggering,
// but we need to do it on key down instead of press (even though the user
@ -88,37 +55,39 @@ export default class AudioPlayer extends React.PureComponent<IProps, IState> {
return `(${formatBytes(bytes)})`;
}
public render(): ReactNode {
protected renderComponent(): ReactNode {
// tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard
// events for accessibility
return <div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}>
<div className='mx_AudioPlayer_primaryContainer'>
<PlayPauseButton
playback={this.props.playback}
playbackPhase={this.state.playbackPhase}
tabIndex={-1} // prevent tabbing into the button
ref={this.playPauseRef}
/>
<div className='mx_AudioPlayer_mediaInfo'>
<span className='mx_AudioPlayer_mediaName'>
{ this.props.mediaName || _t("Unnamed audio") }
</span>
<div className='mx_AudioPlayer_byline'>
<DurationClock playback={this.props.playback} />
&nbsp; { /* easiest way to introduce a gap between the components */ }
{ this.renderFileSize() }
return (
<div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}>
<div className='mx_AudioPlayer_primaryContainer'>
<PlayPauseButton
playback={this.props.playback}
playbackPhase={this.state.playbackPhase}
tabIndex={-1} // prevent tabbing into the button
ref={this.playPauseRef}
/>
<div className='mx_AudioPlayer_mediaInfo'>
<span className='mx_AudioPlayer_mediaName'>
{ this.props.mediaName || _t("Unnamed audio") }
</span>
<div className='mx_AudioPlayer_byline'>
<DurationClock playback={this.props.playback} />
&nbsp; { /* easiest way to introduce a gap between the components */ }
{ this.renderFileSize() }
</div>
</div>
</div>
<div className='mx_AudioPlayer_seek'>
<SeekBar
playback={this.props.playback}
tabIndex={-1} // prevent tabbing into the bar
playbackPhase={this.state.playbackPhase}
ref={this.seekRef}
/>
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />
</div>
</div>
<div className='mx_AudioPlayer_seek'>
<SeekBar
playback={this.props.playback}
tabIndex={-1} // prevent tabbing into the bar
playbackPhase={this.state.playbackPhase}
ref={this.seekRef}
/>
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />
</div>
</div>;
);
}
}

View file

@ -0,0 +1,70 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Playback, PlaybackState } from "../../../audio/Playback";
import { TileShape } from "../rooms/EventTile";
import React, { ReactNode } from "react";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { _t } from "../../../languageHandler";
interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create
// an all-new component instead.
playback: Playback;
mediaName?: string;
tileShape?: TileShape;
}
interface IState {
playbackPhase: PlaybackState;
error?: boolean;
}
@replaceableComponent("views.audio_messages.AudioPlayerBase")
export default abstract class AudioPlayerBase extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
playbackPhase: PlaybackState.Decoding, // default assumption
};
// We don't need to de-register: the class handles this for us internally
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
// Don't wait for the promise to complete - it will emit a progress update when it
// is done, and it's not meant to take long anyhow.
this.props.playback.prepare().catch(e => {
console.error("Error processing audio file:", e);
this.setState({ error: true });
});
}
private onPlaybackUpdate = (ev: PlaybackState) => {
this.setState({ playbackPhase: ev });
};
protected abstract renderComponent(): ReactNode;
public render(): ReactNode {
return <>
{ this.renderComponent() }
{ this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
</>;
}
}

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Clock from "./Clock";
import { Playback } from "../../../voice/Playback";
import { Playback } from "../../../audio/Playback";
interface IProps {
playback: Playback;

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import React from "react";
import { IRecordingUpdate, VoiceRecording } from "../../../voice/VoiceRecording";
import { IRecordingUpdate, VoiceRecording } from "../../../audio/VoiceRecording";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Clock from "./Clock";
import { MarkedExecution } from "../../../utils/MarkedExecution";

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import React from "react";
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../voice/VoiceRecording";
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { arrayFastResample } from "../../../utils/arrays";
import { percentageOf } from "../../../utils/numbers";

View file

@ -18,7 +18,7 @@ import React, { ReactNode } from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { _t } from "../../../languageHandler";
import { Playback, PlaybackState } from "../../../voice/Playback";
import { Playback, PlaybackState } from "../../../audio/Playback";
import classNames from "classnames";
// omitted props are handled by render function

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Clock from "./Clock";
import { Playback, PlaybackState } from "../../../voice/Playback";
import { Playback, PlaybackState } from "../../../audio/Playback";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
interface IProps {

View file

@ -18,7 +18,7 @@ import React from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { arraySeed, arrayTrimFill } from "../../../utils/arrays";
import Waveform from "./Waveform";
import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../voice/Playback";
import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../audio/Playback";
import { percentageOf } from "../../../utils/numbers";
interface IProps {

View file

@ -14,61 +14,30 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Playback, PlaybackState } from "../../../voice/Playback";
import React, { ReactNode } from "react";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import PlayPauseButton from "./PlayPauseButton";
import PlaybackClock from "./PlaybackClock";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { TileShape } from "../rooms/EventTile";
import PlaybackWaveform from "./PlaybackWaveform";
interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create
// an all-new component instead.
playback: Playback;
tileShape?: TileShape;
}
interface IState {
playbackPhase: PlaybackState;
}
import AudioPlayerBase from "./AudioPlayerBase";
@replaceableComponent("views.audio_messages.RecordingPlayback")
export default class RecordingPlayback extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
playbackPhase: PlaybackState.Decoding, // default assumption
};
// We don't need to de-register: the class handles this for us internally
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
// Don't wait for the promise to complete - it will emit a progress update when it
// is done, and it's not meant to take long anyhow.
// noinspection JSIgnoredPromiseFromCall
this.props.playback.prepare();
}
export default class RecordingPlayback extends AudioPlayerBase {
private get isWaveformable(): boolean {
return this.props.tileShape !== TileShape.Notif
&& this.props.tileShape !== TileShape.FileGrid
&& this.props.tileShape !== TileShape.Pinned;
}
private onPlaybackUpdate = (ev: PlaybackState) => {
this.setState({ playbackPhase: ev });
};
public render(): ReactNode {
protected renderComponent(): ReactNode {
const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : '';
return <div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}>
<PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
<PlaybackClock playback={this.props.playback} />
{ this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> }
</div>;
return (
<div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}>
<PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
<PlaybackClock playback={this.props.playback} />
{ this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> }
</div>
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -182,14 +182,23 @@ export class SendCustomEvent extends GenericEditor<ISendCustomEventProps, ISendC
<br />
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" />
<Field
id="evContent"
label={_t("Event Content")}
type="text"
className="mx_DevTools_textarea"
autoComplete="off"
value={this.state.evContent}
onChange={this.onChange}
element="textarea" />
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> }
{ showTglFlip && <div style={{ float: "right" }}>
<input id="isStateEvent" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
<input
id="isStateEvent"
className="mx_DevTools_tgl mx_DevTools_tgl-flip"
type="checkbox"
checked={this.state.isStateEvent}
onChange={this.onChange}
@ -282,14 +291,24 @@ class SendAccountData extends GenericEditor<ISendAccountDataProps, ISendAccountD
{ this.textInput('eventType', _t('Event Type')) }
<br />
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" />
<Field
id="evContent"
label={_t("Event Content")}
type="text"
className="mx_DevTools_textarea"
autoComplete="off"
value={this.state.evContent}
onChange={this.onChange}
element="textarea"
/>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> }
{ !this.state.message && <div style={{ float: "right" }}>
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
<input
id="isRoomAccountData"
className="mx_DevTools_tgl mx_DevTools_tgl-flip"
type="checkbox"
checked={this.state.isRoomAccountData}
disabled={this.props.forceMode}
@ -371,11 +390,18 @@ class FilteredList extends React.PureComponent<IFilteredListProps, IFilteredList
render() {
return <div>
<Field label={_t('Filter results')} autoFocus={true} size={64}
type="text" autoComplete="off" value={this.props.query} onChange={this.onQuery}
<Field
label={_t('Filter results')}
autoFocus={true}
size={64}
type="text"
autoComplete="off"
value={this.props.query}
onChange={this.onQuery}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
// force re-render so that autoFocus is applied when this component is re-used
key={this.props.children[0] ? this.props.children[0].key : ''} />
key={this.props.children[0] ? this.props.children[0].key : ''}
/>
<TruncatedList getChildren={this.getChildren}
getChildCount={this.getChildCount}
@ -459,11 +485,16 @@ class RoomStateExplorer extends React.PureComponent<IExplorerProps, IRoomStateEx
render() {
if (this.state.event) {
if (this.state.editing) {
return <SendCustomEvent room={this.props.room} forceStateEvent={true} onBack={this.onBack} inputs={{
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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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!") }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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