diff --git a/res/css/structures/_RoomDirectory.scss b/res/css/structures/_RoomDirectory.scss index 241c921f20..d6ae0bb739 100644 --- a/res/css/structures/_RoomDirectory.scss +++ b/res/css/structures/_RoomDirectory.scss @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +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. @@ -45,7 +46,7 @@ limitations under the License. } .mx_RoomDirectory_listheader { - display: flex; + display: block; margin-top: 13px; margin-bottom: 13px; } diff --git a/res/css/views/directory/_NetworkDropdown.scss b/res/css/views/directory/_NetworkDropdown.scss index d402f6c48f..3dc3a99dd4 100644 --- a/res/css/views/directory/_NetworkDropdown.scss +++ b/res/css/views/directory/_NetworkDropdown.scss @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +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. @@ -15,70 +16,143 @@ limitations under the License. */ .mx_NetworkDropdown { + height: 32px; position: relative; -} + width: max-content; + padding-right: 32px; + margin-left: auto; + margin-right: 9px; + margin-top: 12px; -.mx_NetworkDropdown_input { - position: relative; - 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_AccessibleButton { + width: max-content; + } } .mx_NetworkDropdown_menu { - position: absolute; - left: -1px; - right: -1px; - top: 100%; - z-index: 2; + //position: absolute; + //left: -1px; + //right: -1px; + //top: 100%; + //z-index: 2; + width: 204px; margin: 0; - padding: 0px; - border-radius: 3px; + box-sizing: border-box; + border-radius: 4px; border: 1px solid $accent-color; background-color: $primary-bg-color; } -.mx_NetworkDropdown_menu .mx_NetworkDropdown_networkoption:hover { - background-color: $focus-bg-color; -} - .mx_NetworkDropdown_menu_network { 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: 0; + + &::before { + content: ""; + position: absolute; + width: 16px; + height: 16px; + right: 12px; + top: 4px; + 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; + + &::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; + } +} diff --git a/res/css/views/elements/_DirectorySearchBox.scss b/res/css/views/elements/_DirectorySearchBox.scss index ef944f6fa0..75ef3fbabd 100644 --- a/res/css/views/elements/_DirectorySearchBox.scss +++ b/res/css/views/elements/_DirectorySearchBox.scss @@ -18,7 +18,6 @@ limitations under the License. display: flex; padding-left: 9px; padding-right: 9px; - margin: 0 5px 0 0 !important; } .mx_DirectorySearchBox_joinButton { diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index a0b9a8fe57..0099af8398 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -600,9 +600,8 @@ export default createReactClass({ break; case 'view_room_directory': { const RoomDirectory = sdk.getComponent("structures.RoomDirectory"); - Modal.createTrackedDialog('Room directory', '', RoomDirectory, { - config: this.props.config, - }, 'mx_RoomDirectory_dialogWrapper'); + Modal.createTrackedDialog('Room directory', '', RoomDirectory, {}, + 'mx_RoomDirectory_dialogWrapper', false, true); // View the welcome or home page if we need something to look at this._viewSomethingBehindModal(); diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index 3c3d67578e..ddc172fca7 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -28,6 +28,7 @@ import { _t } from '../../languageHandler'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; import Analytics from '../../Analytics'; import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; +import {ALL_ROOMS} from "../views/directory/NetworkDropdown"; const MAX_NAME_LENGTH = 80; const MAX_TOPIC_LENGTH = 160; @@ -40,25 +41,17 @@ export default createReactClass({ displayName: 'RoomDirectory', propTypes: { - config: PropTypes.object, onFinished: PropTypes.func.isRequired, }, - getDefaultProps: function() { - return { - config: {}, - }; - }, - getInitialState: function() { return { publicRooms: [], loading: true, protocolsLoading: true, error: null, - instanceId: null, - includeAll: false, - roomServer: null, + instanceId: undefined, + roomServer: MatrixClientPeg.getHomeserverName(), filterString: null, }; }, @@ -98,6 +91,10 @@ export default createReactClass({ }); }, + componentDidMount: function() { + this.refreshRoomList(); + }, + componentWillUnmount: function() { if (this.filterTimeout) { clearTimeout(this.filterTimeout); @@ -130,10 +127,10 @@ export default createReactClass({ if (my_server != MatrixClientPeg.getHomeserverName()) { opts.server = my_server; } - if (this.state.instanceId) { - opts.third_party_instance_id = this.state.instanceId; - } else if (this.state.includeAll) { + if (this.state.instanceId === ALL_ROOMS) { 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 (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 this.nextBatch = null; this.setState({ @@ -257,7 +254,6 @@ export default createReactClass({ publicRooms: [], roomServer: server, instanceId: instanceId, - includeAll: includeAll, error: null, }, this.refreshRoomList); // We also refresh the room list each time even though this @@ -305,7 +301,7 @@ export default createReactClass({ onJoinFromSearchClick: function(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 // in the dropdown if (alias.indexOf(':') == -1) { @@ -587,7 +583,7 @@ export default createReactClass({ } 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}); } else if (instance_expected_field_type) { placeholder = instance_expected_field_type.placeholder; @@ -604,10 +600,18 @@ export default createReactClass({ listHeader =
+ -
; } const explanation = diff --git a/src/components/views/dialogs/TextInputDialog.js b/src/components/views/dialogs/TextInputDialog.js index 0ffc072cc0..4b21e8206e 100644 --- a/src/components/views/dialogs/TextInputDialog.js +++ b/src/components/views/dialogs/TextInputDialog.js @@ -28,9 +28,11 @@ export default createReactClass({ PropTypes.string, ]), value: PropTypes.string, + placeholder: PropTypes.string, button: PropTypes.string, focus: PropTypes.bool, onFinished: PropTypes.func.isRequired, + hasCancel: PropTypes.bool, }, getDefaultProps: function() { @@ -39,6 +41,7 @@ export default createReactClass({ value: "", description: "", focus: true, + hasCancel: true, }; }, @@ -80,13 +83,17 @@ export default createReactClass({ className="mx_TextInputDialog_input" defaultValue={this.props.value} autoFocus={this.props.focus} + placeholder={this.props.placeholder} size="64" /> - + onCancel={this.onCancel} + hasCancel={this.props.hasCancel} + /> ); }, diff --git a/src/components/views/directory/NetworkDropdown.js b/src/components/views/directory/NetworkDropdown.js index cb6a015d86..229f80ef29 100644 --- a/src/components/views/directory/NetworkDropdown.js +++ b/src/components/views/directory/NetworkDropdown.js @@ -1,6 +1,7 @@ /* Copyright 2016 OpenMarket Ltd 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"); you may not use this file except in compliance with the License. @@ -17,239 +18,225 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; + import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {instanceForInstanceId} from '../../../utils/DirectoryUtils'; +import {ContextMenu, useContextMenu, ContextMenuButton, MenuItemRadio, MenuItem} 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 AccessibleButton from "../elements/AccessibleButton"; -const DEFAULT_ICON_URL = require("../../../../res/img/network-matrix.svg"); +export const ALL_ROOMS = Symbol("ALL_ROOMS"); -export default class NetworkDropdown extends React.Component { - constructor(props) { - super(props); +const SETTING_NAME = "room_directory_servers"; - this.dropdownRootElement = null; - this.ignoreEvent = null; +const inPlaceOf = (elementRect) => ({ + right: window.innerWidth - elementRect.right, + top: elementRect.top, + chevronOffset: 0, + chevronFace: "none", +}); - 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.onInputKeyUp = this.onInputKeyUp.bind(this); - this.collectRoot = this.collectRoot.bind(this); - this.collectInputTextBox = this.collectInputTextBox.bind(this); +// 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. - this.inputTextBox = null; +const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, selectedInstanceId}) => { + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); + const userDefinedServers = useSettingValue(SETTING_NAME); - const server = MatrixClientPeg.getHomeserverName(); - this.state = { - expanded: false, - selectedServer: server, - selectedInstanceId: null, - includeAllNetworks: false, + const handlerFactory = (server, instanceId) => { + return () => { + onOptionChange(server, instanceId); + closeMenu(); }; - } + }; - componentWillMount() { - // 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); + // we either show the button or the dropdown in its place. + let content; + if (menuDisplayed) { + const config = SdkConfig.get(); + const roomDirectory = config.roomDirectory || {}; - // fire this now so the defaults can be set up - const {selectedServer, selectedInstanceId, includeAllNetworks} = this.state; - this.props.onOptionChange(selectedServer, selectedInstanceId, includeAllNetworks); - } + const hsName = MatrixClientPeg.getHomeserverName(); + const configServers = new Set(roomDirectory.servers); - componentWillUnmount() { - document.removeEventListener('click', this.onDocumentClick, false); - } - - componentDidUpdate() { - if (this.state.expanded && this.inputTextBox) { - this.inputTextBox.focus(); - } - } - - onDocumentClick(ev) { - // 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()); - } + // configured servers take preference over user-defined ones, if one occurs in both ignore the latter one. + const removableServers = new Set(userDefinedServers.filter(s => !configServers.has(s) && s !== hsName)); + 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(), + ...Array.from(removableServers).sort(), + ]; // 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. // We can't get thirdparty protocols for remote server yet though, so for those // we can only show the default room list. - for (const server of servers) { - options.push(this._makeMenuOption(server, null, true)); - if (server === MatrixClientPeg.getHomeserverName()) { - 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 options = servers.map(server => { + const serverSelected = server === selectedServerName; + const entries = []; - const sortedInstances = this.props.protocols[proto].instances; - sortedInstances.sort(function(x, y) { - const a = x.desc; - const b = y.desc; - if (a < b) { - return -1; - } else if (a > b) { - return 1; - } else { - return 0; - } - }); - - for (const instance of sortedInstances) { - if (!instance.instance_id) continue; - options.push(this._makeMenuOption(server, instance, false)); - } - } - } + const protocolsList = server === hsName ? Object.values(protocols) : []; + if (protocolsList.length > 0) { + // add a fake protocol with the ALL_ROOMS symbol + protocolsList.push({ + instances: [{ + instance_id: ALL_ROOMS, + desc: _t("All rooms"), + }], + }); } - } - return options; - } + protocolsList.forEach(({instances=[]}) => { + [...instances].sort((b, a) => { + return a.desc.localeCompare(b.desc); + }).forEach(({desc, instance_id: instanceId}) => { + entries.push( + + { desc } + ); + }); + }); - _makeMenuOption(server, instance, includeAll, handleClicks) { - if (handleClicks === undefined) handleClicks = true; + let subtitle; + if (server === hsName) { + subtitle = ( +
+ {_t("Your server")} +
+ ); + } - let icon; - let name; - let key; + let removeButton; + if (removableServers.has(server)) { + 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 %(serverName)s", { + serverName: server, + }, { + b: serverName => { serverName }, + }), + button: _t("Remove"), + }); - if (!instance && includeAll) { - key = server; - name = server; - } else if (!instance) { - key = server + '_all'; - name = 'Matrix'; - icon = ; - } else { - key = server + '_inst_' + instance.instance_id; - const imgUrl = instance.icon ? - MatrixClientPeg.get().mxcUrlToHttp(instance.icon, 25, 25, 'crop', true) : - DEFAULT_ICON_URL; - icon = ; - name = instance.desc; - } + const [ok] = await finished; + if (!ok) return; - const clickHandler = handleClicks ? this.onMenuOptionClick.bind(this, server, instance, includeAll) : null; + // delete from setting + await SettingsStore.setValue(SETTING_NAME, null, "account", servers.filter(s => s !== server)); - return
- {icon} - {name} -
; - } + // the selected server is being removed, reset to our HS + if (serverSelected === server) { + onOptionChange(hsName, undefined); + } + }; + removeButton = ; + } - render() { - let currentValue; + return ( +
+
+ { server } + { removeButton } +
+ { subtitle } - let menu; - if (this.state.expanded) { - const menuOptions = this._getMenuOptions(); - menu =
- {menuOptions} -
; - currentValue = ; - } else { - const instance = instanceForInstanceId(this.props.protocols, this.state.selectedInstanceId); - currentValue = this._makeMenuOption( - this.state.selectedServer, instance, this.state.includeAllNetworks, false, + + {_t("Matrix")} + + { entries } +
); + }); + + 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 address of a new server you want to explore."), + button: _t("Add"), + hasCancel: false, + placeholder: _t("Server address"), + }); + + const [ok, newServer] = await finished; + if (!ok) return; + + if (!userDefinedServers.includes(newServer)) { + const servers = [...userDefinedServers, newServer]; + await SettingsStore.setValue(SETTING_NAME, null, "account", servers); + } + + onOptionChange(newServer); // change filter to the new server + }; + + const buttonRect = handle.current.getBoundingClientRect(); + content = +
+ {options} + + {_t("Add a new server...")} + +
+
; + } 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
-
+ content = + {currentValue} - - {menu} -
-
; + + ({selectedServerName}) + + ; } -} + + return
+ {content} +
; +}; NetworkDropdown.propTypes = { onOptionChange: PropTypes.func.isRequired, 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 = { - protocols: {}, - config: {}, -}; +export default NetworkDropdown; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 2f8f5c48ad..a5763fe5c5 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1437,6 +1437,15 @@ "And %(count)s more...|other": "And %(count)s more...", "ex. @bob:example.com": "ex. @bob:example.com", "Add User": "Add User", + "All rooms": "All rooms", + "Your server": "Your server", + "Matrix": "Matrix", + "Add a new server": "Add a new server", + "Enter the address of a new server you want to explore.": "Enter the address of a new server you want to explore.", + "Server address": "Server address", + "Add a new server...": "Add a new server...", + "%(networkName)s rooms": "%(networkName)s rooms", + "Matrix rooms": "Matrix rooms", "Matrix ID": "Matrix ID", "Matrix Room ID": "Matrix Room ID", "email address": "email address", diff --git a/src/settings/Settings.js b/src/settings/Settings.js index b77fb392e9..8024c411f7 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -324,6 +324,10 @@ export const SETTINGS = { supportedLevels: ['account'], default: [], }, + "room_directory_servers": { + supportedLevels: ['account'], + default: [], + }, "integrationProvisioning": { supportedLevels: ['account'], default: true,