Compare commits

..
Author SHA1 Message Date
Shivam MishraandVishnu Narayanan c8b65f86c9 feat: remove prompt param 2026-05-29 18:33:07 +05:30
759 changed files with 3177 additions and 18856 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ gem 'faraday_middleware-aws-sigv4'
##--- gems for server & infra configuration ---## ##--- gems for server & infra configuration ---##
gem 'dotenv-rails', '>= 3.0.0' gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman' gem 'foreman'
gem 'puma', '~> 7.2', '>= 7.2.1' gem 'puma'
gem 'vite_rails' gem 'vite_rails'
# metrics on heroku # metrics on heroku
gem 'barnes' gem 'barnes'
+26 -39
View File
@@ -136,8 +136,6 @@ GEM
audited (5.4.1) audited (5.4.1)
activerecord (>= 5.0, < 7.7) activerecord (>= 5.0, < 7.7)
activesupport (>= 5.0, < 7.7) activesupport (>= 5.0, < 7.7)
auth-sanitizer (0.2.1)
version_gem (~> 1.1, >= 1.1.10)
aws-actionmailbox-ses (0.1.0) aws-actionmailbox-ses (0.1.0)
actionmailbox (>= 7.1.0) actionmailbox (>= 7.1.0)
aws-sdk-s3 (~> 1, >= 1.123.0) aws-sdk-s3 (~> 1, >= 1.123.0)
@@ -170,7 +168,7 @@ GEM
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.22) bcrypt (3.1.22)
benchmark (0.4.1) benchmark (0.4.1)
bigdecimal (3.3.1) bigdecimal (3.2.2)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.16.0) bootsnap (1.16.0)
msgpack (~> 1.2) msgpack (~> 1.2)
@@ -186,7 +184,6 @@ GEM
bundler (>= 1.2.0, < 3) bundler (>= 1.2.0, < 3)
thor (~> 1.0) thor (~> 1.0)
byebug (11.1.3) byebug (11.1.3)
cgi (0.5.1)
childprocess (5.1.0) childprocess (5.1.0)
logger (~> 1.5) logger (~> 1.5)
cld3 (3.7.0) cld3 (3.7.0)
@@ -315,7 +312,7 @@ GEM
hashie hashie
faraday-multipart (1.2.0) faraday-multipart (1.2.0)
multipart-post (~> 2.0) multipart-post (~> 2.0)
faraday-net_http (3.4.4) faraday-net_http (3.4.2)
net-http (~> 0.5) net-http (~> 0.5)
faraday-net_http_persistent (2.1.0) faraday-net_http_persistent (2.1.0)
faraday (~> 2.5) faraday (~> 2.5)
@@ -438,8 +435,7 @@ GEM
hana (1.3.7) hana (1.3.7)
hash_diff (1.1.1) hash_diff (1.1.1)
hashdiff (1.1.0) hashdiff (1.1.0)
hashie (5.1.0) hashie (5.0.0)
logger
html2text (0.4.0) html2text (0.4.0)
nokogiri (>= 1.0, < 2.0) nokogiri (>= 1.0, < 2.0)
http (5.1.1) http (5.1.1)
@@ -474,7 +470,7 @@ GEM
rails-dom-testing (>= 1, < 3) rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0) railties (>= 4.2.0)
thor (>= 0.14, < 2.0) thor (>= 0.14, < 2.0)
json (2.19.8) json (2.19.5)
json_refs (0.1.8) json_refs (0.1.8)
hana hana
json_schemer (0.2.24) json_schemer (0.2.24)
@@ -572,7 +568,7 @@ GEM
ruby2_keywords ruby2_keywords
msgpack (1.8.0) msgpack (1.8.0)
multi_json (1.15.0) multi_json (1.15.0)
multi_xml (0.9.1) multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5) bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1) multipart-post (2.4.1)
mutex_m (0.3.0) mutex_m (0.3.0)
@@ -597,7 +593,7 @@ GEM
sidekiq sidekiq
newrelic_rpm (9.6.0) newrelic_rpm (9.6.0)
base64 base64
nio4r (2.7.5) nio4r (2.7.3)
nokogiri (1.19.3) nokogiri (1.19.3)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
@@ -607,26 +603,19 @@ GEM
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu) nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
oauth (1.1.6) oauth (1.1.0)
auth-sanitizer (~> 0.2, >= 0.2.1) oauth-tty (~> 1.0, >= 1.0.1)
base64 (~> 0.1) snaky_hash (~> 2.0)
cgi version_gem (~> 1.1)
oauth-tty (~> 1.0, >= 1.0.8) oauth-tty (1.0.5)
snaky_hash (~> 2.0, >= 2.0.5) version_gem (~> 1.1, >= 1.1.1)
version_gem (~> 1.1, >= 1.1.11) oauth2 (2.0.9)
oauth-tty (1.0.8) faraday (>= 0.17.3, < 3.0)
auth-sanitizer (~> 0.1, >= 0.1.3) jwt (>= 1.0, < 3.0)
cgi
version_gem (~> 1.1, >= 1.1.9)
oauth2 (2.0.22)
auth-sanitizer (~> 0.2, >= 0.2.1)
faraday (>= 0.17.3, < 4.0)
jwt (>= 1.0, < 4.0)
logger (~> 1.2)
multi_xml (~> 0.5) multi_xml (~> 0.5)
rack (>= 1.2, < 4) rack (>= 1.2, < 4)
snaky_hash (~> 2.0, >= 2.0.5) snaky_hash (~> 2.0)
version_gem (~> 1.1, >= 1.1.11) version_gem (~> 1.1)
oj (3.16.10) oj (3.16.10)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
ostruct (>= 0.2) ostruct (>= 0.2)
@@ -693,7 +682,7 @@ GEM
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (7.0.5) public_suffix (7.0.5)
puma (7.2.1) puma (6.4.3)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.3.0) pundit (2.3.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
@@ -946,9 +935,9 @@ GEM
gli gli
hashie hashie
logger logger
snaky_hash (2.0.5) snaky_hash (2.0.1)
hashie (>= 0.1.0, < 6) hashie
version_gem (>= 1.1.8, < 3) version_gem (~> 1.1, >= 1.1.1)
sorbet-runtime (0.5.11934) sorbet-runtime (0.5.11934)
spring (4.1.1) spring (4.1.1)
spring-watcher-listen (2.1.0) spring-watcher-listen (2.1.0)
@@ -1006,14 +995,12 @@ GEM
valid_email2 (5.2.6) valid_email2 (5.2.6)
activemodel (>= 3.2) activemodel (>= 3.2)
mail (~> 2.5) mail (~> 2.5)
version_gem (1.1.11) version_gem (1.1.4)
vite_rails (3.10.0) vite_rails (3.0.17)
railties (>= 5.1, < 9) railties (>= 5.1, < 8)
vite_ruby (~> 3.0, >= 3.2.2) vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.10.2) vite_ruby (3.8.0)
dry-cli (>= 0.7, < 2) dry-cli (>= 0.7, < 2)
logger (~> 1.6)
mutex_m
rack-proxy (~> 0.6, >= 0.6.1) rack-proxy (~> 0.6, >= 0.6.1)
zeitwerk (~> 2.2) zeitwerk (~> 2.2)
warden (1.2.9) warden (1.2.9)
@@ -1143,7 +1130,7 @@ DEPENDENCIES
pgvector pgvector
procore-sift procore-sift
pry-rails pry-rails
puma (~> 7.2, >= 7.2.1) puma
pundit pundit
rack-attack (>= 6.7.0) rack-attack (>= 6.7.0)
rack-cors (= 2.0.0) rack-cors (= 2.0.0)
+1 -1
View File
@@ -1 +1 @@
4.14.2 4.14.1
@@ -92,18 +92,10 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment) def fallback_params(attachment)
{ {
fallback_title: attachment['title'], fallback_title: attachment['title'],
external_url: attachment['url'] || attachment.dig('payload', 'url') external_url: attachment['url']
} }
end end
# Facebook shared posts point to page URLs, not downloadable media URLs.
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
def normalize_file_type(type)
return :fallback if type.to_sym == :share
super
end
def conversation_params def conversation_params
{ {
account_id: @inbox.account_id, account_id: @inbox.account_id,
+7 -17
View File
@@ -13,7 +13,6 @@ class Messages::MessageBuilder
@account = conversation.account @account = conversation.account
@message_type = params[:message_type] || 'outgoing' @message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments] @attachments = params[:attachments]
@is_voice_message = ActiveModel::Type::Boolean.new.cast(params[:is_voice_message])
@automation_rule = content_attributes&.dig(:automation_rule_id) @automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters) return unless params.instance_of?(ActionController::Parameters)
@@ -57,25 +56,16 @@ class Messages::MessageBuilder
file: uploaded_attachment file: uploaded_attachment
) )
attachment.file_type = attachment_file_type(uploaded_attachment) attachment.file_type = if uploaded_attachment.is_a?(String)
tag_voice_message(attachment) file_type_by_signed_id(
uploaded_attachment
)
else
file_type(uploaded_attachment&.content_type)
end
end end
end end
def attachment_file_type(uploaded_attachment)
if uploaded_attachment.is_a?(String)
file_type_by_signed_id(uploaded_attachment)
else
file_type(uploaded_attachment&.content_type)
end
end
def tag_voice_message(attachment)
return unless @is_voice_message && attachment.file_type == 'audio'
attachment.meta = (attachment.meta || {}).merge('is_voice_message' => true)
end
def process_emails def process_emails
return unless @conversation.inbox&.inbox_type == 'Email' return unless @conversation.inbox&.inbox_type == 'Email'
@@ -11,7 +11,7 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
enable_fb_login: '0', enable_fb_login: '0',
force_authentication: '1', force_authentication: '1',
response_type: 'code', response_type: 'code',
state: generate_instagram_token(Current.account.id, params[:return_to]) state: generate_instagram_token(Current.account.id)
} }
) )
if redirect_url if redirect_url
@@ -8,15 +8,7 @@ class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseC
end end
def state def state
# The sgid purpose doubles as a return hint: onboarding tags it so the callback Current.account.to_sgid(expires_in: 15.minutes).to_s
# can route the user back to inbox setup. The purpose is part of the signed
# payload (tamper-proof), and a non-onboarding request keeps the default
# purpose, leaving callers like Notion byte-identical.
Current.account.to_sgid(expires_in: 15.minutes, for: state_purpose).to_s
end
def state_purpose
params[:return_to] == 'onboarding' ? 'onboarding' : 'default'
end end
def base_url def base_url
@@ -1,51 +0,0 @@
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
def update
@account = Current.account
finalize = finalizing_account_details?
@account.assign_attributes(account_params)
@account.custom_attributes.merge!(custom_attributes_params)
@account.custom_attributes.delete('onboarding_step') if finalize
@account.save!
# TODO: re-enable when the help center generation UI is ready to surface progress
# Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present?
render 'api/v1/accounts/update', format: :json
end
def help_center_generation
render json: help_center_generation_status
end
private
def finalizing_account_details?
@account.custom_attributes['onboarding_step'] == 'account_details'
end
def website
custom_attributes_params[:website]
end
def account_params
params.permit(:name, :locale)
end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
end
def help_center_generation_status
{
generation_id: nil,
state: nil,
articles_count: 0,
categories_count: 0
}
end
end
Api::V1::Accounts::OnboardingsController.prepend_mod_with('Api::V1::Accounts::OnboardingsController')
@@ -80,15 +80,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
:id, :color, :custom_domain, :header_text, :homepage_link, :id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, :name, :page_title, :slug, :archived,
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] }, { config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }, { social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
) )
end end
def locale_translation_keys
params.dig(:portal, :config, :locale_translations)&.keys || []
end
def live_chat_widget_params def live_chat_widget_params
permitted_params = params.permit(:inbox_id) permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id) return {} unless permitted_params.key?(:inbox_id)
@@ -3,7 +3,7 @@ class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::O
def create def create
redirect_url = Tiktok::AuthClient.authorize_url( redirect_url = Tiktok::AuthClient.authorize_url(
state: generate_tiktok_token(Current.account.id, params[:return_to]) state: generate_tiktok_token(Current.account.id)
) )
if redirect_url if redirect_url
@@ -58,6 +58,7 @@ class Api::V1::AccountsController < Api::BaseController
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email)) @account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
@account.custom_attributes.merge!(custom_attributes_params) @account.custom_attributes.merge!(custom_attributes_params)
@account.settings.merge!(settings_params) @account.settings.merge!(settings_params)
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update' @account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save! @account.save!
end end
@@ -1,9 +1,8 @@
module AccessTokenAuthHelper module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = { BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => %w[show toggle_status toggle_typing_status toggle_priority create update custom_attributes], 'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations/messages' => ['create'], 'api/v1/accounts/conversations/messages' => ['create'],
'api/v1/accounts/conversations/assignments' => ['create'], 'api/v1/accounts/conversations/assignments' => ['create']
'api/v1/accounts/conversations/labels' => %w[index create]
}.freeze }.freeze
def ensure_access_token def ensure_access_token
@@ -28,8 +28,6 @@ class Instagram::CallbacksController < ApplicationController
@long_lived_token_response = exchange_for_long_lived_token(@response.token) @long_lived_token_response = exchange_for_long_lived_token(@response.token)
inbox, already_exists = find_or_create_inbox inbox, already_exists = find_or_create_inbox
return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding'
if already_exists if already_exists
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else else
@@ -151,10 +149,6 @@ class Instagram::CallbacksController < ApplicationController
verify_instagram_token(params[:state]) verify_instagram_token(params[:state])
end end
def return_to
instagram_token_return_to(params[:state])
end
def oauth_code def oauth_code
params[:code] params[:code]
end end
@@ -14,11 +14,4 @@ class Microsoft::CallbacksController < OauthCallbackController
def imap_address def imap_address
'outlook.office365.com' 'outlook.office365.com'
end end
# Exchange Online's SMTP AUTH (XOAUTH2) rejects proxy addresses in the SASL `user=` field;
# it must match the token's UPN. `preferred_username` is the documented v2.0 claim;
# `upn` is the v1.0 fallback.
def imap_login_identity
users_data['preferred_username'] || users_data['upn'] || super
end
end end
+2 -25
View File
@@ -16,8 +16,6 @@ class OauthCallbackController < ApplicationController
def handle_response def handle_response
inbox, already_exists = find_or_create_inbox inbox, already_exists = find_or_create_inbox
return redirect_to app_onboarding_inbox_setup_url(account_id: account.id) if return_to == 'onboarding'
if already_exists if already_exists
redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id) redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
else else
@@ -46,7 +44,7 @@ class OauthCallbackController < ApplicationController
def update_channel(channel_email) def update_channel(channel_email)
channel_email.update!({ channel_email.update!({
imap_login: imap_login_identity, imap_address: imap_address, imap_login: users_data['email'], imap_address: imap_address,
imap_port: '993', imap_enabled: true, imap_port: '993', imap_enabled: true,
provider: provider_name, provider: provider_name,
provider_config: { provider_config: {
@@ -57,13 +55,6 @@ class OauthCallbackController < ApplicationController
}) })
end end
# Identity used as the IMAP/SMTP login (SASL XOAUTH2 `user=` field). Defaults to the
# id_token's email claim; providers override when their server requires a different
# claim (e.g. Microsoft SMTP requires UPN).
def imap_login_identity
users_data['email']
end
def provider_name def provider_name
raise NotImplementedError raise NotImplementedError
end end
@@ -90,19 +81,10 @@ class OauthCallbackController < ApplicationController
decoded_token[0] decoded_token[0]
end end
# The sgid purpose carries the onboarding return hint (see
# OauthAuthorizationController#state). Try the onboarding purpose first — a match
# both resolves the account and records the return target — then fall back to the
# default purpose used by every other caller.
def account_from_signed_id def account_from_signed_id
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank? raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
if (account = GlobalID::Locator.locate_signed(params[:state], for: 'onboarding')) account = GlobalID::Locator.locate_signed(params[:state])
@return_to = 'onboarding'
else
account = GlobalID::Locator.locate_signed(params[:state])
end
raise 'Invalid or expired state' if account.nil? raise 'Invalid or expired state' if account.nil?
account account
@@ -112,11 +94,6 @@ class OauthCallbackController < ApplicationController
@account ||= account_from_signed_id @account ||= account_from_signed_id
end end
def return_to
account # resolving the sgid records which purpose matched
@return_to
end
# Fallback name, for when name field is missing from users_data # Fallback name, for when name field is missing from users_data
def fallback_name def fallback_name
users_data['email'].split('@').first.parameterize.titleize users_data['email'].split('@').first.parameterize.titleize
@@ -1,31 +0,0 @@
class Public::Api::V1::Portals::SearchController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:index]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
layout 'portal'
def index
@query = params[:query].to_s.strip
@articles = @portal.articles.published.includes(:category).where(locale: params[:locale])
search_articles
@articles = @articles.page(params[:page]).per(10)
end
private
def search_articles
@articles = @query.present? ? @articles.search(search_params) : @articles.none
end
def search_params
params.permit(:query, :locale, :sort, :status, :page).tap do |permitted|
permitted[:query] = @query
end
end
end
Public::Api::V1::Portals::SearchController.prepend_mod_with('Public::Api::V1::Portals::SearchController')
@@ -9,7 +9,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
layout 'portal' layout 'portal'
def show def show
@og_image_url = helpers.set_og_image_url('', @portal.localized_value('header_text', @locale)) @og_image_url = helpers.set_og_image_url('', @portal.header_text)
end end
def sitemap def sitemap
@@ -20,8 +20,6 @@ class Tiktok::CallbacksController < ApplicationController
def process_successful_authorization def process_successful_authorization
inbox, already_exists = find_or_create_inbox inbox, already_exists = find_or_create_inbox
return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding'
if already_exists if already_exists
redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else else
@@ -129,10 +127,6 @@ class Tiktok::CallbacksController < ApplicationController
@account_id ||= verify_tiktok_token(params[:state]) @account_id ||= verify_tiktok_token(params[:state])
end end
def return_to
tiktok_token_return_to(params[:state])
end
def account def account
@account ||= Account.find(account_id) @account ||= Account.find(account_id)
end end
+1 -1
View File
@@ -12,7 +12,7 @@ class ContactDrop < BaseDrop
end end
def first_name def first_name
@obj.try(:name).try(:split).try(:first).try(:capitalize) @obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end end
def last_name def last_name
+1 -1
View File
@@ -12,7 +12,7 @@ class UserDrop < BaseDrop
end end
def first_name def first_name
@obj.try(:name).try(:split).try(:first).try(:capitalize) @obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
end end
def last_name def last_name
+9 -16
View File
@@ -4,21 +4,21 @@ module Instagram::IntegrationHelper
# Generates a signed JWT token for Instagram integration # Generates a signed JWT token for Instagram integration
# #
# @param account_id [Integer] The account ID to encode in the token # @param account_id [Integer] The account ID to encode in the token
# @param return_to [String, nil] Optional onboarding return hint
# @return [String, nil] The encoded JWT token or nil if client secret is missing # @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_instagram_token(account_id, return_to = nil) def generate_instagram_token(account_id)
return if client_secret.blank? return if client_secret.blank?
JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256') JWT.encode(token_payload(account_id), client_secret, 'HS256')
rescue StandardError => e rescue StandardError => e
Rails.logger.error("Failed to generate Instagram token: #{e.message}") Rails.logger.error("Failed to generate Instagram token: #{e.message}")
nil nil
end end
def token_payload(account_id, return_to = nil) def token_payload(account_id)
payload = { sub: account_id, iat: Time.current.to_i } {
payload[:return_to] = return_to if return_to.present? sub: account_id,
payload iat: Time.current.to_i
}
end end
# Verifies and decodes a Instagram JWT token # Verifies and decodes a Instagram JWT token
@@ -28,14 +28,7 @@ module Instagram::IntegrationHelper
def verify_instagram_token(token) def verify_instagram_token(token)
return if token.blank? || client_secret.blank? return if token.blank? || client_secret.blank?
decode_token(token, client_secret)&.dig('sub') decode_token(token, client_secret)
end
# Reads the onboarding return hint from a Instagram JWT token, if present.
def instagram_token_return_to(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)&.dig('return_to')
end end
private private
@@ -48,7 +41,7 @@ module Instagram::IntegrationHelper
JWT.decode(token, secret, true, { JWT.decode(token, secret, true, {
algorithm: 'HS256', algorithm: 'HS256',
verify_expiration: true verify_expiration: true
}).first }).first['sub']
rescue StandardError => e rescue StandardError => e
Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}") Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}")
nil nil
+3 -10
View File
@@ -45,16 +45,9 @@ module PortalHelper
theme.present? && theme != 'system' ? "?theme=#{theme}" : '' theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
end end
def portal_query_string(theme, is_plain_layout_enabled)
query_params = {}
query_params[:theme] = theme if theme.present? && theme != 'system'
query_params[:show_plain_layout] = true if is_plain_layout_enabled
query_params.present? ? "?#{query_params.to_query}" : ''
end
def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled) def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled)
if is_plain_layout_enabled if is_plain_layout_enabled
"/hc/#{portal_slug}/#{portal_locale}#{portal_query_string(theme, is_plain_layout_enabled)}" "/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}"
else else
"/hc/#{portal_slug}/#{portal_locale}" "/hc/#{portal_slug}/#{portal_locale}"
end end
@@ -68,7 +61,7 @@ module PortalHelper
is_plain_layout_enabled = params[:is_plain_layout_enabled] is_plain_layout_enabled = params[:is_plain_layout_enabled]
if is_plain_layout_enabled if is_plain_layout_enabled
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{portal_query_string(theme, is_plain_layout_enabled)}" "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}"
else else
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}" "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}"
end end
@@ -76,7 +69,7 @@ module PortalHelper
def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled) def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled)
if is_plain_layout_enabled if is_plain_layout_enabled
"/hc/#{portal_slug}/articles/#{article_slug}#{portal_query_string(theme, is_plain_layout_enabled)}" "/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}"
else else
"/hc/#{portal_slug}/articles/#{article_slug}" "/hc/#{portal_slug}/articles/#{article_slug}"
end end
-6
View File
@@ -34,12 +34,6 @@ disable_branding:
enabled: <%= (ChatwootHub.pricing_plan != 'community') %> enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-sailbot-fill' icon: 'icon-sailbot-fill'
enterprise: true enterprise: true
voice_calls:
name: 'Voice Calls'
description: 'Enable voice calling capabilities for your agents and customers.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-voice-line'
enterprise: true
# ------- Product Features ------- # # ------- Product Features ------- #
help_center: help_center:
+9 -16
View File
@@ -2,12 +2,11 @@ module Tiktok::IntegrationHelper
# Generates a signed JWT token for Tiktok integration # Generates a signed JWT token for Tiktok integration
# #
# @param account_id [Integer] The account ID to encode in the token # @param account_id [Integer] The account ID to encode in the token
# @param return_to [String, nil] Optional onboarding return hint
# @return [String, nil] The encoded JWT token or nil if client secret is missing # @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_tiktok_token(account_id, return_to = nil) def generate_tiktok_token(account_id)
return if client_secret.blank? return if client_secret.blank?
JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256') JWT.encode(token_payload(account_id), client_secret, 'HS256')
rescue StandardError => e rescue StandardError => e
Rails.logger.error("Failed to generate TikTok token: #{e.message}") Rails.logger.error("Failed to generate TikTok token: #{e.message}")
nil nil
@@ -20,14 +19,7 @@ module Tiktok::IntegrationHelper
def verify_tiktok_token(token) def verify_tiktok_token(token)
return if token.blank? || client_secret.blank? return if token.blank? || client_secret.blank?
decode_token(token, client_secret)&.dig('sub') decode_token(token, client_secret)
end
# Reads the onboarding return hint from a Tiktok JWT token, if present.
def tiktok_token_return_to(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)&.dig('return_to')
end end
private private
@@ -36,17 +28,18 @@ module Tiktok::IntegrationHelper
@client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil) @client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
end end
def token_payload(account_id, return_to = nil) def token_payload(account_id)
payload = { sub: account_id, iat: Time.current.to_i } {
payload[:return_to] = return_to if return_to.present? sub: account_id,
payload iat: Time.current.to_i
}
end end
def decode_token(token, secret) def decode_token(token, secret)
JWT.decode(token, secret, true, { JWT.decode(token, secret, true, {
algorithm: 'HS256', algorithm: 'HS256',
verify_expiration: true verify_expiration: true
}).first }).first['sub']
rescue StandardError => e rescue StandardError => e
Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}") Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}")
nil nil
@@ -48,12 +48,6 @@ class TwilioVoiceClient extends EventTarget {
return !!this.activeConnection; return !!this.activeConnection;
} }
setMuted(shouldMute) {
if (!this.activeConnection) return false;
this.activeConnection.mute(shouldMute);
return shouldMute;
}
endClientCall() { endClientCall() {
if (this.activeConnection) { if (this.activeConnection) {
this.activeConnection.disconnect(); this.activeConnection.disconnect();
@@ -12,7 +12,6 @@ export const buildCreatePayload = ({
bccEmails = '', bccEmails = '',
toEmails = '', toEmails = '',
templateParams, templateParams,
isVoiceMessage = false,
}) => { }) => {
let payload; let payload;
if (files && files.length !== 0) { if (files && files.length !== 0) {
@@ -34,9 +33,6 @@ export const buildCreatePayload = ({
if (contentAttributes) { if (contentAttributes) {
payload.append('content_attributes', JSON.stringify(contentAttributes)); payload.append('content_attributes', JSON.stringify(contentAttributes));
} }
if (isVoiceMessage) {
payload.append('is_voice_message', true);
}
} else { } else {
payload = { payload = {
content: message, content: message,
@@ -68,7 +64,6 @@ class MessageApi extends ApiClient {
bccEmails = '', bccEmails = '',
toEmails = '', toEmails = '',
templateParams, templateParams,
isVoiceMessage = false,
}) { }) {
return axios({ return axios({
method: 'post', method: 'post',
@@ -83,7 +78,6 @@ class MessageApi extends ApiClient {
bccEmails, bccEmails,
toEmails, toEmails,
templateParams, templateParams,
isVoiceMessage,
}), }),
}); });
} }
@@ -1,18 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class OnboardingAPI extends ApiClient {
constructor() {
super('onboarding', { accountScoped: true });
}
update(data) {
return axios.patch(this.url, data);
}
getHelpCenterGeneration() {
return axios.get(`${this.url}/help_center_generation`);
}
}
export default new OnboardingAPI();
@@ -83,29 +83,5 @@ describe('#ConversationAPI', () => {
template_params: undefined, template_params: undefined,
}); });
}); });
it('appends is_voice_message when isVoiceMessage is true', () => {
const formPayload = buildCreatePayload({
message: 'voice message',
echoId: 42,
isPrivate: false,
files: [new Blob(['audio-data'], { type: 'audio/ogg' })],
isVoiceMessage: true,
});
expect(formPayload).toBeInstanceOf(FormData);
expect(formPayload.get('is_voice_message')).toEqual('true');
});
it('does not append is_voice_message when isVoiceMessage is false', () => {
const formPayload = buildCreatePayload({
message: 'regular audio',
echoId: 43,
isPrivate: false,
files: [new Blob(['audio-data'], { type: 'audio/ogg' })],
isVoiceMessage: false,
});
expect(formPayload).toBeInstanceOf(FormData);
expect(formPayload.get('is_voice_message')).toBeNull();
});
}); });
}); });
@@ -49,7 +49,6 @@ const emit = defineEmits(['edit', 'delete']);
const { t } = useI18n(); const { t } = useI18n();
const STATUS_COMPLETED = 'completed'; const STATUS_COMPLETED = 'completed';
const STATUS_PROCESSING = 'processing';
const { formatMessage } = useMessageFormatter(); const { formatMessage } = useMessageFormatter();
@@ -69,15 +68,9 @@ const campaignStatus = computed(() => {
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED'); : t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
} }
if (props.status === STATUS_COMPLETED) { return props.status === STATUS_COMPLETED
return t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED'); ? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
} : t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
if (props.status === STATUS_PROCESSING) {
return t('CAMPAIGN.SMS.CARD.STATUS.PROCESSING');
}
return t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
}); });
const inboxName = computed(() => props.inbox?.name || ''); const inboxName = computed(() => props.inbox?.name || '');
@@ -46,8 +46,9 @@ const formattedLastActivityAt = computed(() => {
:src="avatarSource" :src="avatarSource"
class="shrink-0" class="shrink-0"
:name="name" :name="name"
:size="42" :size="48"
hide-offline-status hide-offline-status
rounded-full
/> />
<div class="flex flex-col gap-0.5 flex-1 min-w-0"> <div class="flex flex-col gap-0.5 flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0"> <div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
@@ -141,6 +141,7 @@ const handleUpdateCompany = async () => {
:src="avatarSource" :src="avatarSource"
:size="72" :size="72"
:allow-upload="!isAvatarBusy" :allow-upload="!isAvatarBusy"
rounded-full
hide-offline-status hide-offline-status
@upload="handleAvatarUpload" @upload="handleAvatarUpload"
@delete="handleAvatarDelete" @delete="handleAvatarDelete"
@@ -124,9 +124,10 @@ const handleAvatarHover = isHovered => {
<Avatar <Avatar
:name="name" :name="name"
:src="thumbnail" :src="thumbnail"
:size="42" :size="48"
:status="availabilityStatus" :status="availabilityStatus"
hide-offline-status hide-offline-status
rounded-full
> >
<template v-if="selectable" #overlay="{ size }"> <template v-if="selectable" #overlay="{ size }">
<label <label
@@ -1,48 +1,34 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import { usePolicy } from 'dashboard/composables/usePolicy';
const emit = defineEmits(['add', 'import', 'export']); const emit = defineEmits(['add', 'import', 'export']);
const { t } = useI18n(); const { t } = useI18n();
const { checkPermissions } = usePolicy();
const contactMenuItems = computed(() => [ const contactMenuItems = [
{ {
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'), label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
action: 'add', action: 'add',
value: 'add', value: 'add',
icon: 'i-lucide-plus', icon: 'i-lucide-plus',
}, },
...(checkPermissions(['administrator', 'contact_manage']) {
? [ label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'),
{ action: 'export',
label: t( value: 'export',
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT' icon: 'i-lucide-upload',
), },
action: 'export', {
value: 'export', label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'),
icon: 'i-lucide-upload', action: 'import',
}, value: 'import',
] icon: 'i-lucide-download',
: []), },
...(checkPermissions(['administrator', 'contact_manage']) ];
? [
{
label: t(
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'
),
action: 'import',
value: 'import',
icon: 'i-lucide-download',
},
]
: []),
]);
const showActionsDropdown = ref(false); const showActionsDropdown = ref(false);
const handleContactAction = ({ action }) => { const handleContactAction = ({ action }) => {
@@ -53,9 +53,6 @@ const localeMenuLabels = computed(() => ({
'publish-locale': t( 'publish-locale': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE' 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
), ),
'customize-content': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
),
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'), delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
})); }));
@@ -131,7 +128,7 @@ const handleAction = ({ action, value }) => {
<DropdownMenu <DropdownMenu
v-if="showDropdownMenu" v-if="showDropdownMenu"
:menu-items="localeMenuItems" :menu-items="localeMenuItems"
class="ltr:right-0 rtl:left-0 mt-1 top-full z-60 min-w-[150px]" class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
@action="handleAction" @action="handleAction"
/> />
</div> </div>
@@ -1,98 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
portal: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const activeLocale = ref('');
const name = ref('');
const pageTitle = ref('');
const headerText = ref('');
const localeTranslations = computed(
() => props.portal?.config?.locale_translations || {}
);
const openForLocale = localeCode => {
const existing = localeTranslations.value[localeCode] || {};
activeLocale.value = localeCode;
name.value = existing.name || '';
pageTitle.value = existing.page_title || '';
headerText.value = existing.header_text || '';
dialogRef.value?.open();
};
const onConfirm = async () => {
const translations = { ...localeTranslations.value };
const fields = {};
if (name.value.trim()) fields.name = name.value.trim();
if (pageTitle.value.trim()) fields.page_title = pageTitle.value.trim();
if (headerText.value.trim()) fields.header_text = headerText.value.trim();
if (Object.keys(fields).length) {
translations[activeLocale.value] = fields;
} else {
delete translations[activeLocale.value];
}
try {
await store.dispatch('portals/update', {
portalSlug: props.portal?.slug,
config: { locale_translations: translations },
});
dialogRef.value?.close();
useAlert(t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.ERROR_MESSAGE')
);
}
};
defineExpose({ openForLocale });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.TITLE')"
:description="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.DESCRIPTION')"
@confirm="onConfirm"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.NAME.LABEL') }}
</label>
<Input v-model="name" :placeholder="portal.name" />
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.PAGE_TITLE.LABEL') }}
</label>
<Input v-model="pageTitle" :placeholder="portal.page_title" />
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.HEADER_TEXT.LABEL') }}
</label>
<Input v-model="headerText" :placeholder="portal.header_text" />
</div>
</div>
</Dialog>
</template>
@@ -1,7 +1,5 @@
<script setup> <script setup>
import { ref } from 'vue';
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue'; import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
import { useStore } from 'dashboard/composables/store'; import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables'; import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -25,8 +23,6 @@ const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings(); const { uiSettings, updateUISettings } = useUISettings();
const contentDialogRef = ref(null);
const isLocaleDefault = code => { const isLocaleDefault = code => {
return props.portal?.meta?.default_locale === code; return props.portal?.meta?.default_locale === code;
}; };
@@ -152,8 +148,6 @@ const handleAction = ({ action }, localeCode) => {
moveLocaleToDraft({ localeCode: localeCode }); moveLocaleToDraft({ localeCode: localeCode });
} else if (action === 'publish-locale') { } else if (action === 'publish-locale') {
publishLocale({ localeCode: localeCode }); publishLocale({ localeCode: localeCode });
} else if (action === 'customize-content') {
contentDialogRef.value.openForLocale(localeCode);
} else if (action === 'delete') { } else if (action === 'delete') {
deletePortalLocale({ localeCode: localeCode }); deletePortalLocale({ localeCode: localeCode });
} }
@@ -173,6 +167,5 @@ const handleAction = ({ action }, localeCode) => {
:category-count="locale.categoriesCount || 0" :category-count="locale.categoriesCount || 0"
@action="handleAction($event, locale.code)" @action="handleAction($event, locale.code)"
/> />
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
</ul> </ul>
</template> </template>
@@ -182,10 +182,7 @@ const MIN_SEARCH_LENGTH = 2;
export const createContactSearcher = () => { export const createContactSearcher = () => {
let controller = null; let controller = null;
return async ( return async (query, { skipMinLength = false } = {}) => {
query,
{ skipMinLength = false, reachableOnly = true } = {}
) => {
const trimmed = typeof query === 'string' ? query.trim() : ''; const trimmed = typeof query === 'string' ? query.trim() : '';
controller?.abort(); controller?.abort();
@@ -202,8 +199,6 @@ export const createContactSearcher = () => {
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal }); } = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
const camelCasedPayload = camelcaseKeys(payload, { deep: true }); const camelCasedPayload = camelcaseKeys(payload, { deep: true });
if (!reachableOnly) return camelCasedPayload || [];
// Filter contacts that have either phone_number or email // Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter( const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email contact => contact.phoneNumber || contact.email
@@ -197,9 +197,9 @@ const channelIcon = computed(() => {
? $t('CONVERSATION.VOICE_WIDGET.END_CALL') ? $t('CONVERSATION.VOICE_WIDGET.END_CALL')
: $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL') : $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')
" "
icon="i-ph-phone-bold" icon="i-ph-phone-x-bold"
ruby ruby
class="!rounded-full rotate-[135deg]" class="!rounded-full rotate-[134deg]"
@click="isOngoing ? $emit('end') : $emit('reject')" @click="isOngoing ? $emit('end') : $emit('reject')"
/> />
</div> </div>
@@ -4,7 +4,6 @@ import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession'; import { useCallSession } from 'dashboard/composables/useCallSession';
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession'; import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox'; import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants'; import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
@@ -12,7 +11,7 @@ import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilit
import CallCard from 'dashboard/components-next/call/CallCard.vue'; import CallCard from 'dashboard/components-next/call/CallCard.vue';
import countriesList from 'shared/constants/countries.js'; import countriesList from 'shared/constants/countries.js';
const RINGTONE_URL = '/audio/dashboard/ringtone.mp3'; const RINGTONE_URL = '/audio/dashboard/bell.mp3';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -30,8 +29,8 @@ const {
formattedCallDuration, formattedCallDuration,
} = useCallSession(); } = useCallSession();
// Mute routes by provider: WhatsApp toggles the local mic track, Twilio uses // Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
// the Voice SDK connection's native mute. Both surface the same button. // don't expose a mic track on the browser side.
const isMuted = ref(false); const isMuted = ref(false);
const isWhatsappActive = computed( const isWhatsappActive = computed(
() => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP () => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
@@ -64,11 +63,7 @@ const stackedCardState = call =>
const toggleMute = () => { const toggleMute = () => {
isMuted.value = !isMuted.value; isMuted.value = !isMuted.value;
if (isWhatsappActive.value) { setWhatsappCallMuted(isMuted.value);
setWhatsappCallMuted(isMuted.value);
} else {
TwilioVoiceClient.setMuted(isMuted.value);
}
}; };
watch(hasActiveCall, active => { watch(hasActiveCall, active => {
@@ -261,7 +256,7 @@ onBeforeUnmount(stopRingtone);
:call-info="getCallInfo(activeCall || primaryIncomingCall)" :call-info="getCallInfo(activeCall || primaryIncomingCall)"
:duration="hasActiveCall ? formattedCallDuration : ''" :duration="hasActiveCall ? formattedCallDuration : ''"
:is-muted="isMuted" :is-muted="isMuted"
:show-mute="hasActiveCall" :show-mute="hasActiveCall && isWhatsappActive"
@accept="handleJoinCall(primaryIncomingCall)" @accept="handleJoinCall(primaryIncomingCall)"
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)" @reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
@dismiss="dismissCall(primaryIncomingCall?.callSid)" @dismiss="dismissCall(primaryIncomingCall?.callSid)"
@@ -1,7 +1,6 @@
<script setup> <script setup>
import { computed, h, watch, ref } from 'vue'; import { computed, h, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { debounce } from '@chatwoot/utils';
import Button from 'next/button/Button.vue'; import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
import FilterSelect from './inputs/FilterSelect.vue'; import FilterSelect from './inputs/FilterSelect.vue';
@@ -110,34 +109,6 @@ const inputFieldType = computed(() => {
return 'text'; return 'text';
}); });
const asyncOptions = ref([]);
const isSearching = ref(false);
const lastSearchQuery = ref('');
const performAsyncSearch = async query => {
let results;
try {
results = await currentFilter.value.searchOptions(query);
} catch {
results = [];
}
// skip stale responses — a newer search in this row owns the UI
if (query !== lastSearchQuery.value) return;
// null means another row's search aborted ours, reset instead of staying stuck on the searching state
if (results !== null) asyncOptions.value = results;
isSearching.value = false;
};
const debouncedAsyncSearch = debounce(performAsyncSearch, 300);
const onAsyncSearch = query => {
const hasQuery = !!query.trim();
lastSearchQuery.value = query;
if (!hasQuery) asyncOptions.value = [];
isSearching.value = hasQuery;
debouncedAsyncSearch(query);
};
const resetModelOnAttributeKeyChange = newAttributeKey => { const resetModelOnAttributeKeyChange = newAttributeKey => {
/** /**
* Resets the filter values and operator when the attribute key changes. This ensures that * Resets the filter values and operator when the attribute key changes. This ensures that
@@ -150,16 +121,11 @@ const resetModelOnAttributeKeyChange = newAttributeKey => {
const newInputType = getInputType(newOperator, filter); const newInputType = getInputType(newOperator, filter);
if (newInputType === 'multiSelect') { if (newInputType === 'multiSelect') {
values.value = []; values.value = [];
} else if ( } else if (['searchSelect', 'booleanSelect'].includes(newInputType)) {
['searchSelect', 'asyncSearchSelect', 'booleanSelect'].includes(
newInputType
)
) {
values.value = {}; values.value = {};
} else { } else {
values.value = ''; values.value = '';
} }
asyncOptions.value = [];
filterOperator.value = newOperator.value; filterOperator.value = newOperator.value;
}; };
@@ -219,16 +185,6 @@ defineExpose({ validate, resetValidation });
:options="currentFilter.options" :options="currentFilter.options"
dropdown-max-height="max-h-64" dropdown-max-height="max-h-64"
/> />
<SingleSelect
v-else-if="inputType === 'asyncSearchSelect'"
v-model="values"
async-search
:options="asyncOptions"
:is-searching="isSearching"
:search-placeholder="currentFilter.searchPlaceholder"
dropdown-max-height="max-h-64"
@search="onAsyncSearch"
/>
<SingleSelect <SingleSelect
v-else-if="inputType === 'booleanSelect'" v-else-if="inputType === 'booleanSelect'"
v-model="values" v-model="values"
@@ -7,7 +7,6 @@ export const CONVERSATION_ATTRIBUTES = {
ASSIGNEE_ID: 'assignee_id', ASSIGNEE_ID: 'assignee_id',
INBOX_ID: 'inbox_id', INBOX_ID: 'inbox_id',
TEAM_ID: 'team_id', TEAM_ID: 'team_id',
CONTACT_ID: 'contact_id',
DISPLAY_ID: 'display_id', DISPLAY_ID: 'display_id',
CAMPAIGN_ID: 'campaign_id', CAMPAIGN_ID: 'campaign_id',
LABELS: 'labels', LABELS: 'labels',
@@ -1,37 +1,9 @@
import { ref } from 'vue';
import ContactAPI from 'dashboard/api/contacts';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConversationFilterContext } from '../provider';
import { import {
CONVERSATION_ATTRIBUTES,
getCustomAttributeInputType, getCustomAttributeInputType,
buildAttributesFilterTypes, buildAttributesFilterTypes,
replaceUnderscoreWithSpace, replaceUnderscoreWithSpace,
} from './filterHelper'; } from './filterHelper';
vi.mock('dashboard/api/contacts', () => ({
default: {
search: vi.fn(),
},
}));
vi.mock('dashboard/composables/store.js', () => ({
useMapGetter: vi.fn(),
}));
vi.mock('next/icon/provider', () => ({
useChannelIcon: () => ref('i-test-channel'),
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params = {}) => {
if (key === 'FILTER.CONTACT_FALLBACK') return `Contact #${params.id}`;
return key;
},
}),
}));
describe('filterHelper', () => { describe('filterHelper', () => {
describe('getCustomAttributeInputType', () => { describe('getCustomAttributeInputType', () => {
it('returns date for date type', () => { it('returns date for date type', () => {
@@ -163,64 +135,3 @@ describe('filterHelper', () => {
}); });
}); });
}); });
const storeValues = {
'attributes/getConversationAttributes': ref([]),
'labels/getLabels': ref([]),
'agents/getAgents': ref([]),
'inboxes/getInboxes': ref([]),
'teams/getTeams': ref([]),
'campaigns/getAllCampaigns': ref([]),
};
describe('useConversationFilterContext', () => {
beforeEach(() => {
vi.clearAllMocks();
useMapGetter.mockImplementation(key => storeValues[key] || ref([]));
});
it('exposes contact as an async searchable conversation filter', () => {
const { filterTypes } = useConversationFilterContext();
const contactFilter = filterTypes.value.find(
filter => filter.attributeKey === CONVERSATION_ATTRIBUTES.CONTACT_ID
);
expect(contactFilter).toMatchObject({
attributeKey: 'contact_id',
label: 'FILTER.ATTRIBUTES.CONTACT',
inputType: 'asyncSearchSelect',
dataType: 'number',
attributeModel: 'standard',
});
expect(
contactFilter.filterOperators.map(operator => operator.value)
).toEqual(['equal_to', 'not_equal_to']);
});
it('uses the existing contact search API for contact filter options', async () => {
ContactAPI.search.mockResolvedValue({
data: {
payload: [
{ id: 1, name: 'Jane Doe' },
{ id: 2, email: 'alex@example.com' },
{ id: 3 },
],
},
});
const { filterTypes } = useConversationFilterContext();
const contactFilter = filterTypes.value.find(
filter => filter.attributeKey === CONVERSATION_ATTRIBUTES.CONTACT_ID
);
const options = await contactFilter.searchOptions('jane');
expect(ContactAPI.search).toHaveBeenCalledWith('jane', 1, 'name', '', {
signal: expect.any(AbortSignal),
});
expect(options).toEqual([
{ id: 1, name: 'Jane Doe' },
{ id: 2, name: 'alex@example.com' },
{ id: 3, name: 'Contact #3' },
]);
});
});
@@ -11,8 +11,6 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { const {
options, options,
asyncSearch,
isSearching,
disableSearch, disableSearch,
disableDeselect, disableDeselect,
placeholderIcon, placeholderIcon,
@@ -25,14 +23,6 @@ const {
type: Array, type: Array,
required: true, required: true,
}, },
asyncSearch: {
type: Boolean,
default: false,
},
isSearching: {
type: Boolean,
default: false,
},
disableSearch: { disableSearch: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -63,12 +53,6 @@ const {
}, },
}); });
const emit = defineEmits(['search']);
// the input is re-inserted on every dropdown open (v-if),
// where the native autofocus attribute is ignored so focus it via a directive instead
const vFocus = { mounted: el => el.focus() };
const { t } = useI18n(); const { t } = useI18n();
const selected = defineModel({ const selected = defineModel({
type: Object, type: Object,
@@ -76,9 +60,7 @@ const selected = defineModel({
}); });
const searchTerm = ref(''); const searchTerm = ref('');
const searchResults = computed(() => { const searchResults = computed(() => {
if (asyncSearch) return options;
if (!options) return []; if (!options) return [];
return picoSearch(options, searchTerm.value, ['name']); return picoSearch(options, searchTerm.value, ['name']);
}); });
@@ -95,11 +77,7 @@ const selectedItem = computed(() => {
if (!optionToSearch) return null; if (!optionToSearch) return null;
// extract the selected item from the options array // extract the selected item from the options array
// this ensures that options like icon is also included // this ensures that options like icon is also included
return ( return options.find(option => option.id === optionToSearch.id);
options.find(option => option.id === optionToSearch.id) ||
// async options may not include the selected option, fall back to it
(asyncSearch && optionToSearch.id !== undefined ? optionToSearch : null)
);
}); });
const toggleSelected = option => { const toggleSelected = option => {
@@ -153,19 +131,13 @@ const toggleSelected = option => {
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" /> <Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
<input <input
v-model="searchTerm" v-model="searchTerm"
v-focus autofocus
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full" class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')" :placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
@input="emit('search', $event.target.value)"
/> />
</div> </div>
<DropdownSection :height="dropdownMaxHeight"> <DropdownSection :height="dropdownMaxHeight">
<template v-if="isSearching"> <template v-if="searchResults.length">
<DropdownItem disabled>
{{ t('DROPDOWN_MENU.SEARCHING') }}
</DropdownItem>
</template>
<template v-else-if="searchResults.length">
<DropdownItem <DropdownItem
v-for="option in searchResults" v-for="option in searchResults"
:key="option.id" :key="option.id"
@@ -3,7 +3,6 @@ import { useI18n } from 'vue-i18n';
import { useOperators } from './operators'; import { useOperators } from './operators';
import { useMapGetter } from 'dashboard/composables/store.js'; import { useMapGetter } from 'dashboard/composables/store.js';
import { useChannelIcon } from 'next/icon/provider'; import { useChannelIcon } from 'next/icon/provider';
import { createContactSearcher } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import { import {
buildAttributesFilterTypes, buildAttributesFilterTypes,
CONVERSATION_ATTRIBUTES, CONVERSATION_ATTRIBUTES,
@@ -31,7 +30,7 @@ import languages from 'dashboard/components/widgets/conversation/advancedFilterI
* @property {string} value - This is a proxy for the attribute key used in FilterSelect * @property {string} value - This is a proxy for the attribute key used in FilterSelect
* @property {string} attributeName - The attribute name used to display on the UI * @property {string} attributeName - The attribute name used to display on the UI
* @property {string} label - This is a proxy for the attribute name used in FilterSelect * @property {string} label - This is a proxy for the attribute name used in FilterSelect
* @property {'multiSelect'|'searchSelect'|'asyncSearchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute * @property {'multiSelect'|'searchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
* @property {FilterOption[]} [options] - The options available for the attribute if it is a multiSelect or singleSelect type * @property {FilterOption[]} [options] - The options available for the attribute if it is a multiSelect or singleSelect type
* @property {'text'|'number'} dataType * @property {'text'|'number'} dataType
* @property {FilterOperator[]} filterOperators - The operators available for the attribute * @property {FilterOperator[]} filterOperators - The operators available for the attribute
@@ -69,30 +68,6 @@ export function useConversationFilterContext() {
getOperatorTypes, getOperatorTypes,
} = useOperators(); } = useOperators();
const searchContacts = createContactSearcher();
const contactOptionName = contact =>
contact.name ||
contact.email ||
contact.phoneNumber ||
contact.identifier ||
t('FILTER.CONTACT_FALLBACK', { id: contact.id });
const searchContactOptions = async query => {
const contacts = await searchContacts(query, {
skipMinLength: true,
reachableOnly: false,
});
// null means the request was aborted (a newer search is in-flight)
if (contacts === null) return null;
return contacts.map(contact => ({
id: contact.id,
name: contactOptionName(contact),
}));
};
/** /**
* @type {import('vue').ComputedRef<FilterType[]>} * @type {import('vue').ComputedRef<FilterType[]>}
*/ */
@@ -183,18 +158,6 @@ export function useConversationFilterContext() {
filterOperators: presenceOperators.value, filterOperators: presenceOperators.value,
attributeModel: 'standard', attributeModel: 'standard',
}, },
{
attributeKey: CONVERSATION_ATTRIBUTES.CONTACT_ID,
value: CONVERSATION_ATTRIBUTES.CONTACT_ID,
attributeName: t('FILTER.ATTRIBUTES.CONTACT'),
label: t('FILTER.ATTRIBUTES.CONTACT'),
inputType: 'asyncSearchSelect',
searchOptions: searchContactOptions,
searchPlaceholder: t('FILTER.CONTACT_SEARCH_PLACEHOLDER'),
dataType: 'number',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
{ {
attributeKey: CONVERSATION_ATTRIBUTES.DISPLAY_ID, attributeKey: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
value: CONVERSATION_ATTRIBUTES.DISPLAY_ID, value: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
@@ -31,7 +31,6 @@ import FileBubble from './bubbles/File.vue';
import AudioBubble from './bubbles/Audio.vue'; import AudioBubble from './bubbles/Audio.vue';
import VideoBubble from './bubbles/Video.vue'; import VideoBubble from './bubbles/Video.vue';
import EmbedBubble from './bubbles/Embed.vue'; import EmbedBubble from './bubbles/Embed.vue';
import FallbackBubble from './bubbles/Fallback.vue';
import InstagramStoryBubble from './bubbles/InstagramStory.vue'; import InstagramStoryBubble from './bubbles/InstagramStory.vue';
import EmailBubble from './bubbles/Email/Index.vue'; import EmailBubble from './bubbles/Email/Index.vue';
import UnsupportedBubble from './bubbles/Unsupported.vue'; import UnsupportedBubble from './bubbles/Unsupported.vue';
@@ -329,8 +328,6 @@ const componentToRender = computed(() => {
if (Array.isArray(props.attachments) && props.attachments.length === 1) { if (Array.isArray(props.attachments) && props.attachments.length === 1) {
const fileType = props.attachments[0].fileType; const fileType = props.attachments[0].fileType;
if (fileType === ATTACHMENT_TYPES.FALLBACK) return FallbackBubble;
if (!props.content) { if (!props.content) {
if (fileType === ATTACHMENT_TYPES.IMAGE) return ImageBubble; if (fileType === ATTACHMENT_TYPES.IMAGE) return ImageBubble;
if (fileType === ATTACHMENT_TYPES.FILE) return FileBubble; if (fileType === ATTACHMENT_TYPES.FILE) return FileBubble;
@@ -1,37 +0,0 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import FormattedContent from './Text/FormattedContent.vue';
import { useMessageContext } from '../provider.js';
const { attachments, content } = useMessageContext();
const attachment = computed(() => attachments.value?.[0] || {});
const url = computed(
() => attachment.value.dataUrl || attachment.value.data_url
);
const title = computed(
() =>
attachment.value.fallbackTitle ||
attachment.value.fallback_title ||
url.value
);
</script>
<template>
<BaseBubble class="p-3" data-bubble-name="fallback">
<FormattedContent v-if="content" :content="content" class="mb-2" />
<a
v-if="url"
:href="url"
target="_blank"
rel="noopener noreferrer"
class="block max-w-[320px] truncate text-sm text-n-brand underline"
>
{{ title }}
</a>
<span v-else class="text-sm text-n-slate-11">
{{ title }}
</span>
</BaseBubble>
</template>
@@ -145,6 +145,7 @@ const allowedMenuItems = computed(() => {
:src="currentUser.avatar_url" :src="currentUser.avatar_url"
:status="currentUserAvailability" :status="currentUserAvailability"
class="flex-shrink-0" class="flex-shrink-0"
rounded-full
/> />
<div v-if="!isCollapsed" class="min-w-0"> <div v-if="!isCollapsed" class="min-w-0">
<div class="text-sm font-medium leading-4 truncate text-n-slate-12"> <div class="text-sm font-medium leading-4 truncate text-n-slate-12">
@@ -151,9 +151,6 @@ const activeFolder = computed(() => {
return undefined; return undefined;
}); });
const getContact = useMapGetter('contacts/getContact');
const folderContactId = useMapGetter('customViews/getActiveFolderContactId');
const activeFolderName = computed(() => { const activeFolderName = computed(() => {
return activeFolder.value?.name; return activeFolder.value?.name;
}); });
@@ -459,7 +456,6 @@ function setParamsForEditFolderModal() {
inboxes: inboxesList.value, inboxes: inboxesList.value,
labels: labels.value, labels: labels.value,
campaigns: campaigns.value, campaigns: campaigns.value,
contacts: [getContact.value(folderContactId.value)],
languages: languages, languages: languages,
countries: countries, countries: countries,
priority: [ priority: [
@@ -14,11 +14,11 @@ const props = defineProps({
const emit = defineEmits(['removeAttachment']); const emit = defineEmits(['removeAttachment']);
const nonRecordedAudioAttachments = computed(() => { const nonRecordedAudioAttachments = computed(() => {
return props.attachments.filter(attachment => !attachment?.isVoiceMessage); return props.attachments.filter(attachment => !attachment?.isRecordedAudio);
}); });
const recordedAudioAttachments = computed(() => const recordedAudioAttachments = computed(() =>
props.attachments.filter(attachment => attachment.isVoiceMessage) props.attachments.filter(attachment => attachment.isRecordedAudio)
); );
const onRemoveAttachment = itemIndex => { const onRemoveAttachment = itemIndex => {
@@ -4,7 +4,7 @@ import { ref, onMounted, onUnmounted } from 'vue';
import WaveSurfer from 'wavesurfer.js'; import WaveSurfer from 'wavesurfer.js';
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js'; import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js';
import { format, intervalToDuration } from 'date-fns'; import { format, intervalToDuration } from 'date-fns';
import { convertAudio } from './utils/audioConversionUtils'; import { convertAudio } from './utils/mp3ConversionUtils';
const props = defineProps({ const props = defineProps({
audioRecordFormat: { audioRecordFormat: {
@@ -18,7 +18,6 @@ const emit = defineEmits([
'finishRecord', 'finishRecord',
'pause', 'pause',
'play', 'play',
'recordError',
]); ]);
const waveformContainer = ref(null); const waveformContainer = ref(null);
@@ -27,7 +26,6 @@ const record = ref(null);
const isRecording = ref(false); const isRecording = ref(false);
const isPlaying = ref(false); const isPlaying = ref(false);
const hasRecording = ref(false); const hasRecording = ref(false);
const recordedAudioUrl = ref(null);
const formatTimeProgress = time => { const formatTimeProgress = time => {
const duration = intervalToDuration({ start: 0, end: time }); const duration = intervalToDuration({ start: 0, end: time });
@@ -37,28 +35,6 @@ const formatTimeProgress = time => {
); );
}; };
const AUDIO_EXTENSION_MAP = {
'audio/ogg': 'ogg',
'audio/mp3': 'mp3',
'audio/mpeg': 'mp3',
'audio/wav': 'wav',
'audio/webm': 'webm',
};
const getRecordPluginOptions = audioFormat => {
const options = {
scrollingWaveform: true,
renderRecordedAudio: false,
};
if (
audioFormat === 'audio/ogg' &&
MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
) {
options.mimeType = 'audio/ogg;codecs=opus';
}
return options;
};
const initWaveSurfer = () => { const initWaveSurfer = () => {
wavesurfer.value = WaveSurfer.create({ wavesurfer.value = WaveSurfer.create({
container: waveformContainer.value, container: waveformContainer.value,
@@ -69,7 +45,10 @@ const initWaveSurfer = () => {
barGap: 1, barGap: 1,
barRadius: 2, barRadius: 2,
plugins: [ plugins: [
RecordPlugin.create(getRecordPluginOptions(props.audioRecordFormat)), RecordPlugin.create({
scrollingWaveform: true,
renderRecordedAudio: false,
}),
], ],
}); });
@@ -83,34 +62,21 @@ const initWaveSurfer = () => {
}); });
record.value.on('record-end', async blob => { record.value.on('record-end', async blob => {
try { const audioUrl = URL.createObjectURL(blob);
const audioBlob = await convertAudio(blob, props.audioRecordFormat); const audioBlob = await convertAudio(blob, props.audioRecordFormat);
// Use the converted blob's actual type, which may differ from the const fileName = `${getUuid()}.mp3`;
// requested format when the browser can't produce it (e.g. Safari falls const file = new File([audioBlob], fileName, {
// back to MP3 instead of OGG). This keeps the filename, content type, and type: props.audioRecordFormat,
// voice-note flag consistent with the real bytes. });
const audioType = audioBlob.type || props.audioRecordFormat; wavesurfer.value.load(audioUrl);
const ext = AUDIO_EXTENSION_MAP[audioType] || 'mp3'; emit('finishRecord', {
const fileName = `${getUuid()}.${ext}`; name: file.name,
const file = new File([audioBlob], fileName, { type: file.type,
type: audioType, size: file.size,
}); file,
if (recordedAudioUrl.value) URL.revokeObjectURL(recordedAudioUrl.value); });
recordedAudioUrl.value = URL.createObjectURL(audioBlob); hasRecording.value = true;
wavesurfer.value.load(recordedAudioUrl.value); isRecording.value = false;
emit('finishRecord', {
name: file.name,
type: file.type,
size: file.size,
file,
});
hasRecording.value = true;
isRecording.value = false;
} catch (error) {
isRecording.value = false;
hasRecording.value = false;
emit('recordError', { error });
}
}); });
record.value.on('record-progress', time => { record.value.on('record-progress', time => {
@@ -143,10 +109,6 @@ onMounted(() => {
}); });
onUnmounted(() => { onUnmounted(() => {
if (recordedAudioUrl.value) {
URL.revokeObjectURL(recordedAudioUrl.value);
recordedAudioUrl.value = null;
}
if (wavesurfer.value) { if (wavesurfer.value) {
wavesurfer.value.destroy(); wavesurfer.value.destroy();
} }
@@ -32,6 +32,7 @@ import {
CONVERSATION_EVENTS, CONVERSATION_EVENTS,
CAPTAIN_EVENTS, CAPTAIN_EVENTS,
} from 'dashboard/helper/AnalyticsHelper/events'; } from 'dashboard/helper/AnalyticsHelper/events';
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
import { import {
messageSchema, messageSchema,
@@ -42,7 +43,6 @@ import {
MessageMarkdownSerializer, MessageMarkdownSerializer,
EditorState, EditorState,
Selection, Selection,
imageResizeView,
} from '@chatwoot/prosemirror-schema'; } from '@chatwoot/prosemirror-schema';
import { import {
suggestionsPlugin, suggestionsPlugin,
@@ -57,6 +57,7 @@ import {
insertAtCursor, insertAtCursor,
removeSignature as removeSignatureHelper, removeSignature as removeSignatureHelper,
scrollCursorIntoView, scrollCursorIntoView,
setURLWithQueryAndSize,
getFormattingForEditor, getFormattingForEditor,
getSelectionCoords, getSelectionCoords,
calculateMenuPosition, calculateMenuPosition,
@@ -71,7 +72,6 @@ import {
import { createTypingIndicator } from '@chatwoot/utils'; import { createTypingIndicator } from '@chatwoot/utils';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper'; import { uploadFile } from 'dashboard/helper/uploadHelper';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
const props = defineProps({ const props = defineProps({
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
@@ -93,6 +93,7 @@ const props = defineProps({
channelType: { type: String, default: '' }, channelType: { type: String, default: '' },
conversationId: { type: Number, default: null }, conversationId: { type: Number, default: null },
medium: { type: String, default: '' }, medium: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
focusOnMount: { type: Boolean, default: true }, focusOnMount: { type: Boolean, default: true },
}); });
@@ -118,14 +119,6 @@ const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const DEFAULT_FORMATTING = 'Context::Default'; const DEFAULT_FORMATTING = 'Context::Default';
const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote'; const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote';
const MESSAGE_SIGNATURE_FORMATTING = 'Context::MessageSignature';
const INLINE_IMAGE_PASTE_TYPES = [
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/webp',
];
const effectiveChannelType = computed(() => const effectiveChannelType = computed(() =>
getEffectiveChannelType(props.channelType, props.medium) getEffectiveChannelType(props.channelType, props.medium)
@@ -199,8 +192,12 @@ const cannedSearchTerm = ref('');
const variableSearchTerm = ref(''); const variableSearchTerm = ref('');
const emojiSearchTerm = ref(''); const emojiSearchTerm = ref('');
const range = ref(null); const range = ref(null);
const isImageNodeSelected = ref(false);
const toolbarPosition = ref({ top: 0, left: 0 });
const selectedImageNode = ref(null);
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
const showSelectionMenu = ref(false); const showSelectionMenu = ref(false);
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
// element ref // element ref
const editorRoot = useTemplateRef('editorRoot'); const editorRoot = useTemplateRef('editorRoot');
@@ -478,6 +475,16 @@ function removeSignature() {
reloadState(content); reloadState(content);
} }
function setToolbarPosition() {
const editorRect = editorRoot.value.getBoundingClientRect();
const rect = selectedImageNode.value.getBoundingClientRect();
toolbarPosition.value = {
top: `${rect.top - editorRect.top - 30}px`,
left: `${rect.left - editorRect.left - 4}px`,
};
}
function setMenubarPosition({ selection } = {}) { function setMenubarPosition({ selection } = {}) {
const wrapper = editorRoot.value; const wrapper = editorRoot.value;
if (!selection || !wrapper) return; if (!selection || !wrapper) return;
@@ -513,6 +520,30 @@ function checkSelection(editorState) {
if (hasSelection) setMenubarPosition(editorState); if (hasSelection) setMenubarPosition(editorState);
} }
function setURLWithQueryAndImageSize(size) {
if (!props.showImageResizeToolbar) {
return;
}
setURLWithQueryAndSize(selectedImageNode.value, size, editorView);
isImageNodeSelected.value = false;
}
function isEditorMouseFocusedOnAnImage() {
if (!props.showImageResizeToolbar) {
return;
}
selectedImageNode.value = document.querySelector(
'img.ProseMirror-selectednode'
);
if (selectedImageNode.value) {
isImageNodeSelected.value = !!selectedImageNode.value;
// Get the position of the selected node
setToolbarPosition();
} else {
isImageNodeSelected.value = false;
}
}
function emitOnChange() { function emitOnChange() {
emit('input', contentFromEditor()); emit('input', contentFromEditor());
emit('update:modelValue', contentFromEditor()); emit('update:modelValue', contentFromEditor());
@@ -532,6 +563,21 @@ function toggleSignatureInEditor(signatureEnabled) {
emitOnChange(); emitOnChange();
} }
function updateImgToolbarOnDelete() {
// check if the selected node is present or not on keyup
// this is needed because the user can select an image and then delete it
// in that case, the selected node will be null and we need to hide the toolbar
// otherwise, the toolbar will be visible even when the image is deleted and cause some errors
if (selectedImageNode.value) {
const hasImgSelectedNode = document.querySelector(
'img.ProseMirror-selectednode'
);
if (!hasImgSelectedNode) {
isImageNodeSelected.value = false;
}
}
}
function isEnterToSendEnabled() { function isEnterToSendEnabled() {
return isEditorHotKeyEnabled('enter'); return isEditorHotKeyEnabled('enter');
} }
@@ -540,6 +586,17 @@ function isCmdPlusEnterToSendEnabled() {
return isEditorHotKeyEnabled('cmd_enter'); return isEditorHotKeyEnabled('cmd_enter');
} }
useKeyboardEvents({
'Alt+KeyP': {
action: focusEditorInputField,
allowOnFocusedInput: false,
},
'Alt+KeyL': {
action: focusEditorInputField,
allowOnFocusedInput: false,
},
});
function onImageInsertInEditor(fileUrl) { function onImageInsertInEditor(fileUrl) {
const { tr } = editorView.state; const { tr } = editorView.state;
@@ -560,11 +617,7 @@ async function uploadImageToStorage(file) {
onImageInsertInEditor(fileUrl); onImageInsertInEditor(fileUrl);
} }
useAlert( useAlert(
props.channelType === MESSAGE_SIGNATURE_FORMATTING t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS')
? t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS'
)
: t('CONVERSATION.REPLYBOX.IMAGE_UPLOAD_SUCCESS')
); );
} catch (error) { } catch (error) {
useAlert( useAlert(
@@ -573,8 +626,8 @@ async function uploadImageToStorage(file) {
} }
} }
function uploadImageIfWithinSizeLimit(file) { function onFileChange() {
if (!file) return; const file = imageUpload.value.files[0];
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) { if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
uploadImageToStorage(file); uploadImageToStorage(file);
} else { } else {
@@ -587,61 +640,10 @@ function uploadImageIfWithinSizeLimit(file) {
) )
); );
} }
imageUpload.value = '';
} }
function onFileChange() {
const input = imageUpload.value;
uploadImageIfWithinSizeLimit(input.files[0]);
input.value = '';
}
const allowsInlineImagePaste = computed(
() =>
!props.isPrivate &&
(props.channelType === INBOX_TYPES.EMAIL ||
props.channelType === INBOX_TYPES.WEB)
);
// Shift+Cmd/Ctrl+V on email/website: upload a clipboard image inline. This
// gesture's native paste event carries no image, so clipboard.read() is the
// only way to get the bytes. No preventDefault: text still pastes natively.
async function pasteInlineImageFromClipboard() {
if (!editorView?.hasFocus()) return;
if (!allowsInlineImagePaste.value || !navigator.clipboard?.read) return;
try {
const items = await navigator.clipboard.read();
const imageItem = items.find(item =>
item.types.some(type => INLINE_IMAGE_PASTE_TYPES.includes(type))
);
if (!imageItem) return;
const imageType = imageItem.types.find(type =>
INLINE_IMAGE_PASTE_TYPES.includes(type)
);
const blob = await imageItem.getType(imageType);
uploadImageIfWithinSizeLimit(
new File([blob], 'pasted-image', { type: imageType })
);
} catch (error) {
// clipboard-read denied/unfocused (NotAllowedError): image can't be read.
// Text paste is unaffected — ProseMirror handles it from the native event.
}
}
useKeyboardEvents({
'Alt+KeyP': {
action: focusEditorInputField,
allowOnFocusedInput: false,
},
'Alt+KeyL': {
action: focusEditorInputField,
allowOnFocusedInput: false,
},
'$mod+Shift+KeyV': {
action: pasteInlineImageFromClipboard,
allowOnFocusedInput: true,
},
});
function handleLineBreakWhenEnterToSendEnabled(event) { function handleLineBreakWhenEnterToSendEnabled(event) {
if ( if (
hasPressedEnterAndNotCmdOrShift(event) && hasPressedEnterAndNotCmdOrShift(event) &&
@@ -734,9 +736,6 @@ function createEditorView() {
editorView = new EditorView(editor.value, { editorView = new EditorView(editor.value, {
state: state, state: state,
editable: () => !props.disabled, editable: () => !props.disabled,
nodeViews: {
image: imageResizeView,
},
dispatchTransaction: tx => { dispatchTransaction: tx => {
state = state.apply(tx); state = state.apply(tx);
editorView.updateState(state); editorView.updateState(state);
@@ -749,10 +748,12 @@ function createEditorView() {
keyup: () => { keyup: () => {
if (!props.disabled) { if (!props.disabled) {
typingIndicator.start(); typingIndicator.start();
updateImgToolbarOnDelete();
} }
}, },
keydown: (view, event) => !props.disabled && onKeydown(event), keydown: (view, event) => !props.disabled && onKeydown(event),
focus: () => !props.disabled && emit('focus'), focus: () => !props.disabled && emit('focus'),
click: () => !props.disabled && isEditorMouseFocusedOnAnImage(),
blur: () => { blur: () => {
if (props.disabled) return; if (props.disabled) return;
typingIndicator.stop(); typingIndicator.stop();
@@ -917,6 +918,23 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@change="onFileChange" @change="onFileChange"
/> />
<div ref="editor" /> <div ref="editor" />
<div
v-show="isImageNodeSelected && showImageResizeToolbar"
class="absolute shadow-md rounded-[6px] flex gap-1 py-1 px-1 bg-n-solid-3 outline outline-1 outline-n-weak text-n-slate-12"
:style="{
top: toolbarPosition.top,
left: toolbarPosition.left,
}"
>
<button
v-for="size in sizes"
:key="size.name"
class="text-xs font-medium rounded-[4px] outline outline-1 outline-n-strong px-1.5 py-0.5 hover:bg-n-slate-5"
@click="setURLWithQueryAndImageSize(size)"
>
{{ size.name }}
</button>
</div>
<slot name="footer" /> <slot name="footer" />
</div> </div>
</template> </template>
@@ -979,6 +997,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply text-n-slate-11; @apply text-n-slate-11;
} }
} }
ol li {
@apply list-item list-decimal;
}
} }
} }
@@ -1,7 +1,5 @@
import lamejs from '@breezystack/lamejs'; import lamejs from '@breezystack/lamejs';
import { remuxWebmToOgg } from './webmOpusToOgg';
const writeString = (view, offset, string) => { const writeString = (view, offset, string) => {
// eslint-disable-next-line no-plusplus // eslint-disable-next-line no-plusplus
for (let i = 0; i < string.length; i++) { for (let i = 0; i < string.length; i++) {
@@ -141,20 +139,6 @@ export const convertAudio = async (inputBlob, outputFormat, bitrate = 128) => {
audio = await convertToWav(inputBlob); audio = await convertToWav(inputBlob);
} else if (outputFormat === 'audio/mp3') { } else if (outputFormat === 'audio/mp3') {
audio = await convertToMp3(inputBlob, bitrate); audio = await convertToMp3(inputBlob, bitrate);
} else if (outputFormat === 'audio/ogg') {
const inputType = inputBlob.type.split(';')[0].trim();
if (inputType === 'audio/webm' || inputType === 'video/webm') {
audio = await remuxWebmToOgg(inputBlob);
} else if (inputType === 'audio/ogg') {
audio = inputBlob;
} else {
// Browsers that record neither WebM nor OGG (e.g. Safari records
// audio/mp4) cannot produce OGG/Opus. Fall back to MP3 so the recording
// still sends as a regular audio message instead of failing. The caller
// keys the voice-note flag off the returned blob type, so an MP3 result
// is never mislabeled as an OGG/Opus voice note.
audio = await convertToMp3(inputBlob, bitrate);
}
} else { } else {
throw new Error('Unsupported output format'); throw new Error('Unsupported output format');
} }
@@ -1,457 +0,0 @@
/* eslint-disable no-bitwise */
/**
* WebM/Opus → OGG/Opus remuxer
*
* Chrome's MediaRecorder produces WebM containers even when
* `audio/ogg;codecs=opus` is requested. WhatsApp Cloud API requires
* proper OGG/Opus files for voice messages.
*
* This module extracts raw Opus packets from the WebM (EBML) container
* and repackages them into a valid OGG bitstream. The audio data itself
* is never re-encoded — only the container format changes.
*
* References:
* EBML (container for WebM): RFC 8794 — https://www.rfc-editor.org/rfc/rfc8794
* Matroska/WebM elements: https://www.matroska.org/technical/elements.html
* OGG bitstream framing: RFC 3533 — https://www.rfc-editor.org/rfc/rfc3533
* Opus codec: RFC 6716 — https://www.rfc-editor.org/rfc/rfc6716
* Opus in OGG (OpusHead/Tags): RFC 7845 — https://www.rfc-editor.org/rfc/rfc7845
*/
// ======================== EBML / WebM parser ========================
const EBML_IDS = {
Segment: 0x18538067,
SegmentInfo: 0x1549a966,
Tracks: 0x1654ae6b,
TrackEntry: 0xae,
CodecPrivate: 0x63a2,
Audio: 0xe1,
SamplingFrequency: 0xb5,
Channels: 0x9f,
Cluster: 0x1f43b675,
Timecode: 0xe7,
SimpleBlock: 0xa3,
BlockGroup: 0xa0,
Block: 0xa1,
};
const MASTER_ELEMENTS = new Set([
0x1a45dfa3, // EBML header
EBML_IDS.Segment,
EBML_IDS.SegmentInfo,
EBML_IDS.Tracks,
EBML_IDS.TrackEntry,
EBML_IDS.Audio,
EBML_IDS.Cluster,
EBML_IDS.BlockGroup,
]);
/** Read an EBML variable-length integer (data size). */
function readVint(data, pos) {
if (pos >= data.length) return null;
const first = data[pos];
if (first === 0) return null;
let len = 1;
let mask = 0x80;
while (len <= 8 && !(first & mask)) {
len += 1;
mask >>= 1;
}
if (len > 8 || pos + len > data.length) return null;
let value = first & (mask - 1);
for (let i = 1; i < len; i += 1) {
value = value * 256 + data[pos + i];
}
return { value, length: len };
}
/** Read an EBML element ID (leading marker bits are kept). */
function readElementId(data, pos) {
if (pos >= data.length) return null;
const first = data[pos];
if (first === 0) return null;
let len = 1;
let mask = 0x80;
while (len <= 4 && !(first & mask)) {
len += 1;
mask >>= 1;
}
if (len > 4 || pos + len > data.length) return null;
let id = first;
for (let i = 1; i < len; i += 1) {
id = id * 256 + data[pos + i];
}
return { id, length: len };
}
function readUintBE(data, offset, length) {
let v = 0;
for (let i = 0; i < length; i += 1) v = v * 256 + data[offset + i];
return v;
}
function readFloatBE(data, offset, length) {
if (length !== 4 && length !== 8) return NaN;
const buf = new ArrayBuffer(length);
const u8 = new Uint8Array(buf);
for (let i = 0; i < length; i += 1) u8[i] = data[offset + i];
const view = new DataView(buf);
return length === 4 ? view.getFloat32(0) : view.getFloat64(0);
}
/** Extract the raw Opus frame from a SimpleBlock / Block element. */
function extractFrameFromBlock(data, offset, end) {
const trackVint = readVint(data, offset);
if (!trackVint) return null;
let pos = offset + trackVint.length;
// int16 relative timecode (big-endian, signed) skip
pos += 2;
// Flags byte skip. Lacing (Xiph/EBML/fixed-size) is NOT supported;
// this assumes single-frame blocks as produced by MediaRecorder.
const flags = data[pos];
const lacingBits = (flags >> 1) & 0x03;
if (lacingBits !== 0) {
// eslint-disable-next-line no-console
console.warn(
'webmOpusToOgg: laced SimpleBlock detected (unsupported), frame may be invalid'
);
}
pos += 1;
if (pos >= end) return null;
return data.slice(pos, end);
}
/**
* Walk the EBML tree and collect metadata + Opus frames.
* We only descend into master elements and only extract the fields we need.
*/
function parseWebM(buffer) {
const data = new Uint8Array(buffer);
const result = {
channels: 1,
sampleRate: 48000,
codecPrivate: null,
frames: [],
};
function walk(start, end) {
let pos = start;
while (pos < end) {
const idRes = readElementId(data, pos);
if (!idRes) break;
pos += idRes.length;
const sizeRes = readVint(data, pos);
if (!sizeRes) break;
pos += sizeRes.length;
// Handle "unknown size" (all-ones VINT) by treating it as the rest of the parent
// Use Math.pow instead of bit-shift to avoid 32-bit overflow for 5+ byte VINTs
const maxVint = 2 ** (7 * sizeRes.length) - 1;
const elEnd =
sizeRes.value === maxVint ? end : Math.min(pos + sizeRes.value, end);
if (MASTER_ELEMENTS.has(idRes.id)) {
walk(pos, elEnd);
} else {
switch (idRes.id) {
case EBML_IDS.Channels:
result.channels = readUintBE(data, pos, sizeRes.value);
break;
case EBML_IDS.SamplingFrequency:
result.sampleRate = readFloatBE(data, pos, sizeRes.value);
break;
case EBML_IDS.CodecPrivate:
result.codecPrivate = data.slice(pos, elEnd);
break;
case EBML_IDS.SimpleBlock:
case EBML_IDS.Block: {
const frame = extractFrameFromBlock(data, pos, elEnd);
if (frame && frame.length > 0) result.frames.push(frame);
break;
}
default:
break;
}
}
pos = elEnd;
}
}
walk(0, data.length);
return result;
}
// ======================== OGG writer ========================
/** OGG CRC-32 table (polynomial 0x04C11DB7). */
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let c = i << 24;
for (let j = 0; j < 8; j += 1) {
c = ((c << 1) ^ (c & 0x80000000 ? 0x04c11db7 : 0)) >>> 0;
}
t[i] = c;
}
return t;
})();
function oggCrc32(bytes) {
let crc = 0;
for (let i = 0; i < bytes.length; i += 1) {
crc = (CRC_TABLE[((crc >>> 24) ^ bytes[i]) & 0xff] ^ (crc << 8)) >>> 0;
}
return crc;
}
/**
* Build one OGG page.
*
* @param {number} headerType 0x02 = BOS, 0x04 = EOS, 0x00 = normal
* @param {number} granulePosition 48 kHz sample count
* @param {number} serialNumber logical stream id
* @param {number} pageSeq page sequence counter
* @param {Uint8Array[]} packets one or more complete Opus packets
*/
function createOggPage(
headerType,
granulePosition,
serialNumber,
pageSeq,
packets
) {
// Build the lacing / segment table
const segTable = [];
let dataLen = 0;
packets.forEach(pkt => {
let rem = pkt.length;
while (rem >= 255) {
segTable.push(255);
rem -= 255;
}
segTable.push(rem); // final segment (0 when pkt.length is a multiple of 255)
dataLen += pkt.length;
});
const hdrLen = 27 + segTable.length;
const page = new Uint8Array(hdrLen + dataLen);
const dv = new DataView(page.buffer);
// Capture pattern
page.set([0x4f, 0x67, 0x67, 0x53]); // "OggS"
page[4] = 0; // version
page[5] = headerType;
// Granule position (int64 LE)
dv.setUint32(6, granulePosition & 0xffffffff, true);
dv.setUint32(
10,
Math.floor(granulePosition / 0x100000000) & 0xffffffff,
true
);
dv.setUint32(14, serialNumber, true); // serial
dv.setUint32(18, pageSeq, true); // page sequence
dv.setUint32(22, 0, true); // CRC placeholder
page[26] = segTable.length;
for (let i = 0; i < segTable.length; i += 1) page[27 + i] = segTable[i];
let off = hdrLen;
packets.forEach(pkt => {
page.set(pkt, off);
off += pkt.length;
});
// Fill in the CRC
dv.setUint32(22, oggCrc32(page), true);
return page;
}
// ======================== Opus helpers ========================
/** Lookup table: frame duration in ms for each Opus TOC config index (0-31). */
const OPUS_FRAME_MS = [
10,
20,
40,
60, // 0-3 SILK NB
10,
20,
40,
60, // 4-7 SILK MB
10,
20,
40,
60, // 8-11 SILK WB
10,
20, // 12-13 Hybrid SWB
10,
20, // 14-15 Hybrid FB
2.5,
5,
10,
20, // 16-19 CELT NB
2.5,
5,
10,
20, // 20-23 CELT WB
2.5,
5,
10,
20, // 24-27 CELT SWB
2.5,
5,
10,
20, // 28-31 CELT FB
];
/** Return the total number of 48 kHz PCM samples represented by an Opus packet. */
function opusPacketSamples(pkt) {
if (!pkt || pkt.length === 0) return 960; // default 20 ms
const toc = pkt[0];
const config = (toc >> 3) & 0x1f;
const code = toc & 0x03;
const samplesPerFrame = ((OPUS_FRAME_MS[config] || 20) * 48000) / 1000;
let frameCount;
if (code <= 1) frameCount = code + 1;
else if (code === 2) frameCount = 2;
else frameCount = pkt.length >= 2 ? pkt[1] & 0x3f : 1;
return samplesPerFrame * frameCount;
}
function buildOpusHead(channels, sampleRate, preSkip) {
const buf = new Uint8Array(19);
const dv = new DataView(buf.buffer);
buf.set(new TextEncoder().encode('OpusHead'));
buf[8] = 1; // version
buf[9] = channels;
dv.setUint16(10, preSkip, true);
dv.setUint32(12, sampleRate, true);
dv.setInt16(16, 0, true); // output gain
buf[18] = 0; // channel mapping family
return buf;
}
function buildOpusTags() {
const vendor = new TextEncoder().encode('chatwoot');
const buf = new Uint8Array(8 + 4 + vendor.length + 4);
const dv = new DataView(buf.buffer);
buf.set(new TextEncoder().encode('OpusTags'));
dv.setUint32(8, vendor.length, true);
buf.set(vendor, 12);
dv.setUint32(12 + vendor.length, 0, true); // 0 user comments
return buf;
}
// ======================== Public API ========================
const MAX_FRAMES_PER_PAGE = 50; // ~1 s at 20 ms/frame
const MAX_SEGMENTS_PER_PAGE = 255;
/**
* Remux a WebM/Opus blob into an OGG/Opus blob.
* If the input is already OGG (starts with "OggS"), it is returned as-is.
*
* @param {Blob} webmBlob
* @returns {Promise<Blob>} OGG/Opus blob
*/
export async function remuxWebmToOgg(webmBlob) {
const buffer = await webmBlob.arrayBuffer();
const bytes = new Uint8Array(buffer);
// Already OGG? Return unchanged.
if (
bytes.length >= 4 &&
bytes[0] === 0x4f &&
bytes[1] === 0x67 &&
bytes[2] === 0x67 &&
bytes[3] === 0x53
) {
return webmBlob;
}
const { channels, sampleRate, codecPrivate, frames } = parseWebM(buffer);
if (frames.length === 0) {
throw new Error('No Opus frames found in WebM input');
}
// Extract pre-skip from the WebM CodecPrivate (which IS the OpusHead)
let preSkip = 312;
if (codecPrivate && codecPrivate.length >= 12) {
const magic = new TextDecoder().decode(codecPrivate.slice(0, 8));
if (magic === 'OpusHead') {
preSkip = new DataView(
codecPrivate.buffer,
codecPrivate.byteOffset,
codecPrivate.length
).getUint16(10, true);
}
}
const serial = (Math.random() * 0x100000000) >>> 0;
let pageSeq = 0;
const pages = [];
// Page 0 OpusHead (BOS)
pages.push(
createOggPage(0x02, 0, serial, pageSeq, [
buildOpusHead(channels, sampleRate, preSkip),
])
);
pageSeq += 1;
// Page 1 OpusTags
pages.push(createOggPage(0x00, 0, serial, pageSeq, [buildOpusTags()]));
pageSeq += 1;
// Audio pages
let granule = 0;
let idx = 0;
while (idx < frames.length) {
const packets = [];
let segs = 0;
while (idx < frames.length && packets.length < MAX_FRAMES_PER_PAGE) {
const pkt = frames[idx];
// createOggPage always appends a terminating lacing value, so a packet
// spans floor(len/255)+1 segments (including the extra 0 when len is an
// exact multiple of 255). Math.ceil would undercount those cases.
const pktSegs = Math.floor(pkt.length / 255) + 1;
if (segs + pktSegs > MAX_SEGMENTS_PER_PAGE && packets.length > 0) break;
packets.push(pkt);
segs += pktSegs;
granule += opusPacketSamples(pkt);
idx += 1;
}
const isLast = idx >= frames.length;
pages.push(
createOggPage(isLast ? 0x04 : 0x00, granule, serial, pageSeq, packets)
);
pageSeq += 1;
}
// Concatenate pages into a single buffer
const total = pages.reduce((s, p) => s + p.length, 0);
const out = new Uint8Array(total);
let off = 0;
pages.forEach(p => {
out.set(p, off);
off += p.length;
});
return new Blob([out], { type: 'audio/ogg' });
}
@@ -124,6 +124,7 @@ const copyConversationId = async () => {
:size="32" :size="32"
:status="currentContact.availability_status" :status="currentContact.availability_status"
hide-offline-status hide-offline-status
rounded-full
/> />
<div <div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2" class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
@@ -375,9 +375,6 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`; return `draft-${this.conversationIdByRoute}-${this.replyType}`;
}, },
audioRecordFormat() { audioRecordFormat() {
if (this.isAWhatsAppCloudChannel) {
return AUDIO_FORMATS.OGG;
}
if (this.isAWhatsAppChannel || this.isATelegramChannel) { if (this.isAWhatsAppChannel || this.isATelegramChannel) {
return AUDIO_FORMATS.MP3; return AUDIO_FORMATS.MP3;
} }
@@ -1011,18 +1008,14 @@ export default {
onFinishRecorder(file) { onFinishRecorder(file) {
this.recordingAudioState = 'stopped'; this.recordingAudioState = 'stopped';
this.hasRecordedAudio = true; this.hasRecordedAudio = true;
// Added a new key isVoiceMessage to the file to identify recorded audio // Added a new key isRecordedAudio to the file to find it's and recorded audio
// Because to filter and show only non recorded audio and other attachments // Because to filter and show only non recorded audio and other attachments
const autoRecordedFile = { const autoRecordedFile = {
...file, ...file,
isVoiceMessage: true, isRecordedAudio: true,
}; };
return file && this.onFileUpload(autoRecordedFile); return file && this.onFileUpload(autoRecordedFile);
}, },
onRecordError() {
this.toggleAudioRecorder();
useAlert(this.$t('CONVERSATION.REPLYBOX.AUDIO_CONVERSION_FAILED'));
},
toggleTyping(status) { toggleTyping(status) {
const conversationId = this.currentChat.id; const conversationId = this.currentChat.id;
const isPrivate = this.isPrivate; const isPrivate = this.isPrivate;
@@ -1049,7 +1042,7 @@ export default {
isPrivate: this.isPrivate, isPrivate: this.isPrivate,
thumb: reader.result, thumb: reader.result,
blobSignedId: blob ? blob.signed_id : undefined, blobSignedId: blob ? blob.signed_id : undefined,
isVoiceMessage: file?.isVoiceMessage || false, isRecordedAudio: file?.isRecordedAudio || false,
}); });
}; };
}, },
@@ -1085,7 +1078,6 @@ export default {
private: false, private: false,
message: caption, message: caption,
sender: this.sender, sender: this.sender,
isVoiceMessage: attachment.isVoiceMessage || false,
}; };
attachmentPayload = this.setReplyToInPayload(attachmentPayload); attachmentPayload = this.setReplyToInPayload(attachmentPayload);
@@ -1135,9 +1127,6 @@ export default {
this.attachedFiles.forEach(attachment => { this.attachedFiles.forEach(attachment => {
if (this.globalConfig.directUploadsEnabled) { if (this.globalConfig.directUploadsEnabled) {
messagePayload.files.push(attachment.blobSignedId); messagePayload.files.push(attachment.blobSignedId);
if (attachment.isVoiceMessage) {
messagePayload.isVoiceMessage = true;
}
} else { } else {
messagePayload.files.push(attachment.resource.file); messagePayload.files.push(attachment.resource.file);
} }
@@ -1226,7 +1215,7 @@ export default {
this.hasRecordedAudio = false; this.hasRecordedAudio = false;
// Only clear the recorded audio when we click toggle button. // Only clear the recorded audio when we click toggle button.
this.attachedFiles = this.attachedFiles.filter( this.attachedFiles = this.attachedFiles.filter(
file => !file?.isVoiceMessage file => !file?.isRecordedAudio
); );
}, },
toggleEditorSize() { toggleEditorSize() {
@@ -1304,7 +1293,6 @@ export default {
:audio-record-format="audioRecordFormat" :audio-record-format="audioRecordFormat"
@recorder-progress-changed="onRecordProgressChanged" @recorder-progress-changed="onRecordProgressChanged"
@finish-record="onFinishRecorder" @finish-record="onFinishRecorder"
@record-error="onRecordError"
@play="recordingAudioState = 'playing'" @play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'" @pause="recordingAudioState = 'paused'"
/> />
@@ -46,14 +46,6 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_2, filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard', attributeModel: 'standard',
}, },
{
attributeKey: 'contact_id',
attributeI18nKey: 'CONTACT',
inputType: 'search_select',
dataType: 'number',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'standard',
},
{ {
attributeKey: 'display_id', attributeKey: 'display_id',
attributeI18nKey: 'CONVERSATION_IDENTIFIER', attributeI18nKey: 'CONVERSATION_IDENTIFIER',
@@ -141,10 +133,6 @@ export const filterAttributeGroups = [
key: 'team_id', key: 'team_id',
i18nKey: 'TEAM_NAME', i18nKey: 'TEAM_NAME',
}, },
{
key: 'contact_id',
i18nKey: 'CONTACT',
},
{ {
key: 'display_id', key: 'display_id',
i18nKey: 'CONVERSATION_IDENTIFIER', i18nKey: 'CONVERSATION_IDENTIFIER',
@@ -10,12 +10,10 @@ vi.mock('shared/helpers/mitt', () => ({
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => { vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal(); const actual = await importOriginal();
return { actual.default = {
...actual, track: vi.fn(),
default: {
track: vi.fn(),
},
}; };
return actual;
}); });
describe('useTrack', () => { describe('useTrack', () => {
@@ -16,12 +16,10 @@ vi.mock('vue-i18n');
vi.mock('dashboard/api/captain/tasks'); vi.mock('dashboard/api/captain/tasks');
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => { vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal(); const actual = await importOriginal();
return { actual.default = {
...actual, track: vi.fn(),
default: {
track: vi.fn(),
},
}; };
return actual;
}); });
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({ vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
CAPTAIN_EVENTS: { CAPTAIN_EVENTS: {
@@ -1,148 +0,0 @@
import { useFacebookPageConnect } from '../useFacebookPageConnect';
import { useMapGetter } from 'dashboard/composables/store';
import ChannelApi from 'dashboard/api/channels';
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
vi.mock('dashboard/composables/store', () => ({ useMapGetter: vi.fn() }));
vi.mock('dashboard/api/channels', () => ({
default: { fetchFacebookPages: vi.fn() },
}));
vi.mock(
'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils',
() => ({ setupFacebookSdk: vi.fn() })
);
const flushPromises = () =>
new Promise(resolve => {
setTimeout(resolve, 0);
});
const createDeferred = () => {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const ACCOUNT_ID = 42;
const PAGES = [
{ id: 'p1', name: 'Page One', access_token: 'page-token-1' },
{ id: 'p2', name: 'Page Two', access_token: 'page-token-2', exists: true },
];
const pagesResponse = {
data: { data: { page_details: PAGES, user_access_token: 'long-token' } },
};
// FB.login invokes its callback with the given response.
const stubLogin = response => {
window.FB = { login: vi.fn(callback => callback(response)) };
};
const connected = {
status: 'connected',
authResponse: { accessToken: 'user-token' },
};
describe('useFacebookPageConnect', () => {
beforeEach(() => {
vi.clearAllMocks();
window.chatwootConfig = { fbAppId: 'fb-app', fbApiVersion: 'v22.0' };
useMapGetter.mockReturnValue({ value: ACCOUNT_ID });
setupFacebookSdk.mockResolvedValue();
ChannelApi.fetchFacebookPages.mockResolvedValue(pagesResponse);
stubLogin(connected);
});
it('resolves the user token and pages on a connected login', async () => {
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).resolves.toEqual({
userAccessToken: 'long-token',
pages: PAGES,
});
expect(setupFacebookSdk).toHaveBeenCalledWith('fb-app', 'v22.0');
expect(ChannelApi.fetchFacebookPages).toHaveBeenCalledWith(
'user-token',
ACCOUNT_ID
);
expect(window.FB.login).toHaveBeenCalledWith(expect.any(Function), {
scope: expect.stringContaining('pages_show_list'),
});
});
it('resolves null when the user is not authorized', async () => {
stubLogin({ status: 'not_authorized' });
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).resolves.toBeNull();
expect(ChannelApi.fetchFacebookPages).not.toHaveBeenCalled();
});
it('resolves null on an unknown login status', async () => {
stubLogin({ status: 'unknown' });
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).resolves.toBeNull();
});
it('rejects when fetching pages fails', async () => {
ChannelApi.fetchFacebookPages.mockRejectedValue(new Error('fetch failed'));
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).rejects.toThrow('fetch failed');
});
it('rejects when the SDK fails to load', async () => {
const error = new Error('script load failed');
error.name = 'ScriptLoaderError';
setupFacebookSdk.mockRejectedValue(error);
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).rejects.toThrow('script load failed');
});
it('ignores a second call while a run is in flight', async () => {
const pending = createDeferred();
ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise);
const { loginAndFetchPages } = useFacebookPageConnect();
const first = loginAndFetchPages();
const second = loginAndFetchPages();
await expect(second).resolves.toBeNull();
pending.resolve(pagesResponse);
await first;
expect(window.FB.login).toHaveBeenCalledTimes(1);
});
it('toggles isAuthenticating across a run', async () => {
const pending = createDeferred();
ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise);
const { isAuthenticating, loginAndFetchPages } = useFacebookPageConnect();
expect(isAuthenticating.value).toBe(false);
const result = loginAndFetchPages();
await flushPromises();
expect(isAuthenticating.value).toBe(true);
pending.resolve(pagesResponse);
await result;
expect(isAuthenticating.value).toBe(false);
});
it('preloads the SDK once and reuses it for login', async () => {
const { preloadSdk, loginAndFetchPages } = useFacebookPageConnect();
preloadSdk();
preloadSdk();
await loginAndFetchPages();
expect(setupFacebookSdk).toHaveBeenCalledTimes(1);
});
});
@@ -1,208 +0,0 @@
import { useWhatsappEmbeddedSignup } from '../useWhatsappEmbeddedSignup';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
vi.mock(
'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils',
() => ({
setupFacebookSdk: vi.fn(),
initWhatsAppEmbeddedSignup: vi.fn(),
createMessageHandler: vi.fn(),
isValidBusinessData: vi.fn(data =>
Boolean(data && data.business_id && data.waba_id)
),
})
);
const flushPromises = () =>
new Promise(resolve => {
setTimeout(resolve, 0);
});
const createDeferred = () => {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const VALID_BUSINESS = {
business_id: 'biz-1',
waba_id: 'waba-1',
phone_number_id: 'phone-1',
};
describe('useWhatsappEmbeddedSignup', () => {
// The mocked createMessageHandler captures the callback the composable
// registers, so tests can simulate Meta's WA_EMBEDDED_SIGNUP postMessages
// directly without the window-event + origin plumbing (that is covered by
// the utils' own tests).
let signupCallback;
let registeredListener;
const emit = data => signupCallback(data);
beforeEach(() => {
vi.clearAllMocks();
window.chatwootConfig = {
whatsappAppId: 'app-id',
whatsappConfigurationId: 'config-id',
whatsappApiVersion: 'v22.0',
};
setupFacebookSdk.mockResolvedValue();
createMessageHandler.mockImplementation(callback => {
signupCallback = callback;
registeredListener = () => {};
return registeredListener;
});
});
it('resolves credentials when the auth code arrives before the business data', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
await flushPromises(); // SDK setup + FB.login resolve the code first
emit({ event: 'FINISH', data: VALID_BUSINESS });
await expect(result).resolves.toEqual({
code: 'auth-code',
business_id: 'biz-1',
waba_id: 'waba-1',
phone_number_id: 'phone-1',
});
expect(setupFacebookSdk).toHaveBeenCalledWith('app-id', 'v22.0');
expect(initWhatsAppEmbeddedSignup).toHaveBeenCalledWith('config-id');
});
it('resolves credentials when the business data arrives before the auth code', async () => {
const code = createDeferred();
initWhatsAppEmbeddedSignup.mockReturnValue(code.promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
// Business data lands first, while FB.login is still pending.
emit({
event: 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING',
data: VALID_BUSINESS,
});
code.resolve('late-code');
await expect(result).resolves.toEqual({
code: 'late-code',
business_id: 'biz-1',
waba_id: 'waba-1',
phone_number_id: 'phone-1',
});
});
it('defaults phone_number_id to an empty string when absent', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
await flushPromises();
emit({
event: 'FINISH',
data: { business_id: 'biz-1', waba_id: 'waba-1' },
});
await expect(result).resolves.toMatchObject({ phone_number_id: '' });
});
it('resolves null when FB.login is cancelled', async () => {
initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('Login cancelled'));
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
await expect(runEmbeddedSignup()).resolves.toBeNull();
});
it('resolves null on a CANCEL event', async () => {
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
emit({ event: 'CANCEL' });
await expect(result).resolves.toBeNull();
});
it('rejects with the Meta error message on an error event', async () => {
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
emit({ event: 'error', error_message: 'WABA not eligible' });
await expect(result).rejects.toThrow('WABA not eligible');
});
it('rejects when the business data is invalid', async () => {
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
emit({ event: 'FINISH', data: { business_id: 'biz-1' } }); // no waba_id
await expect(result).rejects.toThrow('Invalid business data');
});
it('rejects when the SDK or login fails for a non-cancel reason', async () => {
initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('popup blocked'));
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
await expect(runEmbeddedSignup()).rejects.toThrow('popup blocked');
});
it('ignores a second call while a run is in flight', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const first = runEmbeddedSignup();
const second = runEmbeddedSignup();
await expect(second).resolves.toBeNull();
// Let the first run finish so it doesn't leak into the next test.
await flushPromises();
emit({ event: 'FINISH', data: VALID_BUSINESS });
await first;
expect(setupFacebookSdk).toHaveBeenCalledTimes(1);
});
it('toggles isAuthenticating and removes the listener once settled', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const removeSpy = vi.spyOn(window, 'removeEventListener');
const { isAuthenticating, runEmbeddedSignup } = useWhatsappEmbeddedSignup();
expect(isAuthenticating.value).toBe(false);
const result = runEmbeddedSignup();
expect(isAuthenticating.value).toBe(true);
await flushPromises();
emit({ event: 'FINISH', data: VALID_BUSINESS });
await result;
expect(isAuthenticating.value).toBe(false);
expect(removeSpy).toHaveBeenCalledWith('message', registeredListener);
removeSpy.mockRestore();
});
});
@@ -52,10 +52,6 @@ export function useAccount() {
}); });
}; };
const finishOnboarding = async data => {
await store.dispatch('accounts/finishOnboarding', data);
};
return { return {
accountId, accountId,
route, route,
@@ -65,6 +61,5 @@ export function useAccount() {
isCloudFeatureEnabled, isCloudFeatureEnabled,
isOnChatwootCloud, isOnChatwootCloud,
updateAccount, updateAccount,
finishOnboarding,
}; };
} }
@@ -1,81 +0,0 @@
import { ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import ChannelApi from 'dashboard/api/channels';
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
// Page-management + messaging scopes required to list pages and create a
// Channel::FacebookPage inbox (mirrors the standalone settings flow).
const FB_PAGE_SCOPES =
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages';
// Headless half of the Facebook Page connect flow: load the Meta SDK, run
// FB.login for page scopes, and fetch the user's pages. The caller owns the
// page-picker UI and the channel creation, because choosing a page is an
// interactive step (a user can manage several pages).
//
// Split into preloadSdk() + loginAndFetchPages() for popup safety: FB.login
// opens a popup and needs the click's transient activation. Preloading the SDK
// when the picker opens means the click-time `await` resolves within that
// activation window; a cold load resolves on the script's `load` task seconds
// later, after activation has expired, and the popup gets blocked.
export function useFacebookPageConnect() {
const accountId = useMapGetter('getCurrentAccountId');
const isAuthenticating = ref(false);
let sdkSetupPromise = null;
// Idempotent — call this when the picker UI opens. A failed load clears the
// cache so a later attempt can retry instead of being stuck on a rejection.
const preloadSdk = () => {
if (!sdkSetupPromise) {
sdkSetupPromise = setupFacebookSdk(
window.chatwootConfig?.fbAppId,
window.chatwootConfig?.fbApiVersion
).catch(error => {
sdkSetupPromise = null;
throw error;
});
}
return sdkSetupPromise;
};
// FB.login never rejects; resolve the user access token on success and null
// for any other status (closed popup, not_authorized, unknown).
const login = () =>
new Promise(resolve => {
window.FB.login(
response => {
resolve(
response.status === 'connected'
? response.authResponse?.accessToken || null
: null
);
},
{ scope: FB_PAGE_SCOPES }
);
});
// Resolves { userAccessToken, pages } on success, null when the user cancels,
// and rejects on SDK-load or page-fetch failure (the caller maps it to UI).
const loginAndFetchPages = async () => {
if (isAuthenticating.value) return null;
isAuthenticating.value = true;
try {
await preloadSdk();
const token = await login();
if (!token) return null;
const response = await ChannelApi.fetchFacebookPages(
token,
accountId.value
);
const { page_details: pages, user_access_token: userAccessToken } =
response.data.data;
return { userAccessToken, pages };
} finally {
isAuthenticating.value = false;
}
};
return { isAuthenticating, preloadSdk, loginAndFetchPages };
}
@@ -1,96 +0,0 @@
import { ref } from 'vue';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
isValidBusinessData,
} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
// Drives Meta's WhatsApp embedded-signup popup (Facebook JS SDK). FB.login()
// resolves an auth `code` while the WABA identifiers (waba_id, phone_number_id)
// arrive separately over a postMessage event — order isn't guaranteed, so we
// hold both and resolve once both are present.
//
// `runEmbeddedSignup` returns the signup credentials; the caller exchanges them
// for an inbox via `inboxes/createWhatsAppEmbeddedSignup` and owns its own UX
// (alerts, navigation, etc). Resolves `null` when the user cancels the popup;
// rejects on SDK load or signup errors. The window listener is scoped to a
// single run, so this is safe to call from anywhere without lifecycle wiring.
export function useWhatsappEmbeddedSignup() {
const isAuthenticating = ref(false);
const runEmbeddedSignup = () => {
if (isAuthenticating.value) return Promise.resolve(null);
isAuthenticating.value = true;
return new Promise((resolve, reject) => {
let authCode = null;
let businessData = null;
let settled = false;
let messageHandler;
const settle = (fn, value) => {
if (settled) return;
settled = true;
window.removeEventListener('message', messageHandler);
isAuthenticating.value = false;
fn(value);
};
// Both the auth code and the business data arrive asynchronously and in
// no fixed order; only resolve once we're holding both.
const resolveIfReady = () => {
if (!authCode || !businessData) return;
settle(resolve, {
code: authCode,
business_id: businessData.business_id,
waba_id: businessData.waba_id,
phone_number_id: businessData.phone_number_id || '',
});
};
messageHandler = createMessageHandler(data => {
if (
data.event === 'FINISH' ||
data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING'
) {
if (!isValidBusinessData(data.data)) {
settle(reject, new Error('Invalid business data'));
return;
}
businessData = data.data;
resolveIfReady();
} else if (data.event === 'CANCEL') {
settle(resolve, null);
} else if (data.event === 'error') {
settle(reject, new Error(data.error_message || 'Signup error'));
}
});
window.addEventListener('message', messageHandler);
(async () => {
try {
await setupFacebookSdk(
window.chatwootConfig?.whatsappAppId,
window.chatwootConfig?.whatsappApiVersion
);
authCode = await initWhatsAppEmbeddedSignup(
window.chatwootConfig?.whatsappConfigurationId
);
resolveIfReady();
} catch (error) {
// FB.login() rejects with 'Login cancelled' when the user dismisses
// the popup — treat it as a cancel rather than an error.
if (error.message === 'Login cancelled') {
settle(resolve, null);
} else {
settle(reject, error);
}
}
})();
});
};
return { isAuthenticating, runEmbeddedSignup };
}
+20 -2
View File
@@ -14,7 +14,6 @@ export const FORMATTING = {
'link', 'link',
'bulletList', 'bulletList',
'orderedList', 'orderedList',
'imageUpload',
'undo', 'undo',
'redo', 'redo',
], ],
@@ -31,7 +30,6 @@ export const FORMATTING = {
'strike', 'strike',
'bulletList', 'bulletList',
'orderedList', 'orderedList',
'imageUpload',
'undo', 'undo',
'redo', 'redo',
], ],
@@ -265,3 +263,23 @@ export const MARKDOWN_PATTERNS = [
], ],
}, },
]; ];
// Editor image resize options for Message Editor
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{
name: 'Small',
height: '24px',
},
{
name: 'Medium',
height: '48px',
},
{
name: 'Large',
height: '72px',
},
{
name: 'Original Size',
height: 'auto',
},
];
@@ -37,13 +37,6 @@ export const getValuesName = (values, list, idKey, nameKey) => {
}; };
}; };
const getValuesForContact = (values, contacts) => ({
id: values[0],
name:
contacts?.find(contact => contact.id === values[0])?.name ||
`Contact #${values[0]}`,
});
export const getValuesForStatus = values => { export const getValuesForStatus = values => {
return values.map(value => ({ id: value, name: value })); return values.map(value => ({ id: value, name: value }));
}; };
@@ -91,7 +84,6 @@ export const getValuesForFilter = (filter, params) => {
campaigns, campaigns,
labels, labels,
priority, priority,
contacts,
} = params; } = params;
switch (attribute_key) { switch (attribute_key) {
case 'status': case 'status':
@@ -102,8 +94,6 @@ export const getValuesForFilter = (filter, params) => {
return getValuesName(values, inboxes, 'id', 'name'); return getValuesName(values, inboxes, 'id', 'name');
case 'team_id': case 'team_id':
return getValuesName(values, teams, 'id', 'name'); return getValuesName(values, teams, 'id', 'name');
case 'contact_id':
return getValuesForContact(values, contacts);
case 'campaign_id': case 'campaign_id':
return getValuesName(values, campaigns, 'id', 'title'); return getValuesName(values, campaigns, 'id', 'title');
case 'labels': case 'labels':
@@ -379,6 +379,31 @@ export const findNodeToInsertImage = (editorState, fileUrl) => {
}; };
}; };
/**
* Set URL with query and size.
*
* @param {Object} selectedImageNode - The current selected node.
* @param {Object} size - The size to set.
* @param {Object} editorView - The editor view.
*/
export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
if (selectedImageNode) {
// Create and apply the transaction
const tr = editorView.state.tr.setNodeMarkup(
editorView.state.selection.from,
null,
{
src: selectedImageNode.src,
height: size.height,
}
);
if (tr.docChanged) {
editorView.dispatch(tr);
}
}
}
/** /**
* Strips unsupported markdown formatting from content based on the editor schema. * Strips unsupported markdown formatting from content based on the editor schema.
* This ensures canned responses with rich formatting can be inserted into channels * This ensures canned responses with rich formatting can be inserted into channels
@@ -159,13 +159,6 @@ export const LOCALE_MENU_ITEMS = {
value: 'publish', value: 'publish',
icon: 'i-lucide-eye', icon: 'i-lucide-eye',
}, },
customizeContent: {
label:
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT',
action: 'customize-content',
value: 'customize-content',
icon: 'i-lucide-pencil',
},
delete: { delete: {
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE', label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
action: 'delete', action: 'delete',
@@ -179,28 +172,20 @@ const disableLocaleMenuItems = menuItems =>
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => { export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
if (isDefault) { if (isDefault) {
return [ return disableLocaleMenuItems([
...disableLocaleMenuItems([ LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.makeDefault, LOCALE_MENU_ITEMS.moveToDraft,
LOCALE_MENU_ITEMS.moveToDraft, LOCALE_MENU_ITEMS.delete,
]), ]);
LOCALE_MENU_ITEMS.customizeContent,
...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]),
];
} }
if (isDraft) { if (isDraft) {
return [ return [LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.delete];
LOCALE_MENU_ITEMS.publishLocale,
LOCALE_MENU_ITEMS.customizeContent,
LOCALE_MENU_ITEMS.delete,
];
} }
return [ return [
LOCALE_MENU_ITEMS.makeDefault, LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.moveToDraft, LOCALE_MENU_ITEMS.moveToDraft,
LOCALE_MENU_ITEMS.customizeContent,
LOCALE_MENU_ITEMS.delete, LOCALE_MENU_ITEMS.delete,
]; ];
}; };
@@ -273,41 +273,6 @@ describe('customViewsHelper', () => {
}; };
expect(generateValuesForEditCustomViews(filter, params)).toEqual('1'); expect(generateValuesForEditCustomViews(filter, params)).toEqual('1');
}); });
it('returns contact name for contact filters when contact is available', () => {
const filter = {
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [123],
};
const params = {
contacts: [{ id: 123, name: 'John Doe' }],
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 123,
name: 'John Doe',
});
});
it('returns fallback contact display value when contact is not available', () => {
const filter = {
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [123],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 123,
name: 'Contact #123',
});
});
}); });
describe('#generateCustomAttributesInputType', () => { describe('#generateCustomAttributesInputType', () => {
@@ -16,6 +16,7 @@ import {
insertAtCursor, insertAtCursor,
removeSignature, removeSignature,
replaceSignature, replaceSignature,
setURLWithQueryAndSize,
stripInlineBase64Images, stripInlineBase64Images,
stripUnsupportedFormatting, stripUnsupportedFormatting,
stripUnsupportedMarkdown, stripUnsupportedMarkdown,
@@ -652,6 +653,71 @@ describe('findNodeToInsertImage', () => {
}); });
}); });
describe('setURLWithQueryAndSize', () => {
let selectedNode;
let editorView;
beforeEach(() => {
selectedNode = {
setAttribute: vi.fn(),
};
const tr = {
setNodeMarkup: vi.fn().mockReturnValue({
docChanged: true,
}),
};
const state = {
selection: { from: 0 },
tr,
};
editorView = {
state,
dispatch: vi.fn(),
};
});
it('updates the URL with the given size and updates the editor view', () => {
const size = { height: '20px' };
setURLWithQueryAndSize(selectedNode, size, editorView);
// Check if the editor view is updated
expect(editorView.dispatch).toHaveBeenCalledTimes(1);
});
it('updates the URL with the given size and updates the editor view with original size', () => {
const size = { height: 'auto' };
setURLWithQueryAndSize(selectedNode, size, editorView);
// Check if the editor view is updated
expect(editorView.dispatch).toHaveBeenCalledTimes(1);
});
it('does not update the editor view if the document has not changed', () => {
editorView.state.tr.setNodeMarkup = vi.fn().mockReturnValue({
docChanged: false,
});
const size = { height: '20px' };
setURLWithQueryAndSize(selectedNode, size, editorView);
// Check if the editor view dispatch was not called
expect(editorView.dispatch).not.toHaveBeenCalled();
});
it('does not perform any operations if selectedNode is not provided', () => {
setURLWithQueryAndSize(null, { height: '20px' }, editorView);
// Ensure the dispatch method wasn't called
expect(editorView.dispatch).not.toHaveBeenCalled();
});
});
describe('getContentNode', () => { describe('getContentNode', () => {
let mockEditorView; let mockEditorView;
@@ -64,25 +64,4 @@ describe('#filterQueryGenerator', () => {
filterQueryGenerator(testData).payload.every(i => Array.isArray(i.values)) filterQueryGenerator(testData).payload.every(i => Array.isArray(i.values))
).toBe(true); ).toBe(true);
}); });
it('serializes a selected contact object to contact id', () => {
const result = filterQueryGenerator([
{
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: { id: 123, name: 'Jane Doe' },
query_operator: 'and',
},
]);
expect(result).toMatchObject({
payload: [
{
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [123],
},
],
});
});
}); });
@@ -74,40 +74,37 @@ describe('PortalHelper', () => {
}); });
describe('buildLocaleMenuItems', () => { describe('buildLocaleMenuItems', () => {
it('disables other actions but keeps customize enabled for the default locale', () => { it('returns disabled actions for the default locale', () => {
const items = buildLocaleMenuItems({ isDefault: true, isDraft: false });
const customize = items.find(item => item.action === 'customize-content');
expect(customize).toBeTruthy();
expect(customize.disabled).toBeFalsy();
expect( expect(
items buildLocaleMenuItems({
.filter(item => item.action !== 'customize-content') isDefault: true,
.every(item => item.disabled) isDraft: false,
).toBe(true); })
).toEqual(
expect.arrayContaining([
expect.objectContaining({ action: 'change-default', disabled: true }),
expect.objectContaining({ action: 'move-to-draft', disabled: true }),
expect.objectContaining({ action: 'delete', disabled: true }),
])
);
}); });
it('returns publish, customize, and delete actions for draft locales', () => { it('returns publish and delete actions for draft locales', () => {
expect( expect(
buildLocaleMenuItems({ buildLocaleMenuItems({
isDefault: false, isDefault: false,
isDraft: true, isDraft: true,
}).map(({ action }) => action) }).map(({ action }) => action)
).toEqual(['publish-locale', 'customize-content', 'delete']); ).toEqual(['publish-locale', 'delete']);
}); });
it('returns default, draft, customize, and delete actions for live locales', () => { it('returns default, draft, and delete actions for live locales', () => {
expect( expect(
buildLocaleMenuItems({ buildLocaleMenuItems({
isDefault: false, isDefault: false,
isDraft: false, isDraft: false,
}).map(({ action }) => action) }).map(({ action }) => action)
).toEqual([ ).toEqual(['change-default', 'move-to-draft', 'delete']);
'change-default',
'move-to-draft',
'customize-content',
'delete',
]);
}); });
}); });
}); });
@@ -1,6 +1,11 @@
{ {
"BULK_ACTION": { "BULK_ACTION": {
"CONVERSATIONS_SELECTED": "{conversationCount} conversations selected", "CONVERSATIONS_SELECTED": "{conversationCount} conversations selected",
"AGENT_SELECT_LABEL": "Select agent",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {conversationCount} {conversationLabel} to",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"NONE": "None", "NONE": "None",
"CLEAR_SELECTION": "Clear", "CLEAR_SELECTION": "Clear",
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?", "ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
@@ -15,6 +20,7 @@
"RESOLVE_SUCCESFUL": "Conversations resolved successfully.", "RESOLVE_SUCCESFUL": "Conversations resolved successfully.",
"RESOLVE_FAILED": "Failed to resolve conversations. Please try again.", "RESOLVE_FAILED": "Failed to resolve conversations. Please try again.",
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.", "ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
"AGENT_LIST_LOADING": "Loading agents",
"UPDATE": { "UPDATE": {
"CHANGE_STATUS": "Change status", "CHANGE_STATUS": "Change status",
"SNOOZE_UNTIL": "Snooze", "SNOOZE_UNTIL": "Snooze",
@@ -27,16 +33,16 @@
}, },
"LABELS": { "LABELS": {
"ASSIGN_LABELS": "Assign labels", "ASSIGN_LABELS": "Assign labels",
"REMOVE_LABELS": "Remove labels", "NO_LABELS_FOUND": "No labels found",
"ASSIGN_SELECTED_LABELS": "Assign selected labels", "ASSIGN_SELECTED_LABELS": "Assign selected labels",
"REMOVE_SELECTED_LABELS": "Remove selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully.", "ASSIGN_SUCCESFUL": "Labels assigned successfully.",
"ASSIGN_FAILED": "Failed to assign labels. Please try again.", "ASSIGN_FAILED": "Failed to assign labels. Please try again."
"REMOVE_SUCCESFUL": "Labels removed successfully.",
"REMOVE_FAILED": "Failed to remove labels. Please try again."
}, },
"TEAMS": { "TEAMS": {
"TEAM_SELECT_LABEL": "Select team",
"NONE": "None", "NONE": "None",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?", "ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?", "UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "Teams assigned successfully.", "ASSIGN_SUCCESFUL": "Teams assigned successfully.",
@@ -88,7 +88,6 @@
}, },
"CARD": { "CARD": {
"STATUS": { "STATUS": {
"PROCESSING": "በሂደት ላይ",
"COMPLETED": "Completed", "COMPLETED": "Completed",
"SCHEDULED": "Scheduled" "SCHEDULED": "Scheduled"
}, },
@@ -147,7 +146,6 @@
}, },
"CARD": { "CARD": {
"STATUS": { "STATUS": {
"PROCESSING": "በሂደት ላይ",
"COMPLETED": "Completed", "COMPLETED": "Completed",
"SCHEDULED": "Scheduled" "SCHEDULED": "Scheduled"
}, },
@@ -63,7 +63,6 @@
"CODE": "Code", "CODE": "Code",
"BULLET_LIST": "Bullet List", "BULLET_LIST": "Bullet List",
"ORDERED_LIST": "Ordered List", "ORDERED_LIST": "Ordered List",
"TABLE": "Table", "TABLE": "Table"
"IMAGE": "Image"
} }
} }
@@ -20,8 +20,6 @@
"CALL": "ደውል", "CALL": "ደውል",
"CALL_INITIATED": "ለእውነተኛው እውቀት መደወል እየተከናወነ ነው…", "CALL_INITIATED": "ለእውነተኛው እውቀት መደወል እየተከናወነ ነው…",
"CALL_FAILED": "ጥሪውን መጀመር አልቻልንም። እባክዎ ደግመው ይሞክሩ።.", "CALL_FAILED": "ጥሪውን መጀመር አልቻልንም። እባክዎ ደግመው ይሞክሩ።.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"CLICK_TO_EDIT": "Click to edit", "CLICK_TO_EDIT": "Click to edit",
"VOICE_INBOX_PICKER": { "VOICE_INBOX_PICKER": {
"TITLE": "የድምፅ ኢንቦክስ ይምረጡ" "TITLE": "የድምፅ ኢንቦክስ ይምረጡ"
@@ -585,11 +583,8 @@
}, },
"CONTACTS_BULK_ACTIONS": { "CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "መለያዎችን መሰጠት", "ASSIGN_LABELS": "መለያዎችን መሰጠት",
"REMOVE_LABELS": "Remove Labels",
"ASSIGN_LABELS_SUCCESS": "መለያዎች በተሳካ ሁኔታ ተመዝግበዋል።.", "ASSIGN_LABELS_SUCCESS": "መለያዎች በተሳካ ሁኔታ ተመዝግበዋል።.",
"ASSIGN_LABELS_FAILED": "መለያዎችን ማስመዝገብ አልተሳካም", "ASSIGN_LABELS_FAILED": "መለያዎችን ማስመዝገብ አልተሳካም",
"REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
"REMOVE_LABELS_FAILED": "Failed to remove labels",
"DESCRIPTION": "ለተመረጡት እውነተኛዎች የሚያክሉትን መለያዎች ይምረጡ።.", "DESCRIPTION": "ለተመረጡት እውነተኛዎች የሚያክሉትን መለያዎች ይምረጡ።.",
"NO_LABELS_FOUND": "እስካሁን መለያዎች አልተገኙም።.", "NO_LABELS_FOUND": "እስካሁን መለያዎች አልተገኙም።.",
"SELECTED_COUNT": "{count} ተመርጧል", "SELECTED_COUNT": "{count} ተመርጧል",
@@ -62,7 +62,6 @@
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.", "UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.", "UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
"UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.", "UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.",
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully", "SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again", "FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "No response", "NO_RESPONSE": "No response",
@@ -80,22 +79,13 @@
"OUTGOING_CALL": "Outgoing call", "OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress", "CALL_IN_PROGRESS": "Call in progress",
"NO_ANSWER": "No answer", "NO_ANSWER": "No answer",
"NO_ANSWER_OUTBOUND_LABEL": "No answer",
"NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up",
"MISSED_CALL": "Missed call", "MISSED_CALL": "Missed call",
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
"CALL_ENDED": "Call ended", "CALL_ENDED": "Call ended",
"HANDLED_BY": "Handled by {agentName}",
"NOT_ANSWERED_YET": "Not answered yet", "NOT_ANSWERED_YET": "Not answered yet",
"CALLING": "Calling…",
"THEY_ANSWERED": "They answered", "THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered", "YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered", "AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Join call", "JOIN_CALL": "Join call"
"CALL_BACK": "Call back",
"TRANSCRIPT_SHOW_MORE": "Show more",
"TRANSCRIPT_SHOW_LESS": "Show less"
}, },
"HEADER": { "HEADER": {
"RESOLVE_ACTION": "ተፈትኗል", "RESOLVE_ACTION": "ተፈትኗል",
@@ -110,12 +100,6 @@
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow", "SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week", "SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply", "SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
"WHATSAPP_CALL": "Start WhatsApp call",
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
"VOICE_CALL": "Start call",
"VOICE_CALL_FAILED": "Could not start the call.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"SLA_STATUS": { "SLA_STATUS": {
"FRT": "FRT {status}", "FRT": "FRT {status}",
"NRT": "NRT {status}", "NRT": "NRT {status}",
@@ -231,7 +215,6 @@
"TIP_AUDIORECORDER_ICON": "Record audio", "TIP_AUDIORECORDER_ICON": "Record audio",
"TIP_AUDIORECORDER_PERMISSION": "Allow access to audio", "TIP_AUDIORECORDER_PERMISSION": "Allow access to audio",
"TIP_AUDIORECORDER_ERROR": "Could not open the audio", "TIP_AUDIORECORDER_ERROR": "Could not open the audio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Drag and drop here to attach", "DRAG_DROP": "Drag and drop here to attach",
"START_AUDIO_RECORDING": "Start audio recording", "START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording", "STOP_AUDIO_RECORDING": "Stop audio recording",
@@ -316,13 +299,8 @@
"NOT_ANSWERED_YET": "Not answered yet", "NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab", "HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject", "REJECT_CALL": "Reject",
"DISMISS_CALL": "Dismiss",
"JOIN_CALL": "Join call", "JOIN_CALL": "Join call",
"END_CALL": "End call", "END_CALL": "End call"
"MUTE": "Mute mic",
"UNMUTE": "Unmute mic",
"VIEW_CHAT_HISTORY": "View chat history",
"GO_TO_CONVERSATION": "Go to conversation thread"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {
@@ -607,12 +607,9 @@
"DRAFT": "እቅድ", "DRAFT": "እቅድ",
"ARCHIVE": "አርክቭ", "ARCHIVE": "አርክቭ",
"TRANSLATE": "Translate", "TRANSLATE": "Translate",
"MOVE_TO_CATEGORY": "Category",
"DELETE": "Delete", "DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully", "STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles", "STATUS_ERROR": "Failed to update articles",
"CATEGORY_SUCCESS": "Articles moved successfully",
"CATEGORY_ERROR": "Failed to move articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles", "DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.", "DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "Delete", "DELETE_CONFIRM": "Delete",
@@ -869,28 +866,6 @@
}, },
"EDIT_CONFIGURATION": "ቅንብር አርትዕ" "EDIT_CONFIGURATION": "ቅንብር አርትዕ"
}, },
"LAYOUT_CONTENT": {
"HEADER": "Appearance",
"DESCRIPTION": "Pick the layout that fits how your visitors read.",
"LAYOUT": {
"CLASSIC": {
"TITLE": "Classic",
"DESCRIPTION": "A welcoming home page with search and featured topics."
},
"SIDEBAR": {
"TITLE": "Documentation",
"DESCRIPTION": "Side-by-side navigation that keeps every guide a click away."
}
},
"SOCIAL_LINKS": {
"HEADER": "Social links",
"DESCRIPTION": "Add the handle for each network and your help center builds the full link. Shown in the documentation layout footer.",
"PLACEHOLDER": "handle",
"ADD": "Add social link",
"REMOVE": "አስወግድ"
},
"SAVE": "ለውጦች አስቀምጥ"
},
"API": { "API": {
"CREATE_PORTAL": { "CREATE_PORTAL": {
"SUCCESS_MESSAGE": "ፖርታል በተሳካ ሁኔታ ተፈጥሯል", "SUCCESS_MESSAGE": "ፖርታል በተሳካ ሁኔታ ተፈጥሯል",
@@ -308,7 +308,6 @@
"AUTH_PROCESSING": "ከMeta ጋር በመረጋገጥ ላይ ነው", "AUTH_PROCESSING": "ከMeta ጋር በመረጋገጥ ላይ ነው",
"WAITING_FOR_BUSINESS_INFO": "እባክዎ በMeta መስኮት ውስጥ የንግድ ቅንብር ያርኩ...", "WAITING_FOR_BUSINESS_INFO": "እባክዎ በMeta መስኮት ውስጥ የንግድ ቅንብር ያርኩ...",
"PROCESSING": "የWhatsApp ቢዝነስ መለያዎን እየተዘጋጀ ነው", "PROCESSING": "የWhatsApp ቢዝነስ መለያዎን እየተዘጋጀ ነው",
"ENABLING_CALLING": "Enabling WhatsApp Calling on your number…",
"LOADING_SDK": "Facebook SDK እየተጫነ ነው...", "LOADING_SDK": "Facebook SDK እየተጫነ ነው...",
"CANCELLED": "የWhatsApp ምዝገባ ተሰርዟል", "CANCELLED": "የWhatsApp ምዝገባ ተሰርዟል",
"SUCCESS_TITLE": "የWhatsApp ቢዝነስ መለያ ተገናኝቷል!", "SUCCESS_TITLE": "የWhatsApp ቢዝነስ መለያ ተገናኝቷል!",
@@ -318,8 +317,7 @@
"AUTH_NOT_COMPLETED": "ማረጋገጫ አልተጠናቀቀም። ሂደቱን እባክዎ ዳግም ይጀምሩ።.", "AUTH_NOT_COMPLETED": "ማረጋገጫ አልተጠናቀቀም። ሂደቱን እባክዎ ዳግም ይጀምሩ።.",
"SUCCESS_FALLBACK": "የWhatsApp የንግድ መለያ በተሳካ ሁኔታ ተቋቋመ", "SUCCESS_FALLBACK": "የWhatsApp የንግድ መለያ በተሳካ ሁኔታ ተቋቋመ",
"MANUAL_FALLBACK": "ቁጥርዎ ከWhatsApp Business Platform (API) ጋር ከተገናኘ እና ወይም እርስዎ የቴክኖሎጂ አቅራቢ ከሆኑ እና የእርስዎን ቁጥር በራስዎ ሲያስገቡ፣ እባክዎ የ{link} ሂደትን ይጠቀሙ", "MANUAL_FALLBACK": "ቁጥርዎ ከWhatsApp Business Platform (API) ጋር ከተገናኘ እና ወይም እርስዎ የቴክኖሎጂ አቅራቢ ከሆኑ እና የእርስዎን ቁጥር በራስዎ ሲያስገቡ፣ እባክዎ የ{link} ሂደትን ይጠቀሙ",
"MANUAL_LINK_TEXT": "የእጅ ማቀናበሪያ ሂደት", "MANUAL_LINK_TEXT": "የእጅ ማቀናበሪያ ሂደት"
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
}, },
"API": { "API": {
"ERROR_MESSAGE": "WhatsApp ቻናሉን ማስቀመጥ አልተቻለም" "ERROR_MESSAGE": "WhatsApp ቻናሉን ማስቀመጥ አልተቻለም"
@@ -467,10 +465,6 @@
"TITLE": "ዋትስአፕ", "TITLE": "ዋትስአፕ",
"DESCRIPTION": "በWhatsApp ላይ ደንበኞችዎን ድጋፍ ያድርጉ" "DESCRIPTION": "በWhatsApp ላይ ደንበኞችዎን ድጋፍ ያድርጉ"
}, },
"WHATSAPP_CALL": {
"TITLE": "WhatsApp Call",
"DESCRIPTION": "Take voice calls on your WhatsApp number"
},
"EMAIL": { "EMAIL": {
"TITLE": "ኢሜይል", "TITLE": "ኢሜይል",
"DESCRIPTION": "ከGmail, Outlook ወይም ከሌሎች አቅራቢዎች ጋር ያገናኙ" "DESCRIPTION": "ከGmail, Outlook ወይም ከሌሎች አቅራቢዎች ጋር ያገናኙ"
@@ -643,8 +637,7 @@
"BOT_CONFIGURATION": "የቦት ቅንብሮች", "BOT_CONFIGURATION": "የቦት ቅንብሮች",
"ACCOUNT_HEALTH": "የመለያ ጤና", "ACCOUNT_HEALTH": "የመለያ ጤና",
"CSAT": "የደንበኞች ደህንነት ግምገማ (CSAT)", "CSAT": "የደንበኞች ደህንነት ግምገማ (CSAT)",
"VOICE": "ድምጽ", "VOICE": "ድምጽ"
"CALLS": "Calls"
}, },
"VOICE_CONFIGURATION": { "VOICE_CONFIGURATION": {
"ENABLE_VOICE": { "ENABLE_VOICE": {
@@ -655,26 +648,6 @@
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections." "DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
} }
}, },
"WHATSAPP_CALLING": {
"ENABLE": {
"LABEL": "Enable WhatsApp Calling",
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
},
"ENABLE_FAILED": "Voice calling couldn't be turned on for this number — it isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then try again.",
"PHONE_NUMBER": {
"LABEL": "Business phone number",
"HELP_TEXT": "WhatsApp number that customers will call."
},
"HOW_IT_WORKS": {
"LABEL": "How it works",
"DESCRIPTION": "Calls are placed peer-to-peer between the agent's browser and Meta — no extra credentials are required. Make sure the agent's browser has microphone permission for this site."
},
"PERMISSION_REQUEST_BODY": {
"LABEL": "Call permission request message",
"HELP_TEXT": "Shown to the contact when they haven't yet consented to receive calls. Leave blank to use the default.",
"PLACEHOLDER": "We would like to call you regarding your conversation."
}
},
"CHANNEL_PREFERENCES": "የቻናል ቅድሚያዎች", "CHANNEL_PREFERENCES": "የቻናል ቅድሚያዎች",
"WIDGET_FEATURES": "የዊጅት ባህሪያት", "WIDGET_FEATURES": "የዊጅት ባህሪያት",
"ACCOUNT_HEALTH": { "ACCOUNT_HEALTH": {
@@ -827,10 +800,6 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "ከWhatsApp መልእክት አብነቶች እጅግ በእጅ ማስተካከል ለእንደገና የሚገኙ አብነቶችን ያዘምኑ።.", "WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "ከWhatsApp መልእክት አብነቶች እጅግ በእጅ ማስተካከል ለእንደገና የሚገኙ አብነቶችን ያዘምኑ።.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "አብነቶችን ያዘምኑ", "WHATSAPP_TEMPLATES_SYNC_BUTTON": "አብነቶችን ያዘምኑ",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "የአብነት ማስተካከያ በተሳካ ሁኔታ ተጀምሯል። ለማዘመን ጥቂት ደቂቃዎች ሊወስድ ይችላል።.", "WHATSAPP_TEMPLATES_SYNC_SUCCESS": "የአብነት ማስተካከያ በተሳካ ሁኔታ ተጀምሯል። ለማዘመን ጥቂት ደቂቃዎች ሊወስድ ይችላል።.",
"WHATSAPP_CALLING_ENABLED": {
"LABEL": "Enable voice calling",
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
},
"UPDATE_PRE_CHAT_FORM_SETTINGS": "የቀደም ቻት ቅጥያ ቅንብሮችን አዘምን" "UPDATE_PRE_CHAT_FORM_SETTINGS": "የቀደም ቻት ቅጥያ ቅንብሮችን አዘምን"
}, },
"HELP_CENTER": { "HELP_CENTER": {
@@ -57,8 +57,7 @@
"CONTACT_CREATED": "እውቂያ ተፈጥሯል", "CONTACT_CREATED": "እውቂያ ተፈጥሯል",
"CONTACT_UPDATED": "እውቂያ ተሻሽሏል", "CONTACT_UPDATED": "እውቂያ ተሻሽሏል",
"CONVERSATION_TYPING_ON": "ውይይት ማስተካከያ በተጠቃሚ ላይ ነው", "CONVERSATION_TYPING_ON": "ውይይት ማስተካከያ በተጠቃሚ ላይ ነው",
"CONVERSATION_TYPING_OFF": "ውይይት ማስተካከያ ከተጠቃሚ ላይ አልተጠቀሰም", "CONVERSATION_TYPING_OFF": "ውይይት ማስተካከያ ከተጠቃሚ ላይ አልተጠቀሰም"
"INBOX_UPDATED": "Inbox updated"
} }
}, },
"NAME": { "NAME": {
@@ -1,6 +1,11 @@
{ {
"BULK_ACTION": { "BULK_ACTION": {
"CONVERSATIONS_SELECTED": "{conversationCount} المحادثات المحددة", "CONVERSATIONS_SELECTED": "{conversationCount} المحادثات المحددة",
"AGENT_SELECT_LABEL": "اختر وكيل",
"ASSIGN_CONFIRMATION_LABEL": "هل أنت متأكد من أنك تريد تعيين {conversationCount} {conversationLabel} إلى",
"UNASSIGN_CONFIRMATION_LABEL": "هل أنت متأكد من إلغاء تعيين {conversationCount} {conversationLabel}؟",
"GO_BACK_LABEL": "العودة للخلف",
"ASSIGN_LABEL": "تكليف",
"NONE": "لا شيء", "NONE": "لا شيء",
"CLEAR_SELECTION": "مسح", "CLEAR_SELECTION": "مسح",
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?", "ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
@@ -15,6 +20,7 @@
"RESOLVE_SUCCESFUL": "تم تسوية المحادثات بنجاح.", "RESOLVE_SUCCESFUL": "تم تسوية المحادثات بنجاح.",
"RESOLVE_FAILED": "فشل في حل المحادثات، يرجى المحاولة مرة أخرى.", "RESOLVE_FAILED": "فشل في حل المحادثات، يرجى المحاولة مرة أخرى.",
"ALL_CONVERSATIONS_SELECTED_ALERT": "المحادثات المرئية في هذه الصفحة هي المحددة فقط.", "ALL_CONVERSATIONS_SELECTED_ALERT": "المحادثات المرئية في هذه الصفحة هي المحددة فقط.",
"AGENT_LIST_LOADING": "جاري جلب الوكلاء",
"UPDATE": { "UPDATE": {
"CHANGE_STATUS": "تغيير الحالة", "CHANGE_STATUS": "تغيير الحالة",
"SNOOZE_UNTIL": "تأجيل", "SNOOZE_UNTIL": "تأجيل",
@@ -27,16 +33,16 @@
}, },
"LABELS": { "LABELS": {
"ASSIGN_LABELS": "إضافة وسم", "ASSIGN_LABELS": "إضافة وسم",
"REMOVE_LABELS": "Remove labels", "NO_LABELS_FOUND": "لم يتم العثور على تصنيفات",
"ASSIGN_SELECTED_LABELS": "تعيين التسميات المحددة", "ASSIGN_SELECTED_LABELS": "تعيين التسميات المحددة",
"REMOVE_SELECTED_LABELS": "Remove selected labels",
"ASSIGN_SUCCESFUL": "تم تعيين التسميات بنجاح.", "ASSIGN_SUCCESFUL": "تم تعيين التسميات بنجاح.",
"ASSIGN_FAILED": "فشل في تعيين التسميات ، الرجاء المحاولة مرة أخرى.", "ASSIGN_FAILED": "فشل في تعيين التسميات ، الرجاء المحاولة مرة أخرى."
"REMOVE_SUCCESFUL": "Labels removed successfully.",
"REMOVE_FAILED": "Failed to remove labels. Please try again."
}, },
"TEAMS": { "TEAMS": {
"TEAM_SELECT_LABEL": "اختر فريق",
"NONE": "لا شيء", "NONE": "لا شيء",
"NO_TEAMS_AVAILABLE": "لا توجد فرق مضافة إلى هذا الحساب حتى الآن.",
"ASSIGN_SELECTED_TEAMS": "تعيين فريق محدد.",
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?", "ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?", "UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "تم تعيين الفرق بنجاح.", "ASSIGN_SUCCESFUL": "تم تعيين الفرق بنجاح.",
@@ -88,7 +88,6 @@
}, },
"CARD": { "CARD": {
"STATUS": { "STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "مكتمل", "COMPLETED": "مكتمل",
"SCHEDULED": "Scheduled" "SCHEDULED": "Scheduled"
}, },
@@ -147,7 +146,6 @@
}, },
"CARD": { "CARD": {
"STATUS": { "STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "مكتمل", "COMPLETED": "مكتمل",
"SCHEDULED": "Scheduled" "SCHEDULED": "Scheduled"
}, },
@@ -63,7 +63,6 @@
"CODE": "Code", "CODE": "Code",
"BULLET_LIST": "Bullet List", "BULLET_LIST": "Bullet List",
"ORDERED_LIST": "Ordered List", "ORDERED_LIST": "Ordered List",
"TABLE": "Table", "TABLE": "Table"
"IMAGE": "Image"
} }
} }
@@ -20,8 +20,6 @@
"CALL": "Call", "CALL": "Call",
"CALL_INITIATED": "جار الاتصال بجهة الاتصال…", "CALL_INITIATED": "جار الاتصال بجهة الاتصال…",
"CALL_FAILED": "تعذر بدء المكالمة. الرجاء المحاولة مرة أخرى.", "CALL_FAILED": "تعذر بدء المكالمة. الرجاء المحاولة مرة أخرى.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"CLICK_TO_EDIT": "Click to edit", "CLICK_TO_EDIT": "Click to edit",
"VOICE_INBOX_PICKER": { "VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox" "TITLE": "Choose a voice inbox"
@@ -585,11 +583,8 @@
}, },
"CONTACTS_BULK_ACTIONS": { "CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "تعيين التسميات", "ASSIGN_LABELS": "تعيين التسميات",
"REMOVE_LABELS": "Remove Labels",
"ASSIGN_LABELS_SUCCESS": "تم تعيين التسميات بنجاح.", "ASSIGN_LABELS_SUCCESS": "تم تعيين التسميات بنجاح.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels", "ASSIGN_LABELS_FAILED": "Failed to assign labels",
"REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
"REMOVE_LABELS_FAILED": "Failed to remove labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.", "DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.", "NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected", "SELECTED_COUNT": "{count} selected",
@@ -62,7 +62,6 @@
"UNSUPPORTED_MESSAGE_FACEBOOK": "هذه الرسالة غير مدعومة، يمكنك مشاهدة هذه الرسالة على تطبيق فيسبوك (Messenger).", "UNSUPPORTED_MESSAGE_FACEBOOK": "هذه الرسالة غير مدعومة، يمكنك مشاهدة هذه الرسالة على تطبيق فيسبوك (Messenger).",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "هذه الرسالة غير مدعومة، يمكنك عرض هذه الرسالة على تطبيق Instagram.", "UNSUPPORTED_MESSAGE_INSTAGRAM": "هذه الرسالة غير مدعومة، يمكنك عرض هذه الرسالة على تطبيق Instagram.",
"UNSUPPORTED_MESSAGE_TIKTOK": "هذه الرسالة غير مدعومة. يمكنك مشاهدة هذه الرسالة على تطبيق TikTok.", "UNSUPPORTED_MESSAGE_TIKTOK": "هذه الرسالة غير مدعومة. يمكنك مشاهدة هذه الرسالة على تطبيق TikTok.",
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
"SUCCESS_DELETE_MESSAGE": "تم حذف الرسالة بنجاح", "SUCCESS_DELETE_MESSAGE": "تم حذف الرسالة بنجاح",
"FAIL_DELETE_MESSSAGE": "تعذر حذف الرسالة! حاول مرة أخرى", "FAIL_DELETE_MESSSAGE": "تعذر حذف الرسالة! حاول مرة أخرى",
"NO_RESPONSE": "لا توجد استجابة", "NO_RESPONSE": "لا توجد استجابة",
@@ -80,22 +79,13 @@
"OUTGOING_CALL": "Outgoing call", "OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress", "CALL_IN_PROGRESS": "Call in progress",
"NO_ANSWER": "No answer", "NO_ANSWER": "No answer",
"NO_ANSWER_OUTBOUND_LABEL": "No answer",
"NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up",
"MISSED_CALL": "Missed call", "MISSED_CALL": "Missed call",
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
"CALL_ENDED": "Call ended", "CALL_ENDED": "Call ended",
"HANDLED_BY": "Handled by {agentName}",
"NOT_ANSWERED_YET": "Not answered yet", "NOT_ANSWERED_YET": "Not answered yet",
"CALLING": "Calling…",
"THEY_ANSWERED": "They answered", "THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered", "YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered", "AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "انضم إلى المكالمة", "JOIN_CALL": "انضم إلى المكالمة"
"CALL_BACK": "Call back",
"TRANSCRIPT_SHOW_MORE": "Show more",
"TRANSCRIPT_SHOW_LESS": "Show less"
}, },
"HEADER": { "HEADER": {
"RESOLVE_ACTION": "حل المحادثة", "RESOLVE_ACTION": "حل المحادثة",
@@ -110,12 +100,6 @@
"SNOOZED_UNTIL_TOMORROW": "تأجيل حتى الغد", "SNOOZED_UNTIL_TOMORROW": "تأجيل حتى الغد",
"SNOOZED_UNTIL_NEXT_WEEK": "تأجيل حتى الأسبوع القادم", "SNOOZED_UNTIL_NEXT_WEEK": "تأجيل حتى الأسبوع القادم",
"SNOOZED_UNTIL_NEXT_REPLY": "تأجيل حتى الرد التالي", "SNOOZED_UNTIL_NEXT_REPLY": "تأجيل حتى الرد التالي",
"WHATSAPP_CALL": "Start WhatsApp call",
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
"VOICE_CALL": "Start call",
"VOICE_CALL_FAILED": "Could not start the call.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"SLA_STATUS": { "SLA_STATUS": {
"FRT": "FRT {status}", "FRT": "FRT {status}",
"NRT": "NRT {status}", "NRT": "NRT {status}",
@@ -231,7 +215,6 @@
"TIP_AUDIORECORDER_ICON": "تسجيل الصوت", "TIP_AUDIORECORDER_ICON": "تسجيل الصوت",
"TIP_AUDIORECORDER_PERMISSION": "السماح بالوصول إلى الصوت", "TIP_AUDIORECORDER_PERMISSION": "السماح بالوصول إلى الصوت",
"TIP_AUDIORECORDER_ERROR": "تعذر فتح الصوت", "TIP_AUDIORECORDER_ERROR": "تعذر فتح الصوت",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "اسحب و أسقط هنا للإرفاق", "DRAG_DROP": "اسحب و أسقط هنا للإرفاق",
"START_AUDIO_RECORDING": "بدء التسجيل الصوتي", "START_AUDIO_RECORDING": "بدء التسجيل الصوتي",
"STOP_AUDIO_RECORDING": "إيقاف التسجيل الصوتي", "STOP_AUDIO_RECORDING": "إيقاف التسجيل الصوتي",
@@ -316,13 +299,8 @@
"NOT_ANSWERED_YET": "لم يتم الرد بعد", "NOT_ANSWERED_YET": "لم يتم الرد بعد",
"HANDLED_IN_ANOTHER_TAB": "يتم التعامل معها في علامة تبويب أخرى", "HANDLED_IN_ANOTHER_TAB": "يتم التعامل معها في علامة تبويب أخرى",
"REJECT_CALL": "رفض", "REJECT_CALL": "رفض",
"DISMISS_CALL": "تجاهل",
"JOIN_CALL": "انضم إلى المكالمة", "JOIN_CALL": "انضم إلى المكالمة",
"END_CALL": "إنهاء المكالمة", "END_CALL": "إنهاء المكالمة"
"MUTE": "Mute mic",
"UNMUTE": "Unmute mic",
"VIEW_CHAT_HISTORY": "View chat history",
"GO_TO_CONVERSATION": "Go to conversation thread"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {
@@ -607,12 +607,9 @@
"DRAFT": "مسودة", "DRAFT": "مسودة",
"ARCHIVE": "Archive", "ARCHIVE": "Archive",
"TRANSLATE": "ترجم", "TRANSLATE": "ترجم",
"MOVE_TO_CATEGORY": "الفئة",
"DELETE": "حذف", "DELETE": "حذف",
"STATUS_SUCCESS": "Articles updated successfully", "STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles", "STATUS_ERROR": "Failed to update articles",
"CATEGORY_SUCCESS": "Articles moved successfully",
"CATEGORY_ERROR": "Failed to move articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles", "DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.", "DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "حذف", "DELETE_CONFIRM": "حذف",
@@ -869,28 +866,6 @@
}, },
"EDIT_CONFIGURATION": "Edit configuration" "EDIT_CONFIGURATION": "Edit configuration"
}, },
"LAYOUT_CONTENT": {
"HEADER": "المظهر",
"DESCRIPTION": "Pick the layout that fits how your visitors read.",
"LAYOUT": {
"CLASSIC": {
"TITLE": "Classic",
"DESCRIPTION": "A welcoming home page with search and featured topics."
},
"SIDEBAR": {
"TITLE": "Documentation",
"DESCRIPTION": "Side-by-side navigation that keeps every guide a click away."
}
},
"SOCIAL_LINKS": {
"HEADER": "Social links",
"DESCRIPTION": "Add the handle for each network and your help center builds the full link. Shown in the documentation layout footer.",
"PLACEHOLDER": "handle",
"ADD": "Add social link",
"REMOVE": "حذف"
},
"SAVE": "Save changes"
},
"API": { "API": {
"CREATE_PORTAL": { "CREATE_PORTAL": {
"SUCCESS_MESSAGE": "تم إنشاء البوابة بنجاح", "SUCCESS_MESSAGE": "تم إنشاء البوابة بنجاح",
@@ -308,7 +308,6 @@
"AUTH_PROCESSING": "Authenticating with Meta", "AUTH_PROCESSING": "Authenticating with Meta",
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...", "WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
"PROCESSING": "Setting up your WhatsApp Business Account", "PROCESSING": "Setting up your WhatsApp Business Account",
"ENABLING_CALLING": "Enabling WhatsApp Calling on your number…",
"LOADING_SDK": "Loading Facebook SDK...", "LOADING_SDK": "Loading Facebook SDK...",
"CANCELLED": "WhatsApp Signup was cancelled", "CANCELLED": "WhatsApp Signup was cancelled",
"SUCCESS_TITLE": "WhatsApp Business Account Connected!", "SUCCESS_TITLE": "WhatsApp Business Account Connected!",
@@ -318,8 +317,7 @@
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.", "AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured", "SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow", "MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow", "MANUAL_LINK_TEXT": "manual setup flow"
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
}, },
"API": { "API": {
"ERROR_MESSAGE": "لم نتمكن من حفظ قناة واتساب" "ERROR_MESSAGE": "لم نتمكن من حفظ قناة واتساب"
@@ -467,10 +465,6 @@
"TITLE": "واتساب", "TITLE": "واتساب",
"DESCRIPTION": "Support your customers on WhatsApp" "DESCRIPTION": "Support your customers on WhatsApp"
}, },
"WHATSAPP_CALL": {
"TITLE": "WhatsApp Call",
"DESCRIPTION": "Take voice calls on your WhatsApp number"
},
"EMAIL": { "EMAIL": {
"TITLE": "البريد الإلكتروني", "TITLE": "البريد الإلكتروني",
"DESCRIPTION": "Connect with Gmail, Outlook, or other providers" "DESCRIPTION": "Connect with Gmail, Outlook, or other providers"
@@ -643,8 +637,7 @@
"BOT_CONFIGURATION": "اعدادات البوت", "BOT_CONFIGURATION": "اعدادات البوت",
"ACCOUNT_HEALTH": "Account Health", "ACCOUNT_HEALTH": "Account Health",
"CSAT": "تقييم رضاء العملاء", "CSAT": "تقييم رضاء العملاء",
"VOICE": "Voice", "VOICE": "Voice"
"CALLS": "Calls"
}, },
"VOICE_CONFIGURATION": { "VOICE_CONFIGURATION": {
"ENABLE_VOICE": { "ENABLE_VOICE": {
@@ -655,26 +648,6 @@
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections." "DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
} }
}, },
"WHATSAPP_CALLING": {
"ENABLE": {
"LABEL": "Enable WhatsApp Calling",
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
},
"ENABLE_FAILED": "Voice calling couldn't be turned on for this number — it isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then try again.",
"PHONE_NUMBER": {
"LABEL": "Business phone number",
"HELP_TEXT": "WhatsApp number that customers will call."
},
"HOW_IT_WORKS": {
"LABEL": "How it works",
"DESCRIPTION": "Calls are placed peer-to-peer between the agent's browser and Meta — no extra credentials are required. Make sure the agent's browser has microphone permission for this site."
},
"PERMISSION_REQUEST_BODY": {
"LABEL": "Call permission request message",
"HELP_TEXT": "Shown to the contact when they haven't yet consented to receive calls. Leave blank to use the default.",
"PLACEHOLDER": "We would like to call you regarding your conversation."
}
},
"CHANNEL_PREFERENCES": "Channel Preferences", "CHANNEL_PREFERENCES": "Channel Preferences",
"WIDGET_FEATURES": "Widget features", "WIDGET_FEATURES": "Widget features",
"ACCOUNT_HEALTH": { "ACCOUNT_HEALTH": {
@@ -827,10 +800,6 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.", "WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates", "WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.", "WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
"WHATSAPP_CALLING_ENABLED": {
"LABEL": "Enable voice calling",
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
},
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings" "UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
}, },
"HELP_CENTER": { "HELP_CENTER": {
@@ -57,8 +57,7 @@
"CONTACT_CREATED": "Contact created", "CONTACT_CREATED": "Contact created",
"CONTACT_UPDATED": "Contact updated", "CONTACT_UPDATED": "Contact updated",
"CONVERSATION_TYPING_ON": "Conversation Typing On", "CONVERSATION_TYPING_ON": "Conversation Typing On",
"CONVERSATION_TYPING_OFF": "Conversation Typing Off", "CONVERSATION_TYPING_OFF": "Conversation Typing Off"
"INBOX_UPDATED": "Inbox updated"
} }
}, },
"NAME": { "NAME": {
@@ -1,6 +1,11 @@
{ {
"BULK_ACTION": { "BULK_ACTION": {
"CONVERSATIONS_SELECTED": "{conversationCount} conversations selected", "CONVERSATIONS_SELECTED": "{conversationCount} conversations selected",
"AGENT_SELECT_LABEL": "Select agent",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {conversationCount} {conversationLabel} to",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"NONE": "Heç biri", "NONE": "Heç biri",
"CLEAR_SELECTION": "Clear", "CLEAR_SELECTION": "Clear",
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?", "ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
@@ -15,6 +20,7 @@
"RESOLVE_SUCCESFUL": "Conversations resolved successfully.", "RESOLVE_SUCCESFUL": "Conversations resolved successfully.",
"RESOLVE_FAILED": "Failed to resolve conversations. Please try again.", "RESOLVE_FAILED": "Failed to resolve conversations. Please try again.",
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.", "ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
"AGENT_LIST_LOADING": "Loading agents",
"UPDATE": { "UPDATE": {
"CHANGE_STATUS": "Change status", "CHANGE_STATUS": "Change status",
"SNOOZE_UNTIL": "Snooze", "SNOOZE_UNTIL": "Snooze",
@@ -27,16 +33,16 @@
}, },
"LABELS": { "LABELS": {
"ASSIGN_LABELS": "Assign labels", "ASSIGN_LABELS": "Assign labels",
"REMOVE_LABELS": "Remove labels", "NO_LABELS_FOUND": "No labels found",
"ASSIGN_SELECTED_LABELS": "Assign selected labels", "ASSIGN_SELECTED_LABELS": "Assign selected labels",
"REMOVE_SELECTED_LABELS": "Remove selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully.", "ASSIGN_SUCCESFUL": "Labels assigned successfully.",
"ASSIGN_FAILED": "Failed to assign labels. Please try again.", "ASSIGN_FAILED": "Failed to assign labels. Please try again."
"REMOVE_SUCCESFUL": "Labels removed successfully.",
"REMOVE_FAILED": "Failed to remove labels. Please try again."
}, },
"TEAMS": { "TEAMS": {
"TEAM_SELECT_LABEL": "Select team",
"NONE": "None", "NONE": "None",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?", "ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?", "UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "Teams assigned successfully.", "ASSIGN_SUCCESFUL": "Teams assigned successfully.",
@@ -88,7 +88,6 @@
}, },
"CARD": { "CARD": {
"STATUS": { "STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "Completed", "COMPLETED": "Completed",
"SCHEDULED": "Scheduled" "SCHEDULED": "Scheduled"
}, },
@@ -147,7 +146,6 @@
}, },
"CARD": { "CARD": {
"STATUS": { "STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "Completed", "COMPLETED": "Completed",
"SCHEDULED": "Scheduled" "SCHEDULED": "Scheduled"
}, },
@@ -63,7 +63,6 @@
"CODE": "Code", "CODE": "Code",
"BULLET_LIST": "Bullet List", "BULLET_LIST": "Bullet List",
"ORDERED_LIST": "Ordered List", "ORDERED_LIST": "Ordered List",
"TABLE": "Table", "TABLE": "Table"
"IMAGE": "Image"
} }
} }
@@ -20,8 +20,6 @@
"CALL": "Zəng et", "CALL": "Zəng et",
"CALL_INITIATED": "Calling the contact…", "CALL_INITIATED": "Calling the contact…",
"CALL_FAILED": "Unable to start the call. Please try again.", "CALL_FAILED": "Unable to start the call. Please try again.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"CLICK_TO_EDIT": "Click to edit", "CLICK_TO_EDIT": "Click to edit",
"VOICE_INBOX_PICKER": { "VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox" "TITLE": "Choose a voice inbox"
@@ -585,11 +583,8 @@
}, },
"CONTACTS_BULK_ACTIONS": { "CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Assign Labels", "ASSIGN_LABELS": "Assign Labels",
"REMOVE_LABELS": "Remove Labels",
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.", "ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels", "ASSIGN_LABELS_FAILED": "Failed to assign labels",
"REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
"REMOVE_LABELS_FAILED": "Failed to remove labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.", "DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.", "NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} seçildi", "SELECTED_COUNT": "{count} seçildi",
@@ -62,7 +62,6 @@
"UNSUPPORTED_MESSAGE_FACEBOOK": "Bu mesaj dəstəklənmir. Bu mesajı Facebook Messenger tətbiqində görə bilərsiniz.", "UNSUPPORTED_MESSAGE_FACEBOOK": "Bu mesaj dəstəklənmir. Bu mesajı Facebook Messenger tətbiqində görə bilərsiniz.",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "Bu mesaj dəstəklənmir. Bu mesajı Instagram tətbiqində görə bilərsiniz.", "UNSUPPORTED_MESSAGE_INSTAGRAM": "Bu mesaj dəstəklənmir. Bu mesajı Instagram tətbiqində görə bilərsiniz.",
"UNSUPPORTED_MESSAGE_TIKTOK": "Bu mesaj dəstəklənmir. Bu mesajı TikTok tətbiqində görə bilərsiniz.", "UNSUPPORTED_MESSAGE_TIKTOK": "Bu mesaj dəstəklənmir. Bu mesajı TikTok tətbiqində görə bilərsiniz.",
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
"SUCCESS_DELETE_MESSAGE": "Mesaj uğurla silindi", "SUCCESS_DELETE_MESSAGE": "Mesaj uğurla silindi",
"FAIL_DELETE_MESSSAGE": "Mesajı silmək mümkün olmadı! Yenidən cəhd edin", "FAIL_DELETE_MESSSAGE": "Mesajı silmək mümkün olmadı! Yenidən cəhd edin",
"NO_RESPONSE": "Cavab yoxdur", "NO_RESPONSE": "Cavab yoxdur",
@@ -80,22 +79,13 @@
"OUTGOING_CALL": "Gedən zəng", "OUTGOING_CALL": "Gedən zəng",
"CALL_IN_PROGRESS": "Zəng davam edir", "CALL_IN_PROGRESS": "Zəng davam edir",
"NO_ANSWER": "Cavab yoxdur", "NO_ANSWER": "Cavab yoxdur",
"NO_ANSWER_OUTBOUND_LABEL": "Cavab yoxdur",
"NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up",
"MISSED_CALL": "Qeyri-işlək zəng", "MISSED_CALL": "Qeyri-işlək zəng",
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
"CALL_ENDED": "Zəng bitdi", "CALL_ENDED": "Zəng bitdi",
"HANDLED_BY": "Handled by {agentName}",
"NOT_ANSWERED_YET": "Hələ cavab verilməyib", "NOT_ANSWERED_YET": "Hələ cavab verilməyib",
"CALLING": "Calling…",
"THEY_ANSWERED": "Onlar cavab verdi", "THEY_ANSWERED": "Onlar cavab verdi",
"YOU_ANSWERED": "Siz cavab verdiniz", "YOU_ANSWERED": "Siz cavab verdiniz",
"AGENT_ANSWERED": "{agentName} answered", "AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Zəngə qoşul", "JOIN_CALL": "Zəngə qoşul"
"CALL_BACK": "Call back",
"TRANSCRIPT_SHOW_MORE": "Show more",
"TRANSCRIPT_SHOW_LESS": "Show less"
}, },
"HEADER": { "HEADER": {
"RESOLVE_ACTION": "Həll et", "RESOLVE_ACTION": "Həll et",
@@ -110,12 +100,6 @@
"SNOOZED_UNTIL_TOMORROW": "Sabaha qədər təxirə salındı", "SNOOZED_UNTIL_TOMORROW": "Sabaha qədər təxirə salındı",
"SNOOZED_UNTIL_NEXT_WEEK": "Gələn həftəyə qədər təxirə salındı", "SNOOZED_UNTIL_NEXT_WEEK": "Gələn həftəyə qədər təxirə salındı",
"SNOOZED_UNTIL_NEXT_REPLY": "Növbəti cavaba qədər təxirə salındı", "SNOOZED_UNTIL_NEXT_REPLY": "Növbəti cavaba qədər təxirə salındı",
"WHATSAPP_CALL": "Start WhatsApp call",
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
"VOICE_CALL": "Start call",
"VOICE_CALL_FAILED": "Could not start the call.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"SLA_STATUS": { "SLA_STATUS": {
"FRT": "FRT {status}", "FRT": "FRT {status}",
"NRT": "NRT {status}", "NRT": "NRT {status}",
@@ -231,7 +215,6 @@
"TIP_AUDIORECORDER_ICON": "Səs yaz", "TIP_AUDIORECORDER_ICON": "Səs yaz",
"TIP_AUDIORECORDER_PERMISSION": "Səsə girişə icazə ver", "TIP_AUDIORECORDER_PERMISSION": "Səsə girişə icazə ver",
"TIP_AUDIORECORDER_ERROR": "Səsi açmaq mümkün olmadı", "TIP_AUDIORECORDER_ERROR": "Səsi açmaq mümkün olmadı",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Qoşmaq üçün buraya sürükləyin və buraxın", "DRAG_DROP": "Qoşmaq üçün buraya sürükləyin və buraxın",
"START_AUDIO_RECORDING": "Səs yazısını başla", "START_AUDIO_RECORDING": "Səs yazısını başla",
"STOP_AUDIO_RECORDING": "Səs yazısını dayandırın", "STOP_AUDIO_RECORDING": "Səs yazısını dayandırın",
@@ -316,13 +299,8 @@
"NOT_ANSWERED_YET": "Hələ cavab verilməyib", "NOT_ANSWERED_YET": "Hələ cavab verilməyib",
"HANDLED_IN_ANOTHER_TAB": "Başqa sekmədə işlənir", "HANDLED_IN_ANOTHER_TAB": "Başqa sekmədə işlənir",
"REJECT_CALL": "İmtina et", "REJECT_CALL": "İmtina et",
"DISMISS_CALL": "Dismiss",
"JOIN_CALL": "Zəngə qoşul", "JOIN_CALL": "Zəngə qoşul",
"END_CALL": "Zəngi bitir", "END_CALL": "Zəngi bitir"
"MUTE": "Mute mic",
"UNMUTE": "Unmute mic",
"VIEW_CHAT_HISTORY": "View chat history",
"GO_TO_CONVERSATION": "Go to conversation thread"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {
@@ -607,12 +607,9 @@
"DRAFT": "Qaralama", "DRAFT": "Qaralama",
"ARCHIVE": "Archive", "ARCHIVE": "Archive",
"TRANSLATE": "Tərcümə et", "TRANSLATE": "Tərcümə et",
"MOVE_TO_CATEGORY": "Category",
"DELETE": "Delete", "DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully", "STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles", "STATUS_ERROR": "Failed to update articles",
"CATEGORY_SUCCESS": "Articles moved successfully",
"CATEGORY_ERROR": "Failed to move articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles", "DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.", "DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "Delete", "DELETE_CONFIRM": "Delete",
@@ -869,28 +866,6 @@
}, },
"EDIT_CONFIGURATION": "Edit configuration" "EDIT_CONFIGURATION": "Edit configuration"
}, },
"LAYOUT_CONTENT": {
"HEADER": "Appearance",
"DESCRIPTION": "Pick the layout that fits how your visitors read.",
"LAYOUT": {
"CLASSIC": {
"TITLE": "Classic",
"DESCRIPTION": "A welcoming home page with search and featured topics."
},
"SIDEBAR": {
"TITLE": "Documentation",
"DESCRIPTION": "Side-by-side navigation that keeps every guide a click away."
}
},
"SOCIAL_LINKS": {
"HEADER": "Social links",
"DESCRIPTION": "Add the handle for each network and your help center builds the full link. Shown in the documentation layout footer.",
"PLACEHOLDER": "handle",
"ADD": "Add social link",
"REMOVE": "Sil"
},
"SAVE": "Save changes"
},
"API": { "API": {
"CREATE_PORTAL": { "CREATE_PORTAL": {
"SUCCESS_MESSAGE": "Portal created successfully", "SUCCESS_MESSAGE": "Portal created successfully",
@@ -308,7 +308,6 @@
"AUTH_PROCESSING": "Meta ilə autentifikasiya olunur", "AUTH_PROCESSING": "Meta ilə autentifikasiya olunur",
"WAITING_FOR_BUSINESS_INFO": "Zəhmət olmasa Meta pəncərəsində biznes quraşdırmasını tamamlayın...", "WAITING_FOR_BUSINESS_INFO": "Zəhmət olmasa Meta pəncərəsində biznes quraşdırmasını tamamlayın...",
"PROCESSING": "WhatsApp Biznes Hesabınızı quraşdırırıq", "PROCESSING": "WhatsApp Biznes Hesabınızı quraşdırırıq",
"ENABLING_CALLING": "Enabling WhatsApp Calling on your number…",
"LOADING_SDK": "Facebook SDK yüklənir...", "LOADING_SDK": "Facebook SDK yüklənir...",
"CANCELLED": "WhatsApp Qeydiyyatı ləğv edildi", "CANCELLED": "WhatsApp Qeydiyyatı ləğv edildi",
"SUCCESS_TITLE": "WhatsApp Biznes Hesabı Qoşuldu!", "SUCCESS_TITLE": "WhatsApp Biznes Hesabı Qoşuldu!",
@@ -318,8 +317,7 @@
"AUTH_NOT_COMPLETED": "Autentifikasiya tamamlanmadı. Zəhmət olmasa prosesi yenidən başladın.", "AUTH_NOT_COMPLETED": "Autentifikasiya tamamlanmadı. Zəhmət olmasa prosesi yenidən başladın.",
"SUCCESS_FALLBACK": "WhatsApp Biznes Hesabı uğurla konfiqurasiya edildi", "SUCCESS_FALLBACK": "WhatsApp Biznes Hesabı uğurla konfiqurasiya edildi",
"MANUAL_FALLBACK": "Əgər nömrəniz artıq WhatsApp Business Platformasına (API) qoşulubsa və ya texnoloji təminatçı olaraq öz nömrənizi əlavə edirsinizsə, zəhmət olmasa {link} prosesindən istifadə edin", "MANUAL_FALLBACK": "Əgər nömrəniz artıq WhatsApp Business Platformasına (API) qoşulubsa və ya texnoloji təminatçı olaraq öz nömrənizi əlavə edirsinizsə, zəhmət olmasa {link} prosesindən istifadə edin",
"MANUAL_LINK_TEXT": "əl ilə qurma prosesi", "MANUAL_LINK_TEXT": "əl ilə qurma prosesi"
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
}, },
"API": { "API": {
"ERROR_MESSAGE": "WhatsApp kanalını yadda saxlaya bilmədik" "ERROR_MESSAGE": "WhatsApp kanalını yadda saxlaya bilmədik"
@@ -467,10 +465,6 @@
"TITLE": "WhatsApp", "TITLE": "WhatsApp",
"DESCRIPTION": "Müştərilərinizi WhatsApp-da dəstəkləyin" "DESCRIPTION": "Müştərilərinizi WhatsApp-da dəstəkləyin"
}, },
"WHATSAPP_CALL": {
"TITLE": "WhatsApp Call",
"DESCRIPTION": "Take voice calls on your WhatsApp number"
},
"EMAIL": { "EMAIL": {
"TITLE": "Elektron Poçt", "TITLE": "Elektron Poçt",
"DESCRIPTION": "Gmail, Outlook və ya digər təminatçılarla qoşulun" "DESCRIPTION": "Gmail, Outlook və ya digər təminatçılarla qoşulun"
@@ -643,8 +637,7 @@
"BOT_CONFIGURATION": "Bot Konfiqurasiyası", "BOT_CONFIGURATION": "Bot Konfiqurasiyası",
"ACCOUNT_HEALTH": "Hesabın sağlamlığı", "ACCOUNT_HEALTH": "Hesabın sağlamlığı",
"CSAT": "CSAT", "CSAT": "CSAT",
"VOICE": "Səs", "VOICE": "Səs"
"CALLS": "Calls"
}, },
"VOICE_CONFIGURATION": { "VOICE_CONFIGURATION": {
"ENABLE_VOICE": { "ENABLE_VOICE": {
@@ -655,26 +648,6 @@
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections." "DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
} }
}, },
"WHATSAPP_CALLING": {
"ENABLE": {
"LABEL": "Enable WhatsApp Calling",
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
},
"ENABLE_FAILED": "Voice calling couldn't be turned on for this number — it isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then try again.",
"PHONE_NUMBER": {
"LABEL": "Business phone number",
"HELP_TEXT": "WhatsApp number that customers will call."
},
"HOW_IT_WORKS": {
"LABEL": "How it works",
"DESCRIPTION": "Calls are placed peer-to-peer between the agent's browser and Meta — no extra credentials are required. Make sure the agent's browser has microphone permission for this site."
},
"PERMISSION_REQUEST_BODY": {
"LABEL": "Call permission request message",
"HELP_TEXT": "Shown to the contact when they haven't yet consented to receive calls. Leave blank to use the default.",
"PLACEHOLDER": "We would like to call you regarding your conversation."
}
},
"CHANNEL_PREFERENCES": "Kanal üstünlükləri", "CHANNEL_PREFERENCES": "Kanal üstünlükləri",
"WIDGET_FEATURES": "Widget xüsusiyyətləri", "WIDGET_FEATURES": "Widget xüsusiyyətləri",
"ACCOUNT_HEALTH": { "ACCOUNT_HEALTH": {
@@ -827,10 +800,6 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Mövcud şablonlarınızı yeniləmək üçün WhatsApp-dan mesaj şablonlarını əl ilə sinxronlaşdırın.", "WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Mövcud şablonlarınızı yeniləmək üçün WhatsApp-dan mesaj şablonlarını əl ilə sinxronlaşdırın.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Şablonları sinxronlaşdır", "WHATSAPP_TEMPLATES_SYNC_BUTTON": "Şablonları sinxronlaşdır",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Şablonların sinxronizasiyası uğurla başlandı. Yenilənməsi bir neçə dəqiqə çəkə bilər.", "WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Şablonların sinxronizasiyası uğurla başlandı. Yenilənməsi bir neçə dəqiqə çəkə bilər.",
"WHATSAPP_CALLING_ENABLED": {
"LABEL": "Enable voice calling",
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
},
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Pre Chat Form Parametrlərini Yeniləyin" "UPDATE_PRE_CHAT_FORM_SETTINGS": "Pre Chat Form Parametrlərini Yeniləyin"
}, },
"HELP_CENTER": { "HELP_CENTER": {
@@ -57,8 +57,7 @@
"CONTACT_CREATED": "Əlaqə yaradıldı", "CONTACT_CREATED": "Əlaqə yaradıldı",
"CONTACT_UPDATED": "Əlaqə yeniləndi", "CONTACT_UPDATED": "Əlaqə yeniləndi",
"CONVERSATION_TYPING_ON": "Söhbət Yazılır", "CONVERSATION_TYPING_ON": "Söhbət Yazılır",
"CONVERSATION_TYPING_OFF": "Söhbət Yazması Söndürülüb", "CONVERSATION_TYPING_OFF": "Söhbət Yazması Söndürülüb"
"INBOX_UPDATED": "Inbox updated"
} }
}, },
"NAME": { "NAME": {
@@ -1,6 +1,11 @@
{ {
"BULK_ACTION": { "BULK_ACTION": {
"CONVERSATIONS_SELECTED": "{conversationCount} conversations selected", "CONVERSATIONS_SELECTED": "{conversationCount} conversations selected",
"AGENT_SELECT_LABEL": "Изберете агент",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {conversationCount} {conversationLabel} to",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"NONE": "None", "NONE": "None",
"CLEAR_SELECTION": "Clear", "CLEAR_SELECTION": "Clear",
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?", "ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
@@ -15,6 +20,7 @@
"RESOLVE_SUCCESFUL": "Conversations resolved successfully.", "RESOLVE_SUCCESFUL": "Conversations resolved successfully.",
"RESOLVE_FAILED": "Failed to resolve conversations. Please try again.", "RESOLVE_FAILED": "Failed to resolve conversations. Please try again.",
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.", "ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
"AGENT_LIST_LOADING": "Loading agents",
"UPDATE": { "UPDATE": {
"CHANGE_STATUS": "Change status", "CHANGE_STATUS": "Change status",
"SNOOZE_UNTIL": "Snooze", "SNOOZE_UNTIL": "Snooze",
@@ -27,16 +33,16 @@
}, },
"LABELS": { "LABELS": {
"ASSIGN_LABELS": "Assign labels", "ASSIGN_LABELS": "Assign labels",
"REMOVE_LABELS": "Remove labels", "NO_LABELS_FOUND": "Няма намерени етикети",
"ASSIGN_SELECTED_LABELS": "Assign selected labels", "ASSIGN_SELECTED_LABELS": "Assign selected labels",
"REMOVE_SELECTED_LABELS": "Remove selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully.", "ASSIGN_SUCCESFUL": "Labels assigned successfully.",
"ASSIGN_FAILED": "Failed to assign labels. Please try again.", "ASSIGN_FAILED": "Failed to assign labels. Please try again."
"REMOVE_SUCCESFUL": "Labels removed successfully.",
"REMOVE_FAILED": "Failed to remove labels. Please try again."
}, },
"TEAMS": { "TEAMS": {
"TEAM_SELECT_LABEL": "Изберете екип",
"NONE": "Нито един", "NONE": "Нито един",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?", "ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?", "UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "Teams assigned successfully.", "ASSIGN_SUCCESFUL": "Teams assigned successfully.",
@@ -88,7 +88,6 @@
}, },
"CARD": { "CARD": {
"STATUS": { "STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "Завършено", "COMPLETED": "Завършено",
"SCHEDULED": "Scheduled" "SCHEDULED": "Scheduled"
}, },
@@ -147,7 +146,6 @@
}, },
"CARD": { "CARD": {
"STATUS": { "STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "Завършено", "COMPLETED": "Завършено",
"SCHEDULED": "Scheduled" "SCHEDULED": "Scheduled"
}, },
@@ -63,7 +63,6 @@
"CODE": "Code", "CODE": "Code",
"BULLET_LIST": "Bullet List", "BULLET_LIST": "Bullet List",
"ORDERED_LIST": "Ordered List", "ORDERED_LIST": "Ordered List",
"TABLE": "Table", "TABLE": "Table"
"IMAGE": "Image"
} }
} }
@@ -20,8 +20,6 @@
"CALL": "Call", "CALL": "Call",
"CALL_INITIATED": "Calling the contact…", "CALL_INITIATED": "Calling the contact…",
"CALL_FAILED": "Unable to start the call. Please try again.", "CALL_FAILED": "Unable to start the call. Please try again.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"CLICK_TO_EDIT": "Click to edit", "CLICK_TO_EDIT": "Click to edit",
"VOICE_INBOX_PICKER": { "VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox" "TITLE": "Choose a voice inbox"
@@ -585,11 +583,8 @@
}, },
"CONTACTS_BULK_ACTIONS": { "CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Assign Labels", "ASSIGN_LABELS": "Assign Labels",
"REMOVE_LABELS": "Remove Labels",
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.", "ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels", "ASSIGN_LABELS_FAILED": "Failed to assign labels",
"REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
"REMOVE_LABELS_FAILED": "Failed to remove labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.", "DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.", "NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected", "SELECTED_COUNT": "{count} selected",
@@ -62,7 +62,6 @@
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.", "UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.", "UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
"UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.", "UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.",
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully", "SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again", "FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "No response", "NO_RESPONSE": "No response",
@@ -80,22 +79,13 @@
"OUTGOING_CALL": "Outgoing call", "OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress", "CALL_IN_PROGRESS": "Call in progress",
"NO_ANSWER": "No answer", "NO_ANSWER": "No answer",
"NO_ANSWER_OUTBOUND_LABEL": "No answer",
"NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up",
"MISSED_CALL": "Missed call", "MISSED_CALL": "Missed call",
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
"CALL_ENDED": "Call ended", "CALL_ENDED": "Call ended",
"HANDLED_BY": "Handled by {agentName}",
"NOT_ANSWERED_YET": "Not answered yet", "NOT_ANSWERED_YET": "Not answered yet",
"CALLING": "Calling…",
"THEY_ANSWERED": "They answered", "THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered", "YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered", "AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Join call", "JOIN_CALL": "Join call"
"CALL_BACK": "Call back",
"TRANSCRIPT_SHOW_MORE": "Show more",
"TRANSCRIPT_SHOW_LESS": "Show less"
}, },
"HEADER": { "HEADER": {
"RESOLVE_ACTION": "Resolve", "RESOLVE_ACTION": "Resolve",
@@ -110,12 +100,6 @@
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow", "SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week", "SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply", "SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
"WHATSAPP_CALL": "Start WhatsApp call",
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
"VOICE_CALL": "Start call",
"VOICE_CALL_FAILED": "Could not start the call.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"SLA_STATUS": { "SLA_STATUS": {
"FRT": "FRT {status}", "FRT": "FRT {status}",
"NRT": "NRT {status}", "NRT": "NRT {status}",
@@ -231,7 +215,6 @@
"TIP_AUDIORECORDER_ICON": "Record audio", "TIP_AUDIORECORDER_ICON": "Record audio",
"TIP_AUDIORECORDER_PERMISSION": "Allow access to audio", "TIP_AUDIORECORDER_PERMISSION": "Allow access to audio",
"TIP_AUDIORECORDER_ERROR": "Could not open the audio", "TIP_AUDIORECORDER_ERROR": "Could not open the audio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Drag and drop here to attach", "DRAG_DROP": "Drag and drop here to attach",
"START_AUDIO_RECORDING": "Start audio recording", "START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording", "STOP_AUDIO_RECORDING": "Stop audio recording",
@@ -316,13 +299,8 @@
"NOT_ANSWERED_YET": "Not answered yet", "NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab", "HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject", "REJECT_CALL": "Reject",
"DISMISS_CALL": "Dismiss",
"JOIN_CALL": "Join call", "JOIN_CALL": "Join call",
"END_CALL": "End call", "END_CALL": "End call"
"MUTE": "Mute mic",
"UNMUTE": "Unmute mic",
"VIEW_CHAT_HISTORY": "View chat history",
"GO_TO_CONVERSATION": "Go to conversation thread"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {

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