From 49db9c5d8a3621a3c65ba61487bfafae58104a5b Mon Sep 17 00:00:00 2001 From: Nithin David Thomas Date: Wed, 8 Jul 2020 00:04:44 +0530 Subject: [PATCH] Adds unread message bubbles for widget (#943) Co-authored-by: Sojan Co-authored-by: Pranav Raj S --- .../api/v1/widget/conversations_controller.rb | 8 + app/javascript/packs/widget.js | 6 +- app/javascript/sdk/DOMHelpers.js | 4 + app/javascript/sdk/IFrameHelper.js | 49 ++++- app/javascript/sdk/bubbleHelpers.js | 18 +- app/javascript/widget/App.vue | 97 +++++++- app/javascript/widget/api/conversation.js | 8 + .../widget/assets/scss/_variables.scss | 9 + app/javascript/widget/assets/scss/sdk.js | 14 +- .../widget/components/AgentMessageBubble.vue | 2 +- app/javascript/widget/helpers/actionCable.js | 4 +- app/javascript/widget/i18n/locale/en.json | 4 + app/javascript/widget/store/modules/agent.js | 1 + .../widget/store/modules/conversation.js | 69 +++++- .../store/modules/conversationAttributes.js | 2 + .../specs/conversation/actions.spec.js | 8 + .../specs/conversation/getters.spec.js | 167 ++++++++++++++ .../conversationAttributes/actions.spec.js | 1 + app/javascript/widget/views/Home.vue | 35 ++- app/javascript/widget/views/Router.vue | 86 ++++++++ app/javascript/widget/views/Unread.vue | 207 ++++++++++++++++++ .../widget/conversations/index.json.jbuilder | 1 + config/routes.rb | 3 +- .../widget/conversations_controller_spec.rb | 34 ++- spec/factories/conversations.rb | 1 - 25 files changed, 787 insertions(+), 51 deletions(-) create mode 100644 app/javascript/widget/views/Router.vue create mode 100644 app/javascript/widget/views/Unread.vue diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index e346b6367..c8d3b64a7 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -5,6 +5,14 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController @conversation = conversation end + def update_last_seen + head :ok && return if conversation.nil? + + conversation.user_last_seen_at = DateTime.now.utc + conversation.save! + head :ok + end + def toggle_typing head :ok && return if conversation.nil? diff --git a/app/javascript/packs/widget.js b/app/javascript/packs/widget.js index ed9af223a..2bfdd7c52 100644 --- a/app/javascript/packs/widget.js +++ b/app/javascript/packs/widget.js @@ -3,7 +3,6 @@ import Vuelidate from 'vuelidate'; import VueI18n from 'vue-i18n'; import store from '../widget/store'; import App from '../widget/App.vue'; -import router from '../widget/router'; import ActionCableConnector from '../widget/helpers/actionCable'; import i18n from '../widget/i18n'; @@ -15,10 +14,13 @@ Object.keys(i18n).forEach(lang => { Vue.locale(lang, i18n[lang]); }); +// Event Bus +window.bus = new Vue(); + Vue.config.productionTip = false; + window.onload = () => { window.WOOT_WIDGET = new Vue({ - router, store, render: h => h(App), }).$mount('#app'); diff --git a/app/javascript/sdk/DOMHelpers.js b/app/javascript/sdk/DOMHelpers.js index 66ba6446b..196a98476 100644 --- a/app/javascript/sdk/DOMHelpers.js +++ b/app/javascript/sdk/DOMHelpers.js @@ -61,3 +61,7 @@ export const addClass = (elm, classes) => { export const toggleClass = (elm, classes) => { classHelper(classes, 'toggle', elm); }; + +export const removeClass = (elm, classes) => { + classHelper(classes, 'remove', elm); +}; diff --git a/app/javascript/sdk/IFrameHelper.js b/app/javascript/sdk/IFrameHelper.js index 751e9fa6b..c1ac73046 100644 --- a/app/javascript/sdk/IFrameHelper.js +++ b/app/javascript/sdk/IFrameHelper.js @@ -1,5 +1,5 @@ import Cookies from 'js-cookie'; -import { wootOn, loadCSS } from './DOMHelpers'; +import { wootOn, loadCSS, addClass, removeClass } from './DOMHelpers'; import { body, widgetHolder, @@ -29,7 +29,8 @@ export const IFrameHelper = { iframe.id = 'chatwoot_live_chat_widget'; iframe.style.visibility = 'hidden'; - widgetHolder.className = `woot-widget-holder woot--hide woot-elements--${window.$chatwoot.position}`; + const HolderclassName = `woot-widget-holder woot--hide woot-elements--${window.$chatwoot.position}`; + addClass(widgetHolder, HolderclassName); widgetHolder.appendChild(iframe); body.appendChild(widgetHolder); IFrameHelper.initPostMessageCommunication(); @@ -92,6 +93,8 @@ export const IFrameHelper = { window.$chatwoot.hasLoaded = true; IFrameHelper.sendMessage('config-set', { locale: window.$chatwoot.locale, + position: window.$chatwoot.position, + hideMessageBubble: window.$chatwoot.hideMessageBubble, }); IFrameHelper.onLoad(message.config.channelConfig); IFrameHelper.setCurrentUrl(); @@ -105,6 +108,37 @@ export const IFrameHelper = { toggleBubble: () => { onBubbleClick(); }, + + onBubbleToggle: isOpen => { + if (!isOpen) { + IFrameHelper.events.resetUnreadMode(); + } else { + IFrameHelper.pushEvent('webwidget.triggered'); + } + }, + + setUnreadMode: message => { + const { unreadMessageCount } = message; + const { isOpen } = window.$chatwoot; + const toggleValue = true; + + if (!isOpen && unreadMessageCount > 0) { + IFrameHelper.sendMessage('set-unread-view'); + onBubbleClick({ toggleValue }); + const holderEl = document.querySelector('.woot-widget-holder'); + addClass(holderEl, 'has-unread-view'); + } + }, + + resetUnreadMode: () => { + IFrameHelper.sendMessage('unset-unread-view'); + IFrameHelper.events.removeUnreadClass(); + }, + + removeUnreadClass: () => { + const holderEl = document.querySelector('.woot-widget-holder'); + removeClass(holderEl, 'has-unread-view'); + }, }, pushEvent: eventName => { IFrameHelper.sendMessage('push-event', { eventName }); @@ -125,7 +159,8 @@ export const IFrameHelper = { }); const closeIcon = closeBubble; - closeIcon.className = `woot-elements--${window.$chatwoot.position} woot-widget-bubble woot--close woot--hide`; + const closeIconclassName = `woot-elements--${window.$chatwoot.position} woot-widget-bubble woot--close woot--hide`; + addClass(closeIcon, closeIconclassName); chatIcon.style.background = widgetColor; closeIcon.style.background = widgetColor; @@ -143,9 +178,13 @@ export const IFrameHelper = { }, toggleCloseButton: () => { if (window.matchMedia('(max-width: 668px)').matches) { - IFrameHelper.sendMessage('toggle-close-button', { showClose: true }); + IFrameHelper.sendMessage('toggle-close-button', { + showClose: true, + }); } else { - IFrameHelper.sendMessage('toggle-close-button', { showClose: false }); + IFrameHelper.sendMessage('toggle-close-button', { + showClose: false, + }); } }, }; diff --git a/app/javascript/sdk/bubbleHelpers.js b/app/javascript/sdk/bubbleHelpers.js index cb58a4431..7499d337b 100644 --- a/app/javascript/sdk/bubbleHelpers.js +++ b/app/javascript/sdk/bubbleHelpers.js @@ -31,13 +31,17 @@ export const createNotificationBubble = () => { return notificationBubble; }; -export const onBubbleClick = () => { - window.$chatwoot.isOpen = !window.$chatwoot.isOpen; - toggleClass(chatBubble, 'woot--hide'); - toggleClass(closeBubble, 'woot--hide'); - toggleClass(widgetHolder, 'woot--hide'); - if (window.$chatwoot.isOpen) { - IFrameHelper.pushEvent('webwidget.triggered'); +export const onBubbleClick = (props = {}) => { + const { toggleValue } = props; + const { isOpen } = window.$chatwoot; + if (isOpen !== toggleValue) { + const newIsOpen = toggleValue === undefined ? !isOpen : toggleValue; + window.$chatwoot.isOpen = newIsOpen; + + toggleClass(chatBubble, 'woot--hide'); + toggleClass(closeBubble, 'woot--hide'); + toggleClass(widgetHolder, 'woot--hide'); + IFrameHelper.events.onBubbleToggle(newIsOpen); } }; diff --git a/app/javascript/widget/App.vue b/app/javascript/widget/App.vue index e5414a1ac..bdbd1388c 100755 --- a/app/javascript/widget/App.vue +++ b/app/javascript/widget/App.vue @@ -1,22 +1,57 @@ diff --git a/app/javascript/widget/api/conversation.js b/app/javascript/widget/api/conversation.js index d70aaf342..9933e529a 100755 --- a/app/javascript/widget/api/conversation.js +++ b/app/javascript/widget/api/conversation.js @@ -30,10 +30,18 @@ const toggleTyping = async ({ typingStatus }) => { ); }; +const setUserLastSeenAt = async ({ lastSeen }) => { + return API.post( + `/api/v1/widget/conversations/update_last_seen${window.location.search}`, + { user_last_seen_at: lastSeen } + ); +}; + export { sendMessageAPI, getConversationAPI, getMessagesAPI, sendAttachmentAPI, toggleTyping, + setUserLastSeenAt, }; diff --git a/app/javascript/widget/assets/scss/_variables.scss b/app/javascript/widget/assets/scss/_variables.scss index 7c3ba1a9b..f7f3837a3 100755 --- a/app/javascript/widget/assets/scss/_variables.scss +++ b/app/javascript/widget/assets/scss/_variables.scss @@ -60,6 +60,11 @@ $color-body: #3c4858; $color-heading: #1f2d3d; $color-error: #ff382d; +// Color-palettes + +$color-primary-light: #c7e3ff; +$color-primary-dark: darken($color-woot, 20%); + // Thumbnail $thumbnail-radius: 4rem; @@ -110,3 +115,7 @@ $spinkit-size: 1.6rem !default; // Break points $break-point-medium: 667px; + +// Timing functions + +$ease-in-cubic: cubic-bezier(.17, .67, .83, .67); diff --git a/app/javascript/widget/assets/scss/sdk.js b/app/javascript/widget/assets/scss/sdk.js index bee0ca26e..558812c57 100644 --- a/app/javascript/widget/assets/scss/sdk.js +++ b/app/javascript/widget/assets/scss/sdk.js @@ -11,6 +11,18 @@ export const SDK_CSS = ` .woot-widget-holder { transition-duration: 0.5s, 0.5s; } +.woot-widget-holder.has-unread-view { + box-shadow: none !important; + -moz-box-shadow: none !important; + -o-box-shadow: none !important; + -webkit-box-shadow: none !important; + -o-border-radius: 0 !important; + -moz-border-radius: 0 !important; + -webkit-border-radius: 0 !important; + border-radius: 0 !important; + bottom: 94px; +} + .woot-widget-holder iframe { width: 100% !important; height: 100% !important; @@ -94,7 +106,7 @@ export const SDK_CSS = ` .woot-widget-holder { visibility: hidden !important; z-index: -1 !important; opacity: 0; - bottom: 60px; + bottom: -20000px; } @media only screen and (max-width: 667px) { diff --git a/app/javascript/widget/components/AgentMessageBubble.vue b/app/javascript/widget/components/AgentMessageBubble.vue index 1062614da..2676839a6 100755 --- a/app/javascript/widget/components/AgentMessageBubble.vue +++ b/app/javascript/widget/components/AgentMessageBubble.vue @@ -1,5 +1,5 @@ diff --git a/app/javascript/widget/views/Unread.vue b/app/javascript/widget/views/Unread.vue new file mode 100644 index 000000000..2707f0638 --- /dev/null +++ b/app/javascript/widget/views/Unread.vue @@ -0,0 +1,207 @@ + + + + + + diff --git a/app/views/api/v1/widget/conversations/index.json.jbuilder b/app/views/api/v1/widget/conversations/index.json.jbuilder index 5c15a8364..8eb943785 100644 --- a/app/views/api/v1/widget/conversations/index.json.jbuilder +++ b/app/views/api/v1/widget/conversations/index.json.jbuilder @@ -1,5 +1,6 @@ if @conversation json.id @conversation.display_id json.inbox_id @conversation.inbox_id + json.user_last_seen_at @conversation.user_last_seen_at.to_i json.status @conversation.status end diff --git a/config/routes.rb b/config/routes.rb index 7c41848a4..0023e81ad 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -109,8 +109,9 @@ Rails.application.routes.draw do namespace :widget do resources :events, only: [:create] resources :messages, only: [:index, :create, :update] - resources :conversations do + resources :conversations, only: [:index] do collection do + post :update_last_seen post :toggle_typing end end diff --git a/spec/controllers/api/v1/widget/conversations_controller_spec.rb b/spec/controllers/api/v1/widget/conversations_controller_spec.rb index 7b3cc6538..8056b552f 100644 --- a/spec/controllers/api/v1/widget/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/widget/conversations_controller_spec.rb @@ -9,6 +9,24 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } } let(:token) { ::Widget::TokenService.new(payload: payload).generate_token } + describe 'GET /api/v1/widget/conversations' do + context 'with a conversation' do + it 'returns the correct conversation params' do + allow(Rails.configuration.dispatcher).to receive(:dispatch) + get '/api/v1/widget/conversations', + headers: { 'X-Auth-Token' => token }, + params: { website_token: web_widget.website_token }, + as: :json + + expect(response).to have_http_status(:success) + json_response = JSON.parse(response.body) + + expect(json_response['id']).to eq(conversation.display_id) + expect(json_response['status']).to eq(conversation.status) + end + end + end + describe 'POST /api/v1/widget/conversations/toggle_typing' do context 'with a conversation' do it 'dispatches the correct typing status' do @@ -25,20 +43,20 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do end end - describe 'POST /api/v1/widget/conversations' do + describe 'POST /api/v1/widget/conversations/update_last_seen' do context 'with a conversation' do it 'returns the correct conversation params' do allow(Rails.configuration.dispatcher).to receive(:dispatch) - get '/api/v1/widget/conversations', - headers: { 'X-Auth-Token' => token }, - params: { website_token: web_widget.website_token }, - as: :json + expect(conversation.user_last_seen_at).to eq(nil) + + post '/api/v1/widget/conversations/update_last_seen', + headers: { 'X-Auth-Token' => token }, + params: { website_token: web_widget.website_token }, + as: :json expect(response).to have_http_status(:success) - json_response = JSON.parse(response.body) - expect(json_response['id']).to eq(conversation.display_id) - expect(json_response['status']).to eq(conversation.status) + expect(conversation.reload.user_last_seen_at).not_to eq(nil) end end end diff --git a/spec/factories/conversations.rb b/spec/factories/conversations.rb index ca5960a51..e3647cc64 100644 --- a/spec/factories/conversations.rb +++ b/spec/factories/conversations.rb @@ -4,7 +4,6 @@ FactoryBot.define do factory :conversation do status { 'open' } display_id { rand(10_000_000) } - user_last_seen_at { Time.current } agent_last_seen_at { Time.current } locked { false } identifier { SecureRandom.hex }