Feature: Ability to mute contacts (#891)

fixes: #867
This commit is contained in:
Abdulkadir Poyraz 2020-05-26 15:13:59 +03:00 committed by GitHub
parent d8d14fc4a4
commit b1aab228ae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 148 additions and 2 deletions

View file

@ -12,6 +12,8 @@ Layout/LineLength:
Max: 150 Max: 150
Metrics/ClassLength: Metrics/ClassLength:
Max: 125 Max: 125
Exclude:
- 'app/models/conversation.rb'
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 25 Max: 25
Style/Documentation: Style/Documentation:

View file

@ -20,6 +20,11 @@ class Api::V1::Accounts::ConversationsController < Api::BaseController
def show; end def show; end
def mute
@conversation.mute!
head :ok
end
def toggle_status def toggle_status
@status = @conversation.toggle_status @status = @conversation.toggle_status
end end

View file

@ -39,6 +39,10 @@ class ConversationApi extends ApiClient {
typing_status: status, typing_status: status,
}); });
} }
mute(conversationId) {
return axios.post(`${this.url}/${conversationId}/mute`);
}
} }
export default new ConversationApi(); export default new ConversationApi();

View file

@ -15,6 +15,7 @@
"UPDATE_ERROR": "Couldn't update labels, try again.", "UPDATE_ERROR": "Couldn't update labels, try again.",
"TAG_PLACEHOLDER": "Add new label", "TAG_PLACEHOLDER": "Add new label",
"PLACEHOLDER": "Search or add a label" "PLACEHOLDER": "Search or add a label"
} },
"MUTE_CONTACT": "Mute Contact"
} }
} }

View file

@ -90,10 +90,14 @@
icon="ion-clock" icon="ion-clock"
/> />
</div> </div>
<a v-show="!currentChat.muted" class="contact--mute" @click="mute">
{{ $t('CONTACT_PANEL.MUTE_CONTACT') }}
</a>
</div> </div>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import ContactConversations from './ContactConversations.vue'; import ContactConversations from './ContactConversations.vue';
import ContactDetailsItem from './ContactDetailsItem.vue'; import ContactDetailsItem from './ContactDetailsItem.vue';
@ -117,6 +121,9 @@ export default {
}, },
}, },
computed: { computed: {
...mapGetters({
currentChat: 'getSelectedChat',
}),
currentConversationMetaData() { currentConversationMetaData() {
return this.$store.getters[ return this.$store.getters[
'conversationMetadata/getConversationMetadata' 'conversationMetadata/getConversationMetadata'
@ -166,6 +173,9 @@ export default {
onPanelToggle() { onPanelToggle() {
this.onToggle(); this.onToggle();
}, },
mute() {
this.$store.dispatch('muteConversation', this.conversationId);
},
}, },
}; };
</script> </script>
@ -248,4 +258,10 @@ export default {
padding: 0.2rem; padding: 0.2rem;
} }
} }
.contact--mute {
color: $alert-color;
display: block;
text-align: center;
}
</style> </style>

View file

@ -215,6 +215,15 @@ const actions = {
// Handle error // Handle error
} }
}, },
muteConversation: async ({ commit }, conversationId) => {
try {
await ConversationApi.mute(conversationId);
commit(types.default.MUTE_CONVERSATION);
} catch (error) {
//
}
},
}; };
export default actions; export default actions;

View file

@ -10,6 +10,7 @@ const initialSelectedChat = {
id: null, id: null,
meta: {}, meta: {},
status: null, status: null,
muted: false,
seen: false, seen: false,
agentTyping: 'off', agentTyping: 'off',
dataFetched: false, dataFetched: false,
@ -116,6 +117,12 @@ const mutations = {
_state.selectedChat.status = status; _state.selectedChat.status = status;
}, },
[types.default.MUTE_CONVERSATION](_state) {
const [chat] = getSelectedChatConversation(_state);
chat.muted = true;
_state.selectedChat.muted = true;
},
[types.default.SEND_MESSAGE](_state, currentMessage) { [types.default.SEND_MESSAGE](_state, currentMessage) {
const [chat] = getSelectedChatConversation(_state); const [chat] = getSelectedChatConversation(_state);
const allMessagesExceptCurrent = (chat.messages || []).filter( const allMessagesExceptCurrent = (chat.messages || []).filter(

View file

@ -21,4 +21,16 @@ describe('#actions', () => {
expect(commit.mock.calls).toEqual([]); expect(commit.mock.calls).toEqual([]);
}); });
}); });
describe('#muteConversation', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue(null);
await actions.muteConversation({ commit }, 1);
expect(commit.mock.calls).toEqual([[types.default.MUTE_CONVERSATION]]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.getConversation({ commit });
expect(commit.mock.calls).toEqual([]);
});
});
}); });

View file

@ -22,6 +22,7 @@ export default {
RESOLVE_CONVERSATION: 'RESOLVE_CONVERSATION', RESOLVE_CONVERSATION: 'RESOLVE_CONVERSATION',
ADD_CONVERSATION: 'ADD_CONVERSATION', ADD_CONVERSATION: 'ADD_CONVERSATION',
UPDATE_CONVERSATION: 'UPDATE_CONVERSATION', UPDATE_CONVERSATION: 'UPDATE_CONVERSATION',
MUTE_CONVERSATION: 'MUTE_CONVERSATION',
SEND_MESSAGE: 'SEND_MESSAGE', SEND_MESSAGE: 'SEND_MESSAGE',
ASSIGN_AGENT: 'ASSIGN_AGENT', ASSIGN_AGENT: 'ASSIGN_AGENT',
SET_CHAT_META: 'SET_CHAT_META', SET_CHAT_META: 'SET_CHAT_META',

View file

@ -74,6 +74,15 @@ class Conversation < ApplicationRecord
save save
end end
def mute!
resolved!
Redis::Alfred.setex(mute_key, 1, mute_period)
end
def muted?
!Redis::Alfred.get(mute_key).nil?
end
def lock! def lock!
update!(locked: true) update!(locked: true)
end end
@ -184,4 +193,12 @@ class Conversation < ApplicationRecord
messages.create(activity_message_params(content)) messages.create(activity_message_params(content))
end end
def mute_key
format('CONVERSATION::%<id>d::MUTED', id: id)
end
def mute_period
6.hours
end
end end

View file

@ -141,7 +141,7 @@ class Message < ApplicationRecord
end end
def reopen_conversation def reopen_conversation
conversation.open! if incoming? && conversation.resolved? conversation.open! if incoming? && conversation.resolved? && !conversation.muted?
end end
def execute_message_template_hooks def execute_message_template_hooks

View file

@ -17,6 +17,7 @@ end
json.inbox_id conversation.inbox_id json.inbox_id conversation.inbox_id
json.status conversation.status json.status conversation.status
json.muted conversation.muted?
json.timestamp conversation.messages.last.try(:created_at).try(:to_i) json.timestamp conversation.messages.last.try(:created_at).try(:to_i)
json.user_last_seen_at conversation.user_last_seen_at.to_i json.user_last_seen_at conversation.user_last_seen_at.to_i
json.agent_last_seen_at conversation.agent_last_seen_at.to_i json.agent_last_seen_at conversation.agent_last_seen_at.to_i

View file

@ -48,6 +48,7 @@ Rails.application.routes.draw do
resources :labels, only: [:create, :index] resources :labels, only: [:create, :index]
end end
member do member do
post :mute
post :toggle_status post :toggle_status
post :toggle_typing_status post :toggle_typing_status
post :update_last_seen post :update_last_seen

View file

@ -177,4 +177,30 @@ RSpec.describe 'Conversations API', type: :request do
end end
end end
end end
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/mute' 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}/mute"
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) }
it 'mutes conversation' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/mute",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.resolved?).to eq(true)
expect(conversation.reload.muted?).to eq(true)
end
end
end
end end

View file

@ -59,6 +59,19 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
expect(conversation.messages.last.attachments.first.file.present?).to eq(true) expect(conversation.messages.last.attachments.first.file.present?).to eq(true)
expect(conversation.messages.last.attachments.first.file_type).to eq('image') expect(conversation.messages.last.attachments.first.file_type).to eq('image')
end end
it 'does not reopen conversation when conversation is muted' do
conversation.mute!
message_params = { content: 'hello world', timestamp: Time.current }
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.resolved?).to eq(true)
end
end end
end end

View file

@ -171,6 +171,37 @@ RSpec.describe Conversation, type: :model do
end end
end end
describe '#mute!' do
subject(:mute!) { conversation.mute! }
let(:conversation) { create(:conversation) }
it 'marks conversation as resolved' do
mute!
expect(conversation.reload.resolved?).to eq(true)
end
it 'marks conversation as muted in redis' do
mute!
expect(Redis::Alfred.get(conversation.send(:mute_key))).not_to eq(nil)
end
end
describe '#muted?' do
subject(:muted?) { conversation.muted? }
let(:conversation) { create(:conversation) }
it 'return true if conversation is muted' do
conversation.mute!
expect(muted?).to eq(true)
end
it 'returns false if conversation is not muted' do
expect(muted?).to eq(false)
end
end
describe 'unread_messages' do describe 'unread_messages' do
subject(:unread_messages) { conversation.unread_messages } subject(:unread_messages) { conversation.unread_messages }