// Babel JSX 'use strict'; if (typeof require == 'function') { if (typeof React == 'undefined') { var React = require('react'); var ReactDOM = require('react-dom'); } if (typeof Tinode == 'undefined') { var Tinode = require('tinode-sdk'); var Drafty = Tinode.Drafty; } if (typeof firebase == 'undefined') { var firebase = require('firebase/app'); require('firebase/messaging'); } var package_version = require('../version.json').version; } // Name of this application, used in the User-Agent. const APP_NAME = 'TinodeWeb/' + (package_version || '0.15'); const KNOWN_HOSTS = {hosted: 'api.tinode.co', local: 'localhost:6060'}; // Default host name and port to connect to. const DEFAULT_HOST = KNOWN_HOSTS.hosted; // var DEFAULT_HOST = KNOWN_HOSTS.local; // Sound to play on message received. const POP_SOUND = new Audio('audio/msg.mp3'); // API key. Use https://github.com/tinode/chat/tree/master/keygen to generate your own const API_KEY = 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K'; // Minimum time between two keypress notifications, milliseconds. const KEYPRESS_DELAY = 3*1000; // Delay before sending a {note} for reciving a message, milliseconds. const RECEIVED_DELAY = 500; // Delay before sending a read notification, milliseconds. const READ_DELAY = 1000; // The shortest allowed tag length. Matches one on the server. const MIN_TAG_LENGTH = 4; // Mediaquery breakpoint between desktop and mobile, in px. Should match the value // in @meadia (max-size: 640px) in base.css const MEDIA_BREAKPOINT = 640; // Size of css 'rem' unit in pixels. Default 1rem = 10pt = 13px. const REM_SIZE = 13; // Size of the avatar image const AVATAR_SIZE = 128; // Number of chat messages to fetch in one call. const MESSAGES_PAGE = 24; // Maximum in-band (included directly into the message) attachment size which fits into // a message of 256K in size, assuming base64 encoding and 1024 bytes of overhead. // This is size of an object *before* base64 encoding is applied. // Increase this limit to a greater value in production, if desired. Also increase // max_message_size in server config. // MAX_INBAND_ATTACHMENT_SIZE = base64DecodedLen(max_message_size - overhead); const MAX_INBAND_ATTACHMENT_SIZE = 195840; // Absolute maximum attachment size to be used with the server = 8MB. Increase to // something like 100MB in production. const MAX_EXTERN_ATTACHMENT_SIZE = 1 << 23; // Maximum allowed linear dimension of an inline image in pixels. You may want // to adjust it to 1600 or 2400 for production. const MAX_IMAGE_DIM = 768; // Supported image MIME types and corresponding file extensions. const SUPPORTED_IMAGE_FORMATS = ['image/jpeg', 'image/gif', 'image/png', 'image/svg', 'image/svg+xml']; const MIME_EXTENSIONS = ['jpg', 'gif', 'png', 'svg', 'svg']; // Helper functions for storing values in localStorage. // By default localStorage can store only strings, not objects or other types. // Replace old object with the new one. Storage.prototype.setObject = function(key, value) { this.setItem(key, JSON.stringify(value)); } // Get stored object. Storage.prototype.getObject = function(key) { let value = this.getItem(key); return value && JSON.parse(value); } // Partially or wholly update stored object. Storage.prototype.updateObject = function(key, value) { let oldVal = this.getObject(key); this.setObject(key, Object.assign(oldVal || {}, value)); } // Short representation of time in the past. function shortDateFormat(then) { var locale = window.navigator.userLanguage || window.navigator.language; var now = new Date(); if (then.getFullYear() == now.getFullYear()) { if (then.getMonth() == now.getMonth() && then.getDate() == now.getDate()) { return then.toLocaleTimeString(locale, {hour12: false, hour: '2-digit', minute: '2-digit'}); } else { return then.toLocaleDateString(locale, {hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'}); } } return then.toLocaleDateString(locale, {hour12: false, year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'}); } // Convert a number of bytes to human-readable format. function bytesToHumanSize(bytes) { if (!bytes || bytes == 0) { return '0 Bytes'; } var sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; var bucket = Math.min(Math.floor(Math.log2(bytes) / 10) | 0, sizes.length-1); var count = bytes / Math.pow(1024, bucket); var round = bucket > 0 ? (count < 3 ? 2 : (count < 30 ? 1 : 0)) : 0; return count.toFixed(round) + ' ' + sizes[bucket]; } // Make a data URL from public.photo function makeImageUrl(photo) { return (photo && photo.type && photo.data) ? 'data:image/' + photo.type + ';base64,' + photo.data : null; } // Calculate linear dimensions for scaling image down to fit under a certain size. // Returns an object which contains destination sizes, source sizes, and offsets // into source (when making square images). function fitImageSize(width, height, maxWidth, maxHeight, forceSquare) { if (!width || !height || !maxWidth || !maxHeight) { return null; } if (forceSquare) { maxWidth = maxHeight = Math.min(maxWidth, maxHeight); } let scale = Math.min( Math.min(width, maxWidth) / width, Math.min(height, maxHeight) / height ); let size = { dstWidth: (width * scale) | 0, dstHeight: (height * scale) | 0, }; if (forceSquare) { // Also calculate parameters for making the image square. size.dstWidth = size.dstHeight = Math.min(size.dstWidth, size.dstHeight); size.srcWidth = size.srcHeight = Math.min(width, height); size.xoffset = ((width - size.srcWidth) / 2) | 0; size.yoffset = ((height - size.srcWidth) / 2) | 0; } else { size.xoffset = size.yoffset = 0; size.srcWidth = width; size.srcHeight = height; } return size; } // Ensure file's extension matches mime content type function fileNameForMime(fname, mime) { var idx = SUPPORTED_IMAGE_FORMATS.indexOf(mime); var ext = MIME_EXTENSIONS[idx]; var at = fname.lastIndexOf('.'); if (at >= 0) { fname = fname.substring(0, at); } return fname + '.' + ext; } // Get mime type from data URL header. function getMimeType(header) { var mime = /^data:(image\/[-+a-z0-9.]+);base64/.exec(header); return (mime && mime.length > 1) ? mime[1] : null; } // Given length of a binary object in bytes, calculate the length after // base64 encoding. function base64EncodedLen(n) { return Math.floor((n + 2) / 3) * 4; } // Given length of a base64-encoded object, calculate decoded size of the // pbject in bytes. function base64DecodedLen(n) { return Math.floor(n / 4) * 3; } // Re-encode string to standard base64 encoding with padding. // The string may be base64-URL encoded without the padding. function base64ReEncode(str) { if (str) { str = str.replace('-', '+').replace('_', '/'); try { str = btoa(atob(str)); } catch(err) { console.log("Failed to base64 re-decode string"); str = null; } } return str; } // Convert uploaded image into a base64-encoded string possibly scaling // linear dimensions or constraining to a square. function imageFileScaledToBase64(file, width, height, forceSquare, onSuccess, onError) { var img = new Image(); img.crossOrigin = 'Anonymous'; img.onerror = function(err) { onError("Image format unrecognized"); } img.onload = function() { var dim = fitImageSize(this.width, this.height, width, height, forceSquare); if (!dim) { onError("Invalid image"); return; } var canvas = document.createElement('canvas'); canvas.width = dim.dstWidth; canvas.height = dim.dstHeight; var ctx = canvas.getContext('2d'); ctx.imageSmoothingEnabled = true; ctx.drawImage(this, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight, 0, 0, dim.dstWidth, dim.dstHeight); var mime = (this.width != dim.dstWidth || this.height != dim.dstHeight || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) ? 'image/jpeg' : file.type; var imageBits = canvas.toDataURL(mime); var parts = imageBits.split(','); // Get actual image type: 'data:image/png;base64,' mime = getMimeType(parts[0]); if (!mime) { onError("Unsupported image format"); return; } // Ensure the image is not too large var quality = 0.78; if (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE) { mime = 'image/jpeg'; } if (mime == 'image/jpeg') { // Reduce size of the jpeg by reducing image quality while (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE && quality > 0.45) { imageBits = canvas.toDataURL(mime, quality); quality *= 0.84; } } if (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE) { onError("The image size " + bytesToHumanSize(base64DecodedLen(imageBits.length)) + " exceeds the " + bytesToHumanSize(MAX_INBAND_ATTACHMENT_SIZE) + " limit.", "err"); return; } canvas = null; onSuccess(imageBits.split(',')[1], mime, dim.dstWidth, dim.dstHeight, fileNameForMime(file.name, mime)); }; img.src = URL.createObjectURL(file); } // Convert uploaded image file to base64-encoded string without scaling/converting the image function imageFileToBase64(file, onSuccess, onError) { var reader = new FileReader(); reader.addEventListener('load', function() { var parts = reader.result.split(','); var mime = getMimeType(parts[0]); if (!mime) { onError("Failed to process image file"); return; } // Get image size. var img = new Image(); img.crossOrigin = 'Anonymous'; img.onload = function() { onSuccess(parts[1], mime, this.width, this.height, fileNameForMime(file.name, mime)); } img.onerror = function(err) { onError("Image format not recognized"); } img.src = URL.createObjectURL(file); }, false); reader.readAsDataURL(file); } function fileToBase64(file, onSuccess, onError) { var reader = new FileReader(); reader.addEventListener('load', function() { onSuccess(file.type, reader.result.split(',')[1], file.name); }); reader.readAsDataURL(file); } // File pasted from the clipboard. It's either an inline image or a file attachment. // FIXME: handle large files out of band. function filePasted(event, onImageSuccess, onAttachmentSuccess, onError) { var items = (event.clipboardData || event.originalEvent.clipboardData || {}).items; for (var i in items) { var item = items[i]; if (item.kind === 'file') { var file = item.getAsFile(); if (!file) { console.log("Failed to get file object from pasted file item", item.kind, item.type); continue; } if (file.type && file.type.split('/')[0] == 'image') { // Handle inline image if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) { imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false, onImageSuccess, onError); } else { imageFileToBase64(file, onImageSuccess, onError); } } else { // Handle file attachment fileToBase64(file, onAttachmentSuccess, onError) } // Indicate that the pasted data contains a file. return true; } } // No file found. return false; } // Make shortcut icon appear with a green dot + show unread count in title. function updateFavicon(count) { var oldIcon = document.getElementById('shortcut-icon'); if (oldIcon) { var head = document.head || document.getElementsByTagName('head')[0]; var newIcon = document.createElement('link'); newIcon.type = 'image/png'; newIcon.id = 'shortcut-icon'; newIcon.rel = 'shortcut icon'; newIcon.href = 'img/logo32x32' + (count > 0 ? 'a' : '') + '.png'; head.removeChild(oldIcon); head.appendChild(newIcon); } document.title = (count > 0 ? '('+count+') ' : '') + 'Tinode'; } // Get 32 bit integer hash value for a string. Ideally it should produce the same value // as Java's String#hash(). function stringHash(value) { var hash = 0; value = '' + value; for (var i = 0; i < value.length; i++) { hash = ((hash << 5) - hash) + value.charCodeAt(i); hash = hash & hash; // Convert to 32bit integer } return hash; } // Helper functions for hash navigation. // Parse hash as in http://www.example.com/path#hash as if it were // path and arguments. function parseUrlHash(hash) { // Split path from args, path -> parts[0], args->path[1] let parts = hash.split('?', 2); let params = {}; let path = []; if (parts[0]) { path = parts[0].substr(1).split('/'); } if (parts[1]) { parts[1].split('&').forEach(function(part) { let item = part.split('='); if (item[0]) { params[decodeURIComponent(item[0])] = decodeURIComponent(item[1]); } }); } return {path: path, params: params}; } function navigateTo(url) { window.location.hash = url; } function composeUrlHash(path, params) { var url = path.join('/'); var args = []; for (var key in params) { if (params.hasOwnProperty(key)) { args.push(key + '=' + params[key]); } } if (args.length > 0) { url += '?' + args.join('&'); } return url; } function addUrlParam(hash, key, value) { var parsed = parseUrlHash(hash); parsed.params[key] = value; return composeUrlHash(parsed.path, parsed.params); } function removeUrlParam(hash, key) { var parsed = parseUrlHash(hash); delete parsed.params[key]; return composeUrlHash(parsed.path, parsed.params); } function setUrlSidePanel(hash, sidepanel) { var parsed = parseUrlHash(hash); parsed.path[0] = sidepanel; return composeUrlHash(parsed.path, parsed.params); } function setUrlTopic(hash, topic) { var parsed = parseUrlHash(hash); parsed.path[1] = topic; // Close InfoView on topic change. delete parsed.params.info; return composeUrlHash(parsed.path, parsed.params); } // END of hash navigation helper functions // Detect server address from the URL function detectServerAddress() { let host = DEFAULT_HOST; if (typeof window.location == 'object') { if (window.location.protocol == 'file:' || window.location.hostname == 'localhost') { host = KNOWN_HOSTS.local; } else if (window.location.hostname) { host = window.location.hostname + (window.location.port ? ':' + window.location.port : ''); } } return host; } // Detect if the page is served over HTTPS. function isSecureConnection() { if (typeof window.location == 'object') { return window.location.protocol == 'https:'; } return false; } function isLocalHost() { if (typeof window.location == 'object') { return window.location.hostname == 'localhost'; } return false; } // Create VCard which represents topic 'public' info function vcard(fn, imageDataUrl) { var card = null; if ((fn && fn.trim()) || imageDataUrl) { card = {}; if (fn) { card.fn = fn.trim(); } if (imageDataUrl) { var dataStart = imageDataUrl.indexOf(','); card.photo = { data: imageDataUrl.substring(dataStart+1), type: 'jpg' }; } } return card; } // Deep-shallow compare two arrays: shallow compare each element. function arrayEqual(a, b) { // Compare lengths first. if (a.length != b.length) { return false; } // Order of elements is ignored. a.sort(); b.sort(); for (var i = 0, l = a.length; i < l; i++) { if (a[i] !== b[i]) { return false; } } return true; } /* BEGIN Context Menu: popup/dropdown menu */ class ContextMenu extends React.Component { constructor(props) { super(props); this.handlePageClick = this.handlePageClick.bind(this); this.handleEscapeKey = this.handleEscapeKey.bind(this); this.handleClick = this.handleClick.bind(this); // Preconfigured menu items. this.MenuItems = { 'topic_info': {title: "Info", handler: null}, 'messages_clear': {title: "Clear messages", handler: (params, errorHandler) => { this.deleteMessages(true, false, params, errorHandler); }}, 'messages_clear_hard': {title: "Clear for All", handler: (params, errorHandler) => { this.deleteMessages(true, true, params, errorHandler); }}, 'message_delete': {title: "Delete", handler: (params, errorHandler) => { this.deleteMessages(false, false, params, errorHandler); }}, 'message_delete_hard': {title: "Delete for All", handler: (params, errorHandler) => { this.deleteMessages(false, true, params, errorHandler); }}, 'topic_unmute': {title: "Unmute", handler: this.topicPermissionSetter.bind(this, '+P')}, 'topic_mute': {title: "Mute", handler: this.topicPermissionSetter.bind(this, '-P')}, 'topic_unblock': {title: "Unblock", handler: this.topicPermissionSetter.bind(this, '+J')}, 'topic_block': {title: "Block", handler: this.topicPermissionSetter.bind(this, '-J')}, 'topic_delete': {title: "Delete", handler: (params, errorHandler) => { let topic = this.props.tinode.getTopic(params.topicName); if (!topic) { console.log("Topic not found: ", params.topicName); return; } topic.delTopic().catch((err) => { if (errorHandler) { errorHandler(err.message, 'err'); } }); }}, 'permissions': {title: "Edit permissions", handler: null}, 'member_delete': {title: "Remove", handler: (params, errorHandler) => { let topic = this.props.tinode.getTopic(params.topicName); if (!topic || !params.user) { console.log("Topic or user not found: '" + params.topicName + "', '" + params.user + "'"); return; } topic.delSubscription(params.user).catch((err) => { if (errorHandler) { errorHandler(err.message, 'err'); } }); }}, 'member_mute': {title: "Mute", handler: this.topicPermissionSetter.bind(this, '-P')}, 'member_unmute': {title: "Unmute", handler: this.topicPermissionSetter.bind(this, '+P')}, 'member_block': {title: "Block", handler: this.topicPermissionSetter.bind(this, '-J')}, 'member_unblock': {title: "Unblock", handler: this.topicPermissionSetter.bind(this, '+J')}, }; } componentDidMount() { document.addEventListener('mousedown', this.handlePageClick, false); document.addEventListener('keyup', this.handleEscapeKey, false); } componentWillUnmount() { document.removeEventListener('mousedown', this.handlePageClick, false); document.removeEventListener('keyup', this.handleEscapeKey, false); } handlePageClick(e) { if (ReactDOM.findDOMNode(this).contains(e.target)) { return; } e.preventDefault(); e.stopPropagation(); this.props.hide(); } handleEscapeKey(e) { if (e.keyCode === 27) { this.props.hide(); } } handleClick(e) { e.preventDefault(); e.stopPropagation(); this.props.hide(); let item = this.props.items[e.currentTarget.dataset.id]; if (typeof item == 'string') { item = this.MenuItems[item]; } item.handler(this.props.params, this.props.onError); } // Menu Actions deleteMessages(all, hard, params, errorHandler) { var topic = this.props.tinode.getTopic(params.topicName); if (!topic) { console.log("Topic not found: ", params.topicName); return; } // We don't know if the message is still pending (e.g. attachment is being uploaded), // so try cancelling first. No harm if we can't cancel. if (topic.cancelSend(params.seq)) { return Promise.resolve(); } // Can't cancel. Delete instead. var promise = all ? topic.delMessagesAll(hard) : topic.delMessagesList([params.seq], hard); promise.catch((err) => { if (errorHandler) { errorHandler(err.message, 'err'); } }); } // Function is used by context menu to set permissions. topicPermissionSetter(mode, params, errorHandler) { var topic = this.props.tinode.getTopic(params.topicName); if (!topic) { console.log("Topic not found", params.topicName); return; } var am, user; if (params.user) { user = topic.subscriber(params.user); if (!user) { console.log("Subscriber not found", params.topicName + "[" + params.user + "]"); return; } am = user.acs.updateGiven(mode).getGiven(); } else { am = topic.getAccessMode().updateWant(mode).getWant(); } topic.setMeta({sub: {user: params.user, mode: am}}).catch((err) => { if (errorHandler) { errorHandler(err.message, 'err'); } }); } render() { let count = 0; let menu = []; this.props.items.map((item) => { if (typeof item == 'string') { item = this.MenuItems[item]; } if (item && item.title) { menu.push( item.title == '-' ?
  • :
  • {item.title}
  • ); } count++; }); // Ensure that menu is inside the app-container. var hSize = 12 * REM_SIZE; var vSize = REM_SIZE * (0.7 + menu.length * 2.5); var left = (this.props.bounds.right - this.props.clickAt.x < hSize) ? (this.props.clickAt.x - this.props.bounds.left - hSize) : (this.props.clickAt.x - this.props.bounds.left); var top = (this.props.bounds.bottom - this.props.clickAt.y < vSize) ? (this.props.clickAt.y - this.props.bounds.top - vSize) : (this.props.clickAt.y - this.props.bounds.top); var position = { left: left + 'px', top: top + 'px' }; return ( ); } } /* END Popup/dropdown menu */ /* The X menu to be displayed in title bars */ class MenuCancel extends React.PureComponent { constructor(props) { super(props); } render() { return ( close ); } } class LoadSpinner extends React.PureComponent { render() { return (this.props.show ?
    : null); } } // Toggle [Title text >] -> [Title text v] class MoreButton extends React.PureComponent { constructor(props) { super(props); this.state = { open: props.open }; this.handleToggle = this.handleToggle.bind(this); } handleToggle() { let open = !this.state.open; this.setState({open: open}); if (this.props.onToggle) { this.props.onToggle(open); } } render() { return (); } } /* BEGIN Lettertile: Avatar box: either a bitmap or a letter tile or a stock icon. */ class LetterTile extends React.PureComponent { render() { let avatar; if (this.props.avatar === true) { if (this.props.topic && this.props.title && this.props.title.trim()) { let letter = this.props.title.trim().charAt(0); let color = "lettertile dark-color" + (Math.abs(stringHash(this.props.topic)) % 16); avatar = (
    {letter}
    ) } else { avatar = (Tinode.topicType(this.props.topic) == 'grp') ? group : person; } } else if (this.props.avatar) { avatar = avatar; } else { avatar = null; } return avatar; } } /* END Lettertile */ /* BEGIN In-place text editor. Shows text with an icon * which toggles it into an input field */ class InPlaceEdit extends React.Component { constructor(props) { super(props); this.state = { active: props.active, initialValue: props.value || '', value: props.value || '' }; this.handeTextChange = this.handeTextChange.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); this.handleStartEditing = this.handleStartEditing.bind(this); this.handleEditingFinished = this.handleEditingFinished.bind(this); this.handlePasswordFinished = this.handlePasswordFinished.bind(this); } static getDerivedStateFromProps(nextProps, prevState) { // If text has changed while in read mode, update text and discard changes. // Ignore update if in edit mode. if (prevState.initialValue != nextProps.value && !prevState.active) { return { initialValue: nextProps.value || '', value: nextProps.value || '' }; } return null; } handeTextChange(e) { this.setState({value: e.target.value}); } handleKeyDown(e) { if (e.keyCode === 27) { // Escape pressed this.setState({value: this.props.value, active: false}); } else if (e.keyCode === 13) { // Enter pressed this.handleEditingFinished(); } } handleStartEditing() { if (!this.props.readOnly) { ReactDOM.findDOMNode(this).focus(); this.setState({active: true}); } } handleEditingFinished() { this.setState({active: false}); let value = this.state.value.trim(); if ((value || this.props.value) && (value !== this.props.value)) { this.props.onFinished(value); } } handlePasswordFinished(value) { this.setState({active: false}); if (value && (value !== this.props.value)) { this.props.onFinished(value); } } render() { if (this.state.active) { var fieldType = this.props.type || 'text'; } else { var spanText = this.props.type == 'password' ? '••••••••' : this.state.value; var spanClass = 'in-place-edit' + (this.props.readOnly ? ' disabled' : ''); if (!spanText) { spanText = this.props.placeholder; spanClass += ' placeholder'; } if (spanText.length > 20) { // FIXME: this is wrong for RTL languages. spanText = spanText.substring(0, 19) + '...'; } } return ( this.state.active ? (fieldType == 'password' ? : ) : {spanText} ); } }; class VisiblePassword extends React.PureComponent { constructor(props) { super(props); this.state = { value: this.props.value, visible: false }; this.handleVisibility = this.handleVisibility.bind(this); this.handeTextChange = this.handeTextChange.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); this.handleEditingFinished = this.handleEditingFinished.bind(this); } handeTextChange(e) { this.setState({value: e.target.value}); if (this.props.onChange) { this.props.onChange(e); } } handleVisibility(e) { e.preventDefault(); this.setState({visible: !this.state.visible}); } handleKeyDown(e) { if (e.keyCode == 27) { // Escape pressed this.setState({value: this.props.value, visible: false}); if (this.props.onFinished) { this.props.onFinished(); } } else if (e.keyCode == 13) { // Enter pressed this.handleEditingFinished(); } } handleEditingFinished(e) { if (e) { let currentTarget = e.currentTarget; setTimeout(() => { if (!currentTarget.contains(document.activeElement)) { if (this.props.onFinished) { this.props.onFinished(this.state.value); } } }, 0); } else if (this.props.onFinished) { this.props.onFinished(this.state.value.trim()); } } render() { return (
    {this.state.visible ? 'visibility' : 'visibility_off'}
    ); } } /* END InPlaceEdit */ /* BEGIN Combobox for selecting host name */ class HostSelector extends React.PureComponent { constructor(props) { super(props); this.state = { hostName: props.serverAddress, changed: false }; this.handleHostNameChange = this.handleHostNameChange.bind(this); this.handleEditingFinished = this.handleEditingFinished.bind(this); } handleHostNameChange(e) { this.setState({hostName: e.target.value, changed: true}); } handleEditingFinished() { if (this.state.changed) { this.setState({changed: false}); this.props.onServerAddressChange(this.state.hostName.trim()); } } render() { var hostOptions = []; for (var key in KNOWN_HOSTS) { var item = KNOWN_HOSTS[key]; hostOptions.push(