feat: Add support for markdown in messages (#1642)

Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
Nithin David Thomas 2021-01-15 14:40:50 +05:30 committed by GitHub
parent 5adbc84e0c
commit a5c3c4301c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 208 additions and 40 deletions

View file

@ -3,7 +3,7 @@
@include margin($zero);
background: $color-woot;
border-radius: $space-one;
color: $color-white;
color: var(--white);
font-size: $font-size-small;
font-weight: $font-weight-normal;
position: relative;
@ -11,9 +11,8 @@
.message-text__wrap {
position: relative;
.link {
color: $color-white;
color: var(--white);
text-decoration: underline;
}
}
@ -88,8 +87,6 @@
}
}
.content-box {
text-align: center;
}
@ -138,7 +135,6 @@
@include flex-weight(1);
@include margin($zero);
flex-direction: column;
// Firefox flexbox fix
height: 100%;
overflow-y: auto;
padding-bottom: var(--space-normal);
@ -164,7 +160,7 @@
@include elegant-card;
@include round-corner;
background: $color-woot;
color: $color-white;
color: var(--white);
font-size: $font-size-mini;
font-weight: $font-weight-medium;
margin: $space-one auto;
@ -215,6 +211,7 @@
color: $color-primary-dark;
}
}
}
+.right {
@ -303,6 +300,12 @@
}
}
.activity-wrap .message-text__wrap {
.text-content p {
margin-bottom: 0;
}
}
.conversation-footer {
display: flex;
flex-direction: column;
@ -320,7 +323,7 @@
.typing-indicator {
@include elegant-card;
@include round-corner;
background: $color-white;
background: var(--white);
color: $color-light-gray;
font-size: $font-size-mini;
font-weight: $font-weight-bold;
@ -333,3 +336,65 @@
}
}
}
.left .bubble .text-content {
h1,
h2,
h3,
h4,
h5,
h6 {
color: var(--color-body);
}
a {
color: var(--color-woot);
text-decoration: underline;
}
blockquote {
border-left-color: var(--s-300);
p {
color: var(--s-300);
}
}
p:last-child {
margin-bottom: 0;
}
}
.right .bubble .text-content {
h1,
h2,
h3,
h4,
h5,
h6 {
color: var(--white);
}
a {
color: var(--white);
text-decoration: underline;
}
blockquote {
border-left-color: var(--w-100);
p {
color: var(--w-100);
}
}
pre code {
background: var(--color-background);
}
p:last-child {
margin-bottom: 0;
}
}

View file

@ -27,7 +27,7 @@
<p v-if="lastMessageInChat" class="conversation--message">
<i v-if="messageByAgent" class="ion-ios-undo message-from-agent"></i>
<span v-if="lastMessageInChat.content">
{{ lastMessageInChat.content }}
{{ parsedLastMessage }}
</span>
<span v-else-if="!lastMessageInChat.attachments">{{ ` ` }}</span>
<span v-else>
@ -47,6 +47,7 @@
<script>
import { mapGetters } from 'vuex';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import Thumbnail from '../Thumbnail';
import conversationMixin from '../../../mixins/conversations';
@ -59,7 +60,7 @@ export default {
Thumbnail,
},
mixins: [timeMixin, conversationMixin],
mixins: [timeMixin, conversationMixin, messageFormatterMixin],
props: {
activeLabel: {
type: String,
@ -129,6 +130,10 @@ export default {
const { message_type: messageType } = lastMessage;
return messageType === MESSAGE_TYPE.OUTGOING;
},
parsedLastMessage() {
return this.getPlainText(this.lastMessageInChat.content);
},
},
methods: {

View file

@ -1,6 +1,6 @@
<template>
<div class="message-text__wrap">
<span v-html="message"></span>
<div class="text-content" v-html="message"></div>
</div>
</template>

View file

@ -57,6 +57,7 @@
import { mapGetters } from 'vuex';
import { frontendURL, conversationUrl } from '../../../../helper/URLHelper';
import timeMixin from '../../../../mixins/time';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
export default {
directives: {
@ -66,7 +67,7 @@ export default {
},
},
},
mixins: [timeMixin],
mixins: [timeMixin, messageFormatterMixin],
props: {
show: {
type: Boolean,
@ -107,7 +108,8 @@ export default {
},
methods: {
prepareContent(content = '') {
return content.replace(
const plainTextContent = this.getPlainText(content);
return plainTextContent.replace(
new RegExp(`(${this.searchTerm})`, 'ig'),
'<span class="searchkey--highlight">$1</span>'
);

View file

@ -1,4 +1,7 @@
import marked from 'marked';
import DOMPurify from 'dompurify';
import { escapeHtml } from './HTMLSanitizer';
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
const TWITTER_USERNAME_REPLACEMENT =
'$1<a href="http://twitter.com/$2" target="_blank" rel="noreferrer nofollow noopener">@$2</a>';
@ -9,41 +12,49 @@ const TWITTER_HASH_REPLACEMENT =
class MessageFormatter {
constructor(message, isATweet = false) {
this.message = escapeHtml(message || '') || '';
this.message = DOMPurify.sanitize(escapeHtml(message) || '');
this.isATweet = isATweet;
this.marked = marked;
const renderer = {
heading(text) {
return `<strong>${text}</strong>`;
},
link(url, title, text) {
return `<a rel="noreferrer noopener nofollow" href="${url}" class="link" title="${title ||
''}" target="_blank">${text}</a>`;
},
};
this.marked.use({ renderer });
}
formatMessage() {
const linkifiedMessage = this.linkify();
const messageWithNextLines = linkifiedMessage.replace(/\n/g, '<br>');
if (this.isATweet) {
const messageWithUserName = messageWithNextLines.replace(
const withUserName = this.message.replace(
TWITTER_USERNAME_REGEX,
TWITTER_USERNAME_REPLACEMENT
);
return messageWithUserName.replace(
const withHash = withUserName.replace(
TWITTER_HASH_REGEX,
TWITTER_HASH_REPLACEMENT
);
const markedDownOutput = marked(withHash);
return markedDownOutput;
}
return messageWithNextLines;
}
linkify() {
if (!this.message) {
return '';
}
const urlRegex = /(https?:\/\/[^\s]+)/g;
return this.message.replace(
urlRegex,
url =>
`<a rel="noreferrer noopener nofollow" href="${url}" class="link" target="_blank">${url}</a>`
);
return marked(this.message);
}
get formattedMessage() {
return this.formatMessage();
}
get plainText() {
const strippedOutHtml = new DOMParser().parseFromString(
this.formattedMessage,
'text/html'
);
return strippedOutHtml.body.textContent || '';
}
}
export default MessageFormatter;

View file

@ -4,9 +4,25 @@ describe('#MessageFormatter', () => {
describe('content with links', () => {
it('should format correctly', () => {
const message =
'Chatwoot is an opensource tool\nSee more at https://www.chatwoot.com';
expect(new MessageFormatter(message).formattedMessage).toEqual(
'Chatwoot is an opensource tool<br>See more at <a rel="noreferrer noopener nofollow" href="https://www.chatwoot.com" class="link" target="_blank">https://www.chatwoot.com</a>'
'Chatwoot is an opensource tool. [Chatwoot](https://www.chatwoot.com)';
expect(new MessageFormatter(message).formattedMessage).toMatch(
'<p>Chatwoot is an opensource tool. <a rel="noreferrer noopener nofollow" href="https://www.chatwoot.com" class="link" title="" target="_blank">Chatwoot</a></p>'
);
});
it('should format correctly', () => {
const message =
'Chatwoot is an opensource tool. https://www.chatwoot.com';
expect(new MessageFormatter(message).formattedMessage).toMatch(
'<p>Chatwoot is an opensource tool. <a rel="noreferrer noopener nofollow" href="https://www.chatwoot.com" class="link" title="" target="_blank">https://www.chatwoot.com</a></p>'
);
});
});
describe('parses heading to strong', () => {
it('should format correctly', () => {
const message = '### opensource \n ## tool';
expect(new MessageFormatter(message).formattedMessage).toMatch(
'<strong>opensource</strong><strong>tool</strong>'
);
});
});
@ -14,21 +30,31 @@ describe('#MessageFormatter', () => {
describe('tweets', () => {
it('should return the same string if not tags or @mentions', () => {
const message = 'Chatwoot is an opensource tool';
expect(new MessageFormatter(message).formattedMessage).toEqual(message);
expect(new MessageFormatter(message).formattedMessage).toMatch(message);
});
it('should add links to @mentions', () => {
const message =
'@chatwootapp is an opensource tool thanks @longnonexistenttwitterusername';
expect(new MessageFormatter(message, true).formattedMessage).toEqual(
'<a href="http://twitter.com/chatwootapp" target="_blank" rel="noreferrer nofollow noopener">@chatwootapp</a> is an opensource tool thanks @longnonexistenttwitterusername'
expect(new MessageFormatter(message, true).formattedMessage).toMatch(
'<p><a href="http://twitter.com/chatwootapp" target="_blank" rel="noreferrer nofollow noopener">@chatwootapp</a> is an opensource tool thanks @longnonexistenttwitterusername</p>'
);
});
it('should add links to #tags', () => {
const message = '#chatwootapp is an opensource tool';
expect(new MessageFormatter(message, true).formattedMessage).toEqual(
'<a href="https://twitter.com/hashtag/chatwootapp" target="_blank" rel="noreferrer nofollow noopener">#chatwootapp</a> is an opensource tool'
expect(new MessageFormatter(message, true).formattedMessage).toMatch(
'<p><a href="https://twitter.com/hashtag/chatwootapp" target="_blank" rel="noreferrer nofollow noopener">#chatwootapp</a> is an opensource tool</p>'
);
});
});
describe('plain text content', () => {
it('returns the plain text without HTML', () => {
const message =
'<b>Chatwoot is an opensource tool. https://www.chatwoot.com</b>';
expect(new MessageFormatter(message).plainText).toMatch(
'Chatwoot is an opensource tool. https://www.chatwoot.com'
);
});
});

View file

@ -6,6 +6,10 @@ export default {
const messageFormatter = new MessageFormatter(message, isATweet);
return messageFormatter.formattedMessage;
},
getPlainText(message, isATweet) {
const messageFormatter = new MessageFormatter(message, isATweet);
return messageFormatter.plainText;
},
truncateMessage(description = '') {
if (description.length < 100) {
return description;

View file

@ -0,0 +1,17 @@
import { shallowMount } from '@vue/test-utils';
import messageFormatterMixin from '../messageFormatterMixin';
describe('messageFormatterMixin', () => {
it('returns correct plain text', () => {
const Component = {
render() {},
mixins: [messageFormatterMixin],
};
const wrapper = shallowMount(Component);
const message =
'<b>Chatwoot is an opensource tool. https://www.chatwoot.com</b>';
expect(wrapper.vm.getPlainText(message)).toMatch(
'Chatwoot is an opensource tool. https://www.chatwoot.com'
);
});
});

View file

@ -4,7 +4,7 @@
v-if="!isCards && !isOptions && !isForm && !isArticle"
class="chat-bubble agent"
>
<span v-html="formatMessage(message, false)"></span>
<div class="message-content" v-html="formatMessage(message, false)"></div>
<email-input
v-if="isTemplateEmail"
:message-id="messageId"
@ -133,3 +133,13 @@ export default {
}
}
</style>
<style lang="scss" scoped>
@import '~widget/assets/scss/variables.scss';
.chat-bubble .message-content::v-deep pre {
background: $color-primary-light;
color: $color-body;
overflow: scroll;
padding: $space-smaller;
}
</style>

View file

@ -94,6 +94,7 @@ export default {
.message-wrap {
margin-right: $space-small;
max-width: 100%;
}
.in-progress {

View file

@ -44,12 +44,17 @@ export default {
padding: $space-slab $space-normal $space-slab $space-normal;
text-align: left;
word-break: break-word;
max-width: 100%;
> a {
color: $color-primary;
word-break: break-all;
}
.link {
text-decoration: underline;
}
&.user {
border-bottom-right-radius: $space-smaller;
@ -59,3 +64,13 @@ export default {
}
}
</style>
<style lang="scss" scoped>
@import '~widget/assets/scss/variables.scss';
.chat-bubble.user::v-deep pre {
background: $color-primary-light;
color: $color-body;
overflow: scroll;
padding: $space-smaller;
}
</style>

View file

@ -23,12 +23,14 @@
"core-js": "3",
"country-code-emoji": "^1.0.0",
"date-fns": "^2.16.1",
"dompurify": "^2.2.6",
"dotenv": "^8.0.0",
"foundation-sites": "~6.5.3",
"highlight.js": "~10.4.1",
"ionicons": "~2.0.1",
"js-cookie": "^2.2.1",
"lodash.groupby": "^4.6.0",
"marked": "^1.2.7",
"md5": "^2.3.0",
"query-string": "5",
"spinkit": "~1.2.5",

View file

@ -3860,6 +3860,11 @@ domhandler@^2.3.0:
dependencies:
domelementtype "1"
dompurify@^2.2.6:
version "2.2.6"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.2.6.tgz#54945dc5c0b45ce5ae228705777e8e59d7b2edc4"
integrity sha512-7b7ZArhhH0SP6W2R9cqK6RjaU82FZ2UPM7RO8qN1b1wyvC/NY1FNWcX1Pu00fFOAnzEORtwXe4bPaClg6pUybQ==
domutils@^1.5.1, domutils@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a"
@ -7016,6 +7021,11 @@ map-visit@^1.0.0:
dependencies:
object-visit "^1.0.0"
marked@^1.2.7:
version "1.2.7"
resolved "https://registry.yarnpkg.com/marked/-/marked-1.2.7.tgz#6e14b595581d2319cdcf033a24caaf41455a01fb"
integrity sha512-No11hFYcXr/zkBvL6qFmAp1z6BKY3zqLMHny/JN/ey+al7qwCM2+CMBL9BOgqMxZU36fz4cCWfn2poWIf7QRXA==
material-colors@^1.0.0:
version "1.2.6"
resolved "https://registry.yarnpkg.com/material-colors/-/material-colors-1.2.6.tgz#6d1958871126992ceecc72f4bcc4d8f010865f46"