From edeba50f6012a1d62d1abe07b245492fe6a7a7c6 Mon Sep 17 00:00:00 2001 From: Igor Zhukov Date: Tue, 1 Sep 2015 19:32:30 +0300 Subject: [PATCH] Supported server-side message entities Also added basic markdown formatting for code Closes #147 --- app/js/directives.js | 55 +++ app/js/services.js | 619 +++++++++++++++++++----------- app/less/app.less | 5 + app/partials/desktop/message.html | 4 +- app/partials/mobile/message.html | 2 +- 5 files changed, 458 insertions(+), 227 deletions(-) diff --git a/app/js/directives.js b/app/js/directives.js index 7cd32640..e7bc807a 100755 --- a/app/js/directives.js +++ b/app/js/directives.js @@ -420,6 +420,61 @@ angular.module('myApp.directives', ['myApp.filters']) }) + .directive('myMessageText', function(AppMessagesManager, AppUsersManager, RichTextProcessor) { + return { + link: link, + scope: { + message: '=myMessageText' + } + }; + + function updateHtml (message, element) { + var entities = message.totalEntities; + var fromUser = AppUsersManager.getUser(message.from_id); + var fromBot = fromUser.pFlags.bot && fromUser.username || false; + var withBot = (fromBot || + message.to_id && ( + message.to_id.chat_id || + message.to_id.user_id && AppUsersManager.isBot(message.to_id.user_id) + ) + ); + + var options = { + noCommands: !withBot, + fromBot: fromBot, + entities: entities + }; + if (message.flags & 16) { + var user = AppUsersManager.getSelf(); + if (user) { + options.highlightUsername = user.username; + } + } + var html = RichTextProcessor.wrapRichText(message.message, options); + // console.log('dd', entities, html); + + element.html(html.valueOf()); + } + + function link ($scope, element, attrs) { + var message = $scope.message; + var msgID = message.id; + // var msgID = $scope.$eval(attrs.myMessageText); + // var message = AppMessagesManager.getMessage(msgID); + + updateHtml(message, element); + + if (message.pending) { + var unlink = $scope.$on('messages_pending', function () { + if (message.id != msgID) { + updateHtml(message, element); + unlink(); + } + }) + } + } + }) + .directive('myReplyMarkup', function() { return { diff --git a/app/js/services.js b/app/js/services.js index 82c73c12..1a8e9559 100755 --- a/app/js/services.js +++ b/app/js/services.js @@ -1736,6 +1736,12 @@ angular.module('myApp.services', ['myApp.i18n', 'izhukov.utils']) selective: (apiMessage.reply_markup.flags & 4) > 0 }; } + + if (apiMessage.message && apiMessage.message.length) { + var myEntities = RichTextProcessor.parseEntities(apiMessage.message); + var apiEntities = apiMessage.entities || []; + apiMessage.totalEntities = RichTextProcessor.mergeEntities(myEntities, apiEntities, !apiMessage.pending); + } }); } @@ -1751,8 +1757,11 @@ angular.module('myApp.services', ['myApp.i18n', 'izhukov.utils']) inputPeer = AppPeersManager.getInputPeerByID(peerID), flags = 0, replyToMsgID = options.replyToMsgID, + entities = [], message; + text = RichTextProcessor.parseMarkdown(text, entities); + if (historyStorage === undefined) { historyStorage = historiesStorage[peerID] = {count: null, history: [], pending: []}; } @@ -1777,6 +1786,7 @@ angular.module('myApp.services', ['myApp.i18n', 'izhukov.utils']) message: text, random_id: randomIDS, reply_to_msg_id: replyToMsgID, + entities: entities, pending: true }; @@ -1806,18 +1816,24 @@ angular.module('myApp.services', ['myApp.i18n', 'izhukov.utils']) if (replyToMsgID) { flags |= 1; } + if (entities.length) { + flags |= 8; + } + console.log(flags, entities); MtpApiManager.invokeApi('messages.sendMessage', { flags: flags, peer: inputPeer, message: text, random_id: randomID, - reply_to_msg_id: replyToMsgID + reply_to_msg_id: replyToMsgID, + entities: entities }, sentRequestOptions).then(function (updates) { if (updates._ == 'updateShortSentMessage') { message.flags = updates.flags; message.date = updates.date; message.id = updates.id; message.media = updates.media; + message.entities = updates.entities; updates = { _: 'updates', users: [], @@ -2467,26 +2483,6 @@ angular.module('myApp.services', ['myApp.i18n', 'izhukov.utils']) } } - if (message.message && message.message.length) { - var options = { - noCommands: !withBot, - fromBot: fromBot - }; - if (!Config.Navigator.mobile) { - options.extractUrlEmbed = true; - } - if (message.flags & 16) { - var user = AppUsersManager.getSelf(); - if (user) { - options.highlightUsername = user.username; - } - } - message.richMessage = RichTextProcessor.wrapRichText(message.message, options); - if (options.extractedUrlEmbed) { - message.richUrlEmbed = options.extractedUrlEmbed; - } - } - return messagesForHistory[msgID] = message; } @@ -4665,6 +4661,8 @@ angular.module('myApp.services', ['myApp.i18n', 'izhukov.utils']) var soundcloudRegExp = /^https?:\/\/(?:soundcloud\.com|snd\.sc)\/([a-zA-Z0-9%\-\_]+)\/([a-zA-Z0-9%\-\_]+)/i; var spotifyRegExp = /(https?:\/\/(open\.spotify\.com|play\.spotify\.com|spoti\.fi)\/(.+)|spotify:(.+))/i; + var markdownRegExp = /(^|\n)```(.{0,16})\n([\s\S]+?)\n```(\n|$)|(^|\s)`([^\n]+?)`/; + var siteHashtags = { Telegram: '#/im?q=%23{1}', Twitter: 'https://twitter.com/hashtag/{1}', @@ -4681,7 +4679,10 @@ angular.module('myApp.services', ['myApp.i18n', 'izhukov.utils']) return { wrapRichText: wrapRichText, - wrapPlainText: wrapPlainText + wrapPlainText: wrapPlainText, + parseEntities: parseEntities, + parseMarkdown: parseMarkdown, + mergeEntities: mergeEntities }; function getEmojiSpritesheetCoords(emojiCode) { @@ -4699,210 +4700,427 @@ angular.module('myApp.services', ['myApp.i18n', 'izhukov.utils']) return null; } - function wrapRichText(text, options) { - if (!text || !text.length) { - return ''; - } - + function parseEntities (text, options) { options = options || {}; var match, raw = text, - html = [], url, - contextSite = options.contextSite || 'Telegram', - contextExternal = contextSite != 'Telegram', - emojiFound = false, - emojiTitle, - emojiCoords; + entities = [], + emojiCode, + emojiCoords, + matchIndex, + rawOffset = 0; // var start = tsNow(); while ((match = raw.match(fullRegExp))) { - html.push(encodeEntities(raw.substr(0, match.index))); + matchIndex = rawOffset + match.index; - if (match[3]) { // telegram.me links - var contextUrl = !options.noLinks && siteMentions[contextSite]; - if (contextUrl) { - var attr = ''; - if (options.highlightUsername && - options.highlightUsername.toLowerCase() == match[3].toLowerCase()) { - attr = 'class="im_message_mymention"'; - } - html.push( - match[1], - '', - encodeEntities(match[2] + match[3]), - '' - ); - } else { - html.push( - match[1], - encodeEntities(match[2] + match[3]) - ); - } + if (match[3]) { // mentions + entities.push({ + _: 'messageEntityMention', + offset: matchIndex + match[1].length, + length: match[2].length + match[3].length + }); } - else if (match[4]) { // URL & e-mail - if (!options.noLinks) { - if (emailRegExp.test(match[4])) { - html.push( - '', - encodeEntities(match[4]), - '' - ); - } else { - var url = false, - protocol = match[5], - tld = match[6], - excluded = ''; - - if (tld) { - if (!protocol && (tld.substr(0, 4) === 'xn--' || Config.TLD.indexOf(tld.toLowerCase()) !== -1)) { - protocol = 'http://'; - } - - if (protocol) { - var balanced = checkBrackets(match[4]); + else if (match[4]) { + if (emailRegExp.test(match[4])) { // email + entities.push({ + _: 'messageEntityEmail', + offset: matchIndex, + length: match[4].length + }); + } else { + var url = false, + protocol = match[5], + tld = match[6], + excluded = ''; + + if (tld) { // URL + if (!protocol && (tld.substr(0, 4) === 'xn--' || Config.TLD.indexOf(tld.toLowerCase()) !== -1)) { + protocol = 'http://'; + } - if (balanced.length !== match[4].length) { - excluded = match[4].substring(balanced.length); - match[4] = balanced; - } + if (protocol) { + var balanced = checkBrackets(match[4]); - url = (match[5] ? '' : protocol) + match[4]; + if (balanced.length !== match[4].length) { + excluded = match[4].substring(balanced.length); + match[4] = balanced; } - var tgMeMatch; - if (tld == 'me' && - (tgMeMatch = url.match(/^https?:\/\/telegram\.me\/(.+)/))) { - var path = tgMeMatch[1].split('/'); - switch (path[0]) { - case 'joinchat': - url = 'tg://join?invite=' + path[1]; - break; - case 'addstickers': - url = 'tg://addstickers?set=' + path[1]; - break; - default: - var domainQuery = path[0].split('?'); - url = 'tg://resolve?domain=' + domainQuery[0] + (domainQuery[1] ? '&' + domainQuery[1] : ''); - } - } - } else { // IP address - url = (match[5] ? '' : 'http://') + match[4]; + url = (match[5] ? '' : protocol) + match[4]; } - if (url) { - html.push( - '', - encodeEntities(match[4]), - '', - excluded - ); - - if (options.extractUrlEmbed && - !options.extractedUrlEmbed) { - options.extractedUrlEmbed = findExternalEmbed(url); + var tgMeMatch; + if (tld == 'me' && + (tgMeMatch = url.match(/^https?:\/\/telegram\.me\/(.+)/))) { + var path = tgMeMatch[1].split('/'); + switch (path[0]) { + case 'joinchat': + url = 'tg://join?invite=' + path[1]; + break; + case 'addstickers': + url = 'tg://addstickers?set=' + path[1]; + break; + default: + var domainQuery = path[0].split('?'); + url = 'tg://resolve?domain=' + domainQuery[0] + (domainQuery[1] ? '&' + domainQuery[1] : ''); } - } else { - html.push(encodeEntities(match[0])); } + } else { // IP address + url = (match[5] ? '' : 'http://') + match[4]; + } + + if (url) { + entities.push({ + _: 'messageEntityUrl', + offset: matchIndex, + length: match[4].length + }); } - } else { - html.push(encodeEntities(match[0])); } } else if (match[7]) { // New line - if (!options.noLinebreaks) { - html.push('
'); - } else { - html.push(' '); - } + entities.push({ + _: 'messageEntityLinebreak', + offset: matchIndex, + length: 1 + }); } - else if (match[8]) { + else if (match[8]) { // Emoji if ((emojiCode = emojiMap[match[8]]) && (emojiCoords = getEmojiSpritesheetCoords(emojiCode))) { - emojiTitle = encodeEntities(emojiData[emojiCode][1][0]); - emojiFound = true; - html.push( - '', - ':', emojiTitle, ':' - ); - } else { - html.push(encodeEntities(match[8])); + entities.push({ + _: 'messageEntityEmoji', + offset: matchIndex, + length: match[0].length, + coords: emojiCoords, + title: emojiData[emojiCode][1][0] + }); + } + } + else if (match[10]) { // Hashtag + entities.push({ + _: 'messageEntityHashtag', + offset: matchIndex + match[9].length, + length: match[10].length + }); + } + else if (match[12]) { // Bot command + entities.push({ + _: 'messageEntityBotCommand', + offset: matchIndex + match[11].length, + length: 1 + match[12].length + (match[13] ? 1 + match[13].length : 0) + }); + } + raw = raw.substr(match.index + match[0].length); + rawOffset += match.index + match[0].length; + } + + if (entities.length) { + console.log('parse entities', text, entities.slice()); + } + + return entities; + } + + function parseMarkdown (text, entities) { + if (text.indexOf('`') == -1) { + return text; + } + var raw = text; + var match; + var newText = []; + while (match = raw.match(markdownRegExp)) { + newText.push(raw.substr(0, match.index)); + + if (match[3]) { // pre + newText.push(match[1] + match[3] + match[4]); + entities.push({ + _: 'messageEntityPre', + language: match[2] || '', + offset: match.index + match[1].length, + length: match[3].length + }) + } else { // code + newText.push(match[5] + match[6]); + entities.push({ + _: 'messageEntityCode', + offset: match.index + match[5].length, + length: match[6].length + }) + } + raw = raw.substr(match.index + match[0].length); + } + newText.push(raw); + return newText.join(''); + } + + function mergeEntities (currentEntities, newEntities, fromApi) { + var totalEntities = newEntities.slice(); + + var i, len = currentEntities.length; + var j, len2 = newEntities.length; + var startJ = 0; + var curEntity, newEntity; + var start, end, cStart, cEnd, bad; + for (i = 0; i < len; i++) { + curEntity = currentEntities[i]; + if (fromApi && + curEntity._ != 'messageEntityLinebreak' && + curEntity._ != 'messageEntityEmoji') { + continue; + } + // console.log('s', curEntity, newEntities); + start = curEntity.offset; + end = start + curEntity.length; + bad = false; + for (j = startJ; j < len2; j++) { + newEntity = newEntities[j]; + cStart = newEntity.offset; + cEnd = cStart + newEntity.length; + if (cStart <= start) { + startJ = j; + } + if (start >= cStart && start < cEnd || + end > cStart && end <= cEnd) { + // console.log('bad', curEntity, newEntity); + bad = true; + break; } + if (cStart >= end) { + break; + } + } + if (bad) { + continue; } - else if (match[10]) { - var contextUrl = !options.noLinks && siteHashtags[contextSite] || options.contextHashtag; - if (contextUrl) { + totalEntities.push(curEntity); + } + + totalEntities.sort(function (a, b) { + return a.offset - b.offset; + }); + + // console.log('merge', currentEntities, newEntities, totalEntities); + + return totalEntities; + } + + function wrapRichText (text, options) { + if (!text || !text.length) { + return ''; + } + + options = options || {}; + + var entities = options.entities, + contextSite = options.contextSite || 'Telegram', + contextExternal = contextSite != 'Telegram', + emojiFound = false; + + if (entities === undefined) { + entities = parseEntities(text, options); + } + + var i = 0; + var len = entities.length; + var entity; + var entityText; + var skipEntity; + var url; + var html = []; + var lastOffset = 0; + for (i = 0; i < len; i++) { + entity = entities[i]; + if (entity.offset > lastOffset) { + html.push( + encodeEntities(text.substr(lastOffset, entity.offset - lastOffset)) + ); + } + else if (entity.offset < lastOffset) { + continue; + } + skipEntity = false; + entityText = text.substr(entity.offset, entity.length); + switch (entity._) { + case 'messageEntityMention': + var contextUrl = !options.noLinks && siteMentions[contextSite]; + if (!contextUrl) { + skipEntity = true; + break; + } + var username = entityText.substr(1); + var attr = ''; + if (options.highlightUsername && + options.highlightUsername.toLowerCase() == username.toLowerCase()) { + attr = 'class="im_message_mymention"'; + } + html.push( + '', + encodeEntities(entityText), + '' + ); + break; + + case 'messageEntityHashtag': + var contextUrl = !options.noLinks && siteHashtags[contextSite]; + if (!contextUrl) { + skipEntity = true; + break; + } + var hashtag = entityText.substr(1); html.push( - encodeEntities(match[9]), '', - encodeEntities(match[10]), + encodeEntities(entityText), '' ); - } else { + break; + + case 'messageEntityEmail': + if (options.noLinks) { + skipEntity = true; + break; + } + html.push( + '', + encodeEntities(entityText), + '' + ); + break; + + case 'messageEntityUrl': + case 'messageEntityTextUrl': + if (options.noLinks) { + skipEntity = true; + break; + } + var url = entity.url || entityText; + if (!url.match(/^https?:\/\//i)) { + url = 'http://' + url; + } + var tgMeMatch; + if ((tgMeMatch = url.match(/^https?:\/\/telegram\.me\/(.+)/))) { + var path = tgMeMatch[1].split('/'); + switch (path[0]) { + case 'joinchat': + url = 'tg://join?invite=' + path[1]; + break; + case 'addstickers': + url = 'tg://addstickers?set=' + path[1]; + break; + default: + var domainQuery = path[0].split('?'); + url = 'tg://resolve?domain=' + domainQuery[0] + (domainQuery[1] ? '&' + domainQuery[1] : ''); + } + } html.push( - encodeEntities(match[9]), - encodeEntities(match[10]) + '', + encodeEntities(entityText), + '' ); - } - } - else if (match[12]) { // Bot commands - if (!options.noLinks && - !options.noCommands && - !contextExternal) { - var bot = match[13] || options.fromBot; + break; + + case 'messageEntityLinebreak': + html.push(options.noLinebreaks ? ' ' : '
'); + break; + + case 'messageEntityEmoji': + html.push( + '', + ':', entity.title, ':' + ); + emojiFound = true; + break; + + case 'messageEntityBotCommand': + if (options.noLinks || options.noCommands || contextExternal) { + skipEntity = true; + break; + } + var command = entityText; + var bot, atPos; + if ((atPos = command.indexOf('@')) != -1) { + bot = command.substr(atPos); + command = command.substr(0, atPos); + } else { + bot = options.fromBot; + } html.push( - encodeEntities(match[11]), '', - encodeEntities('/' + match[12] + (match[13] ? '@' + match[13] : '')), - '', - encodeEntities(match[14]) + encodeEntities(entityText), + '' ); - } else { + break; + + case 'messageEntityBold': html.push( - encodeEntities(match[0]) + '', + encodeEntities(entityText), + '' ); - } - } - raw = raw.substr(match.index + match[0].length); - } + break; - html.push(encodeEntities(raw)); + case 'messageEntityItalic': + html.push( + '', + encodeEntities(entityText), + '' + ); + break; - // var timeDiff = tsNow() - start; - // if (timeDiff > 1) { - // console.log(dT(), 'wrap text', text.length, timeDiff); - // } + case 'messageEntityCode': + html.push( + '', + encodeEntities(entityText), + '' + ); + break; - text = $sanitize(html.join('')); + case 'messageEntityPre': + html.push( + '
',
+            encodeEntities(entityText),
+            '
' + ); + break; - // console.log(3, text, html); + default: + skipEntity = true; + } + if (!skipEntity) { + lastOffset = entity.offset + entity.length; + } + } + html.push(encodeEntities(text.substr(lastOffset))); + + text = $sanitize(html.join('')); if (emojiFound) { text = text.replace(/\ufe0f|️|�|‍/g, '', text); @@ -4930,53 +5148,6 @@ angular.module('myApp.services', ['myApp.i18n', 'izhukov.utils']) return url; } - function findExternalEmbed(url) { - var embedUrlMatches, - result; - - if (embedUrlMatches = url.match(youtubeRegExp)) { - return ['youtube', embedUrlMatches[1]]; - } - if (embedUrlMatches = url.match(vimeoRegExp)) { - return ['vimeo', embedUrlMatches[1]]; - } - else if (embedUrlMatches = url.match(instagramRegExp)) { - return ['instagram', embedUrlMatches[1]]; - } - else if (embedUrlMatches = url.match(vineRegExp)) { - return ['vine', embedUrlMatches[1]]; - } - else if (embedUrlMatches = url.match(soundcloudRegExp)) { - var badFolders = 'explore,upload,pages,terms-of-use,mobile,jobs,imprint'.split(','); - var badSubfolders = 'sets'.split(','); - if (badFolders.indexOf(embedUrlMatches[1]) == -1 && - badSubfolders.indexOf(embedUrlMatches[2]) == -1) { - return ['soundcloud', embedUrlMatches[0]]; - } - } - else if (embedUrlMatches = url.match(spotifyRegExp)) { - return ['spotify', embedUrlMatches[3].replace('/', ':')]; - } - - if (!Config.Modes.chrome_packed) { // Need external JS - if (embedUrlMatches = url.match(twitterRegExp)) { - return ['twitter', embedUrlMatches[0]]; - } - else if (embedUrlMatches = url.match(facebookRegExp)) { - if (embedUrlMatches[2]!= undefined){ - return ['facebook', "https://www.facebook.com/"+embedUrlMatches[2]+"/posts/"+embedUrlMatches[1]]; - } - return ['facebook', embedUrlMatches[0]]; - } - // Sorry, GPlus widget has no `xfbml.render` like callback and is too wide. - // else if (embedUrlMatches = url.match(gplusRegExp)) { - // return ['gplus', embedUrlMatches[0]]; - // } - } - - return false; - } - function wrapPlainText (text, options) { if (emojiSupported) { return text; diff --git a/app/less/app.less b/app/less/app.less index ebfa5efa..6223d3f5 100644 --- a/app/less/app.less +++ b/app/less/app.less @@ -2049,6 +2049,11 @@ a.im_message_fwd_photo { word-wrap: break-word; line-height: 150%; } +.im_message_text pre { + margin-bottom: 0; + max-height: 300px; + overflow: auto; +} .im_message_photo_caption, .im_message_video_caption { clear: both; diff --git a/app/partials/desktop/message.html b/app/partials/desktop/message.html index 5ef09e7e..fc3c5017 100644 --- a/app/partials/desktop/message.html +++ b/app/partials/desktop/message.html @@ -52,8 +52,8 @@ -
- +
+
diff --git a/app/partials/mobile/message.html b/app/partials/mobile/message.html index c3815529..e2c18e08 100644 --- a/app/partials/mobile/message.html +++ b/app/partials/mobile/message.html @@ -62,7 +62,7 @@
-
+