From cb22b396eb08f50263865b20abdc4906c986eab9 Mon Sep 17 00:00:00 2001 From: Pranav Raj S Date: Fri, 3 Apr 2020 13:04:58 +0530 Subject: [PATCH] Feature: Website SDK (#653) Add SDK functions Co-authored-by: Sojan --- app/actions/contact_identify_action.rb | 47 ++++ app/actions/contact_merge_action.rb | 1 + app/builders/messages/message_builder.rb | 6 +- .../api/v1/widget/contacts_controller.rb | 18 ++ .../api/v1/widget/labels_controller.rb | 24 ++ .../api/v1/widget/messages_controller.rb | 7 +- app/javascript/packs/sdk.js | 265 ++++-------------- app/javascript/sdk/DOMHelpers.js | 63 +++++ app/javascript/sdk/IFrameHelper.js | 134 +++++++++ app/javascript/sdk/bubbleHelpers.js | 51 ++++ .../helpers/BaseActionCableConnector.js | 8 +- app/javascript/widget/App.vue | 6 + app/javascript/widget/api/contact.js | 10 - app/javascript/widget/api/contacts.js | 12 + .../widget/api/conversationLabels.js | 12 + app/javascript/widget/api/message.js | 11 + .../widget/components/template/EmailInput.vue | 4 +- app/javascript/widget/helpers/actionCable.js | 9 + app/javascript/widget/store/index.js | 16 +- .../widget/store/modules/contacts.js | 28 ++ .../store/modules/conversationLabels.js | 32 +++ .../store/modules/{contact.js => message.js} | 10 +- app/jobs/contact_avatar_job.rb | 8 + app/models/account.rb | 2 +- app/models/contact.rb | 15 +- .../hook_execution_service.rb | 6 +- app/services/twitter/webhooks_base_service.rb | 7 +- .../v1/widget/messages/update.json.jbuilder | 1 + app/views/widget_tests/index.html.erb | 5 + config/routes.rb | 4 +- ...0200331095710_add_identifier_to_contact.rb | 7 + db/schema.rb | 5 +- spec/actions/contact_identify_action_spec.rb | 45 +++ .../api/v1/widget/contacts_controller_spec.rb | 42 +++ .../api/v1/widget/labels_controller_spec.rb | 63 +++++ .../api/v1/widget/messages_controller_spec.rb | 2 +- spec/finders/message_finder_spec.rb | 5 +- .../hook_execution_service_spec.rb | 5 +- 38 files changed, 734 insertions(+), 262 deletions(-) create mode 100644 app/actions/contact_identify_action.rb create mode 100644 app/controllers/api/v1/widget/contacts_controller.rb create mode 100644 app/controllers/api/v1/widget/labels_controller.rb create mode 100644 app/javascript/sdk/DOMHelpers.js create mode 100644 app/javascript/sdk/IFrameHelper.js create mode 100644 app/javascript/sdk/bubbleHelpers.js delete mode 100755 app/javascript/widget/api/contact.js create mode 100644 app/javascript/widget/api/contacts.js create mode 100644 app/javascript/widget/api/conversationLabels.js create mode 100755 app/javascript/widget/api/message.js create mode 100644 app/javascript/widget/store/modules/contacts.js create mode 100644 app/javascript/widget/store/modules/conversationLabels.js rename app/javascript/widget/store/modules/{contact.js => message.js} (70%) create mode 100644 app/jobs/contact_avatar_job.rb create mode 100644 app/views/api/v1/widget/messages/update.json.jbuilder create mode 100644 db/migrate/20200331095710_add_identifier_to_contact.rb create mode 100644 spec/actions/contact_identify_action_spec.rb create mode 100644 spec/controllers/api/v1/widget/contacts_controller_spec.rb create mode 100644 spec/controllers/api/v1/widget/labels_controller_spec.rb diff --git a/app/actions/contact_identify_action.rb b/app/actions/contact_identify_action.rb new file mode 100644 index 000000000..75af71a1f --- /dev/null +++ b/app/actions/contact_identify_action.rb @@ -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 diff --git a/app/actions/contact_merge_action.rb b/app/actions/contact_merge_action.rb index 393b3861b..8261a51ad 100644 --- a/app/actions/contact_merge_action.rb +++ b/app/actions/contact_merge_action.rb @@ -9,6 +9,7 @@ class ContactMergeAction merge_contact_inboxes remove_mergee_contact end + @base_contact end private diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 6d3a7d39b..a01df50c4 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -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` # Assumptions # 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? @contact = Contact.create!(contact_params.except(:remote_avatar_url)) - avatar_resource = LocalResource.new(contact_params[:remote_avatar_url]) - @contact.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding) - + ContactAvatarJob.perform_later(@contact, contact_params[:remote_avatar_url]) if contact_params[:remote_avatar_url] @contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id) end diff --git a/app/controllers/api/v1/widget/contacts_controller.rb b/app/controllers/api/v1/widget/contacts_controller.rb new file mode 100644 index 000000000..b7ac793e7 --- /dev/null +++ b/app/controllers/api/v1/widget/contacts_controller.rb @@ -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 diff --git a/app/controllers/api/v1/widget/labels_controller.rb b/app/controllers/api/v1/widget/labels_controller.rb new file mode 100644 index 000000000..efe84f5e3 --- /dev/null +++ b/app/controllers/api/v1/widget/labels_controller.rb @@ -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 diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index 809975ab4..7d16f7641 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -17,7 +17,6 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController def update @message.update!(input_submitted_email: contact_email) update_contact(contact_email) - head :no_content rescue StandardError => e render json: { error: @contact.errors, message: e.message }.to_json, status: 500 end @@ -96,7 +95,11 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController def update_contact(email) contact_with_email = @account.contacts.find_by(email: 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 @contact.update!( email: email, diff --git a/app/javascript/packs/sdk.js b/app/javascript/packs/sdk.js index 650bd1816..459c2bc4b 100755 --- a/app/javascript/packs/sdk.js +++ b/app/javascript/packs/sdk.js @@ -1,233 +1,62 @@ import Cookies from 'js-cookie'; +import { IFrameHelper } from '../sdk/IFrameHelper'; +import { onBubbleClick } from '../sdk/bubbleHelpers'; -import { SDK_CSS } from '../widget/assets/scss/sdk'; -/* eslint-disable no-param-reassign */ -const bubbleImg = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAUVBMVEUAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////8IN+deAAAAGnRSTlMAAwgJEBk0TVheY2R5eo+ut8jb5OXs8fX2+cjRDTIAAADsSURBVHgBldZbkoMgFIThRgQv8SKKgGf/C51UnJqaRI30/9zfe+NQUQ3TvG7bOk9DVeCmshmj/CuOTYnrdBfkUOg0zlOtl9OWVuEk4+QyZ3DIevmSt/ioTvK1VH/s5bY3YdM9SBZ/mUUyWgx+U06ycgp7D8msxSvtc4HXL9BLdj2elSEfhBJAI0QNgJEBI1BEBsQClVBVGDgwYOLAhJkDM1YOrNg4sLFAsLJgZsHEgoEFFQt0JAFGFjQsKAMJ0LFAexKgZYFyJIDxJIBNJEDNAtSJBLCeBDCOBFAPzwFA94ED+zmhwDO9358r8ANtIsMXi7qVAwAAAABJRU5ErkJggg=='; +const runSDK = ({ baseUrl, websiteToken }) => { + const chatwootSettings = window.chatwootSettings || {}; + window.$chatwoot = { + baseUrl, + hasLoaded: false, + hideMessageBubble: chatwootSettings.hideMessageBubble || false, + isOpen: false, + position: chatwootSettings.position || 'right', + websiteToken, -const body = document.getElementsByTagName('body')[0]; -const holder = document.createElement('div'); - -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(); + toggle() { + onBubbleClick(); }, - set_auth_token: message => { - Cookies.set('cw_conversation', message.authToken); - }, - toggleBubble: () => { - bubbleClickCallback(); - }, - }, - initPostMessageCommunication: () => { - window.onmessage = e => { - if ( - typeof e.data !== 'string' || - e.data.indexOf('chatwoot-widget:') !== 0 - ) { - return; + + setUser(identifier, user) { + if (typeof identifier === 'string' || typeof identifier === 'number') { + window.$chatwoot.identifier = identifier; + window.$chatwoot.user = user || {}; + IFrameHelper.sendMessage('set-user', { + identifier, + user: window.$chatwoot.user, + }); + } else { + throw new Error('Identifier should be a string or a number'); } - 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(); - createBubbleHolder(); + Cookies.remove('cw_conversation'); + 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({ baseUrl, websiteToken, }); -} +}; window.chatwootSDK = { - run: loadIframe, + run: runSDK, }; diff --git a/app/javascript/sdk/DOMHelpers.js b/app/javascript/sdk/DOMHelpers.js new file mode 100644 index 000000000..66ba6446b --- /dev/null +++ b/app/javascript/sdk/DOMHelpers.js @@ -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); +}; diff --git a/app/javascript/sdk/IFrameHelper.js b/app/javascript/sdk/IFrameHelper.js new file mode 100644 index 000000000..ec55c731d --- /dev/null +++ b/app/javascript/sdk/IFrameHelper.js @@ -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 }); + } + }, +}; diff --git a/app/javascript/sdk/bubbleHelpers.js b/app/javascript/sdk/bubbleHelpers.js new file mode 100644 index 000000000..243717263 --- /dev/null +++ b/app/javascript/sdk/bubbleHelpers.js @@ -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; +}; diff --git a/app/javascript/shared/helpers/BaseActionCableConnector.js b/app/javascript/shared/helpers/BaseActionCableConnector.js index 4ad5fc2f4..703b6e74a 100644 --- a/app/javascript/shared/helpers/BaseActionCableConnector.js +++ b/app/javascript/shared/helpers/BaseActionCableConnector.js @@ -2,8 +2,8 @@ import { createConsumer } from '@rails/actioncable'; class BaseActionCableConnector { constructor(app, pubsubToken) { - const consumer = createConsumer(); - consumer.subscriptions.create( + this.consumer = createConsumer(); + this.consumer.subscriptions.create( { channel: 'RoomChannel', pubsub_token: pubsubToken, @@ -16,6 +16,10 @@ class BaseActionCableConnector { this.events = {}; } + disconnect() { + this.consumer.disconnect(); + } + onReceived = ({ event, data } = {}) => { if (this.events[event] && typeof this.events[event] === 'function') { this.events[event](data); diff --git a/app/javascript/widget/App.vue b/app/javascript/widget/App.vue index e08dcb341..11cc3c508 100755 --- a/app/javascript/widget/App.vue +++ b/app/javascript/widget/App.vue @@ -47,6 +47,12 @@ export default { window.refererURL = message.refererURL; } else if (message.event === 'toggle-close-button') { 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); } }); }, diff --git a/app/javascript/widget/api/contact.js b/app/javascript/widget/api/contact.js deleted file mode 100755 index e5529c3dc..000000000 --- a/app/javascript/widget/api/contact.js +++ /dev/null @@ -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; -}; diff --git a/app/javascript/widget/api/contacts.js b/app/javascript/widget/api/contacts.js new file mode 100644 index 000000000..1a8ee5ea6 --- /dev/null +++ b/app/javascript/widget/api/contacts.js @@ -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, + }); + }, +}; diff --git a/app/javascript/widget/api/conversationLabels.js b/app/javascript/widget/api/conversationLabels.js new file mode 100644 index 000000000..95ae90f78 --- /dev/null +++ b/app/javascript/widget/api/conversationLabels.js @@ -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}`)); + }, +}; diff --git a/app/javascript/widget/api/message.js b/app/javascript/widget/api/message.js new file mode 100755 index 000000000..96723775d --- /dev/null +++ b/app/javascript/widget/api/message.js @@ -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 }, + }); + }, +}; diff --git a/app/javascript/widget/components/template/EmailInput.vue b/app/javascript/widget/components/template/EmailInput.vue index 2979dadc6..1951e8db4 100644 --- a/app/javascript/widget/components/template/EmailInput.vue +++ b/app/javascript/widget/components/template/EmailInput.vue @@ -53,7 +53,7 @@ export default { }, computed: { ...mapGetters({ - uiFlags: 'contact/getUIFlags', + uiFlags: 'message/getUIFlags', widgetColor: 'appConfig/getWidgetColor', }), hasSubmitted() { @@ -71,7 +71,7 @@ export default { }, methods: { onSubmit() { - this.$store.dispatch('contact/updateContactAttributes', { + this.$store.dispatch('message/updateContactAttributes', { email: this.email, messageId: this.messageId, }); diff --git a/app/javascript/widget/helpers/actionCable.js b/app/javascript/widget/helpers/actionCable.js index c8a5bbd33..1e4a12523 100644 --- a/app/javascript/widget/helpers/actionCable.js +++ b/app/javascript/widget/helpers/actionCable.js @@ -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; diff --git a/app/javascript/widget/store/index.js b/app/javascript/widget/store/index.js index d0a10b386..076951c8b 100755 --- a/app/javascript/widget/store/index.js +++ b/app/javascript/widget/store/index.js @@ -1,17 +1,21 @@ import Vue from 'vue'; 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 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); export default new Vuex.Store({ modules: { - appConfig, - contact, - conversation, agent, + appConfig, + message, + contacts, + conversation, + conversationLabels, }, }); diff --git a/app/javascript/widget/store/modules/contacts.js b/app/javascript/widget/store/modules/contacts.js new file mode 100644 index 000000000..3fa9cc1d9 --- /dev/null +++ b/app/javascript/widget/store/modules/contacts.js @@ -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: {}, +}; diff --git a/app/javascript/widget/store/modules/conversationLabels.js b/app/javascript/widget/store/modules/conversationLabels.js new file mode 100644 index 000000000..3fbcd230d --- /dev/null +++ b/app/javascript/widget/store/modules/conversationLabels.js @@ -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, +}; diff --git a/app/javascript/widget/store/modules/contact.js b/app/javascript/widget/store/modules/message.js similarity index 70% rename from app/javascript/widget/store/modules/contact.js rename to app/javascript/widget/store/modules/message.js index 8987df242..4243023a7 100644 --- a/app/javascript/widget/store/modules/contact.js +++ b/app/javascript/widget/store/modules/message.js @@ -1,4 +1,5 @@ -import { updateContact } from 'widget/api/contact'; +import MessageAPI from 'widget/api/message'; +import { refreshActionCableConnector } from '../../helpers/actionCable'; const state = { uiFlags: { @@ -14,7 +15,11 @@ const actions = { updateContactAttributes: async ({ commit }, { email, messageId }) => { commit('toggleUpdateStatus', true); try { - await updateContact({ email, messageId }); + const { + data: { + contact: { pubsub_token: pubsubToken }, + }, + } = await MessageAPI.update({ email, messageId }); commit( 'conversation/updateMessage', { @@ -23,6 +28,7 @@ const actions = { }, { root: true } ); + refreshActionCableConnector(pubsubToken); } catch (error) { // Ignore error } diff --git a/app/jobs/contact_avatar_job.rb b/app/jobs/contact_avatar_job.rb new file mode 100644 index 000000000..a99daca3e --- /dev/null +++ b/app/jobs/contact_avatar_job.rb @@ -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 diff --git a/app/models/account.rb b/app/models/account.rb index ca81bea2e..e67ee592e 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -3,7 +3,7 @@ # Table name: accounts # # id :integer not null, primary key -# locale :integer default("English") +# locale :integer default("eng") # name :string not null # created_at :datetime not null # updated_at :datetime not null diff --git a/app/models/contact.rb b/app/models/contact.rb index 9e07fc8fe..9687bcda5 100644 --- a/app/models/contact.rb +++ b/app/models/contact.rb @@ -5,6 +5,7 @@ # id :integer not null, primary key # additional_attributes :jsonb # email :string +# identifier :string # name :string # phone_number :string # pubsub_token :string @@ -14,8 +15,10 @@ # # Indexes # -# index_contacts_on_account_id (account_id) -# index_contacts_on_pubsub_token (pubsub_token) UNIQUE +# index_contacts_on_account_id (account_id) +# 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 @@ -23,6 +26,8 @@ class Contact < ApplicationRecord include Avatarable include AvailabilityStatusable 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 has_many :conversations, dependent: :destroy @@ -30,6 +35,8 @@ class Contact < ApplicationRecord has_many :inboxes, through: :contact_inboxes has_many :messages, dependent: :destroy + before_validation :downcase_email + def get_source_id(inbox_id) contact_inboxes.find_by!(inbox_id: inbox_id).source_id end @@ -49,4 +56,8 @@ class Contact < ApplicationRecord name: name } end + + def downcase_email + email.downcase! if email.present? + end end diff --git a/app/services/message_templates/hook_execution_service.rb b/app/services/message_templates/hook_execution_service.rb index 5117529c0..c626c4671 100644 --- a/app/services/message_templates/hook_execution_service.rb +++ b/app/services/message_templates/hook_execution_service.rb @@ -17,6 +17,10 @@ class MessageTemplates::HookExecutionService end 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 diff --git a/app/services/twitter/webhooks_base_service.rb b/app/services/twitter/webhooks_base_service.rb index 2f3d09290..ea0abadf8 100644 --- a/app/services/twitter/webhooks_base_service.rb +++ b/app/services/twitter/webhooks_base_service.rb @@ -30,11 +30,6 @@ class Twitter::WebhooksBaseService user['id'], user['name'], additional_contact_attributes(user) ) @contact = @contact_inbox.contact - avatar_resource = LocalResource.new(user['profile_image_url']) - @contact.avatar.attach( - io: avatar_resource.file, - filename: avatar_resource.tmp_filename, - content_type: avatar_resource.encoding - ) + ContactAvatarJob.perform_later(@contact, user['profile_image_url']) if user['profile_image_url'] end end diff --git a/app/views/api/v1/widget/messages/update.json.jbuilder b/app/views/api/v1/widget/messages/update.json.jbuilder new file mode 100644 index 000000000..da1e28d00 --- /dev/null +++ b/app/views/api/v1/widget/messages/update.json.jbuilder @@ -0,0 +1 @@ +json.contact @contact diff --git a/app/views/widget_tests/index.html.erb b/app/views/widget_tests/index.html.erb index b5e0fa385..6a9803273 100644 --- a/app/views/widget_tests/index.html.erb +++ b/app/views/widget_tests/index.html.erb @@ -1,6 +1,11 @@