feat: Add support for markdown in messages (#1642)
Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
parent
5adbc84e0c
commit
a5c3c4301c
13 changed files with 208 additions and 40 deletions
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>'
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
|
|
|
@ -94,6 +94,7 @@ export default {
|
|||
|
||||
.message-wrap {
|
||||
margin-right: $space-small;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.in-progress {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue