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:
parent
fabc3170b7
commit
5bc8219db5
36 changed files with 663 additions and 78 deletions
|
@ -25,11 +25,10 @@ class Api::V1::Accounts::ConversationsController < Api::BaseController
|
|||
end
|
||||
|
||||
def toggle_typing_status
|
||||
user = current_user.presence || @resource
|
||||
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'
|
||||
Rails.configuration.dispatcher.dispatch(CONVERSATION_TYPING_OFF, Time.zone.now, conversation: @conversation)
|
||||
trigger_typing_event(CONVERSATION_TYPING_OFF)
|
||||
end
|
||||
head :ok
|
||||
end
|
||||
|
@ -42,6 +41,11 @@ class Api::V1::Accounts::ConversationsController < Api::BaseController
|
|||
|
||||
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
|
||||
DateTime.strptime(params[:agent_last_seen_at].to_s, '%s')
|
||||
end
|
||||
|
|
27
app/controllers/api/v1/widget/conversations_controller.rb
Normal file
27
app/controllers/api/v1/widget/conversations_controller.rb
Normal 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
|
|
@ -33,6 +33,12 @@ class ConversationApi extends ApiClient {
|
|||
agent_last_seen_at: lastSeen,
|
||||
});
|
||||
}
|
||||
|
||||
toggleTyping({ conversationId, status }) {
|
||||
return axios.post(`${this.url}/${conversationId}/toggle_typing_status`, {
|
||||
typing_status: status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConversationApi();
|
||||
|
|
BIN
app/javascript/dashboard/assets/images/typing.gif
Normal file
BIN
app/javascript/dashboard/assets/images/typing.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
|
@ -377,8 +377,8 @@ $form-button-radius: $global-radius;
|
|||
// 20. Label
|
||||
// ---------
|
||||
|
||||
$label-background: $primary-color;
|
||||
$label-color: $white;
|
||||
$label-background: lighten($primary-color, 40%);
|
||||
$label-color: $primary-color;
|
||||
$label-color-alt: $black;
|
||||
$label-palette: $foundation-palette;
|
||||
$label-font-size: $font-size-micro;
|
||||
|
|
|
@ -1,25 +1,53 @@
|
|||
.conversation {
|
||||
@include flex;
|
||||
@include flex-shrink;
|
||||
@include padding($space-normal $zero $zero $space-normal);
|
||||
border-left: 4px solid transparent;
|
||||
@include padding(0 0 0 $space-normal);
|
||||
align-items: center;
|
||||
border-bottom: 1px solid transparent;
|
||||
border-left: $space-micro solid transparent;
|
||||
border-top: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
background: $color-background;
|
||||
border-bottom-color: $color-border-light;
|
||||
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 {
|
||||
@include margin($zero $zero $zero $space-one);
|
||||
@include margin(0 0 0 $space-one);
|
||||
@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 {
|
||||
font-size: $font-size-small;
|
||||
margin-bottom: $zero;
|
||||
margin-bottom: 0;
|
||||
text-transform: capitalize;
|
||||
|
||||
.label {
|
||||
|
@ -39,7 +67,7 @@
|
|||
font-weight: $font-weight-normal;
|
||||
height: $space-medium;
|
||||
line-height: $space-medium;
|
||||
margin: $zero;
|
||||
margin: 0;
|
||||
max-width: 96%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
@ -54,20 +82,20 @@
|
|||
|
||||
.conversation--meta {
|
||||
@include flex;
|
||||
display: block;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
right: $space-normal;
|
||||
top: $space-normal;
|
||||
|
||||
.unread {
|
||||
$unread-size: $space-two - $space-micro;
|
||||
$unread-size: $space-normal;
|
||||
@include round-corner;
|
||||
@include light-shadow;
|
||||
background: darken($success-color, 3%);
|
||||
color: $color-white;
|
||||
display: none;
|
||||
font-size: $font-size-micro;
|
||||
font-weight: $font-weight-medium;
|
||||
font-weight: $font-weight-black;
|
||||
height: $unread-size;
|
||||
line-height: $unread-size;
|
||||
margin-left: auto;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
@mixin bubble-with-types {
|
||||
@include padding($space-one $space-normal);
|
||||
@include padding($space-small $space-normal);
|
||||
@include margin($zero);
|
||||
background: $color-woot;
|
||||
border-radius: $space-one;
|
||||
|
@ -204,12 +204,14 @@
|
|||
height: 100%;
|
||||
margin-bottom: $space-small;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.conversation-panel>li {
|
||||
@include flex;
|
||||
@include flex-shrink;
|
||||
@include margin($zero $zero $space-micro);
|
||||
position: relative;
|
||||
|
||||
&:first-child {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,10 +27,22 @@
|
|||
:data="message"
|
||||
/>
|
||||
</ul>
|
||||
<ReplyBox
|
||||
:conversation-id="currentChat.id"
|
||||
@scrollToMessage="focusLastMessage"
|
||||
/>
|
||||
<div class="conversation-footer">
|
||||
<div v-if="isAnyoneTyping" class="typing-indicator-wrap">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
|
@ -42,6 +54,7 @@ import ConversationHeader from './ConversationHeader';
|
|||
import ReplyBox from './ReplyBox';
|
||||
import Message from './Message';
|
||||
import conversationMixin from '../../../mixins/conversations';
|
||||
import { getTypingUsersText } from '../../../helper/commons';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
@ -81,6 +94,27 @@ export default {
|
|||
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() {
|
||||
const [chat] = this.allConversations.filter(
|
||||
c => c.id === this.currentChat.id
|
||||
|
|
|
@ -20,8 +20,8 @@
|
|||
class="input"
|
||||
type="text"
|
||||
:placeholder="$t(messagePlaceHolder())"
|
||||
@click="onClick()"
|
||||
@blur="onBlur()"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<file-upload
|
||||
v-if="showFileUpload"
|
||||
|
@ -260,25 +260,16 @@ export default {
|
|||
onBlur() {
|
||||
this.toggleTyping('off');
|
||||
},
|
||||
onClick() {
|
||||
this.markSeen();
|
||||
onFocus() {
|
||||
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) {
|
||||
if (this.channelType === 'Channel::FacebookPage') {
|
||||
if (this.channelType === 'Channel::WebWidget' && !this.isPrivate) {
|
||||
const conversationId = this.currentChat.id;
|
||||
this.$store.dispatch('toggleTyping', {
|
||||
status,
|
||||
inboxId: this.currentChat.inbox_id,
|
||||
contactId: this.currentChat.meta.sender.id,
|
||||
conversationId,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
@ -4,6 +4,7 @@ import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnec
|
|||
class ActionCableConnector extends BaseActionCableConnector {
|
||||
constructor(app, pubsubToken) {
|
||||
super(app, pubsubToken);
|
||||
this.CancelTyping = [];
|
||||
this.events = {
|
||||
'message.created': this.onMessageCreated,
|
||||
'message.updated': this.onMessageUpdated,
|
||||
|
@ -13,6 +14,8 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||
'user:logout': this.onLogout,
|
||||
'page:reload': this.onReload,
|
||||
'assignee.changed': this.onAssigneeChanged,
|
||||
'conversation.typing_on': this.onTypingOn,
|
||||
'conversation.typing_off': this.onTypingOff,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -43,6 +46,44 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||
onStatusChange = 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 {
|
||||
|
|
|
@ -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`;
|
||||
};
|
||||
|
|
26
app/javascript/dashboard/helper/specs/commons.spec.js
Normal file
26
app/javascript/dashboard/helper/specs/commons.spec.js
Normal 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');
|
||||
});
|
||||
});
|
|
@ -10,6 +10,7 @@ import contactConversations from './modules/contactConversations';
|
|||
import contacts from './modules/contacts';
|
||||
import conversationLabels from './modules/conversationLabels';
|
||||
import conversationMetadata from './modules/conversationMetadata';
|
||||
import conversationTypingStatus from './modules/conversationTypingStatus';
|
||||
import conversationPage from './modules/conversationPage';
|
||||
import conversations from './modules/conversations';
|
||||
import inboxes from './modules/inboxes';
|
||||
|
@ -22,22 +23,23 @@ import accounts from './modules/accounts';
|
|||
Vue.use(Vuex);
|
||||
export default new Vuex.Store({
|
||||
modules: {
|
||||
accounts,
|
||||
agents,
|
||||
auth,
|
||||
billing,
|
||||
cannedResponse,
|
||||
Channel,
|
||||
contacts,
|
||||
contactConversations,
|
||||
contacts,
|
||||
conversationLabels,
|
||||
conversationMetadata,
|
||||
conversationPage,
|
||||
conversations,
|
||||
conversationTypingStatus,
|
||||
inboxes,
|
||||
inboxMembers,
|
||||
reports,
|
||||
userNotificationSettings,
|
||||
webhooks,
|
||||
accounts,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -160,10 +160,10 @@ const actions = {
|
|||
commit(types.default.UPDATE_CONVERSATION, conversation);
|
||||
},
|
||||
|
||||
toggleTyping: async ({ commit }, { status, inboxId, contactId }) => {
|
||||
toggleTyping: async ({ commit }, { status, conversationId }) => {
|
||||
try {
|
||||
await FBChannel.toggleTyping({ status, inboxId, contactId });
|
||||
commit(types.default.FB_TYPING, { status });
|
||||
commit(types.default.SET_AGENT_TYPING, { status });
|
||||
await ConversationApi.toggleTyping({ status, conversationId });
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
|
|
|
@ -6,6 +6,14 @@ import getters, { getSelectedChatConversation } from './getters';
|
|||
import actions from './actions';
|
||||
import wootConstants from '../../../constants';
|
||||
|
||||
const initialSelectedChat = {
|
||||
id: null,
|
||||
meta: {},
|
||||
status: null,
|
||||
seen: false,
|
||||
agentTyping: 'off',
|
||||
dataFetched: false,
|
||||
};
|
||||
const state = {
|
||||
allConversations: [],
|
||||
convTabStats: {
|
||||
|
@ -13,14 +21,7 @@ const state = {
|
|||
unAssignedCount: 0,
|
||||
allCount: 0,
|
||||
},
|
||||
selectedChat: {
|
||||
id: null,
|
||||
meta: {},
|
||||
status: null,
|
||||
seen: false,
|
||||
agentTyping: 'off',
|
||||
dataFetched: false,
|
||||
},
|
||||
selectedChat: { ...initialSelectedChat },
|
||||
listLoadingStatus: true,
|
||||
chatStatusFilter: wootConstants.STATUS_TYPE.OPEN,
|
||||
currentInbox: null,
|
||||
|
@ -42,14 +43,7 @@ const mutations = {
|
|||
},
|
||||
[types.default.EMPTY_ALL_CONVERSATION](_state) {
|
||||
_state.allConversations = [];
|
||||
_state.selectedChat = {
|
||||
id: null,
|
||||
meta: {},
|
||||
status: null,
|
||||
seen: false,
|
||||
agentTyping: 'off',
|
||||
dataFetched: false,
|
||||
};
|
||||
_state.selectedChat = { ...initialSelectedChat };
|
||||
},
|
||||
[types.default.SET_ALL_MESSAGES_LOADED](_state) {
|
||||
const [chat] = getSelectedChatConversation(_state);
|
||||
|
@ -175,7 +169,7 @@ const mutations = {
|
|||
_state.selectedChat.seen = true;
|
||||
},
|
||||
|
||||
[types.default.FB_TYPING](_state, { status }) {
|
||||
[types.default.SET_AGENT_TYPING](_state, { status }) {
|
||||
_state.selectedChat.agentTyping = status;
|
||||
},
|
||||
|
||||
|
|
|
@ -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' } },
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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([]);
|
||||
});
|
||||
});
|
|
@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -28,7 +28,7 @@ export default {
|
|||
ADD_MESSAGE: 'ADD_MESSAGE',
|
||||
MARK_SEEN: 'MARK_SEEN',
|
||||
MARK_MESSAGE_READ: 'MARK_MESSAGE_READ',
|
||||
FB_TYPING: 'FB_TYPING',
|
||||
SET_AGENT_TYPING: 'SET_AGENT_TYPING',
|
||||
SET_PREVIOUS_CONVERSATIONS: 'SET_PREVIOUS_CONVERSATIONS',
|
||||
SET_ACTIVE_INBOX: 'SET_ACTIVE_INBOX',
|
||||
|
||||
|
@ -104,4 +104,8 @@ export default {
|
|||
// Notification Settings
|
||||
SET_USER_NOTIFICATION_UI_FLAG: 'SET_USER_NOTIFICATION_UI_FLAG',
|
||||
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',
|
||||
};
|
||||
|
|
|
@ -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 };
|
||||
|
|
BIN
app/javascript/widget/assets/images/typing.gif
Normal file
BIN
app/javascript/widget/assets/images/typing.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
37
app/javascript/widget/components/AgentTypingBubble.vue
Normal file
37
app/javascript/widget/components/AgentTypingBubble.vue
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
<template>
|
||||
<div class="about">
|
||||
<h1>Chatwoot</h1>
|
||||
</div>
|
||||
</template>
|
|
@ -52,22 +52,28 @@ class ActionCableListener < BaseListener
|
|||
conversation = event.data[:conversation]
|
||||
account = conversation.account
|
||||
user = event.data[:user]
|
||||
tokens = user_tokens(account, conversation.inbox.members) +
|
||||
[conversation.contact.pubsub_token]
|
||||
tokens = typing_event_listener_tokens(account, conversation, user)
|
||||
|
||||
broadcast(tokens, CONVERSATION_TYPING_ON,
|
||||
conversation: conversation.push_event_data, user: user.push_event_data)
|
||||
broadcast(
|
||||
tokens,
|
||||
CONVERSATION_TYPING_ON,
|
||||
conversation: conversation.push_event_data,
|
||||
user: user.push_event_data
|
||||
)
|
||||
end
|
||||
|
||||
def conversation_typing_off(event)
|
||||
conversation = event.data[:conversation]
|
||||
account = conversation.account
|
||||
user = event.data[:user]
|
||||
tokens = user_tokens(account, conversation.inbox.members) +
|
||||
[conversation.contact.pubsub_token]
|
||||
tokens = typing_event_listener_tokens(account, conversation, user)
|
||||
|
||||
broadcast(tokens, CONVERSATION_TYPING_OFF,
|
||||
conversation: conversation.push_event_data, user: user.push_event_data)
|
||||
broadcast(
|
||||
tokens,
|
||||
CONVERSATION_TYPING_OFF,
|
||||
conversation: conversation.push_event_data,
|
||||
user: user.push_event_data
|
||||
)
|
||||
end
|
||||
|
||||
def assignee_changed(event)
|
||||
|
@ -90,6 +96,10 @@ class ActionCableListener < BaseListener
|
|||
|
||||
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)
|
||||
agent_tokens = agents.pluck(:pubsub_token)
|
||||
admin_tokens = account.administrators.pluck(:pubsub_token)
|
||||
|
|
|
@ -102,6 +102,11 @@ Rails.application.routes.draw do
|
|||
namespace :widget do
|
||||
resources :events, only: [:create]
|
||||
resources :messages, only: [:index, :create, :update]
|
||||
resources :conversations do
|
||||
collection do
|
||||
post :toggle_typing
|
||||
end
|
||||
end
|
||||
resource :contact, only: [:update]
|
||||
resources :inbox_members, only: [:index]
|
||||
resources :labels, only: [:create, :destroy]
|
||||
|
|
|
@ -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
|
|
@ -38,7 +38,7 @@ describe ActionCableListener do
|
|||
# HACK: to reload conversation inbox members
|
||||
expect(conversation.inbox.reload.inbox_members.count).to eq(1)
|
||||
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,
|
||||
user: agent.push_event_data
|
||||
)
|
||||
|
@ -54,7 +54,7 @@ describe ActionCableListener do
|
|||
# HACK: to reload conversation inbox members
|
||||
expect(conversation.inbox.reload.inbox_members.count).to eq(1)
|
||||
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,
|
||||
user: agent.push_event_data
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue