diff --git a/README.md b/README.md index 394e7c35..6b819cce 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,32 @@ You can configure the app by copying `vector/config.sample.json` to addresses) to matrix IDs: see http://matrix.org/docs/spec/identity_service/unstable.html for more details. Currently the only public matrix identity servers are https://matrix.org and https://vector.im. In future identity servers will be decentralised. - +1. `roomDirectory`: config for the public room directory. This section encodes behaviour + on the room directory screen for filtering the list by server / network type and joining + third party networks. This config section will disappear once APIs are available to + get this information for home servers. This section is optional. +1. `roomDirectory.servers`: List of other Home Servers' directories to include in the drop + down list. Optional. +1. `roomDirectory.serverConfig`: Config for each server in `roomDirectory.servers`. Optional. +1. `roomDirectory.serverConfig..networks`: List of networks (named + in `roomDirectory.networks`) to include for this server. Optional. +1. `roomDirectory.networks`: config for each network type. Optional. +1. `roomDirectory..name`: Human-readable name for the network. Required. +1. `roomDirectory..protocol`: Protocol as given by the server in + `/_matrix/client/unstable/thirdparty/protocols` response. Required to be able to join + this type of third party network. +1. `roomDirectory..domain`: Domain as given by the server in + `/_matrix/client/unstable/thirdparty/protocols` response, if present. Required to be + able to join this type of third party network, if present in `thirdparty/protocols`. +1. `roomDirectory..portalRoomPattern`: Regular expression matching aliases + for portal rooms to locations on this network. Required. +1. `roomDirectory..icon`: URL to an icon to be displayed for this network. Required. +1. `roomDirectory..example`: Textual example of a location on this network, + eg. '#channel' for an IRC network. Optional. +1. `roomDirectory..nativePattern`: Regular expression that matches a + valid location on this network. This is used as a hint to the user to indicate + when a valid location has been entered so it's not necessary for this to be + exactly correct. Optional. Running as a Desktop app ======================== diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index a42e76b4..ca698e52 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -52,23 +52,48 @@ module.exports = React.createClass({ return { publicRooms: [], loading: true, - filterByNetwork: null, + network: null, roomServer: null, + filterString: null, } }, componentWillMount: function() { // precompile Regexps - this.networkPatterns = {}; - if (this.props.config.networkPatterns) { - for (const network of Object.keys(this.props.config.networkPatterns)) { - this.networkPatterns[network] = new RegExp(this.props.config.networkPatterns[network]); + this.portalRoomPatterns = {}; + this.nativePatterns = {}; + if (this.props.config.networks) { + for (const network of Object.keys(this.props.config.networks)) { + const network_info = this.props.config.networks[network]; + if (network_info.portalRoomPattern) { + this.portalRoomPatterns[network] = new RegExp(network_info.portalRoomPattern); + } + if (network_info.nativePattern) { + this.nativePatterns[network] = new RegExp(network_info.nativePattern); + } } } + this.nextBatch = null; - this.filterString = null; this.filterTimeout = null; this.scrollPanel = null; + this.protocols = null; + + MatrixClientPeg.get().getThirdpartyProtocols().done((response) => { + this.protocols = response; + }, (err) => { + if (MatrixClientPeg.get().isGuest()) { + // Guests currently aren't allowed to use this API, so + // ignore this as otherwise this error is literally the + // thing you see when loading the client! + return; + } + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Failed to get protocol list from Home Server", + description: "The Home Server may be too old to support third party networks", + }); + }); // dis.dispatch({ // action: 'ui_opacity', @@ -97,7 +122,7 @@ module.exports = React.createClass({ getMoreRooms: function() { if (!MatrixClientPeg.get()) return q(); - const my_filter_string = this.filterString; + const my_filter_string = this.state.filterString; const my_server = this.state.roomServer; // remember the next batch token when we sent the request // too. If it's changed, appending to the list will corrupt it. @@ -107,10 +132,10 @@ module.exports = React.createClass({ opts.server = my_server; } if (this.nextBatch) opts.since = this.nextBatch; - if (this.filterString) opts.filter = { generic_search_term: my_filter_string } ; + if (my_filter_string) opts.filter = { generic_search_term: my_filter_string } ; return MatrixClientPeg.get().publicRooms(opts).then((data) => { if ( - my_filter_string != this.filterString || + my_filter_string != this.state.filterString || my_server != this.state.roomServer || my_next_batch != this.nextBatch) { @@ -129,7 +154,7 @@ module.exports = React.createClass({ return Boolean(data.next_batch); }, (err) => { if ( - my_filter_string != this.filterString || + my_filter_string != this.state.filterString || my_server != this.state.roomServer || my_next_batch != this.nextBatch) { @@ -215,7 +240,7 @@ module.exports = React.createClass({ // to clear the list anyway. publicRooms: [], roomServer: server, - filterByNetwork: network, + network: network, }, this.refreshRoomList); // We also refresh the room list each time even though this // filtering is client-side. It hopefully won't be client side @@ -232,7 +257,9 @@ module.exports = React.createClass({ }, onFilterChange: function(alias) { - this.filterString = alias || null; + this.setState({ + filterString: alias || null, + }); // don't send the request for a little bit, // no point hammering the server with a @@ -248,17 +275,52 @@ module.exports = React.createClass({ }, onFilterClear: function() { - this.filterString = null; + // update immediately + this.setState({ + filterString: null, + }, this.refreshRoomList); if (this.filterTimeout) { clearTimeout(this.filterTimeout); } - // update immediately - this.refreshRoomList(); }, onJoinClick: function(alias) { - this.showRoomAlias(alias); + // If we're on the 'Matrix' network (or all networks), + // just show that rooms alias + if (this.state.network == null || this.state.network == '_matrix') { + this.showRoomAlias(alias); + } else { + // This is a 3rd party protocol. Let's see if we + // can join it + const fields = this._getFieldsForThirdPartyLocation(alias, this.state.network); + if (!fields) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Unable to join network", + description: "Riot does not know how to join a room on this network", + }); + return; + } + const protocol = this._protocolForThirdPartyNetwork(this.state.network); + MatrixClientPeg.get().getThirdpartyLocation(protocol, fields).done((resp) => { + if (resp.length > 0 && resp[0].alias) { + this.showRoomAlias(resp[0].alias); + } else { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Room not found", + description: "Couldn't find a matching Matrix room", + }); + } + }, (e) => { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Fetching third party location failed", + description: "Unable to look up room ID from server", + }); + }); + } }, showRoomAlias: function(alias) { @@ -311,8 +373,8 @@ module.exports = React.createClass({ if (!this.state.publicRooms) return []; var rooms = this.state.publicRooms.filter((a) => { - if (this.state.filterByNetwork) { - if (!this._isRoomInNetwork(a, this.state.roomServer, this.state.filterByNetwork)) return false; + if (this.state.network) { + if (!this._isRoomInNetwork(a, this.state.roomServer, this.state.network)) return false; } return true; @@ -382,7 +444,7 @@ module.exports = React.createClass({ * Terrible temporary function that guess what network a public room * entry is in, until synapse is able to tell us */ - _isRoomInNetwork(room, server, network) { + _isRoomInNetwork: function(room, server, network) { // We carve rooms into two categories here. 'portal' rooms are // rooms created by a user joining a bridge 'portal' alias to // participate in that room or a foreign network. A room is a @@ -396,7 +458,7 @@ module.exports = React.createClass({ if (room.aliases && room.aliases.length == 1) { if (this.props.config.serverConfig && this.props.config.serverConfig[server] && this.props.config.serverConfig[server].networks) { for (const n of this.props.config.serverConfig[server].networks) { - const pat = this.networkPatterns[n]; + const pat = this.portalRoomPatterns[n]; if (pat && pat.test(room.aliases[0])) { roomNetwork = n; } @@ -406,6 +468,83 @@ module.exports = React.createClass({ return roomNetwork == network; }, + _stringLooksLikeId: function(s, network) { + let pat = /^#[^\s]+:[^\s]/; + if ( + network && network != '_matrix' && + this.nativePatterns[network] + ) { + pat = this.nativePatterns[network]; + } + + return pat.test(s); + }, + + _protocolForThirdPartyNetwork: function(network) { + if ( + this.props.config.networks && + this.props.config.networks[network] && + this.props.config.networks[network].protocol + ) { + return this.props.config.networks[network].protocol; + } + }, + + _getFieldsForThirdPartyLocation: function(user_input, network) { + if (!this.props.config.networks || !this.props.config.networks[network]) return null; + + const network_info = this.props.config.networks[network]; + if (!network_info.protocol) return null; + + if (!this.protocols) return null; + + let matched_instance; + // Try to find which instance in the 'protocols' response + // matches this network. We look for a matching protocol + // and the existence of a 'domain' field and if present, + // its value. + if (this.protocols[network_info.protocol].instances.length == 1) { + const the_instance = this.protocols[network_info.protocol].instances[0]; + // If there's only one instance in this protocol, use it + // as long as it has no domain (which we assume to mean it's + // there is only one possible instance). + if ( + ( + the_instance.fields.domain === undefined && + network_info.domain === undefined + ) || + ( + the_instance.fields.domain !== undefined && + the_instance.fields.domain == network_info.domain + ) + ) { + matched_instance = the_instance; + } + } else if (network_info.domain) { + // otherwise, we look for one with a matching domain. + for (const this_instance of this.protocols[network_info.protocol].instances) { + if (this_instance.fields.domain == network_info.domain) { + matched_instance = this_instance; + } + } + } + + if (matched_instance === undefined) return null; + + // now make an object with the fields specified by that protocol. We + // require that the values of all but the last field come from the + // instance. The last is the user input. + const required_fields = this.protocols[network_info.protocol].location_fields; + const fields = {}; + for (let i = 0; i < required_fields.length - 1; ++i) { + const this_field = required_fields[i]; + if (matched_instance.fields[this_field] === undefined) return null; + fields[this_field] = matched_instance.fields[this_field]; + } + fields[required_fields[required_fields.length - 1]] = user_input; + return fields; + }, + render: function() { let content; if (this.state.loading) { @@ -435,6 +574,23 @@ module.exports = React.createClass({ } } + let placeholder = 'Search for a room'; + if (this.state.network === null || this.state.network === '_matrix') { + placeholder = '#example:' + this.state.roomServer; + } else if ( + this.props.config.networks && + this.props.config.networks[this.state.network] && + this.props.config.networks[this.state.network].example && + this._getFieldsForThirdPartyLocation(this.state.filterString, this.state.network) + ) { + placeholder = this.props.config.networks[this.state.network].example; + } + + const showJoinButton = ( + this._stringLooksLikeId(this.state.filterString, this.state.network) && + this._getFieldsForThirdPartyLocation(this.state.filterString, this.state.network) + ); + const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader'); const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown'); const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox'); @@ -446,6 +602,7 @@ module.exports = React.createClass({ diff --git a/src/components/views/directory/NetworkDropdown.js b/src/components/views/directory/NetworkDropdown.js index e6596319..eb60c4a5 100644 --- a/src/components/views/directory/NetworkDropdown.js +++ b/src/components/views/directory/NetworkDropdown.js @@ -40,7 +40,7 @@ export default class NetworkDropdown extends React.Component { this.props.config.serverConfig && this.props.config.serverConfig[server] && this.props.config.serverConfig[server].networks && - '_matrix' in this.props.config.serverConfig[server].networks + this.props.config.serverConfig[server].networks.indexOf('_matrix') > -1 ) { defaultNetwork = '_matrix'; } @@ -170,8 +170,22 @@ export default class NetworkDropdown extends React.Component { icon = ; span_class = 'mx_NetworkDropdown_menu_network'; } else { - name = this.props.config.networkNames[network]; - icon = ; + if (this.props.config.networks[network] === undefined) { + throw new Error(network + ' network missing from config'); + } + if (this.props.config.networks[network].name) { + name = this.props.config.networks[network].name; + } else { + name = network; + } + if (this.props.config.networks[network].icon) { + // omit height here so if people define a non-square logo in the config, it + // will keep the aspect when it scales + icon = ; + } else { + icon = ; + } + span_class = 'mx_NetworkDropdown_menu_network'; } @@ -199,6 +213,7 @@ export default class NetworkDropdown extends React.Component { ; current_value = } else { current_value = this._makeMenuOption( diff --git a/vector/config.sample.json b/vector/config.sample.json index 37043d0f..0e3392a2 100644 --- a/vector/config.sample.json +++ b/vector/config.sample.json @@ -15,24 +15,53 @@ "_matrix", "gitter", "irc:freenode", - "irc:mozilla" + "irc:mozilla", + "irc:snoonet", + "irc:oftc" ] } }, - "networkPatterns": { - "gitter": "#gitter_.*:matrix.org", - "irc:freenode": "#freenode_.*:matrix.org", - "irc:mozilla": "#mozilla_.*:matrix.org" - }, - "networkNames": { - "irc:freenode": "Freenode", - "irc:mozilla": "Mozilla", - "gitter": "Gitter" - }, - "networkIcons": { - "irc:freenode": "//matrix.org/_matrix/media/v1/download/matrix.org/DHLHpDDgWNNejFmrewvwEAHX", - "irc:mozilla": "//matrix.org/_matrix/media/v1/download/matrix.org/DHLHpDDgWNNejFmrewvwEAHX", - "gitter": "//gitter.im/favicon.ico" + "networks": { + "gitter": { + "protocol": "gitter", + "portalRoomPattern": "#gitter_.*:matrix.org", + "name": "Gitter", + "icon": "//gitter.im/favicon.ico", + "example": "org/community", + "nativePattern": "[^\\s]+/[^\\s]+$" + }, + "irc:freenode": { + "portalRoomPattern": "#freenode_.*:matrix.org", + "name": "Freenode", + "icon": "//matrix.org/_matrix/media/v1/download/matrix.org/DHLHpDDgWNNejFmrewvwEAHX", + "example": "#channel", + "nativePattern": "^#[^\\s]+$" + }, + "irc:mozilla": { + "portalRoomPattern": "#mozilla_.*:matrix.org", + "name": "Mozilla", + "icon": "//matrix.org/_matrix/media/v1/download/matrix.org/DHLHpDDgWNNejFmrewvwEAHX", + "example": "#channel", + "nativePattern": "^#[^\\s]+$" + }, + "irc:snoonet": { + "protocol": "irc", + "domain": "ipv6-irc.snoonet.org", + "portalRoomPattern": "#_snoonet_.*:matrix.org", + "name": "Snoonet", + "icon": "//matrix.org/_matrix/media/v1/download/matrix.org/DHLHpDDgWNNejFmrewvwEAHX", + "example": "#channel", + "nativePattern": "^#[^\\s]+$" + }, + "irc:oftc": { + "protocol": "irc", + "domain": "irc.oftc.net", + "portalRoomPattern": "#_oftc_.*:matrix.org", + "name": "OFTC", + "icon": "//matrix.org/_matrix/media/v1/download/matrix.org/DHLHpDDgWNNejFmrewvwEAHX", + "example": "#channel", + "nativePattern": "^#[^\\s]+$" + } } } }