feat(conversations): add message creation limit lock

This commit is contained in:
Sony Mathew
2026-07-02 00:28:31 +05:30
parent 926a9d8a69
commit 48e7985b0f
73 changed files with 1691 additions and 58 deletions
@@ -125,6 +125,10 @@ export default {
type: Boolean,
default: false,
},
isMessageCreationLocked: {
type: Boolean,
default: false,
},
},
emits: [
'toggleInsertArticle',
@@ -250,7 +254,16 @@ export default {
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
},
enableInsertArticleInReply() {
return this.portalSlug;
return !this.isMessageCreationLocked && this.portalSlug;
},
showQuotedReplyButton() {
return this.showQuotedReplyToggle && !this.isMessageCreationLocked;
},
showWhatsAppTemplateButton() {
return this.enableWhatsAppTemplates && !this.isMessageCreationLocked;
},
showContentTemplateButton() {
return this.enableContentTemplates && !this.isMessageCreationLocked;
},
isFetchingAppIntegrations() {
return this.uiFlags.isFetching;
@@ -340,7 +353,7 @@ export default {
@click="toggleMessageSignature"
/>
<NextButton
v-if="showQuotedReplyToggle"
v-if="showQuotedReplyButton"
v-tooltip.top-end="quotedReplyToggleTooltip"
icon="i-ph-quotes"
:variant="quotedReplyEnabled ? 'solid' : 'faded'"
@@ -350,7 +363,7 @@ export default {
@click="$emit('toggleQuotedReply')"
/>
<NextButton
v-if="enableWhatsAppTemplates"
v-if="showWhatsAppTemplateButton"
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
icon="i-ph-whatsapp-logo"
slate
@@ -359,7 +372,7 @@ export default {
@click="$emit('selectWhatsappTemplate')"
/>
<NextButton
v-if="enableContentTemplates"
v-if="showContentTemplateButton"
v-tooltip.top-end="'Content Templates'"
icon="i-ph-whatsapp-logo"
slate
@@ -3,7 +3,7 @@ import { ref } from 'vue';
import CopilotEditor from 'dashboard/components/widgets/WootWriter/CopilotEditor.vue';
import CaptainLoader from 'dashboard/components/widgets/conversation/copilot/CaptainLoader.vue';
defineProps({
const props = defineProps({
showCopilotEditor: {
type: Boolean,
default: false,
@@ -16,6 +16,10 @@ defineProps({
type: String,
default: '',
},
isMessageCreationLocked: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
@@ -41,6 +45,8 @@ const clearEditorSelection = () => {
};
const onSend = () => {
if (props.isMessageCreationLocked) return;
emit('send', copilotEditorContent.value);
copilotEditorContent.value = '';
};
@@ -58,7 +64,9 @@ const onSend = () => {
@after-enter="emit('contentReady')"
>
<CopilotEditor
v-if="showCopilotEditor && !isGeneratingContent"
v-if="
showCopilotEditor && !isGeneratingContent && !isMessageCreationLocked
"
key="copilot-editor"
v-model="copilotEditorContent"
class="copilot-editor"
@@ -193,6 +193,19 @@ export default {
}
return this.$t('CONVERSATION.CANNOT_REPLY');
},
messageCreationLockBannerMessage() {
if (!this.currentChat?.message_creation_locked) {
return '';
}
if (this.currentChat.message_creation_lock_reason === 'message_limit') {
return this.$t('CONVERSATION.MESSAGE_CREATION_LOCK.MESSAGE_LIMIT', {
limit: this.currentChat.message_limit,
});
}
return this.$t('CONVERSATION.MESSAGE_CREATION_LOCK.MANUAL');
},
replyWindowLink() {
if (this.isAFacebookInbox || this.isAnInstagramChannel) {
return REPLY_POLICY.FACEBOOK;
@@ -435,6 +448,8 @@ export default {
},
async handleMessageRetry(message) {
if (!message) return;
if (this.currentChat.message_creation_locked) return;
const payload = useSnakeCase(message);
await this.$store.dispatch('sendMessageWithData', payload);
},
@@ -455,7 +470,13 @@ export default {
>
<div ref="topBannerRef">
<Banner
v-if="!currentChat.can_reply"
v-if="currentChat.message_creation_locked"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="messageCreationLockBannerMessage"
/>
<Banner
v-else-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
@@ -201,6 +201,9 @@ export default {
!(this.isAWhatsAppChannel || this.isAPIInbox)
);
},
isMessageCreationLocked() {
return !!this.currentChat?.message_creation_locked;
},
inboxId() {
return this.currentChat.inbox_id;
},
@@ -209,6 +212,9 @@ export default {
},
messagePlaceHolder() {
if (this.isEditorDisabled) {
if (this.isMessageCreationLocked) {
return this.$t('CONVERSATION.FOOTER.MESSAGE_CREATION_LOCKED');
}
if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP');
}
@@ -326,7 +332,11 @@ export default {
return !this.isOnPrivateNote && this.showFileUpload;
},
showAudioRecorderEditor() {
return this.showAudioRecorder && this.isRecordingAudio;
return (
!this.isMessageCreationLocked &&
this.showAudioRecorder &&
this.isRecordingAudio
);
},
isOnPrivateNote() {
return this.replyType === REPLY_EDITOR_MODES.NOTE;
@@ -439,6 +449,10 @@ export default {
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
},
isEditorDisabled() {
if (this.isMessageCreationLocked) {
return true;
}
return (
(this.isAWhatsAppChannel || this.isAPIInbox) &&
!this.isOnPrivateNote &&
@@ -804,6 +818,8 @@ export default {
}
},
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
if (this.isMessageCreationLocked) return;
const messages = this.getMultipleMessagesPayload(message);
messages.forEach(messagePayload => {
this.sendMessage(
@@ -888,6 +904,8 @@ export default {
editorMessage = '',
copilotAcceptedMessage = ''
) {
if (this.isMessageCreationLocked) return;
try {
await this.$store.dispatch(
'createPendingMessageAndSend',
@@ -907,6 +925,8 @@ export default {
}
},
async onSendWhatsAppReply(messagePayload) {
if (this.isMessageCreationLocked) return;
this.sendMessage({
conversationId: this.currentChat.id,
...messagePayload,
@@ -914,6 +934,8 @@ export default {
this.hideWhatsappTemplatesModal();
},
async onSendContentTemplateReply(messagePayload) {
if (this.isMessageCreationLocked) return;
this.sendMessage({
conversationId: this.currentChat.id,
...messagePayload,
@@ -921,6 +943,8 @@ export default {
this.hideContentTemplatesModal();
},
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
if (this.isMessageCreationLocked) return;
// Clear attachments when switching between private note and reply modes
// This is to prevent from breaking the upload rules
if (this.attachedFiles.length > 0) this.attachedFiles = [];
@@ -943,6 +967,8 @@ export default {
this.onFocus();
},
executeCopilotAction(action, data) {
if (this.isMessageCreationLocked) return;
this.copilot.execute(action, data);
},
clearMessage() {
@@ -1235,6 +1261,8 @@ export default {
this.$nextTick(() => this.messageEditor?.focusEditorInputField());
},
onSubmitCopilotReply() {
if (this.isMessageCreationLocked) return;
const acceptedMessage = this.copilot.accept();
this.message = acceptedMessage;
this.setCopilotAcceptedMessage(acceptedMessage);
@@ -1315,6 +1343,7 @@ export default {
v-if="copilot.isActive.value && !showAudioRecorderEditor"
:show-copilot-editor="copilot.showEditor.value"
:is-generating-content="copilot.isGenerating.value"
:is-message-creation-locked="isMessageCreationLocked"
:generated-content="copilot.generatedContent.value"
:placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')"
@focus="onFocus"
@@ -1395,7 +1424,9 @@ export default {
<CopilotReplyBottomPanel
v-if="copilot.isActive.value"
key="copilot-bottom-panel"
:is-generating-content="copilot.isButtonDisabled.value"
:is-generating-content="
copilot.isButtonDisabled.value || isMessageCreationLocked
"
@submit="onSubmitCopilotReply"
@cancel="copilot.reset"
/>
@@ -1412,6 +1443,7 @@ export default {
:is-send-disabled="isReplyButtonDisabled"
:is-note="isPrivate"
:is-editor-disabled="isEditorDisabled"
:is-message-creation-locked="isMessageCreationLocked"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
@@ -0,0 +1,149 @@
import { shallowMount } from '@vue/test-utils';
import CopilotEditorSection from '../CopilotEditorSection.vue';
import MessagesView from '../MessagesView.vue';
import ReplyBox from '../ReplyBox.vue';
import ReplyBottomPanel from '../../WootWriter/ReplyBottomPanel.vue';
const mountCopilotEditorSection = props =>
shallowMount(CopilotEditorSection, {
props: {
showCopilotEditor: true,
isGeneratingContent: false,
generatedContent: 'Suggested reply',
...props,
},
global: {
stubs: {
Transition: false,
CopilotEditor: {
template:
'<button data-testid="copilot-editor" @click="$emit(\'send\')" />',
},
CaptainLoader: true,
},
},
});
describe('conversation message creation lock UI', () => {
describe('MessagesView', () => {
it('returns the message-limit banner copy with the configured limit', () => {
const $t = vi.fn((key, params) => `${key}:${params.limit}`);
const message =
MessagesView.computed.messageCreationLockBannerMessage.call({
currentChat: {
message_creation_locked: true,
message_creation_lock_reason: 'message_limit',
message_limit: 10000,
},
$t,
});
expect(message).toBe(
'CONVERSATION.MESSAGE_CREATION_LOCK.MESSAGE_LIMIT:10000'
);
expect($t).toHaveBeenCalledWith(
'CONVERSATION.MESSAGE_CREATION_LOCK.MESSAGE_LIMIT',
{ limit: 10000 }
);
});
it('returns the manual lock banner copy', () => {
const $t = vi.fn(key => key);
const message =
MessagesView.computed.messageCreationLockBannerMessage.call({
currentChat: {
message_creation_locked: true,
message_creation_lock_reason: 'manual',
},
$t,
});
expect(message).toBe('CONVERSATION.MESSAGE_CREATION_LOCK.MANUAL');
});
});
describe('ReplyBox', () => {
it('disables the editor when message creation is locked', () => {
const isDisabled = ReplyBox.computed.isEditorDisabled.call({
isMessageCreationLocked: true,
isAWhatsAppChannel: false,
isAPIInbox: false,
isOnPrivateNote: false,
currentChat: { can_reply: true },
});
expect(isDisabled).toBe(true);
});
it('uses the locked placeholder when message creation is locked', () => {
const placeholder = ReplyBox.computed.messagePlaceHolder.call({
isEditorDisabled: true,
isMessageCreationLocked: true,
$t: key => key,
});
expect(placeholder).toBe('CONVERSATION.FOOTER.MESSAGE_CREATION_LOCKED');
});
});
describe('ReplyBottomPanel', () => {
it('keeps template actions available when only the editor is disabled', () => {
const showWhatsAppTemplateButton =
ReplyBottomPanel.computed.showWhatsAppTemplateButton.call({
enableWhatsAppTemplates: true,
isMessageCreationLocked: false,
});
const showContentTemplateButton =
ReplyBottomPanel.computed.showContentTemplateButton.call({
enableContentTemplates: true,
isMessageCreationLocked: false,
});
expect(showWhatsAppTemplateButton).toBe(true);
expect(showContentTemplateButton).toBe(true);
});
it('hides template actions when message creation is locked', () => {
const showWhatsAppTemplateButton =
ReplyBottomPanel.computed.showWhatsAppTemplateButton.call({
enableWhatsAppTemplates: true,
isMessageCreationLocked: true,
});
const showContentTemplateButton =
ReplyBottomPanel.computed.showContentTemplateButton.call({
enableContentTemplates: true,
isMessageCreationLocked: true,
});
expect(showWhatsAppTemplateButton).toBe(false);
expect(showContentTemplateButton).toBe(false);
});
});
describe('CopilotEditorSection', () => {
it('keeps the follow-up editor available when message creation is unlocked', async () => {
const wrapper = mountCopilotEditorSection({
isMessageCreationLocked: false,
});
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="copilot-editor"]').exists()).toBe(
true
);
});
it('hides the follow-up editor when message creation is locked', async () => {
const wrapper = mountCopilotEditorSection({
isMessageCreationLocked: true,
});
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="copilot-editor"]').exists()).toBe(
false
);
});
});
});
@@ -44,6 +44,10 @@
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You wont be able to send messages from this conversation anymore.",
"MESSAGE_CREATION_LOCK": {
"MESSAGE_LIMIT": "This conversation has reached the {limit} message limit. We will drop all messages after this limit.",
"MANUAL": "This conversation is locked. We will drop all new messages until it is unlocked."
},
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
@@ -213,6 +217,7 @@
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
"MESSAGING_RESTRICTED_API": "You can only reply using a template message due to message window restriction",
"MESSAGE_CREATION_LOCKED": "Message creation is locked for this conversation",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
"CLICK_HERE": "Click here to update",
@@ -289,12 +289,14 @@ const actions = {
sendMessageWithData: async ({ commit }, pendingMessage) => {
const { conversation_id: conversationId, id } = pendingMessage;
const shouldRetryMessage =
hasMessageFailedWithExternalError(pendingMessage);
try {
commit(types.ADD_MESSAGE, {
...pendingMessage,
status: MESSAGE_STATUS.PROGRESS,
});
const response = hasMessageFailedWithExternalError(pendingMessage)
const response = shouldRetryMessage
? await MessageApi.retry(conversationId, id)
: await MessageApi.create(pendingMessage);
commit(types.ADD_MESSAGE, {
@@ -309,6 +311,31 @@ const actions = {
const errorMessage = error.response
? error.response.data.error
: undefined;
const errorResponse = error.response?.data || {};
if (errorResponse.message_creation_locked) {
if (shouldRetryMessage) {
commit(types.ADD_MESSAGE, {
...pendingMessage,
meta: {
...(pendingMessage.meta || {}),
error: errorResponse.error,
},
status: MESSAGE_STATUS.FAILED,
});
} else {
commit(types.DELETE_MESSAGE, pendingMessage);
}
commit(types.SET_CONVERSATION_MESSAGE_CREATION_LOCK, {
conversationId,
message_limit: errorResponse.message_limit,
message_limit_reached: errorResponse.message_limit_reached,
message_creation_locked: errorResponse.message_creation_locked,
message_creation_lock_reason:
errorResponse.message_creation_lock_reason,
});
throw error;
}
commit(types.ADD_MESSAGE, {
...pendingMessage,
meta: {
@@ -322,6 +349,12 @@ const actions = {
addMessage({ commit, rootGetters }, message) {
commit(types.ADD_MESSAGE, message);
if (message.conversation) {
commit(types.SET_CONVERSATION_MESSAGE_CREATION_LOCK, {
conversationId: message.conversation_id,
...message.conversation,
});
}
if (message.message_type === MESSAGE_TYPE.INCOMING) {
commit(types.SET_CONVERSATION_CAN_REPLY, {
conversationId: message.conversation_id,
@@ -29,6 +29,24 @@ const getConversationById = _state => conversationId => {
return _state.allConversations.find(c => c.id === conversationId);
};
const MESSAGE_CREATION_LOCK_FIELDS = [
'message_limit',
'message_limit_reached',
'message_creation_locked',
'message_creation_lock_reason',
];
const updateConversationMessageCreationLock = (
conversation,
lockState = {}
) => {
MESSAGE_CREATION_LOCK_FIELDS.forEach(field => {
if (Object.prototype.hasOwnProperty.call(lockState, field)) {
conversation[field] = lockState[field];
}
});
};
// mutations
export const mutations = {
[types.SET_ALL_CONVERSATION](_state, conversationList) {
@@ -205,6 +223,20 @@ export const mutations = {
});
},
[types.DELETE_MESSAGE]({ allConversations }, message) {
const { conversation_id: conversationId } = message;
const [chat] = getSelectedChatConversation({
allConversations,
selectedChatId: conversationId,
});
if (!chat) return;
const pendingMessageIndex = findPendingMessageIndex(chat, message);
if (pendingMessageIndex !== -1) {
chat.messages.splice(pendingMessageIndex, 1);
}
},
[types.ADD_MESSAGE]({ allConversations, selectedChatId }, message) {
const { conversation_id: conversationId } = message;
const [chat] = getSelectedChatConversation({
@@ -216,17 +248,29 @@ export const mutations = {
const pendingMessageIndex = findPendingMessageIndex(chat, message);
if (pendingMessageIndex !== -1) {
chat.messages[pendingMessageIndex] = message;
updateConversationMessageCreationLock(chat, message.conversation);
} else {
chat.messages.push(message);
chat.timestamp = message.created_at;
const { conversation: { unread_count: unreadCount = 0 } = {} } = message;
chat.unread_count = unreadCount;
updateConversationMessageCreationLock(chat, message.conversation);
if (selectedChatId === conversationId) {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
}
}
},
[types.SET_CONVERSATION_MESSAGE_CREATION_LOCK](
_state,
{ conversationId, ...lockState }
) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
updateConversationMessageCreationLock(chat, lockState);
}
},
[types.ADD_CONVERSATION](_state, conversation) {
const exists = _state.allConversations.some(c => c.id === conversation.id);
if (!exists) {
@@ -288,6 +288,138 @@ describe('#actions', () => {
actions.addMessage({ commit }, message);
expect(commit.mock.calls).toEqual([[types.ADD_MESSAGE, message]]);
});
it('syncs message creation lock metadata from message payload', () => {
const message = {
id: 1,
message_type: 1,
conversation_id: 1,
conversation: {
message_limit: 10000,
message_limit_reached: true,
message_creation_locked: true,
message_creation_lock_reason: 'message_limit',
},
};
actions.addMessage({ commit }, message);
expect(commit.mock.calls).toEqual([
[types.ADD_MESSAGE, message],
[
types.SET_CONVERSATION_MESSAGE_CREATION_LOCK,
{
conversationId: 1,
...message.conversation,
},
],
]);
});
});
describe('#sendMessageWithData', () => {
it('removes optimistic message and updates lock metadata when creation is locked', async () => {
const localCommit = vi.fn();
const pendingMessage = {
id: 'temp-1',
conversation_id: 1,
message: 'Locked message',
};
const error = {
response: {
data: {
error: 'This conversation has reached the 10000 message limit.',
message_limit: 10000,
message_limit_reached: true,
message_creation_locked: true,
message_creation_lock_reason: 'message_limit',
},
},
};
axios.mockRejectedValue(error);
await expect(
actions.sendMessageWithData({ commit: localCommit }, pendingMessage)
).rejects.toBe(error);
expect(localCommit.mock.calls).toEqual([
[
types.ADD_MESSAGE,
{
...pendingMessage,
status: 'progress',
},
],
[types.DELETE_MESSAGE, pendingMessage],
[
types.SET_CONVERSATION_MESSAGE_CREATION_LOCK,
{
conversationId: 1,
message_limit: 10000,
message_limit_reached: true,
message_creation_locked: true,
message_creation_lock_reason: 'message_limit',
},
],
]);
});
it('keeps an existing failed message when retry is locked', async () => {
const localCommit = vi.fn();
const pendingMessage = {
id: 42,
conversation_id: 1,
message: 'Retry message',
status: 'failed',
content_attributes: {
external_error: 'Provider rejected the message',
},
};
const error = {
response: {
data: {
error: 'This conversation has reached the 10000 message limit.',
message_limit: 10000,
message_limit_reached: true,
message_creation_locked: true,
message_creation_lock_reason: 'message_limit',
},
},
};
axios.post.mockRejectedValue(error);
await expect(
actions.sendMessageWithData({ commit: localCommit }, pendingMessage)
).rejects.toBe(error);
expect(localCommit.mock.calls).toEqual([
[
types.ADD_MESSAGE,
{
...pendingMessage,
status: 'progress',
},
],
[
types.ADD_MESSAGE,
{
...pendingMessage,
meta: {
error: error.response.data.error,
},
status: 'failed',
},
],
[
types.SET_CONVERSATION_MESSAGE_CREATION_LOCK,
{
conversationId: 1,
message_limit: 10000,
message_limit_reached: true,
message_creation_locked: true,
message_creation_lock_reason: 'message_limit',
},
],
]);
});
});
describe('#markMessagesRead', () => {
@@ -109,6 +109,46 @@ describe('#mutations', () => {
});
});
describe('#SET_CONVERSATION_MESSAGE_CREATION_LOCK', () => {
it('sets message creation lock metadata', () => {
const state = { allConversations: [{ id: 1, messages: [] }] };
mutations[types.SET_CONVERSATION_MESSAGE_CREATION_LOCK](state, {
conversationId: 1,
message_limit: 10000,
message_limit_reached: true,
message_creation_locked: true,
message_creation_lock_reason: 'message_limit',
});
expect(state.allConversations[0]).toMatchObject({
message_limit: 10000,
message_limit_reached: true,
message_creation_locked: true,
message_creation_lock_reason: 'message_limit',
});
});
});
describe('#DELETE_MESSAGE', () => {
it('removes a pending message from the conversation', () => {
const state = {
allConversations: [
{
id: 1,
messages: [{ id: 'temp-1', echo_id: 'temp-1' }],
},
],
};
mutations[types.DELETE_MESSAGE](state, {
conversation_id: 1,
id: 'temp-1',
});
expect(state.allConversations[0].messages).toEqual([]);
});
});
describe('#ADD_MESSAGE', () => {
it('does not add message to the store if conversation does not exist', () => {
const state = { allConversations: [] };
@@ -208,6 +248,32 @@ describe('#mutations', () => {
]);
expect(emitter.emit).not.toHaveBeenCalled();
});
it('updates conversation lock metadata from message payload', () => {
const state = {
allConversations: [{ id: 1, messages: [] }],
selectedChatId: 1,
};
mutations[types.ADD_MESSAGE](state, {
conversation_id: 1,
content: 'Test message',
created_at: 1602256198,
conversation: {
message_limit: 10000,
message_limit_reached: true,
message_creation_locked: true,
message_creation_lock_reason: 'message_limit',
},
});
expect(state.allConversations[0]).toMatchObject({
message_limit: 10000,
message_limit_reached: true,
message_creation_locked: true,
message_creation_lock_reason: 'message_limit',
});
});
});
describe('#CHANGE_CONVERSATION_STATUS', () => {
@@ -54,6 +54,8 @@ export default {
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
SET_CONVERSATION_MESSAGE_CREATION_LOCK:
'SET_CONVERSATION_MESSAGE_CREATION_LOCK',
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS',