Compare commits

..
Author SHA1 Message Date
Pranav 20ba6fdc1a Update the script for Inbox Migration 2026-02-23 16:55:15 -08:00
2121 changed files with 46331 additions and 99110 deletions
-65
View File
@@ -1,65 +0,0 @@
---
:position: before
:position_in_additional_file_patterns: before
:position_in_class: before
:position_in_factory: before
:position_in_fixture: before
:position_in_routes: before
:position_in_serializer: before
:position_in_test: before
:classified_sort: true
:exclude_controllers: true
:exclude_factories: true
:exclude_fixtures: true
:exclude_helpers: true
:exclude_scaffolds: true
:exclude_serializers: true
:exclude_sti_subclasses: false
:exclude_tests: true
:force: false
:format_markdown: false
:format_rdoc: false
:format_yard: false
:frozen: false
:grouped_polymorphic: false
:ignore_model_sub_dir: false
:ignore_unknown_models: false
:include_version: false
:show_check_constraints: false
:show_complete_foreign_keys: false
:show_foreign_keys: true
:show_indexes: true
:show_indexes_include: false
:simple_indexes: false
:sort: false
:timestamp: false
:trace: false
:with_comment: true
:with_column_comments: true
:with_table_comments: true
:position_of_column_comment: :with_name
:active_admin: false
:command:
:debug: false
:hide_default_column_types: json,jsonb,hstore
:hide_limit_column_types: integer,bigint,boolean
:timestamp_columns:
- created_at
- updated_at
:ignore_columns:
:ignore_routes:
:models: true
:routes: false
:skip_on_db_migrate: false
:target_action: :do_annotations
:wrapper:
:wrapper_close:
:wrapper_open:
:classes_default_to_s: []
:additional_file_patterns: []
:model_dir:
- app/models
- enterprise/app/models
:require: []
:root_dir:
- ''
-6
View File
@@ -1,9 +1,3 @@
--- ---
ignore: ignore:
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated) - CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
# Chatwoot defaults to Active Storage redirect-style URLs, and its recommended
# storage setup uses local/cloud storage with optional direct uploads to the
# storage provider rather than Rails proxy mode. Revisit if we enable
# rails_storage_proxy or other app-served Active Storage proxy routes.
- CVE-2026-33658
+2 -2
View File
@@ -93,8 +93,8 @@ jobs:
exit 1 exit 1
fi fi
mkdir -p ~/tmp mkdir -p ~/tmp
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.19.0/openapi-generator-cli-7.19.0.jar > ~/tmp/openapi-generator-cli-7.19.0.jar curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
java -jar ~/tmp/openapi-generator-cli-7.19.0.jar validate -i swagger/swagger.json java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
# Bundle audit # Bundle audit
- run: - run:
-9
View File
@@ -68,15 +68,6 @@
- Example: `feat(auth): add user authentication` - Example: `feat(auth): add user authentication`
- Don't reference Claude in commit messages - Don't reference Claude in commit messages
## PR Description Format
- Start with a short, user-facing paragraph describing the product change.
- Add a `Closes` section with relevant issue links (GitHub, Linear, etc.).
- For feature PRs, add `How to test` from a product/UX standpoint.
- For bugfix PRs, use `How to reproduce` when helpful.
- Optionally add a `What changed` section for implementation highlights.
- Do not add a `How this was tested` section listing specs/commands.
## Project-Specific ## Project-Specific
- **Translations**: - **Translations**:
+1 -2
View File
@@ -191,7 +191,7 @@ gem 'reverse_markdown'
gem 'iso-639' gem 'iso-639'
gem 'ruby-openai' gem 'ruby-openai'
gem 'ai-agents', '>= 0.9.1' gem 'ai-agents'
# TODO: Move this gem as a dependency of ai-agents # TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2' gem 'ruby_llm', '>= 1.8.2'
@@ -268,7 +268,6 @@ group :development, :test do
gem 'seed_dump' gem 'seed_dump'
gem 'shoulda-matchers' gem 'shoulda-matchers'
gem 'simplecov', '>= 0.21', require: false gem 'simplecov', '>= 0.21', require: false
gem 'skooma'
gem 'spring' gem 'spring'
gem 'spring-watcher-listen' gem 'spring-watcher-listen'
end end
+6 -17
View File
@@ -126,7 +126,7 @@ GEM
jbuilder (~> 2) jbuilder (~> 2)
rails (>= 4.2, < 7.2) rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6) selectize-rails (~> 0.6)
ai-agents (0.9.1) ai-agents (0.9.0)
ruby_llm (~> 1.9.1) ruby_llm (~> 1.9.1)
annotaterb (4.20.0) annotaterb (4.20.0)
activerecord (>= 6.0.0) activerecord (>= 6.0.0)
@@ -166,7 +166,7 @@ GEM
multi_json (~> 1) multi_json (~> 1)
statsd-ruby (~> 1.1) statsd-ruby (~> 1.1)
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.22) bcrypt (3.1.20)
benchmark (0.4.1) benchmark (0.4.1)
bigdecimal (3.2.2) bigdecimal (3.2.2)
bindex (0.8.1) bindex (0.8.1)
@@ -191,7 +191,7 @@ GEM
coderay (1.1.3) coderay (1.1.3)
commonmarker (0.23.10) commonmarker (0.23.10)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.5)
connection_pool (2.5.5) connection_pool (2.5.3)
crack (1.0.0) crack (1.0.0)
bigdecimal bigdecimal
rexml rexml
@@ -465,7 +465,7 @@ GEM
rails-dom-testing (>= 1, < 3) rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0) railties (>= 4.2.0)
thor (>= 0.14, < 2.0) thor (>= 0.14, < 2.0)
json (2.19.2) json (2.18.1)
json_refs (0.1.8) json_refs (0.1.8)
hana hana
json_schemer (0.2.24) json_schemer (0.2.24)
@@ -473,12 +473,6 @@ GEM
hana (~> 1.3) hana (~> 1.3)
regexp_parser (~> 2.0) regexp_parser (~> 2.0)
uri_template (~> 0.7) uri_template (~> 0.7)
json_skooma (0.2.5)
bigdecimal
hana (~> 1.3)
regexp_parser (~> 2.0)
uri-idna (~> 0.2)
zeitwerk (~> 2.6)
judoscale-rails (1.8.2) judoscale-rails (1.8.2)
judoscale-ruby (= 1.8.2) judoscale-ruby (= 1.8.2)
railties railties
@@ -742,7 +736,7 @@ GEM
ffi (~> 1.0) ffi (~> 1.0)
redis (5.0.6) redis (5.0.6)
redis-client (>= 0.9.0) redis-client (>= 0.9.0)
redis-client (0.26.4) redis-client (0.22.2)
connection_pool connection_pool
redis-namespace (1.10.0) redis-namespace (1.10.0)
redis (>= 4) redis (>= 4)
@@ -916,9 +910,6 @@ GEM
simplecov_json_formatter (~> 0.1) simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2) simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
skooma (0.3.7)
json_skooma (~> 0.2.5)
zeitwerk (~> 2.6)
slack-ruby-client (2.7.0) slack-ruby-client (2.7.0)
faraday (>= 2.0.1) faraday (>= 2.0.1)
faraday-mashify faraday-mashify
@@ -979,7 +970,6 @@ GEM
unicode-emoji (4.0.4) unicode-emoji (4.0.4)
uniform_notifier (1.17.0) uniform_notifier (1.17.0)
uri (1.1.1) uri (1.1.1)
uri-idna (0.3.1)
uri_template (0.7.0) uri_template (0.7.0)
valid_email2 (5.2.6) valid_email2 (5.2.6)
activemodel (>= 3.2) activemodel (>= 3.2)
@@ -1034,7 +1024,7 @@ DEPENDENCIES
administrate (>= 0.20.1) administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3) administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0) administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.9.1) ai-agents
annotaterb annotaterb
attr_extras attr_extras
audited (~> 5.4, >= 5.4.1) audited (~> 5.4, >= 5.4.1)
@@ -1153,7 +1143,6 @@ DEPENDENCIES
sidekiq_alive sidekiq_alive
simplecov (>= 0.21) simplecov (>= 0.21)
simplecov_json_formatter simplecov_json_formatter
skooma
slack-ruby-client (~> 2.7.0) slack-ruby-client (~> 2.7.0)
spring spring
spring-watcher-listen spring-watcher-listen
+2 -6
View File
@@ -40,12 +40,8 @@ run:
fi fi
force_run: force_run:
@echo "Cleaning up Overmind processes..." rm -f ./.overmind.sock
@lsof -ti:3036 2>/dev/null | xargs kill -9 2>/dev/null || true rm -f tmp/pids/*.pid
@lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true
@rm -f ./.overmind.sock
@rm -f tmp/pids/*.pid
@echo "Cleanup complete"
overmind start -f Procfile.dev overmind start -f Procfile.dev
force_run_tunnel: force_run_tunnel:
+1 -1
View File
@@ -1 +1 @@
4.12.1 4.11.1
+1 -1
View File
@@ -104,7 +104,7 @@ class ContactIdentifyAction
# blank identifier or email will throw unique index error # blank identifier or email will throw unique index error
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded # TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.discard_invalid_attrs if discard_invalid_attrs @contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save! if @contact.changed? @contact.save!
enqueue_avatar_job enqueue_avatar_job
end end
@@ -105,19 +105,15 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end end
def message_params def message_params
content_attributes = {
in_reply_to_external_id: response.in_reply_to_external_id
}
content_attributes[:external_echo] = true if @outgoing_echo
{ {
account_id: conversation.account_id, account_id: conversation.account_id,
inbox_id: conversation.inbox_id, inbox_id: conversation.inbox_id,
message_type: @message_type, message_type: @message_type,
status: @outgoing_echo ? :delivered : :sent,
content: response.content, content: response.content,
source_id: response.identifier, source_id: response.identifier,
content_attributes: content_attributes, content_attributes: {
in_reply_to_external_id: response.in_reply_to_external_id
},
sender: @outgoing_echo ? nil : @contact_inbox.contact sender: @outgoing_echo ? nil : @contact_inbox.contact
} }
end end
@@ -2,17 +2,12 @@ class Messages::Messenger::MessageBuilder
include ::FileTypeHelper include ::FileTypeHelper
def process_attachment(attachment) def process_attachment(attachment)
# This check handles very rare case if there are multiple files to attach with only one unsupported file # This check handles very rare case if there are multiple files to attach with only one usupported file
return if unsupported_file_type?(attachment['type']) return if unsupported_file_type?(attachment['type'])
params = attachment_params(attachment) attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
attachment_obj.save! attachment_obj.save!
if facebook_reel?(attachment) attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
update_facebook_reel_content(attachment)
elsif params[:remote_file_url]
attach_file(attachment_obj, params[:remote_file_url])
end
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention' fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story' fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post' fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
@@ -31,7 +26,7 @@ class Messages::Messenger::MessageBuilder
end end
def attachment_params(attachment) def attachment_params(attachment)
file_type = normalize_file_type(attachment['type']) file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id } params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
@@ -105,28 +100,6 @@ class Messages::Messenger::MessageBuilder
private private
# Facebook may send attachment types that don't directly match our file_type enum.
# Map known aliases to their canonical enum values.
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
def normalize_file_type(type)
sym = type.to_sym
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
end
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
# direct video URLs. Downloading these yields HTML, not video content.
def facebook_reel?(attachment)
attachment['type'].to_sym == :reel
end
def update_facebook_reel_content(attachment)
url = attachment.dig('payload', 'url')
return if url.blank?
@message.update!(content: url) if @message.content.blank?
end
def unsupported_file_type?(attachment_type) def unsupported_file_type?(attachment_type)
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym [:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
end end
@@ -40,7 +40,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end end
def reorder def reorder
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash]) Article.update_positions(params[:positions_hash])
head :ok head :ok
end end
@@ -1,7 +1,7 @@
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
before_action :portal before_action :portal
before_action :check_authorization before_action :check_authorization
before_action :fetch_category, except: [:index, :create, :reorder] before_action :fetch_category, except: [:index, :create]
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
def index def index
@@ -32,11 +32,6 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
head :ok head :ok
end end
def reorder
Category.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok
end
private private
def fetch_category def fetch_category
@@ -44,7 +39,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
end end
def portal def portal
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id]) @portal ||= Current.account.portals.find_by(slug: params[:portal_id])
end end
def related_categories_records def related_categories_records
@@ -1,55 +0,0 @@
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
extend ActiveSupport::Concern
included do
skip_before_action :check_authorization, only: [:health, :register_webhook]
before_action :check_admin_authorization?, only: [:register_webhook]
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def register_webhook
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
render json: { message: 'Webhook registered successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end
@@ -201,9 +201,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end end
def fetch_contact def fetch_contact
contact_scope = Current.account.contacts @contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
contact_scope = contact_scope.includes(contact_inboxes: [:inbox]) if @include_contact_inboxes
@contact = contact_scope.find(params[:id])
end end
def process_avatar_from_url def process_avatar_from_url
@@ -107,7 +107,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def toggle_typing_status def toggle_typing_status
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, Current.user, params) typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params)
typing_status_manager.toggle_typing_status typing_status_manager.toggle_typing_status
head :ok head :ok
end end
@@ -116,8 +116,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations. # High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
# Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load. # Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
# Always update immediately if there are unread messages to maintain accurate read/unread state. # Always update immediately if there are unread messages to maintain accurate read/unread state.
# Visiting a conversation should clear any unread inbox notifications for this conversation.
Notification::MarkConversationReadService.new(user: Current.user, account: Current.account, conversation: @conversation).perform
return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any? return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any?
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any? return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
@@ -1,7 +1,6 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
before_action :fetch_inbox before_action :fetch_inbox
before_action :validate_whatsapp_channel before_action :validate_whatsapp_channel
before_action :validate_captain_enabled, only: [:analyze]
def show def show
service = CsatTemplateManagementService.new(@inbox) service = CsatTemplateManagementService.new(@inbox)
@@ -25,23 +24,6 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end end
def analyze
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
result = CsatTemplateUtilityAnalysisService.new(
account: Current.account,
inbox: @inbox,
message: template_params[:message],
button_text: template_params[:button_text],
language: template_params[:language]
).perform
render json: result
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end
private private
def fetch_inbox def fetch_inbox
@@ -64,12 +46,6 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Message is required' }, status: :unprocessable_entity render json: { error: 'Message is required' }, status: :unprocessable_entity
end end
def validate_captain_enabled
return if Current.account.feature_enabled?('captain_integration')
render json: { error: 'Captain is required for template analysis' }, status: :forbidden
end
def render_template_creation_result(result) def render_template_creation_result(result)
if result[:success] if result[:success]
render_successful_template_creation(result) render_successful_template_creation(result)
@@ -4,9 +4,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create] before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox # we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show] before_action :check_authorization, except: [:show, :health]
before_action :validate_whatsapp_cloud_channel, only: [:health]
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
def index def index
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] })) @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
@@ -71,6 +70,23 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
end end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private private
def fetch_inbox def fetch_inbox
@@ -82,6 +98,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end end
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def create_channel def create_channel
return unless allowed_channel_types.include?(permitted_params[:channel][:type]) return unless allowed_channel_types.include?(permitted_params[:channel][:type])
@@ -178,6 +200,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def get_channel_attributes(channel_type) def get_channel_attributes(channel_type)
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : [] channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
end end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end end
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController') Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
@@ -126,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
return unless @hook&.access_token return unless @hook&.access_token
begin begin
linear_client = Linear.new(@hook.access_token, refresh_token: @hook.settings&.[]('refresh_token')) linear_client = Linear.new(@hook.access_token)
linear_client.revoke_token linear_client.revoke_token
rescue StandardError => e rescue StandardError => e
Rails.logger.error "Failed to revoke Linear token: #{e.message}" Rails.logger.error "Failed to revoke Linear token: #{e.message}"
@@ -79,7 +79,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params def portal_params
params.require(:portal).permit( params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link, :id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] } :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
) )
end end
@@ -100,7 +100,7 @@ class Api::V1::AccountsController < Api::BaseController
end end
def check_signup_enabled def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled? raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
end end
def validate_captcha def validate_captcha
@@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
contact = @contact contact = @contact
end end
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? @contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
identify_contact(contact) identify_contact(contact)
end end
@@ -43,15 +43,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end end
def set_conversation def set_conversation
return unless conversation.nil? @conversation = create_conversation if conversation.nil?
@conversation = create_conversation
apply_labels if permitted_params[:labels].present?
end
def apply_labels
valid_labels = inbox.account.labels.where(title: permitted_params[:labels]).pluck(:title)
@conversation.update_labels(valid_labels) if valid_labels.present?
end end
def message_finder_params def message_finder_params
@@ -72,14 +64,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def permitted_params def permitted_params
# timestamp parameter is used in create conversation method # timestamp parameter is used in create conversation method
# custom_attributes and labels are applied when a new conversation is created alongside the first message params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to])
params.permit(
:id, :before, :after, :website_token,
contact: [:name, :email],
message: [:content, :referer_url, :timestamp, :echo_id, :reply_to],
custom_attributes: {},
labels: []
)
end end
def set_message def set_message
@@ -58,7 +58,7 @@ class Api::V2::AccountsController < Api::BaseController
end end
def check_signup_enabled def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled? raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
end end
def validate_captcha def validate_captcha
@@ -1,6 +1,6 @@
module AccessTokenAuthHelper module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = { BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes], 'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations/messages' => ['create'], 'api/v1/accounts/conversations/messages' => ['create'],
'api/v1/accounts/conversations/assignments' => ['create'] 'api/v1/accounts/conversations/assignments' => ['create']
}.freeze }.freeze
@@ -28,7 +28,7 @@ module AccessTokenAuthHelper
def validate_bot_access_token! def validate_bot_access_token!
return if Current.user.is_a?(User) return if Current.user.is_a?(User)
return if @resource.is_a?(AgentBot) && agent_bot_accessible? return if agent_bot_accessible?
render_unauthorized('Access to this endpoint is not authorized for bots') render_unauthorized('Access to this endpoint is not authorized for bots')
end end
@@ -10,12 +10,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
private private
def sign_in_user def sign_in_user
# Capture before skip_confirmation! sets confirmed_at, which would
# make oauth_user_needs_password_reset? return false and skip the
# password reset for persisted unconfirmed users.
needs_password_reset = oauth_user_needs_password_reset?
@resource.skip_confirmation! if confirmable_enabled? @resource.skip_confirmation! if confirmable_enabled?
set_random_password_if_oauth_user if needs_password_reset
# once the resource is found and verified # once the resource is found and verified
# we can just send them to the login page again with the SSO params # we can just send them to the login page again with the SSO params
@@ -25,10 +20,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end end
def sign_in_user_on_mobile def sign_in_user_on_mobile
# See comment in sign_in_user for why this is captured before skip_confirmation!
needs_password_reset = oauth_user_needs_password_reset?
@resource.skip_confirmation! if confirmable_enabled? @resource.skip_confirmation! if confirmable_enabled?
set_random_password_if_oauth_user if needs_password_reset
# once the resource is found and verified # once the resource is found and verified
# we can just send them to the login page again with the SSO params # we can just send them to the login page again with the SSO params
@@ -45,7 +37,6 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain? return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
create_account_for_user create_account_for_user
set_random_password_if_oauth_user
token = @resource.send(:set_reset_password_token) token = @resource.send(:set_reset_password_token)
frontend_url = ENV.fetch('FRONTEND_URL', nil) frontend_url = ENV.fetch('FRONTEND_URL', nil)
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}" redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
@@ -60,7 +51,8 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end end
def account_signup_allowed? def account_signup_allowed?
GlobalConfigService.account_signup_enabled? # set it to true by default, this is the behaviour across the app
GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false'
end end
def resource_class(_mapping = nil) def resource_class(_mapping = nil)
@@ -90,15 +82,6 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image']) Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
end end
def oauth_user_needs_password_reset?
@resource.present? && (@resource.new_record? || !@resource.confirmed?)
end
def set_random_password_if_oauth_user
# Password must satisfy secure_password requirements (uppercase, lowercase, number, special char)
@resource.update(password: "#{SecureRandom.hex(16)}aA1!") if @resource.persisted?
end
def default_devise_mapping def default_devise_mapping
'user' 'user'
end end
+12 -39
View File
@@ -2,8 +2,6 @@ class Linear::CallbacksController < ApplicationController
include Linear::IntegrationHelper include Linear::IntegrationHelper
def show def show
return redirect_to(safe_linear_redirect_uri) if params[:code].blank? || account_id.blank?
@response = oauth_client.auth_code.get_token( @response = oauth_client.auth_code.get_token(
params[:code], params[:code],
redirect_uri: "#{base_url}/linear/callback" redirect_uri: "#{base_url}/linear/callback"
@@ -12,7 +10,7 @@ class Linear::CallbacksController < ApplicationController
handle_response handle_response
rescue StandardError => e rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}") Rails.logger.error("Linear callback error: #{e.message}")
redirect_to safe_linear_redirect_uri redirect_to linear_redirect_uri
end end
private private
@@ -33,19 +31,22 @@ class Linear::CallbacksController < ApplicationController
end end
def handle_response def handle_response
raise ArgumentError, 'Missing access token in Linear OAuth response' if parsed_body['access_token'].blank? hook = account.hooks.new(
hook = account.hooks.find_or_initialize_by(app_id: 'linear')
hook.assign_attributes(
access_token: parsed_body['access_token'], access_token: parsed_body['access_token'],
status: 'enabled', status: 'enabled',
settings: merged_integration_settings(hook.settings) app_id: 'linear',
settings: {
token_type: parsed_body['token_type'],
expires_in: parsed_body['expires_in'],
scope: parsed_body['scope']
}
) )
# You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251
hook.save! hook.save!
redirect_to linear_redirect_uri redirect_to linear_redirect_uri
rescue StandardError => e rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}") Rails.logger.error("Linear callback error: #{e.message}")
redirect_to safe_linear_redirect_uri redirect_to linear_redirect_uri
end end
def account def account
@@ -53,47 +54,19 @@ class Linear::CallbacksController < ApplicationController
end end
def account_id def account_id
return @account_id if instance_variable_defined?(:@account_id) return unless params[:state]
@account_id = params[:state].present? ? verify_linear_token(params[:state]) : nil verify_linear_token(params[:state])
end end
def linear_redirect_uri def linear_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear" "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear"
end end
def safe_linear_redirect_uri
return base_url if account_id.blank?
linear_redirect_uri
rescue StandardError
base_url
end
def parsed_body def parsed_body
@parsed_body ||= @response.response.parsed @parsed_body ||= @response.response.parsed
end end
def integration_settings
{
token_type: parsed_body['token_type'],
expires_in: parsed_body['expires_in'],
expires_on: expires_on,
scope: parsed_body['scope'],
refresh_token: parsed_body['refresh_token']
}.compact
end
def merged_integration_settings(existing_settings)
existing_settings.to_h.with_indifferent_access.merge(integration_settings)
end
def expires_on
return if parsed_body['expires_in'].blank?
(Time.current.utc + parsed_body['expires_in'].to_i.seconds).to_s
end
def base_url def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000') ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end end
@@ -1,101 +0,0 @@
class Platform::Api::V1::EmailChannelMigrationsController < PlatformController
before_action :set_account
before_action :validate_account_permissible
before_action :validate_feature_flag
before_action :validate_params
def create
results = migrate_email_channels
render json: { results: results }, status: :ok
end
private
def set_account
@account = Account.find(params[:account_id])
end
def validate_account_permissible
return if @platform_app.platform_app_permissibles.find_by(permissible: @account)
render json: { error: 'Non permissible resource' }, status: :unauthorized
end
def validate_feature_flag
return if ActiveModel::Type::Boolean.new.cast(ENV.fetch('EMAIL_CHANNEL_MIGRATION', false))
render json: { error: 'Email channel migration is not enabled' }, status: :forbidden
end
def validate_params
return render json: { error: 'Missing migrations parameter' }, status: :unprocessable_entity if migration_params.blank?
return unless migration_params.size > MAX_MIGRATIONS
return render json: { error: "Too many migrations (max #{MAX_MIGRATIONS})" },
status: :unprocessable_entity
end
def migrate_email_channels
migration_params.map { |entry| migrate_single(entry) }
end
MAX_MIGRATIONS = 25
SUPPORTED_PROVIDERS = %w[google microsoft].freeze
def migrate_single(entry)
validate_provider!(entry[:provider])
ActiveRecord::Base.transaction do
channel = create_channel(entry)
inbox = create_inbox(channel, entry)
{ email: entry[:email], inbox_id: inbox.id, channel_id: channel.id, status: 'success' }
end
rescue StandardError => e
{ email: entry[:email], status: 'error', message: e.message }
end
def create_channel(entry)
Channel::Email.create!(
account_id: @account.id,
email: entry[:email],
provider: entry[:provider],
provider_config: entry[:provider_config]&.to_h,
imap_enabled: entry.fetch(:imap_enabled, true),
imap_address: entry[:imap_address] || default_imap_address(entry[:provider]),
imap_port: entry[:imap_port] || 993,
imap_login: entry[:imap_login] || entry[:email],
imap_enable_ssl: entry.fetch(:imap_enable_ssl, true)
)
end
def create_inbox(channel, entry)
@account.inboxes.create!(
name: entry[:inbox_name] || "Migrated #{entry[:provider]&.capitalize}: #{entry[:email]}",
channel: channel
)
end
def validate_provider!(provider)
return if SUPPORTED_PROVIDERS.include?(provider)
raise ArgumentError, "Unsupported provider '#{provider}'. Must be one of: #{SUPPORTED_PROVIDERS.join(', ')}"
end
def default_imap_address(provider)
case provider
when 'google' then 'imap.gmail.com'
when 'microsoft' then 'outlook.office365.com'
else ''
end
end
def migration_params
params.permit(migrations: [
:email, :provider, :inbox_name,
:imap_enabled, :imap_address, :imap_port, :imap_login, :imap_enable_ssl,
{ provider_config: {} }
])[:migrations]
end
end
@@ -1,13 +1,11 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index] before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal before_action :portal
before_action :ensure_portal_feature_enabled
before_action :set_category, except: [:index, :show, :tracking_pixel] before_action :set_category, except: [:index, :show, :tracking_pixel]
before_action :set_article, only: [:show] before_action :set_article, only: [:show]
layout 'portal' layout 'portal'
def index def index
@search_query = list_params[:query]
@articles = @portal.articles.published.includes(:category, :author) @articles = @portal.articles.published.includes(:category, :author)
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present? @articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
@@ -75,9 +73,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end end
def list_params def list_params
@list_params ||= params.permit(:query, :locale, :sort, :status, :page, :per_page).tap do |permitted| params.permit(:query, :locale, :sort, :status, :page, :per_page)
permitted[:query] = permitted[:query].to_s.strip.presence
end
end end
def permitted_params def permitted_params
@@ -1,7 +1,6 @@
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index] before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal before_action :portal
before_action :ensure_portal_feature_enabled
before_action :set_category, only: [:show] before_action :set_category, only: [:show]
layout 'portal' layout 'portal'
@@ -1,8 +1,7 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show] before_action :ensure_custom_domain_request, only: [:show]
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal before_action :portal
before_action :ensure_portal_feature_enabled before_action :redirect_to_portal_with_locale, only: [:show]
layout 'portal' layout 'portal'
def show def show
@@ -25,7 +24,6 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
def redirect_to_portal_with_locale def redirect_to_portal_with_locale
return if params[:locale].present? return if params[:locale].present?
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}" redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end end
end end
-7
View File
@@ -18,11 +18,4 @@ class PublicController < ActionController::Base
Please send us an email at support@chatwoot.com with the custom domain name and account API key" Please send us an email at support@chatwoot.com with the custom domain name and account API key"
}, status: :unauthorized and return }, status: :unauthorized and return
end end
def ensure_portal_feature_enabled
return unless ChatwootApp.chatwoot_cloud?
return if @portal.account.feature_enabled?('help_center')
render 'public/api/v1/portals/not_active', status: :payment_required
end
end end
+1 -11
View File
@@ -77,23 +77,13 @@ class WidgetsController < ActionController::Base
end end
def allow_iframe_requests def allow_iframe_requests
if @web_widget.allowed_domains.blank? || embedded_from_non_web_origin? if @web_widget.allowed_domains.blank?
response.headers.delete('X-Frame-Options') response.headers.delete('X-Frame-Options')
else else
domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ') domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ')
response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}" response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}"
end end
end end
# Mobile WebViews (iOS/Android) load content from file:// or null origins,
# which cannot match any domain in frame-ancestors. When the per-inbox flag
# is enabled, skip frame-ancestors for these requests.
def embedded_from_non_web_origin?
return false unless @web_widget.allow_mobile_webview?
origin = request.headers['Origin']
origin.blank? || origin == 'null' || origin&.start_with?('file://')
end
end end
WidgetsController.prepend_mod_with('WidgetsController') WidgetsController.prepend_mod_with('WidgetsController')
+1 -1
View File
@@ -25,7 +25,7 @@ class UserDashboard < Administrate::BaseDashboard
current_sign_in_ip: Field::String, current_sign_in_ip: Field::String,
last_sign_in_ip: Field::String, last_sign_in_ip: Field::String,
confirmation_token: Field::String, confirmation_token: Field::String,
confirmed_at: ConfirmedAtField, confirmed_at: Field::DateTime,
confirmation_sent_at: Field::DateTime, confirmation_sent_at: Field::DateTime,
unconfirmed_email: Field::String, unconfirmed_email: Field::String,
name: Field::String.with_options(searchable: true), name: Field::String.with_options(searchable: true),
-4
View File
@@ -1,4 +0,0 @@
require 'administrate/field/base'
class ConfirmedAtField < Administrate::Field::DateTime
end
-1
View File
@@ -11,7 +11,6 @@ class ConversationFinder
'priority_desc' => %w[sort_on_priority desc], 'priority_desc' => %w[sort_on_priority desc],
'waiting_since_asc' => %w[sort_on_waiting_since asc], 'waiting_since_asc' => %w[sort_on_waiting_since asc],
'waiting_since_desc' => %w[sort_on_waiting_since desc], 'waiting_since_desc' => %w[sort_on_waiting_since desc],
'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc],
# To be removed in v3.5.0 # To be removed in v3.5.0
'latest' => %w[sort_on_last_activity_at desc], 'latest' => %w[sort_on_last_activity_at desc],
+5 -9
View File
@@ -47,15 +47,11 @@ module Filters::FilterHelper
def handle_additional_attributes(query_hash, filter_operator_value, data_type) def handle_additional_attributes(query_hash, filter_operator_value, data_type)
if data_type == 'text_case_insensitive' if data_type == 'text_case_insensitive'
ActiveRecord::Base.sanitize_sql_array( "LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
["LOWER(#{filter_config[:table_name]}.additional_attributes ->> ?) #{filter_operator_value} #{query_hash[:query_operator]}", "#{filter_operator_value} #{query_hash[:query_operator]}"
query_hash[:attribute_key]]
)
else else
ActiveRecord::Base.sanitize_sql_array( "#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
["#{filter_config[:table_name]}.additional_attributes ->> ? #{filter_operator_value} #{query_hash[:query_operator]} ", "#{filter_operator_value} #{query_hash[:query_operator]} "
query_hash[:attribute_key]]
)
end end
end end
@@ -74,7 +70,7 @@ module Filters::FilterHelper
def date_filter(current_filter, query_hash, filter_operator_value) def date_filter(current_filter, query_hash, filter_operator_value)
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \ "(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
"#{filter_operator_value} #{query_hash[:query_operator]}" "#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
end end
def text_case_insensitive_filter(query_hash, filter_operator_value) def text_case_insensitive_filter(query_hash, filter_operator_value)
-6
View File
@@ -1,10 +1,4 @@
module TimezoneHelper module TimezoneHelper
def timezone_name_from_params(timezone, offset)
return timezone if timezone.present? && ActiveSupport::TimeZone[timezone].present?
timezone_name_from_offset(offset)
end
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset] # ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
# would return the timezone without considering day light savings. To get the correct timezone, # would return the timezone without considering day light savings. To get the correct timezone,
# this method uses zone.now.utc_offset for comparison as referenced in the issues below # this method uses zone.now.utc_offset for comparison as referenced in the issues below
+1 -3
View File
@@ -98,9 +98,7 @@ export default {
mql.onchange = e => setColorTheme(e.matches); mql.onchange = e => setColorTheme(e.matches);
}, },
setLocale(locale) { setLocale(locale) {
if (locale) { this.$root.$i18n.locale = locale;
this.$root.$i18n.locale = locale;
}
}, },
async initializeAccount() { async initializeAccount() {
await this.$store.dispatch('accounts/get'); await this.$store.dispatch('accounts/get');
@@ -31,12 +31,6 @@ class CaptainCustomTools extends ApiClient {
delete(id) { delete(id) {
return axios.delete(`${this.url}/${id}`); return axios.delete(`${this.url}/${id}`);
} }
test(data = {}) {
return axios.post(`${this.url}/test`, {
custom_tool: data,
});
}
} }
export default new CaptainCustomTools(); export default new CaptainCustomTools();
+2 -2
View File
@@ -57,14 +57,14 @@ class ContactAPI extends ApiClient {
return axios.post(`${this.url}/${contactId}/labels`, { labels }); return axios.post(`${this.url}/${contactId}/labels`, { labels });
} }
search(search = '', page = 1, sortAttr = 'name', label = '', options = {}) { search(search = '', page = 1, sortAttr = 'name', label = '') {
let requestURL = `${this.url}/search?${buildContactParams( let requestURL = `${this.url}/search?${buildContactParams(
page, page,
sortAttr, sortAttr,
label, label,
search search
)}`; )}`;
return axios.get(requestURL, { signal: options.signal }); return axios.get(requestURL);
} }
active(page = 1, sortAttr = 'name') { active(page = 1, sortAttr = 'name') {
@@ -25,12 +25,6 @@ class CategoriesAPI extends PortalsAPI {
delete({ portalSlug, categoryId }) { delete({ portalSlug, categoryId }) {
return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`); return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`);
} }
reorder({ portalSlug, reorderedGroup }) {
return axios.post(`${this.url}/${portalSlug}/categories/reorder`, {
positions_hash: reorderedGroup,
});
}
} }
export default new CategoriesAPI(); export default new CategoriesAPI();
@@ -9,10 +9,6 @@ class InboxHealthAPI extends ApiClient {
getHealthStatus(inboxId) { getHealthStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/health`); return axios.get(`${this.url}/${inboxId}/health`);
} }
registerWebhook(inboxId) {
return axios.post(`${this.url}/${inboxId}/register_webhook`);
}
} }
export default new InboxHealthAPI(); export default new InboxHealthAPI();
-6
View File
@@ -42,12 +42,6 @@ class Inboxes extends CacheEnabledApiClient {
getCSATTemplateStatus(inboxId) { getCSATTemplateStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/csat_template`); return axios.get(`${this.url}/${inboxId}/csat_template`);
} }
analyzeCSATTemplateUtility(inboxId, template) {
return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, {
template,
});
}
} }
export default new Inboxes(); export default new Inboxes();
@@ -68,19 +68,7 @@ describe('#ContactsAPI', () => {
it('#search', () => { it('#search', () => {
contactAPI.search('leads', 1, 'date', 'customer-support'); contactAPI.search('leads', 1, 'date', 'customer-support');
expect(axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support', '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support'
{ signal: undefined }
);
});
it('#search with signal', () => {
const controller = new AbortController();
contactAPI.search('leads', 1, 'date', 'customer-support', {
signal: controller.signal,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
{ signal: controller.signal }
); );
}); });
@@ -8,6 +8,5 @@ describe('#BulkActionsAPI', () => {
expect(categoriesAPI).toHaveProperty('create'); expect(categoriesAPI).toHaveProperty('create');
expect(categoriesAPI).toHaveProperty('update'); expect(categoriesAPI).toHaveProperty('update');
expect(categoriesAPI).toHaveProperty('delete'); expect(categoriesAPI).toHaveProperty('delete');
expect(categoriesAPI).toHaveProperty('reorder');
}); });
}); });
@@ -106,10 +106,6 @@ select {
&[disabled] { &[disabled] {
@apply field-disabled; @apply field-disabled;
} }
option:not(:disabled) {
@apply bg-n-solid-2 text-n-slate-12;
}
} }
// Textarea // Textarea
@@ -1,6 +1,6 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
import RadioCard from './RadioCard.vue'; import RadioCard from '../RadioCard.vue';
const selectedOption = ref('round_robin'); const selectedOption = ref('round_robin');
@@ -1,5 +1,4 @@
<script setup> <script setup>
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
defineProps({ defineProps({
@@ -29,11 +28,7 @@ const handleButtonClick = () => {
{{ headerTitle }} {{ headerTitle }}
</span> </span>
<div <div
v-on-click-outside="[ v-on-clickaway="() => emit('close')"
() => emit('close'),
// This will prevent closing the modal when the editor Create link popup is open
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
]"
class="relative group/campaign-button" class="relative group/campaign-button"
> >
<Button <Button
@@ -1,63 +1,207 @@
<script setup> <script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { CONVERSATION_PRIORITY } from 'shared/constants/messages'; import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue'; defineProps({
const props = defineProps({
priority: { priority: {
type: String, type: String,
default: '', default: '',
}, },
showEmpty: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const icons = {
[CONVERSATION_PRIORITY.URGENT]: 'i-woot-priority-urgent',
[CONVERSATION_PRIORITY.HIGH]: 'i-woot-priority-high',
[CONVERSATION_PRIORITY.MEDIUM]: 'i-woot-priority-medium',
[CONVERSATION_PRIORITY.LOW]: 'i-woot-priority-low',
};
const priorityLabels = {
[CONVERSATION_PRIORITY.URGENT]: 'CONVERSATION.PRIORITY.OPTIONS.URGENT',
[CONVERSATION_PRIORITY.HIGH]: 'CONVERSATION.PRIORITY.OPTIONS.HIGH',
[CONVERSATION_PRIORITY.MEDIUM]: 'CONVERSATION.PRIORITY.OPTIONS.MEDIUM',
[CONVERSATION_PRIORITY.LOW]: 'CONVERSATION.PRIORITY.OPTIONS.LOW',
};
const iconName = computed(() => {
if (props.priority && icons[props.priority]) {
return icons[props.priority];
}
return props.showEmpty ? 'i-woot-priority-empty' : '';
});
const tooltipContent = computed(() => {
if (props.priority && priorityLabels[props.priority]) {
return t(priorityLabels[props.priority]);
}
if (props.showEmpty) {
return t('CONVERSATION.PRIORITY.OPTIONS.NONE');
}
return '';
}); });
</script> </script>
<!-- eslint-disable vue/no-static-inline-styles -->
<template> <template>
<Icon <div class="inline-flex items-center justify-center rounded-md">
v-tooltip.top="{ <!-- Low Priority -->
content: tooltipContent, <svg
delay: { show: 500, hide: 0 }, v-if="priority === CONVERSATION_PRIORITY.LOW"
}" width="20"
:icon="iconName" height="20"
class="size-4 text-n-slate-5" viewBox="0 0 20 20"
/> fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-slate-6"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-slate-6"
/>
</g>
</svg>
<!-- Medium Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-slate-6"
/>
</g>
</svg>
<!-- High Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.HIGH"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-amber-9"
/>
</g>
</svg>
<!-- Urgent Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.URGENT"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-ruby-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-ruby-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-ruby-9"
/>
</g>
</svg>
</div>
</template> </template>
@@ -28,7 +28,7 @@ const props = defineProps({
medium: { type: String, default: '' }, medium: { type: String, default: '' },
}); });
const emit = defineEmits(['update:modelValue', 'executeCopilotAction']); const emit = defineEmits(['update:modelValue']);
const slots = useSlots(); const slots = useSlots();
@@ -113,9 +113,6 @@ watch(
@input="handleInput" @input="handleInput"
@focus="handleFocus" @focus="handleFocus"
@blur="handleBlur" @blur="handleBlur"
@execute-copilot-action="
(...args) => emit('executeCopilotAction', ...args)
"
/> />
<div <div
v-if="showCharacterCount || slots.actions" v-if="showCharacterCount || slots.actions"
@@ -26,7 +26,6 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {
<EmptyStateLayout <EmptyStateLayout
:title="$t('HELP_CENTER.TITLE')" :title="$t('HELP_CENTER.TITLE')"
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')" :subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
class="bg-n-surface-1"
> >
<template #empty-state-item> <template #empty-state-item>
<div class="grid grid-cols-2 gap-4 p-px"> <div class="grid grid-cols-2 gap-4 p-px">
@@ -1,22 +1,8 @@
<script setup> <script setup>
import LocaleCard from './LocaleCard.vue'; import LocaleCard from './LocaleCard.vue';
const locales = [ const locales = [
{ { name: 'English', isDefault: true, articleCount: 29, categoryCount: 5 },
name: 'English', { name: 'Spanish', isDefault: false, articleCount: 29, categoryCount: 5 },
code: 'en',
isDefault: true,
isDraft: false,
articleCount: 29,
categoryCount: 5,
},
{
name: 'Spanish',
code: 'es',
isDefault: false,
isDraft: true,
articleCount: 29,
categoryCount: 5,
},
]; ];
</script> </script>
@@ -33,8 +19,6 @@ const locales = [
<LocaleCard <LocaleCard
:locale="locale.name" :locale="locale.name"
:is-default="locale.isDefault" :is-default="locale.isDefault"
:is-draft="locale.isDraft"
:locale-code="locale.code"
:article-count="locale.articleCount" :article-count="locale.articleCount"
:category-count="locale.categoryCount" :category-count="locale.categoryCount"
/> />
@@ -2,7 +2,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core'; import { useToggle } from '@vueuse/core';
import { buildLocaleMenuItems } from 'dashboard/helper/portalHelper'; import { LOCALE_MENU_ITEMS } from 'dashboard/helper/portalHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue'; import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
@@ -17,10 +17,6 @@ const props = defineProps({
type: Boolean, type: Boolean,
required: true, required: true,
}, },
isDraft: {
type: Boolean,
required: true,
},
localeCode: { localeCode: {
type: String, type: String,
required: true, required: true,
@@ -41,28 +37,11 @@ const { t } = useI18n();
const [showDropdownMenu, toggleDropdown] = useToggle(); const [showDropdownMenu, toggleDropdown] = useToggle();
const localeLabel = computed(() => `${props.locale} (${props.localeCode})`);
const localeMenuLabels = computed(() => ({
'change-default': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT'
),
'move-to-draft': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MOVE_TO_DRAFT'
),
'publish-locale': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
),
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
}));
const localeMenuItems = computed(() => const localeMenuItems = computed(() =>
buildLocaleMenuItems({ LOCALE_MENU_ITEMS.map(item => ({
isDefault: props.isDefault,
isDraft: props.isDraft,
}).map(item => ({
...item, ...item,
label: localeMenuLabels.value[item.action], label: t(item.label),
disabled: props.isDefault,
})) }))
); );
@@ -77,7 +56,7 @@ const handleAction = ({ action, value }) => {
<div class="flex justify-between gap-2"> <div class="flex justify-between gap-2">
<div class="flex items-center justify-start gap-2"> <div class="flex items-center justify-start gap-2">
<span class="text-sm font-medium text-n-slate-12 line-clamp-1"> <span class="text-sm font-medium text-n-slate-12 line-clamp-1">
{{ localeLabel }} {{ locale }} ({{ localeCode }})
</span> </span>
<span <span
v-if="isDefault" v-if="isDefault"
@@ -85,12 +64,6 @@ const handleAction = ({ action, value }) => {
> >
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }} {{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
</span> </span>
<span
v-else-if="isDraft"
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-slate-11 px-2 py-0.5"
>
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DRAFT') }}
</span>
</div> </div>
<div class="flex items-center justify-end gap-4"> <div class="flex items-center justify-end gap-4">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
@@ -113,7 +86,6 @@ const handleAction = ({ action, value }) => {
</span> </span>
</div> </div>
<div <div
v-if="localeMenuItems.length"
v-on-clickaway="() => toggleDropdown(false)" v-on-clickaway="() => toggleDropdown(false)"
class="relative group" class="relative group"
> >
@@ -58,22 +58,18 @@ const openArticle = id => {
} }
}; };
const onReorder = async reorderedGroup => { const onReorder = reorderedGroup => {
try { store.dispatch('articles/reorder', {
await store.dispatch('articles/reorder', { reorderedGroup,
reorderedGroup, portalSlug: route.params.portalSlug,
portalSlug: route.params.portalSlug, });
});
} catch {
useAlert(t('HELP_CENTER.REORDER_ARTICLE.API.ERROR_MESSAGE'));
}
}; };
const onDragEnd = () => { const onDragEnd = () => {
// Collect and sort existing positions, falling back to index+1 for null/0 values // Reuse existing positions to maintain order within the current group
const sortedArticlePositions = localArticles.value const sortedArticlePositions = localArticles.value
.map((article, index) => article.position || index + 1) .map(article => article.position)
.sort((a, b) => a - b); .sort((a, b) => a - b); // Use custom sort to handle numeric values correctly
const orderedArticles = localArticles.value.map(article => article.id); const orderedArticles = localArticles.value.map(article => article.id);
@@ -98,17 +98,6 @@ const handleAction = ({ action, id, category: categoryData }) => {
deleteCategory(categoryData); deleteCategory(categoryData);
} }
}; };
const reorderCategories = async reorderedGroup => {
try {
await store.dispatch('categories/reorder', {
portalSlug: route.params.portalSlug,
reorderedGroup,
});
} catch {
useAlert(t('HELP_CENTER.REORDER_CATEGORY.API.ERROR_MESSAGE'));
}
};
</script> </script>
<template> <template>
@@ -133,7 +122,6 @@ const reorderCategories = async reorderedGroup => {
:categories="categories" :categories="categories"
@click="openCategoryArticles" @click="openCategoryArticles"
@action="handleAction" @action="handleAction"
@reorder="reorderCategories"
/> />
<CategoryEmptyState <CategoryEmptyState
v-else v-else
@@ -1,22 +1,14 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue';
import Draggable from 'vuedraggable';
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue'; import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
const props = defineProps({ defineProps({
categories: { categories: {
type: Array, type: Array,
required: true, required: true,
}, },
}); });
const emit = defineEmits(['click', 'action', 'reorder']); const emit = defineEmits(['click', 'action']);
const localCategories = ref(props.categories);
const dragEnabled = computed(() => {
return localCategories.value?.length > 1;
});
const handleClick = slug => { const handleClick = slug => {
emit('click', slug); emit('click', slug);
@@ -25,57 +17,21 @@ const handleClick = slug => {
const handleAction = ({ action, value, id }, category) => { const handleAction = ({ action, value, id }, category) => {
emit('action', { action, value, id, category }); emit('action', { action, value, id, category });
}; };
const onDragEnd = () => {
// Collect and sort existing positions, falling back to index+1 for null/0 values
const sortedPositions = localCategories.value
.map((category, index) => category.position || index + 1)
.sort((a, b) => a - b);
const reorderedGroup = localCategories.value.reduce(
(obj, category, index) => {
obj[category.id] = sortedPositions[index];
return obj;
},
{}
);
emit('reorder', reorderedGroup);
};
watch(
() => props.categories,
newCategories => {
localCategories.value = newCategories;
},
{ deep: true }
);
</script> </script>
<template> <template>
<Draggable <ul role="list" class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2">
v-model="localCategories" <CategoryCard
:disabled="!dragEnabled" v-for="category in categories"
item-key="id" :id="category.id"
tag="ul" :key="category.id"
role="list" :title="category.name"
class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2" :icon="category.icon"
@end="onDragEnd" :description="category.description"
> :articles-count="category.meta.articles_count || 0"
<template #item="{ element }"> :slug="category.slug"
<li class="list-none"> @click="handleClick(category.slug)"
<CategoryCard @action="handleAction($event, category)"
:id="element.id" />
:title="element.name" </ul>
:icon="element.icon"
:description="element.description"
:articles-count="element.meta?.articles_count || 0"
:slug="element.slug"
:class="{ 'cursor-grab': dragEnabled }"
@click="handleClick(element.slug)"
@action="handleAction($event, element)"
/>
</li>
</template>
</Draggable>
</template> </template>
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed, watch } from 'vue'; import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store'; import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables'; import { useAlert, useTrack } from 'dashboard/composables';
@@ -24,20 +24,12 @@ const dialogRef = ref(null);
const isUpdating = ref(false); const isUpdating = ref(false);
const selectedLocale = ref(''); const selectedLocale = ref('');
const localeStatus = ref('published');
const addedLocales = computed(() => { const addedLocales = computed(() => {
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {}; const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
return allowedLocales.map(locale => locale.code); return allowedLocales.map(locale => locale.code);
}); });
const draftedLocales = computed(() => {
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
return allowedLocales
.filter(locale => locale.draft)
.map(locale => locale.code);
});
const locales = computed(() => { const locales = computed(() => {
return Object.keys(allLocales) return Object.keys(allLocales)
.map(key => { .map(key => {
@@ -49,44 +41,17 @@ const locales = computed(() => {
.filter(locale => !addedLocales.value.includes(locale.value)); .filter(locale => !addedLocales.value.includes(locale.value));
}); });
const statusOptions = computed(() => [
{
value: 'published',
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.LIVE'),
},
{
value: 'draft',
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.DRAFT'),
},
]);
const resetForm = () => {
selectedLocale.value = '';
localeStatus.value = 'published';
};
watch(localeStatus, value => {
if (!value) {
localeStatus.value = 'published';
}
});
const onCreate = async () => { const onCreate = async () => {
if (!selectedLocale.value) return; if (!selectedLocale.value) return;
isUpdating.value = true; isUpdating.value = true;
const updatedLocales = [...addedLocales.value, selectedLocale.value]; const updatedLocales = [...addedLocales.value, selectedLocale.value];
const updatedDraftLocales =
localeStatus.value === 'draft'
? [...new Set([...draftedLocales.value, selectedLocale.value])]
: draftedLocales.value;
try { try {
await store.dispatch('portals/update', { await store.dispatch('portals/update', {
portalSlug: props.portal?.slug, portalSlug: props.portal?.slug,
config: { config: {
allowed_locales: updatedLocales, allowed_locales: updatedLocales,
draft_locales: updatedDraftLocales,
default_locale: props.portal?.meta?.default_locale, default_locale: props.portal?.meta?.default_locale,
}, },
}); });
@@ -97,7 +62,7 @@ const onCreate = async () => {
from: route.name, from: route.name,
}); });
resetForm(); selectedLocale.value = '';
dialogRef.value?.close(); dialogRef.value?.close();
useAlert( useAlert(
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE') t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')
@@ -122,7 +87,6 @@ defineExpose({ dialogRef });
type="edit" type="edit"
:title="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.TITLE')" :title="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.TITLE')"
:description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')" :description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')"
@close="resetForm"
@confirm="onCreate" @confirm="onCreate"
> >
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
@@ -134,16 +98,6 @@ defineExpose({ dialogRef });
" "
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5" class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/> />
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.LABEL') }}
</span>
<ComboBox
v-model="localeStatus"
:options="statusOptions"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
</div> </div>
</Dialog> </Dialog>
</template> </template>
@@ -29,7 +29,6 @@ const isLocaleDefault = code => {
const updatePortalLocales = async ({ const updatePortalLocales = async ({
newAllowedLocales, newAllowedLocales,
newDraftLocales,
defaultLocale, defaultLocale,
messageKey, messageKey,
}) => { }) => {
@@ -40,7 +39,6 @@ const updatePortalLocales = async ({
config: { config: {
default_locale: defaultLocale, default_locale: defaultLocale,
allowed_locales: newAllowedLocales, allowed_locales: newAllowedLocales,
draft_locales: newDraftLocales,
}, },
}); });
@@ -55,12 +53,8 @@ const updatePortalLocales = async ({
const changeDefaultLocale = ({ localeCode }) => { const changeDefaultLocale = ({ localeCode }) => {
const newAllowedLocales = props.locales.map(locale => locale.code); const newAllowedLocales = props.locales.map(locale => locale.code);
const newDraftLocales = props.locales
.filter(locale => locale.isDraft)
.map(locale => locale.code);
updatePortalLocales({ updatePortalLocales({
newAllowedLocales, newAllowedLocales,
newDraftLocales,
defaultLocale: localeCode, defaultLocale: localeCode,
messageKey: 'CHANGE_DEFAULT_LOCALE', messageKey: 'CHANGE_DEFAULT_LOCALE',
}); });
@@ -87,15 +81,11 @@ const deletePortalLocale = async ({ localeCode }) => {
const updatedLocales = props.locales const updatedLocales = props.locales
.filter(locale => locale.code !== localeCode) .filter(locale => locale.code !== localeCode)
.map(locale => locale.code); .map(locale => locale.code);
const updatedDraftLocales = props.locales
.filter(locale => locale.code !== localeCode && locale.isDraft)
.map(locale => locale.code);
const defaultLocale = props.portal.meta.default_locale; const defaultLocale = props.portal.meta.default_locale;
await updatePortalLocales({ await updatePortalLocales({
newAllowedLocales: updatedLocales, newAllowedLocales: updatedLocales,
newDraftLocales: updatedDraftLocales,
defaultLocale, defaultLocale,
messageKey: 'DELETE_LOCALE', messageKey: 'DELETE_LOCALE',
}); });
@@ -108,46 +98,9 @@ const deletePortalLocale = async ({ localeCode }) => {
}); });
}; };
const updateDraftLocales = async ({ localeCode, shouldDraft, messageKey }) => {
const newAllowedLocales = props.locales.map(locale => locale.code);
const currentDraftLocales = props.locales
.filter(locale => locale.isDraft)
.map(locale => locale.code);
const newDraftLocales = shouldDraft
? [...new Set([...currentDraftLocales, localeCode])]
: currentDraftLocales.filter(locale => locale !== localeCode);
await updatePortalLocales({
newAllowedLocales,
newDraftLocales,
defaultLocale: props.portal.meta.default_locale,
messageKey,
});
};
const moveLocaleToDraft = async ({ localeCode }) => {
await updateDraftLocales({
localeCode,
shouldDraft: true,
messageKey: 'DRAFT_LOCALE',
});
};
const publishLocale = async ({ localeCode }) => {
await updateDraftLocales({
localeCode,
shouldDraft: false,
messageKey: 'PUBLISH_LOCALE',
});
};
const handleAction = ({ action }, localeCode) => { const handleAction = ({ action }, localeCode) => {
if (action === 'change-default') { if (action === 'change-default') {
changeDefaultLocale({ localeCode: localeCode }); changeDefaultLocale({ localeCode: localeCode });
} else if (action === 'move-to-draft') {
moveLocaleToDraft({ localeCode: localeCode });
} else if (action === 'publish-locale') {
publishLocale({ localeCode: localeCode });
} else if (action === 'delete') { } else if (action === 'delete') {
deletePortalLocale({ localeCode: localeCode }); deletePortalLocale({ localeCode: localeCode });
} }
@@ -161,7 +114,6 @@ const handleAction = ({ action }, localeCode) => {
:key="index" :key="index"
:locale="locale.name" :locale="locale.name"
:is-default="isLocaleDefault(locale.code)" :is-default="isLocaleDefault(locale.code)"
:is-draft="locale.isDraft"
:locale-code="locale.code" :locale-code="locale.code"
:article-count="locale.articlesCount || 0" :article-count="locale.articlesCount || 0"
:category-count="locale.categoriesCount || 0" :category-count="locale.categoriesCount || 0"
@@ -4,49 +4,37 @@ import LocalesPage from './LocalesPage.vue';
const locales = [ const locales = [
{ {
name: 'English (en-US)', name: 'English (en-US)',
code: 'en',
isDefault: true, isDefault: true,
isDraft: false,
articleCount: 5, articleCount: 5,
categoryCount: 5, categoryCount: 5,
}, },
{ {
name: 'Spanish (es-ES)', name: 'Spanish (es-ES)',
code: 'es',
isDefault: false, isDefault: false,
isDraft: true,
articleCount: 20, articleCount: 20,
categoryCount: 10, categoryCount: 10,
}, },
{ {
name: 'English (en-UK)', name: 'English (en-UK)',
code: 'en_GB',
isDefault: false, isDefault: false,
isDraft: false,
articleCount: 15, articleCount: 15,
categoryCount: 7, categoryCount: 7,
}, },
{ {
name: 'Malay (ms-MY)', name: 'Malay (ms-MY)',
code: 'ms',
isDefault: false, isDefault: false,
isDraft: false,
articleCount: 15, articleCount: 15,
categoryCount: 7, categoryCount: 7,
}, },
{ {
name: 'Malayalam (ml-IN)', name: 'Malayalam (ml-IN)',
code: 'ml',
isDefault: false, isDefault: false,
isDraft: false,
articleCount: 10, articleCount: 10,
categoryCount: 5, categoryCount: 5,
}, },
{ {
name: 'Hindi (hi-IN)', name: 'Hindi (hi-IN)',
code: 'hi',
isDefault: false, isDefault: false,
isDraft: false,
articleCount: 15, articleCount: 15,
categoryCount: 7, categoryCount: 7,
}, },
@@ -12,7 +12,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { import {
createContactSearcher, searchContacts,
createNewContact, createNewContact,
fetchContactableInboxes, fetchContactableInboxes,
processContactableInboxes, processContactableInboxes,
@@ -39,7 +39,6 @@ const props = defineProps({
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
const searchContacts = createContactSearcher();
const store = useStore(); const store = useStore();
const { t } = useI18n(); const { t } = useI18n();
const { width: windowWidth } = useWindowSize(); const { width: windowWidth } = useWindowSize();
@@ -108,17 +107,15 @@ const onContactSearch = debounce(
isSearching.value = true; isSearching.value = true;
contacts.value = []; contacts.value = [];
try { try {
const results = await searchContacts(query); contacts.value = await searchContacts(query);
// null means the request was aborted (a newer search is in-flight),
if (results === null) return;
contacts.value = results;
isSearching.value = false; isSearching.value = false;
} catch (error) { } catch (error) {
isSearching.value = false;
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE')); useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
} finally {
isSearching.value = false;
} }
}, },
400, 300,
false false
); );
@@ -141,7 +138,6 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
contact = rest; contact = rest;
} }
selectedContact.value = contact; selectedContact.value = contact;
contacts.value = [];
if (contact?.id) { if (contact?.id) {
isFetchingInboxes.value = true; isFetchingInboxes.value = true;
try { try {
@@ -277,7 +273,7 @@ useKeyboardEvents(keyboardEvents);
handleClickOutside, handleClickOutside,
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785 // Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
// This will prevent closing the compose conversation modal when the editor Create link popup is open // This will prevent closing the compose conversation modal when the editor Create link popup is open
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] }, { ignore: ['div.ProseMirror-prompt'] },
]" ]"
class="relative" class="relative"
:class="{ :class="{
@@ -15,9 +15,6 @@ import {
prepareWhatsAppMessagePayload, prepareWhatsAppMessagePayload,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js'; } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import ContactSelector from './ContactSelector.vue'; import ContactSelector from './ContactSelector.vue';
import InboxSelector from './InboxSelector.vue'; import InboxSelector from './InboxSelector.vue';
import EmailOptions from './EmailOptions.vue'; import EmailOptions from './EmailOptions.vue';
@@ -25,7 +22,6 @@ import MessageEditor from './MessageEditor.vue';
import ActionButtons from './ActionButtons.vue'; import ActionButtons from './ActionButtons.vue';
import InboxEmptyState from './InboxEmptyState.vue'; import InboxEmptyState from './InboxEmptyState.vue';
import AttachmentPreviews from './AttachmentPreviews.vue'; import AttachmentPreviews from './AttachmentPreviews.vue';
import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
const props = defineProps({ const props = defineProps({
contacts: { type: Array, default: () => [] }, contacts: { type: Array, default: () => [] },
@@ -46,7 +42,6 @@ const props = defineProps({
const emit = defineEmits([ const emit = defineEmits([
'searchContacts', 'searchContacts',
'resetContactSearch',
'discard', 'discard',
'updateSelectedContact', 'updateSelectedContact',
'updateTargetInbox', 'updateTargetInbox',
@@ -56,8 +51,6 @@ const emit = defineEmits([
const DEFAULT_FORMATTING = 'Context::Default'; const DEFAULT_FORMATTING = 'Context::Default';
const copilot = useCopilotReply();
const showContactsDropdown = ref(false); const showContactsDropdown = ref(false);
const showInboxesDropdown = ref(false); const showInboxesDropdown = ref(false);
const showCcEmailsDropdown = ref(false); const showCcEmailsDropdown = ref(false);
@@ -164,8 +157,22 @@ const isAnyDropdownActive = computed(() => {
}); });
const handleContactSearch = value => { const handleContactSearch = value => {
showContactsDropdown.value = value.trim().length > 1; showContactsDropdown.value = true;
emit('searchContacts', value); const query = typeof value === 'string' ? value.trim() : '';
const hasAlphabet = Array.from(query).some(char => {
const lower = char.toLowerCase();
const upper = char.toUpperCase();
return lower !== upper;
});
const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query);
const keys = ['email', 'phone_number', 'name'].filter(key => {
if (key === 'phone_number' && hasAlphabet) return false;
if (key === 'name' && isEmailLike) return false;
return true;
});
emit('searchContacts', { keys, query: value });
}; };
const handleDropdownUpdate = (type, value) => { const handleDropdownUpdate = (type, value) => {
@@ -179,17 +186,13 @@ const handleDropdownUpdate = (type, value) => {
}; };
const searchCcEmails = value => { const searchCcEmails = value => {
showBccEmailsDropdown.value = false; showCcEmailsDropdown.value = true;
emit('resetContactSearch'); emit('searchContacts', { keys: ['email'], query: value });
showCcEmailsDropdown.value = value.trim().length >= 2;
emit('searchContacts', value);
}; };
const searchBccEmails = value => { const searchBccEmails = value => {
showCcEmailsDropdown.value = false; showBccEmailsDropdown.value = true;
emit('resetContactSearch'); emit('searchContacts', { keys: ['email'], query: value });
showBccEmailsDropdown.value = value.trim().length >= 2;
emit('searchContacts', value);
}; };
const setSelectedContact = async ({ value, action, ...rest }) => { const setSelectedContact = async ({ value, action, ...rest }) => {
@@ -207,7 +210,6 @@ const stripMessageFormatting = channelType => {
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => { const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
v$.value.$reset(); v$.value.$reset();
copilot.reset(false);
// Strip unsupported formatting when changing the target inbox // Strip unsupported formatting when changing the target inbox
if (channelType) { if (channelType) {
@@ -234,7 +236,6 @@ const removeSignatureFromMessage = () => {
const removeTargetInbox = value => { const removeTargetInbox = value => {
v$.value.$reset(); v$.value.$reset();
copilot.reset(false);
removeSignatureFromMessage(); removeSignatureFromMessage();
stripMessageFormatting(DEFAULT_FORMATTING); stripMessageFormatting(DEFAULT_FORMATTING);
@@ -244,7 +245,6 @@ const removeTargetInbox = value => {
}; };
const clearSelectedContact = () => { const clearSelectedContact = () => {
copilot.reset(false);
removeSignatureFromMessage(); removeSignatureFromMessage();
emit('clearSelectedContact'); emit('clearSelectedContact');
state.message = ''; state.message = '';
@@ -276,7 +276,6 @@ const handleAttachFile = files => {
}; };
const clearForm = () => { const clearForm = () => {
copilot.reset(false);
Object.assign(state, { Object.assign(state, {
message: '', message: '',
subject: '', subject: '',
@@ -339,24 +338,6 @@ const shouldShowMessageEditor = computed(() => {
!inboxTypes.value.isTwilioWhatsapp !inboxTypes.value.isTwilioWhatsapp
); );
}); });
const isCopilotActive = computed(() => copilot.isActive?.value ?? false);
const onSubmitCopilotReply = () => {
const acceptedMessage = copilot.accept();
state.message = acceptedMessage;
};
useKeyboardEvents({
'$mod+Enter': {
action: () => {
if (isCopilotActive.value && !copilot.isButtonDisabled.value) {
onSubmitCopilotReply();
}
},
allowOnFocusedInput: true,
},
});
</script> </script>
<template> <template>
@@ -387,7 +368,6 @@ useKeyboardEvents({
:show-inboxes-dropdown="showInboxesDropdown" :show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList" :contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid" :has-errors="validationStates.isInboxInvalid"
:is-fetching-inboxes="isFetchingInboxes"
@update-inbox="removeTargetInbox" @update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event" @toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction" @handle-inbox-action="handleInboxAction"
@@ -416,7 +396,6 @@ useKeyboardEvents({
:has-errors="validationStates.isMessageInvalid" :has-errors="validationStates.isMessageInvalid"
:channel-type="inboxChannelType" :channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''" :medium="targetInbox?.medium || ''"
:copilot="copilot"
/> />
<AttachmentPreviews <AttachmentPreviews
@@ -426,15 +405,7 @@ useKeyboardEvents({
/> />
</div> </div>
<CopilotReplyBottomPanel
v-if="isCopilotActive"
:is-generating-content="copilot.isButtonDisabled.value"
class="h-[3.25rem] !px-4 !py-2"
@submit="onSubmitCopilotReply"
@cancel="copilot.reset"
/>
<ActionButtons <ActionButtons
v-else
:attached-files="state.attachedFiles" :attached-files="state.attachedFiles"
:is-whatsapp-inbox="inboxTypes.isWhatsapp" :is-whatsapp-inbox="inboxTypes.isWhatsapp"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
@@ -44,16 +44,14 @@ const bccEmailsArray = computed(() =>
); );
const contactEmailsList = computed(() => { const contactEmailsList = computed(() => {
return props.contacts return props.contacts?.map(({ name, id, email }) => ({
?.filter(contact => contact.email) id,
.map(({ name, id, email }) => ({ label: email,
id, email,
label: email, thumbnail: { name: name, src: '' },
email, value: id,
thumbnail: { name: name, src: '' }, action: 'email',
value: id, }));
action: 'email',
}));
}); });
// Handle updates from TagInput and convert array back to string // Handle updates from TagInput and convert array back to string
@@ -99,6 +97,7 @@ const inputClass = computed(() => {
type="email" type="email"
allow-create allow-create
class="flex-1 min-h-7" class="flex-1 min-h-7"
@focus="emit('updateDropdown', 'cc', true)"
@input="emit('searchCcEmails', $event)" @input="emit('searchCcEmails', $event)"
@on-click-outside="emit('updateDropdown', 'cc', false)" @on-click-outside="emit('updateDropdown', 'cc', false)"
@update:model-value="handleCcUpdate" @update:model-value="handleCcUpdate"
@@ -132,6 +131,7 @@ const inputClass = computed(() => {
allow-create allow-create
class="flex-1 min-h-7" class="flex-1 min-h-7"
focus-on-mount focus-on-mount
@focus="emit('updateDropdown', 'bcc', true)"
@input="emit('searchBccEmails', $event)" @input="emit('searchBccEmails', $event)"
@on-click-outside="emit('updateDropdown', 'bcc', false)" @on-click-outside="emit('updateDropdown', 'bcc', false)"
@update:model-value="handleBccUpdate" @update:model-value="handleBccUpdate"
@@ -1,15 +1,9 @@
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template> <template>
<div <div
class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3" class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3"
> >
<span class="text-sm dark:text-n-amber-11 text-n-amber-11"> <span class="text-sm dark:text-n-amber-11 text-n-amber-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }} {{ $t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
</span> </span>
</div> </div>
</template> </template>
@@ -6,7 +6,6 @@ import { generateLabelForContactableInboxesList } from 'dashboard/components-nex
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({ const props = defineProps({
targetInbox: { targetInbox: {
@@ -29,10 +28,6 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isFetchingInboxes: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits([ const emit = defineEmits([
@@ -76,9 +71,7 @@ const targetInboxLabel = computed(() => {
v-on-click-outside="() => emit('toggleDropdown', false)" v-on-click-outside="() => emit('toggleDropdown', false)"
class="relative flex items-center h-7" class="relative flex items-center h-7"
> >
<Spinner v-if="isFetchingInboxes" :size="16" />
<Button <Button
v-else
:label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')" :label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')"
variant="link" variant="link"
size="sm" size="sm"
@@ -3,7 +3,6 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Editor from 'dashboard/components-next/Editor/Editor.vue'; import Editor from 'dashboard/components-next/Editor/Editor.vue';
import CopilotEditorSection from 'dashboard/components/widgets/conversation/CopilotEditorSection.vue';
const props = defineProps({ const props = defineProps({
hasErrors: { type: Boolean, default: false }, hasErrors: { type: Boolean, default: false },
@@ -11,7 +10,6 @@ const props = defineProps({
messageSignature: { type: String, default: '' }, messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' }, channelType: { type: String, default: '' },
medium: { type: String, default: '' }, medium: { type: String, default: '' },
copilot: { type: Object, default: null },
}); });
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`); const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
@@ -22,67 +20,29 @@ const modelValue = defineModel({
type: String, type: String,
default: '', default: '',
}); });
const isCopilotActive = computed(() => props.copilot?.isActive?.value ?? false);
const executeCopilotAction = (action, data) => {
if (props.copilot) {
props.copilot.execute(action, data);
}
};
</script> </script>
<template> <template>
<div class="flex-1 h-full px-4 py-4"> <div class="flex-1 h-full">
<Transition <Editor
mode="out-in" v-model="modelValue"
enter-active-class="transition-all duration-300 ease-out" :editor-key="editorKey"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]" :placeholder="
enter-to-class="opacity-100 translate-y-0 scale-100" t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
leave-active-class="transition-all duration-200 ease-in" "
leave-from-class="opacity-100 translate-y-0 scale-100" class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]" :class="
> hasErrors
<div ? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
:key="copilot ? copilot.editorTransitionKey.value : 'rich'" : ''
class="h-full" "
> enable-variables
<CopilotEditorSection :show-character-count="false"
v-if="isCopilotActive" :signature="messageSignature"
:show-copilot-editor="copilot.showEditor.value" allow-signature
:is-generating-content="copilot.isGenerating.value" :send-with-signature="sendWithSignature"
:generated-content="copilot.generatedContent.value" :channel-type="channelType"
class="!mb-0" :medium="medium"
@focus="() => {}" />
@blur="() => {}"
@clear-selection="() => {}"
@content-ready="copilot.setContentReady"
@send="copilot.sendFollowUp"
/>
<Editor
v-else
v-model="modelValue"
:editor-key="editorKey"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-0 [&>div]:py-0 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[12rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
enable-captain-tools
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
:medium="medium"
@execute-copilot-action="executeCopilotAction"
/>
</div>
</Transition>
</div> </div>
</template> </template>
@@ -176,45 +176,40 @@ export const prepareWhatsAppMessagePayload = ({
}; };
}; };
// API Calls export const generateContactQuery = ({ keys = ['email'], query }) => {
const MIN_SEARCH_LENGTH = 2; return {
payload: keys.map(key => {
export const createContactSearcher = () => { const filterPayload = {
let controller = null; attribute_key: key,
filter_operator: 'contains',
return async (query, { skipMinLength = false } = {}) => { values: [query],
const trimmed = typeof query === 'string' ? query.trim() : ''; attribute_model: 'standard',
};
controller?.abort(); if (keys.findIndex(k => k === key) !== keys.length - 1) {
filterPayload.query_operator = 'or';
if (!trimmed || (!skipMinLength && trimmed.length < MIN_SEARCH_LENGTH))
return [];
controller = new AbortController();
const { signal } = controller;
try {
const {
data: { payload },
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email
);
return filteredPayload || [];
} catch (error) {
// Return null for aborted requests so callers can distinguish
// "request was cancelled" from "no results found"
if (error?.name === 'AbortError' || error?.name === 'CanceledError') {
return null;
} }
throw error; return filterPayload;
} }),
}; };
}; };
// API Calls
export const searchContacts = async ({ keys, query }) => {
const {
data: { payload },
} = await ContactAPI.filter(
undefined,
'name',
generateContactQuery({ keys, query })
);
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email
);
return filteredPayload || [];
};
export const createNewContact = async input => { export const createNewContact = async input => {
const payload = { const payload = {
name: input.startsWith('+') name: input.startsWith('+')
@@ -336,13 +336,72 @@ describe('composeConversationHelper', () => {
}); });
}); });
describe('API calls', () => { describe('generateContactQuery', () => {
describe('createContactSearcher', () => { it('generates correct query structure for contact search', () => {
let searchContacts; const query = 'test@example.com';
beforeEach(() => { const expected = {
searchContacts = helpers.createContactSearcher(); payload: [
}); {
attribute_key: 'email',
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
},
],
};
expect(helpers.generateContactQuery({ keys: ['email'], query })).toEqual(
expected
);
});
it('handles empty query', () => {
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [''],
attribute_model: 'standard',
},
],
};
expect(
helpers.generateContactQuery({ keys: ['email'], query: '' })
).toEqual(expected);
});
it('handles mutliple keys', () => {
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
query_operator: 'or',
},
{
attribute_key: 'phone_number',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
};
expect(
helpers.generateContactQuery({
keys: ['email', 'phone_number'],
query: 'john',
})
).toEqual(expected);
});
});
describe('API calls', () => {
describe('searchContacts', () => {
it('searches contacts and returns camelCase results', async () => { it('searches contacts and returns camelCase results', async () => {
const mockPayload = [ const mockPayload = [
{ {
@@ -354,11 +413,14 @@ describe('composeConversationHelper', () => {
}, },
]; ];
ContactAPI.search.mockResolvedValue({ ContactAPI.filter.mockResolvedValue({
data: { payload: mockPayload }, data: { payload: mockPayload },
}); });
const result = await searchContacts('john'); const result = await helpers.searchContacts({
keys: ['email'],
query: 'john',
});
expect(result).toEqual([ expect(result).toEqual([
{ {
@@ -370,56 +432,16 @@ describe('composeConversationHelper', () => {
}, },
]); ]);
expect(ContactAPI.search).toHaveBeenCalledWith( expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
'john', payload: [
1, {
'name', attribute_key: 'email',
'', filter_operator: 'contains',
expect.objectContaining({ signal: expect.any(AbortSignal) }) values: ['john'],
); attribute_model: 'standard',
}); },
],
it('returns empty array for queries shorter than 2 characters', async () => {
const result = await searchContacts('j');
expect(result).toEqual([]);
expect(ContactAPI.search).not.toHaveBeenCalled();
});
it('returns empty array for empty or whitespace-only queries', async () => {
expect(await searchContacts('')).toEqual([]);
expect(await searchContacts(' ')).toEqual([]);
expect(await searchContacts(null)).toEqual([]);
expect(ContactAPI.search).not.toHaveBeenCalled();
});
it('aborts previous in-flight request when a new search starts', async () => {
const mockPayload = [
{ id: 1, name: 'Result', email: 'r@test.com', phone_number: null },
];
let resolveFirst;
const firstCall = new Promise(resolve => {
resolveFirst = resolve;
}); });
ContactAPI.search
.mockReturnValueOnce(firstCall)
.mockResolvedValueOnce({ data: { payload: mockPayload } });
// Start first search (will hang)
const first = searchContacts('alpha');
// Start second search (aborts first)
const second = searchContacts('beta');
// Resolve the first call with CanceledError (simulating axios abort)
const canceledError = new Error('canceled');
canceledError.name = 'CanceledError';
resolveFirst(Promise.reject(canceledError));
const [firstResult, secondResult] = await Promise.all([first, second]);
expect(firstResult).toBeNull();
expect(secondResult).toEqual([
{ id: 1, name: 'Result', email: 'r@test.com', phoneNumber: null },
]);
}); });
it('searches contacts and returns only contacts with email or phone number', async () => { it('searches contacts and returns only contacts with email or phone number', async () => {
@@ -447,11 +469,14 @@ describe('composeConversationHelper', () => {
}, },
]; ];
ContactAPI.search.mockResolvedValue({ ContactAPI.filter.mockResolvedValue({
data: { payload: mockPayload }, data: { payload: mockPayload },
}); });
const result = await searchContacts('john'); const result = await helpers.searchContacts({
keys: ['email'],
query: 'john',
});
// Should only return contacts with either email or phone number // Should only return contacts with either email or phone number
expect(result).toEqual([ expect(result).toEqual([
@@ -471,21 +496,24 @@ describe('composeConversationHelper', () => {
}, },
]); ]);
expect(ContactAPI.search).toHaveBeenCalledWith( expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
'john', payload: [
1, {
'name', attribute_key: 'email',
'', filter_operator: 'contains',
expect.objectContaining({ signal: expect.any(AbortSignal) }) values: ['john'],
); attribute_model: 'standard',
},
],
});
}); });
it('handles empty search results', async () => { it('handles empty search results', async () => {
ContactAPI.search.mockResolvedValue({ ContactAPI.filter.mockResolvedValue({
data: { payload: [] }, data: { payload: [] },
}); });
const result = await searchContacts('nonexistent'); const result = await helpers.searchContacts('nonexistent');
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
@@ -508,11 +536,11 @@ describe('composeConversationHelper', () => {
}, },
]; ];
ContactAPI.search.mockResolvedValue({ ContactAPI.filter.mockResolvedValue({
data: { payload: mockPayload }, data: { payload: mockPayload },
}); });
const result = await searchContacts('test'); const result = await helpers.searchContacts('test');
expect(result).toEqual([ expect(result).toEqual([
{ {
@@ -534,36 +562,6 @@ describe('composeConversationHelper', () => {
}); });
}); });
describe('createContactSearcher isolation', () => {
it('creates isolated searcher instances that do not cancel each other', async () => {
const searcherA = helpers.createContactSearcher();
const searcherB = helpers.createContactSearcher();
const payloadA = [
{ id: 1, name: 'Alice', email: 'a@test.com', phone_number: null },
];
const payloadB = [
{ id: 2, name: 'Bob', email: 'b@test.com', phone_number: null },
];
ContactAPI.search
.mockResolvedValueOnce({ data: { payload: payloadA } })
.mockResolvedValueOnce({ data: { payload: payloadB } });
const [resultA, resultB] = await Promise.all([
searcherA('alice'),
searcherB('bob'),
]);
expect(resultA).toEqual([
{ id: 1, name: 'Alice', email: 'a@test.com', phoneNumber: null },
]);
expect(resultB).toEqual([
{ id: 2, name: 'Bob', email: 'b@test.com', phoneNumber: null },
]);
});
});
describe('createNewContact', () => { describe('createNewContact', () => {
it('creates new contact with capitalized name', async () => { it('creates new contact with capitalized name', async () => {
const mockContact = { id: 1, name: 'John', email: 'john@example.com' }; const mockContact = { id: 1, name: 'John', email: 'john@example.com' };
@@ -14,10 +14,6 @@ defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
hideToggle: {
type: Boolean,
default: false,
},
}); });
const modelValue = defineModel({ type: Boolean, default: false }); const modelValue = defineModel({ type: Boolean, default: false });
@@ -32,8 +28,7 @@ const modelValue = defineModel({ type: Boolean, default: false });
<span class="text-heading-3 text-n-slate-12"> <span class="text-heading-3 text-n-slate-12">
{{ header }} {{ header }}
</span> </span>
<div v-if="hideToggle" class="size-2" /> <ToggleSwitch v-model="modelValue" />
<ToggleSwitch v-else v-model="modelValue" />
</div> </div>
<span v-if="description" class="text-body-main text-n-slate-11"> <span v-if="description" class="text-body-main text-n-slate-11">
{{ description }} {{ description }}
@@ -18,18 +18,10 @@ const newMessage = ref('');
const isLoading = ref(false); const isLoading = ref(false);
const formatMessagesForApi = () => { const formatMessagesForApi = () => {
return messages.value.map(message => { return messages.value.map(message => ({
const payload = { role: message.sender,
role: message.sender, content: message.content,
content: message.content, }));
};
if (message.sender === 'assistant' && message.agentName) {
payload.agent_name = message.agentName;
}
return payload;
});
}; };
const resetConversation = () => { const resetConversation = () => {
@@ -70,7 +62,6 @@ const sendMessage = async () => {
messages.value.push({ messages.value.push({
content: data.response, content: data.response,
sender: 'assistant', sender: 'assistant',
agentName: data.agent_name,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
} catch (error) { } catch (error) {
@@ -80,12 +71,6 @@ const sendMessage = async () => {
isLoading.value = false; isLoading.value = false;
} }
}; };
const handleEnterKey = event => {
if (event.isComposing) return;
event.preventDefault();
sendMessage();
};
</script> </script>
<template> <template>
@@ -119,7 +104,7 @@ const handleEnterKey = event => {
v-model="newMessage" v-model="newMessage"
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10" class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')" :placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
@keydown.enter.exact="handleEnterKey" @keyup.enter="sendMessage"
/> />
<NextButton <NextButton
ghost ghost
@@ -12,7 +12,6 @@ import {
import CardLayout from 'dashboard/components-next/CardLayout.vue'; import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({ const props = defineProps({
id: { id: {
@@ -35,34 +34,14 @@ const props = defineProps({
type: Number, type: Number,
required: true, required: true,
}, },
isSelected: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
showSelectionControl: {
type: Boolean,
default: false,
},
showMenu: {
type: Boolean,
default: true,
},
}); });
const emit = defineEmits(['action', 'select', 'hover']); const emit = defineEmits(['action']);
const { checkPermissions } = usePolicy(); const { checkPermissions } = usePolicy();
const { t } = useI18n(); const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle(); const [showActionsDropdown, toggleDropdown] = useToggle();
const modelValue = computed({
get: () => props.isSelected,
set: () => emit('select', props.id),
});
const menuItems = computed(() => { const menuItems = computed(() => {
const allOptions = [ const allOptions = [
@@ -100,23 +79,12 @@ const handleAction = ({ action, value }) => {
</script> </script>
<template> <template>
<CardLayout <CardLayout>
:selectable="selectable"
class="relative"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div
v-show="showSelectionControl"
class="absolute top-7 ltr:left-3 rtl:right-3"
>
<Checkbox v-model="modelValue" />
</div>
<div class="flex gap-1 justify-between w-full"> <div class="flex gap-1 justify-between w-full">
<span class="text-base text-n-slate-12 line-clamp-1"> <span class="text-base text-n-slate-12 line-clamp-1">
{{ name }} {{ name }}
</span> </span>
<div v-if="showMenu" class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<div <div
v-on-clickaway="() => toggleDropdown(false)" v-on-clickaway="() => toggleDropdown(false)"
class="flex relative items-center group" class="flex relative items-center group"
@@ -21,22 +21,16 @@ const emit = defineEmits(['deleteSuccess']);
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
const bulkDeleteDialogRef = ref(null); const bulkDeleteDialogRef = ref(null);
const i18nKey = computed(() => { const i18nKey = computed(() => props.type.toUpperCase());
const i18nTypeMap = {
AssistantResponse: 'RESPONSES',
AssistantDocument: 'DOCUMENTS',
};
return i18nTypeMap[props.type];
});
const handleBulkDelete = async ids => { const handleBulkDelete = async ids => {
if (!ids) return; if (!ids) return;
try { try {
await store.dispatch('captainBulkActions/handleBulkDelete', { await store.dispatch(
ids: Array.from(props.bulkIds), 'captainBulkActions/handleBulkDelete',
type: props.type, Array.from(props.bulkIds)
}); );
emit('deleteSuccess'); emit('deleteSuccess');
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`)); useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));
@@ -27,7 +27,6 @@ const initialState = {
conversationFaqs: false, conversationFaqs: false,
memories: false, memories: false,
citations: false, citations: false,
contactAttributes: false,
}, },
}; };
@@ -60,7 +59,6 @@ const updateStateFromAssistant = assistant => {
conversationFaqs: config.feature_faq || false, conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false, memories: config.feature_memory || false,
citations: config.feature_citation || false, citations: config.feature_citation || false,
contactAttributes: config.feature_contact_attributes || false,
}; };
}; };
@@ -81,7 +79,6 @@ const handleBasicInfoUpdate = async () => {
feature_faq: state.features.conversationFaqs, feature_faq: state.features.conversationFaqs,
feature_memory: state.features.memories, feature_memory: state.features.memories,
feature_citation: state.features.citations, feature_citation: state.features.citations,
feature_contact_attributes: state.features.contactAttributes,
}, },
}; };
@@ -141,10 +138,6 @@ watch(
<input v-model="state.features.citations" type="checkbox" /> <input v-model="state.features.citations" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }} {{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
</label> </label>
<label class="flex items-center gap-2">
<input v-model="state.features.contactAttributes" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONTACT_ATTRIBUTES') }}
</label>
</div> </div>
</div> </div>
@@ -101,9 +101,12 @@ const authTypeLabel = computed(() => {
</Policy> </Policy>
</div> </div>
</div> </div>
<div class="flex items-center justify-between w-full gap-4 min-w-0"> <div class="flex items-center justify-between w-full gap-4">
<div class="flex items-center gap-3 flex-1 min-w-0"> <div class="flex items-center gap-3 flex-1">
<span v-if="description" class="text-sm truncate text-n-slate-11"> <span
v-if="description"
class="text-sm truncate text-n-slate-11 flex-1"
>
{{ description }} {{ description }}
</span> </span>
<span <span
@@ -1,10 +1,9 @@
<script setup> <script setup>
import { reactive, computed, ref, useTemplateRef, watch } from 'vue'; import { reactive, computed, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { required, maxLength } from '@vuelidate/validators'; import { required } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import CustomToolsAPI from 'dashboard/api/captain/customTools';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue'; import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
@@ -73,12 +72,8 @@ const DEFAULT_PARAM = {
required: false, required: false,
}; };
// OpenAI enforces a 64-char limit on function names. The backend slug is
// "custom_" (7 chars) + parameterized title, so cap the title conservatively.
const MAX_TOOL_NAME_LENGTH = 55;
const validationRules = { const validationRules = {
title: { required, maxLength: maxLength(MAX_TOOL_NAME_LENGTH) }, title: { required },
endpoint_url: { required }, endpoint_url: { required },
http_method: { required }, http_method: { required },
auth_type: { required }, auth_type: { required },
@@ -108,15 +103,9 @@ const isLoading = computed(() =>
); );
const getErrorMessage = (field, errorKey) => { const getErrorMessage = (field, errorKey) => {
if (!v$.value[field].$error) return ''; return v$.value[field].$error
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
const failedRule = v$.value[field].$errors[0]?.$validator; : '';
if (failedRule === 'maxLength') {
return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.MAX_LENGTH_ERROR`, {
max: MAX_TOOL_NAME_LENGTH,
});
}
return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`);
}; };
const formErrors = computed(() => ({ const formErrors = computed(() => ({
@@ -151,30 +140,6 @@ const handleSubmit = async () => {
emit('submit', state); emit('submit', state);
}; };
const isTesting = ref(false);
const testResult = ref(null);
const isTestDisabled = computed(
() => state.endpoint_url.includes('{{') || !!state.request_template
);
const handleTest = async () => {
if (!state.endpoint_url) return;
isTesting.value = true;
testResult.value = null;
try {
const { data } = await CustomToolsAPI.test(state);
const isOk = data.status >= 200 && data.status < 300;
testResult.value = { success: isOk, status: data.status };
} catch (e) {
const message =
e.response?.data?.error || t('CAPTAIN.CUSTOM_TOOLS.TEST.ERROR');
testResult.value = { success: false, message };
} finally {
isTesting.value = false;
}
};
</script> </script>
<template> <template>
@@ -283,45 +248,6 @@ const handleTest = async () => {
class="[&_textarea]:font-mono" class="[&_textarea]:font-mono"
/> />
<div class="flex flex-col gap-2">
<Button
type="button"
variant="faded"
color="slate"
icon="i-lucide-play"
:label="t('CAPTAIN.CUSTOM_TOOLS.TEST.BUTTON')"
:is-loading="isTesting"
:disabled="isTesting || !state.endpoint_url || isTestDisabled"
@click="handleTest"
/>
<p v-if="isTestDisabled" class="text-xs text-n-slate-11">
{{ t('CAPTAIN.CUSTOM_TOOLS.TEST.DISABLED_HINT') }}
</p>
<div
v-if="testResult"
class="flex items-center gap-2 px-3 py-2 text-xs rounded-lg"
:class="
testResult.success
? 'bg-n-teal-2 text-n-teal-11'
: 'bg-n-ruby-2 text-n-ruby-11'
"
>
<span
:class="
testResult.success ? 'i-lucide-check-circle' : 'i-lucide-x-circle'
"
class="size-3.5 shrink-0"
/>
{{
testResult.status
? t('CAPTAIN.CUSTOM_TOOLS.TEST.SUCCESS', {
status: testResult.status,
})
: testResult.message
}}
</div>
</div>
<div class="flex gap-3 justify-between items-center w-full"> <div class="flex gap-3 justify-between items-center w-full">
<Button <Button
type="button" type="button"
@@ -1,11 +1,8 @@
<script setup> <script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['click']); const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const onClick = () => { const onClick = () => {
emit('click'); emit('click');
@@ -13,15 +10,6 @@ const onClick = () => {
</script> </script>
<template> <template>
<FeatureSpotlight
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
fallback-thumbnail="/assets/images/dashboard/captain/assistant-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
learn-more-url="https://chwt.app/hc/captain-tools"
class="mb-8"
:hide-actions="!isOnChatwootCloud"
/>
<EmptyStateLayout <EmptyStateLayout
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')" :title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')" :subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
@@ -29,12 +29,6 @@ const handleInput = () => {
nextTick(adjustHeight); nextTick(adjustHeight);
}; };
const handleEnterKey = event => {
if (event.isComposing) return;
event.preventDefault();
sendMessage();
};
onMounted(() => { onMounted(() => {
nextTick(adjustHeight); nextTick(adjustHeight);
}); });
@@ -49,7 +43,7 @@ onMounted(() => {
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12" class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
rows="1" rows="1"
@input="handleInput" @input="handleInput"
@keydown.enter.exact="handleEnterKey" @keydown.enter.exact.prevent="sendMessage"
/> />
<button <button
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11" class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
@@ -96,17 +96,6 @@ const close = () => {
isOpen.value = false; isOpen.value = false;
}; };
// Only close if the close event originated from this dialog,
// not from a child dialog (e.g. ProseMirror prompt) bubbling up.
const handleDialogClose = e => e.target === dialogRef.value && close();
// Only close on click-outside if this dialog is the topmost one.
// If another dialog (e.g. ProseMirror prompt) is open on top, ignore.
const handleClickOutside = () => {
const dialogs = document.querySelectorAll('dialog[open]');
if (dialogs[dialogs.length - 1] === dialogRef.value) close();
};
const confirm = () => { const confirm = () => {
emit('confirm'); emit('confirm');
}; };
@@ -124,9 +113,9 @@ defineExpose({ open, close });
positionClass, positionClass,
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible', overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
]" ]"
@close.prevent="handleDialogClose" @close="close"
> >
<OnClickOutside @trigger="handleClickOutside"> <OnClickOutside @trigger="close">
<form <form
ref="dialogContentRef" ref="dialogContentRef"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl" class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
@@ -129,7 +129,6 @@ const props = defineProps({
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
isEmailInbox: { type: Boolean, default: false }, isEmailInbox: { type: Boolean, default: false },
private: { type: Boolean, default: false }, private: { type: Boolean, default: false },
additionalAttributes: { type: Object, default: () => ({}) }, // eslint-disable-line vue/no-unused-properties
sender: { type: Object, default: null }, sender: { type: Object, default: null },
senderId: { type: Number, default: null }, senderId: { type: Number, default: null },
senderType: { type: String, default: null }, senderType: { type: String, default: null },
@@ -173,10 +172,7 @@ const variant = computed(() => {
return MESSAGE_VARIANTS.AGENT; return MESSAGE_VARIANTS.AGENT;
} }
const isBot = const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
props.sender?.type === SENDER_TYPES.AGENT_BOT ||
props.senderType === SENDER_TYPES.AGENT_BOT ||
(!props.sender && !props.additionalAttributes?.senderName);
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) { if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
return MESSAGE_VARIANTS.BOT; return MESSAGE_VARIANTS.BOT;
} }
@@ -454,13 +450,12 @@ const avatarInfo = computed(() => {
}; };
} }
// If no sender, check for Slack (or other integration) sender info // If no sender, return bot info
if (!props.sender) { if (!props.sender) {
const { senderName, senderAvatarUrl } = props.additionalAttributes || {}; return {
if (senderName) { name: t('CONVERSATION.BOT'),
return { name: senderName, src: senderAvatarUrl ?? '' }; src: '',
} };
return { name: t('CONVERSATION.BOT'), src: '' };
} }
const { sender } = props; const { sender } = props;
@@ -67,9 +67,6 @@ const isSent = computed(() => {
return sourceId.value && status.value === MESSAGE_STATUS.SENT; return sourceId.value && status.value === MESSAGE_STATUS.SENT;
} }
// API inbox messages use real sent/delivered/read status values from the external system.
if (isAPIInbox.value) return status.value === MESSAGE_STATUS.SENT;
// All messages will be mark as sent for the Line channel, as there is no source ID. // All messages will be mark as sent for the Line channel, as there is no source ID.
if (isALineChannel.value) return true; if (isALineChannel.value) return true;
@@ -84,15 +81,12 @@ const isDelivered = computed(() => {
isATwilioChannel.value || isATwilioChannel.value ||
isASmsInbox.value || isASmsInbox.value ||
isAFacebookInbox.value || isAFacebookInbox.value ||
isAnInstagramChannel.value ||
isATiktokChannel.value isATiktokChannel.value
) { ) {
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED; return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
} }
// API inbox messages use real delivered status from the external system. // All messages marked as delivered for the web widget inbox and API inbox once they are sent.
if (isAPIInbox.value) return status.value === MESSAGE_STATUS.DELIVERED; if (isAWebWidgetInbox.value || isAPIInbox.value) {
// All messages marked as delivered for the web widget inbox once they are sent.
if (isAWebWidgetInbox.value) {
return status.value === MESSAGE_STATUS.SENT; return status.value === MESSAGE_STATUS.SENT;
} }
if (isALineChannel.value) { if (isALineChannel.value) {
@@ -63,16 +63,6 @@ const hasAdvancedAssignment = computed(() => {
); );
}); });
const hasCustomTools = computed(() => {
return (
isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS
) ||
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.CAPTAIN_V2)
);
});
const toggleShortcutModalFn = show => { const toggleShortcutModalFn = show => {
if (show) { if (show) {
emit('openKeyShortcutModal'); emit('openKeyShortcutModal');
@@ -374,18 +364,14 @@ const menuItems = computed(() => {
navigationPath: 'captain_assistants_inboxes_index', navigationPath: 'captain_assistants_inboxes_index',
}), }),
}, },
...(hasCustomTools.value {
? [ name: 'Tools',
{ label: t('SIDEBAR.CAPTAIN_TOOLS'),
name: 'Tools', activeOn: ['captain_tools_index'],
label: t('SIDEBAR.CAPTAIN_TOOLS'), to: accountScopedRoute('captain_assistants_index', {
activeOn: ['captain_tools_index'], navigationPath: 'captain_tools_index',
to: accountScopedRoute('captain_assistants_index', { }),
navigationPath: 'captain_tools_index', },
}),
},
]
: []),
{ {
name: 'Settings', name: 'Settings',
label: t('SIDEBAR.CAPTAIN_SETTINGS'), label: t('SIDEBAR.CAPTAIN_SETTINGS'),
@@ -72,7 +72,7 @@ const isNewTagInValidType = computed(() =>
const showInput = computed(() => const showInput = computed(() =>
props.mode === MODE.SINGLE props.mode === MODE.SINGLE
? !tags.value.length ? isFocused.value && !tags.value.length
: isFocused.value || !tags.value.length : isFocused.value || !tags.value.length
); );
@@ -17,7 +17,10 @@ import {
useFunctionGetter, useFunctionGetter,
} from 'dashboard/composables/store.js'; } from 'dashboard/composables/store.js';
import { Virtualizer } from 'virtua/vue'; // [VITE] [TODO] We are using vue-virtual-scroll for now, since that seemed the simplest way to migrate
// from the current one. But we should consider using tanstack virtual in the future
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import ChatListHeader from './ChatListHeader.vue'; import ChatListHeader from './ChatListHeader.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue'; import ConversationFilter from 'next/filter/ConversationFilter.vue';
@@ -26,9 +29,9 @@ import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue'; import ConversationItem from './ConversationItem.vue';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue'; import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue'; import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import IntersectionObserver from './IntersectionObserver.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue'; import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue'; import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -43,6 +46,7 @@ import {
useSnakeCase, useSnakeCase,
} from 'dashboard/composables/useTransformKeys'; } from 'dashboard/composables/useTransformKeys';
import { useEmitter } from 'dashboard/composables/emitter'; import { useEmitter } from 'dashboard/composables/emitter';
import { useEventListener } from '@vueuse/core';
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes'; import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
@@ -66,6 +70,8 @@ import { matchesFilters } from '../store/modules/conversations/helpers/filterHel
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events'; import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js'; import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
const props = defineProps({ const props = defineProps({
conversationInbox: { type: [String, Number], default: 0 }, conversationInbox: { type: [String, Number], default: 0 },
teamId: { type: [String, Number], default: 0 }, teamId: { type: [String, Number], default: 0 },
@@ -85,9 +91,9 @@ const store = useStore();
const resolveAttributesModalRef = ref(null); const resolveAttributesModalRef = ref(null);
const conversationListRef = ref(null); const conversationListRef = ref(null);
const virtualListRef = ref(null); const conversationDynamicScroller = ref(null);
provide('contextMenuElementTarget', virtualListRef); provide('contextMenuElementTarget', conversationDynamicScroller);
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME); const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN); const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
@@ -155,6 +161,12 @@ const {
const { checkMissingAttributes } = useConversationRequiredAttributes(); const { checkMissingAttributes } = useConversationRequiredAttributes();
// computed // computed
const intersectionObserverOptions = computed(() => {
return {
root: conversationListRef.value,
rootMargin: '100px 0px 100px 0px',
};
});
const hasAppliedFilters = computed(() => { const hasAppliedFilters = computed(() => {
return appliedFilters.value.length !== 0; return appliedFilters.value.length !== 0;
@@ -372,6 +384,18 @@ function setFiltersFromUISettings() {
function emitConversationLoaded() { function emitConversationLoaded() {
emit('conversationLoad'); emit('conversationLoad');
// [VITE] removing this since the library has changed
// nextTick(() => {
// // Addressing a known issue in the virtual list library where dynamically added items
// // might not render correctly. This workaround involves a slight manual adjustment
// // to the scroll position, triggering the list to refresh its rendering.
// const virtualList = conversationListRef.value;
// const scrollToOffset = virtualList?.scrollToOffset;
// const currentOffset = virtualList?.getOffset() || 0;
// if (scrollToOffset) {
// scrollToOffset(currentOffset + 1);
// }
// });
} }
function fetchFilteredConversations(payload) { function fetchFilteredConversations(payload) {
@@ -583,13 +607,16 @@ function loadMoreConversations() {
} }
} }
// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll. // Add a method to handle scroll events
// If the list doesn’t fill the viewport, loading can stall. function handleScroll() {
// IntersectionObserver triggers as soon as the sentinel is visible. const scroller = conversationDynamicScroller.value;
const intersectionObserverOptions = computed(() => ({ if (scroller && scroller.hasScrollbar) {
root: conversationListRef.value, const { scrollTop, scrollHeight, clientHeight } = scroller.$el;
rootMargin: '100px 0px 100px 0px', if (scrollHeight - (scrollTop + clientHeight) < 100) {
})); loadMoreConversations();
}
}
}
function updateAssigneeTab(selectedTab) { function updateAssigneeTab(selectedTab) {
if (activeAssigneeTab.value !== selectedTab) { if (activeAssigneeTab.value !== selectedTab) {
@@ -795,6 +822,8 @@ useEmitter('fetch_conversation_stats', () => {
store.dispatch('conversationStats/get', conversationFilters.value); store.dispatch('conversationStats/get', conversationFilters.value);
}); });
useEventListener(conversationDynamicScroller, 'scroll', handleScroll);
onMounted(() => { onMounted(() => {
store.dispatch('setChatListFilters', conversationFilters.value); store.dispatch('setChatListFilters', conversationFilters.value);
setFiltersFromUISettings(); setFiltersFromUISettings();
@@ -948,40 +977,61 @@ watch(conversationFilters, (newVal, oldVal) => {
/> />
<div <div
ref="conversationListRef" ref="conversationListRef"
class="flex-1 min-h-0 overflow-y-auto conversations-list" class="overflow-hidden flex-1 conversations-list hover:overflow-y-auto"
:class="{ '!overflow-hidden': isContextMenuOpen }" :class="{ 'overflow-hidden': isContextMenuOpen }"
> >
<Virtualizer <DynamicScroller
ref="virtualListRef" ref="conversationDynamicScroller"
v-slot="{ item, index }" :items="conversationList"
:data="conversationList" :min-item-size="24"
class="overflow-auto w-full h-full"
> >
<ConversationItem <template #default="{ item, index, active }">
:source="item" <!--
:label="label" If we encounter resizing issues, we can set the `watchData` prop to true
:team-id="teamId" this will deeply watch the entire object instead of just size dependencies
:folders-id="foldersId" But it can impact performance
:conversation-type="conversationType" -->
:show-assignee="showAssigneeInConversationCard" <DynamicScrollerItem
:data-index="index" :item="item"
@select-conversation="selectConversation" :active="active"
@de-select-conversation="deSelectConversation" :data-index="index"
/> :size-dependencies="[
</Virtualizer> item.messages,
<div v-if="chatListLoading" class="flex justify-center my-4"> item.labels,
<Spinner class="text-n-brand" /> item.uuid,
</div> item.inbox_id,
<p ]"
v-else-if="showEndOfListMessage" >
class="p-4 text-center text-n-slate-11" <ConversationItem
> :source="item"
{{ $t('CHAT_LIST.EOF') }} :label="label"
</p> :team-id="teamId"
<IntersectionObserver :folders-id="foldersId"
v-else :conversation-type="conversationType"
:options="intersectionObserverOptions" :show-assignee="showAssigneeInConversationCard"
@observed="loadMoreConversations" @select-conversation="selectConversation"
/> @de-select-conversation="deSelectConversation"
/>
</DynamicScrollerItem>
</template>
<template #after>
<div v-if="chatListLoading" class="flex justify-center my-4">
<Spinner class="text-n-brand" />
</div>
<p
v-else-if="showEndOfListMessage"
class="p-4 text-center text-n-slate-11"
>
{{ $t('CHAT_LIST.EOF') }}
</p>
<IntersectionObserver
v-else
:options="intersectionObserverOptions"
@observed="loadMoreConversations"
/>
</template>
</DynamicScroller>
</div> </div>
<Dialog <Dialog
ref="deleteConversationDialogRef" ref="deleteConversationDialogRef"
@@ -50,6 +50,7 @@ export default {
<template> <template>
<ConversationCard <ConversationCard
:key="source.id"
:active-label="label" :active-label="label"
:team-id="teamId" :team-id="teamId"
:folders-id="foldersId" :folders-id="foldersId"
@@ -24,10 +24,6 @@ export default {
type: [String, Date, Number], type: [String, Date, Number],
default: '', default: '',
}, },
conversationId: {
type: [String, Number],
default: '',
},
}, },
data() { data() {
return { return {
@@ -78,15 +74,6 @@ export default {
createdAtTimestamp() { createdAtTimestamp() {
this.createdAtTimeAgo = dynamicTime(this.createdAtTimestamp); this.createdAtTimeAgo = dynamicTime(this.createdAtTimestamp);
}, },
conversationId() {
// Reset display values and timer when the row is recycled to a different conversation.
this.lastActivityAtTimeAgo = dynamicTime(this.lastActivityTimestamp);
this.createdAtTimeAgo = dynamicTime(this.createdAtTimestamp);
if (this.isAutoRefreshEnabled) {
clearTimeout(this.timer);
this.createTimer();
}
},
}, },
mounted() { mounted() {
if (this.isAutoRefreshEnabled) { if (this.isAutoRefreshEnabled) {
@@ -124,6 +111,7 @@ export default {
v-tooltip.top="{ v-tooltip.top="{
content: tooltipText, content: tooltipText,
delay: { show: 1000, hide: 0 }, delay: { show: 1000, hide: 0 },
hideOnClick: true,
}" }"
class="ml-auto leading-4 text-xxs text-n-slate-10 hover:text-n-slate-11" class="ml-auto leading-4 text-xxs text-n-slate-10 hover:text-n-slate-11"
> >
@@ -107,8 +107,7 @@ function onKeydown(view, event) {
emit('keydown'); emit('keydown');
// Handle Enter key to send message (Shift+Enter for new line) // Handle Enter key to send message (Shift+Enter for new line)
// Skip if IME composition is active (CJK character confirmation) if (event.key === 'Enter' && !event.shiftKey) {
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault(); event.preventDefault();
handleSubmit(); handleSubmit();
return true; // Prevent ProseMirror's default Enter handling return true; // Prevent ProseMirror's default Enter handling
@@ -10,23 +10,11 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
const props = defineProps({ defineProps({
hasSelection: { hasSelection: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isEditorMenuPopover: {
type: Boolean,
default: false,
},
editorContent: {
type: String,
default: undefined,
},
conversationId: {
type: Number,
default: null,
},
}); });
const emit = defineEmits(['executeCopilotAction']); const emit = defineEmits(['executeCopilotAction']);
@@ -37,13 +25,6 @@ const { draftMessage } = useCaptain();
const replyMode = useMapGetter('draftMessages/getReplyEditorMode'); const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
// When editorContent prop is passed, use it exclusively (even if empty)
// This ensures each editor instance shows menu items based on its own content
// Falls back to global draftMessage only when editorContent is not provided
const effectiveContent = computed(() =>
props.editorContent !== undefined ? props.editorContent : draftMessage.value
);
// Selection-based menu items (when text is selected) // Selection-based menu items (when text is selected)
const menuItems = computed(() => { const menuItems = computed(() => {
const items = []; const items = [];
@@ -61,9 +42,8 @@ const menuItems = computed(() => {
icon: 'i-fluent-pen-sparkle-24-regular', icon: 'i-fluent-pen-sparkle-24-regular',
}); });
} else if ( } else if (
props.conversationId &&
replyMode.value === REPLY_EDITOR_MODES.REPLY && replyMode.value === REPLY_EDITOR_MODES.REPLY &&
effectiveContent.value draftMessage.value
) { ) {
items.push({ items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'), label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
@@ -72,7 +52,7 @@ const menuItems = computed(() => {
}); });
} }
if (effectiveContent.value) { if (draftMessage.value) {
items.push( items.push(
{ {
label: t( label: t(
@@ -125,7 +105,7 @@ const menuItems = computed(() => {
const generalMenuItems = computed(() => { const generalMenuItems = computed(() => {
const items = []; const items = [];
if (props.conversationId && replyMode.value === REPLY_EDITOR_MODES.REPLY) { if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
items.push({ items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'), label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
key: 'reply_suggestion', key: 'reply_suggestion',
@@ -133,10 +113,7 @@ const generalMenuItems = computed(() => {
}); });
} }
if ( if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) {
props.conversationId &&
(replyMode.value === REPLY_EDITOR_MODES.NOTE || true)
) {
items.push({ items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'), label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
key: 'summarize', key: 'summarize',
@@ -199,8 +176,8 @@ const handleSubMenuItemClick = (parentItem, subItem) => {
<DropdownBody <DropdownBody
ref="menuRef" ref="menuRef"
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5" class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
:class="{ 'selection-menu': hasSelection && isEditorMenuPopover }" :class="{ 'selection-menu': hasSelection }"
:style="hasSelection && isEditorMenuPopover ? selectionMenuStyle : {}" :style="hasSelection ? selectionMenuStyle : {}"
> >
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5"> <div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
<div <div
@@ -202,11 +202,6 @@ const editorRoot = useTemplateRef('editorRoot');
const imageUpload = useTemplateRef('imageUpload'); const imageUpload = useTemplateRef('imageUpload');
const editor = useTemplateRef('editor'); const editor = useTemplateRef('editor');
const isEditorMenuPopover = computed(
() =>
editorRoot.value?.classList.contains('popover-prosemirror-menu') ?? false
);
const handleCopilotAction = actionKey => { const handleCopilotAction = actionKey => {
if (actionKey === 'improve_selection' && editorView?.state) { if (actionKey === 'improve_selection' && editorView?.state) {
const { from, to } = editorView.state.selection; const { from, to } = editorView.state.selection;
@@ -216,7 +211,7 @@ const handleCopilotAction = actionKey => {
emit('executeCopilotAction', 'improve', selectedText); emit('executeCopilotAction', 'improve', selectedText);
} }
} else { } else {
emit('executeCopilotAction', actionKey, props.modelValue); emit('executeCopilotAction', actionKey);
} }
showSelectionMenu.value = false; showSelectionMenu.value = false;
@@ -489,7 +484,6 @@ function setToolbarPosition() {
function setMenubarPosition({ selection } = {}) { function setMenubarPosition({ selection } = {}) {
const wrapper = editorRoot.value; const wrapper = editorRoot.value;
if (!selection || !wrapper) return; if (!selection || !wrapper) return;
if (!isEditorMenuPopover.value) return;
const rect = wrapper.getBoundingClientRect(); const rect = wrapper.getBoundingClientRect();
const isRtl = getComputedStyle(wrapper).direction === 'rtl'; const isRtl = getComputedStyle(wrapper).direction === 'rtl';
@@ -828,8 +822,6 @@ onMounted(() => {
} }
}); });
defineExpose({ focusEditorInputField });
// BUS Event to insert text or markdown into the editor at the // BUS Event to insert text or markdown into the editor at the
// current cursor position. // current cursor position.
// Components using this // Components using this
@@ -874,12 +866,8 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
v-if="showSelectionMenu" v-if="showSelectionMenu"
v-on-click-outside="handleClickOutside" v-on-click-outside="handleClickOutside"
:has-selection="isTextSelected" :has-selection="isTextSelected"
:is-editor-menu-popover="isEditorMenuPopover"
:editor-content="modelValue"
:conversation-id="conversationId"
:show-selection-menu="showSelectionMenu" :show-selection-menu="showSelectionMenu"
:show-general-menu="false" :show-general-menu="false"
class="copilot-editor-menu"
@execute-copilot-action="handleCopilotAction" @execute-copilot-action="handleCopilotAction"
/> />
<input <input
@@ -980,15 +968,11 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply overflow-auto min-h-[5rem] max-h-[7.5rem]; @apply overflow-auto min-h-[5rem] max-h-[7.5rem];
} }
.ProseMirror-prompt-backdrop::backdrop {
@apply bg-n-alpha-black1 backdrop-blur-[4px];
}
.ProseMirror-prompt { .ProseMirror-prompt {
@apply bg-n-alpha-3 border border-n-strong p-6 shadow-xl rounded-xl w-96 !important; @apply z-[9999] bg-n-alpha-3 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
h5 { h5 {
@apply text-n-slate-12 mb-3; @apply text-n-slate-12 mb-1.5;
} }
.ProseMirror-prompt-buttons { .ProseMirror-prompt-buttons {
@@ -1042,17 +1026,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0; @apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
} }
// Default copilot menu position (non-popover editors like components-next/Editor)
// When popover-prosemirror-menu is NOT on the wrapper, anchor below the menubar
:not(.popover-prosemirror-menu) > .copilot-editor-menu {
top: 1.5rem !important;
[dir='rtl'] & {
left: auto !important;
right: 0 !important;
}
}
// Float editor menu // Float editor menu
.popover-prosemirror-menu { .popover-prosemirror-menu {
position: relative; position: relative;
@@ -326,4 +326,26 @@ export default {
max-height: 7.5rem; max-height: 7.5rem;
overflow: auto; overflow: auto;
} }
.ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 min-w-80 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
h5 {
@apply text-n-slate-12 mb-1.5;
}
.ProseMirror-prompt-buttons {
button {
@apply h-8 px-3;
&[type='submit'] {
@apply bg-n-brand text-white hover:bg-n-brand/90;
}
&[type='button'] {
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
}
}
}
}
</style> </style>
@@ -189,7 +189,7 @@ export default {
}, },
showAudioRecorderButton() { showAudioRecorderButton() {
if (this.isEditorDisabled) return false; if (this.isEditorDisabled) return false;
if (this.isALineChannel || this.isATiktokChannel) { if (this.isALineChannel) {
return false; return false;
} }
// Disable audio recorder for safari browser as recording is not supported // Disable audio recorder for safari browser as recording is not supported
@@ -380,11 +380,7 @@ export default {
@click="$emit('selectContentTemplate')" @click="$emit('selectContentTemplate')"
/> />
<VideoCallButton <VideoCallButton
v-if=" v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
(isAWebWidgetInbox || isAPIInbox) &&
!isOnPrivateNote &&
!isEditorDisabled
"
:conversation-id="conversationId" :conversation-id="conversationId"
/> />
<transition name="modal-fade"> <transition name="modal-fade">
@@ -49,10 +49,6 @@ export default {
type: Number, type: Number,
default: () => 0, default: () => 0,
}, },
editorContent: {
type: String,
default: undefined,
},
}, },
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'], emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
setup(props, { emit }) { setup(props, { emit }) {
@@ -77,8 +73,8 @@ export default {
const { captainTasksEnabled } = useCaptain(); const { captainTasksEnabled } = useCaptain();
const showCopilotMenu = ref(false); const showCopilotMenu = ref(false);
const handleCopilotAction = (actionKey, data) => { const handleCopilotAction = actionKey => {
emit('executeCopilotAction', actionKey, data || props.editorContent); emit('executeCopilotAction', actionKey);
showCopilotMenu.value = false; showCopilotMenu.value = false;
}; };
@@ -178,8 +174,6 @@ export default {
v-if="showCopilotMenu" v-if="showCopilotMenu"
v-on-click-outside="handleClickOutside" v-on-click-outside="handleClickOutside"
:has-selection="false" :has-selection="false"
:editor-content="editorContent"
:conversation-id="conversationId"
class="ltr:right-0 rtl:left-0 bottom-full mb-2" class="ltr:right-0 rtl:left-0 bottom-full mb-2"
@execute-copilot-action="handleCopilotAction" @execute-copilot-action="handleCopilotAction"
/> />
@@ -86,10 +86,6 @@ const chatSortOptions = computed(() => [
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_asc.TEXT'), label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_asc.TEXT'),
value: 'priority_asc', value: 'priority_asc',
}, },
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_desc_created_at_asc.TEXT'),
value: 'priority_desc_created_at_asc',
},
{ {
label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_asc.TEXT'), label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_asc.TEXT'),
value: 'waiting_since_asc', value: 'waiting_since_asc',
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue'; import { computed, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store'; import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper'; import { getLastMessage } from 'dashboard/helper/conversationHelper';
@@ -10,7 +10,7 @@ import InboxName from '../InboxName.vue';
import ConversationContextMenu from './contextMenu/Index.vue'; import ConversationContextMenu from './contextMenu/Index.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue'; import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue'; import CardLabels from './conversationCardComponents/CardLabels.vue';
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue'; import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue'; import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue'; import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import VoiceCallStatus from './VoiceCallStatus.vue'; import VoiceCallStatus from './VoiceCallStatus.vue';
@@ -50,21 +50,10 @@ const store = useStore();
const hovered = ref(false); const hovered = ref(false);
const showContextMenu = ref(false); const showContextMenu = ref(false);
const contextMenu = ref({ x: null, y: null }); const contextMenu = ref({
x: null,
// Reset UI state when conversation changes at same index (no :key, instance reused on reorder) y: null,
// This prevents context menu/hover state from leaking to a different conversation });
// Emit contextMenuToggle(false) to sync parent state if menu was open during recycling
const resetState = () => {
if (showContextMenu.value) {
emit('contextMenuToggle', false);
}
hovered.value = false;
showContextMenu.value = false;
contextMenu.value = { x: null, y: null };
};
watch(() => props.chat.id, resetState);
const currentChat = useMapGetter('getSelectedChat'); const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes'); const inboxesList = useMapGetter('inboxes/getInboxes');
@@ -305,7 +294,7 @@ const deleteConversation = () => {
> >
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" /> <InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
<div <div
class="flex items-baseline gap-2 flex-shrink-0" class="flex items-center gap-2 flex-shrink-0"
:class="{ :class="{
'flex-1 justify-between': !showInboxName, 'flex-1 justify-between': !showInboxName,
}" }"
@@ -317,10 +306,7 @@ const deleteConversation = () => {
<fluent-icon icon="person" size="12" class="text-n-slate-11" /> <fluent-icon icon="person" size="12" class="text-n-slate-11" />
{{ assignee.name }} {{ assignee.name }}
</span> </span>
<CardPriorityIcon <PriorityMark :priority="chat.priority" class="flex-shrink-0" />
:priority="chat.priority"
class="flex-shrink-0 !size-3.5"
/>
</div> </div>
</div> </div>
<h4 <h4
@@ -366,7 +352,6 @@ const deleteConversation = () => {
<TimeAgo <TimeAgo
:last-activity-timestamp="chat.timestamp" :last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at" :created-at-timestamp="chat.created_at"
:conversation-id="chat.id"
/> />
</span> </span>
<span <span
@@ -78,7 +78,7 @@ const onSend = () => {
<div <div
v-else-if="isGeneratingContent" v-else-if="isGeneratingContent"
key="loading-state" key="loading-state"
class="bg-n-iris-5 rounded min-h-[4.75rem] w-full mb-4 p-4 flex items-start" class="bg-n-iris-5 rounded min-h-16 w-full mb-4 p-4 flex items-start"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<CaptainLoader class="text-n-iris-10 size-4" /> <CaptainLoader class="text-n-iris-10 size-4" />
@@ -85,12 +85,7 @@ export default {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS')); useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'));
this.onCancel(); this.onCancel();
} catch (error) { } catch (error) {
const status = error?.response?.status; useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'));
if (status === 402) {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_PAYMENT_REQUIRED'));
} else {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'));
}
} finally { } finally {
this.isSubmitting = false; this.isSubmitting = false;
} }
@@ -0,0 +1,54 @@
<script>
import { CONVERSATION_PRIORITY } from '../../../../shared/constants/messages';
export default {
name: 'PriorityMark',
props: {
priority: {
type: String,
default: '',
validate: value =>
[...Object.values(CONVERSATION_PRIORITY), ''].includes(value),
},
},
data() {
return {
CONVERSATION_PRIORITY,
};
},
computed: {
tooltipText() {
return this.$t(
`CONVERSATION.PRIORITY.OPTIONS.${this.priority.toUpperCase()}`
);
},
isUrgent() {
return this.priority === CONVERSATION_PRIORITY.URGENT;
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<span
v-if="priority"
v-tooltip="{
content: tooltipText,
delay: { show: 1500, hide: 0 },
hideOnClick: true,
}"
class="shrink-0 rounded-sm inline-flex items-center justify-center w-3.5 h-3.5"
:class="{
'bg-n-ruby-4 text-n-ruby-10': isUrgent,
'bg-n-slate-4 text-n-slate-11': !isUrgent,
}"
>
<fluent-icon
:icon="`priority-${priority.toLowerCase()}`"
:size="isUrgent ? 12 : 14"
class="flex-shrink-0"
view-box="0 0 14 14"
/>
</span>
</template>
@@ -99,7 +99,6 @@ export default {
} = useUISettings(); } = useUISettings();
const replyEditor = useTemplateRef('replyEditor'); const replyEditor = useTemplateRef('replyEditor');
const messageEditor = useTemplateRef('messageEditor');
const copilot = useCopilotReply(); const copilot = useCopilotReply();
const shortcutKey = useKbd(['$mod', '+', 'enter']); const shortcutKey = useKbd(['$mod', '+', 'enter']);
@@ -110,7 +109,6 @@ export default {
setQuotedReplyFlagForInbox, setQuotedReplyFlagForInbox,
fetchQuotedReplyFlagFromUISettings, fetchQuotedReplyFlagFromUISettings,
replyEditor, replyEditor,
messageEditor,
copilot, copilot,
shortcutKey, shortcutKey,
}; };
@@ -164,8 +162,7 @@ export default {
this.inReplyTo?.id && this.inReplyTo?.id &&
!this.isPrivate && !this.isPrivate &&
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) && this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
!this.is360DialogWhatsAppChannel && !this.is360DialogWhatsAppChannel
!this.copilot.isActive.value
); );
}, },
showWhatsappTemplates() { showWhatsappTemplates() {
@@ -203,13 +200,9 @@ export default {
}, },
messagePlaceHolder() { messagePlaceHolder() {
if (this.isEditorDisabled) { if (this.isEditorDisabled) {
if (this.isAWhatsAppChannel) { return this.isAWhatsAppChannel
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP'); ? this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP')
} : this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
if (this.isAPIInbox) {
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_API');
}
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
} }
return this.isPrivate return this.isPrivate
? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT') ? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT')
@@ -253,9 +246,6 @@ export default {
if (this.isAnInstagramChannel) { if (this.isAnInstagramChannel) {
return MESSAGE_MAX_LENGTH.INSTAGRAM; return MESSAGE_MAX_LENGTH.INSTAGRAM;
} }
if (this.isATelegramChannel) {
return MESSAGE_MAX_LENGTH.TELEGRAM;
}
if (this.isATiktokChannel) { if (this.isATiktokChannel) {
return MESSAGE_MAX_LENGTH.TIKTOK; return MESSAGE_MAX_LENGTH.TIKTOK;
} }
@@ -289,8 +279,7 @@ export default {
this.isASmsInbox || this.isASmsInbox ||
this.isATelegramChannel || this.isATelegramChannel ||
this.isALineChannel || this.isALineChannel ||
this.isAnInstagramChannel || this.isAnInstagramChannel
this.isATiktokChannel
); );
}, },
replyButtonLabel() { replyButtonLabel() {
@@ -437,7 +426,7 @@ export default {
}, },
isEditorDisabled() { isEditorDisabled() {
return ( return (
(this.isAWhatsAppChannel || this.isAPIInbox) && this.isAWhatsAppChannel &&
!this.isOnPrivateNote && !this.isOnPrivateNote &&
!this.currentChat.can_reply !this.currentChat.can_reply
); );
@@ -512,7 +501,7 @@ export default {
); );
this.fetchAndSetReplyTo(); this.fetchAndSetReplyTo();
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage); emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
// A hacky fix to solve the drag and drop // A hacky fix to solve the drag and drop
// Is showing on top of new conversation modal drag and drop // Is showing on top of new conversation modal drag and drop
@@ -527,7 +516,7 @@ export default {
unmounted() { unmounted() {
document.removeEventListener('paste', this.onPaste); document.removeEventListener('paste', this.onPaste);
document.removeEventListener('keydown', this.handleKeyEvents); document.removeEventListener('keydown', this.handleKeyEvents);
emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage); emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
emitter.off(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor); emitter.off(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
emitter.off( emitter.off(
BUS_EVENTS.NEW_CONVERSATION_MODAL, BUS_EVENTS.NEW_CONVERSATION_MODAL,
@@ -548,10 +537,7 @@ export default {
}, },
setCopilotAcceptedMessage(message, replyType = this.replyType) { setCopilotAcceptedMessage(message, replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType); const key = this.getDraftKey(this.conversationIdByRoute, replyType);
this.copilotAcceptedMessages[key] = trimContent( this.copilotAcceptedMessages[key] = trimContent(message || '');
message || '',
this.maxLength
);
}, },
clearCopilotAcceptedMessage(replyType = this.replyType) { clearCopilotAcceptedMessage(replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType); const key = this.getDraftKey(this.conversationIdByRoute, replyType);
@@ -609,7 +595,7 @@ export default {
saveDraft(conversationId, replyType) { saveDraft(conversationId, replyType) {
if (this.message || this.message === '') { if (this.message || this.message === '') {
const key = this.getDraftKey(conversationId, replyType); const key = this.getDraftKey(conversationId, replyType);
const draftToSave = trimContent(this.message || '', this.maxLength); const draftToSave = trimContent(this.message || '');
this.$store.dispatch('draftMessages/set', { this.$store.dispatch('draftMessages/set', {
key, key,
@@ -765,13 +751,11 @@ export default {
this.isATwilioWhatsAppChannel || this.isATwilioWhatsAppChannel ||
this.isAWhatsAppCloudChannel || this.isAWhatsAppCloudChannel ||
this.is360DialogWhatsAppChannel; this.is360DialogWhatsAppChannel;
// Instagram and TikTok do not support sending text and attachments in the same message. // When users send messages containing both text and attachments on Instagram, Instagram treats them as separate messages.
// For Instagram, combining them causes duplicate messages due to separate echo events per component. // Although Chatwoot combines these into a single message, Instagram sends separate echo events for each component.
// For TikTok, the API rejects messages that mix text and media. // This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
// To handle both cases, text and attachments are always sent as separate messages.
const isOnInstagram = this.isAnInstagramChannel; const isOnInstagram = this.isAnInstagramChannel;
const isOnTiktok = this.isATiktokChannel; if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
if ((isOnWhatsApp || isOnInstagram || isOnTiktok) && !this.isPrivate) {
this.sendMessageAsMultipleMessages( this.sendMessageAsMultipleMessages(
this.message, this.message,
copilotAcceptedMessage copilotAcceptedMessage
@@ -1085,8 +1069,7 @@ export default {
const multipleMessagePayload = []; const multipleMessagePayload = [];
if (this.attachedFiles && this.attachedFiles.length) { if (this.attachedFiles && this.attachedFiles.length) {
let caption = let caption = this.isAnInstagramChannel ? '' : message;
this.isAnInstagramChannel || this.isATiktokChannel ? '' : message;
this.attachedFiles.forEach(attachment => { this.attachedFiles.forEach(attachment => {
const attachedFile = this.globalConfig.directUploadsEnabled const attachedFile = this.globalConfig.directUploadsEnabled
? attachment.blobSignedId ? attachment.blobSignedId
@@ -1108,13 +1091,11 @@ export default {
const hasNoAttachments = const hasNoAttachments =
!this.attachedFiles || !this.attachedFiles.length; !this.attachedFiles || !this.attachedFiles.length;
// For Instagram and TikTok, text must always be sent as a separate message (no captions on attachments). // For Instagram, we need a separate text message
// For WhatsApp, we only need a text message if there are no attachments. // For WhatsApp, we only need a text message if there are no attachments
if ( if (
((this.isAnInstagramChannel || this.isATiktokChannel) && (this.isAnInstagramChannel && this.message) ||
this.message) || (!this.isAnInstagramChannel && hasNoAttachments)
(!(this.isAnInstagramChannel || this.isATiktokChannel) &&
hasNoAttachments)
) { ) {
let messagePayload = { let messagePayload = {
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
@@ -1199,15 +1180,6 @@ export default {
return false; return false;
}); });
}, },
onReplyToMessage() {
this.fetchAndSetReplyTo();
if (this.inReplyTo) {
this.$nextTick(() => {
const pos = this.isSignatureEnabledForInbox ? 'start' : 'end';
this.messageEditor?.focusEditorInputField(pos);
});
}
},
resetReplyToMessage() { resetReplyToMessage() {
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO; const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId); LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
@@ -1263,7 +1235,6 @@ export default {
:is-editor-disabled="isEditorDisabled" :is-editor-disabled="isEditorDisabled"
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold" :is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining" :characters-remaining="charactersRemaining"
:editor-content="message"
:popout-reply-box="popOutReplyBox" :popout-reply-box="popOutReplyBox"
@set-reply-mode="setReplyMode" @set-reply-mode="setReplyMode"
@toggle-popout="togglePopout" @toggle-popout="togglePopout"
@@ -1330,7 +1301,6 @@ export default {
/> />
<WootMessageEditor <WootMessageEditor
v-else-if="!showAudioRecorderEditor" v-else-if="!showAudioRecorderEditor"
ref="messageEditor"
v-model="message" v-model="message"
:conversation-id="conversationId" :conversation-id="conversationId"
:editor-id="editorStateId" :editor-id="editorStateId"
@@ -1483,7 +1453,7 @@ export default {
} }
.reply-box__top { .reply-box__top {
@apply relative py-0 px-3 -mt-px; @apply relative py-0 px-4 -mt-px;
} }
.emoji-dialog { .emoji-dialog {
@@ -14,7 +14,7 @@ const emit = defineEmits(['dismiss']);
<template> <template>
<div <div
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5" class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
> >
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" /> <fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
<div class="flex-grow gap-1 mt-px text-xs truncate"> <div class="flex-grow gap-1 mt-px text-xs truncate">
@@ -32,7 +32,6 @@ const emit = defineEmits(['dismiss']);
xs xs
slate slate
icon="i-lucide-x" icon="i-lucide-x"
class="flex-shrink-0"
@click.stop="emit('dismiss')" @click.stop="emit('dismiss')"
/> />
</div> </div>
@@ -100,8 +100,6 @@ export default {
onCmdSnoozeConversation(snoozeType) { onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) { if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomTimeSnoozeModal = true; this.showCustomTimeSnoozeModal = true;
} else if (typeof snoozeType === 'number') {
this.updateConversations('snoozed', snoozeType);
} else { } else {
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null); this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
} }

Some files were not shown because too many files have changed in this diff Show More