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
|
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
|
||||||
|
|
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,
|
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();
|
||||||
|
|
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
|
// 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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 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,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -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',
|
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',
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
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"
|
: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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="about">
|
|
||||||
<h1>Chatwoot</h1>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
|
@ -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)
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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
|
# 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
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in a new issue