From 4e5c8965ff72576a9e20850dd30d9985f4073192 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 14 May 2018 13:52:10 -0700 Subject: [PATCH] Move to react for newlines, emoji, and links in message body --- background.html | 13 +- js/signal.js | 2 + js/views/conversation_list_item_view.js | 22 ++- js/views/conversation_view.js | 2 +- js/views/message_view.js | 32 ++-- package.json | 1 + stylesheets/_global.scss | 2 + test/index.html | 4 +- test/styleguide/legacy_templates.js | 2 +- ts/components/conversation/AddNewLines.tsx | 40 +++++ ts/components/conversation/Emojify.tsx | 172 +++++++++++++++++++++ ts/components/conversation/MessageBody.md | 59 +++++++ ts/components/conversation/MessageBody.tsx | 66 ++++++++ ts/components/conversation/Quote.tsx | 6 +- yarn.lock | 4 + 15 files changed, 399 insertions(+), 28 deletions(-) create mode 100644 ts/components/conversation/AddNewLines.tsx create mode 100644 ts/components/conversation/Emojify.tsx create mode 100644 ts/components/conversation/MessageBody.md create mode 100644 ts/components/conversation/MessageBody.tsx diff --git a/background.html b/background.html index 7bdbf5f87..037967c3e 100644 --- a/background.html +++ b/background.html @@ -8,14 +8,15 @@ Signal @@ -283,7 +284,7 @@ {{ #hasBody }}
{{ #message }} -
{{ message }}
+
{{ /message }}
{{ /hasBody }} @@ -375,7 +376,7 @@ {{ unreadCount }} {{ /unreadCount }} {{ #last_message }} -

{{ last_message }}

+

{{ /last_message }} diff --git a/js/signal.js b/js/signal.js index e5b62f875..02693fcfb 100644 --- a/js/signal.js +++ b/js/signal.js @@ -23,6 +23,7 @@ const { LightboxGallery } = require('../ts/components/LightboxGallery'); const { MediaGallery, } = require('../ts/components/conversation/media-gallery/MediaGallery'); +const { MessageBody } = require('../ts/components/conversation/MessageBody'); const { Quote } = require('../ts/components/conversation/Quote'); // Migrations @@ -58,6 +59,7 @@ exports.setup = (options = {}) => { Lightbox, LightboxGallery, MediaGallery, + MessageBody, Types: { Message: MediaGalleryMessage, }, diff --git a/js/views/conversation_list_item_view.js b/js/views/conversation_list_item_view.js index 3f1b4928c..0a73bcb60 100644 --- a/js/views/conversation_list_item_view.js +++ b/js/views/conversation_list_item_view.js @@ -55,12 +55,14 @@ }, render: function() { + const lastMessage = this.model.get('lastMessage'); + this.$el.html( Mustache.render( _.result(this, 'template', ''), { title: this.model.getTitle(), - last_message: this.model.get('lastMessage'), + last_message: Boolean(lastMessage), last_message_timestamp: this.model.get('timestamp'), number: this.model.getNumber(), avatar: this.model.getAvatar(), @@ -74,7 +76,23 @@ this.timeStampView.update(); emoji_util.parse(this.$('.name')); - emoji_util.parse(this.$('.last-message')); + + if (lastMessage) { + if (this.bodyView) { + this.bodyView.remove(); + this.bodyView = null; + } + this.bodyView = new Whisper.ReactWrapperView({ + className: 'body-wrapper', + Component: window.Signal.Components.MessageBody, + props: { + text: lastMessage, + disableJumbomoji: true, + disableLinks: true, + }, + }); + this.$('.last-message').append(this.bodyView.el); + } var unread = this.model.get('unreadCount'); if (unread > 0) { diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index c6d8d3f6e..f45ba86dc 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -1293,7 +1293,7 @@ className: 'quote-wrapper', Component: window.Signal.Components.Quote, props: Object.assign({}, props, { - text: props.text ? window.emoji.signalReplace(props.text) : null, + text: props.text, onClose: () => { this.setQuoteMessage(null); }, diff --git a/js/views/message_view.js b/js/views/message_view.js index d0a5f9ea0..22304234f 100644 --- a/js/views/message_view.js +++ b/js/views/message_view.js @@ -19,8 +19,6 @@ window.Whisper = window.Whisper || {}; - const URL_REGEX = /(^|[\s\n]|)((?:https?|ftp):\/\/[-A-Z0-9\u00A0-\uD7FF\uE000-\uFDCF\uFDF0-\uFFFD+\u0026\u2019@#/%?=()~_|!:,.;]*[-A-Z0-9+\u0026@#/%=~()_|])/gi; - const ErrorIconView = Whisper.View.extend({ templateName: 'error-icon', className: 'error-icon-container', @@ -440,7 +438,7 @@ className: 'quote-wrapper', Component: window.Signal.Components.Quote, props: Object.assign({}, props, { - text: props.text ? window.emoji.signalReplace(props.text) : null, + text: props.text, }), }); this.$('.inner-bubble').prepend(this.quoteView.el); @@ -566,11 +564,13 @@ const hasAttachments = attachments && attachments.length > 0; const hasBody = this.hasTextContents(); + const messageBody = this.model.get('body'); + this.$el.html( Mustache.render( _.result(this, 'template', ''), { - message: this.model.get('body'), + message: Boolean(messageBody), hasBody, timestamp: this.model.get('sent_at'), sender: (contact && contact.getTitle()) || '', @@ -589,17 +589,19 @@ this.renderControl(); - const body = this.$('.body'); - - emoji_util.parse(body); - - if (body.length > 0) { - const escapedBody = body.html(); - body.html( - escapedBody - .replace(/\n/g, '
') - .replace(URL_REGEX, "$1$2") - ); + if (messageBody) { + if (this.bodyView) { + this.bodyView.remove(); + this.bodyView = null; + } + this.bodyView = new Whisper.ReactWrapperView({ + className: 'body-wrapper', + Component: window.Signal.Components.MessageBody, + props: { + text: messageBody, + }, + }); + this.$('.body').append(this.bodyView.el); } this.renderSent(); diff --git a/package.json b/package.json index 1037c2367..7817e59d4 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "dependencies": { "@sindresorhus/is": "^0.8.0", "@types/google-libphonenumber": "^7.4.14", + "@types/linkify-it": "^2.0.3", "archiver": "^2.1.1", "blob-util": "^1.3.0", "blueimp-canvas-to-blob": "^3.14.0", diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index 8b13bd55d..2e4100ebb 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -399,6 +399,8 @@ $avatar-size: 44px; p { overflow-x: hidden; + overflow-y: hidden; + height: 1.2em; text-overflow: ellipsis; } diff --git a/test/index.html b/test/index.html index 2389de05a..23a8c3157 100644 --- a/test/index.html +++ b/test/index.html @@ -211,7 +211,7 @@ {{ #hasBody }}
{{ #message }} -
{{ message }}
+
{{ /message }}
{{ /hasBody }} @@ -298,7 +298,7 @@ {{ unreadCount }} {{ /unreadCount }} {{ #last_message }} -

{{ last_message }}

+

{{ /last_message }} diff --git a/test/styleguide/legacy_templates.js b/test/styleguide/legacy_templates.js index 92770648f..7440922e7 100644 --- a/test/styleguide/legacy_templates.js +++ b/test/styleguide/legacy_templates.js @@ -42,7 +42,7 @@ window.Whisper.View.Templates = { {{ #hasBody }}
{{ #message }} -
{{ message }}
+
{{ /message }}
{{ /hasBody }} diff --git a/ts/components/conversation/AddNewLines.tsx b/ts/components/conversation/AddNewLines.tsx new file mode 100644 index 000000000..7e9647b9b --- /dev/null +++ b/ts/components/conversation/AddNewLines.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +interface Props { + text: string; +} + +export class AddNewLines extends React.Component { + public render() { + const { text } = this.props; + const results: Array = []; + const FIND_NEWLINES = /\n/g; + + let match = FIND_NEWLINES.exec(text); + let last = 0; + let count = 1; + + if (!match) { + return {text}; + } + + while (match) { + if (last < match.index) { + const textWithNoNewline = text.slice(last, match.index); + results.push({textWithNoNewline}); + } + + results.push(
); + + // @ts-ignore + last = FIND_NEWLINES.lastIndex; + match = FIND_NEWLINES.exec(text); + } + + if (last < text.length) { + results.push({text.slice(last)}); + } + + return {results}; + } +} diff --git a/ts/components/conversation/Emojify.tsx b/ts/components/conversation/Emojify.tsx new file mode 100644 index 000000000..d2ea80b67 --- /dev/null +++ b/ts/components/conversation/Emojify.tsx @@ -0,0 +1,172 @@ +import React from 'react'; + +import classnames from 'classnames'; +import is from '@sindresorhus/is'; + +// @ts-ignore +import EmojiConvertor from 'emoji-js'; + +import { AddNewLines } from './AddNewLines'; + +function getCountOfAllMatches(str: string, regex: RegExp) { + let match = regex.exec(str); + let count = 0; + + if (!regex.global) { + return match ? 1 : 0; + } + + while (match) { + count += 1; + match = regex.exec(str); + } + + return count; +} + +function hasNormalCharacters(str: string) { + const noEmoji = str.replace(instance.rx_unified, '').trim(); + return noEmoji.length > 0; +} + +export function getSizeClass(str: string) { + if (hasNormalCharacters(str)) { + return ''; + } + + const emojiCount = getCountOfAllMatches(str, instance.rx_unified); + if (emojiCount > 8) { + return ''; + } else if (emojiCount > 6) { + return 'small'; + } else if (emojiCount > 4) { + return 'medium'; + } else if (emojiCount > 2) { + return 'large'; + } else { + return 'jumbo'; + } +} + +// Taken from emoji-js/replace_unified +function getEmojiReplacementData( + m: string, + p1: string | undefined, + p2: string | undefined +) { + let val = instance.map.unified[p1]; + if (val) { + let idx = null; + if (p2 === '\uD83C\uDFFB') { + idx = '1f3fb'; + } + if (p2 === '\uD83C\uDFFC') { + idx = '1f3fc'; + } + if (p2 === '\uD83C\uDFFD') { + idx = '1f3fd'; + } + if (p2 === '\uD83C\uDFFE') { + idx = '1f3fe'; + } + if (p2 === '\uD83C\uDFFF') { + idx = '1f3ff'; + } + if (idx) { + return { + idx, + actual: p2, + }; + } + return { + idx: val, + }; + } + + val = instance.map.unified_vars[p1]; + if (val) { + return { + idx: val[1], + actual: '', + }; + } + + return m; +} + +// Some of this logic taken from emoji-js/replacement +function getImageTag({ + match, + sizeClass, + key, +}: { + match: any; + sizeClass: string | undefined; + key: string | number; +}) { + const result = getEmojiReplacementData(match[0], match[1], match[2]); + + if (is.string(result)) { + return {match[0]}; + } + + const img = instance.find_image(result.idx); + const title = instance.data[result.idx][3][0]; + + return ( + + ); +} + +const instance = new EmojiConvertor(); +instance.init_unified(); +instance.init_colons(); +instance.img_sets.apple.path = + 'node_modules/emoji-datasource-apple/img/apple/64/'; +instance.include_title = true; +instance.replace_mode = 'img'; +instance.supports_css = false; // needed to avoid spans with background-image + +interface Props { + text: string; + sizeClass?: string; +} + +export class Emojify extends React.Component { + public render() { + const { text, sizeClass } = this.props; + const results: Array = []; + + let match = instance.rx_unified.exec(text); + let last = 0; + let count = 1; + + if (!match) { + return ; + } + + while (match) { + if (last < match.index) { + const textWithNoEmoji = text.slice(last, match.index); + results.push(); + } + + results.push(getImageTag({ match, sizeClass, key: count++ })); + + last = instance.rx_unified.lastIndex; + match = instance.rx_unified.exec(text); + } + + if (last < text.length) { + results.push(); + } + + return {results}; + } +} diff --git a/ts/components/conversation/MessageBody.md b/ts/components/conversation/MessageBody.md new file mode 100644 index 000000000..c4b2da7a6 --- /dev/null +++ b/ts/components/conversation/MessageBody.md @@ -0,0 +1,59 @@ +### Plain text + +```jsx + +``` + +```jsx + +``` + +### Jumbo emoji + +```jsx + +``` + +```jsx + +``` + +```jsx + +``` + +```jsx + +``` + +```jsx + +``` + +### Text and emoji + +```jsx + +``` + +```jsx + +``` + +### Links + +```jsx + +``` + +```jsx + +``` + +```jsx + +``` + +```jsx + +``` diff --git a/ts/components/conversation/MessageBody.tsx b/ts/components/conversation/MessageBody.tsx new file mode 100644 index 000000000..c4d3935d3 --- /dev/null +++ b/ts/components/conversation/MessageBody.tsx @@ -0,0 +1,66 @@ +import React from 'react'; + +import createLinkify from 'linkify-it'; + +import { Emojify, getSizeClass } from './Emojify'; + +const linkify = createLinkify(); + +interface Props { + text: string; + disableJumbomoji?: boolean; + disableLinks?: boolean; +} + +const SUPPORTED_PROTOCOLS = /^(http|https):/i; + +export class MessageBody extends React.Component { + public render() { + const { text, disableJumbomoji, disableLinks } = this.props; + const matchData = linkify.match(text) || []; + const results: Array = []; + let last = 0; + let count = 1; + + // We only use this sizeClass if there was no link detected, because jumbo emoji + // only fire when there's no other text in the message. + const sizeClass = disableJumbomoji ? '' : getSizeClass(text); + + if (disableLinks || matchData.length === 0) { + return ; + } + + matchData.forEach( + (match: { + index: number; + url: string; + lastIndex: number; + text: string; + }) => { + if (last < match.index) { + const textWithNoLink = text.slice(last, match.index); + results.push(); + } + + const { url, text: originalText } = match; + if (SUPPORTED_PROTOCOLS.test(url)) { + results.push( + + {originalText} + + ); + } else { + results.push(); + } + + last = match.lastIndex; + } + ); + + if (last < text.length) { + results.push(); + } + + return {results}; + } +} diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx index b1157202a..e9d161d6d 100644 --- a/ts/components/conversation/Quote.tsx +++ b/ts/components/conversation/Quote.tsx @@ -4,6 +4,8 @@ import classnames from 'classnames'; import * as MIME from '../../../ts/types/MIME'; import * as GoogleChrome from '../../../ts/util/GoogleChrome'; +import { MessageBody } from './MessageBody'; + interface Props { attachments: Array; authorColor: string; @@ -111,7 +113,9 @@ export class Quote extends React.Component { if (text) { return ( -
+
+ +
); } diff --git a/yarn.lock b/yarn.lock index f1df7ed2a..dce778264 100644 --- a/yarn.lock +++ b/yarn.lock @@ -99,6 +99,10 @@ version "3.3.1" resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.3.1.tgz#55758d44d422756d6329cbf54e6d41931d7ba28f" +"@types/linkify-it@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-2.0.3.tgz#5352a2d7a35d7c77b527483cd6e68da9148bd780" + "@types/lodash@^4.14.106": version "4.14.106" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.106.tgz#6093e9a02aa567ddecfe9afadca89e53e5dce4dd"