Compare commits

..
Author SHA1 Message Date
Pranav f4f8c94d6a Update spec 2026-01-09 15:29:04 -08:00
Pranav 8fcdfe28a1 Update swagger 2026-01-09 15:25:03 -08:00
salmonumbrella 4f503f33e1 docs(api): add contact merge endpoint to swagger documentation
Add API documentation for POST /api/v1/accounts/{account_id}/actions/contact_merge

This endpoint allows merging two contacts, where the base contact survives
and receives all data from the mergee contact.

Closes chatwoot/docs#243
2026-01-02 01:42:52 -08:00
915 changed files with 4324 additions and 27690 deletions
+3 -47
View File
@@ -76,7 +76,7 @@ jobs:
bundle install
- node/install:
node-version: '24.12'
node-version: '23.7'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -117,7 +117,7 @@ jobs:
steps:
- checkout
- node/install:
node-version: '24.12'
node-version: '23.7'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -148,7 +148,7 @@ jobs:
steps:
- checkout
- node/install:
node-version: '24.12'
node-version: '23.7'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -218,49 +218,6 @@ jobs:
source ~/.rvm/scripts/rvm
bundle install
# Install and configure OpenSearch
- run:
name: Install OpenSearch
command: |
# Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients)
wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz
tar -xzf opensearch-2.11.0-linux-x64.tar.gz
sudo mv opensearch-2.11.0 /opt/opensearch
- run:
name: Configure and Start OpenSearch
command: |
# Configure OpenSearch for single-node testing
cat > /opt/opensearch/config/opensearch.yml \<< EOF
cluster.name: chatwoot-test
node.name: node-1
network.host: 0.0.0.0
http.port: 9200
discovery.type: single-node
plugins.security.disabled: true
EOF
# Set ownership and permissions
sudo chown -R $USER:$USER /opt/opensearch
# Start OpenSearch in background
/opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid
- run:
name: Wait for OpenSearch to be ready
command: |
echo "Waiting for OpenSearch to start..."
for i in {1..30}; do
if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then
echo "OpenSearch is ready!"
exit 0
fi
echo "Waiting... ($i/30)"
sleep 2
done
echo "OpenSearch failed to start"
exit 1
# Configure environment and database
- run:
name: Database Setup and Configure Environment Variables
@@ -277,7 +234,6 @@ jobs:
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env"
# Database setup
- run:
+1 -1
View File
@@ -10,7 +10,7 @@ services:
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '24.12.0'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
+1 -1
View File
@@ -11,7 +11,7 @@ services:
dockerfile: .devcontainer/Dockerfile
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '24.12.0'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
-2
View File
@@ -274,5 +274,3 @@ AZURE_APP_SECRET=
# Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 24
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
+3 -3
View File
@@ -28,7 +28,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
@@ -43,7 +43,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
@@ -94,7 +94,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 24
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 24
node-version: 23
cache: 'pnpm'
- name: pnpm
+1 -1
View File
@@ -1 +1 @@
24.12.0
23.7.0
+1 -7
View File
@@ -47,14 +47,8 @@
- 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
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors
## Commit Messages
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
- Example: `feat(auth): add user authentication`
- Don't reference Claude in commit messages
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
## Project-Specific
+15 -20
View File
@@ -140,27 +140,24 @@ GEM
actionmailbox (>= 7.1.0)
aws-sdk-s3 (~> 1, >= 1.123.0)
aws-sdk-sns (~> 1, >= 1.61.0)
aws-eventstream (1.4.0)
aws-partitions (1.1198.0)
aws-sdk-core (3.240.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
aws-eventstream (1.2.0)
aws-partitions (1.760.0)
aws-sdk-core (3.188.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.208.0)
aws-sdk-core (~> 3, >= 3.234.0)
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-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (~> 1.4)
aws-sdk-sns (1.70.0)
aws-sdk-core (~> 3, >= 3.188.0)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.12.1)
aws-sigv4 (1.5.2)
aws-eventstream (~> 1, >= 1.0.2)
barnes (0.0.9)
multi_json (~> 1)
@@ -441,8 +438,7 @@ GEM
http-cookie (1.0.5)
domain_name (~> 0.5)
http-form_data (2.3.0)
httparty (0.24.0)
csv
httparty (0.21.0)
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
@@ -556,8 +552,7 @@ GEM
ruby2_keywords
msgpack (1.8.0)
multi_json (1.15.0)
multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5)
multi_xml (0.6.0)
multipart-post (2.3.0)
mutex_m (0.3.0)
neighbor (0.2.3)
+2 -1
View File
@@ -8,6 +8,7 @@ ___
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p>
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
@@ -136,4 +137,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2026, Chatwoot Inc - Released under the MIT License.
*Chatwoot* &copy; 2017-2025, Chatwoot Inc - Released under the MIT License.
+1 -1
View File
@@ -1 +1 @@
4.9.1
4.8.0
+1 -1
View File
@@ -1 +1 @@
3.5.0
3.4.3
@@ -1,6 +1,4 @@
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
include AttachmentConcern
before_action :check_authorization
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
@@ -11,32 +9,25 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
def show; end
def create
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
return render_could_not_create_error(error) if error
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
@automation_rule.actions = actions
@automation_rule.actions = params[:actions]
@automation_rule.conditions = params[:conditions]
return render_could_not_create_error(@automation_rule.errors.messages) unless @automation_rule.valid?
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
@automation_rule.save!
blobs.each { |blob| @automation_rule.files.attach(blob) }
process_attachments
@automation_rule
end
def update
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @automation_rule)
return render_could_not_create_error(error) if error
ActiveRecord::Base.transaction do
@automation_rule.assign_attributes(automation_rules_permit)
@automation_rule.actions = actions if params[:actions]
@automation_rule.conditions = params[:conditions] if params[:conditions]
@automation_rule.save!
blobs.each { |blob| @automation_rule.files.attach(blob) }
automation_rule_update
process_attachments
rescue StandardError => e
Rails.logger.error e
render_could_not_create_error(@automation_rule.errors.messages)
render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
end
end
@@ -52,11 +43,29 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rule = new_rule
end
def process_attachments
actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
return if actions.blank?
actions.each do |action|
blob_id = action['action_params']
blob = ActiveStorage::Blob.find_by(id: blob_id)
@automation_rule.files.attach(blob)
end
end
private
def automation_rule_update
@automation_rule.update!(automation_rules_permit)
@automation_rule.actions = params[:actions] if params[:actions]
@automation_rule.conditions = params[:conditions] if params[:conditions]
@automation_rule.save!
end
def automation_rules_permit
params.permit(
:name, :description, :event_name, :active,
:name, :description, :event_name, :account_id, :active,
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
actions: [:action_name, { action_params: [] }]
)
@@ -1,76 +0,0 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :authorize_account_update, only: [:update]
def show
render json: preferences_payload
end
def update
params_to_update = captain_params
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
@current_account.save!
render json: preferences_payload
end
private
def preferences_payload
{
providers: Llm::Models.providers,
models: Llm::Models.models,
features: features_with_account_preferences
}
end
def authorize_account_update
authorize @current_account, :update?
end
def captain_params
permitted = {}
permitted[:captain_models] = merged_captain_models if params[:captain_models].present?
permitted[:captain_features] = merged_captain_features if params[:captain_features].present?
permitted
end
def merged_captain_models
existing_models = @current_account.captain_models || {}
existing_models.merge(permitted_captain_models)
end
def merged_captain_features
existing_features = @current_account.captain_features || {}
existing_features.merge(permitted_captain_features)
end
def permitted_captain_models
params.require(:captain_models).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end
def permitted_captain_features
params.require(:captain_features).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end
def features_with_account_preferences
preferences = Current.account.captain_preferences
account_features = preferences[:features] || {}
account_models = preferences[:models] || {}
Llm::Models.feature_keys.index_with do |feature_key|
config = Llm::Models.feature_config(feature_key)
config.merge(
enabled: account_features[feature_key] == true,
selected: account_models[feature_key] || config[:default]
)
end
end
end
@@ -64,7 +64,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def permitted_params
params.require(:twilio_channel).permit(
:messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
:account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
)
end
end
@@ -1,6 +1,4 @@
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
include AttachmentConcern
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
@@ -13,32 +11,26 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
end
def create
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
return render_could_not_create_error(error) if error
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
@macro.set_visibility(current_user, permitted_params)
@macro.actions = actions
@macro.actions = params[:actions]
return render_could_not_create_error(@macro.errors.messages) unless @macro.valid?
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
@macro.save!
blobs.each { |blob| @macro.files.attach(blob) }
process_attachments
@macro
end
def update
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @macro)
return render_could_not_create_error(error) if error
ActiveRecord::Base.transaction do
@macro.assign_attributes(macros_with_user)
@macro.update!(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
@macro.actions = actions if params[:actions]
process_attachments
@macro.save!
blobs.each { |blob| @macro.files.attach(blob) }
rescue StandardError => e
Rails.logger.error e
render_could_not_create_error(@macro.errors.messages)
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
end
end
@@ -55,9 +47,20 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
private
def process_attachments
actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
return if actions.blank?
actions.each do |action|
blob_id = action['action_params']
blob = ActiveStorage::Blob.find_by(id: blob_id)
@macro.files.attach(blob)
end
end
def permitted_params
params.permit(
:name, :visibility,
:name, :account_id, :visibility,
actions: [:action_name, { action_params: [] }]
)
end
@@ -62,7 +62,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def process_attached_logo
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_signed(blob_id)
blob = ActiveStorage::Blob.find_by(id: blob_id)
@portal.logo.attach(blob)
end
@@ -78,7 +78,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link,
:id, :account_id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
)
end
@@ -28,7 +28,5 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
search_type: search_type,
params: params
).perform
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end
end
@@ -1,15 +0,0 @@
class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include Tiktok::IntegrationHelper
def create
redirect_url = Tiktok::AuthClient.authorize_url(
state: generate_tiktok_token(Current.account.id)
)
if redirect_url
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
end
@@ -59,7 +59,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
end
def render_success(file_blob)
render json: { file_url: url_for(file_blob), blob_id: file_blob.signed_id }
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
end
def render_error(message, status)
@@ -1,35 +0,0 @@
module AttachmentConcern
extend ActiveSupport::Concern
def validate_and_prepare_attachments(actions, record = nil)
blobs = []
return [blobs, actions, nil] if actions.blank?
sanitized = actions.map do |action|
next action unless action[:action_name] == 'send_attachment'
result = process_attachment_action(action, record, blobs)
return [nil, nil, I18n.t('errors.attachments.invalid')] unless result
result
end
[blobs, sanitized, nil]
end
private
def process_attachment_action(action, record, blobs)
blob_id = action[:action_params].first
blob = ActiveStorage::Blob.find_signed(blob_id.to_s)
return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present?
return action if blob_already_attached?(record, blob_id)
nil
end
def blob_already_attached?(record, blob_id)
record&.files&.any? { |f| f.blob_id == blob_id.to_i }
end
end
+1 -2
View File
@@ -16,7 +16,7 @@ class DashboardController < ActionController::Base
CHATWOOT_INBOX_TOKEN
API_CHANNEL_NAME
API_CHANNEL_THUMBNAIL
CLOUD_ANALYTICS_TOKEN
ANALYTICS_TOKEN
DIRECT_UPLOADS_ENABLED
MAXIMUM_FILE_UPLOAD_SIZE
HCAPTCHA_SITE_KEY
@@ -73,7 +73,6 @@ class DashboardController < ActionController::Base
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
TIKTOK_APP_ID: GlobalConfigService.load('TIKTOK_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'),
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
@@ -46,7 +46,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
'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]
@@ -1,144 +0,0 @@
class Tiktok::CallbacksController < ApplicationController
include Tiktok::IntegrationHelper
def show
return handle_authorization_error if params[:error].present?
return handle_ungranted_scopes_error unless all_scopes_granted?
process_successful_authorization
rescue StandardError => e
handle_error(e)
end
private
def all_scopes_granted?
granted_scopes = short_term_access_token[:scope].to_s.split(',')
(Tiktok::AuthClient::REQUIRED_SCOPES - granted_scopes).blank?
end
def process_successful_authorization
inbox, already_exists = find_or_create_inbox
if already_exists
redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else
redirect_to app_tiktok_inbox_agents_url(account_id: account_id, inbox_id: inbox.id)
end
end
def handle_error(error)
Rails.logger.error("TikTok Channel creation Error: #{error.message}")
ChatwootExceptionTracker.new(error).capture_exception
redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message)
end
# Handles the case when a user denies permissions or cancels the authorization flow
def handle_authorization_error
redirect_to_error_page(
error_type: params[:error] || 'access_denied',
code: params[:error_code],
error_message: params[:error_description] || 'User cancelled the Authorization'
)
end
# Handles the case when a user partially accepted the required scopes
def handle_ungranted_scopes_error
redirect_to_error_page(
error_type: 'ungranted_scopes',
code: 400,
error_message: 'User did not grant all the required scopes'
)
end
# Centralized method to redirect to error page with appropriate parameters
# This ensures consistent error handling across different error scenarios
# Frontend will handle the error page based on the error_type
def redirect_to_error_page(error_type:, code:, error_message:)
redirect_to app_new_tiktok_inbox_url(
account_id: account_id,
error_type: error_type,
code: code,
error_message: error_message
)
end
def find_or_create_inbox
business_details = tiktok_client.business_account_details
channel_tiktok = find_channel
channel_exists = channel_tiktok.present?
if channel_tiktok
update_channel(channel_tiktok, business_details)
else
channel_tiktok = create_channel_with_inbox(business_details)
end
# reauthorized will also update cache keys for the associated inbox
channel_tiktok.reauthorized!
set_avatar(channel_tiktok.inbox, business_details[:profile_image]) if business_details[:profile_image].present?
[channel_tiktok.inbox, channel_exists]
end
def create_channel_with_inbox(business_details)
ActiveRecord::Base.transaction do
channel_tiktok = Channel::Tiktok.create!(
account: account,
business_id: short_term_access_token[:business_id],
access_token: short_term_access_token[:access_token],
refresh_token: short_term_access_token[:refresh_token],
expires_at: short_term_access_token[:expires_at],
refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at]
)
account.inboxes.create!(
account: account,
channel: channel_tiktok,
name: business_details[:display_name].presence || business_details[:username]
)
channel_tiktok
end
end
def find_channel
Channel::Tiktok.find_by(business_id: short_term_access_token[:business_id], account: account)
end
def update_channel(channel_tiktok, business_details)
channel_tiktok.update!(
access_token: short_term_access_token[:access_token],
refresh_token: short_term_access_token[:refresh_token],
expires_at: short_term_access_token[:expires_at],
refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at]
)
channel_tiktok.inbox.update!(name: business_details[:display_name].presence || business_details[:username])
end
def set_avatar(inbox, avatar_url)
Avatar::AvatarFromUrlJob.perform_later(inbox, avatar_url)
end
def account_id
@account_id ||= verify_tiktok_token(params[:state])
end
def account
@account ||= Account.find(account_id)
end
def short_term_access_token
@short_term_access_token ||= Tiktok::AuthClient.obtain_short_term_access_token(params[:code])
end
def tiktok_client
@tiktok_client ||= Tiktok::Client.new(
business_id: short_term_access_token[:business_id],
access_token: short_term_access_token[:access_token]
)
end
end
@@ -1,53 +0,0 @@
class Webhooks::TiktokController < ActionController::API
before_action :verify_signature!
def events
event = JSON.parse(request_payload)
if echo_event?
# Add delay to prevent race condition where echo arrives before send message API completes
# This avoids duplicate messages when echo comes early during API processing
::Webhooks::TiktokEventsJob.set(wait: 2.seconds).perform_later(event)
else
::Webhooks::TiktokEventsJob.perform_later(event)
end
head :ok
end
private
def request_payload
@request_payload ||= request.body.read
end
def verify_signature!
signature_header = request.headers['Tiktok-Signature']
client_secret = GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
received_timestamp, received_signature = extract_signature_parts(signature_header)
return head :unauthorized unless client_secret && received_timestamp && received_signature
signature_payload = "#{received_timestamp}.#{request_payload}"
computed_signature = OpenSSL::HMAC.hexdigest('SHA256', client_secret, signature_payload)
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed_signature, received_signature)
# Check timestamp delay (acceptable delay: 5 seconds)
current_timestamp = Time.current.to_i
delay = current_timestamp - received_timestamp
return head :unauthorized if delay > 5
end
def extract_signature_parts(signature_header)
return [nil, nil] if signature_header.blank?
keys = signature_header.split(',')
signature_parts = keys.map { |part| part.split('=') }.to_h
[signature_parts['t']&.to_i, signature_parts['s']]
end
def echo_event?
params[:event] == 'im_send_msg'
end
end
-6
View File
@@ -78,12 +78,6 @@ instagram:
enabled: true
icon: 'icon-instagram'
config_key: 'instagram'
tiktok:
name: 'TikTok'
description: 'Stay connected with your customers on TikTok'
enabled: true
icon: 'icon-tiktok'
config_key: 'tiktok'
whatsapp:
name: 'WhatsApp'
description: 'Manage your WhatsApp business interactions from Chatwoot.'
-47
View File
@@ -1,47 +0,0 @@
module Tiktok::IntegrationHelper
# Generates a signed JWT token for Tiktok integration
#
# @param account_id [Integer] The account ID to encode in the token
# @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_tiktok_token(account_id)
return if client_secret.blank?
JWT.encode(token_payload(account_id), client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate TikTok token: #{e.message}")
nil
end
# Verifies and decodes a Tiktok JWT token
#
# @param token [String] The JWT token to verify
# @return [Integer, nil] The account ID from the token or nil if invalid
def verify_tiktok_token(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)
end
private
def client_secret
@client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
end
def token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i
}
end
def decode_token(token, secret)
JWT.decode(token, secret, true, {
algorithm: 'HS256',
verify_expiration: true
}).first['sub']
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}")
nil
end
end
@@ -1,18 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainPreferences extends ApiClient {
constructor() {
super('captain/preferences', { accountScoped: true });
}
get() {
return axios.get(this.url);
}
updatePreferences(data) {
return axios.put(this.url, data);
}
}
export default new CaptainPreferences();
@@ -1,14 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class TiktokChannel extends ApiClient {
constructor() {
super('tiktok', { accountScoped: true });
}
generateAuthorization(payload) {
return axios.post(`${this.url}/authorization`, payload);
}
}
export default new TiktokChannel();
@@ -1,95 +0,0 @@
import { Device } from '@twilio/voice-sdk';
import VoiceAPI from './voiceAPIClient';
const createCallDisconnectedEvent = () => new CustomEvent('call:disconnected');
class TwilioVoiceClient extends EventTarget {
constructor() {
super();
this.device = null;
this.activeConnection = null;
this.initialized = false;
this.inboxId = null;
}
async initializeDevice(inboxId) {
this.destroyDevice();
const response = await VoiceAPI.getToken(inboxId);
const { token, account_id } = response || {};
if (!token) throw new Error('Invalid token');
this.device = new Device(token, {
allowIncomingWhileBusy: true,
disableAudioContextSounds: true,
appParams: { account_id },
});
this.device.removeAllListeners();
this.device.on('connect', conn => {
this.activeConnection = conn;
conn.on('disconnect', this.onDisconnect);
});
this.device.on('disconnect', this.onDisconnect);
this.device.on('tokenWillExpire', async () => {
const r = await VoiceAPI.getToken(this.inboxId);
if (r?.token) this.device.updateToken(r.token);
});
this.initialized = true;
this.inboxId = inboxId;
return this.device;
}
get hasActiveConnection() {
return !!this.activeConnection;
}
endClientCall() {
if (this.activeConnection) {
this.activeConnection.disconnect();
}
this.activeConnection = null;
if (this.device) {
this.device.disconnectAll();
}
}
destroyDevice() {
if (this.device) {
this.device.destroy();
}
this.activeConnection = null;
this.device = null;
this.initialized = false;
this.inboxId = null;
}
async joinClientCall({ to, conversationId }) {
if (!this.device || !this.initialized || !to) return null;
if (this.activeConnection) return this.activeConnection;
const params = {
To: to,
is_agent: 'true',
conversation_id: conversationId,
};
const connection = await this.device.connect({ params });
this.activeConnection = connection;
connection.on('disconnect', this.onDisconnect);
return connection;
}
onDisconnect = () => {
this.activeConnection = null;
this.dispatchEvent(createCallDisconnectedEvent());
};
}
export default new TwilioVoiceClient();
@@ -1,40 +0,0 @@
/* global axios */
import ApiClient from '../../ApiClient';
import ContactsAPI from '../../contacts';
class VoiceAPI extends ApiClient {
constructor() {
super('voice', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
initiateCall(contactId, inboxId) {
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
}
leaveConference(inboxId, conversationId) {
return axios
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
params: { conversation_id: conversationId },
})
.then(r => r.data);
}
joinConference({ conversationId, inboxId, callSid }) {
return axios
.post(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
conversation_id: conversationId,
call_sid: callSid,
})
.then(r => r.data);
}
getToken(inboxId) {
if (!inboxId) return Promise.reject(new Error('Inbox ID is required'));
return axios
.get(`${this.baseUrl()}/inboxes/${inboxId}/conference/token`)
.then(r => r.data);
}
}
export default new VoiceAPI();
-10
View File
@@ -32,16 +32,6 @@ class Inboxes extends CacheEnabledApiClient {
syncTemplates(inboxId) {
return axios.post(`${this.url}/${inboxId}/sync_templates`);
}
createCSATTemplate(inboxId, template) {
return axios.post(`${this.url}/${inboxId}/csat_template`, {
template,
});
}
getCSATTemplateStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/csat_template`);
}
}
export default new Inboxes();
+4 -14
View File
@@ -14,48 +14,38 @@ class SearchAPI extends ApiClient {
});
}
contacts({ q, page = 1, since, until }) {
contacts({ q, page = 1 }) {
return axios.get(`${this.url}/contacts`, {
params: {
q,
page: page,
since,
until,
},
});
}
conversations({ q, page = 1, since, until }) {
conversations({ q, page = 1 }) {
return axios.get(`${this.url}/conversations`, {
params: {
q,
page: page,
since,
until,
},
});
}
messages({ q, page = 1, since, until, from, inboxId }) {
messages({ q, page = 1 }) {
return axios.get(`${this.url}/messages`, {
params: {
q,
page: page,
since,
until,
from,
inbox_id: inboxId,
},
});
}
articles({ q, page = 1, since, until }) {
articles({ q, page = 1 }) {
return axios.get(`${this.url}/articles`, {
params: {
q,
page: page,
since,
until,
},
});
}
@@ -1,134 +0,0 @@
import searchAPI from '../search';
import ApiClient from '../ApiClient';
describe('#SearchAPI', () => {
it('creates correct instance', () => {
expect(searchAPI).toBeInstanceOf(ApiClient);
expect(searchAPI).toHaveProperty('get');
expect(searchAPI).toHaveProperty('contacts');
expect(searchAPI).toHaveProperty('conversations');
expect(searchAPI).toHaveProperty('messages');
expect(searchAPI).toHaveProperty('articles');
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
get: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
vi.clearAllMocks();
});
it('#get', () => {
searchAPI.get({ q: 'test query' });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search', {
params: { q: 'test query' },
});
});
it('#contacts', () => {
searchAPI.contacts({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
params: { q: 'test', page: 1, since: undefined, until: undefined },
});
});
it('#contacts with date filters', () => {
searchAPI.contacts({
q: 'test',
page: 2,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
});
});
it('#conversations', () => {
searchAPI.conversations({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/search/conversations',
{
params: { q: 'test', page: 1, since: undefined, until: undefined },
}
);
});
it('#conversations with date filters', () => {
searchAPI.conversations({
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/search/conversations',
{
params: { q: 'test', page: 1, since: 1700000000, until: 1732000000 },
}
);
});
it('#messages', () => {
searchAPI.messages({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
params: {
q: 'test',
page: 1,
since: undefined,
until: undefined,
from: undefined,
inbox_id: undefined,
},
});
});
it('#messages with all filters', () => {
searchAPI.messages({
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
from: 'contact:42',
inboxId: 10,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
params: {
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
from: 'contact:42',
inbox_id: 10,
},
});
});
it('#articles', () => {
searchAPI.articles({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
params: { q: 'test', page: 1, since: undefined, until: undefined },
});
});
it('#articles with date filters', () => {
searchAPI.articles({
q: 'test',
page: 2,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
});
});
});
});
@@ -1,35 +0,0 @@
import ApiClient from '../ApiClient';
import tiktokClient from '../channel/tiktokClient';
describe('#TiktokClient', () => {
it('creates correct instance', () => {
expect(tiktokClient).toBeInstanceOf(ApiClient);
expect(tiktokClient).toHaveProperty('generateAuthorization');
});
describe('#generateAuthorization', () => {
const originalAxios = window.axios;
const originalPathname = window.location.pathname;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
window.history.pushState({}, '', '/app/accounts/1/settings');
});
afterEach(() => {
window.axios = originalAxios;
window.history.pushState({}, '', originalPathname);
});
it('posts to the authorization endpoint', () => {
tiktokClient.generateAuthorization({ state: 'test-state' });
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/accounts/1/tiktok/authorization',
{ state: 'test-state' }
);
});
});
});
@@ -19,7 +19,7 @@ const handleClick = () => {
<template>
<div
class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2"
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
>
<div
class="flex w-full gap-3 py-5"
@@ -35,10 +35,6 @@ const sortMenus = [
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
value: 'created_at',
},
{
label: t('COMPANIES.SORT_BY.OPTIONS.CONTACTS_COUNT'),
value: 'contacts_count',
},
];
const orderingMenus = [
@@ -44,7 +44,6 @@ const SOCIAL_CONFIG = {
LINKEDIN: 'i-ri-linkedin-box-fill',
FACEBOOK: 'i-ri-facebook-circle-fill',
INSTAGRAM: 'i-ri-instagram-line',
TIKTOK: 'i-ri-tiktok-fill',
TWITTER: 'i-ri-twitter-x-fill',
GITHUB: 'i-ri-github-fill',
};
@@ -66,7 +65,6 @@ const defaultState = {
facebook: '',
github: '',
instagram: '',
tiktok: '',
linkedin: '',
twitter: '',
},
@@ -40,12 +40,7 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
</script>
<template>
<Dialog
ref="dialogRef"
width="3xl"
overflow-y-auto
@confirm="handleDialogConfirm"
>
<Dialog ref="dialogRef" width="3xl" @confirm="handleDialogConfirm">
<ContactsForm
ref="contactsFormRef"
is-new-contact
@@ -6,7 +6,6 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -68,17 +67,6 @@ const startCall = async inboxId => {
contactId: props.contactId,
inboxId,
});
const { call_sid: callSid, conversation_id: conversationId } = response;
// Add call to store immediately so widget shows
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
conversationId,
inboxId,
callDirection: 'outbound',
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(response?.conversation_id);
} catch (error) {
@@ -1,88 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
import { computed } from 'vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
badges: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['edit', 'delete']);
const iconByType = {
text: 'i-lucide-align-justify',
checkbox: 'i-lucide-circle-check-big',
list: 'i-lucide-list',
date: 'i-lucide-calendar',
link: 'i-lucide-link',
number: 'i-lucide-hash',
};
const attributeIcon = computed(() => {
const typeKey = props.attribute.type?.toLowerCase();
return iconByType[typeKey] || 'i-lucide-align-justify';
});
</script>
<template>
<div
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
>
<div class="flex flex-wrap gap-2 justify-between items-center">
<div class="flex flex-wrap gap-2 items-center min-w-0">
<h4 class="text-sm font-medium truncate text-n-slate-12">
{{ attribute.label }}
</h4>
<div class="w-px h-3 bg-n-strong" />
<div class="flex gap-2 items-center text-sm text-n-slate-11">
<div class="flex items-center gap-1.5 text-n-slate-11">
<Icon :icon="attributeIcon" class="size-4" />
<span class="text-sm">{{ attribute.type }}</span>
</div>
<div class="w-px h-3 bg-n-weak" />
<div class="flex items-center gap-1.5 text-n-slate-11">
<Icon icon="i-lucide-key-round" class="size-4" />
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
</div>
</div>
</div>
<div class="flex gap-2 items-center">
<AttributeBadge
v-for="badge in badges"
:key="badge.type"
:type="badge.type"
/>
<div
v-if="badges.length > 0"
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
/>
<Button
icon="i-lucide-pencil-line"
size="sm"
color="slate"
ghost
@click="emit('edit', attribute)"
/>
<div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-trash"
size="sm"
color="slate"
ghost
@click="emit('delete', attribute)"
/>
</div>
</div>
<p class="mb-0 text-sm text-n-slate-11">
{{ attribute.attribute_description || attribute.description || '' }}
</p>
</div>
</template>
@@ -1,42 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
type: {
type: String,
default: 'resolution',
validator: value => ['pre-chat', 'resolution'].includes(value),
},
});
const { t } = useI18n();
const attributeConfig = {
'pre-chat': {
colorClass: 'text-n-blue-11',
icon: 'i-lucide-message-circle',
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
},
resolution: {
colorClass: 'text-n-teal-11',
icon: 'i-lucide-circle-check-big',
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
},
};
const config = computed(
() => attributeConfig[props.type] || attributeConfig.resolution
);
</script>
<template>
<div
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
>
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
<span class="text-xs" :class="config.colorClass">{{
t(config.labelKey)
}}</span>
</div>
</template>
@@ -171,13 +171,10 @@ const onPaste = e => {
const files = e.clipboardData?.files;
if (!files?.length) return;
// Filter valid files (non-zero size)
Array.from(files)
.filter(file => file.size > 0)
.forEach(file => {
const { name, type, size } = file;
onFileUpload({ file, name, type, size });
});
Array.from(files).forEach(file => {
const { name, type, size } = file;
onFileUpload({ file, name, type, size });
});
};
useEventListener(document, 'paste', onPaste);
@@ -146,7 +146,7 @@ const STYLE_CONFIG = {
solid:
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
faded:
'bg-n-teal-9/10 text-n-teal-11 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
outline:
'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9',
link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent',
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
menuItems: {
@@ -38,13 +37,9 @@ const props = defineProps({
type: String,
default: '',
},
disableLocalFiltering: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['action', 'search']);
const emit = defineEmits(['action']);
const { t } = useI18n();
@@ -62,7 +57,6 @@ const flattenedMenuItems = computed(() => {
});
const filteredMenuItems = computed(() => {
if (props.disableLocalFiltering) return props.menuItems;
if (!searchQuery.value) return flattenedMenuItems.value;
return flattenedMenuItems.value.filter(item =>
@@ -75,7 +69,7 @@ const filteredMenuSections = computed(() => {
return [];
}
if (props.disableLocalFiltering || !searchQuery.value) {
if (!searchQuery.value) {
return props.menuSections;
}
@@ -95,12 +89,6 @@ const filteredMenuSections = computed(() => {
.filter(section => section.items.length > 0);
});
const handleSearchInput = event => {
if (props.disableLocalFiltering) {
emit('search', event.target.value);
}
};
const handleAction = item => {
const { action, value, ...rest } = item;
emit('action', { action, value, ...rest });
@@ -130,7 +118,7 @@ onMounted(() => {
>
<div
v-if="showSearch"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2 z-20"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
>
<div class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
@@ -142,7 +130,6 @@ onMounted(() => {
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
@input="handleSearchInput"
/>
</div>
</div>
@@ -154,23 +141,10 @@ onMounted(() => {
>
<p
v-if="section.title"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky z-10 bg-n-alpha-3 backdrop-blur-sm"
:class="showSearch ? 'top-10' : 'top-0'"
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
>
{{ section.title }}
</p>
<div
v-if="section.isLoading"
class="flex items-center justify-center py-2"
>
<Spinner :size="24" />
</div>
<div
v-else-if="!section.items.length && section.emptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{ section.emptyState }}
</div>
<button
v-for="(item, itemIndex) in section.items"
:key="item.value || itemIndex"
@@ -261,6 +235,5 @@ onMounted(() => {
: t('DROPDOWN_MENU.EMPTY_STATE')
}}
</div>
<slot name="footer" />
</div>
</template>
@@ -13,7 +13,6 @@ export function useChannelIcon(inbox) {
'Channel::WebWidget': 'i-woot-website',
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
'Channel::Voice': 'i-ri-phone-fill',
};
@@ -61,12 +61,6 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-woot-instagram');
});
it('returns correct icon for TikTok channel', () => {
const inbox = { channel_type: 'Channel::Tiktok' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-tiktok');
});
describe('TwilioSms channel', () => {
it('returns chat icon for regular Twilio SMS channel', () => {
const inbox = { channel_type: 'Channel::TwilioSms' };
@@ -28,7 +28,6 @@ import ImageBubble from './bubbles/Image.vue';
import FileBubble from './bubbles/File.vue';
import AudioBubble from './bubbles/Audio.vue';
import VideoBubble from './bubbles/Video.vue';
import EmbedBubble from './bubbles/Embed.vue';
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
import EmailBubble from './bubbles/Email/Index.vue';
import UnsupportedBubble from './bubbles/Unsupported.vue';
@@ -318,7 +317,6 @@ const componentToRender = computed(() => {
if (fileType === ATTACHMENT_TYPES.AUDIO) return AudioBubble;
if (fileType === ATTACHMENT_TYPES.VIDEO) return VideoBubble;
if (fileType === ATTACHMENT_TYPES.IG_REEL) return VideoBubble;
if (fileType === ATTACHMENT_TYPES.EMBED) return EmbedBubble;
if (fileType === ATTACHMENT_TYPES.LOCATION) return LocationBubble;
}
// Attachment content is the name of the contact
@@ -42,10 +42,7 @@ const props = defineProps({
const emit = defineEmits(['retry']);
const allMessages = computed(() => {
return useCamelCase(props.messages, {
deep: true,
stopPaths: ['content_attributes.translations'],
});
return useCamelCase(props.messages, { deep: true });
});
const currentChat = useMapGetter('getSelectedChat');
@@ -20,7 +20,6 @@ const {
isAWhatsAppChannel,
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
} = useInbox();
const {
@@ -61,8 +60,7 @@ const isSent = computed(() => {
isAFacebookInbox.value ||
isASmsInbox.value ||
isATelegramChannel.value ||
isAnInstagramChannel.value ||
isATiktokChannel.value
isAnInstagramChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
}
@@ -80,8 +78,7 @@ const isDelivered = computed(() => {
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isASmsInbox.value ||
isAFacebookInbox.value ||
isATiktokChannel.value
isAFacebookInbox.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
}
@@ -103,8 +100,7 @@ const isRead = computed(() => {
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isAFacebookInbox.value ||
isAnInstagramChannel.value ||
isATiktokChannel.value
isAnInstagramChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.READ;
}
@@ -1,30 +0,0 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import { useMessageContext } from '../provider.js';
import { useI18n } from 'vue-i18n';
const { attachments } = useMessageContext();
const { t } = useI18n();
const attachment = computed(() => {
return attachments.value[0];
});
</script>
<template>
<BaseBubble class="overflow-hidden p-3" data-bubble-name="embed">
<div
class="w-full max-w-[360px] sm:max-w-[420px] min-h-[520px] h-[70vh] max-h-[680px]"
>
<iframe
class="w-full h-full border-0 rounded-lg"
:title="t('CHAT_LIST.ATTACHMENTS.embed.CONTENT')"
:src="attachment.dataUrl"
loading="lazy"
allow="autoplay; encrypted-media; picture-in-picture"
allowfullscreen
/>
</div>
</BaseBubble>
</template>
@@ -1,28 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
buttonText: {
type: String,
required: true,
},
});
</script>
<template>
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
<div class="p-3 rounded-xl bg-n-alpha-2">
<span
v-dompurify-html="message.content"
class="text-sm font-medium prose prose-bubble"
/>
</div>
<div class="flex gap-2">
<Button :label="buttonText" slate class="!text-n-blue-text w-full" />
</div>
</div>
</template>
@@ -17,10 +17,6 @@ const { attachment } = defineProps({
type: Object,
required: true,
},
showTranscribedText: {
type: Boolean,
default: true,
},
});
defineOptions({
@@ -186,7 +182,7 @@ const downloadAudio = async () => {
</div>
<div
v-if="attachment.transcribedText && showTranscribedText"
v-if="attachment.transcribedText"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
>
{{ attachment.transcribedText }}
@@ -49,7 +49,6 @@ export const ATTACHMENT_TYPES = {
STORY_MENTION: 'story_mention',
CONTACT: 'contact',
IG_REEL: 'ig_reel',
EMBED: 'embed',
IG_POST: 'ig_post',
IG_STORY: 'ig_story',
};
@@ -1,11 +1,12 @@
<script setup>
import { h, ref, computed, onMounted } from 'vue';
import { h, computed, onMounted } from 'vue';
import { provideSidebarContext } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components';
import { emitter } from 'shared/helpers/mitt';
@@ -54,7 +55,14 @@ const toggleShortcutModalFn = show => {
useSidebarKeyboardShortcuts(toggleShortcutModalFn);
const expandedItem = ref(null);
// We're using localStorage to store the expanded item in the sidebar
// This helps preserve context when navigating between portal and dashboard layouts
// and also when the user refreshes the page
const expandedItem = useStorage(
'next-sidebar-expanded-item',
null,
sessionStorage
);
const setExpandedItem = name => {
expandedItem.value = expandedItem.value === name ? null : name;
@@ -485,12 +493,6 @@ const menuItems = computed(() => {
icon: 'i-lucide-briefcase',
to: accountScopedRoute('general_settings_index'),
},
// {
// name: 'Settings Captain',
// label: t('SIDEBAR.CAPTAIN_AI'),
// icon: 'i-woot-captain',
// to: accountScopedRoute('captain_settings_index'),
// },
{
name: 'Settings Agents',
label: t('SIDEBAR.AGENTS'),
@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, watch, nextTick } from 'vue';
import { computed, onMounted, nextTick } from 'vue';
import { useSidebarContext } from './provider';
import { useRoute, useRouter } from 'vue-router';
import Policy from 'dashboard/components/policy.vue';
@@ -126,16 +126,6 @@ onMounted(async () => {
setExpandedItem(props.name);
}
});
watch(
hasActiveChild,
hasNewActiveChild => {
if (hasNewActiveChild && !isExpanded.value) {
setExpandedItem(props.name);
}
},
{ once: true }
);
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
@@ -1,5 +1,6 @@
<script setup>
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import Auth from 'dashboard/api/auth';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
@@ -24,6 +25,7 @@ defineOptions({
});
const { t } = useI18n();
const route = useRoute();
const { uiSettings } = useUISettings();
const currentUser = useMapGetter('getCurrentUser');
@@ -45,7 +47,7 @@ const isBannerClosed = computed(() => {
});
const showYearInReviewMenuItem = computed(() => {
return isBannerClosed.value;
return route.query['year-in-review'] === 'true' && isBannerClosed.value;
});
const openYearInReviewModal = () => {
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref, onMounted, nextTick, watch } from 'vue';
import { computed, ref, onMounted, nextTick } from 'vue';
import { useResizeObserver } from '@vueuse/core';
const props = defineProps({
@@ -31,24 +31,20 @@ const enableTransition = ref(false);
const activeElement = computed(() => tabRefs.value[activeTab.value]);
const updateIndicator = () => {
nextTick(() => {
if (!activeElement.value) return;
if (!activeElement.value) return;
indicatorStyle.value = {
left: `${activeElement.value.offsetLeft}px`,
width: `${activeElement.value.offsetWidth}px`,
};
});
indicatorStyle.value = {
left: `${activeElement.value.offsetLeft}px`,
width: `${activeElement.value.offsetWidth}px`,
};
};
useResizeObserver(activeElement, updateIndicator);
// Watch for prop/tabs changes to update indicator position
watch([() => props.initialActiveTab, () => props.tabs], updateIndicator, {
immediate: true,
useResizeObserver(activeElement, () => {
if (enableTransition.value || !activeElement.value) updateIndicator();
});
onMounted(() => {
updateIndicator();
nextTick(() => {
enableTransition.value = true;
});
@@ -70,7 +66,7 @@ const showDivider = index => {
<template>
<div
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 dark:bg-n-solid-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
>
<div
class="absolute rounded-lg bg-n-solid-active shadow-sm pointer-events-none h-8 outline-1 outline outline-n-container inset-y-0"
@@ -1,5 +1,6 @@
<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';
@@ -8,6 +9,7 @@ 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();
@@ -29,7 +31,10 @@ const isBannerClosed = computed(() => {
});
const shouldShowBanner = computed(
() => !isBannerClosed.value && !isACustomBrandedInstance.value
() =>
route.query['year-in-review'] === 'true' &&
!isBannerClosed.value &&
!isACustomBrandedInstance.value
);
const openModal = () => {
@@ -5,8 +5,6 @@ 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 { useTrack } from 'dashboard/composables';
import { YEAR_IN_REVIEW_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import IntroSlide from './slides/IntroSlide.vue';
import ConversationsSlide from './slides/ConversationsSlide.vue';
import BusiestDaySlide from './slides/BusiestDaySlide.vue';
@@ -110,7 +108,6 @@ const fetchYearInReviewData = async () => {
const nextSlide = () => {
if (currentSlide.value < 4) {
useTrack(YEAR_IN_REVIEW_EVENTS.NEXT_CLICKED);
if (!hasConversations.value && currentSlide.value === 1) {
currentSlide.value = 4;
} else {
@@ -144,7 +141,6 @@ const close = () => {
};
const open = () => {
useTrack(YEAR_IN_REVIEW_EVENTS.MODAL_OPENED);
isOpen.value = true;
fetchYearInReviewData();
playDrumroll();
@@ -155,7 +151,6 @@ const currentSlideBackground = computed(
);
const shareCurrentSlide = async () => {
useTrack(YEAR_IN_REVIEW_EVENTS.SHARE_CLICKED);
showShareModal.value = true;
nextTick(() => {
if (shareModalRef.value) {
@@ -23,10 +23,6 @@ const hasInstagramConfigured = computed(() => {
return window.chatwootConfig?.instagramAppId;
});
const hasTiktokConfigured = computed(() => {
return window.chatwootConfig?.tiktokAppId;
});
const isActive = computed(() => {
const { key } = props.channel;
if (Object.keys(props.enabledFeatures).length === 0) {
@@ -48,10 +44,6 @@ const isActive = computed(() => {
);
}
if (key === 'tiktok') {
return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value;
}
if (key === 'voice') {
return props.enabledFeatures.channel_voice;
}
@@ -65,7 +57,6 @@ const isActive = computed(() => {
'telegram',
'line',
'instagram',
'tiktok',
'voice',
].includes(key);
});
@@ -1,184 +0,0 @@
<script setup>
import { watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession';
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const router = useRouter();
const store = useStore();
const {
activeCall,
incomingCalls,
hasActiveCall,
isJoining,
joinCall,
endCall: endCallSession,
rejectIncomingCall,
dismissCall,
formattedCallDuration,
} = useCallSession();
const getCallInfo = call => {
const conversation = store.getters.getConversationById(call?.conversationId);
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
const sender = conversation?.meta?.sender;
return {
conversation,
inbox,
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
inboxName: inbox?.name || 'Customer support',
avatar: sender?.avatar || sender?.thumbnail,
};
};
const handleEndCall = async () => {
const call = activeCall.value;
if (!call) return;
const inboxId = call.inboxId || getCallInfo(call).conversation?.inbox_id;
if (!inboxId) return;
await endCallSession({
conversationId: call.conversationId,
inboxId,
});
};
const handleJoinCall = async call => {
const { conversation } = getCallInfo(call);
if (!call || !conversation || isJoining.value) return;
// End current active call before joining new one
if (hasActiveCall.value) {
await handleEndCall();
}
const result = await joinCall({
conversationId: call.conversationId,
inboxId: conversation.inbox_id,
callSid: call.callSid,
});
if (result) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: call.conversationId },
});
}
};
// Auto-join outbound calls when window is visible
watch(
() => incomingCalls.value[0],
call => {
if (
call?.callDirection === 'outbound' &&
!hasActiveCall.value &&
WindowVisibilityHelper.isWindowVisible()
) {
handleJoinCall(call);
}
},
{ immediate: true }
);
</script>
<template>
<div
v-if="incomingCalls.length || hasActiveCall"
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-2 w-72"
>
<!-- Incoming Calls (shown above active call) -->
<div
v-for="call in hasActiveCall ? incomingCalls : []"
:key="call.callSid"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex">
<Avatar
:src="getCallInfo(call).avatar"
:name="getCallInfo(call).contactName"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(call).contactName }}
</p>
<p class="text-xs text-n-slate-11 truncate">
{{ getCallInfo(call).inboxName }}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="dismissCall(call.callSid)"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
@click="handleJoinCall(call)"
>
<i class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
<!-- Main Call Widget -->
<div
v-if="hasActiveCall || incomingCalls.length"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
:class="{ 'animate-pulse': !hasActiveCall }"
>
<Avatar
:src="getCallInfo(activeCall || incomingCalls[0]).avatar"
:name="getCallInfo(activeCall || incomingCalls[0]).contactName"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(activeCall || incomingCalls[0]).contactName }}
</p>
<p v-if="hasActiveCall" class="font-mono text-sm text-n-teal-9">
{{ formattedCallDuration }}
</p>
<p v-else class="text-xs text-n-slate-11">
{{
incomingCalls[0]?.callDirection === 'outbound'
? $t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL')
: $t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL')
}}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="
hasActiveCall
? handleEndCall()
: rejectIncomingCall(incomingCalls[0]?.callSid)
"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
v-if="!hasActiveCall"
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
@click="handleJoinCall(incomingCalls[0])"
>
<i class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
</div>
</template>
@@ -55,7 +55,6 @@ import {
getSelectionCoords,
calculateMenuPosition,
getEffectiveChannelType,
stripUnsupportedFormatting,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -132,10 +131,8 @@ const editorMenuOptions = computed(() => {
const createState = (content, placeholder, plugins = [], methods = {}) => {
const schema = editorSchema.value;
// Strip unsupported formatting before parsing to prevent "Token type not supported" errors
const sanitizedContent = stripUnsupportedFormatting(content, schema);
return EditorState.create({
doc: new MessageMarkdownTransformer(schema).parse(sanitizedContent),
doc: new MessageMarkdownTransformer(schema).parse(content),
plugins: buildEditor({
schema,
placeholder,
@@ -679,18 +676,11 @@ function createEditorView() {
typingIndicator.stop();
emit('blur');
},
paste: (view, event) => {
paste: (_view, event) => {
if (props.disabled) return;
const { files } = event.clipboardData;
if (!files.length) return;
event.preventDefault();
// Paste text content alongside files (e.g., spreadsheet data from Numbers app)
// Numbers app includes invalid 0-byte attachments with text, so we paste the text here
// while ReplyBox filters and handles valid file attachments
const text = event.clipboardData.getData('text/plain');
if (text) {
view.dispatch(view.state.tr.insertText(text));
emitOnChange();
const data = event.clipboardData.files;
if (data.length > 0) {
event.preventDefault();
}
},
},
@@ -862,7 +852,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
max-height: none !important;
min-height: 0 !important;
padding: 0 !important;
display: none !important;
}
> .ProseMirror {
@@ -41,9 +41,6 @@ const getTemplateType = template => {
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
}
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.CALL_TO_ACTION) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.CALL_TO_ACTION');
}
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
};
@@ -205,9 +205,6 @@ export default {
if (this.isAWhatsAppCloudChannel) {
return REPLY_POLICY.WHATSAPP_CLOUD;
}
if (this.isATiktokChannel) {
return REPLY_POLICY.TIKTOK;
}
if (!this.isAPIInbox) {
return REPLY_POLICY.TWILIO_WHATSAPP;
}
@@ -221,9 +218,6 @@ export default {
) {
return this.$t('CONVERSATION.24_HOURS_WINDOW');
}
if (this.isATiktokChannel) {
return this.$t('CONVERSATION.48_HOURS_WINDOW');
}
if (!this.isAPIInbox) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
}
@@ -7,7 +7,9 @@ import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue';
@@ -44,6 +46,7 @@ import {
appendSignature,
removeSignature,
getEffectiveChannelType,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -69,6 +72,8 @@ export default {
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
ResizableTextArea,
CannedResponse,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@@ -109,6 +114,8 @@ export default {
recordingAudioState: '',
recordingAudioDurationText: '',
replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
hasSlashCommand: false,
bccEmails: '',
ccEmails: '',
toEmails: '',
@@ -141,6 +148,9 @@ export default {
if (!senderId) return {};
return this.$store.getters['contacts/getContact'](senderId);
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
},
shouldShowReplyToMessage() {
return (
this.inReplyTo?.id &&
@@ -224,9 +234,6 @@ export default {
if (this.isAnInstagramChannel) {
return MESSAGE_MAX_LENGTH.INSTAGRAM;
}
if (this.isATiktokChannel) {
return MESSAGE_MAX_LENGTH.TIKTOK;
}
if (this.isATwilioWhatsAppChannel) {
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
}
@@ -399,6 +406,19 @@ 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) {
@@ -441,7 +461,25 @@ export default {
this.resetRecorderAndClearAttachments();
}
},
message() {
message(updatedMessage) {
// Check if the message starts with a slash.
const bodyWithoutSignature = removeSignature(
updatedMessage,
this.signatureToApply
);
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Determine if the user is potentially typing a slash command.
// This is true if the message starts with a slash and the rich content editor is not active.
this.hasSlashCommand = startsWithSlash && !this.showRichContentEditor;
this.showMentions = this.hasSlashCommand;
// If a slash command is active, extract the command text after the slash.
// If not, reset the mentionSearchKey.
this.mentionSearchKey = this.hasSlashCommand
? bodyWithoutSignature.substring(1)
: '';
// Autosave the current message draft.
this.doAutoSaveDraft();
},
@@ -491,14 +529,20 @@ export default {
methods: {
handleInsert(article) {
const { url, title } = article;
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
if (this.isRichEditorEnabled) {
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
} else {
this.addIntoEditor(
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
);
}
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
},
@@ -567,14 +611,26 @@ export default {
if (this.isPrivate) {
return message;
}
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
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.messageSignature, effectiveChannelType)
: removeSignature(message, this.messageSignature, effectiveChannelType);
? appendSignature(message, this.signatureToApply)
: removeSignature(message, this.signatureToApply);
},
removeFromDraft() {
if (this.conversationIdByRoute) {
@@ -590,6 +646,7 @@ export default {
Escape: {
action: () => {
this.hideEmojiPicker();
this.hideMentions();
},
allowOnFocusedInput: true,
},
@@ -632,15 +689,20 @@ export default {
},
onPaste(e) {
// Don't handle paste if compose new conversation modal is open
if (this.newConversationModalActive) return;
// Filter valid files (non-zero size)
Array.from(e.clipboardData.files)
.filter(file => file.size > 0)
.forEach(file => {
const { name, type, size } = file;
this.onFileUpload({ name, type, size, file });
});
if (this.newConversationModalActive) {
return;
}
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput?.$el?.blur();
}
if (!data.length || !data[0]) {
return;
}
data.forEach(file => {
const { name, type, size } = file;
this.onFileUpload({ name, type, size, file: file });
});
},
toggleUserMention(currentMentionState) {
this.showUserMentions = currentMentionState;
@@ -767,15 +829,19 @@ 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
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
} else {
message = appendSignature(message, this.signatureToApply);
}
}
const updatedMessage = replaceVariablesInMessage({
@@ -799,30 +865,52 @@ export default {
});
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
this.replyType = mode;
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
if (this.showRichContentEditor) {
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
return;
}
this.$nextTick(() => this.$refs.messageInput.focus());
},
clearEditorSelection() {
this.updateEditorSelectionWith = '';
},
insertIntoTextEditor(text, selectionStart, selectionEnd) {
const { message } = this;
const newMessage =
message.slice(0, selectionStart) +
text +
message.slice(selectionEnd, message.length);
this.message = newMessage;
},
addIntoEditor(content) {
this.updateEditorSelectionWith = content;
this.onFocus();
if (this.showRichContentEditor) {
this.updateEditorSelectionWith = content;
this.onFocus();
}
if (!this.showRichContentEditor) {
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
}
},
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
this.message = appendSignature(
this.message,
this.messageSignature,
effectiveChannelType
);
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.attachedFiles = [];
this.isRecordingAudio = false;
@@ -857,6 +945,9 @@ export default {
this.toggleEmojiPicker();
}
},
hideMentions() {
this.showMentions = false;
},
onTypingOn() {
this.toggleTyping('on');
},
@@ -1103,6 +1194,13 @@ export default {
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<CannedResponse
v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideMentions"
class="normal-editor__canned-box"
:search-key="mentionSearchKey"
@replace="replaceText"
/>
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
@@ -1126,7 +1224,23 @@ export default {
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<ResizableTextArea
v-else-if="!showRichContentEditor"
ref="messageInput"
v-model="message"
class="rounded-none input"
:placeholder="messagePlaceHolder"
:min-height="4"
:signature="signatureToApply"
allow-signature
:send-with-signature="sendWithSignature"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
/>
<WootMessageEditor
v-else
v-model="message"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
@@ -1252,6 +1366,10 @@ export default {
.reply-box__top {
@apply relative py-0 px-4 -mt-px;
textarea {
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
}
}
.emoji-dialog {
@@ -1271,4 +1389,9 @@ export default {
@apply ltr:left-1 rtl:right-1 -bottom-2;
}
}
.normal-editor__canned-box {
width: calc(100% - 2 * 1rem);
left: 1rem;
}
</style>
@@ -1,7 +1,6 @@
<script setup>
import { ref, watch, computed, nextTick } from 'vue';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
const props = defineProps({
items: {
@@ -16,8 +15,6 @@ const props = defineProps({
const emit = defineEmits(['mentionSelect']);
const { getPlainText } = useMessageFormatter();
const mentionsListContainerRef = ref(null);
const selectedIndex = ref(0);
@@ -97,7 +94,7 @@ const variableKey = (item = {}) => {
'text-n-slate-12': index === selectedIndex,
}"
>
{{ getPlainText(item.description) }}
{{ item.description }}
</p>
<p
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-n-slate-11 group-hover:text-n-slate-12 text-ellipsis whitespace-nowrap"
@@ -48,7 +48,6 @@ const mockStore = createStore({
12: { id: 12, channel_type: INBOX_TYPES.SMS },
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
};
return inboxes[id] || null;
},
@@ -216,12 +215,6 @@ describe('useInbox', () => {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAVoiceChannel).toBe(true);
// Test Tiktok
wrapper = mount(createTestComponent(15), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isATiktokChannel).toBe(true);
});
});
@@ -273,7 +266,6 @@ describe('useInbox', () => {
'is360DialogWhatsAppChannel',
'isAnEmailChannel',
'isAnInstagramChannel',
'isATiktokChannel',
'isAVoiceChannel',
];
@@ -1,31 +1,39 @@
import { selectTranslation } from '../useTranslations';
import { ref } from 'vue';
import { useTranslations } from '../useTranslations';
describe('selectTranslation', () => {
it('returns null when translations is null', () => {
expect(selectTranslation(null, 'en', 'en')).toBeNull();
describe('useTranslations', () => {
it('returns false and null when contentAttributes is null', () => {
const contentAttributes = ref(null);
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
});
it('returns null when translations is empty', () => {
expect(selectTranslation({}, 'en', 'en')).toBeNull();
it('returns false and null when translations are missing', () => {
const contentAttributes = ref({});
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
});
it('returns first translation when no locale matches', () => {
const translations = { en: 'Hello', es: 'Hola' };
expect(selectTranslation(translations, 'fr', 'de')).toBe('Hello');
it('returns false and null when translations is an empty object', () => {
const contentAttributes = ref({ translations: {} });
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
});
it('returns translation matching agent locale', () => {
const translations = { en: 'Hello', es: 'Hola', zh_CN: '你好' };
expect(selectTranslation(translations, 'es', 'en')).toBe('Hola');
});
it('falls back to account locale when agent locale not found', () => {
const translations = { en: 'Hello', zh_CN: '你好' };
expect(selectTranslation(translations, 'fr', 'zh_CN')).toBe('你好');
});
it('returns first translation when both locales are undefined', () => {
const translations = { en: 'Hello', es: 'Hola' };
expect(selectTranslation(translations, undefined, undefined)).toBe('Hello');
it('returns true and correct translation content when translations exist', () => {
const contentAttributes = ref({
translations: { en: 'Hello' },
});
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(true);
// Should return the first translation (en: 'Hello')
expect(translationContent.value).toBe('Hello');
});
});
@@ -1,110 +0,0 @@
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { useCallsStore } from 'dashboard/stores/calls';
import Timer from 'dashboard/helper/Timer';
export function useCallSession() {
const callsStore = useCallsStore();
const isJoining = ref(false);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
callDuration.value = elapsed;
});
const activeCall = computed(() => callsStore.activeCall);
const incomingCalls = computed(() => callsStore.incomingCalls);
const hasActiveCall = computed(() => callsStore.hasActiveCall);
watch(
hasActiveCall,
active => {
if (active) {
durationTimer.start();
} else {
durationTimer.stop();
callDuration.value = 0;
}
},
{ immediate: true }
);
onMounted(() => {
TwilioVoiceClient.addEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
});
onUnmounted(() => {
durationTimer.stop();
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
});
const endCall = async ({ conversationId, inboxId }) => {
await VoiceAPI.leaveConference(inboxId, conversationId);
TwilioVoiceClient.endClientCall();
durationTimer.stop();
callsStore.clearActiveCall();
};
const joinCall = async ({ conversationId, inboxId, callSid }) => {
if (isJoining.value) return null;
isJoining.value = true;
try {
const device = await TwilioVoiceClient.initializeDevice(inboxId);
if (!device) return null;
const joinResponse = await VoiceAPI.joinConference({
conversationId,
inboxId,
callSid,
});
await TwilioVoiceClient.joinClientCall({
to: joinResponse?.conference_sid,
conversationId,
});
callsStore.setCallActive(callSid);
durationTimer.start();
return { conferenceSid: joinResponse?.conference_sid };
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to join call:', error);
return null;
} finally {
isJoining.value = false;
}
};
const rejectIncomingCall = callSid => {
TwilioVoiceClient.endClientCall();
callsStore.dismissCall(callSid);
};
const dismissCall = callSid => {
callsStore.dismissCall(callSid);
};
const formattedCallDuration = computed(() => {
const minutes = Math.floor(callDuration.value / 60);
const seconds = callDuration.value % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
return {
activeCall,
incomingCalls,
hasActiveCall,
isJoining,
formattedCallDuration,
joinCall,
endCall,
rejectIncomingCall,
dismissCall,
};
}
@@ -17,7 +17,6 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.API,
],
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
@@ -25,7 +24,6 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.API,
],
};
@@ -130,10 +128,6 @@ export const useInbox = (inboxId = null) => {
return channelType.value === INBOX_TYPES.INSTAGRAM;
});
const isATiktokChannel = computed(() => {
return channelType.value === INBOX_TYPES.TIKTOK;
});
const isAVoiceChannel = computed(() => {
return channelType.value === INBOX_TYPES.VOICE;
});
@@ -155,7 +149,6 @@ export const useInbox = (inboxId = null) => {
is360DialogWhatsAppChannel,
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
isAVoiceChannel,
};
};
@@ -1,25 +1,4 @@
import { computed } from 'vue';
import { useUISettings } from './useUISettings';
import { useAccount } from './useAccount';
/**
* Select translation based on locale priority.
* @param {Object} translations - Translations object with locale keys
* @param {string} agentLocale - Agent's preferred locale
* @param {string} accountLocale - Account's default locale
* @returns {string|null} Selected translation or null
*/
export function selectTranslation(translations, agentLocale, accountLocale) {
if (!translations || Object.keys(translations).length === 0) return null;
if (agentLocale && translations[agentLocale]) {
return translations[agentLocale];
}
if (accountLocale && translations[accountLocale]) {
return translations[accountLocale];
}
return translations[Object.keys(translations)[0]];
}
/**
* Composable to extract translation state/content from contentAttributes.
@@ -27,9 +6,6 @@ export function selectTranslation(translations, agentLocale, accountLocale) {
* @returns {Object} { hasTranslations, translationContent }
*/
export function useTranslations(contentAttributes) {
const { uiSettings } = useUISettings();
const { currentAccount } = useAccount();
const hasTranslations = computed(() => {
if (!contentAttributes.value) return false;
const { translations = {} } = contentAttributes.value;
@@ -38,11 +14,8 @@ export function useTranslations(contentAttributes) {
const translationContent = computed(() => {
if (!hasTranslations.value) return null;
return selectTranslation(
contentAttributes.value.translations,
uiSettings.value?.locale,
currentAccount.value?.locale
);
const translations = contentAttributes.value.translations;
return translations[Object.keys(translations)[0]];
});
return { hasTranslations, translationContent };
+2 -16
View File
@@ -109,11 +109,6 @@ export const FORMATTING = {
nodes: [],
menu: [],
},
'Channel::Tiktok': {
marks: [],
nodes: [],
menu: [],
},
// Special contexts (not actual channels)
'Context::Default': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
@@ -140,11 +135,6 @@ export const FORMATTING = {
nodes: [],
menu: ['strong', 'em', 'link', 'undo', 'redo'],
},
'Context::Plain': {
marks: [],
nodes: [],
menu: [],
},
};
// Editor menu options for Full Editor
@@ -237,12 +227,8 @@ export const MARKDOWN_PATTERNS = [
patterns: [{ pattern: /`([^`]+)`/g, replacement: '$1' }],
},
{
type: 'link', // PM: link
patterns: [
{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }, // [text](url) -> text
{ pattern: /<([a-zA-Z][a-zA-Z0-9+.-]*:[^\s>]+)>/g, replacement: '$1' }, // <https://...>, <mailto:...>, <tel:...>, <ftp://...>, etc
{ pattern: /<([^\s@]+@[^\s@>]+)>/g, replacement: '$1' }, // <user@example.com> -> user@example.com
],
type: 'link', // PM: link, eg: [text](url)
patterns: [{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }],
},
];
@@ -5,5 +5,4 @@ export const LOCAL_STORAGE_KEYS = {
COLOR_SCHEME: 'color_scheme',
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
MESSAGE_REPLY_TO: 'messageReplyTo',
RECENT_SEARCHES: 'recentSearches',
};
-2
View File
@@ -37,13 +37,11 @@ export const FEATURE_FLAGS = {
CHATWOOT_V4: 'chatwoot_v4',
REPORT_V4: 'report_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CHANNEL_TIKTOK: 'channel_tiktok',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_V2: 'captain_integration_v2',
SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search',
};
export const PREMIUM_FEATURES = [
@@ -131,9 +131,3 @@ export const LINEAR_EVENTS = Object.freeze({
LINK_ISSUE: 'Linked a linear issue',
UNLINK_ISSUE: 'Unlinked a linear issue',
});
export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
MODAL_OPENED: 'Year in Review: Modal opened',
NEXT_CLICKED: 'Year in Review: Next clicked',
SHARE_CLICKED: 'Year in Review: Share clicked',
});
@@ -1,4 +1,4 @@
import * as amplitude from '@amplitude/analytics-browser';
import posthog from 'posthog-js';
/**
* AnalyticsHelper class to initialize and track user analytics
@@ -26,10 +26,12 @@ export class AnalyticsHelper {
return;
}
amplitude.init(this.analyticsToken, {
defaultTracking: false,
posthog.init(this.analyticsToken, {
api_host: 'https://app.posthog.com',
capture_pageview: false,
persistence: 'localStorage+cookie',
});
this.analytics = amplitude;
this.analytics = posthog;
}
/**
@@ -43,26 +45,20 @@ export class AnalyticsHelper {
}
this.user = user;
this.analytics.setUserId(`user-${this.user.id.toString()}`);
const identifyEvent = new amplitude.Identify();
identifyEvent.set('email', this.user.email);
identifyEvent.set('name', this.user.name);
identifyEvent.set('avatar', this.user.avatar_url);
this.analytics.identify(identifyEvent);
this.analytics.identify(this.user.id.toString(), {
email: this.user.email,
name: this.user.name,
avatar: this.user.avatar_url,
});
const { accounts, account_id: accountId } = this.user;
const [currentAccount] = accounts.filter(
account => account.id === accountId
);
if (currentAccount) {
const groupId = `account-${currentAccount.id.toString()}`;
this.analytics.setGroup('company', groupId);
const groupIdentify = new amplitude.Identify();
groupIdentify.set('name', currentAccount.name);
this.analytics.groupIdentify('company', groupId, groupIdentify);
this.analytics.group('company', currentAccount.id.toString(), {
name: currentAccount.name,
});
}
}
@@ -76,21 +72,20 @@ export class AnalyticsHelper {
if (!this.analytics) {
return;
}
this.analytics.track(eventName, properties);
this.analytics.capture(eventName, properties);
}
/**
* Track the page views
* @function
* @param {string} pageName - Page name
* @param {Object} [properties={}] - Page view properties
* @param {Object} params - Page view properties
*/
page(pageName, properties = {}) {
page(params) {
if (!this.analytics) {
return;
}
this.analytics.track('$pageview', { pageName, ...properties });
this.analytics.capture('$pageview', params);
}
}
@@ -1,15 +1,12 @@
import helperObject, { AnalyticsHelper } from '../';
vi.mock('@amplitude/analytics-browser', () => ({
init: vi.fn(),
setUserId: vi.fn(),
identify: vi.fn(),
setGroup: vi.fn(),
groupIdentify: vi.fn(),
track: vi.fn(),
Identify: vi.fn(() => ({
set: vi.fn(),
})),
vi.mock('posthog-js', () => ({
default: {
init: vi.fn(),
identify: vi.fn(),
capture: vi.fn(),
group: vi.fn(),
},
}));
describe('helperObject', () => {
@@ -25,12 +22,12 @@ describe('AnalyticsHelper', () => {
});
describe('init', () => {
it('should initialize amplitude with the correct token', async () => {
it('should initialize posthog with the correct token', async () => {
await analyticsHelper.init();
expect(analyticsHelper.analytics).not.toBe(null);
});
it('should not initialize amplitude if token is not provided', async () => {
it('should not initialize posthog if token is not provided', async () => {
analyticsHelper = new AnalyticsHelper();
await analyticsHelper.init();
expect(analyticsHelper.analytics).toBe(null);
@@ -39,15 +36,10 @@ describe('AnalyticsHelper', () => {
describe('identify', () => {
beforeEach(() => {
analyticsHelper.analytics = {
setUserId: vi.fn(),
identify: vi.fn(),
setGroup: vi.fn(),
groupIdentify: vi.fn(),
};
analyticsHelper.analytics = { identify: vi.fn(), group: vi.fn() };
});
it('should call setUserId and identify on amplitude with correct arguments', () => {
it('should call identify on posthog with correct arguments', () => {
analyticsHelper.identify({
id: 123,
email: 'test@example.com',
@@ -57,18 +49,19 @@ describe('AnalyticsHelper', () => {
account_id: 1,
});
expect(analyticsHelper.analytics.setUserId).toHaveBeenCalledWith(
'user-123'
);
expect(analyticsHelper.analytics.identify).toHaveBeenCalled();
expect(analyticsHelper.analytics.setGroup).toHaveBeenCalledWith(
expect(analyticsHelper.analytics.identify).toHaveBeenCalledWith('123', {
email: 'test@example.com',
name: 'Test User',
avatar: 'avatar_url',
});
expect(analyticsHelper.analytics.group).toHaveBeenCalledWith(
'company',
'account-1'
'1',
{ name: 'Account 1' }
);
expect(analyticsHelper.analytics.groupIdentify).toHaveBeenCalled();
});
it('should call identify on amplitude without group', () => {
it('should call identify on posthog without group', () => {
analyticsHelper.identify({
id: 123,
email: 'test@example.com',
@@ -78,10 +71,10 @@ describe('AnalyticsHelper', () => {
account_id: 5,
});
expect(analyticsHelper.analytics.setGroup).not.toHaveBeenCalled();
expect(analyticsHelper.analytics.group).not.toHaveBeenCalled();
});
it('should not call analytics methods if analytics is null', () => {
it('should not call analytics.page if analytics is null', () => {
analyticsHelper.analytics = null;
analyticsHelper.identify({});
expect(analyticsHelper.analytics).toBe(null);
@@ -90,27 +83,27 @@ describe('AnalyticsHelper', () => {
describe('track', () => {
beforeEach(() => {
analyticsHelper.analytics = { track: vi.fn() };
analyticsHelper.analytics = { capture: vi.fn() };
analyticsHelper.user = { id: 123 };
});
it('should call track on amplitude with correct arguments', () => {
it('should call capture on posthog with correct arguments', () => {
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith(
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
'Test Event',
{ prop1: 'value1', prop2: 'value2' }
);
});
it('should call track on amplitude with default properties', () => {
it('should call capture on posthog with default properties', () => {
analyticsHelper.track('Test Event');
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith(
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
'Test Event',
{}
);
});
it('should not call track on amplitude if analytics is not initialized', () => {
it('should not call capture on posthog if analytics is not initialized', () => {
analyticsHelper.analytics = null;
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
expect(analyticsHelper.analytics).toBe(null);
@@ -119,25 +112,24 @@ describe('AnalyticsHelper', () => {
describe('page', () => {
beforeEach(() => {
analyticsHelper.analytics = { track: vi.fn() };
analyticsHelper.analytics = { capture: vi.fn() };
});
it('should call the track method for pageview with the correct arguments', () => {
const pageName = 'home';
const properties = {
path: '/test',
name: 'home',
it('should call the capture method for pageview with the correct arguments', () => {
const params = {
name: 'Test page',
url: '/test',
};
analyticsHelper.page(pageName, properties);
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith(
analyticsHelper.page(params);
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
'$pageview',
{ pageName: 'home', path: '/test', name: 'home' }
params
);
});
it('should not call analytics.track if analytics is null', () => {
it('should not call analytics.capture if analytics is null', () => {
analyticsHelper.analytics = null;
analyticsHelper.page('home');
analyticsHelper.page();
expect(analyticsHelper.analytics).toBe(null);
});
});
-28
View File
@@ -1,28 +0,0 @@
export default class Timer {
constructor(onTick = null) {
this.elapsed = 0;
this.intervalId = null;
this.onTick = onTick;
}
start() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
this.elapsed = 0;
this.intervalId = setInterval(() => {
this.elapsed += 1;
if (this.onTick) {
this.onTick(this.elapsed);
}
}, 1000);
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.elapsed = 0;
}
}
+45 -17
View File
@@ -34,7 +34,6 @@ export function extractTextFromMarkdown(markdown) {
/**
* Strip unsupported markdown formatting based on channel capabilities.
* Uses MARKDOWN_PATTERNS from editor constants.
*
* @param {string} markdown - markdown text to process
* @param {string} channelType - The channel type to check supported formatting
@@ -44,16 +43,35 @@ export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
if (!markdown) return '';
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
const supported = [...marks, ...nodes];
const has = (arr, key) => arr.includes(key);
// Apply patterns from MARKDOWN_PATTERNS for unsupported types
const result = MARKDOWN_PATTERNS.reduce((text, { type, patterns }) => {
if (supported.includes(type)) return text;
return patterns.reduce(
(t, { pattern, replacement }) => t.replace(pattern, replacement),
text
);
}, markdown);
// 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')
@@ -168,22 +186,27 @@ export function appendSignature(body, signature, channelType) {
/**
* Removes the signature from the body, along with the signature delimiter.
* Tries multiple signature variants: original, channel-stripped, and fully stripped.
* 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 unique list of signature variants to try
// Build list of signatures to try: original, channel-stripped, and fully stripped
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 = [
cleanSignature(signature),
cleanedSignature,
channelStripped,
cleanSignature(extractTextFromMarkdown(signature)),
fullyStripped,
].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
// Find the first matching signature
@@ -202,12 +225,17 @@ export function removeSignature(body, signature, channelType) {
newBody = newBody.substring(0, signatureIndex).trimEnd();
}
// Remove delimiter if it's at the end
if (newBody.endsWith(SIGNATURE_DELIMITER)) {
// let's find the delimiter and remove it
const delimiterIndex = newBody.lastIndexOf(SIGNATURE_DELIMITER);
if (
delimiterIndex !== -1 &&
delimiterIndex === newBody.length - SIGNATURE_DELIMITER.length // this will ensure the delimiter is at the end
) {
// if the delimiter is at the end, remove it
newBody = newBody.slice(0, -SIGNATURE_DELIMITER.length);
newBody = newBody.substring(0, delimiterIndex);
}
// return the value
return newBody;
}
@@ -1,5 +1,3 @@
import DOMPurify from 'dompurify';
// Quote detection strategies
const QUOTE_INDICATORS = [
'.gmail_quote_container',
@@ -31,7 +29,7 @@ export class EmailQuoteExtractor {
static extractQuotes(htmlContent) {
// Create a temporary DOM element to parse HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
tempDiv.innerHTML = htmlContent;
// Remove elements matching class selectors
QUOTE_INDICATORS.forEach(selector => {
@@ -58,7 +56,7 @@ export class EmailQuoteExtractor {
*/
static hasQuotes(htmlContent) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
tempDiv.innerHTML = htmlContent;
// Check for class-based quotes
// eslint-disable-next-line no-restricted-syntax
@@ -20,7 +20,6 @@ const FEATURE_HELP_URLS = {
webhook: 'https://chwt.app/hc/webhooks',
billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
captain_billing: 'https://chwt.app/hc/captain_billing',
};
export function getHelpUrlForFeature(featureName) {
-6
View File
@@ -10,7 +10,6 @@ export const INBOX_TYPES = {
LINE: 'Channel::Line',
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
TIKTOK: 'Channel::Tiktok',
VOICE: 'Channel::Voice',
};
@@ -29,7 +28,6 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
};
@@ -45,7 +43,6 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
[INBOX_TYPES.LINE]: 'i-ri-line-line',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-line',
[INBOX_TYPES.VOICE]: 'i-ri-phone-line',
};
@@ -139,9 +136,6 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.INSTAGRAM:
return 'brand-instagram';
case INBOX_TYPES.TIKTOK:
return 'brand-tiktok';
case INBOX_TYPES.VOICE:
return 'phone';
@@ -1,5 +1,4 @@
import { format, parseISO, isValid as isValidDate } from 'date-fns';
import DOMPurify from 'dompurify';
/**
* Extracts plain text from HTML content
@@ -14,7 +13,7 @@ export const extractPlainTextFromHtml = html => {
return html.replace(/<[^>]*>/g, ' ');
}
const tempDiv = document.createElement('div');
tempDiv.innerHTML = DOMPurify.sanitize(html);
tempDiv.innerHTML = html;
return tempDiv.textContent || tempDiv.innerText || '';
};
@@ -1,113 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import Timer from '../Timer';
describe('Timer', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('constructor', () => {
it('initializes with elapsed 0 and no interval', () => {
const timer = new Timer();
expect(timer.elapsed).toBe(0);
expect(timer.intervalId).toBeNull();
});
it('accepts an onTick callback', () => {
const onTick = vi.fn();
const timer = new Timer(onTick);
expect(timer.onTick).toBe(onTick);
});
});
describe('start', () => {
it('starts the timer and increments elapsed every second', () => {
const timer = new Timer();
timer.start();
expect(timer.elapsed).toBe(0);
vi.advanceTimersByTime(1000);
expect(timer.elapsed).toBe(1);
vi.advanceTimersByTime(1000);
expect(timer.elapsed).toBe(2);
vi.advanceTimersByTime(3000);
expect(timer.elapsed).toBe(5);
});
it('calls onTick callback with elapsed value', () => {
const onTick = vi.fn();
const timer = new Timer(onTick);
timer.start();
vi.advanceTimersByTime(1000);
expect(onTick).toHaveBeenCalledWith(1);
vi.advanceTimersByTime(1000);
expect(onTick).toHaveBeenCalledWith(2);
expect(onTick).toHaveBeenCalledTimes(2);
});
it('resets elapsed to 0 when restarted', () => {
const timer = new Timer();
timer.start();
vi.advanceTimersByTime(5000);
expect(timer.elapsed).toBe(5);
timer.start();
expect(timer.elapsed).toBe(0);
vi.advanceTimersByTime(2000);
expect(timer.elapsed).toBe(2);
});
it('clears previous interval when restarted', () => {
const timer = new Timer();
timer.start();
const firstIntervalId = timer.intervalId;
timer.start();
expect(timer.intervalId).not.toBe(firstIntervalId);
});
});
describe('stop', () => {
it('stops the timer and resets elapsed to 0', () => {
const timer = new Timer();
timer.start();
vi.advanceTimersByTime(3000);
expect(timer.elapsed).toBe(3);
timer.stop();
expect(timer.elapsed).toBe(0);
expect(timer.intervalId).toBeNull();
});
it('prevents further increments after stopping', () => {
const timer = new Timer();
timer.start();
vi.advanceTimersByTime(2000);
timer.stop();
vi.advanceTimersByTime(5000);
expect(timer.elapsed).toBe(0);
});
it('handles stop when timer is not running', () => {
const timer = new Timer();
expect(() => timer.stop()).not.toThrow();
expect(timer.elapsed).toBe(0);
});
});
});
@@ -896,22 +896,6 @@ describe('stripUnsupportedFormatting', () => {
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves autolinks when schema supports links', () => {
const content = 'Check out <https://cegrafic.com/catalogo/>';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves various URI scheme autolinks', () => {
const content =
'Email <mailto:user@example.com> or call <tel:+1234567890>';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves email autolinks', () => {
const content = 'Contact us at <support@chatwoot.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);
@@ -983,38 +967,6 @@ describe('stripUnsupportedFormatting', () => {
).toBe('Check this link');
});
it('converts autolinks to plain URLs when schema does not support links', () => {
const content = 'Visit <https://cegrafic.com/catalogo/> for more info';
const expected = 'Visit https://cegrafic.com/catalogo/ for more info';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('handles multiple autolinks in content', () => {
const content = 'Check <https://example.com> and <https://test.com>';
const expected = 'Check https://example.com and https://test.com';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('converts URI scheme autolinks to plain text', () => {
const content =
'Email <mailto:support@example.com> or call <tel:+1234567890>';
const expected =
'Email mailto:support@example.com or call tel:+1234567890';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('converts email autolinks to plain text', () => {
const content = 'Reach us at <admin@chatwoot.com> for help';
const expected = 'Reach us at admin@chatwoot.com for help';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('handles mixed autolink types', () => {
const content = 'Visit <https://example.com> or email <info@example.com>';
const expected = 'Visit https://example.com or email info@example.com';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('strips bullet list markers', () => {
expect(
stripUnsupportedFormatting('- item 1\n- item 2', emptySchema)
@@ -96,58 +96,4 @@ describe('EmailQuoteExtractor', () => {
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true);
});
describe('HTML sanitization', () => {
it('removes onerror handlers from img tags in extractQuotes', () => {
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onerror');
expect(cleanedHtml).toContain('<p>Hello</p>');
});
it('removes onerror handlers from img tags in hasQuotes', () => {
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
// Should not throw and should safely check for quotes
const result = EmailQuoteExtractor.hasQuotes(maliciousHtml);
expect(result).toBe(false);
});
it('removes script tags in extractQuotes', () => {
const maliciousHtml =
'<p>Content</p><script>alert("xss")</script><p>More</p>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('<script');
expect(cleanedHtml).not.toContain('alert');
expect(cleanedHtml).toContain('<p>Content</p>');
expect(cleanedHtml).toContain('<p>More</p>');
});
it('removes onclick handlers in extractQuotes', () => {
const maliciousHtml = '<p onclick="alert(1)">Click me</p>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onclick');
expect(cleanedHtml).toContain('Click me');
});
it('removes javascript: URLs in extractQuotes', () => {
const maliciousHtml = '<a href="javascript:alert(1)">Link</a>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
// eslint-disable-next-line no-script-url
expect(cleanedHtml).not.toContain('javascript:');
expect(cleanedHtml).toContain('Link');
});
it('removes encoded payloads with event handlers in extractQuotes', () => {
const maliciousHtml =
'<img src="x" id="PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==" onerror="eval(atob(this.id))">';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onerror');
expect(cleanedHtml).not.toContain('eval');
});
});
});
@@ -38,9 +38,6 @@ describe('#Inbox Helpers', () => {
it('should return correct class for Email', () => {
expect(getInboxClassByType('Channel::Email')).toEqual('mail');
});
it('should return correct class for TikTok', () => {
expect(getInboxClassByType(INBOX_TYPES.TIKTOK)).toEqual('brand-tiktok');
});
});
describe('getInboxIconByType', () => {
@@ -83,10 +80,6 @@ describe('#Inbox Helpers', () => {
expect(getInboxIconByType(INBOX_TYPES.LINE)).toBe('i-ri-line-fill');
});
it('returns correct icon for TikTok', () => {
expect(getInboxIconByType(INBOX_TYPES.TIKTOK)).toBe('i-ri-tiktok-fill');
});
it('returns default icon for unknown type', () => {
expect(getInboxIconByType('UNKNOWN_TYPE')).toBe('i-ri-chat-1-fill');
});
@@ -109,12 +102,6 @@ describe('#Inbox Helpers', () => {
);
});
it('returns correct line icon for TikTok', () => {
expect(getInboxIconByType(INBOX_TYPES.TIKTOK, null, 'line')).toBe(
'i-ri-tiktok-line'
);
});
it('returns correct line icon for unknown type', () => {
expect(getInboxIconByType('UNKNOWN_TYPE', null, 'line')).toBe(
'i-ri-chat-1-line'
@@ -33,26 +33,6 @@ describe('quotedEmailHelper', () => {
expect(result).toContain('Line 1');
expect(result).toContain('Line 2');
});
it('sanitizes onerror handlers from img tags', () => {
const html = '<p>Hello</p><img src="x" onerror="alert(1)">';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Hello');
});
it('sanitizes script tags', () => {
const html = '<p>Safe</p><script>alert(1)</script><p>Content</p>';
const result = extractPlainTextFromHtml(html);
expect(result).toContain('Safe');
expect(result).toContain('Content');
expect(result).not.toContain('alert');
});
it('sanitizes onclick handlers', () => {
const html = '<p onclick="alert(1)">Click me</p>';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Click me');
});
});
describe('getEmailSenderName', () => {
-79
View File
@@ -1,79 +0,0 @@
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
import { useCallsStore } from 'dashboard/stores/calls';
import types from 'dashboard/store/mutation-types';
export const TERMINAL_STATUSES = [
'completed',
'busy',
'failed',
'no-answer',
'canceled',
'missed',
'ended',
];
export const isInbound = direction => direction === 'inbound';
const isVoiceCallMessage = message => {
return CONTENT_TYPES.VOICE_CALL === message?.content_type;
};
const shouldSkipCall = (callDirection, senderId, currentUserId) => {
return callDirection === 'outbound' && senderId !== currentUserId;
};
function extractCallData(message) {
const contentData = message?.content_attributes?.data || {};
return {
callSid: contentData.call_sid,
status: contentData.status,
callDirection: contentData.call_direction,
conversationId: message?.conversation_id,
senderId: message?.sender?.id,
};
}
export function handleVoiceCallCreated(message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const { callSid, callDirection, conversationId, senderId } =
extractCallData(message);
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
conversationId,
callDirection,
senderId,
});
}
export function handleVoiceCallUpdated(commit, message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const { callSid, status, callDirection, conversationId, senderId } =
extractCallData(message);
const callsStore = useCallsStore();
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
const callInfo = { conversationId, callStatus: status };
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
const isNewCall =
status === 'ringing' &&
!shouldSkipCall(callDirection, senderId, currentUserId);
if (isNewCall) {
callsStore.addCall({
callSid,
conversationId,
callDirection,
senderId,
});
}
}
@@ -129,10 +129,6 @@
"ENABLE_REGEX": {
"LABEL": "Enable regex validation"
}
},
"BADGES": {
"PRE_CHAT": "Pre-chat",
"RESOLUTION": "Resolution"
}
}
}
@@ -102,9 +102,6 @@
},
"contact": {
"CONTENT": "Shared contact"
},
"embed": {
"CONTENT": "Embedded content"
}
},
"CHAT_SORT_BY_FILTER": {
@@ -1,33 +0,0 @@
{
"COMPANIES": {
"HEADER": "Companies",
"SORT_BY": {
"LABEL": "Sort by",
"OPTIONS": {
"NAME": "Name",
"DOMAIN": "Domain",
"CREATED_AT": "Created at",
"CONTACTS_COUNT": "Contacts count"
}
},
"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"
}
}
}
@@ -18,8 +18,7 @@
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "New message",
"CALL": "ደውል",
"CALL_INITIATED": "Calling the contact…",
"CALL_FAILED": "Unable to start the call. Please try again.",
"CALL_UNDER_DEVELOPMENT": "መደወል በልማት ላይ ነው",
"VOICE_INBOX_PICKER": {
"TITLE": "የድምፅ ኢንቦክስ ይምረጡ"
},
@@ -457,9 +456,6 @@
"INSTAGRAM": {
"PLACEHOLDER": "Add Instagram"
},
"TIKTOK": {
"PLACEHOLDER": "Add TikTok"
},
"LINKEDIN": {
"PLACEHOLDER": "Add LinkedIn"
},
@@ -28,7 +28,6 @@
"TYPES": {
"MEDIA": "Media",
"QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Text"
}
},
@@ -32,7 +32,6 @@
"LOADING_CONVERSATIONS": "Loading Conversations",
"CANNOT_REPLY": "You cannot reply due to",
"24_HOURS_WINDOW": "24 hour message window restriction",
"48_HOURS_WINDOW": "48 hour message window restriction",
"API_HOURS_WINDOW": "ለዚህ ውይይት መመለስ በ{hours} ሰአታት ውስጥ ብቻ ይቻላል",
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
"ASSIGN_TO_ME": "Assign to me",
@@ -197,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",
@@ -275,16 +275,6 @@
"SIDEBAR": {
"CONTACT": "Contact",
"COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
}
},
"EMAIL_TRANSCRIPT": {
@@ -57,13 +57,6 @@
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
},
"TIKTOK": {
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
"CONNECT_YOUR_TIKTOK_PROFILE": "Connect your TikTok Profile",
"HELP": "To add your TikTok profile as a channel, you need to authenticate your TikTok Profile by clicking on 'Continue with TikTok' ",
"ERROR_MESSAGE": "There was an error connecting to TikTok, please try again",
"ERROR_AUTH": "There was an error connecting to TikTok, please try again"
},
"TWITTER": {
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",
"ERROR_MESSAGE": "There was an error connecting to Twitter, please try again",
@@ -388,11 +381,7 @@
"API": {
"ERROR_MESSAGE": "We were not able to save the email channel"
},
"FINISH_MESSAGE": "Start forwarding your emails to the following email address.",
"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",
@@ -478,10 +467,6 @@
"TITLE": "Instagram",
"DESCRIPTION": "Connect your instagram account"
},
"TIKTOK": {
"TITLE": "TikTok",
"DESCRIPTION": "Connect your TikTok account"
},
"VOICE": {
"TITLE": "Voice",
"DESCRIPTION": "Integrate with Twilio Voice"
@@ -722,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.",
@@ -808,35 +792,6 @@
"LABEL": "Message",
"PLACEHOLDER": "Please enter a message to show users with the form"
},
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Language",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"SURVEY_RULE": {
"LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -848,7 +803,6 @@
"SELECT_PLACEHOLDER": "select labels"
},
"NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@@ -1050,7 +1004,6 @@
"LINE": "Line",
"API": "API Channel",
"INSTAGRAM": "Instagram",
"TIKTOK": "TikTok",
"VOICE": "Voice"
}
}
@@ -1,7 +1,7 @@
{
"SEARCH": {
"TABS": {
"ALL": "All results",
"ALL": "All",
"CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages",
@@ -19,50 +19,14 @@
"LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'",
"PLACEHOLDER_KEYBINDING": "/to focus",
"PLACEHOLDER_KEYBINDING": "/ to focus",
"INPUT_PLACEHOLDER": "Type 3 or more characters to search",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Clear all",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ",
"BOT_LABEL": "Bot",
"READ_MORE": "Read more",
"READ_LESS": "Read less",
"WROTE": "wrote:",
"FROM": "From",
"EMAIL": "Email",
"EMAIL_SUBJECT": "Subject",
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "created {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "Last 7 days",
"LAST_30_DAYS": "Last 30 days",
"LAST_60_DAYS": "Last 60 days",
"LAST_90_DAYS": "Last 90 days",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "and",
"APPLY": "Apply",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Clear filter"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "Sender",
"IN": "Inbox",
"AGENTS": "Agents",
"CONTACTS": "Contacts",
"INBOXES": "Inboxes",
"NO_AGENTS": "No agents found",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
"FROM": "from",
"EMAIL": "email",
"EMAIL_SUBJECT": "subject"
}
}
@@ -234,7 +234,6 @@
"CONTACT_SUPPORT": "Contact support",
"SELECTOR_SUBTITLE": "Select an account from the following list",
"PROFILE_SETTINGS": "Profile settings",
"YEAR_IN_REVIEW": "Year in Review",
"KEYBOARD_SHORTCUTS": "Keyboard shortcuts",
"APPEARANCE": "Change appearance",
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
@@ -307,8 +306,6 @@
"SETTINGS": "Settings",
"CONTACTS": "Contacts",
"ACTIVE": "Active",
"COMPANIES": "Companies",
"ALL_COMPANIES": "All Companies",
"CAPTAIN": "Captain",
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents",
@@ -401,44 +398,19 @@
"BUTTON_TXT": "Buy more credits",
"DOCUMENTS": "Documents",
"RESPONSES": "Responses",
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more.",
"REFRESH_CREDITS": "Refresh"
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more."
},
"CHAT_WITH_US": {
"TITLE": "Need help?",
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
"BUTTON_TXT": "Chat with us"
},
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again.",
"TOPUP": {
"BUY_CREDITS": "Buy more credits",
"MODAL_TITLE": "Buy AI Credits",
"MODAL_DESCRIPTION": "Purchase additional credits for Captain AI.",
"CREDITS": "CREDITS",
"ONE_TIME": "one-time",
"POPULAR": "Most Popular",
"NOTE_TITLE": "Note:",
"NOTE_DESCRIPTION": "Credits are added immediately and expire in 6 months. An active subscription is required to use credits. Purchased credits are consumed after your monthly plan credits.",
"CANCEL": "Cancel",
"PURCHASE": "Purchase Credits",
"LOADING": "Loading options...",
"FETCH_ERROR": "Failed to load credit options. Please try again.",
"PURCHASE_ERROR": "Failed to process purchase. Please try again.",
"PURCHASE_SUCCESS": "Successfully added {credits} credits to your account",
"CONFIRM": {
"TITLE": "Confirm Purchase",
"DESCRIPTION": "You are about to purchase {credits} credits for {amount}.",
"INSTANT_DEDUCTION_NOTE": "Your saved card will be charged immediately upon confirmation.",
"GO_BACK": "Go Back",
"CONFIRM_PURCHASE": "Confirm Purchase"
}
}
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
},
"SECURITY_SETTINGS": {
"TITLE": "Security",
"DESCRIPTION": "Manage your account security settings.",
"LINK_TEXT": "Learn more about SAML SSO",
"SAML_DISABLED_MESSAGE": "SAML SSO is currently disabled. Please contact your administrator to enable this feature.",
"SAML": {
"TITLE": "SAML SSO",
"NOTE": "Configure SAML single sign-on for your account. Users will authenticate through your identity provider instead of using email/password.",

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