Merge branch 'develop' into codex/fix-inbox-finish-setup-guidance

This commit is contained in:
Muhsin Keloth
2026-04-21 13:51:36 +04:00
committed by GitHub
1089 changed files with 50709 additions and 11977 deletions
+65
View File
@@ -0,0 +1,65 @@
---
: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:
- ''
+5
View File
@@ -2,3 +2,8 @@
ignore:
- 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
View File
@@ -40,6 +40,8 @@ gem 'json_refs'
gem 'rack-attack', '>= 6.7.0'
# a utility tool for streaming, flexible and safe downloading of remote files
gem 'down'
# SSRF-safe URL fetching
gem 'ssrf_filter', '~> 1.5'
# authentication type to fetch and send mail over oauth2.0
gem 'gmail_xoauth'
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
+2
View File
@@ -942,6 +942,7 @@ GEM
activesupport (>= 5.2)
sprockets (>= 3.0.0)
squasher (0.7.2)
ssrf_filter (1.5.0)
stackprof (0.2.25)
statsd-ruby (1.5.0)
stripe (18.0.1)
@@ -1158,6 +1159,7 @@ DEPENDENCIES
spring
spring-watcher-listen
squasher
ssrf_filter (~> 1.5)
stackprof
stripe (~> 18.0)
telephone_number
+1 -1
View File
@@ -1 +1 @@
4.12.0
4.13.0
+3 -5
View File
@@ -1,4 +1,6 @@
class Email::BaseBuilder
include EmailAddressParseable
pattr_initialize [:inbox!]
private
@@ -39,7 +41,7 @@ class Email::BaseBuilder
end
def business_name
inbox.business_name || inbox.sanitized_name
inbox.sanitized_business_name
end
def account_support_email
@@ -47,8 +49,4 @@ class Email::BaseBuilder
# can save it in the format "Name <email@domain.com>"
parse_email(account.support_email)
end
def parse_email(email_string)
Mail::Address.new(email_string).address
end
end
+1
View File
@@ -1,6 +1,7 @@
class V2::ReportBuilder
include DateRangeHelper
include ReportHelper
attr_reader :account, :params
DEFAULT_GROUP_BY = 'day'.freeze
@@ -11,10 +11,6 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def fetch_conversations_count
account.conversations.where(created_at: range).group('assignee_id').count
end
def prepare_report
account.account_users.map do |account_user|
build_agent_stats(account_user)
+27 -32
View File
@@ -9,37 +9,13 @@ class V2::Reports::BaseSummaryBuilder
private
def load_data
@conversations_count = fetch_conversations_count
load_reporting_events_data
end
results = data_source.summary
def load_reporting_events_data
# Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
index_key = group_by_key.to_s.split('.').last
results = reporting_events
.select(
"#{group_by_key} as #{index_key}",
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
"AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
"AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
"AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
)
.group(group_by_key)
.index_by { |record| record.public_send(index_key) }
@resolved_count = results.transform_values(&:resolved_count)
@avg_resolution_time = results.transform_values(&:avg_resolution_time)
@avg_first_response_time = results.transform_values(&:avg_first_response_time)
@avg_reply_time = results.transform_values(&:avg_reply_time)
end
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range)
end
def fetch_conversations_count
# Override this method
@conversations_count = results.transform_values { |data| data[:conversations_count] }
@resolved_count = results.transform_values { |data| data[:resolved_conversations_count] }
@avg_resolution_time = results.transform_values { |data| data[:avg_resolution_time] }
@avg_first_response_time = results.transform_values { |data| data[:avg_first_response_time] }
@avg_reply_time = results.transform_values { |data| data[:avg_reply_time] }
end
def group_by_key
@@ -50,7 +26,26 @@ class V2::Reports::BaseSummaryBuilder
# Override this method
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
def data_source
@data_source ||= Reports::DataSource.for(
account: account,
metric: nil,
dimension_type: summary_dimension_type,
dimension_id: nil,
scope: nil,
range: range,
group_by: 'day',
timezone_offset: params[:timezone_offset],
business_hours: params[:business_hours]
)
end
def summary_dimension_type
{
'account_id' => 'account',
'user_id' => 'agent',
'inbox_id' => 'inbox',
'conversations.team_id' => 'team'
}.fetch(group_by_key.to_s)
end
end
@@ -3,23 +3,10 @@ class V2::Reports::Conversations::BaseReportBuilder
private
AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
COUNT_METRICS = %w[
conversations_count
incoming_messages_count
outgoing_messages_count
resolutions_count
bot_resolutions_count
bot_handoffs_count
].freeze
def builder_class(metric)
case metric
when *AVG_METRICS
V2::Reports::Timeseries::AverageReportBuilder
when *COUNT_METRICS
V2::Reports::Timeseries::CountReportBuilder
end
return unless Reports::ReportMetricRegistry.supported?(metric)
V2::Reports::Timeseries::ReportBuilder
end
def log_invalid_metric
@@ -11,15 +11,6 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def load_data
@conversations_count = fetch_conversations_count
load_reporting_events_data
end
def fetch_conversations_count
account.conversations.where(created_at: range).group(group_by_key).count
end
def prepare_report
account.inboxes.map do |inbox|
build_inbox_stats(inbox)
@@ -40,8 +31,4 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
def group_by_key
:inbox_id
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
end
end
@@ -6,14 +6,6 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def fetch_conversations_count
account.conversations.where(created_at: range).group(:team_id).count
end
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
end
def prepare_report
account.teams.map do |team|
build_team_stats(team)
@@ -1,48 +0,0 @@
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_average_time = reporting_events.average(average_value_key)
grouped_event_count = reporting_events.count
grouped_average_time.each_with_object([]) do |element, arr|
event_date, average_time = element
arr << {
value: average_time,
timestamp: event_date.in_time_zone(timezone).to_i,
count: grouped_event_count[event_date]
}
end
end
def aggregate_value
object_scope.average(average_value_key)
end
private
def event_name
metric_to_event_name = {
avg_first_response_time: :first_response,
avg_resolution_time: :conversation_resolved,
reply_time: :reply_time
}
metric_to_event_name[params[:metric].to_sym]
end
def object_scope
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
end
def reporting_events
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
)
end
def average_value_key
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
end
end
@@ -1,12 +1,13 @@
class V2::Reports::Timeseries::BaseTimeseriesBuilder
include TimezoneHelper
include DateRangeHelper
DEFAULT_GROUP_BY = 'day'.freeze
pattr_initialize :account, :params
def scope
case params[:type].to_sym
case dimension_type.to_sym
when :account
account
when :inbox
@@ -20,6 +21,20 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
end
end
def data_source
@data_source ||= Reports::DataSource.for(
account: account,
metric: params[:metric],
dimension_type: dimension_type,
dimension_id: params[:id],
scope: scope,
range: range,
group_by: group_by,
timezone_offset: params[:timezone_offset],
business_hours: params[:business_hours]
)
end
def inbox
@inbox ||= account.inboxes.find(params[:id])
end
@@ -43,4 +58,10 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
end
private
def dimension_type
(params[:type].presence || 'account').to_s
end
end
@@ -1,78 +0,0 @@
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_count.each_with_object([]) do |element, arr|
event_date, event_count = element
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
# because it converts the date to 12:00 AM server timezone.
# The desired output should be 12:00 AM in the specified timezone.
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
end
end
def aggregate_value
object_scope.count
end
private
def metric
@metric ||= params[:metric]
end
def object_scope
send("scope_for_#{metric}")
end
def scope_for_conversations_count
scope.conversations.where(account_id: account.id, created_at: range)
end
def scope_for_incoming_messages_count
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
end
def scope_for_outgoing_messages_count
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
end
def scope_for_resolutions_count
scope.reporting_events.where(
name: :conversation_resolved,
account_id: account.id,
created_at: range
)
end
def scope_for_bot_resolutions_count
scope.reporting_events.where(
name: :conversation_bot_resolved,
account_id: account.id,
created_at: range
)
end
def scope_for_bot_handoffs_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_handoff,
account_id: account.id,
created_at: range
).distinct
end
def grouped_count
# IMPORTANT: time_zone parameter affects both data grouping AND output timestamps
# It converts timestamps to the target timezone before grouping, which means
# the same event can fall into different day buckets depending on timezone
# Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day)
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
).count
end
end
@@ -0,0 +1,9 @@
class V2::Reports::Timeseries::ReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
data_source.timeseries
end
def aggregate_value
data_source.aggregate
end
end
@@ -34,6 +34,10 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
@agent_bot.reload
end
def reset_secret
@agent_bot.reset_secret!
end
private
def agent_bot
@@ -57,7 +57,7 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
if result.nil?
render json: { message: nil }
elsif result[:error]
render json: { error: result[:error] }, status: :unprocessable_entity
render json: { error: result[:error] }, status: :unprocessable_content
else
response_data = { message: result[:message] }
response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context]
@@ -69,3 +69,5 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
authorize(:'captain/tasks')
end
end
Api::V1::Accounts::Captain::TasksController.prepend_mod_with('Api::V1::Accounts::Captain::TasksController')
@@ -116,6 +116,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# 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.
# 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, false) if !assignee? && @conversation.unread_messages.any?
@@ -66,6 +66,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
head :ok
end
def reset_secret
return head :not_found unless @inbox.api?
@inbox.channel.reset_secret!
end
def destroy
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
@@ -5,7 +5,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
elsif params[:external_url].present?
create_from_url
else
render_error('No file or URL provided', :unprocessable_entity)
render_error(I18n.t('errors.upload.missing_input'), :unprocessable_entity)
end
render_success(result) if result.is_a?(ActiveStorage::Blob)
@@ -19,35 +19,21 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
end
def create_from_url
uri = parse_uri(params[:external_url])
return if performed?
fetch_and_process_file_from_uri(uri)
end
def parse_uri(url)
uri = URI.parse(url)
validate_uri(uri)
uri
rescue URI::InvalidURIError, SocketError
render_error('Invalid URL provided', :unprocessable_entity)
nil
end
def validate_uri(uri)
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
end
def fetch_and_process_file_from_uri(uri)
uri.open do |file|
create_and_save_blob(file, File.basename(uri.path), file.content_type)
SafeFetch.fetch(params[:external_url].to_s) do |result|
create_and_save_blob(result.tempfile, result.filename, result.content_type)
end
rescue OpenURI::HTTPError => e
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
rescue SocketError
render_error('Invalid URL provided', :unprocessable_entity)
rescue SafeFetch::HttpError => e
render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity)
rescue SafeFetch::FetchError
render_error(I18n.t('errors.upload.fetch_failed'), :unprocessable_entity)
rescue SafeFetch::FileTooLargeError
render_error(I18n.t('errors.upload.file_too_large'), :unprocessable_entity)
rescue SafeFetch::UnsupportedContentTypeError
render_error(I18n.t('errors.upload.unsupported_content_type'), :unprocessable_entity)
rescue SafeFetch::Error
render_error(I18n.t('errors.upload.invalid_url'), :unprocessable_entity)
rescue StandardError
render_error('An unexpected error occurred', :internal_server_error)
render_error(I18n.t('errors.upload.unexpected'), :internal_server_error)
end
def create_and_save_blob(io, filename, content_type)
+32 -2
View File
@@ -30,9 +30,20 @@ class Api::V1::AccountsController < Api::BaseController
locale: account_params[:locale],
user: current_user
).perform
enqueue_branding_enrichment
if @user
send_auth_headers(@user)
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
# Authenticated users (dashboard "add account") and api_only signups
# need the full response with account_id. API-only deployments have no
# frontend to handle the email confirmation flow, so they need auth
# tokens to proceed.
# Unauthenticated web signup returns only the email — no session is
# created until the user confirms via the email link.
if current_user || api_only_signup?
send_auth_headers(@user)
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
else
render json: { email: @user.email }
end
else
render_error_response(CustomExceptions::Account::SignupFailed.new({}))
end
@@ -59,6 +70,16 @@ class Api::V1::AccountsController < Api::BaseController
private
def enqueue_branding_enrichment
return if account_params[:email].blank?
Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email])
Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30)
rescue StandardError => e
# Enrichment is optional — never let queue/Redis failures abort signup
ChatwootExceptionTracker.new(e).capture_exception
end
def ensure_account_name
# ensure that account_name and user_full_name is present
# this is becuase the account builder and the models validations are not triggered
@@ -103,6 +124,15 @@ class Api::V1::AccountsController < Api::BaseController
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end
def api_only_signup?
# CW_API_ONLY_SERVER is the canonical flag for API-only deployments.
# ENABLE_ACCOUNT_SIGNUP='api_only' is a legacy sentinel for the same purpose.
# Read ENABLE_ACCOUNT_SIGNUP raw from InstallationConfig because GlobalConfig.get
# typecasts it to boolean, coercing 'api_only' to true.
ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false)) ||
InstallationConfig.find_by(name: 'ENABLE_ACCOUNT_SIGNUP')&.value.to_s == 'api_only'
end
def validate_captcha
raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
end
@@ -43,7 +43,15 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end
def set_conversation
@conversation = create_conversation if conversation.nil?
return unless 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
def message_finder_params
@@ -64,7 +72,14 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def permitted_params
# timestamp parameter is used in create conversation method
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to])
# 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],
custom_attributes: {},
labels: []
)
end
def set_message
@@ -3,15 +3,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
render_report_with(V2::Reports::AgentSummaryBuilder, type: :agent)
end
def team
render_report_with(V2::Reports::TeamSummaryBuilder)
render_report_with(V2::Reports::TeamSummaryBuilder, type: :team)
end
def inbox
render_report_with(V2::Reports::InboxSummaryBuilder)
render_report_with(V2::Reports::InboxSummaryBuilder, type: :inbox)
end
def label
@@ -38,8 +38,9 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
}
end
def render_report_with(builder_class)
builder = builder_class.new(account: Current.account, params: @builder_params)
def render_report_with(builder_class, type: nil)
builder_params = type.present? ? @builder_params.merge(type: type) : @builder_params
builder = builder_class.new(account: Current.account, params: builder_params)
render json: builder.build
end
@@ -0,0 +1,18 @@
# Unauthenticated endpoint for resending confirmation emails during signup.
# This is a standalone controller (not on DeviseOverrides::ConfirmationsController)
# because OmniAuth middleware intercepts all POST /auth/* routes as provider
# callbacks, and Devise controller filters cause 307 redirects for custom actions.
# Inherits from ActionController::API to avoid both issues entirely.
# Rate-limited by Rack::Attack (IP + email) and gated by hCaptcha.
class Auth::ResendConfirmationsController < ActionController::API
def create
return head(:ok) unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
email = params[:email]
return head(:ok) unless email.is_a?(String)
user = User.from_email(email.strip.downcase)
user&.send_confirmation_instructions unless user&.confirmed?
head :ok
end
end
@@ -0,0 +1,88 @@
module TwilioSignatureVerifyConcern
extend ActiveSupport::Concern
included do
before_action :verify_twilio_signature!
end
private
def verify_twilio_signature!
channel = find_twilio_channel
return log_and_reject_missing_channel if channel.blank?
return if channel.api_key_sid.present? && log_api_key_skip(channel)
head :forbidden unless valid_signature?(channel)
end
def log_and_reject_missing_channel
Rails.logger.warn(
'[TWILIO] Channel not found for webhook ' \
"account_sid=#{params[:AccountSid]} messaging_service_sid=#{params[:MessagingServiceSid]} " \
"to=#{params[:To]} from=#{params[:From]}"
)
head :forbidden
end
def log_api_key_skip(channel)
Rails.logger.warn(
'[TWILIO] Signature validation skipped: channel uses API key authentication. ' \
"account_sid=#{params[:AccountSid]} channel_id=#{channel.id}"
)
end
def valid_signature?(channel)
signature = request.headers['X-Twilio-Signature']
if signature.blank?
Rails.logger.warn("[TWILIO] Missing X-Twilio-Signature header account_sid=#{params[:AccountSid]}")
return false
end
validator = Twilio::Security::RequestValidator.new(channel.auth_token)
request_url = reconstruct_url
return true if validator.validate(request_url, request.request_parameters, signature)
Rails.logger.warn(
'[TWILIO] Signature validation failed ' \
"account_sid=#{params[:AccountSid]} channel_id=#{channel.id} url=#{request_url} ip=#{request.remote_ip}"
)
false
end
def find_twilio_channel
if params[:MessagingServiceSid].present?
channel = ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid])
return channel if channel.present? && (params[:AccountSid].blank? || channel.account_sid == params[:AccountSid])
return nil
end
return if params[:AccountSid].blank?
find_channel_by_phone_number
end
def find_channel_by_phone_number
channel_lookup_phone_numbers.each do |phone|
channel = ::Channel::TwilioSms.find_by(account_sid: params[:AccountSid], phone_number: phone)
return channel if channel
end
nil
end
def channel_lookup_phone_numbers
[params[:To], params[:From]].compact_blank
end
def reconstruct_url
url = request.original_url
url = url.sub('http://', 'https://') if url.start_with?('http://') && https_request?
url
end
def https_request?
return true if request.ssl?
forwarded_proto = request.headers['X-Forwarded-Proto'].to_s.split(',').map(&:strip).find(&:present?)
forwarded_proto&.casecmp?('https')
end
end
@@ -10,7 +10,12 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
private
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?
set_random_password_if_oauth_user if needs_password_reset
# once the resource is found and verified
# we can just send them to the login page again with the SSO params
@@ -20,7 +25,10 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end
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?
set_random_password_if_oauth_user if needs_password_reset
# once the resource is found and verified
# we can just send them to the login page again with the SSO params
@@ -37,6 +45,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
create_account_for_user
set_random_password_if_oauth_user
token = @resource.send(:set_reset_password_token)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
@@ -81,6 +90,15 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
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
'user'
end
@@ -0,0 +1,101 @@
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,6 +1,7 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :ensure_portal_feature_enabled
before_action :set_category, except: [:index, :show, :tracking_pixel]
before_action :set_article, only: [:show]
layout 'portal'
@@ -61,7 +62,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@parsed_content = render_article_content(@article.content)
@parsed_content = render_article_content(@article.content.to_s)
end
def set_category
@@ -1,6 +1,7 @@
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :ensure_portal_feature_enabled
before_action :set_category, only: [:show]
layout 'portal'
@@ -1,7 +1,8 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show]
before_action :portal
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
before_action :ensure_portal_feature_enabled
layout 'portal'
def show
@@ -24,6 +25,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
def redirect_to_portal_with_locale
return if params[:locale].present?
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
end
+7
View File
@@ -18,4 +18,11 @@ class PublicController < ActionController::Base
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
}, status: :unauthorized and return
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
@@ -1,4 +1,6 @@
class Twilio::CallbackController < ApplicationController
include TwilioSignatureVerifyConcern
def create
Webhooks::TwilioEventsJob.perform_later(permitted_params.to_unsafe_hash)
@@ -1,4 +1,6 @@
class Twilio::DeliveryStatusController < ApplicationController
include TwilioSignatureVerifyConcern
def create
Webhooks::TwilioDeliveryStatusJob.perform_later(permitted_params.to_unsafe_hash)
@@ -18,4 +20,8 @@ class Twilio::DeliveryStatusController < ApplicationController
:ErrorMessage
)
end
def channel_lookup_phone_numbers
[params[:From]].compact_blank
end
end
+3 -1
View File
@@ -98,7 +98,9 @@ export default {
mql.onchange = e => setColorTheme(e.matches);
},
setLocale(locale) {
this.$root.$i18n.locale = locale;
if (locale) {
this.$root.$i18n.locale = locale;
}
},
async initializeAccount() {
await this.$store.dispatch('accounts/get');
@@ -25,6 +25,10 @@ class AgentBotsAPI extends ApiClient {
resetAccessToken(botId) {
return axios.post(`${this.url}/${botId}/reset_access_token`);
}
resetSecret(botId) {
return axios.post(`${this.url}/${botId}/reset_secret`);
}
}
export default new AgentBotsAPI();
@@ -31,6 +31,12 @@ class CaptainCustomTools extends ApiClient {
delete(id) {
return axios.delete(`${this.url}/${id}`);
}
test(data = {}) {
return axios.post(`${this.url}/test`, {
custom_tool: data,
});
}
}
export default new CaptainCustomTools();
+4
View File
@@ -48,6 +48,10 @@ class Inboxes extends CacheEnabledApiClient {
template,
});
}
resetSecret(inboxId) {
return axios.post(`${this.url}/${inboxId}/reset_secret`);
}
}
export default new Inboxes();
@@ -106,6 +106,10 @@ select {
&[disabled] {
@apply field-disabled;
}
option:not(:disabled) {
@apply bg-n-solid-2 text-n-slate-12;
}
}
// Textarea
@@ -20,11 +20,11 @@ const excludedLabels = defineModel('excludedLabels', {
const excludeOlderThanMinutes = defineModel('excludeOlderThanMinutes', {
type: Number,
default: 10,
default: null,
});
// Duration limits: 10 minutes to 999 days (in minutes)
const MIN_DURATION_MINUTES = 10;
// Duration limits: 1 minute to 999 days (in minutes)
const MIN_DURATION_MINUTES = 1;
const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes
const { t } = useI18n();
@@ -27,7 +27,7 @@ const { t } = useI18n();
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
const DEFAULT_CONVERSATION_LIMIT = 10;
const MIN_CONVERSATION_LIMIT = 1;
const MIN_CONVERSATION_LIMIT = 0;
const MAX_CONVERSATION_LIMIT = 100000;
const selectedInboxIds = computed(
@@ -42,6 +42,7 @@ const availableInboxes = computed(() =>
const isLimitValid = limit => {
return (
Number.isInteger(limit.conversationLimit) &&
limit.conversationLimit >= MIN_CONVERSATION_LIMIT &&
limit.conversationLimit <= MAX_CONVERSATION_LIMIT
);
@@ -107,11 +107,10 @@ const closeMobileSidebar = () => {
size="sm"
/>
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<template #trigger>
<Button
:label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
size="sm"
@click="toggle"
/>
</template>
</ComposeConversation>
@@ -114,8 +114,8 @@ const emit = defineEmits([
</div>
<div class="w-px h-4 bg-n-strong" />
<ComposeConversation>
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
<template #trigger>
<Button :label="buttonLabel" size="sm" />
</template>
</ComposeConversation>
</div>
@@ -103,6 +103,7 @@ const showPagination = computed(() => {
<ContactsActiveFiltersPreview
v-if="showActiveFiltersPreview"
:active-segment="activeSegment"
class="mb-1"
@clear-filters="emit('clearFilters')"
@open-filter="openFilter"
/>
@@ -0,0 +1,58 @@
<script setup>
import { ref, computed } from 'vue';
import Avatar from 'next/avatar/Avatar.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
contact: { type: Object, required: true },
selected: { type: Boolean, default: false },
enableSelection: { type: Boolean, default: true },
hideThumbnail: { type: Boolean, default: false },
});
const emit = defineEmits(['selectConversation']);
const hovered = ref(false);
const onThumbnailHover = () => {
hovered.value = !props.hideThumbnail;
};
const onThumbnailLeave = () => {
hovered.value = false;
};
const selectedModel = computed({
get: () => props.selected,
set: value => {
emit('selectConversation', value);
},
});
</script>
<template>
<div
class="relative flex items-center flex-shrink-0"
@mouseenter="onThumbnailHover"
@mouseleave="onThumbnailLeave"
>
<Avatar
v-if="!hideThumbnail"
:name="contact.name"
:src="contact.thumbnail"
:size="24"
:status="contact.availability_status"
hide-offline-status
>
<template v-if="enableSelection" #overlay>
<div
v-if="hovered || selected"
class="flex items-center justify-center rounded-md cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] size-6"
@click.stop
>
<Checkbox v-model="selectedModel" />
</div>
</template>
</Avatar>
</div>
</template>
@@ -0,0 +1,47 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import MessagePreview from './MessagePreview.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
import UnreadBadge from './UnreadBadge.vue';
defineProps({
lastMessage: { type: Object, default: null },
voiceCallStatus: { type: String, default: '' },
voiceCallDirection: { type: String, default: '' },
unreadCount: { type: Number, default: 0 },
showExpandedPreview: { type: Boolean, default: false },
});
</script>
<template>
<div
class="grid grid-cols-[1fr_auto] gap-1.5"
:class="showExpandedPreview ? 'items-end' : 'items-center'"
>
<VoiceCallStatus
v-if="voiceCallStatus"
key="voice-status-row"
:status="voiceCallStatus"
:direction="voiceCallDirection"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
/>
<MessagePreview
v-else-if="lastMessage"
key="message-preview"
:message="lastMessage"
:multi-line="showExpandedPreview"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
/>
<span
v-else
key="no-messages"
class="inline-grid grid-flow-col auto-cols-max items-center gap-1 text-body-main"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
>
<Icon icon="i-lucide-info" class="size-3.5" />
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span>
<UnreadBadge :count="unreadCount" :align-bottom="showExpandedPreview" />
</div>
</template>
@@ -0,0 +1,175 @@
<script setup>
import { ref, computed, nextTick, useSlots, watch, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useThrottleFn } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
labels: {
type: Array,
default: () => [],
},
disableToggle: {
type: Boolean,
default: false,
},
});
defineOptions({ inheritAttrs: false });
const attrs = useAttrs();
const slots = useSlots();
const { t } = useI18n();
const accountLabels = useMapGetter('labels/getLabels');
const activeLabels = computed(() => {
return accountLabels.value.filter(({ title }) =>
props.labels.includes(title)
);
});
const showAllLabels = ref(false);
const showExpandLabelButton = ref(false);
const labelPosition = ref(-1);
const labelContainer = ref(null);
// Show if there are labels OR if before slot exists
const showSection = computed(
() => activeLabels.value.length > 0 || !!slots.before
);
const computeVisibleLabelPosition = () => {
const container = labelContainer.value;
if (!container || activeLabels.value.length === 0) {
showExpandLabelButton.value = false;
return;
}
const labels = container.querySelectorAll('[data-label]');
if (labels.length === 0) {
showExpandLabelButton.value = false;
return;
}
// Early exit if all labels are visible
if (showAllLabels.value) return;
const beforeSlot = container.querySelector('[data-before-slot]');
const beforeSlotWidth = beforeSlot?.offsetWidth ?? 0;
const availableWidth = container.clientWidth - 46 - beforeSlotWidth;
let totalWidth = 0;
const labelsArray = Array.from(labels);
// Find last visible label index using some() - stops early on overflow
const overflowIndex = labelsArray.findIndex(label => {
totalWidth += label.offsetWidth + 6;
return totalWidth > availableWidth;
});
const visibleIndex =
overflowIndex === -1 ? labelsArray.length - 1 : overflowIndex - 1;
labelPosition.value = visibleIndex;
showExpandLabelButton.value = visibleIndex < labelsArray.length - 1;
};
const throttledCalculate = useThrottleFn(computeVisibleLabelPosition, 16);
watch(activeLabels, () => nextTick(throttledCalculate), { immediate: true });
const hiddenLabelsCount = computed(() => {
if (!showExpandLabelButton.value || showAllLabels.value) return 0;
return activeLabels.value.length - labelPosition.value - 1;
});
// Check if all labels are hidden (none visible)
const allLabelsHidden = computed(() => {
return labelPosition.value === -1 && activeLabels.value.length > 0;
});
// Label text for button when disableToggle is true and all labels are hidden
const labelsCountText = computed(() => {
if (props.disableToggle && allLabelsHidden.value) {
return t('CONVERSATION.CARD.LABELS_COUNT', {
count: activeLabels.value.length,
});
}
if (!showAllLabels.value && hiddenLabelsCount.value > 0) {
return hiddenLabelsCount.value;
}
return '';
});
const hiddenLabelsTooltip = computed(() => {
if (!props.disableToggle) return '';
// When all labels are hidden, show all label titles
if (allLabelsHidden.value) {
return activeLabels.value.map(label => label.title).join(', ');
}
if (!showExpandLabelButton.value) return '';
const hiddenLabels = activeLabels.value.slice(labelPosition.value + 1);
return hiddenLabels.map(label => label.title).join(', ');
});
const tooltipText = computed(() => {
if (props.disableToggle && hiddenLabelsTooltip.value) {
return hiddenLabelsTooltip.value;
}
return showAllLabels.value
? t('CONVERSATION.CARD.HIDE_LABELS')
: t('CONVERSATION.CARD.SHOW_LABELS');
});
const onShowLabels = e => {
e.stopPropagation();
if (props.disableToggle) return;
showAllLabels.value = !showAllLabels.value;
nextTick(() => computeVisibleLabelPosition());
};
</script>
<template>
<div
v-if="showSection"
ref="labelContainer"
v-bind="attrs"
v-resize="throttledCalculate"
data-labels-container
class="flex items-center flex-shrink min-w-0 min-h-6 gap-x-1.5 gap-y-1 [&:not(:has([data-label],[data-before-slot]))]:hidden"
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
>
<slot name="before" />
<Label
v-for="(label, index) in activeLabels"
:key="label ? label.id : index"
data-label
:label="label"
compact
:class="{
'invisible absolute': !showAllLabels && index > labelPosition,
}"
/>
<Button
v-if="showExpandLabelButton || (disableToggle && allLabelsHidden)"
v-tooltip.top="{
content: tooltipText,
delay: { show: 500, hide: 0 },
}"
:label="labelsCountText"
xs
slate
:no-animation="disableToggle"
:icon="labelsCountText ? 'i-lucide-plus' : 'i-lucide-chevron-left'"
class="!py-0 !px-1.5 flex-shrink-0 !rounded-md !bg-n-button-color -outline-offset-1 !gap-0.5 [&>span:first-child]:!text-n-slate-10 [&>span:last-child]:!text-n-slate-11"
:class="{ 'cursor-default': disableToggle }"
@click="onShowLabels"
/>
</div>
<template v-else />
</template>
@@ -1,207 +1,63 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
defineProps({
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
priority: {
type: String,
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>
<!-- eslint-disable vue/no-static-inline-styles -->
<template>
<div class="inline-flex items-center justify-center rounded-md">
<!-- Low Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.LOW"
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-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>
<Icon
v-tooltip.top="{
content: tooltipContent,
delay: { show: 500, hide: 0 },
}"
:icon="iconName"
class="size-4 text-n-slate-5"
/>
</template>
@@ -0,0 +1,42 @@
<script setup>
import { computed } from 'vue';
import { CONVERSATION_STATUS } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
status: {
type: String,
default: '',
},
showEmpty: {
type: Boolean,
default: false,
},
});
const icons = {
[CONVERSATION_STATUS.OPEN]: 'i-woot-status-open',
[CONVERSATION_STATUS.RESOLVED]: 'i-woot-status-resolved',
[CONVERSATION_STATUS.PENDING]: 'i-woot-status-pending',
[CONVERSATION_STATUS.SNOOZED]: 'i-woot-status-snoozed',
};
const iconName = computed(() => {
if (props.status && icons[props.status]) {
return icons[props.status];
}
return props.showEmpty ? 'i-woot-status-empty' : '';
});
</script>
<template>
<Icon
v-tooltip.top="{
content: status,
delay: { show: 500, hide: 0 },
}"
:icon="iconName"
class="size-4 flex-shrink-0"
/>
</template>
@@ -0,0 +1,186 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import CardAvatar from './CardAvatar.vue';
import CardContent from './CardContent.vue';
import CardLabels from './CardLabelsV5.vue';
import CardPriorityIcon from './CardPriorityIcon.vue';
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
import Avatar from 'next/avatar/Avatar.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
import CardStatusIcon from './CardStatusIcon.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
chat: { type: Object, required: true },
currentContact: { type: Object, required: true },
assignee: { type: Object, default: () => ({}) },
inbox: { type: Object, default: () => ({}) },
selected: { type: Boolean, default: false },
isActiveChat: { type: Boolean, default: false },
showAssignee: { type: Boolean, default: false },
showInboxName: { type: Boolean, default: false },
isInboxView: { type: Boolean, default: false },
});
const emit = defineEmits([
'selectConversation',
'deSelectConversation',
'click',
'contextmenu',
]);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const unreadCount = computed(() => props.chat.unread_count);
const slaCardLabel = useTemplateRef('slaCardLabel');
const hasSlaPolicyId = computed(
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
);
const selectedModel = computed({
get: () => props.selected,
set: value => {
if (value) {
emit('selectConversation', value);
} else {
emit('deSelectConversation', value);
}
},
});
</script>
<template>
<div
class="conversation relative cursor-pointer group grid gap-4 items-center px-3 h-12 border-b border-n-slate-3 hover:border-n-surface-1 hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 !border-n-surface-1':
isActiveChat,
'selected bg-n-slate-2 dark:bg-n-slate-3 !border-n-surface-1': selected,
'hover:bg-n-alpha-1': !isActiveChat && !selected,
'grid-cols-[minmax(0,2fr)_minmax(0,1fr)]': showLabelsSection,
'grid-cols-[minmax(0,2fr)_max-content]': !showLabelsSection,
}"
@click="$emit('click', $event)"
@contextmenu="$emit('contextmenu', $event)"
>
<!-- LEFT SECTION -->
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="flex items-center justify-center flex-shrink-0" @click.stop>
<Checkbox v-model="selectedModel" />
</div>
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div class="w-4 flex items-center justify-center flex-shrink-0">
<CardPriorityIcon :priority="chat.priority" show-empty />
</div>
<div class="w-4 flex items-center justify-center flex-shrink-0">
<Avatar
v-if="showAssignee && assignee.name"
v-tooltip.top="{
content: assignee.name,
delay: { show: 500, hide: 0 },
}"
:name="assignee.name"
:src="assignee.thumbnail"
:size="14"
:status="assignee.availability_status"
hide-offline-status
/>
<Icon
v-else
icon="i-woot-empty-assignee"
class="size-4 text-n-slate-7"
/>
</div>
<div class="w-4 flex items-center justify-center flex-shrink-0">
<CardStatusIcon :status="chat.status" show-empty />
</div>
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div v-if="!isInboxView && showInboxName" class="w-20 flex-shrink-0">
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
</div>
<div
v-if="!isInboxView && showInboxName"
class="w-px h-3 bg-n-slate-6 flex-shrink-0"
/>
<div
v-tooltip.top="{
content: chat.id,
delay: { show: 500, hide: 0 },
}"
class="h-6 flex items-center gap-1 max-w-20 w-full min-w-0 flex-shrink-0"
>
<Icon
icon="i-woot-hash"
class="size-3.5 text-n-slate-10 flex-shrink-0"
/>
<span class="text-body-main text-n-slate-11 truncate">
{{ chat.id }}
</span>
</div>
<CardAvatar
:contact="currentContact"
:selected="false"
:enable-selection="false"
:hide-thumbnail="false"
/>
<h4
class="text-heading-3 my-0 capitalize truncate text-n-slate-12 font-medium w-32 flex-shrink-0"
>
{{ currentContact.name }}
</h4>
<CardContent
:last-message="lastMessageInChat"
:voice-call-status="voiceCallData.status"
:voice-call-direction="voiceCallData.direction"
:unread-count="unreadCount"
:show-expanded-preview="false"
/>
</div>
<!-- RIGHT SECTION -->
<div class="flex items-center justify-end gap-1.5 flex-shrink-0">
<div v-if="showLabelsSection" class="min-w-0 w-full">
<CardLabels
:labels="chat.labels"
disable-toggle
class="my-0 [&>div]:justify-end justify-end"
/>
</div>
<div v-if="hasSlaPolicyId" class="flex-shrink-0">
<SLACardLabel ref="slaCardLabel" :chat="chat" />
</div>
<div class="flex-shrink-0 w-[4.375rem] text-end">
<TimeAgo
:conversation-id="chat.id"
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
class="font-440 !text-xs text-n-slate-11"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,156 @@
<script setup>
import { computed } from 'vue';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
message: {
type: Object,
required: true,
},
showMessageType: {
type: Boolean,
default: true,
},
defaultEmptyMessage: {
type: String,
default: '',
},
multiLine: {
type: Boolean,
default: false,
},
});
const { getPlainText } = useMessageFormatter();
const attachmentIcons = {
image: 'i-lucide-image',
audio: 'i-lucide-headphones',
video: 'i-lucide-video',
file: 'i-lucide-file',
location: 'i-lucide-map-pin',
fallback: 'i-lucide-link-2',
};
const messageByAgent = computed(() => {
const { message_type: messageType } = props.message;
return messageType === MESSAGE_TYPE.OUTGOING;
});
const isMessageAnActivity = computed(() => {
const { message_type: messageType } = props.message;
return messageType === MESSAGE_TYPE.ACTIVITY;
});
const isMessagePrivate = computed(() => {
const { private: isPrivate } = props.message;
return isPrivate;
});
const parsedLastMessage = computed(() => {
const { content_attributes: contentAttributes } = props.message;
const { email: { subject } = {} } = contentAttributes || {};
return getPlainText(subject || props.message.content);
});
const lastMessageFileType = computed(() => {
const [{ file_type: fileType } = {}] = props.message.attachments;
return fileType;
});
const attachmentIcon = computed(() => {
return attachmentIcons[lastMessageFileType.value];
});
const attachmentMessageContent = computed(() => {
return `CHAT_LIST.ATTACHMENTS.${lastMessageFileType.value}.CONTENT`;
});
const isMessageSticker = computed(() => {
return props.message && props.message.content_type === 'sticker';
});
</script>
<template>
<div
class="min-w-0 text-sm"
:class="
multiLine
? 'flex items-start gap-1'
: 'grid grid-cols-[auto_1fr] items-center gap-1'
"
>
<template v-if="showMessageType && !multiLine">
<Icon
v-if="isMessagePrivate"
icon="i-lucide-lock-keyhole"
class="size-3.5"
/>
<Icon
v-else-if="messageByAgent"
icon="i-lucide-undo-2"
class="size-3.5"
/>
<Icon
v-else-if="isMessageAnActivity"
icon="i-lucide-info"
class="size-3.5"
/>
</template>
<span
class="min-w-0 text-body-main"
:class="multiLine ? 'line-clamp-2' : 'truncate'"
>
<!-- Case for previous and conversation conversation card -->
<template v-if="showMessageType && multiLine">
<Icon
v-if="isMessagePrivate"
icon="i-lucide-lock-keyhole"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<Icon
v-else-if="messageByAgent"
icon="i-lucide-undo-2"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<Icon
v-else-if="isMessageAnActivity"
icon="i-lucide-info"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
</template>
<span
v-if="message.content && isMessageSticker"
class="inline-grid grid-flow-col auto-cols-max items-center gap-1"
>
<Icon icon="i-lucide-image" class="size-3.5" />
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
</span>
<template v-else-if="message.content">
{{ parsedLastMessage }}
</template>
<span
v-else-if="message.attachments"
class="inline-block align-middle truncate"
>
<Icon
v-if="attachmentIcon && showMessageType"
:icon="attachmentIcon"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<span class="inline-block align-middle">
{{ $t(attachmentMessageContent) }}
</span>
</span>
<template v-else>
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
</template>
</span>
</div>
</template>
@@ -0,0 +1,28 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
count: { type: Number, required: true },
alignBottom: { type: Boolean, default: false },
});
const { t } = useI18n();
const displayCount = computed(() =>
props.count > 9 ? t('CHAT_LIST.UNREAD_COUNT_OVERFLOW') : props.count
);
</script>
<template>
<span
v-if="count > 0"
class="bg-n-teal-9 rounded-full h-4 min-w-4 max-w-5 px-1 w-fit font-medium text-xxs leading-3 text-white inline-grid place-items-center flex-shrink-0"
:class="{
'mb-0.5': alignBottom,
}"
>
{{ displayCount }}
</span>
<span v-else />
</template>
@@ -0,0 +1,69 @@
<script setup>
import { computed } from 'vue';
import {
VOICE_CALL_STATUS,
VOICE_CALL_DIRECTION,
} from 'dashboard/components-next/message/constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
status: { type: String, default: '' },
direction: { type: String, default: '' },
});
const LABEL_KEYS = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
);
const labelKey = computed(() => {
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
if (props.status === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const iconName = computed(() => {
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
const statusColor = computed(
() => COLOR_MAP[props.status] || 'text-n-slate-11'
);
</script>
<template>
<div class="grid grid-cols-[auto_1fr] items-center gap-1 min-w-0 text-sm">
<Icon class="size-3.5" :icon="iconName" :class="statusColor" />
<span class="truncate text-body-main" :class="statusColor">
{{ $t(labelKey) }}
</span>
</div>
</template>
@@ -0,0 +1,19 @@
<script setup>
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
defineProps({
inbox: {
type: Object,
default: () => {},
},
});
</script>
<template>
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
<span class="truncate text-body-main text-n-slate-11">
{{ inbox.name }}
</span>
</div>
</template>
@@ -57,7 +57,7 @@ useKeyboardEvents(keyboardEvents);
<template>
<ButtonGroup
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow"
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow !z-20"
>
<Button
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
@@ -0,0 +1,82 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from '@chatwoot/utils';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
});
const REFRESH_INTERVAL = 60000;
const timer = ref(null);
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
});
defineOptions({
inheritAttrs: false,
});
const appliedSLA = computed(() => props.chat?.applied_sla);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value || {},
chat: props.chat,
});
};
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
watch(() => props.chat, updateSlaStatus);
defineExpose({
hasSlaThreshold,
});
</script>
<template>
<div
v-if="hasSlaThreshold"
v-bind="$attrs"
class="relative flex items-center cursor-pointer min-w-fit group"
>
<Label
:label="slaStatus.threshold"
:color="isSlaMissed ? 'ruby' : 'amber'"
compact
>
<template #icon>
<Icon icon="i-lucide-flame" class="flex-shrink-0 size-3.5" />
</template>
</Label>
</div>
<template v-else />
</template>
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { ref, computed, watch } from 'vue';
import { debounce } from '@chatwoot/utils';
import { useI18n } from 'vue-i18n';
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
@@ -27,58 +27,50 @@ const props = defineProps({
const emit = defineEmits([
'saveArticle',
'saveArticleAsync',
'goBack',
'setAuthor',
'setCategory',
'previewArticle',
'createArticle',
]);
const { t } = useI18n();
const isNewArticle = computed(() => !props.article?.id);
const saveAndSync = value => {
emit('saveArticle', value);
};
const localTitle = ref(props.article?.title ?? '');
const localContent = ref(props.article?.content ?? '');
// this will only send the data to the backend
// but will not update the local state preventing unnecessary re-renders
// since the data is already saved and we keep the editor text as the source of truth
const quickSave = debounce(
value => emit('saveArticleAsync', value),
400,
false
// Sync local state when navigating to a different article or on initial fetch
watch(
() => props.article?.id,
newId => {
if (newId) {
localTitle.value = props.article?.title ?? '';
localContent.value = props.article?.content ?? '';
}
}
);
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause
// so we can save the data to the backend and retrieve the updated data
// this will update the local state with response data
// Only use to save for existing articles
const saveAndSyncDebounced = debounce(saveAndSync, 2500, false);
// Debounced save for new articles
const quickSaveNewArticle = debounce(saveAndSync, 400, false);
const debouncedSave = debounce(value => emit('saveArticle', value), 500, false);
const handleSave = value => {
if (isNewArticle.value) {
quickSaveNewArticle(value);
} else {
quickSave(value);
saveAndSyncDebounced(value);
}
if (isNewArticle.value) return;
debouncedSave(value);
};
const articleTitle = computed({
get: () => props.article.title,
get: () => localTitle.value,
set: value => {
localTitle.value = value;
handleSave({ title: value });
},
});
const articleContent = computed({
get: () => props.article.content,
get: () => localContent.value,
set: content => {
localContent.value = content;
handleSave({ content });
},
});
@@ -98,6 +90,14 @@ const setCategoryId = categoryId => {
const previewArticle = () => {
emit('previewArticle');
};
const handleCreateArticle = event => {
if (!isNewArticle.value) return;
const title = event?.target?.value || '';
if (title.trim()) {
emit('createArticle', { title, content: localContent.value });
}
};
</script>
<template>
@@ -122,10 +122,11 @@ const previewArticle = () => {
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
placeholder="Title"
autofocus
@blur="handleCreateArticle"
/>
<ArticleEditorControls
:article="article"
@save-article="saveAndSync"
@save-article="values => emit('saveArticle', values)"
@set-author="setAuthorId"
@set-category="setCategoryId"
/>
@@ -160,8 +161,12 @@ const previewArticle = () => {
}
.editor-root .has-selection {
.ProseMirror-menubar:not(:has(*)) {
display: none !important;
}
.ProseMirror-menubar {
@apply h-8 rounded-lg !px-2 z-50 bg-n-solid-3 items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
display: flex;
top: var(--selection-top, auto) !important;
left: var(--selection-left, 0) !important;
@@ -169,15 +174,10 @@ const previewArticle = () => {
position: absolute !important;
.ProseMirror-menuitem {
@apply mr-0;
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
.ProseMirror-icon {
@apply p-0 mt-0 !mr-0;
svg {
width: 20px !important;
height: 20px !important;
}
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
}
}
@@ -2,13 +2,10 @@
import { reactive, ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useWindowSize } from '@vueuse/core';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { vOnClickOutside } from '@vueuse/components';
import { useAlert } from 'dashboard/composables';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { debounce } from '@chatwoot/utils';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
@@ -18,22 +15,18 @@ import {
processContactableInboxes,
mergeInboxDetails,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import wootConstants from 'dashboard/constants/globals';
import Popover from 'dashboard/components-next/popover/Popover.vue';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
const props = defineProps({
alignPosition: {
type: String,
default: 'left',
},
contactId: {
type: String,
default: null,
},
isModal: {
type: Boolean,
default: false,
align: {
type: String,
default: 'end',
},
});
@@ -42,23 +35,16 @@ const emit = defineEmits(['close']);
const searchContacts = createContactSearcher();
const store = useStore();
const { t } = useI18n();
const { width: windowWidth } = useWindowSize();
const { fetchSignatureFlagFromUISettings } = useUISettings();
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
const viewInModal = computed(() => props.isModal || isSmallScreen.value);
const popoverRef = ref(null);
const contacts = ref([]);
const selectedContact = ref(null);
const targetInbox = ref(null);
const isCreatingContact = ref(false);
const isFetchingInboxes = ref(false);
const isSearching = ref(false);
const showComposeNewConversation = ref(false);
const formState = reactive({
message: '',
@@ -95,14 +81,6 @@ const directUploadsEnabled = computed(
const activeContact = computed(() => contactById.value(props.contactId));
const composePopoverClass = computed(() => {
if (viewInModal.value) return '';
return props.alignPosition === 'right'
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
: 'absolute rtl:left-0 rtl:right-[unset] ltr:right-0 ltr:left-[unset]';
});
const onContactSearch = debounce(
async query => {
isSearching.value = true;
@@ -172,7 +150,7 @@ const clearSelectedContact = () => {
};
const closeCompose = () => {
showComposeNewConversation.value = false;
popoverRef.value?.hide();
if (!props.contactId) {
// If contactId is passed as prop
// Then don't allow to remove the selected contact
@@ -180,7 +158,6 @@ const closeCompose = () => {
}
targetInbox.value = null;
resetContacts();
emit('close');
};
const discardCompose = () => {
@@ -213,8 +190,15 @@ const createConversation = async ({ payload, isFromWhatsApp }) => {
}
};
const toggle = () => {
showComposeNewConversation.value = !showComposeNewConversation.value;
const onPopoverShow = () => {
// Flag to prevent triggering drag n drop,
// When compose modal is active
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
};
const onPopoverHide = () => {
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
emit('close');
};
watch(
@@ -242,64 +226,22 @@ watch(
{ immediate: true, deep: true }
);
const handleClickOutside = () => {
if (!showComposeNewConversation.value) return;
showComposeNewConversation.value = false;
emit('close');
};
const onModalBackdropClick = () => {
if (!viewInModal.value) return;
handleClickOutside();
};
onMounted(() => resetContacts());
const keyboardEvents = {
Escape: {
action: () => {
if (showComposeNewConversation.value) {
showComposeNewConversation.value = false;
emit('close');
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
}
},
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
v-on-click-outside="[
handleClickOutside,
// 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
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
]"
class="relative"
:class="{
'z-50': showComposeNewConversation && !viewInModal,
}"
<Popover
ref="popoverRef"
:align="align"
@show="onPopoverShow"
@hide="onPopoverHide"
>
<slot
name="trigger"
:is-open="showComposeNewConversation"
:toggle="toggle"
/>
<div
v-if="showComposeNewConversation"
:class="{
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
viewInModal,
}"
@click.self="onModalBackdropClick"
>
<template #default="{ isOpen }">
<slot name="trigger" :is-open="isOpen" />
</template>
<template #content>
<ComposeNewConversationForm
:form-state="formState"
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
@@ -321,6 +263,6 @@ useKeyboardEvents(keyboardEvents);
@create-conversation="createConversation"
@discard="discardCompose"
/>
</div>
</div>
</template>
</Popover>
</template>
@@ -220,7 +220,7 @@ useEventListener(document, 'paste', onPaste);
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="top-full mt-1.5 ltr:left-0 rtl:right-0"
class="!top-auto !bottom-full mb-1.5 ltr:left-0 rtl:right-0"
:on-click="onClickInsertEmoji"
/>
</div>
@@ -361,7 +361,7 @@ useKeyboardEvents({
<template>
<div
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
class="w-full md:w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
>
<div class="flex-1 overflow-y-auto divide-y divide-n-strong">
<ContactSelector
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, useSlots } from 'vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -30,23 +30,40 @@ const modelValue = defineModel({
});
const selectedCount = computed(() => modelValue.value.size);
const totalCount = computed(() => props.allItems.length);
const visibleItemIds = computed(() => props.allItems.map(item => item.id));
const visibleItemCount = computed(() => visibleItemIds.value.length);
const selectedVisibleCount = computed(
() => visibleItemIds.value.filter(id => modelValue.value.has(id)).length
);
const hasSelected = computed(() => selectedCount.value > 0);
const isIndeterminate = computed(
() => hasSelected.value && selectedCount.value < totalCount.value
() =>
selectedVisibleCount.value > 0 &&
selectedVisibleCount.value < visibleItemCount.value
);
const allSelected = computed(
() => totalCount.value > 0 && selectedCount.value === totalCount.value
() =>
visibleItemCount.value > 0 &&
selectedVisibleCount.value === visibleItemCount.value
);
const slots = useSlots();
const hasSecondaryActions = computed(() => Boolean(slots['secondary-actions']));
const bulkCheckboxState = computed({
get: () => allSelected.value,
set: shouldSelectAll => {
const newSelectedIds = shouldSelectAll
? new Set(props.allItems.map(item => item.id))
: new Set();
modelValue.value = newSelectedIds;
if (!visibleItemCount.value) {
return;
}
const updatedSelection = new Set(modelValue.value);
if (shouldSelectAll) {
visibleItemIds.value.forEach(id => updatedSelection.add(id));
} else {
visibleItemIds.value.forEach(id => updatedSelection.delete(id));
}
modelValue.value = updatedSelection;
},
});
</script>
@@ -63,7 +80,7 @@ const bulkCheckboxState = computed({
v-if="hasSelected"
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center gap-1.5 min-w-0">
<Checkbox
v-model="bulkCheckboxState"
@@ -78,21 +95,23 @@ const bulkCheckboxState = computed({
<span class="text-sm text-n-slate-10 truncate tabular-nums">
{{ selectedCountLabel }}
</span>
<div class="h-4 w-px bg-n-strong" />
<slot name="secondary-actions" />
</div>
<div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount">
<Button
:label="deleteLabel"
sm
ruby
ghost
class="!px-1.5"
icon="i-lucide-trash"
@click="emit('bulkDelete')"
/>
</slot>
<slot v-if="hasSecondaryActions" name="secondary-actions" />
<div v-if="hasSecondaryActions" class="h-4 w-px bg-n-strong" />
<div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount">
<Button
:label="deleteLabel"
sm
ruby
ghost
class="!px-1.5"
icon="i-lucide-trash"
@click="emit('bulkDelete')"
/>
</slot>
</div>
</div>
</div>
<div v-else class="flex items-center gap-3">
@@ -12,6 +12,7 @@ import {
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
id: {
@@ -34,14 +35,34 @@ const props = defineProps({
type: Number,
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']);
const emit = defineEmits(['action', 'select', 'hover']);
const { checkPermissions } = usePolicy();
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const modelValue = computed({
get: () => props.isSelected,
set: () => emit('select', props.id),
});
const menuItems = computed(() => {
const allOptions = [
@@ -79,12 +100,23 @@ const handleAction = ({ action, value }) => {
</script>
<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">
<span class="text-base text-n-slate-12 line-clamp-1">
{{ name }}
</span>
<div class="flex gap-2 items-center">
<div v-if="showMenu" class="flex gap-2 items-center">
<div
v-on-clickaway="() => toggleDropdown(false)"
class="flex relative items-center group"
@@ -21,16 +21,22 @@ const emit = defineEmits(['deleteSuccess']);
const { t } = useI18n();
const store = useStore();
const bulkDeleteDialogRef = ref(null);
const i18nKey = computed(() => props.type.toUpperCase());
const i18nKey = computed(() => {
const i18nTypeMap = {
AssistantResponse: 'RESPONSES',
AssistantDocument: 'DOCUMENTS',
};
return i18nTypeMap[props.type];
});
const handleBulkDelete = async ids => {
if (!ids) return;
try {
await store.dispatch(
'captainBulkActions/handleBulkDelete',
Array.from(props.bulkIds)
);
await store.dispatch('captainBulkActions/handleBulkDelete', {
ids: Array.from(props.bulkIds),
type: props.type,
});
emit('deleteSuccess');
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));
@@ -6,6 +6,13 @@ import { useAccount } from 'dashboard/composables/useAccount';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
defineProps({
featurePrefix: {
type: String,
default: 'CAPTAIN',
},
});
const router = useRouter();
const currentUser = useMapGetter('getCurrentUser');
@@ -31,7 +38,7 @@ const openBilling = () => {
>
<BasePaywallModal
class="mx-auto"
feature-prefix="CAPTAIN"
:feature-prefix="featurePrefix"
:i18n-key="i18nKey"
:is-super-admin="isSuperAdmin"
:is-on-chatwoot-cloud="isOnChatwootCloud"
@@ -101,12 +101,9 @@ const authTypeLabel = computed(() => {
</Policy>
</div>
</div>
<div class="flex items-center justify-between w-full gap-4">
<div class="flex items-center gap-3 flex-1">
<span
v-if="description"
class="text-sm truncate text-n-slate-11 flex-1"
>
<div class="flex items-center justify-between w-full gap-4 min-w-0">
<div class="flex items-center gap-3 flex-1 min-w-0">
<span v-if="description" class="text-sm truncate text-n-slate-11">
{{ description }}
</span>
<span
@@ -1,9 +1,10 @@
<script setup>
import { reactive, computed, useTemplateRef, watch } from 'vue';
import { reactive, computed, ref, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { required, maxLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import CustomToolsAPI from 'dashboard/api/captain/customTools';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
@@ -72,8 +73,12 @@ const DEFAULT_PARAM = {
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 = {
title: { required },
title: { required, maxLength: maxLength(MAX_TOOL_NAME_LENGTH) },
endpoint_url: { required },
http_method: { required },
auth_type: { required },
@@ -103,9 +108,15 @@ const isLoading = computed(() =>
);
const getErrorMessage = (field, errorKey) => {
return v$.value[field].$error
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
: '';
if (!v$.value[field].$error) return '';
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(() => ({
@@ -140,6 +151,30 @@ const handleSubmit = async () => {
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>
<template>
@@ -248,6 +283,45 @@ const handleSubmit = async () => {
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">
<Button
type="button"
@@ -1,8 +1,11 @@
<script setup>
import { useAccount } from 'dashboard/composables/useAccount';
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';
const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const onClick = () => {
emit('click');
@@ -10,6 +13,15 @@ const onClick = () => {
</script>
<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
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
@@ -36,7 +36,13 @@ const props = defineProps({
},
});
const emit = defineEmits(['enterPress', 'input', 'blur', 'focus']);
const emit = defineEmits([
'enterPress',
'escapePress',
'input',
'blur',
'focus',
]);
const modelValue = defineModel({
type: [String, Number],
@@ -49,6 +55,10 @@ const onEnterPress = () => {
emit('enterPress');
};
const onEscapePress = () => {
emit('escapePress');
};
const handleInput = event => {
emit('input', event.target.value);
modelValue.value = event.target.value;
@@ -102,6 +112,7 @@ defineExpose({
@focus="handleFocus"
@blur="handleBlur"
@keydown.enter.prevent="onEnterPress"
@keydown.escape.prevent="onEscapePress"
/>
</div>
</template>
@@ -32,6 +32,7 @@ const convertToMinutes = newValue => {
const transformedValue = computed({
get() {
if (duration.value == null) return null;
if (unit.value === DURATION_UNITS.MINUTES) return duration.value;
if (unit.value === DURATION_UNITS.HOURS)
return Math.floor(duration.value / 60);
@@ -41,6 +42,10 @@ const transformedValue = computed({
return 0;
},
set(newValue) {
if (newValue == null || newValue === '') {
duration.value = null;
return;
}
let minuteValue = convertToMinutes(newValue);
duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
@@ -53,6 +58,7 @@ const transformedValue = computed({
// this might create some confusion, especially when saving
// this watcher fixes it by rounding the duration basically, to the nearest unit value
watch(unit, () => {
if (duration.value == null) return;
let adjustedValue = convertToMinutes(transformedValue.value);
duration.value = Math.min(Math.max(adjustedValue, props.min), props.max);
});
@@ -226,4 +226,10 @@ const handleSeeOriginal = () => {
}
}
}
// Email clients (Gmail, Outlook) hardcode dir="ltr" on wrapper elements.
// In RTL apps this forces email content LTR regardless of actual text.
[dir='rtl'] .letter-render [dir='ltr'] {
direction: inherit;
}
</style>
@@ -0,0 +1,121 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
align: {
type: String,
default: 'end',
validator: v => ['start', 'end'].includes(v),
},
});
const emit = defineEmits(['show', 'hide']);
const isActive = ref(false);
const triggerRef = ref(null);
const popoverRef = ref(null);
const mobileContentRef = ref(null);
const breakpoints = useBreakpoints(breakpointsTailwind);
const isMobile = breakpoints.smaller('md');
const showPopover = computed(() => isActive.value && !isMobile.value);
const { fixedPosition, updatePosition } = useDropdownPosition(
triggerRef,
popoverRef,
showPopover,
{ align: props.align }
);
const show = async () => {
isActive.value = true;
if (!isMobile.value) {
await nextTick();
updatePosition();
}
emit('show');
};
const hide = () => {
if (!isActive.value) return;
isActive.value = false;
emit('hide');
};
const toggle = async () => {
if (isActive.value) hide();
else await show();
};
// Recalculate position when switching from mobile to desktop while open
watch(isMobile, async mobile => {
if (!isActive.value || mobile) return;
await nextTick();
updatePosition();
});
const handleClickOutside = event => {
if (triggerRef.value?.contains(event.target)) return;
hide();
};
// Selectors for teleported elements that should not trigger close
const clickOutsideIgnore = [
'dialog.ProseMirror-prompt-backdrop',
'[data-popover-content]',
];
useKeyboardEvents({
Escape: {
action: () => isActive.value && hide(),
allowOnFocusedInput: true,
},
});
defineExpose({ show, hide, toggle });
</script>
<template>
<span ref="triggerRef" class="inline-flex" @click="toggle">
<slot :is-open="isActive" />
</span>
<TeleportWithDirection to="body">
<!-- Mobile: centered modal with backdrop -->
<div
v-if="isActive && isMobile"
data-popover-backdrop
class="fixed inset-0 z-[9999] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center bg-n-alpha-black1"
>
<div
ref="mobileContentRef"
v-on-click-outside="[
handleClickOutside,
{ ignore: clickOutsideIgnore },
]"
data-popover-content
class="relative w-full max-w-lg max-h-[calc(100vh-4rem)] mx-4 overflow-y-auto bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
>
<slot name="content" :hide="hide" />
</div>
</div>
<!-- Desktop: fixed popover -->
<div
v-else-if="showPopover"
ref="popoverRef"
v-on-click-outside="[handleClickOutside, { ignore: clickOutsideIgnore }]"
data-popover-content
:class="fixedPosition.class"
:style="fixedPosition.style"
class="bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl overflow-y-auto max-h-[calc(100vh-2rem)]"
>
<slot name="content" :hide="hide" />
</div>
</TeleportWithDirection>
</template>
@@ -10,8 +10,6 @@ import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useWindowSize, useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
@@ -184,15 +182,6 @@ const closeMobileSidebar = () => {
emit('closeMobileSidebar');
};
const onComposeOpen = toggleFn => {
toggleFn();
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
};
const onComposeClose = () => {
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
};
const newReportRoutes = () => [
{
name: 'Reports Agent',
@@ -250,6 +239,12 @@ const menuItems = computed(() => {
activeOn: ['conversation_through_mentions'],
to: accountScopedRoute('conversation_mentions'),
},
{
name: 'Participating',
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
activeOn: ['conversation_through_participating'],
to: accountScopedRoute('conversation_participating'),
},
{
name: 'Unattended',
activeOn: ['conversation_through_unattended'],
@@ -728,7 +723,13 @@ const menuItems = computed(() => {
<aside
v-on-click-outside="[
closeMobileSidebar,
{ ignore: ['#mobile-sidebar-launcher'] },
{
ignore: [
'#mobile-sidebar-launcher',
'[data-popover-content]',
'[data-popover-backdrop]',
],
},
]"
class="bg-n-background flex flex-col text-sm pb-px fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
:class="[
@@ -796,8 +797,8 @@ const menuItems = computed(() => {
>
<span class="i-lucide-search size-4 text-n-slate-11" />
</RouterLink>
<ComposeConversation align-position="right" @close="onComposeClose">
<template #trigger="{ toggle, isOpen }">
<ComposeConversation align="start">
<template #trigger="{ isOpen }">
<Button
icon="i-lucide-pen-line"
color="slate"
@@ -809,7 +810,6 @@ const menuItems = computed(() => {
: '!h-7 !outline-n-weak !text-n-slate-11',
{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen },
]"
@click="onComposeOpen(toggle)"
/>
</template>
</ComposeConversation>
@@ -158,9 +158,11 @@ const activeChild = computed(() => {
return rankedPage ?? activeOnPages[0];
}
return navigableChildren.value.find(
child => child.to && route.path.startsWith(resolvePath(child.to))
);
return navigableChildren.value.find(child => {
if (!child.to) return false;
const childPath = resolvePath(child.to);
return route.path === childPath || route.path.startsWith(`${childPath}/`);
});
});
const hasActiveChild = computed(() => {
@@ -1,15 +1,5 @@
<script setup>
// [TODO] This componet is too big and bulky to be in the same file, we can consider splitting this into multiple
// composables and components, useVirtualChatList, useChatlistFilters
import {
ref,
unref,
provide,
computed,
watch,
onMounted,
defineEmits,
} from 'vue';
import { ref, unref, provide, computed, watch, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import {
@@ -17,23 +7,19 @@ import {
useFunctionGetter,
} from 'dashboard/composables/store.js';
import { Virtualizer } from 'virtua/vue';
import ChatListHeader from './ChatListHeader.vue';
import ConversationList from './ConversationList.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.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 { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
import { useFilter } from 'shared/composables/useFilter';
import { useTrack } from 'dashboard/composables';
@@ -56,6 +42,7 @@ import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHe
import { conversationListPageURL } from '../helper/URLHelper';
import {
isOnMentionsView,
isOnParticipatingView,
isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers';
import {
@@ -84,10 +71,6 @@ const route = useRoute();
const store = useStore();
const resolveAttributesModalRef = ref(null);
const conversationListRef = ref(null);
const virtualListRef = ref(null);
provide('contextMenuElementTarget', virtualListRef);
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
@@ -99,7 +82,6 @@ const chatsOnView = ref([]);
const foldersQuery = ref({});
const showAddFoldersModal = ref(false);
const showDeleteFoldersModal = ref(false);
const isContextMenuOpen = ref(false);
const appliedFilter = ref([]);
const advancedFilterTypes = ref(
advancedFilterOptions.map(filter => ({
@@ -113,6 +95,7 @@ const chatLists = useMapGetter('getFilteredConversations');
const mineChatsList = useMapGetter('getMineChats');
const allChatList = useMapGetter('getAllStatusChats');
const unAssignedChatsList = useMapGetter('getUnAssignedChats');
const participatingChatsList = useMapGetter('getParticipatingChats');
const chatListLoading = useMapGetter('getChatListLoadingStatus');
const activeInbox = useMapGetter('getSelectedInbox');
const conversationStats = useMapGetter('conversationStats/getStats');
@@ -128,7 +111,6 @@ const currentAccountId = useMapGetter('getCurrentAccountId');
const getTeamFn = useMapGetter('teams/getTeam');
const getConversationById = useMapGetter('getConversationById');
useChatListKeyboardEvents(conversationListRef);
const {
selectedConversations,
selectedInboxes,
@@ -296,13 +278,15 @@ const pageTitle = computed(() => {
if (props.label) {
return `#${props.label}`;
}
if (props.conversationType === 'mention') {
if (props.conversationType === wootConstants.CONVERSATION_TYPE.MENTION) {
return t('CHAT_LIST.MENTION_HEADING');
}
if (props.conversationType === 'participating') {
if (
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
) {
return t('CONVERSATION_PARTICIPANTS.SIDEBAR_MENU_TITLE');
}
if (props.conversationType === 'unattended') {
if (props.conversationType === wootConstants.CONVERSATION_TYPE.UNATTENDED) {
return t('CHAT_LIST.UNATTENDED_HEADING');
}
if (hasActiveFolders.value) {
@@ -311,12 +295,30 @@ const pageTitle = computed(() => {
return t('CHAT_LIST.TAB_HEADING');
});
function filterByAssigneeTab(conversations) {
if (activeAssigneeTab.value === wootConstants.ASSIGNEE_TYPE.ME) {
return conversations.filter(
c => c.meta?.assignee?.id === currentUser.value?.id
);
}
if (activeAssigneeTab.value === wootConstants.ASSIGNEE_TYPE.UNASSIGNED) {
return conversations.filter(c => !c.meta?.assignee);
}
return [...conversations];
}
const conversationList = computed(() => {
let localConversationList = [];
if (!hasAppliedFiltersOrActiveFolders.value) {
const filters = conversationFilters.value;
if (activeAssigneeTab.value === 'me') {
if (
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
) {
localConversationList = filterByAssigneeTab(
participatingChatsList.value(filters)
);
} else if (activeAssigneeTab.value === 'me') {
localConversationList = [...mineChatsList.value(filters)];
} else if (activeAssigneeTab.value === 'unassigned') {
localConversationList = [...unAssignedChatsList.value(filters)];
@@ -338,7 +340,7 @@ const conversationList = computed(() => {
});
const showEndOfListMessage = computed(() => {
return (
return !!(
conversationList.value.length &&
hasCurrentPageEndReached.value &&
!chatListLoading.value
@@ -583,14 +585,6 @@ function loadMoreConversations() {
}
}
// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll.
// If the list doesnt fill the viewport, loading can stall.
// IntersectionObserver triggers as soon as the sentinel is visible.
const intersectionObserverOptions = computed(() => ({
root: conversationListRef.value,
rootMargin: '100px 0px 100px 0px',
}));
function updateAssigneeTab(selectedTab) {
if (activeAssigneeTab.value !== selectedTab) {
resetBulkActions();
@@ -637,9 +631,11 @@ function redirectToConversationList() {
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
conversationType = wootConstants.CONVERSATION_TYPE.MENTION;
} else if (isOnParticipatingView({ route: { name } })) {
conversationType = wootConstants.CONVERSATION_TYPE.PARTICIPATING;
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
conversationType = wootConstants.CONVERSATION_TYPE.UNATTENDED;
}
router.push(
conversationListPageURL({
@@ -782,10 +778,6 @@ function allSelectedConversationsStatus(status) {
});
}
function onContextMenuToggle(state) {
isContextMenuOpen.value = state;
}
function toggleSelectAll(check) {
selectAllConversations(check, conversationList);
}
@@ -833,7 +825,6 @@ provide('assignTeam', onAssignTeam);
provide('assignLabels', onAssignLabels);
provide('removeLabels', onRemoveLabels);
provide('updateConversationStatus', handleResolveConversation);
provide('toggleContextMenu', onContextMenuToggle);
provide('markAsUnread', markAsUnread);
provide('markAsRead', markAsRead);
provide('assignPriority', assignPriority);
@@ -946,43 +937,18 @@ watch(conversationFilters, (newVal, oldVal) => {
@assign-labels="onAssignLabels"
@assign-team="onAssignTeamsForBulk"
/>
<div
ref="conversationListRef"
class="flex-1 min-h-0 overflow-y-auto conversations-list"
:class="{ '!overflow-hidden': isContextMenuOpen }"
>
<Virtualizer
ref="virtualListRef"
v-slot="{ item, index }"
:data="conversationList"
>
<ConversationItem
:source="item"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
:data-index="index"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
/>
</Virtualizer>
<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"
/>
</div>
<ConversationList
:conversation-list="conversationList"
:is-loading="chatListLoading"
:show-end-of-list-message="showEndOfListMessage"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
:is-on-expanded-layout="isOnExpandedLayout"
@load-more="loadMoreConversations"
/>
<Dialog
ref="deleteConversationDialogRef"
type="alert"
@@ -1,74 +1,244 @@
<script>
<script setup>
import { computed, ref, watch, inject } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import ConversationCard from './widgets/conversation/ConversationCard.vue';
export default {
components: {
ConversationCard,
},
inject: [
'selectConversation',
'deSelectConversation',
'assignAgent',
'assignTeam',
'assignLabels',
'removeLabels',
'updateConversationStatus',
'toggleContextMenu',
'markAsUnread',
'markAsRead',
'assignPriority',
'isConversationSelected',
'deleteConversation',
],
props: {
source: {
type: Object,
required: true,
},
teamId: {
type: [String, Number],
default: 0,
},
label: {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
foldersId: {
type: [String, Number],
default: 0,
},
showAssignee: {
type: Boolean,
default: false,
},
},
import ConversationCardExpanded from 'dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import ConversationContextMenu from './widgets/conversation/contextMenu/Index.vue';
const props = defineProps({
source: { type: Object, required: true },
teamId: { type: [String, Number], default: 0 },
label: { type: String, default: '' },
conversationType: { type: String, default: '' },
foldersId: { type: [String, Number], default: 0 },
showAssignee: { type: Boolean, default: false },
showExpanded: { type: Boolean, default: false },
});
const router = useRouter();
const store = useStore();
const selectConversation = inject('selectConversation');
const deSelectConversation = inject('deSelectConversation');
const assignAgent = inject('assignAgent');
const assignTeam = inject('assignTeam');
const assignLabels = inject('assignLabels');
const removeLabels = inject('removeLabels');
const updateConversationStatus = inject('updateConversationStatus');
const toggleContextMenu = inject('toggleContextMenu');
const markAsUnread = inject('markAsUnread');
const markAsRead = inject('markAsRead');
const assignPriority = inject('assignPriority');
const isConversationSelected = inject('isConversationSelected');
const deleteConversation = inject('deleteConversation');
// --- Context menu state (shared by both layouts) ---
const showContextMenu = ref(false);
const contextMenu = ref({ x: null, y: null });
// Reset context menu state when the row is recycled to a different conversation.
watch(
() => props.source.id,
() => {
if (showContextMenu.value) {
toggleContextMenu(false);
}
showContextMenu.value = false;
contextMenu.value = { x: null, y: null };
}
);
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
const chatMetadata = computed(() => props.source.meta || {});
const assignee = computed(() => chatMetadata.value.assignee || {});
const senderId = computed(() => chatMetadata.value.sender?.id);
const currentContact = computed(() =>
senderId.value ? store.getters['contacts/getContact'](senderId.value) : {}
);
const isActiveChat = computed(() => currentChat.value.id === props.source.id);
const inbox = computed(() => {
const inboxId = props.source.inbox_id;
return inboxId ? store.getters['inboxes/getInbox'](inboxId) : {};
});
const showInboxName = computed(
() => !activeInbox.value && inboxesList.value.length > 1
);
const isInboxView = computed(() => !!activeInbox.value);
const showAssigneeForExpandedCard = computed(
() => props.showExpanded || props.showAssignee
);
const conversationPath = computed(() =>
frontendURL(
conversationUrl({
accountId: accountId.value,
activeInbox: activeInbox.value,
id: props.source.id,
label: props.label,
teamId: props.teamId,
conversationType: props.conversationType,
foldersId: props.foldersId,
})
)
);
const onCardClick = e => {
const path = conversationPath.value;
if (!path) return;
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
if (isActiveChat.value) return;
router.push({ path });
};
const onExpandedSelect = checked => {
if (checked) {
selectConversation(props.source.id, inbox.value.id);
} else {
deSelectConversation(props.source.id, inbox.value.id);
}
};
const openContextMenu = e => {
e.preventDefault();
toggleContextMenu(true);
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const closeContextMenu = () => {
toggleContextMenu(false);
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
};
const onUpdateConversation = (status, snoozedUntil) => {
closeContextMenu();
updateConversationStatus(props.source.id, status, snoozedUntil);
};
const onAssignAgent = agent => {
assignAgent(agent, [props.source.id]);
closeContextMenu();
};
const onAssignLabel = label => {
assignLabels([label.title], [props.source.id]);
};
const onRemoveLabel = label => {
removeLabels([label.title], [props.source.id]);
};
const onAssignTeam = team => {
assignTeam(team, props.source.id);
closeContextMenu();
};
const onMarkAsUnread = () => {
markAsUnread(props.source.id);
closeContextMenu();
};
const onMarkAsRead = () => {
markAsRead(props.source.id);
closeContextMenu();
};
const onAssignPriority = priority => {
assignPriority(priority, props.source.id);
closeContextMenu();
};
const onDeleteConversation = () => {
deleteConversation(props.source.id);
closeContextMenu();
};
</script>
<template>
<ConversationCard
:active-label="label"
:team-id="teamId"
:folders-id="foldersId"
<!-- Expanded layout: wide screen + expanded setting -->
<ConversationCardExpanded
v-if="showExpanded"
:chat="source"
:conversation-type="conversationType"
:current-contact="currentContact"
:assignee="assignee"
:inbox="inbox"
:selected="isConversationSelected(source.id)"
:is-active-chat="isActiveChat"
:show-assignee="showAssigneeForExpandedCard"
:show-inbox-name="showInboxName"
:is-inbox-view="isInboxView"
@select-conversation="onExpandedSelect"
@de-select-conversation="onExpandedSelect"
@click="onCardClick"
@contextmenu="openContextMenu"
/>
<!-- Default (condensed) layout -->
<ConversationCard
v-else
:chat="source"
:current-contact="currentContact"
:assignee="assignee"
:inbox="inbox"
:selected="isConversationSelected(source.id)"
:is-active-chat="isActiveChat"
:show-assignee="showAssignee"
enable-context-menu
:show-inbox-name="showInboxName"
@click="onCardClick"
@contextmenu="openContextMenu"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
@assign-agent="assignAgent"
@assign-team="assignTeam"
@assign-label="assignLabels"
@remove-label="removeLabels"
@update-conversation-status="updateConversationStatus"
@context-menu-toggle="toggleContextMenu"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
/>
<!-- Shared context menu for both layouts -->
<ContextMenu
v-if="showContextMenu"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<ConversationContextMenu
:status="source.status"
:inbox-id="inbox.id"
:priority="source.priority"
:chat-id="source.id"
:has-unread-messages="source.unread_count > 0"
:conversation-labels="source.labels"
:conversation-url="conversationPath"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@remove-label="onRemoveLabel"
@assign-team="onAssignTeam"
@mark-as-unread="onMarkAsUnread"
@mark-as-read="onMarkAsRead"
@assign-priority="onAssignPriority"
@delete-conversation="onDeleteConversation"
@close="closeContextMenu"
/>
</ContextMenu>
</template>
@@ -0,0 +1,94 @@
<script setup>
import { ref, computed, provide } from 'vue';
import { Virtualizer } from 'virtua/vue';
import { useBreakpoints } from '@vueuse/core';
import { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
import ConversationItem from './ConversationItem.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import wootConstants from 'dashboard/constants/globals';
const props = defineProps({
conversationList: { type: Array, default: () => [] },
isLoading: { type: Boolean, default: false },
showEndOfListMessage: { type: Boolean, default: false },
label: { type: String, default: '' },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
conversationType: { type: String, default: '' },
showAssignee: { type: Boolean, default: false },
isOnExpandedLayout: { type: Boolean, default: false },
});
const emit = defineEmits(['loadMore']);
const conversationListRef = ref(null);
const virtualListRef = ref(null);
const isContextMenuOpen = ref(false);
provide('contextMenuElementTarget', virtualListRef);
const breakpoints = useBreakpoints({
lg: wootConstants.LARGE_SCREEN_BREAKPOINT,
});
const isLgScreen = breakpoints.greaterOrEqual('lg');
const showExpandedCards = computed(
() => props.isOnExpandedLayout && isLgScreen.value
);
useChatListKeyboardEvents(conversationListRef);
const intersectionObserverOptions = computed(() => ({
root: conversationListRef.value,
rootMargin: '100px 0px 100px 0px',
}));
const onContextMenuToggle = state => {
isContextMenuOpen.value = state;
};
const loadMoreConversations = () => {
emit('loadMore');
};
provide('toggleContextMenu', onContextMenuToggle);
defineExpose({ conversationListRef });
</script>
<template>
<div
ref="conversationListRef"
class="flex-1 min-h-0 overflow-y-auto conversations-list"
:class="{ '!overflow-hidden': isContextMenuOpen }"
>
<Virtualizer
ref="virtualListRef"
v-slot="{ item }"
:data="conversationList"
class="[&>div:has(+_div_.active)>*]:!border-n-surface-1 [&>div:has(+_div_.selected)>*]:!border-n-surface-1"
>
<ConversationItem
:source="item"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssignee"
:show-expanded="showExpandedCards"
/>
</Virtualizer>
<div v-if="isLoading" 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"
/>
</div>
</template>
@@ -27,10 +27,6 @@ const props = defineProps({
type: Boolean,
default: true,
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
@@ -208,10 +204,7 @@ onMounted(() => {
<template>
<div class="space-y-2 mb-4">
<div
class="overflow-y-auto"
:class="{ 'max-h-96': isPopout, 'max-h-56': !isPopout }"
>
<div class="overflow-y-auto max-h-56">
<p
v-dompurify-html="formatMessage(generatedContent, false)"
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4"
@@ -313,7 +313,12 @@ const plugins = computed(() => {
const sendWithSignature = computed(() => {
// this is considered the source of truth, we watch this property
// on change, we toggle the signature in the editor
if (props.allowSignature && !props.isPrivate && props.channelType) {
if (
props.allowSignature &&
!props.isPrivate &&
props.channelType &&
!props.disabled
) {
return fetchSignatureFlagFromUISettings(props.channelType);
}
@@ -436,6 +441,7 @@ function reloadState(content = props.modelValue) {
}
function addSignature() {
if (props.disabled) return;
let content = props.modelValue;
// see if the content is empty, if it is before appending the signature
// we need to add a paragraph node and move the cursor at the start of the editor
@@ -454,6 +460,7 @@ function addSignature() {
}
function removeSignature() {
if (props.disabled) return;
if (!props.signature) return;
let content = props.modelValue;
content = removeSignatureHelper(
@@ -806,7 +813,7 @@ watch(
watch(sendWithSignature, newValue => {
// see if the allowSignature flag is true
if (props.allowSignature) {
if (props.allowSignature && !props.disabled) {
toggleSignatureInEditor(newValue);
}
});
@@ -977,7 +984,32 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
}
.ProseMirror-woot-style {
@apply overflow-auto min-h-[5rem] max-h-[7.5rem];
@apply overflow-auto;
}
.ProseMirror-woot-style:not(
:where(.resizable-editor-wrapper .ProseMirror-woot-style)
) {
@apply min-h-[5rem] max-h-[7.5rem];
}
// Resizable editor wrapper styles
.resizable-editor-wrapper {
.ProseMirror-woot-style {
min-height: clamp(
var(--editor-min-allowed, var(--editor-min-height, 5rem)),
var(--editor-height, var(--editor-min-height, 5rem)),
var(--editor-max-allowed, var(--editor-max-height, 7.5rem))
);
max-height: clamp(
var(--editor-min-allowed, var(--editor-min-height, 5rem)),
var(--editor-height, var(--editor-min-height, 5rem)),
var(--editor-max-allowed, var(--editor-max-height, 7.5rem))
);
transition:
min-height var(--editor-height-transition, 180ms ease),
max-height var(--editor-height-transition, 180ms ease);
}
}
.ProseMirror-prompt-backdrop::backdrop {
@@ -8,13 +8,22 @@ import {
EditorState,
Selection,
} from '@chatwoot/prosemirror-schema';
import {
suggestionsPlugin,
triggerCharacters,
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
import { toggleMark } from 'prosemirror-commands';
import { wrapInList } from 'prosemirror-schema-list';
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import SlashCommandMenu from './SlashCommandMenu.vue';
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const SLASH_MENU_OFFSET = 4;
const createState = (
content,
placeholder,
@@ -40,6 +49,7 @@ let editorView = null;
let state;
export default {
components: { SlashCommandMenu },
mixins: [keyboardEventListenerMixins],
props: {
modelValue: { type: String, default: '' },
@@ -62,8 +72,15 @@ export default {
},
data() {
return {
plugins: [imagePastePlugin(this.handleImageUpload)],
plugins: [
imagePastePlugin(this.handleImageUpload),
this.createSlashPlugin(),
],
isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection
showSlashMenu: false,
slashSearchTerm: '',
slashRange: null,
slashMenuPosition: null,
};
},
watch: {
@@ -79,7 +96,7 @@ export default {
created() {
state = createState(
this.modelValue,
this.modelValue || '',
this.placeholder,
this.plugins,
{ onImageUpload: this.openFileBrowser },
@@ -95,6 +112,126 @@ export default {
}
},
methods: {
createSlashPlugin() {
return suggestionsPlugin({
matcher: triggerCharacters('/', 0),
suggestionClass: '',
onEnter: args => {
this.showSlashMenu = true;
this.slashRange = args.range;
this.slashSearchTerm = args.text || '';
this.updateSlashMenuPosition(args.range.from);
return false;
},
onChange: args => {
this.slashRange = args.range;
this.slashSearchTerm = args.text;
return false;
},
onExit: () => {
this.slashSearchTerm = '';
this.showSlashMenu = false;
this.slashMenuPosition = null;
return false;
},
onKeyDown: ({ event }) => {
return (
event.keyCode === 13 &&
this.showSlashMenu &&
this.$refs.slashMenu?.hasItems
);
},
});
},
updateSlashMenuPosition(pos) {
if (!editorView) return;
const coords = editorView.coordsAtPos(pos);
const editorRect = this.$refs.editor.getBoundingClientRect();
const isRtl = getComputedStyle(this.$refs.editor).direction === 'rtl';
this.slashMenuPosition = {
top: coords.bottom - editorRect.top + SLASH_MENU_OFFSET,
...(isRtl
? { right: editorRect.right - coords.right }
: { left: coords.left - editorRect.left }),
};
},
removeSlashTriggerText() {
if (!editorView || !this.slashRange) return;
const { from, to } = this.slashRange;
editorView.dispatch(editorView.state.tr.delete(from, to));
state = editorView.state;
},
executeSlashCommand(actionKey) {
if (!editorView) return;
this.removeSlashTriggerText();
const { schema } = editorView.state;
const commandMap = {
strong: () =>
toggleMark(schema.marks.strong)(
editorView.state,
editorView.dispatch
),
em: () =>
toggleMark(schema.marks.em)(editorView.state, editorView.dispatch),
strike: () =>
toggleMark(schema.marks.strike)(
editorView.state,
editorView.dispatch
),
code: () =>
toggleMark(schema.marks.code)(editorView.state, editorView.dispatch),
h1: () =>
toggleBlockType(schema.nodes.heading, { level: 1 })(
editorView.state,
editorView.dispatch
),
h2: () =>
toggleBlockType(schema.nodes.heading, { level: 2 })(
editorView.state,
editorView.dispatch
),
h3: () =>
toggleBlockType(schema.nodes.heading, { level: 3 })(
editorView.state,
editorView.dispatch
),
bulletList: () =>
wrapInList(schema.nodes.bullet_list)(
editorView.state,
editorView.dispatch
),
orderedList: () =>
wrapInList(schema.nodes.ordered_list)(
editorView.state,
editorView.dispatch
),
insertTable: () => {
const { table, table_row, table_header, table_cell, paragraph } =
schema.nodes;
const headerCells = [0, 1, 2].map(() =>
table_header.createAndFill(null, paragraph.create())
);
const dataCells = [0, 1, 2].map(() =>
table_cell.createAndFill(null, paragraph.create())
);
const headerRow = table_row.create(null, headerCells);
const dataRow = table_row.create(null, dataCells);
const tableNode = table.create(null, [headerRow, dataRow]);
const tr = editorView.state.tr.replaceSelectionWith(tableNode);
editorView.dispatch(tr.scrollIntoView());
},
};
const command = commandMap[actionKey];
if (command) {
command();
state = editorView.state;
this.emitOnChange();
editorView.focus();
}
},
contentFromEditor() {
if (editorView) {
return ArticleMarkdownSerializer.serialize(editorView.state.doc);
@@ -170,7 +307,7 @@ export default {
},
reloadState() {
state = createState(
this.modelValue,
this.modelValue || '',
this.placeholder,
this.plugins,
{ onImageUpload: this.openFileBrowser },
@@ -262,7 +399,8 @@ export default {
// Get the editor's width
const editorWidth = editor.offsetWidth;
const menubarWidth = 480; // Menubar width (adjust as needed (px))
const menubar = editor.querySelector('.ProseMirror-menubar');
const menubarWidth = menubar ? menubar.scrollWidth : 480;
// Get the end position of the selection
const { bottom: endBottom, right: endRight } = editorView.coordsAtPos(to);
@@ -290,7 +428,15 @@ export default {
<template>
<div>
<div class="editor-root editor--article">
<div class="editor-root editor--article relative">
<SlashCommandMenu
v-if="showSlashMenu"
ref="slashMenu"
:search-key="slashSearchTerm"
:enabled-menu-options="enabledMenuOptions"
:position="slashMenuPosition"
@select-action="executeSlashCommand"
/>
<input
ref="imageUploadInput"
type="file"
@@ -128,7 +128,6 @@ export default {
},
},
emits: [
'replaceText',
'toggleInsertArticle',
'selectWhatsappTemplate',
'selectContentTemplate',
@@ -277,9 +276,6 @@ export default {
toggleMessageSignature() {
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
},
replaceText(text) {
this.$emit('replaceText', text);
},
toggleInsertArticle() {
this.$emit('toggleInsertArticle');
},
@@ -54,7 +54,7 @@ export default {
default: undefined,
},
},
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
emits: ['setReplyMode', 'toggleEditorSize', 'executeCopilotAction'],
setup(props, { emit }) {
const setReplyMode = mode => {
emit('setReplyMode', mode);
@@ -189,7 +189,7 @@ export default {
class="text-n-slate-11"
sm
icon="i-lucide-maximize-2"
@click="$emit('togglePopout')"
@click="$emit('toggleEditorSize')"
/>
</div>
</div>
@@ -0,0 +1,180 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
searchKey: {
type: String,
default: '',
},
enabledMenuOptions: {
type: Array,
default: () => [],
},
position: {
type: Object,
default: null,
},
});
const emit = defineEmits(['selectAction']);
const { t } = useI18n();
const EDITOR_ACTIONS = [
{
value: 'h1',
labelKey: 'SLASH_COMMANDS.HEADING_1',
icon: 'i-lucide-heading-1',
menuKey: 'h1',
},
{
value: 'h2',
labelKey: 'SLASH_COMMANDS.HEADING_2',
icon: 'i-lucide-heading-2',
menuKey: 'h2',
},
{
value: 'h3',
labelKey: 'SLASH_COMMANDS.HEADING_3',
icon: 'i-lucide-heading-3',
menuKey: 'h3',
},
{
value: 'strong',
labelKey: 'SLASH_COMMANDS.BOLD',
icon: 'i-lucide-bold',
menuKey: 'strong',
},
{
value: 'em',
labelKey: 'SLASH_COMMANDS.ITALIC',
icon: 'i-lucide-italic',
menuKey: 'em',
},
{
value: 'insertTable',
labelKey: 'SLASH_COMMANDS.TABLE',
icon: 'i-lucide-table',
menuKey: 'insertTable',
},
{
value: 'strike',
labelKey: 'SLASH_COMMANDS.STRIKETHROUGH',
icon: 'i-lucide-strikethrough',
menuKey: 'strike',
},
{
value: 'code',
labelKey: 'SLASH_COMMANDS.CODE',
icon: 'i-lucide-code',
menuKey: 'code',
},
{
value: 'bulletList',
labelKey: 'SLASH_COMMANDS.BULLET_LIST',
icon: 'i-lucide-list',
menuKey: 'bulletList',
},
{
value: 'orderedList',
labelKey: 'SLASH_COMMANDS.ORDERED_LIST',
icon: 'i-lucide-list-ordered',
menuKey: 'orderedList',
},
];
const listContainerRef = ref(null);
const selectedIndex = ref(0);
const items = computed(() => {
const search = props.searchKey.toLowerCase();
return EDITOR_ACTIONS.filter(action => {
if (!props.enabledMenuOptions.includes(action.menuKey)) return false;
if (!search) return true;
return t(action.labelKey).toLowerCase().includes(search);
});
});
const hasItems = computed(() => items.value.length > 0);
const menuStyle = computed(() => {
if (!props.position) return {};
const style = { top: `${props.position.top}px` };
if (props.position.right != null) {
style.right = `${props.position.right}px`;
} else {
style.left = `${props.position.left}px`;
}
return style;
});
const adjustScroll = () => {
nextTick(() => {
const container = listContainerRef.value;
if (!container) return;
const el = container.querySelector(`#slash-item-${selectedIndex.value}`);
if (el) {
el.scrollIntoView({ block: 'nearest', behavior: 'auto' });
}
});
};
const onSelect = () => {
const item = items.value[selectedIndex.value];
if (item) emit('selectAction', item.value);
};
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
// Reset selection when filtered items change
watch(items, () => {
selectedIndex.value = 0;
});
const onHover = index => {
selectedIndex.value = index;
};
const onItemClick = index => {
selectedIndex.value = index;
onSelect();
};
defineExpose({ hasItems });
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div
v-if="hasItems"
ref="listContainerRef"
class="bg-n-alpha-3 backdrop-blur-[100px] outline outline-1 outline-n-container absolute rounded-xl z-50 flex flex-col min-w-[10rem] shadow-lg p-2 overflow-auto max-h-[15rem]"
:style="menuStyle"
>
<button
v-for="(item, index) in items"
:id="`slash-item-${index}`"
:key="item.value"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 border-0 rounded-lg text-n-slate-12 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2"
:class="{
'bg-n-alpha-1 dark:bg-n-alpha-2': index === selectedIndex,
}"
@mouseover="onHover(index)"
@click="onItemClick(index)"
>
<Icon :icon="item.icon" class="flex-shrink-0 size-3.5" />
<span class="min-w-0 text-sm truncate">
{{ t(item.labelKey) }}
</span>
</button>
</div>
</template>
@@ -1,98 +1,41 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Avatar from 'next/avatar/Avatar.vue';
import MessagePreview from './MessagePreview.vue';
import InboxName from '../InboxName.vue';
import ConversationContextMenu from './contextMenu/Index.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue';
import PriorityMark from './PriorityMark.vue';
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
import UnreadBadge from 'dashboard/components-next/Conversation/ConversationCard/UnreadBadge.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
activeLabel: { type: String, default: '' },
chat: { type: Object, default: () => ({}) },
hideInboxName: { type: Boolean, default: false },
hideThumbnail: { type: Boolean, default: false },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
showAssignee: { type: Boolean, default: false },
conversationType: { type: String, default: '' },
chat: { type: Object, required: true },
currentContact: { type: Object, required: true },
assignee: { type: Object, default: () => ({}) },
inbox: { type: Object, default: () => ({}) },
selected: { type: Boolean, default: false },
isActiveChat: { type: Boolean, default: false },
showAssignee: { type: Boolean, default: false },
showInboxName: { type: Boolean, default: false },
hideThumbnail: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
enableContextMenu: { type: Boolean, default: false },
allowedContextMenuOptions: { type: Array, default: () => [] },
});
const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
'removeLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
'click',
'contextmenu',
'selectConversation',
'deSelectConversation',
]);
const router = useRouter();
const store = useStore();
const hovered = ref(false);
const showContextMenu = ref(false);
const contextMenu = ref({ x: null, y: null });
// Reset UI state when conversation changes at same index (no :key, instance reused on reorder)
// 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 inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
const chatMetadata = computed(() => props.chat.meta || {});
const assignee = computed(() => chatMetadata.value.assignee || {});
const senderId = computed(() => chatMetadata.value.sender?.id);
const currentContact = computed(() => {
return senderId.value
? store.getters['contacts/getContact'](senderId.value)
: {};
});
const isActiveChat = computed(() => {
return currentChat.value.id === props.chat.id;
});
const unreadCount = computed(() => props.chat.unread_count);
const hasUnread = computed(() => unreadCount.value > 0);
const isInboxNameVisible = computed(() => !activeInbox.value);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const voiceCallData = computed(() => ({
@@ -100,24 +43,10 @@ const voiceCallData = computed(() => ({
direction: props.chat.additional_attributes?.call_direction,
}));
const inboxId = computed(() => props.chat.inbox_id);
const inbox = computed(() => {
return inboxId.value ? store.getters['inboxes/getInbox'](inboxId.value) : {};
});
const showInboxName = computed(() => {
return (
!props.hideInboxName &&
isInboxNameVisible.value &&
inboxesList.value.length > 1
);
});
const showMetaSection = computed(() => {
return (
showInboxName.value ||
(props.showAssignee && assignee.value.name) ||
props.showInboxName ||
(props.showAssignee && props.assignee.name) ||
props.chat.priority
);
});
@@ -136,41 +65,6 @@ const messagePreviewClass = computed(() => {
];
});
const conversationPath = computed(() => {
return frontendURL(
conversationUrl({
accountId: accountId.value,
activeInbox: activeInbox.value,
id: props.chat.id,
label: props.activeLabel,
teamId: props.teamId,
conversationType: props.conversationType,
foldersId: props.foldersId,
})
);
});
const onCardClick = e => {
const path = conversationPath.value;
if (!path) return;
// Handle Ctrl/Cmd + Click for new tab
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
// Skip if already active
if (isActiveChat.value) return;
router.push({ path });
};
const onThumbnailHover = () => {
hovered.value = !props.hideThumbnail;
};
@@ -181,83 +75,37 @@ const onThumbnailLeave = () => {
const onSelectConversation = checked => {
if (checked) {
emit('selectConversation', props.chat.id, inbox.value.id);
emit('selectConversation', props.chat.id, props.inbox.id);
} else {
emit('deSelectConversation', props.chat.id, inbox.value.id);
emit('deSelectConversation', props.chat.id, props.inbox.id);
}
};
const openContextMenu = e => {
if (!props.enableContextMenu) return;
e.preventDefault();
emit('contextMenuToggle', true);
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const selectedModel = computed({
get: () => props.selected,
set: value => onSelectConversation(value),
});
const closeContextMenu = () => {
emit('contextMenuToggle', false);
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
};
const onUpdateConversation = (status, snoozedUntil) => {
closeContextMenu();
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
};
const onAssignAgent = agent => {
emit('assignAgent', agent, [props.chat.id]);
closeContextMenu();
};
const onAssignLabel = label => {
emit('assignLabel', [label.title], [props.chat.id]);
};
const onRemoveLabel = label => {
emit('removeLabel', [label.title], [props.chat.id]);
};
const onAssignTeam = team => {
emit('assignTeam', team, props.chat.id);
closeContextMenu();
};
const markAsUnread = () => {
emit('markAsUnread', props.chat.id);
closeContextMenu();
};
const markAsRead = () => {
emit('markAsRead', props.chat.id);
closeContextMenu();
};
const assignPriority = priority => {
emit('assignPriority', priority, props.chat.id);
closeContextMenu();
};
const deleteConversation = () => {
emit('deleteConversation', props.chat.id);
closeContextMenu();
};
watch(
() => props.chat.id,
() => {
hovered.value = false;
}
);
</script>
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 border-t-0 border-b-0 border-l-0 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 cursor-pointer conversation border-b border-n-slate-3 hover:border-n-surface-1 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
:class="{
'active animate-card-select bg-n-background border-n-weak': isActiveChat,
'bg-n-slate-2': selected,
'active animate-card-select bg-n-background !border-n-surface-1':
isActiveChat,
'selected bg-n-slate-2 !border-n-surface-1': selected,
'px-0': compact,
'px-3': !compact,
}"
@click="onCardClick"
@contextmenu="openContextMenu($event)"
@click="$emit('click', $event)"
@contextmenu="$emit('contextmenu', $event)"
>
<div
class="relative"
@@ -272,7 +120,6 @@ const deleteConversation = () => {
:status="currentContact.availability_status"
:class="!showInboxName ? 'mt-4' : 'mt-8'"
hide-offline-status
rounded-full
>
<template #overlay="{ size }">
<label
@@ -281,20 +128,12 @@ const deleteConversation = () => {
:style="{ width: `${size}px`, height: `${size}px` }"
@click.stop
>
<input
:value="selected"
:checked="selected"
class="!m-0 cursor-pointer"
type="checkbox"
@change="onSelectConversation($event.target.checked)"
/>
<Checkbox v-model="selectedModel" />
</label>
</template>
</Avatar>
</div>
<div
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
>
<div class="px-0 py-3 flex-1 min-w-0 border-line">
<div
v-if="showMetaSection"
class="flex items-center min-w-0 gap-1"
@@ -305,7 +144,7 @@ const deleteConversation = () => {
>
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
<div
class="flex items-center gap-2 flex-shrink-0"
class="flex items-baseline gap-2 flex-shrink-0"
:class="{
'flex-1 justify-between': !showInboxName,
}"
@@ -317,7 +156,10 @@ const deleteConversation = () => {
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
{{ assignee.name }}
</span>
<PriorityMark :priority="chat.priority" class="flex-shrink-0" />
<CardPriorityIcon
:priority="chat.priority"
class="flex-shrink-0 !size-3.5"
/>
</div>
</div>
<h4
@@ -366,12 +208,11 @@ const deleteConversation = () => {
:conversation-id="chat.id"
/>
</span>
<span
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
:class="hasUnread ? 'block' : 'hidden'"
>
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
<UnreadBadge
v-if="hasUnread"
:count="unreadCount"
class="ltr:ml-auto rtl:mr-auto mt-1"
/>
</div>
<CardLabels
v-if="showLabelsSection"
@@ -383,32 +224,5 @@ const deleteConversation = () => {
</template>
</CardLabels>
</div>
<ContextMenu
v-if="showContextMenu"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<ConversationContextMenu
:status="chat.status"
:inbox-id="inbox.id"
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="hasUnread"
:conversation-labels="chat.labels"
:conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@remove-label="onRemoveLabel"
@assign-team="onAssignTeam"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
@close="closeContextMenu"
/>
</ContextMenu>
</div>
</template>
@@ -45,6 +45,7 @@ const backButtonUrl = computed(() => {
const conversationTypeMap = {
conversation_through_mentions: 'mention',
conversation_through_participating: 'participating',
conversation_through_unattended: 'unattended',
};
return conversationListPageURL({
@@ -41,7 +41,16 @@ const closeContactPanel = () => {
<template>
<div
v-on-click-outside="() => closeContactPanel()"
v-on-click-outside="[
() => closeContactPanel(),
{
ignore: [
'dialog.ProseMirror-prompt-backdrop',
'[data-popover-content]',
'[data-popover-backdrop]',
],
},
]"
class="bg-n-surface-2 h-full overflow-hidden flex flex-col fixed top-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out ltr:right-0 rtl:left-0 md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
:class="[
{
@@ -16,10 +16,6 @@ defineProps({
type: String,
default: '',
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
@@ -69,7 +65,6 @@ const onSend = () => {
:generated-content="generatedContent"
:min-height="4"
:enabled-menu-options="[]"
:is-popout="isPopout"
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"
@@ -1,7 +1,7 @@
<script>
import { ref, provide } from 'vue';
import { ref, provide, useTemplateRef } from 'vue';
import { useElementSize } from '@vueuse/core';
// composable
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
@@ -11,6 +11,7 @@ import MessageList from 'next/message/MessageList.vue';
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ResizableEditorWrapper from './ResizableEditorWrapper.vue';
// stores and apis
import { mapGetters } from 'vuex';
@@ -43,21 +44,16 @@ export default {
Banner,
ConversationLabelSuggestion,
Spinner,
ResizableEditorWrapper,
},
mixins: [inboxMixin],
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const keyboardEvents = {
Escape: {
action: () => {
isPopOutReplyBox.value = false;
},
},
};
useKeyboardEvents(keyboardEvents);
const resizableEditorWrapperRef = ref(null);
const messagesViewRef = useTemplateRef('messagesViewRef');
const topBannerRef = useTemplateRef('topBannerRef');
const { height: containerHeight } = useElementSize(messagesViewRef);
const { height: topBannerHeight } = useElementSize(topBannerRef);
const {
captainTasksEnabled,
@@ -68,11 +64,15 @@ export default {
provide('contextMenuElementTarget', conversationPanelRef);
return {
isPopOutReplyBox,
captainTasksEnabled,
getLabelSuggestions,
isLabelSuggestionFeatureEnabled,
conversationPanelRef,
resizableEditorWrapperRef,
messagesViewRef,
topBannerRef,
containerHeight,
topBannerHeight,
};
},
data() {
@@ -254,6 +254,7 @@ export default {
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
this.messageSentSinceOpened = false;
this.resetReplyEditorHeight();
},
},
@@ -437,26 +438,37 @@ export default {
const payload = useSnakeCase(message);
await this.$store.dispatch('sendMessageWithData', payload);
},
toggleReplyEditorSize() {
this.resizableEditorWrapperRef?.toggleEditorExpand?.();
},
resetReplyEditorHeight() {
this.resizableEditorWrapperRef?.resetEditorHeight?.();
},
},
};
</script>
<template>
<div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0">
<Banner
v-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
:href-link="replyWindowLink"
:href-link-text="replyWindowLinkText"
/>
<Banner
v-else-if="hasDuplicateInstagramInbox"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
<div
ref="messagesViewRef"
class="flex flex-col justify-between flex-grow h-full min-w-0 m-0"
>
<div ref="topBannerRef">
<Banner
v-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
:href-link="replyWindowLink"
:href-link-text="replyWindowLinkText"
/>
<Banner
v-else-if="hasDuplicateInstagramInbox"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
</div>
<MessageList
ref="conversationPanelRef"
class="conversation-panel flex-shrink flex-grow basis-px flex flex-col overflow-y-auto relative h-full m-0 pb-4"
@@ -498,13 +510,7 @@ export default {
/>
</template>
</MessageList>
<div
class="flex relative flex-col"
:class="{
'modal-mask': isPopOutReplyBox,
'bg-n-surface-1': !isPopOutReplyBox,
}"
>
<div class="flex relative flex-col bg-n-surface-1">
<div
v-if="isAnyoneTyping"
class="absolute flex items-center w-full h-0 -top-7"
@@ -520,42 +526,12 @@ export default {
/>
</div>
</div>
<ReplyBox
:pop-out-reply-box="isPopOutReplyBox"
@update:pop-out-reply-box="isPopOutReplyBox = $event"
/>
<ResizableEditorWrapper
ref="resizableEditorWrapperRef"
:container-height="Math.max(0, containerHeight - topBannerHeight)"
>
<ReplyBox @toggle-editor-size="toggleReplyEditorSize" />
</ResizableEditorWrapper>
</div>
</div>
</template>
<style scoped lang="scss">
.modal-mask {
@apply fixed;
&::v-deep {
.ProseMirror-woot-style {
@apply max-h-[25rem];
}
.reply-box {
@apply border border-n-weak max-w-[75rem] w-[70%];
&.is-private {
@apply dark:border-n-amber-3/30 border-n-amber-12/5;
}
}
.reply-box .reply-box__top {
@apply relative min-h-[27.5rem];
}
.reply-box__top .input {
@apply min-h-[27.5rem];
}
.emoji-dialog {
@apply absolute ltr:left-auto rtl:right-auto bottom-1;
}
}
}
</style>
@@ -1,53 +0,0 @@
<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 },
}"
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>
@@ -27,7 +27,6 @@ import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import {
getMessageVariables,
getUndefinedVariablesInMessage,
replaceVariablesInMessage,
} from '@chatwoot/utils';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
@@ -82,13 +81,7 @@ export default {
CopilotReplyBottomPanel,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
popOutReplyBox: {
type: Boolean,
default: false,
},
},
emits: ['update:popOutReplyBox'],
emits: ['toggleEditorSize'],
setup() {
const {
uiSettings,
@@ -253,6 +246,9 @@ export default {
if (this.isAnInstagramChannel) {
return MESSAGE_MAX_LENGTH.INSTAGRAM;
}
if (this.isATelegramChannel) {
return MESSAGE_MAX_LENGTH.TELEGRAM;
}
if (this.isATiktokChannel) {
return MESSAGE_MAX_LENGTH.TIKTOK;
}
@@ -545,7 +541,10 @@ export default {
},
setCopilotAcceptedMessage(message, replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
this.copilotAcceptedMessages[key] = trimContent(message || '');
this.copilotAcceptedMessages[key] = trimContent(
message || '',
this.maxLength
);
},
clearCopilotAcceptedMessage(replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
@@ -603,7 +602,7 @@ export default {
saveDraft(conversationId, replyType) {
if (this.message || this.message === '') {
const key = this.getDraftKey(conversationId, replyType);
const draftToSave = trimContent(this.message || '');
const draftToSave = trimContent(this.message || '', this.maxLength);
this.$store.dispatch('draftMessages/set', {
key,
@@ -630,10 +629,17 @@ export default {
return message;
}
// Even when editor is disabled (e.g. WhatsApp/API can't reply), we must
// still normalize stale signatures out of drafts when signature is off.
if (this.isEditorDisabled && this.sendWithSignature) {
return message;
}
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
return this.sendWithSignature
? appendSignature(message, this.messageSignature, effectiveChannelType)
: removeSignature(message, this.messageSignature, effectiveChannelType);
@@ -785,7 +791,6 @@ export default {
this.clearMessage();
this.hideEmojiPicker();
this.$emit('update:popOutReplyBox', false);
}
},
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
@@ -905,32 +910,6 @@ export default {
});
this.hideContentTemplatesModal();
},
replaceText(message) {
if (this.sendWithSignature && !this.private) {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
const updatedMessage = replaceVariablesInMessage({
message,
variables: this.messageVariables,
});
setTimeout(() => {
useTrack(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
this.message = updatedMessage;
}, 100);
},
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
// Clear attachments when switching between private note and reply modes
// This is to prevent from breaking the upload rules
@@ -1231,8 +1210,9 @@ export default {
file => !file?.isRecordedAudio
);
},
togglePopout() {
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
toggleEditorSize() {
this.$emit('toggleEditorSize');
this.$nextTick(() => this.messageEditor?.focusEditorInputField());
},
onSubmitCopilotReply() {
const acceptedMessage = this.copilot.accept();
@@ -1258,9 +1238,8 @@ export default {
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
:editor-content="message"
:popout-reply-box="popOutReplyBox"
@set-reply-mode="setReplyMode"
@toggle-popout="togglePopout"
@toggle-editor-size="toggleEditorSize"
@toggle-copilot="copilot.toggleEditor"
@execute-copilot-action="executeCopilotAction"
/>
@@ -1289,7 +1268,7 @@ export default {
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
:class="{
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
'emoji-dialog--expanded': isOnExpandedLayout,
}"
:on-click="addIntoEditor"
/>
@@ -1313,7 +1292,6 @@ export default {
:show-copilot-editor="copilot.showEditor.value"
:is-generating-content="copilot.isGenerating.value"
:generated-content="copilot.generatedContent.value"
:is-popout="popOutReplyBox"
:placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')"
@focus="onFocus"
@blur="onBlur"
@@ -1429,7 +1407,6 @@ export default {
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
/>
@@ -0,0 +1,154 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, useTemplateRef } from 'vue';
import { useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
const props = defineProps({
containerHeight: { type: Number, default: 0 },
});
const DEFAULT_HEIGHT = 120;
const MIN_HEIGHT = 80;
const MIN_MESSAGES_HEIGHT = 200;
const EXPAND_RATIO = 0.5;
const RESET_DELAY_MS = 120;
const wrapperRef = useTemplateRef('wrapperRef');
const surroundingHeight = ref(0);
const editorHeight = ref(DEFAULT_HEIGHT);
const isResizing = ref(false);
const startY = ref(0);
const startHeight = ref(0);
let resetTimeoutId = null;
const clamp = (val, min, max) => Math.min(Math.max(val, min), max);
// Measure height of elements surrounding the editor (top panel, email fields, bottom panel)
const measureSurroundingHeight = () => {
if (wrapperRef.value) {
surroundingHeight.value = Math.max(
0,
wrapperRef.value.offsetHeight - editorHeight.value
);
}
};
const isContainerReady = computed(() => props.containerHeight > 0);
const sizeBounds = computed(() => {
const h = props.containerHeight;
const s = surroundingHeight.value;
const max = Math.max(MIN_HEIGHT, h - MIN_MESSAGES_HEIGHT - s);
const expanded = clamp(Math.floor(h * EXPAND_RATIO - s / 2), MIN_HEIGHT, max);
return {
min: MIN_HEIGHT,
max: isContainerReady.value ? max : DEFAULT_HEIGHT,
expanded,
default: clamp(DEFAULT_HEIGHT, MIN_HEIGHT, max),
};
});
const clampToBounds = val =>
clamp(val, sizeBounds.value.min, sizeBounds.value.max);
const clearDragStyles = () => {
Object.assign(document.body.style, { cursor: '', userSelect: '' });
};
const getClientY = e => (e.touches ? e.touches[0].clientY : e.clientY);
const onResizeStart = event => {
editorHeight.value = clampToBounds(editorHeight.value);
measureSurroundingHeight();
isResizing.value = true;
startY.value = getClientY(event);
startHeight.value = clampToBounds(editorHeight.value);
editorHeight.value = startHeight.value;
Object.assign(document.body.style, {
cursor: 'row-resize',
userSelect: 'none',
});
};
const onResizeMove = event => {
if (!isResizing.value) return;
if (event.touches) event.preventDefault();
editorHeight.value = clampToBounds(
startHeight.value + startY.value - getClientY(event)
);
};
const onResizeEnd = () => {
if (!isResizing.value) return;
isResizing.value = false;
clearDragStyles();
};
const resetEditorHeight = () => {
editorHeight.value = sizeBounds.value.default;
};
const toggleEditorExpand = () => {
editorHeight.value = clampToBounds(editorHeight.value);
measureSurroundingHeight();
const { expanded, max, default: defaultHeight } = sizeBounds.value;
const isExpanded = editorHeight.value > defaultHeight;
// If expanded is too close to default, use max so the toggle is always noticeable
const target = expanded - defaultHeight < 100 ? max : expanded;
editorHeight.value = isExpanded ? defaultHeight : target;
};
const handleMessageSent = () => {
clearTimeout(resetTimeoutId);
resetTimeoutId = setTimeout(resetEditorHeight, RESET_DELAY_MS);
};
onMounted(() => {
emitter.on(BUS_EVENTS.MESSAGE_SENT, handleMessageSent);
});
onBeforeUnmount(() => {
emitter.off(BUS_EVENTS.MESSAGE_SENT, handleMessageSent);
clearTimeout(resetTimeoutId);
if (isResizing.value) {
isResizing.value = false;
clearDragStyles();
}
});
useEventListener(document, 'mousemove', onResizeMove);
useEventListener(document, 'mouseup', onResizeEnd);
useEventListener(document, 'touchmove', onResizeMove, { passive: false });
useEventListener(document, 'touchend', onResizeEnd);
useEventListener(document, 'touchcancel', onResizeEnd);
useEventListener(window, 'blur', onResizeEnd);
defineExpose({ toggleEditorExpand, resetEditorHeight });
</script>
<template>
<div
ref="wrapperRef"
class="relative resizable-editor-wrapper"
:style="{
'--editor-height': editorHeight + 'px',
'--editor-min-allowed': sizeBounds.min + 'px',
'--editor-max-allowed': sizeBounds.max + 'px',
'--editor-height-transition': isResizing ? 'none' : '180ms ease',
}"
>
<div
class="group absolute inset-x-0 -top-4 z-10 flex h-4 cursor-row-resize select-none items-center justify-center bg-gradient-to-b from-transparent from-10% dark:to-n-surface-1/80 to-n-surface-1/90 backdrop-blur-[0.01875rem]"
@mousedown="onResizeStart"
@touchstart.prevent="onResizeStart"
@dblclick="resetEditorHeight"
>
<div
class="w-8 h-0.5 mt-1 rounded-full bg-n-slate-6 group-hover:bg-n-slate-8 transition-all duration-200 motion-safe:group-hover:animate-bounce"
:class="{ 'bg-n-slate-8 animate-bounce': isResizing }"
/>
</div>
<slot />
</div>
</template>
@@ -8,6 +8,7 @@ import {
agents,
teams,
labels,
booleanFilterOptions,
statusFilterOptions,
messageTypeOptions,
priorityOptions,
@@ -73,6 +74,8 @@ describe('useAutomation', () => {
return countries;
case 'message_type':
return messageTypeOptions;
case 'private_note':
return booleanFilterOptions;
case 'priority':
return priorityOptions;
default:
@@ -89,7 +92,9 @@ describe('useAutomation', () => {
case 'assign_team':
return teams;
case 'assign_agent':
return agents;
return options.addNoneToListFn
? options.addNoneToListFn(options.agents)
: options.agents;
case 'send_email_to_team':
return teams;
case 'send_message':
@@ -226,6 +231,9 @@ describe('useAutomation', () => {
expect(getConditionDropdownValues('message_type')).toEqual(
messageTypeOptions
);
expect(getConditionDropdownValues('private_note')).toEqual(
booleanFilterOptions
);
expect(getConditionDropdownValues('priority')).toEqual(priorityOptions);
});
@@ -234,7 +242,11 @@ describe('useAutomation', () => {
expect(getActionDropdownValues('add_label')).toEqual(labels);
expect(getActionDropdownValues('assign_team')).toEqual(teams);
expect(getActionDropdownValues('assign_agent')).toEqual(agents);
expect(getActionDropdownValues('assign_agent')).toEqual([
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
{ id: 'last_responding_agent', name: 'AUTOMATION.LAST_RESPONDING_AGENT' },
...agents,
]);
expect(getActionDropdownValues('send_email_to_team')).toEqual(teams);
expect(getActionDropdownValues('send_message')).toEqual([]);
expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies);
@@ -0,0 +1,239 @@
import { ref } from 'vue';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
// Mock @vueuse/core — return reactive refs we can control per test
const mockBounding = () => ({
top: ref(0),
bottom: ref(0),
left: ref(0),
right: ref(0),
width: ref(0),
height: ref(0),
update: vi.fn(),
});
const triggerBounds = mockBounding();
const dropdownBounds = mockBounding();
const winWidth = ref(1024);
const winHeight = ref(768);
vi.mock('@vueuse/core', () => {
let callCount = 0;
return {
// First call = trigger, second = dropdown, third = container (if any)
useElementBounding: () => {
callCount += 1;
return callCount % 3 === 1 ? triggerBounds : dropdownBounds;
},
useWindowSize: () => ({ width: winWidth, height: winHeight }),
};
});
const setTrigger = ({ top, bottom, left, right }) => {
triggerBounds.top.value = top;
triggerBounds.bottom.value = bottom;
triggerBounds.left.value = left ?? 100;
triggerBounds.right.value = right ?? 200;
};
const setDropdown = ({ width, height }) => {
dropdownBounds.width.value = width ?? 200;
dropdownBounds.height.value = height;
};
describe('useDropdownPosition', () => {
beforeEach(() => {
winWidth.value = 1024;
winHeight.value = 768;
document.body.innerHTML = '<div id="app" dir="ltr"></div>';
});
describe('verticalClass (relative mode)', () => {
it('places below when enough space', () => {
// Trigger at y=100, dropdown 200px tall
// Space below = 768 - 140 = 628 → fits (628 > 216)
setTrigger({ top: 100, bottom: 140 });
setDropdown({ height: 200 });
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
expect(position.value.class).toBe('top-full mt-2');
});
it('places above when not enough space below but enough above', () => {
// Trigger near bottom at y=600
// Space below = 128 → doesn't fit. Space above = 600 → fits
setTrigger({ top: 600, bottom: 640 });
setDropdown({ height: 200 });
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
expect(position.value.class).toBe('bottom-full mb-2');
});
it('picks the side with more space when dropdown fits neither', () => {
// Dropdown 500px tall, won't fit above (300) or below (428)
// Below has more room → stays below
setTrigger({ top: 300, bottom: 340 });
setDropdown({ height: 500 });
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
expect(position.value.class).toBe('top-full mt-2');
});
it('picks above when above has more space and neither fits', () => {
// Dropdown 600px tall, won't fit above (500) or below (228)
// Above has more room → flips above
setTrigger({ top: 500, bottom: 540 });
setDropdown({ height: 600 });
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
expect(position.value.class).toBe('bottom-full mb-2');
});
it('returns default when disabled', () => {
setTrigger({ top: 700, bottom: 740 });
setDropdown({ height: 200 });
const { position } = useDropdownPosition(
ref(null),
ref(null),
ref(false)
);
expect(position.value.class).toBe('top-full mt-2');
expect(position.value.style).toEqual({});
});
});
describe('fixedPosition', () => {
it('places below with correct top and maxHeight', () => {
// Trigger at y=140, space below = 628
// top = 140 + 8(gap) = 148
// maxHeight = 628 - 8(gap) - 16(margin) = 604
setTrigger({ top: 100, bottom: 140 });
setDropdown({ height: 200, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.top).toBe('148px');
expect(fixedPosition.value.style.bottom).toBeUndefined();
expect(fixedPosition.value.style.maxHeight).toBe('604px');
});
it('flips above with correct bottom and maxHeight', () => {
// Trigger near bottom at y=650, space below = 78 → doesn't fit
// Flips above: bottom = 768 - 650 + 8 = 126
// maxHeight = 650 - 8 - 16 = 626
setTrigger({ top: 650, bottom: 690 });
setDropdown({ height: 200, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.bottom).toBe('126px');
expect(fixedPosition.value.style.top).toBeUndefined();
expect(fixedPosition.value.style.maxHeight).toBe('626px');
});
it('constrains maxHeight to available space on short viewports', () => {
// Short viewport (400px), trigger in the middle, dropdown 500px tall
// Neither side fits → above (200) > below (160) → places above
// maxHeight capped to 200 - 8 - 16 = 176
winHeight.value = 400;
setTrigger({ top: 200, bottom: 240 });
setDropdown({ height: 500, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.bottom).toBeDefined();
expect(fixedPosition.value.style.maxHeight).toBe('176px');
});
it('returns defaults when disabled', () => {
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(false)
);
expect(fixedPosition.value.class).toBe('fixed z-[9999]');
expect(fixedPosition.value.style).toEqual({});
});
});
describe('horizontal positioning (fixedPosition)', () => {
it('anchors to the right edge by default (align=end, LTR)', () => {
// align=end + LTR → anchorLeft=false → uses style.right
// right = 1024 - 900 = 124
setTrigger({ top: 100, bottom: 140, left: 800, right: 900 });
setDropdown({ height: 100, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.right).toBe('124px');
});
it('anchors to the left edge when align=start (LTR)', () => {
// align=start + LTR → anchorLeft=true → uses style.left
setTrigger({ top: 100, bottom: 140, left: 100, right: 200 });
setDropdown({ height: 100, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true),
{ align: 'start' }
);
expect(fixedPosition.value.style.left).toBe('100px');
});
it('shifts left when dropdown overflows right edge', () => {
// Trigger at x=900, dropdown 300px wide → 900+300=1200 > 1024
// Falls back to right: 16px (margin)
setTrigger({ top: 100, bottom: 140, left: 900, right: 1000 });
setDropdown({ height: 100, width: 300 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true),
{ align: 'start' }
);
expect(fixedPosition.value.style.right).toBe('16px');
});
});
describe('RTL', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="app" dir="rtl"></div>';
});
it('flips anchor direction in RTL (align=end anchors left)', () => {
// align=end + RTL → anchorLeft=true → uses style.left
setTrigger({ top: 100, bottom: 140, left: 100, right: 200 });
setDropdown({ height: 100, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.left).toBe('100px');
});
});
});
@@ -0,0 +1,98 @@
import { useEditableAutomation } from '../useEditableAutomation';
import useAutomationValues from '../useAutomationValues';
vi.mock('../useAutomationValues');
describe('useEditableAutomation', () => {
beforeEach(() => {
useAutomationValues.mockReturnValue({
getConditionDropdownValues: vi.fn(attributeKey => {
if (attributeKey === 'private_note') {
return [
{ id: true, name: 'True' },
{ id: false, name: 'False' },
];
}
return [];
}),
getActionDropdownValues: vi.fn(actionName => {
if (actionName === 'assign_agent') {
return [
{ id: 'nil', name: 'None' },
{
id: 'last_responding_agent',
name: 'Last Responding Agent',
},
{ id: 1, name: 'Agent 1' },
];
}
return [];
}),
});
});
it('rehydrates boolean conditions as a single selected option', () => {
const automation = {
event_name: 'message_created',
conditions: [
{
attribute_key: 'private_note',
filter_operator: 'equal_to',
values: [false],
query_operator: null,
},
],
actions: [],
};
const automationTypes = {
message_created: {
conditions: [{ key: 'private_note', inputType: 'search_select' }],
},
};
const { formatAutomation } = useEditableAutomation();
const result = formatAutomation(automation, [], automationTypes, []);
expect(result.conditions).toEqual([
{
attribute_key: 'private_note',
filter_operator: 'equal_to',
values: { id: false, name: 'False' },
query_operator: 'and',
},
]);
});
it('rehydrates last responding agent as a selected action option', () => {
const automation = {
event_name: 'conversation_created',
conditions: [],
actions: [
{
action_name: 'assign_agent',
action_params: ['last_responding_agent'],
},
],
};
const automationActionTypes = [
{ key: 'assign_agent', inputType: 'search_select' },
];
const { formatAutomation } = useEditableAutomation();
const result = formatAutomation(automation, [], {}, automationActionTypes);
expect(result.actions).toEqual([
{
action_name: 'assign_agent',
action_params: [
{
id: 'last_responding_agent',
name: 'Last Responding Agent',
},
],
},
]);
});
});
@@ -119,24 +119,30 @@ describe('useMacros', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toHaveLength(mockLabels.length);
expect(getMacroDropdownValues('assign_team')).toHaveLength(
mockTeams.length
);
mockTeams.length + 1
); // +1 for "None"
expect(getMacroDropdownValues('assign_agent')).toHaveLength(
mockAgents.length + 1
); // +1 for "Self"
mockAgents.length + 2
); // +2 for "None" and "Self"
});
it('returns teams for assign_team and send_email_to_team types', () => {
it('returns teams with "None" option for assign_team and teams only for send_email_to_team', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('assign_team')).toEqual(mockTeams);
const assignTeamResult = getMacroDropdownValues('assign_team');
expect(assignTeamResult[0]).toEqual({
id: 'nil',
name: 'AUTOMATION.NONE_OPTION',
});
expect(assignTeamResult.slice(1)).toEqual(mockTeams);
expect(getMacroDropdownValues('send_email_to_team')).toEqual(mockTeams);
});
it('returns agents with "Self" option for assign_agent type', () => {
it('returns agents with "None" and "Self" options for assign_agent type', () => {
const { getMacroDropdownValues } = useMacros();
const result = getMacroDropdownValues('assign_agent');
expect(result[0]).toEqual({ id: 'self', name: 'Self' });
expect(result.slice(1)).toEqual(mockAgents);
expect(result[0]).toEqual({ id: 'nil', name: 'AUTOMATION.NONE_OPTION' });
expect(result[1]).toEqual({ id: 'self', name: 'Self' });
expect(result.slice(2)).toEqual(mockAgents);
});
it('returns formatted labels for add_label and remove_label types', () => {
@@ -172,8 +178,11 @@ describe('useMacros', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toEqual([]);
expect(getMacroDropdownValues('assign_team')).toEqual([]);
expect(getMacroDropdownValues('assign_team')).toEqual([
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
]);
expect(getMacroDropdownValues('assign_agent')).toEqual([
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
{ id: 'self', name: 'Self' },
]);
});
@@ -121,8 +121,19 @@ export default function useAutomationValues() {
* @returns {Array} An array of action dropdown values.
*/
const getActionDropdownValues = type => {
let agentsList = agents.value;
if (type === 'assign_agent') {
agentsList = [
{
id: 'last_responding_agent',
name: t('AUTOMATION.LAST_RESPONDING_AGENT'),
},
...agentsList,
];
}
return getActionOptions({
agents: agents.value,
agents: agentsList,
labels: labels.value,
teams: teams.value,
slaPolicies: slaPolicies.value,
@@ -0,0 +1,128 @@
import { computed, unref, watch } from 'vue';
import { useElementBounding, useWindowSize } from '@vueuse/core';
const FALLBACK_SIZE = 200;
const SAFE_MARGIN = 16;
const GAP = 8;
/**
* Auto-position a floating element based on available viewport space.
*
* @param {Ref} triggerRef - Trigger element ref
* @param {Ref} dropdownRef - Dropdown/popover element ref
* @param {Ref} enabled - Whether to calculate position
* @param {Object} options
* @param {Ref} [options.container] - Constraining container ref
* @param {number} [options.margin=16] - Min distance from viewport/container edges
* @param {string} [options.align='end'] - 'start' or 'end' (flips automatically for RTL)
*/
export function useDropdownPosition(
triggerRef,
dropdownRef,
enabled,
{ container = null, margin = SAFE_MARGIN, align = 'end' } = {}
) {
const trigger = useElementBounding(triggerRef);
const dropdown = useElementBounding(dropdownRef);
const bounds = useElementBounding(container);
const { width: winWidth, height: winHeight } = useWindowSize();
const isRTL = computed(
() => document.querySelector('#app[dir]')?.getAttribute('dir') === 'rtl'
);
// Whether to anchor to the left edge of the trigger
const anchorLeft = computed(() => (align === 'start') !== isRTL.value);
const verticalClass = computed(() => {
if (!unref(enabled)) return 'top-full mt-2';
const dh = dropdown.height.value || FALLBACK_SIZE;
const spaceBelow = winHeight.value - trigger.bottom.value;
const spaceAbove = trigger.top.value;
// Only flip above if it fits there; otherwise stay below (more room or equal)
if (spaceBelow >= dh + margin) return 'top-full mt-2';
if (spaceAbove >= dh + margin) return 'bottom-full mb-2';
return spaceBelow >= spaceAbove ? 'top-full mt-2' : 'bottom-full mb-2';
});
// Relative mode: Tailwind class + style for absolute-in-parent dropdowns
const position = computed(() => {
if (!unref(enabled)) return { class: 'top-full mt-2', style: {} };
const dw = dropdown.width.value || FALLBACK_SIZE;
const leftBound = container ? bounds.left.value : 0;
const rightBound = container ? bounds.right.value : winWidth.value;
const style = {};
if (anchorLeft.value) {
const available = rightBound - trigger.left.value;
const overflow = dw - available;
style.left = overflow > 0 ? `-${overflow}px` : '0px';
} else {
const available = trigger.right.value - leftBound;
const overflow = dw - available;
style.right = overflow > 0 ? `-${overflow}px` : '0px';
}
return { class: verticalClass.value, style };
});
// Fixed mode: styles for teleported popovers
const fixedPosition = computed(() => {
if (!unref(enabled)) return { class: 'fixed z-[9999]', style: {} };
const dh = dropdown.height.value || FALLBACK_SIZE;
const dw = dropdown.width.value || FALLBACK_SIZE;
const spaceBelow = winHeight.value - trigger.bottom.value;
const style = {};
// Vertical: prefer below, flip above only if it fits, else pick the larger side
const spaceAbove = trigger.top.value;
const placeAbove =
spaceBelow < dh + margin &&
(spaceAbove >= dh + margin || spaceAbove > spaceBelow);
if (placeAbove) {
style.bottom = `${winHeight.value - trigger.top.value + GAP}px`;
style.maxHeight = `${spaceAbove - GAP - margin}px`;
} else {
style.top = `${trigger.bottom.value + GAP}px`;
style.maxHeight = `${spaceBelow - GAP - margin}px`;
}
// Horizontal
if (anchorLeft.value) {
const left = trigger.left.value;
if (left + dw > winWidth.value - margin) {
style.right = `${margin}px`;
} else {
style.left = `${Math.max(margin, left)}px`;
}
} else {
const right = winWidth.value - trigger.right.value;
if (trigger.right.value - dw < margin) {
style.left = `${margin}px`;
} else {
style.right = `${right}px`;
}
}
return { class: 'fixed z-[9999]', style };
});
const updatePosition = () => {
trigger.update();
dropdown.update();
if (container) bounds.update();
};
// Update position when dropdown opens to ensure RTL state is current
watch(
() => unref(enabled),
isEnabled => {
if (isEnabled) updatePosition();
}
);
return { position, fixedPosition, updatePosition };
}
@@ -46,11 +46,26 @@ export function useEditableAutomation() {
if (inputType === 'comma_separated_plain_text') {
return { ...condition, values: condition.values.join(',') };
}
const dropdownValues = getConditionDropdownValues(
condition.attribute_key
);
const hasBooleanOptions =
inputType === 'search_select' &&
dropdownValues.length &&
dropdownValues.every(item => typeof item.id === 'boolean');
if (hasBooleanOptions) {
return {
...condition,
query_operator: condition.query_operator || 'and',
values: dropdownValues.find(item => item.id === condition.values[0]),
};
}
return {
...condition,
query_operator: condition.query_operator || 'and',
values: [...getConditionDropdownValues(condition.attribute_key)].filter(
item => [...condition.values].includes(item.id)
values: [...dropdownValues].filter(item =>
[...condition.values].includes(item.id)
),
};
});
@@ -12,11 +12,14 @@ import { useKeyboardEvents } from './useKeyboardEvents';
/**
* Wrap the action in a function that calls the action and prevents the default event behavior.
* Only prevents default when items are available to navigate.
* @param {Function} action - The action to be called.
* @param {import('vue').Ref<Array>} items - A ref to the array of selectable items.
* @returns {{action: Function, allowOnFocusedInput: boolean}} An object containing the action and a flag to allow the event on focused input.
*/
const createAction = action => ({
const createAction = (action, items) => ({
action: e => {
if (!items.value?.length) return;
action();
e.preventDefault();
},
@@ -38,15 +41,14 @@ const createKeyboardEvents = (
items
) => {
const events = {
ArrowUp: createAction(moveSelectionUp),
'Control+KeyP': createAction(moveSelectionUp),
ArrowDown: createAction(moveSelectionDown),
'Control+KeyN': createAction(moveSelectionDown),
ArrowUp: createAction(moveSelectionUp, items),
'Control+KeyP': createAction(moveSelectionUp, items),
ArrowDown: createAction(moveSelectionDown, items),
'Control+KeyN': createAction(moveSelectionDown, items),
};
// Adds an event handler for the Enter key if the onSelect function is provided.
if (typeof onSelect === 'function') {
events.Enter = createAction(() => items.value?.length > 0 && onSelect());
events.Enter = createAction(onSelect, items);
}
return events;
@@ -15,6 +15,11 @@ export const useMacros = () => {
const teams = computed(() => getters['teams/getTeams'].value);
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
const withNoneOption = options => [
{ id: 'nil', name: t('AUTOMATION.NONE_OPTION') },
...(options || []),
];
/**
* Get dropdown values based on the specified type
* @param {string} type - The type of dropdown values to retrieve
@@ -23,10 +28,15 @@ export const useMacros = () => {
const getMacroDropdownValues = type => {
switch (type) {
case 'assign_team':
return withNoneOption(teams.value);
case 'send_email_to_team':
return teams.value;
case 'assign_agent':
return [{ id: 'self', name: 'Self' }, ...agents.value];
return [
...withNoneOption(),
{ id: 'self', name: 'Self' },
...agents.value,
];
case 'add_label':
case 'remove_label':
return labels.value.map(i => ({
@@ -172,6 +172,7 @@ export const FORMATTING = {
export const ARTICLE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'strike',
'link',
'undo',
'redo',
@@ -182,6 +183,7 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'h3',
'imageUpload',
'code',
'insertTable',
];
/**

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