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"