feat: Allow users to mark a conversation as unread (#5924)

Allow users to mark conversations as unread.
Loom video: https://www.loom.com/share/ab70552d3c9c48b685da7dfa64be8bb3

fixes: #5552

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Sojan Jose 2022-11-24 07:55:45 +00:00 committed by GitHub
parent e593e516b8
commit 606fc9046a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 190 additions and 48 deletions

View file

@ -75,10 +75,13 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def update_last_seen def update_last_seen
# rubocop:disable Rails/SkipsModelValidations update_last_seen_on_conversation(DateTime.now.utc, assignee?)
@conversation.update_column(:agent_last_seen_at, DateTime.now.utc) end
@conversation.update_column(:assignee_last_seen_at, DateTime.now.utc) if assignee?
# rubocop:enable Rails/SkipsModelValidations def unread
last_incoming_message = @conversation.messages.incoming.last
last_seen_at = last_incoming_message.created_at - 1.second if last_incoming_message.present?
update_last_seen_on_conversation(last_seen_at, true)
end end
def custom_attributes def custom_attributes
@ -88,6 +91,13 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
private private
def update_last_seen_on_conversation(last_seen_at, update_assignee)
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_column(:agent_last_seen_at, last_seen_at)
@conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present?
# rubocop:enable Rails/SkipsModelValidations
end
def set_conversation_status def set_conversation_status
status = params[:status] == 'bot' ? 'pending' : params[:status] status = params[:status] == 'bot' ? 'pending' : params[:status]
@conversation.status = status @conversation.status = status
@ -163,10 +173,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def conversation_finder def conversation_finder
@conversation_finder ||= ConversationFinder.new(current_user, params) @conversation_finder ||= ConversationFinder.new(Current.user, params)
end end
def assignee? def assignee?
@conversation.assignee_id? && current_user == @conversation.assignee @conversation.assignee_id? && Current.user == @conversation.assignee
end end
end end

View file

@ -68,6 +68,10 @@ class ConversationApi extends ApiClient {
return axios.post(`${this.url}/${id}/update_last_seen`); return axios.post(`${this.url}/${id}/update_last_seen`);
} }
markMessagesUnread({ id }) {
return axios.post(`${this.url}/${id}/unread`);
}
toggleTyping({ conversationId, status, isPrivate }) { toggleTyping({ conversationId, status, isPrivate }) {
return axios.post(`${this.url}/${conversationId}/toggle_typing_status`, { return axios.post(`${this.url}/${conversationId}/toggle_typing_status`, {
typing_status: status, typing_status: status,

View file

@ -126,6 +126,7 @@
@assign-label="onAssignLabels" @assign-label="onAssignLabels"
@update-conversation-status="toggleConversationStatus" @update-conversation-status="toggleConversationStatus"
@context-menu-toggle="onContextMenuToggle" @context-menu-toggle="onContextMenuToggle"
@mark-as-unread="markAsUnread"
/> />
<div v-if="chatListLoading" class="text-center"> <div v-if="chatListLoading" class="text-center">
@ -185,6 +186,7 @@ import {
hasPressedAltAndJKey, hasPressedAltAndJKey,
hasPressedAltAndKKey, hasPressedAltAndKKey,
} from 'shared/helpers/KeyboardHelpers'; } from 'shared/helpers/KeyboardHelpers';
import { conversationListPageURL } from '../helper/URLHelper';
export default { export default {
components: { components: {
@ -637,6 +639,29 @@ export default {
this.showAlert(this.$t('BULK_ACTION.ASSIGN_FAILED')); this.showAlert(this.$t('BULK_ACTION.ASSIGN_FAILED'));
} }
}, },
async markAsUnread(conversationId) {
try {
await this.$store.dispatch('markMessagesUnread', {
id: conversationId,
});
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = this.$route;
this.$router.push(
conversationListPageURL({
accountId,
conversationType: name === 'conversation_mentions' ? 'mention' : '',
customViewId: this.foldersId,
inboxId,
label,
teamId,
})
);
} catch (error) {
// Ignore error
}
},
async onAssignTeam(team, conversationId = null) { async onAssignTeam(team, conversationId = null) {
try { try {
await this.$store.dispatch('assignTeam', { await this.$store.dispatch('assignTeam', {

View file

@ -102,10 +102,12 @@
<conversation-context-menu <conversation-context-menu
:status="chat.status" :status="chat.status"
:inbox-id="inbox.id" :inbox-id="inbox.id"
:has-unread-messages="hasUnread"
@update-conversation="onUpdateConversation" @update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent" @assign-agent="onAssignAgent"
@assign-label="onAssignLabel" @assign-label="onAssignLabel"
@assign-team="onAssignTeam" @assign-team="onAssignTeam"
@mark-as-unread="markAsUnread"
/> />
</woot-context-menu> </woot-context-menu>
</div> </div>
@ -241,7 +243,7 @@ export default {
}, },
unreadCount() { unreadCount() {
return this.unreadMessagesCount(this.chat); return this.chat.unread_count;
}, },
hasUnread() { hasUnread() {
@ -359,6 +361,10 @@ export default {
this.$emit('assign-team', team, this.chat.id); this.$emit('assign-team', team, this.chat.id);
this.closeContextMenu(); this.closeContextMenu();
}, },
async markAsUnread() {
this.$emit('mark-as-unread', this.chat.id);
this.closeContextMenu();
},
}, },
}; };
</script> </script>

View file

@ -44,11 +44,11 @@
" "
:is-web-widget-inbox="isAWebWidgetInbox" :is-web-widget-inbox="isAWebWidgetInbox"
/> />
<li v-show="getUnreadCount != 0" class="unread--toast"> <li v-show="unreadMessageCount != 0" class="unread--toast">
<span class="text-uppercase"> <span class="text-uppercase">
{{ getUnreadCount }} {{ unreadMessageCount }}
{{ {{
getUnreadCount > 1 unreadMessageCount > 1
? $t('CONVERSATION.UNREAD_MESSAGES') ? $t('CONVERSATION.UNREAD_MESSAGES')
: $t('CONVERSATION.UNREAD_MESSAGE') : $t('CONVERSATION.UNREAD_MESSAGE')
}} }}
@ -137,7 +137,6 @@ export default {
allConversations: 'getAllConversations', allConversations: 'getAllConversations',
inboxesList: 'inboxes/getInboxes', inboxesList: 'inboxes/getInboxes',
listLoadingStatus: 'getAllMessagesLoaded', listLoadingStatus: 'getAllMessagesLoaded',
getUnreadCount: 'getUnreadCount',
loadingChatList: 'getChatListLoadingStatus', loadingChatList: 'getChatListLoadingStatus',
}), }),
inboxId() { inboxId() {
@ -271,6 +270,9 @@ export default {
} }
return ''; return '';
}, },
unreadMessageCount() {
return this.currentChat.unread_count;
},
}, },
watch: { watch: {
@ -331,7 +333,7 @@ export default {
}, },
scrollToBottom() { scrollToBottom() {
let relevantMessages = []; let relevantMessages = [];
if (this.getUnreadCount > 0) { if (this.unreadMessageCount > 0) {
// capturing only the unread messages // capturing only the unread messages
relevantMessages = this.conversationPanel.querySelectorAll( relevantMessages = this.conversationPanel.querySelectorAll(
'.message--unread' '.message--unread'

View file

@ -1,5 +1,11 @@
<template> <template>
<div class="menu-container"> <div class="menu-container">
<menu-item
v-if="!hasUnreadMessages"
:option="unreadOption"
variant="icon"
@click="$emit('mark-as-unread')"
/>
<template v-for="option in statusMenuConfig"> <template v-for="option in statusMenuConfig">
<menu-item <menu-item
v-if="show(option.key)" v-if="show(option.key)"
@ -79,6 +85,10 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
hasUnreadMessages: {
type: Boolean,
default: false,
},
inboxId: { inboxId: {
type: Number, type: Number,
default: null, default: null,
@ -87,6 +97,10 @@ export default {
data() { data() {
return { return {
STATUS_TYPE: wootConstants.STATUS_TYPE, STATUS_TYPE: wootConstants.STATUS_TYPE,
unreadOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'),
icon: 'mail',
},
statusMenuConfig: [ statusMenuConfig: [
{ {
key: wootConstants.STATUS_TYPE.RESOLVED, key: wootConstants.STATUS_TYPE.RESOLVED,

View file

@ -66,6 +66,7 @@ export const conversationListPageURL = ({
inboxId, inboxId,
label, label,
teamId, teamId,
customViewId,
}) => { }) => {
let url = `accounts/${accountId}/dashboard`; let url = `accounts/${accountId}/dashboard`;
if (label) { if (label) {
@ -76,6 +77,8 @@ export const conversationListPageURL = ({
url = `accounts/${accountId}/mentions/conversations`; url = `accounts/${accountId}/mentions/conversations`;
} else if (inboxId) { } else if (inboxId) {
url = `accounts/${accountId}/inbox/${inboxId}`; url = `accounts/${accountId}/inbox/${inboxId}`;
} else if (customViewId) {
url = `accounts/${accountId}/custom_view/${customViewId}`;
} }
return frontendURL(url); return frontendURL(url);
}; };

View file

@ -29,6 +29,12 @@ describe('#URL Helpers', () => {
'/app/accounts/1/team/1' '/app/accounts/1/team/1'
); );
}); });
it('should return url to custom view', () => {
expect(conversationListPageURL({ accountId: 1, customViewId: 1 })).toBe(
'/app/accounts/1/custom_view/1'
);
});
}); });
describe('conversationUrl', () => { describe('conversationUrl', () => {
it('should return direct conversation URL if activeInbox is nil', () => { it('should return direct conversation URL if activeInbox is nil', () => {

View file

@ -64,6 +64,7 @@
"CARD_CONTEXT_MENU": { "CARD_CONTEXT_MENU": {
"PENDING": "Mark as pending", "PENDING": "Mark as pending",
"RESOLVED": "Mark as resolved", "RESOLVED": "Mark as resolved",
"MARK_AS_UNREAD": "Mark as unread",
"REOPEN": "Reopen conversation", "REOPEN": "Reopen conversation",
"SNOOZE": { "SNOOZE": {
"TITLE": "Snooze", "TITLE": "Snooze",

View file

@ -34,14 +34,6 @@ export default {
lastNonActivityMessageFromAPI lastNonActivityMessageFromAPI
); );
}, },
unreadMessagesCount(m) {
return m.messages.filter(
chat =>
chat.created_at * 1000 > m.agent_last_seen_at * 1000 &&
chat.message_type === 0 &&
chat.private !== true
).length;
},
hasUserReadMessage(createdAt, contactLastSeen) { hasUserReadMessage(createdAt, contactLastSeen) {
return !(contactLastSeen - createdAt < 0); return !(contactLastSeen - createdAt < 0);
}, },

View file

@ -4,13 +4,6 @@ import commonHelpers from '../../helper/commons';
commonHelpers(); commonHelpers();
describe('#conversationMixin', () => { describe('#conversationMixin', () => {
it('should return unread message count 2 if conversation is passed', () => {
expect(
conversationMixin.methods.unreadMessagesCount(
conversationFixture.conversation
)
).toEqual(2);
});
it('should return read messages if conversation is passed', () => { it('should return read messages if conversation is passed', () => {
expect( expect(
conversationMixin.methods.readMessages(conversationFixture.conversation) conversationMixin.methods.readMessages(conversationFixture.conversation)

View file

@ -8,7 +8,7 @@ import {
buildConversationList, buildConversationList,
isOnMentionsView, isOnMentionsView,
} from './helpers/actionHelpers'; } from './helpers/actionHelpers';
import messageReadActions from './actions/messageReadActions';
// actions // actions
const actions = { const actions = {
getConversation: async ({ commit }, conversationId) => { getConversation: async ({ commit }, conversationId) => {
@ -257,17 +257,6 @@ const actions = {
dispatch('contacts/setContact', sender); dispatch('contacts/setContact', sender);
}, },
markMessagesRead: async ({ commit }, data) => {
try {
const {
data: { id, agent_last_seen_at: lastSeen },
} = await ConversationApi.markMessageRead(data);
setTimeout(() => commit(types.MARK_MESSAGE_READ, { id, lastSeen }), 4000);
} catch (error) {
// Handle error
}
},
setChatFilter({ commit }, data) { setChatFilter({ commit }, data) {
commit(types.CHANGE_CHAT_STATUS_FILTER, data); commit(types.CHANGE_CHAT_STATUS_FILTER, data);
}, },
@ -336,6 +325,7 @@ const actions = {
clearConversationFilters({ commit }) { clearConversationFilters({ commit }) {
commit(types.CLEAR_CONVERSATION_FILTERS); commit(types.CLEAR_CONVERSATION_FILTERS);
}, },
...messageReadActions,
}; };
export default actions; export default actions;

View file

@ -0,0 +1,35 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
import ConversationApi from '../../../../api/inbox/conversation';
import mutationTypes from '../../../mutation-types';
export default {
markMessagesRead: async ({ commit }, data) => {
try {
const {
data: { id, agent_last_seen_at: lastSeen },
} = await ConversationApi.markMessageRead(data);
setTimeout(
() =>
commit(mutationTypes.UPDATE_MESSAGE_UNREAD_COUNT, { id, lastSeen }),
4000
);
} catch (error) {
// Handle error
}
},
markMessagesUnread: async ({ commit }, { id }) => {
try {
const {
data: { agent_last_seen_at: lastSeen, unread_count: unreadCount },
} = await ConversationApi.markMessagesUnread({ id });
commit(mutationTypes.UPDATE_MESSAGE_UNREAD_COUNT, {
id,
lastSeen,
unreadCount,
});
} catch (error) {
throwErrorMessage(error);
}
},
};

View file

@ -146,13 +146,16 @@ export const mutations = {
_state.listLoadingStatus = false; _state.listLoadingStatus = false;
}, },
[types.MARK_MESSAGE_READ](_state, { id, lastSeen }) { [types.UPDATE_MESSAGE_UNREAD_COUNT](
_state,
{ id, lastSeen, unreadCount = 0 }
) {
const [chat] = _state.allConversations.filter(c => c.id === id); const [chat] = _state.allConversations.filter(c => c.id === id);
if (chat) { if (chat) {
chat.agent_last_seen_at = lastSeen; Vue.set(chat, 'agent_last_seen_at', lastSeen);
Vue.set(chat, 'unread_count', unreadCount);
} }
}, },
[types.CHANGE_CHAT_STATUS_FILTER](_state, data) { [types.CHANGE_CHAT_STATUS_FILTER](_state, data) {
_state.chatStatusFilter = data; _state.chatStatusFilter = data;
}, },

View file

@ -245,7 +245,7 @@ describe('#actions', () => {
jest.runAllTimers(); jest.runAllTimers();
expect(commit).toHaveBeenCalledTimes(1); expect(commit).toHaveBeenCalledTimes(1);
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[types.MARK_MESSAGE_READ, { id: 1, lastSeen }], [types.UPDATE_MESSAGE_UNREAD_COUNT, { id: 1, lastSeen }],
]); ]);
}); });
it('sends correct mutations if api is unsuccessful', async () => { it('sends correct mutations if api is unsuccessful', async () => {
@ -255,6 +255,30 @@ describe('#actions', () => {
}); });
}); });
describe('#markMessagesUnread', () => {
it('sends correct mutations if API is successful', async () => {
const lastSeen = new Date().getTime() / 1000;
axios.post.mockResolvedValue({
data: { id: 1, agent_last_seen_at: lastSeen, unread_count: 1 },
});
await actions.markMessagesUnread({ commit }, { id: 1 });
jest.runAllTimers();
expect(commit).toHaveBeenCalledTimes(1);
expect(commit.mock.calls).toEqual([
[
types.UPDATE_MESSAGE_UNREAD_COUNT,
{ id: 1, lastSeen, unreadCount: 1 },
],
]);
});
it('sends correct mutations if API is unsuccessful', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.markMessagesUnread({ commit }, { id: 1 })
).rejects.toThrow(Error);
});
});
describe('#sendEmailTranscript', () => { describe('#sendEmailTranscript', () => {
it('sends correct mutations if api is successful', async () => { it('sends correct mutations if api is successful', async () => {
axios.post.mockResolvedValue({}); axios.post.mockResolvedValue({});

View file

@ -11,20 +11,20 @@ describe('#mutations', () => {
}); });
}); });
describe('#MARK_MESSAGE_READ', () => { describe('#UPDATE_MESSAGE_UNREAD_COUNT', () => {
it('mark conversation as read', () => { it('mark conversation as read', () => {
const state = { allConversations: [{ id: 1 }] }; const state = { allConversations: [{ id: 1 }] };
const lastSeen = new Date().getTime() / 1000; const lastSeen = new Date().getTime() / 1000;
mutations[types.MARK_MESSAGE_READ](state, { id: 1, lastSeen }); mutations[types.UPDATE_MESSAGE_UNREAD_COUNT](state, { id: 1, lastSeen });
expect(state.allConversations).toEqual([ expect(state.allConversations).toEqual([
{ id: 1, agent_last_seen_at: lastSeen }, { id: 1, agent_last_seen_at: lastSeen, unread_count: 0 },
]); ]);
}); });
it('doesnot send any mutation if chat doesnot exist', () => { it('doesnot send any mutation if chat doesnot exist', () => {
const state = { allConversations: [] }; const state = { allConversations: [] };
const lastSeen = new Date().getTime() / 1000; const lastSeen = new Date().getTime() / 1000;
mutations[types.MARK_MESSAGE_READ](state, { id: 1, lastSeen }); mutations[types.UPDATE_MESSAGE_UNREAD_COUNT](state, { id: 1, lastSeen });
expect(state.allConversations).toEqual([]); expect(state.allConversations).toEqual([]);
}); });
}); });

View file

@ -36,7 +36,7 @@ export default {
ADD_MESSAGE: 'ADD_MESSAGE', ADD_MESSAGE: 'ADD_MESSAGE',
DELETE_MESSAGE: 'DELETE_MESSAGE', DELETE_MESSAGE: 'DELETE_MESSAGE',
ADD_PENDING_MESSAGE: 'ADD_PENDING_MESSAGE', ADD_PENDING_MESSAGE: 'ADD_PENDING_MESSAGE',
MARK_MESSAGE_READ: 'MARK_MESSAGE_READ', UPDATE_MESSAGE_UNREAD_COUNT: 'UPDATE_MESSAGE_UNREAD_COUNT',
SET_PREVIOUS_CONVERSATIONS: 'SET_PREVIOUS_CONVERSATIONS', SET_PREVIOUS_CONVERSATIONS: 'SET_PREVIOUS_CONVERSATIONS',
SET_ACTIVE_INBOX: 'SET_ACTIVE_INBOX', SET_ACTIVE_INBOX: 'SET_ACTIVE_INBOX',
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES: UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:

View file

@ -0,0 +1 @@
json.partial! 'api/v1/conversations/partials/conversation', formats: [:json], conversation: @conversation

View file

@ -85,6 +85,7 @@ Rails.application.routes.draw do
post :toggle_status post :toggle_status
post :toggle_typing_status post :toggle_typing_status
post :update_last_seen post :update_last_seen
post :unread
post :custom_attributes post :custom_attributes
end end
end end

View file

@ -508,6 +508,38 @@ RSpec.describe 'Conversations API', type: :request do
end end
end end
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/unread' do
let(:conversation) { create(:conversation, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
before do
create(:inbox_member, user: agent, inbox: conversation.inbox)
create(:message, conversation: conversation, account: account, inbox: conversation.inbox, content: 'Hello', message_type: 'incoming')
end
it 'updates last seen' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
last_seen_at = conversation.messages.incoming.last.created_at - 1.second
expect(conversation.reload.agent_last_seen_at).to eq(last_seen_at)
expect(conversation.reload.assignee_last_seen_at).to eq(last_seen_at)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/mute' do describe 'POST /api/v1/accounts/{account.id}/conversations/:id/mute' do
let(:conversation) { create(:conversation, account: account) } let(:conversation) { create(:conversation, account: account) }