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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 663 additions and 78 deletions

View file

@ -25,11 +25,10 @@ class Api::V1::Accounts::ConversationsController < Api::BaseController
end end
def toggle_typing_status def toggle_typing_status
user = current_user.presence || @resource
if params[:typing_status] == 'on' if params[:typing_status] == 'on'
Rails.configuration.dispatcher.dispatch(CONVERSATION_TYPING_ON, Time.zone.now, conversation: @conversation, user: user) trigger_typing_event(CONVERSATION_TYPING_ON)
elsif params[:typing_status] == 'off' elsif params[:typing_status] == 'off'
Rails.configuration.dispatcher.dispatch(CONVERSATION_TYPING_OFF, Time.zone.now, conversation: @conversation) trigger_typing_event(CONVERSATION_TYPING_OFF)
end end
head :ok head :ok
end end
@ -42,6 +41,11 @@ class Api::V1::Accounts::ConversationsController < Api::BaseController
private private
def trigger_typing_event(event)
user = current_user.presence || @resource
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user)
end
def parsed_last_seen_at def parsed_last_seen_at
DateTime.strptime(params[:agent_last_seen_at].to_s, '%s') DateTime.strptime(params[:agent_last_seen_at].to_s, '%s')
end end

View file

@ -0,0 +1,27 @@
class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
include Events::Types
before_action :set_web_widget
before_action :set_contact
def toggle_typing
head :ok if conversation.nil?
if permitted_params[:typing_status] == 'on'
trigger_typing_event(CONVERSATION_TYPING_ON)
elsif permitted_params[:typing_status] == 'off'
trigger_typing_event(CONVERSATION_TYPING_OFF)
end
head :ok
end
private
def trigger_typing_event(event)
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact)
end
def permitted_params
params.permit(:id, :typing_status, :website_token)
end
end

View file

@ -33,6 +33,12 @@ class ConversationApi extends ApiClient {
agent_last_seen_at: lastSeen, agent_last_seen_at: lastSeen,
}); });
} }
toggleTyping({ conversationId, status }) {
return axios.post(`${this.url}/${conversationId}/toggle_typing_status`, {
typing_status: status,
});
}
} }
export default new ConversationApi(); export default new ConversationApi();

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -377,8 +377,8 @@ $form-button-radius: $global-radius;
// 20. Label // 20. Label
// --------- // ---------
$label-background: $primary-color; $label-background: lighten($primary-color, 40%);
$label-color: $white; $label-color: $primary-color;
$label-color-alt: $black; $label-color-alt: $black;
$label-palette: $foundation-palette; $label-palette: $foundation-palette;
$label-font-size: $font-size-micro; $label-font-size: $font-size-micro;

View file

@ -1,25 +1,53 @@
.conversation { .conversation {
@include flex; @include flex;
@include flex-shrink; @include flex-shrink;
@include padding($space-normal $zero $zero $space-normal); @include padding(0 0 0 $space-normal);
border-left: 4px solid transparent; align-items: center;
border-bottom: 1px solid transparent;
border-left: $space-micro solid transparent;
border-top: 1px solid transparent;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
&.active { &.active {
background: $color-background; background: $color-background;
border-bottom-color: $color-border-light;
border-left-color: $color-woot; border-left-color: $color-woot;
border-top-color: $color-border-light;
.conversation--details {
border-top-color: transparent;
}
+.conversation .conversation--details {
border-top-color: transparent;
}
} }
&:first-child {
.conversation--details {
border-top-color: transparent;
}
}
&:nth-last-child(2) {
.conversation--details {
border-bottom-color: $color-border-light;
}
}
.conversation--details { .conversation--details {
@include margin($zero $zero $zero $space-one); @include margin(0 0 0 $space-one);
@include border-light-bottom; @include border-light-bottom;
@include padding($zero $zero $space-slab $zero); @include border-light-top;
@include padding($space-slab 0);
border-bottom-color: transparent;
} }
.conversation--user { .conversation--user {
font-size: $font-size-small; font-size: $font-size-small;
margin-bottom: $zero; margin-bottom: 0;
text-transform: capitalize; text-transform: capitalize;
.label { .label {
@ -39,7 +67,7 @@
font-weight: $font-weight-normal; font-weight: $font-weight-normal;
height: $space-medium; height: $space-medium;
line-height: $space-medium; line-height: $space-medium;
margin: $zero; margin: 0;
max-width: 96%; max-width: 96%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -54,20 +82,20 @@
.conversation--meta { .conversation--meta {
@include flex; @include flex;
display: block;
flex-direction: column; flex-direction: column;
position: absolute; position: absolute;
right: $space-normal; right: $space-normal;
top: $space-normal; top: $space-normal;
.unread { .unread {
$unread-size: $space-two - $space-micro; $unread-size: $space-normal;
@include round-corner; @include round-corner;
@include light-shadow;
background: darken($success-color, 3%); background: darken($success-color, 3%);
color: $color-white; color: $color-white;
display: none; display: none;
font-size: $font-size-micro; font-size: $font-size-micro;
font-weight: $font-weight-medium; font-weight: $font-weight-black;
height: $unread-size; height: $unread-size;
line-height: $unread-size; line-height: $unread-size;
margin-left: auto; margin-left: auto;

View file

@ -1,5 +1,5 @@
@mixin bubble-with-types { @mixin bubble-with-types {
@include padding($space-one $space-normal); @include padding($space-small $space-normal);
@include margin($zero); @include margin($zero);
background: $color-woot; background: $color-woot;
border-radius: $space-one; border-radius: $space-one;
@ -204,12 +204,14 @@
height: 100%; height: 100%;
margin-bottom: $space-small; margin-bottom: $space-small;
overflow-y: auto; overflow-y: auto;
position: relative;
} }
.conversation-panel>li { .conversation-panel>li {
@include flex; @include flex;
@include flex-shrink; @include flex-shrink;
@include margin($zero $zero $space-micro); @include margin($zero $zero $space-micro);
position: relative;
&:first-child { &:first-child {
margin-top: auto; margin-top: auto;
@ -393,3 +395,34 @@
} }
} }
} }
.conversation-footer {
display: flex;
flex-direction: column;
position: relative;
}
.typing-indicator-wrap {
align-items: center;
display: flex;
height: 0;
position: absolute;
top: -$space-large;
width: 100%;
.typing-indicator {
@include elegant-card;
@include round-corner;
background: $color-white;
color: $color-light-gray;
font-size: $font-size-mini;
font-weight: $font-weight-bold;
margin: $space-one auto;
padding: $space-small $space-normal $space-small $space-two;
.gif {
margin-left: $space-small;
width: $space-medium;
}
}
}

View file

@ -27,10 +27,22 @@
:data="message" :data="message"
/> />
</ul> </ul>
<ReplyBox <div class="conversation-footer">
:conversation-id="currentChat.id" <div v-if="isAnyoneTyping" class="typing-indicator-wrap">
@scrollToMessage="focusLastMessage" <div class="typing-indicator">
/> {{ typingUserNames }}
<img
class="gif"
src="~dashboard/assets/images/typing.gif"
alt="Someone is typing"
/>
</div>
</div>
<ReplyBox
:conversation-id="currentChat.id"
@scrollToMessage="focusLastMessage"
/>
</div>
</div> </div>
</template> </template>
@ -42,6 +54,7 @@ import ConversationHeader from './ConversationHeader';
import ReplyBox from './ReplyBox'; import ReplyBox from './ReplyBox';
import Message from './Message'; import Message from './Message';
import conversationMixin from '../../../mixins/conversations'; import conversationMixin from '../../../mixins/conversations';
import { getTypingUsersText } from '../../../helper/commons';
export default { export default {
components: { components: {
@ -81,6 +94,27 @@ export default {
loadingChatList: 'getChatListLoadingStatus', loadingChatList: 'getChatListLoadingStatus',
}), }),
typingUsersList() {
const userList = this.$store.getters[
'conversationTypingStatus/getUserList'
](this.currentChat.id);
return userList;
},
isAnyoneTyping() {
const userList = this.typingUsersList;
return userList.length !== 0;
},
typingUserNames() {
const userList = this.typingUsersList;
if (this.isAnyoneTyping) {
const userListAsName = getTypingUsersText(userList);
return userListAsName;
}
return '';
},
getMessages() { getMessages() {
const [chat] = this.allConversations.filter( const [chat] = this.allConversations.filter(
c => c.id === this.currentChat.id c => c.id === this.currentChat.id

View file

@ -20,8 +20,8 @@
class="input" class="input"
type="text" type="text"
:placeholder="$t(messagePlaceHolder())" :placeholder="$t(messagePlaceHolder())"
@click="onClick()" @focus="onFocus"
@blur="onBlur()" @blur="onBlur"
/> />
<file-upload <file-upload
v-if="showFileUpload" v-if="showFileUpload"
@ -260,25 +260,16 @@ export default {
onBlur() { onBlur() {
this.toggleTyping('off'); this.toggleTyping('off');
}, },
onClick() { onFocus() {
this.markSeen();
this.toggleTyping('on'); this.toggleTyping('on');
}, },
markSeen() {
if (this.channelType === 'Channel::FacebookPage') {
this.$store.dispatch('markSeen', {
inboxId: this.currentChat.inbox_id,
contactId: this.currentChat.meta.sender.id,
});
}
},
toggleTyping(status) { toggleTyping(status) {
if (this.channelType === 'Channel::FacebookPage') { if (this.channelType === 'Channel::WebWidget' && !this.isPrivate) {
const conversationId = this.currentChat.id;
this.$store.dispatch('toggleTyping', { this.$store.dispatch('toggleTyping', {
status, status,
inboxId: this.currentChat.inbox_id, conversationId,
contactId: this.currentChat.meta.sender.id,
}); });
} }
}, },

View file

@ -4,6 +4,7 @@ import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnec
class ActionCableConnector extends BaseActionCableConnector { class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) { constructor(app, pubsubToken) {
super(app, pubsubToken); super(app, pubsubToken);
this.CancelTyping = [];
this.events = { this.events = {
'message.created': this.onMessageCreated, 'message.created': this.onMessageCreated,
'message.updated': this.onMessageUpdated, 'message.updated': this.onMessageUpdated,
@ -13,6 +14,8 @@ class ActionCableConnector extends BaseActionCableConnector {
'user:logout': this.onLogout, 'user:logout': this.onLogout,
'page:reload': this.onReload, 'page:reload': this.onReload,
'assignee.changed': this.onAssigneeChanged, 'assignee.changed': this.onAssigneeChanged,
'conversation.typing_on': this.onTypingOn,
'conversation.typing_off': this.onTypingOff,
}; };
} }
@ -43,6 +46,44 @@ class ActionCableConnector extends BaseActionCableConnector {
onStatusChange = data => { onStatusChange = data => {
this.app.$store.dispatch('updateConversation', data); this.app.$store.dispatch('updateConversation', data);
}; };
onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id;
this.clearTimer(conversationId);
this.app.$store.dispatch('conversationTypingStatus/create', {
conversationId,
user,
});
this.initTimer({ conversation, user });
};
onTypingOff = ({ conversation, user }) => {
const conversationId = conversation.id;
this.clearTimer(conversationId);
this.app.$store.dispatch('conversationTypingStatus/destroy', {
conversationId,
user,
});
};
clearTimer = conversationId => {
const timerEvent = this.CancelTyping[conversationId];
if (timerEvent) {
clearTimeout(timerEvent);
this.CancelTyping[conversationId] = null;
}
};
initTimer = ({ conversation, user }) => {
const conversationId = conversation.id;
// Turn off typing automatically after 30 seconds
this.CancelTyping[conversationId] = setTimeout(() => {
this.onTypingOff({ conversation, user });
}, 30000);
};
} }
export default { export default {

View file

@ -9,3 +9,20 @@ export default () => {
}); });
} }
}; };
export const getTypingUsersText = (users = []) => {
const count = users.length;
if (count === 1) {
const [user] = users;
return `${user.name} is typing`;
}
if (count === 2) {
const [first, second] = users;
return `${first.name} and ${second.name} are typing`;
}
const [user] = users;
const rest = users.length - 1;
return `${user.name} and ${rest} others are typing`;
};

View file

@ -0,0 +1,26 @@
import { getTypingUsersText } from '../commons';
describe('#getTypingUsersText', () => {
it('returns the correct text is there is only one typing user', () => {
expect(getTypingUsersText([{ name: 'Pranav' }])).toEqual(
'Pranav is typing'
);
});
it('returns the correct text is there are two typing users', () => {
expect(
getTypingUsersText([{ name: 'Pranav' }, { name: 'Nithin' }])
).toEqual('Pranav and Nithin are typing');
});
it('returns the correct text is there are more than two users are typing', () => {
expect(
getTypingUsersText([
{ name: 'Pranav' },
{ name: 'Nithin' },
{ name: 'Subin' },
{ name: 'Sojan' },
])
).toEqual('Pranav and 3 others are typing');
});
});

View file

@ -10,6 +10,7 @@ import contactConversations from './modules/contactConversations';
import contacts from './modules/contacts'; import contacts from './modules/contacts';
import conversationLabels from './modules/conversationLabels'; import conversationLabels from './modules/conversationLabels';
import conversationMetadata from './modules/conversationMetadata'; import conversationMetadata from './modules/conversationMetadata';
import conversationTypingStatus from './modules/conversationTypingStatus';
import conversationPage from './modules/conversationPage'; import conversationPage from './modules/conversationPage';
import conversations from './modules/conversations'; import conversations from './modules/conversations';
import inboxes from './modules/inboxes'; import inboxes from './modules/inboxes';
@ -22,22 +23,23 @@ import accounts from './modules/accounts';
Vue.use(Vuex); Vue.use(Vuex);
export default new Vuex.Store({ export default new Vuex.Store({
modules: { modules: {
accounts,
agents, agents,
auth, auth,
billing, billing,
cannedResponse, cannedResponse,
Channel, Channel,
contacts,
contactConversations, contactConversations,
contacts,
conversationLabels, conversationLabels,
conversationMetadata, conversationMetadata,
conversationPage, conversationPage,
conversations, conversations,
conversationTypingStatus,
inboxes, inboxes,
inboxMembers, inboxMembers,
reports, reports,
userNotificationSettings, userNotificationSettings,
webhooks, webhooks,
accounts,
}, },
}); });

View file

@ -0,0 +1,60 @@
import Vue from 'vue';
import * as types from '../mutation-types';
const state = {
records: {},
};
export const getters = {
getUserList: $state => id => {
return $state.records[Number(id)] || [];
},
};
export const actions = {
create: ({ commit }, { conversationId, user }) => {
commit(types.default.ADD_USER_TYPING_TO_CONVERSATION, {
conversationId,
user,
});
},
destroy: ({ commit }, { conversationId, user }) => {
commit(types.default.REMOVE_USER_TYPING_FROM_CONVERSATION, {
conversationId,
user,
});
},
};
export const mutations = {
[types.default.ADD_USER_TYPING_TO_CONVERSATION]: (
$state,
{ conversationId, user }
) => {
const records = $state.records[conversationId] || [];
const hasUserRecordAlready = !!records.filter(
record => record.id === user.id && record.type === user.type
).length;
if (!hasUserRecordAlready) {
Vue.set($state.records, conversationId, [...records, user]);
}
},
[types.default.REMOVE_USER_TYPING_FROM_CONVERSATION]: (
$state,
{ conversationId, user }
) => {
const records = $state.records[conversationId] || [];
const updatedRecords = records.filter(
record => record.id !== user.id || record.type !== user.type
);
Vue.set($state.records, conversationId, updatedRecords);
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View file

@ -160,10 +160,10 @@ const actions = {
commit(types.default.UPDATE_CONVERSATION, conversation); commit(types.default.UPDATE_CONVERSATION, conversation);
}, },
toggleTyping: async ({ commit }, { status, inboxId, contactId }) => { toggleTyping: async ({ commit }, { status, conversationId }) => {
try { try {
await FBChannel.toggleTyping({ status, inboxId, contactId }); commit(types.default.SET_AGENT_TYPING, { status });
commit(types.default.FB_TYPING, { status }); await ConversationApi.toggleTyping({ status, conversationId });
} catch (error) { } catch (error) {
// Handle error // Handle error
} }

View file

@ -6,6 +6,14 @@ import getters, { getSelectedChatConversation } from './getters';
import actions from './actions'; import actions from './actions';
import wootConstants from '../../../constants'; import wootConstants from '../../../constants';
const initialSelectedChat = {
id: null,
meta: {},
status: null,
seen: false,
agentTyping: 'off',
dataFetched: false,
};
const state = { const state = {
allConversations: [], allConversations: [],
convTabStats: { convTabStats: {
@ -13,14 +21,7 @@ const state = {
unAssignedCount: 0, unAssignedCount: 0,
allCount: 0, allCount: 0,
}, },
selectedChat: { selectedChat: { ...initialSelectedChat },
id: null,
meta: {},
status: null,
seen: false,
agentTyping: 'off',
dataFetched: false,
},
listLoadingStatus: true, listLoadingStatus: true,
chatStatusFilter: wootConstants.STATUS_TYPE.OPEN, chatStatusFilter: wootConstants.STATUS_TYPE.OPEN,
currentInbox: null, currentInbox: null,
@ -42,14 +43,7 @@ const mutations = {
}, },
[types.default.EMPTY_ALL_CONVERSATION](_state) { [types.default.EMPTY_ALL_CONVERSATION](_state) {
_state.allConversations = []; _state.allConversations = [];
_state.selectedChat = { _state.selectedChat = { ...initialSelectedChat };
id: null,
meta: {},
status: null,
seen: false,
agentTyping: 'off',
dataFetched: false,
};
}, },
[types.default.SET_ALL_MESSAGES_LOADED](_state) { [types.default.SET_ALL_MESSAGES_LOADED](_state) {
const [chat] = getSelectedChatConversation(_state); const [chat] = getSelectedChatConversation(_state);
@ -175,7 +169,7 @@ const mutations = {
_state.selectedChat.seen = true; _state.selectedChat.seen = true;
}, },
[types.default.FB_TYPING](_state, { status }) { [types.default.SET_AGENT_TYPING](_state, { status }) {
_state.selectedChat.agentTyping = status; _state.selectedChat.agentTyping = status;
}, },

View file

@ -0,0 +1,36 @@
import { actions } from '../../conversationTypingStatus';
import * as types from '../../../mutation-types';
const commit = jest.fn();
describe('#actions', () => {
describe('#create', () => {
it('sends correct actions', () => {
actions.create(
{ commit },
{ conversationId: 1, user: { id: 1, name: 'user-1' } }
);
expect(commit.mock.calls).toEqual([
[
types.default.ADD_USER_TYPING_TO_CONVERSATION,
{ conversationId: 1, user: { id: 1, name: 'user-1' } },
],
]);
});
});
describe('#destroy', () => {
it('sends correct actions', () => {
actions.destroy(
{ commit },
{ conversationId: 1, user: { id: 1, name: 'user-1' } }
);
expect(commit.mock.calls).toEqual([
[
types.default.REMOVE_USER_TYPING_FROM_CONVERSATION,
{ conversationId: 1, user: { id: 1, name: 'user-1' } },
],
]);
});
});
});

View file

@ -0,0 +1,19 @@
import { getters } from '../../conversationTypingStatus';
describe('#getters', () => {
it('getUserList', () => {
const state = {
records: {
1: [
{ id: 1, name: 'user-1' },
{ id: 2, name: 'user-2' },
],
},
};
expect(getters.getUserList(state)(1)).toEqual([
{ id: 1, name: 'user-1' },
{ id: 2, name: 'user-2' },
]);
expect(getters.getUserList(state)(2)).toEqual([]);
});
});

View file

@ -0,0 +1,67 @@
import * as types from '../../../mutation-types';
import { mutations } from '../../conversationTypingStatus';
describe('#mutations', () => {
describe('#ADD_USER_TYPING_TO_CONVERSATION', () => {
it('add user to state', () => {
const state = { records: {} };
mutations[types.default.ADD_USER_TYPING_TO_CONVERSATION](state, {
conversationId: 1,
user: { id: 1, type: 'contact', name: 'user-1' },
});
expect(state.records).toEqual({
1: [{ id: 1, type: 'contact', name: 'user-1' }],
});
});
it('doesnot add user if user already exist', () => {
const state = {
records: {
1: [{ id: 1, type: 'contact', name: 'user-1' }],
},
};
mutations[types.default.ADD_USER_TYPING_TO_CONVERSATION](state, {
conversationId: 1,
user: { id: 1, type: 'contact', name: 'user-1' },
});
expect(state.records).toEqual({
1: [{ id: 1, type: 'contact', name: 'user-1' }],
});
});
it('add user to state if no matching user profiles are seen', () => {
const state = {
records: {
1: [{ id: 1, type: 'user', name: 'user-1' }],
},
};
mutations[types.default.ADD_USER_TYPING_TO_CONVERSATION](state, {
conversationId: 1,
user: { id: 1, type: 'contact', name: 'user-1' },
});
expect(state.records).toEqual({
1: [
{ id: 1, type: 'user', name: 'user-1' },
{ id: 1, type: 'contact', name: 'user-1' },
],
});
});
});
describe('#REMOVE_USER_TYPING_FROM_CONVERSATION', () => {
it('remove add user if user exist', () => {
const state = {
records: {
1: [{ id: 1, type: 'contact', name: 'user-1' }],
},
};
mutations[types.default.REMOVE_USER_TYPING_FROM_CONVERSATION](state, {
conversationId: 1,
user: { id: 1, type: 'contact', name: 'user-1' },
});
expect(state.records).toEqual({
1: [],
});
});
});
});

View file

@ -28,7 +28,7 @@ export default {
ADD_MESSAGE: 'ADD_MESSAGE', ADD_MESSAGE: 'ADD_MESSAGE',
MARK_SEEN: 'MARK_SEEN', MARK_SEEN: 'MARK_SEEN',
MARK_MESSAGE_READ: 'MARK_MESSAGE_READ', MARK_MESSAGE_READ: 'MARK_MESSAGE_READ',
FB_TYPING: 'FB_TYPING', SET_AGENT_TYPING: 'SET_AGENT_TYPING',
SET_PREVIOUS_CONVERSATIONS: 'SET_PREVIOUS_CONVERSATIONS', SET_PREVIOUS_CONVERSATIONS: 'SET_PREVIOUS_CONVERSATIONS',
SET_ACTIVE_INBOX: 'SET_ACTIVE_INBOX', SET_ACTIVE_INBOX: 'SET_ACTIVE_INBOX',
@ -104,4 +104,8 @@ export default {
// Notification Settings // Notification Settings
SET_USER_NOTIFICATION_UI_FLAG: 'SET_USER_NOTIFICATION_UI_FLAG', SET_USER_NOTIFICATION_UI_FLAG: 'SET_USER_NOTIFICATION_UI_FLAG',
SET_USER_NOTIFICATION: 'SET_USER_NOTIFICATION', SET_USER_NOTIFICATION: 'SET_USER_NOTIFICATION',
// User Typing
ADD_USER_TYPING_TO_CONVERSATION: 'ADD_USER_TYPING_TO_CONVERSATION',
REMOVE_USER_TYPING_FROM_CONVERSATION: 'REMOVE_USER_TYPING_FROM_CONVERSATION',
}; };

View file

@ -19,4 +19,11 @@ const getConversationAPI = async ({ before }) => {
return result; 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" :placeholder="placeholder"
:value="value" :value="value"
@input="$emit('input', $event.target.value)" @input="$emit('input', $event.target.value)"
@focus="onFocus"
@blur="onBlur"
/> />
</resizable-textarea> </resizable-textarea>
</template> </template>
@ -17,8 +19,25 @@ export default {
ResizableTextarea, ResizableTextarea,
}, },
props: { props: {
placeholder: String, placeholder: {
value: String, 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> </script>

View file

@ -1,23 +1,29 @@
<template> <template>
<div class="conversation--container"> <div class="conversation--container">
<div class="conversation-wrap"> <div class="conversation-wrap" :class="{ 'is-typing': isAgentTyping }">
<div v-if="isFetchingList" class="message--loader"> <div v-if="isFetchingList" class="message--loader">
<spinner></spinner> <spinner></spinner>
</div> </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> <date-separator :date="groupedMessage.date"></date-separator>
<ChatMessage <chat-message
v-for="message in groupedMessage.messages" v-for="message in groupedMessage.messages"
:key="message.id" :key="message.id"
:message="message" :message="message"
/> />
</div> </div>
<agent-typing-bubble v-if="isAgentTyping" />
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import ChatMessage from 'widget/components/ChatMessage.vue'; import ChatMessage from 'widget/components/ChatMessage.vue';
import AgentTypingBubble from 'widget/components/AgentTypingBubble.vue';
import DateSeparator from 'shared/components/DateSeparator.vue'; import DateSeparator from 'shared/components/DateSeparator.vue';
import Spinner from 'shared/components/Spinner.vue'; import Spinner from 'shared/components/Spinner.vue';
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
@ -26,6 +32,7 @@ export default {
name: 'ConversationWrap', name: 'ConversationWrap',
components: { components: {
ChatMessage, ChatMessage,
AgentTypingBubble,
DateSeparator, DateSeparator,
Spinner, Spinner,
}, },
@ -44,6 +51,7 @@ export default {
allMessagesLoaded: 'conversation/getAllMessagesLoaded', allMessagesLoaded: 'conversation/getAllMessagesLoaded',
isFetchingList: 'conversation/getIsFetchingList', isFetchingList: 'conversation/getIsFetchingList',
conversationSize: 'conversation/getConversationSize', conversationSize: 'conversation/getConversationSize',
isAgentTyping: 'conversation/getIsAgentTyping',
}), }),
}, },
watch: { watch: {
@ -109,3 +117,15 @@ export default {
text-align: center; text-align: center;
} }
</style> </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 = { this.events = {
'message.created': this.onMessageCreated, 'message.created': this.onMessageCreated,
'message.updated': this.onMessageUpdated, 'message.updated': this.onMessageUpdated,
'conversation.typing_on': this.onTypingOn,
'conversation.typing_off': this.onTypingOff,
}; };
} }
@ -16,6 +18,35 @@ class ActionCableConnector extends BaseActionCableConnector {
onMessageUpdated = data => { onMessageUpdated = data => {
this.app.$store.dispatch('conversation/updateMessage', 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 => { export const refreshActionCableConnector = pubsubToken => {

View file

@ -4,6 +4,7 @@ import {
sendMessageAPI, sendMessageAPI,
getConversationAPI, getConversationAPI,
sendAttachmentAPI, sendAttachmentAPI,
toggleTyping,
} 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';
@ -36,11 +37,13 @@ const state = {
uiFlags: { uiFlags: {
allMessagesLoaded: false, allMessagesLoaded: false,
isFetchingList: false, isFetchingList: false,
isAgentTyping: false,
}, },
}; };
export const getters = { export const getters = {
getAllMessagesLoaded: _state => _state.uiFlags.allMessagesLoaded, getAllMessagesLoaded: _state => _state.uiFlags.allMessagesLoaded,
getIsAgentTyping: _state => _state.uiFlags.isAgentTyping,
getConversation: _state => _state.conversations, getConversation: _state => _state.conversations,
getConversationSize: _state => Object.keys(_state.conversations).length, getConversationSize: _state => Object.keys(_state.conversations).length,
getEarliestMessage: _state => { getEarliestMessage: _state => {
@ -132,6 +135,18 @@ export const actions = {
updateMessage({ commit }, data) { updateMessage({ commit }, data) {
commit('pushMessageToConversation', data); commit('pushMessageToConversation', data);
}, },
toggleAgentTyping({ commit }, data) {
commit('toggleAgentTypingStatus', data);
},
toggleUserTyping: async (_, data) => {
try {
await toggleTyping(data);
} catch (error) {
// console error
}
},
}; };
export const mutations = { export const mutations = {
@ -192,6 +207,11 @@ export const mutations = {
}, },
}; };
}, },
toggleAgentTypingStatus($state, { status }) {
const isTyping = status === 'on';
$state.uiFlags.isAgentTyping = isTyping;
},
}; };
export default { 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', () => { describe('#sendMessage', () => {
it('sends correct mutations', () => { it('sends correct mutations', () => {
const mockDate = new Date(1466424490000); const mockDate = new Date(1466424490000);

View file

@ -48,10 +48,12 @@ describe('#getters', () => {
uiFlags: { uiFlags: {
allMessagesLoaded: false, allMessagesLoaded: false,
isFetchingList: false, isFetchingList: false,
isAgentTyping: false,
}, },
}; };
expect(getters.getAllMessagesLoaded(state)).toEqual(false); expect(getters.getAllMessagesLoaded(state)).toEqual(false);
expect(getters.getIsFetchingList(state)).toEqual(false); expect(getters.getIsFetchingList(state)).toEqual(false);
expect(getters.getIsAgentTyping(state)).toEqual(false);
}); });
it('uiFlags', () => { 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', () => { describe('#updateAttachmentMessageStatus', () => {
it('Updates status of loading messages if payload is not empty', () => { it('Updates status of loading messages if payload is not empty', () => {
const state = { const state = {

View file

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

View file

@ -52,22 +52,28 @@ class ActionCableListener < BaseListener
conversation = event.data[:conversation] conversation = event.data[:conversation]
account = conversation.account account = conversation.account
user = event.data[:user] user = event.data[:user]
tokens = user_tokens(account, conversation.inbox.members) + tokens = typing_event_listener_tokens(account, conversation, user)
[conversation.contact.pubsub_token]
broadcast(tokens, CONVERSATION_TYPING_ON, broadcast(
conversation: conversation.push_event_data, user: user.push_event_data) tokens,
CONVERSATION_TYPING_ON,
conversation: conversation.push_event_data,
user: user.push_event_data
)
end end
def conversation_typing_off(event) def conversation_typing_off(event)
conversation = event.data[:conversation] conversation = event.data[:conversation]
account = conversation.account account = conversation.account
user = event.data[:user] user = event.data[:user]
tokens = user_tokens(account, conversation.inbox.members) + tokens = typing_event_listener_tokens(account, conversation, user)
[conversation.contact.pubsub_token]
broadcast(tokens, CONVERSATION_TYPING_OFF, broadcast(
conversation: conversation.push_event_data, user: user.push_event_data) tokens,
CONVERSATION_TYPING_OFF,
conversation: conversation.push_event_data,
user: user.push_event_data
)
end end
def assignee_changed(event) def assignee_changed(event)
@ -90,6 +96,10 @@ class ActionCableListener < BaseListener
private private
def typing_event_listener_tokens(account, conversation, user)
(user_tokens(account, conversation.inbox.members) + [conversation.contact.pubsub_token]) - [user&.pubsub_token]
end
def user_tokens(account, agents) def user_tokens(account, agents)
agent_tokens = agents.pluck(:pubsub_token) agent_tokens = agents.pluck(:pubsub_token)
admin_tokens = account.administrators.pluck(:pubsub_token) admin_tokens = account.administrators.pluck(:pubsub_token)

View file

@ -102,6 +102,11 @@ 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
collection do
post :toggle_typing
end
end
resource :contact, only: [:update] resource :contact, only: [:update]
resources :inbox_members, only: [:index] resources :inbox_members, only: [:index]
resources :labels, only: [:create, :destroy] resources :labels, only: [:create, :destroy]

View file

@ -0,0 +1,27 @@
require 'rails_helper'
RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
let(:account) { create(:account) }
let(:web_widget) { create(:channel_widget, account: account) }
let(:contact) { create(:contact, account: account, email: nil) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) }
let!(:conversation) { create(:conversation, contact: contact, account: account, inbox: web_widget.inbox, contact_inbox: contact_inbox) }
let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } }
let(:token) { ::Widget::TokenService.new(payload: payload).generate_token }
describe 'POST /api/v1/widget/conversations/toggle_typing' do
context 'with a conversation' do
it 'dispatches the correct typing status' do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
post '/api/v1/widget/conversations/toggle_typing',
headers: { 'X-Auth-Token' => token },
params: { typing_status: 'on', website_token: web_widget.website_token },
as: :json
expect(response).to have_http_status(:success)
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(Conversation::CONVERSATION_TYPING_ON, kind_of(Time), { conversation: conversation, user: contact })
end
end
end
end

View file

@ -38,7 +38,7 @@ describe ActionCableListener do
# HACK: to reload conversation inbox members # HACK: to reload conversation inbox members
expect(conversation.inbox.reload.inbox_members.count).to eq(1) expect(conversation.inbox.reload.inbox_members.count).to eq(1)
expect(ActionCableBroadcastJob).to receive(:perform_later).with( expect(ActionCableBroadcastJob).to receive(:perform_later).with(
[agent.pubsub_token, admin.pubsub_token, conversation.contact.pubsub_token], [admin.pubsub_token, conversation.contact.pubsub_token],
'conversation.typing_on', conversation: conversation.push_event_data, 'conversation.typing_on', conversation: conversation.push_event_data,
user: agent.push_event_data user: agent.push_event_data
) )
@ -54,7 +54,7 @@ describe ActionCableListener do
# HACK: to reload conversation inbox members # HACK: to reload conversation inbox members
expect(conversation.inbox.reload.inbox_members.count).to eq(1) expect(conversation.inbox.reload.inbox_members.count).to eq(1)
expect(ActionCableBroadcastJob).to receive(:perform_later).with( expect(ActionCableBroadcastJob).to receive(:perform_later).with(
[agent.pubsub_token, admin.pubsub_token, conversation.contact.pubsub_token], [admin.pubsub_token, conversation.contact.pubsub_token],
'conversation.typing_off', conversation: conversation.push_event_data, 'conversation.typing_off', conversation: conversation.push_event_data,
user: agent.push_event_data user: agent.push_event_data
) )