Compare commits

..
463 changed files with 4015 additions and 16808 deletions
+49 -196
View File
@@ -3,7 +3,6 @@ orbs:
node: circleci/node@6.1.0
qlty-orb: qltysh/qlty-orb@0.0
# Shared defaults for setup steps
defaults: &defaults
working_directory: ~/build
machine:
@@ -13,106 +12,10 @@ defaults: &defaults
RAILS_LOG_TO_STDOUT: false
COVERAGE: true
LOG_LEVEL: warn
parallelism: 4
jobs:
# Separate job for linting (no parallelism needed)
lint:
<<: *defaults
steps:
- checkout
# Install minimal system dependencies for linting
- run:
name: Install System Dependencies
command: |
sudo apt-get update
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
libpq-dev \
build-essential \
git \
curl \
libssl-dev \
zlib1g-dev \
libreadline-dev \
libyaml-dev \
openjdk-11-jdk \
jq \
software-properties-common \
ca-certificates \
imagemagick \
libxml2-dev \
libxslt1-dev \
file \
g++ \
gcc \
autoconf \
gnupg2 \
patch \
ruby-dev \
liblzma-dev \
libgmp-dev \
libncurses5-dev \
libffi-dev \
libgdbm6 \
libgdbm-dev \
libvips
- run:
name: Install RVM and Ruby 3.4.4
command: |
sudo apt-get install -y gpg
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
\curl -sSL https://get.rvm.io | bash -s stable
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
source ~/.rvm/scripts/rvm
rvm install "3.4.4"
rvm use 3.4.4 --default
gem install bundler -v 2.5.16
- run:
name: Install Application Dependencies
command: |
source ~/.rvm/scripts/rvm
bundle install
- node/install:
node-version: '23.7'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
# Swagger verification
- run:
name: Verify swagger API specification
command: |
bundle exec rake swagger:build
if [[ `git status swagger/swagger.json --porcelain` ]]
then
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
exit 1
fi
mkdir -p ~/tmp
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
# Bundle audit
- run:
name: Bundle audit
command: bundle exec bundle audit update && bundle exec bundle audit check -v
# Rubocop linting
- run:
name: Rubocop
command: bundle exec rubocop --parallel
# ESLint linting
- run:
name: eslint
command: pnpm run eslint
# Separate job for frontend tests
frontend-tests:
build:
<<: *defaults
steps:
- checkout
@@ -122,38 +25,8 @@ jobs:
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
- run:
name: Run frontend tests (with coverage)
command: pnpm run test:coverage
- run:
name: Move coverage files if they exist
command: |
if [ -d "coverage" ]; then
mkdir -p ~/build/coverage
cp -r coverage ~/build/coverage/frontend || true
fi
when: always
- persist_to_workspace:
root: ~/build
paths:
- coverage
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 16
steps:
- checkout
- node/install:
node-version: '23.7'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
- run: node --version
- run: pnpm --version
- run:
name: Add PostgreSQL repository and update
command: |
@@ -218,6 +91,20 @@ jobs:
source ~/.rvm/scripts/rvm
bundle install
# Swagger verification
- run:
name: Verify swagger API specification
command: |
bundle exec rake swagger:build
if [[ `git status swagger/swagger.json --porcelain` ]]
then
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
exit 1
fi
mkdir -p ~/tmp
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
# Configure environment and database
- run:
name: Database Setup and Configure Environment Variables
@@ -240,91 +127,57 @@ jobs:
name: Run DB migrations
command: bundle exec rails db:chatwoot_prepare
# Run backend tests (parallelized)
# Bundle audit
- run:
name: Bundle audit
command: bundle exec bundle audit update && bundle exec bundle audit check -v
# Rubocop linting
- run:
name: Rubocop
command: bundle exec rubocop
# ESLint linting
- run:
name: eslint
command: pnpm run eslint
- run:
name: Run frontend tests (with coverage)
command: |
mkdir -p ~/build/coverage/frontend
pnpm run test:coverage
# Run backend tests
- run:
name: Run backend tests
command: |
mkdir -p ~/tmp/test-results/rspec
mkdir -p ~/tmp/test-artifacts
mkdir -p ~/build/coverage/backend
# Use round-robin distribution (same as GitHub Actions) for better test isolation
# This prevents tests with similar timing from being grouped on the same runner
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
TESTS=""
for i in "${!SPEC_FILES[@]}"; do
if [ $(( i % $CIRCLE_NODE_TOTAL )) -eq $CIRCLE_NODE_INDEX ]; then
TESTS="$TESTS ${SPEC_FILES[$i]}"
fi
done
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
--format RspecJunitFormatter \
--out ~/tmp/test-results/rspec.xml \
-- $TESTS
-- ${TESTFILES}
no_output_timeout: 30m
# Store test results for better splitting in future runs
- store_test_results:
path: ~/tmp/test-results
# Qlty coverage publish
- qlty-orb/coverage_publish:
files: |
coverage/coverage.json
coverage/lcov.info
- run:
name: Move coverage files if they exist
name: List coverage directory contents
command: |
if [ -d "coverage" ]; then
mkdir -p ~/build/coverage
cp -r coverage ~/build/coverage/backend || true
fi
when: always
ls -R ~/build/coverage
- persist_to_workspace:
root: ~/build
paths:
- coverage
# Collect coverage from all jobs
coverage:
<<: *defaults
steps:
- checkout
- attach_workspace:
at: ~/build
# Qlty coverage publish
- qlty-orb/coverage_publish:
files: |
coverage/frontend/lcov.info
- run:
name: List coverage directory contents
command: |
ls -R ~/build/coverage || echo "No coverage directory"
- store_artifacts:
path: coverage
destination: coverage
build:
<<: *defaults
steps:
- run:
name: Legacy build aggregator
command: |
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
workflows:
version: 2
build:
jobs:
- lint
- frontend-tests
- backend-tests
- coverage:
requires:
- frontend-tests
- backend-tests
- build:
requires:
- lint
- coverage
-6
View File
@@ -105,7 +105,6 @@ MAILER_INBOUND_EMAIL_DOMAIN=
# mandrill for Mandrill
# postmark for Postmark
# sendgrid for Sendgrid
# ses for Amazon SES
RAILS_INBOUND_EMAIL_SERVICE=
# Use one of the following based on the email ingress service
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
@@ -115,10 +114,6 @@ RAILS_INBOUND_EMAIL_PASSWORD=
MAILGUN_INGRESS_SIGNING_KEY=
MANDRILL_INGRESS_API_KEY=
# SNS topic ARN for ActionMailbox (format: arn:aws:sns:region:account-id:topic-name)
# Configure only if the rails_inbound_email_service = ses
ACTION_MAILBOX_SES_SNS_TOPIC=
# Creating Your Inbound Webhook Instructions for Postmark and Sendgrid:
# Inbound webhook URL format:
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
@@ -220,7 +215,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
# DD_TRACE_AGENT_URL=
# MaxMindDB API key to download GeoLite2 City database
# IP_LOOKUP_API_KEY=
+15 -79
View File
@@ -1,6 +1,4 @@
name: Run Chatwoot CE spec
permissions:
contents: read
on:
push:
branches:
@@ -10,58 +8,11 @@ on:
workflow_dispatch:
jobs:
# Separate linting jobs for faster feedback
lint-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Run Rubocop
run: bundle exec rubocop --parallel
lint-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
- name: Run ESLint
run: pnpm run eslint
# Frontend tests run in parallel with backend
frontend-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
- name: Run frontend tests
run: pnpm run test:coverage
# Backend tests with parallelization
backend-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ci_node_total: [16]
ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
test:
runs-on: ubuntu-22.04
services:
postgres:
image: pgvector/pgvector:pg16
image: pgvector/pgvector:pg15
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ''
@@ -69,6 +20,8 @@ jobs:
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
# tmpfs makes DB faster by using RAM
options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready
@@ -76,7 +29,7 @@ jobs:
--health-timeout 5s
--health-retries 5
redis:
image: redis:alpine
image: redis
ports:
- 6379:6379
options: --entrypoint redis-server
@@ -90,7 +43,7 @@ jobs:
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: actions/setup-node@v4
with:
@@ -111,36 +64,19 @@ jobs:
- name: Seed database
run: bundle exec rake db:schema:load
- name: Run backend tests (parallelized)
- name: Run frontend tests
run: pnpm run test:coverage
# Run rails tests
- name: Run backend tests
run: |
# Get all spec files and split them using round-robin distribution
# This ensures slow tests are distributed evenly across all nodes
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
TESTS=""
for i in "${!SPEC_FILES[@]}"; do
# Assign spec to this node if: index % total == node_index
if [ $(( i % ${{ matrix.ci_node_total }} )) -eq ${{ matrix.ci_node_index }} ]; then
TESTS="$TESTS ${SPEC_FILES[$i]}"
fi
done
if [ -n "$TESTS" ]; then
bundle exec rspec --profile=10 --format progress --format json --out tmp/rspec_results.json $TESTS
fi
bundle exec rspec --profile=10 --format documentation
env:
NODE_OPTIONS: --openssl-legacy-provider
- name: Upload test results
- name: Upload rails log folder
uses: actions/upload-artifact@v4
if: always()
with:
name: rspec-results-${{ matrix.ci_node_index }}
path: tmp/rspec_results.json
- name: Upload rails log folder
uses: actions/upload-artifact@v4
if: failure()
with:
name: rails-log-folder-${{ matrix.ci_node_index }}
name: rails-log-folder
path: log
-2
View File
@@ -99,5 +99,3 @@ CLAUDE.local.md
# Histoire deployment
.netlify
.histoire
.pnpm-store/*
local/
+1 -1
View File
@@ -1 +1 @@
23.7.0
20.5.1
+1 -1
View File
@@ -39,7 +39,7 @@ exclude_patterns = [
"**/target/**",
"**/templates/**",
"**/testdata/**",
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/captain/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
]
test_patterns = [
+1 -12
View File
@@ -7,8 +7,6 @@ plugins:
require:
- ./rubocop/use_from_email.rb
- ./rubocop/custom_cop_location.rb
- ./rubocop/attachment_download.rb
- ./rubocop/one_class_per_file.rb
Layout/LineLength:
Max: 150
@@ -42,12 +40,6 @@ Style/SymbolArray:
Style/OpenStructUse:
Enabled: false
Chatwoot/AttachmentDownload:
Enabled: true
Exclude:
- 'spec/**/*'
- 'test/**/*'
Style/OptionalBooleanParameter:
Exclude:
- 'app/services/email_templates/db_resolver_service.rb'
@@ -95,7 +87,7 @@ Metrics/ModuleLength:
Rails/HelperInstanceVariable:
Exclude:
- enterprise/app/helpers/captain/chat_helper.rb
- enterprise/app/helpers/captain/chat_response_helper.rb
Rails/ApplicationController:
Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb'
@@ -213,9 +205,6 @@ UseFromEmail:
CustomCopLocation:
Enabled: true
Style/OneClassPerFile:
Enabled: true
AllCops:
NewCops: enable
Exclude:
-7
View File
@@ -10,9 +10,6 @@
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
- **Run Project**: `overmind start -f Procfile.dev`
- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`)
- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used
- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.)
## Code Style
@@ -40,15 +37,12 @@
- MVP focus: Least code change, happy-path only
- No unnecessary defensive programming
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
- Break down complex tasks into small, testable units
- Iterate after confirmation
- Avoid writing specs unless explicitly asked
- Remove dead/unreachable/unused code
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
- Don't reference Claude in commit messages
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
## Project-Specific
@@ -79,4 +73,3 @@ Practical checklist for any change impacting core logic or public APIs
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
+3 -11
View File
@@ -55,9 +55,6 @@ gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby',
gem 'google-cloud-storage', '>= 1.48.0', require: false
gem 'image_processing'
##-- for actionmailbox --##
gem 'aws-actionmailbox-ses', '~> 0'
##-- gems for database --#
gem 'groupdate'
gem 'pg'
@@ -162,7 +159,7 @@ gem 'working_hours'
gem 'pg_search'
# Subscriptions, Billing
gem 'stripe', '~> 18.0'
gem 'stripe'
## - helper gems --##
## to populate db with sample data
@@ -191,16 +188,11 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.7.0'
gem 'ai-agents', '>= 0.4.3'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema'
# OpenTelemetry for LLM observability
gem 'opentelemetry-sdk'
gem 'opentelemetry-exporter-otlp'
gem 'shopify_api'
### Gems required only in specific deployment environments ###
@@ -215,7 +207,7 @@ group :production do
end
group :development do
gem 'annotaterb'
gem 'annotate'
gem 'bullet'
gem 'letter_opener'
gem 'scss_lint', require: false
+14 -45
View File
@@ -126,23 +126,19 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.7.0)
ruby_llm (~> 1.8.2)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
ai-agents (0.4.3)
ruby_llm (~> 1.3)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
ast (2.4.3)
attr_extras (7.1.0)
audited (5.4.1)
activerecord (>= 5.0, < 7.7)
activesupport (>= 5.0, < 7.7)
aws-actionmailbox-ses (0.1.0)
actionmailbox (>= 7.1.0)
aws-sdk-s3 (~> 1, >= 1.123.0)
aws-sdk-sns (~> 1, >= 1.61.0)
aws-eventstream (1.2.0)
aws-partitions (1.760.0)
aws-sdk-core (3.188.0)
aws-sdk-core (3.171.1)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
@@ -150,13 +146,10 @@ GEM
aws-sdk-kms (1.64.0)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.126.0)
aws-sdk-core (~> 3, >= 3.174.0)
aws-sdk-s3 (1.122.0)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sdk-sns (1.70.0)
aws-sdk-core (~> 3, >= 3.188.0)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.5.2)
aws-eventstream (~> 1, >= 1.0.2)
barnes (0.0.9)
@@ -625,25 +618,6 @@ GEM
faraday (>= 1.0, < 3)
multi_json (>= 1.0)
openssl (3.2.0)
opentelemetry-api (1.7.0)
opentelemetry-common (0.23.0)
opentelemetry-api (~> 1.0)
opentelemetry-exporter-otlp (0.31.1)
google-protobuf (>= 3.18)
googleapis-common-protos-types (~> 1.3)
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
opentelemetry-sdk (~> 1.10)
opentelemetry-semantic_conventions
opentelemetry-registry (0.4.0)
opentelemetry-api (~> 1.1)
opentelemetry-sdk (1.10.0)
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
opentelemetry-registry (~> 0.2)
opentelemetry-semantic_conventions
opentelemetry-semantic_conventions (1.36.0)
opentelemetry-api (~> 1.0)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.1)
@@ -819,7 +793,7 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.8.2)
ruby_llm (1.5.1)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -827,9 +801,8 @@ GEM
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1.0)
ruby_llm-schema (~> 0.2.1)
zeitwerk (~> 2)
ruby_llm-schema (0.2.5)
ruby_llm-schema (0.1.0)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
@@ -929,7 +902,7 @@ GEM
squasher (0.7.2)
stackprof (0.2.25)
statsd-ruby (1.5.0)
stripe (18.0.1)
stripe (8.5.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.4.0)
@@ -1018,11 +991,10 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.7.0)
annotaterb
ai-agents (>= 0.4.3)
annotate
attr_extras
audited (~> 5.4, >= 5.4.1)
aws-actionmailbox-ses (~> 0)
aws-sdk-s3
azure-storage-blob!
barnes
@@ -1093,8 +1065,6 @@ DEPENDENCIES
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
omniauth-saml
opensearch-ruby
opentelemetry-exporter-otlp
opentelemetry-sdk
pg
pg_search
pgvector
@@ -1120,7 +1090,6 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
ruby_llm (>= 1.8.2)
ruby_llm-schema
scout_apm
scss_lint
@@ -1141,7 +1110,7 @@ DEPENDENCIES
spring-watcher-listen
squasher
stackprof
stripe (~> 18.0)
stripe
telephone_number
test-prof
tidewave
+1 -1
View File
@@ -1 +1 @@
4.8.0
4.4.0
-2
View File
@@ -103,5 +103,3 @@ class ContactInboxBuilder
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
end
end
ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder')
-3
View File
@@ -138,7 +138,6 @@ class Messages::MessageBuilder
private: @private,
sender: sender,
content_type: @params[:content_type],
content_attributes: content_attributes.presence,
items: @items,
in_reply_to: @in_reply_to,
echo_id: @params[:echo_id],
@@ -223,5 +222,3 @@ class Messages::MessageBuilder
})
end
end
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')
@@ -9,8 +9,6 @@ class Messages::Messenger::MessageBuilder
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
update_attachment_file_type(attachment_obj)
end
@@ -29,7 +27,7 @@ class Messages::Messenger::MessageBuilder
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
@@ -41,17 +39,9 @@ class Messages::Messenger::MessageBuilder
end
def file_type_params(attachment)
# Handle different URL field names for different attachment types
url = case attachment['type'].to_sym
when :ig_story
attachment['payload']['story_media_url']
else
attachment['payload']['url']
end
{
external_url: url,
remote_file_url: url
external_url: attachment['payload']['url'],
remote_file_url: attachment['payload']['url']
}
end
@@ -78,21 +68,6 @@ class Messages::Messenger::MessageBuilder
message.save!
end
def fetch_ig_story_link(attachment)
message = attachment.message
# For ig_story, we don't have the same API call as story_mention, so we'll set it up similarly but with generic content
message.content_attributes[:image_type] = 'ig_story'
message.content = I18n.t('conversations.messages.instagram_shared_story_content')
message.save!
end
def fetch_ig_post_link(attachment)
message = attachment.message
message.content_attributes[:image_type] = 'ig_post'
message.content = I18n.t('conversations.messages.instagram_shared_post_content')
message.save!
end
# This is a placeholder method to be overridden by child classes
def get_story_object_from_source_id(_source_id)
{}
@@ -101,6 +76,6 @@ class Messages::Messenger::MessageBuilder
private
def unsupported_file_type?(attachment_type)
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
[:template, :unsupported_type].include? attachment_type.to_sym
end
end
-74
View File
@@ -1,74 +0,0 @@
class YearInReviewBuilder
attr_reader :account, :user_id, :year
def initialize(account:, user_id:, year:)
@account = account
@user_id = user_id
@year = year
end
def build
{
year: year,
total_conversations: total_conversations_count,
busiest_day: busiest_day_data,
support_personality: support_personality_data
}
end
private
def year_range
@year_range ||= begin
start_time = Time.zone.local(year, 1, 1).beginning_of_day
end_time = Time.zone.local(year, 12, 31).end_of_day
start_time..end_time
end
end
def total_conversations_count
account.conversations
.where(assignee_id: user_id, created_at: year_range)
.count
end
def busiest_day_data
daily_counts = account.conversations
.where(assignee_id: user_id, created_at: year_range)
.group_by_day(:created_at, range: year_range, time_zone: Time.zone)
.count
return nil if daily_counts.empty?
busiest_date, count = daily_counts.max_by { |_date, cnt| cnt }
return nil if count.zero?
{
date: busiest_date.strftime('%b %d'),
count: count
}
end
def support_personality_data
response_time = average_response_time
return { avg_response_time_seconds: 0 } if response_time.nil?
{
avg_response_time_seconds: response_time.to_i
}
end
def average_response_time
avg_time = account.reporting_events
.where(
name: 'first_response',
user_id: user_id,
created_at: year_range
)
.average(:value)
avg_time&.to_f
end
end
@@ -4,7 +4,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
before_action :agent_bot, except: [:index, :create]
def index
@agent_bots = AgentBot.accessible_to(Current.account)
@agent_bots = AgentBot.where(account_id: [nil, Current.account.id])
end
def show; end
@@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
private
def agent_bot
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:id]) if params[:action] == 'show'
@agent_bot = AgentBot.where(account_id: [nil, Current.account.id]).find(params[:id]) if params[:action] == 'show'
@agent_bot ||= Current.account.agent_bots.find(params[:id])
end
@@ -1,7 +1,7 @@
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
# assigns agent/team to a conversation
def create
if params.key?(:assignee_id) || agent_bot_assignment?
if params.key?(:assignee_id)
set_agent
elsif params.key?(:team_id)
set_team
@@ -13,23 +13,17 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
private
def set_agent
resource = Conversations::AssignmentService.new(
conversation: @conversation,
assignee_id: params[:assignee_id],
assignee_type: params[:assignee_type]
).perform
render_agent(resource)
@agent = Current.account.users.find_by(id: params[:assignee_id])
@conversation.assignee = @agent
@conversation.save!
render_agent
end
def render_agent(resource)
case resource
when User
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: resource }
when AgentBot
render partial: 'api/v1/models/agent_bot_slim', formats: [:json], locals: { resource: resource }
else
def render_agent
if @agent.nil?
render json: nil
else
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: @agent }
end
end
@@ -38,8 +32,4 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
@conversation.update!(team: @team)
render json: @team
end
def agent_bot_assignment?
params[:assignee_type].to_s == 'AgentBot'
end
end
@@ -1,160 +0,0 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
DEFAULT_LANGUAGE = 'en'.freeze
before_action :fetch_inbox
before_action :validate_whatsapp_channel
def show
template = @inbox.csat_config&.dig('template')
return render json: { template_exists: false } unless template
template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
status_result = @inbox.channel.provider_service.get_template_status(template_name)
render_template_status_response(status_result, template_name)
rescue StandardError => e
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
render json: { error: e.message }, status: :internal_server_error
end
def create
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
# Delete existing template even though we are using a new one.
# We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
delete_existing_template_if_needed
result = create_template_via_provider(template_params)
render_template_creation_result(result)
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error "Error creating CSAT template: #{e.message}"
render json: { error: 'Template creation failed' }, status: :internal_server_error
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @inbox, :show?
end
def validate_whatsapp_channel
return if @inbox.whatsapp?
render json: { error: 'CSAT template operations only available for WhatsApp channels' },
status: :bad_request
end
def extract_template_params
params.require(:template).permit(:message, :button_text, :language)
end
def render_missing_message_error
render json: { error: 'Message is required' }, status: :unprocessable_entity
end
def create_template_via_provider(template_params)
template_config = {
message: template_params[:message],
button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
language: template_params[:language] || DEFAULT_LANGUAGE,
template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
}
@inbox.channel.provider_service.create_csat_template(template_config)
end
def render_template_creation_result(result)
if result[:success]
render_successful_template_creation(result)
else
render_failed_template_creation(result)
end
end
def render_successful_template_creation(result)
render json: {
template: {
name: result[:template_name],
template_id: result[:template_id],
status: 'PENDING',
language: result[:language] || DEFAULT_LANGUAGE
}
}, status: :created
end
def render_failed_template_creation(result)
whatsapp_error = parse_whatsapp_error(result[:response_body])
error_message = whatsapp_error[:user_message] || result[:error]
render json: {
error: error_message,
details: whatsapp_error[:technical_details]
}, status: :unprocessable_entity
end
def delete_existing_template_if_needed
template = @inbox.csat_config&.dig('template')
return true if template.blank?
template_name = template['name']
return true if template_name.blank?
template_status = @inbox.channel.provider_service.get_template_status(template_name)
return true unless template_status[:success]
deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name)
if deletion_result[:success]
Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
true
else
Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
false
end
rescue StandardError => e
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
false
end
def render_template_status_response(status_result, template_name)
if status_result[:success]
render json: {
template_exists: true,
template_name: template_name,
status: status_result[:template][:status],
template_id: status_result[:template][:id]
}
else
render json: {
template_exists: false,
error: 'Template not found'
}
end
end
def parse_whatsapp_error(response_body)
return { user_message: nil, technical_details: nil } if response_body.blank?
begin
error_data = JSON.parse(response_body)
whatsapp_error = error_data['error'] || {}
user_message = whatsapp_error['error_user_msg'] || whatsapp_error['message']
technical_details = {
code: whatsapp_error['code'],
subcode: whatsapp_error['error_subcode'],
type: whatsapp_error['type'],
title: whatsapp_error['error_user_title']
}.compact
{ user_message: user_message, technical_details: technical_details }
rescue JSON::ParserError
{ user_message: nil, technical_details: response_body }
end
end
end
@@ -152,37 +152,31 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def format_csat_config(config)
formatted = {
'display_type' => config['display_type'] || 'emoji',
'message' => config['message'] || '',
:survey_rules => {
'operator' => config.dig('survey_rules', 'operator') || 'contains',
'values' => config.dig('survey_rules', 'values') || []
},
'button_text' => config['button_text'] || 'Please rate us',
'language' => config['language'] || 'en'
{
display_type: config['display_type'] || 'emoji',
message: config['message'] || '',
survey_rules: {
operator: config.dig('survey_rules', 'operator') || 'contains',
values: config.dig('survey_rules', 'values') || []
}
}
format_template_config(config, formatted)
formatted
end
def format_template_config(config, formatted)
formatted['template'] = config['template'] if config['template'].present?
end
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
{ csat_config: [:display_type, :message, :button_text, :language,
{ survey_rules: [:operator, { values: [] }],
template: [:name, :template_id, :created_at, :language] }] }]
{ csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }]
end
def permitted_params(channel_attributes = [])
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
params.permit(*inbox_attributes, channel: [:type, *channel_attributes])
params.permit(
*inbox_attributes,
channel: [:type, *channel_attributes]
)
end
def channel_type_from_params
@@ -198,7 +192,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def get_channel_attributes(channel_type)
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
channel_type.constantize::EDITABLE_ATTRS.presence
else
[]
end
end
def whatsapp_channel?
@@ -9,13 +9,7 @@ class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
private
def set_global_config
@global_config = GlobalConfig.get(
'LOGO_THUMBNAIL',
'BRAND_NAME',
'WIDGET_BRAND_URL',
'MAXIMUM_FILE_UPLOAD_SIZE',
'INSTALLATION_NAME'
)
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME')
end
def set_contact
@@ -1,26 +0,0 @@
class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseController
def show
year = params[:year] || 2025
cache_key = "year_in_review_#{Current.account.id}_#{year}"
cached_data = Current.user.ui_settings&.dig(cache_key)
if cached_data.present?
render json: cached_data
else
builder = YearInReviewBuilder.new(
account: Current.account,
user_id: Current.user.id,
year: year
)
data = builder.build
ui_settings = Current.user.ui_settings || {}
ui_settings[cache_key] = data
Current.user.update(ui_settings: ui_settings)
render json: data
end
end
end
@@ -25,9 +25,6 @@ module EnsureCurrentAccountHelper
end
def account_accessible_for_bot?(account)
return if @resource.account_id == account.id
return if @resource.agent_bot_inboxes.find_by(account_id: account.id)
render_unauthorized('Bot is not authorized to access this account')
render_unauthorized('Bot is not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
end
end
+20 -35
View File
@@ -1,31 +1,6 @@
class DashboardController < ActionController::Base
include SwitchLocale
GLOBAL_CONFIG_KEYS = %w[
LOGO
LOGO_DARK
LOGO_THUMBNAIL
INSTALLATION_NAME
WIDGET_BRAND_URL
TERMS_URL
BRAND_URL
BRAND_NAME
PRIVACY_URL
DISPLAY_MANIFEST
CREATE_NEW_ACCOUNT_FROM_DASHBOARD
CHATWOOT_INBOX_TOKEN
API_CHANNEL_NAME
API_CHANNEL_THUMBNAIL
ANALYTICS_TOKEN
DIRECT_UPLOADS_ENABLED
MAXIMUM_FILE_UPLOAD_SIZE
HCAPTCHA_SITE_KEY
LOGOUT_REDIRECT_LINK
DISABLE_USER_PROFILE_UPDATE
DEPLOYMENT_ENV
INSTALLATION_PRICING_PLAN
].freeze
before_action :set_application_pack
before_action :set_global_config
before_action :set_dashboard_scripts
@@ -44,7 +19,25 @@ class DashboardController < ActionController::Base
end
def set_global_config
@global_config = GlobalConfig.get(*GLOBAL_CONFIG_KEYS).merge(app_config)
@global_config = GlobalConfig.get(
'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL',
'INSTALLATION_NAME',
'WIDGET_BRAND_URL', 'TERMS_URL',
'BRAND_URL', 'BRAND_NAME',
'PRIVACY_URL',
'DISPLAY_MANIFEST',
'CREATE_NEW_ACCOUNT_FROM_DASHBOARD',
'CHATWOOT_INBOX_TOKEN',
'API_CHANNEL_NAME',
'API_CHANNEL_THUMBNAIL',
'ANALYTICS_TOKEN',
'DIRECT_UPLOADS_ENABLED',
'HCAPTCHA_SITE_KEY',
'LOGOUT_REDIRECT_LINK',
'DISABLE_USER_PROFILE_UPDATE',
'DEPLOYMENT_ENV',
'INSTALLATION_PRICING_PLAN'
).merge(app_config)
end
def set_dashboard_scripts
@@ -78,18 +71,10 @@ class DashboardController < ActionController::Base
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH,
ALLOWED_LOGIN_METHODS: allowed_login_methods
GIT_SHA: GIT_HASH
}
end
def allowed_login_methods
methods = ['email']
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
methods << 'saml' if ChatwootHub.pricing_plan != 'community' && GlobalConfigService.load('ENABLE_SAML_SSO_LOGIN', 'true').to_s != 'false'
methods
end
def set_application_pack
@application_pack = if request.path.include?('/auth') || request.path.include?('/login')
'v3app'
@@ -15,20 +15,14 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
end
def create
errors = []
params['app_config'].each do |key, value|
next unless @allowed_configs.include?(key)
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
i.value = value
errors.concat(i.errors.full_messages) unless i.save
end
if errors.any?
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
else
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
i.save!
end
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
end
private
@@ -48,13 +42,10 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI]
}
@allowed_configs = mapping.fetch(
@config,
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE]
)
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
end
end
+1 -8
View File
@@ -14,14 +14,7 @@ class WidgetsController < ActionController::Base
private
def set_global_config
@global_config = GlobalConfig.get(
'LOGO_THUMBNAIL',
'BRAND_NAME',
'WIDGET_BRAND_URL',
'DIRECT_UPLOADS_ENABLED',
'MAXIMUM_FILE_UPLOAD_SIZE',
'INSTALLATION_NAME'
)
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED', 'INSTALLATION_NAME')
end
def set_web_widget
-7
View File
@@ -9,13 +9,6 @@ captain:
icon: 'icon-captain'
config_key: 'captain'
enterprise: true
saml:
name: 'SAML SSO'
description: 'Configuration for controlling SAML Single Sign-On availability'
enabled: <%= ChatwootApp.enterprise? %>
icon: 'icon-lock-line'
config_key: 'saml'
enterprise: true
custom_branding:
name: 'Custom Branding'
description: 'Apply your own branding to this installation.'
-37
View File
@@ -1,37 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
export const buildCompanyParams = (page, sort) => {
let params = `page=${page}`;
if (sort) {
params = `${params}&sort=${sort}`;
}
return params;
};
export const buildSearchParams = (query, page, sort) => {
let params = `q=${encodeURIComponent(query)}&page=${page}`;
if (sort) {
params = `${params}&sort=${sort}`;
}
return params;
};
class CompanyAPI extends ApiClient {
constructor() {
super('companies', { accountScoped: true });
}
get(params = {}) {
const { page = 1, sort = 'name' } = params;
const requestURL = `${this.url}?${buildCompanyParams(page, sort)}`;
return axios.get(requestURL);
}
search(query = '', page = 1, sort = 'name') {
const requestURL = `${this.url}/search?${buildSearchParams(query, page, sort)}`;
return axios.get(requestURL);
}
}
export default new CompanyAPI();
-6
View File
@@ -47,12 +47,6 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/labels`);
}
initiateCall(contactId, inboxId) {
return axios.post(`${this.url}/${contactId}/call`, {
inbox_id: inboxId,
});
}
updateContactLabels(contactId, labels) {
return axios.post(`${this.url}/${contactId}/labels`, { labels });
}
@@ -23,10 +23,6 @@ class EnterpriseAccountAPI extends ApiClient {
action_type: action,
});
}
createTopupCheckout(credits) {
return axios.post(`${this.url}topup_checkout`, { credits });
}
}
export default new EnterpriseAccountAPI();
@@ -11,8 +11,6 @@ describe('#enterpriseAccountAPI', () => {
expect(accountAPI).toHaveProperty('delete');
expect(accountAPI).toHaveProperty('checkout');
expect(accountAPI).toHaveProperty('toggleDeletion');
expect(accountAPI).toHaveProperty('createTopupCheckout');
expect(accountAPI).toHaveProperty('getLimits');
});
describe('API calls', () => {
@@ -61,29 +59,5 @@ describe('#enterpriseAccountAPI', () => {
{ action_type: 'undelete' }
);
});
it('#createTopupCheckout with credits', () => {
accountAPI.createTopupCheckout(1000);
expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/topup_checkout',
{ credits: 1000 }
);
});
it('#createTopupCheckout with different credit amounts', () => {
const creditAmounts = [1000, 2500, 6000, 12000];
creditAmounts.forEach(credits => {
accountAPI.createTopupCheckout(credits);
expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/topup_checkout',
{ credits }
);
});
});
it('#getLimits', () => {
accountAPI.getLimits();
expect(axiosMock.get).toHaveBeenCalledWith('/enterprise/api/v1/limits');
});
});
});
@@ -63,9 +63,10 @@ class ConversationApi extends ApiClient {
}
assignAgent({ conversationId, agentId }) {
return axios.post(`${this.url}/${conversationId}/assignments`, {
assignee_id: agentId,
});
return axios.post(
`${this.url}/${conversationId}/assignments?assignee_id=${agentId}`,
{}
);
}
assignTeam({ conversationId, teamId }) {
@@ -57,12 +57,6 @@ class OpenAIAPI extends ApiClient {
content,
};
// Always include conversation_display_id when available for session tracking
if (conversationId) {
data.conversation_display_id = conversationId;
}
// For conversation-level events, only send conversation_display_id
if (this.conversation_events.includes(type)) {
data = {
conversation_display_id: conversationId,
@@ -1,142 +0,0 @@
import companyAPI, {
buildCompanyParams,
buildSearchParams,
} from '../companies';
import ApiClient from '../ApiClient';
describe('#CompanyAPI', () => {
it('creates correct instance', () => {
expect(companyAPI).toBeInstanceOf(ApiClient);
expect(companyAPI).toHaveProperty('get');
expect(companyAPI).toHaveProperty('show');
expect(companyAPI).toHaveProperty('create');
expect(companyAPI).toHaveProperty('update');
expect(companyAPI).toHaveProperty('delete');
expect(companyAPI).toHaveProperty('search');
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#get with default params', () => {
companyAPI.get({});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=1&sort=name'
);
});
it('#get with page and sort params', () => {
companyAPI.get({ page: 2, sort: 'domain' });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=2&sort=domain'
);
});
it('#get with descending sort', () => {
companyAPI.get({ page: 1, sort: '-created_at' });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=1&sort=-created_at'
);
});
it('#search with query', () => {
companyAPI.search('acme', 1, 'name');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=acme&page=1&sort=name'
);
});
it('#search with special characters in query', () => {
companyAPI.search('acme & co', 2, 'domain');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=acme%20%26%20co&page=2&sort=domain'
);
});
it('#search with descending sort', () => {
companyAPI.search('test', 1, '-created_at');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=test&page=1&sort=-created_at'
);
});
it('#search with empty query', () => {
companyAPI.search('', 1, 'name');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=&page=1&sort=name'
);
});
});
});
describe('#buildCompanyParams', () => {
it('returns correct string with page only', () => {
expect(buildCompanyParams(1)).toBe('page=1');
});
it('returns correct string with page and sort', () => {
expect(buildCompanyParams(1, 'name')).toBe('page=1&sort=name');
});
it('returns correct string with different page', () => {
expect(buildCompanyParams(3, 'domain')).toBe('page=3&sort=domain');
});
it('returns correct string with descending sort', () => {
expect(buildCompanyParams(1, '-created_at')).toBe(
'page=1&sort=-created_at'
);
});
it('returns correct string without sort parameter', () => {
expect(buildCompanyParams(2, '')).toBe('page=2');
});
});
describe('#buildSearchParams', () => {
it('returns correct string with all parameters', () => {
expect(buildSearchParams('acme', 1, 'name')).toBe(
'q=acme&page=1&sort=name'
);
});
it('returns correct string with special characters', () => {
expect(buildSearchParams('acme & co', 2, 'domain')).toBe(
'q=acme%20%26%20co&page=2&sort=domain'
);
});
it('returns correct string with empty query', () => {
expect(buildSearchParams('', 1, 'name')).toBe('q=&page=1&sort=name');
});
it('returns correct string without sort parameter', () => {
expect(buildSearchParams('test', 1, '')).toBe('q=test&page=1');
});
it('returns correct string with descending sort', () => {
expect(buildSearchParams('company', 3, '-created_at')).toBe(
'q=company&page=3&sort=-created_at'
);
});
it('encodes special characters correctly', () => {
expect(buildSearchParams('test@example.com', 1, 'name')).toBe(
'q=test%40example.com&page=1&sort=name'
);
});
});
@@ -92,10 +92,8 @@ describe('#ConversationAPI', () => {
it('#assignAgent', () => {
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments`,
{
assignee_id: 34,
}
`/api/v1/conversations/12/assignments?assignee_id=34`,
{}
);
});
@@ -1,16 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class YearInReviewAPI extends ApiClient {
constructor() {
super('year_in_review', { accountScoped: true, apiVersion: 'v2' });
}
get(year) {
return axios.get(`${this.url}`, {
params: { year },
});
}
}
export default new YearInReviewAPI();
@@ -1,95 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { formatDistanceToNow } from 'date-fns';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
domain: { type: String, default: '' },
contactsCount: { type: Number, default: 0 },
description: { type: String, default: '' },
avatarUrl: { type: String, default: '' },
updatedAt: { type: [String, Number], default: null },
});
const emit = defineEmits(['showCompany']);
const { t } = useI18n();
const onClickViewDetails = () => emit('showCompany', props.id);
const displayName = computed(() => props.name || t('COMPANIES.UNNAMED'));
const avatarSource = computed(() => props.avatarUrl || null);
const formattedUpdatedAt = computed(() => {
if (!props.updatedAt) return '';
return formatDistanceToNow(new Date(props.updatedAt), { addSuffix: true });
});
</script>
<template>
<CardLayout layout="row" @click="onClickViewDetails">
<div class="flex items-center justify-start flex-1 gap-4">
<Avatar
:username="displayName"
:src="avatarSource"
class="shrink-0"
:name="name"
:size="48"
hide-offline-status
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
<span class="text-base font-medium truncate text-n-slate-12">
{{ displayName }}
</span>
<span
v-if="domain && description"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
>
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
<span class="truncate">{{ domain }}</span>
</span>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0">
<span
v-if="domain && !description"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
>
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
<span class="truncate">{{ domain }}</span>
</span>
<span v-if="description" class="text-sm text-n-slate-11 truncate">
{{ description }}
</span>
<div
v-if="(description || domain) && contactsCount"
class="w-px h-3 bg-n-slate-6"
/>
<span
v-if="contactsCount"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
>
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
{{ t('COMPANIES.CONTACTS_COUNT', { n: contactsCount }) }}
</span>
</div>
<span
v-if="updatedAt"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 flex-shrink-0"
>
{{ formattedUpdatedAt }}
</span>
</div>
</div>
</div>
</CardLayout>
</template>
@@ -1,55 +0,0 @@
<script setup>
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CompanySortMenu from './components/CompanySortMenu.vue';
defineProps({
showSearch: { type: Boolean, default: true },
searchValue: { type: String, default: '' },
headerTitle: { type: String, required: true },
activeSort: { type: String, default: 'last_activity_at' },
activeOrdering: { type: String, default: '' },
});
const emit = defineEmits(['search', 'update:sort']);
</script>
<template>
<header class="sticky top-0 z-10">
<div
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
>
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-row flex-shrink-0 gap-2">
<div class="flex items-center">
<CompanySortMenu
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
</div>
<div v-if="showSearch" class="flex items-center gap-2 w-full">
<Input
:model-value="searchValue"
type="search"
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
:custom-input-class="[
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
]"
class="w-full"
@input="emit('search', $event.target.value)"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2 rtl:right-2"
/>
</template>
</Input>
</div>
</div>
</div>
</header>
</template>
@@ -1,116 +0,0 @@
<script setup>
import { ref, computed, toRef } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import SelectMenu from 'dashboard/components-next/selectmenu/SelectMenu.vue';
const props = defineProps({
activeSort: {
type: String,
default: 'name',
},
activeOrdering: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:sort']);
const { t } = useI18n();
const isMenuOpen = ref(false);
const sortMenus = [
{
label: t('COMPANIES.SORT_BY.OPTIONS.NAME'),
value: 'name',
},
{
label: t('COMPANIES.SORT_BY.OPTIONS.DOMAIN'),
value: 'domain',
},
{
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
value: 'created_at',
},
];
const orderingMenus = [
{
label: t('COMPANIES.ORDER.OPTIONS.ASCENDING'),
value: '',
},
{
label: t('COMPANIES.ORDER.OPTIONS.DESCENDING'),
value: '-',
},
];
// Converted the props to refs for better reactivity
const activeSort = toRef(props, 'activeSort');
const activeOrdering = toRef(props, 'activeOrdering');
const activeSortLabel = computed(() => {
const selectedMenu = sortMenus.find(menu => menu.value === activeSort.value);
return selectedMenu?.label || t('COMPANIES.SORT_BY.LABEL');
});
const activeOrderingLabel = computed(() => {
const selectedMenu = orderingMenus.find(
menu => menu.value === activeOrdering.value
);
return selectedMenu?.label || t('COMPANIES.ORDER.LABEL');
});
const handleSortChange = value => {
emit('update:sort', { sort: value, order: props.activeOrdering });
};
const handleOrderChange = value => {
emit('update:sort', { sort: props.activeSort, order: value });
};
</script>
<template>
<div class="relative">
<Button
icon="i-lucide-arrow-down-up"
color="slate"
size="sm"
variant="ghost"
:class="isMenuOpen ? 'bg-n-alpha-2' : ''"
@click="isMenuOpen = !isMenuOpen"
/>
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
{{ t('COMPANIES.SORT_BY.LABEL') }}
</span>
<SelectMenu
:model-value="activeSort"
:options="sortMenus"
:label="activeSortLabel"
@update:model-value="handleSortChange"
/>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
{{ t('COMPANIES.ORDER.LABEL') }}
</span>
<SelectMenu
:model-value="activeOrdering"
:options="orderingMenus"
:label="activeOrderingLabel"
@update:model-value="handleOrderChange"
/>
</div>
</div>
</div>
</template>
@@ -1,51 +0,0 @@
<script setup>
import CompanyHeader from './CompaniesHeader/CompanyHeader.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
defineProps({
searchValue: { type: String, default: '' },
headerTitle: { type: String, default: '' },
currentPage: { type: Number, default: 1 },
totalItems: { type: Number, default: 100 },
activeSort: { type: String, default: 'name' },
activeOrdering: { type: String, default: '' },
showPaginationFooter: { type: Boolean, default: true },
});
const emit = defineEmits(['update:currentPage', 'update:sort', 'search']);
const updateCurrentPage = page => {
emit('update:currentPage', page);
};
</script>
<template>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
>
<div class="flex flex-col w-full h-full transition-all duration-300">
<CompanyHeader
:search-value="searchValue"
:header-title="headerTitle"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@search="emit('search', $event)"
@update:sort="emit('update:sort', $event)"
/>
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]">
<slot name="default" />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
<PaginationFooter
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="25"
@update:current-page="updateCurrentPage"
/>
</footer>
</div>
</section>
</template>
@@ -102,7 +102,6 @@ const closeMobileSidebar = () => {
/>
<VoiceCallButton
:phone="selectedContact?.phoneNumber"
:contact-id="contactId"
:label="$t('CONTACT_PANEL.CALL')"
size="sm"
/>
@@ -1,18 +1,15 @@
<script setup>
import { computed, ref, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
phone: { type: String, default: '' },
contactId: { type: [String, Number], required: true },
label: { type: String, default: '' },
icon: { type: [String, Object, Function], default: '' },
size: { type: String, default: 'sm' },
@@ -21,17 +18,10 @@ const props = defineProps({
defineOptions({ inheritAttrs: false });
const attrs = useAttrs();
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const dialogRef = ref(null);
const inboxesList = useMapGetter('inboxes/getInboxes');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const voiceInboxes = computed(() =>
(inboxesList.value || []).filter(
inbox => inbox.channel_type === INBOX_TYPES.VOICE
@@ -42,51 +32,20 @@ const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
// Unified behavior: hide when no phone
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
const isInitiatingCall = computed(() => {
return contactsUiFlags.value?.isInitiatingCall || false;
});
const dialogRef = ref(null);
const navigateToConversation = conversationId => {
const accountId = route.params.accountId;
if (conversationId && accountId) {
const path = frontendURL(
conversationUrl({
accountId,
id: conversationId,
})
);
router.push({ path });
}
};
const startCall = async inboxId => {
if (isInitiatingCall.value) return;
try {
const response = await store.dispatch('contacts/initiateCall', {
contactId: props.contactId,
inboxId,
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(response?.conversation_id);
} catch (error) {
const apiError = error?.message;
useAlert(apiError || t('CONTACT_PANEL.CALL_FAILED'));
}
};
const onClick = async () => {
const onClick = () => {
if (voiceInboxes.value.length > 1) {
dialogRef.value?.open();
return;
}
const [inbox] = voiceInboxes.value;
await startCall(inbox.id);
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
};
const onPickInbox = async inbox => {
const onPickInbox = () => {
// Placeholder until actual call wiring happens
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
dialogRef.value?.close();
await startCall(inbox.id);
};
</script>
@@ -96,8 +55,6 @@ const onPickInbox = async inbox => {
v-if="shouldRender"
v-tooltip.top-end="tooltipLabel || null"
v-bind="attrs"
:disabled="isInitiatingCall"
:is-loading="isInitiatingCall"
:label="label"
:icon="icon"
:size="size"
@@ -19,12 +19,12 @@ const props = defineProps({
},
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
enableCaptainTools: { type: Boolean, default: false },
signature: { type: String, default: '' },
allowSignature: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
medium: { type: String, default: '' },
});
const emit = defineEmits(['update:modelValue']);
@@ -102,12 +102,12 @@ watch(
:disabled="disabled"
:enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
:enable-captain-tools="enableCaptainTools"
:signature="signature"
:allow-signature="allowSignature"
:send-with-signature="sendWithSignature"
:channel-type="channelType"
:medium="medium"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -139,6 +139,19 @@ watch(
.editor-wrapper {
::v-deep {
.ProseMirror-menubar-wrapper {
@apply gap-2 !important;
.ProseMirror-menubar {
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !top-0 !relative !important;
.ProseMirror-menuitem {
@apply h-5 !important;
}
.ProseMirror-icon {
@apply p-1 w-3 h-3 text-n-slate-12 dark:text-n-slate-12 !important;
}
}
.ProseMirror.ProseMirror-woot-style {
p {
@apply first:mt-0 !important;
@@ -172,7 +172,7 @@ const previewArticle = () => {
@apply mr-0;
.ProseMirror-icon {
@apply p-0 mt-0 !mr-0;
@apply p-0 mt-1 !mr-0;
svg {
width: 20px !important;
@@ -7,7 +7,7 @@ import { vOnClickOutside } from '@vueuse/components';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
@@ -50,6 +50,12 @@ const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
);
const signatureToApply = computed(() =>
props.isEmailOrWebWidgetInbox
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature)
);
const {
fetchSignatureFlagFromUISettings,
setSignatureFlagForInbox,
@@ -74,20 +80,12 @@ const isRegularMessageMode = computed(() => {
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
});
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
const shouldShowSignatureButton = computed(() => {
return (
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
);
});
const setSignature = () => {
if (props.messageSignature) {
if (signatureToApply.value) {
if (sendWithSignature.value) {
emit('addSignature', props.messageSignature);
emit('addSignature', signatureToApply.value);
} else {
emit('removeSignature', props.messageSignature);
emit('removeSignature', signatureToApply.value);
}
}
};
@@ -103,7 +101,7 @@ watch(
() => props.hasSelectedInbox,
newValue => {
nextTick(() => {
if (newValue && !isVoiceInbox.value) setSignature();
if (newValue && props.isEmailOrWebWidgetInbox) setSignature();
});
},
{ immediate: true }
@@ -222,7 +220,7 @@ useKeyboardEvents(keyboardEvents);
/>
</FileUpload>
<Button
v-if="shouldShowSignatureButton"
v-if="hasSelectedInbox && isRegularMessageMode"
icon="i-lucide-signature"
color="slate"
size="sm"
@@ -39,7 +39,7 @@ const removeAttachment = id => {
</script>
<template>
<div class="flex flex-col gap-4 p-4 max-h-48 overflow-y-auto">
<div class="flex flex-col gap-4 p-4">
<div
v-if="filteredImageAttachments.length > 0"
class="flex flex-wrap gap-3"
@@ -6,7 +6,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
getEffectiveChannelType,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import {
buildContactableInboxesList,
@@ -87,12 +87,6 @@ const whatsappMessageTemplates = computed(() =>
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const inboxMedium = computed(() => props.targetInbox?.medium || '');
const effectiveChannelType = computed(() =>
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
);
const validationRules = computed(() => ({
selectedContact: { required },
targetInbox: { required },
@@ -200,7 +194,6 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
const handleInboxAction = ({ value, action, ...rest }) => {
v$.value.$reset();
state.message = '';
emit('updateTargetInbox', { ...rest });
showInboxesDropdown.value = false;
state.attachedFiles = [];
@@ -209,28 +202,25 @@ const handleInboxAction = ({ value, action, ...rest }) => {
const removeSignatureFromMessage = () => {
// Always remove the signature from message content when inbox/contact is removed
// to ensure no leftover signature content remains
if (props.messageSignature) {
state.message = removeSignature(
state.message,
props.messageSignature,
effectiveChannelType.value
);
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature);
if (signatureToRemove) {
state.message = removeSignature(state.message, signatureToRemove);
}
};
const removeTargetInbox = value => {
v$.value.$reset();
removeSignatureFromMessage();
state.message = '';
emit('updateTargetInbox', value);
state.attachedFiles = [];
};
const clearSelectedContact = () => {
removeSignatureFromMessage();
emit('clearSelectedContact');
state.message = '';
state.attachedFiles = [];
removeSignatureFromMessage();
};
const onClickInsertEmoji = emoji => {
@@ -238,19 +228,11 @@ const onClickInsertEmoji = emoji => {
};
const handleAddSignature = signature => {
state.message = appendSignature(
state.message,
signature,
effectiveChannelType.value
);
state.message = appendSignature(state.message, signature);
};
const handleRemoveSignature = signature => {
state.message = removeSignature(
state.message,
signature,
effectiveChannelType.value
);
state.message = removeSignature(state.message, signature);
};
const handleAttachFile = files => {
@@ -374,10 +356,10 @@ const shouldShowMessageEditor = computed(() => {
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0"
:channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''"
/>
<AttachmentPreviews
@@ -1,49 +1,127 @@
<script setup>
import { computed } from 'vue';
import { ref, watch, computed, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import {
appendSignature,
extractTextFromMarkdown,
removeSignature,
} from 'dashboard/helper/editorHelper';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
const props = defineProps({
isEmailOrWebWidgetInbox: { type: Boolean, required: true },
hasErrors: { type: Boolean, default: false },
hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
medium: { type: String, default: '' },
});
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
const { t } = useI18n();
const modelValue = defineModel({
type: String,
default: '',
});
const state = ref({
hasSlashCommand: false,
showMentions: false,
mentionSearchKey: '',
});
const plainTextSignature = computed(() =>
extractTextFromMarkdown(props.messageSignature)
);
watch(
modelValue,
newValue => {
if (props.isEmailOrWebWidgetInbox) return;
const bodyWithoutSignature = newValue
? removeSignature(newValue, plainTextSignature.value)
: '';
// Check if message starts with slash
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Update slash command and mentions state
state.value = {
...state.value,
hasSlashCommand: startsWithSlash,
showMentions: startsWithSlash,
mentionSearchKey: startsWithSlash ? bodyWithoutSignature.slice(1) : '',
};
},
{ immediate: true }
);
const hideMention = () => {
state.value.showMentions = false;
};
const replaceText = async message => {
// Only append signature on replace if sendWithSignature is true
const finalMessage = props.sendWithSignature
? appendSignature(message, plainTextSignature.value)
: message;
await nextTick();
modelValue.value = finalMessage;
};
</script>
<template>
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
<Editor
:key="editorKey"
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
:medium="medium"
/>
<template v-if="isEmailOrWebWidgetInbox">
<Editor
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
/>
</template>
<template v-else>
<TextArea
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
:custom-text-area-class="
hasErrors
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
: ''
"
auto-height
allow-signature
:signature="messageSignature"
:send-with-signature="sendWithSignature"
>
<CannedResponse
v-if="state.showMentions && state.hasSlashCommand"
v-on-clickaway="hideMention"
class="normal-editor__canned-box"
:search-key="state.mentionSearchKey"
@replace="replaceText"
/>
</TextArea>
</template>
</div>
</template>
@@ -118,7 +118,6 @@ watch(
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
:message="formErrors.description"
:message-type="formErrors.description ? 'error' : 'info'"
class="z-0"
/>
<div class="flex flex-col gap-2">
@@ -108,7 +108,6 @@ watch(
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
:message="formErrors.handoffMessage"
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
class="z-0"
/>
<Editor
@@ -117,7 +116,6 @@ watch(
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
:message="formErrors.resolutionMessage"
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
class="z-0"
/>
<Editor
@@ -128,7 +126,6 @@ watch(
:message="formErrors.instructions"
:max-length="20000"
:message-type="formErrors.instructions ? 'error' : 'info'"
class="z-0"
/>
<div class="flex flex-col gap-2">
@@ -299,12 +299,7 @@ const componentToRender = computed(() => {
return DyteBubble;
}
const instagramSharedTypes = [
ATTACHMENT_TYPES.STORY_MENTION,
ATTACHMENT_TYPES.IG_STORY,
ATTACHMENT_TYPES.IG_POST,
];
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
if (props.contentAttributes.imageType === 'story_mention') {
return InstagramStoryBubble;
}
@@ -481,7 +476,7 @@ provideMessageContext({
<div
v-if="shouldRenderMessage"
:id="`message${props.id}`"
class="flex mb-2 w-full message-bubble-container"
class="flex w-full message-bubble-container mb-2"
:data-message-id="props.id"
:class="[
flexOrientationClass,
@@ -1,8 +1,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
import BaseBubble from './Base.vue';
import Button from 'next/button/Button.vue';
import Icon from 'next/icon/Icon.vue';
@@ -12,6 +11,7 @@ import { downloadFile } from '@chatwoot/utils';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
const emit = defineEmits(['error']);
const { t } = useI18n();
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
@@ -20,16 +20,14 @@ const attachment = computed(() => {
return attachments.value[0];
});
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry();
const hasError = ref(false);
const showGallery = ref(false);
const isDownloading = ref(false);
onMounted(() => {
if (attachment.value?.dataUrl) {
loadWithRetry(attachment.value.dataUrl);
}
});
const handleError = () => {
hasError.value = true;
emit('error');
};
const downloadAttachment = async () => {
const { fileType, dataUrl, extension } = attachment.value;
@@ -42,10 +40,6 @@ const downloadAttachment = async () => {
isDownloading.value = false;
}
};
const handleImageError = () => {
hasError.value = true;
};
</script>
<template>
@@ -60,12 +54,14 @@ const handleImageError = () => {
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
</p>
</div>
<div v-else-if="isLoaded" class="relative group rounded-lg overflow-hidden">
<div v-else class="relative group rounded-lg overflow-hidden">
<img
class="skip-context-menu"
:src="attachment.dataUrl"
:width="attachment.width"
:height="attachment.height"
@click="onClick"
@error="handleError"
/>
<div
class="inset-0 p-2 pointer-events-none absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex"
@@ -90,7 +86,7 @@ const handleImageError = () => {
v-model:show="showGallery"
:attachment="useSnakeCase(attachment)"
:all-attachments="filteredCurrentChatAttachments"
@error="handleImageError"
@error="handleError"
@close="() => (showGallery = false)"
/>
</template>
@@ -1,102 +1,41 @@
<script setup>
import { computed } from 'vue';
import { useMessageContext } from '../provider.js';
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import { useMessageContext } from '../provider.js';
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
const LABEL_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const SUBTEXT_MAP = {
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
[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 BG_COLOR_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9',
[VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse',
[VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
};
const { contentAttributes, messageType } = useMessageContext();
const { contentAttributes } = useMessageContext();
const data = computed(() => contentAttributes.value?.data);
const status = computed(() => data.value?.status?.toString());
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
const status = computed(() => data.value?.status);
const direction = computed(() => data.value?.call_direction);
const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
if (status.value === 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 { labelKey, subtextKey, bubbleIconBg, bubbleIconName } =
useVoiceCallStatus(status, direction);
const containerRingClass = computed(() => {
return status.value === 'ringing' ? 'ring-1 ring-emerald-300' : '';
});
const subtextKey = computed(() => {
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
});
const iconName = computed(() => {
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
</script>
<template>
<BaseBubble class="p-0 border-none" hide-meta>
<div class="flex overflow-hidden flex-col w-full max-w-xs">
<div
class="flex overflow-hidden flex-col w-full max-w-xs bg-white rounded-lg border border-slate-100 text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
:class="containerRingClass"
>
<div class="flex gap-3 items-center p-3 w-full">
<div
class="flex justify-center items-center rounded-full size-10 shrink-0"
:class="bgColor"
:class="bubbleIconBg"
>
<Icon
class="size-5"
:icon="iconName"
:class="{
'text-n-slate-1': status === VOICE_CALL_STATUS.COMPLETED,
'text-white': status !== VOICE_CALL_STATUS.COMPLETED,
}"
/>
<span class="text-xl" :class="bubbleIconName" />
</div>
<div class="flex overflow-hidden flex-col flex-grow">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ $t(labelKey) }}
</span>
<span class="text-xs text-n-slate-11">
{{ $t(subtextKey) }}
</span>
<span class="text-base font-medium truncate">{{ $t(labelKey) }}</span>
<span class="text-xs text-slate-500">{{ $t(subtextKey) }}</span>
</div>
</div>
</div>
@@ -49,8 +49,6 @@ export const ATTACHMENT_TYPES = {
STORY_MENTION: 'story_mention',
CONTACT: 'contact',
IG_REEL: 'ig_reel',
IG_POST: 'ig_post',
IG_STORY: 'ig_story',
};
export const CONTENT_TYPES = {
@@ -75,16 +73,3 @@ export const MEDIA_TYPES = [
ATTACHMENT_TYPES.AUDIO,
ATTACHMENT_TYPES.IG_REEL,
];
export const VOICE_CALL_STATUS = {
IN_PROGRESS: 'in-progress',
RINGING: 'ringing',
COMPLETED: 'completed',
NO_ANSWER: 'no-answer',
FAILED: 'failed',
};
export const VOICE_CALL_DIRECTION = {
INBOUND: 'inbound',
OUTBOUND: 'outbound',
};
@@ -1,7 +1,6 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useNumberFormatter } from 'shared/composables/useNumberFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -25,7 +24,6 @@ const props = defineProps({
});
const emit = defineEmits(['update:currentPage']);
const { t } = useI18n();
const { formatCompactNumber, formatFullNumber } = useNumberFormatter();
const totalPages = computed(() =>
Math.ceil(props.totalItems / props.itemsPerPage)
@@ -45,27 +43,21 @@ const changePage = newPage => {
};
const currentPageInformation = computed(() => {
const translationKey = props.currentPageInfo || 'PAGINATION_FOOTER.SHOWING';
return t(
translationKey,
props.currentPageInfo ? props.currentPageInfo : 'PAGINATION_FOOTER.SHOWING',
{
startItem: formatFullNumber(startItem.value),
endItem: formatFullNumber(endItem.value),
totalItems: formatCompactNumber(props.totalItems),
},
Number(props.totalItems)
startItem: startItem.value,
endItem: endItem.value,
totalItems: props.totalItems,
}
);
});
const pageInfo = computed(() => {
return t(
'PAGINATION_FOOTER.CURRENT_PAGE_INFO',
{
currentPage: '',
totalPages: formatCompactNumber(totalPages.value),
},
Number(totalPages.value)
);
return t('PAGINATION_FOOTER.CURRENT_PAGE_INFO', {
currentPage: '',
totalPages: totalPages.value,
});
});
</script>
@@ -99,11 +91,9 @@ const pageInfo = computed(() => {
/>
<div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
{{ formatFullNumber(currentPage) }}
</span>
<span class="truncate">
{{ pageInfo }}
{{ currentPage }}
</span>
<span class="truncate">{{ pageInfo }}</span>
</div>
<Button
icon="i-lucide-chevron-right"
@@ -14,7 +14,6 @@ import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue';
import SidebarChangelogCard from './SidebarChangelogCard.vue';
import YearInReviewBanner from '../year-in-review/YearInReviewBanner.vue';
import ChannelLeaf from './ChannelLeaf.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
@@ -352,23 +351,6 @@ const menuItems = computed(() => {
},
],
},
{
name: 'Companies',
label: t('SIDEBAR.COMPANIES'),
icon: 'i-lucide-building-2',
children: [
{
name: 'All Companies',
label: t('SIDEBAR.ALL_COMPANIES'),
to: accountScopedRoute(
'companies_dashboard_index',
{},
{ page: 1, search: undefined }
),
activeOn: ['companies_dashboard_index'],
},
],
},
{
name: 'Reports',
label: t('SIDEBAR.REPORTS'),
@@ -652,7 +634,6 @@ const menuItems = computed(() => {
<div
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
/>
<YearInReviewBanner />
<SidebarChangelogCard
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
/>
@@ -1,14 +1,11 @@
<script setup>
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import { computed } from 'vue';
import Auth from 'dashboard/api/auth';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Avatar from 'next/avatar/Avatar.vue';
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import YearInReviewModal from 'dashboard/components-next/year-in-review/YearInReviewModal.vue';
import {
DropdownContainer,
@@ -25,8 +22,6 @@ defineOptions({
});
const { t } = useI18n();
const route = useRoute();
const { uiSettings } = useUISettings();
const currentUser = useMapGetter('getCurrentUser');
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
@@ -36,29 +31,6 @@ const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showYearInReviewModal = ref(false);
const bannerClosedKey = computed(() => {
return `yir_closed_${accountId.value}_2025`;
});
const isBannerClosed = computed(() => {
return uiSettings.value?.[bannerClosedKey.value] === true;
});
const showYearInReviewMenuItem = computed(() => {
return route.query['year-in-review'] === 'true' && isBannerClosed.value;
});
const openYearInReviewModal = () => {
showYearInReviewModal.value = true;
emit('close');
};
const closeYearInReviewModal = () => {
showYearInReviewModal.value = false;
};
const showChatSupport = computed(() => {
return (
isFeatureEnabledonAccount.value(
@@ -70,13 +42,6 @@ const showChatSupport = computed(() => {
const menuItems = computed(() => {
return [
{
show: showYearInReviewMenuItem.value,
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.YEAR_IN_REVIEW'),
icon: 'i-lucide-gift',
click: openYearInReviewModal,
},
{
show: showChatSupport.value,
showOnCustomBrandedInstance: false,
@@ -192,9 +157,4 @@ const allowedMenuItems = computed(() => {
</template>
</DropdownBody>
</DropdownContainer>
<YearInReviewModal
:show="showYearInReviewModal"
@close="closeYearInReviewModal"
/>
</template>
@@ -1,247 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { toPng } from 'html-to-image';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
slideElement: {
type: Object,
default: null,
},
slideBackground: {
type: String,
required: true,
},
year: {
type: [Number, String],
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const isGenerating = ref(false);
const shareImageUrl = ref(null);
const shareImageBlob = ref(null);
const generateImage = async () => {
if (!props.slideElement) return;
isGenerating.value = true;
try {
let slideElement = props.slideElement;
if (slideElement && '$el' in slideElement) {
slideElement = slideElement.$el;
}
if (!slideElement) {
// eslint-disable-next-line no-console
console.error('No slide element found');
return;
}
const colorMap = {
'bg-[#5BD58A]': '#5BD58A',
'bg-[#60a5fa]': '#60a5fa',
'bg-[#fb923c]': '#fb923c',
'bg-[#f87171]': '#f87171',
'bg-[#fbbf24]': '#fbbf24',
};
const bgColor = colorMap[props.slideBackground] || '#ffffff';
const dataUrl = await toPng(slideElement, {
quality: 2,
pixelRatio: 1.2,
backgroundColor: bgColor,
});
const img = new Image();
img.src = dataUrl;
await new Promise(resolve => {
img.onload = resolve;
});
const finalCanvas = document.createElement('canvas');
const borderSize = 20;
const bottomPadding = 50;
finalCanvas.width = img.width + borderSize * 2;
finalCanvas.height = img.height + borderSize * 2 + bottomPadding;
const ctx = finalCanvas.getContext('2d');
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, finalCanvas.width, finalCanvas.height);
ctx.drawImage(img, borderSize, borderSize);
ctx.fillStyle = '#1f2d3d';
ctx.font = 'normal 16px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(
t('YEAR_IN_REVIEW.SHARE_MODAL.BRANDING'),
borderSize,
img.height + borderSize + 35
);
const logo = new Image();
logo.src = window.location.origin + '/brand-assets/logo.svg';
await new Promise(resolve => {
logo.onload = resolve;
});
const logoHeight = 30;
const logoWidth = (logo.width / logo.height) * logoHeight;
const logoX = finalCanvas.width - borderSize - logoWidth;
const logoY = img.height + borderSize + 15;
ctx.drawImage(logo, logoX, logoY, logoWidth, logoHeight);
shareImageUrl.value = finalCanvas.toDataURL('image/png');
shareImageBlob.value = await new Promise(resolve => {
finalCanvas.toBlob(resolve, 'image/png');
});
} catch (err) {
// Handle errors silently for now
// eslint-disable-next-line no-console
console.error('Failed to generate image:', err);
} finally {
isGenerating.value = false;
}
};
const downloadImage = () => {
if (!shareImageUrl.value) return;
const link = document.createElement('a');
link.href = shareImageUrl.value;
link.download = `chatwoot-year-in-review-${props.year}.png`;
link.click();
};
const shareImage = async () => {
if (!shareImageUrl.value) return;
try {
if (!shareImageBlob.value) {
downloadImage();
return;
}
const file = new File(
[shareImageBlob.value],
`chatwoot-year-in-review-${props.year}.png`,
{
type: 'image/png',
}
);
if (
navigator.share &&
navigator.canShare &&
navigator.canShare({ files: [file] })
) {
await navigator.share({
title: t('YEAR_IN_REVIEW.SHARE_MODAL.SHARE_TITLE', {
year: props.year,
}),
text: t('YEAR_IN_REVIEW.SHARE_MODAL.SHARE_TEXT', { year: props.year }),
files: [file],
});
return;
}
downloadImage();
} catch (err) {
// Fallback to download if sharing fails
downloadImage();
}
};
const close = () => {
shareImageUrl.value = null;
shareImageBlob.value = null;
emit('close');
};
const handleOpen = async () => {
if (props.show && !shareImageUrl.value) {
await generateImage();
}
};
defineExpose({ handleOpen });
</script>
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-[10001]"
@click="close"
>
<div v-if="isGenerating" class="flex items-center justify-center">
<div class="text-center">
<div
class="inline-block w-12 h-12 border-4 rounded-full border-white border-t-transparent animate-spin"
/>
<p class="mt-4 text-sm text-white">
{{ t('YEAR_IN_REVIEW.SHARE_MODAL.PREPARING') }}
</p>
</div>
</div>
<div
v-else-if="shareImageUrl"
class="max-w-2xl w-full mx-4 flex flex-col gap-6 bg-slate-800 rounded-2xl p-6"
@click.stop
>
<div class="flex items-center justify-between">
<h3 class="text-xl font-medium text-white">
{{ t('YEAR_IN_REVIEW.SHARE_MODAL.TITLE') }}
</h3>
<button
class="w-10 h-10 flex items-center justify-center rounded-full text-white hover:bg-white hover:bg-opacity-20 transition-colors"
@click="close"
>
<i class="i-lucide-x w-6 h-6" />
</button>
</div>
<div>
<img
:src="shareImageUrl"
alt="Year in Review"
class="w-full h-auto"
/>
</div>
<div class="flex gap-3">
<button
class="flex-[2] px-4 py-3 flex items-center justify-center gap-2 rounded-full text-white bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="downloadImage"
>
<i class="i-lucide-download w-5 h-5" />
<span class="text-sm font-medium">{{
t('YEAR_IN_REVIEW.SHARE_MODAL.DOWNLOAD')
}}</span>
</button>
<button
class="w-10 h-10 flex items-center justify-center rounded-full text-white bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="shareImage"
>
<i class="i-lucide-share-2 w-5 h-5" />
</button>
</div>
</div>
</div>
</Teleport>
</template>
@@ -1,93 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useStoreGetters } from 'dashboard/composables/store';
import YearInReviewModal from './YearInReviewModal.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const yearInReviewBannerImage =
'/assets/images/dashboard/year-in-review/year-in-review-sidebar.png';
const route = useRoute();
const { t } = useI18n();
const { uiSettings, updateUISettings } = useUISettings();
const getters = useStoreGetters();
const showModal = ref(false);
const modalRef = ref(null);
const currentYear = 2025;
const isACustomBrandedInstance =
getters['globalConfig/isACustomBrandedInstance'];
const bannerClosedKey = computed(() => {
const accountId = getters.getCurrentAccountId.value;
return `yir_closed_${accountId}_${currentYear}`;
});
const isBannerClosed = computed(() => {
return uiSettings.value?.[bannerClosedKey.value] === true;
});
const shouldShowBanner = computed(
() =>
route.query['year-in-review'] === 'true' &&
!isBannerClosed.value &&
!isACustomBrandedInstance.value
);
const openModal = () => {
showModal.value = true;
};
const closeModal = () => {
showModal.value = false;
};
const closeBanner = event => {
event.stopPropagation();
updateUISettings({ [bannerClosedKey.value]: true });
};
</script>
<template>
<div v-if="shouldShowBanner" class="relative">
<div
class="mx-2 my-1 p-3 bg-n-iris-9 rounded-lg cursor-pointer hover:shadow-md transition-all"
@click="openModal"
>
<div class="flex items-start justify-between gap-2 mb-3">
<span
class="text-sm font-semibold text-white leading-tight tracking-tight flex-1"
>
{{ t('YEAR_IN_REVIEW.BANNER.TITLE', { year: currentYear }) }}
</span>
<button
class="inline-flex items-center justify-center rounded hover:bg-white hover:bg-opacity-20 transition-colors p-0"
@click="closeBanner"
>
<Icon
icon="i-lucide-x size-4 mt-0.5 text-n-slate-1 dark:text-n-slate-12"
/>
</button>
</div>
<div class="flex flex-col gap-3">
<img
:src="yearInReviewBannerImage"
alt="Year in Review"
class="w-full h-auto rounded"
/>
<button
class="w-full px-3 py-2 bg-white text-n-iris-9 text-xs font-medium rounded-mdtracking-tight"
@click.stop="openModal"
>
{{ t('YEAR_IN_REVIEW.BANNER.BUTTON') }}
</button>
</div>
</div>
<YearInReviewModal ref="modalRef" :show="showModal" @close="closeModal" />
</div>
</template>
@@ -1,384 +0,0 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import YearInReviewAPI from 'dashboard/api/yearInReview';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useStoreGetters } from 'dashboard/composables/store';
import IntroSlide from './slides/IntroSlide.vue';
import ConversationsSlide from './slides/ConversationsSlide.vue';
import BusiestDaySlide from './slides/BusiestDaySlide.vue';
import PersonalitySlide from './slides/PersonalitySlide.vue';
import ThankYouSlide from './slides/ThankYouSlide.vue';
import ShareModal from './ShareModal.vue';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const { uiSettings } = useUISettings();
const getters = useStoreGetters();
const isOpen = ref(false);
const currentSlide = ref(0);
const yearData = ref(null);
const isLoading = ref(false);
const error = ref(null);
const slideRefs = ref([]);
const showShareModal = ref(false);
const shareModalRef = ref(null);
const drumrollAudio = ref(null);
const hasConversations = computed(() => {
return yearData.value?.total_conversations > 0;
});
const totalSlides = computed(() => {
if (!hasConversations.value) {
return 3;
}
return 5;
});
const slideIndexMap = computed(() => {
if (!hasConversations.value) {
return [0, 1, 4];
}
return [0, 1, 2, 3, 4];
});
const currentVisualSlide = computed(() => {
return slideIndexMap.value.indexOf(currentSlide.value);
});
const slideBackgrounds = [
'bg-[#5BD58A]',
'bg-[#60a5fa]',
'bg-[#fb923c]',
'bg-[#f87171]',
'bg-[#fbbf24]',
];
const playDrumroll = () => {
try {
if (!drumrollAudio.value) {
drumrollAudio.value = new Audio('/audio/dashboard/drumroll.mp3');
drumrollAudio.value.volume = 0.5;
}
drumrollAudio.value.currentTime = 0;
drumrollAudio.value.play().catch(err => {
// eslint-disable-next-line no-console
console.log('Could not play drumroll:', err);
});
} catch (err) {
// eslint-disable-next-line no-console
console.log('Error playing drumroll:', err);
}
};
const fetchYearInReviewData = async () => {
const year = 2025;
const accountId = getters.getCurrentAccountId.value;
const cacheKey = `year_in_review_${accountId}_${year}`;
const cachedData = uiSettings.value?.[cacheKey];
if (cachedData) {
yearData.value = cachedData;
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await YearInReviewAPI.get(year);
yearData.value = response.data;
} catch (err) {
error.value = err.message;
} finally {
isLoading.value = false;
}
};
const nextSlide = () => {
if (currentSlide.value < 4) {
if (!hasConversations.value && currentSlide.value === 1) {
currentSlide.value = 4;
} else {
currentSlide.value += 1;
}
}
};
const previousSlide = () => {
if (currentSlide.value > 0) {
if (!hasConversations.value && currentSlide.value === 4) {
currentSlide.value = 1;
} else {
currentSlide.value -= 1;
}
}
};
const goToSlide = visualIndex => {
currentSlide.value = slideIndexMap.value[visualIndex];
};
const close = () => {
currentSlide.value = 0;
isOpen.value = false;
yearData.value = null;
isLoading.value = false;
error.value = null;
emit('close');
};
const open = () => {
isOpen.value = true;
fetchYearInReviewData();
playDrumroll();
};
const currentSlideBackground = computed(
() => slideBackgrounds[currentSlide.value]
);
const shareCurrentSlide = async () => {
showShareModal.value = true;
nextTick(() => {
if (shareModalRef.value) {
shareModalRef.value.handleOpen();
}
});
};
const closeShareModal = () => {
showShareModal.value = false;
};
const keyboardEvents = {
Escape: { action: close },
ArrowLeft: { action: previousSlide },
ArrowRight: { action: nextSlide },
};
useKeyboardEvents(keyboardEvents);
defineExpose({ open, close });
watch(
() => props.show,
newValue => {
if (newValue) {
open();
}
}
);
</script>
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-[9999] bg-black font-interDisplay"
>
<div class="relative w-full h-full overflow-hidden">
<div
v-if="isLoading"
class="flex items-center justify-center w-full h-full bg-n-slate-2"
>
<div class="text-center">
<div
class="inline-block w-12 h-12 border-4 rounded-full border-n-slate-6 border-t-n-slate-11 animate-spin"
/>
<p class="mt-4 text-sm text-n-slate-11">
{{ t('YEAR_IN_REVIEW.LOADING') }}
</p>
</div>
</div>
<div
v-else-if="error"
class="flex items-center justify-center w-full h-full bg-n-slate-2"
>
<div class="text-center">
<p class="text-lg font-semibold text-red-600">
{{ t('YEAR_IN_REVIEW.ERROR') }}
</p>
<p class="mt-2 text-sm text-n-slate-11">{{ error }}</p>
<button
class="mt-4 px-4 py-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="close"
>
<span class="text-sm font-medium">{{
t('YEAR_IN_REVIEW.CLOSE')
}}</span>
</button>
</div>
</div>
<div
v-else-if="yearData"
class="relative w-full h-full"
:class="currentSlideBackground"
>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<IntroSlide
v-if="currentSlide === 0"
:key="0"
:ref="el => (slideRefs[0] = el)"
:year="yearData.year"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<ConversationsSlide
v-if="currentSlide === 1"
:key="1"
:ref="el => (slideRefs[1] = el)"
:total-conversations="yearData.total_conversations"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<BusiestDaySlide
v-if="
currentSlide === 2 && hasConversations && yearData.busiest_day
"
:key="2"
:ref="el => (slideRefs[2] = el)"
:busiest-day="yearData.busiest_day"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<PersonalitySlide
v-if="
currentSlide === 3 &&
hasConversations &&
yearData.support_personality
"
:key="3"
:ref="el => (slideRefs[3] = el)"
:support-personality="yearData.support_personality"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<ThankYouSlide
v-if="currentSlide === 4"
:key="4"
:ref="el => (slideRefs[4] = el)"
:year="yearData.year"
/>
</Transition>
<div
class="absolute bottom-8 left-0 right-0 flex items-center justify-between px-8"
>
<button
v-if="currentSlide > 0"
class="px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="previousSlide"
>
<i class="i-lucide-chevron-left w-5 h-5" />
<span class="text-sm font-medium">
{{ t('YEAR_IN_REVIEW.NAVIGATION.PREVIOUS') }}
</span>
</button>
<div v-else class="w-20" />
<div class="flex gap-2">
<button
v-for="index in totalSlides"
:key="index"
class="w-2 h-2 rounded-full transition-all"
:class="
currentVisualSlide === index - 1
? 'bg-white w-8'
: 'bg-white bg-opacity-50'
"
@click="goToSlide(index - 1)"
/>
</div>
<button
class="px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
:class="{ invisible: currentVisualSlide === totalSlides - 1 }"
@click="nextSlide"
>
<span
v-if="currentVisualSlide < totalSlides - 1"
class="text-sm font-medium"
>
{{ t('YEAR_IN_REVIEW.NAVIGATION.NEXT') }}
</span>
<i
v-if="currentVisualSlide < totalSlides - 1"
class="i-lucide-chevron-right w-5 h-5"
/>
</button>
</div>
<button
class="absolute top-4 left-4 px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="shareCurrentSlide"
>
<i class="i-lucide-share-2 w-5 h-5" />
<span class="text-sm font-medium">{{
t('YEAR_IN_REVIEW.NAVIGATION.SHARE')
}}</span>
</button>
<button
class="absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full text-n-slate-12 dark:text-n-slate-1 hover:bg-white hover:bg-opacity-20 transition-colors"
@click="close"
>
<i class="i-lucide-x w-6 h-6" />
</button>
</div>
</div>
</div>
<ShareModal
ref="shareModalRef"
:show="showShareModal"
:slide-element="slideRefs[currentSlide]"
:slide-background="currentSlideBackground"
:year="yearData?.year"
@close="closeShareModal"
/>
</Teleport>
</template>
@@ -1,76 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
busiestDay: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const coffeeImage =
'/assets/images/dashboard/year-in-review/third-frame-coffee.png';
const doubleQuotesImage =
'/assets/images/dashboard/year-in-review/double-quotes.png';
const performanceHelperText = computed(() => {
const count = props.busiestDay.count;
if (count <= 5) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.0_5');
if (count <= 10) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.5_10');
if (count <= 25) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.10_25');
if (count <= 50) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.25_50');
if (count <= 100) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.50_100');
if (count <= 500) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.100_500');
return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.500_PLUS');
});
</script>
<template>
<div class="absolute inset-0 flex items-center justify-center px-8 md:px-32">
<div class="flex flex-col gap-4 w-full max-w-3xl">
<div class="flex items-center justify-center flex-1">
<div class="flex items-center justify-between gap-6 flex-1 md:gap-16">
<div class="text-white flex gap-2 flex-col">
<div class="text-2xl lg:text-3xl xl:text-4xl tracking-tight">
{{ t('YEAR_IN_REVIEW.BUSIEST_DAY.TITLE') }}
</div>
<div class="text-6xl md:text-8xl lg:text-[140px] tracking-tighter">
{{ busiestDay.date }}
</div>
</div>
<img
:src="coffeeImage"
alt="Coffee"
class="w-auto h-32 md:h-56 lg:h-72"
/>
</div>
</div>
<div class="flex flex-col gap-2 flex-1">
<div class="flex items-center justify-center gap-3 md:gap-8">
<img
:src="doubleQuotesImage"
alt="Quote"
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
/>
<div class="flex-1">
<p
class="text-xl md:text-3xl lg:text-4xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
>
{{
t('YEAR_IN_REVIEW.BUSIEST_DAY.MESSAGE', {
count: busiestDay.count,
})
}}
{{ performanceHelperText }}
</p>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -1,94 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
totalConversations: {
type: Number,
required: true,
},
});
const { t } = useI18n();
const cloudImage =
'/assets/images/dashboard/year-in-review/second-frame-cloud-icon.png';
const doubleQuotesImage =
'/assets/images/dashboard/year-in-review/double-quotes.png';
const hasData = computed(() => {
return props.totalConversations > 0;
});
const formatNumber = num => {
if (num >= 100000) {
return '100k+';
}
return new Intl.NumberFormat().format(num);
};
const performanceHelperText = computed(() => {
if (!hasData.value) {
return t('YEAR_IN_REVIEW.CONVERSATIONS.FALLBACK');
}
const count = props.totalConversations;
if (count <= 50) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.0_50');
if (count <= 100) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.50_100');
if (count <= 500) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.100_500');
if (count <= 2000)
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.500_2000');
if (count <= 10000)
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.2000_10000');
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.10000_PLUS');
});
</script>
<template>
<div
class="absolute inset-0 flex items-center justify-center px-8 md:px-32 py-20"
>
<div
class="flex flex-col gap-16"
:class="totalConversations > 100 ? 'max-w-4xl' : 'max-w-3xl'"
>
<div class="flex items-center justify-center flex-1">
<div
class="flex items-center justify-between gap-6 flex-1"
:class="totalConversations > 100 ? 'md:gap-16' : 'md:gap-8'"
>
<div class="text-white flex gap-3 flex-col">
<div class="text-2xl md:text-3xl lg:text-4xl tracking-tight">
{{ t('YEAR_IN_REVIEW.CONVERSATIONS.TITLE') }}
</div>
<div class="text-6xl md:text-8xl lg:text-[180px] tracking-tighter">
{{ formatNumber(totalConversations) }}
</div>
<div class="text-2xl md:text-3xl lg:text-4xl tracking-tight -mt-2">
{{ t('YEAR_IN_REVIEW.CONVERSATIONS.SUBTITLE') }}
</div>
</div>
<img
:src="cloudImage"
alt="Cloud"
class="w-auto h-32 md:h-56 lg:h-80 -mr-2"
/>
</div>
</div>
<div class="flex items-center justify-center gap-3 md:gap-6">
<img
:src="doubleQuotesImage"
alt="Quote"
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
/>
<p
class="text-xl md:text-3xl lg:text-4xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
>
{{ performanceHelperText }}
</p>
</div>
</div>
</div>
</template>
@@ -1,43 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
defineProps({
year: {
type: Number,
required: true,
},
});
const candlesImagePath =
'/assets/images/dashboard/year-in-review/first-frame-candles.png';
const { t } = useI18n();
</script>
<template>
<div
class="absolute inset-0 flex flex-col items-center justify-center text-black px-8 md:px-16 lg:px-24 py-10 md:py-16 lg:py-20 bg-cover bg-center min-h-[700px]"
:style="{
backgroundImage: `url('/assets/images/dashboard/year-in-review/first-frame-bg.png')`,
}"
>
<div class="text-center max-w-3xl">
<h1
class="text-8xl md:text-9xl lg:text-[220px] font-semibold mb-4 md:mb-6 leading-none tracking-tight text-n-slate-12 dark:text-n-slate-1"
>
{{ year }}
</h1>
<h2
class="text-3xl md:text-4xl lg:text-5xl font-medium mb-12 md:mb-16 lg:mb-20 text-n-slate-12 dark:text-n-slate-1"
>
{{ t('YEAR_IN_REVIEW.TITLE') }}
</h2>
</div>
<img
:src="candlesImagePath"
alt="Candles"
class="absolute bottom-0 left-1/2 transform -translate-x-1/2 w-auto h-32 md:h-48 lg:h-64"
/>
</div>
</template>
@@ -1,99 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
supportPersonality: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const clockImage =
'/assets/images/dashboard/year-in-review/fourth-frame-clock.png';
const doubleQuotesImage =
'/assets/images/dashboard/year-in-review/double-quotes.png';
const formatResponseTime = seconds => {
if (seconds < 60) {
return 'less than a minute';
}
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
return minutes === 1 ? '1 minute' : `${minutes} minutes`;
}
if (seconds < 86400) {
const hours = Math.floor(seconds / 3600);
return hours === 1 ? '1 hour' : `${hours} hours`;
}
return 'more than a day';
};
const personality = computed(() => {
const seconds = props.supportPersonality.avg_response_time_seconds;
const minutes = seconds / 60;
if (minutes < 2) {
return 'Swift Helper';
}
if (minutes < 5) {
return 'Quick Responder';
}
if (minutes < 15) {
return 'Steady Support';
}
return 'Thoughtful Advisor';
});
const personalityMessage = computed(() => {
const seconds = props.supportPersonality.avg_response_time_seconds;
const time = formatResponseTime(seconds);
const personalityKeyMap = {
'Swift Helper': 'SWIFT_HELPER',
'Quick Responder': 'QUICK_RESPONDER',
'Steady Support': 'STEADY_SUPPORT',
'Thoughtful Advisor': 'THOUGHTFUL_ADVISOR',
};
const key = personalityKeyMap[personality.value];
if (!key) return '';
return t(`YEAR_IN_REVIEW.PERSONALITY.MESSAGES.${key}`, { time });
});
</script>
<template>
<div class="absolute inset-0 flex items-center justify-center px-8 md:px-32">
<div class="flex flex-col gap-9 max-w-3xl">
<div class="mb-4 md:mb-6">
<img :src="clockImage" alt="Clock" class="w-auto h-28" />
<div class="flex items-center justify-start flex-1 mt-9">
<div class="text-n-slate-1 dark:text-n-slate-12 flex gap-3 flex-col">
<div class="text-2xl md:text-4xl tracking-tight">
{{ t('YEAR_IN_REVIEW.PERSONALITY.TITLE') }}
</div>
<div class="text-6xl md:text-7xl lg:text-8xl tracking-tighter">
{{ personality }}
</div>
</div>
</div>
</div>
<div class="flex items-center justify-center gap-3 md:gap-6">
<img
:src="doubleQuotesImage"
alt="Quote"
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
/>
<p
class="text-xl md:text-3xl lg:text-3xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
>
{{ personalityMessage }}
</p>
</div>
</div>
</div>
</template>
@@ -1,43 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
defineProps({
year: {
type: Number,
required: true,
},
});
const { t } = useI18n();
const signatureImage =
'/assets/images/dashboard/year-in-review/fifth-frame-signature.png';
</script>
<template>
<div
class="absolute inset-0 flex items-center justify-center px-8 md:px-32 py-20"
>
<div class="flex flex-col items-start max-w-4xl">
<div
class="text-3xl md:text-5xl lg:text-6xl font-bold tracking-tight !leading-tight text-n-slate-12 dark:text-n-slate-1"
>
{{ t('YEAR_IN_REVIEW.THANK_YOU.TITLE', { year }) }}
</div>
<div
class="text-xl lg:text-3xl mt-8 font-medium !leading-snug text-n-slate-12 dark:text-n-slate-1"
>
{{
t('YEAR_IN_REVIEW.THANK_YOU.MESSAGE', { nextYear: Number(year) + 1 })
}}
</div>
<div class="mt-12">
<img
:src="signatureImage"
alt="Chatwoot Team Signature"
class="w-auto h-8 md:h-10"
/>
</div>
</div>
</div>
</template>
@@ -249,7 +249,7 @@ const activeAssigneeTabCount = computed(() => {
});
const conversationListPagination = computed(() => {
const conversationsPerPage = 25;
const conversationsPerPage = wootConstants.CONVERSATIONS_PER_PAGE;
const hasChatsOnView =
chatsOnView.value &&
Array.isArray(chatsOnView.value) &&
@@ -339,7 +339,10 @@ const conversationList = computed(() => {
});
}
return localConversationList;
// Clip to current page size to fix pagination order bug
const maxItems =
currentFiltersPage.value * wootConstants.CONVERSATIONS_PER_PAGE;
return localConversationList.slice(0, maxItems);
});
const showEndOfListMessage = computed(() => {
@@ -26,11 +26,13 @@ import { useAlert } from 'dashboard/composables';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
import {
MESSAGE_EDITOR_MENU_OPTIONS,
MESSAGE_EDITOR_IMAGE_RESIZES,
} from 'dashboard/constants/editor';
import {
messageSchema,
buildMessageSchema,
buildEditor,
EditorView,
MessageMarkdownTransformer,
@@ -51,10 +53,6 @@ import {
removeSignature as removeSignatureHelper,
scrollCursorIntoView,
setURLWithQueryAndSize,
getFormattingForEditor,
getSelectionCoords,
calculateMenuPosition,
getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -77,12 +75,12 @@ const props = defineProps({
enableCannedResponses: { type: Boolean, default: true },
enableCaptainTools: { type: Boolean, default: false },
variables: { type: Object, default: () => ({}) },
enabledMenuOptions: { type: Array, default: () => [] },
signature: { type: String, default: '' },
// allowSignature is a kill switch, ensuring no signature methods
// are triggered except when this flag is true
allowSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
medium: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
focusOnMount: { type: Boolean, default: true },
});
@@ -105,40 +103,22 @@ const { t } = useI18n();
const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const DEFAULT_FORMATTING = 'Context::Default';
const effectiveChannelType = computed(() =>
getEffectiveChannelType(props.channelType, props.medium)
);
const editorSchema = computed(() => {
if (!props.channelType) return messageSchema;
const formatType = props.isPrivate
? DEFAULT_FORMATTING
: effectiveChannelType.value;
const formatting = getFormattingForEditor(formatType);
return buildMessageSchema(formatting.marks, formatting.nodes);
});
const editorMenuOptions = computed(() => {
const formatType = props.isPrivate
? DEFAULT_FORMATTING
: effectiveChannelType.value || DEFAULT_FORMATTING;
const formatting = getFormattingForEditor(formatType);
return formatting.menu;
});
const createState = (content, placeholder, plugins = [], methods = {}) => {
const schema = editorSchema.value;
const createState = (
content,
placeholder,
plugins = [],
methods = {},
enabledMenuOptions = []
) => {
return EditorState.create({
doc: new MessageMarkdownTransformer(schema).parse(content),
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
plugins: buildEditor({
schema,
schema: messageSchema,
placeholder,
methods,
plugins,
enabledMenuOptions: editorMenuOptions.value,
enabledMenuOptions,
}),
});
};
@@ -173,8 +153,6 @@ const range = ref(null);
const isImageNodeSelected = ref(false);
const toolbarPosition = ref({ top: 0, left: 0 });
const selectedImageNode = ref(null);
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
const showSelectionMenu = ref(false);
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
// element ref
@@ -196,6 +174,12 @@ const shouldShowCannedResponses = computed(() => {
);
});
const editorMenuOptions = computed(() => {
return props.enabledMenuOptions.length
? props.enabledMenuOptions
: MESSAGE_EDITOR_MENU_OPTIONS;
});
function createSuggestionPlugin({
trigger,
minChars = 0,
@@ -309,13 +293,8 @@ function isBodyEmpty(content) {
// if the signature is present, we need to remove it before checking
// note that we don't update the editorView, so this is safe
// Use effective channel type to match how signature was appended
const bodyWithoutSignature = props.signature
? removeSignatureHelper(
content,
props.signature,
effectiveChannelType.value
)
? removeSignatureHelper(content, props.signature)
: content;
// trimming should remove all the whitespaces, so we can check the length
@@ -383,11 +362,7 @@ function addSignature() {
// 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
const contentWasEmpty = isBodyEmpty(content);
content = appendSignature(
content,
props.signature,
effectiveChannelType.value
);
content = appendSignature(content, props.signature);
// need to reload first, ensuring that the editorView is updated
reloadState(content);
@@ -399,11 +374,7 @@ function addSignature() {
function removeSignature() {
if (!props.signature) return;
let content = props.modelValue;
content = removeSignatureHelper(
content,
props.signature,
effectiveChannelType.value
);
content = removeSignatureHelper(content, props.signature);
// reload the state, ensuring that the editorView is updated
reloadState(content);
}
@@ -429,38 +400,6 @@ function setToolbarPosition() {
};
}
function setMenubarPosition({ selection } = {}) {
const wrapper = editorRoot.value;
if (!selection || !wrapper) return;
const rect = wrapper.getBoundingClientRect();
const isRtl = getComputedStyle(wrapper).direction === 'rtl';
// Calculate coords and final position
const coords = getSelectionCoords(editorView, selection, rect);
const { left, top, width } = calculateMenuPosition(coords, rect, isRtl);
wrapper.style.setProperty('--selection-left', `${left}px`);
wrapper.style.setProperty(
'--selection-right',
`${rect.width - left - width}px`
);
wrapper.style.setProperty('--selection-top', `${top}px`);
}
function checkSelection(editorState) {
showSelectionMenu.value = false;
const hasSelection = editorState.selection.from !== editorState.selection.to;
if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection;
const wrapper = editorRoot.value;
if (!wrapper) return;
wrapper.classList.toggle('has-selection', hasSelection);
if (hasSelection) setMenubarPosition(editorState);
}
function setURLWithQueryAndImageSize(size) {
if (!props.showImageResizeToolbar) {
return;
@@ -590,9 +529,7 @@ async function insertNodeIntoEditor(node, from = 0, to = 0) {
function insertContentIntoEditor(content, defaultFrom = 0) {
const from = defaultFrom || editorView.state.selection.from || 0;
// Use the editor's current schema to ensure compatibility with buildMessageSchema
const currentSchema = editorView.state.schema;
let node = new MessageMarkdownTransformer(currentSchema).parse(content);
let node = new MessageMarkdownTransformer(messageSchema).parse(content);
insertNodeIntoEditor(node, from, undefined);
}
@@ -659,7 +596,6 @@ function createEditorView() {
if (tx.docChanged) {
emitOnChange();
}
checkSelection(state);
},
handleDOMEvents: {
keyup: () => {
@@ -825,33 +761,15 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
.ProseMirror-menubar-wrapper {
@apply flex flex-col gap-3;
@apply flex flex-col;
.ProseMirror-menubar {
min-height: 1.25rem !important;
@apply items-center gap-4 flex pb-0 bg-transparent text-n-slate-11 relative ltr:-left-[3px] rtl:-right-[3px];
@apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
.ProseMirror-menu-active {
@apply bg-n-slate-5 dark:bg-n-solid-3 !important;
@apply bg-n-slate-5 dark:bg-n-solid-3;
}
.ProseMirror-menuitem {
@apply mr-0 size-4 flex items-center justify-center;
.ProseMirror-icon {
@apply size-4 flex items-center justify-center flex-shrink-0;
svg {
@apply size-full;
}
}
}
}
.ProseMirror-menubar:not(:has(*)) {
max-height: none !important;
min-height: 0 !important;
padding: 0 !important;
}
> .ProseMirror {
@@ -942,53 +860,4 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
.editor-warning__message {
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
}
// Float editor menu
.popover-prosemirror-menu {
position: relative;
.ProseMirror p:last-child {
margin-bottom: 10px !important;
}
.ProseMirror-menubar {
display: none; // Hide by default
}
&.has-selection {
// Hide menu completely when it has no items
.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;
display: flex;
width: fit-content !important;
position: absolute !important;
// Default/LTR: position from left
top: var(--selection-top);
left: var(--selection-left);
// RTL: position from right instead
[dir='rtl'] & {
left: auto;
right: var(--selection-right);
}
.ProseMirror-menuitem {
@apply mr-0 size-4 flex items-center;
.ProseMirror-icon {
@apply p-0.5 flex-shrink-0;
}
}
.ProseMirror-menu-active {
@apply bg-n-slate-3;
}
}
}
}
</style>
@@ -78,6 +78,10 @@ export default {
type: Boolean,
default: false,
},
showEditorToggle: {
type: Boolean,
default: false,
},
isOnPrivateNote: {
type: Boolean,
default: false,
@@ -126,6 +130,7 @@ export default {
emits: [
'replaceText',
'toggleInsertArticle',
'toggleEditor',
'selectWhatsappTemplate',
'selectContentTemplate',
'toggleQuotedReply',
@@ -320,8 +325,18 @@ export default {
sm
@click="toggleAudioRecorder"
/>
<NextButton
v-if="showEditorToggle"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
icon="i-ph-quotes"
slate
faded
sm
@click="$emit('toggleEditor')"
/>
<NextButton
v-if="showAudioPlayStopButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
:icon="audioRecorderPlayStopIcon"
slate
faded
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Avatar from 'next/avatar/Avatar.vue';
import MessagePreview from './MessagePreview.vue';
@@ -13,7 +14,6 @@ import CardLabels from './conversationCardComponents/CardLabels.vue';
import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
const props = defineProps({
activeLabel: { type: String, default: '' },
@@ -83,10 +83,15 @@ const isInboxNameVisible = computed(() => !activeInbox.value);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const callStatus = computed(
() => props.chat.additional_attributes?.call_status
);
const callDirection = computed(
() => props.chat.additional_attributes?.call_direction
);
const { labelKey: voiceLabelKey, listIconColor: voiceIconColor } =
useVoiceCallStatus(callStatus, callDirection);
const inboxId = computed(() => props.chat.inbox_id);
@@ -312,13 +317,20 @@ const deleteConversation = () => {
>
{{ currentContact.name }}
</h4>
<VoiceCallStatus
v-if="voiceCallData.status"
<div
v-if="callStatus"
key="voice-status-row"
:status="voiceCallData.status"
:direction="voiceCallData.direction"
:message-preview-class="messagePreviewClass"
/>
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
<span
class="inline-block -mt-0.5 align-middle text-[16px] i-ph-phone-incoming"
:class="[voiceIconColor]"
/>
<span class="mx-1">
{{ $t(voiceLabelKey) }}
</span>
</div>
<MessagePreview
v-else-if="lastMessageInChat"
key="message-preview"
@@ -258,6 +258,8 @@ export default {
created() {
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
// when a new message comes in, we refetch the label suggestions
emitter.on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions);
// when a message is sent we set the flag to true this hides the label suggestions,
// until the chat is changed and the flag is reset in the watch for currentChat
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
@@ -289,10 +291,6 @@ export default {
return;
}
// Early exit if conversation already has labels - no need to suggest more
const existingLabels = this.currentChat?.labels || [];
if (existingLabels.length > 0) return;
// method available in mixin, need to ensure that integrations are present
await this.fetchIntegrationsIfRequired();
@@ -45,7 +45,7 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
appendSignature,
removeSignature,
getEffectiveChannelType,
replaceSignature,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
@@ -61,6 +61,7 @@ export default {
ArticleSearchPopover,
AttachmentPreview,
AudioRecorder,
CannedResponse,
ReplyBoxBanner,
EmojiInput,
MessageSignatureMissingAlert,
@@ -68,12 +69,11 @@ export default {
ReplyEmailHead,
ReplyToMessage,
ReplyTopPanel,
ResizableTextArea,
ContentTemplates,
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
ResizableTextArea,
CannedResponse,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@@ -86,6 +86,7 @@ export default {
setup() {
const {
uiSettings,
updateUISettings,
isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox,
@@ -96,6 +97,7 @@ export default {
return {
uiSettings,
updateUISettings,
isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox,
@@ -113,6 +115,7 @@ export default {
isRecordingAudio: false,
recordingAudioState: '',
recordingAudioDurationText: '',
isUploading: false,
replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
hasSlashCommand: false,
@@ -144,12 +147,9 @@ export default {
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
currentContact() {
const senderId = this.currentChat?.meta?.sender?.id;
if (!senderId) return {};
return this.$store.getters['contacts/getContact'](senderId);
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
return this.$store.getters['contacts/getContact'](
this.currentChat.meta.sender.id
);
},
shouldShowReplyToMessage() {
return (
@@ -159,32 +159,34 @@ export default {
!this.is360DialogWhatsAppChannel
);
},
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
}
if (this.isAPIInbox) {
const {
display_rich_content_editor: displayRichContentEditor = false,
} = this.uiSettings;
return displayRichContentEditor;
}
return false;
},
showWhatsappTemplates() {
// We support templates for API channels if someone updates templates manually via API
// That's why we don't explicitly check for channel type here
const templates = this.$store.getters['inboxes/getWhatsAppTemplates'](
this.inboxId
);
return !!(templates && templates.length) && !this.isPrivate;
return this.isAWhatsAppCloudChannel && !this.isPrivate;
},
showContentTemplates() {
return this.isATwilioWhatsAppChannel && !this.isPrivate;
},
isPrivate() {
if (
this.currentChat.can_reply ||
this.isAWhatsAppChannel ||
this.isAPIInbox
) {
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
return this.isOnPrivateNote;
}
return true;
},
isReplyRestricted() {
return (
!this.currentChat?.can_reply &&
!(this.isAWhatsAppChannel || this.isAPIInbox)
);
return !this.currentChat?.can_reply && !this.isAWhatsAppChannel;
},
inboxId() {
return this.currentChat.inbox_id;
@@ -286,6 +288,9 @@ export default {
hasAttachments() {
return this.attachedFiles.length;
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel;
},
showAudioRecorder() {
return !this.isOnPrivateNote && this.showFileUpload;
},
@@ -325,11 +330,21 @@ export default {
return !this.isPrivate && this.sendWithSignature;
},
isSignatureAvailable() {
return !!this.messageSignature;
return !!this.signatureToApply;
},
sendWithSignature() {
return this.fetchSignatureFlagFromUISettings(this.channelType);
},
editorMessageKey() {
const { editor_message_key: isEnabled } = this.uiSettings;
return isEnabled;
},
commandPlusEnterToSendEnabled() {
return this.editorMessageKey === 'cmd_enter';
},
enterToSendEnabled() {
return this.editorMessageKey === 'enter';
},
conversationId() {
return this.currentChat.id;
},
@@ -356,6 +371,12 @@ export default {
});
return variables;
},
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
},
connectedPortalSlug() {
const { help_center: portal = {} } = this.inbox;
const { slug = '' } = portal;
@@ -406,19 +427,6 @@ export default {
!!this.quotedEmailText
);
},
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
}
return false;
},
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
},
},
watch: {
currentChat(conversation, oldConversation) {
@@ -434,7 +442,7 @@ export default {
return;
}
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) {
if (canReply || this.isAWhatsAppChannel) {
this.replyType = REPLY_EDITOR_MODES.REPLY;
} else {
this.replyType = REPLY_EDITOR_MODES.NOTE;
@@ -492,7 +500,7 @@ export default {
mounted() {
this.getFromDraft();
// Don't use the keyboard listener mixin here as the events here are supposed to be
// working even if the editor is focussed.
// working even if input/textarea is focussed.
document.addEventListener('paste', this.onPaste);
document.addEventListener('keydown', this.handleKeyEvents);
this.setCCAndToEmailsFromLastChat();
@@ -546,6 +554,28 @@ export default {
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
},
toggleRichContentEditor() {
this.updateUISettings({
display_rich_content_editor: !this.showRichContentEditor,
});
const plainTextSignature = extractTextFromMarkdown(this.messageSignature);
if (!this.showRichContentEditor && this.messageSignature) {
// remove the old signature -> extract text from markdown -> attach new signature
let message = removeSignature(this.message, this.messageSignature);
message = extractTextFromMarkdown(message);
message = appendSignature(message, plainTextSignature);
this.message = message;
} else {
this.message = replaceSignature(
this.message,
plainTextSignature,
this.messageSignature
);
}
},
toggleQuotedReply() {
if (!this.isAnEmailChannel) {
return;
@@ -611,23 +641,7 @@ export default {
if (this.isPrivate) {
return message;
}
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
return this.sendWithSignature
? appendSignature(
message,
this.messageSignature,
effectiveChannelType
)
: removeSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
return this.sendWithSignature
? appendSignature(message, this.signatureToApply)
: removeSignature(message, this.signatureToApply);
@@ -690,7 +704,7 @@ export default {
onPaste(e) {
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput?.$el?.blur();
this.$refs.messageInput.$el.blur();
}
if (!data.length || !data[0]) {
return;
@@ -825,19 +839,7 @@ export default {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
} else {
message = appendSignature(message, this.signatureToApply);
}
message = appendSignature(message, this.signatureToApply);
}
const updatedMessage = replaceVariablesInMessage({
@@ -859,8 +861,7 @@ export default {
this.$store.dispatch('draftMessages/setReplyEditorMode', {
mode,
});
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
this.replyType = mode;
if (canReply || this.isAWhatsAppChannel) this.replyType = mode;
if (this.showRichContentEditor) {
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
@@ -894,19 +895,7 @@ export default {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
this.message = appendSignature(
this.message,
this.messageSignature,
effectiveChannelType
);
} else {
this.message = appendSignature(this.message, this.signatureToApply);
}
this.message = appendSignature(this.message, this.signatureToApply);
}
this.attachedFiles = [];
this.isRecordingAudio = false;
@@ -924,15 +913,19 @@ export default {
},
toggleAudioRecorder() {
this.isRecordingAudio = !this.isRecordingAudio;
this.isRecorderAudioStopped = !this.isRecordingAudio;
if (!this.isRecordingAudio) {
this.resetAudioRecorderInput();
}
},
toggleAudioRecorderPlayPause() {
if (!this.$refs.audioRecorderInput) return;
if (!this.recordingAudioState) {
if (!this.isRecordingAudio) {
return;
}
if (!this.isRecorderAudioStopped) {
this.isRecorderAudioStopped = true;
this.$refs.audioRecorderInput.stopRecording();
} else {
} else if (this.isRecorderAudioStopped) {
this.$refs.audioRecorderInput.playPause();
}
},
@@ -1239,17 +1232,16 @@ export default {
v-else
v-model="message"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
class="input"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
enable-variables
:variables="messageVariables"
:signature="messageSignature"
:signature="signatureToApply"
allow-signature
:channel-type="channelType"
:medium="inbox.medium"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@@ -1297,6 +1289,7 @@ export default {
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
:show-audio-recorder="showAudioRecorder"
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
@@ -1309,6 +1302,7 @@ export default {
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@toggle-editor="toggleRichContentEditor"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
@@ -30,7 +30,7 @@ const assignedAgent = computed({
return currentChat.value?.meta?.assignee;
},
set(agent) {
const agentId = agent ? agent.id : null;
const agentId = agent ? agent.id : 0;
store.dispatch('setCurrentChatAssignee', agent);
store.dispatch('assignAgent', {
conversationId: currentChat.value?.id,
@@ -1,77 +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: '' },
messagePreviewClass: { type: [String, Array, Object], 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="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
<Icon
class="inline-block -mt-0.5 align-middle size-4"
:icon="iconName"
:class="statusColor"
/>
<span class="mx-1" :class="statusColor">
{{ $t(labelKey) }}
</span>
</div>
</template>
@@ -1,57 +0,0 @@
import { ref } from 'vue';
export const useLoadWithRetry = (config = {}) => {
const maxRetry = config.max_retry || 3;
const backoff = config.backoff || 1000;
const isLoaded = ref(false);
const hasError = ref(false);
const loadWithRetry = async url => {
const attemptLoad = () => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
isLoaded.value = true;
hasError.value = false;
resolve();
};
img.onerror = () => {
reject(new Error('Failed to load image'));
};
img.src = url;
});
};
const sleep = ms => {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
};
const retry = async (attempt = 0) => {
try {
await attemptLoad();
} catch (error) {
if (attempt + 1 >= maxRetry) {
hasError.value = true;
isLoaded.value = false;
return;
}
await sleep(backoff * (attempt + 1));
await retry(attempt + 1);
}
};
await retry();
};
return {
isLoaded,
hasError,
loadWithRetry,
};
};
@@ -12,11 +12,7 @@ vi.mock('dashboard/composables', () => ({
}));
vi.mock('vue-i18n');
vi.mock('activestorage');
vi.mock('shared/helpers/FileHelper', () => ({
checkFileSizeLimit: vi.fn(),
resolveMaximumFileUploadSize: vi.fn(value => Number(value) || 40),
DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE: 40,
}));
vi.mock('shared/helpers/FileHelper');
vi.mock('@chatwoot/utils');
describe('useFileUpload', () => {
@@ -40,9 +36,7 @@ describe('useFileUpload', () => {
getCurrentAccountId: { value: '123' },
getCurrentUser: { value: { access_token: 'test-token' } },
getSelectedChat: { value: { id: '456' } },
'globalConfig/get': {
value: { directUploadsEnabled: true, maximumFileUploadSize: 40 },
},
'globalConfig/get': { value: { directUploadsEnabled: true } },
};
return getterMap[getter];
});
@@ -92,9 +86,7 @@ describe('useFileUpload', () => {
getCurrentAccountId: { value: '123' },
getCurrentUser: { value: { access_token: 'test-token' } },
getSelectedChat: { value: { id: '456' } },
'globalConfig/get': {
value: { directUploadsEnabled: false, maximumFileUploadSize: 40 },
},
'globalConfig/get': { value: { directUploadsEnabled: false } },
};
return getterMap[getter];
});
@@ -1,5 +1,5 @@
import { computed } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
@@ -9,7 +9,6 @@ export function useCaptain() {
const store = useStore();
const { isCloudFeatureEnabled, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const uiFlags = useMapGetter('accounts/getUIFlags');
const captainEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
@@ -35,8 +34,6 @@ export function useCaptain() {
return null;
});
const isFetchingLimits = computed(() => uiFlags.value.isFetchingLimits);
const fetchLimits = () => {
if (isEnterprise) {
store.dispatch('accounts/limits');
@@ -49,6 +46,5 @@ export function useCaptain() {
documentLimits,
responseLimits,
fetchLimits,
isFetchingLimits,
};
}
@@ -4,11 +4,7 @@ import { useI18n } from 'vue-i18n';
import { DirectUpload } from 'activestorage';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
import {
DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE,
resolveMaximumFileUploadSize,
} from 'shared/helpers/FileHelper';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages';
/**
* Composable for handling file uploads in conversations
@@ -25,34 +21,18 @@ export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => {
const currentChat = useMapGetter('getSelectedChat');
const globalConfig = useMapGetter('globalConfig/get');
const installationLimit = resolveMaximumFileUploadSize(
globalConfig.value?.maximumFileUploadSize
);
// helper: compute max upload size for a given file's mime
const maxSizeFor = mime => {
// Use default/installation limit for private notes
// Use default file size limit for private notes
if (isPrivateNote) {
return installationLimit;
return MAXIMUM_FILE_UPLOAD_SIZE;
}
const channelType = inbox?.channel_type;
if (!channelType || channelType === INBOX_TYPES.WEB) {
return installationLimit;
}
const channelLimit = getMaxUploadSizeByChannel({
channelType,
return getMaxUploadSizeByChannel({
channelType: inbox?.channel_type,
medium: inbox?.medium, // e.g. 'sms' | 'whatsapp' | etc.
mime, // e.g. 'image/png'
});
if (channelLimit === DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE) {
return installationLimit;
}
return Math.min(channelLimit, installationLimit);
};
const alertOverLimit = maxSizeMB =>
@@ -0,0 +1,161 @@
import { computed, unref } from 'vue';
const CALL_STATUSES = {
IN_PROGRESS: 'in-progress',
RINGING: 'ringing',
NO_ANSWER: 'no-answer',
BUSY: 'busy',
FAILED: 'failed',
COMPLETED: 'completed',
CANCELED: 'canceled',
};
const CALL_DIRECTIONS = {
INBOUND: 'inbound',
OUTBOUND: 'outbound',
};
/**
* Composable for handling voice call status display logic
* @param {Ref|string} statusRef - Call status (ringing, in-progress, etc.)
* @param {Ref|string} directionRef - Call direction (inbound, outbound)
* @returns {Object} UI properties for displaying call status
*/
export function useVoiceCallStatus(statusRef, directionRef) {
const status = computed(() => unref(statusRef)?.toString());
const direction = computed(() => unref(directionRef)?.toString());
// Status group helpers
const isFailedStatus = computed(() =>
[
CALL_STATUSES.NO_ANSWER,
CALL_STATUSES.BUSY,
CALL_STATUSES.FAILED,
].includes(status.value)
);
const isEndedStatus = computed(() =>
[CALL_STATUSES.COMPLETED, CALL_STATUSES.CANCELED].includes(status.value)
);
const isOutbound = computed(
() => direction.value === CALL_DIRECTIONS.OUTBOUND
);
const labelKey = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS';
}
if (s === CALL_STATUSES.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
if (s === CALL_STATUSES.NO_ANSWER) {
return 'CONVERSATION.VOICE_CALL.MISSED_CALL';
}
if (isFailedStatus.value) {
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
}
if (isEndedStatus.value) {
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
}
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const subtextKey = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.RINGING) {
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
}
if (s === CALL_STATUSES.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
}
if (isFailedStatus.value) {
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
}
if (isEndedStatus.value) {
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
}
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
});
const bubbleIconName = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS) {
return isOutbound.value
? 'i-ph-phone-outgoing-fill'
: 'i-ph-phone-incoming-fill';
}
if (isFailedStatus.value) {
return 'i-ph-phone-x-fill';
}
// For ringing/completed/canceled show direction when possible
return isOutbound.value
? 'i-ph-phone-outgoing-fill'
: 'i-ph-phone-incoming-fill';
});
const bubbleIconBg = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS) {
return 'bg-n-teal-9';
}
if (isFailedStatus.value) {
return 'bg-n-ruby-9';
}
if (isEndedStatus.value) {
return 'bg-n-slate-11';
}
// default (e.g., ringing)
return 'bg-n-teal-9 animate-pulse';
});
const listIconColor = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS || s === CALL_STATUSES.RINGING) {
return 'text-n-teal-9';
}
if (isFailedStatus.value) {
return 'text-n-ruby-9';
}
if (isEndedStatus.value) {
return 'text-n-slate-11';
}
return 'text-n-teal-9';
});
return {
status,
labelKey,
subtextKey,
bubbleIconName,
bubbleIconBg,
listIconColor,
};
}
+25 -217
View File
@@ -1,143 +1,23 @@
// Formatting rules for different contexts (channels and special contexts)
// marks: inline formatting (strong, em, code, link, strike)
// nodes: block structures (bulletList, orderedList, codeBlock, blockquote)
export const FORMATTING = {
// Channel formatting
'Channel::Email': {
marks: ['strong', 'em', 'code', 'link'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
menu: [
'strong',
'em',
'code',
'link',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::WebWidget': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
menu: [
'strong',
'em',
'code',
'link',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::Api': {
marks: ['strong', 'em'],
nodes: [],
menu: ['strong', 'em', 'undo', 'redo'],
},
'Channel::FacebookPage': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'strong',
'em',
'code',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::TwitterProfile': {
marks: [],
nodes: [],
menu: [],
},
'Channel::TwilioSms': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Sms': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Whatsapp': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'strong',
'em',
'code',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::Line': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['codeBlock'],
menu: ['strong', 'em', 'code', 'strike', 'undo', 'redo'],
},
'Channel::Telegram': {
marks: ['strong', 'em', 'link', 'code'],
nodes: [],
menu: ['strong', 'em', 'link', 'code', 'undo', 'redo'],
},
'Channel::Instagram': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList'],
menu: [
'strong',
'em',
'code',
'bulletList',
'orderedList',
'strike',
'undo',
'redo',
],
},
'Channel::Voice': {
marks: [],
nodes: [],
menu: [],
},
// Special contexts (not actual channels)
'Context::Default': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
menu: [
'strong',
'em',
'code',
'link',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Context::MessageSignature': {
marks: ['strong', 'em', 'link'],
nodes: ['image'],
menu: ['strong', 'em', 'link', 'undo', 'redo', 'imageUpload'],
},
'Context::InboxSettings': {
marks: ['strong', 'em', 'link'],
nodes: [],
menu: ['strong', 'em', 'link', 'undo', 'redo'],
},
};
export const MESSAGE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
'bulletList',
'orderedList',
'code',
];
export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
'imageUpload',
];
// Editor menu options for Full Editor
export const ARTICLE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
@@ -153,86 +33,14 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'code',
];
/**
* Markdown formatting patterns for stripping unsupported formatting.
*
* Maps camelCase type names to ProseMirror snake_case schema names.
* Order matters: codeBlock before code to avoid partial matches.
*/
export const MARKDOWN_PATTERNS = [
// --- BLOCK NODES ---
{
type: 'codeBlock', // PM: code_block, eg: ```js\ncode\n```
patterns: [
{ pattern: /`{3}(?:\w+)?\n?([\s\S]*?)`{3}/g, replacement: '$1' },
],
},
{
type: 'blockquote', // PM: blockquote, eg: > quote
patterns: [{ pattern: /^> ?/gm, replacement: '' }],
},
{
type: 'bulletList', // PM: bullet_list, eg: - item
patterns: [{ pattern: /^[\t ]*[-*+]\s+/gm, replacement: '' }],
},
{
type: 'orderedList', // PM: ordered_list, eg: 1. item
patterns: [{ pattern: /^[\t ]*\d+\.\s+/gm, replacement: '' }],
},
{
type: 'heading', // PM: heading, eg: ## Heading
patterns: [{ pattern: /^#{1,6}\s+/gm, replacement: '' }],
},
{
type: 'horizontalRule', // PM: horizontal_rule, eg: ---
patterns: [{ pattern: /^(?:---|___|\*\*\*)\s*$/gm, replacement: '' }],
},
{
type: 'image', // PM: image, eg: ![alt](url)
patterns: [{ pattern: /!\[([^\]]*)\]\([^)]+\)/g, replacement: '$1' }],
},
{
type: 'hardBreak', // PM: hard_break, eg: line\\\n or line \n
patterns: [
{ pattern: /\\\n/g, replacement: '\n' },
{ pattern: / {2,}\n/g, replacement: '\n' },
],
},
// --- INLINE MARKS ---
{
type: 'strong', // PM: strong, eg: **bold** or __bold__
patterns: [
{ pattern: /\*\*(.+?)\*\*/g, replacement: '$1' },
{ pattern: /__(.+?)__/g, replacement: '$1' },
],
},
{
type: 'em', // PM: em, eg: *italic* or _italic_
patterns: [
{ pattern: /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, replacement: '$1' },
// Match _text_ only at word boundaries (whitespace/string start/end)
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
{
pattern: /(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
replacement: '$1',
},
],
},
{
type: 'strike', // PM: strike, eg: ~~strikethrough~~
patterns: [{ pattern: /~~(.+?)~~/g, replacement: '$1' }],
},
{
type: 'code', // PM: code, eg: `inline code`
patterns: [{ pattern: /`([^`]+)`/g, replacement: '$1' }],
},
{
type: 'link', // PM: link, eg: [text](url)
patterns: [{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }],
},
export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
];
// Editor image resize options for Message Editor
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{
name: 'Small',
@@ -1,5 +1,6 @@
export default {
GRAVATAR_URL: 'https://www.gravatar.com/avatar/',
CONVERSATIONS_PER_PAGE: 25,
ASSIGNEE_TYPE: {
ME: 'me',
UNASSIGNED: 'unassigned',
-1
View File
@@ -41,7 +41,6 @@ export const FEATURE_FLAGS = {
CAPTAIN_V2: 'captain_integration_v2',
SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
COMPANIES: 'companies',
};
export const PREMIUM_FEATURES = [
+30 -258
View File
@@ -5,82 +5,6 @@ import {
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import camelcaseKeys from 'camelcase-keys';
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
* Links will be converted to text, and not removed.
*
* @param {string} markdown - markdown text to be extracted
* @returns {string} - The extracted text.
*/
export function extractTextFromMarkdown(markdown) {
if (!markdown) return '';
return markdown
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/`.*?`/g, '') // Remove inline code
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n') // Trim each line & remove any lines only having spaces
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
.trim(); // Trim any extra space
}
/**
* Strip unsupported markdown formatting based on channel capabilities.
*
* @param {string} markdown - markdown text to process
* @param {string} channelType - The channel type to check supported formatting
* @returns {string} - The markdown with unsupported formatting removed
*/
export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
if (!markdown) return '';
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
const has = (arr, key) => arr.includes(key);
// Define stripping rules: [condition, pattern, replacement]
const rules = [
[!has(nodes, 'image'), /!\[.*?\]\(.*?\)/g, ''],
[!has(marks, 'link'), /\[([^\]]+)\]\([^)]+\)/g, '$1'],
[!has(nodes, 'codeBlock'), /```[\s\S]*?```/g, ''],
[!has(marks, 'code'), /`([^`]+)`/g, '$1'],
[!has(marks, 'strong'), /\*\*([^*]+)\*\*/g, '$1'],
[!has(marks, 'strong'), /__([^_]+)__/g, '$1'],
[!has(marks, 'em'), /\*([^*]+)\*/g, '$1'],
// Match _text_ only at word boundaries (whitespace/string start/end)
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
[
!has(marks, 'em'),
/(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
'$1',
],
[!has(marks, 'strike'), /~~([^~]+)~~/g, '$1'],
[!has(nodes, 'blockquote'), /^>\s?/gm, ''],
[!has(nodes, 'bulletList'), /^[-*+]\s+/gm, ''],
[!has(nodes, 'orderedList'), /^\d+\.\s+/gm, ''],
];
const result = rules.reduce(
(text, [shouldStrip, pattern, replacement]) =>
shouldStrip ? text.replace(pattern, replacement) : text,
markdown
);
return result
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n')
.replace(/\n{2,}/g, '\n')
.trim();
}
/**
* The delimiter used to separate the signature from the rest of the body.
@@ -143,39 +67,15 @@ export function findSignatureInBody(body, signature) {
return -1;
}
/**
* Gets the effective channel type for formatting purposes.
* For Twilio channels, returns WhatsApp or Twilio based on medium.
*
* @param {string} channelType - The channel type
* @param {string} medium - Optional. The medium for Twilio channels (sms/whatsapp)
* @returns {string} - The effective channel type for formatting
*/
export function getEffectiveChannelType(channelType, medium) {
if (channelType === INBOX_TYPES.TWILIO) {
return medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
? INBOX_TYPES.WHATSAPP
: INBOX_TYPES.TWILIO;
}
return channelType;
}
/**
* Appends the signature to the body, separated by the signature delimiter.
* Automatically strips unsupported formatting based on channel capabilities.
*
* @param {string} body - The body to append the signature to.
* @param {string} signature - The signature to append.
* @param {string} channelType - Optional. The effective channel type to determine supported formatting.
* For Twilio channels, pass the result of getEffectiveChannelType().
* @returns {string} - The body with the signature appended.
*/
export function appendSignature(body, signature, channelType) {
// Strip only unsupported formatting based on channel capabilities
const preparedSignature = channelType
? stripUnsupportedSignatureMarkdown(signature, channelType)
: signature;
const cleanedSignature = cleanSignature(preparedSignature);
export function appendSignature(body, signature) {
const cleanedSignature = cleanSignature(signature);
// if signature is already present, return body
if (findSignatureInBody(body, cleanedSignature) > -1) {
return body;
@@ -186,34 +86,16 @@ export function appendSignature(body, signature, channelType) {
/**
* Removes the signature from the body, along with the signature delimiter.
* Tries to find both the original signature and the stripped version.
*
* @param {string} body - The body to remove the signature from.
* @param {string} signature - The signature to remove.
* @param {string} channelType - Optional. The effective channel type for channel-specific stripping.
* For Twilio channels, pass the result of getEffectiveChannelType().
* @returns {string} - The body with the signature removed.
*/
export function removeSignature(body, signature, channelType) {
// Build list of signatures to try: original, channel-stripped, and fully stripped
export function removeSignature(body, signature) {
// this will find the index of the signature if it exists
// Regardless of extra spaces or new lines after the signature, the index will be the same if present
const cleanedSignature = cleanSignature(signature);
const channelStripped = channelType
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
: null;
const fullyStripped = cleanSignature(extractTextFromMarkdown(signature));
// Try signatures in order: original → channel-specific → fully stripped
const signaturesToTry = [
cleanedSignature,
channelStripped,
fullyStripped,
].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
// Find the first matching signature
const signatureIndex = signaturesToTry.reduce(
(index, sig) => (index === -1 ? findSignatureInBody(body, sig) : index),
-1
);
const signatureIndex = findSignatureInBody(body, cleanedSignature);
// no need to trim the ends here, because it will simply be removed in the next method
let newBody = body;
@@ -254,6 +136,28 @@ export function replaceSignature(body, oldSignature, newSignature) {
return appendSignature(withoutSignature, newSignature);
}
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
* Links will be converted to text, and not removed.
*
* @param {string} markdown - markdown text to be extracted
* @returns
*/
export function extractTextFromMarkdown(markdown) {
return markdown
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/`.*?`/g, '') // Remove inline code
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n') // Trim each line & remove any lines only having spaces
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
.trim(); // Trim any extra space
}
/**
* Scrolls the editor view into current cursor position
*
@@ -379,47 +283,6 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
}
}
/**
* Strips unsupported markdown formatting from content based on the editor schema.
* This ensures canned responses with rich formatting can be inserted into channels
* that don't support certain formatting (e.g., API channels don't support bold).
*
* @param {string} content - The markdown content to sanitize
* @param {Object} schema - The ProseMirror schema with supported marks and nodes
* @returns {string} - Content with unsupported formatting stripped
*/
export function stripUnsupportedFormatting(content, schema) {
if (!content || typeof content !== 'string') return content;
if (!schema) return content;
let sanitizedContent = content;
// Get supported marks and nodes from the schema
// Note: ProseMirror uses snake_case internally (code_block, bullet_list, etc.)
// but our FORMATTING constant uses camelCase (codeBlock, bulletList, etc.)
// We use camelcase-keys to normalize node names for comparison
const supportedMarks = Object.keys(schema.marks || {});
const nodeKeys = Object.keys(schema.nodes || {});
const nodeKeysObj = Object.fromEntries(nodeKeys.map(k => [k, true]));
const supportedNodes = Object.keys(camelcaseKeys(nodeKeysObj));
// Process each formatting type in order (codeBlock before code is important!)
MARKDOWN_PATTERNS.forEach(({ type, patterns }) => {
// Check if this format type is supported by the schema
const isMarkSupported = supportedMarks.includes(type);
const isNodeSupported = supportedNodes.includes(type);
// If not supported, strip the formatting
if (!isMarkSupported && !isNodeSupported) {
patterns.forEach(({ pattern, replacement }) => {
sanitizedContent = sanitizedContent.replace(pattern, replacement);
});
}
});
return sanitizedContent;
}
/**
* Content Node Creation Helper Functions for
* - mention
@@ -450,17 +313,8 @@ const createNode = (editorView, nodeType, content) => {
return mentionNode;
}
case 'cannedResponse': {
// Strip unsupported formatting before parsing to ensure content can be inserted
// into channels that don't support certain markdown features (e.g., API channels)
const sanitizedContent = stripUnsupportedFormatting(
content,
state.schema
);
return new MessageMarkdownTransformer(state.schema).parse(
sanitizedContent
);
}
case 'cannedResponse':
return new MessageMarkdownTransformer(messageSchema).parse(content);
case 'variable':
return state.schema.text(`{{${content}}}`);
case 'emoji':
@@ -535,85 +389,3 @@ export const getContentNode = (
? creator(editorView, content, from, to, variables)
: { node: null, from, to };
};
/**
* Get the formatting configuration for a specific channel type.
* Returns the appropriate marks, nodes, and menu items for the editor.
*
* @param {string} channelType - The channel type (e.g., 'Channel::FacebookPage', 'Channel::WebWidget')
* @returns {Object} The formatting configuration with marks, nodes, and menu properties
*/
export function getFormattingForEditor(channelType) {
return FORMATTING[channelType] || FORMATTING['Context::Default'];
}
/**
* Menu Positioning Helpers
* Handles floating menu bar positioning for text selection in the editor.
*/
const MENU_CONFIG = { H: 46, W: 300, GAP: 10 };
/**
* Calculate selection coordinates with bias to handle line-wraps correctly.
* @param {EditorView} editorView - ProseMirror editor view
* @param {Selection} selection - Current text selection
* @param {DOMRect} rect - Container bounding rect
* @returns {{start: Object, end: Object, selTop: number, onTop: boolean}}
*/
export function getSelectionCoords(editorView, selection, rect) {
const start = editorView.coordsAtPos(selection.from, 1);
const end = editorView.coordsAtPos(selection.to, -1);
const selTop = Math.min(start.top, end.top);
const spaceAbove = selTop - rect.top;
const onTop =
spaceAbove > MENU_CONFIG.H + MENU_CONFIG.GAP || end.bottom > rect.bottom;
return { start, end, selTop, onTop };
}
/**
* Calculate anchor position based on selection visibility and RTL direction.
* @param {Object} coords - Selection coordinates from getSelectionCoords
* @param {DOMRect} rect - Container bounding rect
* @param {boolean} isRtl - Whether text direction is RTL
* @returns {number} Anchor x-position for menu
*/
export function getMenuAnchor(coords, rect, isRtl) {
const { start, end, onTop } = coords;
if (!onTop) return end.left;
// If start of selection is visible, align to text. Else stick to container edge.
if (start.top >= rect.top) return isRtl ? start.right : start.left;
return isRtl ? rect.right - MENU_CONFIG.GAP : rect.left + MENU_CONFIG.GAP;
}
/**
* Calculate final menu position (left, top) within container bounds.
* @param {Object} coords - Selection coordinates from getSelectionCoords
* @param {DOMRect} rect - Container bounding rect
* @param {boolean} isRtl - Whether text direction is RTL
* @returns {{left: number, top: number, width: number}}
*/
export function calculateMenuPosition(coords, rect, isRtl) {
const { start, end, selTop, onTop } = coords;
const anchor = getMenuAnchor(coords, rect, isRtl);
// Calculate Left: shift by width if RTL, then make relative to container
const rawLeft = (isRtl ? anchor - MENU_CONFIG.W : anchor) - rect.left;
// Ensure menu stays within container bounds
const left = Math.min(Math.max(0, rawLeft), rect.width - MENU_CONFIG.W);
// Calculate Top: align to selection or bottom of selection
const top = onTop
? Math.max(-26, selTop - rect.top - MENU_CONFIG.H - MENU_CONFIG.GAP)
: Math.max(start.bottom, end.bottom) - rect.top + MENU_CONFIG.GAP;
return { left, top, width: MENU_CONFIG.W };
}
/* End Menu Positioning Helpers */
-5
View File
@@ -13,11 +13,6 @@ export const INBOX_TYPES = {
VOICE: 'Channel::Voice',
};
export const TWILIO_CHANNEL_MEDIUM = {
WHATSAPP: 'whatsapp',
SMS: 'sms',
};
const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
@@ -1,11 +1,15 @@
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
import { getContentNode } from '../editorHelper';
import { MessageMarkdownTransformer } from '@chatwoot/prosemirror-schema';
import {
MessageMarkdownTransformer,
messageSchema,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
vi.mock('@chatwoot/prosemirror-schema', () => ({
MessageMarkdownTransformer: vi.fn(),
messageSchema: {},
}));
vi.mock('@chatwoot/utils', () => ({
@@ -58,18 +62,12 @@ describe('getContentNode', () => {
const to = 10;
const updatedMessage = 'Hello John';
// Mock the node that will be returned by parse
const mockNode = { textContent: updatedMessage };
replaceVariablesInMessage.mockReturnValue(updatedMessage);
MessageMarkdownTransformer.mockImplementation(() => ({
parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
}));
// Mock MessageMarkdownTransformer instance with parse method
const mockTransformer = {
parse: vi.fn().mockReturnValue(mockNode),
};
MessageMarkdownTransformer.mockImplementation(() => mockTransformer);
const result = getContentNode(
const { node } = getContentNode(
editorView,
'cannedResponse',
content,
@@ -81,15 +79,8 @@ describe('getContentNode', () => {
message: content,
variables,
});
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(
editorView.state.schema
);
expect(mockTransformer.parse).toHaveBeenCalledWith(updatedMessage);
expect(result.node).toBe(mockNode);
expect(result.node.textContent).toBe(updatedMessage);
// When textContent matches updatedMessage, from should remain unchanged
expect(result.from).toBe(from);
expect(result.to).toBe(to);
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
expect(node.textContent).toBe(updatedMessage);
});
});
@@ -5,18 +5,11 @@ import {
replaceSignature,
cleanSignature,
extractTextFromMarkdown,
stripUnsupportedSignatureMarkdown,
insertAtCursor,
findNodeToInsertImage,
setURLWithQueryAndSize,
getContentNode,
getFormattingForEditor,
getSelectionCoords,
getMenuAnchor,
calculateMenuPosition,
stripUnsupportedFormatting,
} from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema';
import { EditorView } from '@chatwoot/prosemirror-schema';
import { Schema } from 'prosemirror-model';
@@ -145,107 +138,6 @@ describe('appendSignature', () => {
});
});
describe('stripUnsupportedSignatureMarkdown', () => {
const richSignature =
'**Bold** _italic_ [link](http://example.com) ![](http://localhost:3000/image.png)';
it('keeps all formatting for Email channel (supports image, link, strong, em)', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Email'
);
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('[link](http://example.com)');
expect(result).toContain('![](http://localhost:3000/image.png)');
});
it('strips images but keeps bold/italic for Api channel', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Api'
);
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('link'); // link text kept
expect(result).not.toContain('[link]('); // link syntax removed
expect(result).not.toContain('![]('); // image removed
});
it('strips images but keeps bold/italic/link for Telegram channel', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Telegram'
);
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('[link](http://example.com)');
expect(result).not.toContain('![](');
});
it('strips all formatting for SMS channel', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Sms'
);
expect(result).toContain('Bold');
expect(result).toContain('italic');
expect(result).toContain('link');
expect(result).not.toContain('**');
expect(result).not.toContain('_');
expect(result).not.toContain('[');
expect(result).not.toContain('![](');
});
it('returns empty string for empty input', () => {
expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe('');
expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe('');
});
});
describe('appendSignature with channelType', () => {
const signatureWithImage =
'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)';
it('keeps images for Email channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::Email'
);
expect(result).toContain('![](http://localhost:3000/image.png');
});
it('keeps images for WebWidget channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::WebWidget'
);
expect(result).toContain('![](http://localhost:3000/image.png');
});
it('strips images but keeps text for Api channel', () => {
const result = appendSignature('Hello', signatureWithImage, 'Channel::Api');
expect(result).not.toContain('![](');
expect(result).toContain('Thanks');
});
it('strips images but keeps text for WhatsApp channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::Whatsapp'
);
expect(result).not.toContain('![](');
expect(result).toContain('Thanks');
});
it('keeps images when channelType is not provided', () => {
const result = appendSignature('Hello', signatureWithImage);
expect(result).toContain('![](http://localhost:3000/image.png');
});
it('keeps bold/italic for channels that support them', () => {
const boldSignature = '**Bold** *italic* Thanks';
const result = appendSignature('Hello', boldSignature, 'Channel::Api');
// Api supports strong and em
expect(result).toContain('**Bold**');
expect(result).toContain('*italic*');
});
});
describe('cleanSignature', () => {
it('removes any instance of horizontal rule', () => {
const options = [
@@ -304,37 +196,6 @@ describe('removeSignature', () => {
});
});
describe('removeSignature with stripped signature', () => {
const signatureWithImage =
'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)';
it('removes stripped signature from body', () => {
// Simulate a body where signature was added with images stripped
const bodyWithStrippedSignature = 'Hello\n\n--\n\nThanks';
const result = removeSignature(
bodyWithStrippedSignature,
signatureWithImage
);
expect(result).toBe('Hello\n\n');
});
it('removes original signature from body', () => {
// Simulate a body where signature was added with images (using cleanSignature format)
const cleanedSig = cleanSignature(signatureWithImage);
const bodyWithOriginalSignature = `Hello\n\n--\n\n${cleanedSig}`;
const result = removeSignature(
bodyWithOriginalSignature,
signatureWithImage
);
expect(result).toBe('Hello\n\n');
});
it('handles signature without images', () => {
const simpleSignature = 'Best regards';
const body = 'Hello\n\n--\n\nBest regards';
const result = removeSignature(body, simpleSignature);
expect(result).toBe('Hello\n\n');
});
});
describe('replaceSignature', () => {
it('appends the new signature if not present', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
@@ -397,11 +258,15 @@ describe('insertAtCursor', () => {
expect(result).toBeUndefined();
});
it('should insert text node at cursor position', () => {
it('should unwrap doc nodes that are wrapped in a paragraph', () => {
const docNode = schema.node('doc', null, [
schema.node('paragraph', null, [schema.text('Hello')]),
]);
const editorState = createEditorState();
const editorView = new EditorView(document.body, { state: editorState });
insertAtCursor(editorView, schema.text('Hello'), 0);
insertAtCursor(editorView, docNode, 0);
// Check if node was unwrapped and inserted correctly
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
@@ -761,349 +626,3 @@ describe('getContentNode', () => {
});
});
});
describe('getFormattingForEditor', () => {
describe('channel-specific formatting', () => {
it('returns full formatting for Email channel', () => {
const result = getFormattingForEditor('Channel::Email');
expect(result).toEqual(FORMATTING['Channel::Email']);
});
it('returns full formatting for WebWidget channel', () => {
const result = getFormattingForEditor('Channel::WebWidget');
expect(result).toEqual(FORMATTING['Channel::WebWidget']);
});
it('returns limited formatting for WhatsApp channel', () => {
const result = getFormattingForEditor('Channel::Whatsapp');
expect(result).toEqual(FORMATTING['Channel::Whatsapp']);
});
it('returns no formatting for API channel', () => {
const result = getFormattingForEditor('Channel::Api');
expect(result).toEqual(FORMATTING['Channel::Api']);
});
it('returns limited formatting for FacebookPage channel', () => {
const result = getFormattingForEditor('Channel::FacebookPage');
expect(result).toEqual(FORMATTING['Channel::FacebookPage']);
});
it('returns no formatting for TwitterProfile channel', () => {
const result = getFormattingForEditor('Channel::TwitterProfile');
expect(result).toEqual(FORMATTING['Channel::TwitterProfile']);
});
it('returns no formatting for SMS channel', () => {
const result = getFormattingForEditor('Channel::Sms');
expect(result).toEqual(FORMATTING['Channel::Sms']);
});
it('returns limited formatting for Telegram channel', () => {
const result = getFormattingForEditor('Channel::Telegram');
expect(result).toEqual(FORMATTING['Channel::Telegram']);
});
it('returns formatting for Instagram channel', () => {
const result = getFormattingForEditor('Channel::Instagram');
expect(result).toEqual(FORMATTING['Channel::Instagram']);
});
});
describe('context-specific formatting', () => {
it('returns default formatting for Context::Default', () => {
const result = getFormattingForEditor('Context::Default');
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns signature formatting for Context::MessageSignature', () => {
const result = getFormattingForEditor('Context::MessageSignature');
expect(result).toEqual(FORMATTING['Context::MessageSignature']);
});
it('returns widget builder formatting for Context::InboxSettings', () => {
const result = getFormattingForEditor('Context::InboxSettings');
expect(result).toEqual(FORMATTING['Context::InboxSettings']);
});
});
describe('fallback behavior', () => {
it('returns default formatting for unknown channel type', () => {
const result = getFormattingForEditor('Channel::Unknown');
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns default formatting for null channel type', () => {
const result = getFormattingForEditor(null);
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns default formatting for undefined channel type', () => {
const result = getFormattingForEditor(undefined);
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns default formatting for empty string', () => {
const result = getFormattingForEditor('');
expect(result).toEqual(FORMATTING['Context::Default']);
});
});
describe('return value structure', () => {
it('always returns an object with marks, nodes, and menu properties', () => {
const result = getFormattingForEditor('Channel::Email');
expect(result).toHaveProperty('marks');
expect(result).toHaveProperty('nodes');
expect(result).toHaveProperty('menu');
expect(Array.isArray(result.marks)).toBe(true);
expect(Array.isArray(result.nodes)).toBe(true);
expect(Array.isArray(result.menu)).toBe(true);
});
});
});
describe('stripUnsupportedFormatting', () => {
describe('when schema supports all formatting', () => {
const fullSchema = {
marks: { strong: {}, em: {}, code: {}, strike: {}, link: {} },
nodes: { bulletList: {}, orderedList: {}, codeBlock: {}, blockquote: {} },
};
it('preserves all formatting when schema supports it', () => {
const content = '**bold** and *italic* and `code`';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves links when schema supports them', () => {
const content = 'Check [this link](https://example.com)';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves lists when schema supports them', () => {
const content = '- item 1\n- item 2\n1. first\n2. second';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
});
describe('when schema has no formatting support (eg:SMS channel)', () => {
const emptySchema = {
marks: {},
nodes: {},
};
it('strips bold formatting', () => {
expect(stripUnsupportedFormatting('**bold text**', emptySchema)).toBe(
'bold text'
);
expect(stripUnsupportedFormatting('__bold text__', emptySchema)).toBe(
'bold text'
);
});
it('strips italic formatting', () => {
expect(stripUnsupportedFormatting('*italic text*', emptySchema)).toBe(
'italic text'
);
expect(stripUnsupportedFormatting('_italic text_', emptySchema)).toBe(
'italic text'
);
});
it('preserves underscores in URLs and mid-word positions', () => {
// Underscores in URLs should not be stripped as italic formatting
expect(
stripUnsupportedFormatting(
'https://www.chatwoot.com/new_first_second-third/ssd',
emptySchema
)
).toBe('https://www.chatwoot.com/new_first_second-third/ssd');
// Underscores in variable names should not be stripped
expect(
stripUnsupportedFormatting('some_variable_name', emptySchema)
).toBe('some_variable_name');
// But actual italic formatting with spaces should still be stripped
expect(
stripUnsupportedFormatting('hello _world_ there', emptySchema)
).toBe('hello world there');
});
it('strips inline code formatting', () => {
expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe(
'inline code'
);
});
it('strips strikethrough formatting', () => {
expect(stripUnsupportedFormatting('~~strikethrough~~', emptySchema)).toBe(
'strikethrough'
);
});
it('strips links but keeps text', () => {
expect(
stripUnsupportedFormatting(
'Check [this link](https://example.com)',
emptySchema
)
).toBe('Check this link');
});
it('strips bullet list markers', () => {
expect(
stripUnsupportedFormatting('- item 1\n- item 2', emptySchema)
).toBe('item 1\nitem 2');
expect(
stripUnsupportedFormatting('* item 1\n* item 2', emptySchema)
).toBe('item 1\nitem 2');
});
it('strips ordered list markers', () => {
expect(
stripUnsupportedFormatting('1. first\n2. second', emptySchema)
).toBe('first\nsecond');
});
it('strips code block markers', () => {
expect(
stripUnsupportedFormatting('```javascript\ncode here\n```', emptySchema)
).toBe('code here\n');
});
it('strips blockquote markers', () => {
expect(stripUnsupportedFormatting('> quoted text', emptySchema)).toBe(
'quoted text'
);
});
it('handles complex content with multiple formatting types', () => {
const content =
'**Bold** and *italic* with `code` and [link](url)\n- list item';
const expected = 'Bold and italic with code and link\nlist item';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
});
describe('when schema has partial support', () => {
const partialSchema = {
marks: { strong: {}, em: {} },
nodes: {},
};
it('preserves supported marks and strips unsupported ones', () => {
const content = '**bold** and `code`';
expect(stripUnsupportedFormatting(content, partialSchema)).toBe(
'**bold** and code'
);
});
it('strips unsupported nodes but keeps supported marks', () => {
const content = '**bold** text\n- list item';
expect(stripUnsupportedFormatting(content, partialSchema)).toBe(
'**bold** text\nlist item'
);
});
});
describe('edge cases', () => {
it('returns content unchanged if content is empty', () => {
expect(stripUnsupportedFormatting('', {})).toBe('');
});
it('returns content unchanged if content is null', () => {
expect(stripUnsupportedFormatting(null, {})).toBe(null);
});
it('returns content unchanged if content is undefined', () => {
expect(stripUnsupportedFormatting(undefined, {})).toBe(undefined);
});
it('returns content unchanged if schema is null', () => {
expect(stripUnsupportedFormatting('**bold**', null)).toBe('**bold**');
});
it('handles nested formatting correctly', () => {
const emptySchema = { marks: {}, nodes: {} };
// After stripping bold (**), the remaining *and italic* becomes italic and is stripped too
expect(
stripUnsupportedFormatting('**bold *and italic***', emptySchema)
).toBe('bold and italic');
});
});
});
describe('Menu positioning helpers', () => {
const mockEditorView = {
coordsAtPos: vi.fn((pos, bias) => {
// Return different coords based on position
if (bias === 1) return { top: 100, bottom: 120, left: 50, right: 100 };
return { top: 100, bottom: 120, left: 150, right: 200 };
}),
};
const wrapperRect = { top: 50, bottom: 300, left: 0, right: 400, width: 400 };
describe('getSelectionCoords', () => {
it('returns selection coordinates with onTop flag', () => {
const selection = { from: 0, to: 10 };
const result = getSelectionCoords(mockEditorView, selection, wrapperRect);
expect(result).toHaveProperty('start');
expect(result).toHaveProperty('end');
expect(result).toHaveProperty('selTop');
expect(result).toHaveProperty('onTop');
});
});
describe('getMenuAnchor', () => {
it('returns end.left when menu is below selection', () => {
const coords = { start: { left: 50 }, end: { left: 150 }, onTop: false };
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(150);
});
it('returns start.left for LTR when menu is above and visible', () => {
const coords = { start: { top: 100, left: 50 }, end: {}, onTop: true };
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(50);
});
it('returns start.right for RTL when menu is above and visible', () => {
const coords = { start: { top: 100, right: 100 }, end: {}, onTop: true };
expect(getMenuAnchor(coords, wrapperRect, true)).toBe(100);
});
});
describe('calculateMenuPosition', () => {
it('returns bounded left and top positions', () => {
const coords = {
start: { top: 100, bottom: 120, left: 50 },
end: { top: 100, bottom: 120, left: 150 },
selTop: 100,
onTop: false,
};
const result = calculateMenuPosition(coords, wrapperRect, false);
expect(result).toHaveProperty('left');
expect(result).toHaveProperty('top');
expect(result).toHaveProperty('width', 300);
expect(result.left).toBeGreaterThanOrEqual(0);
});
});
});
@@ -351,8 +351,7 @@
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
"NEW_ASSISTANT": "Create Assistant",
"EMPTY_LIST": "No assistants found, please create one to get started"
"NEW_ASSISTANT": "Create Assistant"
},
"COPILOT": {
"TITLE": "Copilot",
@@ -351,8 +351,7 @@
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
"NEW_ASSISTANT": "Create Assistant",
"EMPTY_LIST": "No assistants found, please create one to get started"
"NEW_ASSISTANT": "Create Assistant"
},
"COPILOT": {
"TITLE": "Copilot",
@@ -351,8 +351,7 @@
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
"NEW_ASSISTANT": "Create Assistant",
"EMPTY_LIST": "No assistants found, please create one to get started"
"NEW_ASSISTANT": "Create Assistant"
},
"COPILOT": {
"TITLE": "Copilot",
@@ -351,8 +351,7 @@
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
"NEW_ASSISTANT": "Create Assistant",
"EMPTY_LIST": "No assistants found, please create one to get started"
"NEW_ASSISTANT": "Create Assistant"
},
"COPILOT": {
"TITLE": "Copilot",
@@ -351,8 +351,7 @@
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
"NEW_ASSISTANT": "Create Assistant",
"EMPTY_LIST": "No assistants found, please create one to get started"
"NEW_ASSISTANT": "Create Assistant"
},
"COPILOT": {
"TITLE": "Copilot",
@@ -351,8 +351,7 @@
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
"NEW_ASSISTANT": "Create Assistant",
"EMPTY_LIST": "No assistants found, please create one to get started"
"NEW_ASSISTANT": "Create Assistant"
},
"COPILOT": {
"TITLE": "Copilot",
@@ -351,8 +351,7 @@
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
"NEW_ASSISTANT": "Create Assistant",
"EMPTY_LIST": "No assistants found, please create one to get started"
"NEW_ASSISTANT": "Create Assistant"
},
"COPILOT": {
"TITLE": "Copilot",
@@ -351,8 +351,7 @@
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
"NEW_ASSISTANT": "Create Assistant",
"EMPTY_LIST": "No assistants found, please create one to get started"
"NEW_ASSISTANT": "Create Assistant"
},
"COPILOT": {
"TITLE": "Copilot",
@@ -351,8 +351,7 @@
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
"NEW_ASSISTANT": "Create Assistant",
"EMPTY_LIST": "No assistants found, please create one to get started"
"NEW_ASSISTANT": "Create Assistant"
},
"COPILOT": {
"TITLE": "Copilot",
@@ -351,8 +351,7 @@
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
"NEW_ASSISTANT": "Create Assistant",
"EMPTY_LIST": "No assistants found, please create one to get started"
"NEW_ASSISTANT": "Create Assistant"
},
"COPILOT": {
"TITLE": "Copilot",
@@ -1,32 +0,0 @@
{
"COMPANIES": {
"HEADER": "Companies",
"SORT_BY": {
"LABEL": "Sort by",
"OPTIONS": {
"NAME": "Name",
"DOMAIN": "Domain",
"CREATED_AT": "Created at"
}
},
"ORDER": {
"LABEL": "Order",
"OPTIONS": {
"ASCENDING": "Ascending",
"DESCENDING": "Descending"
}
},
"SEARCH_PLACEHOLDER": "Search companies...",
"LOADING": "Loading companies...",
"UNNAMED": "Unnamed Company",
"CONTACTS_COUNT": "{n} contact | {n} contacts",
"EMPTY_STATE": {
"TITLE": "No companies found"
}
},
"COMPANIES_LAYOUT": {
"PAGINATION_FOOTER": {
"SHOWING": "Showing {startItem} – {endItem} of {totalItems} company | Showing {startItem} – {endItem} of {totalItems} companies"
}
}
}
@@ -1,7 +1,7 @@
{
"PAGINATION_FOOTER": {
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} item | Showing {startItem} - {endItem} of {totalItems} items",
"CURRENT_PAGE_INFO": "{currentPage} of {totalPages} page | {currentPage} of {totalPages} pages"
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} items",
"CURRENT_PAGE_INFO": "{currentPage} of {totalPages} pages"
},
"COMBOBOX": {
"PLACEHOLDER": "Select an option...",
@@ -18,8 +18,7 @@
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "New message",
"CALL": "Call",
"CALL_INITIATED": "Calling the contact…",
"CALL_FAILED": "Unable to start the call. Please try again.",
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
"VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox"
},
@@ -377,7 +376,7 @@
}
},
"PAGINATION_FOOTER": {
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} contact | Showing {startItem} - {endItem} of {totalItems} contacts"
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} contacts"
},
"FILTER": {
"NAME": "Name",
@@ -196,6 +196,7 @@
"INSERT_READ_MORE": "Read more",
"DISMISS_REPLY": "Dismiss reply",
"REPLYING_TO": "Replying to:",
"TIP_FORMAT_ICON": "Show rich text editor",
"TIP_EMOJI_ICON": "Show emoji selector",
"TIP_ATTACH_ICON": "Attach files",
"TIP_AUDIORECORDER_ICON": "Record audio",
@@ -381,11 +381,7 @@
"API": {
"ERROR_MESSAGE": "We were not able to save the email channel"
},
"FINISH_MESSAGE": "Your email inbox has been created successfully! You can start forwarding your emails to the address below, or configure SMTP and IMAP credentials to send and receive emails directly.",
"FINISH_MESSAGE_NO_FORWARDING": "Your email inbox has been created successfully! You need to configure SMTP and IMAP credentials to send and receive emails. Without these settings, no emails will be processed.",
"FORWARDING_ADDRESS_LABEL": "Forward emails to this address:",
"CONFIGURE_SMTP_IMAP_LINK": "Click here",
"CONFIGURE_SMTP_IMAP_TEXT": " to configure IMAP and SMTP settings"
"FINISH_MESSAGE": "Start forwarding your emails to the following email address."
},
"LINE_CHANNEL": {
"TITLE": "LINE Channel",
@@ -711,7 +707,6 @@
"INBOX_IDENTIFIER_SUB_TEXT": "Use the `inbox_identifier` token shown here to authentication your API clients.",
"FORWARD_EMAIL_TITLE": "Forward to Email",
"FORWARD_EMAIL_SUB_TEXT": "Start forwarding your emails to the following email address.",
"FORWARD_EMAIL_NOT_CONFIGURED": "Forwarding emails to your inbox is currently disabled on this installation. To use this feature, it must be enabled by your administrator. Please get in touch with them to proceed.",
"ALLOW_MESSAGES_AFTER_RESOLVED": "Allow messages after conversation resolved",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved.",
"WHATSAPP_SECTION_SUBHEADER": "This API Key is used for the integration with the WhatsApp APIs.",
@@ -8,7 +8,6 @@ import bulkActions from './bulkActions.json';
import campaign from './campaign.json';
import cannedMgmt from './cannedMgmt.json';
import chatlist from './chatlist.json';
import companies from './companies.json';
import components from './components.json';
import contact from './contact.json';
import contactFilters from './contactFilters.json';
@@ -38,7 +37,6 @@ import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
import yearInReview from './yearInReview.json';
export default {
...advancedFilters,
@@ -51,7 +49,6 @@ export default {
...campaign,
...cannedMgmt,
...chatlist,
...companies,
...components,
...contact,
...contactFilters,
@@ -81,5 +78,4 @@ export default {
...whatsappTemplates,
...contentTemplates,
...mfa,
...yearInReview,
};

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