// Name of this application, used in the User-Agent var APP_NAME = "TinodeWeb/0.15"; var KNOWN_HOSTS = {hosted: "api.tinode.co", local: "localhost:6060"}; // Default host name and port to connect to. var DEFAULT_HOST = KNOWN_HOSTS.hosted; // var DEFAULT_HOST = KNOWN_HOSTS.local; // Sound to play on message received. var POP_SOUND = new Audio('audio/msg.mp3'); // Unicode symbol used to clear objects var DEL_CHAR = "\u2421"; // API key. Use https://github.com/tinode/chat/tree/master/keygen to generate your own var API_KEY = "AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K"; // Minimum time between two keypress notifications, milliseconds. var KEYPRESS_DELAY = 3*1000; // Delay before sending a {note} for reciving a message, milliseconds. var RECEIVED_DELAY = 500; // Delay before sending a read notification, milliseconds. var READ_DELAY = 1000; // The shortest allowed tag length. Matches one on the server. var MIN_TAG_LENGTH = 4; // Mediaquery breakpoint between desktop and mobile, in px. Should match the value // in @meadia (max-size: 640px) in base.css var MEDIA_BREAKPOINT = 640; // Size of css 'rem' unit in pixels. Default 1rem = 10pt = 13px. var REM_SIZE = 13; // Size of the avatar image var AVATAR_SIZE = 128; // Number of chat messages to fetch in one call. var MESSAGES_PAGE = 24; // Maximum in-band attachment size = 128K (included directly into the message). Increase // this limit to a greater value in production, if desired. Also increase max_message_size // in server config. var MAX_INBAND_ATTACHMENT_SIZE = 1 << 17; // Absolute maximum attachment size to be used with the server = 8MB. Increase to // something like 100MB in production. var 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. var MAX_IMAGE_SIZE = 768; // Supported image MIME types and corresponding file extensions. var SUPPORTED_IMAGE_FORMATS = ['image/jpeg', 'image/gif', 'image/png', 'image/svg', 'image/svg+xml']; var MIME_EXTENSIONS = ['jpg', 'gif', 'png', 'svg', 'svg']; // Tinode is defined in 'tinode.js'. var Drafty = Tinode.Drafty; // Helper functions for storing values in localStorage. // By default localStorage can store only strings, not objects or other types. Storage.prototype.setObject = function(key, value) { this.setItem(key, JSON.stringify(value)); } Storage.prototype.getObject = function(key) { var value = this.getItem(key); return value && JSON.parse(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); } var scale = Math.min( Math.min(width, maxWidth) / width, Math.min(height, maxHeight) / height ); var 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; } // 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 (mime == "image/jpeg") { // Reduce size of the jpeg by reducing image quality while (imageBits.length * 0.75 > MAX_INBAND_ATTACHMENT_SIZE && quality > 0.45) { imageBits = canvas.toDataURL(mime, quality); quality *= 0.84; } } if (imageBits.length * 0.75 > MAX_INBAND_ATTACHMENT_SIZE) { onError("The image size " + bytesToHumanSize(imageBits.length * 0.75) + " 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_SIZE, MAX_IMAGE_SIZE, 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] var parts = hash.split('?', 2); var params = {}; var path = []; if (parts[0]) { path = parts[0].substr(1).split("/"); } if (parts[1]) { parts[1].split("&").forEach(function(part) { var item = part.split("="); if (item[0]) { params[decodeURIComponent(item[0])] = decodeURIComponent(item[1]); } }); } return {path: path, params: params}; } 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() { var host = DEFAULT_HOST; 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; } // 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. 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 */ // Function is used by context menu to set permissions. function topicPermissionSetter(mode, params, errorHandler) { var topic = 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(); } var instance = this; topic.setMeta({sub: {user: params.user, mode: am}}).catch(function(err) { if (errorHandler) { errorHandler(err.message, "err"); } }); } function deleteMessages(all, hard, params, errorHandler) { var topic = Tinode.getTopic(params.topicName); if (!topic) { console.log("Topic not found: " + params.topicName); return; } var promise = all ? topic.delMessagesAll(hard) : topic.delMessagesList([params.seq], hard); promise.catch(function(err) { if (errorHandler) { errorHandler(err.message, "err"); } }); } // Context menu items. var ContextMenuItems = { "topic_info": {title: "Info", handler: null}, "messages_clear": {title: "Clear messages", handler: function(params, errorHandler) { deleteMessages(true, false, params, errorHandler); }}, "messages_clear_hard": {title: "Clear for All", handler: function(params, errorHandler) { deleteMessages(true, true, params, errorHandler); }}, "message_delete": {title: "Delete", handler: function(params, errorHandler) { deleteMessages(false, false, params, errorHandler); }}, "message_delete_hard": {title: "Delete for All", handler: function(params, errorHandler) { deleteMessages(false, true, params, errorHandler); }}, "topic_unmute": {title: "Unmute", handler: topicPermissionSetter.bind(this, "+P")}, "topic_mute": {title: "Mute", handler: topicPermissionSetter.bind(this, "-P")}, "topic_unblock": {title: "Unblock", handler: topicPermissionSetter.bind(this, "+J")}, "topic_block": {title: "Block", handler: topicPermissionSetter.bind(this, "-J")}, "topic_delete": {title: "Delete", handler: function(params, errorHandler) { var topic = Tinode.getTopic(params.topicName); if (!topic) { console.log("Topic not found: " + params.topicName); return; } topic.delTopic().catch(function(err) { if (errorHandler) { errorHandler(err.message, "err"); } }); }}, "permissions": {title: "Edit permissions", handler: null}, "member_delete": {title: "Remove", handler: function(params, errorHandler) { var topic = 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(function(err) { if (errorHandler) { errorHandler(err.message, "err"); } }); }}, "member_mute": {title: "Mute", handler: topicPermissionSetter.bind(this, "-P")}, "member_unmute": {title: "Unmute", handler: topicPermissionSetter.bind(this, "+P")}, "member_block": {title: "Block", handler: topicPermissionSetter.bind(this, "-J")}, "member_unblock": {title: "Unblock", handler: topicPermissionSetter.bind(this, "+J")}, }; class ContextMenu extends React.Component { constructor(props) { super(props); this.toggle = this.toggle.bind(this); this.handlePageClick = this.handlePageClick.bind(this); this.handleEscapeKey = this.handleEscapeKey.bind(this); this.handleClick = this.handleClick.bind(this); } componentWillUnmount() { this.toggle(false); } componentWillReceiveProps(nextProps) { this.toggle(nextProps.visible); } toggle(visible) { if (visible) { document.addEventListener('mousedown', this.handlePageClick, false); document.addEventListener('keyup', this.handleEscapeKey, false); } else { 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(); var item = this.props.items[e.currentTarget.dataset.id]; item.handler(this.props.params, this.props.onError); } render() { if (!this.props.visible) { return null; } var count = 0; var instance = this; var menu = []; this.props.items.map(function(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() { var 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() { var avatar; if (this.props.avatar === true) { if (this.props.topic && this.props.title && this.props.title.trim()) { var letter = this.props.title.trim().charAt(0); var 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.state, text: props.text || "" }; this.handeTextChange = this.handeTextChange.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); this.handleStartEditing = this.handleStartEditing.bind(this); this.handleEditingFinished = this.handleEditingFinished.bind(this); } componentWillReceiveProps(nextProps) { // If text has changed while in read mode, update text and discard changes. // Ignore update if in edit mode. if (this.props.text != nextProps.text && !this.state.active) { this.setState({text: nextProps.text || ""}); } } handeTextChange(e) { this.setState({text: e.target.value}); } handleKeyDown(e) { if (e.keyCode === 27) { // Escape pressed this.setState({text: this.props.text, 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}); var text = this.state.text.trim(); if ((text || this.props.text) && (text !== this.props.text)) { this.props.onFinished(text); } } render() { var spanText = this.props.type === "password" ? "••••••••" : this.state.text; var spanClass = "in-place-edit" + (this.props.readOnly ? " disabled" : ""); if (!spanText) { spanText = this.props.placeholder; spanClass += " placeholder"; } if (spanText.length > 20) { spanText = spanText.substring(0, 19) + "..."; } return ( this.state.active ? : {spanText} ); } }; /* 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(