Move to react for newlines, emoji, and links in message body

This commit is contained in:
Scott Nonnenberg 2018-05-14 13:52:10 -07:00
parent 721935b0c8
commit 4e5c8965ff
15 changed files with 400 additions and 29 deletions

View file

@ -8,14 +8,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy"
content="default-src 'none';
child-src 'self';
connect-src 'self' https: wss:;
font-src 'self';
frame-src 'none';
img-src 'self' blob: data:;
media-src 'self' blob:;
object-src 'none'"
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
font-src 'self';
media-src 'self' blob:;
child-src 'self';
object-src 'none'"
>
<title>Signal</title>
<link href='images/icon_128.png' rel='shortcut icon'>
@ -283,7 +284,7 @@
{{ #hasBody }}
<div class='content' dir='auto'>
{{ #message }}
<div class='body'>{{ message }}</div>
<div class='body'></div>
{{ /message }}
</div>
{{ /hasBody }}
@ -375,7 +376,7 @@
<span class='unread-count'>{{ unreadCount }}</span>
{{ /unreadCount }}
{{ #last_message }}
<p class='last-message' dir='auto'> {{ last_message }} </p>
<p class='last-message' dir='auto'></p>
{{ /last_message }}
</div>
</script>

View file

@ -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,
},

View file

@ -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) {

View file

@ -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);
},

View file

@ -19,8 +19,6 @@
window.Whisper = window.Whisper || {};
const URL_REGEX = /(^|[\s\n]|<br\/?>)((?: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, '<br>')
.replace(URL_REGEX, "$1<a href='$2' target='_blank'>$2</a>")
);
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();

View file

@ -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",

View file

@ -399,6 +399,8 @@ $avatar-size: 44px;
p {
overflow-x: hidden;
overflow-y: hidden;
height: 1.2em;
text-overflow: ellipsis;
}

View file

@ -211,7 +211,7 @@
{{ #hasBody }}
<div class='content' dir='auto'>
{{ #message }}
<div class='body'>{{ message }}</div>
<div class='body'></div>
{{ /message }}
</div>
{{ /hasBody }}
@ -298,7 +298,7 @@
<span class='unread-count'>{{ unreadCount }}</span>
{{ /unreadCount }}
{{ #last_message }}
<p class='last-message' dir='auto'> {{ last_message }} </p>
<p class='last-message' dir='auto'></p>
{{ /last_message }}
</div>
</script>

View file

@ -42,7 +42,7 @@ window.Whisper.View.Templates = {
{{ #hasBody }}
<div class='content' dir='auto'>
{{ #message }}
<div class='body'>{{ message }}</div>
<div class='body'></div>
{{ /message }}
</div>
{{ /hasBody }}

View file

@ -0,0 +1,40 @@
import React from 'react';
interface Props {
text: string;
}
export class AddNewLines extends React.Component<Props, {}> {
public render() {
const { text } = this.props;
const results: Array<any> = [];
const FIND_NEWLINES = /\n/g;
let match = FIND_NEWLINES.exec(text);
let last = 0;
let count = 1;
if (!match) {
return <span>{text}</span>;
}
while (match) {
if (last < match.index) {
const textWithNoNewline = text.slice(last, match.index);
results.push(<span key={count++}>{textWithNoNewline}</span>);
}
results.push(<br key={count++} />);
// @ts-ignore
last = FIND_NEWLINES.lastIndex;
match = FIND_NEWLINES.exec(text);
}
if (last < text.length) {
results.push(<span key={count++}>{text.slice(last)}</span>);
}
return <span>{results}</span>;
}
}

View file

@ -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 <span key={key}>{match[0]}</span>;
}
const img = instance.find_image(result.idx);
const title = instance.data[result.idx][3][0];
return (
<img
key={key}
src={img.path}
className={classnames('emoji', sizeClass)}
data-codepoints={img.full_idx}
title={`:${title}:`}
/>
);
}
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<Props, {}> {
public render() {
const { text, sizeClass } = this.props;
const results: Array<any> = [];
let match = instance.rx_unified.exec(text);
let last = 0;
let count = 1;
if (!match) {
return <AddNewLines text={text} />;
}
while (match) {
if (last < match.index) {
const textWithNoEmoji = text.slice(last, match.index);
results.push(<AddNewLines key={count++} text={textWithNoEmoji} />);
}
results.push(getImageTag({ match, sizeClass, key: count++ }));
last = instance.rx_unified.lastIndex;
match = instance.rx_unified.exec(text);
}
if (last < text.length) {
results.push(<AddNewLines key={count++} text={text.slice(last)} />);
}
return <span>{results}</span>;
}
}

View file

@ -0,0 +1,59 @@
### Plain text
```jsx
<MessageBody text="Plain text message" />
```
```jsx
<MessageBody text="Plain text message\n\nWith a new line." />
```
### Jumbo emoji
```jsx
<MessageBody text="🔥" />
```
```jsx
<MessageBody text="🔥🔥" />
```
```jsx
<MessageBody text="🔥🔥🔥🔥" />
```
```jsx
<MessageBody text="🔥🔥🔥🔥🔥🔥🔥🔥" />
```
```jsx
<MessageBody text="🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥" />
```
### Text and emoji
```jsx
<MessageBody text="Plain text 🔥message. With 🔥emoji🔥 sprinkled 🔥about" />
```
```jsx
<MessageBody text="🔥Message starting and ending with emoji🔥" />
```
### Links
```jsx
<MessageBody text="This before and after link. Before. https://somewhere.com After." />
```
```jsx
<MessageBody text="Link https://somewhere.com\nWhat do you think? How about this one? \n\nhttps://anotherlink.com" />
```
```jsx
<MessageBody text="Link https://somewhere.com\nWhat do you think? How about this one? \n\nhttps://anotherlink.com" />
```
```jsx
<MessageBody text="should not render as link:\nmailto:someone@somewhere.com\nftp://something.com\n//local/share\n\\local\share\n\nshould render as link:\ngithub.com\nhttps://blah.com" />
```

View file

@ -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<Props, {}> {
public render() {
const { text, disableJumbomoji, disableLinks } = this.props;
const matchData = linkify.match(text) || [];
const results: Array<any> = [];
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 <Emojify text={text} sizeClass={sizeClass} />;
}
matchData.forEach(
(match: {
index: number;
url: string;
lastIndex: number;
text: string;
}) => {
if (last < match.index) {
const textWithNoLink = text.slice(last, match.index);
results.push(<Emojify key={count++} text={textWithNoLink} />);
}
const { url, text: originalText } = match;
if (SUPPORTED_PROTOCOLS.test(url)) {
results.push(
<a key={count++} href={url}>
{originalText}
</a>
);
} else {
results.push(<Emojify key={count++} text={originalText} />);
}
last = match.lastIndex;
}
);
if (last < text.length) {
results.push(<Emojify key={count++} text={text.slice(last)} />);
}
return <span>{results}</span>;
}
}

View file

@ -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<QuotedAttachment>;
authorColor: string;
@ -111,7 +113,9 @@ export class Quote extends React.Component<Props, {}> {
if (text) {
return (
<div className="text" dangerouslySetInnerHTML={{ __html: text }} />
<div className="text">
<MessageBody text={text} />
</div>
);
}

View file

@ -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"