Move to react for newlines, emoji, and links in message body
This commit is contained in:
parent
721935b0c8
commit
4e5c8965ff
15 changed files with 400 additions and 29 deletions
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -399,6 +399,8 @@ $avatar-size: 44px;
|
|||
|
||||
p {
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
height: 1.2em;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 }}
|
||||
|
|
40
ts/components/conversation/AddNewLines.tsx
Normal file
40
ts/components/conversation/AddNewLines.tsx
Normal 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>;
|
||||
}
|
||||
}
|
172
ts/components/conversation/Emojify.tsx
Normal file
172
ts/components/conversation/Emojify.tsx
Normal 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>;
|
||||
}
|
||||
}
|
59
ts/components/conversation/MessageBody.md
Normal file
59
ts/components/conversation/MessageBody.md
Normal 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" />
|
||||
```
|
66
ts/components/conversation/MessageBody.tsx
Normal file
66
ts/components/conversation/MessageBody.tsx
Normal 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>;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue