Compare commits

..
Author SHA1 Message Date
Shivam MishraandGitHub 22e0ea7502 Merge branch 'develop' into feat/reporting-events-rollup 2026-03-11 18:10:23 +05:30
Shivam Mishra 9e918e0a9f chore: remove dead code 2026-03-10 13:32:47 +05:30
Shivam Mishra f56b0c9f2e fix: coerce nil rollup metric values to zero 2026-03-10 13:22:48 +05:30
Shivam MishraandGitHub c194f10f49 Merge branch 'develop' into feat/reporting-events-rollup 2026-03-10 12:54:44 +05:30
Shivam MishraandGitHub 63172eb08c Merge branch 'develop' into feat/reporting-events-rollup 2026-03-09 19:32:03 +05:30
Shivam Mishra 275a76a1a0 fix: preserve 404s for invalid rollup filters 2026-03-09 14:20:27 +05:30
Shivam Mishra 6f127d86b9 feat: add validity gate 2026-03-09 13:57:49 +05:30
Shivam Mishra 014393297b feat: validate timezone 2026-03-09 13:49:21 +05:30
Shivam Mishra 3d67ef02ef chore: disable team rollup read 2026-03-09 13:34:44 +05:30
Shivam Mishra d86c8d1ede style: rubcop issues 2026-03-09 12:55:49 +05:30
Shivam Mishra 8324d84fbf fix: start od week 2026-03-09 12:50:29 +05:30
Shivam Mishra 0bc80bf95f fix: data gaps for missing dates 2026-03-09 12:28:03 +05:30
Shivam Mishra e230e97320 chore: remove compare job 2026-03-09 12:14:03 +05:30
Shivam Mishra 8f331ea7be refactor: split rake task 2026-03-09 11:31:16 +05:30
Shivam Mishra 7feb0ecaef style: fix abc size 2026-03-09 11:20:33 +05:30
Shivam Mishra 5a7c615efa style: simplify output 2026-03-09 11:16:31 +05:30
Shivam MishraandGitHub dcb2198147 Merge branch 'develop' into feat/reporting-events-rollup 2026-03-09 11:07:31 +05:30
Shivam MishraandGitHub d0f07c8f5f Merge branch 'develop' into feat/reporting-events-rollup 2026-03-05 20:39:27 +05:30
Shivam MishraandGitHub a7274bc8e8 Merge branch 'develop' into feat/reporting-events-rollup 2026-03-05 15:14:15 +05:30
Shivam Mishra 7c6097e4c8 feat: add task to set timezone for reporting events 2026-03-05 15:00:05 +05:30
Shivam Mishra 35d936ae04 chore: cleaner output for rake 2026-03-05 14:54:10 +05:30
Shivam Mishra 65ccf8bae2 feat: add plan 2026-03-05 14:51:07 +05:30
Shivam Mishra 0d27e76c3f feat: simplify backfill 2026-03-05 14:48:40 +05:30
Shivam Mishra 8c98b619bd feat: allow timezone offset for summary reports 2026-03-05 14:34:03 +05:30
Shivam Mishra 47fc8072ad feat: allow rollup path for inbox 2026-03-05 14:34:03 +05:30
Shivam Mishra dfd92acd4a feat: don't allow without timezone 2026-03-05 14:34:03 +05:30
Shivam MishraandGitHub ea5a46ee0c Merge branch 'develop' into feat/reporting-events-rollup 2026-03-05 14:11:45 +05:30
Shivam Mishra 9534d7157d fix: pass summary dimension type to rollup-backed builders 2026-03-03 18:11:42 +05:30
Shivam MishraandGitHub 51f298c129 Merge branch 'develop' into feat/reporting-events-rollup 2026-03-03 17:40:59 +05:30
Shivam MishraandGitHub 2e18cb3b15 Merge branch 'develop' into feat/reporting-events-rollup 2026-02-24 20:24:15 +05:30
Shivam MishraandGitHub c8637bf0de Merge branch 'develop' into feat/reporting-events-rollup 2026-02-24 16:07:37 +05:30
Shivam MishraandGitHub a15dffeadb Merge branch 'develop' into feat/reporting-events-rollup 2026-02-23 17:55:15 +05:30
Shivam Mishra 22ba4a6dfe Merge branch 'feat/reporting-events-rollup' of github.com:chatwoot/chatwoot into feat/reporting-events-rollup 2026-02-18 18:22:39 +05:30
Shivam Mishra e840419029 refactor: extract backfill rake task into a class
Reduces block length by moving logic from the rake task block
into a dedicated ReportingEventsRollupBackfill class with
focused methods.
2026-02-18 18:22:18 +05:30
Shivam Mishra b56765d9ba style: split migration into smaller methods to reduce method length 2026-02-18 18:22:11 +05:30
Shivam Mishra 63dce7778e style: fix rubocop offenses in timeseries report builders
Remove duplicate branches, multiline block chains, and reduce
method complexity in average and count report builders.
2026-02-18 18:22:03 +05:30
Shivam Mishra 8e74ee6716 style: extract methods from upsert_rollup to reduce method length 2026-02-18 18:21:55 +05:30
Shivam Mishra 325afc5ab1 style: replace case-when with hash lookup in dimension_type_to_rollup 2026-02-18 18:21:49 +05:30
Shivam MishraandGitHub addc179c01 Merge branch 'develop' into feat/reporting-events-rollup 2026-02-18 18:18:52 +05:30
Shivam Mishra 4b9b1ed7c6 fix: DST spec 2026-02-18 18:16:08 +05:30
Shivam Mishra 4992cb379a refactor: remove redundant scan 2026-02-18 18:12:32 +05:30
Shivam Mishra 98035d5f62 style: rspec name 2026-02-18 18:12:16 +05:30
Shivam Mishra 164b916670 style: rubocop issues in rake task 2026-02-18 18:10:46 +05:30
Shivam Mishra 308accab6d style: rubocop issues 2026-02-18 16:13:13 +05:30
Shivam Mishra 254271a37a style: rubocop autofix changes 2026-02-18 16:05:57 +05:30
Shivam MishraandGitHub b339bf128a Merge branch 'develop' into feat/reporting-events-rollup 2026-02-17 14:47:25 +05:30
Shivam Mishra 0313cd87c2 fix: emit zero-value periods in rollup count timeseries
The rollup path only emitted periods with data, while the raw path
uses group_by_period with default_value: 0 to include inactive periods.
This also reads group_by from params instead of calling a method that
summary builders don't implement.
2026-02-13 15:21:26 +05:30
Shivam Mishra eb08245b3d fix: pass epoch timestamps to comparison builders
The comparison rake task was passing YYYY-MM-DD strings as since/until,
but DateRangeHelper parses those fields as Unix seconds via
DateTime.strptime(value, '%s'), causing validation to run against
the wrong time window.
2026-02-13 13:24:45 +05:30
Shivam Mishra d5d9d3ad7d fix: exclude until boundary from rollup date range
DateRangeHelper#range builds an exclusive range (since...until), but
rollup_date_range was converting range.last directly to a date, causing
an extra day to be included when until falls at midnight.
2026-02-13 13:22:00 +05:30
Shivam Mishra 17f5112015 fix: skip per-metric rollup check in summary builders
Summary builders fetch all metrics in a single query and don't use
params[:metric], so metric_covered? was always returning false and
preventing rollup usage for summary reports.
2026-02-13 12:44:03 +05:30
Shivam Mishra 649f58a57a chore: update rake task to better find correct IANA timzone 2026-02-13 12:39:05 +05:30
Shivam Mishra 69a75e538e feat: add some implementation notes 2026-02-13 12:38:45 +05:30
Shivam Mishra 4f124f64d4 chore: remove use_rollup override 2026-02-13 12:38:35 +05:30
Shivam MishraandGitHub f24b931527 Merge branch 'develop' into feat/reporting-events-rollup 2026-02-12 12:14:01 +05:30
Shivam Mishra b6a84af227 fix: Address three PR review bugs in rollup system
Fix metric_covered? NoMethodError when metric param is nil - summary
builders don't pass a metric param, so add nil guard before calling
to_sym.

Fix business_hours not respected in rollup paths - rollup queries were
always using sum_value instead of switching to sum_value_business_hours
when business_hours param is true. Add rollup_value_column helper to
RollupConditions and use it across all rollup read paths.

Fix date range timezone mismatch in rollup queries - rollup date column
stores account-local dates but queries filtered with UTC DateTime
boundaries from DateRangeHelper. Add rollup_date_range helper that
converts UTC range to account-local dates and use it across all rollup
query paths.
2026-02-12 12:12:07 +05:30
Shivam MishraandGitHub 2380218b05 Merge branch 'develop' into feat/reporting-events-rollup 2026-02-11 22:55:11 +05:30
Shivam Mishra af59acd6a9 docs: Add TODO to remove use_rollup override before merge
Add TODO comment noting that the use_rollup parameter override is only
for validation purposes via the comparison rake task and should be
removed before merging to production.
2026-02-11 22:52:12 +05:30
Shivam Mishra f29f9517d8 refactor: Extend comparison period from 6 months to 1 year
Increase comparison period to 1 year for more comprehensive validation.
Will now compare ~365 daily summaries instead of ~180 days.
2026-02-11 22:47:21 +05:30
Shivam Mishra ab013b9e2b refactor: Change comparison from weekly to daily granularity
Update comparison rake task to compare daily summaries instead of weekly.
Provides more granular comparison (180+ days vs 26 weeks for 6 months) and
better detection of specific date discrepancies. Progress now shows day-by-day
comparison with date in output.
2026-02-11 22:45:11 +05:30
Shivam Mishra 9a17ae6a76 refactor: Add use_rollup parameter to base summary builder
Add explicit use_rollup parameter to BaseSummaryBuilder that can override
the automatic rollup detection logic. This allows the comparison rake task
to explicitly control whether to use rollup or raw data without toggling
feature flags. Cleaner approach that avoids database writes during comparison.
2026-02-11 22:43:12 +05:30
Shivam Mishra d0713408a9 feat: Add comparison rake task to validate rollup accuracy
Add interactive rake task that compares rollup data vs raw events data for
summary reports. Generates weekly summaries for past 6 months across agent,
inbox, and team dimensions. Compares metrics (conversations count, resolution
time, first response time, reply time) and reports any mismatches. Useful
for validating rollup data accuracy after backfill.
2026-02-11 22:39:10 +05:30
Shivam Mishra dcfb1599c8 feat: Add interactive backfill rake task for reporting rollups
Add BackfillService and rake task to backfill historical reporting events
into the rollup table. Service uses delete-first idempotency strategy with
hybrid SQL+Ruby aggregation. Rake task provides interactive prompts for
account ID, timezone, date range override, dry run, and confirmation before
proceeding. Includes live progress tracking and fail-fast error handling.
2026-02-11 22:28:19 +05:30
Shivam Mishra de0d6ca8a5 test: add unit tests for RollupConditions concern 2026-02-11 21:56:03 +05:30
Shivam Mishra a89cdd57cf test: add unit tests for RollupService 2026-02-11 21:55:57 +05:30
Shivam Mishra 67abd78568 test: add unit tests for ReportingEventsRollup model 2026-02-11 21:55:51 +05:30
Shivam Mishra d28f11e119 test: add factory for ReportingEventsRollup 2026-02-11 21:55:44 +05:30
Shivam Mishra d080407a66 refactor: update report builders to use ReportingEventsRollup model 2026-02-11 21:55:35 +05:30
Shivam Mishra 2234a6707c fix: resolve ambiguous column references in rollup upsert SQL 2026-02-11 21:55:29 +05:30
Shivam Mishra be540a08ec fix: add concurrent index creation to reporting events rollup migration 2026-02-11 21:55:21 +05:30
Shivam Mishra 4f53d32924 feat: Add reporting events rollup feature flag 2026-02-11 21:28:51 +05:30
Shivam Mishra 078d880a0e feat: Add reporting timezone to account settings 2026-02-11 21:28:29 +05:30
Shivam Mishra 46ab6c3946 feat: Add rollup read path to report builders 2026-02-11 21:28:17 +05:30
Shivam Mishra 0e35067bc8 feat: Add rollup conditions concern for report builders 2026-02-11 21:28:11 +05:30
Shivam Mishra 8474295a5d feat: Add rollup service for writing aggregated metrics 2026-02-11 21:28:07 +05:30
Shivam Mishra 1fa461327a feat: Add reporting events rollup database migration and model 2026-02-11 21:24:47 +05:30
2122 changed files with 52572 additions and 130164 deletions
-65
View File
@@ -1,65 +0,0 @@
---
:position: before
:position_in_additional_file_patterns: before
:position_in_class: before
:position_in_factory: before
:position_in_fixture: before
:position_in_routes: before
:position_in_serializer: before
:position_in_test: before
:classified_sort: true
:exclude_controllers: true
:exclude_factories: true
:exclude_fixtures: true
:exclude_helpers: true
:exclude_scaffolds: true
:exclude_serializers: true
:exclude_sti_subclasses: false
:exclude_tests: true
:force: false
:format_markdown: false
:format_rdoc: false
:format_yard: false
:frozen: false
:grouped_polymorphic: false
:ignore_model_sub_dir: false
:ignore_unknown_models: false
:include_version: false
:show_check_constraints: false
:show_complete_foreign_keys: false
:show_foreign_keys: true
:show_indexes: true
:show_indexes_include: false
:simple_indexes: false
:sort: false
:timestamp: false
:trace: false
:with_comment: true
:with_column_comments: true
:with_table_comments: true
:position_of_column_comment: :with_name
:active_admin: false
:command:
:debug: false
:hide_default_column_types: json,jsonb,hstore
:hide_limit_column_types: integer,bigint,boolean
:timestamp_columns:
- created_at
- updated_at
:ignore_columns:
:ignore_routes:
:models: true
:routes: false
:skip_on_db_migrate: false
:target_action: :do_annotations
:wrapper:
:wrapper_close:
:wrapper_open:
:classes_default_to_s: []
:additional_file_patterns: []
:model_dir:
- app/models
- enterprise/app/models
:require: []
:root_dir:
- ''
-6
View File
@@ -1,9 +1,3 @@
---
ignore:
- 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,8 +40,6 @@ 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 -4
View File
@@ -166,7 +166,7 @@ GEM
multi_json (~> 1)
statsd-ruby (~> 1.1)
base64 (0.3.0)
bcrypt (3.1.22)
bcrypt (3.1.20)
benchmark (0.4.1)
bigdecimal (3.2.2)
bindex (0.8.1)
@@ -465,7 +465,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.2)
json (2.18.1)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -942,7 +942,6 @@ 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)
@@ -1159,7 +1158,6 @@ DEPENDENCIES
spring
spring-watcher-listen
squasher
ssrf_filter (~> 1.5)
stackprof
stripe (~> 18.0)
telephone_number
+1 -1
View File
@@ -1 +1 @@
4.13.0
4.11.2
+1 -1
View File
@@ -104,7 +104,7 @@ class ContactIdentifyAction
# blank identifier or email will throw unique index error
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save! if @contact.changed?
@contact.save!
enqueue_avatar_job
end
+5 -3
View File
@@ -1,6 +1,4 @@
class Email::BaseBuilder
include EmailAddressParseable
pattr_initialize [:inbox!]
private
@@ -41,7 +39,7 @@ class Email::BaseBuilder
end
def business_name
inbox.sanitized_business_name
inbox.business_name || inbox.sanitized_name
end
def account_support_email
@@ -49,4 +47,8 @@ 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
@@ -11,6 +11,10 @@ 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)
+80 -28
View File
@@ -1,5 +1,6 @@
class V2::Reports::BaseSummaryBuilder
include DateRangeHelper
include V2::Reports::Concerns::RollupConditions
def build
load_data
@@ -8,14 +9,84 @@ class V2::Reports::BaseSummaryBuilder
private
def load_data
results = data_source.summary
# Summary builders fetch all metrics in a single query, so the per-metric
# check from RollupConditions does not apply.
def metric_covered?
true
end
@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] }
def load_data
@conversations_count = fetch_conversations_count
use_rollup? ? load_rollup_data : load_reporting_events_data
end
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
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
def load_rollup_data
dimension_type = dimension_type_to_rollup
value_col = rollup_value_column
# Fetch rollup data grouped by dimension_id
rollup_rows = ReportingEventsRollup.where(
account_id: account.id,
dimension_type: dimension_type,
date: rollup_date_range
).group(:dimension_id).select(
'dimension_id',
'SUM(CASE WHEN metric = \'resolutions_count\' THEN count ELSE 0 END) as resolved_count',
"SUM(CASE WHEN metric = 'resolution_time' THEN count ELSE 0 END) as resolution_count",
"SUM(CASE WHEN metric = 'resolution_time' THEN #{value_col} ELSE 0 END) as resolution_sum_value",
"SUM(CASE WHEN metric = 'first_response' THEN count ELSE 0 END) as first_response_count",
"SUM(CASE WHEN metric = 'first_response' THEN #{value_col} ELSE 0 END) as first_response_sum_value",
"SUM(CASE WHEN metric = 'reply_time' THEN count ELSE 0 END) as reply_count",
"SUM(CASE WHEN metric = 'reply_time' THEN #{value_col} ELSE 0 END) as reply_sum_value"
)
# Convert to the expected format
results = {}
rollup_rows.each do |row|
results[row.dimension_id] = row
end
@resolved_count = results.transform_values { |r| r.resolved_count.to_i }
@avg_resolution_time = results.transform_values do |r|
r.resolution_count.to_i.zero? ? nil : (r.resolution_sum_value.to_f / r.resolution_count.to_i)
end
@avg_first_response_time = results.transform_values do |r|
r.first_response_count.to_i.zero? ? nil : (r.first_response_sum_value.to_f / r.first_response_count.to_i)
end
@avg_reply_time = results.transform_values do |r|
r.reply_count.to_i.zero? ? nil : (r.reply_sum_value.to_f / r.reply_count.to_i)
end
end
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range)
end
def fetch_conversations_count
# Override this method
end
def group_by_key
@@ -26,26 +97,7 @@ class V2::Reports::BaseSummaryBuilder
# Override this method
end
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)
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
end
end
@@ -0,0 +1,75 @@
module V2::Reports::Concerns::RollupConditions
COVERED_METRICS = {
avg_first_response_time: :first_response,
avg_resolution_time: :resolution_time,
reply_time: :reply_time,
resolutions_count: :resolutions_count,
bot_resolutions_count: :bot_resolutions_count,
bot_handoffs_count: :bot_handoffs_count
}.freeze
SUPPORTED_DIMENSIONS = %w[account agent inbox].freeze
def use_rollup?
# Condition 0: reporting_timezone must be configured on account
return false if account.reporting_timezone.blank?
# Condition 1: Feature flag must be enabled
return false unless account.feature_enabled?('reporting_events_rollup')
# Condition 2: Metric must be covered by rollup
return false unless metric_covered?
# Condition 3: Not hourly granularity
return false if params[:group_by] == 'hour'
# Condition 4: Dimension must be supported
return false unless dimension_supported?
# Condition 5: Timezone must match
timezone_matches_account?
end
private
def metric_covered?
return false if params[:metric].blank?
COVERED_METRICS.key?(params[:metric].to_sym)
end
def dimension_supported?
return true if params[:type].blank? # account dimension (no specific ID)
SUPPORTED_DIMENSIONS.include?(params[:type].to_s)
end
def timezone_matches_account?
return false if params[:timezone_offset].blank?
offset_in_seconds = params[:timezone_offset].to_f * 3600
account_zone = ActiveSupport::TimeZone[account.reporting_timezone]
account_zone&.now&.utc_offset == offset_in_seconds
end
def metric_to_rollup_metric(metric)
COVERED_METRICS[metric.to_sym]
end
def rollup_value_column
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :sum_value_business_hours : :sum_value
end
def rollup_date_range
tz = ActiveSupport::TimeZone[account.reporting_timezone]
start_date = range.first.in_time_zone(tz).to_date
# range is exclusive (since...until), so subtract a second to avoid
# including the until boundary day when it falls exactly at midnight.
end_date = (range.last - 1.second).in_time_zone(tz).to_date
start_date..end_date
end
def dimension_type_to_rollup
{ 'account' => :account, 'agent' => :agent, 'inbox' => :inbox }[params[:type].to_s]
end
end
@@ -3,10 +3,23 @@ class V2::Reports::Conversations::BaseReportBuilder
private
def builder_class(metric)
return unless Reports::ReportMetricRegistry.supported?(metric)
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
V2::Reports::Timeseries::ReportBuilder
def builder_class(metric)
case metric
when *AVG_METRICS
V2::Reports::Timeseries::AverageReportBuilder
when *COUNT_METRICS
V2::Reports::Timeseries::CountReportBuilder
end
end
def log_invalid_metric
@@ -11,6 +11,10 @@ class V2::Reports::InboxSummaryBuilder < 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(group_by_key).count
end
def prepare_report
account.inboxes.map do |inbox|
build_inbox_stats(inbox)
@@ -31,4 +35,8 @@ 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,6 +6,14 @@ 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)
@@ -0,0 +1,145 @@
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
use_rollup? ? rollup_timeseries : raw_timeseries
end
def aggregate_value
use_rollup? ? rollup_aggregate_value : raw_aggregate_value
end
private
def raw_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 rollup_timeseries
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
metric = metric_to_rollup_metric(params[:metric])
rollup_rows = ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
)
group_and_aggregate_rollup(rollup_rows)
end
def raw_aggregate_value
object_scope.average(average_value_key)
end
def rollup_aggregate_value
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
metric = metric_to_rollup_metric(params[:metric])
value_col = rollup_value_column
result = ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
).pick(Arel.sql("SUM(count), SUM(#{value_col})"))
return nil if result.blank? || result[0].to_i.zero?
result[1].to_f / result[0].to_i
end
def dimension_id_for_rollup
params[:type].to_s == 'account' ? account.id : scope.id
end
def group_and_aggregate_rollup(rollup_rows)
grouped_data = aggregate_rollup_rows(rollup_rows, all_periods_in_range.index_with { { count: 0, sum_value: 0.0 } })
results = grouped_data.map do |date_key, data|
value = data[:count].zero? ? 0 : data[:sum_value] / data[:count]
{ value: value, timestamp: date_key.in_time_zone(timezone).to_i, count: data[:count] }
end
results.sort_by { |h| h[:timestamp] }
end
def aggregate_rollup_rows(rollup_rows, grouped_data = {})
value_col = rollup_value_column
rollup_rows.each_with_object(grouped_data) do |row, all_grouped_data|
date_key = date_key_for_group(row.date)
all_grouped_data[date_key] ||= { count: 0, sum_value: 0.0 }
all_grouped_data[date_key][:count] += row.count
all_grouped_data[date_key][:sum_value] += row.public_send(value_col)
end
end
def date_key_for_group(date)
case group_by
when 'week' then date.beginning_of_week(:sunday)
when 'month' then date.beginning_of_month
when 'year' then date.beginning_of_year
else date
end
end
def all_periods_in_range
date_range = rollup_date_range
dates = []
current = date_key_for_group(date_range.first)
while current <= date_range.last
dates << current
current = advance_period(current)
end
dates
end
def advance_period(date)
case group_by
when 'week' then date + 1.week
when 'month' then date + 1.month
when 'year' then date + 1.year
else date + 1.day
end
end
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,13 +1,14 @@
class V2::Reports::Timeseries::BaseTimeseriesBuilder
include TimezoneHelper
include DateRangeHelper
include V2::Reports::Concerns::RollupConditions
DEFAULT_GROUP_BY = 'day'.freeze
pattr_initialize :account, :params
def scope
case dimension_type.to_sym
case params[:type].to_sym
when :account
account
when :inbox
@@ -21,20 +22,6 @@ 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
@@ -58,10 +45,4 @@ 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
@@ -0,0 +1,169 @@
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
use_rollup? ? rollup_timeseries : raw_timeseries
end
def aggregate_value
use_rollup? ? rollup_aggregate_value : raw_aggregate_value
end
private
def raw_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 rollup_timeseries
metric = metric_to_rollup_metric(params[:metric])
return [] if metric.blank?
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
rollup_rows = ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
)
group_and_aggregate_rollup_counts(rollup_rows)
end
def raw_aggregate_value
object_scope.count
end
def rollup_aggregate_value
metric = metric_to_rollup_metric(params[:metric])
return 0 if metric.blank?
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
).sum(:count).to_i
end
def dimension_id_for_rollup
params[:type].to_s == 'account' ? account.id : scope.id
end
def group_and_aggregate_rollup_counts(rollup_rows)
# Pre-fill all periods with zero to match raw path's default_value: 0 behavior
grouped_data = all_periods_in_range.index_with { 0 }
rollup_rows.each do |row|
date_key = date_key_for_period(row.date)
grouped_data[date_key] ||= 0
grouped_data[date_key] += row.count
end
results = grouped_data.map do |date_key, count|
{ value: count, timestamp: date_key.in_time_zone(timezone).to_i }
end
results.sort_by { |h| h[:timestamp] }
end
def date_key_for_period(date)
case group_by
when 'week' then date.beginning_of_week(:sunday)
when 'month' then date.beginning_of_month
when 'year' then date.beginning_of_year
else date
end
end
def all_periods_in_range
date_range = rollup_date_range
dates = []
current = date_key_for_period(date_range.first)
while current <= date_range.last
dates << current
current = advance_period(current)
end
dates
end
def advance_period(date)
case group_by
when 'week' then date + 1.week
when 'month' then date + 1.month
when 'year' then date + 1.year
else date + 1.day
end
end
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
@@ -1,9 +0,0 @@
class V2::Reports::Timeseries::ReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
data_source.timeseries
end
def aggregate_value
data_source.aggregate
end
end
@@ -34,10 +34,6 @@ 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
@@ -1,55 +0,0 @@
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
extend ActiveSupport::Concern
included do
skip_before_action :check_authorization, only: [:health, :register_webhook]
before_action :check_admin_authorization?, only: [:register_webhook]
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def register_webhook
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
render json: { message: 'Webhook registered successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end
@@ -116,8 +116,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
# 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?
@@ -4,9 +4,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
before_action :check_authorization, except: [:show, :health]
before_action :validate_whatsapp_cloud_channel, only: [:health]
def index
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
@@ -66,17 +65,28 @@ 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') }
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def fetch_inbox
@@ -88,6 +98,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def create_channel
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
@@ -184,6 +200,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def get_channel_attributes(channel_type)
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
@@ -126,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
return unless @hook&.access_token
begin
linear_client = Linear.new(@hook.access_token, refresh_token: @hook.settings&.[]('refresh_token'))
linear_client = Linear.new(@hook.access_token)
linear_client.revoke_token
rescue StandardError => e
Rails.logger.error "Failed to revoke Linear token: #{e.message}"
@@ -79,7 +79,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
)
end
@@ -5,7 +5,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
elsif params[:external_url].present?
create_from_url
else
render_error(I18n.t('errors.upload.missing_input'), :unprocessable_entity)
render_error('No file or URL provided', :unprocessable_entity)
end
render_success(result) if result.is_a?(ActiveStorage::Blob)
@@ -19,21 +19,35 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
end
def create_from_url
SafeFetch.fetch(params[:external_url].to_s) do |result|
create_and_save_blob(result.tempfile, result.filename, result.content_type)
uri = parse_uri(params[:external_url])
return if performed?
fetch_and_process_file_from_uri(uri)
end
def parse_uri(url)
uri = URI.parse(url)
validate_uri(uri)
uri
rescue URI::InvalidURIError, SocketError
render_error('Invalid URL provided', :unprocessable_entity)
nil
end
def validate_uri(uri)
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
end
def fetch_and_process_file_from_uri(uri)
uri.open do |file|
create_and_save_blob(file, File.basename(uri.path), file.content_type)
end
rescue 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 OpenURI::HTTPError => e
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
rescue SocketError
render_error('Invalid URL provided', :unprocessable_entity)
rescue StandardError
render_error(I18n.t('errors.upload.unexpected'), :internal_server_error)
render_error('An unexpected error occurred', :internal_server_error)
end
def create_and_save_blob(io, filename, content_type)
+2 -32
View File
@@ -30,20 +30,9 @@ class Api::V1::AccountsController < Api::BaseController
locale: account_params[:locale],
user: current_user
).perform
enqueue_branding_enrichment
if @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
send_auth_headers(@user)
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
else
render_error_response(CustomExceptions::Account::SignupFailed.new({}))
end
@@ -70,16 +59,6 @@ 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
@@ -124,15 +103,6 @@ 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
@@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
contact = @contact
end
@contact_inbox.update(hmac_verified: true) if should_verify_hmac?
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
identify_contact(contact)
end
@@ -43,15 +43,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end
def set_conversation
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?
@conversation = create_conversation if conversation.nil?
end
def message_finder_params
@@ -72,14 +64,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def permitted_params
# timestamp parameter is used in create conversation method
# custom_attributes and labels are applied when a new conversation is created alongside the first message
params.permit(
:id, :before, :after, :website_token,
contact: [:name, :email],
message: [:content, :referer_url, :timestamp, :echo_id, :reply_to],
custom_attributes: {},
labels: []
)
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to])
end
def set_message
@@ -36,6 +36,7 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
until: permitted_params[:until],
business_hours: ActiveModel::Type::Boolean.new.cast(permitted_params[:business_hours])
}
@builder_params[:timezone_offset] = permitted_params[:timezone_offset] if permitted_params[:timezone_offset].present?
end
def render_report_with(builder_class, type: nil)
@@ -45,7 +46,7 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
end
def permitted_params
params.permit(:since, :until, :business_hours)
params.permit(:since, :until, :business_hours, :timezone_offset)
end
def date_range_too_long?
@@ -1,18 +0,0 @@
# 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
@@ -1,90 +0,0 @@
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])
if channel.present?
return channel if params[:AccountSid].blank? || channel.account_sid == params[:AccountSid]
return nil
end
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,12 +10,7 @@ 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
@@ -25,10 +20,7 @@ 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
@@ -45,7 +37,6 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
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}"
@@ -90,15 +81,6 @@ 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
+12 -39
View File
@@ -2,8 +2,6 @@ class Linear::CallbacksController < ApplicationController
include Linear::IntegrationHelper
def show
return redirect_to(safe_linear_redirect_uri) if params[:code].blank? || account_id.blank?
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: "#{base_url}/linear/callback"
@@ -12,7 +10,7 @@ class Linear::CallbacksController < ApplicationController
handle_response
rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}")
redirect_to safe_linear_redirect_uri
redirect_to linear_redirect_uri
end
private
@@ -33,19 +31,22 @@ class Linear::CallbacksController < ApplicationController
end
def handle_response
raise ArgumentError, 'Missing access token in Linear OAuth response' if parsed_body['access_token'].blank?
hook = account.hooks.find_or_initialize_by(app_id: 'linear')
hook.assign_attributes(
hook = account.hooks.new(
access_token: parsed_body['access_token'],
status: 'enabled',
settings: merged_integration_settings(hook.settings)
app_id: 'linear',
settings: {
token_type: parsed_body['token_type'],
expires_in: parsed_body['expires_in'],
scope: parsed_body['scope']
}
)
# You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251
hook.save!
redirect_to linear_redirect_uri
rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}")
redirect_to safe_linear_redirect_uri
redirect_to linear_redirect_uri
end
def account
@@ -53,47 +54,19 @@ class Linear::CallbacksController < ApplicationController
end
def account_id
return @account_id if instance_variable_defined?(:@account_id)
return unless params[:state]
@account_id = params[:state].present? ? verify_linear_token(params[:state]) : nil
verify_linear_token(params[:state])
end
def linear_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear"
end
def safe_linear_redirect_uri
return base_url if account_id.blank?
linear_redirect_uri
rescue StandardError
base_url
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
def integration_settings
{
token_type: parsed_body['token_type'],
expires_in: parsed_body['expires_in'],
expires_on: expires_on,
scope: parsed_body['scope'],
refresh_token: parsed_body['refresh_token']
}.compact
end
def merged_integration_settings(existing_settings)
existing_settings.to_h.with_indifferent_access.merge(integration_settings)
end
def expires_on
return if parsed_body['expires_in'].blank?
(Time.current.utc + parsed_body['expires_in'].to_i.seconds).to_s
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
@@ -1,101 +0,0 @@
class Platform::Api::V1::EmailChannelMigrationsController < PlatformController
before_action :set_account
before_action :validate_account_permissible
before_action :validate_feature_flag
before_action :validate_params
def create
results = migrate_email_channels
render json: { results: results }, status: :ok
end
private
def set_account
@account = Account.find(params[:account_id])
end
def validate_account_permissible
return if @platform_app.platform_app_permissibles.find_by(permissible: @account)
render json: { error: 'Non permissible resource' }, status: :unauthorized
end
def validate_feature_flag
return if ActiveModel::Type::Boolean.new.cast(ENV.fetch('EMAIL_CHANNEL_MIGRATION', false))
render json: { error: 'Email channel migration is not enabled' }, status: :forbidden
end
def validate_params
return render json: { error: 'Missing migrations parameter' }, status: :unprocessable_entity if migration_params.blank?
return unless migration_params.size > MAX_MIGRATIONS
return render json: { error: "Too many migrations (max #{MAX_MIGRATIONS})" },
status: :unprocessable_entity
end
def migrate_email_channels
migration_params.map { |entry| migrate_single(entry) }
end
MAX_MIGRATIONS = 25
SUPPORTED_PROVIDERS = %w[google microsoft].freeze
def migrate_single(entry)
validate_provider!(entry[:provider])
ActiveRecord::Base.transaction do
channel = create_channel(entry)
inbox = create_inbox(channel, entry)
{ email: entry[:email], inbox_id: inbox.id, channel_id: channel.id, status: 'success' }
end
rescue StandardError => e
{ email: entry[:email], status: 'error', message: e.message }
end
def create_channel(entry)
Channel::Email.create!(
account_id: @account.id,
email: entry[:email],
provider: entry[:provider],
provider_config: entry[:provider_config]&.to_h,
imap_enabled: entry.fetch(:imap_enabled, true),
imap_address: entry[:imap_address] || default_imap_address(entry[:provider]),
imap_port: entry[:imap_port] || 993,
imap_login: entry[:imap_login] || entry[:email],
imap_enable_ssl: entry.fetch(:imap_enable_ssl, true)
)
end
def create_inbox(channel, entry)
@account.inboxes.create!(
name: entry[:inbox_name] || "Migrated #{entry[:provider]&.capitalize}: #{entry[:email]}",
channel: channel
)
end
def validate_provider!(provider)
return if SUPPORTED_PROVIDERS.include?(provider)
raise ArgumentError, "Unsupported provider '#{provider}'. Must be one of: #{SUPPORTED_PROVIDERS.join(', ')}"
end
def default_imap_address(provider)
case provider
when 'google' then 'imap.gmail.com'
when 'microsoft' then 'outlook.office365.com'
else ''
end
end
def migration_params
params.permit(migrations: [
:email, :provider, :inbox_name,
:imap_enabled, :imap_address, :imap_port, :imap_login, :imap_enable_ssl,
{ provider_config: {} }
])[:migrations]
end
end
@@ -1,13 +1,11 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
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'
def index
@search_query = list_params[:query]
@articles = @portal.articles.published.includes(:category, :author)
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
@@ -62,7 +60,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.to_s)
@parsed_content = render_article_content(@article.content)
end
def set_category
@@ -75,9 +73,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end
def list_params
@list_params ||= params.permit(:query, :locale, :sort, :status, :page, :per_page).tap do |permitted|
permitted[:query] = permitted[:query].to_s.strip.presence
end
params.permit(:query, :locale, :sort, :status, :page, :per_page)
end
def permitted_params
@@ -1,7 +1,6 @@
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,8 +1,7 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show]
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
before_action :ensure_portal_feature_enabled
before_action :redirect_to_portal_with_locale, only: [:show]
layout 'portal'
def show
@@ -25,7 +24,6 @@ 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,11 +18,4 @@ class PublicController < ActionController::Base
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
}, 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,68 +0,0 @@
class SuperAdmin::PushDiagnosticsController < SuperAdmin::ApplicationController
def show
@query = params[:user_query].to_s.strip
@user = resolve_user(@query)
@subscriptions = @user ? @user.notification_subscriptions.order(:id) : []
@results = []
end
def create
@user = User.find_by(id: params[:user_id])
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if @user.nil?
ids = parsed_subscription_ids
if ids.empty?
return redirect_to super_admin_push_diagnostics_path(user_query: @user.id),
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_test')
end
run_test_and_render(ids)
end
def destroy_subscriptions
user = User.find_by(id: params[:user_id])
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if user.nil?
ids = parsed_subscription_ids
if ids.empty?
return redirect_to super_admin_push_diagnostics_path(user_query: user.id),
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_delete')
end
deleted_count = user.notification_subscriptions.where(id: ids).destroy_all.size
log_super_admin_action("deleted #{deleted_count} subscriptions for user #{user.id}: #{ids}")
redirect_to super_admin_push_diagnostics_path(user_query: user.id),
notice: I18n.t('super_admin.push_diagnostics.subscriptions_deleted', count: deleted_count)
end
private
def run_test_and_render(ids)
@query = @user.id.to_s
@subscriptions = @user.notification_subscriptions.order(:id)
@results = Notification::PushTestService.new(
user: @user, subscription_ids: ids,
title: params[:push_title], body: params[:push_body]
).perform
log_super_admin_action("test sent for user #{@user.id} subscriptions #{ids}")
render :show
end
def log_super_admin_action(message)
Rails.logger.info(
"[SuperAdmin] push diagnostics #{message} " \
"(actor_id=#{current_super_admin&.id}, actor_email=#{current_super_admin&.email})"
)
end
def resolve_user(query)
return if query.blank?
query.match?(/\A\d+\z/) ? User.find_by(id: query) : User.from_email(query)
end
def parsed_subscription_ids
Array(params[:subscription_ids]).reject(&:blank?).map(&:to_i)
end
end
@@ -1,6 +1,4 @@
class Twilio::CallbackController < ApplicationController
include TwilioSignatureVerifyConcern
def create
Webhooks::TwilioEventsJob.perform_later(permitted_params.to_unsafe_hash)
@@ -1,6 +1,4 @@
class Twilio::DeliveryStatusController < ApplicationController
include TwilioSignatureVerifyConcern
def create
Webhooks::TwilioDeliveryStatusJob.perform_later(permitted_params.to_unsafe_hash)
@@ -20,8 +18,4 @@ class Twilio::DeliveryStatusController < ApplicationController
:ErrorMessage
)
end
def channel_lookup_phone_numbers
[params[:From]].compact_blank
end
end
+1 -11
View File
@@ -77,23 +77,13 @@ class WidgetsController < ActionController::Base
end
def allow_iframe_requests
if @web_widget.allowed_domains.blank? || embedded_from_non_web_origin?
if @web_widget.allowed_domains.blank?
response.headers.delete('X-Frame-Options')
else
domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ')
response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}"
end
end
# Mobile WebViews (iOS/Android) load content from file:// or null origins,
# which cannot match any domain in frame-ancestors. When the per-inbox flag
# is enabled, skip frame-ancestors for these requests.
def embedded_from_non_web_origin?
return false unless @web_widget.allow_mobile_webview?
origin = request.headers['Origin']
origin.blank? || origin == 'null' || origin&.start_with?('file://')
end
end
WidgetsController.prepend_mod_with('WidgetsController')
-6
View File
@@ -1,10 +1,4 @@
module TimezoneHelper
def timezone_name_from_params(timezone, offset)
return timezone if timezone.present? && ActiveSupport::TimeZone[timezone].present?
timezone_name_from_offset(offset)
end
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
# would return the timezone without considering day light savings. To get the correct timezone,
# this method uses zone.now.utc_offset for comparison as referenced in the issues below
+1 -3
View File
@@ -98,9 +98,7 @@ export default {
mql.onchange = e => setColorTheme(e.matches);
},
setLocale(locale) {
if (locale) {
this.$root.$i18n.locale = locale;
}
this.$root.$i18n.locale = locale;
},
async initializeAccount() {
await this.$store.dispatch('accounts/get');
@@ -25,10 +25,6 @@ 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,12 +31,6 @@ 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();
@@ -9,10 +9,6 @@ class InboxHealthAPI extends ApiClient {
getHealthStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/health`);
}
registerWebhook(inboxId) {
return axios.post(`${this.url}/${inboxId}/register_webhook`);
}
}
export default new InboxHealthAPI();
-4
View File
@@ -48,10 +48,6 @@ class Inboxes extends CacheEnabledApiClient {
template,
});
}
resetSecret(inboxId) {
return axios.post(`${this.url}/${inboxId}/reset_secret`);
}
}
export default new Inboxes();
@@ -1,6 +1,8 @@
/* global axios */
import ApiClient from './ApiClient';
const getTimeOffset = () => -new Date().getTimezoneOffset() / 60;
class SummaryReportsAPI extends ApiClient {
constructor() {
super('summary_reports', { accountScoped: true, apiVersion: 'v2' });
@@ -12,6 +14,7 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
},
});
}
@@ -22,6 +25,7 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
},
});
}
@@ -32,6 +36,7 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
},
});
}
@@ -42,6 +47,7 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
},
});
}
@@ -106,10 +106,6 @@ 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: null,
default: 10,
});
// Duration limits: 1 minute to 999 days (in minutes)
const MIN_DURATION_MINUTES = 1;
// Duration limits: 10 minutes to 999 days (in minutes)
const MIN_DURATION_MINUTES = 10;
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 = 0;
const MIN_CONVERSATION_LIMIT = 1;
const MAX_CONVERSATION_LIMIT = 100000;
const selectedInboxIds = computed(
@@ -42,7 +42,6 @@ const availableInboxes = computed(() =>
const isLimitValid = limit => {
return (
Number.isInteger(limit.conversationLimit) &&
limit.conversationLimit >= MIN_CONVERSATION_LIMIT &&
limit.conversationLimit <= MAX_CONVERSATION_LIMIT
);
@@ -107,10 +107,11 @@ const closeMobileSidebar = () => {
size="sm"
/>
<ComposeConversation :contact-id="contactId">
<template #trigger>
<template #trigger="{ toggle }">
<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>
<Button :label="buttonLabel" size="sm" />
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
</div>
@@ -103,7 +103,6 @@ const showPagination = computed(() => {
<ContactsActiveFiltersPreview
v-if="showActiveFiltersPreview"
:active-segment="activeSegment"
class="mb-1"
@clear-filters="emit('clearFilters')"
@open-filter="openFilter"
/>
@@ -1,58 +0,0 @@
<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>
@@ -1,47 +0,0 @@
<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>
@@ -1,175 +0,0 @@
<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,63 +1,207 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
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>
<Icon
v-tooltip.top="{
content: tooltipContent,
delay: { show: 500, hide: 0 },
}"
:icon="iconName"
class="size-4 text-n-slate-5"
/>
<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>
</template>
@@ -1,42 +0,0 @@
<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>
@@ -1,186 +0,0 @@
<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>
@@ -1,156 +0,0 @@
<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>
@@ -1,28 +0,0 @@
<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>
@@ -1,69 +0,0 @@
<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>
@@ -1,19 +0,0 @@
<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 !z-20"
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"
>
<Button
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
@@ -1,82 +0,0 @@
<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>
@@ -26,7 +26,6 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {
<EmptyStateLayout
:title="$t('HELP_CENTER.TITLE')"
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
class="bg-n-surface-1"
>
<template #empty-state-item>
<div class="grid grid-cols-2 gap-4 p-px">
@@ -1,22 +1,8 @@
<script setup>
import LocaleCard from './LocaleCard.vue';
const locales = [
{
name: 'English',
code: 'en',
isDefault: true,
isDraft: false,
articleCount: 29,
categoryCount: 5,
},
{
name: 'Spanish',
code: 'es',
isDefault: false,
isDraft: true,
articleCount: 29,
categoryCount: 5,
},
{ name: 'English', isDefault: true, articleCount: 29, categoryCount: 5 },
{ name: 'Spanish', isDefault: false, articleCount: 29, categoryCount: 5 },
];
</script>
@@ -33,8 +19,6 @@ const locales = [
<LocaleCard
:locale="locale.name"
:is-default="locale.isDefault"
:is-draft="locale.isDraft"
:locale-code="locale.code"
:article-count="locale.articleCount"
:category-count="locale.categoryCount"
/>
@@ -2,7 +2,7 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { buildLocaleMenuItems } from 'dashboard/helper/portalHelper';
import { LOCALE_MENU_ITEMS } from 'dashboard/helper/portalHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -17,10 +17,6 @@ const props = defineProps({
type: Boolean,
required: true,
},
isDraft: {
type: Boolean,
required: true,
},
localeCode: {
type: String,
required: true,
@@ -41,28 +37,11 @@ const { t } = useI18n();
const [showDropdownMenu, toggleDropdown] = useToggle();
const localeLabel = computed(() => `${props.locale} (${props.localeCode})`);
const localeMenuLabels = computed(() => ({
'change-default': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT'
),
'move-to-draft': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MOVE_TO_DRAFT'
),
'publish-locale': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
),
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
}));
const localeMenuItems = computed(() =>
buildLocaleMenuItems({
isDefault: props.isDefault,
isDraft: props.isDraft,
}).map(item => ({
LOCALE_MENU_ITEMS.map(item => ({
...item,
label: localeMenuLabels.value[item.action],
label: t(item.label),
disabled: props.isDefault,
}))
);
@@ -77,7 +56,7 @@ const handleAction = ({ action, value }) => {
<div class="flex justify-between gap-2">
<div class="flex items-center justify-start gap-2">
<span class="text-sm font-medium text-n-slate-12 line-clamp-1">
{{ localeLabel }}
{{ locale }} ({{ localeCode }})
</span>
<span
v-if="isDefault"
@@ -85,12 +64,6 @@ const handleAction = ({ action, value }) => {
>
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
</span>
<span
v-else-if="isDraft"
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-slate-11 px-2 py-0.5"
>
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DRAFT') }}
</span>
</div>
<div class="flex items-center justify-end gap-4">
<div class="flex items-center gap-4">
@@ -113,7 +86,6 @@ const handleAction = ({ action, value }) => {
</span>
</div>
<div
v-if="localeMenuItems.length"
v-on-clickaway="() => toggleDropdown(false)"
class="relative group"
>
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { computed } from 'vue';
import { debounce } from '@chatwoot/utils';
import { useI18n } from 'vue-i18n';
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
@@ -27,50 +27,58 @@ const props = defineProps({
const emit = defineEmits([
'saveArticle',
'saveArticleAsync',
'goBack',
'setAuthor',
'setCategory',
'previewArticle',
'createArticle',
]);
const { t } = useI18n();
const isNewArticle = computed(() => !props.article?.id);
const localTitle = ref(props.article?.title ?? '');
const localContent = ref(props.article?.content ?? '');
const saveAndSync = value => {
emit('saveArticle', value);
};
// 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 ?? '';
}
}
// 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
);
const debouncedSave = debounce(value => emit('saveArticle', value), 500, false);
// 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 handleSave = value => {
if (isNewArticle.value) return;
debouncedSave(value);
if (isNewArticle.value) {
quickSaveNewArticle(value);
} else {
quickSave(value);
saveAndSyncDebounced(value);
}
};
const articleTitle = computed({
get: () => localTitle.value,
get: () => props.article.title,
set: value => {
localTitle.value = value;
handleSave({ title: value });
},
});
const articleContent = computed({
get: () => localContent.value,
get: () => props.article.content,
set: content => {
localContent.value = content;
handleSave({ content });
},
});
@@ -90,14 +98,6 @@ 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,11 +122,10 @@ const handleCreateArticle = event => {
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="values => emit('saveArticle', values)"
@save-article="saveAndSync"
@set-author="setAuthorId"
@set-category="setCategoryId"
/>
@@ -161,12 +160,8 @@ const handleCreateArticle = event => {
}
.editor-root .has-selection {
.ProseMirror-menubar:not(:has(*)) {
display: none !important;
}
.ProseMirror-menubar {
@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;
@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;
display: flex;
top: var(--selection-top, auto) !important;
left: var(--selection-left, 0) !important;
@@ -174,10 +169,15 @@ const handleCreateArticle = event => {
position: absolute !important;
.ProseMirror-menuitem {
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
@apply mr-0;
.ProseMirror-icon {
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
@apply p-0 mt-0 !mr-0;
svg {
width: 20px !important;
height: 20px !important;
}
}
}
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
@@ -24,20 +24,12 @@ const dialogRef = ref(null);
const isUpdating = ref(false);
const selectedLocale = ref('');
const localeStatus = ref('published');
const addedLocales = computed(() => {
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
return allowedLocales.map(locale => locale.code);
});
const draftedLocales = computed(() => {
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
return allowedLocales
.filter(locale => locale.draft)
.map(locale => locale.code);
});
const locales = computed(() => {
return Object.keys(allLocales)
.map(key => {
@@ -49,44 +41,17 @@ const locales = computed(() => {
.filter(locale => !addedLocales.value.includes(locale.value));
});
const statusOptions = computed(() => [
{
value: 'published',
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.LIVE'),
},
{
value: 'draft',
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.DRAFT'),
},
]);
const resetForm = () => {
selectedLocale.value = '';
localeStatus.value = 'published';
};
watch(localeStatus, value => {
if (!value) {
localeStatus.value = 'published';
}
});
const onCreate = async () => {
if (!selectedLocale.value) return;
isUpdating.value = true;
const updatedLocales = [...addedLocales.value, selectedLocale.value];
const updatedDraftLocales =
localeStatus.value === 'draft'
? [...new Set([...draftedLocales.value, selectedLocale.value])]
: draftedLocales.value;
try {
await store.dispatch('portals/update', {
portalSlug: props.portal?.slug,
config: {
allowed_locales: updatedLocales,
draft_locales: updatedDraftLocales,
default_locale: props.portal?.meta?.default_locale,
},
});
@@ -97,7 +62,7 @@ const onCreate = async () => {
from: route.name,
});
resetForm();
selectedLocale.value = '';
dialogRef.value?.close();
useAlert(
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')
@@ -122,7 +87,6 @@ defineExpose({ dialogRef });
type="edit"
:title="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.TITLE')"
:description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')"
@close="resetForm"
@confirm="onCreate"
>
<div class="flex flex-col gap-6">
@@ -134,16 +98,6 @@ defineExpose({ dialogRef });
"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.LABEL') }}
</span>
<ComboBox
v-model="localeStatus"
:options="statusOptions"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
</div>
</Dialog>
</template>
@@ -29,7 +29,6 @@ const isLocaleDefault = code => {
const updatePortalLocales = async ({
newAllowedLocales,
newDraftLocales,
defaultLocale,
messageKey,
}) => {
@@ -40,7 +39,6 @@ const updatePortalLocales = async ({
config: {
default_locale: defaultLocale,
allowed_locales: newAllowedLocales,
draft_locales: newDraftLocales,
},
});
@@ -55,12 +53,8 @@ const updatePortalLocales = async ({
const changeDefaultLocale = ({ localeCode }) => {
const newAllowedLocales = props.locales.map(locale => locale.code);
const newDraftLocales = props.locales
.filter(locale => locale.isDraft)
.map(locale => locale.code);
updatePortalLocales({
newAllowedLocales,
newDraftLocales,
defaultLocale: localeCode,
messageKey: 'CHANGE_DEFAULT_LOCALE',
});
@@ -87,15 +81,11 @@ const deletePortalLocale = async ({ localeCode }) => {
const updatedLocales = props.locales
.filter(locale => locale.code !== localeCode)
.map(locale => locale.code);
const updatedDraftLocales = props.locales
.filter(locale => locale.code !== localeCode && locale.isDraft)
.map(locale => locale.code);
const defaultLocale = props.portal.meta.default_locale;
await updatePortalLocales({
newAllowedLocales: updatedLocales,
newDraftLocales: updatedDraftLocales,
defaultLocale,
messageKey: 'DELETE_LOCALE',
});
@@ -108,46 +98,9 @@ const deletePortalLocale = async ({ localeCode }) => {
});
};
const updateDraftLocales = async ({ localeCode, shouldDraft, messageKey }) => {
const newAllowedLocales = props.locales.map(locale => locale.code);
const currentDraftLocales = props.locales
.filter(locale => locale.isDraft)
.map(locale => locale.code);
const newDraftLocales = shouldDraft
? [...new Set([...currentDraftLocales, localeCode])]
: currentDraftLocales.filter(locale => locale !== localeCode);
await updatePortalLocales({
newAllowedLocales,
newDraftLocales,
defaultLocale: props.portal.meta.default_locale,
messageKey,
});
};
const moveLocaleToDraft = async ({ localeCode }) => {
await updateDraftLocales({
localeCode,
shouldDraft: true,
messageKey: 'DRAFT_LOCALE',
});
};
const publishLocale = async ({ localeCode }) => {
await updateDraftLocales({
localeCode,
shouldDraft: false,
messageKey: 'PUBLISH_LOCALE',
});
};
const handleAction = ({ action }, localeCode) => {
if (action === 'change-default') {
changeDefaultLocale({ localeCode: localeCode });
} else if (action === 'move-to-draft') {
moveLocaleToDraft({ localeCode: localeCode });
} else if (action === 'publish-locale') {
publishLocale({ localeCode: localeCode });
} else if (action === 'delete') {
deletePortalLocale({ localeCode: localeCode });
}
@@ -161,7 +114,6 @@ const handleAction = ({ action }, localeCode) => {
:key="index"
:locale="locale.name"
:is-default="isLocaleDefault(locale.code)"
:is-draft="locale.isDraft"
:locale-code="locale.code"
:article-count="locale.articlesCount || 0"
:category-count="locale.categoriesCount || 0"
@@ -4,49 +4,37 @@ import LocalesPage from './LocalesPage.vue';
const locales = [
{
name: 'English (en-US)',
code: 'en',
isDefault: true,
isDraft: false,
articleCount: 5,
categoryCount: 5,
},
{
name: 'Spanish (es-ES)',
code: 'es',
isDefault: false,
isDraft: true,
articleCount: 20,
categoryCount: 10,
},
{
name: 'English (en-UK)',
code: 'en_GB',
isDefault: false,
isDraft: false,
articleCount: 15,
categoryCount: 7,
},
{
name: 'Malay (ms-MY)',
code: 'ms',
isDefault: false,
isDraft: false,
articleCount: 15,
categoryCount: 7,
},
{
name: 'Malayalam (ml-IN)',
code: 'ml',
isDefault: false,
isDraft: false,
articleCount: 10,
categoryCount: 5,
},
{
name: 'Hindi (hi-IN)',
code: 'hi',
isDefault: false,
isDraft: false,
articleCount: 15,
categoryCount: 7,
},
@@ -2,10 +2,13 @@
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 {
@@ -15,18 +18,22 @@ 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,
},
align: {
type: String,
default: 'end',
isModal: {
type: Boolean,
default: false,
},
});
@@ -35,16 +42,23 @@ const emit = defineEmits(['close']);
const searchContacts = createContactSearcher();
const store = useStore();
const { t } = useI18n();
const { width: windowWidth } = useWindowSize();
const { fetchSignatureFlagFromUISettings } = useUISettings();
const popoverRef = ref(null);
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
const viewInModal = computed(() => props.isModal || isSmallScreen.value);
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: '',
@@ -81,6 +95,14 @@ 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;
@@ -150,7 +172,7 @@ const clearSelectedContact = () => {
};
const closeCompose = () => {
popoverRef.value?.hide();
showComposeNewConversation.value = false;
if (!props.contactId) {
// If contactId is passed as prop
// Then don't allow to remove the selected contact
@@ -158,6 +180,7 @@ const closeCompose = () => {
}
targetInbox.value = null;
resetContacts();
emit('close');
};
const discardCompose = () => {
@@ -190,15 +213,8 @@ const createConversation = async ({ payload, isFromWhatsApp }) => {
}
};
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');
const toggle = () => {
showComposeNewConversation.value = !showComposeNewConversation.value;
};
watch(
@@ -226,22 +242,64 @@ 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>
<Popover
ref="popoverRef"
:align="align"
@show="onPopoverShow"
@hide="onPopoverHide"
<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,
}"
>
<template #default="{ isOpen }">
<slot name="trigger" :is-open="isOpen" />
</template>
<template #content>
<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"
>
<ComposeNewConversationForm
:form-state="formState"
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
@@ -263,6 +321,6 @@ onMounted(() => resetContacts());
@create-conversation="createConversation"
@discard="discardCompose"
/>
</template>
</Popover>
</div>
</div>
</template>
@@ -220,7 +220,7 @@ useEventListener(document, 'paste', onPaste);
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="!top-auto !bottom-full mb-1.5 ltr:left-0 rtl:right-0"
class="top-full mt-1.5 ltr:left-0 rtl:right-0"
:on-click="onClickInsertEmoji"
/>
</div>
@@ -361,7 +361,7 @@ useKeyboardEvents({
<template>
<div
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)]"
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)]"
>
<div class="flex-1 overflow-y-auto divide-y divide-n-strong">
<ContactSelector
@@ -14,10 +14,6 @@ defineProps({
type: Boolean,
default: false,
},
hideToggle: {
type: Boolean,
default: false,
},
});
const modelValue = defineModel({ type: Boolean, default: false });
@@ -32,8 +28,7 @@ const modelValue = defineModel({ type: Boolean, default: false });
<span class="text-heading-3 text-n-slate-12">
{{ header }}
</span>
<div v-if="hideToggle" class="size-2" />
<ToggleSwitch v-else v-model="modelValue" />
<ToggleSwitch v-model="modelValue" />
</div>
<span v-if="description" class="text-body-main text-n-slate-11">
{{ description }}
@@ -80,12 +80,6 @@ const sendMessage = async () => {
isLoading.value = false;
}
};
const handleEnterKey = event => {
if (event.isComposing) return;
event.preventDefault();
sendMessage();
};
</script>
<template>
@@ -119,7 +113,7 @@ const handleEnterKey = event => {
v-model="newMessage"
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
@keydown.enter.exact="handleEnterKey"
@keyup.enter="sendMessage"
/>
<NextButton
ghost
@@ -1,5 +1,5 @@
<script setup>
import { computed, useSlots } from 'vue';
import { computed } from 'vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -30,40 +30,23 @@ const modelValue = defineModel({
});
const selectedCount = computed(() => modelValue.value.size);
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 totalCount = computed(() => props.allItems.length);
const hasSelected = computed(() => selectedCount.value > 0);
const isIndeterminate = computed(
() =>
selectedVisibleCount.value > 0 &&
selectedVisibleCount.value < visibleItemCount.value
() => hasSelected.value && selectedCount.value < totalCount.value
);
const allSelected = computed(
() =>
visibleItemCount.value > 0 &&
selectedVisibleCount.value === visibleItemCount.value
() => totalCount.value > 0 && selectedCount.value === totalCount.value
);
const slots = useSlots();
const hasSecondaryActions = computed(() => Boolean(slots['secondary-actions']));
const bulkCheckboxState = computed({
get: () => allSelected.value,
set: shouldSelectAll => {
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;
const newSelectedIds = shouldSelectAll
? new Set(props.allItems.map(item => item.id))
: new Set();
modelValue.value = newSelectedIds;
},
});
</script>
@@ -80,7 +63,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 min-w-0">
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5 min-w-0">
<Checkbox
v-model="bulkCheckboxState"
@@ -95,23 +78,21 @@ 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 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>
<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 v-else class="flex items-center gap-3">
@@ -12,7 +12,6 @@ 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: {
@@ -35,34 +34,14 @@ 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', 'select', 'hover']);
const emit = defineEmits(['action']);
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 = [
@@ -100,23 +79,12 @@ const handleAction = ({ action, value }) => {
</script>
<template>
<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>
<CardLayout>
<div class="flex gap-1 justify-between w-full">
<span class="text-base text-n-slate-12 line-clamp-1">
{{ name }}
</span>
<div v-if="showMenu" class="flex gap-2 items-center">
<div class="flex gap-2 items-center">
<div
v-on-clickaway="() => toggleDropdown(false)"
class="flex relative items-center group"
@@ -21,22 +21,16 @@ const emit = defineEmits(['deleteSuccess']);
const { t } = useI18n();
const store = useStore();
const bulkDeleteDialogRef = ref(null);
const i18nKey = computed(() => {
const i18nTypeMap = {
AssistantResponse: 'RESPONSES',
AssistantDocument: 'DOCUMENTS',
};
return i18nTypeMap[props.type];
});
const i18nKey = computed(() => props.type.toUpperCase());
const handleBulkDelete = async ids => {
if (!ids) return;
try {
await store.dispatch('captainBulkActions/handleBulkDelete', {
ids: Array.from(props.bulkIds),
type: props.type,
});
await store.dispatch(
'captainBulkActions/handleBulkDelete',
Array.from(props.bulkIds)
);
emit('deleteSuccess');
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));
@@ -6,13 +6,6 @@ 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');
@@ -38,7 +31,7 @@ const openBilling = () => {
>
<BasePaywallModal
class="mx-auto"
:feature-prefix="featurePrefix"
feature-prefix="CAPTAIN"
:i18n-key="i18nKey"
:is-super-admin="isSuperAdmin"
:is-on-chatwoot-cloud="isOnChatwootCloud"
@@ -27,7 +27,6 @@ const initialState = {
conversationFaqs: false,
memories: false,
citations: false,
contactAttributes: false,
},
};
@@ -60,7 +59,6 @@ const updateStateFromAssistant = assistant => {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
citations: config.feature_citation || false,
contactAttributes: config.feature_contact_attributes || false,
};
};
@@ -81,7 +79,6 @@ const handleBasicInfoUpdate = async () => {
feature_faq: state.features.conversationFaqs,
feature_memory: state.features.memories,
feature_citation: state.features.citations,
feature_contact_attributes: state.features.contactAttributes,
},
};
@@ -141,10 +138,6 @@ watch(
<input v-model="state.features.citations" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
</label>
<label class="flex items-center gap-2">
<input v-model="state.features.contactAttributes" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONTACT_ATTRIBUTES') }}
</label>
</div>
</div>
@@ -101,9 +101,12 @@ const authTypeLabel = computed(() => {
</Policy>
</div>
</div>
<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">
<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"
>
{{ description }}
</span>
<span
@@ -1,10 +1,9 @@
<script setup>
import { reactive, computed, ref, useTemplateRef, watch } from 'vue';
import { reactive, computed, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, maxLength } from '@vuelidate/validators';
import { required } 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';
@@ -73,12 +72,8 @@ 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, maxLength: maxLength(MAX_TOOL_NAME_LENGTH) },
title: { required },
endpoint_url: { required },
http_method: { required },
auth_type: { required },
@@ -108,15 +103,9 @@ const isLoading = computed(() =>
);
const getErrorMessage = (field, errorKey) => {
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`);
return v$.value[field].$error
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
: '';
};
const formErrors = computed(() => ({
@@ -151,30 +140,6 @@ 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>
@@ -283,45 +248,6 @@ const handleTest = 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,11 +1,8 @@
<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');
@@ -13,15 +10,6 @@ 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')"
@@ -29,12 +29,6 @@ const handleInput = () => {
nextTick(adjustHeight);
};
const handleEnterKey = event => {
if (event.isComposing) return;
event.preventDefault();
sendMessage();
};
onMounted(() => {
nextTick(adjustHeight);
});
@@ -49,7 +43,7 @@ onMounted(() => {
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
rows="1"
@input="handleInput"
@keydown.enter.exact="handleEnterKey"
@keydown.enter.exact.prevent="sendMessage"
/>
<button
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
@@ -36,13 +36,7 @@ const props = defineProps({
},
});
const emit = defineEmits([
'enterPress',
'escapePress',
'input',
'blur',
'focus',
]);
const emit = defineEmits(['enterPress', 'input', 'blur', 'focus']);
const modelValue = defineModel({
type: [String, Number],
@@ -55,10 +49,6 @@ const onEnterPress = () => {
emit('enterPress');
};
const onEscapePress = () => {
emit('escapePress');
};
const handleInput = event => {
emit('input', event.target.value);
modelValue.value = event.target.value;
@@ -112,7 +102,6 @@ defineExpose({
@focus="handleFocus"
@blur="handleBlur"
@keydown.enter.prevent="onEnterPress"
@keydown.escape.prevent="onEscapePress"
/>
</div>
</template>
@@ -32,7 +32,6 @@ 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);
@@ -42,10 +41,6 @@ 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);
@@ -58,7 +53,6 @@ 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);
});
@@ -67,9 +67,6 @@ const isSent = computed(() => {
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
}
// API inbox messages use real sent/delivered/read status values from the external system.
if (isAPIInbox.value) return status.value === MESSAGE_STATUS.SENT;
// All messages will be mark as sent for the Line channel, as there is no source ID.
if (isALineChannel.value) return true;
@@ -89,10 +86,8 @@ const isDelivered = computed(() => {
) {
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
}
// API inbox messages use real delivered status from the external system.
if (isAPIInbox.value) return status.value === MESSAGE_STATUS.DELIVERED;
// All messages marked as delivered for the web widget inbox once they are sent.
if (isAWebWidgetInbox.value) {
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
if (isAWebWidgetInbox.value || isAPIInbox.value) {
return status.value === MESSAGE_STATUS.SENT;
}
if (isALineChannel.value) {
@@ -226,10 +226,4 @@ 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>
@@ -1,121 +0,0 @@
<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,6 +10,8 @@ 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';
@@ -182,6 +184,15 @@ 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',
@@ -239,12 +250,6 @@ 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'],
@@ -723,13 +728,7 @@ const menuItems = computed(() => {
<aside
v-on-click-outside="[
closeMobileSidebar,
{
ignore: [
'#mobile-sidebar-launcher',
'[data-popover-content]',
'[data-popover-backdrop]',
],
},
{ ignore: ['#mobile-sidebar-launcher'] },
]"
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="[
@@ -797,8 +796,8 @@ const menuItems = computed(() => {
>
<span class="i-lucide-search size-4 text-n-slate-11" />
</RouterLink>
<ComposeConversation align="start">
<template #trigger="{ isOpen }">
<ComposeConversation align-position="right" @close="onComposeClose">
<template #trigger="{ toggle, isOpen }">
<Button
icon="i-lucide-pen-line"
color="slate"
@@ -810,6 +809,7 @@ 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,11 +158,9 @@ const activeChild = computed(() => {
return rankedPage ?? activeOnPages[0];
}
return navigableChildren.value.find(child => {
if (!child.to) return false;
const childPath = resolvePath(child.to);
return route.path === childPath || route.path.startsWith(`${childPath}/`);
});
return navigableChildren.value.find(
child => child.to && route.path.startsWith(resolvePath(child.to))
);
});
const hasActiveChild = computed(() => {
@@ -1,5 +1,15 @@
<script setup>
import { ref, unref, provide, computed, watch, onMounted } from 'vue';
// [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 { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import {
@@ -7,19 +17,23 @@ 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';
@@ -42,7 +56,6 @@ import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHe
import { conversationListPageURL } from '../helper/URLHelper';
import {
isOnMentionsView,
isOnParticipatingView,
isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers';
import {
@@ -71,6 +84,10 @@ 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);
@@ -82,6 +99,7 @@ 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 => ({
@@ -95,7 +113,6 @@ 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');
@@ -111,6 +128,7 @@ const currentAccountId = useMapGetter('getCurrentAccountId');
const getTeamFn = useMapGetter('teams/getTeam');
const getConversationById = useMapGetter('getConversationById');
useChatListKeyboardEvents(conversationListRef);
const {
selectedConversations,
selectedInboxes,
@@ -278,15 +296,13 @@ const pageTitle = computed(() => {
if (props.label) {
return `#${props.label}`;
}
if (props.conversationType === wootConstants.CONVERSATION_TYPE.MENTION) {
if (props.conversationType === 'mention') {
return t('CHAT_LIST.MENTION_HEADING');
}
if (
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
) {
if (props.conversationType === 'participating') {
return t('CONVERSATION_PARTICIPANTS.SIDEBAR_MENU_TITLE');
}
if (props.conversationType === wootConstants.CONVERSATION_TYPE.UNATTENDED) {
if (props.conversationType === 'unattended') {
return t('CHAT_LIST.UNATTENDED_HEADING');
}
if (hasActiveFolders.value) {
@@ -295,30 +311,12 @@ 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 (
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
) {
localConversationList = filterByAssigneeTab(
participatingChatsList.value(filters)
);
} else if (activeAssigneeTab.value === 'me') {
if (activeAssigneeTab.value === 'me') {
localConversationList = [...mineChatsList.value(filters)];
} else if (activeAssigneeTab.value === 'unassigned') {
localConversationList = [...unAssignedChatsList.value(filters)];
@@ -340,7 +338,7 @@ const conversationList = computed(() => {
});
const showEndOfListMessage = computed(() => {
return !!(
return (
conversationList.value.length &&
hasCurrentPageEndReached.value &&
!chatListLoading.value
@@ -585,6 +583,14 @@ function loadMoreConversations() {
}
}
// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll.
// If the list doesn’t 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();
@@ -631,11 +637,9 @@ function redirectToConversationList() {
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = wootConstants.CONVERSATION_TYPE.MENTION;
} else if (isOnParticipatingView({ route: { name } })) {
conversationType = wootConstants.CONVERSATION_TYPE.PARTICIPATING;
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = wootConstants.CONVERSATION_TYPE.UNATTENDED;
conversationType = 'unattended';
}
router.push(
conversationListPageURL({
@@ -778,6 +782,10 @@ function allSelectedConversationsStatus(status) {
});
}
function onContextMenuToggle(state) {
isContextMenuOpen.value = state;
}
function toggleSelectAll(check) {
selectAllConversations(check, conversationList);
}
@@ -825,6 +833,7 @@ provide('assignTeam', onAssignTeam);
provide('assignLabels', onAssignLabels);
provide('removeLabels', onRemoveLabels);
provide('updateConversationStatus', handleResolveConversation);
provide('toggleContextMenu', onContextMenuToggle);
provide('markAsUnread', markAsUnread);
provide('markAsRead', markAsRead);
provide('assignPriority', assignPriority);
@@ -937,18 +946,43 @@ watch(conversationFilters, (newVal, oldVal) => {
@assign-labels="onAssignLabels"
@assign-team="onAssignTeamsForBulk"
/>
<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"
/>
<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>
<Dialog
ref="deleteConversationDialogRef"
type="alert"
@@ -1,244 +1,74 @@
<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';
<script>
import ConversationCard from './widgets/conversation/ConversationCard.vue';
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();
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,
},
},
};
</script>
<template>
<!-- Expanded layout: wide screen + expanded setting -->
<ConversationCardExpanded
v-if="showExpanded"
:chat="source"
: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
:active-label="label"
:team-id="teamId"
:folders-id="foldersId"
:chat="source"
:current-contact="currentContact"
:assignee="assignee"
:inbox="inbox"
:conversation-type="conversationType"
:selected="isConversationSelected(source.id)"
:is-active-chat="isActiveChat"
:show-assignee="showAssignee"
:show-inbox-name="showInboxName"
@click="onCardClick"
@contextmenu="openContextMenu"
enable-context-menu
@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>
@@ -1,94 +0,0 @@
<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,6 +27,10 @@ const props = defineProps({
type: Boolean,
default: true,
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
@@ -103,8 +107,7 @@ function onKeydown(view, event) {
emit('keydown');
// Handle Enter key to send message (Shift+Enter for new line)
// Skip if IME composition is active (CJK character confirmation)
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
return true; // Prevent ProseMirror's default Enter handling
@@ -204,7 +207,10 @@ onMounted(() => {
<template>
<div class="space-y-2 mb-4">
<div class="overflow-y-auto max-h-56">
<div
class="overflow-y-auto"
:class="{ 'max-h-96': isPopout, 'max-h-56': !isPopout }"
>
<p
v-dompurify-html="formatMessage(generatedContent, false)"
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4"
@@ -313,12 +313,7 @@ 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 &&
!props.disabled
) {
if (props.allowSignature && !props.isPrivate && props.channelType) {
return fetchSignatureFlagFromUISettings(props.channelType);
}
@@ -441,7 +436,6 @@ 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
@@ -460,7 +454,6 @@ function addSignature() {
}
function removeSignature() {
if (props.disabled) return;
if (!props.signature) return;
let content = props.modelValue;
content = removeSignatureHelper(
@@ -813,7 +806,7 @@ watch(
watch(sendWithSignature, newValue => {
// see if the allowSignature flag is true
if (props.allowSignature && !props.disabled) {
if (props.allowSignature) {
toggleSignatureInEditor(newValue);
}
});
@@ -835,8 +828,6 @@ onMounted(() => {
}
});
defineExpose({ focusEditorInputField });
// BUS Event to insert text or markdown into the editor at the
// current cursor position.
// Components using this
@@ -984,32 +975,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
}
.ProseMirror-woot-style {
@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);
}
@apply overflow-auto min-h-[5rem] max-h-[7.5rem];
}
.ProseMirror-prompt-backdrop::backdrop {
@@ -8,22 +8,13 @@ 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,
@@ -49,7 +40,6 @@ let editorView = null;
let state;
export default {
components: { SlashCommandMenu },
mixins: [keyboardEventListenerMixins],
props: {
modelValue: { type: String, default: '' },
@@ -72,15 +62,8 @@ export default {
},
data() {
return {
plugins: [
imagePastePlugin(this.handleImageUpload),
this.createSlashPlugin(),
],
plugins: [imagePastePlugin(this.handleImageUpload)],
isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection
showSlashMenu: false,
slashSearchTerm: '',
slashRange: null,
slashMenuPosition: null,
};
},
watch: {
@@ -96,7 +79,7 @@ export default {
created() {
state = createState(
this.modelValue || '',
this.modelValue,
this.placeholder,
this.plugins,
{ onImageUpload: this.openFileBrowser },
@@ -112,126 +95,6 @@ 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);
@@ -307,7 +170,7 @@ export default {
},
reloadState() {
state = createState(
this.modelValue || '',
this.modelValue,
this.placeholder,
this.plugins,
{ onImageUpload: this.openFileBrowser },
@@ -399,8 +262,7 @@ export default {
// Get the editor's width
const editorWidth = editor.offsetWidth;
const menubar = editor.querySelector('.ProseMirror-menubar');
const menubarWidth = menubar ? menubar.scrollWidth : 480;
const menubarWidth = 480; // Menubar width (adjust as needed (px))
// Get the end position of the selection
const { bottom: endBottom, right: endRight } = editorView.coordsAtPos(to);
@@ -428,15 +290,7 @@ export default {
<template>
<div>
<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"
/>
<div class="editor-root editor--article">
<input
ref="imageUploadInput"
type="file"

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