Merge pull request #4209 from matrix-org/t3chguy/redesign/room_directory

Room Directory Explore Servers redesign
This commit is contained in:
Michael Telatynski 2020-03-18 11:51:42 +00:00 committed by GitHub
commit ef79492f2c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 489 additions and 300 deletions

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -45,9 +46,8 @@ limitations under the License.
} }
.mx_RoomDirectory_listheader { .mx_RoomDirectory_listheader {
display: flex; display: block;
margin-top: 12px; margin-top: 13px;
margin-bottom: 12px;
} }
.mx_RoomDirectory_searchbox { .mx_RoomDirectory_searchbox {
@ -64,7 +64,7 @@ limitations under the License.
} }
.mx_RoomDirectory_table { .mx_RoomDirectory_table {
font-size: 14px; font-size: 12px;
color: $primary-fg-color; color: $primary-fg-color;
width: 100%; width: 100%;
text-align: left; text-align: left;
@ -112,6 +112,7 @@ limitations under the License.
.mx_RoomDirectory_name { .mx_RoomDirectory_name {
display: inline-block; display: inline-block;
font-size: 18px;
font-weight: 600; font-weight: 600;
} }
@ -148,8 +149,8 @@ limitations under the License.
padding: 0; padding: 0;
} }
.mx_RoomDirectory p { .mx_RoomDirectory > span {
font-size: 14px; font-size: 15px;
margin-top: 0; margin-top: 0;
.mx_AccessibleButton { .mx_AccessibleButton {

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,70 +15,149 @@ limitations under the License.
*/ */
.mx_NetworkDropdown { .mx_NetworkDropdown {
height: 32px;
position: relative; position: relative;
} width: max-content;
padding-right: 32px;
margin-left: auto;
margin-right: 9px;
margin-top: 12px;
.mx_NetworkDropdown_input { .mx_AccessibleButton {
position: relative; width: max-content;
border-radius: 3px; }
border: 1px solid $strong-input-border-color;
font-weight: 300;
font-size: 13px;
user-select: none;
}
.mx_NetworkDropdown_arrow {
border-color: $primary-fg-color transparent transparent;
border-style: solid;
border-width: 5px 5px 0;
display: block;
height: 0;
position: absolute;
right: 10px;
top: 16px;
width: 0;
}
.mx_NetworkDropdown_networkoption {
height: 37px;
line-height: 37px;
padding-left: 8px;
padding-right: 8px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.mx_NetworkDropdown_networkoption img {
margin: 5px;
width: 25px;
vertical-align: middle;
}
input.mx_NetworkDropdown_networkoption, input.mx_NetworkDropdown_networkoption:focus {
border: 0;
padding-top: 0;
padding-bottom: 0;
} }
.mx_NetworkDropdown_menu { .mx_NetworkDropdown_menu {
position: absolute; min-width: 204px;
left: -1px;
right: -1px;
top: 100%;
z-index: 2;
margin: 0; margin: 0;
padding: 0px; box-sizing: border-box;
border-radius: 3px; border-radius: 4px;
border: 1px solid $accent-color; border: 1px solid $dialog-close-fg-color;
background-color: $primary-bg-color; background-color: $primary-bg-color;
} }
.mx_NetworkDropdown_menu .mx_NetworkDropdown_networkoption:hover {
background-color: $focus-bg-color;
}
.mx_NetworkDropdown_menu_network { .mx_NetworkDropdown_menu_network {
font-weight: bold; font-weight: bold;
} }
.mx_NetworkDropdown_server {
padding: 12px 0;
border-bottom: 1px solid $input-darker-fg-color;
.mx_NetworkDropdown_server_title {
padding: 0 10px;
font-size: 15px;
font-weight: 600;
line-height: 20px;
margin-bottom: 4px;
// remove server button
.mx_AccessibleButton {
position: absolute;
display: inline;
right: 12px;
height: 16px;
width: 16px;
margin-top: 4px;
&::after {
content: "";
position: absolute;
width: 16px;
height: 16px;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
mask-image: url('$(res)/img/feather-customised/x.svg');
background-color: $notice-primary-color;
}
}
}
.mx_NetworkDropdown_server_subtitle {
padding: 0 10px;
font-size: 10px;
line-height: 14px;
margin-top: -4px;
margin-bottom: 4px;
color: $muted-fg-color;
}
.mx_NetworkDropdown_server_network {
font-size: 12px;
line-height: 16px;
padding: 4px 10px;
cursor: pointer;
position: relative;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
&[aria-checked=true]::after {
content: "";
position: absolute;
width: 16px;
height: 16px;
right: 10px;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
mask-image: url('$(res)/img/feather-customised/check.svg');
background-color: $input-valid-border-color;
}
}
}
.mx_NetworkDropdown_server_add,
.mx_NetworkDropdown_server_network {
&:hover {
background-color: $header-panel-bg-color;
}
}
.mx_NetworkDropdown_server_add {
padding: 16px 10px 16px 32px;
position: relative;
border-radius: 0 0 4px 4px;
&::before {
content: "";
position: absolute;
width: 16px;
height: 16px;
left: 7px;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
mask-image: url('$(res)/img/feather-customised/plus.svg');
background-color: $muted-fg-color;
}
}
.mx_NetworkDropdown_handle {
position: relative;
&::after {
content: "";
position: absolute;
width: 24px;
height: 24px;
right: -28px; // - (24 + 4)
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
background-color: $primary-fg-color;
}
.mx_NetworkDropdown_handle_server {
color: $muted-fg-color;
font-size: 12px;
}
}
.mx_NetworkDropdown_dialog .mx_Dialog {
width: 45vw;
}

View file

@ -18,7 +18,6 @@ limitations under the License.
display: flex; display: flex;
padding-left: 9px; padding-left: 9px;
padding-right: 9px; padding-right: 9px;
margin: 0 5px 0 0 !important;
} }
.mx_DirectorySearchBox_joinButton { .mx_DirectorySearchBox_joinButton {

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 9L12 15L18 9" stroke="#2E2F32" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 195 B

View file

@ -350,7 +350,7 @@ export const ContextMenuButton = ({ label, isExpanded, children, ...props }) =>
}; };
ContextMenuButton.propTypes = { ContextMenuButton.propTypes = {
...AccessibleButton.propTypes, ...AccessibleButton.propTypes,
label: PropTypes.string.isRequired, label: PropTypes.string,
isExpanded: PropTypes.bool.isRequired, // whether or not the context menu is currently open isExpanded: PropTypes.bool.isRequired, // whether or not the context menu is currently open
}; };
@ -377,7 +377,6 @@ export const MenuGroup = ({children, label, ...props}) => {
</div>; </div>;
}; };
MenuGroup.propTypes = { MenuGroup.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string.isRequired, label: PropTypes.string.isRequired,
className: PropTypes.string, // optional className: PropTypes.string, // optional
}; };

View file

@ -600,9 +600,8 @@ export default createReactClass({
break; break;
case 'view_room_directory': { case 'view_room_directory': {
const RoomDirectory = sdk.getComponent("structures.RoomDirectory"); const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
Modal.createTrackedDialog('Room directory', '', RoomDirectory, { Modal.createTrackedDialog('Room directory', '', RoomDirectory, {},
config: this.props.config, 'mx_RoomDirectory_dialogWrapper', false, true);
}, 'mx_RoomDirectory_dialogWrapper');
// View the welcome or home page if we need something to look at // View the welcome or home page if we need something to look at
this._viewSomethingBehindModal(); this._viewSomethingBehindModal();

View file

@ -28,6 +28,7 @@ import { _t } from '../../languageHandler';
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
import Analytics from '../../Analytics'; import Analytics from '../../Analytics';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import {ALL_ROOMS} from "../views/directory/NetworkDropdown";
const MAX_NAME_LENGTH = 80; const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 160; const MAX_TOPIC_LENGTH = 160;
@ -40,25 +41,17 @@ export default createReactClass({
displayName: 'RoomDirectory', displayName: 'RoomDirectory',
propTypes: { propTypes: {
config: PropTypes.object,
onFinished: PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
}, },
getDefaultProps: function() {
return {
config: {},
};
},
getInitialState: function() { getInitialState: function() {
return { return {
publicRooms: [], publicRooms: [],
loading: true, loading: true,
protocolsLoading: true, protocolsLoading: true,
error: null, error: null,
instanceId: null, instanceId: undefined,
includeAll: false, roomServer: MatrixClientPeg.getHomeserverName(),
roomServer: null,
filterString: null, filterString: null,
}; };
}, },
@ -98,6 +91,10 @@ export default createReactClass({
}); });
}, },
componentDidMount: function() {
this.refreshRoomList();
},
componentWillUnmount: function() { componentWillUnmount: function() {
if (this.filterTimeout) { if (this.filterTimeout) {
clearTimeout(this.filterTimeout); clearTimeout(this.filterTimeout);
@ -130,10 +127,10 @@ export default createReactClass({
if (my_server != MatrixClientPeg.getHomeserverName()) { if (my_server != MatrixClientPeg.getHomeserverName()) {
opts.server = my_server; opts.server = my_server;
} }
if (this.state.instanceId) { if (this.state.instanceId === ALL_ROOMS) {
opts.third_party_instance_id = this.state.instanceId;
} else if (this.state.includeAll) {
opts.include_all_networks = true; opts.include_all_networks = true;
} else if (this.state.instanceId) {
opts.third_party_instance_id = this.state.instanceId;
} }
if (this.nextBatch) opts.since = this.nextBatch; if (this.nextBatch) opts.since = this.nextBatch;
if (my_filter_string) opts.filter = { generic_search_term: my_filter_string }; if (my_filter_string) opts.filter = { generic_search_term: my_filter_string };
@ -247,7 +244,7 @@ export default createReactClass({
} }
}, },
onOptionChange: function(server, instanceId, includeAll) { onOptionChange: function(server, instanceId) {
// clear next batch so we don't try to load more rooms // clear next batch so we don't try to load more rooms
this.nextBatch = null; this.nextBatch = null;
this.setState({ this.setState({
@ -257,7 +254,6 @@ export default createReactClass({
publicRooms: [], publicRooms: [],
roomServer: server, roomServer: server,
instanceId: instanceId, instanceId: instanceId,
includeAll: includeAll,
error: null, error: null,
}, this.refreshRoomList); }, this.refreshRoomList);
// We also refresh the room list each time even though this // We also refresh the room list each time even though this
@ -305,7 +301,7 @@ export default createReactClass({
onJoinFromSearchClick: function(alias) { onJoinFromSearchClick: function(alias) {
// If we don't have a particular instance id selected, just show that rooms alias // If we don't have a particular instance id selected, just show that rooms alias
if (!this.state.instanceId) { if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
// If the user specified an alias without a domain, add on whichever server is selected // If the user specified an alias without a domain, add on whichever server is selected
// in the dropdown // in the dropdown
if (alias.indexOf(':') == -1) { if (alias.indexOf(':') == -1) {
@ -593,7 +589,7 @@ export default createReactClass({
} }
let placeholder = _t('Find a room…'); let placeholder = _t('Find a room…');
if (!this.state.instanceId) { if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {exampleRoom: "#example:" + this.state.roomServer}); placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {exampleRoom: "#example:" + this.state.roomServer});
} else if (instance_expected_field_type) { } else if (instance_expected_field_type) {
placeholder = instance_expected_field_type.placeholder; placeholder = instance_expected_field_type.placeholder;
@ -610,10 +606,18 @@ export default createReactClass({
listHeader = <div className="mx_RoomDirectory_listheader"> listHeader = <div className="mx_RoomDirectory_listheader">
<DirectorySearchBox <DirectorySearchBox
className="mx_RoomDirectory_searchbox" className="mx_RoomDirectory_searchbox"
onChange={this.onFilterChange} onClear={this.onFilterClear} onJoinClick={this.onJoinFromSearchClick} onChange={this.onFilterChange}
placeholder={placeholder} showJoinButton={showJoinButton} onClear={this.onFilterClear}
onJoinClick={this.onJoinFromSearchClick}
placeholder={placeholder}
showJoinButton={showJoinButton}
/>
<NetworkDropdown
protocols={this.protocols}
onOptionChange={this.onOptionChange}
selectedServerName={this.state.roomServer}
selectedInstanceId={this.state.instanceId}
/> />
<NetworkDropdown config={this.props.config} protocols={this.protocols} onOptionChange={this.onOptionChange} />
</div>; </div>;
} }
const explanation = const explanation =
@ -634,7 +638,7 @@ export default createReactClass({
title={_t("Explore rooms")} title={_t("Explore rooms")}
> >
<div className="mx_RoomDirectory"> <div className="mx_RoomDirectory">
<p>{explanation}</p> {explanation}
<div className="mx_RoomDirectory_list"> <div className="mx_RoomDirectory_list">
{listHeader} {listHeader}
{content} {content}

View file

@ -33,6 +33,7 @@ export default createReactClass({
onFinished: PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
headerImage: PropTypes.string, headerImage: PropTypes.string,
quitOnly: PropTypes.bool, // quitOnly doesn't show the cancel button just the quit [x]. quitOnly: PropTypes.bool, // quitOnly doesn't show the cancel button just the quit [x].
fixedWidth: PropTypes.bool,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -63,11 +64,14 @@ export default createReactClass({
primaryButtonClass = "danger"; primaryButtonClass = "danger";
} }
return ( return (
<BaseDialog className="mx_QuestionDialog" onFinished={this.props.onFinished} <BaseDialog
className="mx_QuestionDialog"
onFinished={this.props.onFinished}
title={this.props.title} title={this.props.title}
contentId='mx_Dialog_content' contentId='mx_Dialog_content'
headerImage={this.props.headerImage} headerImage={this.props.headerImage}
hasCancel={this.props.hasCancelButton} hasCancel={this.props.hasCancelButton}
fixedWidth={this.props.fixedWidth}
> >
<div className="mx_Dialog_content" id='mx_Dialog_content'> <div className="mx_Dialog_content" id='mx_Dialog_content'>
{ this.props.description } { this.props.description }

View file

@ -18,6 +18,7 @@ import React, {createRef} from 'react';
import createReactClass from 'create-react-class'; import createReactClass from 'create-react-class';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import Field from "../elements/Field";
export default createReactClass({ export default createReactClass({
displayName: 'TextInputDialog', displayName: 'TextInputDialog',
@ -28,9 +29,13 @@ export default createReactClass({
PropTypes.string, PropTypes.string,
]), ]),
value: PropTypes.string, value: PropTypes.string,
placeholder: PropTypes.string,
button: PropTypes.string, button: PropTypes.string,
focus: PropTypes.bool, focus: PropTypes.bool,
onFinished: PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
hasCancel: PropTypes.bool,
validator: PropTypes.func, // result of withValidation
fixedWidth: PropTypes.bool,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -39,34 +44,70 @@ export default createReactClass({
value: "", value: "",
description: "", description: "",
focus: true, focus: true,
hasCancel: true,
};
},
getInitialState: function() {
return {
value: this.props.value,
valid: false,
}; };
}, },
UNSAFE_componentWillMount: function() { UNSAFE_componentWillMount: function() {
this._textinput = createRef(); this._field = createRef();
}, },
componentDidMount: function() { componentDidMount: function() {
if (this.props.focus) { if (this.props.focus) {
// Set the cursor at the end of the text input // Set the cursor at the end of the text input
this._textinput.current.value = this.props.value; // this._field.current.value = this.props.value;
this._field.current.focus();
} }
}, },
onOk: function() { onOk: async function(ev) {
this.props.onFinished(true, this._textinput.current.value); ev.preventDefault();
if (this.props.validator) {
await this._field.current.validate({ allowEmpty: false });
if (!this._field.current.state.valid) {
this._field.current.focus();
this._field.current.validate({ allowEmpty: false, focused: true });
return;
}
}
this.props.onFinished(true, this.state.value);
}, },
onCancel: function() { onCancel: function() {
this.props.onFinished(false); this.props.onFinished(false);
}, },
onChange: function(ev) {
this.setState({
value: ev.target.value,
});
},
onValidate: async function(fieldState) {
const result = await this.props.validator(fieldState);
this.setState({
valid: result.valid,
});
return result;
},
render: function() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return ( return (
<BaseDialog className="mx_TextInputDialog" onFinished={this.props.onFinished} <BaseDialog
className="mx_TextInputDialog"
onFinished={this.props.onFinished}
title={this.props.title} title={this.props.title}
fixedWidth={this.props.fixedWidth}
> >
<form onSubmit={this.onOk}> <form onSubmit={this.onOk}>
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
@ -74,19 +115,26 @@ export default createReactClass({
<label htmlFor="textinput"> { this.props.description } </label> <label htmlFor="textinput"> { this.props.description } </label>
</div> </div>
<div> <div>
<input <Field
id="textinput" id="mx_TextInputDialog_field"
ref={this._textinput}
className="mx_TextInputDialog_input" className="mx_TextInputDialog_input"
defaultValue={this.props.value} ref={this._field}
autoFocus={this.props.focus} type="text"
size="64" /> label={this.props.placeholder}
value={this.state.value}
onChange={this.onChange}
onValidate={this.props.validator ? this.onValidate : undefined}
size="64"
/>
</div> </div>
</div> </div>
</form> </form>
<DialogButtons primaryButton={this.props.button} <DialogButtons
primaryButton={this.props.button}
onPrimaryButtonClick={this.onOk} onPrimaryButtonClick={this.onOk}
onCancel={this.onCancel} /> onCancel={this.onCancel}
hasCancel={this.props.hasCancel}
/>
</BaseDialog> </BaseDialog>
); );
}, },

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,241 +16,275 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, {useEffect, useState} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {instanceForInstanceId} from '../../../utils/DirectoryUtils'; import {instanceForInstanceId} from '../../../utils/DirectoryUtils';
import {
ContextMenu,
useContextMenu,
ContextMenuButton,
MenuItemRadio,
MenuItem,
MenuGroup,
} from "../../structures/ContextMenu";
import {_t} from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import {useSettingValue} from "../../../hooks/useSettings";
import * as sdk from "../../../index";
import Modal from "../../../Modal";
import SettingsStore from "../../../settings/SettingsStore";
import withValidation from "../elements/Validation";
const DEFAULT_ICON_URL = require("../../../../res/img/network-matrix.svg"); export const ALL_ROOMS = Symbol("ALL_ROOMS");
export default class NetworkDropdown extends React.Component { const SETTING_NAME = "room_directory_servers";
constructor(props) {
super(props);
this.dropdownRootElement = null; const inPlaceOf = (elementRect) => ({
this.ignoreEvent = null; right: window.innerWidth - elementRect.right,
top: elementRect.top,
chevronOffset: 0,
chevronFace: "none",
});
this.onInputClick = this.onInputClick.bind(this); const validServer = withValidation({
this.onRootClick = this.onRootClick.bind(this); rules: [
this.onDocumentClick = this.onDocumentClick.bind(this); {
this.onMenuOptionClick = this.onMenuOptionClick.bind(this); key: "required",
this.onInputKeyUp = this.onInputKeyUp.bind(this); test: async ({ value }) => !!value,
this.collectRoot = this.collectRoot.bind(this); invalid: () => _t("Enter a server name"),
this.collectInputTextBox = this.collectInputTextBox.bind(this); }, {
key: "available",
final: true,
test: async ({ value }) => {
try {
const opts = {
limit: 1,
server: value,
};
// check if we can successfully load this server's room directory
await MatrixClientPeg.get().publicRooms(opts);
return true;
} catch (e) {
return false;
}
},
valid: () => _t("Looks good"),
invalid: () => _t("Can't find this server or its room list"),
},
],
});
this.inputTextBox = null; // This dropdown sources homeservers from three places:
// + your currently connected homeserver
// + homeservers in config.json["roomDirectory"]
// + homeservers in SettingsStore["room_directory_servers"]
// if a server exists in multiple, only keep the top-most entry.
const server = MatrixClientPeg.getHomeserverName(); const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, selectedInstanceId}) => {
this.state = { const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
expanded: false, const _userDefinedServers = useSettingValue(SETTING_NAME);
selectedServer: server, const [userDefinedServers, _setUserDefinedServers] = useState(_userDefinedServers);
selectedInstanceId: null,
includeAllNetworks: false, const handlerFactory = (server, instanceId) => {
return () => {
onOptionChange(server, instanceId);
closeMenu();
}; };
} };
componentWillMount() { const setUserDefinedServers = servers => {
// Listen for all clicks on the document so we can close the _setUserDefinedServers(servers);
// menu when the user clicks somewhere else SettingsStore.setValue(SETTING_NAME, null, "account", servers);
document.addEventListener('click', this.onDocumentClick, false); };
// keep local echo up to date with external changes
useEffect(() => {
_setUserDefinedServers(_userDefinedServers);
}, [_userDefinedServers]);
// fire this now so the defaults can be set up // we either show the button or the dropdown in its place.
const {selectedServer, selectedInstanceId, includeAllNetworks} = this.state; let content;
this.props.onOptionChange(selectedServer, selectedInstanceId, includeAllNetworks); if (menuDisplayed) {
} const config = SdkConfig.get();
const roomDirectory = config.roomDirectory || {};
componentWillUnmount() { const hsName = MatrixClientPeg.getHomeserverName();
document.removeEventListener('click', this.onDocumentClick, false); const configServers = new Set(roomDirectory.servers);
}
componentDidUpdate() { // configured servers take preference over user-defined ones, if one occurs in both ignore the latter one.
if (this.state.expanded && this.inputTextBox) { const removableServers = new Set(userDefinedServers.filter(s => !configServers.has(s) && s !== hsName));
this.inputTextBox.focus(); const servers = [
} // we always show our connected HS, this takes precedence over it being configured or user-defined
} hsName,
...Array.from(configServers).filter(s => s !== hsName).sort(),
onDocumentClick(ev) { ...Array.from(removableServers).sort(),
// Close the dropdown if the user clicks anywhere that isn't ];
// within our root element
if (ev !== this.ignoreEvent) {
this.setState({
expanded: false,
});
}
}
onRootClick(ev) {
// 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
// dropdown immediately after opening it.
// NB. We can't just stopPropagation() because then the event
// doesn't reach the React onClick().
this.ignoreEvent = ev;
}
onInputClick(ev) {
this.setState({
expanded: !this.state.expanded,
});
ev.preventDefault();
}
onMenuOptionClick(server, instance, includeAll) {
this.setState({
expanded: false,
selectedServer: server,
selectedInstanceId: instance ? instance.instance_id : null,
includeAllNetworks: includeAll,
});
this.props.onOptionChange(server, instance ? instance.instance_id : null, includeAll);
}
onInputKeyUp(e) {
if (e.key === 'Enter') {
this.setState({
expanded: false,
selectedServer: e.target.value,
selectedNetwork: null,
includeAllNetworks: false,
});
this.props.onOptionChange(e.target.value, null);
}
}
collectRoot(e) {
if (this.dropdownRootElement) {
this.dropdownRootElement.removeEventListener('click', this.onRootClick, false);
}
if (e) {
e.addEventListener('click', this.onRootClick, false);
}
this.dropdownRootElement = e;
}
collectInputTextBox(e) {
this.inputTextBox = e;
}
_getMenuOptions() {
const options = [];
const roomDirectory = this.props.config.roomDirectory || {};
let servers = [];
if (roomDirectory.servers) {
servers = servers.concat(roomDirectory.servers);
}
if (!servers.includes(MatrixClientPeg.getHomeserverName())) {
servers.unshift(MatrixClientPeg.getHomeserverName());
}
// For our own HS, we can use the instance_ids given in the third party protocols // For our own HS, we can use the instance_ids given in the third party protocols
// response to get the server to filter the room list by network for us. // response to get the server to filter the room list by network for us.
// We can't get thirdparty protocols for remote server yet though, so for those // We can't get thirdparty protocols for remote server yet though, so for those
// we can only show the default room list. // we can only show the default room list.
for (const server of servers) { const options = servers.map(server => {
options.push(this._makeMenuOption(server, null, true)); const serverSelected = server === selectedServerName;
if (server === MatrixClientPeg.getHomeserverName()) { const entries = [];
options.push(this._makeMenuOption(server, null, false));
if (this.props.protocols) {
for (const proto of Object.keys(this.props.protocols)) {
if (!this.props.protocols[proto].instances) continue;
const sortedInstances = this.props.protocols[proto].instances; const protocolsList = server === hsName ? Object.values(protocols) : [];
sortedInstances.sort(function(x, y) { if (protocolsList.length > 0) {
const a = x.desc; // add a fake protocol with the ALL_ROOMS symbol
const b = y.desc; protocolsList.push({
if (a < b) { instances: [{
return -1; instance_id: ALL_ROOMS,
} else if (a > b) { desc: _t("All rooms"),
return 1; }],
} else { });
return 0;
}
});
for (const instance of sortedInstances) {
if (!instance.instance_id) continue;
options.push(this._makeMenuOption(server, instance, false));
}
}
}
} }
}
return options; protocolsList.forEach(({instances=[]}) => {
} [...instances].sort((b, a) => {
return a.desc.localeCompare(b.desc);
}).forEach(({desc, instance_id: instanceId}) => {
entries.push(
<MenuItemRadio
key={String(instanceId)}
active={serverSelected && instanceId === selectedInstanceId}
onClick={handlerFactory(server, instanceId)}
label={desc}
className="mx_NetworkDropdown_server_network"
>
{ desc }
</MenuItemRadio>);
});
});
_makeMenuOption(server, instance, includeAll, handleClicks) { let subtitle;
if (handleClicks === undefined) handleClicks = true; if (server === hsName) {
subtitle = (
<div className="mx_NetworkDropdown_server_subtitle">
{_t("Your server")}
</div>
);
}
let icon; let removeButton;
let name; if (removableServers.has(server)) {
let key; const onClick = async () => {
closeMenu();
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const {finished} = Modal.createTrackedDialog("Network Dropdown", "Remove server", QuestionDialog, {
title: _t("Are you sure?"),
description: _t("Are you sure you want to remove <b>%(serverName)s</b>", {
serverName: server,
}, {
b: serverName => <b>{ serverName }</b>,
}),
button: _t("Remove"),
fixedWidth: false,
}, "mx_NetworkDropdown_dialog");
if (!instance && includeAll) { const [ok] = await finished;
key = server; if (!ok) return;
name = server;
} else if (!instance) {
key = server + '_all';
name = 'Matrix';
icon = <img src={require("../../../../res/img/network-matrix.svg")} />;
} else {
key = server + '_inst_' + instance.instance_id;
const imgUrl = instance.icon ?
MatrixClientPeg.get().mxcUrlToHttp(instance.icon, 25, 25, 'crop', true) :
DEFAULT_ICON_URL;
icon = <img src={imgUrl} />;
name = instance.desc;
}
const clickHandler = handleClicks ? this.onMenuOptionClick.bind(this, server, instance, includeAll) : null; // delete from setting
setUserDefinedServers(servers.filter(s => s !== server));
return <div key={key} className="mx_NetworkDropdown_networkoption" onClick={clickHandler}> // the selected server is being removed, reset to our HS
{icon} if (serverSelected === server) {
<span className="mx_NetworkDropdown_menu_network">{name}</span> onOptionChange(hsName, undefined);
</div>; }
} };
removeButton = <MenuItem onClick={onClick} label={_t("Remove server")} />;
}
render() { // ARIA: in actual fact the entire menu is one large radio group but for better screen reader support
let currentValue; // we use group to notate server wrongly.
return (
<MenuGroup label={server} className="mx_NetworkDropdown_server" key={server}>
<div className="mx_NetworkDropdown_server_title">
{ server }
{ removeButton }
</div>
{ subtitle }
let menu; <MenuItemRadio
if (this.state.expanded) { active={serverSelected && !selectedInstanceId}
const menuOptions = this._getMenuOptions(); onClick={handlerFactory(server, undefined)}
menu = <div className="mx_NetworkDropdown_menu"> label={_t("Matrix")}
{menuOptions} className="mx_NetworkDropdown_server_network"
</div>; >
currentValue = <input type="text" className="mx_NetworkDropdown_networkoption" {_t("Matrix")}
ref={this.collectInputTextBox} onKeyUp={this.onInputKeyUp} </MenuItemRadio>
placeholder="matrix.org" // 'matrix.org' as an example of an HS name { entries }
/>; </MenuGroup>
} else {
const instance = instanceForInstanceId(this.props.protocols, this.state.selectedInstanceId);
currentValue = this._makeMenuOption(
this.state.selectedServer, instance, this.state.includeAllNetworks, false,
); );
});
const onClick = async () => {
closeMenu();
const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog");
const { finished } = Modal.createTrackedDialog("Network Dropdown", "Add a new server", TextInputDialog, {
title: _t("Add a new server"),
description: _t("Enter the name of a new server you want to explore."),
button: _t("Add"),
hasCancel: false,
placeholder: _t("Server name"),
validator: validServer,
fixedWidth: false,
}, "mx_NetworkDropdown_dialog");
const [ok, newServer] = await finished;
if (!ok) return;
if (!userDefinedServers.includes(newServer)) {
setUserDefinedServers([...userDefinedServers, newServer]);
}
onOptionChange(newServer); // change filter to the new server
};
const buttonRect = handle.current.getBoundingClientRect();
content = <ContextMenu {...inPlaceOf(buttonRect)} onFinished={closeMenu}>
<div className="mx_NetworkDropdown_menu">
{options}
<MenuItem className="mx_NetworkDropdown_server_add" label={undefined} onClick={onClick}>
{_t("Add a new server...")}
</MenuItem>
</div>
</ContextMenu>;
} else {
let currentValue;
if (selectedInstanceId === ALL_ROOMS) {
currentValue = _t("All rooms");
} else if (selectedInstanceId) {
const instance = instanceForInstanceId(protocols, selectedInstanceId);
currentValue = _t("%(networkName)s rooms", {
networkName: instance.desc,
});
} else {
currentValue = _t("Matrix rooms");
} }
return <div className="mx_NetworkDropdown" ref={this.collectRoot}> content = <ContextMenuButton
<div className="mx_NetworkDropdown_input mx_no_textinput" onClick={this.onInputClick}> className="mx_NetworkDropdown_handle"
onClick={openMenu}
isExpanded={menuDisplayed}
>
<span>
{currentValue} {currentValue}
<span className="mx_NetworkDropdown_arrow" /> </span> <span className="mx_NetworkDropdown_handle_server">
{menu} ({selectedServerName})
</div> </span>
</div>; </ContextMenuButton>;
} }
}
return <div className="mx_NetworkDropdown" ref={handle}>
{content}
</div>;
};
NetworkDropdown.propTypes = { NetworkDropdown.propTypes = {
onOptionChange: PropTypes.func.isRequired, onOptionChange: PropTypes.func.isRequired,
protocols: PropTypes.object, protocols: PropTypes.object,
// The room directory config. May have a 'servers' key that is a list of server names to include in the dropdown
config: PropTypes.object,
}; };
NetworkDropdown.defaultProps = { export default NetworkDropdown;
protocols: {},
config: {},
};

View file

@ -1449,6 +1449,20 @@
"And %(count)s more...|other": "And %(count)s more...", "And %(count)s more...|other": "And %(count)s more...",
"ex. @bob:example.com": "ex. @bob:example.com", "ex. @bob:example.com": "ex. @bob:example.com",
"Add User": "Add User", "Add User": "Add User",
"Enter a server name": "Enter a server name",
"Looks good": "Looks good",
"Can't find this server or its room list": "Can't find this server or its room list",
"All rooms": "All rooms",
"Your server": "Your server",
"Are you sure you want to remove <b>%(serverName)s</b>": "Are you sure you want to remove <b>%(serverName)s</b>",
"Remove server": "Remove server",
"Matrix": "Matrix",
"Add a new server": "Add a new server",
"Enter the name of a new server you want to explore.": "Enter the name of a new server you want to explore.",
"Server name": "Server name",
"Add a new server...": "Add a new server...",
"%(networkName)s rooms": "%(networkName)s rooms",
"Matrix rooms": "Matrix rooms",
"Matrix ID": "Matrix ID", "Matrix ID": "Matrix ID",
"Matrix Room ID": "Matrix Room ID", "Matrix Room ID": "Matrix Room ID",
"email address": "email address", "email address": "email address",

View file

@ -330,6 +330,10 @@ export const SETTINGS = {
supportedLevels: ['account'], supportedLevels: ['account'],
default: [], default: [],
}, },
"room_directory_servers": {
supportedLevels: ['account'],
default: [],
},
"integrationProvisioning": { "integrationProvisioning": {
supportedLevels: ['account'], supportedLevels: ['account'],
default: true, default: true,