diff --git a/.env.example b/.env.example index a97679f7e..d76338e28 100644 --- a/.env.example +++ b/.env.example @@ -81,7 +81,7 @@ SMTP_OPENSSL_VERIFY_MODE=peer # Mail Incoming # This is the domain set for the reply emails when conversation continuity is enabled MAILER_INBOUND_EMAIL_DOMAIN= -# Set this to appropriate ingress channel with regards to incoming emails +# Set this to the appropriate ingress channel with regards to incoming emails # Possible values are : # relay for Exim, Postfix, Qmail # mailgun for Mailgun @@ -91,10 +91,20 @@ MAILER_INBOUND_EMAIL_DOMAIN= RAILS_INBOUND_EMAIL_SERVICE= # Use one of the following based on the email ingress service # Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html +# Set this to a password of your choice and use it in the Inbound webhook RAILS_INBOUND_EMAIL_PASSWORD= + MAILGUN_INGRESS_SIGNING_KEY= MANDRILL_INGRESS_API_KEY= +# Creating Your Inbound Webhook Instructions for Postmark and Sendgrid: +# Inbound webhook URL format: +# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails +# Note: Replace the values inside the brackets; do not include the brackets themselves. +# Example: https://actionmailbox:mYRandomPassword3@chatwoot.example.com/rails/action_mailbox/postmark/inbound_emails +# For Postmark +# Ensure the 'Include raw email content in JSON payload' checkbox is selected in the inbound webhook section. + # Storage ACTIVE_STORAGE_SERVICE=local @@ -176,6 +186,9 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38: # MICROSOFT CLARITY # MS_CLARITY_TOKEN=xxxxxxxxx +# GOOGLE_TAG_MANAGER +# GOOGLE_TAG = GTM-XXXXXXX + ## Scout ## https://scoutapm.com/docs/ruby/configuration # SCOUT_KEY=YOURKEY @@ -240,3 +253,9 @@ AZURE_APP_SECRET= # Sentiment analysis model file path SENTIMENT_FILE_PATH= + + +# Housekeeping/Performance related configurations +# Set to true if you want to remove stale contact inboxes +# contact_inboxes with no conversation older than 90 days will be removed +# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false diff --git a/.eslintrc.js b/.eslintrc.js index a58c77e98..03e7e995b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,6 +4,7 @@ module.exports = { 'prettier', 'plugin:vue/recommended', 'plugin:storybook/recommended', + 'plugin:cypress/recommended', ], parserOptions: { parser: '@babel/eslint-parser', diff --git a/Gemfile b/Gemfile index d68bbd8de..a40b0f816 100644 --- a/Gemfile +++ b/Gemfile @@ -107,16 +107,16 @@ gem 'google-cloud-translate-v3' gem 'ddtrace', require: false gem 'elastic-apm', require: false gem 'newrelic_rpm', require: false -gem 'newrelic-sidekiq-metrics', require: false +gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false gem 'scout_apm', require: false -gem 'sentry-rails', '>= 5.12.0', require: false +gem 'sentry-rails', '>= 5.13.0', require: false gem 'sentry-ruby', require: false -gem 'sentry-sidekiq', '>= 5.12.0', require: false +gem 'sentry-sidekiq', '>= 5.13.0', require: false ##-- background job processing --## gem 'sidekiq', '>= 7.1.3' # We want cron jobs -gem 'sidekiq-cron', '>= 1.10.1' +gem 'sidekiq-cron', '>= 1.11.0' ##-- Push notification service --## gem 'fcm' @@ -159,7 +159,7 @@ gem 'lograge', '~> 0.14.0', require: false # worked with microsoft refresh token gem 'omniauth-oauth2' -gem 'audited', '~> 5.4', '>= 5.4.0' +gem 'audited', '~> 5.4', '>= 5.4.1' # need for google auth gem 'omniauth' @@ -200,6 +200,8 @@ group :development do # profiling gem 'rack-mini-profiler', '>= 3.1.1', require: false gem 'stackprof' + # Should install the associated chrome extension to view query logs + gem 'meta_request' end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index a47f351c4..8c782dd48 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,9 +126,9 @@ GEM rake (>= 10.4, < 14.0) ast (2.4.2) attr_extras (7.1.0) - audited (5.4.0) - activerecord (>= 5.0, < 7.2) - request_store (~> 1.2) + audited (5.4.1) + activerecord (>= 5.0, < 7.7) + activesupport (>= 5.0, < 7.7) aws-eventstream (1.2.0) aws-partitions (1.760.0) aws-sdk-core (3.171.1) @@ -148,6 +148,7 @@ GEM barnes (0.0.9) multi_json (~> 1) statsd-ruby (~> 1.1) + base64 (0.1.1) bcrypt (3.1.19) bindex (0.8.1) blingfire (0.1.8) @@ -174,7 +175,7 @@ GEM rexml crass (1.0.6) csv-safe (3.2.1) - cypress-on-rails (1.13.1) + cypress-on-rails (1.16.0) rack database_cleaner (2.0.2) database_cleaner-active_record (>= 2, < 3) @@ -261,7 +262,7 @@ GEM rake flag_shih_tzu (0.3.23) foreman (0.87.2) - fugit (1.8.1) + fugit (1.9.0) et-orbi (~> 1, >= 1.2.7) raabro (~> 1.4) gapic-common (0.18.0) @@ -450,13 +451,16 @@ GEM marcel (1.0.2) maxminddb (0.1.22) memoist (0.16.2) + meta_request (0.7.4) + rack-contrib (>= 1.1, < 3) + railties (>= 3.0.0, < 7.1) method_source (1.0.0) mime-types (3.4.1) mime-types-data (~> 3.2015) mime-types-data (3.2023.0218.1) mini_magick (4.12.0) mini_mime (1.1.5) - mini_portile2 (2.8.4) + mini_portile2 (2.8.5) minitest (5.20.0) mock_redis (0.36.0) ruby2_keywords @@ -478,10 +482,11 @@ GEM net-smtp (0.3.3) net-protocol netrc (0.11.0) - newrelic-sidekiq-metrics (1.6.1) - newrelic_rpm (~> 8) + newrelic-sidekiq-metrics (1.6.2) + newrelic_rpm (>= 8.0.0) sidekiq - newrelic_rpm (8.16.0) + newrelic_rpm (9.6.0) + base64 nio4r (2.5.9) nokogiri (1.15.4) mini_portile2 (~> 2.8.2) @@ -553,10 +558,12 @@ GEM pundit (2.3.0) activesupport (>= 3.0.0) raabro (1.4.0) - racc (1.7.1) + racc (1.7.3) rack (2.2.8) rack-attack (6.7.0) rack (>= 1.0, < 4) + rack-contrib (2.4.0) + rack (< 4) rack-cors (2.0.1) rack (>= 2.0.0) rack-mini-profiler (3.1.1) @@ -597,13 +604,13 @@ GEM thor (~> 1.0) zeitwerk (~> 2.5) rainbow (3.1.1) - rake (13.0.6) + rake (13.1.0) rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) redis (5.0.6) redis-client (>= 0.9.0) - redis-client (0.17.0) + redis-client (0.18.0) connection_pool redis-namespace (1.10.0) redis (>= 4) @@ -702,23 +709,23 @@ GEM activesupport (>= 4) selectize-rails (0.12.6) semantic_range (3.0.0) - sentry-rails (5.12.0) + sentry-rails (5.13.0) railties (>= 5.0) - sentry-ruby (~> 5.12.0) - sentry-ruby (5.12.0) + sentry-ruby (~> 5.13.0) + sentry-ruby (5.13.0) concurrent-ruby (~> 1.0, >= 1.0.2) - sentry-sidekiq (5.12.0) - sentry-ruby (~> 5.12.0) + sentry-sidekiq (5.13.0) + sentry-ruby (~> 5.13.0) sidekiq (>= 3.0) sexp_processor (4.17.0) shoulda-matchers (5.3.0) activesupport (>= 5.2.0) - sidekiq (7.1.6) + sidekiq (7.2.0) concurrent-ruby (< 2) connection_pool (>= 2.3.0) rack (>= 2.2.4) redis-client (>= 0.14.0) - sidekiq-cron (1.10.1) + sidekiq-cron (1.11.0) fugit (~> 1.8) globalid (>= 1.0.1) sidekiq (>= 6) @@ -758,7 +765,7 @@ GEM stripe (8.5.0) telephone_number (1.4.20) test-prof (1.2.1) - thor (1.2.2) + thor (1.3.0) tilt (2.2.0) time_diff (0.3.0) activesupport @@ -838,7 +845,7 @@ DEPENDENCIES administrate-field-belongs_to_search annotate attr_extras - audited (~> 5.4, >= 5.4.0) + audited (~> 5.4, >= 5.4.1) aws-sdk-s3 azure-storage-blob! barnes @@ -893,9 +900,10 @@ DEPENDENCIES listen lograge (~> 0.14.0) maxminddb + meta_request mock_redis neighbor - newrelic-sidekiq-metrics + newrelic-sidekiq-metrics (>= 1.6.2) newrelic_rpm omniauth omniauth-google-oauth2 @@ -927,12 +935,12 @@ DEPENDENCIES scout_apm scss_lint seed_dump - sentry-rails (>= 5.12.0) + sentry-rails (>= 5.13.0) sentry-ruby - sentry-sidekiq (>= 5.12.0) + sentry-sidekiq (>= 5.13.0) shoulda-matchers sidekiq (>= 7.1.3) - sidekiq-cron (>= 1.10.1) + sidekiq-cron (>= 1.11.0) simplecov (= 0.17.1) slack-ruby-client (~> 2.2.0) spring diff --git a/Procfile.test b/Procfile.test index 32792c245..1770cdbc6 100644 --- a/Procfile.test +++ b/Procfile.test @@ -1,3 +1,3 @@ backend: RAILS_ENV=test bin/rails s -p 5050 -frontend: bin/webpack-dev-server -worker: dotenv RAILS_ENV=test bundle exec sidekiq -C config/sidekiq.yml +frontend: export NODE_OPTIONS=--openssl-legacy-provider && bin/webpack-dev-server +worker: RAILS_ENV=test dotenv bundle exec sidekiq -C config/sidekiq.yml diff --git a/README.md b/README.md index ac11d649d..11ac5dc2c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ -

- Woot-logo +Chat dashboard dark mode +Chat dashboard -

Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.

-

+___ -

+# Chatwoot + +Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc. +

Deploy @@ -13,9 +15,7 @@

-___ - -

+

Maintainability CircleCI Badge Docker Pull Badge @@ -29,9 +29,8 @@ ___ Artifact HUB

-Chat dashboard - - +Chat dashboard dark mode +Chat dashboard Chatwoot is an open-source, self-hosted customer engagement suite. Chatwoot lets you view and manage your customer data, communicate with them irrespective of which medium they use, and re-engage them based on their profile. diff --git a/app/builders/messages/instagram/message_builder.rb b/app/builders/messages/instagram/message_builder.rb index e2c594376..5b2243906 100644 --- a/app/builders/messages/instagram/message_builder.rb +++ b/app/builders/messages/instagram/message_builder.rb @@ -76,6 +76,10 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present? end + def message_reply_attributes + message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present? + end + def build_message return if @outgoing_echo && already_sent_from_chatwoot? return if message_content.blank? && all_unsupported_files? @@ -118,7 +122,10 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder message_type: message_type, source_id: message_identifier, content: message_content, - sender: @outgoing_echo ? nil : contact + sender: @outgoing_echo ? nil : contact, + content_attributes: { + in_reply_to_external_id: message_reply_attributes + } } end diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index af312bf2e..0ec73edc3 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -9,11 +9,11 @@ class Messages::MessageBuilder @user = user @message_type = params[:message_type] || 'outgoing' @attachments = params[:attachments] - @automation_rule = @params&.dig(:content_attributes, :automation_rule_id) + @automation_rule = content_attributes&.dig(:automation_rule_id) return unless params.instance_of?(ActionController::Parameters) - @in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to) - @items = params.to_unsafe_h&.dig(:content_attributes, :items) + @in_reply_to = content_attributes&.dig(:in_reply_to) + @items = content_attributes&.dig(:items) end def perform @@ -26,6 +26,38 @@ class Messages::MessageBuilder private + # Extracts content attributes from the given params. + # - Converts ActionController::Parameters to a regular hash if needed. + # - Attempts to parse a JSON string if content is a string. + # - Returns an empty hash if content is not present, if there's a parsing error, or if it's an unexpected type. + def content_attributes + params = convert_to_hash(@params) + content_attributes = params.fetch(:content_attributes, {}) + + return parse_json(content_attributes) if content_attributes.is_a?(String) + return content_attributes if content_attributes.is_a?(Hash) + + {} + end + + # Converts the given object to a hash. + # If it's an instance of ActionController::Parameters, converts it to an unsafe hash. + # Otherwise, returns the object as-is. + def convert_to_hash(obj) + return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters) + + obj + end + + # Attempts to parse a string as JSON. + # If successful, returns the parsed hash with symbolized names. + # If unsuccessful, returns nil. + def parse_json(content) + JSON.parse(content, symbolize_names: true) + rescue JSON::ParserError + {} + end + def process_attachments return if @attachments.blank? diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb index 7762cc476..d164778ac 100644 --- a/app/controllers/api/v1/accounts/articles_controller.rb +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -10,7 +10,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController @articles_count = @all_articles.count @articles = if list_params[:category_slug].present? - @all_articles.order_by_position.page(@current_page).per(50) + @all_articles.order_by_position.page(@current_page) else @all_articles.order_by_updated_at.page(@current_page) end diff --git a/app/controllers/api/v1/accounts/categories_controller.rb b/app/controllers/api/v1/accounts/categories_controller.rb index 73fc5d885..834b19ed9 100644 --- a/app/controllers/api/v1/accounts/categories_controller.rb +++ b/app/controllers/api/v1/accounts/categories_controller.rb @@ -48,7 +48,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle def category_params params.require(:category).permit( - :name, :description, :position, :slug, :locale, :parent_category_id, :associated_category_id + :name, :description, :position, :slug, :locale, :icon, :parent_category_id, :associated_category_id ) end diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 6d2a181f0..bd8da5549 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -47,6 +47,11 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController head :ok end + def logo + @portal.logo.purge if @portal.logo.attached? + head :ok + end + def process_attached_logo blob_id = params[:blob_id] blob = ActiveStorage::Blob.find_by(id: blob_id) diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb index 229cf27e9..5b87e2d1a 100644 --- a/app/controllers/api/v1/widget/base_controller.rb +++ b/app/controllers/api/v1/widget/base_controller.rb @@ -79,6 +79,9 @@ class Api::V1::Widget::BaseController < ApplicationController sender: @contact, content: permitted_params[:message][:content], inbox_id: conversation.inbox_id, + content_attributes: { + in_reply_to: permitted_params[:message][:reply_to] + }, echo_id: permitted_params[:message][:echo_id], message_type: :incoming } diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index df0ef64ff..1d1ba8a7e 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -28,7 +28,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController conversation.contact_last_seen_at = DateTime.now.utc conversation.save! - ::Conversations::MarkMessagesAsReadJob.perform_later(conversation) + ::Conversations::UpdateMessageStatusJob.perform_later(conversation.id, conversation.contact_last_seen_at) head :ok end diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index 306a5f46f..a51b4c2d6 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -64,7 +64,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController def permitted_params # timestamp parameter is used in create conversation method - params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id]) + params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to]) end def set_message diff --git a/app/controllers/public/api/v1/inboxes/conversations_controller.rb b/app/controllers/public/api/v1/inboxes/conversations_controller.rb index c79952372..671863bdc 100644 --- a/app/controllers/public/api/v1/inboxes/conversations_controller.rb +++ b/app/controllers/public/api/v1/inboxes/conversations_controller.rb @@ -23,6 +23,7 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox def update_last_seen @conversation.contact_last_seen_at = DateTime.now.utc @conversation.save! + ::Conversations::UpdateMessageStatusJob.perform_later(@conversation.id, @conversation.contact_last_seen_at) head :ok end diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index bc5f6c0fd..4e967cfbb 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -39,10 +39,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B ) end - def portal - @portal ||= Portal.find_by!(slug: permitted_params[:slug], archived: false) - end - def list_params params.permit(:query, :locale, :sort, :status) end diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index d9fec27b1..df9526a69 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -14,10 +14,14 @@ class Public::Api::V1::Portals::BaseController < PublicController @theme = if %w[dark light].include?(params[:theme]) params[:theme] else - '' + 'system' end end + def portal + @portal ||= Portal.find_by!(slug: params[:slug], archived: false) + end + def set_locale(&) switch_locale_with_portal(&) if params[:locale].present? switch_locale_with_article(&) if params[:article_slug].present? @@ -40,6 +44,8 @@ class Public::Api::V1::Portals::BaseController < PublicController def switch_locale_with_article(&) article = Article.find_by(slug: params[:article_slug]) + Rails.logger.info "Article: not found for slug: #{params[:article_slug]}" + render_404 && return if article.blank? @locale = if article.category.present? article.category.locale @@ -53,4 +59,9 @@ class Public::Api::V1::Portals::BaseController < PublicController def allow_iframe_requests response.headers.delete('X-Frame-Options') if @is_plain_layout_enabled end + + def render_404 + portal + render 'public/api/v1/portals/error/404', status: :not_found + end end diff --git a/app/controllers/public/api/v1/portals/categories_controller.rb b/app/controllers/public/api/v1/portals/categories_controller.rb index 7326f383b..c3a7b59e9 100644 --- a/app/controllers/public/api/v1/portals/categories_controller.rb +++ b/app/controllers/public/api/v1/portals/categories_controller.rb @@ -13,10 +13,9 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals: private def set_category - @category = @portal.categories.find_by!(locale: params[:locale], slug: params[:category_slug]) - end + @category = @portal.categories.find_by(locale: params[:locale], slug: params[:category_slug]) - def portal - @portal ||= Portal.find_by!(slug: params[:slug], archived: false) + Rails.logger.info "Category: not found for slug: #{params[:category_slug]}" + render_404 && return if @category.blank? end end diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index 3551b5ac6..72d4eeda1 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -1,11 +1,48 @@ module PortalHelper def generate_portal_bg_color(portal_color, theme) base_color = theme == 'dark' ? 'black' : 'white' - "color-mix(in srgb, #{portal_color} 10%, #{base_color})" + "color-mix(in srgb, #{portal_color} 20%, #{base_color})" end def generate_portal_bg(portal_color, theme) - bg_image = theme == 'dark' ? 'grid_dark.svg' : 'grid.svg' + bg_image = theme == 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg' "background: url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}" end + + def language_name(locale) + language_map = YAML.load_file(Rails.root.join('config/languages/language_map.yml')) + language_map[locale] || locale + end + + def get_theme_names(theme) + if theme == 'light' + I18n.t('public_portal.header.appearance.light') + elsif theme == 'dark' + I18n.t('public_portal.header.appearance.dark') + else + I18n.t('public_portal.header.appearance.system') + end + end + + def get_theme_icon(theme) + if theme == 'light' + 'icons/sun' + elsif theme == 'dark' + 'icons/moon' + else + 'icons/monitor' + end + end + + def generate_gradient_to_bottom(theme) + "background-image: linear-gradient(to bottom, transparent, #{theme == 'dark' ? '#151718' : 'white'})" + end + + def generate_article_link(portal_slug, article_slug, theme) + "/hc/#{portal_slug}/articles/#{article_slug}#{theme.present? && theme != 'system' ? "?theme=#{theme}" : ''}" + end + + def render_category_content(content) + ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text + end end diff --git a/app/javascript/dashboard/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js index fb21a0847..e5847dc1b 100644 --- a/app/javascript/dashboard/api/helpCenter/articles.js +++ b/app/javascript/dashboard/api/helpCenter/articles.js @@ -1,6 +1,7 @@ /* global axios */ import PortalsAPI from './portals'; +import { getArticleSearchURL } from 'dashboard/helper/URLHelper.js'; class ArticlesAPI extends PortalsAPI { constructor() { @@ -12,14 +13,31 @@ class ArticlesAPI extends PortalsAPI { portalSlug, locale, status, - author_id, - category_slug, + authorId, + categorySlug, + sort, }) { - let baseUrl = `${this.url}/${portalSlug}/articles?page=${pageNumber}&locale=${locale}`; - if (status !== undefined) baseUrl += `&status=${status}`; - if (author_id) baseUrl += `&author_id=${author_id}`; - if (category_slug) baseUrl += `&category_slug=${category_slug}`; - return axios.get(baseUrl); + const url = getArticleSearchURL({ + pageNumber, + portalSlug, + locale, + status, + authorId, + categorySlug, + sort, + host: this.url, + }); + + return axios.get(url); + } + + searchArticles({ portalSlug, query }) { + const url = getArticleSearchURL({ + portalSlug, + query, + host: this.url, + }); + return axios.get(url); } getArticle({ id, portalSlug }) { diff --git a/app/javascript/dashboard/api/helpCenter/portals.js b/app/javascript/dashboard/api/helpCenter/portals.js index 28220b0b5..7c6210dbd 100644 --- a/app/javascript/dashboard/api/helpCenter/portals.js +++ b/app/javascript/dashboard/api/helpCenter/portals.js @@ -17,6 +17,10 @@ class PortalsAPI extends ApiClient { deletePortal(portalSlug) { return axios.delete(`${this.url}/${portalSlug}`); } + + deleteLogo(portalSlug) { + return axios.delete(`${this.url}/${portalSlug}/logo`); + } } export default PortalsAPI; diff --git a/app/javascript/dashboard/api/inbox/message.js b/app/javascript/dashboard/api/inbox/message.js index fc77b24f5..6cb885e3d 100644 --- a/app/javascript/dashboard/api/inbox/message.js +++ b/app/javascript/dashboard/api/inbox/message.js @@ -26,9 +26,13 @@ export const buildCreatePayload = ({ payload.append('echo_id', echoId); payload.append('cc_emails', ccEmails); payload.append('bcc_emails', bccEmails); + if (toEmails) { payload.append('to_emails', toEmails); } + if (contentAttributes) { + payload.append('content_attributes', JSON.stringify(contentAttributes)); + } } else { payload = { content: message, diff --git a/app/javascript/dashboard/api/specs/article.spec.js b/app/javascript/dashboard/api/specs/article.spec.js index b8886b4f8..02c0f82d8 100644 --- a/app/javascript/dashboard/api/specs/article.spec.js +++ b/app/javascript/dashboard/api/specs/article.spec.js @@ -34,7 +34,7 @@ describe('#PortalAPI', () => { portalSlug: 'room-rental', locale: 'en-US', status: 'published', - author_id: '1', + authorId: '1', }); expect(axiosMock.get).toHaveBeenCalledWith( '/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1' @@ -85,6 +85,33 @@ describe('#PortalAPI', () => { window.axios = originalAxios; }); + it('#searchArticles', () => { + articlesAPI.searchArticles({ + query: 'test', + portalSlug: 'room-rental', + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/portals/room-rental/articles?query=test' + ); + }); + }); + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: jest.fn(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve()), + patch: jest.fn(() => Promise.resolve()), + delete: jest.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + it('#updateArticle', () => { articlesAPI.updateArticle({ articleId: 1, diff --git a/app/javascript/dashboard/api/specs/inbox/message.spec.js b/app/javascript/dashboard/api/specs/inbox/message.spec.js index e75bf34b0..0d45b2157 100644 --- a/app/javascript/dashboard/api/specs/inbox/message.spec.js +++ b/app/javascript/dashboard/api/specs/inbox/message.spec.js @@ -50,6 +50,7 @@ describe('#ConversationAPI', () => { message: 'test content', echoId: 12, isPrivate: true, + contentAttributes: { in_reply_to: 12 }, files: [new Blob(['test-content'], { type: 'application/pdf' })], }); expect(formPayload).toBeInstanceOf(FormData); @@ -57,6 +58,10 @@ describe('#ConversationAPI', () => { expect(formPayload.get('echo_id')).toEqual('12'); expect(formPayload.get('private')).toEqual('true'); expect(formPayload.get('cc_emails')).toEqual(''); + expect(formPayload.get('bcc_emails')).toEqual(''); + expect(formPayload.get('content_attributes')).toEqual( + '{"in_reply_to":12}' + ); }); it('builds object payload if file is not available', () => { diff --git a/app/javascript/dashboard/assets/scss/_date-picker.scss b/app/javascript/dashboard/assets/scss/_date-picker.scss index 920fb8fa2..2132d5fd5 100644 --- a/app/javascript/dashboard/assets/scss/_date-picker.scss +++ b/app/javascript/dashboard/assets/scss/_date-picker.scss @@ -1,9 +1,5 @@ @import '~vue2-datepicker/scss/index'; -.mx-datepicker-popup { - @apply z-[99999]; -} - .date-picker { &.no-margin { .mx-input { @@ -29,10 +25,52 @@ .mx-input[readonly] { @apply bg-white dark:bg-slate-900 cursor-pointer; } + + .mx-icon-calendar { + @apply dark:text-slate-500; + } } -.mx-calendar-content .cell:hover { - @apply bg-slate-75 dark:bg-slate-700 text-slate-900 dark:text-slate-100; +.mx-datepicker-main { + @apply border-0 bg-white dark:bg-slate-800; + + .cell { + &.disabled { + @apply bg-slate-25 dark:bg-slate-900 text-slate-200 dark:text-slate-300; + } + + &:hover, + &.hover-in-range, + &.in-range { + @apply bg-slate-75 dark:bg-slate-700 text-slate-900 dark:text-slate-100; + } + } + + .mx-time { + @apply border-0 bg-white dark:bg-slate-800; + + .mx-time-header { + @apply border-0; + } + + .mx-time-item { + &.disabled { + @apply bg-slate-25 dark:bg-slate-900; + } + + &:hover { + @apply bg-slate-75 dark:bg-slate-700; + } + } + } + + .today { + @apply font-semibold; + } +} + +.mx-datepicker-popup { + @apply z-[99999]; } .mx-datepicker-inline { @@ -41,24 +79,4 @@ .mx-calendar { @apply w-full; } - - .cell.disabled { - @apply bg-slate-25 dark:bg-slate-900 text-slate-200 dark:text-slate-300; - } - - .mx-time-item.disabled { - @apply bg-slate-25 dark:bg-slate-900; - } - - .today { - @apply font-semibold; - } - - .mx-datepicker-main { - @apply border-0 bg-white dark:bg-slate-800; - } - - .mx-time-header { - @apply border-0; - } } diff --git a/app/javascript/dashboard/assets/scss/_formulate.scss b/app/javascript/dashboard/assets/scss/_formulate.scss index 57c43a3d6..d683ce3c7 100644 --- a/app/javascript/dashboard/assets/scss/_formulate.scss +++ b/app/javascript/dashboard/assets/scss/_formulate.scss @@ -16,3 +16,23 @@ width: 100%; } } + +.integration-hooks { + .formulate-input[data-type='checkbox'] { + .formulate-input-wrapper { + @apply flex; + + .formulate-input-element { + @apply pr-2; + + input { + @apply mb-0; + } + } + } + + .formulate-input-element-decorator { + @apply hidden; + } + } +} diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index b90ee30f6..194bf5e5d 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -207,6 +207,7 @@ import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCust import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue'; import alertMixin from 'shared/mixins/alertMixin'; import filterMixin from 'shared/mixins/filterMixin'; +import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages'; import countries from 'shared/constants/countries'; import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper'; @@ -238,6 +239,7 @@ export default { eventListenerMixins, alertMixin, filterMixin, + uiSettingsMixin, ], props: { conversationInbox: { @@ -408,8 +410,12 @@ export default { }, conversationListPagination() { const conversationsPerPage = 25; + const hasChatsOnView = + this.chatsOnView && + Array.isArray(this.chatsOnView) && + !this.chatsOnView.length; const isNoFiltersOrFoldersAndChatListNotEmpty = - !this.hasAppliedFiltersOrActiveFolders && this.chatsOnView !== []; + !this.hasAppliedFiltersOrActiveFolders && hasChatsOnView; const isUnderPerPage = this.chatsOnView.length < conversationsPerPage && this.activeAssigneeTabCount < conversationsPerPage && @@ -515,6 +521,7 @@ export default { }, }, mounted() { + this.setFiltersFromUISettings(); this.$store.dispatch('setChatStatusFilter', this.activeStatus); this.$store.dispatch('setChatSortFilter', this.activeSortBy); this.resetAndFetchData(); @@ -544,6 +551,12 @@ export default { this.$store.dispatch('customViews/update', payloadData); this.closeAdvanceFiltersModal(); }, + setFiltersFromUISettings() { + const { conversations_filter_by: filterBy = {} } = this.uiSettings; + const { status, order_by: orderBy } = filterBy; + this.activeStatus = status || wootConstants.STATUS_TYPE.OPEN; + this.activeSortBy = orderBy || wootConstants.SORT_BY_TYPE.LATEST; + }, onClickOpenAddFoldersModal() { this.showAddFoldersModal = true; }, diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue b/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue index fe615ad3a..f291d221e 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue @@ -136,6 +136,7 @@ export default { toStateName: 'labels_list', showModalForNewItem: true, modalName: 'AddLabel', + dataTestid: 'sidebar-new-label-button', children: this.labels.map(label => ({ id: label.id, label: label.title, diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue index 112ce9ab2..05f6ebfe1 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue @@ -78,6 +78,7 @@ variant="clear" color-scheme="secondary" icon="add" + :data-testid="menuItem.dataTestid" @click="e => newLinkClick(e, navigate)" > {{ $t(`SIDEBAR.${menuItem.newLinkTag}`) }} diff --git a/app/javascript/dashboard/components/widgets/AIAssistanceButton.vue b/app/javascript/dashboard/components/widgets/AIAssistanceButton.vue index ab4047a1e..2d7951b05 100644 --- a/app/javascript/dashboard/components/widgets/AIAssistanceButton.vue +++ b/app/javascript/dashboard/components/widgets/AIAssistanceButton.vue @@ -61,6 +61,7 @@ export default { computed: { ...mapGetters({ currentChat: 'getSelectedChat', + isAChatwootInstance: 'globalConfig/isAChatwootInstance', }), isAICTAModalDismissed() { return this.uiSettings.is_open_ai_cta_modal_dismissed; @@ -70,7 +71,8 @@ export default { return ( this.isAdmin && !this.isAIIntegrationEnabled && - !this.isAICTAModalDismissed + !this.isAICTAModalDismissed && + this.isAChatwootInstance ); }, // Display a AI CTA button for agents and other admins who have not yet opened the AI assistance modal. diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 864f8ac35..33bca2e68 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -135,6 +135,7 @@ export default { // allowSignature is a kill switch, ensuring no signature methods // are triggered except when this flag is true allowSignature: { type: Boolean, default: false }, + channelType: { type: String, default: '' }, showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar }, data() { @@ -266,8 +267,11 @@ export default { sendWithSignature() { // this is considered the source of truth, we watch this property // on change, we toggle the signature in the editor - const { send_with_signature: isEnabled } = this.uiSettings; - return isEnabled && this.allowSignature && !this.isPrivate; + if (this.allowSignature && !this.isPrivate && this.channelType) { + return this.fetchSignatureFlagFromUiSettings(this.channelType); + } + + return false; }, }, watch: { @@ -442,7 +446,7 @@ export default { this.onFocus(); }, click: () => { - // this.isEditorMouseFocusedOnAnImage(); Enable it when the backend supports for message resize is done. + this.isEditorMouseFocusedOnAnImage(); }, blur: () => { this.onBlur(); @@ -670,7 +674,7 @@ export default { () => this.resetTyping(), TYPING_INDICATOR_IDLE_TIME ); - // this.updateImgToolbarOnDelete(); Enable it when the backend supports for message resize is done. + this.updateImgToolbarOnDelete(); }, onKeydown(event) { if (this.isEnterToSendEnabled()) { diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue index 8477accc2..eb9b26f30 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue @@ -101,7 +101,7 @@

@@ -109,6 +109,16 @@

+
diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue index ab7d577ac..01a6bb442 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue @@ -32,7 +32,7 @@ }} -
- {{ $t('CONVERSATION.REPLIED_TO_STORY') }} - - -
+
+ -
@@ -88,7 +80,6 @@ :story-id="`${storyId}`" :is-a-tweet="isATweet" :is-a-whatsapp-channel="isAWhatsAppChannel" - :has-instagram-story="hasInstagramStory" :is-email="isEmailContentType" :is-private="data.private" :message-type="data.message_type" @@ -137,19 +128,18 @@ @@ -1124,7 +1188,7 @@ export default { } .reply-box { - @apply border-t border-slate-50 dark:border-slate-700 bg-white dark:bg-slate-900; + @apply relative border-t border-slate-50 dark:border-slate-700 bg-white dark:bg-slate-900; &.is-private { @apply bg-yellow-50 dark:bg-yellow-200; diff --git a/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue b/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue index e03cc74ed..416af5d06 100644 --- a/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue +++ b/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue @@ -114,10 +114,6 @@ export default { type: Boolean, default: true, }, - hasInstagramStory: { - type: Boolean, - default: true, - }, messageType: { type: Number, default: 1, @@ -196,28 +192,46 @@ export default { if (!this.showStatusIndicators) { return false; } - + // Messages will be marked as sent for the Email channel if they have a source ID. if (this.isAnEmailChannel) { return !!this.sourceId; } - if (this.isAWhatsAppChannel || this.isATwilioChannel) { + if ( + this.isAWhatsAppChannel || + this.isATwilioChannel || + this.isAFacebookInbox || + this.isASmsInbox || + this.isATelegramChannel + ) { return this.sourceId && this.isSent; } + // All messages will be mark as sent for the Line channel, as there is no source ID. + if (this.isALineChannel) { + return true; + } + return false; }, showDeliveredIndicator() { if (!this.showStatusIndicators) { return false; } - - if (this.isAWhatsAppChannel || this.isATwilioChannel) { + if ( + this.isAWhatsAppChannel || + this.isATwilioChannel || + this.isASmsInbox || + this.isAFacebookInbox + ) { return this.sourceId && this.isDelivered; } - // We will consider messages as delivered for web widget inbox if they are sent - if (this.isAWebWidgetInbox) { + // All messages marked as delivered for the web widget inbox and API inbox once they are sent. + if (this.isAWebWidgetInbox || this.isAPIInbox) { return this.isSent; } + if (this.isALineChannel) { + return this.isDelivered; + } return false; }, @@ -225,14 +239,16 @@ export default { if (!this.showStatusIndicators) { return false; } - - if (this.isAWebWidgetInbox || this.isAPIInbox) { - const { contact_last_seen_at: contactLastSeenAt } = this.currentChat; - return contactLastSeenAt >= this.createdAt; + if ( + this.isAWhatsAppChannel || + this.isATwilioChannel || + this.isAFacebookInbox + ) { + return this.sourceId && this.isRead; } - if (this.isAWhatsAppChannel || this.isATwilioChannel) { - return this.sourceId && this.isRead; + if (this.isAWebWidgetInbox || this.isAPIInbox) { + return this.isRead; } return false; diff --git a/app/javascript/dashboard/components/widgets/conversation/bubble/Image.vue b/app/javascript/dashboard/components/widgets/conversation/bubble/Image.vue index 75042298a..d763d6bec 100644 --- a/app/javascript/dashboard/components/widgets/conversation/bubble/Image.vue +++ b/app/javascript/dashboard/components/widgets/conversation/bubble/Image.vue @@ -1,6 +1,6 @@ + + + + diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/portals/EditPortal.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/portals/EditPortal.vue index 53cc83c44..53a8bdf40 100644 --- a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/portals/EditPortal.vue +++ b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/portals/EditPortal.vue @@ -9,7 +9,7 @@ " :show-new-button="false" /> -
+
-
-
- +
+ +
diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/portals/EditPortalBasic.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/portals/EditPortalBasic.vue index c6d9f8b15..8e3579f02 100644 --- a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/portals/EditPortalBasic.vue +++ b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/portals/EditPortalBasic.vue @@ -7,6 +7,7 @@ $t('HELP_CENTER.PORTAL.EDIT.EDIT_BASIC_INFO.BUTTON_TEXT') " @submit="updatePortalSettings" + @delete-logo="deleteLogo" /> @@ -70,6 +71,19 @@ export default { this.showAlert(this.alertMessage); } }, + async deleteLogo() { + try { + const portalSlug = this.lastPortalSlug; + await this.$store.dispatch('portals/deleteLogo', { + portalSlug, + }); + } catch (error) { + this.alertMessage = + error?.message || + this.$t('HELP_CENTER.PORTAL.ADD.LOGO.IMAGE_DELETE_ERROR'); + this.showAlert(this.alertMessage); + } + }, }, }; diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/portals/PortalDetails.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/portals/PortalDetails.vue index 98aaf544f..2857867bb 100644 --- a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/portals/PortalDetails.vue +++ b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/portals/PortalDetails.vue @@ -40,9 +40,12 @@ export default { methods: { async createPortal(portal) { try { + const { blob_id: blobId } = portal; await this.$store.dispatch('portals/create', { portal, + blob_id: blobId, }); + this.alertMessage = this.$t( 'HELP_CENTER.PORTAL.ADD.API.SUCCESS_MESSAGE_FOR_BASIC' ); diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js index 9fcee3f45..e86aa5161 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js @@ -67,6 +67,11 @@ export const AUTOMATIONS = { name: 'Add a label', attributeI18nKey: 'ADD_LABEL', }, + { + key: 'remove_label', + name: 'Remove a label', + attributeI18nKey: 'REMOVE_LABEL', + }, { key: 'send_email_to_team', name: 'Send an email to team', @@ -556,6 +561,11 @@ export const AUTOMATION_ACTION_TYPES = [ label: 'Add a label', inputType: 'multi_select', }, + { + key: 'remove_label', + label: 'Remove a label', + inputType: 'multi_select', + }, { key: 'send_email_to_team', label: 'Send an email to team', diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue index 6f55c3801..8dafb3389 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue @@ -232,7 +232,7 @@ export default { }, { scope: - 'pages_manage_metadata,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages', + 'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages', } ); }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/facebook/Reauthorize.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/facebook/Reauthorize.vue index addb4ca8f..e94f8748c 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/facebook/Reauthorize.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/facebook/Reauthorize.vue @@ -80,7 +80,7 @@ export default { }, { scope: - 'pages_manage_metadata,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages', + 'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages', auth_type: 'reauthorize', } ); diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrationapps/NewHook.vue b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/NewHook.vue index 93eed8795..454f03dac 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrationapps/NewHook.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/NewHook.vue @@ -1,6 +1,6 @@