From df86411cb7d9ab68aad99dd74b4340bff971bcc7 Mon Sep 17 00:00:00 2001 From: or-else Date: Tue, 7 Aug 2018 12:15:10 +0300 Subject: [PATCH] rearranging components, splitting js/babel from html --- drafty.js | 825 --------- index.html | 5169 +--------------------------------------------------- index.js | 5113 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 5117 insertions(+), 5990 deletions(-) delete mode 100644 drafty.js create mode 100644 index.js diff --git a/drafty.js b/drafty.js deleted file mode 100644 index a6da2477..00000000 --- a/drafty.js +++ /dev/null @@ -1,825 +0,0 @@ -// Basic parser and formatter for very simple text markup. Mostly targeted at -// mobile use cases similar to Telegram and WhatsApp. -// -// Supports: -// *abc* -> abc -// _abc_ -> abc -// ~abc~ -> abc -// `abc` -> abc -// Nested frmatting is supported, e.g. *abc _def_* -> abc def -// -// URLs, @mentions, and #hashtags are extracted and converted into links. -// -// JSON data representation is inspired by Draft.js raw formatting. - -/* -Text: - this is *bold*, `code` and _italic_, ~strike~ - combined *bold and _italic_* - an url: https://www.example.com/abc#fragment and another _www.tinode.co_ - this is a @mention and a #hashtag in a string - second #hashtag - -Sample JSON representation of the text above: -{ - "txt": "this is bold, code and italic, strike combined bold and italic an url: https://www.example.com/abc#fragment " + - "and another www.tinode.co this is a @mention and a #hashtag in a string second #hashtag", - "fmt": [ - { "at":8, "len":4,"tp":"ST" },{ "at":14, "len":4, "tp":"CO" },{ "at":23, "len":6, "tp":"EM"}, - { "at":31, "len":6, "tp":"DL" },{ "tp":"BR", "len":1, "at":37 },{ "at":56, "len":6, "tp":"EM" }, - { "at":47, "len":15, "tp":"ST" },{ "tp":"BR", "len":1, "at":62 },{ "at":120, "len":13, "tp":"EM" }, - { "at":71, "len":36, "key":0 },{ "at":120, "len":13, "key":1 },{ "tp":"BR", "len":1, "at":133 }, - { "at":144, "len":8, "key":2 },{ "at":159, "len":8, "key":3 },{ "tp":"BR", "len":1, "at":179 }, - { "at":187, "len":8, "key":3 },{ "tp":"BR", "len":1, "at":195 } - ], - "ent": [ - { "tp":"LN", "data":{ "url":"https://www.example.com/abc#fragment" } }, - { "tp":"LN", "data":{ "url":"http://www.tinode.co" } }, - { "tp":"MN", "data":{ "val":"mention" } }, - { "tp":"HT", "data":{ "val":"hashtag" } } - ] -} -*/ - -(function(environment) { // closure for web browsers - 'use strict'; - - // Regular expressions for parsing inline formats. Javascript does not support lookbehind, - // so it's a bit messy. - var INLINE_STYLES = [ - // Strong = bold, *bold text* - {name: "ST", start: /(?:^|\W)(\*)[^\s*]/, end: /[^\s*](\*)(?=$|\W)/}, - // Emphesized = italic, _italic text_ - {name: "EM", start: /(?:^|[\W_])(_)[^\s_]/, end: /[^\s_](_)(?=$|[\W_])/}, - // Deleted, ~strike this though~ - {name: "DL", start: /(?:^|\W)(~)[^\s~]/, end: /[^\s~](~)(?=$|\W)/}, - // Code block `this is monospace` - {name: "CO", start: /(?:^|\W)(`)[^`]/, end: /[^`](`)(?=$|\W)/} - ]; - - // RegExps for entity extraction (RF = reference) - var ENTITY_TYPES = [ - // URLs - {name: "LN", dataName: "url", - pack: function(val) { - // Check if the protocol is specified, if not use http - if (!/^[a-z]+:\/\//i.test(val)) { - val = 'http://' + val; - } - return {url: val}; - }, - re: /(https?:\/\/)?(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b(?:[-a-zA-Z0-9@:%_\+.~#?&//=]*)/g}, - // Mentions @user (must be 2 or more characters) - {name: "MN", dataName: "val", - pack: function(val) { return {val: val.slice(1)}; }, - re: /\B@(\w\w+)/g}, - // Hashtags #hashtag, like metion 2 or more characters. - {name: "HT", dataName: "val", - pack: function(val) { return {val: val.slice(1)}; }, - re: /\B#(\w\w+)/g} - ]; - - // HTML tag name suggestions - var HTML_TAGS = { - ST: { name: 'b', isVoid: false }, - EM: { name: 'i', isVoid: false }, - DL: { name: 'del', isVoid: false }, - CO: { name: 'tt', isVoid: false }, - BR: { name: 'br', isVoid: true }, - LN: { name: 'a', isVoid: false }, - MN: { name: 'a', isVoid: false }, - HT: { name: 'a', isVoid: false }, - IM: { name: 'img', isVoid: true } - }; - - // Convert base64-encoded string into Blob. - function base64toObjectUrl(b64, contentType) { - var bin; - try { - bin = atob(b64); - } catch (err) { - console.log("Drafty: failed to decode base64-encoded object", err.message); - bin = atob(""); - } - var length = bin.length; - var buf = new ArrayBuffer(length); - var arr = new Uint8Array(buf); - for (var i = 0; i < length; i++) { - arr[i] = bin.charCodeAt(i); - } - - return URL.createObjectURL(new Blob([buf], {type: contentType})); - } - - // Helpers for converting Drafty to HTML. - var DECORATORS = { - ST: { open: function() { return ''; }, close: function() { return ''; }}, - EM: { open: function() { return ''; }, close: function() { return ''}}, - DL: { open: function() { return ''; }, close: function() { return ''}}, - CO: { open: function() { return ''; }, close: function() { return ''}}, - BR: { open: function() { return ''; }, close: function() { return '
'}}, - LN: { - open: function(data) { return ''; }, - close: function(data) { return ''; }, - props: function(data) { return { href: data.url, target: "_blank" }; }, - }, - MN: { - open: function(data) { return ''; }, - close: function(data) { return ''; }, - props: function(data) { return { name: data.val }; }, - }, - HT: { - open: function(data) { return ''; }, - close: function(data) { return ''; }, - props: function(data) { return { name: data.val }; }, - }, - IM: { - open: function(data) { - // Don't use data.ref for preview: it's a security risk. - var previewUrl = base64toObjectUrl(data.val, data.mime); - var downloadUrl = data.ref ? data.ref : previewUrl; - var res = (data.name ? '' : '') + - ''; - console.log("open: " + res); - return res; - }, - close: function(data) { - return (data.name ? '' : ''); - }, - props: function(data) { - var url = base64toObjectUrl(data.val, data.mime); - return { - src: url, - title: data.name, - 'data-width': data.width, - 'data-height': data.height, - 'data-name': data.name, - 'data-size': (data.val.length * 0.75) | 0, - 'data-mime': data.mime - }; - }, - } - }; - - var Drafty = (function() { - - // Take a string and defined earlier style spans, re-compose them into a tree where each leaf is - // a same-style (including unstyled) string. I.e. 'hello *bold _italic_* and ~more~ world' -> - // ('hello ', (b: 'bold ', (i: 'italic')), ' and ', (s: 'more'), ' world'); - // - // This is needed in order to clear markup, i.e. 'hello *world*' -> 'hello world' and convert - // ranges from markup-ed offsets to plain text offsets. - function chunkify(line, start, end, spans) { - var chunks = []; - - if (spans.length == 0) { - return []; - } - - for (var i in spans) { - // Get the next chunk from the queue - var span = spans[i]; - - // Grab the initial unstyled chunk - if (span.start > start) { - chunks.push({text: line.slice(start, span.start)}); - } - - // Grab the styled chunk. It may include subchunks. - var chunk = {type: span.type}; - var chld = chunkify(line, span.start + 1, span.end - 1, span.children); - if (chld.length > 0) { - chunk.children = chld; - } else { - chunk.text = span.text; - } - chunks.push(chunk); - start = span.end + 1; // '+1' is to skip the formatting character - } - - // Grab the remaining unstyled chunk, after the last span - if (start < end) { - chunks.push({text: line.slice(start, end)}); - } - - return chunks; - } - - // Same as chunkify but used for formatting. - function forEach(line, start, end, spans, formatter, context) { - // Add un-styled range before the styled span starts. - // Process ranges calling formatter for each range. - var result = []; - for (var i = 0; i < spans.length; i++) { - var span = spans[i]; - - // Add un-styled range before the styled span starts. - if (start < span.at) { - result.push(formatter.call(context, null, undefined, line.slice(start, span.at))); - start = span.at; - } - // Get all spans which are within current span. - var subspans = []; - for (var si = i + 1; si < spans.length && spans[si].at < span.at + span.len; si++) { - subspans.push(spans[si]); - i = si; - } - - var tag = HTML_TAGS[span.tp] || {}; - result.push(formatter.call(context, span.tp, span.data, - tag.isVoid ? null : forEach(line, start, span.at + span.len, subspans, formatter, context))); - - start = span.at + span.len; - } - - // Add the last unformatted range. - if (start < end) { - result.push(formatter.call(context, null, undefined, line.slice(start, end))); - } - - return result; - } - - // Detect starts and ends of formatting spans. Unformatted spans are - // ignored at this stage. - function spannify(original, re_start, re_end, type) { - var result = []; - var index = 0; - var line = original.slice(0); // make a copy; - - while (line.length > 0) { - // match[0]; // match, like '*abc*' - // match[1]; // match captured in parenthesis, like 'abc' - // match['index']; // offset where the match started. - - // Find the opening token. - var start = re_start.exec(line); - if (start == null) { - break; - } - - // Because javascript RegExp does not support lookbehind, the actual offset may not point - // at the markup character. Find it in the matched string. - var start_offset = start['index'] + start[0].lastIndexOf(start[1]); - // Clip the processed part of the string. - line = line.slice(start_offset + 1); - // start_offset is an offset within the clipped string. Convert to original index. - start_offset += index; - // Index now point to the beginning of 'line' within the 'original' string. - index = start_offset + 1; - - // Find the matching closing token. - var end = re_end ? re_end.exec(line) : null; - if (end == null) { - break; - } - var end_offset = end['index'] + end[0].indexOf(end[1]); - // Clip the processed part of the string. - line = line.slice(end_offset + 1); - // Update offsets - end_offset += index; - // Index now point to the beginning of 'line' within the 'original' string. - index = end_offset + 1; - - result.push({ - text: original.slice(start_offset+1, end_offset), - children: [], - start: start_offset, - end: end_offset, - type: type - }); - } - - return result; - } - - // Convert linear array or spans into a tree representation. - // Keep standalone and nested spans, throw away partially overlapping spans. - function toTree(spans) { - if (spans.length == 0) { - return []; - } - - var tree = [spans[0]]; - var last = spans[0]; - for (var i = 1; i < spans.length; i++) { - // Keep spans which start after the end of the previous span or those which - // are complete within the previous span. - - if (spans[i].start > last.end) { - // Span is completely outside of the previous span. - tree.push(spans[i]); - last = spans[i]; - } else if (spans[i].end < last.end) { - // Span is fully inside of the previous span. Push to subnode. - last.children.push(spans[i]); - } - // Span could partially overlap, ignoring it as invalid. - } - - // Recursively rearrange the subnodes. - for (var i in tree) { - tree[i].children = toTree(tree[i].children); - } - - return tree; - } - - // Get a list of entities from a text. - function extractEntities(line) { - var match; - var extracted = []; - ENTITY_TYPES.map(function(entity) { - while ((match = entity.re.exec(line)) !== null) { - extracted.push({ - offset: match['index'], - len: match[0].length, - unique: match[0], - data: entity.pack(match[0]), - type: entity.name}); - } - }); - - if (extracted.length == 0) { - return extracted; - } - - // Remove entities detected inside other entities, like #hashtag in a URL. - extracted.sort(function(a,b) { - return a.offset - b.offset; - }); - - var idx = -1; - extracted = extracted.filter(function(el) { - var result = (el.offset > idx); - idx = el.offset + el.len; - return result; - }); - - return extracted; - } - - // Convert the chunks into format suitable for serialization. - function draftify(chunks, startAt) { - var plain = ""; - var ranges = []; - for (var i in chunks) { - var chunk = chunks[i]; - if (!chunk.text) { - var drafty = draftify(chunk.children, plain.length + startAt); - chunk.text = drafty.txt; - ranges = ranges.concat(drafty.fmt); - } - - if (chunk.type) { - ranges.push({at: plain.length + startAt, len: chunk.text.length, tp: chunk.type}); - } - - plain += chunk.text; - } - return {txt: plain, fmt: ranges}; - } - - // Splice two strings: insert second string into the first one at the given index - function splice(src, at, insert) { - return src.slice(0, at) + insert + src.slice(at); - } - - return { - - /** - * Parse plain text into structured representation. - * @param {String} content plain-text content to parse. - * @return {Drafty} parsed object. - */ - parse: function(content) { - // Make sure we are parsing strings only. - if (typeof content != "string") { - return null; - } - - // Split text into lines. It makes further processing easier. - var lines = content.split(/\r?\n/); - - // Holds entities referenced from text - var entityMap = []; - var entityIndex = {}; - - // Processing lines one by one, hold intermediate result in blx. - var blx = []; - lines.map(function(line) { - var spans = []; - var entities = []; - - // Find formatted spans in the string. - // Try to match each style. - INLINE_STYLES.map(function(style) { - // Each style could be matched multiple times. - spans = spans.concat(spannify(line, style.start, style.end, style.name)); - }); - - var block; - if (spans.length == 0) { - block = {txt: line}; - } else { - // Sort spans by style occurence early -> late - spans.sort(function(a,b) { - return a.start - b.start; - }); - - // Convert an array of possibly overlapping spans into a tree - spans = toTree(spans); - - // Build a tree representation of the entire string, not - // just the formatted parts. - var chunks = chunkify(line, 0, line.length, spans); - - var drafty = draftify(chunks, 0); - - block = {txt: drafty.txt, fmt: drafty.fmt}; - } - - // Extract entities from the cleaned up string. - entities = extractEntities(block.txt); - if (entities.length > 0) { - var ranges = []; - for (var i in entities) { - // {offset: match['index'], unique: match[0], len: match[0].length, data: ent.packer(), type: ent.name} - var entity = entities[i]; - var index = entityIndex[entity.unique]; - if (!index) { - index = entityMap.length; - entityIndex[entity.unique] = index; - entityMap.push({tp: entity.type, data: entity.data}); - } - ranges.push({at: entity.offset, len: entity.len, key: index}); - } - block.ent = ranges; - } - - blx.push(block); - }); - - var result = {txt: ""}; - - // Merge lines and save line breaks as BR inline formatting. - if (blx.length > 0) { - result.txt = blx[0].txt; - result.fmt = (blx[0].fmt || []).concat(blx[0].ent || []); - - for (var i = 1; i 0) { - result.ent = entityMap; - } - } - return result; - }, - - /** - * Add inline image to Drafty content - * - * @param {Drafty} content object to add image to. - * @param {integer} at index where the object is inserted. The length of the image is always 1. - * @param {string} mime mime-type of the image, e.g. "image/png" - * @param {string} base64bits base64-encoded image content (or preview, if large image is attached) - * @param {integer} width width of the image - * @param {integer} height height of the image - * @param {string} fname file name suggestion for downloading the image. - * @param {string} refurl reference to the content. Could be null or undefined. - * @param {integer} size size of the external file. Treat is as an untrusted hint. - */ - insertImage: function(content, at, mime, base64bits, width, height, fname, refurl, size) { - content = content || {txt: " "}; - content.ent = content.ent || []; - content.fmt = content.fmt || []; - - content.fmt.push({ - at: at, - len: 1, - key: content.ent.length - }); - content.ent.push({ - tp: "IM", - data: { - mime: mime, - val: base64bits, - width: width, - height: height, - name: fname, - ref: refurl, - size: size | 0 - } - }); - - return content; - }, - - /** - * Add file to Drafty content. Either as a blob or as a reference. - * - * @param {Drafty} content object to attach file to. - * @param {string} mime mime-type of the file, e.g. "image/png" - * @param {string} base64bits base64-encoded file content - * @param {string} fname file name suggestion for downloading. - * @param {string} refurl reference to the content. Could be null or undefined. - * @param {integer} size size of the external file. Treat is as an untrusted hint. - */ - attachFile: function(content, mime, base64bits, fname, refurl, size) { - content = content || {txt: ""}; - content.ent = content.ent || []; - content.fmt = content.fmt || []; - - content.fmt.push({ - at: -1, - len: 0, - key: content.ent.length - }); - - content.ent.push({ - tp: "EX", - data: { - mime: mime, - val: base64bits, - name: fname, - ref: refurl, - size: size | 0 - } - }); - - return content; - }, - - /** - * Given the structured representation of rich text, convert it to HTML. - * No attempt is made to strip pre-existing html markup. - * This is potentially unsafe because `content.txt` may contain malicious - * markup. - * - * @param {drafy} content - structured representation of rich text. - * - * @return HTML-representation of content. - */ - UNSAFE_toHTML: function(content) { - var {txt, fmt, ent} = content; - - var markup = []; - if (fmt) { - for (var i in fmt) { - var range = fmt[i]; - var tp = range.tp, data; - if (!tp) { - var entity = ent[range.key]; - if (entity) { - tp = entity.tp; - data = entity.data; - } - } - - if (DECORATORS[tp]) { - // Because we later sort in descending order, closing markup must come first. - // Otherwise zero-length objects will not be represented correctly. - markup.push({idx: range.at + range.len, what: DECORATORS[tp].close(data)}); - markup.push({idx: range.at, what: DECORATORS[tp].open(data)}); - } - } - } - - markup.sort(function(a, b) { - return b.idx - a.idx; // in descending order - }); - - for (var i in markup) { - if (markup[i].what) { - txt = splice(txt, markup[i].idx, markup[i].what); - } - } - - return txt; - }, - - /** - * Callback for applying custom formatting/transformation to a Drafty object. - * Called once for each syle span. - * - * @callback Formatter - * @param {string} style style code such as "ST" or "IM". - * @param {Object} data entity's data - * @param {Object} values possibly styled subspans contained in this style span. - */ - - /** - * Transform Drafty using custom formatting. - * - * @param {Drafty} content - content to transform. - * @param {Formatter} formatter - callback which transforms individual elements - * @param {Object} context - context provided to formatter as 'this'. - * - * @return {Object} transformed object - */ - format: function(content, formatter, context) { - var {txt, fmt, ent} = content; - - txt = txt || ""; - - if (!fmt) { - return [txt]; - } - - var spans = [].concat(fmt); - - // Zero values may have been stripped. Restore them. - spans.map(function(s) { - s.at = s.at || 0; - s.len = s.len || 0; - }); - - // Soft spans first by start index (asc) then by length (desc). - spans.sort(function(a, b) { - if (a.at - b.at == 0) { - return b.len - a.len; // longer one comes first (<0) - } - return a.at - b.at; - }); - - // Denormalize entities into spans. Create a copy of the objects to leave - // original Drafty object unchanged. - spans = spans.map(function(s) { - var data; - var tp = s.tp; - if (!tp) { - s.key = s.key || 0; - data = ent[s.key].data; - tp = ent[s.key].tp; - } - return {tp: tp, data: data, at: s.at, len: s.len}; - }); - - return forEach(txt, 0, txt.length, spans, formatter, context); - }, - - /** - * Given structured representation of rich text, convert it to plain text. - * - * @param {Drafty} content - content to convert to plain text. - */ - toPlainText: function(content) { - return content.txt; - }, - - /** - * Returns true if content has no markup and no entities. - * - * @param {Drafty} content - content to check for presence of markup. - * @returns true is content is plain text, false otherwise. - */ - isPlainText: function(content) { - return !(content.fmt || content.ent); - }, - - /** - * Check if the drafty content has attachments. - * - * @param {Drafty} content - content to check for attachments. - * @returns true if there are attachments. - */ - hasAttachment: function(content) { - if (content.ent && content.ent.length > 0) { - for (var i in content.ent) { - if (content.ent[i].tp == "EX") { - return true; - } - } - } - return false; - }, - - /** - * Callback for applying custom formatting/transformation to a Drafty object. - * Called once for each syle span. - * - * @callback AttachmentCallback - * @param {Object} data attachment data - * @param {number} index attachment's index in `content.ent`. - */ - - /** - * Enumerate attachments - * - * @param {Drafty} content - drafty object to process for attachments. - * @param {AttachmentCallback} callback - callback to call for each attachment. - * @param {Object} content - value of "this" for callback. - */ - attachments: function(content, callback, context) { - if (content.ent && content.ent.length > 0) { - for (var i in content.ent) { - if (content.ent[i].tp == "EX") { - callback.call(context, content.ent[i].data, i); - } - } - } - }, - - /** - * Given the entity, get URL which can be used for downloading - * entity data. - * - * @param {Object} entity to get the URl from. - */ - getDownloadUrl: function(entity) { - return entity.ref ? entity.ref : base64toObjectUrl(entity.val, entity.mime); - }, - - /** - * Given the entity, get URL which can be used for previewing - * the entity. - * - * @param {Object} entity to get the URl from. - */ - getPreviewUrl: function(entity) { - return base64toObjectUrl(entity.val, entity.mime); - }, - - /** - * Get approximate size of the entity. - * - * @param {Object} entity to get the size for. - */ - getEntitySize: function(entity) { - // Either size hint or length of value. The value is base64 encoded, - // the actual object size is smaller than the encoded length. - return entity.size ? entity.size : entity.val ? (entity.val.length * 0.75) | 0 : 0; - }, - - /** - * Get entity mime type. - * - * @param {Object} entity to get the type for. - */ - getEntityMimeType: function(entity) { - return entity.mime || "text/plain"; - }, - - /** - * Get HTML tag for a given two-letter style name - * @param {string} style - two-letter style, like ST or LN - * @returns - */ - tagName: function(style) { - return HTML_TAGS[style] ? HTML_TAGS[style].name : undefined; - }, - - /** - * For a given data bundle generate an object with HTML attributes, - * for instance, given {url: "http://www.example.com/"} return - * {href: "http://www.example.com/"} - * @param {string} style - tw-letter style to generate attributes for. - * @param {Object} data - data bundle to convert to attributes - * @returns object with HTML attributes. - */ - attrValue: function(style, data) { - if (data && DECORATORS[style]) { - return DECORATORS[style].props(data); - } - - return undefined; - }, - - /** - * Drafty MIME type. - * @returns string suitabe for HTTP Content-Type field. - */ - getContentType: function() { - return "text/x-drafty"; - } - }; - }); - - // Export for the window object or node; Check that is not already defined. - if (typeof(environment.Drafty) === 'undefined') { - environment.Drafty = Drafty(); - } -})(this); // 'this' will be a window object for the browsers. diff --git a/index.html b/index.html index 8cfd6f7c..d99cbc13 100644 --- a/index.html +++ b/index.html @@ -28,5175 +28,14 @@ - - + + + +
- diff --git a/index.js b/index.js new file mode 100644 index 00000000..1717d440 --- /dev/null +++ b/index.js @@ -0,0 +1,5113 @@ +// 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']; + +// 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']; + var bucket = Math.floor(Math.log2(bytes) / 10) | 0; + var count = bytes / Math.pow(1024, bucket); + var round = bucket > 0 ? (count < 10 ? 2 : (count < 100 ? 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. +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.getTopicType(this.props.topic) === "grp") ? + group : + person; + } + } else if (this.props.avatar) { + avatar = avatar; + } else { + avatar = null; + } + return avatar; + } +} +/* END Lettertile */ + +/* File uload spinner with a cancel inside */ +class FileUploadProgress extends React.PureComponent { + constructor(props) { + super(props); + } + + render() { + return ( +
    +
    + {this.props.progress < 0.999 ? + close cancel + : + finishing... + } +
    + ); + } +} + +/* 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( +