diff --git a/.env.example b/.env.example index de671599c..3ff349ffc 100644 --- a/.env.example +++ b/.env.example @@ -256,6 +256,8 @@ AZURE_APP_SECRET= ## Change these values to fine tune performance # control the concurrency setting of sidekiq # SIDEKIQ_CONCURRENCY=10 +# Enable verbose logging each time a job is dequeued in Sidekiq +# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false # AI powered features diff --git a/Gemfile b/Gemfile index 18442e3b0..abbd3332f 100644 --- a/Gemfile +++ b/Gemfile @@ -21,6 +21,7 @@ gem 'telephone_number' gem 'time_diff' gem 'tzinfo-data' gem 'valid_email2' +gem 'email-provider-info' # compress javascript config.assets.js_compressor gem 'uglifier' ##-- used for single column multiple binary flags in notification settings/feature flagging --## diff --git a/Gemfile.lock b/Gemfile.lock index 2f4da34e3..99e75b33c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -270,6 +270,7 @@ GEM concurrent-ruby (~> 1.0) http (>= 3.0) ruby2_keywords + email-provider-info (0.0.1) email_reply_trimmer (0.1.13) erubi (1.13.0) et-orbi (1.2.11) @@ -1016,6 +1017,7 @@ DEPENDENCIES dotenv-rails (>= 3.0.0) down elastic-apm + email-provider-info email_reply_trimmer facebook-messenger factory_bot_rails (>= 6.4.3) diff --git a/app/controllers/api/v1/accounts/bulk_actions_controller.rb b/app/controllers/api/v1/accounts/bulk_actions_controller.rb index 34db47861..222c66714 100644 --- a/app/controllers/api/v1/accounts/bulk_actions_controller.rb +++ b/app/controllers/api/v1/accounts/bulk_actions_controller.rb @@ -1,13 +1,12 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController - before_action :type_matches? - def create - if type_matches? - ::BulkActionsJob.perform_later( - account: @current_account, - user: current_user, - params: permitted_params - ) + case normalized_type + when 'Conversation' + enqueue_conversation_job + head :ok + when 'Contact' + check_authorization_for_contact_action + enqueue_contact_job head :ok else render json: { success: false }, status: :unprocessable_entity @@ -16,11 +15,54 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll private - def type_matches? - ['Conversation'].include?(params[:type]) + def normalized_type + params[:type].to_s.camelize end - def permitted_params - params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []]) + def enqueue_conversation_job + ::BulkActionsJob.perform_later( + account: @current_account, + user: current_user, + params: conversation_params + ) + end + + def enqueue_contact_job + Contacts::BulkActionJob.perform_later( + @current_account.id, + current_user.id, + contact_params + ) + end + + def delete_contact_action? + params[:action_name] == 'delete' + end + + def check_authorization_for_contact_action + authorize(Contact, :destroy?) if delete_contact_action? + end + + def conversation_params + # TODO: Align conversation payloads with the `{ action_name, action_attributes }` + # and then remove this method in favor of a common params method. + base = params.permit( + :snoozed_until, + fields: [:status, :assignee_id, :team_id] + ) + append_common_bulk_attributes(base) + end + + def contact_params + # TODO: remove this method in favor of a common params method. + # once legacy conversation payloads are migrated. + append_common_bulk_attributes({}) + end + + def append_common_bulk_attributes(base_params) + # NOTE: Conversation payloads historically diverged per action. Going forward we + # want all objects to share a common contract: `{ action_name, action_attributes }` + common = params.permit(:type, :action_name, ids: [], labels: [add: [], remove: []]) + base_params.merge(common) end end diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 0fcb8c9fe..a63cb1a90 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -1,6 +1,5 @@ diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue index 0e893b767..12bed151d 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue @@ -8,6 +8,7 @@ import Button from 'dashboard/components-next/button/Button.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Flag from 'dashboard/components-next/flag/Flag.vue'; import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue'; +import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; import countries from 'shared/constants/countries'; const props = defineProps({ @@ -20,9 +21,17 @@ const props = defineProps({ availabilityStatus: { type: String, default: null }, isExpanded: { type: Boolean, default: false }, isUpdating: { type: Boolean, default: false }, + selectable: { type: Boolean, default: false }, + isSelected: { type: Boolean, default: false }, }); -const emit = defineEmits(['toggle', 'updateContact', 'showContact']); +const emit = defineEmits([ + 'toggle', + 'updateContact', + 'showContact', + 'select', + 'avatarHover', +]); const { t } = useI18n(); @@ -88,111 +97,148 @@ const onClickExpand = () => { }; const onClickViewDetails = () => emit('showContact', props.id); + +const toggleSelect = checked => { + emit('select', checked); +}; + +const handleAvatarHover = isHovered => { + emit('avatarHover', isHovered); +}; diff --git a/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue b/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue index aec21a976..b7014c42f 100644 --- a/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue +++ b/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue @@ -10,6 +10,7 @@ import Button from 'dashboard/components-next/button/Button.vue'; import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue'; import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue'; import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue'; +import Policy from 'dashboard/components/policy.vue'; const props = defineProps({ selectedContact: { @@ -174,27 +175,29 @@ const handleAvatarDelete = async () => { @click="updateContact" /> -
-
-
- {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }} -
- - {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }} - + +
+
+
+ {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }} +
+ + {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }} + +
+
-
- +
diff --git a/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue b/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue index 7acc2ff55..72bc2c7dd 100644 --- a/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue +++ b/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue @@ -10,7 +10,15 @@ import { } from 'shared/helpers/CustomErrors'; import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue'; -defineProps({ contacts: { type: Array, required: true } }); +const props = defineProps({ + contacts: { type: Array, required: true }, + selectedContactIds: { + type: Array, + default: () => [], + }, +}); + +const emit = defineEmits(['toggleContact']); const { t } = useI18n(); const store = useStore(); @@ -20,6 +28,9 @@ const route = useRoute(); const uiFlags = useMapGetter('contacts/getUIFlags'); const isUpdating = computed(() => uiFlags.value.isUpdating); const expandedCardId = ref(null); +const hoveredAvatarId = ref(null); + +const selectedIdsSet = computed(() => new Set(props.selectedContactIds || [])); const updateContact = async updatedData => { try { @@ -58,25 +69,43 @@ const onClickViewDetails = async id => { const toggleExpanded = id => { expandedCardId.value = expandedCardId.value === id ? null : id; }; + +const isSelected = id => selectedIdsSet.value.has(id); + +const shouldShowSelection = id => { + return hoveredAvatarId.value === id || isSelected(id); +}; + +const handleSelect = (id, value) => { + emit('toggleContact', { id, value }); +}; + +const handleAvatarHover = (id, isHovered) => { + hoveredAvatarId.value = isHovered ? id : null; +}; diff --git a/app/javascript/dashboard/components-next/Conversation/SidepanelSwitch.vue b/app/javascript/dashboard/components-next/Conversation/SidepanelSwitch.vue index 1ee969275..5e96df3c6 100644 --- a/app/javascript/dashboard/components-next/Conversation/SidepanelSwitch.vue +++ b/app/javascript/dashboard/components-next/Conversation/SidepanelSwitch.vue @@ -1,5 +1,6 @@ diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue index a2f139bdc..67936fa59 100644 --- a/app/javascript/dashboard/components-next/Editor/Editor.vue +++ b/app/javascript/dashboard/components-next/Editor/Editor.vue @@ -21,6 +21,10 @@ const props = defineProps({ enableCannedResponses: { type: Boolean, default: true }, enabledMenuOptions: { type: Array, default: () => [] }, enableCaptainTools: { type: Boolean, default: false }, + signature: { type: String, default: '' }, + allowSignature: { type: Boolean, default: false }, + sendWithSignature: { type: Boolean, default: false }, + channelType: { type: String, default: '' }, }); const emit = defineEmits(['update:modelValue']); @@ -100,6 +104,10 @@ watch( :enable-canned-responses="enableCannedResponses" :enabled-menu-options="enabledMenuOptions" :enable-captain-tools="enableCaptainTools" + :signature="signature" + :allow-signature="allowSignature" + :send-with-signature="sendWithSignature" + :channel-type="channelType" @input="handleInput" @focus="handleFocus" @blur="handleBlur" diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue index 2d8b219f7..bec54593f 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue @@ -14,6 +14,7 @@ import { } from 'dashboard/helper/portalHelper'; import wootConstants from 'dashboard/constants/globals'; +import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue'; import Button from 'dashboard/components-next/button/Button.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; @@ -140,11 +141,12 @@ const updateArticleStatus = async ({ value }) => { :disabled="!articleId" @click="previewArticle" /> -
+
- + diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue index 773ebe315..92c5850de 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue @@ -92,7 +92,6 @@ const setSignature = () => { const toggleMessageSignature = () => { setSignatureFlagForInbox(props.channelType, !sendWithSignature.value); - setSignature(); }; // Added this watch to dynamically set signature on target inbox change. diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index 4d6d41dac..a02d6d495 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -199,16 +199,20 @@ const handleInboxAction = ({ value, action, ...rest }) => { state.attachedFiles = []; }; -const removeTargetInbox = value => { - v$.value.$reset(); - // Remove the signature from message content - // Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp) - if (props.sendWithSignature) { - const signatureToRemove = inboxTypes.value.isEmailOrWebWidget - ? props.messageSignature - : extractTextFromMarkdown(props.messageSignature); +const removeSignatureFromMessage = () => { + // Always remove the signature from message content when inbox/contact is removed + // to ensure no leftover signature content remains + const signatureToRemove = inboxTypes.value.isEmailOrWebWidget + ? props.messageSignature + : extractTextFromMarkdown(props.messageSignature); + if (signatureToRemove) { state.message = removeSignature(state.message, signatureToRemove); } +}; + +const removeTargetInbox = value => { + v$.value.$reset(); + removeSignatureFromMessage(); emit('updateTargetInbox', value); state.attachedFiles = []; }; @@ -216,6 +220,7 @@ const removeTargetInbox = value => { const clearSelectedContact = () => { emit('clearSelectedContact'); state.attachedFiles = []; + removeSignatureFromMessage(); }; const onClickInsertEmoji = emoji => { @@ -354,6 +359,7 @@ const shouldShowMessageEditor = computed(() => { :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" :has-errors="validationStates.isMessageInvalid" :has-attachments="state.attachedFiles.length > 0" + :channel-type="inboxChannelType" /> { " enable-variables :show-character-count="false" + :signature="messageSignature" + allow-signature + :send-with-signature="sendWithSignature" + :channel-type="channelType" />