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 @@