mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +00:00 
			
		
		
		
	feat: Add support for markdown in messages (#1642)
Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
		 Nithin David Thomas
					Nithin David Thomas
				
			
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			 GitHub
						GitHub
					
				
			
						parent
						
							5adbc84e0c
						
					
				
				
					commit
					a5c3c4301c
				
			| @@ -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" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user