diff --git a/README.md b/README.md index 066802c072..b0ea2124fb 100644 --- a/README.md +++ b/README.md @@ -81,33 +81,9 @@ You can configure the app by copying `config.sample.json` to and https://vector.im. In future identity servers will be decentralised. 1. `integrations_ui_url`: URL to the web interface for the integrations server. 1. `integrations_rest_url`: URL to the REST interface for the integrations server. -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`: config for the public room directory. 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. If set, this will - override any networks sent by the Home Server (eg. if ASes are configured). -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. 1. `update_base_url` (electron app only): HTTPS URL to a web server to download updates from. This should be the path to the directory containing `macos` and `win32` (for update packages, not installer packages). diff --git a/config.sample.json b/config.sample.json index a25bdb110d..e6384221c1 100644 --- a/config.sample.json +++ b/config.sample.json @@ -8,64 +8,6 @@ "roomDirectory": { "servers": [ "matrix.org" - ], - "serverConfig": { - "matrix.org": { - "networks": [ - "_matrix", - "gitter", - "irc:freenode", - "irc:mozilla", - "irc:snoonet", - "irc:oftc" - ] - } - }, - "networks": { - "gitter": { - "protocol": "gitter", - "portalRoomPattern": "#gitter_.*:matrix.org", - "name": "Gitter", - "icon": "//gitter.im/favicon.ico", - "example": "org/community", - "nativePattern": "[^\\s]+/[^\\s]+$" - }, - "irc:freenode": { - "protocol": "irc", - "domain": "chat.freenode.net", - "portalRoomPattern": "#freenode_.*:matrix.org", - "name": "Freenode", - "icon": "//matrix.org/_matrix/media/v1/download/matrix.org/DHLHpDDgWNNejFmrewvwEAHX", - "example": "#channel", - "nativePattern": "^#[^\\s]+$" - }, - "irc:mozilla": { - "protocol": "irc", - "domain": "irc.mozilla.org", - "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]+$" - } - } + ] } } diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index 7960b4cf86..372b1cda68 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -31,6 +31,8 @@ var linkifyMatrix = require('matrix-react-sdk/lib/linkify-matrix'); var sanitizeHtml = require('sanitize-html'); var q = require('q'); +import {instanceForInstanceId, protocolNameForInstanceId} from '../../utils/DirectoryUtils'; + linkifyMatrix(linkify); module.exports = React.createClass({ @@ -42,9 +44,7 @@ module.exports = React.createClass({ getDefaultProps: function() { return { - config: { - networks: [], - }, + config: {}, } }, @@ -52,37 +52,26 @@ module.exports = React.createClass({ return { publicRooms: [], loading: true, - network: null, - instance_id: null, + protocolsLoading: true, + instanceId: null, + includeAll: false, roomServer: null, filterString: null, } }, componentWillMount: function() { - // precompile Regexps - 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.filterTimeout = null; this.scrollPanel = null; this.protocols = null; + this.setState({protocolsLoading: true}); MatrixClientPeg.get().getThirdpartyProtocols().done((response) => { this.protocols = response; + this.setState({protocolsLoading: false}); }, (err) => { + this.setState({protocolsLoading: false}); if (MatrixClientPeg.get().isGuest()) { // Guests currently aren't allowed to use this API, so // ignore this as otherwise this error is literally the @@ -132,9 +121,9 @@ module.exports = React.createClass({ if (my_server != MatrixClientPeg.getHomeServerName()) { opts.server = my_server; } - if (this.state.instance_id) { - opts.third_party_instance_id = this.state.instance_id; - } else if (this.state.network !== '_matrix') { + if (this.state.instanceId) { + opts.third_party_instanceId = this.state.instanceId; + } else if (this.state.includeAll) { opts.include_all_networks = true; } if (this.nextBatch) opts.since = this.nextBatch; @@ -237,7 +226,7 @@ module.exports = React.createClass({ } }, - onOptionChange: function(server, network, instance_id) { + onOptionChange: function(server, instanceId, includeAll) { // clear next batch so we don't try to load more rooms this.nextBatch = null; this.setState({ @@ -246,8 +235,8 @@ module.exports = React.createClass({ // to clear the list anyway. publicRooms: [], roomServer: server, - network: network, - instance_id: instance_id, + instanceId: instanceId, + includeAll: includeAll, }, this.refreshRoomList); // We also refresh the room list each time even though this // filtering is client-side. It hopefully won't be client side @@ -278,7 +267,7 @@ module.exports = React.createClass({ this.filterTimeout = setTimeout(() => { this.filterTimeout = null; this.refreshRoomList(); - }, 300); + }, 700); }, onFilterClear: function() { @@ -295,12 +284,19 @@ module.exports = React.createClass({ onJoinClick: function(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') { + if (!this.state.instanceId) { + // If the user specified an alias without a domain, add on whichever server is selected + // in the dropdown + if (alias.indexOf(':') == -1) { + alias = alias + ':' + this.state.roomServer; + } 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); + const protocol_name = protocolNameForInstanceId(this.protocols, this.state.instanceId); + const instance = instanceForInstanceId(this.protocols, this.state.instanceId); + const fields = this._getFieldsForThirdPartyLocation(alias, this.protocols[protocol_name], instance); if (!fields) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { @@ -309,8 +305,7 @@ module.exports = React.createClass({ }); return; } - const protocol = this._protocolForThirdPartyNetwork(this.state.network); - MatrixClientPeg.get().getThirdpartyLocation(protocol, fields).done((resp) => { + MatrixClientPeg.get().getThirdpartyLocation(protocol_name, fields).done((resp) => { if (resp.length > 0 && resp[0].alias) { this.showRoomAlias(resp[0].alias); } else { @@ -379,13 +374,7 @@ module.exports = React.createClass({ if (!this.state.publicRooms) return []; - var rooms = this.state.publicRooms.filter((a) => { - if (this.state.network) { - if (!this._isRoomInNetwork(a, this.state.roomServer, this.state.network)) return false; - } - - return true; - }); + var rooms = this.state.publicRooms; var rows = []; var self = this; var guestRead, guestJoin, perms; @@ -447,119 +436,46 @@ module.exports = React.createClass({ this.scrollPanel = element; }, - /** - * Terrible temporary function that guess what network a public room - * entry is in, until synapse is able to tell us - */ - _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 - // portal room if it has exactly one alias and that alias matches - // a pattern defined in the config. Its network is the key - // of the pattern that it matches. - // All other rooms are considered 'native matrix' rooms, and - // go into the special '_matrix' network. - - let roomNetwork = '_matrix'; - 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.portalRoomPatterns[n]; - if (pat && pat.test(room.aliases[0])) { - roomNetwork = n; - } - } - } - } - return roomNetwork == network; - }, - - _stringLooksLikeId: function(s, network) { + _stringLooksLikeId: function(s, field_type) { let pat = /^#[^\s]+:[^\s]/; - if ( - network && network != '_matrix' && - this.nativePatterns[network] - ) { - pat = this.nativePatterns[network]; + if (field_type && field_type.regexp) { + pat = new RegExp(field_type.regexp); } 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] && - this.protocols[network_info.protocol].instances && - 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 + _getFieldsForThirdPartyLocation: function(user_input, protocol, instance) { + // 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 required_fields = protocol.location_fields; + if (!required_fields) return null; 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]; + if (instance.fields[this_field] === undefined) return null; + fields[this_field] = instance.fields[this_field]; } fields[required_fields[required_fields.length - 1]] = user_input; return fields; }, render: function() { + const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader'); + const Loader = sdk.getComponent("elements.Spinner"); + + if (this.state.protocolsLoading) { + return ( +
+ + +
+ ); + } + let content; if (this.state.loading) { - const Loader = sdk.getComponent("elements.Spinner"); content =
; @@ -590,26 +506,35 @@ 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) + const protocol_name = protocolNameForInstanceId(this.protocols, this.state.instanceId); + let instance_expected_field_type; + if ( + protocol_name && + this.protocols && + this.protocols[protocol_name] && + this.protocols[protocol_name].location_fields.length > 0 && + this.protocols[protocol_name].field_types ) { - placeholder = this.props.config.networks[this.state.network].example; + const last_field = this.protocols[protocol_name].location_fields.slice(-1)[0]; + instance_expected_field_type = this.protocols[protocol_name].field_types[last_field]; } - let showJoinButton = this._stringLooksLikeId(this.state.filterString, this.state.network); - if (this.state.network && this.state.network != '_matrix') { - if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.state.network) === null) { + + let placeholder = 'Search for a room'; + if (!this.state.instanceId) { + placeholder = '#example:' + this.state.roomServer; + } else if (instance_expected_field_type) { + placeholder = instance_expected_field_type.placeholder; + } + + let showJoinButton = this._stringLooksLikeId(this.state.filterString, instance_expected_field_type); + if (protocol_name) { + const instance = instanceForInstanceId(this.protocols, this.state.instanceId); + if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.protocols[protocol_name], instance) === null) { showJoinButton = false; } } - const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader'); const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown'); const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox'); return ( diff --git a/src/components/views/directory/NetworkDropdown.js b/src/components/views/directory/NetworkDropdown.js index 4b6de26eaf..155dd01ad4 100644 --- a/src/components/views/directory/NetworkDropdown.js +++ b/src/components/views/directory/NetworkDropdown.js @@ -16,6 +16,7 @@ limitations under the License. import React from 'react'; import MatrixClientPeg from 'matrix-react-sdk/lib/MatrixClientPeg'; +import {instanceForInstanceId} from '../../../utils/DirectoryUtils'; const DEFAULT_ICON_URL = "img/network-matrix.svg"; @@ -30,7 +31,6 @@ export default class NetworkDropdown extends React.Component { this.onRootClick = this.onRootClick.bind(this); this.onDocumentClick = this.onDocumentClick.bind(this); this.onMenuOptionClick = this.onMenuOptionClick.bind(this); - this.onMenuOptionClickProtocolInstance = this.onMenuOptionClickProtocolInstance.bind(this); this.onInputKeyUp = this.onInputKeyUp.bind(this); this.collectRoot = this.collectRoot.bind(this); this.collectInputTextBox = this.collectInputTextBox.bind(this); @@ -38,20 +38,11 @@ export default class NetworkDropdown extends React.Component { this.inputTextBox = null; const server = MatrixClientPeg.getHomeServerName(); - let defaultNetwork = null; - if ( - this.props.config.serverConfig && - this.props.config.serverConfig[server] && - this.props.config.serverConfig[server].networks && - this.props.config.serverConfig[server].networks.indexOf('_matrix') > -1 - ) { - defaultNetwork = '_matrix'; - } - this.state = { expanded: false, selectedServer: server, - selectedNetwork: defaultNetwork, + selectedInstance: null, + includeAllNetworks: false, }; } @@ -61,7 +52,7 @@ export default class NetworkDropdown extends React.Component { document.addEventListener('click', this.onDocumentClick, false); // fire this now so the defaults can be set up - this.props.onOptionChange(this.state.selectedServer, this.state.selectedNetwork); + this.props.onOptionChange(this.state.selectedServer, this.state.selectedInstance, this.state.includeAllNetworks); } componentWillUnmount() { @@ -101,24 +92,14 @@ export default class NetworkDropdown extends React.Component { ev.preventDefault(); } - onMenuOptionClick(server, network) { + onMenuOptionClick(server, instance, includeAll) { this.setState({ expanded: false, selectedServer: server, - selectedNetwork: network, - selectedInstanceId: null, + selectedInstanceId: instance ? instance.instance_id : null, + includeAll: includeAll, }); - this.props.onOptionChange(server, network); - } - - onMenuOptionClickProtocolInstance(server, instance_id) { - this.setState({ - expanded: false, - selectedServer: server, - selectedNetwork: null, - selectedInstanceId: instance_id, - }); - this.props.onOptionChange(server, null, instance_id); + this.props.onOptionChange(server, instance ? instance.instance_id : null, includeAll); } onInputKeyUp(e) { @@ -158,33 +139,21 @@ export default class NetworkDropdown extends React.Component { servers.unshift(MatrixClientPeg.getHomeServerName()); } - // if the thirdparty/protocols entries have instance_ids, - // we can get the local server listings from here. If not, - // the server is too old. - let use_protocols = true; - for (const proto of Object.keys(this.props.protocols)) { - if (!this.props.protocols[proto].instances) continue; - for (const instance of this.props.protocols[proto].instances) { - if (!instance.instance_id) use_protocols = false; - } - } - // 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 (if the - // server is new enough), although for now we prefer the config if it exists. - // For remote HSes, we use the data from the config. + // 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)); - if (this.props.config.serverConfig && this.props.config.serverConfig[server] && this.props.config.serverConfig[server].networks) { - for (const network of this.props.config.serverConfig[server].networks) { - options.push(this._makeMenuOption(server, network)); - } - } else if (server == MatrixClientPeg.getHomeServerName() && use_protocols) { - options.push(this._makeMenuOption(server, '_matrix')); - for (const proto of Object.keys(this.props.protocols)) { - if (!this.props.protocols[proto].instances) continue; - for (const instance of this.props.protocols[proto].instances) { - options.push(this._makeMenuOptionFromProtocolInstance(server, this.props.protocols[proto], instance)); + 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; + for (const instance of this.props.protocols[proto].instances) { + if (!instance.instance_id) continue; + options.push(this._makeMenuOption(server, instance, false)); + } } } } @@ -193,82 +162,36 @@ export default class NetworkDropdown extends React.Component { return options; } - _makeMenuOptionFromProtocolInstance(server, protocol, instance, handleClicks) { + _makeMenuOption(server, instance, includeAll, handleClicks) { if (handleClicks === undefined) handleClicks = true; - const name = instance.desc; - const icon = ; - const key = instance.instance_id; - const click_handler = handleClicks ? this.onMenuOptionClickProtocolInstance.bind(this, server, instance.instance_id) : null; - - return
- {icon} - {name} -
; - } - - _makeMenuOption(server, network, handleClicks) { - if (handleClicks === undefined) handleClicks = true; let icon; let name; let span_class; + let key; - if (network === null) { + if (!instance && includeAll) { + key = server; name = server; span_class = 'mx_NetworkDropdown_menu_all'; - } else if (network == '_matrix') { + } else if (!instance) { + key = server + '_all'; name = 'Matrix'; icon = ; span_class = 'mx_NetworkDropdown_menu_network'; } else { - 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 = ; - } - + key = server + '_inst_'+instance.instance_id; + icon = ; + name = instance.desc; span_class = 'mx_NetworkDropdown_menu_network'; } - const click_handler = handleClicks ? this.onMenuOptionClick.bind(this, server, network) : null; - - let key = server; - if (network !== null) { - key += '_' + network; - } + const click_handler = handleClicks ? this.onMenuOptionClick.bind(this, server, instance, includeAll) : null; return
{icon} - {name} -
; - } - - _protocolNameForInstanceId(instance_id) { - for (const proto of Object.keys(this.props.protocols)) { - if (!this.props.protocols[proto].instances) continue; - for (const instance of this.props.protocols[proto].instances) { - if (instance.instance_id == instance_id) return proto; - } - } - } - - instanceForInstanceId(instance_id) { - for (const proto of Object.keys(this.props.protocols)) { - if (!this.props.protocols[proto].instances) continue; - for (const instance of this.props.protocols[proto].instances) { - if (instance.instance_id == instance_id) return instance; - } - } + {name} + } render() { @@ -285,17 +208,10 @@ export default class NetworkDropdown extends React.Component { placeholder="matrix.org" // 'matrix.org' as an example of an HS name /> } else { - if (this.state.selectedInstanceId) { - const protocolName = this._protocolNameForInstanceId(this.state.selectedInstanceId); - const instance = this.instanceForInstanceId(this.state.selectedInstanceId); - current_value = this._makeMenuOptionFromProtocolInstance( - this.state.selectedServer, this.props.protocols[protocolName], instance, false - ); - } else { - current_value = this._makeMenuOption( - this.state.selectedServer, this.state.selectedNetwork, false - ); - } + const instance = instanceForInstanceId(this.props.protocols, this.state.selectedInstanceId); + current_value = this._makeMenuOption( + this.state.selectedServer, instance, this.state.includeAll, false + ); } return
@@ -310,14 +226,11 @@ export default class NetworkDropdown extends React.Component { NetworkDropdown.propTypes = { onOptionChange: React.PropTypes.func.isRequired, - config: React.PropTypes.object, protocols: React.PropTypes.object, + config: React.PropTypes.object, }; NetworkDropdown.defaultProps = { - config: { - networks: [], - }, protocols: {}, + config: {}, }; - diff --git a/src/utils/DirectoryUtils.js b/src/utils/DirectoryUtils.js new file mode 100644 index 0000000000..9dc4d926c5 --- /dev/null +++ b/src/utils/DirectoryUtils.js @@ -0,0 +1,23 @@ +// Find a protocol 'instance' with a given instance_id +// in the supplied protocols dict +export function instanceForInstanceId(protocols, instance_id) { + if (!instance_id) return null; + for (const proto of Object.keys(protocols)) { + if (!protocols[proto].instances) continue; + for (const instance of protocols[proto].instances) { + if (instance.instance_id == instance_id) return instance; + } + } +} + +// given an instance_id, return the name of the protocol for +// that instance ID in the supplied protocols dict +export function protocolNameForInstanceId(protocols, instance_id) { + if (!instance_id) return null; + for (const proto of Object.keys(protocols)) { + if (!protocols[proto].instances) continue; + for (const instance of protocols[proto].instances) { + if (instance.instance_id == instance_id) return proto; + } + } +}