mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 11:08:04 +00:00 
			
		
		
		
	Feature: Website SDK (#653)
Add SDK functions Co-authored-by: Sojan <sojan@pepalo.com>
This commit is contained in:
		
							
								
								
									
										47
									
								
								app/actions/contact_identify_action.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								app/actions/contact_identify_action.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | |||||||
|  | class ContactIdentifyAction | ||||||
|  |   pattr_initialize [:contact!, :params!] | ||||||
|  |  | ||||||
|  |   def perform | ||||||
|  |     ActiveRecord::Base.transaction do | ||||||
|  |       @contact = merge_contact(existing_identified_contact, @contact) if merge_contacts?(existing_identified_contact, @contact) | ||||||
|  |       @contact = merge_contact(existing_email_contact, @contact) if merge_contacts?(existing_email_contact, @contact) | ||||||
|  |       update_contact | ||||||
|  |     end | ||||||
|  |     @contact | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def account | ||||||
|  |     @account ||= @contact.account | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def existing_identified_contact | ||||||
|  |     return if params[:identifier].blank? | ||||||
|  |  | ||||||
|  |     @existing_identified_contact ||= Contact.where(account_id: account.id).find_by(identifier: params[:identifier]) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def existing_email_contact | ||||||
|  |     return if params[:email].blank? | ||||||
|  |  | ||||||
|  |     @existing_email_contact ||= Contact.where(account_id: account.id).find_by(email: params[:email]) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def merge_contacts?(existing_contact, _contact) | ||||||
|  |     existing_contact && existing_contact.id != @contact.id | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def update_contact | ||||||
|  |     @contact.update!(params.slice(:name, :email, :identifier)) | ||||||
|  |     ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def merge_contact(base_contact, merge_contact) | ||||||
|  |     ContactMergeAction.new( | ||||||
|  |       account: account, | ||||||
|  |       base_contact: base_contact, | ||||||
|  |       mergee_contact: merge_contact | ||||||
|  |     ).perform | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -9,6 +9,7 @@ class ContactMergeAction | |||||||
|       merge_contact_inboxes |       merge_contact_inboxes | ||||||
|       remove_mergee_contact |       remove_mergee_contact | ||||||
|     end |     end | ||||||
|  |     @base_contact | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   private |   private | ||||||
|   | |||||||
| @@ -1,5 +1,3 @@ | |||||||
| require 'open-uri' |  | ||||||
|  |  | ||||||
| # This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo` | # This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo` | ||||||
| # Assumptions | # Assumptions | ||||||
| # 1. Incase of an outgoing message which is echo, source_id will NOT be nil, | # 1. Incase of an outgoing message which is echo, source_id will NOT be nil, | ||||||
| @@ -36,9 +34,7 @@ class Messages::MessageBuilder | |||||||
|     return if contact.present? |     return if contact.present? | ||||||
|  |  | ||||||
|     @contact = Contact.create!(contact_params.except(:remote_avatar_url)) |     @contact = Contact.create!(contact_params.except(:remote_avatar_url)) | ||||||
|     avatar_resource = LocalResource.new(contact_params[:remote_avatar_url]) |     ContactAvatarJob.perform_later(@contact, contact_params[:remote_avatar_url]) if contact_params[:remote_avatar_url] | ||||||
|     @contact.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding) |  | ||||||
|  |  | ||||||
|     @contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id) |     @contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								app/controllers/api/v1/widget/contacts_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								app/controllers/api/v1/widget/contacts_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController | ||||||
|  |   before_action :set_web_widget | ||||||
|  |   before_action :set_contact | ||||||
|  |  | ||||||
|  |   def update | ||||||
|  |     contact_identify_action = ContactIdentifyAction.new( | ||||||
|  |       contact: @contact, | ||||||
|  |       params: permitted_params.to_h.deep_symbolize_keys | ||||||
|  |     ) | ||||||
|  |     render json: contact_identify_action.perform | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def permitted_params | ||||||
|  |     params.permit(:website_token, :identifier, :email, :name, :avatar_url) | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										24
									
								
								app/controllers/api/v1/widget/labels_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								app/controllers/api/v1/widget/labels_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | |||||||
|  | class Api::V1::Widget::LabelsController < Api::V1::Widget::BaseController | ||||||
|  |   before_action :set_web_widget | ||||||
|  |   before_action :set_contact | ||||||
|  |  | ||||||
|  |   def create | ||||||
|  |     conversation.label_list.add(permitted_params[:label]) | ||||||
|  |     conversation.save! | ||||||
|  |  | ||||||
|  |     head :no_content | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def destroy | ||||||
|  |     conversation.label_list.remove(permitted_params[:id]) | ||||||
|  |     conversation.save! | ||||||
|  |  | ||||||
|  |     head :no_content | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def permitted_params | ||||||
|  |     params.permit(:id, :label, :website_token) | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -17,7 +17,6 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController | |||||||
|   def update |   def update | ||||||
|     @message.update!(input_submitted_email: contact_email) |     @message.update!(input_submitted_email: contact_email) | ||||||
|     update_contact(contact_email) |     update_contact(contact_email) | ||||||
|     head :no_content |  | ||||||
|   rescue StandardError => e |   rescue StandardError => e | ||||||
|     render json: { error: @contact.errors, message: e.message }.to_json, status: 500 |     render json: { error: @contact.errors, message: e.message }.to_json, status: 500 | ||||||
|   end |   end | ||||||
| @@ -96,7 +95,11 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController | |||||||
|   def update_contact(email) |   def update_contact(email) | ||||||
|     contact_with_email = @account.contacts.find_by(email: email) |     contact_with_email = @account.contacts.find_by(email: email) | ||||||
|     if contact_with_email |     if contact_with_email | ||||||
|       ::ContactMergeAction.new(account: @account, base_contact: contact_with_email, mergee_contact: @contact).perform |       @contact = ::ContactMergeAction.new( | ||||||
|  |         account: @account, | ||||||
|  |         base_contact: contact_with_email, | ||||||
|  |         mergee_contact: @contact | ||||||
|  |       ).perform | ||||||
|     else |     else | ||||||
|       @contact.update!( |       @contact.update!( | ||||||
|         email: email, |         email: email, | ||||||
|   | |||||||
| @@ -1,233 +1,62 @@ | |||||||
| import Cookies from 'js-cookie'; | import Cookies from 'js-cookie'; | ||||||
|  | import { IFrameHelper } from '../sdk/IFrameHelper'; | ||||||
|  | import { onBubbleClick } from '../sdk/bubbleHelpers'; | ||||||
|  |  | ||||||
| import { SDK_CSS } from '../widget/assets/scss/sdk'; | const runSDK = ({ baseUrl, websiteToken }) => { | ||||||
| /* eslint-disable no-param-reassign */ |   const chatwootSettings = window.chatwootSettings || {}; | ||||||
| const bubbleImg = |   window.$chatwoot = { | ||||||
|   'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAUVBMVEUAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////8IN+deAAAAGnRSTlMAAwgJEBk0TVheY2R5eo+ut8jb5OXs8fX2+cjRDTIAAADsSURBVHgBldZbkoMgFIThRgQv8SKKgGf/C51UnJqaRI30/9zfe+NQUQ3TvG7bOk9DVeCmshmj/CuOTYnrdBfkUOg0zlOtl9OWVuEk4+QyZ3DIevmSt/ioTvK1VH/s5bY3YdM9SBZ/mUUyWgx+U06ycgp7D8msxSvtc4HXL9BLdj2elSEfhBJAI0QNgJEBI1BEBsQClVBVGDgwYOLAhJkDM1YOrNg4sLFAsLJgZsHEgoEFFQt0JAFGFjQsKAMJ0LFAexKgZYFyJIDxJIBNJEDNAtSJBLCeBDCOBFAPzwFA94ED+zmhwDO9358r8ANtIsMXi7qVAwAAAABJRU5ErkJggg=='; |     baseUrl, | ||||||
|  |     hasLoaded: false, | ||||||
|  |     hideMessageBubble: chatwootSettings.hideMessageBubble || false, | ||||||
|  |     isOpen: false, | ||||||
|  |     position: chatwootSettings.position || 'right', | ||||||
|  |     websiteToken, | ||||||
|  |  | ||||||
| const body = document.getElementsByTagName('body')[0]; |     toggle() { | ||||||
| const holder = document.createElement('div'); |       onBubbleClick(); | ||||||
|  |  | ||||||
| const bubbleHolder = document.createElement('div'); |  | ||||||
| const chatBubble = document.createElement('div'); |  | ||||||
| const closeBubble = document.createElement('div'); |  | ||||||
|  |  | ||||||
| const notification_bubble = document.createElement('span'); |  | ||||||
| const bodyOverFlowStyle = document.body.style.overflow; |  | ||||||
|  |  | ||||||
| function loadCSS() { |  | ||||||
|   const css = document.createElement('style'); |  | ||||||
|   css.type = 'text/css'; |  | ||||||
|   css.innerHTML = `${SDK_CSS}`; |  | ||||||
|   document.body.appendChild(css); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function wootOn(elm, event, fn) { |  | ||||||
|   if (document.addEventListener) { |  | ||||||
|     elm.addEventListener(event, fn, false); |  | ||||||
|   } else if (document.attachEvent) { |  | ||||||
|     // <= IE 8 loses scope so need to apply, we add this to object so we |  | ||||||
|     // can detach later (can't detach anonymous functions) |  | ||||||
|     // eslint-disable-next-line |  | ||||||
|     elm[event + fn] = function() { |  | ||||||
|       // eslint-disable-next-line |  | ||||||
|       return fn.apply(elm, arguments); |  | ||||||
|     }; |  | ||||||
|     elm.attachEvent(`on${event}`, elm[event + fn]); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function classHelper(classes, action, elm) { |  | ||||||
|   let search; |  | ||||||
|   let replace; |  | ||||||
|   let i; |  | ||||||
|   let has = false; |  | ||||||
|   if (classes) { |  | ||||||
|     // Trim any whitespace |  | ||||||
|     const classarray = classes.split(/\s+/); |  | ||||||
|     for (i = 0; i < classarray.length; i += 1) { |  | ||||||
|       search = new RegExp(`\\b${classarray[i]}\\b`, 'g'); |  | ||||||
|       replace = new RegExp(` *${classarray[i]}\\b`, 'g'); |  | ||||||
|       if (action === 'remove') { |  | ||||||
|         // eslint-disable-next-line |  | ||||||
|         elm.className = elm.className.replace(replace, ''); |  | ||||||
|       } else if (action === 'toggle') { |  | ||||||
|         // eslint-disable-next-line |  | ||||||
|         elm.className = elm.className.match(search) |  | ||||||
|           ? elm.className.replace(replace, '') |  | ||||||
|           : `${elm.className} ${classarray[i]}`; |  | ||||||
|       } else if (action === 'has') { |  | ||||||
|         if (elm.className.match(search)) { |  | ||||||
|           has = true; |  | ||||||
|           break; |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|   return has; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function addClass(elm, classes) { |  | ||||||
|   if (classes) { |  | ||||||
|     elm.className += ` ${classes}`; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // Toggle class |  | ||||||
| function toggleClass(elm, classes) { |  | ||||||
|   classHelper(classes, 'toggle', elm); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| const createBubbleIcon = ({ className, src, target }) => { |  | ||||||
|   target.className = className; |  | ||||||
|   const bubbleIcon = document.createElement('img'); |  | ||||||
|   bubbleIcon.src = src; |  | ||||||
|   target.appendChild(bubbleIcon); |  | ||||||
|   return target; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| function createBubbleHolder() { |  | ||||||
|   addClass(bubbleHolder, 'woot--bubble-holder'); |  | ||||||
|   body.appendChild(bubbleHolder); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function createNotificationBubble() { |  | ||||||
|   addClass(notification_bubble, 'woot--notification'); |  | ||||||
|   return notification_bubble; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function bubbleClickCallback() { |  | ||||||
|   toggleClass(chatBubble, 'woot--hide'); |  | ||||||
|   toggleClass(closeBubble, 'woot--hide'); |  | ||||||
|   toggleClass(holder, 'woot--hide'); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function onClickChatBubble() { |  | ||||||
|   wootOn(bubbleHolder, 'click', bubbleClickCallback); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function disableScroll() { |  | ||||||
|   document.body.style.overflow = 'hidden'; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function enableScroll() { |  | ||||||
|   document.body.style.overflow = bodyOverFlowStyle; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| const IFrameHelper = { |  | ||||||
|   createFrame: ({ baseUrl, websiteToken }) => { |  | ||||||
|     const iframe = document.createElement('iframe'); |  | ||||||
|     const cwCookie = Cookies.get('cw_conversation'); |  | ||||||
|     let widgetUrl = `${baseUrl}/widget?website_token=${websiteToken}`; |  | ||||||
|     if (cwCookie) { |  | ||||||
|       widgetUrl = `${widgetUrl}&cw_conversation=${cwCookie}`; |  | ||||||
|     } |  | ||||||
|     iframe.src = widgetUrl; |  | ||||||
|  |  | ||||||
|     iframe.id = 'chatwoot_live_chat_widget'; |  | ||||||
|     iframe.style.visibility = 'hidden'; |  | ||||||
|     holder.className = 'woot-widget-holder woot--hide'; |  | ||||||
|     holder.appendChild(iframe); |  | ||||||
|     body.appendChild(holder); |  | ||||||
|     IFrameHelper.initPostMessageCommunication(); |  | ||||||
|     IFrameHelper.initLocationListener(); |  | ||||||
|     IFrameHelper.initWindowSizeListener(); |  | ||||||
|   }, |  | ||||||
|   getAppFrame: () => document.getElementById('chatwoot_live_chat_widget'), |  | ||||||
|   sendMessage: (key, value) => { |  | ||||||
|     const element = IFrameHelper.getAppFrame(); |  | ||||||
|     element.contentWindow.postMessage( |  | ||||||
|       `chatwoot-widget:${JSON.stringify({ event: key, ...value })}`, |  | ||||||
|       '*' |  | ||||||
|     ); |  | ||||||
|   }, |  | ||||||
|   events: { |  | ||||||
|     loaded: message => { |  | ||||||
|       Cookies.set('cw_conversation', message.config.authToken); |  | ||||||
|       IFrameHelper.sendMessage('config-set', {}); |  | ||||||
|       IFrameHelper.onLoad(message.config.channelConfig); |  | ||||||
|       IFrameHelper.setCurrentUrl(); |  | ||||||
|       IFrameHelper.toggleCloseButton(); |  | ||||||
|     }, |     }, | ||||||
|     set_auth_token: message => { |  | ||||||
|       Cookies.set('cw_conversation', message.authToken); |     setUser(identifier, user) { | ||||||
|     }, |       if (typeof identifier === 'string' || typeof identifier === 'number') { | ||||||
|     toggleBubble: () => { |         window.$chatwoot.identifier = identifier; | ||||||
|       bubbleClickCallback(); |         window.$chatwoot.user = user || {}; | ||||||
|     }, |         IFrameHelper.sendMessage('set-user', { | ||||||
|   }, |           identifier, | ||||||
|   initPostMessageCommunication: () => { |           user: window.$chatwoot.user, | ||||||
|     window.onmessage = e => { |         }); | ||||||
|       if ( |       } else { | ||||||
|         typeof e.data !== 'string' || |         throw new Error('Identifier should be a string or a number'); | ||||||
|         e.data.indexOf('chatwoot-widget:') !== 0 |  | ||||||
|       ) { |  | ||||||
|         return; |  | ||||||
|       } |       } | ||||||
|       const message = JSON.parse(e.data.replace('chatwoot-widget:', '')); |     }, | ||||||
|       if (typeof IFrameHelper.events[message.event] === 'function') { |  | ||||||
|         IFrameHelper.events[message.event](message); |     setLabel(label = '') { | ||||||
|  |       IFrameHelper.sendMessage('set-label', { label }); | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     removeLabel(label = '') { | ||||||
|  |       IFrameHelper.sendMessage('remove-label', { label }); | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     reset() { | ||||||
|  |       if (window.$chatwoot.isOpen) { | ||||||
|  |         onBubbleClick(); | ||||||
|       } |       } | ||||||
|     }; |  | ||||||
|   }, |  | ||||||
|   initLocationListener: () => { |  | ||||||
|     window.onhashchange = () => { |  | ||||||
|       IFrameHelper.setCurrentUrl(); |  | ||||||
|     }; |  | ||||||
|   }, |  | ||||||
|   initWindowSizeListener: () => { |  | ||||||
|     wootOn(window, 'resize', () => { |  | ||||||
|       IFrameHelper.toggleCloseButton(); |  | ||||||
|     }); |  | ||||||
|   }, |  | ||||||
|   onLoad: ({ widget_color: widgetColor }) => { |  | ||||||
|     const iframe = IFrameHelper.getAppFrame(); |  | ||||||
|     iframe.style.visibility = ''; |  | ||||||
|     iframe.setAttribute('id', `chatwoot_live_chat_widget`); |  | ||||||
|     iframe.onmouseenter = disableScroll; |  | ||||||
|     iframe.onmouseleave = enableScroll; |  | ||||||
|  |  | ||||||
|     loadCSS(); |       Cookies.remove('cw_conversation'); | ||||||
|     createBubbleHolder(); |       const iframe = IFrameHelper.getAppFrame(); | ||||||
|  |       iframe.src = IFrameHelper.getUrl({ | ||||||
|  |         baseUrl: window.$chatwoot.baseUrl, | ||||||
|  |         websiteToken: window.$chatwoot.websiteToken, | ||||||
|  |       }); | ||||||
|  |     }, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|     const chatIcon = createBubbleIcon({ |  | ||||||
|       className: 'woot-widget-bubble', |  | ||||||
|       src: bubbleImg, |  | ||||||
|       target: chatBubble, |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     const closeIcon = closeBubble; |  | ||||||
|     closeIcon.className = 'woot-widget-bubble woot--close woot--hide'; |  | ||||||
|  |  | ||||||
|     chatIcon.style.background = widgetColor; |  | ||||||
|     closeIcon.style.background = widgetColor; |  | ||||||
|  |  | ||||||
|     bubbleHolder.appendChild(chatIcon); |  | ||||||
|     bubbleHolder.appendChild(closeIcon); |  | ||||||
|     bubbleHolder.appendChild(createNotificationBubble()); |  | ||||||
|     onClickChatBubble(); |  | ||||||
|   }, |  | ||||||
|   setCurrentUrl: () => { |  | ||||||
|     IFrameHelper.sendMessage('set-current-url', { |  | ||||||
|       refererURL: window.location.href, |  | ||||||
|     }); |  | ||||||
|   }, |  | ||||||
|   toggleCloseButton: () => { |  | ||||||
|     if (window.matchMedia('(max-width: 668px)').matches) { |  | ||||||
|       IFrameHelper.sendMessage('toggle-close-button', { showClose: true }); |  | ||||||
|     } else { |  | ||||||
|       IFrameHelper.sendMessage('toggle-close-button', { showClose: false }); |  | ||||||
|     } |  | ||||||
|   }, |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| function loadIframe({ baseUrl, websiteToken }) { |  | ||||||
|   IFrameHelper.createFrame({ |   IFrameHelper.createFrame({ | ||||||
|     baseUrl, |     baseUrl, | ||||||
|     websiteToken, |     websiteToken, | ||||||
|   }); |   }); | ||||||
| } | }; | ||||||
|  |  | ||||||
| window.chatwootSDK = { | window.chatwootSDK = { | ||||||
|   run: loadIframe, |   run: runSDK, | ||||||
| }; | }; | ||||||
|   | |||||||
							
								
								
									
										63
									
								
								app/javascript/sdk/DOMHelpers.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								app/javascript/sdk/DOMHelpers.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | |||||||
|  | import { SDK_CSS } from '../widget/assets/scss/sdk'; | ||||||
|  |  | ||||||
|  | export const loadCSS = () => { | ||||||
|  |   const css = document.createElement('style'); | ||||||
|  |   css.type = 'text/css'; | ||||||
|  |   css.innerHTML = `${SDK_CSS}`; | ||||||
|  |   document.body.appendChild(css); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const wootOn = (elm, event, fn) => { | ||||||
|  |   if (document.addEventListener) { | ||||||
|  |     elm.addEventListener(event, fn, false); | ||||||
|  |   } else if (document.attachEvent) { | ||||||
|  |     // <= IE 8 loses scope so need to apply, we add this to object so we | ||||||
|  |     // can detach later (can't detach anonymous functions) | ||||||
|  |     // eslint-disable-next-line | ||||||
|  |     elm[event + fn] = function() { | ||||||
|  |       // eslint-disable-next-line | ||||||
|  |       return fn.apply(elm, arguments); | ||||||
|  |     }; | ||||||
|  |     elm.attachEvent(`on${event}`, elm[event + fn]); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const classHelper = (classes, action, elm) => { | ||||||
|  |   let search; | ||||||
|  |   let replace; | ||||||
|  |   let i; | ||||||
|  |   let has = false; | ||||||
|  |   if (classes) { | ||||||
|  |     // Trim any whitespace | ||||||
|  |     const classarray = classes.split(/\s+/); | ||||||
|  |     for (i = 0; i < classarray.length; i += 1) { | ||||||
|  |       search = new RegExp(`\\b${classarray[i]}\\b`, 'g'); | ||||||
|  |       replace = new RegExp(` *${classarray[i]}\\b`, 'g'); | ||||||
|  |       if (action === 'remove') { | ||||||
|  |         // eslint-disable-next-line | ||||||
|  |         elm.className = elm.className.replace(replace, ''); | ||||||
|  |       } else if (action === 'toggle') { | ||||||
|  |         // eslint-disable-next-line | ||||||
|  |         elm.className = elm.className.match(search) | ||||||
|  |           ? elm.className.replace(replace, '') | ||||||
|  |           : `${elm.className} ${classarray[i]}`; | ||||||
|  |       } else if (action === 'has') { | ||||||
|  |         if (elm.className.match(search)) { | ||||||
|  |           has = true; | ||||||
|  |           break; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   return has; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const addClass = (elm, classes) => { | ||||||
|  |   if (classes) { | ||||||
|  |     elm.className += ` ${classes}`; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const toggleClass = (elm, classes) => { | ||||||
|  |   classHelper(classes, 'toggle', elm); | ||||||
|  | }; | ||||||
							
								
								
									
										134
									
								
								app/javascript/sdk/IFrameHelper.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								app/javascript/sdk/IFrameHelper.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,134 @@ | |||||||
|  | import Cookies from 'js-cookie'; | ||||||
|  | import { wootOn, loadCSS } from './DOMHelpers'; | ||||||
|  | import { | ||||||
|  |   body, | ||||||
|  |   widgetHolder, | ||||||
|  |   createBubbleHolder, | ||||||
|  |   disableScroll, | ||||||
|  |   enableScroll, | ||||||
|  |   createBubbleIcon, | ||||||
|  |   bubbleImg, | ||||||
|  |   chatBubble, | ||||||
|  |   closeBubble, | ||||||
|  |   bubbleHolder, | ||||||
|  |   createNotificationBubble, | ||||||
|  |   onClickChatBubble, | ||||||
|  |   onBubbleClick, | ||||||
|  | } from './bubbleHelpers'; | ||||||
|  |  | ||||||
|  | export const IFrameHelper = { | ||||||
|  |   getUrl({ baseUrl, websiteToken }) { | ||||||
|  |     return `${baseUrl}/widget?website_token=${websiteToken}`; | ||||||
|  |   }, | ||||||
|  |   createFrame: ({ baseUrl, websiteToken }) => { | ||||||
|  |     const iframe = document.createElement('iframe'); | ||||||
|  |     const cwCookie = Cookies.get('cw_conversation'); | ||||||
|  |     let widgetUrl = IFrameHelper.getUrl({ baseUrl, websiteToken }); | ||||||
|  |     if (cwCookie) { | ||||||
|  |       widgetUrl = `${widgetUrl}&cw_conversation=${cwCookie}`; | ||||||
|  |     } | ||||||
|  |     iframe.src = widgetUrl; | ||||||
|  |  | ||||||
|  |     iframe.id = 'chatwoot_live_chat_widget'; | ||||||
|  |     iframe.style.visibility = 'hidden'; | ||||||
|  |     widgetHolder.className = 'woot-widget-holder woot--hide'; | ||||||
|  |     widgetHolder.appendChild(iframe); | ||||||
|  |     body.appendChild(widgetHolder); | ||||||
|  |     IFrameHelper.initPostMessageCommunication(); | ||||||
|  |     IFrameHelper.initLocationListener(); | ||||||
|  |     IFrameHelper.initWindowSizeListener(); | ||||||
|  |   }, | ||||||
|  |   getAppFrame: () => document.getElementById('chatwoot_live_chat_widget'), | ||||||
|  |   sendMessage: (key, value) => { | ||||||
|  |     const element = IFrameHelper.getAppFrame(); | ||||||
|  |     element.contentWindow.postMessage( | ||||||
|  |       `chatwoot-widget:${JSON.stringify({ event: key, ...value })}`, | ||||||
|  |       '*' | ||||||
|  |     ); | ||||||
|  |   }, | ||||||
|  |   initLocationListener: () => { | ||||||
|  |     window.onhashchange = () => { | ||||||
|  |       IFrameHelper.setCurrentUrl(); | ||||||
|  |     }; | ||||||
|  |   }, | ||||||
|  |   initPostMessageCommunication: () => { | ||||||
|  |     window.onmessage = e => { | ||||||
|  |       if ( | ||||||
|  |         typeof e.data !== 'string' || | ||||||
|  |         e.data.indexOf('chatwoot-widget:') !== 0 | ||||||
|  |       ) { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       const message = JSON.parse(e.data.replace('chatwoot-widget:', '')); | ||||||
|  |       if (typeof IFrameHelper.events[message.event] === 'function') { | ||||||
|  |         IFrameHelper.events[message.event](message); | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  |   }, | ||||||
|  |   initWindowSizeListener: () => { | ||||||
|  |     wootOn(window, 'resize', () => { | ||||||
|  |       IFrameHelper.toggleCloseButton(); | ||||||
|  |     }); | ||||||
|  |   }, | ||||||
|  |   events: { | ||||||
|  |     loaded: message => { | ||||||
|  |       Cookies.set('cw_conversation', message.config.authToken, { | ||||||
|  |         expires: 365, | ||||||
|  |       }); | ||||||
|  |       window.$chatwoot.hasLoaded = true; | ||||||
|  |       IFrameHelper.sendMessage('config-set', {}); | ||||||
|  |       IFrameHelper.onLoad(message.config.channelConfig); | ||||||
|  |       IFrameHelper.setCurrentUrl(); | ||||||
|  |       IFrameHelper.toggleCloseButton(); | ||||||
|  |  | ||||||
|  |       if (window.$chatwoot.user) { | ||||||
|  |         IFrameHelper.sendMessage('set-user', window.$chatwoot.user); | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     toggleBubble: () => { | ||||||
|  |       onBubbleClick(); | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   onLoad: ({ widget_color: widgetColor }) => { | ||||||
|  |     const iframe = IFrameHelper.getAppFrame(); | ||||||
|  |     iframe.style.visibility = ''; | ||||||
|  |     iframe.setAttribute('id', `chatwoot_live_chat_widget`); | ||||||
|  |     iframe.onmouseenter = disableScroll; | ||||||
|  |     iframe.onmouseleave = enableScroll; | ||||||
|  |  | ||||||
|  |     loadCSS(); | ||||||
|  |     createBubbleHolder(); | ||||||
|  |  | ||||||
|  |     if (!window.$chatwoot.hideMessageBubble) { | ||||||
|  |       const chatIcon = createBubbleIcon({ | ||||||
|  |         className: 'woot-widget-bubble', | ||||||
|  |         src: bubbleImg, | ||||||
|  |         target: chatBubble, | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       const closeIcon = closeBubble; | ||||||
|  |       closeIcon.className = 'woot-widget-bubble woot--close woot--hide'; | ||||||
|  |  | ||||||
|  |       chatIcon.style.background = widgetColor; | ||||||
|  |       closeIcon.style.background = widgetColor; | ||||||
|  |  | ||||||
|  |       bubbleHolder.appendChild(chatIcon); | ||||||
|  |       bubbleHolder.appendChild(closeIcon); | ||||||
|  |       bubbleHolder.appendChild(createNotificationBubble()); | ||||||
|  |       onClickChatBubble(); | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   setCurrentUrl: () => { | ||||||
|  |     IFrameHelper.sendMessage('set-current-url', { | ||||||
|  |       refererURL: window.location.href, | ||||||
|  |     }); | ||||||
|  |   }, | ||||||
|  |   toggleCloseButton: () => { | ||||||
|  |     if (window.matchMedia('(max-width: 668px)').matches) { | ||||||
|  |       IFrameHelper.sendMessage('toggle-close-button', { showClose: true }); | ||||||
|  |     } else { | ||||||
|  |       IFrameHelper.sendMessage('toggle-close-button', { showClose: false }); | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  | }; | ||||||
							
								
								
									
										51
									
								
								app/javascript/sdk/bubbleHelpers.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								app/javascript/sdk/bubbleHelpers.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | |||||||
|  | import { addClass, toggleClass, wootOn } from './DOMHelpers'; | ||||||
|  |  | ||||||
|  | export const bubbleImg = | ||||||
|  |   'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAUVBMVEUAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////8IN+deAAAAGnRSTlMAAwgJEBk0TVheY2R5eo+ut8jb5OXs8fX2+cjRDTIAAADsSURBVHgBldZbkoMgFIThRgQv8SKKgGf/C51UnJqaRI30/9zfe+NQUQ3TvG7bOk9DVeCmshmj/CuOTYnrdBfkUOg0zlOtl9OWVuEk4+QyZ3DIevmSt/ioTvK1VH/s5bY3YdM9SBZ/mUUyWgx+U06ycgp7D8msxSvtc4HXL9BLdj2elSEfhBJAI0QNgJEBI1BEBsQClVBVGDgwYOLAhJkDM1YOrNg4sLFAsLJgZsHEgoEFFQt0JAFGFjQsKAMJ0LFAexKgZYFyJIDxJIBNJEDNAtSJBLCeBDCOBFAPzwFA94ED+zmhwDO9358r8ANtIsMXi7qVAwAAAABJRU5ErkJggg=='; | ||||||
|  |  | ||||||
|  | export const body = document.getElementsByTagName('body')[0]; | ||||||
|  | export const widgetHolder = document.createElement('div'); | ||||||
|  |  | ||||||
|  | export const bubbleHolder = document.createElement('div'); | ||||||
|  | export const chatBubble = document.createElement('div'); | ||||||
|  | export const closeBubble = document.createElement('div'); | ||||||
|  |  | ||||||
|  | export const notificationBubble = document.createElement('span'); | ||||||
|  | const bodyOverFlowStyle = document.body.style.overflow; | ||||||
|  |  | ||||||
|  | export const createBubbleIcon = ({ className, src, target }) => { | ||||||
|  |   target.className = className; | ||||||
|  |   const bubbleIcon = document.createElement('img'); | ||||||
|  |   bubbleIcon.src = src; | ||||||
|  |   target.appendChild(bubbleIcon); | ||||||
|  |   return target; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const createBubbleHolder = () => { | ||||||
|  |   addClass(bubbleHolder, 'woot--bubble-holder'); | ||||||
|  |   body.appendChild(bubbleHolder); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const createNotificationBubble = () => { | ||||||
|  |   addClass(notificationBubble, 'woot--notification'); | ||||||
|  |   return notificationBubble; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const onBubbleClick = () => { | ||||||
|  |   window.$chatwoot.isOpen = !window.$chatwoot.isOpen; | ||||||
|  |   toggleClass(chatBubble, 'woot--hide'); | ||||||
|  |   toggleClass(closeBubble, 'woot--hide'); | ||||||
|  |   toggleClass(widgetHolder, 'woot--hide'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const onClickChatBubble = () => { | ||||||
|  |   wootOn(bubbleHolder, 'click', onBubbleClick); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const disableScroll = () => { | ||||||
|  |   document.body.style.overflow = 'hidden'; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const enableScroll = () => { | ||||||
|  |   document.body.style.overflow = bodyOverFlowStyle; | ||||||
|  | }; | ||||||
| @@ -2,8 +2,8 @@ import { createConsumer } from '@rails/actioncable'; | |||||||
|  |  | ||||||
| class BaseActionCableConnector { | class BaseActionCableConnector { | ||||||
|   constructor(app, pubsubToken) { |   constructor(app, pubsubToken) { | ||||||
|     const consumer = createConsumer(); |     this.consumer = createConsumer(); | ||||||
|     consumer.subscriptions.create( |     this.consumer.subscriptions.create( | ||||||
|       { |       { | ||||||
|         channel: 'RoomChannel', |         channel: 'RoomChannel', | ||||||
|         pubsub_token: pubsubToken, |         pubsub_token: pubsubToken, | ||||||
| @@ -16,6 +16,10 @@ class BaseActionCableConnector { | |||||||
|     this.events = {}; |     this.events = {}; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   disconnect() { | ||||||
|  |     this.consumer.disconnect(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   onReceived = ({ event, data } = {}) => { |   onReceived = ({ event, data } = {}) => { | ||||||
|     if (this.events[event] && typeof this.events[event] === 'function') { |     if (this.events[event] && typeof this.events[event] === 'function') { | ||||||
|       this.events[event](data); |       this.events[event](data); | ||||||
|   | |||||||
| @@ -47,6 +47,12 @@ export default { | |||||||
|         window.refererURL = message.refererURL; |         window.refererURL = message.refererURL; | ||||||
|       } else if (message.event === 'toggle-close-button') { |       } else if (message.event === 'toggle-close-button') { | ||||||
|         this.isMobile = message.showClose; |         this.isMobile = message.showClose; | ||||||
|  |       } else if (message.event === 'set-label') { | ||||||
|  |         this.$store.dispatch('conversationLabels/create', message.label); | ||||||
|  |       } else if (message.event === 'remove-label') { | ||||||
|  |         this.$store.dispatch('conversationLabels/destroy', message.label); | ||||||
|  |       } else if (message.event === 'set-user') { | ||||||
|  |         this.$store.dispatch('contacts/update', message); | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
|   }, |   }, | ||||||
|   | |||||||
| @@ -1,10 +0,0 @@ | |||||||
| import authEndPoint from 'widget/api/endPoints'; |  | ||||||
| import { API } from 'widget/helpers/axios'; |  | ||||||
|  |  | ||||||
| export const updateContact = async ({ messageId, email }) => { |  | ||||||
|   const urlData = authEndPoint.updateContact(messageId); |  | ||||||
|   const result = await API.patch(urlData.url, { |  | ||||||
|     contact: { email }, |  | ||||||
|   }); |  | ||||||
|   return result; |  | ||||||
| }; |  | ||||||
							
								
								
									
										12
									
								
								app/javascript/widget/api/contacts.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								app/javascript/widget/api/contacts.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | import { API } from 'widget/helpers/axios'; | ||||||
|  |  | ||||||
|  | const buildUrl = endPoint => `/api/v1/${endPoint}${window.location.search}`; | ||||||
|  |  | ||||||
|  | export default { | ||||||
|  |   update(identifier, userObject) { | ||||||
|  |     return API.patch(buildUrl('widget/contact'), { | ||||||
|  |       identifier, | ||||||
|  |       ...userObject, | ||||||
|  |     }); | ||||||
|  |   }, | ||||||
|  | }; | ||||||
							
								
								
									
										12
									
								
								app/javascript/widget/api/conversationLabels.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								app/javascript/widget/api/conversationLabels.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | import { API } from 'widget/helpers/axios'; | ||||||
|  |  | ||||||
|  | const buildUrl = endPoint => `/api/v1/${endPoint}${window.location.search}`; | ||||||
|  |  | ||||||
|  | export default { | ||||||
|  |   create(label) { | ||||||
|  |     return API.post(buildUrl('widget/labels'), { label }); | ||||||
|  |   }, | ||||||
|  |   destroy(label) { | ||||||
|  |     return API.delete(buildUrl(`widget/labels/${label}`)); | ||||||
|  |   }, | ||||||
|  | }; | ||||||
							
								
								
									
										11
									
								
								app/javascript/widget/api/message.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										11
									
								
								app/javascript/widget/api/message.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | import authEndPoint from 'widget/api/endPoints'; | ||||||
|  | import { API } from 'widget/helpers/axios'; | ||||||
|  |  | ||||||
|  | export default { | ||||||
|  |   update: ({ messageId, email }) => { | ||||||
|  |     const urlData = authEndPoint.updateContact(messageId); | ||||||
|  |     return API.patch(urlData.url, { | ||||||
|  |       contact: { email }, | ||||||
|  |     }); | ||||||
|  |   }, | ||||||
|  | }; | ||||||
| @@ -53,7 +53,7 @@ export default { | |||||||
|   }, |   }, | ||||||
|   computed: { |   computed: { | ||||||
|     ...mapGetters({ |     ...mapGetters({ | ||||||
|       uiFlags: 'contact/getUIFlags', |       uiFlags: 'message/getUIFlags', | ||||||
|       widgetColor: 'appConfig/getWidgetColor', |       widgetColor: 'appConfig/getWidgetColor', | ||||||
|     }), |     }), | ||||||
|     hasSubmitted() { |     hasSubmitted() { | ||||||
| @@ -71,7 +71,7 @@ export default { | |||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|     onSubmit() { |     onSubmit() { | ||||||
|       this.$store.dispatch('contact/updateContactAttributes', { |       this.$store.dispatch('message/updateContactAttributes', { | ||||||
|         email: this.email, |         email: this.email, | ||||||
|         messageId: this.messageId, |         messageId: this.messageId, | ||||||
|       }); |       }); | ||||||
|   | |||||||
| @@ -13,4 +13,13 @@ class ActionCableConnector extends BaseActionCableConnector { | |||||||
|   }; |   }; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | export const refreshActionCableConnector = pubsubToken => { | ||||||
|  |   window.chatwootPubsubToken = pubsubToken; | ||||||
|  |   window.actionCable.disconnect(); | ||||||
|  |   window.actionCable = new ActionCableConnector( | ||||||
|  |     window.WOOT_WIDGET, | ||||||
|  |     window.chatwootPubsubToken | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
| export default ActionCableConnector; | export default ActionCableConnector; | ||||||
|   | |||||||
| @@ -1,17 +1,21 @@ | |||||||
| import Vue from 'vue'; | import Vue from 'vue'; | ||||||
| import Vuex from 'vuex'; | import Vuex from 'vuex'; | ||||||
| import appConfig from 'widget/store/modules/appConfig'; |  | ||||||
| import contact from 'widget/store/modules/contact'; |  | ||||||
| import conversation from 'widget/store/modules/conversation'; |  | ||||||
| import agent from 'widget/store/modules/agent'; | import agent from 'widget/store/modules/agent'; | ||||||
|  | import appConfig from 'widget/store/modules/appConfig'; | ||||||
|  | import contacts from 'widget/store/modules/contacts'; | ||||||
|  | import conversation from 'widget/store/modules/conversation'; | ||||||
|  | import conversationLabels from 'widget/store/modules/conversationLabels'; | ||||||
|  | import message from 'widget/store/modules/message'; | ||||||
|  |  | ||||||
| Vue.use(Vuex); | Vue.use(Vuex); | ||||||
|  |  | ||||||
| export default new Vuex.Store({ | export default new Vuex.Store({ | ||||||
|   modules: { |   modules: { | ||||||
|     appConfig, |  | ||||||
|     contact, |  | ||||||
|     conversation, |  | ||||||
|     agent, |     agent, | ||||||
|  |     appConfig, | ||||||
|  |     message, | ||||||
|  |     contacts, | ||||||
|  |     conversation, | ||||||
|  |     conversationLabels, | ||||||
|   }, |   }, | ||||||
| }); | }); | ||||||
|   | |||||||
							
								
								
									
										28
									
								
								app/javascript/widget/store/modules/contacts.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/javascript/widget/store/modules/contacts.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | |||||||
|  | import ContactsAPI from '../../api/contacts'; | ||||||
|  | import { refreshActionCableConnector } from '../../helpers/actionCable'; | ||||||
|  |  | ||||||
|  | export const actions = { | ||||||
|  |   update: async (_, { identifier, user: userObject }) => { | ||||||
|  |     try { | ||||||
|  |       const user = { | ||||||
|  |         email: userObject.email, | ||||||
|  |         name: userObject.name, | ||||||
|  |         avatar_url: userObject.avatar_url, | ||||||
|  |       }; | ||||||
|  |       const { | ||||||
|  |         data: { pubsub_token: pubsubToken }, | ||||||
|  |       } = await ContactsAPI.update(identifier, user); | ||||||
|  |       refreshActionCableConnector(pubsubToken); | ||||||
|  |     } catch (error) { | ||||||
|  |       // Ingore error | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default { | ||||||
|  |   namespaced: true, | ||||||
|  |   state: {}, | ||||||
|  |   getters: {}, | ||||||
|  |   actions, | ||||||
|  |   mutations: {}, | ||||||
|  | }; | ||||||
							
								
								
									
										32
									
								
								app/javascript/widget/store/modules/conversationLabels.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								app/javascript/widget/store/modules/conversationLabels.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | |||||||
|  | import conversationLabels from '../../api/conversationLabels'; | ||||||
|  |  | ||||||
|  | const state = {}; | ||||||
|  |  | ||||||
|  | export const getters = {}; | ||||||
|  |  | ||||||
|  | export const actions = { | ||||||
|  |   create: async (_, label) => { | ||||||
|  |     try { | ||||||
|  |       await conversationLabels.create(label); | ||||||
|  |     } catch (error) { | ||||||
|  |       // Ingore error | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   destroy: async (_, label) => { | ||||||
|  |     try { | ||||||
|  |       await conversationLabels.destroy(label); | ||||||
|  |     } catch (error) { | ||||||
|  |       // Ingore error | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const mutations = {}; | ||||||
|  |  | ||||||
|  | export default { | ||||||
|  |   namespaced: true, | ||||||
|  |   state, | ||||||
|  |   getters, | ||||||
|  |   actions, | ||||||
|  |   mutations, | ||||||
|  | }; | ||||||
| @@ -1,4 +1,5 @@ | |||||||
| import { updateContact } from 'widget/api/contact'; | import MessageAPI from 'widget/api/message'; | ||||||
|  | import { refreshActionCableConnector } from '../../helpers/actionCable'; | ||||||
| 
 | 
 | ||||||
| const state = { | const state = { | ||||||
|   uiFlags: { |   uiFlags: { | ||||||
| @@ -14,7 +15,11 @@ const actions = { | |||||||
|   updateContactAttributes: async ({ commit }, { email, messageId }) => { |   updateContactAttributes: async ({ commit }, { email, messageId }) => { | ||||||
|     commit('toggleUpdateStatus', true); |     commit('toggleUpdateStatus', true); | ||||||
|     try { |     try { | ||||||
|       await updateContact({ email, messageId }); |       const { | ||||||
|  |         data: { | ||||||
|  |           contact: { pubsub_token: pubsubToken }, | ||||||
|  |         }, | ||||||
|  |       } = await MessageAPI.update({ email, messageId }); | ||||||
|       commit( |       commit( | ||||||
|         'conversation/updateMessage', |         'conversation/updateMessage', | ||||||
|         { |         { | ||||||
| @@ -23,6 +28,7 @@ const actions = { | |||||||
|         }, |         }, | ||||||
|         { root: true } |         { root: true } | ||||||
|       ); |       ); | ||||||
|  |       refreshActionCableConnector(pubsubToken); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       // Ignore error
 |       // Ignore error
 | ||||||
|     } |     } | ||||||
							
								
								
									
										8
									
								
								app/jobs/contact_avatar_job.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								app/jobs/contact_avatar_job.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | |||||||
|  | class ContactAvatarJob < ApplicationJob | ||||||
|  |   queue_as :default | ||||||
|  |  | ||||||
|  |   def perform(contact, avatar_url) | ||||||
|  |     avatar_resource = LocalResource.new(avatar_url) | ||||||
|  |     contact.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding) | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -3,7 +3,7 @@ | |||||||
| # Table name: accounts | # Table name: accounts | ||||||
| # | # | ||||||
| #  id         :integer          not null, primary key | #  id         :integer          not null, primary key | ||||||
| #  locale     :integer          default("English") | #  locale     :integer          default("eng") | ||||||
| #  name       :string           not null | #  name       :string           not null | ||||||
| #  created_at :datetime         not null | #  created_at :datetime         not null | ||||||
| #  updated_at :datetime         not null | #  updated_at :datetime         not null | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ | |||||||
| #  id                    :integer          not null, primary key | #  id                    :integer          not null, primary key | ||||||
| #  additional_attributes :jsonb | #  additional_attributes :jsonb | ||||||
| #  email                 :string | #  email                 :string | ||||||
|  | #  identifier            :string | ||||||
| #  name                  :string | #  name                  :string | ||||||
| #  phone_number          :string | #  phone_number          :string | ||||||
| #  pubsub_token          :string | #  pubsub_token          :string | ||||||
| @@ -14,8 +15,10 @@ | |||||||
| # | # | ||||||
| # Indexes | # Indexes | ||||||
| # | # | ||||||
| #  index_contacts_on_account_id    (account_id) | #  index_contacts_on_account_id         (account_id) | ||||||
| #  index_contacts_on_pubsub_token  (pubsub_token) UNIQUE | #  index_contacts_on_pubsub_token       (pubsub_token) UNIQUE | ||||||
|  | #  uniq_email_per_account_contact       (email,account_id) UNIQUE | ||||||
|  | #  uniq_identifier_per_account_contact  (identifier,account_id) UNIQUE | ||||||
| # | # | ||||||
|  |  | ||||||
| class Contact < ApplicationRecord | class Contact < ApplicationRecord | ||||||
| @@ -23,6 +26,8 @@ class Contact < ApplicationRecord | |||||||
|   include Avatarable |   include Avatarable | ||||||
|   include AvailabilityStatusable |   include AvailabilityStatusable | ||||||
|   validates :account_id, presence: true |   validates :account_id, presence: true | ||||||
|  |   validates :email, allow_blank: true, uniqueness: { scope: [:account_id], case_sensitive: false } | ||||||
|  |   validates :identifier, allow_blank: true, uniqueness: { scope: [:account_id] } | ||||||
|  |  | ||||||
|   belongs_to :account |   belongs_to :account | ||||||
|   has_many :conversations, dependent: :destroy |   has_many :conversations, dependent: :destroy | ||||||
| @@ -30,6 +35,8 @@ class Contact < ApplicationRecord | |||||||
|   has_many :inboxes, through: :contact_inboxes |   has_many :inboxes, through: :contact_inboxes | ||||||
|   has_many :messages, dependent: :destroy |   has_many :messages, dependent: :destroy | ||||||
|  |  | ||||||
|  |   before_validation :downcase_email | ||||||
|  |  | ||||||
|   def get_source_id(inbox_id) |   def get_source_id(inbox_id) | ||||||
|     contact_inboxes.find_by!(inbox_id: inbox_id).source_id |     contact_inboxes.find_by!(inbox_id: inbox_id).source_id | ||||||
|   end |   end | ||||||
| @@ -49,4 +56,8 @@ class Contact < ApplicationRecord | |||||||
|       name: name |       name: name | ||||||
|     } |     } | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def downcase_email | ||||||
|  |     email.downcase! if email.present? | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -17,6 +17,10 @@ class MessageTemplates::HookExecutionService | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def should_send_email_collect? |   def should_send_email_collect? | ||||||
|     conversation.inbox.web_widget? && first_message_from_contact? |     !contact_has_email? && conversation.inbox.web_widget? && first_message_from_contact? | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def contact_has_email? | ||||||
|  |     contact.email | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -30,11 +30,6 @@ class Twitter::WebhooksBaseService | |||||||
|       user['id'], user['name'], additional_contact_attributes(user) |       user['id'], user['name'], additional_contact_attributes(user) | ||||||
|     ) |     ) | ||||||
|     @contact = @contact_inbox.contact |     @contact = @contact_inbox.contact | ||||||
|     avatar_resource = LocalResource.new(user['profile_image_url']) |     ContactAvatarJob.perform_later(@contact, user['profile_image_url']) if user['profile_image_url'] | ||||||
|     @contact.avatar.attach( |  | ||||||
|       io: avatar_resource.file, |  | ||||||
|       filename: avatar_resource.tmp_filename, |  | ||||||
|       content_type: avatar_resource.encoding |  | ||||||
|     ) |  | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								app/views/api/v1/widget/messages/update.json.jbuilder
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								app/views/api/v1/widget/messages/update.json.jbuilder
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | json.contact @contact | ||||||
| @@ -1,6 +1,11 @@ | |||||||
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" /> | <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" /> | ||||||
|  |  | ||||||
| <script> | <script> | ||||||
|  |  | ||||||
|  | window.chatwootSettings = { | ||||||
|  |   hideMessageBubble: false, | ||||||
|  | }; | ||||||
|  |  | ||||||
| (function(d,t) { | (function(d,t) { | ||||||
|   var BASE_URL = ''; |   var BASE_URL = ''; | ||||||
|   var g=d.createElement(t),s=d.getElementsByTagName(t)[0]; |   var g=d.createElement(t),s=d.getElementsByTagName(t)[0]; | ||||||
|   | |||||||
| @@ -103,8 +103,10 @@ Rails.application.routes.draw do | |||||||
|       resource :profile, only: [:show, :update] |       resource :profile, only: [:show, :update] | ||||||
|  |  | ||||||
|       namespace :widget do |       namespace :widget do | ||||||
|         resources :messages, only: [:index, :create, :update] |         resource :contact, only: [:update] | ||||||
|         resources :inbox_members, only: [:index] |         resources :inbox_members, only: [:index] | ||||||
|  |         resources :labels, only: [:create, :destroy] | ||||||
|  |         resources :messages, only: [:index, :create, :update] | ||||||
|       end |       end | ||||||
|  |  | ||||||
|       resources :webhooks, only: [] do |       resources :webhooks, only: [] do | ||||||
|   | |||||||
							
								
								
									
										7
									
								
								db/migrate/20200331095710_add_identifier_to_contact.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								db/migrate/20200331095710_add_identifier_to_contact.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | |||||||
|  | class AddIdentifierToContact < ActiveRecord::Migration[6.0] | ||||||
|  |   def change | ||||||
|  |     add_column :contacts, :identifier, :string, index: true, default: nil | ||||||
|  |     add_index :contacts, ['identifier', :account_id], unique: true, name: 'uniq_identifier_per_account_contact' | ||||||
|  |     add_index :contacts, ['email', :account_id], unique: true, name: 'uniq_email_per_account_contact' | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -10,7 +10,7 @@ | |||||||
| # | # | ||||||
| # It's strongly recommended that you check this file into your version control system. | # It's strongly recommended that you check this file into your version control system. | ||||||
|  |  | ||||||
| ActiveRecord::Schema.define(version: 2020_03_25_210612) do | ActiveRecord::Schema.define(version: 2020_03_31_095710) do | ||||||
|  |  | ||||||
|   # These are extensions that must be enabled in order to support this database |   # These are extensions that must be enabled in order to support this database | ||||||
|   enable_extension "plpgsql" |   enable_extension "plpgsql" | ||||||
| @@ -157,7 +157,10 @@ ActiveRecord::Schema.define(version: 2020_03_25_210612) do | |||||||
|     t.datetime "updated_at", null: false |     t.datetime "updated_at", null: false | ||||||
|     t.string "pubsub_token" |     t.string "pubsub_token" | ||||||
|     t.jsonb "additional_attributes" |     t.jsonb "additional_attributes" | ||||||
|  |     t.string "identifier" | ||||||
|     t.index ["account_id"], name: "index_contacts_on_account_id" |     t.index ["account_id"], name: "index_contacts_on_account_id" | ||||||
|  |     t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true | ||||||
|  |     t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true | ||||||
|     t.index ["pubsub_token"], name: "index_contacts_on_pubsub_token", unique: true |     t.index ["pubsub_token"], name: "index_contacts_on_pubsub_token", unique: true | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										45
									
								
								spec/actions/contact_identify_action_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								spec/actions/contact_identify_action_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | |||||||
|  | require 'rails_helper' | ||||||
|  |  | ||||||
|  | describe ::ContactIdentifyAction do | ||||||
|  |   subject(:contact_identify) { described_class.new(contact: contact, params: params).perform } | ||||||
|  |  | ||||||
|  |   let!(:account) { create(:account) } | ||||||
|  |   let!(:contact) { create(:contact, account: account) } | ||||||
|  |   let(:params) { { name: 'test', identifier: 'test_id' } } | ||||||
|  |  | ||||||
|  |   describe '#perform' do | ||||||
|  |     it 'updates the contact' do | ||||||
|  |       expect(ContactAvatarJob).not_to receive(:perform_later).with(contact, params[:avatar_url]) | ||||||
|  |       contact_identify | ||||||
|  |       expect(contact.reload.name).to eq 'test' | ||||||
|  |       expect(contact.reload.identifier).to eq 'test_id' | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'enques avatar job when avatar url parameter is passed' do | ||||||
|  |       params = { name: 'test', avatar_url: 'https://via.placeholder.com/250x250.png' } | ||||||
|  |       expect(ContactAvatarJob).to receive(:perform_later).with(contact, params[:avatar_url]).once | ||||||
|  |       described_class.new(contact: contact, params: params).perform | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when contact with same identifier exists' do | ||||||
|  |       it 'merges the current contact to identified contact' do | ||||||
|  |         existing_identified_contact = create(:contact, account: account, identifier: 'test_id') | ||||||
|  |         result = contact_identify | ||||||
|  |         expect(result.id).to eq existing_identified_contact.id | ||||||
|  |         expect(result.name).to eq params[:name] | ||||||
|  |         expect { contact.reload }.to raise_error(ActiveRecord::RecordNotFound) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when contact with same email exists' do | ||||||
|  |       it 'merges the current contact to email contact' do | ||||||
|  |         existing_email_contact = create(:contact, account: account, email: 'test@test.com') | ||||||
|  |         params = { email: 'test@test.com' } | ||||||
|  |         result = described_class.new(contact: contact, params: params).perform | ||||||
|  |         expect(result.id).to eq existing_email_contact.id | ||||||
|  |         expect(result.name).to eq existing_email_contact.name | ||||||
|  |         expect { contact.reload }.to raise_error(ActiveRecord::RecordNotFound) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										42
									
								
								spec/controllers/api/v1/widget/contacts_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								spec/controllers/api/v1/widget/contacts_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | |||||||
|  | require 'rails_helper' | ||||||
|  |  | ||||||
|  | RSpec.describe '/api/v1/widget/contacts', type: :request do | ||||||
|  |   let(:account) { create(:account) } | ||||||
|  |   let(:web_widget) { create(:channel_widget, account: account) } | ||||||
|  |   let(:contact) { create(:contact, account: account) } | ||||||
|  |   let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) } | ||||||
|  |   let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } } | ||||||
|  |   let(:token) { ::Widget::TokenService.new(payload: payload).generate_token } | ||||||
|  |  | ||||||
|  |   describe 'PATCH /api/v1/widget/contact' do | ||||||
|  |     let(:params) { { website_token: web_widget.website_token, identifier: 'test' } } | ||||||
|  |  | ||||||
|  |     context 'with invalid website token' do | ||||||
|  |       it 'returns unauthorized' do | ||||||
|  |         patch '/api/v1/widget/contact', params: { website_token: '' } | ||||||
|  |         expect(response).to have_http_status(:not_found) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with correct website token' do | ||||||
|  |       let(:identify_action) { double } | ||||||
|  |  | ||||||
|  |       before do | ||||||
|  |         allow(ContactIdentifyAction).to receive(:new).and_return(identify_action) | ||||||
|  |         allow(identify_action).to receive(:perform) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'calls contact identify' do | ||||||
|  |         patch '/api/v1/widget/contact', | ||||||
|  |               params: params, | ||||||
|  |               headers: { 'X-Auth-Token' => token }, | ||||||
|  |               as: :json | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:success) | ||||||
|  |         expected_params = { contact: contact, params: params } | ||||||
|  |         expect(ContactIdentifyAction).to have_received(:new).with(expected_params) | ||||||
|  |         expect(identify_action).to have_received(:perform) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										63
									
								
								spec/controllers/api/v1/widget/labels_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								spec/controllers/api/v1/widget/labels_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | |||||||
|  | require 'rails_helper' | ||||||
|  |  | ||||||
|  | RSpec.describe '/api/v1/widget/labels', type: :request do | ||||||
|  |   let(:account) { create(:account) } | ||||||
|  |   let(:web_widget) { create(:channel_widget, account: account) } | ||||||
|  |   let(:contact) { create(:contact, account: account) } | ||||||
|  |   let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) } | ||||||
|  |   let!(:conversation) { create(:conversation, contact: contact, account: account, inbox: web_widget.inbox, contact_inbox: contact_inbox) } | ||||||
|  |   let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } } | ||||||
|  |   let(:token) { ::Widget::TokenService.new(payload: payload).generate_token } | ||||||
|  |  | ||||||
|  |   describe 'POST /api/v1/widget/labels' do | ||||||
|  |     let(:params) { { website_token: web_widget.website_token, label: 'customer-support' } } | ||||||
|  |  | ||||||
|  |     context 'with correct website token' do | ||||||
|  |       it 'returns the list of labels' do | ||||||
|  |         post '/api/v1/widget/labels', | ||||||
|  |              params: params, | ||||||
|  |              headers: { 'X-Auth-Token' => token }, | ||||||
|  |              as: :json | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:success) | ||||||
|  |         expect(conversation.reload.label_list.count).to eq 1 | ||||||
|  |         expect(conversation.reload.label_list.first).to eq 'customer-support' | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with invalid website token' do | ||||||
|  |       it 'returns the list of labels' do | ||||||
|  |         post '/api/v1/widget/labels', params: { website_token: '' } | ||||||
|  |         expect(response).to have_http_status(:not_found) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe 'DELETE /api/v1/widget/labels' do | ||||||
|  |     before do | ||||||
|  |       conversation.label_list.add('customer-support') | ||||||
|  |       conversation.save! | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     let(:params) { { website_token: web_widget.website_token, label: 'customer-support' } } | ||||||
|  |  | ||||||
|  |     context 'with correct website token' do | ||||||
|  |       it 'returns the list of labels' do | ||||||
|  |         delete "/api/v1/widget/labels/#{params[:label]}", | ||||||
|  |                params: params, | ||||||
|  |                headers: { 'X-Auth-Token' => token }, | ||||||
|  |                as: :json | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:success) | ||||||
|  |         expect(conversation.reload.label_list.count).to eq 0 | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with invalid website token' do | ||||||
|  |       it 'returns the list of labels' do | ||||||
|  |         delete "/api/v1/widget/labels/#{params[:label]}", params: { website_token: '' } | ||||||
|  |         expect(response).to have_http_status(:not_found) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -3,7 +3,7 @@ require 'rails_helper' | |||||||
| RSpec.describe '/api/v1/widget/messages', type: :request do | RSpec.describe '/api/v1/widget/messages', type: :request do | ||||||
|   let(:account) { create(:account) } |   let(:account) { create(:account) } | ||||||
|   let(:web_widget) { create(:channel_widget, account: account) } |   let(:web_widget) { create(:channel_widget, account: account) } | ||||||
|   let(:contact) { create(:contact, account: account) } |   let(:contact) { create(:contact, account: account, email: nil) } | ||||||
|   let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) } |   let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) } | ||||||
|   let(:conversation) { create(:conversation, contact: contact, account: account, inbox: web_widget.inbox, contact_inbox: contact_inbox) } |   let(:conversation) { create(:conversation, contact: contact, account: account, inbox: web_widget.inbox, contact_inbox: contact_inbox) } | ||||||
|   let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } } |   let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } } | ||||||
|   | |||||||
| @@ -6,7 +6,10 @@ describe ::MessageFinder do | |||||||
|   let!(:account) { create(:account) } |   let!(:account) { create(:account) } | ||||||
|   let!(:user) { create(:user, account: account) } |   let!(:user) { create(:user, account: account) } | ||||||
|   let!(:inbox) { create(:inbox, account: account) } |   let!(:inbox) { create(:inbox, account: account) } | ||||||
|   let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) } |   let!(:contact) { create(:contact, email: nil) } | ||||||
|  |   let!(:conversation) do | ||||||
|  |     create(:conversation, account: account, inbox: inbox, assignee: user, contact: contact) | ||||||
|  |   end | ||||||
|  |  | ||||||
|   before do |   before do | ||||||
|     create(:message, account: account, inbox: inbox, conversation: conversation) |     create(:message, account: account, inbox: inbox, conversation: conversation) | ||||||
|   | |||||||
| @@ -3,7 +3,10 @@ require 'rails_helper' | |||||||
| describe ::MessageTemplates::HookExecutionService do | describe ::MessageTemplates::HookExecutionService do | ||||||
|   context 'when it is a first message from web widget' do |   context 'when it is a first message from web widget' do | ||||||
|     it 'calls ::MessageTemplates::Template::EmailCollect' do |     it 'calls ::MessageTemplates::Template::EmailCollect' do | ||||||
|       message = create(:message) |       contact = create(:contact, email: nil) | ||||||
|  |       conversation = create(:conversation, contact: contact) | ||||||
|  |       message = create(:message, conversation: conversation) | ||||||
|  |  | ||||||
|       # this hook will only get executed for conversations with out any template messages |       # this hook will only get executed for conversations with out any template messages | ||||||
|       message.conversation.messages.template.destroy_all |       message.conversation.messages.template.destroy_all | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Pranav Raj S
					Pranav Raj S