Feature: Typing Indicator on widget and dashboard (#811)

* Adds typing indicator for widget
* typing indicator for agents in dashboard

Co-authored-by: Pranav Raj Sreepuram <pranavrajs@gmail.com>
This commit is contained in:
Nithin David Thomas
2020-05-04 23:07:56 +05:30
committed by GitHub
parent fabc3170b7
commit 5bc8219db5
36 changed files with 663 additions and 78 deletions

View File

@@ -19,4 +19,11 @@ const getConversationAPI = async ({ before }) => {
return result;
};
export { sendMessageAPI, getConversationAPI, sendAttachmentAPI };
const toggleTyping = async ({ typingStatus }) => {
return API.post(
`/api/v1/widget/conversations/toggle_typing${window.location.search}`,
{ typing_status: typingStatus }
);
};
export { sendMessageAPI, getConversationAPI, sendAttachmentAPI, toggleTyping };

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,37 @@
<template>
<div class="agent-message-wrap">
<div class="agent-message">
<div class="avatar-wrap"></div>
<div class="message-wrap">
<div class="typing-bubble chat-bubble agent">
<img
src="~widget/assets/images/typing.gif"
alt="Agent is typing a message"
/>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'AgentTypingBubble',
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped>
@import '~widget/assets/scss/variables.scss';
.typing-bubble {
max-width: $space-medium;
padding: $space-smaller $space-small;
border-bottom-left-radius: $space-two;
border-top-left-radius: $space-small;
img {
width: 100%;
}
}
</style>

View File

@@ -5,6 +5,8 @@
:placeholder="placeholder"
:value="value"
@input="$emit('input', $event.target.value)"
@focus="onFocus"
@blur="onBlur"
/>
</resizable-textarea>
</template>
@@ -17,8 +19,25 @@ export default {
ResizableTextarea,
},
props: {
placeholder: String,
value: String,
placeholder: {
type: String,
default: '',
},
value: {
type: String,
default: '',
},
},
methods: {
onBlur() {
this.toggleTyping('off');
},
onFocus() {
this.toggleTyping('on');
},
toggleTyping(typingStatus) {
this.$store.dispatch('conversation/toggleUserTyping', { typingStatus });
},
},
};
</script>

View File

@@ -1,23 +1,29 @@
<template>
<div class="conversation--container">
<div class="conversation-wrap">
<div class="conversation-wrap" :class="{ 'is-typing': isAgentTyping }">
<div v-if="isFetchingList" class="message--loader">
<spinner></spinner>
</div>
<div v-for="groupedMessage in groupedMessages" :key="groupedMessage.date">
<div
v-for="groupedMessage in groupedMessages"
:key="groupedMessage.date"
class="messages-wrap"
>
<date-separator :date="groupedMessage.date"></date-separator>
<ChatMessage
<chat-message
v-for="message in groupedMessage.messages"
:key="message.id"
:message="message"
/>
</div>
<agent-typing-bubble v-if="isAgentTyping" />
</div>
</div>
</template>
<script>
import ChatMessage from 'widget/components/ChatMessage.vue';
import AgentTypingBubble from 'widget/components/AgentTypingBubble.vue';
import DateSeparator from 'shared/components/DateSeparator.vue';
import Spinner from 'shared/components/Spinner.vue';
import { mapActions, mapGetters } from 'vuex';
@@ -26,6 +32,7 @@ export default {
name: 'ConversationWrap',
components: {
ChatMessage,
AgentTypingBubble,
DateSeparator,
Spinner,
},
@@ -44,6 +51,7 @@ export default {
allMessagesLoaded: 'conversation/getAllMessagesLoaded',
isFetchingList: 'conversation/getIsFetchingList',
conversationSize: 'conversation/getConversationSize',
isAgentTyping: 'conversation/getIsAgentTyping',
}),
},
watch: {
@@ -109,3 +117,15 @@ export default {
text-align: center;
}
</style>
<style lang="scss">
.conversation-wrap.is-typing .messages-wrap div:last-child {
.agent-message {
.agent-name {
display: none;
}
.user-thumbnail-box {
margin-top: 0;
}
}
}
</style>

View File

@@ -6,6 +6,8 @@ class ActionCableConnector extends BaseActionCableConnector {
this.events = {
'message.created': this.onMessageCreated,
'message.updated': this.onMessageUpdated,
'conversation.typing_on': this.onTypingOn,
'conversation.typing_off': this.onTypingOff,
};
}
@@ -16,6 +18,35 @@ class ActionCableConnector extends BaseActionCableConnector {
onMessageUpdated = data => {
this.app.$store.dispatch('conversation/updateMessage', data);
};
onTypingOn = () => {
this.clearTimer();
this.app.$store.dispatch('conversation/toggleAgentTyping', {
status: 'on',
});
this.initTimer();
};
onTypingOff = () => {
this.clearTimer();
this.app.$store.dispatch('conversation/toggleAgentTyping', {
status: 'off',
});
};
clearTimer = () => {
if (this.CancelTyping) {
clearTimeout(this.CancelTyping);
this.CancelTyping = null;
}
};
initTimer = () => {
// Turn off typing automatically after 30 seconds
this.CancelTyping = setTimeout(() => {
this.onTypingOff();
}, 30000);
};
}
export const refreshActionCableConnector = pubsubToken => {

View File

@@ -4,6 +4,7 @@ import {
sendMessageAPI,
getConversationAPI,
sendAttachmentAPI,
toggleTyping,
} from 'widget/api/conversation';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { playNotificationAudio } from 'shared/helpers/AudioNotificationHelper';
@@ -36,11 +37,13 @@ const state = {
uiFlags: {
allMessagesLoaded: false,
isFetchingList: false,
isAgentTyping: false,
},
};
export const getters = {
getAllMessagesLoaded: _state => _state.uiFlags.allMessagesLoaded,
getIsAgentTyping: _state => _state.uiFlags.isAgentTyping,
getConversation: _state => _state.conversations,
getConversationSize: _state => Object.keys(_state.conversations).length,
getEarliestMessage: _state => {
@@ -132,6 +135,18 @@ export const actions = {
updateMessage({ commit }, data) {
commit('pushMessageToConversation', data);
},
toggleAgentTyping({ commit }, data) {
commit('toggleAgentTypingStatus', data);
},
toggleUserTyping: async (_, data) => {
try {
await toggleTyping(data);
} catch (error) {
// console error
}
},
};
export const mutations = {
@@ -192,6 +207,11 @@ export const mutations = {
},
};
},
toggleAgentTypingStatus($state, { status }) {
const isTyping = status === 'on';
$state.uiFlags.isAgentTyping = isTyping;
},
};
export default {

View File

@@ -33,6 +33,15 @@ describe('#actions', () => {
});
});
describe('#toggleAgentTyping', () => {
it('sends correct mutations', () => {
actions.toggleAgentTyping({ commit }, { status: true });
expect(commit).toBeCalledWith('toggleAgentTypingStatus', {
status: true,
});
});
});
describe('#sendMessage', () => {
it('sends correct mutations', () => {
const mockDate = new Date(1466424490000);

View File

@@ -48,10 +48,12 @@ describe('#getters', () => {
uiFlags: {
allMessagesLoaded: false,
isFetchingList: false,
isAgentTyping: false,
},
};
expect(getters.getAllMessagesLoaded(state)).toEqual(false);
expect(getters.getIsFetchingList(state)).toEqual(false);
expect(getters.getIsAgentTyping(state)).toEqual(false);
});
it('uiFlags', () => {

View File

@@ -93,6 +93,20 @@ describe('#mutations', () => {
});
});
describe('#toggleAgentTypingStatus', () => {
it('sets isAgentTyping flag to true', () => {
const state = { uiFlags: { isAgentTyping: false } };
mutations.toggleAgentTypingStatus(state, { status: 'on' });
expect(state.uiFlags.isAgentTyping).toEqual(true);
});
it('sets isAgentTyping flag to false', () => {
const state = { uiFlags: { isAgentTyping: false } };
mutations.toggleAgentTypingStatus(state, { status: 'off' });
expect(state.uiFlags.isAgentTyping).toEqual(false);
});
});
describe('#updateAttachmentMessageStatus', () => {
it('Updates status of loading messages if payload is not empty', () => {
const state = {

View File

@@ -1,5 +0,0 @@
<template>
<div class="about">
<h1>Chatwoot</h1>
</div>
</template>