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
13 changed files with 208 additions and 40 deletions

View File

@@ -3,7 +3,7 @@
@include margin($zero); @include margin($zero);
background: $color-woot; background: $color-woot;
border-radius: $space-one; border-radius: $space-one;
color: $color-white; color: var(--white);
font-size: $font-size-small; font-size: $font-size-small;
font-weight: $font-weight-normal; font-weight: $font-weight-normal;
position: relative; position: relative;
@@ -11,9 +11,8 @@
.message-text__wrap { .message-text__wrap {
position: relative; position: relative;
.link { .link {
color: $color-white; color: var(--white);
text-decoration: underline; text-decoration: underline;
} }
} }
@@ -88,8 +87,6 @@
} }
} }
.content-box { .content-box {
text-align: center; text-align: center;
} }
@@ -138,7 +135,6 @@
@include flex-weight(1); @include flex-weight(1);
@include margin($zero); @include margin($zero);
flex-direction: column; flex-direction: column;
// Firefox flexbox fix
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
padding-bottom: var(--space-normal); padding-bottom: var(--space-normal);
@@ -164,7 +160,7 @@
@include elegant-card; @include elegant-card;
@include round-corner; @include round-corner;
background: $color-woot; background: $color-woot;
color: $color-white; color: var(--white);
font-size: $font-size-mini; font-size: $font-size-mini;
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
margin: $space-one auto; margin: $space-one auto;
@@ -215,6 +211,7 @@
color: $color-primary-dark; color: $color-primary-dark;
} }
} }
} }
+.right { +.right {
@@ -303,6 +300,12 @@
} }
} }
.activity-wrap .message-text__wrap {
.text-content p {
margin-bottom: 0;
}
}
.conversation-footer { .conversation-footer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -320,7 +323,7 @@
.typing-indicator { .typing-indicator {
@include elegant-card; @include elegant-card;
@include round-corner; @include round-corner;
background: $color-white; background: var(--white);
color: $color-light-gray; color: $color-light-gray;
font-size: $font-size-mini; font-size: $font-size-mini;
font-weight: $font-weight-bold; 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"> <p v-if="lastMessageInChat" class="conversation--message">
<i v-if="messageByAgent" class="ion-ios-undo message-from-agent"></i> <i v-if="messageByAgent" class="ion-ios-undo message-from-agent"></i>
<span v-if="lastMessageInChat.content"> <span v-if="lastMessageInChat.content">
{{ lastMessageInChat.content }} {{ parsedLastMessage }}
</span> </span>
<span v-else-if="!lastMessageInChat.attachments">{{ ` ` }}</span> <span v-else-if="!lastMessageInChat.attachments">{{ ` ` }}</span>
<span v-else> <span v-else>
@@ -47,6 +47,7 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { MESSAGE_TYPE } from 'widget/helpers/constants'; import { MESSAGE_TYPE } from 'widget/helpers/constants';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import Thumbnail from '../Thumbnail'; import Thumbnail from '../Thumbnail';
import conversationMixin from '../../../mixins/conversations'; import conversationMixin from '../../../mixins/conversations';
@@ -59,7 +60,7 @@ export default {
Thumbnail, Thumbnail,
}, },
mixins: [timeMixin, conversationMixin], mixins: [timeMixin, conversationMixin, messageFormatterMixin],
props: { props: {
activeLabel: { activeLabel: {
type: String, type: String,
@@ -129,6 +130,10 @@ export default {
const { message_type: messageType } = lastMessage; const { message_type: messageType } = lastMessage;
return messageType === MESSAGE_TYPE.OUTGOING; return messageType === MESSAGE_TYPE.OUTGOING;
}, },
parsedLastMessage() {
return this.getPlainText(this.lastMessageInChat.content);
},
}, },
methods: { methods: {

View File

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

View File

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

View File

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

View File

@@ -4,9 +4,25 @@ describe('#MessageFormatter', () => {
describe('content with links', () => { describe('content with links', () => {
it('should format correctly', () => { it('should format correctly', () => {
const message = const message =
'Chatwoot is an opensource tool\nSee more at https://www.chatwoot.com'; 'Chatwoot is an opensource tool. [Chatwoot](https://www.chatwoot.com)';
expect(new MessageFormatter(message).formattedMessage).toEqual( expect(new MessageFormatter(message).formattedMessage).toMatch(
'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>' '<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', () => { describe('tweets', () => {
it('should return the same string if not tags or @mentions', () => { it('should return the same string if not tags or @mentions', () => {
const message = 'Chatwoot is an opensource tool'; 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', () => { it('should add links to @mentions', () => {
const message = const message =
'@chatwootapp is an opensource tool thanks @longnonexistenttwitterusername'; '@chatwootapp is an opensource tool thanks @longnonexistenttwitterusername';
expect(new MessageFormatter(message, true).formattedMessage).toEqual( expect(new MessageFormatter(message, true).formattedMessage).toMatch(
'<a href="http://twitter.com/chatwootapp" target="_blank" rel="noreferrer nofollow noopener">@chatwootapp</a> is an opensource tool thanks @longnonexistenttwitterusername' '<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', () => { it('should add links to #tags', () => {
const message = '#chatwootapp is an opensource tool'; const message = '#chatwootapp is an opensource tool';
expect(new MessageFormatter(message, true).formattedMessage).toEqual( expect(new MessageFormatter(message, true).formattedMessage).toMatch(
'<a href="https://twitter.com/hashtag/chatwootapp" target="_blank" rel="noreferrer nofollow noopener">#chatwootapp</a> is an opensource tool' '<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); const messageFormatter = new MessageFormatter(message, isATweet);
return messageFormatter.formattedMessage; return messageFormatter.formattedMessage;
}, },
getPlainText(message, isATweet) {
const messageFormatter = new MessageFormatter(message, isATweet);
return messageFormatter.plainText;
},
truncateMessage(description = '') { truncateMessage(description = '') {
if (description.length < 100) { if (description.length < 100) {
return description; 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" v-if="!isCards && !isOptions && !isForm && !isArticle"
class="chat-bubble agent" class="chat-bubble agent"
> >
<span v-html="formatMessage(message, false)"></span> <div class="message-content" v-html="formatMessage(message, false)"></div>
<email-input <email-input
v-if="isTemplateEmail" v-if="isTemplateEmail"
:message-id="messageId" :message-id="messageId"
@@ -133,3 +133,13 @@ export default {
} }
} }
</style> </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 { .message-wrap {
margin-right: $space-small; margin-right: $space-small;
max-width: 100%;
} }
.in-progress { .in-progress {

View File

@@ -44,12 +44,17 @@ export default {
padding: $space-slab $space-normal $space-slab $space-normal; padding: $space-slab $space-normal $space-slab $space-normal;
text-align: left; text-align: left;
word-break: break-word; word-break: break-word;
max-width: 100%;
> a { > a {
color: $color-primary; color: $color-primary;
word-break: break-all; word-break: break-all;
} }
.link {
text-decoration: underline;
}
&.user { &.user {
border-bottom-right-radius: $space-smaller; border-bottom-right-radius: $space-smaller;
@@ -59,3 +64,13 @@ export default {
} }
} }
</style> </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", "core-js": "3",
"country-code-emoji": "^1.0.0", "country-code-emoji": "^1.0.0",
"date-fns": "^2.16.1", "date-fns": "^2.16.1",
"dompurify": "^2.2.6",
"dotenv": "^8.0.0", "dotenv": "^8.0.0",
"foundation-sites": "~6.5.3", "foundation-sites": "~6.5.3",
"highlight.js": "~10.4.1", "highlight.js": "~10.4.1",
"ionicons": "~2.0.1", "ionicons": "~2.0.1",
"js-cookie": "^2.2.1", "js-cookie": "^2.2.1",
"lodash.groupby": "^4.6.0", "lodash.groupby": "^4.6.0",
"marked": "^1.2.7",
"md5": "^2.3.0", "md5": "^2.3.0",
"query-string": "5", "query-string": "5",
"spinkit": "~1.2.5", "spinkit": "~1.2.5",

View File

@@ -3860,6 +3860,11 @@ domhandler@^2.3.0:
dependencies: dependencies:
domelementtype "1" 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: domutils@^1.5.1, domutils@^1.7.0:
version "1.7.0" version "1.7.0"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a"
@@ -7016,6 +7021,11 @@ map-visit@^1.0.0:
dependencies: dependencies:
object-visit "^1.0.0" 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: material-colors@^1.0.0:
version "1.2.6" version "1.2.6"
resolved "https://registry.yarnpkg.com/material-colors/-/material-colors-1.2.6.tgz#6d1958871126992ceecc72f4bcc4d8f010865f46" resolved "https://registry.yarnpkg.com/material-colors/-/material-colors-1.2.6.tgz#6d1958871126992ceecc72f4bcc4d8f010865f46"