diff --git a/Gemfile b/Gemfile index 4044c9b66..9f2f73d83 100644 --- a/Gemfile +++ b/Gemfile @@ -165,7 +165,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1' # need for google auth gem 'omniauth', '>= 2.1.2' -gem 'omniauth-google-oauth2', '>= 1.1.2' +gem 'omniauth-google-oauth2', '>= 1.1.3' gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2' ## Gems for reponse bot @@ -200,7 +200,7 @@ group :development do gem 'rack-mini-profiler', '>= 3.2.0', require: false gem 'stackprof' # Should install the associated chrome extension to view query logs - gem 'meta_request', '>= 0.8.0' + gem 'meta_request', '>= 0.8.3' end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index 46a766b4c..df8279bba 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -457,7 +457,7 @@ GEM marcel (1.0.4) maxminddb (0.1.22) memoist (0.16.2) - meta_request (0.8.2) + meta_request (0.8.3) rack-contrib (>= 1.1, < 3) railties (>= 3.0.0, < 8) method_source (1.1.0) @@ -522,7 +522,7 @@ GEM hashie (>= 3.4.6) rack (>= 2.2.3) rack-protection - omniauth-google-oauth2 (1.1.2) + omniauth-google-oauth2 (1.1.3) jwt (>= 2.0) oauth2 (~> 2.0) omniauth (~> 2.0) @@ -903,14 +903,14 @@ DEPENDENCIES listen lograge (~> 0.14.0) maxminddb - meta_request (>= 0.8.0) + meta_request (>= 0.8.3) mock_redis neighbor net-smtp (~> 0.3.4) newrelic-sidekiq-metrics (>= 1.6.2) newrelic_rpm omniauth (>= 2.1.2) - omniauth-google-oauth2 (>= 1.1.2) + omniauth-google-oauth2 (>= 1.1.3) omniauth-oauth2 omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2) pg diff --git a/app/controllers/api/v1/accounts/upload_controller.rb b/app/controllers/api/v1/accounts/upload_controller.rb index 4c54938b7..6530279da 100644 --- a/app/controllers/api/v1/accounts/upload_controller.rb +++ b/app/controllers/api/v1/accounts/upload_controller.rb @@ -1,13 +1,68 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController def create - file_blob = ActiveStorage::Blob.create_and_upload!( - key: nil, - io: params[:attachment].tempfile, - filename: params[:attachment].original_filename, - content_type: params[:attachment].content_type - ) - file_blob.save! + result = if params[:attachment].present? + create_from_file + elsif params[:external_url].present? + create_from_url + else + render_error('No file or URL provided', :unprocessable_entity) + end + render_success(result) if result.is_a?(ActiveStorage::Blob) + end + + private + + def create_from_file + attachment = params[:attachment] + create_and_save_blob(attachment.tempfile, attachment.original_filename, attachment.content_type) + end + + def create_from_url + uri = parse_uri(params[:external_url]) + return if performed? + + fetch_and_process_file_from_uri(uri) + end + + def parse_uri(url) + uri = URI.parse(url) + validate_uri(uri) + uri + rescue URI::InvalidURIError, SocketError + render_error('Invalid URL provided', :unprocessable_entity) + nil + end + + def validate_uri(uri) + raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) + end + + def fetch_and_process_file_from_uri(uri) + uri.open do |file| + create_and_save_blob(file, File.basename(uri.path), file.content_type) + end + rescue OpenURI::HTTPError => e + render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity) + rescue SocketError + render_error('Invalid URL provided', :unprocessable_entity) + rescue StandardError + render_error('An unexpected error occurred', :internal_server_error) + end + + def create_and_save_blob(io, filename, content_type) + ActiveStorage::Blob.create_and_upload!( + io: io, + filename: filename, + content_type: content_type + ) + end + + def render_success(file_blob) render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id } end + + def render_error(message, status) + render json: { error: message }, status: status + end end diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 05207664f..8841fe780 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -117,35 +117,41 @@ export default { lastConversationIndex, }; }; - const handlePreviousConversation = () => { - const { allConversations, activeConversationIndex } = - getKeyboardListenerParams(); - if (activeConversationIndex === -1) { - allConversations[0].click(); - } - if (activeConversationIndex >= 1) { - allConversations[activeConversationIndex - 1].click(); - } - }; - const handleNextConversation = () => { + const handleConversationNavigation = direction => { const { allConversations, activeConversationIndex, lastConversationIndex, } = getKeyboardListenerParams(); - if (activeConversationIndex === -1) { - allConversations[lastConversationIndex].click(); - } else if (activeConversationIndex < lastConversationIndex) { - allConversations[activeConversationIndex + 1].click(); + + // Determine the new index based on the direction + const newIndex = + direction === 'previous' + ? activeConversationIndex - 1 + : activeConversationIndex + 1; + + // Check if the new index is within the valid range + if ( + allConversations.length > 0 && + newIndex >= 0 && + newIndex <= lastConversationIndex + ) { + // Click the conversation at the new index + allConversations[newIndex].click(); + } else if (allConversations.length > 0) { + // If the new index is out of range, click the first or last conversation based on the direction + const fallbackIndex = + direction === 'previous' ? 0 : lastConversationIndex; + allConversations[fallbackIndex].click(); } }; const keyboardEvents = { 'Alt+KeyJ': { - action: () => handlePreviousConversation(), + action: () => handleConversationNavigation('previous'), allowOnFocusedInput: true, }, 'Alt+KeyK': { - action: () => handleNextConversation(), + action: () => handleConversationNavigation('next'), allowOnFocusedInput: true, }, }; diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 8fe929354..2180a870d 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -17,6 +17,8 @@ import { BUS_EVENTS } from 'shared/constants/busEvents'; import TagAgents from '../conversation/TagAgents.vue'; import CannedResponse from '../conversation/CannedResponse.vue'; import VariableList from '../conversation/VariableList.vue'; +import KeyboardEmojiSelector from './keyboardEmojiSelector.vue'; + import { appendSignature, removeSignature, @@ -24,6 +26,7 @@ import { scrollCursorIntoView, findNodeToInsertImage, setURLWithQueryAndSize, + getContentNode, } from 'dashboard/helper/editorHelper'; const TYPING_INDICATOR_IDLE_TIME = 4000; @@ -35,10 +38,8 @@ import { } from 'shared/helpers/KeyboardHelpers'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; import { useUISettings } from 'dashboard/composables/useUISettings'; -import { - replaceVariablesInMessage, - createTypingIndicator, -} from '@chatwoot/utils'; + +import { createTypingIndicator } from '@chatwoot/utils'; import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { uploadFile } from 'dashboard/helper/uploadHelper'; @@ -71,7 +72,12 @@ const createState = ( export default { name: 'WootMessageEditor', - components: { TagAgents, CannedResponse, VariableList }, + components: { + TagAgents, + CannedResponse, + VariableList, + KeyboardEmojiSelector, + }, mixins: [keyboardEventListenerMixins], props: { value: { type: String, default: '' }, @@ -119,9 +125,11 @@ export default { showUserMentions: false, showCannedMenu: false, showVariables: false, + showEmojiMenu: false, mentionSearchKey: '', cannedSearchTerm: '', variableSearchTerm: '', + emojiSearchTerm: '', editorView: null, range: null, state: undefined, @@ -169,7 +177,7 @@ export default { this.editorView = args.view; this.range = args.range; - this.mentionSearchKey = args.text.replace('@', ''); + this.mentionSearchKey = args.text; return false; }, @@ -198,7 +206,7 @@ export default { this.editorView = args.view; this.range = args.range; - this.cannedSearchTerm = args.text.replace('/', ''); + this.cannedSearchTerm = args.text; return false; }, onExit: () => { @@ -226,7 +234,7 @@ export default { this.editorView = args.view; this.range = args.range; - this.variableSearchTerm = args.text.replace('{{', ''); + this.variableSearchTerm = args.text; return false; }, onExit: () => { @@ -238,6 +246,31 @@ export default { return event.keyCode === 13 && this.showVariables; }, }), + suggestionsPlugin({ + matcher: triggerCharacters(':', 1), // Trigger after ':' and at least 1 characters + suggestionClass: '', + onEnter: args => { + this.showEmojiMenu = true; + this.emojiSearchTerm = args.text || ''; + this.range = args.range; + this.editorView = args.view; + return false; + }, + onChange: args => { + this.editorView = args.view; + this.range = args.range; + this.emojiSearchTerm = args.text; + return false; + }, + onExit: () => { + this.emojiSearchTerm = ''; + this.showEmojiMenu = false; + return false; + }, + onKeyDown: ({ event }) => { + return event.keyCode === 13 && this.showEmojiMenu; + }, + }), ]; }, sendWithSignature() { @@ -267,6 +300,8 @@ export default { }, editorId() { this.showCannedMenu = false; + this.showEmojiMenu = false; + this.showVariables = false; this.cannedSearchTerm = ''; this.reloadState(this.value); }, @@ -517,57 +552,36 @@ export default { this.editorView.dispatch(tr.setSelection(selection)); this.editorView.focus(); }, - insertMentionNode(mentionItem) { + /** + * Inserts special content (mention, canned response, variable, emoji) into the editor. + * @param {string} type - The type of special content to insert. Possible values: 'mention', 'canned_response', 'variable', 'emoji'. + * @param {Object|string} content - The content to insert, depending on the type. + */ + insertSpecialContent(type, content) { if (!this.editorView) { - return null; - } - const node = this.editorView.state.schema.nodes.mention.create({ - userId: mentionItem.id, - userFullName: mentionItem.name, - }); - - this.insertNodeIntoEditor(node, this.range.from, this.range.to); - this.$track(CONVERSATION_EVENTS.USED_MENTIONS); - - return false; - }, - insertCannedResponse(cannedItem) { - const updatedMessage = replaceVariablesInMessage({ - message: cannedItem, - variables: this.variables, - }); - - if (!this.editorView) { - return null; + return; } - let node = new MessageMarkdownTransformer(messageSchema).parse( - updatedMessage + let { node, from, to } = getContentNode( + this.editorView, + type, + content, + this.range, + this.variables ); - const from = - node.textContent === updatedMessage - ? this.range.from - : this.range.from - 1; - - this.insertNodeIntoEditor(node, from, this.range.to); - - this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE); - return false; - }, - insertVariable(variable) { - if (!this.editorView) { - return null; - } - - const content = `{{${variable}}}`; - let node = this.editorView.state.schema.text(content); - const { from, to } = this.range; + if (!node) return; this.insertNodeIntoEditor(node, from, to); - this.showVariables = false; - this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE); - return false; + + const event_map = { + mention: CONVERSATION_EVENTS.USED_MENTIONS, + cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE, + variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE, + emoji: CONVERSATION_EVENTS.INSERTED_AN_EMOJI, + }; + + this.$track(event_map[type]); }, openFileBrowser() { this.$refs.imageUpload.click(); @@ -687,17 +701,22 @@ export default { + +import { shallowRef, computed, onMounted } from 'vue'; +import emojis from 'shared/components/emoji/emojisGroup.json'; +import MentionBox from '../mentions/MentionBox.vue'; + +const props = defineProps({ + searchKey: { + type: String, + default: '', + }, +}); + +const emit = defineEmits(['click']); + +const allEmojis = shallowRef([]); + +const items = computed(() => { + if (!props.searchKey) return []; + const searchTerm = props.searchKey.toLowerCase(); + return allEmojis.value.filter(emoji => + emoji.searchString.includes(searchTerm) + ); +}); + +function loadEmojis() { + allEmojis.value = emojis.flatMap(group => + group.emojis.map(emoji => ({ + ...emoji, + searchString: `${emoji.slug} ${emoji.name}`.toLowerCase(), + })) + ); +} + +function handleMentionClick(item = {}) { + emit('click', item.emoji); +} + +onMounted(() => { + loadEmojis(); +}); + + + + diff --git a/app/javascript/dashboard/components/widgets/conversation/CannedResponse.vue b/app/javascript/dashboard/components/widgets/conversation/CannedResponse.vue index 1600a9cff..1cdc57a0f 100644 --- a/app/javascript/dashboard/components/widgets/conversation/CannedResponse.vue +++ b/app/javascript/dashboard/components/widgets/conversation/CannedResponse.vue @@ -41,14 +41,11 @@ export default { }; + diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationAdvancedFilter.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationAdvancedFilter.vue index 7bd5fdb13..7de6b1f06 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationAdvancedFilter.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationAdvancedFilter.vue @@ -338,7 +338,11 @@ export default { :show-query-operator="i !== appliedFilters.length - 1" :show-user-input="showUserInput(appliedFilters[i].filter_operator)" grouped-filters - :error-message="validationErrors[`filter_${i}`]" + :error-message=" + validationErrors[`filter_${i}`] + ? $t(`CONTACTS_FILTER.ERRORS.VALUE_REQUIRED`) + : '' + " @resetFilter="resetFilter(i, appliedFilters[i])" @removeFilter="removeFilter(i)" /> diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 25268679a..78ca32771 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -237,6 +237,9 @@ export default { if (this.isASmsInbox) { return MESSAGE_MAX_LENGTH.TWILIO_SMS; } + if (this.isAnEmailChannel) { + return MESSAGE_MAX_LENGTH.EMAIL; + } return MESSAGE_MAX_LENGTH.GENERAL; }, showFileUpload() { diff --git a/app/javascript/dashboard/components/widgets/conversation/VariableList.vue b/app/javascript/dashboard/components/widgets/conversation/VariableList.vue index 66ad8965d..ab9dc57d8 100644 --- a/app/javascript/dashboard/components/widgets/conversation/VariableList.vue +++ b/app/javascript/dashboard/components/widgets/conversation/VariableList.vue @@ -56,20 +56,14 @@ export default { }; +