Merge remote-tracking branch 'origin/develop' into jryans/room-view-crypto-crash

This commit is contained in:
J. Ryan Stinnett 2020-07-02 13:46:50 +01:00
commit 209b386e23
62 changed files with 3025 additions and 865 deletions

View file

@ -205,6 +205,11 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
"mx_LeftPanel2_minimized": this.props.isMinimized,
});
const roomListClasses = classNames(
"mx_LeftPanel2_actualRoomListContainer",
"mx_AutoHideScrollbar",
);
return (
<div className={containerClasses}>
{tagPanel}
@ -212,7 +217,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
{this.renderHeader()}
{this.renderSearchExplore()}
<div
className="mx_LeftPanel2_actualRoomListContainer"
className={roomListClasses}
onScroll={this.onScroll}
ref={this.listContainerRef}
>{roomList}</div>

View file

@ -1931,11 +1931,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
getFragmentAfterLogin() {
let fragmentAfterLogin = "";
if (this.props.initialScreenAfterLogin &&
const initialScreenAfterLogin = this.props.initialScreenAfterLogin;
if (initialScreenAfterLogin &&
// XXX: workaround for https://github.com/vector-im/riot-web/issues/11643 causing a login-loop
!["welcome", "login", "register"].includes(this.props.initialScreenAfterLogin.screen)
!["welcome", "login", "register", "start_sso", "start_cas"].includes(initialScreenAfterLogin.screen)
) {
fragmentAfterLogin = `/${this.props.initialScreenAfterLogin.screen}`;
fragmentAfterLogin = `/${initialScreenAfterLogin.screen}`;
}
return fragmentAfterLogin;
}

View file

@ -388,8 +388,11 @@ export default class MessagePanel extends React.Component {
}
return (
<li key={"readMarker_"+eventId} ref={this._readMarkerNode}
className="mx_RoomView_myReadMarker_container">
<li key={"readMarker_"+eventId}
ref={this._readMarkerNode}
className="mx_RoomView_myReadMarker_container"
data-scroll-tokens={eventId}
>
{ hr }
</li>
);

View file

@ -191,12 +191,10 @@ export default class UserMenu extends React.Component<IProps, IState> {
let homeButton = null;
if (this.hasHomePage) {
homeButton = (
<li>
<AccessibleButton onClick={this.onHomeClick}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconHome" />
<span>{_t("Home")}</span>
</AccessibleButton>
</li>
<AccessibleButton onClick={this.onHomeClick}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconHome" />
<span>{_t("Home")}</span>
</AccessibleButton>
);
}
@ -204,7 +202,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
return (
<ContextMenu
chevronFace="none"
left={elementRect.width + elementRect.left}
// -20 to overlap the context menu by just over the width of the `...` icon and make it look connected
left={elementRect.width + elementRect.left - 20}
top={elementRect.top + elementRect.height}
onFinished={this.onCloseMenu}
>
@ -232,49 +231,33 @@ export default class UserMenu extends React.Component<IProps, IState> {
</div>
{hostingLink}
<div className="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst">
<ul>
{homeButton}
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconBell" />
<span>{_t("Notification settings")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconLock" />
<span>{_t("Security & privacy")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, null)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSettings" />
<span>{_t("All settings")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={this.onShowArchived}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconArchive" />
<span>{_t("Archived rooms")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={this.onProvideFeedback}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconMessage" />
<span>{_t("Feedback")}</span>
</AccessibleButton>
</li>
</ul>
{homeButton}
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconBell" />
<span className="mx_IconizedContextMenu_label">{_t("Notification settings")}</span>
</AccessibleButton>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconLock" />
<span className="mx_IconizedContextMenu_label">{_t("Security & privacy")}</span>
</AccessibleButton>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, null)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSettings" />
<span className="mx_IconizedContextMenu_label">{_t("All settings")}</span>
</AccessibleButton>
<AccessibleButton onClick={this.onShowArchived}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconArchive" />
<span className="mx_IconizedContextMenu_label">{_t("Archived rooms")}</span>
</AccessibleButton>
<AccessibleButton onClick={this.onProvideFeedback}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconMessage" />
<span className="mx_IconizedContextMenu_label">{_t("Feedback")}</span>
</AccessibleButton>
</div>
<div className="mx_IconizedContextMenu_optionList">
<ul>
<li className="mx_UserMenu_contextMenu_redRow">
<AccessibleButton onClick={this.onSignOutClick}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSignOut" />
<span>{_t("Sign out")}</span>
</AccessibleButton>
</li>
</ul>
<div className="mx_IconizedContextMenu_optionList mx_UserMenu_contextMenu_redRow">
<AccessibleButton onClick={this.onSignOutClick}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSignOut" />
<span className="mx_IconizedContextMenu_label">{_t("Sign out")}</span>
</AccessibleButton>
</div>
</div>
</ContextMenu>

View file

@ -75,8 +75,12 @@ export default createReactClass({
// If provided, this is used to add a aria-describedby attribute
contentId: PropTypes.string,
// optional additional class for the title element
titleClass: PropTypes.string,
// optional additional class for the title element (basically anything that can be passed to classnames)
titleClass: PropTypes.oneOfType([
PropTypes.string,
PropTypes.object,
PropTypes.arrayOf(PropTypes.string),
]),
},
getDefaultProps: function() {

View file

@ -15,13 +15,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { debounce } from 'lodash';
import classNames from 'classnames';
import React from 'react';
import PropTypes from "prop-types";
import * as sdk from '../../../../index';
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import Field from '../../elements/Field';
import AccessibleButton from '../../elements/AccessibleButton';
import { _t } from '../../../../languageHandler';
import { accessSecretStorage } from '../../../../CrossSigningManager';
// Maximum acceptable size of a key file. It's 59 characters including the spaces we encode,
// so this should be plenty and allow for people putting extra whitespace in the file because
// maybe that's a thing people would do?
const KEY_FILE_MAX_SIZE = 128;
// Don't shout at the user that their key is invalid every time they type a key: wait a short time
const VALIDATION_THROTTLE_MS = 200;
/*
* Access Secure Secret Storage by requesting the user's passphrase.
@ -36,9 +47,14 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
constructor(props) {
super(props);
this._fileUpload = React.createRef();
this.state = {
recoveryKey: "",
recoveryKeyValid: false,
recoveryKeyValid: null,
recoveryKeyCorrect: null,
recoveryKeyFileError: null,
forceRecoveryKey: false,
passPhrase: '',
keyMatches: null,
@ -55,18 +71,89 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
});
}
_onResetRecoveryClick = () => {
// Re-enter the access flow, but resetting storage this time around.
this.props.onFinished(false);
accessSecretStorage(() => {}, /* forceReset = */ true);
_validateRecoveryKeyOnChange = debounce(() => {
this._validateRecoveryKey();
}, VALIDATION_THROTTLE_MS);
async _validateRecoveryKey() {
if (this.state.recoveryKey === '') {
this.setState({
recoveryKeyValid: null,
recoveryKeyCorrect: null,
});
return;
}
try {
const cli = MatrixClientPeg.get();
const decodedKey = cli.keyBackupKeyFromRecoveryKey(this.state.recoveryKey);
const correct = await cli.checkSecretStorageKey(
decodedKey, this.props.keyInfo,
);
this.setState({
recoveryKeyValid: true,
recoveryKeyCorrect: correct,
});
} catch (e) {
this.setState({
recoveryKeyValid: false,
recoveryKeyCorrect: false,
});
}
}
_onRecoveryKeyChange = (e) => {
this.setState({
recoveryKey: e.target.value,
recoveryKeyValid: MatrixClientPeg.get().isValidRecoveryKey(e.target.value),
keyMatches: null,
recoveryKeyFileError: null,
});
// also clear the file upload control so that the user can upload the same file
// the did before (otherwise the onchange wouldn't fire)
if (this._fileUpload.current) this._fileUpload.current.value = null;
// We don't use Field's validation here because a) we want it in a separate place rather
// than in a tooltip and b) we want it to display feedback based on the uploaded file
// as well as the text box. Ideally we would refactor Field's validation logic so we could
// re-use some of it.
this._validateRecoveryKeyOnChange();
}
_onRecoveryKeyFileChange = async e => {
if (e.target.files.length === 0) return;
const f = e.target.files[0];
if (f.size > KEY_FILE_MAX_SIZE) {
this.setState({
recoveryKeyFileError: true,
recoveryKeyCorrect: false,
recoveryKeyValid: false,
});
} else {
const contents = await f.text();
// test it's within the base58 alphabet. We could be more strict here, eg. require the
// right number of characters, but it's really just to make sure that what we're reading is
// text because we'll put it in the text field.
if (/^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz\s]+$/.test(contents)) {
this.setState({
recoveryKeyFileError: null,
recoveryKey: contents.trim(),
});
this._validateRecoveryKey();
} else {
this.setState({
recoveryKeyFileError: true,
recoveryKeyCorrect: false,
recoveryKeyValid: false,
recoveryKey: '',
});
}
}
}
_onRecoveryKeyFileUploadClick = () => {
this._fileUpload.current.click();
}
_onPassPhraseNext = async (e) => {
@ -106,6 +193,20 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
});
}
getKeyValidationText() {
if (this.state.recoveryKeyFileError) {
return _t("Wrong file type");
} else if (this.state.recoveryKeyCorrect) {
return _t("Looks good!");
} else if (this.state.recoveryKeyValid) {
return _t("Wrong Recovery Key");
} else if (this.state.recoveryKeyValid === null) {
return '';
} else {
return _t("Invalid Recovery Key");
}
}
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
@ -118,10 +219,12 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
let content;
let title;
let titleClass;
if (hasPassphrase && !this.state.forceRecoveryKey) {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
title = _t("Enter recovery passphrase");
title = _t("Security Phrase");
titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_securePhraseTitle'];
let keyStatus;
if (this.state.keyMatches === false) {
@ -137,12 +240,15 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
content = <div>
<p>{_t(
"<b>Warning</b>: You should only do this on a trusted computer.", {},
{ b: sub => <b>{sub}</b> },
)}</p>
<p>{_t(
"Access your secure message history and your cross-signing " +
"identity for verifying other sessions by entering your recovery passphrase.",
"Enter your Security Phrase or <button>Use your Security Key</button> to continue.", {},
{
button: s => <AccessibleButton className="mx_linkButton"
element="span"
onClick={this._onUseRecoveryKeyClick}
>
{s}
</AccessibleButton>,
},
)}</p>
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this._onPassPhraseNext}>
@ -153,10 +259,11 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
value={this.state.passPhrase}
autoFocus={true}
autoComplete="new-password"
placeholder={_t("Security Phrase")}
/>
{keyStatus}
<DialogButtons
primaryButton={_t('Next')}
primaryButton={_t('Continue')}
onPrimaryButtonClick={this._onPassPhraseNext}
hasCancel={true}
onCancel={this._onCancel}
@ -164,87 +271,61 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
primaryDisabled={this.state.passPhrase.length === 0}
/>
</form>
{_t(
"If you've forgotten your recovery passphrase you can "+
"<button1>use your recovery key</button1> or " +
"<button2>set up new recovery options</button2>."
, {}, {
button1: s => <AccessibleButton className="mx_linkButton"
element="span"
onClick={this._onUseRecoveryKeyClick}
>
{s}
</AccessibleButton>,
button2: s => <AccessibleButton className="mx_linkButton"
element="span"
onClick={this._onResetRecoveryClick}
>
{s}
</AccessibleButton>,
})}
</div>;
} else {
title = _t("Enter recovery key");
title = _t("Security Key");
titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_secureBackupTitle'];
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let keyStatus;
if (this.state.recoveryKey.length === 0) {
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus" />;
} else if (this.state.keyMatches === false) {
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
{"\uD83D\uDC4E "}{_t(
"Unable to access secret storage. " +
"Please verify that you entered the correct recovery key.",
)}
</div>;
} else if (this.state.recoveryKeyValid) {
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
{"\uD83D\uDC4D "}{_t("This looks like a valid recovery key!")}
</div>;
} else {
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
{"\uD83D\uDC4E "}{_t("Not a valid recovery key")}
</div>;
}
const feedbackClasses = classNames({
'mx_AccessSecretStorageDialog_recoveryKeyFeedback': true,
'mx_AccessSecretStorageDialog_recoveryKeyFeedback_valid': this.state.recoveryKeyCorrect === true,
'mx_AccessSecretStorageDialog_recoveryKeyFeedback_invalid': this.state.recoveryKeyCorrect === false,
});
const recoveryKeyFeedback = <div className={feedbackClasses}>
{this.getKeyValidationText()}
</div>;
content = <div>
<p>{_t(
"<b>Warning</b>: You should only do this on a trusted computer.", {},
{ b: sub => <b>{sub}</b> },
)}</p>
<p>{_t(
"Access your secure message history and your cross-signing " +
"identity for verifying other sessions by entering your recovery key.",
)}</p>
<p>{_t("Use your Security Key to continue.")}</p>
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this._onRecoveryKeyNext}>
<input className="mx_AccessSecretStorageDialog_recoveryKeyInput"
onChange={this._onRecoveryKeyChange}
value={this.state.recoveryKey}
autoFocus={true}
/>
{keyStatus}
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this._onRecoveryKeyNext} spellCheck={false}>
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry">
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry_textInput">
<Field
type="text"
label={_t('Security Key')}
value={this.state.recoveryKey}
onChange={this._onRecoveryKeyChange}
forceValidity={this.state.recoveryKeyCorrect}
/>
</div>
<span className="mx_AccessSecretStorageDialog_recoveryKeyEntry_entryControlSeparatorText">
{_t("or")}
</span>
<div>
<input type="file"
className="mx_AccessSecretStorageDialog_recoveryKeyEntry_fileInput"
ref={this._fileUpload}
onChange={this._onRecoveryKeyFileChange}
/>
<AccessibleButton kind="primary" onClick={this._onRecoveryKeyFileUploadClick}>
{_t("Upload")}
</AccessibleButton>
</div>
</div>
{recoveryKeyFeedback}
<DialogButtons
primaryButton={_t('Next')}
primaryButton={_t('Continue')}
onPrimaryButtonClick={this._onRecoveryKeyNext}
hasCancel={true}
cancelButton={_t("Go Back")}
cancelButtonClass='danger'
onCancel={this._onCancel}
focus={false}
primaryDisabled={!this.state.recoveryKeyValid}
/>
</form>
{_t(
"If you've forgotten your recovery key you can "+
"<button>set up new recovery options</button>."
, {}, {
button: s => <AccessibleButton className="mx_linkButton"
element="span"
onClick={this._onResetRecoveryClick}
>
{s}
</AccessibleButton>,
})}
</div>;
}
@ -252,6 +333,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
<BaseDialog className='mx_AccessSecretStorageDialog'
onFinished={this.props.onFinished}
title={title}
titleClass={titleClass}
>
<div>
{content}

View file

@ -50,7 +50,7 @@ interface IProps {
// to the user.
onValidate?: (input: IFieldState) => Promise<IValidationResult>;
// If specified, overrides the value returned by onValidate.
flagInvalid?: boolean;
forceValidity?: boolean;
// If specified, contents will appear as a tooltip on the element and
// validation feedback tooltips will be suppressed.
tooltipContent?: React.ReactNode;
@ -203,7 +203,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
public render() {
const {
element, prefixComponent, postfixComponent, className, onValidate, children,
tooltipContent, flagInvalid, tooltipClassName, list, ...inputProps} = this.props;
tooltipContent, forceValidity, tooltipClassName, list, ...inputProps} = this.props;
// Set some defaults for the <input> element
const ref = input => this.input = input;
@ -228,15 +228,15 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
postfixContainer = <span className="mx_Field_postfix">{postfixComponent}</span>;
}
const hasValidationFlag = flagInvalid !== null && flagInvalid !== undefined;
const hasValidationFlag = forceValidity !== null && forceValidity !== undefined;
const fieldClasses = classNames("mx_Field", `mx_Field_${this.props.element}`, className, {
// If we have a prefix element, leave the label always at the top left and
// don't animate it, as it looks a bit clunky and would add complexity to do
// properly.
mx_Field_labelAlwaysTopLeft: prefixComponent,
mx_Field_valid: onValidate && this.state.valid === true,
mx_Field_valid: hasValidationFlag ? forceValidity : onValidate && this.state.valid === true,
mx_Field_invalid: hasValidationFlag
? flagInvalid
? !forceValidity
: onValidate && this.state.valid === false,
});

View file

@ -1,39 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 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 PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
export default createReactClass({
displayName: 'ProgressBar',
propTypes: {
value: PropTypes.number,
max: PropTypes.number,
},
render: function() {
// Would use an HTML5 progress tag but if that doesn't animate if you
// use the HTML attributes rather than styles
const progressStyle = {
width: ((this.props.value / this.props.max) * 100)+"%",
};
return (
<div className="mx_ProgressBar"><div className="mx_ProgressBar_fill" style={progressStyle}></div></div>
);
},
});

View file

@ -0,0 +1,28 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
interface IProps {
value: number;
max: number;
}
const ProgressBar: React.FC<IProps> = ({value, max}) => {
return <progress className="mx_ProgressBar" max={max} value={value} />;
};
export default ProgressBar;

View file

@ -42,7 +42,7 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
<input type='radio' disabled={disabled} {...otherProps} />
{/* Used to render the radio button circle */}
<div><div></div></div>
<span>{children}</span>
<div className="mx_RadioButton_content">{children}</div>
<div className="mx_RadioButton_spacer" />
</label>;
}

View file

@ -41,9 +41,8 @@ function StyledRadioGroup<T extends string>({name, definitions, value, className
};
return <React.Fragment>
{definitions.map(d => <React.Fragment>
{definitions.map(d => <React.Fragment key={d.value}>
<StyledRadioButton
key={d.value}
className={classNames(className, d.className)}
onChange={_onChange}
checked={d.value === value}

View file

@ -32,7 +32,7 @@ import StyledCheckbox from "../elements/StyledCheckbox";
import StyledRadioButton from "../elements/StyledRadioButton";
import RoomListStore from "../../../stores/room-list/RoomListStore2";
import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models";
import { TagID } from "../../../stores/room-list/models";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
@ -91,6 +91,12 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
return (this.props.rooms || []).length;
}
private get numVisibleTiles(): number {
if (!this.props.layout) return 0;
const nVisible = Math.floor(this.props.layout.visibleTiles);
return Math.min(nVisible, this.numTiles);
}
public componentDidUpdate() {
this.state.notificationState.setRooms(this.props.rooms);
}
@ -107,7 +113,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
private onResize = (e: React.MouseEvent, data: ResizeCallbackData) => {
const direction = e.movementY < 0 ? -1 : +1;
const tileDiff = this.props.layout.pixelsToTiles(Math.abs(e.movementY)) * direction;
this.props.layout.visibleTiles += tileDiff;
this.props.layout.setVisibleTilesWithin(tileDiff, this.numTiles);
this.forceUpdate(); // because the layout doesn't trigger a re-render
};
@ -173,13 +179,17 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
}
};
private renderTiles(): React.ReactElement[] {
if (this.props.layout && this.props.layout.isCollapsed) return []; // don't waste time on rendering
private renderVisibleTiles(): React.ReactElement[] {
if (this.props.layout && this.props.layout.isCollapsed) {
// don't waste time on rendering
return [];
}
const tiles: React.ReactElement[] = [];
if (this.props.rooms) {
for (const room of this.props.rooms) {
const visibleRooms = this.props.rooms.slice(0, this.numVisibleTiles);
for (const room of visibleRooms) {
tiles.push(
<RoomTile2
room={room}
@ -196,6 +206,11 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
}
private renderMenu(): React.ReactElement {
// TODO: Get a proper invite context menu, or take invites out of the room list.
if (this.props.tagId === DefaultTagID.Invite) {
return null;
}
let contextMenu = null;
if (this.state.menuDisplayed) {
const elementRect = this.menuButtonRef.current.getBoundingClientRect();
@ -338,7 +353,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
public render(): React.ReactElement {
// TODO: Error boundary: https://github.com/vector-im/riot-web/issues/14185
const tiles = this.renderTiles();
const visibleTiles = this.renderVisibleTiles();
const classes = classNames({
'mx_RoomSublist2': true,
@ -347,13 +362,10 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
});
let content = null;
if (tiles.length > 0) {
if (visibleTiles.length > 0) {
const layout = this.props.layout; // to shorten calls
const nVisible = Math.floor(layout.visibleTiles);
const visibleTiles = tiles.slice(0, nVisible);
const maxTilesFactored = layout.tilesWithResizerBoxFactor(tiles.length);
const maxTilesFactored = layout.tilesWithResizerBoxFactor(this.numTiles);
const showMoreBtnClasses = classNames({
'mx_RoomSublist2_showNButton': true,
'mx_RoomSublist2_isCutting': this.state.isResizing && layout.visibleTiles < maxTilesFactored,
@ -363,9 +375,9 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
// floats above the resize handle, if we have one present. If the user has all
// tiles visible, it becomes 'show less'.
let showNButton = null;
if (tiles.length > nVisible) {
if (this.numTiles > visibleTiles.length) {
// we have a cutoff condition - add the button to show all
const numMissing = tiles.length - visibleTiles.length;
const numMissing = this.numTiles - visibleTiles.length;
let showMoreText = (
<span className='mx_RoomSublist2_showNButtonText'>
{_t("Show %(count)s more", {count: numMissing})}
@ -380,7 +392,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
{showMoreText}
</div>
);
} else if (tiles.length <= nVisible && tiles.length > this.props.layout.defaultVisibleTiles) {
} else if (this.numTiles <= visibleTiles.length && this.numTiles > this.props.layout.defaultVisibleTiles) {
// we have all tiles visible - add a button to show less
let showLessText = (
<span className='mx_RoomSublist2_showNButtonText'>
@ -400,7 +412,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
// Figure out if we need a handle
let handles = ['s'];
if (layout.visibleTiles >= tiles.length && tiles.length <= layout.minVisibleTiles) {
if (layout.visibleTiles >= this.numTiles && this.numTiles <= layout.minVisibleTiles) {
handles = []; // no handles, we're at a minimum
}
@ -419,9 +431,9 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
if (showNButton) padding += SHOW_N_BUTTON_HEIGHT;
padding += RESIZE_HANDLE_HEIGHT; // always append the handle height
const relativeTiles = layout.tilesWithPadding(tiles.length, padding);
const relativeTiles = layout.tilesWithPadding(this.numTiles, padding);
const minTilesPx = layout.calculateTilesToPixelsMin(relativeTiles, layout.minVisibleTiles, padding);
const maxTilesPx = layout.tilesToPixelsWithPadding(tiles.length, padding);
const maxTilesPx = layout.tilesToPixelsWithPadding(this.numTiles, padding);
const tilesWithoutPadding = Math.min(relativeTiles, layout.visibleTiles);
const tilesPx = layout.calculateTilesToPixelsMin(relativeTiles, tilesWithoutPadding, padding);

View file

@ -32,10 +32,13 @@ import NotificationBadge, {
TagSpecificNotificationState
} from "./NotificationBadge";
import { _t } from "../../../languageHandler";
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
import { ContextMenu, ContextMenuButton, MenuItemRadio } from "../../structures/ContextMenu";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import RoomTileIcon from "./RoomTileIcon";
import { getRoomNotifsState, ALL_MESSAGES, ALL_MESSAGES_LOUD, MENTIONS_ONLY, MUTE } from "../../../RoomNotifs";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { setRoomNotifsState } from "../../../RoomNotifs";
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
@ -61,11 +64,46 @@ interface IState {
hover: boolean;
notificationState: INotificationState;
selected: boolean;
notificationsMenuDisplayed: boolean;
generalMenuDisplayed: boolean;
}
const contextMenuBelow = (elementRect) => {
// align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.pageXOffset - 9;
let top = elementRect.bottom + window.pageYOffset + 17;
const chevronFace = "none";
return {left, top, chevronFace};
};
interface INotifOptionProps {
active: boolean;
iconClassName: string;
label: string;
onClick(ev: ButtonEvent);
}
const NotifOption: React.FC<INotifOptionProps> = ({active, onClick, iconClassName, label}) => {
const classes = classNames({
mx_RoomTile2_contextMenu_activeRow: active,
});
let activeIcon;
if (active) {
activeIcon = <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconCheck" />;
}
return (
<MenuItemRadio className={classes} onClick={onClick} active={active} label={label}>
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
<span className="mx_IconizedContextMenu_label">{ label }</span>
{ activeIcon }
</MenuItemRadio>
);
};
export default class RoomTile2 extends React.Component<IProps, IState> {
private roomTileRef: React.RefObject<HTMLDivElement> = createRef();
private notificationsMenuButtonRef: React.RefObject<HTMLButtonElement> = createRef();
private generalMenuButtonRef: React.RefObject<HTMLButtonElement> = createRef();
// TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
@ -77,6 +115,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
hover: false,
notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag),
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
notificationsMenuDisplayed: false,
generalMenuDisplayed: false,
};
@ -111,6 +150,18 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
this.setState({selected: isActive});
};
private onNotificationsMenuOpenClick = (ev: InputEvent) => {
ev.preventDefault();
ev.stopPropagation();
this.setState({notificationsMenuDisplayed: true});
};
private onCloseNotificationsMenu = (ev: InputEvent) => {
ev.preventDefault();
ev.stopPropagation();
this.setState({notificationsMenuDisplayed: false});
};
private onGeneralMenuOpenClick = (ev: InputEvent) => {
ev.preventDefault();
ev.stopPropagation();
@ -153,55 +204,129 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
this.setState({generalMenuDisplayed: false}); // hide the menu
};
private async saveNotifState(ev: ButtonEvent, newState: ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE) {
ev.preventDefault();
ev.stopPropagation();
if (MatrixClientPeg.get().isGuest()) return;
try {
// TODO add local echo - https://github.com/vector-im/riot-web/issues/14280
await setRoomNotifsState(this.props.room.roomId, newState);
} catch (error) {
// TODO: some form of error notification to the user to inform them that their state change failed.
// https://github.com/vector-im/riot-web/issues/14281
console.error(error);
}
// Close the context menu
this.setState({
notificationsMenuDisplayed: false,
});
}
private onClickAllNotifs = ev => this.saveNotifState(ev, ALL_MESSAGES);
private onClickAlertMe = ev => this.saveNotifState(ev, ALL_MESSAGES_LOUD);
private onClickMentions = ev => this.saveNotifState(ev, MENTIONS_ONLY);
private onClickMute = ev => this.saveNotifState(ev, MUTE);
private renderNotificationsMenu(): React.ReactElement {
if (this.props.isMinimized || MatrixClientPeg.get().isGuest() || this.props.tag === DefaultTagID.Invite) {
// the menu makes no sense in these cases so do not show one
return null;
}
const state = getRoomNotifsState(this.props.room.roomId);
let contextMenu = null;
if (this.state.notificationsMenuDisplayed) {
const elementRect = this.notificationsMenuButtonRef.current.getBoundingClientRect();
contextMenu = (
<ContextMenu {...contextMenuBelow(elementRect)} onFinished={this.onCloseNotificationsMenu}>
<div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu">
<div className="mx_IconizedContextMenu_optionList">
<NotifOption
label={_t("Use default")}
active={state === ALL_MESSAGES}
iconClassName="mx_RoomTile2_iconBell"
onClick={this.onClickAllNotifs}
/>
<NotifOption
label={_t("All messages")}
active={state === ALL_MESSAGES_LOUD}
iconClassName="mx_RoomTile2_iconBellDot"
onClick={this.onClickAlertMe}
/>
<NotifOption
label={_t("Mentions & Keywords")}
active={state === MENTIONS_ONLY}
iconClassName="mx_RoomTile2_iconBellMentions"
onClick={this.onClickMentions}
/>
<NotifOption
label={_t("None")}
active={state === MUTE}
iconClassName="mx_RoomTile2_iconBellCrossed"
onClick={this.onClickMute}
/>
</div>
</div>
</ContextMenu>
);
}
const classes = classNames("mx_RoomTile2_notificationsButton", {
// Show bell icon for the default case too.
mx_RoomTile2_iconBell: state === ALL_MESSAGES_LOUD || state === ALL_MESSAGES,
mx_RoomTile2_iconBellDot: state === MENTIONS_ONLY,
mx_RoomTile2_iconBellCrossed: state === MUTE,
// XXX: RoomNotifs assumes ALL_MESSAGES is default, this is wrong,
// but cannot be fixed until FTUE Notifications lands.
mx_RoomTile2_notificationsButton_show: state !== ALL_MESSAGES,
});
return (
<React.Fragment>
<ContextMenuButton
className={classes}
onClick={this.onNotificationsMenuOpenClick}
inputRef={this.notificationsMenuButtonRef}
label={_t("Notification options")}
isExpanded={this.state.notificationsMenuDisplayed}
/>
{contextMenu}
</React.Fragment>
);
}
private renderGeneralMenu(): React.ReactElement {
if (this.props.isMinimized) return null; // no menu when minimized
// TODO: Get a proper invite context menu, or take invites out of the room list.
if (this.props.tag === DefaultTagID.Invite) {
return null;
}
let contextMenu = null;
if (this.state.generalMenuDisplayed) {
// The context menu appears within the list, so use the room tile as a reference point
const elementRect = this.roomTileRef.current.getBoundingClientRect();
const elementRect = this.generalMenuButtonRef.current.getBoundingClientRect();
contextMenu = (
<ContextMenu
chevronFace="none"
left={elementRect.left}
top={elementRect.top + elementRect.height + 8}
onFinished={this.onCloseGeneralMenu}
>
<div
className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu"
style={{width: elementRect.width}}
>
<ContextMenu {...contextMenuBelow(elementRect)} onFinished={this.onCloseGeneralMenu}>
<div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu">
<div className="mx_IconizedContextMenu_optionList">
<ul>
<li>
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar" />
<span>{_t("Favourite")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.LowPriority)}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconArrowDown" />
<span>{_t("Low Priority")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={this.onOpenRoomSettings}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" />
<span>{_t("Settings")}</span>
</AccessibleButton>
</li>
</ul>
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar" />
<span className="mx_IconizedContextMenu_label">{_t("Favourite")}</span>
</AccessibleButton>
<AccessibleButton onClick={this.onOpenRoomSettings}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" />
<span className="mx_IconizedContextMenu_label">{_t("Settings")}</span>
</AccessibleButton>
</div>
<div className="mx_IconizedContextMenu_optionList">
<ul>
<li className="mx_RoomTile2_contextMenu_redRow">
<AccessibleButton onClick={this.onLeaveRoomClick}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" />
<span>{_t("Leave Room")}</span>
</AccessibleButton>
</li>
</ul>
<div className="mx_IconizedContextMenu_optionList mx_RoomTile2_contextMenu_redRow">
<AccessibleButton onClick={this.onLeaveRoomClick}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" />
<span className="mx_IconizedContextMenu_label">{_t("Leave Room")}</span>
</AccessibleButton>
</div>
</div>
</ContextMenu>
@ -229,7 +354,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
const classes = classNames({
'mx_RoomTile2': true,
'mx_RoomTile2_selected': this.state.selected,
'mx_RoomTile2_hasMenuOpen': this.state.generalMenuDisplayed,
'mx_RoomTile2_hasMenuOpen': this.state.generalMenuDisplayed || this.state.notificationsMenuDisplayed,
'mx_RoomTile2_minimized': this.props.isMinimized,
});
@ -280,7 +405,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
const avatarSize = 32;
return (
<React.Fragment>
<RovingTabIndexWrapper inputRef={this.roomTileRef}>
<RovingTabIndexWrapper>
{({onFocus, isActive, ref}) =>
<AccessibleButton
onFocus={onFocus}
@ -300,6 +425,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
<div className="mx_RoomTile2_badgeContainer">
{badge}
</div>
{this.renderNotificationsMenu()}
{this.renderGeneralMenu()}
</AccessibleButton>
}

View file

@ -413,7 +413,7 @@ export default class SetIdServer extends React.Component {
tooltipContent={this._getTooltip()}
tooltipClassName="mx_SetIdServer_tooltip"
disabled={this.state.busy}
flagInvalid={!!this.state.error}
forceValidity={this.state.error ? false : null}
/>
<AccessibleButton type="submit" kind="primary_sm"
onClick={this._checkIdServer}

View file

@ -28,6 +28,8 @@ export default class NotificationsSettingsTab extends React.Component {
roomId: PropTypes.string.isRequired,
};
_soundUpload = createRef();
constructor() {
super();
@ -44,8 +46,6 @@ export default class NotificationsSettingsTab extends React.Component {
return;
}
this.setState({currentSound: soundData.name || soundData.url});
this._soundUpload = createRef();
}
async _triggerUploader(e) {

View file

@ -23,6 +23,7 @@ import SettingsStore from "../../../../../settings/SettingsStore";
import Field from "../../../elements/Field";
import * as sdk from "../../../../..";
import PlatformPeg from "../../../../../PlatformPeg";
import {RoomListStoreTempProxy} from "../../../../../stores/room-list/RoomListStoreTempProxy";
export default class PreferencesUserSettingsTab extends React.Component {
static ROOM_LIST_SETTINGS = [
@ -31,6 +32,19 @@ export default class PreferencesUserSettingsTab extends React.Component {
'breadcrumbs',
];
// TODO: Remove temp structures: https://github.com/vector-im/riot-web/issues/14231
static ROOM_LIST_2_SETTINGS = [
'breadcrumbs',
];
// TODO: Remove temp structures: https://github.com/vector-im/riot-web/issues/14231
static eligibleRoomListSettings = () => {
if (RoomListStoreTempProxy.isUsingNewStore()) {
return PreferencesUserSettingsTab.ROOM_LIST_2_SETTINGS;
}
return PreferencesUserSettingsTab.ROOM_LIST_SETTINGS;
};
static COMPOSER_SETTINGS = [
'MessageComposerInput.autoReplaceEmoji',
'MessageComposerInput.suggestEmoji',
@ -175,7 +189,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Room list")}</span>
{this._renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)}
{this._renderGroup(PreferencesUserSettingsTab.eligibleRoomListSettings())}
</div>
<div className="mx_SettingsTab_section">

View file

@ -0,0 +1,53 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import ToastStore from "../../../stores/ToastStore";
import GenericToast, { IProps as IGenericToastProps } from "./GenericToast";
import {useExpiringCounter} from "../../../hooks/useTimeout";
interface IProps extends IGenericToastProps {
toastKey: string;
numSeconds: number;
dismissLabel: string;
onDismiss?();
}
const SECOND = 1000;
const GenericExpiringToast: React.FC<IProps> = ({description, acceptLabel, dismissLabel, onAccept, onDismiss, toastKey, numSeconds}) => {
const onReject = () => {
if (onDismiss) onDismiss();
ToastStore.sharedInstance().dismissToast(toastKey);
};
const counter = useExpiringCounter(onReject, SECOND, numSeconds);
let rejectLabel = dismissLabel;
if (counter > 0) {
rejectLabel += ` (${counter})`;
}
return <GenericToast
description={description}
acceptLabel={acceptLabel}
onAccept={onAccept}
rejectLabel={rejectLabel}
onReject={onReject}
/>;
};
export default GenericExpiringToast;

View file

@ -19,7 +19,7 @@ import React, {ReactChild} from "react";
import FormButton from "../elements/FormButton";
import {XOR} from "../../../@types/common";
interface IProps {
export interface IProps {
description: ReactChild;
acceptLabel: string;