diff --git a/Gemfile.lock b/Gemfile.lock index 5e2abbac6..2222403f1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,63 +9,63 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (6.1.4.6) - actionpack (= 6.1.4.6) - activesupport (= 6.1.4.6) + actioncable (6.1.4.7) + actionpack (= 6.1.4.7) + activesupport (= 6.1.4.7) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.4.6) - actionpack (= 6.1.4.6) - activejob (= 6.1.4.6) - activerecord (= 6.1.4.6) - activestorage (= 6.1.4.6) - activesupport (= 6.1.4.6) + actionmailbox (6.1.4.7) + actionpack (= 6.1.4.7) + activejob (= 6.1.4.7) + activerecord (= 6.1.4.7) + activestorage (= 6.1.4.7) + activesupport (= 6.1.4.7) mail (>= 2.7.1) - actionmailer (6.1.4.6) - actionpack (= 6.1.4.6) - actionview (= 6.1.4.6) - activejob (= 6.1.4.6) - activesupport (= 6.1.4.6) + actionmailer (6.1.4.7) + actionpack (= 6.1.4.7) + actionview (= 6.1.4.7) + activejob (= 6.1.4.7) + activesupport (= 6.1.4.7) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.1.4.6) - actionview (= 6.1.4.6) - activesupport (= 6.1.4.6) + actionpack (6.1.4.7) + actionview (= 6.1.4.7) + activesupport (= 6.1.4.7) rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.4.6) - actionpack (= 6.1.4.6) - activerecord (= 6.1.4.6) - activestorage (= 6.1.4.6) - activesupport (= 6.1.4.6) + actiontext (6.1.4.7) + actionpack (= 6.1.4.7) + activerecord (= 6.1.4.7) + activestorage (= 6.1.4.7) + activesupport (= 6.1.4.7) nokogiri (>= 1.8.5) - actionview (6.1.4.6) - activesupport (= 6.1.4.6) + actionview (6.1.4.7) + activesupport (= 6.1.4.7) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) active_record_query_trace (1.8) - activejob (6.1.4.6) - activesupport (= 6.1.4.6) + activejob (6.1.4.7) + activesupport (= 6.1.4.7) globalid (>= 0.3.6) - activemodel (6.1.4.6) - activesupport (= 6.1.4.6) - activerecord (6.1.4.6) - activemodel (= 6.1.4.6) - activesupport (= 6.1.4.6) + activemodel (6.1.4.7) + activesupport (= 6.1.4.7) + activerecord (6.1.4.7) + activemodel (= 6.1.4.7) + activesupport (= 6.1.4.7) activerecord-import (1.3.0) activerecord (>= 4.2) - activestorage (6.1.4.6) - actionpack (= 6.1.4.6) - activejob (= 6.1.4.6) - activerecord (= 6.1.4.6) - activesupport (= 6.1.4.6) + activestorage (6.1.4.7) + actionpack (= 6.1.4.7) + activejob (= 6.1.4.7) + activerecord (= 6.1.4.7) + activesupport (= 6.1.4.7) marcel (~> 1.0.0) mini_mime (>= 1.1.0) - activesupport (6.1.4.6) + activesupport (6.1.4.7) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -135,7 +135,7 @@ GEM byebug (11.1.3) climate_control (1.0.1) coderay (1.1.3) - commonmarker (0.23.2) + commonmarker (0.23.4) concurrent-ruby (1.1.9) connection_pool (2.2.5) crack (0.4.5) @@ -303,7 +303,7 @@ GEM httpclient (2.8.3) i18n (1.10.0) concurrent-ruby (~> 1.0) - image_processing (1.12.1) + image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) jbuilder (2.11.5) @@ -419,29 +419,29 @@ GEM rack-test (1.1.0) rack (>= 1.0, < 3) rack-timeout (0.6.0) - rails (6.1.4.6) - actioncable (= 6.1.4.6) - actionmailbox (= 6.1.4.6) - actionmailer (= 6.1.4.6) - actionpack (= 6.1.4.6) - actiontext (= 6.1.4.6) - actionview (= 6.1.4.6) - activejob (= 6.1.4.6) - activemodel (= 6.1.4.6) - activerecord (= 6.1.4.6) - activestorage (= 6.1.4.6) - activesupport (= 6.1.4.6) + rails (6.1.4.7) + actioncable (= 6.1.4.7) + actionmailbox (= 6.1.4.7) + actionmailer (= 6.1.4.7) + actionpack (= 6.1.4.7) + actiontext (= 6.1.4.7) + actionview (= 6.1.4.7) + activejob (= 6.1.4.7) + activemodel (= 6.1.4.7) + activerecord (= 6.1.4.7) + activestorage (= 6.1.4.7) + activesupport (= 6.1.4.7) bundler (>= 1.15.0) - railties (= 6.1.4.6) + railties (= 6.1.4.7) sprockets-rails (>= 2.0.0) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) rails-html-sanitizer (1.4.2) loofah (~> 2.3) - railties (6.1.4.6) - actionpack (= 6.1.4.6) - activesupport (= 6.1.4.6) + railties (6.1.4.7) + actionpack (= 6.1.4.7) + activesupport (= 6.1.4.7) method_source rake (>= 0.13) thor (~> 1.0) @@ -574,7 +574,7 @@ GEM spring-watcher-listen (2.0.1) listen (>= 2.7, < 4.0) spring (>= 1.2, < 3.0) - sprockets (4.0.2) + sprockets (4.0.3) concurrent-ruby (~> 1.0) rack (> 1, < 3) sprockets-rails (3.4.2) @@ -751,4 +751,4 @@ RUBY VERSION ruby 3.0.2p107 BUNDLED WITH - 2.2.25 + 2.3.8 diff --git a/app/actions/contact_identify_action.rb b/app/actions/contact_identify_action.rb index 655c6bc1c..746d1bfec 100644 --- a/app/actions/contact_identify_action.rb +++ b/app/actions/contact_identify_action.rb @@ -52,12 +52,11 @@ class ContactIdentifyAction end def update_contact - custom_attributes = params[:custom_attributes] ? @contact.custom_attributes.merge(params[:custom_attributes]) : @contact.custom_attributes # blank identifier or email will throw unique index error # TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded @contact.update!(params.slice(:name, :email, :identifier, :phone_number).reject do |_k, v| v.blank? - end.merge({ custom_attributes: custom_attributes })) + end.merge({ custom_attributes: custom_attributes, additional_attributes: additional_attributes })) ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? end @@ -68,4 +67,16 @@ class ContactIdentifyAction mergee_contact: merge_contact ).perform end + + def custom_attributes + params[:custom_attributes] ? @contact.custom_attributes.deep_merge(params[:custom_attributes].stringify_keys) : @contact.custom_attributes + end + + def additional_attributes + if params[:additional_attributes] + @contact.additional_attributes.deep_merge(params[:additional_attributes].stringify_keys) + else + @contact.additional_attributes + end + end end diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index 08aa58be0..2e58825f4 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -1,10 +1,14 @@ class Messages::Messenger::MessageBuilder + include ::FileTypeHelper + def process_attachment(attachment) return if attachment['type'].to_sym == :template attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url)) attachment_obj.save! attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url] + fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention' + update_attachment_file_type(attachment_obj) end def attach_file(attachment, file_url) @@ -22,7 +26,7 @@ class Messages::Messenger::MessageBuilder file_type = attachment['type'].to_sym params = { file_type: file_type, account_id: @message.account_id } - if [:image, :file, :audio, :video].include? file_type + if [:image, :file, :audio, :video, :share, :story_mention].include? file_type params.merge!(file_type_params(attachment)) elsif file_type == :location params.merge!(location_params(attachment)) @@ -39,4 +43,31 @@ class Messages::Messenger::MessageBuilder remote_file_url: attachment['payload']['url'] } end + + def update_attachment_file_type(attachment) + return unless attachment.file_type == 'share' || attachment.file_type == 'story_mention' + + attachment.file_type = file_type(attachment.file&.content_type) + attachment.save! + end + + def fetch_story_link(attachment) + message = attachment.message + begin + k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? + result = k.get_object(message.source_id, fields: %w[story from]) || {} + rescue Koala::Facebook::AuthenticationError + @inbox.channel.authorization_error! + raise + rescue StandardError => e + result = {} + Sentry.capture_exception(e) + end + story_id = result['story']['mention']['id'] + story_sender = result['from']['username'] + message.content_attributes[:story_sender] = story_sender + message.content_attributes[:story_id] = story_id + message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender) + message.save! + end end diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index 7f0dd470c..1f17dbb08 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -95,15 +95,15 @@ class V2::ReportBuilder end def avg_first_response_time - (get_grouped_values scope.events.where(name: 'first_response')).average(:value) + (get_grouped_values scope.reporting_events.where(name: 'first_response')).average(:value) end def avg_resolution_time - (get_grouped_values scope.events.where(name: 'conversation_resolved')).average(:value) + (get_grouped_values scope.reporting_events.where(name: 'conversation_resolved')).average(:value) end def avg_resolution_time_summary - avg_rt = scope.events + avg_rt = scope.reporting_events .where(name: 'conversation_resolved', created_at: range) .average(:value) @@ -113,7 +113,7 @@ class V2::ReportBuilder end def avg_first_response_time_summary - avg_frt = scope.events + avg_frt = scope.reporting_events .where(name: 'first_response', created_at: range) .average(:value) diff --git a/app/channels/room_channel.rb b/app/channels/room_channel.rb index b3c597770..05bff5d86 100644 --- a/app/channels/room_channel.rb +++ b/app/channels/room_channel.rb @@ -1,5 +1,8 @@ class RoomChannel < ApplicationCable::Channel def subscribed + # TODO: should we only do ensure stream if current account is present? + # for now going ahead with guard clauses in update_subscription and broadcast_presence + ensure_stream current_user current_account @@ -15,6 +18,8 @@ class RoomChannel < ApplicationCable::Channel private def broadcast_presence + return if @current_account.blank? + data = { account_id: @current_account.id, users: ::OnlineStatusTracker.get_available_users(@current_account.id) } data[:contacts] = ::OnlineStatusTracker.get_available_contacts(@current_account.id) if @current_user.is_a? User ActionCable.server.broadcast(@pubsub_token, { event: 'presence.update', data: data }) @@ -26,6 +31,8 @@ class RoomChannel < ApplicationCable::Channel end def update_subscription + return if @current_account.blank? + ::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id) end diff --git a/app/controllers/api/v1/accounts/automation_rules_controller.rb b/app/controllers/api/v1/accounts/automation_rules_controller.rb index 12b7b5957..2dc71bcec 100644 --- a/app/controllers/api/v1/accounts/automation_rules_controller.rb +++ b/app/controllers/api/v1/accounts/automation_rules_controller.rb @@ -3,7 +3,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone] def index - @automation_rules = Current.account.automation_rules.active + @automation_rules = Current.account.automation_rules end def create @@ -32,7 +32,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont def automation_rules_permit params.permit( - :name, :description, :event_name, :account_id, + :name, :description, :event_name, :account_id, :active, conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }], actions: [:action_name, { action_params: [] }] ) diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb index ffd00461b..77a3a7081 100644 --- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb @@ -13,7 +13,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: def destroy ActiveRecord::Base.transaction do - message.update!(content: I18n.t('conversations.messages.deleted'), deleted: true) + message.update!(content: I18n.t('conversations.messages.deleted'), content_attributes: { deleted: true }) message.attachments.destroy_all end end diff --git a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb index 26de6d406..347f028f7 100644 --- a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb +++ b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb @@ -30,8 +30,7 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base def set_csat_survey_responses @csat_survey_responses = filtrate( Current.account.csat_survey_responses.includes([:conversation, :assigned_agent, :contact]) - ) - @csat_survey_responses = @csat_survey_responses.where(created_at: range) if range.present? + ).filter_by_created_at(range).filter_by_assigned_agent_id(params[:user_ids]) end def set_current_page_surveys diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb index 38c880526..0a50835bc 100644 --- a/app/controllers/api/v1/widget/base_controller.rb +++ b/app/controllers/api/v1/widget/base_controller.rb @@ -34,6 +34,8 @@ class Api::V1::Widget::BaseController < ApplicationController ) @contact = @contact_inbox&.contact raise ActiveRecord::RecordNotFound unless @contact + + Current.contact = @contact end def create_conversation diff --git a/app/controllers/api/v1/widget/contacts_controller.rb b/app/controllers/api/v1/widget/contacts_controller.rb index d745c4153..fbc303a4f 100644 --- a/app/controllers/api/v1/widget/contacts_controller.rb +++ b/app/controllers/api/v1/widget/contacts_controller.rb @@ -46,6 +46,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController end def permitted_params - params.permit(:website_token, :identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {}) + params.permit(:website_token, :identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {}, + additional_attributes: {}) end end diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index 8d28345d4..e2ee61424 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -44,6 +44,15 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController head :ok end + def toggle_status + head :not_found && return if conversation.nil? + unless conversation.resolved? + conversation.status = 'resolved' + conversation.save + end + head :ok + end + private def trigger_typing_event(event) diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index a83611676..efa09a43c 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -41,12 +41,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController raise Pundit::NotAuthorizedError unless Current.account_user.administrator? end - def summary_params + def current_summary_params { type: params[:type].to_sym, - since: params[:since], - until: params[:until], id: params[:id], + since: range[:current][:since], + until: range[:current][:until], + group_by: params[:group_by] + } + end + + def previous_summary_params + { + type: params[:type].to_sym, + id: params[:id], + since: range[:previous][:since], + until: range[:previous][:until], group_by: params[:group_by] } end @@ -63,8 +73,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController } end + def range + { + current: { + since: params[:since], + until: params[:until] + }, + previous: { + since: (params[:since].to_i - (params[:until].to_i - params[:since].to_i)).to_s, + until: params[:since] + } + } + end + def summary_metrics - builder = V2::ReportBuilder.new(Current.account, summary_params) - builder.summary + summary = V2::ReportBuilder.new(Current.account, current_summary_params).summary + summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary + summary end end diff --git a/app/dispatchers/async_dispatcher.rb b/app/dispatchers/async_dispatcher.rb index 3f95405f9..f2f238cd4 100644 --- a/app/dispatchers/async_dispatcher.rb +++ b/app/dispatchers/async_dispatcher.rb @@ -12,10 +12,10 @@ class AsyncDispatcher < BaseDispatcher [ CampaignListener.instance, CsatSurveyListener.instance, - EventListener.instance, HookListener.instance, InstallationWebhookListener.instance, NotificationListener.instance, + ReportingEventListener.instance, WebhookListener.instance, AutomationRuleListener.instance ] diff --git a/app/helpers/api/v1/reports_helper.rb b/app/helpers/api/v1/reports_helper.rb deleted file mode 100644 index 308bab3e6..000000000 --- a/app/helpers/api/v1/reports_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module Api::V1::ReportsHelper -end diff --git a/app/helpers/file_type_helper.rb b/app/helpers/file_type_helper.rb index 503c7f25b..db3f6249d 100644 --- a/app/helpers/file_type_helper.rb +++ b/app/helpers/file_type_helper.rb @@ -3,7 +3,7 @@ module FileTypeHelper def file_type(content_type) return :image if image_file?(content_type) return :video if video_file?(content_type) - return :audio if content_type.include?('audio/') + return :audio if content_type&.include?('audio/') :file end @@ -14,7 +14,8 @@ module FileTypeHelper 'image/png', 'image/gif', 'image/bmp', - 'image/webp' + 'image/webp', + 'image' ].include?(content_type) end @@ -23,7 +24,8 @@ module FileTypeHelper 'video/ogg', 'video/mp4', 'video/webm', - 'video/quicktime' + 'video/quicktime', + 'video' ].include?(content_type) end end diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 502d8bda1..2c91de16f 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -1,5 +1,6 @@ diff --git a/app/javascript/dashboard/components/index.js b/app/javascript/dashboard/components/index.js index f737bd313..d2614421c 100644 --- a/app/javascript/dashboard/components/index.js +++ b/app/javascript/dashboard/components/index.js @@ -21,6 +21,7 @@ import SubmitButton from './buttons/FormSubmitButton'; import Tabs from './ui/Tabs/Tabs'; import TabsItem from './ui/Tabs/TabsItem'; import Thumbnail from './widgets/Thumbnail.vue'; +import ConfirmModal from './widgets/modal/ConfirmationModal.vue'; const WootUIKit = { AvatarUploader, @@ -45,6 +46,7 @@ const WootUIKit = { Tabs, TabsItem, Thumbnail, + ConfirmModal, install(Vue) { const keys = Object.keys(this); keys.pop(); // remove 'install' from keys diff --git a/app/javascript/dashboard/components/layout/Sidebar.vue b/app/javascript/dashboard/components/layout/Sidebar.vue index ee64f4949..826d766df 100644 --- a/app/javascript/dashboard/components/layout/Sidebar.vue +++ b/app/javascript/dashboard/components/layout/Sidebar.vue @@ -19,23 +19,6 @@ :current-role="currentRole" @add-label="showAddLabelPopup" /> - - - - - - @@ -46,12 +29,8 @@ import adminMixin from '../../mixins/isAdmin'; import { getSidebarItems } from './config/default-sidebar'; import alertMixin from 'shared/mixins/alertMixin'; -import AccountSelector from './sidebarComponents/AccountSelector.vue'; -import AddAccountModal from './sidebarComponents/AddAccountModal.vue'; -import AddLabelModal from '../../routes/dashboard/settings/labels/AddLabel'; import PrimarySidebar from './sidebarComponents/Primary'; import SecondarySidebar from './sidebarComponents/Secondary'; -import WootKeyShortcutModal from 'components/widgets/modal/WootKeyShortcutModal'; import { hasPressedAltAndCKey, hasPressedAltAndRKey, @@ -65,21 +44,13 @@ import router from '../../routes'; export default { components: { - AccountSelector, - AddAccountModal, - AddLabelModal, PrimarySidebar, SecondarySidebar, - WootKeyShortcutModal, }, mixins: [adminMixin, alertMixin, eventListenerMixins], data() { return { showOptionsMenu: false, - showAccountModal: false, - showCreateAccountModal: false, - showAddLabelModal: false, - showShortcutModal: false, }; }, @@ -162,10 +133,10 @@ export default { } }, toggleKeyShortcutModal() { - this.showShortcutModal = true; + this.$emit('open-key-shortcut-modal'); }, closeKeyShortcutModal() { - this.showShortcutModal = false; + this.$emit('close-key-shortcut-modal'); }, handleKeyEvents(e) { if (hasPressedCommandAndForwardSlash(e)) { @@ -200,20 +171,10 @@ export default { window.$chatwoot.toggle(); }, toggleAccountModal() { - this.showAccountModal = !this.showAccountModal; - }, - openCreateAccountModal() { - this.showAccountModal = false; - this.showCreateAccountModal = true; - }, - closeCreateAccountModal() { - this.showCreateAccountModal = false; + this.$emit('toggle-account-modal'); }, showAddLabelPopup() { - this.showAddLabelModal = true; - }, - hideAddLabelPopup() { - this.showAddLabelModal = false; + this.$emit('show-add-label-popup'); }, }, }; @@ -223,6 +184,8 @@ export default { .woot-sidebar { background: var(--white); display: flex; + min-height: 0; + height: 100%; } diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/Primary.vue b/app/javascript/dashboard/components/layout/sidebarComponents/Primary.vue index 0f568ab75..002f371bf 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/Primary.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/Primary.vue @@ -93,7 +93,7 @@ export default { width: var(--space-jumbo); border-right: 1px solid var(--s-50); box-sizing: content-box; - height: 100vh; + height: 100%; flex-shrink: 0; } diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue b/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue index 6222490e4..1f98305a1 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue @@ -225,7 +225,7 @@ export default { .secondary-menu { background: var(--white); border-right: 1px solid var(--s-50); - height: 100vh; + height: 100%; width: 19rem; flex-shrink: 0; overflow: hidden; diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue index 3a344d985..ffd8018d5 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue @@ -20,7 +20,8 @@ data-view-component="true" label="Beta" class="beta" - >Beta + > + {{ $t('SIDEBAR.BETA') }} @@ -233,7 +234,7 @@ export default { padding-left: var(--space-smaller) !important; margin-left: var(--space-half) !important; display: inline-block; - font-size: var(--font-size-mini); + font-size: var(--font-size-micro); font-weight: var(--font-weight-medium); line-height: 18px; border: 1px solid transparent; diff --git a/app/javascript/dashboard/components/ui/Banner.vue b/app/javascript/dashboard/components/ui/Banner.vue index 52d7c5740..c9d71bfaf 100644 --- a/app/javascript/dashboard/components/ui/Banner.vue +++ b/app/javascript/dashboard/components/ui/Banner.vue @@ -1,6 +1,6 @@ + diff --git a/app/javascript/dashboard/helper/URLHelper.js b/app/javascript/dashboard/helper/URLHelper.js index e229ce7e4..583a99c7c 100644 --- a/app/javascript/dashboard/helper/URLHelper.js +++ b/app/javascript/dashboard/helper/URLHelper.js @@ -1,10 +1,22 @@ import queryString from 'query-string'; +import { DEFAULT_REDIRECT_URL } from '../constants'; export const frontendURL = (path, params) => { const stringifiedParams = params ? `?${queryString.stringify(params)}` : ''; return `/app/${path}${stringifiedParams}`; }; +export const getLoginRedirectURL = (ssoAccountId, user) => { + const { accounts = [] } = user || {}; + const ssoAccount = accounts.find( + account => account.id === Number(ssoAccountId) + ); + if (ssoAccount) { + return frontendURL(`accounts/${ssoAccountId}/dashboard`); + } + return DEFAULT_REDIRECT_URL; +}; + export const conversationUrl = ({ accountId, activeInbox, diff --git a/app/javascript/dashboard/helper/localStorage.js b/app/javascript/dashboard/helper/localStorage.js new file mode 100644 index 000000000..cc963aa48 --- /dev/null +++ b/app/javascript/dashboard/helper/localStorage.js @@ -0,0 +1,17 @@ +class LocalStorage { + constructor(key) { + this.key = key; + } + + store(allItems) { + localStorage.setItem(this.key, JSON.stringify(allItems)); + localStorage.setItem(this.key + ':ts', Date.now()); + } + + get() { + let stored = localStorage.getItem(this.key); + return JSON.parse(stored) || []; + } +} + +export default LocalStorage; diff --git a/app/javascript/dashboard/helper/specs/URLHelper.spec.js b/app/javascript/dashboard/helper/specs/URLHelper.spec.js index ade735642..832d90577 100644 --- a/app/javascript/dashboard/helper/specs/URLHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/URLHelper.spec.js @@ -3,6 +3,7 @@ import { conversationUrl, accountIdFromPathname, isValidURL, + getLoginRedirectURL, } from '../URLHelper'; describe('#URL Helpers', () => { @@ -58,4 +59,24 @@ describe('#URL Helpers', () => { expect(isValidURL('alert.window')).toBe(false); }); }); + + describe('getLoginRedirectURL', () => { + it('should return correct Account URL if account id is present', () => { + expect( + getLoginRedirectURL('7500', { + accounts: [{ id: 7500, name: 'Test Account 7500' }], + }) + ).toBe('/app/accounts/7500/dashboard'); + }); + + it('should return default URL if account id is not present', () => { + expect(getLoginRedirectURL('7500', {})).toBe('/app/'); + expect( + getLoginRedirectURL('7500', { + accounts: [{ id: '7501', name: 'Test Account 7501' }], + }) + ).toBe('/app/'); + expect(getLoginRedirectURL('7500', null)).toBe('/app/'); + }); + }); }); diff --git a/app/javascript/dashboard/i18n/locale/en/advancedFilters.json b/app/javascript/dashboard/i18n/locale/en/advancedFilters.json index f00973f29..46a573c42 100644 --- a/app/javascript/dashboard/i18n/locale/en/advancedFilters.json +++ b/app/javascript/dashboard/i18n/locale/en/advancedFilters.json @@ -21,7 +21,12 @@ "is_present": "Is present", "is_not_present": "Is not present", "is_greater_than": "Is greater than", - "is_lesser_than": "Is lesser than" + "is_less_than": "Is lesser than", + "days_before": "Is x days before" + }, + "ATTRIBUTE_LABELS": { + "TRUE": "True", + "FALSE": "False" }, "ATTRIBUTES": { "STATUS": "Status", @@ -38,7 +43,9 @@ "CUSTOM_ATTRIBUTE_TEXT": "Text", "CUSTOM_ATTRIBUTE_NUMBER": "Number", "CUSTOM_ATTRIBUTE_LINK": "Link", - "CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox" + "CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox", + "CREATED_AT": "Created At", + "LAST_ACTIVITY": "Last Activity" }, "GROUPS": { "STANDARD_FILTERS": "Standard Filters", diff --git a/app/javascript/dashboard/i18n/locale/en/automation.json b/app/javascript/dashboard/i18n/locale/en/automation.json index ce95b861c..8c92467bd 100644 --- a/app/javascript/dashboard/i18n/locale/en/automation.json +++ b/app/javascript/dashboard/i18n/locale/en/automation.json @@ -90,6 +90,18 @@ }, "ACTION": { "DELETE_MESSAGE": "You need to have atleast one action to save" + }, + "TOGGLE": { + "ACTIVATION_TITLE": "Activate Automation Rule", + "DEACTIVATION_TITLE": "Deactivate Automation Rule", + "ACTIVATION_DESCRIPTION": "This action will activate the automation rule '{automationName}'. Are you sure you want to proceed?", + "DEACTIVATION_DESCRIPTION": "This action will deactivate the automation rule '{automationName}'. Are you sure you want to proceed?", + "ACTIVATION_SUCCESFUL": "Automation Rule Activated Successfully", + "DEACTIVATION_SUCCESFUL": "Automation Rule Deactivated Successfully", + "ACTIVATION_ERROR": "Could not Activate Automation, Please try again later", + "DEACTIVATION_ERROR": "Could not Deactivate Automation, Please try again later", + "CONFIRMATION_LABEL": "Yes", + "CANCEL_LABEL": "No" } } } diff --git a/app/javascript/dashboard/i18n/locale/en/chatlist.json b/app/javascript/dashboard/i18n/locale/en/chatlist.json index c1ff81c2d..ccff2c33b 100644 --- a/app/javascript/dashboard/i18n/locale/en/chatlist.json +++ b/app/javascript/dashboard/i18n/locale/en/chatlist.json @@ -76,6 +76,7 @@ "RECEIVED_VIA_EMAIL": "Received via email", "VIEW_TWEET_IN_TWITTER": "View tweet in Twitter", "REPLY_TO_TWEET": "Reply to this tweet", + "LINK_TO_STORY": "Go to instagram story", "SENT": "Sent successfully", "NO_MESSAGES": "No Messages", "NO_CONTENT": "No content available", diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index 3824d9593..037f6f769 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -77,9 +77,8 @@ "CONFIRM": { "TITLE": "Confirm Deletion", "MESSAGE": "Are you sure to delete ", - "PLACE_HOLDER": "Please type {contactName} to confirm", - "YES": "Yes, Delete ", - "NO": "No, Keep " + "YES": "Yes, Delete", + "NO": "No, Keep" }, "API": { "SUCCESS_MESSAGE": "Contact deleted successfully", diff --git a/app/javascript/dashboard/i18n/locale/en/contactFilters.json b/app/javascript/dashboard/i18n/locale/en/contactFilters.json index a752d537c..bd58ccf3e 100644 --- a/app/javascript/dashboard/i18n/locale/en/contactFilters.json +++ b/app/javascript/dashboard/i18n/locale/en/contactFilters.json @@ -22,7 +22,8 @@ "is_present": "Is present", "is_not_present": "Is not present", "is_greater_than": "Is greater than", - "is_lesser_than": "Is lesser than" + "is_lesser_than": "Is lesser than", + "days_before": "Is x days before" }, "ATTRIBUTES": { "NAME": "Name", @@ -35,7 +36,9 @@ "CUSTOM_ATTRIBUTE_TEXT": "Text", "CUSTOM_ATTRIBUTE_NUMBER": "Number", "CUSTOM_ATTRIBUTE_LINK": "Link", - "CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox" + "CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox", + "CREATED_AT": "Created At", + "LAST_ACTIVITY": "Last Activity" }, "GROUPS": { "STANDARD_FILTERS": "Standard Filters", diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 96ba4b429..ac25a5aed 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -74,8 +74,14 @@ "TIP_FORMAT_ICON": "Show rich text editor", "TIP_EMOJI_ICON": "Show emoji selector", "TIP_ATTACH_ICON": "Attach files", + "TIP_AUDIORECORDER_ICON": "Record audio", + "TIP_AUDIORECORDER_PERMISSION": "Allow access to audio", + "TIP_AUDIORECORDER_ERROR": "Could not open the audio", "ENTER_TO_SEND": "Enter to send", "DRAG_DROP": "Drag and drop here to attach", + "START_AUDIO_RECORDING": "Start audio recording", + "STOP_AUDIO_RECORDING": "Stop audio recording", + "": "", "EMAIL_HEAD": { "ADD_BCC": "Add bcc", "CC": { diff --git a/app/javascript/dashboard/i18n/locale/en/generalSettings.json b/app/javascript/dashboard/i18n/locale/en/generalSettings.json index f365a3248..c22d9ee3f 100644 --- a/app/javascript/dashboard/i18n/locale/en/generalSettings.json +++ b/app/javascript/dashboard/i18n/locale/en/generalSettings.json @@ -47,7 +47,8 @@ "CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now." } }, - "UPDATE_CHATWOOT": "An update %{latestChatwootVersion} for Chatwoot is available. Please update your instance." + "UPDATE_CHATWOOT": "An update %{latestChatwootVersion} for Chatwoot is available. Please update your instance.", + "LEARN_MORE":"Learn more" }, "FORMS": { "MULTISELECT": { diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index 229c7b068..053c730e1 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -333,6 +333,11 @@ "CSAT_REPORTS": { "HEADER": "CSAT Reports", "NO_RECORDS": "There are no CSAT survey responses available.", + "FILTERS": { + "AGENTS": { + "PLACEHOLDER": "Choose Agents" + } + }, "TABLE": { "HEADER": { "CONTACT_NAME": "Contact", diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index d353e6b6e..5727e09d0 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -178,7 +178,8 @@ "REPORTS_LABEL": "Labels", "REPORTS_INBOX": "Inbox", "REPORTS_TEAM": "Team", - "SET_AVAILABILITY_TITLE": "Set yourself as" + "SET_AVAILABILITY_TITLE": "Set yourself as", + "BETA": "Beta" }, "CREATE_ACCOUNT": { "NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.", diff --git a/app/javascript/dashboard/mixins/reportMixin.js b/app/javascript/dashboard/mixins/reportMixin.js new file mode 100644 index 000000000..8ef7fdfe5 --- /dev/null +++ b/app/javascript/dashboard/mixins/reportMixin.js @@ -0,0 +1,33 @@ +import { mapGetters } from 'vuex'; +import { formatTime } from '@chatwoot/utils'; + +export default { + computed: { + ...mapGetters({ + accountSummary: 'getAccountSummary', + }), + calculateTrend() { + return metric_key => { + if (!this.accountSummary.previous[metric_key]) return 0; + return Math.round( + ((this.accountSummary[metric_key] - + this.accountSummary.previous[metric_key]) / + this.accountSummary.previous[metric_key]) * + 100 + ); + }; + }, + displayMetric() { + return metric_key => { + if ( + ['avg_first_response_time', 'avg_resolution_time'].includes( + metric_key + ) + ) { + return formatTime(this.accountSummary[metric_key]); + } + return this.accountSummary[metric_key]; + }; + }, + }, +}; diff --git a/app/javascript/dashboard/mixins/specs/reportMixin.spec.js b/app/javascript/dashboard/mixins/specs/reportMixin.spec.js new file mode 100644 index 000000000..003295f95 --- /dev/null +++ b/app/javascript/dashboard/mixins/specs/reportMixin.spec.js @@ -0,0 +1,41 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import reportMixin from '../reportMixin'; +import reportFixtures from './reportMixinFixtures'; +import Vuex from 'vuex'; +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('reportMixin', () => { + let getters; + let store; + beforeEach(() => { + getters = { + getAccountSummary: () => reportFixtures.summary, + }; + store = new Vuex.Store({ getters }); + }); + + it('display the metric', () => { + const Component = { + render() {}, + title: 'TestComponent', + mixins: [reportMixin], + }; + const wrapper = shallowMount(Component, { store, localVue }); + expect(wrapper.vm.displayMetric('conversations_count')).toEqual(5); + expect(wrapper.vm.displayMetric('avg_first_response_time')).toEqual( + '3 Min' + ); + }); + + it('calculate the trend', () => { + const Component = { + render() {}, + title: 'TestComponent', + mixins: [reportMixin], + }; + const wrapper = shallowMount(Component, { store, localVue }); + expect(wrapper.vm.calculateTrend('conversations_count')).toEqual(25); + expect(wrapper.vm.calculateTrend('resolutions_count')).toEqual(0); + }); +}); diff --git a/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js b/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js new file mode 100644 index 000000000..5c8315ab1 --- /dev/null +++ b/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js @@ -0,0 +1,18 @@ +export default { + summary: { + avg_first_response_time: '198.6666666666667', + avg_resolution_time: '208.3333333333333', + conversations_count: 5, + incoming_messages_count: 5, + outgoing_messages_count: 3, + previous: { + avg_first_response_time: '89.0', + avg_resolution_time: '145.0', + conversations_count: 4, + incoming_messages_count: 5, + outgoing_messages_count: 4, + resolutions_count: 0, + }, + resolutions_count: 3, + }, +}; diff --git a/app/javascript/dashboard/routes/dashboard/Dashboard.vue b/app/javascript/dashboard/routes/dashboard/Dashboard.vue index 670c93d5d..11903c9ed 100644 --- a/app/javascript/dashboard/routes/dashboard/Dashboard.vue +++ b/app/javascript/dashboard/routes/dashboard/Dashboard.vue @@ -1,9 +1,33 @@ @@ -12,16 +36,28 @@ import Sidebar from '../../components/layout/Sidebar'; import CommandBar from './commands/commandbar.vue'; import { BUS_EVENTS } from 'shared/constants/busEvents'; +import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal'; +import AddAccountModal from 'dashboard/components/layout/sidebarComponents/AddAccountModal'; +import AccountSelector from 'dashboard/components/layout/sidebarComponents/AccountSelector'; +import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue'; export default { components: { Sidebar, CommandBar, + WootKeyShortcutModal, + AddAccountModal, + AccountSelector, + AddLabelModal, }, data() { return { isSidebarOpen: false, isOnDesktop: true, + showAccountModal: false, + showCreateAccountModal: false, + showAddLabelModal: false, + showShortcutModal: false, }; }, computed: { @@ -68,6 +104,28 @@ export default { toggleSidebar() { this.isSidebarOpen = !this.isSidebarOpen; }, + openCreateAccountModal() { + this.showAccountModal = false; + this.showCreateAccountModal = true; + }, + closeCreateAccountModal() { + this.showCreateAccountModal = false; + }, + toggleAccountModal() { + this.showAccountModal = !this.showAccountModal; + }, + toggleKeyShortcutModal() { + this.showShortcutModal = true; + }, + closeKeyShortcutModal() { + this.showShortcutModal = false; + }, + showAddLabelPopup() { + this.showAddLabelModal = true; + }, + hideAddLabelPopup() { + this.showAddLabelModal = false; + }, }, }; diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue index 04ba42f33..95c9ae61c 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue @@ -11,7 +11,12 @@ v-model="appliedFilters[i]" :filter-groups="filterGroups" :grouped-filters="true" - :input-type="getInputType(appliedFilters[i].attribute_key)" + :input-type=" + getInputType( + appliedFilters[i].attribute_key, + appliedFilters[i].filter_operator + ) + " :operators="getOperators(appliedFilters[i].attribute_key)" :dropdown-values="getDropdownValues(appliedFilters[i].attribute_key)" :show-query-operator="i !== appliedFilters.length - 1" @@ -86,6 +91,12 @@ export default { $each: { values: { required, + ensureBetween0to999(value, prop) { + if (prop.filter_operator === 'days_before') { + return parseInt(value, 10) > 0 && parseInt(value, 10) < 999; + } + return true; + }, }, }, }, @@ -147,6 +158,12 @@ export default { switch (key) { case 'date': return 'date'; + case 'text': + return 'plain_text'; + case 'list': + return 'search_select'; + case 'checkbox': + return 'search_select'; default: return 'plain_text'; } @@ -155,7 +172,9 @@ export default { const type = this.filterTypes.find(filter => filter.attributeKey === key); return type.attributeModel; }, - getInputType(key) { + getInputType(key, operator) { + if (key === 'created_at' || key === 'last_activity_at') + if (operator === 'days_before') return 'plain_text'; const type = this.filterTypes.find(filter => filter.attributeKey === key); return type.inputType; }, @@ -164,6 +183,44 @@ export default { return type.filterOperators; }, getDropdownValues(type) { + const allCustomAttributes = this.$store.getters[ + 'attributes/getAttributesByModel' + ](this.attributeModel); + const isCustomAttributeCheckbox = allCustomAttributes.find(attr => { + return ( + attr.attribute_key === type && + attr.attribute_display_type === 'checkbox' + ); + }); + if (isCustomAttributeCheckbox) { + return [ + { + id: true, + name: this.$t('FILTER.ATTRIBUTE_LABELS.TRUE'), + }, + { + id: false, + name: this.$t('FILTER.ATTRIBUTE_LABELS.FALSE'), + }, + ]; + } + + const isCustomAttributeList = allCustomAttributes.find(attr => { + return ( + attr.attribute_key === type && attr.attribute_display_type === 'list' + ); + }); + + if (isCustomAttributeList) { + return allCustomAttributes + .find(attr => attr.attribute_key === type) + .attribute_values.map(item => { + return { + id: item, + name: item, + }; + }); + } switch (type) { case 'country_code': return countries; diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue index 7536c0d24..dbac2f0bc 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue @@ -253,7 +253,7 @@ export default { this.$store.dispatch('contacts/get', requestParams); } else { this.$store.dispatch('contacts/search', { - search: value, + search: encodeURIComponent(value), ...requestParams, }); } diff --git a/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js b/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js index dc12d2640..2d54c37bc 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js +++ b/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js @@ -1,6 +1,7 @@ import { OPERATOR_TYPES_1, OPERATOR_TYPES_3, + OPERATOR_TYPES_5, } from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'; const filterTypes = [ { @@ -51,6 +52,30 @@ const filterTypes = [ filterOperators: OPERATOR_TYPES_3, attribute_type: 'standard', }, + { + attributeKey: 'created_at', + attributeI18nKey: 'CREATED_AT', + inputType: 'date', + dataType: 'text', + filterOperators: OPERATOR_TYPES_5, + attributeModel: 'standard', + }, + { + attributeKey: 'last_activity_at', + attributeI18nKey: 'LAST_ACTIVITY', + inputType: 'date', + dataType: 'text', + filterOperators: OPERATOR_TYPES_5, + attributeModel: 'standard', + }, + { + attributeKey: 'referer', + attributeI18nKey: 'REFERER_LINK', + inputType: 'plain_text', + dataType: 'text', + filterOperators: OPERATOR_TYPES_5, + attributeModel: 'standard', + }, ]; export const filterAttributeGroups = [ @@ -82,6 +107,14 @@ export const filterAttributeGroups = [ key: 'city', i18nKey: 'CITY', }, + { + key: 'created_at', + i18nKey: 'CREATED_AT', + }, + { + key: 'last_activity_at', + i18nKey: 'LAST_ACTIVITY', + }, ], }, ]; diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactForm.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactForm.vue index 2b3dae832..11e0fb675 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactForm.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactForm.vue @@ -130,11 +130,13 @@ export default { facebook: '', twitter: '', linkedin: '', + github: '', }, socialProfileKeys: [ { key: 'facebook', prefixURL: 'https://facebook.com/' }, { key: 'twitter', prefixURL: 'https://twitter.com/' }, { key: 'linkedin', prefixURL: 'https://linkedin.com/' }, + { key: 'github', prefixURL: 'https://github.com/' }, ], }; }, @@ -183,6 +185,7 @@ export default { twitter: socialProfiles.twitter || twitterScreenName || '', facebook: socialProfiles.facebook || '', linkedin: socialProfiles.linkedin || '', + github: socialProfiles.github || '', }; }, getContactObject() { diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue index 4a58d8a54..24aeeff88 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue @@ -126,17 +126,15 @@ @close="toggleMergeModal" /> - @@ -210,8 +208,7 @@ export default { if (!cityAndCountry) { return ''; } - const countryFlag = countryCode ? flag(countryCode) : '🌎'; - return `${cityAndCountry} ${countryFlag}`; + return this.findCountryFlag(countryCode, cityAndCountry); }, socialProfiles() { const { @@ -222,22 +219,11 @@ export default { return { twitter: twitterScreenName, ...(socialProfiles || {}) }; }, // Delete Modal - deleteConfirmText() { - return `${this.$t('DELETE_CONTACT.CONFIRM.YES')} ${this.contact.name}`; - }, - deleteRejectText() { - return `${this.$t('DELETE_CONTACT.CONFIRM.NO')} ${this.contact.name}`; - }, confirmDeleteMessage() { return `${this.$t('DELETE_CONTACT.CONFIRM.MESSAGE')} ${ this.contact.name } ?`; }, - confirmPlaceHolderText() { - return `${this.$t('DELETE_CONTACT.CONFIRM.PLACE_HOLDER', { - contactName: this.contact.name, - })}`; - }, }, methods: { toggleMergeModal() { @@ -261,6 +247,14 @@ export default { this.showConversationModal = false; this.showEditModal = false; }, + findCountryFlag(countryCode, cityAndCountry) { + try { + const countryFlag = countryCode ? flag(countryCode) : '🌎'; + return `${cityAndCountry} ${countryFlag}`; + } catch (error) { + return ''; + } + }, async deleteContact({ id }) { try { await this.$store.dispatch('contacts/delete', id); diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/Index.vue index be79dd4f5..811fc95c6 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/Index.vue @@ -34,12 +34,19 @@ {{ automation.name }} {{ automation.description }} - - + {{ readableTime(automation.created_on) }} @@ -120,6 +127,11 @@ @saveAutomation="submitAutomation" /> + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue index 1f2839048..fe0240d74 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue @@ -10,6 +10,7 @@ @@ -48,6 +50,7 @@ import fromUnixTime from 'date-fns/fromUnixTime'; import format from 'date-fns/format'; import ReportFilterSelector from './components/FilterSelector'; import { GROUP_BY_FILTER } from './constants'; +import reportMixin from '../../../../mixins/reportMixin'; const REPORTS_KEYS = { CONVERSATIONS: 'conversations_count', @@ -62,6 +65,7 @@ export default { components: { ReportFilterSelector, }, + mixins: [reportMixin], data() { return { from: 0, diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/FilterSelector.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/FilterSelector.vue index 8df8beec3..885b76283 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/FilterSelector.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/FilterSelector.vue @@ -24,7 +24,7 @@ @change="onChange" />
+
+ +
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportFilters.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportFilters.vue index 2fd32ee69..857a74363 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportFilters.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportFilters.vue @@ -255,7 +255,6 @@ export default { }, methods: { onDateRangeChange() { - console.log(this.from, this.to); this.$emit('date-range-change', { from: this.from, to: this.to, diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue index 449b0383f..8ce264080 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue @@ -27,7 +27,8 @@ :heading="metric.NAME" :index="index" :on-click="changeSelection" - :point="accountSummary[metric.KEY]" + :point="displayMetric(metric.KEY)" + :trend="calculateTrend(metric.KEY)" :selected="index === currentSelection" /> @@ -55,6 +56,7 @@ import ReportFilters from './ReportFilters'; import fromUnixTime from 'date-fns/fromUnixTime'; import format from 'date-fns/format'; import { GROUP_BY_FILTER } from '../constants'; +import reportMixin from '../../../../../mixins/reportMixin'; const REPORTS_KEYS = { CONVERSATIONS: 'conversations_count', @@ -68,6 +70,7 @@ export default { components: { ReportFilters, }, + mixins: [reportMixin], props: { type: { type: String, diff --git a/app/javascript/dashboard/routes/index.js b/app/javascript/dashboard/routes/index.js index b2d19cf7f..14ef77bc3 100644 --- a/app/javascript/dashboard/routes/index.js +++ b/app/javascript/dashboard/routes/index.js @@ -5,6 +5,7 @@ import login from './login/login.routes'; import dashboard from './dashboard/dashboard.routes'; import authRoute from './auth/auth.routes'; import { frontendURL } from '../helper/URLHelper'; +import { clearBrowserSessionCookies } from '../store/utils/api'; const routes = [ ...login.routes, @@ -101,6 +102,13 @@ export const validateAuthenticateRoutePermission = (to, from, next) => { return nextRoute ? next(frontendURL(nextRoute)) : next(); }; +const validateSSOLoginParams = to => { + const isLoginRoute = to.name === 'login'; + const { email, sso_auth_token: ssoAuthToken } = to.query || {}; + const hasValidSSOParams = email && ssoAuthToken; + return isLoginRoute && hasValidSSOParams; +}; + const validateRouteAccess = (to, from, next) => { if ( window.chatwootConfig.signupEnabled !== 'true' && @@ -111,6 +119,11 @@ const validateRouteAccess = (to, from, next) => { next(frontendURL(`accounts/${user.account_id}/dashboard`)); } + if (validateSSOLoginParams(to)) { + clearBrowserSessionCookies(); + return next(); + } + if (authIgnoreRoutes.includes(to.name)) { return next(); } diff --git a/app/javascript/dashboard/routes/login/Login.vue b/app/javascript/dashboard/routes/login/Login.vue index 92fea2e36..428974af7 100644 --- a/app/javascript/dashboard/routes/login/Login.vue +++ b/app/javascript/dashboard/routes/login/Login.vue @@ -80,6 +80,7 @@ export default { mixins: [globalConfigMixin], props: { ssoAuthToken: { type: String, default: '' }, + ssoAccountId: { type: String, default: '' }, redirectUrl: { type: String, default: '' }, config: { type: String, default: '' }, email: { type: String, default: '' }, @@ -138,6 +139,7 @@ export default { : this.credentials.email, password: this.credentials.password, sso_auth_token: this.ssoAuthToken, + ssoAccountId: this.ssoAccountId, }; this.$store .dispatch('login', credentials) diff --git a/app/javascript/dashboard/routes/login/login.routes.js b/app/javascript/dashboard/routes/login/login.routes.js index c53cffa92..f43e632bf 100644 --- a/app/javascript/dashboard/routes/login/login.routes.js +++ b/app/javascript/dashboard/routes/login/login.routes.js @@ -12,6 +12,7 @@ export default { email: route.query.email, ssoAuthToken: route.query.sso_auth_token, redirectUrl: route.query.route_url, + ssoAccountId: route.query.sso_account_id, }), }, ], diff --git a/app/javascript/dashboard/store/modules/auth.js b/app/javascript/dashboard/store/modules/auth.js index d9ec54564..5b2feef11 100644 --- a/app/javascript/dashboard/store/modules/auth.js +++ b/app/javascript/dashboard/store/modules/auth.js @@ -6,7 +6,7 @@ import authAPI from '../../api/auth'; import createAxios from '../../helper/APIHelper'; import actionCable from '../../helper/actionCable'; import { setUser, getHeaderExpiry, clearCookiesOnLogout } from '../utils/api'; -import { DEFAULT_REDIRECT_URL } from '../../constants'; +import { getLoginRedirectURL } from '../../helper/URLHelper'; const state = { currentUser: { @@ -88,15 +88,16 @@ export const getters = { // actions export const actions = { - login({ commit }, credentials) { + login({ commit }, { ssoAccountId, ...credentials }) { return new Promise((resolve, reject) => { authAPI .login(credentials) - .then(() => { + .then(response => { commit(types.default.SET_CURRENT_USER); window.axios = createAxios(axios); actionCable.init(Vue); - window.location = DEFAULT_REDIRECT_URL; + + window.location = getLoginRedirectURL(ssoAccountId, response.data); resolve(); }) .catch(error => { diff --git a/app/javascript/dashboard/store/modules/conversations/getters.js b/app/javascript/dashboard/store/modules/conversations/getters.js index e4b063899..60ca0d590 100644 --- a/app/javascript/dashboard/store/modules/conversations/getters.js +++ b/app/javascript/dashboard/store/modules/conversations/getters.js @@ -1,4 +1,5 @@ import authAPI from '../../../api/auth'; +import { MESSAGE_TYPE } from 'shared/constants/messages'; import { applyPageFilters } from './helpers'; export const getSelectedChatConversation = ({ @@ -19,6 +20,26 @@ const getters = { ); return selectedChat || {}; }, + getLastEmailInSelectedChat: (stage, _getters) => { + const selectedChat = _getters.getSelectedChat; + const { messages = [] } = selectedChat; + const lastEmail = [...messages].reverse().find(message => { + const { + content_attributes: contentAttributes = {}, + message_type: messageType, + } = message; + const { email = {} } = contentAttributes; + const isIncomingOrOutgoing = + messageType === MESSAGE_TYPE.OUTGOING || + messageType === MESSAGE_TYPE.INCOMING; + if (email.from && isIncomingOrOutgoing) { + return true; + } + return false; + }); + + return lastEmail; + }, getMineChats: _state => activeFilters => { const currentUserID = authAPI.getCurrentUser().id; diff --git a/app/javascript/dashboard/store/modules/csat.js b/app/javascript/dashboard/store/modules/csat.js index d38b212be..7bc1dad6d 100644 --- a/app/javascript/dashboard/store/modules/csat.js +++ b/app/javascript/dashboard/store/modules/csat.js @@ -82,10 +82,13 @@ export const getters = { }; export const actions = { - get: async function getResponses({ commit }, { page = 1, from, to } = {}) { + get: async function getResponses( + { commit }, + { page = 1, from, to, user_ids } = {} + ) { commit(types.SET_CSAT_RESPONSE_UI_FLAG, { isFetching: true }); try { - const response = await CSATReports.get({ page, from, to }); + const response = await CSATReports.get({ page, from, to, user_ids }); commit(types.SET_CSAT_RESPONSE, response.data); } catch (error) { // Ignore error @@ -93,10 +96,10 @@ export const actions = { commit(types.SET_CSAT_RESPONSE_UI_FLAG, { isFetching: false }); } }, - getMetrics: async function getMetrics({ commit }, { from, to }) { + getMetrics: async function getMetrics({ commit }, { from, to, user_ids }) { commit(types.SET_CSAT_RESPONSE_UI_FLAG, { isFetchingMetrics: true }); try { - const response = await CSATReports.getMetrics({ from, to }); + const response = await CSATReports.getMetrics({ from, to, user_ids }); commit(types.SET_CSAT_RESPONSE_METRICS, response.data); } catch (error) { // Ignore error diff --git a/app/javascript/dashboard/store/modules/labels.js b/app/javascript/dashboard/store/modules/labels.js index 4183c28ed..f597212a3 100644 --- a/app/javascript/dashboard/store/modules/labels.js +++ b/app/javascript/dashboard/store/modules/labels.js @@ -45,7 +45,8 @@ export const actions = { const response = await LabelsAPI.create(cannedObj); commit(types.ADD_LABEL, response.data); } catch (error) { - throw new Error(error); + const errorMessage = error?.response?.data?.message; + throw new Error(errorMessage); } finally { commit(types.SET_LABEL_UI_FLAG, { isCreating: false }); } diff --git a/app/javascript/dashboard/store/modules/reports.js b/app/javascript/dashboard/store/modules/reports.js index 33f62a9c4..cb1efe3ff 100644 --- a/app/javascript/dashboard/store/modules/reports.js +++ b/app/javascript/dashboard/store/modules/reports.js @@ -20,6 +20,7 @@ const state = { incoming_messages_count: 0, outgoing_messages_count: 0, resolutions_count: 0, + previous: {}, }, }; @@ -124,24 +125,6 @@ const mutations = { }, [types.default.SET_ACCOUNT_SUMMARY](_state, summaryData) { _state.accountSummary = summaryData; - // Average First Response Time - let avgFirstResTimeInHr = 0; - if (summaryData.avg_first_response_time) { - avgFirstResTimeInHr = ( - summaryData.avg_first_response_time / 3600 - ).toFixed(2); - avgFirstResTimeInHr = `${avgFirstResTimeInHr} Hr`; - } - // Average Resolution Time - let avgResolutionTimeInHr = 0; - if (summaryData.avg_resolution_time) { - avgResolutionTimeInHr = (summaryData.avg_resolution_time / 3600).toFixed( - 2 - ); - avgResolutionTimeInHr = `${avgResolutionTimeInHr} Hr`; - } - _state.accountSummary.avg_first_response_time = avgFirstResTimeInHr; - _state.accountSummary.avg_resolution_time = avgResolutionTimeInHr; }, }; diff --git a/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js index bb718bb24..7dc0dade5 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js @@ -131,4 +131,34 @@ describe('#getters', () => { expect(getters.getAppliedConversationFilters(state)).toEqual(filtersList); }); }); + + describe('#getLastEmailInSelectedChat', () => { + it('Returns cc in last email', () => { + const state = {}; + const getSelectedChat = { + messages: [ + { + message_type: 1, + content_attributes: { + email: { + from: 'why@how.my', + cc: ['nithin@me.co', 'we@who.why'], + }, + }, + }, + ], + }; + expect( + getters.getLastEmailInSelectedChat(state, { getSelectedChat }) + ).toEqual({ + message_type: 1, + content_attributes: { + email: { + from: 'why@how.my', + cc: ['nithin@me.co', 'we@who.why'], + }, + }, + }); + }); + }); }); diff --git a/app/javascript/dashboard/store/utils/api.js b/app/javascript/dashboard/store/utils/api.js index 651f0cd61..37fd68c7b 100644 --- a/app/javascript/dashboard/store/utils/api.js +++ b/app/javascript/dashboard/store/utils/api.js @@ -38,13 +38,15 @@ export const setAuthCredentials = response => { setUser(response.data.data, expiryDate); }; +export const clearBrowserSessionCookies = () => { + Cookies.remove('auth_data'); + Cookies.remove('user'); +}; + export const clearCookiesOnLogout = () => { window.bus.$emit(CHATWOOT_RESET); window.bus.$emit(ANALYTICS_RESET); - - Cookies.remove('auth_data'); - Cookies.remove('user'); - + clearBrowserSessionCookies(); const globalConfig = window.globalConfig || {}; const logoutRedirectLink = globalConfig.LOGOUT_REDIRECT_LINK || frontendURL('login'); diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 9b3875856..79b9e927e 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -21,7 +21,10 @@ import App from '../dashboard/App'; import i18n from '../dashboard/i18n'; import createAxios from '../dashboard/helper/APIHelper'; import commonHelpers, { isJSONValid } from '../dashboard/helper/commons'; -import { getAlertAudio } from '../shared/helpers/AudioNotificationHelper'; +import { + getAlertAudio, + initOnEvents, +} from '../shared/helpers/AudioNotificationHelper'; import { initFaviconSwitcher } from '../shared/helpers/faviconHelper'; import router from '../dashboard/routes'; import store from '../dashboard/store'; @@ -102,6 +105,13 @@ window.onload = () => { vueActionCable.init(); }; +const setupAudioListeners = () => { + getAlertAudio().then(() => { + initOnEvents.forEach(event => { + document.removeEventListener(event, setupAudioListeners, false); + }); + }); +}; window.addEventListener('load', () => { verifyServiceWorkerExistence(registration => registration.pushManager.getSubscription().then(subscription => { @@ -110,6 +120,9 @@ window.addEventListener('load', () => { } }) ); - getAlertAudio(); + window.playAudioAlert = () => {}; + initOnEvents.forEach(e => { + document.addEventListener(e, setupAudioListeners, false); + }); initFaviconSwitcher(); }); diff --git a/app/javascript/packs/sdk.js b/app/javascript/packs/sdk.js index c4bfcdb08..66f6ba790 100755 --- a/app/javascript/packs/sdk.js +++ b/app/javascript/packs/sdk.js @@ -25,6 +25,7 @@ const runSDK = ({ baseUrl, websiteToken }) => { launcherTitle: chatwootSettings.launcherTitle || '', showPopoutButton: chatwootSettings.showPopoutButton || false, widgetStyle: chatwootSettings.widgetStyle || 'standard', + resetTriggered: false, toggle(state) { IFrameHelper.events.toggleBubble(state); @@ -100,6 +101,8 @@ const runSDK = ({ baseUrl, websiteToken }) => { baseUrl: window.$chatwoot.baseUrl, websiteToken: window.$chatwoot.websiteToken, }); + + window.$chatwoot.resetTriggered = true; }, }; diff --git a/app/javascript/packs/widget.js b/app/javascript/packs/widget.js index f9f57e2ef..421e2f8b9 100644 --- a/app/javascript/packs/widget.js +++ b/app/javascript/packs/widget.js @@ -4,7 +4,6 @@ import VueI18n from 'vue-i18n'; import store from '../widget/store'; import App from '../widget/App.vue'; import ActionCableConnector from '../widget/helpers/actionCable'; -import { getAlertAudio } from 'shared/helpers/AudioNotificationHelper'; import i18n from '../widget/i18n'; import router from '../widget/router'; @@ -33,5 +32,4 @@ window.onload = () => { window.WOOT_WIDGET, window.chatwootPubsubToken ); - getAlertAudio(); }; diff --git a/app/javascript/sdk/IFrameHelper.js b/app/javascript/sdk/IFrameHelper.js index 2d851ba49..032519912 100644 --- a/app/javascript/sdk/IFrameHelper.js +++ b/app/javascript/sdk/IFrameHelper.js @@ -26,6 +26,10 @@ import { dispatchWindowEvent } from 'shared/helpers/CustomEventHelper'; import { CHATWOOT_ERROR, CHATWOOT_READY } from '../widget/constants/sdkEvents'; import { SET_USER_ERROR } from '../widget/constants/errorTypes'; import { getUserCookieName } from './cookieHelpers'; +import { + getAlertAudio, + initOnEvents, +} from 'shared/helpers/AudioNotificationHelper'; import { isFlatWidgetStyle } from './settingsHelper'; export const IFrameHelper = { @@ -114,6 +118,19 @@ export const IFrameHelper = { iframe.setAttribute('style', `height: ${updatedIframeHeight} !important`); }, + setupAudioListeners: () => { + const { baseUrl = '' } = window.$chatwoot; + getAlertAudio(baseUrl).then(() => + initOnEvents.forEach(event => { + document.removeEventListener( + event, + IFrameHelper.setupAudioListeners, + false + ); + }) + ); + }, + events: { loaded: message => { Cookies.set('cw_conversation', message.config.authToken, { @@ -136,7 +153,18 @@ export const IFrameHelper = { if (window.$chatwoot.user) { IFrameHelper.sendMessage('set-user', window.$chatwoot.user); } + dispatchWindowEvent({ eventName: CHATWOOT_READY }); + + window.playAudioAlert = () => {}; + + initOnEvents.forEach(e => { + document.addEventListener(e, IFrameHelper.setupAudioListeners, false); + }); + + if (!window.$chatwoot.resetTriggered) { + dispatchWindowEvent({ eventName: CHATWOOT_READY }); + } }, error: ({ errorType, data }) => { dispatchWindowEvent({ eventName: CHATWOOT_ERROR, data: data }); @@ -212,6 +240,10 @@ export const IFrameHelper = { closeChat: () => { onBubbleClick({ toggleValue: false }); }, + + playAudio: () => { + window.playAudioAlert(); + }, }, pushEvent: eventName => { IFrameHelper.sendMessage('push-event', { eventName }); diff --git a/app/javascript/shared/components/FluentIcon/dashboard-icons.json b/app/javascript/shared/components/FluentIcon/dashboard-icons.json index 3b896a86b..0cbc85d22 100644 --- a/app/javascript/shared/components/FluentIcon/dashboard-icons.json +++ b/app/javascript/shared/components/FluentIcon/dashboard-icons.json @@ -85,6 +85,11 @@ "merge-outline": "M3 6.75A.75.75 0 0 1 3.75 6h4.5a.75.75 0 0 1 .53.22L13.56 11h5.878L15.72 7.28a.75.75 0 1 1 1.06-1.06l4.998 5a.75.75 0 0 1 0 1.06l-4.998 5a.75.75 0 1 1-1.06-1.06l3.718-3.72H13.56l-4.78 4.78a.75.75 0 0 1-.531.22h-4.5a.75.75 0 0 1 0-1.5h4.19l4.25-4.25L7.94 7.5H3.75A.75.75 0 0 1 3 6.75Z", "more-horizontal-outline": "M7.75 12a1.75 1.75 0 1 1-3.5 0 1.75 1.75 0 0 1 3.5 0ZM13.75 12a1.75 1.75 0 1 1-3.5 0 1.75 1.75 0 0 1 3.5 0ZM18 13.75a1.75 1.75 0 1 0 0-3.5 1.75 1.75 0 0 0 0 3.5Z", "more-vertical-outline": "M12 7.75a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5ZM12 13.75a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5ZM10.25 18a1.75 1.75 0 1 0 3.5 0 1.75 1.75 0 0 0-3.5 0Z", + "microphone-outline": "M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z", + "microphone-off-outline": "M19,11C19,12.19 18.66,13.3 18.1,14.28L16.87,13.05C17.14,12.43 17.3,11.74 17.3,11H19M15,11.16L9,5.18V5A3,3 0 0,1 12,2A3,3 0 0,1 15,5V11L15,11.16M4.27,3L21,19.73L19.73,21L15.54,16.81C14.77,17.27 13.91,17.58 13,17.72V21H11V17.72C7.72,17.23 5,14.41 5,11H6.7C6.7,14 9.24,16.1 12,16.1C12.81,16.1 13.6,15.91 14.31,15.58L12.65,13.92L12,14A3,3 0 0,1 9,11V10.28L3,4.27L4.27,3Z", + "microphone-stop-outline": "M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4M9,9V15H15V9", + "microphone-pause-outline": "M13,16V8H15V16H13M9,16V8H11V16H9M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4Z", + "microphone-play-outline": "M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M10,16.5L16,12L10,7.5V16.5Z", "number-symbol-outline": "M10.987 2.89a.75.75 0 1 0-1.474-.28L8.494 7.999 3.75 8a.75.75 0 1 0 0 1.5l4.46-.002-.946 5-4.514.002a.75.75 0 0 0 0 1.5l4.23-.002-.967 5.116a.75.75 0 1 0 1.474.278l1.02-5.395 5.474-.002-.968 5.119a.75.75 0 1 0 1.474.278l1.021-5.398 4.742-.002a.75.75 0 1 0 0-1.5l-4.458.002.946-5 4.512-.002a.75.75 0 1 0 0-1.5l-4.229.002.966-5.104a.75.75 0 0 0-1.474-.28l-1.018 5.385-5.474.002.966-5.107Zm-1.25 6.608 5.474-.003-.946 5-5.474.002.946-5Z", "open-outline": "M6.25 4.5A1.75 1.75 0 0 0 4.5 6.25v11.5c0 .966.783 1.75 1.75 1.75h11.5a1.75 1.75 0 0 0 1.75-1.75v-4a.75.75 0 0 1 1.5 0v4A3.25 3.25 0 0 1 17.75 21H6.25A3.25 3.25 0 0 1 3 17.75V6.25A3.25 3.25 0 0 1 6.25 3h4a.75.75 0 0 1 0 1.5h-4ZM13 3.75a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 .75.75v6.5a.75.75 0 0 1-1.5 0V5.56l-5.22 5.22a.75.75 0 0 1-1.06-1.06l5.22-5.22h-4.69a.75.75 0 0 1-.75-.75Z", "people-outline": "M4 13.999 13 14a2 2 0 0 1 1.995 1.85L15 16v1.5C14.999 21 11.284 22 8.5 22c-2.722 0-6.335-.956-6.495-4.27L2 17.5v-1.501c0-1.054.816-1.918 1.85-1.995L4 14ZM15.22 14H20c1.054 0 1.918.816 1.994 1.85L22 16v1c-.001 3.062-2.858 4-5 4a7.16 7.16 0 0 1-2.14-.322c.336-.386.607-.827.802-1.327A6.19 6.19 0 0 0 17 19.5l.267-.006c.985-.043 3.086-.363 3.226-2.289L20.5 17v-1a.501.501 0 0 0-.41-.492L20 15.5h-4.051a2.957 2.957 0 0 0-.595-1.34L15.22 14H20h-4.78ZM4 15.499l-.1.01a.51.51 0 0 0-.254.136.506.506 0 0 0-.136.253l-.01.101V17.5c0 1.009.45 1.722 1.417 2.242.826.445 2.003.714 3.266.753l.317.005.317-.005c1.263-.039 2.439-.308 3.266-.753.906-.488 1.359-1.145 1.412-2.057l.005-.186V16a.501.501 0 0 0-.41-.492L13 15.5l-9-.001ZM8.5 3a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9Zm9 2a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7Zm-9-.5c-1.654 0-3 1.346-3 3s1.346 3 3 3 3-1.346 3-3-1.346-3-3-3Zm9 2c-1.103 0-2 .897-2 2s.897 2 2 2 2-.897 2-2-.897-2-2-2Z", @@ -119,6 +124,7 @@ "brand-telegram-outline": "M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z", "brand-twitter-outline": "M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z", "brand-whatsapp-outline": "M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z", + "brand-github-outline": "M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385c.6.105.825-.255.825-.57c0-.285-.015-1.23-.015-2.235c-3.015.555-3.795-.735-4.035-1.41c-.135-.345-.72-1.41-1.23-1.695c-.42-.225-1.02-.78-.015-.795c.945-.015 1.62.87 1.845 1.23c1.08 1.815 2.805 1.305 3.495.99c.105-.78.42-1.305.765-1.605c-2.67-.3-5.46-1.335-5.46-5.925c0-1.305.465-2.385 1.23-3.225c-.12-.3-.54-1.53.12-3.18c0 0 1.005-.315 3.3 1.23c.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23c.66 1.65.24 2.88.12 3.18c.765.84 1.23 1.905 1.23 3.225c0 4.605-2.805 5.625-5.475 5.925c.435.375.81 1.095.81 2.22c0 1.605-.015 2.895-.015 3.3c0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z", "add-solid": "M11.883 3.007 12 3a1 1 0 0 1 .993.883L13 4v7h7a1 1 0 0 1 .993.883L21 12a1 1 0 0 1-.883.993L20 13h-7v7a1 1 0 0 1-.883.993L12 21a1 1 0 0 1-.993-.883L11 20v-7H4a1 1 0 0 1-.993-.883L3 12a1 1 0 0 1 .883-.993L4 11h7V4a1 1 0 0 1 .883-.993L12 3l-.117.007Z", "subtract-solid": "M3.997 13H20a1 1 0 1 0 0-2H3.997a1 1 0 1 0 0 2Z" } diff --git a/app/javascript/shared/components/FluentIcon/icons.json b/app/javascript/shared/components/FluentIcon/icons.json index 62798657d..20981ed5c 100644 --- a/app/javascript/shared/components/FluentIcon/icons.json +++ b/app/javascript/shared/components/FluentIcon/icons.json @@ -2,12 +2,15 @@ "arrow-clockwise-outline": "M12 4.75a7.25 7.25 0 1 0 7.201 6.406c-.068-.588.358-1.156.95-1.156.515 0 .968.358 1.03.87a9.25 9.25 0 1 1-3.432-6.116V4.25a1 1 0 1 1 2.001 0v2.698l.034.052h-.034v.25a1 1 0 0 1-1 1h-3a1 1 0 1 1 0-2h.666A7.219 7.219 0 0 0 12 4.75Z", "arrow-right-outline": "M13.267 4.209a.75.75 0 0 0-1.034 1.086l6.251 5.955H3.75a.75.75 0 0 0 0 1.5h14.734l-6.251 5.954a.75.75 0 0 0 1.034 1.087l7.42-7.067a.996.996 0 0 0 .3-.58.758.758 0 0 0-.001-.29.995.995 0 0 0-.3-.578l-7.419-7.067Z", "attach-outline": "M11.772 3.743a6 6 0 0 1 8.66 8.302l-.19.197-8.8 8.798-.036.03a3.723 3.723 0 0 1-5.489-4.973.764.764 0 0 1 .085-.13l.054-.06.086-.088.142-.148.002.003 7.436-7.454a.75.75 0 0 1 .977-.074l.084.073a.75.75 0 0 1 .074.976l-.073.084-7.594 7.613a2.23 2.23 0 0 0 3.174 3.106l8.832-8.83A4.502 4.502 0 0 0 13 4.644l-.168.16-.013.014-9.536 9.536a.75.75 0 0 1-1.133-.977l.072-.084 9.549-9.55h.002Z", + "checkmark-outline": "M4.53 12.97a.75.75 0 0 0-1.06 1.06l4.5 4.5a.75.75 0 0 0 1.06 0l11-11a.75.75 0 0 0-1.06-1.06L8.5 16.94l-3.97-3.97Z", "chevron-left-outline": "M15.53 4.22a.75.75 0 0 1 0 1.06L8.81 12l6.72 6.72a.75.75 0 1 1-1.06 1.06l-7.25-7.25a.75.75 0 0 1 0-1.06l7.25-7.25a.75.75 0 0 1 1.06 0Z", "chevron-right-outline": "M8.293 4.293a1 1 0 0 0 0 1.414L14.586 12l-6.293 6.293a1 1 0 1 0 1.414 1.414l7-7a1 1 0 0 0 0-1.414l-7-7a1 1 0 0 0-1.414 0Z", "dismiss-outline": "m4.397 4.554.073-.084a.75.75 0 0 1 .976-.073l.084.073L12 10.939l6.47-6.47a.75.75 0 1 1 1.06 1.061L13.061 12l6.47 6.47a.75.75 0 0 1 .072.976l-.073.084a.75.75 0 0 1-.976.073l-.084-.073L12 13.061l-6.47 6.47a.75.75 0 0 1-1.06-1.061L10.939 12l-6.47-6.47a.75.75 0 0 1-.072-.976l.073-.084-.073.084Z", "document-outline": "M18.5 20a.5.5 0 0 1-.5.5H6a.5.5 0 0 1-.5-.5V4a.5.5 0 0 1 .5-.5h6V8a2 2 0 0 0 2 2h4.5v10Zm-5-15.379L17.378 8.5H14a.5.5 0 0 1-.5-.5V4.621Zm5.914 3.793-5.829-5.828c-.026-.026-.058-.046-.085-.07a2.072 2.072 0 0 0-.219-.18c-.04-.027-.086-.045-.128-.068-.071-.04-.141-.084-.216-.116a1.977 1.977 0 0 0-.624-.138C12.266 2.011 12.22 2 12.172 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9.828a2 2 0 0 0-.586-1.414Z", "emoji-outline": "M12 1.999c5.524 0 10.002 4.478 10.002 10.002 0 5.523-4.478 10.001-10.002 10.001-5.524 0-10.002-4.478-10.002-10.001C1.998 6.477 6.476 1.999 12 1.999Zm0 1.5a8.502 8.502 0 1 0 0 17.003A8.502 8.502 0 0 0 12 3.5ZM8.462 14.784A4.491 4.491 0 0 0 12 16.502a4.492 4.492 0 0 0 3.535-1.714.75.75 0 1 1 1.177.93A5.991 5.991 0 0 1 12 18.002a5.991 5.991 0 0 1-4.716-2.29.75.75 0 0 1 1.178-.928ZM9 8.75a1.25 1.25 0 1 1 0 2.499A1.25 1.25 0 0 1 9 8.75Zm6 0a1.25 1.25 0 1 1 0 2.499 1.25 1.25 0 0 1 0-2.499Z", "link-outline": "M9.25 7a.75.75 0 0 1 .11 1.492l-.11.008H7a3.5 3.5 0 0 0-.206 6.994L7 15.5h2.25a.75.75 0 0 1 .11 1.492L9.25 17H7a5 5 0 0 1-.25-9.994L7 7h2.25ZM17 7a5 5 0 0 1 .25 9.994L17 17h-2.25a.75.75 0 0 1-.11-1.492l.11-.008H17a3.5 3.5 0 0 0 .206-6.994L17 8.5h-2.25a.75.75 0 0 1-.11-1.492L14.75 7H17ZM7 11.25h10a.75.75 0 0 1 .102 1.493L17 12.75H7a.75.75 0 0 1-.102-1.493L7 11.25h10H7Z", + "more-vertical-outline": "M12 7.75a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5ZM12 13.75a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5ZM10.25 18a1.75 1.75 0 1 0 3.5 0 1.75 1.75 0 0 0-3.5 0Z", "open-outline": "M6.25 4.5A1.75 1.75 0 0 0 4.5 6.25v11.5c0 .966.783 1.75 1.75 1.75h11.5a1.75 1.75 0 0 0 1.75-1.75v-4a.75.75 0 0 1 1.5 0v4A3.25 3.25 0 0 1 17.75 21H6.25A3.25 3.25 0 0 1 3 17.75V6.25A3.25 3.25 0 0 1 6.25 3h4a.75.75 0 0 1 0 1.5h-4ZM13 3.75a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 .75.75v6.5a.75.75 0 0 1-1.5 0V5.56l-5.22 5.22a.75.75 0 0 1-1.06-1.06l5.22-5.22h-4.69a.75.75 0 0 1-.75-.75Z", - "send-outline": "M5.694 12 2.299 3.272c-.236-.607.356-1.188.942-.982l.093.04 18 9a.75.75 0 0 1 .097 1.283l-.097.058-18 9c-.583.291-1.217-.244-1.065-.847l.03-.096L5.694 12 2.299 3.272 5.694 12ZM4.402 4.54l2.61 6.71h6.627a.75.75 0 0 1 .743.648l.007.102a.75.75 0 0 1-.649.743l-.101.007H7.01l-2.609 6.71L19.322 12 4.401 4.54Z" + "send-outline": "M5.694 12 2.299 3.272c-.236-.607.356-1.188.942-.982l.093.04 18 9a.75.75 0 0 1 .097 1.283l-.097.058-18 9c-.583.291-1.217-.244-1.065-.847l.03-.096L5.694 12 2.299 3.272 5.694 12ZM4.402 4.54l2.61 6.71h6.627a.75.75 0 0 1 .743.648l.007.102a.75.75 0 0 1-.649.743l-.101.007H7.01l-2.609 6.71L19.322 12 4.401 4.54Z", + "sign-out-outline": ["M8.502 11.5a1.002 1.002 0 1 1 0 2.004 1.002 1.002 0 0 1 0-2.004Z","M12 4.354v6.651l7.442-.001L17.72 9.28a.75.75 0 0 1-.073-.976l.073-.084a.75.75 0 0 1 .976-.073l.084.073 2.997 2.997a.75.75 0 0 1 .073.976l-.073.084-2.996 3.004a.75.75 0 0 1-1.134-.975l.072-.085 1.713-1.717-7.431.001L12 19.25a.75.75 0 0 1-.88.739l-8.5-1.502A.75.75 0 0 1 2 17.75V5.75a.75.75 0 0 1 .628-.74l8.5-1.396a.75.75 0 0 1 .872.74Zm-1.5.883-7 1.15V17.12l7 1.236V5.237Z","M13 18.501h.765l.102-.006a.75.75 0 0 0 .648-.745l-.007-4.25H13v5.001ZM13.002 10 13 8.725V5h.745a.75.75 0 0 1 .743.647l.007.102.007 4.251h-1.5Z"] } diff --git a/app/javascript/shared/helpers/AudioNotificationHelper.js b/app/javascript/shared/helpers/AudioNotificationHelper.js index 9896d6b8e..672816f90 100644 --- a/app/javascript/shared/helpers/AudioNotificationHelper.js +++ b/app/javascript/shared/helpers/AudioNotificationHelper.js @@ -1,9 +1,10 @@ import { MESSAGE_TYPE } from 'shared/constants/messages'; -import axios from 'axios'; +import { IFrameHelper } from 'widget/helpers/utils'; + import { showBadgeOnFavicon } from './faviconHelper'; -export const getAlertAudio = async () => { - window.playAudioAlert = () => {}; +export const initOnEvents = ['click', 'touchstart', 'keypress']; +export const getAlertAudio = async (baseUrl = '') => { const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const playsound = audioBuffer => { window.playAudioAlert = () => { @@ -16,11 +17,15 @@ export const getAlertAudio = async () => { }; try { - const response = await axios.get('/dashboard/audios/ding.mp3', { - responseType: 'arraybuffer', - }); + const resourceUrl = `${baseUrl}/dashboard/audios/ding.mp3`; + const audioRequest = new Request(resourceUrl); - audioCtx.decodeAudioData(response.data).then(playsound); + fetch(audioRequest) + .then(response => response.arrayBuffer()) + .then(buffer => { + audioCtx.decodeAudioData(buffer).then(playsound); + return new Promise(res => res()); + }); } catch (error) { // error } @@ -89,6 +94,7 @@ export const newMessageNotification = data => { currentUserId, assigneeId ); + if (playAudio && isNotificationEnabled) { window.playAudioAlert(); showBadgeOnFavicon(); @@ -96,5 +102,7 @@ export const newMessageNotification = data => { }; export const playNewMessageNotificationInWidget = () => { - window.playAudioAlert(); + IFrameHelper.sendMessage({ + event: 'playAudio', + }); }; diff --git a/app/javascript/shared/helpers/FileHelper.js b/app/javascript/shared/helpers/FileHelper.js index a784b2ba2..d9ca9f943 100644 --- a/app/javascript/shared/helpers/FileHelper.js +++ b/app/javascript/shared/helpers/FileHelper.js @@ -19,7 +19,7 @@ export const fileSizeInMegaBytes = bytes => { }; export const checkFileSizeLimit = (file, maximumUploadLimit) => { - const fileSize = file?.file?.size; + const fileSize = file?.file?.size || file?.size; const fileSizeInMB = fileSizeInMegaBytes(fileSize); return fileSizeInMB <= maximumUploadLimit; }; diff --git a/app/javascript/shared/helpers/MessageFormatter.js b/app/javascript/shared/helpers/MessageFormatter.js index ba45a02b6..3bc89ec2f 100644 --- a/app/javascript/shared/helpers/MessageFormatter.js +++ b/app/javascript/shared/helpers/MessageFormatter.js @@ -1,4 +1,4 @@ -import marked from 'marked'; +import { marked } from 'marked'; import DOMPurify from 'dompurify'; import { escapeHtml } from './HTMLSanitizer'; @@ -47,7 +47,12 @@ class MessageFormatter { const markedDownOutput = marked(withHash); return markedDownOutput; } - return marked(this.message, { breaks: true, gfm: true }); + DOMPurify.addHook('afterSanitizeAttributes', node => { + if ('target' in node) node.setAttribute('target', '_blank'); + }); + return DOMPurify.sanitize( + marked(this.message, { breaks: true, gfm: true }) + ); } get formattedMessage() { diff --git a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js index 3ac99dbdd..4e13fe832 100644 --- a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js +++ b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js @@ -6,14 +6,14 @@ describe('#MessageFormatter', () => { const message = 'Chatwoot is an opensource tool. [Chatwoot](https://www.chatwoot.com)'; expect(new MessageFormatter(message).formattedMessage).toMatch( - '

Chatwoot is an opensource tool. Chatwoot

' + '

Chatwoot is an opensource tool. Chatwoot

' ); }); it('should format correctly', () => { const message = 'Chatwoot is an opensource tool. https://www.chatwoot.com'; expect(new MessageFormatter(message).formattedMessage).toMatch( - '

Chatwoot is an opensource tool. https://www.chatwoot.com

' + '

Chatwoot is an opensource tool. https://www.chatwoot.com

' ); }); }); @@ -58,4 +58,14 @@ describe('#MessageFormatter', () => { ); }); }); + + describe('#sanitize', () => { + it('sanitizes markup and removes all unnecessary elements', () => { + const message = + '[xssLink](javascript:alert(document.cookie))\n[normalLink](https://google.com)**I am a bold text paragraph**'; + expect(new MessageFormatter(message).formattedMessage).toMatch( + '

xssLink
normalLinkI am a bold text paragraph

' + ); + }); + }); }); diff --git a/app/javascript/shared/mixins/inboxMixin.js b/app/javascript/shared/mixins/inboxMixin.js index aebbeebc1..f0417ae93 100644 --- a/app/javascript/shared/mixins/inboxMixin.js +++ b/app/javascript/shared/mixins/inboxMixin.js @@ -44,6 +44,9 @@ export default { const { medium: medium = '' } = this.inbox; return this.isATwilioChannel && medium === 'sms'; }, + isASmsInbox() { + return this.channelType === INBOX_TYPES.SMS || this.isATwilioSMSChannel; + }, isATwilioWhatsappChannel() { const { medium: medium = '' } = this.inbox; return this.isATwilioChannel && medium === 'whatsapp'; diff --git a/app/javascript/shared/mixins/specs/inboxMixin.spec.js b/app/javascript/shared/mixins/specs/inboxMixin.spec.js index f128a44d8..dc7cc38cc 100644 --- a/app/javascript/shared/mixins/specs/inboxMixin.spec.js +++ b/app/javascript/shared/mixins/specs/inboxMixin.spec.js @@ -62,6 +62,18 @@ describe('inboxMixin', () => { expect(wrapper.vm.isAWebWidgetInbox).toBe(true); }); + it('isASmsInbox returns true if channel type is sms', () => { + const Component = { + render() {}, + mixins: [inboxMixin], + data() { + return { inbox: { channel_type: 'Channel::Sms' } }; + }, + }; + const wrapper = shallowMount(Component); + expect(wrapper.vm.isASmsInbox).toBe(true); + }); + it('isATwilioChannel returns true if channel type is Twilio', () => { const Component = { render() {}, @@ -94,6 +106,7 @@ describe('inboxMixin', () => { 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', () => { diff --git a/app/javascript/widget/api/conversation.js b/app/javascript/widget/api/conversation.js index 3552765c7..fdb3842fd 100755 --- a/app/javascript/widget/api/conversation.js +++ b/app/javascript/widget/api/conversation.js @@ -48,6 +48,11 @@ const sendEmailTranscript = async ({ email }) => { { email } ); }; +const toggleStatus = async () => { + return API.get( + `/api/v1/widget/conversations/toggle_status${window.location.search}` + ); +}; export { createConversationAPI, @@ -58,4 +63,5 @@ export { toggleTyping, setUserLastSeenAt, sendEmailTranscript, + toggleStatus, }; diff --git a/app/javascript/widget/components/ChatFooter.vue b/app/javascript/widget/components/ChatFooter.vue index 423eb2642..7ed33acbd 100755 --- a/app/javascript/widget/components/ChatFooter.vue +++ b/app/javascript/widget/components/ChatFooter.vue @@ -37,12 +37,13 @@ import CustomButton from 'shared/components/Button'; import ChatInputWrap from 'widget/components/ChatInputWrap.vue'; import { BUS_EVENTS } from 'shared/constants/busEvents'; import { sendEmailTranscript } from 'widget/api/conversation'; - +import routerMixin from 'widget/mixins/routerMixin'; export default { components: { ChatInputWrap, CustomButton, }, + mixins: [routerMixin], props: { msg: { type: String, @@ -53,7 +54,7 @@ export default { ...mapGetters({ conversationAttributes: 'conversationAttributes/getConversationParams', widgetColor: 'appConfig/getWidgetColor', - getConversationSize: 'conversation/getConversationSize', + conversationSize: 'conversation/getConversationSize', currentUser: 'contacts/getCurrentUser', isWidgetStyleFlat: 'appConfig/isWidgetStyleFlat', }), @@ -80,12 +81,11 @@ export default { 'clearConversationAttributes', ]), async handleSendMessage(content) { - const conversationSize = this.getConversationSize; await this.sendMessage({ content, }); // Update conversation attributes on new conversation - if (conversationSize === 0) { + if (this.conversationSize === 0) { this.getAttributes(); } }, @@ -95,7 +95,12 @@ export default { startNewConversation() { this.clearConversations(); this.clearConversationAttributes(); - window.bus.$emit(BUS_EVENTS.START_NEW_CONVERSATION); + + // To create a new conversation, we are redirecting + // the user to pre-chat with contact fields disabled + // Pass disableContactFields params to the route + // This would disable the contact fields in the pre-chat form + this.replaceRoute('prechat-form', { disableContactFields: true }); }, async sendTranscript() { const { email } = this.currentUser; diff --git a/app/javascript/widget/components/HeaderActions.vue b/app/javascript/widget/components/HeaderActions.vue index b9fb76ff7..4d6e5faa9 100644 --- a/app/javascript/widget/components/HeaderActions.vue +++ b/app/javascript/widget/components/HeaderActions.vue @@ -1,5 +1,13 @@ diff --git a/app/javascript/widget/components/PreChat/Form.vue b/app/javascript/widget/components/PreChat/Form.vue index c053a58f6..7fdf33375 100644 --- a/app/javascript/widget/components/PreChat/Form.vue +++ b/app/javascript/widget/components/PreChat/Form.vue @@ -10,7 +10,7 @@ {{ headerMessage }} ({}), }, + disableContactFields: { + type: Boolean, + default: false, + }, }, validations() { const identityValidations = { @@ -99,7 +103,7 @@ export default { if (this.hasActiveCampaign) { return identityValidations; } - if (this.options.requireEmail) { + if (this.areContactFieldsVisible) { return { ...identityValidations, ...messageValidation, @@ -135,6 +139,9 @@ export default { } return this.options.preChatMessage; }, + areContactFieldsVisible() { + return this.options.requireEmail && !this.disableContactFields; + }, }, methods: { onSubmit() { diff --git a/app/javascript/widget/components/dropdown/DropdownMenu.vue b/app/javascript/widget/components/dropdown/DropdownMenu.vue new file mode 100644 index 000000000..1bccb7147 --- /dev/null +++ b/app/javascript/widget/components/dropdown/DropdownMenu.vue @@ -0,0 +1,83 @@ + + + + diff --git a/app/javascript/widget/components/dropdown/DropdownMenuItem.vue b/app/javascript/widget/components/dropdown/DropdownMenuItem.vue new file mode 100644 index 000000000..1ba355d98 --- /dev/null +++ b/app/javascript/widget/components/dropdown/DropdownMenuItem.vue @@ -0,0 +1,67 @@ + + + diff --git a/app/javascript/widget/helpers/actionCable.js b/app/javascript/widget/helpers/actionCable.js index c5c262c1b..84edb35ba 100644 --- a/app/javascript/widget/helpers/actionCable.js +++ b/app/javascript/widget/helpers/actionCable.js @@ -11,12 +11,16 @@ class ActionCableConnector extends BaseActionCableConnector { 'conversation.typing_on': this.onTypingOn, 'conversation.typing_off': this.onTypingOff, 'conversation.status_changed': this.onStatusChange, + 'conversation.created': this.onConversationCreated, 'presence.update': this.onPresenceUpdate, 'contact.merged': this.onContactMerge, }; } onStatusChange = data => { + if (data.status === 'resolved') { + this.app.$store.dispatch('campaign/resetCampaign'); + } this.app.$store.dispatch('conversationAttributes/update', data); }; @@ -33,6 +37,10 @@ class ActionCableConnector extends BaseActionCableConnector { this.app.$store.dispatch('conversation/addOrUpdateMessage', data); }; + onConversationCreated = () => { + this.app.$store.dispatch('conversationAttributes/getAttributes'); + }; + onPresenceUpdate = data => { this.app.$store.dispatch('agent/updatePresence', data.users); }; diff --git a/app/javascript/widget/i18n/locale/en.json b/app/javascript/widget/i18n/locale/en.json index 7718ea4d1..66bf83fe5 100644 --- a/app/javascript/widget/i18n/locale/en.json +++ b/app/javascript/widget/i18n/locale/en.json @@ -22,6 +22,7 @@ "IN_A_DAY": "Typically replies in a day" }, "START_CONVERSATION": "Start Conversation", + "END_CONVERSATION": "End Conversation", "CONTINUE_CONVERSATION": "Continue conversation", "START_NEW_CONVERSATION": "Start a new conversation", "UNREAD_VIEW": { diff --git a/app/javascript/widget/mixins/routerMixin.js b/app/javascript/widget/mixins/routerMixin.js index f75cc65e1..b3b37f6fd 100644 --- a/app/javascript/widget/mixins/routerMixin.js +++ b/app/javascript/widget/mixins/routerMixin.js @@ -1,8 +1,8 @@ export default { methods: { - async replaceRoute(name) { + async replaceRoute(name, params = {}) { if (this.$route.name !== name) { - return this.$router.replace({ name }); + return this.$router.replace({ name, params }); } return undefined; }, diff --git a/app/javascript/widget/store/modules/campaign.js b/app/javascript/widget/store/modules/campaign.js index 673f52567..381364543 100644 --- a/app/javascript/widget/store/modules/campaign.js +++ b/app/javascript/widget/store/modules/campaign.js @@ -100,6 +100,7 @@ export const actions = { { root: true } ); await triggerCampaign({ campaignId, websiteToken }); + commit('setCampaignExecuted', true); commit('setActiveCampaign', {}); } catch (error) { commit('setError', true); @@ -113,6 +114,7 @@ export const actions = { }, resetCampaign: async ({ commit }) => { try { + commit('setCampaignExecuted', false); commit('setActiveCampaign', {}); } catch (error) { commit('setError', true); @@ -130,6 +132,12 @@ export const mutations = { setError($state, value) { Vue.set($state.uiFlags, 'isError', value); }, + setHasFetched($state, value) { + Vue.set($state.uiFlags, 'hasFetched', value); + }, + setCampaignExecuted($state, data) { + Vue.set($state, 'campaignHasExecuted', data); + }, }; export default { diff --git a/app/javascript/widget/store/modules/contacts.js b/app/javascript/widget/store/modules/contacts.js index 79fb36de6..ef146ce72 100644 --- a/app/javascript/widget/store/modules/contacts.js +++ b/app/javascript/widget/store/modules/contacts.js @@ -24,17 +24,38 @@ export const actions = { }, update: async ({ dispatch }, { identifier, user: userObject }) => { try { + const { + email, + name, + avatar_url, + identifier_hash, + phone_number, + company_name, + city, + country_code, + description, + custom_attributes, + social_profiles, + } = userObject; const user = { - email: userObject.email, - name: userObject.name, - avatar_url: userObject.avatar_url, - identifier_hash: userObject.identifier_hash, - phone_number: userObject.phone_number, + email, + name, + avatar_url, + identifier_hash, + phone_number, + additional_attributes: { + company_name, + city, + description, + country_code, + social_profiles, + }, + custom_attributes, }; await ContactsAPI.update(identifier, user); dispatch('get'); - if (userObject.identifier_hash) { + if (identifier_hash) { dispatch('conversation/clearConversations', {}, { root: true }); dispatch('conversation/fetchOldConversations', {}, { root: true }); } diff --git a/app/javascript/widget/store/modules/conversation/actions.js b/app/javascript/widget/store/modules/conversation/actions.js index 2ec3560a7..c915ea6e7 100644 --- a/app/javascript/widget/store/modules/conversation/actions.js +++ b/app/javascript/widget/store/modules/conversation/actions.js @@ -5,6 +5,7 @@ import { sendAttachmentAPI, toggleTyping, setUserLastSeenAt, + toggleStatus, } from 'widget/api/conversation'; import { createTemporaryMessage, getNonDeletedMessages } from './helpers'; @@ -130,4 +131,8 @@ export const actions = { // IgnoreError } }, + + resolveConversation: async () => { + await toggleStatus(); + }, }; diff --git a/app/javascript/widget/store/modules/specs/campaign/actions.spec.js b/app/javascript/widget/store/modules/specs/campaign/actions.spec.js index cdd3bf12c..303149daa 100644 --- a/app/javascript/widget/store/modules/specs/campaign/actions.spec.js +++ b/app/javascript/widget/store/modules/specs/campaign/actions.spec.js @@ -132,6 +132,7 @@ describe('#actions', () => { root: true, }, ], + ['setCampaignExecuted', true], ['setActiveCampaign', {}], [ 'conversation/setConversationUIFlag', @@ -176,7 +177,10 @@ describe('#actions', () => { it('sends correct actions if execute campaign API is success', async () => { API.post.mockResolvedValue({}); await actions.resetCampaign({ commit }); - expect(commit.mock.calls).toEqual([['setActiveCampaign', {}]]); + expect(commit.mock.calls).toEqual([ + ['setCampaignExecuted', false], + ['setActiveCampaign', {}], + ]); }); }); }); diff --git a/app/javascript/widget/store/modules/specs/campaign/mutations.spec.js b/app/javascript/widget/store/modules/specs/campaign/mutations.spec.js index b35d0df15..98a723a8b 100644 --- a/app/javascript/widget/store/modules/specs/campaign/mutations.spec.js +++ b/app/javascript/widget/store/modules/specs/campaign/mutations.spec.js @@ -25,4 +25,12 @@ describe('#mutations', () => { expect(state.activeCampaign).toEqual(campaigns[0]); }); }); + + describe('#setCampaignExecuted', () => { + it('set campaign executed flag', () => { + const state = { records: [], uiFlags: {}, campaignHasExecuted: false }; + mutations.setCampaignExecuted(state, true); + expect(state.campaignHasExecuted).toEqual(true); + }); + }); }); diff --git a/app/javascript/widget/views/Home.vue b/app/javascript/widget/views/Home.vue index 32aae26f1..c15fea236 100755 --- a/app/javascript/widget/views/Home.vue +++ b/app/javascript/widget/views/Home.vue @@ -15,7 +15,6 @@ import configMixin from '../mixins/configMixin'; import TeamAvailability from 'widget/components/TeamAvailability'; import { mapGetters } from 'vuex'; -import { BUS_EVENTS } from 'shared/constants/busEvents'; import routerMixin from 'widget/mixins/routerMixin'; export default { name: 'Home', @@ -34,10 +33,7 @@ export default { }, }, data() { - return { - isOnCollapsedView: false, - isOnNewConversation: false, - }; + return {}; }, computed: { ...mapGetters({ @@ -46,12 +42,6 @@ export default { conversationSize: 'conversation/getConversationSize', }), }, - mounted() { - bus.$on(BUS_EVENTS.START_NEW_CONVERSATION, () => { - this.isOnCollapsedView = true; - this.isOnNewConversation = true; - }); - }, methods: { startConversation() { if (this.preChatFormEnabled && !this.conversationSize) { diff --git a/app/javascript/widget/views/PreChatForm.vue b/app/javascript/widget/views/PreChatForm.vue index ac70e4e56..cb0b4c217 100644 --- a/app/javascript/widget/views/PreChatForm.vue +++ b/app/javascript/widget/views/PreChatForm.vue @@ -1,6 +1,10 @@