Adds unread message bubbles for widget (#943)

Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: Pranav Raj S <pranav@thoughtwoot.com>
This commit is contained in:
Nithin David Thomas
2020-07-08 00:04:44 +05:30
committed by GitHub
parent 6a7d810c95
commit 49db9c5d8a
25 changed files with 787 additions and 51 deletions

View File

@@ -5,6 +5,14 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
@conversation = conversation @conversation = conversation
end 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 def toggle_typing
head :ok && return if conversation.nil? head :ok && return if conversation.nil?

View File

@@ -3,7 +3,6 @@ import Vuelidate from 'vuelidate';
import VueI18n from 'vue-i18n'; import VueI18n from 'vue-i18n';
import store from '../widget/store'; import store from '../widget/store';
import App from '../widget/App.vue'; import App from '../widget/App.vue';
import router from '../widget/router';
import ActionCableConnector from '../widget/helpers/actionCable'; import ActionCableConnector from '../widget/helpers/actionCable';
import i18n from '../widget/i18n'; import i18n from '../widget/i18n';
@@ -15,10 +14,13 @@ Object.keys(i18n).forEach(lang => {
Vue.locale(lang, i18n[lang]); Vue.locale(lang, i18n[lang]);
}); });
// Event Bus
window.bus = new Vue();
Vue.config.productionTip = false; Vue.config.productionTip = false;
window.onload = () => { window.onload = () => {
window.WOOT_WIDGET = new Vue({ window.WOOT_WIDGET = new Vue({
router,
store, store,
render: h => h(App), render: h => h(App),
}).$mount('#app'); }).$mount('#app');

View File

@@ -61,3 +61,7 @@ export const addClass = (elm, classes) => {
export const toggleClass = (elm, classes) => { export const toggleClass = (elm, classes) => {
classHelper(classes, 'toggle', elm); classHelper(classes, 'toggle', elm);
}; };
export const removeClass = (elm, classes) => {
classHelper(classes, 'remove', elm);
};

View File

@@ -1,5 +1,5 @@
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { wootOn, loadCSS } from './DOMHelpers'; import { wootOn, loadCSS, addClass, removeClass } from './DOMHelpers';
import { import {
body, body,
widgetHolder, widgetHolder,
@@ -29,7 +29,8 @@ export const IFrameHelper = {
iframe.id = 'chatwoot_live_chat_widget'; iframe.id = 'chatwoot_live_chat_widget';
iframe.style.visibility = 'hidden'; 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); widgetHolder.appendChild(iframe);
body.appendChild(widgetHolder); body.appendChild(widgetHolder);
IFrameHelper.initPostMessageCommunication(); IFrameHelper.initPostMessageCommunication();
@@ -92,6 +93,8 @@ export const IFrameHelper = {
window.$chatwoot.hasLoaded = true; window.$chatwoot.hasLoaded = true;
IFrameHelper.sendMessage('config-set', { IFrameHelper.sendMessage('config-set', {
locale: window.$chatwoot.locale, locale: window.$chatwoot.locale,
position: window.$chatwoot.position,
hideMessageBubble: window.$chatwoot.hideMessageBubble,
}); });
IFrameHelper.onLoad(message.config.channelConfig); IFrameHelper.onLoad(message.config.channelConfig);
IFrameHelper.setCurrentUrl(); IFrameHelper.setCurrentUrl();
@@ -105,6 +108,37 @@ export const IFrameHelper = {
toggleBubble: () => { toggleBubble: () => {
onBubbleClick(); 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 => { pushEvent: eventName => {
IFrameHelper.sendMessage('push-event', { eventName }); IFrameHelper.sendMessage('push-event', { eventName });
@@ -125,7 +159,8 @@ export const IFrameHelper = {
}); });
const closeIcon = closeBubble; 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; chatIcon.style.background = widgetColor;
closeIcon.style.background = widgetColor; closeIcon.style.background = widgetColor;
@@ -143,9 +178,13 @@ export const IFrameHelper = {
}, },
toggleCloseButton: () => { toggleCloseButton: () => {
if (window.matchMedia('(max-width: 668px)').matches) { if (window.matchMedia('(max-width: 668px)').matches) {
IFrameHelper.sendMessage('toggle-close-button', { showClose: true }); IFrameHelper.sendMessage('toggle-close-button', {
showClose: true,
});
} else { } else {
IFrameHelper.sendMessage('toggle-close-button', { showClose: false }); IFrameHelper.sendMessage('toggle-close-button', {
showClose: false,
});
} }
}, },
}; };

View File

@@ -31,13 +31,17 @@ export const createNotificationBubble = () => {
return notificationBubble; return notificationBubble;
}; };
export const onBubbleClick = () => { export const onBubbleClick = (props = {}) => {
window.$chatwoot.isOpen = !window.$chatwoot.isOpen; const { toggleValue } = props;
toggleClass(chatBubble, 'woot--hide'); const { isOpen } = window.$chatwoot;
toggleClass(closeBubble, 'woot--hide'); if (isOpen !== toggleValue) {
toggleClass(widgetHolder, 'woot--hide'); const newIsOpen = toggleValue === undefined ? !isOpen : toggleValue;
if (window.$chatwoot.isOpen) { window.$chatwoot.isOpen = newIsOpen;
IFrameHelper.pushEvent('webwidget.triggered');
toggleClass(chatBubble, 'woot--hide');
toggleClass(closeBubble, 'woot--hide');
toggleClass(widgetHolder, 'woot--hide');
IFrameHelper.events.onBubbleToggle(newIsOpen);
} }
}; };

View File

@@ -1,22 +1,57 @@
<template> <template>
<div id="app" class="woot-widget-wrap" :class="{ 'is-mobile': isMobile }"> <router
<router-view /> :show-unread-view="showUnreadView"
</div> :is-mobile="isMobile"
:grouped-messages="groupedMessages"
:unread-messages="unreadMessages"
:conversation-size="conversationSize"
:available-agents="availableAgents"
:has-fetched="hasFetched"
:conversation-attributes="conversationAttributes"
:unread-message-count="unreadMessageCount"
:is-left-aligned="isLeftAligned"
:hide-message-bubble="hideMessageBubble"
/>
</template> </template>
<script> <script>
import { mapActions } from 'vuex'; /* global bus */
import Vue from 'vue';
import { mapGetters, mapActions } from 'vuex';
import { setHeader } from 'widget/helpers/axios'; import { setHeader } from 'widget/helpers/axios';
import { IFrameHelper } from 'widget/helpers/utils'; import { IFrameHelper } from 'widget/helpers/utils';
import Vue from 'vue';
import Router from './views/Router';
export default { export default {
name: 'App', name: 'App',
components: {
Router,
},
data() { data() {
return { return {
showUnreadView: false,
isMobile: false, isMobile: false,
hideMessageBubble: false,
widgetPosition: 'right',
}; };
}, },
computed: {
...mapGetters({
groupedMessages: 'conversation/getGroupedConversation',
unreadMessages: 'conversation/getUnreadTextMessages',
conversationSize: 'conversation/getConversationSize',
availableAgents: 'agent/availableAgents',
hasFetched: 'agent/getHasFetched',
conversationAttributes: 'conversationAttributes/getConversationParams',
unreadMessageCount: 'conversation/getUnreadMessageCount',
}),
isLeftAligned() {
const isLeft = this.widgetPosition === 'left';
return isLeft;
},
},
mounted() { mounted() {
const { websiteToken, locale } = window.chatwootWebChannel; const { websiteToken, locale } = window.chatwootWebChannel;
this.setLocale(locale); this.setLocale(locale);
@@ -42,9 +77,13 @@ export default {
const message = JSON.parse(e.data.replace(wootPrefix, '')); const message = JSON.parse(e.data.replace(wootPrefix, ''));
if (message.event === 'config-set') { if (message.event === 'config-set') {
this.fetchOldConversations(); this.fetchOldConversations().then(() => {
this.setUnreadView();
});
this.fetchAvailableAgents(websiteToken); this.fetchAvailableAgents(websiteToken);
this.setLocale(message.locale); this.setLocale(message.locale);
this.setPosition(message.position);
this.setHideMessageBubble(message.hideMessageBubble);
} else if (message.event === 'widget-visible') { } else if (message.event === 'widget-visible') {
this.scrollConversationToBottom(); this.scrollConversationToBottom();
} else if (message.event === 'set-current-url') { } else if (message.event === 'set-current-url') {
@@ -52,7 +91,7 @@ export default {
} 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 === 'push-event') { } else if (message.event === 'push-event') {
this.$store.dispatch('events/create', { name: message.eventName }); this.createWidgetEvents(message);
} else if (message.event === 'set-label') { } else if (message.event === 'set-label') {
this.$store.dispatch('conversationLabels/create', message.label); this.$store.dispatch('conversationLabels/create', message.label);
} else if (message.event === 'remove-label') { } else if (message.event === 'remove-label') {
@@ -61,14 +100,19 @@ export default {
this.$store.dispatch('contacts/update', message); this.$store.dispatch('contacts/update', message);
} else if (message.event === 'set-locale') { } else if (message.event === 'set-locale') {
this.setLocale(message.locale); this.setLocale(message.locale);
} else if (message.event === 'set-unread-view') {
this.showUnreadView = true;
} else if (message.event === 'unset-unread-view') {
this.showUnreadView = false;
} }
}); });
this.$store.dispatch('conversationAttributes/get'); this.$store.dispatch('conversationAttributes/get');
this.registerUnreadEvents();
}, },
methods: { methods: {
...mapActions('appConfig', ['setWidgetColor']), ...mapActions('appConfig', ['setWidgetColor']),
...mapActions('conversation', ['fetchOldConversations']), ...mapActions('conversation', ['fetchOldConversations', 'setUserLastSeen']),
...mapActions('agent', ['fetchAvailableAgents']), ...mapActions('agent', ['fetchAvailableAgents']),
scrollConversationToBottom() { scrollConversationToBottom() {
const container = this.$el.querySelector('.conversation-wrap'); const container = this.$el.querySelector('.conversation-wrap');
@@ -80,6 +124,43 @@ export default {
Vue.config.lang = locale; Vue.config.lang = locale;
} }
}, },
setPosition(position) {
const widgetPosition = position || 'right';
this.widgetPosition = widgetPosition;
},
setHideMessageBubble(hideBubble) {
this.hideMessageBubble = !!hideBubble;
},
registerUnreadEvents() {
bus.$on('on-agent-message-recieved', () => this.setUnreadView());
bus.$on('on-unread-view-clicked', () => {
this.unsetUnreadView();
this.setUserLastSeen();
});
},
setUnreadView() {
const { unreadMessageCount } = this;
if (IFrameHelper.isIFrame() && unreadMessageCount > 0) {
IFrameHelper.sendMessage({
event: 'setUnreadMode',
unreadMessageCount,
});
}
},
unsetUnreadView() {
if (IFrameHelper.isIFrame()) {
IFrameHelper.sendMessage({ event: 'resetUnreadMode' });
}
},
createWidgetEvents(message) {
const { eventName } = message;
const isWidgetTriggerEvent = eventName === 'webwidget.triggered';
if (isWidgetTriggerEvent && this.showUnreadView) {
return;
}
this.setUserLastSeen();
this.$store.dispatch('events/create', { name: eventName });
},
}, },
}; };
</script> </script>

View File

@@ -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 { export {
sendMessageAPI, sendMessageAPI,
getConversationAPI, getConversationAPI,
getMessagesAPI, getMessagesAPI,
sendAttachmentAPI, sendAttachmentAPI,
toggleTyping, toggleTyping,
setUserLastSeenAt,
}; };

View File

@@ -60,6 +60,11 @@ $color-body: #3c4858;
$color-heading: #1f2d3d; $color-heading: #1f2d3d;
$color-error: #ff382d; $color-error: #ff382d;
// Color-palettes
$color-primary-light: #c7e3ff;
$color-primary-dark: darken($color-woot, 20%);
// Thumbnail // Thumbnail
$thumbnail-radius: 4rem; $thumbnail-radius: 4rem;
@@ -110,3 +115,7 @@ $spinkit-size: 1.6rem !default;
// Break points // Break points
$break-point-medium: 667px; $break-point-medium: 667px;
// Timing functions
$ease-in-cubic: cubic-bezier(.17, .67, .83, .67);

View File

@@ -11,6 +11,18 @@ export const SDK_CSS = ` .woot-widget-holder {
transition-duration: 0.5s, 0.5s; 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 { .woot-widget-holder iframe {
width: 100% !important; width: 100% !important;
height: 100% !important; height: 100% !important;
@@ -94,7 +106,7 @@ export const SDK_CSS = ` .woot-widget-holder {
visibility: hidden !important; visibility: hidden !important;
z-index: -1 !important; z-index: -1 !important;
opacity: 0; opacity: 0;
bottom: 60px; bottom: -20000px;
} }
@media only screen and (max-width: 667px) { @media only screen and (max-width: 667px) {

View File

@@ -1,5 +1,5 @@
<template> <template>
<div> <div class="chat-bubble-wrap">
<div <div
v-if="!isCards && !isOptions && !isForm && !isArticle" v-if="!isCards && !isOptions && !isForm && !isArticle"
class="chat-bubble agent" class="chat-bubble agent"

View File

@@ -19,7 +19,9 @@ class ActionCableConnector extends BaseActionCableConnector {
}; };
onMessageCreated = data => { onMessageCreated = data => {
this.app.$store.dispatch('conversation/addMessage', data); this.app.$store.dispatch('conversation/addMessage', data).then(() => {
window.bus.$emit('on-agent-message-recieved');
});
}; };
onMessageUpdated = data => { onMessageUpdated = data => {

View File

@@ -14,6 +14,10 @@
"OTHERS_ARE_AVAILABLE": "others are available", "OTHERS_ARE_AVAILABLE": "others are available",
"AND": "and" "AND": "and"
}, },
"UNREAD_VIEW": {
"VIEW_MESSAGES_BUTTON": "See new messages",
"CLOSE_MESSAGES_BUTTON": "Close"
},
"POWERED_BY": "Powered by Chatwoot", "POWERED_BY": "Powered by Chatwoot",
"EMAIL_PLACEHOLDER": "Please enter your email", "EMAIL_PLACEHOLDER": "Please enter your email",
"CHAT_PLACEHOLDER": "Type your message" "CHAT_PLACEHOLDER": "Type your message"

View File

@@ -11,6 +11,7 @@ const state = {
}; };
export const getters = { export const getters = {
getHasFetched: $state => $state.uiFlags.hasFetched,
availableAgents: $state => availableAgents: $state =>
$state.records.filter(agent => agent.availability_status === 'online'), $state.records.filter(agent => agent.availability_status === 'online'),
}; };

View File

@@ -5,6 +5,7 @@ import {
getMessagesAPI, getMessagesAPI,
sendAttachmentAPI, sendAttachmentAPI,
toggleTyping, toggleTyping,
setUserLastSeenAt,
} from 'widget/api/conversation'; } from 'widget/api/conversation';
import { MESSAGE_TYPE } from 'widget/helpers/constants'; import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { playNotificationAudio } from 'shared/helpers/AudioNotificationHelper'; import { playNotificationAudio } from 'shared/helpers/AudioNotificationHelper';
@@ -59,10 +60,22 @@ export const findUndeliveredMessage = (messageInbox, { content }) =>
message => message.content === content && message.status === 'in_progress' message => message.content === content && message.status === 'in_progress'
); );
export const onNewMessageCreated = data => {
const { message_type: messageType } = data;
const isIncomingMessage = messageType === MESSAGE_TYPE.OUTGOING;
if (isIncomingMessage) {
playNotificationAudio();
}
};
export const DEFAULT_CONVERSATION = 'default'; export const DEFAULT_CONVERSATION = 'default';
const state = { const state = {
conversations: {}, conversations: {},
meta: {
userLastSeenAt: undefined,
},
uiFlags: { uiFlags: {
allMessagesLoaded: false, allMessagesLoaded: false,
isFetchingList: false, isFetchingList: false,
@@ -93,6 +106,31 @@ export const getters = {
})); }));
}, },
getIsFetchingList: _state => _state.uiFlags.isFetchingList, getIsFetchingList: _state => _state.uiFlags.isFetchingList,
getUnreadMessageCount: _state => {
const { userLastSeenAt } = _state.meta;
console.log(userLastSeenAt);
const count = Object.values(_state.conversations).filter(chat => {
const { created_at: createdAt, message_type: messageType } = chat;
const isOutGoing = messageType === MESSAGE_TYPE.OUTGOING;
const hasNotSeen = userLastSeenAt
? createdAt * 1000 > userLastSeenAt * 1000
: true;
return hasNotSeen && isOutGoing;
}).length;
return count;
},
getUnreadTextMessages: (_state, _getters) => {
const unreadCount = _getters.getUnreadMessageCount;
const allMessages = [...Object.values(_state.conversations)];
console.log(unreadCount);
const unreadAgentMessages = allMessages.filter(message => {
const { message_type: messageType } = message;
return messageType === MESSAGE_TYPE.OUTGOING;
});
const maxUnreadCount = Math.min(unreadCount, 3);
const allUnreadMessages = unreadAgentMessages.splice(-maxUnreadCount);
return allUnreadMessages;
},
}; };
export const actions = { export const actions = {
@@ -112,7 +150,9 @@ export const actions = {
file_type: fileType, file_type: fileType,
status: 'in_progress', status: 'in_progress',
}; };
const tempMessage = createTemporaryMessage({ attachments: [attachment] }); const tempMessage = createTemporaryMessage({
attachments: [attachment],
});
commit('pushMessageToConversation', tempMessage); commit('pushMessageToConversation', tempMessage);
try { try {
const { data } = await sendAttachmentAPI(params); const { data } = await sendAttachmentAPI(params);
@@ -136,12 +176,9 @@ export const actions = {
} }
}, },
addMessage({ commit }, data) { addMessage: async ({ commit }, data) => {
if (data.message_type === MESSAGE_TYPE.OUTGOING) {
playNotificationAudio();
}
commit('pushMessageToConversation', data); commit('pushMessageToConversation', data);
onNewMessageCreated(data);
}, },
updateMessage({ commit }, data) { updateMessage({ commit }, data) {
@@ -156,7 +193,21 @@ export const actions = {
try { try {
await toggleTyping(data); await toggleTyping(data);
} catch (error) { } catch (error) {
// console error // IgnoreError
}
},
setUserLastSeen: async ({ commit, getters: appGetters }) => {
if (!appGetters.getConversationSize) {
return;
}
const lastSeen = Date.now() / 1000;
try {
commit('setMetaUserLastSeenAt', lastSeen);
await setUserLastSeenAt({ lastSeen });
} catch (error) {
// IgnoreError
} }
}, },
}; };
@@ -224,6 +275,10 @@ export const mutations = {
const isTyping = status === 'on'; const isTyping = status === 'on';
$state.uiFlags.isAgentTyping = isTyping; $state.uiFlags.isAgentTyping = isTyping;
}, },
setMetaUserLastSeenAt($state, lastSeen) {
$state.meta.userLastSeenAt = lastSeen;
},
}; };
export default { export default {

View File

@@ -17,7 +17,9 @@ export const actions = {
get: async ({ commit }) => { get: async ({ commit }) => {
try { try {
const { data } = await getConversationAPI(); const { data } = await getConversationAPI();
const { user_last_seen_at: lastSeen } = data;
commit(SET_CONVERSATION_ATTRIBUTES, data); commit(SET_CONVERSATION_ATTRIBUTES, data);
commit('conversation/setMetaUserLastSeenAt', lastSeen, { root: true });
} catch (error) { } catch (error) {
// Ignore error // Ignore error
} }

View File

@@ -86,4 +86,12 @@ describe('#actions', () => {
}); });
}); });
}); });
describe('#setUserLastSeen', () => {
it('sends correct mutations', () => {
const lastSeen = Math.abs(Date.now() / 1000);
actions.setUserLastSeen({ commit }, { lastSeen });
expect(commit).toBeCalledWith('setMetaUserLastSeenAt', lastSeen);
});
});
}); });

View File

@@ -262,4 +262,171 @@ describe('#getters', () => {
}, },
]); ]);
}); });
describe('getUnreadMessageCount returns', () => {
it('0 if there are no messages and last seen is undefined', () => {
const state = {
conversations: {},
meta: {
userLastSeenAt: undefined,
},
};
expect(getters.getUnreadMessageCount(state)).toEqual(0);
});
it('0 if there are no messages and last seen is present', () => {
const state = {
conversations: {},
meta: {
userLastSeenAt: Date.now(),
},
};
expect(getters.getUnreadMessageCount(state)).toEqual(0);
});
it('unread count if there are messages and last seen is before messages created-at', () => {
const state = {
conversations: {
1: {
id: 1,
content: 'Thanks for the help',
created_at: 1574075964,
message_type: 1,
},
2: {
id: 2,
content: 'Yes, It makes sense',
created_at: 1574092218,
message_type: 1,
},
},
meta: {
userLastSeenAt: 1474075964,
},
};
expect(getters.getUnreadMessageCount(state)).toEqual(2);
});
it('unread count if there are messages and last seen is after messages created-at', () => {
const state = {
conversations: {
1: {
id: 1,
content: 'Thanks for the help',
created_at: 1574075964,
message_type: 1,
},
2: {
id: 2,
content: 'Yes, It makes sense',
created_at: 1574092218,
message_type: 1,
},
3: {
id: 3,
content: 'Yes, It makes sense',
created_at: 1574092218,
message_type: 0,
},
},
meta: {
userLastSeenAt: 1674075964,
},
};
expect(getters.getUnreadMessageCount(state)).toEqual(0);
});
});
describe('getUnreadTextMessages returns', () => {
it('no messages if there are no messages and last seen is undefined', () => {
const state = {
conversations: {},
meta: {
userLastSeenAt: undefined,
},
};
expect(
getters.getUnreadTextMessages(state, { getUnreadMessageCount: 0 })
).toEqual([]);
});
it('0 if there are no messages and last seen is present', () => {
const state = {
conversations: {},
meta: {
userLastSeenAt: Date.now(),
},
};
expect(
getters.getUnreadTextMessages(state, { getUnreadMessageCount: 0 })
).toEqual([]);
});
it('only unread text messages from agent if there are messages and last seen is before messages created-at', () => {
const state = {
conversations: {
1: {
id: 1,
content: 'Thanks for the help',
created_at: 1574075964,
message_type: 1,
},
2: {
id: 2,
content: 'Yes, It makes sense',
created_at: 1574092218,
message_type: 0,
},
},
};
expect(
getters.getUnreadTextMessages(state, { getUnreadMessageCount: 1 })
).toEqual([
{
id: 1,
content: 'Thanks for the help',
created_at: 1574075964,
message_type: 1,
},
]);
});
it('unread messages omitting seen messages ', () => {
const state = {
conversations: {
1: {
id: 1,
content: 'Thanks for the help',
created_at: 1574075964,
message_type: 1,
},
2: {
id: 2,
content: 'Yes, It makes sense',
created_at: 1674075965,
message_type: 1,
},
3: {
id: 3,
content: 'Yes, It makes sense',
created_at: 1574092218,
message_type: 0,
},
},
meta: {
userLastSeenAt: 1674075964,
},
};
expect(
getters.getUnreadTextMessages(state, { getUnreadMessageCount: 1 })
).toEqual([
{
id: 2,
content: 'Yes, It makes sense',
created_at: 1674075965,
message_type: 1,
},
]);
});
});
}); });

View File

@@ -11,6 +11,7 @@ describe('#actions', () => {
await actions.get({ commit }); await actions.get({ commit });
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
['SET_CONVERSATION_ATTRIBUTES', { id: 1, status: 'bot' }], ['SET_CONVERSATION_ATTRIBUTES', { id: 1, status: 'bot' }],
['conversation/setMetaUserLastSeenAt', undefined, { root: true }],
]); ]);
}); });
it('doesnot send mutation if api is error', async () => { it('doesnot send mutation if api is error', async () => {

View File

@@ -25,8 +25,6 @@
</template> </template>
<script> <script>
import { mapGetters } from 'vuex';
import Branding from 'widget/components/Branding.vue'; import Branding from 'widget/components/Branding.vue';
import ChatFooter from 'widget/components/ChatFooter.vue'; import ChatFooter from 'widget/components/ChatFooter.vue';
import ChatHeaderExpanded from 'widget/components/ChatHeaderExpanded.vue'; import ChatHeaderExpanded from 'widget/components/ChatHeaderExpanded.vue';
@@ -46,14 +44,33 @@ export default {
AvailableAgents, AvailableAgents,
}, },
mixins: [configMixin], mixins: [configMixin],
props: {
groupedMessages: {
type: Array,
default: () => [],
},
conversationSize: {
type: Number,
default: 0,
},
availableAgents: {
type: Array,
default: () => [],
},
hasFetched: {
type: Boolean,
default: false,
},
conversationAttributes: {
type: Object,
default: () => {},
},
unreadMessageCount: {
type: Number,
default: 0,
},
},
computed: { computed: {
...mapGetters({
groupedMessages: 'conversation/getGroupedConversation',
conversationSize: 'conversation/getConversationSize',
availableAgents: 'agent/availableAgents',
hasFetched: 'agent/uiFlags/hasFetched',
conversationAttributes: 'conversationAttributes/getConversationParams',
}),
isOpen() { isOpen() {
return this.conversationAttributes.status === 'open'; return this.conversationAttributes.status === 'open';
}, },

View File

@@ -0,0 +1,86 @@
<template>
<div
id="app"
class="woot-widget-wrap"
:class="{ 'is-mobile': isMobile, 'is-widget-right': !isLeftAligned }"
>
<home
v-if="!showUnreadView"
:grouped-messages="groupedMessages"
:conversation-size="conversationSize"
:available-agents="availableAgents"
:has-fetched="hasFetched"
:conversation-attributes="conversationAttributes"
:unread-message-count="unreadMessageCount"
/>
<unread
v-else
:unread-messages="unreadMessages"
:conversation-size="conversationSize"
:available-agents="availableAgents"
:has-fetched="hasFetched"
:conversation-attributes="conversationAttributes"
:unread-message-count="unreadMessageCount"
:hide-message-bubble="hideMessageBubble"
/>
</div>
</template>
<script>
import Home from './Home';
import Unread from './Unread';
export default {
name: 'Router',
components: {
Home,
Unread,
},
props: {
groupedMessages: {
type: Array,
default: () => [],
},
unreadMessages: {
type: Array,
default: () => [],
},
conversationSize: {
type: Number,
default: 0,
},
availableAgents: {
type: Array,
default: () => [],
},
hasFetched: {
type: Boolean,
default: false,
},
isMobile: {
type: Boolean,
default: false,
},
isLeftAligned: {
type: Boolean,
default: false,
},
showUnreadView: {
type: Boolean,
default: false,
},
hideMessageBubble: {
type: Boolean,
default: false,
},
conversationAttributes: {
type: Object,
default: () => {},
},
unreadMessageCount: {
type: Number,
default: 0,
},
},
};
</script>

View File

@@ -0,0 +1,207 @@
<template>
<div class="unread-wrap">
<div class="close-unread-wrap">
<button
v-if="showCloseButton"
class="button small close-unread-button"
@click="closeFullView"
>
<i class="ion-close-round" />
{{ $t('UNREAD_VIEW.CLOSE_MESSAGES_BUTTON') }}
</button>
</div>
<div class="unread-messages">
<agent-bubble
v-for="message in unreadMessages"
:key="message.id"
:message-id="message.id"
:message="message.content"
/>
</div>
<div>
<button
v-if="unreadMessageCount"
class="button clear-button"
@click="openFullView"
>
<i class="ion-arrow-right-c" />
{{ $t('UNREAD_VIEW.VIEW_MESSAGES_BUTTON') }}
</button>
</div>
</div>
</template>
<script>
/* global bus */
import { IFrameHelper } from 'widget/helpers/utils';
import AgentBubble from 'widget/components/AgentMessageBubble.vue';
import configMixin from '../mixins/configMixin';
export default {
name: 'Unread',
components: {
AgentBubble,
},
mixins: [configMixin],
props: {
unreadMessages: {
type: Array,
default: () => [],
},
conversationSize: {
type: Number,
default: 0,
},
availableAgents: {
type: Array,
default: () => [],
},
hasFetched: {
type: Boolean,
default: false,
},
conversationAttributes: {
type: Object,
default: () => {},
},
unreadMessageCount: {
type: Number,
default: 0,
},
hideMessageBubble: {
type: Boolean,
default: false,
},
},
computed: {
showCloseButton() {
return this.unreadMessageCount && this.hideMessageBubble;
},
},
methods: {
openFullView() {
bus.$emit('on-unread-view-clicked');
},
closeFullView() {
if (IFrameHelper.isIFrame()) {
IFrameHelper.sendMessage({
event: 'toggleBubble',
});
}
},
},
};
</script>
<style lang="scss" scoped>
@import '~widget/assets/scss/woot.scss';
.unread-wrap {
width: 100%;
height: 100%;
background: transparent;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
justify-content: flex-end;
overflow: hidden;
.unread-messages {
padding-bottom: $space-small;
}
.clear-button {
background: transparent;
color: $color-woot;
padding: 0;
border: 0;
font-weight: $font-weight-bold;
font-size: $font-size-medium;
transition: all 0.3s $ease-in-cubic;
margin-left: $space-smaller;
padding-right: $space-one;
&:hover {
transform: translateX($space-smaller);
color: $color-primary-dark;
}
}
.close-unread-button {
background: $color-background;
color: $color-gray;
border: 0;
font-weight: $font-weight-bold;
font-size: $font-size-small;
transition: all 0.3s $ease-in-cubic;
margin-bottom: $space-slab;
border-radius: $space-normal;
&:hover {
color: $color-body;
}
}
.close-unread-wrap {
text-align: left;
}
}
</style>
<style lang="scss">
@import '~widget/assets/scss/woot.scss';
.unread-messages {
width: 100%;
margin-top: auto;
padding-bottom: $space-small;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
overflow-y: auto;
.chat-bubble-wrap {
margin-bottom: $space-smaller;
&:first-child {
margin-top: auto;
}
.chat-bubble {
border: 1px solid $color-border-dark;
}
+ .chat-bubble-wrap {
.chat-bubble {
border-top-left-radius: $space-smaller;
}
}
&:last-child .chat-bubble {
border-bottom-left-radius: $space-two;
}
}
}
.is-widget-right .unread-wrap {
text-align: right;
overflow: hidden;
.chat-bubble-wrap {
.chat-bubble {
border-radius: $space-two;
border-bottom-right-radius: $space-smaller;
}
+ .chat-bubble-wrap {
.chat-bubble {
border-top-right-radius: $space-smaller;
}
}
&:last-child .chat-bubble {
border-bottom-right-radius: $space-two;
}
}
.close-unread-wrap {
text-align: right;
}
}
</style>

View File

@@ -1,5 +1,6 @@
if @conversation if @conversation
json.id @conversation.display_id json.id @conversation.display_id
json.inbox_id @conversation.inbox_id json.inbox_id @conversation.inbox_id
json.user_last_seen_at @conversation.user_last_seen_at.to_i
json.status @conversation.status json.status @conversation.status
end end

View File

@@ -109,8 +109,9 @@ Rails.application.routes.draw do
namespace :widget do namespace :widget do
resources :events, only: [:create] resources :events, only: [:create]
resources :messages, only: [:index, :create, :update] resources :messages, only: [:index, :create, :update]
resources :conversations do resources :conversations, only: [:index] do
collection do collection do
post :update_last_seen
post :toggle_typing post :toggle_typing
end end
end end

View File

@@ -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(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } }
let(:token) { ::Widget::TokenService.new(payload: payload).generate_token } 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 describe 'POST /api/v1/widget/conversations/toggle_typing' do
context 'with a conversation' do context 'with a conversation' do
it 'dispatches the correct typing status' 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
end end
describe 'POST /api/v1/widget/conversations' do describe 'POST /api/v1/widget/conversations/update_last_seen' do
context 'with a conversation' do context 'with a conversation' do
it 'returns the correct conversation params' do it 'returns the correct conversation params' do
allow(Rails.configuration.dispatcher).to receive(:dispatch) allow(Rails.configuration.dispatcher).to receive(:dispatch)
get '/api/v1/widget/conversations', expect(conversation.user_last_seen_at).to eq(nil)
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token }, post '/api/v1/widget/conversations/update_last_seen',
as: :json headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token },
as: :json
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response['id']).to eq(conversation.display_id) expect(conversation.reload.user_last_seen_at).not_to eq(nil)
expect(json_response['status']).to eq(conversation.status)
end end
end end
end end

View File

@@ -4,7 +4,6 @@ FactoryBot.define do
factory :conversation do factory :conversation do
status { 'open' } status { 'open' }
display_id { rand(10_000_000) } display_id { rand(10_000_000) }
user_last_seen_at { Time.current }
agent_last_seen_at { Time.current } agent_last_seen_at { Time.current }
locked { false } locked { false }
identifier { SecureRandom.hex } identifier { SecureRandom.hex }