feat: add option for reply to in context menu (#8043)

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Shivam Mishra
2023-10-05 20:01:20 +05:30
committed by GitHub
parent e27274a5a8
commit 1b63adfb2e
8 changed files with 279 additions and 152 deletions

View File

@@ -124,6 +124,7 @@
:message="data" :message="data"
@open="openContextMenu" @open="openContextMenu"
@close="closeContextMenu" @close="closeContextMenu"
@replyTo="handleReplyTo"
/> />
</div> </div>
</li> </li>
@@ -188,6 +189,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
inboxSupportsReplyTo: {
type: Boolean,
default: false,
},
}, },
data() { data() {
return { return {
@@ -269,6 +274,7 @@ export default {
copy: this.hasText, copy: this.hasText,
delete: this.hasText || this.hasAttachments, delete: this.hasText || this.hasAttachments,
cannedResponse: this.isOutgoing && this.hasText, cannedResponse: this.isOutgoing && this.hasText,
replyTo: !this.data.private && this.inboxSupportsReplyTo,
}; };
}, },
contentAttributes() { contentAttributes() {
@@ -494,6 +500,7 @@ export default {
this.showContextMenu = false; this.showContextMenu = false;
this.contextMenuPosition = { x: null, y: null }; this.contextMenuPosition = { x: null, y: null };
}, },
handleReplyTo() {},
setupHighlightTimer() { setupHighlightTimer() {
if (Number(this.$route.query.messageId) !== Number(this.data.id)) { if (Number(this.$route.query.messageId) !== Number(this.data.id)) {
return; return;

View File

@@ -42,6 +42,7 @@
:is-a-whatsapp-channel="isAWhatsAppChannel" :is-a-whatsapp-channel="isAWhatsAppChannel"
:has-instagram-story="hasInstagramStory" :has-instagram-story="hasInstagramStory"
:is-web-widget-inbox="isAWebWidgetInbox" :is-web-widget-inbox="isAWebWidgetInbox"
:inbox-supports-reply-to="inboxSupportsReplyTo"
/> />
<li v-show="unreadMessageCount != 0" class="unread--toast"> <li v-show="unreadMessageCount != 0" class="unread--toast">
<span> <span>
@@ -110,7 +111,7 @@ import { mapGetters } from 'vuex';
import conversationMixin, { import conversationMixin, {
filterDuplicateSourceMessages, filterDuplicateSourceMessages,
} from '../../../mixins/conversations'; } from '../../../mixins/conversations';
import inboxMixin from 'shared/mixins/inboxMixin'; import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import configMixin from 'shared/mixins/configMixin'; import configMixin from 'shared/mixins/configMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import aiMixin from 'dashboard/mixins/aiMixin'; import aiMixin from 'dashboard/mixins/aiMixin';
@@ -123,6 +124,7 @@ import { LocalStorage } from 'shared/helpers/localStorage';
// constants // constants
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { REPLY_POLICY } from 'shared/constants/links'; import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -164,12 +166,14 @@ export default {
computed: { computed: {
...mapGetters({ ...mapGetters({
accountId: 'getCurrentAccountId',
currentChat: 'getSelectedChat', currentChat: 'getSelectedChat',
allConversations: 'getAllConversations', allConversations: 'getAllConversations',
inboxesList: 'inboxes/getInboxes', inboxesList: 'inboxes/getInboxes',
listLoadingStatus: 'getAllMessagesLoaded', listLoadingStatus: 'getAllMessagesLoaded',
loadingChatList: 'getChatListLoadingStatus', loadingChatList: 'getChatListLoadingStatus',
appIntegrations: 'integrations/getAppIntegrations', appIntegrations: 'integrations/getAppIntegrations',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
currentAccountId: 'getCurrentAccountId', currentAccountId: 'getCurrentAccountId',
}), }),
isOpen() { isOpen() {
@@ -316,6 +320,15 @@ export default {
unreadMessageCount() { unreadMessageCount() {
return this.currentChat.unread_count || 0; return this.currentChat.unread_count || 0;
}, },
inboxSupportsReplyTo() {
return (
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.MESSAGE_REPLY_TO
)
);
},
}, },
watch: { watch: {

View File

@@ -16,4 +16,5 @@ export const FEATURE_FLAGS = {
TEAM_MANAGEMENT: 'team_management', TEAM_MANAGEMENT: 'team_management',
VOICE_RECORDER: 'voice_recorder', VOICE_RECORDER: 'voice_recorder',
AUDIT_LOGS: 'audit_logs', AUDIT_LOGS: 'audit_logs',
MESSAGE_REPLY_TO: 'message_reply_to',
}; };

View File

@@ -194,6 +194,7 @@
}, },
"CONTEXT_MENU": { "CONTEXT_MENU": {
"COPY": "Copy", "COPY": "Copy",
"REPLY_TO": "Reply to this message",
"DELETE": "Delete", "DELETE": "Delete",
"CREATE_A_CANNED_RESPONSE": "Add to canned responses", "CREATE_A_CANNED_RESPONSE": "Add to canned responses",
"TRANSLATE": "Translate", "TRANSLATE": "Translate",

View File

@@ -44,6 +44,15 @@
@close="handleClose" @close="handleClose"
> >
<div class="menu-container"> <div class="menu-container">
<menu-item
v-if="enabledOptions['replyTo']"
:option="{
icon: 'arrow-reply',
label: $t('CONVERSATION.CONTEXT_MENU.REPLY_TO'),
}"
variant="icon"
@click="handleReplyTo"
/>
<menu-item <menu-item
v-if="enabledOptions['copy']" v-if="enabledOptions['copy']"
:option="{ :option="{
@@ -204,10 +213,13 @@ export default {
this.handleClose(); this.handleClose();
this.showTranslateModal = true; this.showTranslateModal = true;
}, },
handleReplyTo() {
this.$emit('replyTo', this.message);
this.handleClose();
},
onCloseTranslateModal() { onCloseTranslateModal() {
this.showTranslateModal = false; this.showTranslateModal = false;
}, },
openDeleteModal() { openDeleteModal() {
this.handleClose(); this.handleClose();
this.showDeleteModal = true; this.showDeleteModal = true;

View File

@@ -11,6 +11,24 @@ export const INBOX_TYPES = {
SMS: 'Channel::Sms', SMS: 'Channel::Sms',
}; };
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',
};
// This is a single source of truth for inbox features
// This is used to check if a feature is available for a particular inbox or not
export const INBOX_FEATURE_MAP = {
[INBOX_FEATURES.REPLY_TO]: [
INBOX_TYPES.WEB,
INBOX_TYPES.FB,
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.LINE,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.API,
],
};
export default { export default {
computed: { computed: {
channelType() { channelType() {
@@ -102,4 +120,9 @@ export default {
); );
}, },
}, },
methods: {
inboxHasFeature(feature) {
return INBOX_FEATURE_MAP[feature]?.includes(this.channelType) ?? false;
},
},
}; };

View File

@@ -1,218 +1,286 @@
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import inboxMixin from '../inboxMixin'; import inboxMixin from '../inboxMixin';
function getComponentConfigForInbox(channelType, additionalConfig = {}) {
return {
render() {},
mixins: [inboxMixin],
data() {
return {
inbox: {
channel_type: channelType,
...additionalConfig,
},
};
},
};
}
function getComponentConfigForChat(chat) {
return {
render() {},
mixins: [inboxMixin],
data() {
return {
chat,
};
},
};
}
describe('inboxMixin', () => { describe('inboxMixin', () => {
it('returns the correct channel type', () => { it('returns the correct channel type', () => {
const Component = { const Component = getComponentConfigForInbox('Channel::WebWidget');
render() {},
mixins: [inboxMixin],
data() {
return { inbox: { channel_type: 'Channel::WebWidget' } };
},
};
const wrapper = shallowMount(Component); const wrapper = shallowMount(Component);
expect(wrapper.vm.channelType).toBe('Channel::WebWidget'); expect(wrapper.vm.channelType).toBe('Channel::WebWidget');
}); });
it('isAPIInbox returns true if channel type is API', () => { it('isAPIInbox returns true if channel type is API', () => {
const Component = { const Component = getComponentConfigForInbox('Channel::Api');
render() {},
mixins: [inboxMixin],
data() {
return { inbox: { channel_type: 'Channel::Api' } };
},
};
const wrapper = shallowMount(Component); const wrapper = shallowMount(Component);
expect(wrapper.vm.isAPIInbox).toBe(true); expect(wrapper.vm.isAPIInbox).toBe(true);
}); });
it('isATwitterInbox returns true if channel type is twitter', () => { it('isATwitterInbox returns true if channel type is twitter', () => {
const Component = { const Component = getComponentConfigForInbox('Channel::TwitterProfile');
render() {},
mixins: [inboxMixin],
data() {
return { inbox: { channel_type: 'Channel::TwitterProfile' } };
},
};
const wrapper = shallowMount(Component); const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwitterInbox).toBe(true); expect(wrapper.vm.isATwitterInbox).toBe(true);
}); });
it('isAFacebookInbox returns true if channel type is Facebook', () => { it('isAFacebookInbox returns true if channel type is Facebook', () => {
const Component = { const Component = getComponentConfigForInbox('Channel::FacebookPage');
render() {},
mixins: [inboxMixin],
data() {
return { inbox: { channel_type: 'Channel::FacebookPage' } };
},
};
const wrapper = shallowMount(Component); const wrapper = shallowMount(Component);
expect(wrapper.vm.isAFacebookInbox).toBe(true); expect(wrapper.vm.isAFacebookInbox).toBe(true);
}); });
it('isAWebWidgetInbox returns true if channel type is Facebook', () => { it('isAWebWidgetInbox returns true if channel type is Facebook', () => {
const Component = { const Component = getComponentConfigForInbox('Channel::WebWidget');
render() {},
mixins: [inboxMixin],
data() {
return { inbox: { channel_type: 'Channel::WebWidget' } };
},
};
const wrapper = shallowMount(Component); const wrapper = shallowMount(Component);
expect(wrapper.vm.isAWebWidgetInbox).toBe(true); expect(wrapper.vm.isAWebWidgetInbox).toBe(true);
}); });
it('isASmsInbox returns true if channel type is sms', () => { it('isASmsInbox returns true if channel type is sms', () => {
const Component = { const Component = getComponentConfigForInbox('Channel::Sms');
render() {},
mixins: [inboxMixin],
data() {
return { inbox: { channel_type: 'Channel::Sms' } };
},
};
const wrapper = shallowMount(Component); const wrapper = shallowMount(Component);
expect(wrapper.vm.isASmsInbox).toBe(true); expect(wrapper.vm.isASmsInbox).toBe(true);
}); });
it('isASmsInbox returns true if channel type is twilio sms', () => {
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
medium: 'sms',
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.isASmsInbox).toBe(true);
});
it('isALineChannel returns true if channel type is Line', () => {
const Component = getComponentConfigForInbox('Channel::Line');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isALineChannel).toBe(true);
});
it('isATelegramChannel returns true if channel type is Telegram', () => {
const Component = getComponentConfigForInbox('Channel::Telegram');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATelegramChannel).toBe(true);
});
it('isATwilioChannel returns true if channel type is Twilio', () => { it('isATwilioChannel returns true if channel type is Twilio', () => {
const Component = { const Component = getComponentConfigForInbox('Channel::TwilioSms');
render() {},
mixins: [inboxMixin],
data() {
return {
inbox: {
channel_type: 'Channel::TwilioSms',
},
};
},
};
const wrapper = shallowMount(Component); const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwilioChannel).toBe(true); expect(wrapper.vm.isATwilioChannel).toBe(true);
}); });
it('isATwilioSMSChannel returns true if channel type is Twilio and medium is SMS', () => { describe('isATwilioSMSChannel', () => {
const Component = { it('returns true if channel type is Twilio and medium is SMS', () => {
render() {}, const Component = getComponentConfigForInbox('Channel::TwilioSms', {
mixins: [inboxMixin], medium: 'sms',
data() { });
return { const wrapper = shallowMount(Component);
inbox: { expect(wrapper.vm.isATwilioSMSChannel).toBe(true);
channel_type: 'Channel::TwilioSms', });
medium: 'sms',
},
};
},
};
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwilioChannel).toBe(true);
expect(wrapper.vm.isATwilioSMSChannel).toBe(true);
expect(wrapper.vm.isASmsInbox).toBe(true);
});
it('isATwilioWhatsAppChannel returns true if channel type is Twilio and medium is WhatsApp', () => { it('returns false if channel type is Twilio but medium is not SMS', () => {
const Component = { const Component = getComponentConfigForInbox('Channel::TwilioSms', {
render() {}, medium: 'whatsapp',
mixins: [inboxMixin], });
data() { const wrapper = shallowMount(Component);
return { expect(wrapper.vm.isATwilioSMSChannel).toBe(false);
inbox: { });
channel_type: 'Channel::TwilioSms',
medium: 'whatsapp', it('returns false if channel type is not Twilio but medium is SMS', () => {
}, const Component = getComponentConfigForInbox('Channel::NotTwilio', {
}; medium: 'sms',
}, });
}; const wrapper = shallowMount(Component);
const wrapper = shallowMount(Component); expect(wrapper.vm.isATwilioSMSChannel).toBe(false);
expect(wrapper.vm.isATwilioChannel).toBe(true); });
expect(wrapper.vm.isATwilioWhatsAppChannel).toBe(true);
it('returns false if neither channel type is Twilio nor medium is SMS', () => {
const Component = getComponentConfigForInbox('Channel::NotTwilio', {
medium: 'not_sms',
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwilioSMSChannel).toBe(false);
});
it('returns false if channel type is Twilio but medium is empty', () => {
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
medium: undefined,
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwilioSMSChannel).toBe(false);
});
}); });
it('isAnEmailChannel returns true if channel type is email', () => { it('isAnEmailChannel returns true if channel type is email', () => {
const Component = { const Component = getComponentConfigForInbox('Channel::Email');
render() {},
mixins: [inboxMixin],
data() {
return {
inbox: { channel_type: 'Channel::Email' },
};
},
};
const wrapper = shallowMount(Component); const wrapper = shallowMount(Component);
expect(wrapper.vm.isAnEmailChannel).toBe(true); expect(wrapper.vm.isAnEmailChannel).toBe(true);
}); });
it('isTwitterInboxTweet returns true if Twitter channel type is tweet', () => { it('isTwitterInboxTweet returns true if Twitter channel type is tweet', () => {
const Component = { const Component = getComponentConfigForChat({
render() {}, channel_type: 'Channel::TwitterProfile',
mixins: [inboxMixin], additional_attributes: {
data() { type: 'tweet',
return {
chat: {
channel_type: 'Channel::TwitterProfile',
additional_attributes: {
type: 'tweet',
},
},
};
}, },
}; });
const wrapper = shallowMount(Component); const wrapper = shallowMount(Component);
expect(wrapper.vm.isTwitterInboxTweet).toBe(true); expect(wrapper.vm.isTwitterInboxTweet).toBe(true);
}); });
it('twilioBadge returns string sms if channel type is Twilio and medium is sms', () => { it('twilioBadge returns string sms if channel type is Twilio and medium is sms', () => {
const Component = { const Component = getComponentConfigForInbox('Channel::TwilioSms', {
render() {}, medium: 'sms',
mixins: [inboxMixin], });
data() {
return {
inbox: {
channel_type: 'Channel::TwilioSms',
medium: 'sms',
},
};
},
};
const wrapper = shallowMount(Component); const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwilioSMSChannel).toBe(true); expect(wrapper.vm.isATwilioSMSChannel).toBe(true);
expect(wrapper.vm.twilioBadge).toBe('sms'); expect(wrapper.vm.twilioBadge).toBe('sms');
}); });
it('twitterBadge returns string twitter-tweet if Twitter channel type is tweet', () => { it('twitterBadge returns string twitter-tweet if Twitter channel type is tweet', () => {
const Component = { const Component = getComponentConfigForChat({
render() {}, id: 1,
mixins: [inboxMixin], additional_attributes: {
data() { type: 'tweet',
return {
chat: {
id: 1,
additional_attributes: {
type: 'tweet',
},
},
};
}, },
}; });
const wrapper = shallowMount(Component); const wrapper = shallowMount(Component);
expect(wrapper.vm.isTwitterInboxTweet).toBe(true); expect(wrapper.vm.isTwitterInboxTweet).toBe(true);
expect(wrapper.vm.twitterBadge).toBe('twitter-tweet'); expect(wrapper.vm.twitterBadge).toBe('twitter-tweet');
}); });
it('inboxBadge returns string Channel::Telegram if isATwilioChannel and isATwitterInbox is false', () => { describe('Badges', () => {
const Component = { it('inboxBadge returns string Channel::Telegram if isATwilioChannel and isATwitterInbox is false', () => {
render() {}, const Component = getComponentConfigForInbox('Channel::Telegram');
mixins: [inboxMixin], const wrapper = shallowMount(Component);
data() { expect(wrapper.vm.isATwilioChannel).toBe(false);
return { expect(wrapper.vm.isATwitterInbox).toBe(false);
inbox: { expect(wrapper.vm.channelType).toBe('Channel::Telegram');
channel_type: 'Channel::Telegram', });
},
}; it('inboxBadge returns correct badge for WhatsApp channel', () => {
}, const Component = getComponentConfigForInbox('Channel::Whatsapp');
}; const wrapper = shallowMount(Component);
const wrapper = shallowMount(Component); expect(wrapper.vm.inboxBadge).toBe('whatsapp');
expect(wrapper.vm.isATwilioChannel).toBe(false); });
expect(wrapper.vm.isATwitterInbox).toBe(false);
expect(wrapper.vm.channelType).toBe('Channel::Telegram'); it('inboxBadge returns the twitterBadge when isATwitterInbox is true', () => {
const Component = getComponentConfigForInbox('Channel::TwitterProfile');
const wrapper = shallowMount(Component);
expect(wrapper.vm.inboxBadge).toBe('twitter-dm');
});
it('inboxBadge returns the facebookBadge when isAFacebookInbox is true', () => {
const Component = getComponentConfigForInbox('Channel::FacebookPage');
const wrapper = shallowMount(Component);
expect(wrapper.vm.inboxBadge).toBe('facebook');
});
it('inboxBadge returns the twilioBadge when isATwilioChannel is true', () => {
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
medium: 'sms',
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.inboxBadge).toBe('sms');
});
it('inboxBadge returns "whatsapp" when isAWhatsAppChannel is true', () => {
const Component = getComponentConfigForInbox('Channel::Whatsapp');
const wrapper = shallowMount(Component);
expect(wrapper.vm.inboxBadge).toBe('whatsapp');
});
it('inboxBadge returns the channelType when no specific condition is true', () => {
const Component = getComponentConfigForInbox('Channel::Email');
const wrapper = shallowMount(Component);
expect(wrapper.vm.inboxBadge).toBe('Channel::Email');
});
});
describe('#inboxHasFeature', () => {
it('detects the correct feature', () => {
const Component = getComponentConfigForInbox('Channel::Telegram');
const wrapper = shallowMount(Component);
expect(wrapper.vm.inboxHasFeature('replyTo')).toBe(true);
expect(wrapper.vm.inboxHasFeature('feature-does-not-exist')).toBe(false);
});
it('returns false for feature not included', () => {
const Component = getComponentConfigForInbox('Channel::Sms');
const wrapper = shallowMount(Component);
expect(wrapper.vm.inboxHasFeature('replyTo')).toBe(false);
expect(wrapper.vm.inboxHasFeature('feature-does-not-exist')).toBe(false);
});
});
describe('WhatsApp channel', () => {
it('returns correct whatsAppAPIProvider', () => {
const Component = getComponentConfigForInbox('Channel::Whatsapp', {
provider: 'whatsapp_cloud',
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.whatsAppAPIProvider).toBe('whatsapp_cloud');
});
it('returns empty whatsAppAPIProvider if nothing is present', () => {
const Component = getComponentConfigForInbox('Channel::Whatsapp', {
provider: undefined,
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.whatsAppAPIProvider).toBe('');
});
it('isAWhatsAppCloudChannel returns true if channel type is WhatsApp and provider is whatsapp_cloud', () => {
const Component = getComponentConfigForInbox('Channel::Whatsapp', {
provider: 'whatsapp_cloud',
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.isAWhatsAppCloudChannel).toBe(true);
});
it('is360DialogWhatsAppChannel returns true if channel type is WhatsApp and provider is default', () => {
const Component = getComponentConfigForInbox('Channel::Whatsapp', {
provider: 'default',
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.is360DialogWhatsAppChannel).toBe(true);
});
it('isAWhatsAppChannel returns true if channel type is WhatsApp', () => {
const Component = getComponentConfigForInbox('Channel::Whatsapp');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
});
it('isAWhatsAppChannel returns true if channel type is Twilio and medium is WhatsApp', () => {
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
medium: 'whatsapp',
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
});
}); });
}); });

View File

@@ -57,3 +57,5 @@
enabled: false enabled: false
- name: response_bot - name: response_bot
enabled: false enabled: false
- name: message_reply_to
enabled: false