Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c361a21d68 | ||
|
|
f49694f8c9 |
@@ -76,7 +76,7 @@ gem 'faraday_middleware-aws-sigv4'
|
||||
##--- gems for server & infra configuration ---##
|
||||
gem 'dotenv-rails', '>= 3.0.0'
|
||||
gem 'foreman'
|
||||
gem 'puma', '~> 7.2', '>= 7.2.1'
|
||||
gem 'puma'
|
||||
gem 'vite_rails'
|
||||
# metrics on heroku
|
||||
gem 'barnes'
|
||||
|
||||
+27
-40
@@ -136,8 +136,6 @@ GEM
|
||||
audited (5.4.1)
|
||||
activerecord (>= 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)
|
||||
actionmailbox (>= 7.1.0)
|
||||
aws-sdk-s3 (~> 1, >= 1.123.0)
|
||||
@@ -170,7 +168,7 @@ GEM
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.3.1)
|
||||
bigdecimal (3.2.2)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.16.0)
|
||||
msgpack (~> 1.2)
|
||||
@@ -186,7 +184,6 @@ GEM
|
||||
bundler (>= 1.2.0, < 3)
|
||||
thor (~> 1.0)
|
||||
byebug (11.1.3)
|
||||
cgi (0.5.1)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
cld3 (3.7.0)
|
||||
@@ -315,7 +312,7 @@ GEM
|
||||
hashie
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.4)
|
||||
faraday-net_http (3.4.2)
|
||||
net-http (~> 0.5)
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
@@ -438,8 +435,7 @@ GEM
|
||||
hana (1.3.7)
|
||||
hash_diff (1.1.1)
|
||||
hashdiff (1.1.0)
|
||||
hashie (5.1.0)
|
||||
logger
|
||||
hashie (5.0.0)
|
||||
html2text (0.4.0)
|
||||
nokogiri (>= 1.0, < 2.0)
|
||||
http (5.1.1)
|
||||
@@ -474,7 +470,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.19.8)
|
||||
json (2.19.5)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -572,7 +568,7 @@ GEM
|
||||
ruby2_keywords
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.9.1)
|
||||
multi_xml (0.8.0)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
@@ -582,7 +578,7 @@ GEM
|
||||
uri (>= 0.11.1)
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.6.4.1)
|
||||
net-imap (0.4.24)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -597,7 +593,7 @@ GEM
|
||||
sidekiq
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.5)
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.19.3)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
@@ -607,26 +603,19 @@ GEM
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.6)
|
||||
auth-sanitizer (~> 0.2, >= 0.2.1)
|
||||
base64 (~> 0.1)
|
||||
cgi
|
||||
oauth-tty (~> 1.0, >= 1.0.8)
|
||||
snaky_hash (~> 2.0, >= 2.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.11)
|
||||
oauth-tty (1.0.8)
|
||||
auth-sanitizer (~> 0.1, >= 0.1.3)
|
||||
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)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
oauth-tty (1.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.1)
|
||||
oauth2 (2.0.9)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
jwt (>= 1.0, < 3.0)
|
||||
multi_xml (~> 0.5)
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0, >= 2.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.11)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
@@ -693,7 +682,7 @@ GEM
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (7.0.5)
|
||||
puma (7.2.1)
|
||||
puma (6.4.3)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.3.0)
|
||||
activesupport (>= 3.0.0)
|
||||
@@ -946,9 +935,9 @@ GEM
|
||||
gli
|
||||
hashie
|
||||
logger
|
||||
snaky_hash (2.0.5)
|
||||
hashie (>= 0.1.0, < 6)
|
||||
version_gem (>= 1.1.8, < 3)
|
||||
snaky_hash (2.0.1)
|
||||
hashie
|
||||
version_gem (~> 1.1, >= 1.1.1)
|
||||
sorbet-runtime (0.5.11934)
|
||||
spring (4.1.1)
|
||||
spring-watcher-listen (2.1.0)
|
||||
@@ -1006,14 +995,12 @@ GEM
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
mail (~> 2.5)
|
||||
version_gem (1.1.11)
|
||||
vite_rails (3.10.0)
|
||||
railties (>= 5.1, < 9)
|
||||
version_gem (1.1.4)
|
||||
vite_rails (3.0.17)
|
||||
railties (>= 5.1, < 8)
|
||||
vite_ruby (~> 3.0, >= 3.2.2)
|
||||
vite_ruby (3.10.2)
|
||||
vite_ruby (3.8.0)
|
||||
dry-cli (>= 0.7, < 2)
|
||||
logger (~> 1.6)
|
||||
mutex_m
|
||||
rack-proxy (~> 0.6, >= 0.6.1)
|
||||
zeitwerk (~> 2.2)
|
||||
warden (1.2.9)
|
||||
@@ -1143,7 +1130,7 @@ DEPENDENCIES
|
||||
pgvector
|
||||
procore-sift
|
||||
pry-rails
|
||||
puma (~> 7.2, >= 7.2.1)
|
||||
puma
|
||||
pundit
|
||||
rack-attack (>= 6.7.0)
|
||||
rack-cors (= 2.0.0)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.14.2
|
||||
4.14.0
|
||||
|
||||
@@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder
|
||||
|
||||
def create_contact
|
||||
account.contacts.create!(
|
||||
name: contact_name,
|
||||
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
|
||||
phone_number: contact_attributes[:phone_number],
|
||||
email: contact_attributes[:email],
|
||||
identifier: contact_attributes[:identifier],
|
||||
@@ -59,11 +59,6 @@ class ContactInboxWithContactBuilder
|
||||
)
|
||||
end
|
||||
|
||||
def contact_name
|
||||
name = contact_attributes[:name] || ::Haikunator.haikunate(1000)
|
||||
name.truncate(ApplicationRecord::MAX_STRING_COLUMN_LENGTH, omission: '')
|
||||
end
|
||||
|
||||
def find_contact
|
||||
contact = find_contact_by_identifier(contact_attributes[:identifier])
|
||||
contact ||= find_contact_by_email(contact_attributes[:email])
|
||||
|
||||
@@ -92,18 +92,10 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
def fallback_params(attachment)
|
||||
{
|
||||
fallback_title: attachment['title'],
|
||||
external_url: attachment['url'] || attachment.dig('payload', 'url')
|
||||
external_url: attachment['url']
|
||||
}
|
||||
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
|
||||
{
|
||||
account_id: @inbox.account_id,
|
||||
|
||||
@@ -13,7 +13,6 @@ class Messages::MessageBuilder
|
||||
@account = conversation.account
|
||||
@message_type = params[:message_type] || 'outgoing'
|
||||
@attachments = params[:attachments]
|
||||
@is_voice_message = ActiveModel::Type::Boolean.new.cast(params[:is_voice_message])
|
||||
@automation_rule = content_attributes&.dig(:automation_rule_id)
|
||||
return unless params.instance_of?(ActionController::Parameters)
|
||||
|
||||
@@ -57,25 +56,16 @@ class Messages::MessageBuilder
|
||||
file: uploaded_attachment
|
||||
)
|
||||
|
||||
attachment.file_type = attachment_file_type(uploaded_attachment)
|
||||
tag_voice_message(attachment)
|
||||
attachment.file_type = if uploaded_attachment.is_a?(String)
|
||||
file_type_by_signed_id(
|
||||
uploaded_attachment
|
||||
)
|
||||
else
|
||||
file_type(uploaded_attachment&.content_type)
|
||||
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
|
||||
return unless @conversation.inbox&.inbox_type == 'Email'
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
|
||||
enable_fb_login: '0',
|
||||
force_authentication: '1',
|
||||
response_type: 'code',
|
||||
state: generate_instagram_token(Current.account.id, params[:return_to])
|
||||
state: generate_instagram_token(Current.account.id)
|
||||
}
|
||||
)
|
||||
if redirect_url
|
||||
|
||||
@@ -6,7 +6,8 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
|
||||
{
|
||||
redirect_uri: "#{base_url}/microsoft/callback",
|
||||
scope: scope,
|
||||
state: state
|
||||
state: state,
|
||||
prompt: 'consent'
|
||||
}
|
||||
)
|
||||
if redirect_url
|
||||
|
||||
@@ -8,15 +8,7 @@ class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseC
|
||||
end
|
||||
|
||||
def state
|
||||
# The sgid purpose doubles as a return hint: onboarding tags it so the callback
|
||||
# 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'
|
||||
Current.account.to_sgid(expires_in: 15.minutes).to_s
|
||||
end
|
||||
|
||||
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,
|
||||
:name, :page_title, :slug, :archived,
|
||||
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
|
||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
|
||||
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
|
||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
|
||||
)
|
||||
end
|
||||
|
||||
def locale_translation_keys
|
||||
params.dig(:portal, :config, :locale_translations)&.keys || []
|
||||
end
|
||||
|
||||
def live_chat_widget_params
|
||||
permitted_params = params.permit(: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
|
||||
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
|
||||
|
||||
@@ -58,6 +58,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
|
||||
@account.custom_attributes.merge!(custom_attributes_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.save!
|
||||
end
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
class Api::V1::Profile::SessionsController < Api::BaseController
|
||||
before_action :set_session, only: [:destroy]
|
||||
|
||||
def index
|
||||
@sessions = current_user.user_sessions.where(client_id: active_token_client_ids).order(last_activity_at: :desc)
|
||||
@current_client_id = request.headers['client']
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @session.current?(request.headers['client'])
|
||||
render json: { error: I18n.t('profile_settings.sessions.cannot_revoke_current') }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
revoke_token!(@session.client_id)
|
||||
@session.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_session
|
||||
@session = current_user.user_sessions.find(params[:id])
|
||||
end
|
||||
|
||||
def revoke_token!(client_id)
|
||||
tokens = current_user.tokens
|
||||
tokens.delete(client_id)
|
||||
current_user.update!(tokens: tokens)
|
||||
end
|
||||
|
||||
def active_token_client_ids
|
||||
now = Time.current.to_i
|
||||
(current_user.tokens || {}).select { |_, v| v['expiry'].to_i > now }.keys
|
||||
end
|
||||
end
|
||||
@@ -3,7 +3,6 @@ class ApplicationController < ActionController::Base
|
||||
include RequestExceptionHandler
|
||||
include Pundit::Authorization
|
||||
include SwitchLocale
|
||||
include TrackSessionActivity
|
||||
|
||||
skip_before_action :verify_authenticity_token
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
module AccessTokenAuthHelper
|
||||
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/assignments' => ['create'],
|
||||
'api/v1/accounts/conversations/labels' => %w[index create]
|
||||
'api/v1/accounts/conversations/assignments' => ['create']
|
||||
}.freeze
|
||||
|
||||
def ensure_access_token
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
module TrackSessionActivity
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_action :update_session_activity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_session_activity
|
||||
return unless current_user
|
||||
return if request.headers['client'].blank?
|
||||
|
||||
UserSessionTrackingService.new(
|
||||
user: current_user,
|
||||
request: request,
|
||||
client_id: request.headers['client']
|
||||
).update_activity!
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Session activity update failed: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -24,7 +24,6 @@ class DashboardController < ActionController::Base
|
||||
DISABLE_USER_PROFILE_UPDATE
|
||||
DEPLOYMENT_ENV
|
||||
INSTALLATION_PRICING_PLAN
|
||||
MULTIPLE_CURRENCY_SUPPORTED
|
||||
].freeze
|
||||
|
||||
before_action :set_application_pack
|
||||
|
||||
@@ -20,7 +20,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
end
|
||||
|
||||
def render_create_success
|
||||
track_user_session
|
||||
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
|
||||
end
|
||||
|
||||
@@ -115,19 +114,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
def render_mfa_error(message_key, status = :bad_request)
|
||||
render json: { error: I18n.t(message_key) }, status: status
|
||||
end
|
||||
|
||||
def track_user_session
|
||||
client_id = @token&.try(:client) || response.headers['client']
|
||||
return unless client_id.present? && @resource.present?
|
||||
|
||||
UserSessionTrackingService.new(
|
||||
user: @resource,
|
||||
request: request,
|
||||
client_id: client_id
|
||||
).create_or_update!
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Session tracking failed: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
|
||||
|
||||
@@ -28,8 +28,6 @@ class Instagram::CallbacksController < ApplicationController
|
||||
@long_lived_token_response = exchange_for_long_lived_token(@response.token)
|
||||
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
|
||||
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
|
||||
else
|
||||
@@ -151,10 +149,6 @@ class Instagram::CallbacksController < ApplicationController
|
||||
verify_instagram_token(params[:state])
|
||||
end
|
||||
|
||||
def return_to
|
||||
instagram_token_return_to(params[:state])
|
||||
end
|
||||
|
||||
def oauth_code
|
||||
params[:code]
|
||||
end
|
||||
|
||||
@@ -14,11 +14,4 @@ class Microsoft::CallbacksController < OauthCallbackController
|
||||
def imap_address
|
||||
'outlook.office365.com'
|
||||
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
|
||||
|
||||
@@ -16,8 +16,6 @@ class OauthCallbackController < ApplicationController
|
||||
def handle_response
|
||||
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
|
||||
redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
|
||||
else
|
||||
@@ -46,7 +44,7 @@ class OauthCallbackController < ApplicationController
|
||||
|
||||
def update_channel(channel_email)
|
||||
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,
|
||||
provider: provider_name,
|
||||
provider_config: {
|
||||
@@ -57,13 +55,6 @@ class OauthCallbackController < ApplicationController
|
||||
})
|
||||
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
|
||||
raise NotImplementedError
|
||||
end
|
||||
@@ -90,19 +81,10 @@ class OauthCallbackController < ApplicationController
|
||||
decoded_token[0]
|
||||
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
|
||||
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
|
||||
|
||||
if (account = GlobalID::Locator.locate_signed(params[:state], for: 'onboarding'))
|
||||
@return_to = 'onboarding'
|
||||
else
|
||||
account = GlobalID::Locator.locate_signed(params[:state])
|
||||
end
|
||||
|
||||
account = GlobalID::Locator.locate_signed(params[:state])
|
||||
raise 'Invalid or expired state' if account.nil?
|
||||
|
||||
account
|
||||
@@ -112,11 +94,6 @@ class OauthCallbackController < ApplicationController
|
||||
@account ||= account_from_signed_id
|
||||
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
|
||||
def fallback_name
|
||||
users_data['email'].split('@').first.parameterize.titleize
|
||||
|
||||
@@ -30,8 +30,9 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
|
||||
def process_hmac
|
||||
return if params[:identifier_hash].blank? && !@inbox_channel.hmac_mandatory
|
||||
raise StandardError, 'HMAC failed: Invalid Identifier Hash Provided' unless valid_hmac?
|
||||
raise StandardError, 'HMAC failed: Identifier does not match contact' if hmac_identifier_conflict?
|
||||
|
||||
@contact_inbox.update(hmac_verified: true) if @contact_inbox.present?
|
||||
@contact_inbox.update(hmac_verified: true) if @contact_inbox.present? && hmac_identifier_matches?
|
||||
end
|
||||
|
||||
def valid_hmac?
|
||||
@@ -42,6 +43,24 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
|
||||
)
|
||||
end
|
||||
|
||||
# Rejects the request when the selected contact is already bound to a different identifier,
|
||||
# so a valid (identifier, identifier_hash) pair cannot be replayed against another contact.
|
||||
def hmac_identifier_conflict?
|
||||
return false if @contact_inbox.blank?
|
||||
|
||||
contact_identifier = @contact_inbox.contact.identifier.to_s
|
||||
contact_identifier.present? &&
|
||||
!ActiveSupport::SecurityUtils.secure_compare(contact_identifier, params[:identifier].to_s)
|
||||
end
|
||||
|
||||
# Only grants verified trust when the proven identifier matches the contact's own identifier.
|
||||
# A blank contact identifier never grants trust, preventing replay onto anonymous contacts.
|
||||
def hmac_identifier_matches?
|
||||
contact_identifier = @contact_inbox.contact.identifier.to_s
|
||||
contact_identifier.present? &&
|
||||
ActiveSupport::SecurityUtils.secure_compare(contact_identifier, params[:identifier].to_s)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {})
|
||||
end
|
||||
|
||||
@@ -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'
|
||||
|
||||
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
|
||||
|
||||
def sitemap
|
||||
|
||||
@@ -20,8 +20,6 @@ class Tiktok::CallbacksController < ApplicationController
|
||||
def process_successful_authorization
|
||||
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
|
||||
redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
|
||||
else
|
||||
@@ -129,10 +127,6 @@ class Tiktok::CallbacksController < ApplicationController
|
||||
@account_id ||= verify_tiktok_token(params[:state])
|
||||
end
|
||||
|
||||
def return_to
|
||||
tiktok_token_return_to(params[:state])
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= Account.find(account_id)
|
||||
end
|
||||
|
||||
@@ -12,7 +12,7 @@ class ContactDrop < BaseDrop
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def last_name
|
||||
|
||||
@@ -12,7 +12,7 @@ class UserDrop < BaseDrop
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def last_name
|
||||
|
||||
@@ -40,7 +40,7 @@ class ConversationFinder
|
||||
def perform
|
||||
set_up
|
||||
|
||||
mine_count, unassigned_count, all_count = set_count_for_all_conversations
|
||||
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
|
||||
assigned_count = all_count - unassigned_count
|
||||
|
||||
filter_by_assignee_type
|
||||
@@ -184,17 +184,6 @@ class ConversationFinder
|
||||
end
|
||||
|
||||
def set_count_for_all_conversations
|
||||
return legacy_count_for_all_conversations if @conversations.limit_value || @conversations.offset_value || @conversations.eager_loading?
|
||||
|
||||
counts = @conversations.unscope(:order).pick(
|
||||
Arel.sql("COUNT(*) FILTER (WHERE assignee_id = #{current_user.id})"),
|
||||
Arel.sql('COUNT(*) FILTER (WHERE assignee_id IS NULL)'),
|
||||
Arel.sql('COUNT(*)')
|
||||
)
|
||||
counts || [0, 0, 0]
|
||||
end
|
||||
|
||||
def legacy_count_for_all_conversations
|
||||
[
|
||||
@conversations.assigned_to(current_user).count,
|
||||
@conversations.unassigned.count,
|
||||
|
||||
@@ -22,16 +22,4 @@ module BillingHelper
|
||||
def agents(account)
|
||||
account.users.count
|
||||
end
|
||||
|
||||
# current_period_end moved to the subscription item in newer Stripe API versions; read both.
|
||||
def subscription_period_end(subscription)
|
||||
subscription['current_period_end'] || subscription['items']['data'].first&.[]('current_period_end')
|
||||
end
|
||||
|
||||
def subscription_ends_on(subscription)
|
||||
period_end = subscription_period_end(subscription)
|
||||
return if period_end.blank?
|
||||
|
||||
Time.zone.at(period_end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,21 +4,21 @@ module Instagram::IntegrationHelper
|
||||
# Generates a signed JWT token for Instagram integration
|
||||
#
|
||||
# @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
|
||||
def generate_instagram_token(account_id, return_to = nil)
|
||||
def generate_instagram_token(account_id)
|
||||
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
|
||||
Rails.logger.error("Failed to generate Instagram token: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def token_payload(account_id, return_to = nil)
|
||||
payload = { sub: account_id, iat: Time.current.to_i }
|
||||
payload[:return_to] = return_to if return_to.present?
|
||||
payload
|
||||
def token_payload(account_id)
|
||||
{
|
||||
sub: account_id,
|
||||
iat: Time.current.to_i
|
||||
}
|
||||
end
|
||||
|
||||
# Verifies and decodes a Instagram JWT token
|
||||
@@ -28,14 +28,7 @@ module Instagram::IntegrationHelper
|
||||
def verify_instagram_token(token)
|
||||
return if token.blank? || client_secret.blank?
|
||||
|
||||
decode_token(token, client_secret)&.dig('sub')
|
||||
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')
|
||||
decode_token(token, client_secret)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -48,7 +41,7 @@ module Instagram::IntegrationHelper
|
||||
JWT.decode(token, secret, true, {
|
||||
algorithm: 'HS256',
|
||||
verify_expiration: true
|
||||
}).first
|
||||
}).first['sub']
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}")
|
||||
nil
|
||||
|
||||
@@ -45,16 +45,9 @@ module PortalHelper
|
||||
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
|
||||
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)
|
||||
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
|
||||
"/hc/#{portal_slug}/#{portal_locale}"
|
||||
end
|
||||
@@ -68,7 +61,7 @@ module PortalHelper
|
||||
is_plain_layout_enabled = params[: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
|
||||
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}"
|
||||
end
|
||||
@@ -76,7 +69,7 @@ module PortalHelper
|
||||
|
||||
def generate_article_link(portal_slug, article_slug, theme, 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
|
||||
"/hc/#{portal_slug}/articles/#{article_slug}"
|
||||
end
|
||||
|
||||
@@ -34,12 +34,6 @@ disable_branding:
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-sailbot-fill'
|
||||
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 ------- #
|
||||
help_center:
|
||||
|
||||
@@ -2,12 +2,11 @@ module Tiktok::IntegrationHelper
|
||||
# Generates a signed JWT token for Tiktok integration
|
||||
#
|
||||
# @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
|
||||
def generate_tiktok_token(account_id, return_to = nil)
|
||||
def generate_tiktok_token(account_id)
|
||||
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
|
||||
Rails.logger.error("Failed to generate TikTok token: #{e.message}")
|
||||
nil
|
||||
@@ -20,14 +19,7 @@ module Tiktok::IntegrationHelper
|
||||
def verify_tiktok_token(token)
|
||||
return if token.blank? || client_secret.blank?
|
||||
|
||||
decode_token(token, client_secret)&.dig('sub')
|
||||
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')
|
||||
decode_token(token, client_secret)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -36,17 +28,18 @@ module Tiktok::IntegrationHelper
|
||||
@client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
|
||||
end
|
||||
|
||||
def token_payload(account_id, return_to = nil)
|
||||
payload = { sub: account_id, iat: Time.current.to_i }
|
||||
payload[:return_to] = return_to if return_to.present?
|
||||
payload
|
||||
def token_payload(account_id)
|
||||
{
|
||||
sub: account_id,
|
||||
iat: Time.current.to_i
|
||||
}
|
||||
end
|
||||
|
||||
def decode_token(token, secret)
|
||||
JWT.decode(token, secret, true, {
|
||||
algorithm: 'HS256',
|
||||
verify_expiration: true
|
||||
}).first
|
||||
}).first['sub']
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}")
|
||||
nil
|
||||
|
||||
@@ -106,10 +106,4 @@ export default {
|
||||
const urlData = endPoints('resetAccessToken');
|
||||
return axios.post(urlData.url);
|
||||
},
|
||||
getSessions() {
|
||||
return axios.get('/api/v1/profile/sessions');
|
||||
},
|
||||
revokeSession(id) {
|
||||
return axios.delete(`/api/v1/profile/sessions/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -48,12 +48,6 @@ class TwilioVoiceClient extends EventTarget {
|
||||
return !!this.activeConnection;
|
||||
}
|
||||
|
||||
setMuted(shouldMute) {
|
||||
if (!this.activeConnection) return false;
|
||||
this.activeConnection.mute(shouldMute);
|
||||
return shouldMute;
|
||||
}
|
||||
|
||||
endClientCall() {
|
||||
if (this.activeConnection) {
|
||||
this.activeConnection.disconnect();
|
||||
|
||||
@@ -27,15 +27,6 @@ class EnterpriseAccountAPI extends ApiClient {
|
||||
createTopupCheckout(credits) {
|
||||
return axios.post(`${this.url}topup_checkout`, { credits });
|
||||
}
|
||||
|
||||
// Topup packages for the account's billing currency.
|
||||
getTopupOptions() {
|
||||
return axios.get(`${this.url}topup_options`);
|
||||
}
|
||||
|
||||
switchCurrency(currency) {
|
||||
return axios.post(`${this.url}switch_currency`, { currency });
|
||||
}
|
||||
}
|
||||
|
||||
export default new EnterpriseAccountAPI();
|
||||
|
||||
@@ -12,7 +12,6 @@ export const buildCreatePayload = ({
|
||||
bccEmails = '',
|
||||
toEmails = '',
|
||||
templateParams,
|
||||
isVoiceMessage = false,
|
||||
}) => {
|
||||
let payload;
|
||||
if (files && files.length !== 0) {
|
||||
@@ -34,9 +33,6 @@ export const buildCreatePayload = ({
|
||||
if (contentAttributes) {
|
||||
payload.append('content_attributes', JSON.stringify(contentAttributes));
|
||||
}
|
||||
if (isVoiceMessage) {
|
||||
payload.append('is_voice_message', true);
|
||||
}
|
||||
} else {
|
||||
payload = {
|
||||
content: message,
|
||||
@@ -68,7 +64,6 @@ class MessageApi extends ApiClient {
|
||||
bccEmails = '',
|
||||
toEmails = '',
|
||||
templateParams,
|
||||
isVoiceMessage = false,
|
||||
}) {
|
||||
return axios({
|
||||
method: 'post',
|
||||
@@ -83,7 +78,6 @@ class MessageApi extends ApiClient {
|
||||
bccEmails,
|
||||
toEmails,
|
||||
templateParams,
|
||||
isVoiceMessage,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,12 +60,6 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
disableWhatsappCalling(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
|
||||
}
|
||||
|
||||
setInboundCalls(inboxId, enabled) {
|
||||
return axios.post(`${this.url}/${inboxId}/set_inbound_calls`, {
|
||||
inbound_calls_enabled: enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
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 STATUS_COMPLETED = 'completed';
|
||||
const STATUS_PROCESSING = 'processing';
|
||||
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
@@ -69,15 +68,9 @@ const campaignStatus = computed(() => {
|
||||
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
|
||||
}
|
||||
|
||||
if (props.status === STATUS_COMPLETED) {
|
||||
return t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED');
|
||||
}
|
||||
|
||||
if (props.status === STATUS_PROCESSING) {
|
||||
return t('CAMPAIGN.SMS.CARD.STATUS.PROCESSING');
|
||||
}
|
||||
|
||||
return t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
|
||||
return props.status === STATUS_COMPLETED
|
||||
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
|
||||
: t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
|
||||
});
|
||||
|
||||
const inboxName = computed(() => props.inbox?.name || '');
|
||||
|
||||
@@ -46,8 +46,9 @@ const formattedLastActivityAt = computed(() => {
|
||||
:src="avatarSource"
|
||||
class="shrink-0"
|
||||
:name="name"
|
||||
:size="42"
|
||||
:size="48"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
|
||||
|
||||
+1
@@ -141,6 +141,7 @@ const handleUpdateCompany = async () => {
|
||||
:src="avatarSource"
|
||||
:size="72"
|
||||
:allow-upload="!isAvatarBusy"
|
||||
rounded-full
|
||||
hide-offline-status
|
||||
@upload="handleAvatarUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
|
||||
@@ -124,9 +124,10 @@ const handleAvatarHover = isHovered => {
|
||||
<Avatar
|
||||
:name="name"
|
||||
:src="thumbnail"
|
||||
:size="42"
|
||||
:size="48"
|
||||
:status="availabilityStatus"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
>
|
||||
<template v-if="selectable" #overlay="{ size }">
|
||||
<label
|
||||
|
||||
+15
-29
@@ -1,48 +1,34 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
|
||||
const emit = defineEmits(['add', 'import', 'export']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const contactMenuItems = computed(() => [
|
||||
const contactMenuItems = [
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
|
||||
action: 'add',
|
||||
value: 'add',
|
||||
icon: 'i-lucide-plus',
|
||||
},
|
||||
...(checkPermissions(['administrator', 'contact_manage'])
|
||||
? [
|
||||
{
|
||||
label: t(
|
||||
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'
|
||||
),
|
||||
action: 'export',
|
||||
value: 'export',
|
||||
icon: 'i-lucide-upload',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(checkPermissions(['administrator', 'contact_manage'])
|
||||
? [
|
||||
{
|
||||
label: t(
|
||||
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'
|
||||
),
|
||||
action: 'import',
|
||||
value: 'import',
|
||||
icon: 'i-lucide-download',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'),
|
||||
action: 'export',
|
||||
value: 'export',
|
||||
icon: 'i-lucide-upload',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'),
|
||||
action: 'import',
|
||||
value: 'import',
|
||||
icon: 'i-lucide-download',
|
||||
},
|
||||
];
|
||||
const showActionsDropdown = ref(false);
|
||||
|
||||
const handleContactAction = ({ action }) => {
|
||||
|
||||
@@ -53,9 +53,6 @@ const localeMenuLabels = computed(() => ({
|
||||
'publish-locale': t(
|
||||
'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'),
|
||||
}));
|
||||
|
||||
@@ -131,7 +128,7 @@ const handleAction = ({ action, value }) => {
|
||||
<DropdownMenu
|
||||
v-if="showDropdownMenu"
|
||||
: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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
-98
@@ -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>
|
||||
import { ref } from '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 { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
@@ -25,8 +23,6 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const contentDialogRef = ref(null);
|
||||
|
||||
const isLocaleDefault = code => {
|
||||
return props.portal?.meta?.default_locale === code;
|
||||
};
|
||||
@@ -152,8 +148,6 @@ const handleAction = ({ action }, localeCode) => {
|
||||
moveLocaleToDraft({ localeCode: localeCode });
|
||||
} else if (action === 'publish-locale') {
|
||||
publishLocale({ localeCode: localeCode });
|
||||
} else if (action === 'customize-content') {
|
||||
contentDialogRef.value.openForLocale(localeCode);
|
||||
} else if (action === 'delete') {
|
||||
deletePortalLocale({ localeCode: localeCode });
|
||||
}
|
||||
@@ -173,6 +167,5 @@ const handleAction = ({ action }, localeCode) => {
|
||||
:category-count="locale.categoriesCount || 0"
|
||||
@action="handleAction($event, locale.code)"
|
||||
/>
|
||||
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
+1
-6
@@ -182,10 +182,7 @@ const MIN_SEARCH_LENGTH = 2;
|
||||
export const createContactSearcher = () => {
|
||||
let controller = null;
|
||||
|
||||
return async (
|
||||
query,
|
||||
{ skipMinLength = false, reachableOnly = true } = {}
|
||||
) => {
|
||||
return async (query, { skipMinLength = false } = {}) => {
|
||||
const trimmed = typeof query === 'string' ? query.trim() : '';
|
||||
|
||||
controller?.abort();
|
||||
@@ -202,8 +199,6 @@ export const createContactSearcher = () => {
|
||||
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
|
||||
|
||||
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
||||
if (!reachableOnly) return camelCasedPayload || [];
|
||||
|
||||
// Filter contacts that have either phone_number or email
|
||||
const filteredPayload = camelCasedPayload?.filter(
|
||||
contact => contact.phoneNumber || contact.email
|
||||
|
||||
@@ -197,9 +197,9 @@ const channelIcon = computed(() => {
|
||||
? $t('CONVERSATION.VOICE_WIDGET.END_CALL')
|
||||
: $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')
|
||||
"
|
||||
icon="i-ph-phone-bold"
|
||||
icon="i-ph-phone-x-bold"
|
||||
ruby
|
||||
class="!rounded-full rotate-[135deg]"
|
||||
class="!rounded-full rotate-[134deg]"
|
||||
@click="isOngoing ? $emit('end') : $emit('reject')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
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 countriesList from 'shared/constants/countries.js';
|
||||
|
||||
const RINGTONE_URL = '/audio/dashboard/ringtone.mp3';
|
||||
const RINGTONE_URL = '/audio/dashboard/bell.mp3';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -30,8 +29,8 @@ const {
|
||||
formattedCallDuration,
|
||||
} = useCallSession();
|
||||
|
||||
// Mute routes by provider: WhatsApp toggles the local mic track, Twilio uses
|
||||
// the Voice SDK connection's native mute. Both surface the same button.
|
||||
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
|
||||
// don't expose a mic track on the browser side.
|
||||
const isMuted = ref(false);
|
||||
const isWhatsappActive = computed(
|
||||
() => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
|
||||
@@ -64,11 +63,7 @@ const stackedCardState = call =>
|
||||
|
||||
const toggleMute = () => {
|
||||
isMuted.value = !isMuted.value;
|
||||
if (isWhatsappActive.value) {
|
||||
setWhatsappCallMuted(isMuted.value);
|
||||
} else {
|
||||
TwilioVoiceClient.setMuted(isMuted.value);
|
||||
}
|
||||
setWhatsappCallMuted(isMuted.value);
|
||||
};
|
||||
|
||||
watch(hasActiveCall, active => {
|
||||
@@ -261,7 +256,7 @@ onBeforeUnmount(stopRingtone);
|
||||
:call-info="getCallInfo(activeCall || primaryIncomingCall)"
|
||||
:duration="hasActiveCall ? formattedCallDuration : ''"
|
||||
:is-muted="isMuted"
|
||||
:show-mute="hasActiveCall"
|
||||
:show-mute="hasActiveCall && isWhatsappActive"
|
||||
@accept="handleJoinCall(primaryIncomingCall)"
|
||||
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
|
||||
@dismiss="dismissCall(primaryIncomingCall?.callSid)"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, h, watch, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import FilterSelect from './inputs/FilterSelect.vue';
|
||||
@@ -110,34 +109,6 @@ const inputFieldType = computed(() => {
|
||||
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 => {
|
||||
/**
|
||||
* 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);
|
||||
if (newInputType === 'multiSelect') {
|
||||
values.value = [];
|
||||
} else if (
|
||||
['searchSelect', 'asyncSearchSelect', 'booleanSelect'].includes(
|
||||
newInputType
|
||||
)
|
||||
) {
|
||||
} else if (['searchSelect', 'booleanSelect'].includes(newInputType)) {
|
||||
values.value = {};
|
||||
} else {
|
||||
values.value = '';
|
||||
}
|
||||
asyncOptions.value = [];
|
||||
filterOperator.value = newOperator.value;
|
||||
};
|
||||
|
||||
@@ -219,16 +185,6 @@ defineExpose({ validate, resetValidation });
|
||||
:options="currentFilter.options"
|
||||
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
|
||||
v-else-if="inputType === 'booleanSelect'"
|
||||
v-model="values"
|
||||
|
||||
@@ -7,7 +7,6 @@ export const CONVERSATION_ATTRIBUTES = {
|
||||
ASSIGNEE_ID: 'assignee_id',
|
||||
INBOX_ID: 'inbox_id',
|
||||
TEAM_ID: 'team_id',
|
||||
CONTACT_ID: 'contact_id',
|
||||
DISPLAY_ID: 'display_id',
|
||||
CAMPAIGN_ID: 'campaign_id',
|
||||
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 {
|
||||
CONVERSATION_ATTRIBUTES,
|
||||
getCustomAttributeInputType,
|
||||
buildAttributesFilterTypes,
|
||||
replaceUnderscoreWithSpace,
|
||||
} 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('getCustomAttributeInputType', () => {
|
||||
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 {
|
||||
options,
|
||||
asyncSearch,
|
||||
isSearching,
|
||||
disableSearch,
|
||||
disableDeselect,
|
||||
placeholderIcon,
|
||||
@@ -25,14 +23,6 @@ const {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
asyncSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSearching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableSearch: {
|
||||
type: Boolean,
|
||||
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 selected = defineModel({
|
||||
type: Object,
|
||||
@@ -76,9 +60,7 @@ const selected = defineModel({
|
||||
});
|
||||
|
||||
const searchTerm = ref('');
|
||||
|
||||
const searchResults = computed(() => {
|
||||
if (asyncSearch) return options;
|
||||
if (!options) return [];
|
||||
return picoSearch(options, searchTerm.value, ['name']);
|
||||
});
|
||||
@@ -95,11 +77,7 @@ const selectedItem = computed(() => {
|
||||
if (!optionToSearch) return null;
|
||||
// extract the selected item from the options array
|
||||
// this ensures that options like icon is also included
|
||||
return (
|
||||
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)
|
||||
);
|
||||
return options.find(option => option.id === optionToSearch.id);
|
||||
});
|
||||
|
||||
const toggleSelected = option => {
|
||||
@@ -153,19 +131,13 @@ const toggleSelected = option => {
|
||||
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
v-focus
|
||||
autofocus
|
||||
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
|
||||
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||
@input="emit('search', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
<DropdownSection :height="dropdownMaxHeight">
|
||||
<template v-if="isSearching">
|
||||
<DropdownItem disabled>
|
||||
{{ t('DROPDOWN_MENU.SEARCHING') }}
|
||||
</DropdownItem>
|
||||
</template>
|
||||
<template v-else-if="searchResults.length">
|
||||
<template v-if="searchResults.length">
|
||||
<DropdownItem
|
||||
v-for="option in searchResults"
|
||||
:key="option.id"
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useOperators } from './operators';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useChannelIcon } from 'next/icon/provider';
|
||||
import { createContactSearcher } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import {
|
||||
buildAttributesFilterTypes,
|
||||
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} 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 {'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 {'text'|'number'} dataType
|
||||
* @property {FilterOperator[]} filterOperators - The operators available for the attribute
|
||||
@@ -69,30 +68,6 @@ export function useConversationFilterContext() {
|
||||
getOperatorTypes,
|
||||
} = 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[]>}
|
||||
*/
|
||||
@@ -183,18 +158,6 @@ export function useConversationFilterContext() {
|
||||
filterOperators: presenceOperators.value,
|
||||
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,
|
||||
value: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
|
||||
|
||||
@@ -31,7 +31,6 @@ import FileBubble from './bubbles/File.vue';
|
||||
import AudioBubble from './bubbles/Audio.vue';
|
||||
import VideoBubble from './bubbles/Video.vue';
|
||||
import EmbedBubble from './bubbles/Embed.vue';
|
||||
import FallbackBubble from './bubbles/Fallback.vue';
|
||||
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
|
||||
import EmailBubble from './bubbles/Email/Index.vue';
|
||||
import UnsupportedBubble from './bubbles/Unsupported.vue';
|
||||
@@ -329,8 +328,6 @@ const componentToRender = computed(() => {
|
||||
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
|
||||
const fileType = props.attachments[0].fileType;
|
||||
|
||||
if (fileType === ATTACHMENT_TYPES.FALLBACK) return FallbackBubble;
|
||||
|
||||
if (!props.content) {
|
||||
if (fileType === ATTACHMENT_TYPES.IMAGE) return ImageBubble;
|
||||
if (fileType === ATTACHMENT_TYPES.FILE) return FileBubble;
|
||||
@@ -557,10 +554,11 @@ provideMessageContext({
|
||||
<Avatar v-bind="avatarInfo" :size="24" />
|
||||
</div>
|
||||
<div
|
||||
class="[grid-area:bubble] flex min-w-0"
|
||||
class="[grid-area:bubble] flex"
|
||||
:class="{
|
||||
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
||||
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
|
||||
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
||||
}"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
|
||||
@@ -95,7 +95,7 @@ const replyToPreview = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="text-sm min-w-0"
|
||||
class="text-sm"
|
||||
:class="[
|
||||
messageClass,
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
@@ -175,9 +175,6 @@ useEventListener(document, 'touchend', onResizeEnd);
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const labels = useMapGetter('labels/getLabelsOnSidebar');
|
||||
const allUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getAllUnreadCount'
|
||||
);
|
||||
const getInboxUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getInboxUnreadCount'
|
||||
);
|
||||
@@ -300,22 +297,18 @@ const menuItems = computed(() => {
|
||||
{
|
||||
name: 'All',
|
||||
label: t('SIDEBAR.ALL_CONVERSATIONS'),
|
||||
icon: 'i-lucide-inbox',
|
||||
badgeCount: allUnreadCount.value,
|
||||
activeOn: ['inbox_conversation'],
|
||||
to: accountScopedRoute('home'),
|
||||
},
|
||||
{
|
||||
name: 'Mentions',
|
||||
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
|
||||
icon: 'i-lucide-at-sign',
|
||||
activeOn: ['conversation_through_mentions'],
|
||||
to: accountScopedRoute('conversation_mentions'),
|
||||
},
|
||||
{
|
||||
name: 'Participating',
|
||||
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
|
||||
icon: 'i-lucide-user-round-check',
|
||||
activeOn: ['conversation_through_participating'],
|
||||
to: accountScopedRoute('conversation_participating'),
|
||||
},
|
||||
@@ -323,7 +316,6 @@ const menuItems = computed(() => {
|
||||
name: 'Unattended',
|
||||
activeOn: ['conversation_through_unattended'],
|
||||
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
|
||||
icon: 'i-lucide-clock-alert',
|
||||
to: accountScopedRoute('conversation_unattended'),
|
||||
},
|
||||
{
|
||||
@@ -331,8 +323,6 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.CUSTOM_VIEWS_FOLDER'),
|
||||
icon: 'i-lucide-folder',
|
||||
activeOn: ['conversations_through_folders'],
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: conversationCustomViews.value.map(view => ({
|
||||
name: `${view.name}-${view.id}`,
|
||||
label: view.name,
|
||||
@@ -344,8 +334,6 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.TEAMS'),
|
||||
icon: 'i-lucide-users',
|
||||
activeOn: ['conversations_through_team'],
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: sortedTeams.value.map(team => ({
|
||||
name: `${team.name}-${team.id}`,
|
||||
label: team.name,
|
||||
@@ -358,8 +346,6 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.CHANNELS'),
|
||||
icon: 'i-lucide-mailbox',
|
||||
activeOn: ['conversation_through_inbox'],
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: sortedInboxes.value.map(inbox => ({
|
||||
name: `${inbox.name}-${inbox.id}`,
|
||||
label: inbox.name,
|
||||
@@ -380,8 +366,6 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.LABELS'),
|
||||
icon: 'i-lucide-tag',
|
||||
activeOn: ['conversations_through_label'],
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: sortedLabels.value.map(label => ({
|
||||
name: `${label.title}-${label.id}`,
|
||||
label: label.title,
|
||||
@@ -493,8 +477,6 @@ const menuItems = computed(() => {
|
||||
name: 'Segments',
|
||||
icon: 'i-lucide-group',
|
||||
label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'),
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: contactCustomViews.value.map(view => ({
|
||||
name: `${view.name}-${view.id}`,
|
||||
label: view.name,
|
||||
@@ -513,8 +495,6 @@ const menuItems = computed(() => {
|
||||
name: 'Tagged With',
|
||||
icon: 'i-lucide-tag',
|
||||
label: t('SIDEBAR.TAGGED_WITH'),
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: labels.value.map(label => ({
|
||||
name: `${label.title}-${label.id}`,
|
||||
label: label.title,
|
||||
|
||||
@@ -99,39 +99,20 @@ const handleWindowBlur = () => {
|
||||
closeActivePopover();
|
||||
};
|
||||
|
||||
const hasAccessibleSubChildren = child => {
|
||||
return child.children?.some(
|
||||
subChild => subChild.to && isAllowed(subChild.to)
|
||||
);
|
||||
};
|
||||
|
||||
const visibleChildren = computed(() => {
|
||||
const accessibleItems = computed(() => {
|
||||
if (!hasChildren.value) return [];
|
||||
|
||||
return props.children.filter(child => {
|
||||
if (child.children) return hasAccessibleSubChildren(child);
|
||||
|
||||
// If a item has no link, it means it's just a subgroup header
|
||||
// So we don't need to check for permissions here, because there's nothing to
|
||||
// access here anyway
|
||||
return child.to && isAllowed(child.to);
|
||||
});
|
||||
});
|
||||
|
||||
const accessibleItems = computed(() => {
|
||||
if (!hasChildren.value) return [];
|
||||
|
||||
return visibleChildren.value
|
||||
.flatMap(child => child.children || child)
|
||||
.filter(child => child.to && isAllowed(child.to));
|
||||
});
|
||||
|
||||
const hasAccessibleChildren = computed(() => {
|
||||
return visibleChildren.value.length > 0;
|
||||
return accessibleItems.value.length > 0;
|
||||
});
|
||||
|
||||
const isLastVisibleChild = child => {
|
||||
const lastChild = visibleChildren.value[visibleChildren.value.length - 1];
|
||||
return lastChild === child;
|
||||
};
|
||||
|
||||
const isActive = computed(() => {
|
||||
if (props.to) {
|
||||
if (route.path === resolvePath(props.to)) return true;
|
||||
@@ -293,18 +274,14 @@ watch(
|
||||
<ul
|
||||
v-if="hasChildren"
|
||||
v-show="isExpanded || hasActiveChild"
|
||||
class="grid m-0 list-none min-w-0"
|
||||
class="grid m-0 list-none sidebar-group-children min-w-0"
|
||||
>
|
||||
<template v-for="child in visibleChildren" :key="child.name">
|
||||
<template v-for="child in children" :key="child.name">
|
||||
<SidebarSubGroup
|
||||
v-if="child.children"
|
||||
:name="`${name}:${child.name}`"
|
||||
:label="child.label"
|
||||
:icon="child.icon"
|
||||
:children="child.children"
|
||||
:collapsible="child.collapsible"
|
||||
:show-tree-line="child.showTreeLine"
|
||||
:end-tree-line="child.showTreeLine && isLastVisibleChild(child)"
|
||||
:is-expanded="isExpanded"
|
||||
:active-child="activeChild"
|
||||
/>
|
||||
@@ -322,3 +299,59 @@ watch(
|
||||
</template>
|
||||
</Policy>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.sidebar-group-children .child-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 0.125rem;
|
||||
/* 0.5px */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar-group-children .child-item:first-child::before {
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
/* This selects the last child in a group */
|
||||
/* https://codepen.io/scmmishra/pen/yLmKNLW */
|
||||
.sidebar-group-children > .child-item:last-child::before,
|
||||
.sidebar-group-children
|
||||
> *:last-child
|
||||
> *:last-child
|
||||
> .child-item:last-child::before {
|
||||
height: 20%;
|
||||
}
|
||||
|
||||
.sidebar-group-children > .child-item:last-child::after,
|
||||
.sidebar-group-children
|
||||
> *:last-child
|
||||
> *:last-child
|
||||
> .child-item:last-child::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 12px;
|
||||
bottom: calc(50% - 2px);
|
||||
border-bottom-width: 0.125rem;
|
||||
border-left-width: 0.125rem;
|
||||
border-right-width: 0px;
|
||||
border-top-width: 0px;
|
||||
border-radius: 0 0 0 4px;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#app[dir='rtl'] .sidebar-group-children > .child-item:last-child::after,
|
||||
#app[dir='rtl']
|
||||
.sidebar-group-children
|
||||
> *:last-child
|
||||
> *:last-child
|
||||
> .child-item:last-child::after {
|
||||
right: 0;
|
||||
border-bottom-width: 0.125rem;
|
||||
border-right-width: 0.125rem;
|
||||
border-left-width: 0px;
|
||||
border-top-width: 0px;
|
||||
border-radius: 0 0 4px 0px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,8 +12,6 @@ const props = defineProps({
|
||||
active: { type: Boolean, default: false },
|
||||
component: { type: Function, default: null },
|
||||
badgeCount: { type: [Number, String], default: 0 },
|
||||
hideTreeLine: { type: Boolean, default: false },
|
||||
thinTreeLine: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
|
||||
@@ -21,30 +19,21 @@ const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
|
||||
const shouldRenderComponent = computed(() => {
|
||||
return typeof props.component === 'function' || isVNode(props.component);
|
||||
});
|
||||
|
||||
// Tree-line connector per leaf: vertical line (::before) + rounded elbow on the
|
||||
// last child (::after). Logical props (start / border-s / rounded-es)
|
||||
const TREE_CONNECTOR =
|
||||
"child-item before:content-[''] before:absolute before:start-0 before:w-0.5 before:h-full before:bg-n-slate-4 first:before:rounded-t last:before:h-1/5 last:after:content-[''] last:after:absolute last:after:start-0 last:after:bottom-[calc(50%_-_2px)] last:after:h-3 last:after:w-2.5 last:after:border-b-2 last:after:border-s-2 last:after:rounded-es last:after:border-n-slate-4";
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<Policy
|
||||
:permissions="resolvePermissions(to)"
|
||||
:feature-flag="resolveFeatureFlag(to)"
|
||||
as="li"
|
||||
class="py-0.5 ps-2 ms-3 relative text-n-slate-11 min-w-0"
|
||||
:class="{
|
||||
[TREE_CONNECTOR]: !hideTreeLine,
|
||||
'before:!w-px last:after:!border-b last:after:!border-s':
|
||||
!hideTreeLine && thinTreeLine,
|
||||
}"
|
||||
class="py-0.5 ltr:pl-2 rtl:pr-2 rtl:mr-3 ltr:ml-3 relative text-n-slate-11 child-item before:bg-n-slate-4 after:bg-transparent after:border-n-slate-4 before:left-0 rtl:before:right-0 min-w-0"
|
||||
>
|
||||
<component
|
||||
:is="to ? 'router-link' : 'div'"
|
||||
:to="to"
|
||||
:title="label"
|
||||
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg ltr:hover:bg-gradient-to-r rtl:hover:bg-gradient-to-l from-transparent via-n-slate-3/70 to-n-slate-3/70 group min-w-0"
|
||||
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group min-w-0"
|
||||
:class="{
|
||||
'text-n-slate-12 bg-n-alpha-2 active': active,
|
||||
}"
|
||||
|
||||
@@ -2,69 +2,24 @@
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isExpanded: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: [Object, String],
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showTreeLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
endTreeLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
const TREE_VERTICAL_LINE =
|
||||
"before:content-[''] before:absolute before:-top-1 before:w-0.5 before:bg-n-slate-4 before:start-[-0.5rem]";
|
||||
const TREE_ELBOW =
|
||||
"after:content-[''] after:absolute after:w-2.5 after:h-3 after:bottom-1/2 after:start-[-0.5rem] after:border-b-2 after:border-s-2 after:rounded-es after:border-n-slate-4";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="collapsible ? 'button' : 'div'"
|
||||
:type="collapsible ? 'button' : undefined"
|
||||
:aria-expanded="collapsible ? isExpanded : undefined"
|
||||
:title="label"
|
||||
class="relative justify-between flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 text-n-slate-10 select-none min-w-0"
|
||||
:class="[
|
||||
showTreeLine && TREE_VERTICAL_LINE,
|
||||
showTreeLine &&
|
||||
(endTreeLine ? `before:h-3 ${TREE_ELBOW}` : 'before:-bottom-1'),
|
||||
{
|
||||
'w-full': !collapsible,
|
||||
'pointer-events-none': !collapsible,
|
||||
'ms-5 cursor-pointer hover:bg-n-alpha-2': collapsible,
|
||||
},
|
||||
]"
|
||||
@click.stop="emit('toggle')"
|
||||
<div
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 text-n-slate-10 select-none pointer-events-none"
|
||||
>
|
||||
<div class="min-w-0 inline-flex gap-2 items-center">
|
||||
<Icon v-if="icon" :icon="icon" class="size-4 flex-shrink-0" />
|
||||
<span class="text-sm font-medium leading-5 flex-grow truncate text-start">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="collapsible"
|
||||
class="size-3 flex-shrink-0 text-n-slate-10 me-0.5"
|
||||
:class="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
/>
|
||||
</component>
|
||||
<Icon v-if="icon" :icon="icon" class="size-4" />
|
||||
<span class="text-sm font-medium leading-5 flex-grow">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -145,6 +145,7 @@ const allowedMenuItems = computed(() => {
|
||||
:src="currentUser.avatar_url"
|
||||
:status="currentUserAvailability"
|
||||
class="flex-shrink-0"
|
||||
rounded-full
|
||||
/>
|
||||
<div v-if="!isCollapsed" class="min-w-0">
|
||||
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
|
||||
import SidebarGroupSeparator from './SidebarGroupSeparator.vue';
|
||||
|
||||
@@ -11,42 +7,15 @@ import { useSidebarContext } from './provider';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
label: { type: String, required: true },
|
||||
icon: { type: [Object, String], required: true },
|
||||
children: { type: Array, default: undefined },
|
||||
activeChild: { type: Object, default: undefined },
|
||||
collapsible: { type: Boolean, default: false },
|
||||
showTreeLine: { type: Boolean, default: false },
|
||||
endTreeLine: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { isAllowed } = useSidebarContext();
|
||||
const scrollableContainer = ref(null);
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const minimizedSectionsKey = LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS;
|
||||
|
||||
const getMinimizedSections = () => {
|
||||
const minimizedSections = LocalStorage.get(minimizedSectionsKey);
|
||||
return minimizedSections &&
|
||||
typeof minimizedSections === 'object' &&
|
||||
!Array.isArray(minimizedSections)
|
||||
? minimizedSections
|
||||
: {};
|
||||
};
|
||||
|
||||
const minimizedSections = ref(getMinimizedSections());
|
||||
const storageKey = computed(() =>
|
||||
accountId.value ? `${accountId.value}:${props.name}` : props.name
|
||||
);
|
||||
const isSubGroupExpanded = computed(
|
||||
() => !props.collapsible || !minimizedSections.value[storageKey.value]
|
||||
);
|
||||
const hasActiveChild = computed(() =>
|
||||
props.children.some(child => child.name === props.activeChild?.name)
|
||||
);
|
||||
|
||||
const accessibleItems = computed(() =>
|
||||
props.children.filter(child => {
|
||||
@@ -59,117 +28,84 @@ const hasAccessibleItems = computed(() => {
|
||||
});
|
||||
|
||||
const isScrollable = computed(() => {
|
||||
return (
|
||||
props.isExpanded &&
|
||||
isSubGroupExpanded.value &&
|
||||
accessibleItems.value.length > 7
|
||||
);
|
||||
return accessibleItems.value.length > 7;
|
||||
});
|
||||
|
||||
const scrollEnd = ref(false);
|
||||
|
||||
const CHILDREN_TRUNK =
|
||||
"before:content-[''] before:absolute before:top-0 before:bottom-0 before:w-0.5 before:bg-n-slate-4 before:start-[-0.5rem]";
|
||||
|
||||
const hideLeafTreeLine = computed(
|
||||
() => props.showTreeLine && !props.isExpanded
|
||||
);
|
||||
|
||||
const toggleSubGroup = () => {
|
||||
if (!props.collapsible) return;
|
||||
|
||||
if (isSubGroupExpanded.value) {
|
||||
LocalStorage.updateJsonStore(minimizedSectionsKey, storageKey.value, true);
|
||||
} else {
|
||||
LocalStorage.deleteFromJsonStore(minimizedSectionsKey, storageKey.value);
|
||||
}
|
||||
|
||||
minimizedSections.value = getMinimizedSections();
|
||||
};
|
||||
|
||||
const expandSubGroupOnActiveChild = () => {
|
||||
if (!props.collapsible || !hasActiveChild.value || isSubGroupExpanded.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
LocalStorage.deleteFromJsonStore(minimizedSectionsKey, storageKey.value);
|
||||
minimizedSections.value = getMinimizedSections();
|
||||
};
|
||||
|
||||
const shouldShowItem = child => {
|
||||
return (
|
||||
isSubGroupExpanded.value &&
|
||||
(props.isExpanded || props.activeChild?.name === child.name)
|
||||
);
|
||||
};
|
||||
|
||||
// set scrollEnd to true when the scroll reaches the end
|
||||
useEventListener(scrollableContainer, 'scroll', () => {
|
||||
const { scrollHeight, scrollTop, clientHeight } = scrollableContainer.value;
|
||||
scrollEnd.value = scrollHeight - scrollTop === clientHeight;
|
||||
});
|
||||
|
||||
useEventListener(window, 'storage', event => {
|
||||
if (event.key === minimizedSectionsKey) {
|
||||
minimizedSections.value = getMinimizedSections();
|
||||
}
|
||||
});
|
||||
|
||||
watch([hasActiveChild, storageKey], expandSubGroupOnActiveChild, {
|
||||
immediate: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="relative flex flex-col list-none min-w-0">
|
||||
<template v-if="hasAccessibleItems">
|
||||
<SidebarGroupSeparator
|
||||
v-show="isExpanded"
|
||||
:label
|
||||
:icon
|
||||
:collapsible
|
||||
:is-expanded="isSubGroupExpanded"
|
||||
:show-tree-line="showTreeLine"
|
||||
:end-tree-line="endTreeLine"
|
||||
class="my-1"
|
||||
@toggle="toggleSubGroup"
|
||||
<SidebarGroupSeparator
|
||||
v-if="hasAccessibleItems"
|
||||
v-show="isExpanded"
|
||||
:label
|
||||
:icon
|
||||
class="my-1"
|
||||
/>
|
||||
<ul
|
||||
v-if="children.length"
|
||||
class="m-0 list-none reset-base relative group min-w-0"
|
||||
>
|
||||
<!-- Each element has h-8, which is 32px, we will show 7 items with one hidden at the end,
|
||||
which is 14rem. Then we add 16px so that we have some text visible from the next item -->
|
||||
<div
|
||||
ref="scrollableContainer"
|
||||
class="min-w-0"
|
||||
:class="{
|
||||
'max-h-[calc(14rem+16px)] overflow-y-scroll no-scrollbar': isScrollable,
|
||||
}"
|
||||
>
|
||||
<SidebarGroupLeaf
|
||||
v-for="child in children"
|
||||
v-show="isExpanded || activeChild?.name === child.name"
|
||||
v-bind="child"
|
||||
:key="child.name"
|
||||
:active="activeChild?.name === child.name"
|
||||
/>
|
||||
<ul
|
||||
v-if="children.length"
|
||||
class="m-0 list-none reset-base relative group min-w-0"
|
||||
:class="[
|
||||
{ 'ms-5': collapsible },
|
||||
showTreeLine && !endTreeLine && CHILDREN_TRUNK,
|
||||
]"
|
||||
</div>
|
||||
<div
|
||||
v-if="isScrollable && isExpanded"
|
||||
v-show="!scrollEnd"
|
||||
class="absolute bg-gradient-to-t from-n-background w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="24"
|
||||
viewBox="0 0 16 24"
|
||||
fill="none"
|
||||
class="text-n-slate-9 opacity-50 group-hover:opacity-100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<div
|
||||
ref="scrollableContainer"
|
||||
class="min-w-0"
|
||||
:class="{
|
||||
'max-h-60 overflow-y-scroll no-scrollbar': isScrollable,
|
||||
}"
|
||||
>
|
||||
<SidebarGroupLeaf
|
||||
v-for="child in children"
|
||||
v-show="shouldShowItem(child)"
|
||||
v-bind="child"
|
||||
:key="child.name"
|
||||
:active="activeChild?.name === child.name"
|
||||
:hide-tree-line="hideLeafTreeLine"
|
||||
thin-tree-line
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isScrollable && isExpanded"
|
||||
v-show="!scrollEnd"
|
||||
class="absolute bg-gradient-to-t from-n-background w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
|
||||
>
|
||||
<Icon
|
||||
icon="i-woot-chevrons-down"
|
||||
class="w-4 h-6 text-n-slate-9 opacity-50 group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
</ul>
|
||||
</template>
|
||||
</li>
|
||||
<path
|
||||
d="M4 4L8 8L12 4"
|
||||
stroke="currentColor"
|
||||
opacity="0.5"
|
||||
stroke-width="1.33333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 10L8 14L12 10"
|
||||
stroke="currentColor"
|
||||
opacity="0.75"
|
||||
stroke-width="1.33333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 16L8 20L12 16"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.33333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { ref } from 'vue';
|
||||
import SidebarSubGroup from '../SidebarSubGroup.vue';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { provideSidebarContext } from '../provider';
|
||||
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useMapGetter: () => ref(1),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/usePolicy', () => ({
|
||||
usePolicy: () => ({
|
||||
shouldShow: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
resolve: () => ({ path: '/' }),
|
||||
getRoutes: () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
const children = [
|
||||
{
|
||||
name: 'Sales-1',
|
||||
label: 'Sales',
|
||||
to: { name: 'team_conversations' },
|
||||
},
|
||||
];
|
||||
|
||||
const mountSubGroup = props => {
|
||||
return mount(
|
||||
{
|
||||
components: { SidebarSubGroup },
|
||||
setup() {
|
||||
provideSidebarContext({});
|
||||
},
|
||||
template: '<SidebarSubGroup v-bind="$attrs" />',
|
||||
},
|
||||
{
|
||||
attrs: {
|
||||
name: 'Conversation:Teams',
|
||||
label: 'Teams',
|
||||
icon: 'i-lucide-users',
|
||||
children,
|
||||
isExpanded: true,
|
||||
collapsible: true,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
SidebarGroupLeaf: {
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
hideTreeLine: { type: Boolean, default: false },
|
||||
thinTreeLine: { type: Boolean, default: false },
|
||||
},
|
||||
template:
|
||||
'<li class="sidebar-leaf" :data-hide-tree-line="String(hideTreeLine)" :data-thin-tree-line="String(thinTreeLine)">{{ label }}</li>',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
describe('SidebarSubGroup', () => {
|
||||
let localStorageStore;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageStore = {};
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
getItem: key => localStorageStore[key] || null,
|
||||
setItem: (key, value) => {
|
||||
localStorageStore[key] = String(value);
|
||||
},
|
||||
removeItem: key => {
|
||||
delete localStorageStore[key];
|
||||
},
|
||||
clear: () => {
|
||||
localStorageStore = {};
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('keeps collapsible sections expanded by default', () => {
|
||||
const wrapper = mountSubGroup();
|
||||
|
||||
expect(wrapper.find('button').attributes('aria-expanded')).toBe('true');
|
||||
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the tree line on the separator, positioned relative to it', () => {
|
||||
const wrapper = mountSubGroup({ showTreeLine: true });
|
||||
const button = wrapper.find('button');
|
||||
|
||||
expect(button.classes()).toContain('relative');
|
||||
expect(button.classes()).toContain('before:bg-n-slate-4');
|
||||
expect(button.classes()).toContain('before:-bottom-1');
|
||||
});
|
||||
|
||||
it('renders the end curve on the last separator', () => {
|
||||
const wrapper = mountSubGroup({ showTreeLine: true, endTreeLine: true });
|
||||
const button = wrapper.find('button');
|
||||
|
||||
expect(button.classes()).toContain('before:h-3');
|
||||
expect(button.classes()).toContain('after:border-b-2');
|
||||
});
|
||||
|
||||
it('draws nested item tree lines via the leaf connectors', () => {
|
||||
const wrapper = mountSubGroup({ showTreeLine: true });
|
||||
|
||||
expect(
|
||||
wrapper.find('.sidebar-leaf').attributes('data-hide-tree-line')
|
||||
).toBe('false');
|
||||
});
|
||||
|
||||
it('marks nested item connectors as thin', () => {
|
||||
const wrapper = mountSubGroup({ showTreeLine: true });
|
||||
|
||||
expect(
|
||||
wrapper.find('.sidebar-leaf').attributes('data-thin-tree-line')
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
it('minimizes the section and stores it by account and section name', async () => {
|
||||
const wrapper = mountSubGroup();
|
||||
|
||||
await wrapper.find('button').trigger('click');
|
||||
|
||||
const storedSections = JSON.parse(
|
||||
window.localStorage.getItem(LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS)
|
||||
);
|
||||
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false');
|
||||
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(false);
|
||||
expect(storedSections).toEqual({ '1:Conversation:Teams': true });
|
||||
});
|
||||
|
||||
it('uses the stored minimized state when mounted again', () => {
|
||||
window.localStorage.setItem(
|
||||
LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS,
|
||||
JSON.stringify({ '1:Conversation:Teams': true })
|
||||
);
|
||||
|
||||
const wrapper = mountSubGroup();
|
||||
|
||||
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false');
|
||||
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it('expands a stored minimized section when one of its children is active', () => {
|
||||
window.localStorage.setItem(
|
||||
LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS,
|
||||
JSON.stringify({ '1:Conversation:Teams': true })
|
||||
);
|
||||
|
||||
const wrapper = mountSubGroup({ activeChild: children[0] });
|
||||
|
||||
const storedSections = JSON.parse(
|
||||
window.localStorage.getItem(LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS)
|
||||
);
|
||||
expect(wrapper.find('button').attributes('aria-expanded')).toBe('true');
|
||||
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(true);
|
||||
expect(storedSections).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -151,9 +151,6 @@ const activeFolder = computed(() => {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const getContact = useMapGetter('contacts/getContact');
|
||||
const folderContactId = useMapGetter('customViews/getActiveFolderContactId');
|
||||
|
||||
const activeFolderName = computed(() => {
|
||||
return activeFolder.value?.name;
|
||||
});
|
||||
@@ -459,7 +456,6 @@ function setParamsForEditFolderModal() {
|
||||
inboxes: inboxesList.value,
|
||||
labels: labels.value,
|
||||
campaigns: campaigns.value,
|
||||
contacts: [getContact.value(folderContactId.value)],
|
||||
languages: languages,
|
||||
countries: countries,
|
||||
priority: [
|
||||
|
||||
@@ -14,11 +14,11 @@ const props = defineProps({
|
||||
const emit = defineEmits(['removeAttachment']);
|
||||
|
||||
const nonRecordedAudioAttachments = computed(() => {
|
||||
return props.attachments.filter(attachment => !attachment?.isVoiceMessage);
|
||||
return props.attachments.filter(attachment => !attachment?.isRecordedAudio);
|
||||
});
|
||||
|
||||
const recordedAudioAttachments = computed(() =>
|
||||
props.attachments.filter(attachment => attachment.isVoiceMessage)
|
||||
props.attachments.filter(attachment => attachment.isRecordedAudio)
|
||||
);
|
||||
|
||||
const onRemoveAttachment = itemIndex => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js';
|
||||
import { format, intervalToDuration } from 'date-fns';
|
||||
import { convertAudio } from './utils/audioConversionUtils';
|
||||
import { convertAudio } from './utils/mp3ConversionUtils';
|
||||
|
||||
const props = defineProps({
|
||||
audioRecordFormat: {
|
||||
@@ -18,7 +18,6 @@ const emit = defineEmits([
|
||||
'finishRecord',
|
||||
'pause',
|
||||
'play',
|
||||
'recordError',
|
||||
]);
|
||||
|
||||
const waveformContainer = ref(null);
|
||||
@@ -27,7 +26,6 @@ const record = ref(null);
|
||||
const isRecording = ref(false);
|
||||
const isPlaying = ref(false);
|
||||
const hasRecording = ref(false);
|
||||
const recordedAudioUrl = ref(null);
|
||||
|
||||
const formatTimeProgress = 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 = () => {
|
||||
wavesurfer.value = WaveSurfer.create({
|
||||
container: waveformContainer.value,
|
||||
@@ -69,7 +45,10 @@ const initWaveSurfer = () => {
|
||||
barGap: 1,
|
||||
barRadius: 2,
|
||||
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 => {
|
||||
try {
|
||||
const audioBlob = await convertAudio(blob, props.audioRecordFormat);
|
||||
// Use the converted blob's actual type, which may differ from the
|
||||
// requested format when the browser can't produce it (e.g. Safari falls
|
||||
// back to MP3 instead of OGG). This keeps the filename, content type, and
|
||||
// voice-note flag consistent with the real bytes.
|
||||
const audioType = audioBlob.type || props.audioRecordFormat;
|
||||
const ext = AUDIO_EXTENSION_MAP[audioType] || 'mp3';
|
||||
const fileName = `${getUuid()}.${ext}`;
|
||||
const file = new File([audioBlob], fileName, {
|
||||
type: audioType,
|
||||
});
|
||||
if (recordedAudioUrl.value) URL.revokeObjectURL(recordedAudioUrl.value);
|
||||
recordedAudioUrl.value = URL.createObjectURL(audioBlob);
|
||||
wavesurfer.value.load(recordedAudioUrl.value);
|
||||
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 });
|
||||
}
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
const audioBlob = await convertAudio(blob, props.audioRecordFormat);
|
||||
const fileName = `${getUuid()}.mp3`;
|
||||
const file = new File([audioBlob], fileName, {
|
||||
type: props.audioRecordFormat,
|
||||
});
|
||||
wavesurfer.value.load(audioUrl);
|
||||
emit('finishRecord', {
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
file,
|
||||
});
|
||||
hasRecording.value = true;
|
||||
isRecording.value = false;
|
||||
});
|
||||
|
||||
record.value.on('record-progress', time => {
|
||||
@@ -143,10 +109,6 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (recordedAudioUrl.value) {
|
||||
URL.revokeObjectURL(recordedAudioUrl.value);
|
||||
recordedAudioUrl.value = null;
|
||||
}
|
||||
if (wavesurfer.value) {
|
||||
wavesurfer.value.destroy();
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
CONVERSATION_EVENTS,
|
||||
CAPTAIN_EVENTS,
|
||||
} from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
|
||||
|
||||
import {
|
||||
messageSchema,
|
||||
@@ -42,7 +43,6 @@ import {
|
||||
MessageMarkdownSerializer,
|
||||
EditorState,
|
||||
Selection,
|
||||
imageResizeView,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import {
|
||||
suggestionsPlugin,
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
insertAtCursor,
|
||||
removeSignature as removeSignatureHelper,
|
||||
scrollCursorIntoView,
|
||||
setURLWithQueryAndSize,
|
||||
getFormattingForEditor,
|
||||
getSelectionCoords,
|
||||
calculateMenuPosition,
|
||||
@@ -71,7 +72,6 @@ import {
|
||||
import { createTypingIndicator } from '@chatwoot/utils';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
@@ -93,6 +93,7 @@ const props = defineProps({
|
||||
channelType: { type: String, default: '' },
|
||||
conversationId: { type: Number, default: null },
|
||||
medium: { type: String, default: '' },
|
||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||
focusOnMount: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
@@ -118,14 +119,6 @@ const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
const DEFAULT_FORMATTING = 'Context::Default';
|
||||
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(() =>
|
||||
getEffectiveChannelType(props.channelType, props.medium)
|
||||
@@ -199,8 +192,12 @@ const cannedSearchTerm = ref('');
|
||||
const variableSearchTerm = ref('');
|
||||
const emojiSearchTerm = ref('');
|
||||
const range = ref(null);
|
||||
const isImageNodeSelected = ref(false);
|
||||
const toolbarPosition = ref({ top: 0, left: 0 });
|
||||
const selectedImageNode = ref(null);
|
||||
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
||||
const showSelectionMenu = ref(false);
|
||||
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
|
||||
|
||||
// element ref
|
||||
const editorRoot = useTemplateRef('editorRoot');
|
||||
@@ -478,6 +475,16 @@ function removeSignature() {
|
||||
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 } = {}) {
|
||||
const wrapper = editorRoot.value;
|
||||
if (!selection || !wrapper) return;
|
||||
@@ -513,6 +520,30 @@ function checkSelection(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() {
|
||||
emit('input', contentFromEditor());
|
||||
emit('update:modelValue', contentFromEditor());
|
||||
@@ -532,6 +563,21 @@ function toggleSignatureInEditor(signatureEnabled) {
|
||||
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() {
|
||||
return isEditorHotKeyEnabled('enter');
|
||||
}
|
||||
@@ -540,6 +586,17 @@ function isCmdPlusEnterToSendEnabled() {
|
||||
return isEditorHotKeyEnabled('cmd_enter');
|
||||
}
|
||||
|
||||
useKeyboardEvents({
|
||||
'Alt+KeyP': {
|
||||
action: focusEditorInputField,
|
||||
allowOnFocusedInput: false,
|
||||
},
|
||||
'Alt+KeyL': {
|
||||
action: focusEditorInputField,
|
||||
allowOnFocusedInput: false,
|
||||
},
|
||||
});
|
||||
|
||||
function onImageInsertInEditor(fileUrl) {
|
||||
const { tr } = editorView.state;
|
||||
|
||||
@@ -560,11 +617,7 @@ async function uploadImageToStorage(file) {
|
||||
onImageInsertInEditor(fileUrl);
|
||||
}
|
||||
useAlert(
|
||||
props.channelType === MESSAGE_SIGNATURE_FORMATTING
|
||||
? t(
|
||||
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS'
|
||||
)
|
||||
: t('CONVERSATION.REPLYBOX.IMAGE_UPLOAD_SUCCESS')
|
||||
t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
@@ -573,8 +626,8 @@ async function uploadImageToStorage(file) {
|
||||
}
|
||||
}
|
||||
|
||||
function uploadImageIfWithinSizeLimit(file) {
|
||||
if (!file) return;
|
||||
function onFileChange() {
|
||||
const file = imageUpload.value.files[0];
|
||||
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
|
||||
uploadImageToStorage(file);
|
||||
} 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) {
|
||||
if (
|
||||
hasPressedEnterAndNotCmdOrShift(event) &&
|
||||
@@ -734,9 +736,6 @@ function createEditorView() {
|
||||
editorView = new EditorView(editor.value, {
|
||||
state: state,
|
||||
editable: () => !props.disabled,
|
||||
nodeViews: {
|
||||
image: imageResizeView,
|
||||
},
|
||||
dispatchTransaction: tx => {
|
||||
state = state.apply(tx);
|
||||
editorView.updateState(state);
|
||||
@@ -749,10 +748,12 @@ function createEditorView() {
|
||||
keyup: () => {
|
||||
if (!props.disabled) {
|
||||
typingIndicator.start();
|
||||
updateImgToolbarOnDelete();
|
||||
}
|
||||
},
|
||||
keydown: (view, event) => !props.disabled && onKeydown(event),
|
||||
focus: () => !props.disabled && emit('focus'),
|
||||
click: () => !props.disabled && isEditorMouseFocusedOnAnImage(),
|
||||
blur: () => {
|
||||
if (props.disabled) return;
|
||||
typingIndicator.stop();
|
||||
@@ -917,6 +918,23 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<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" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -979,6 +997,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@apply text-n-slate-11;
|
||||
}
|
||||
}
|
||||
|
||||
ol li {
|
||||
@apply list-item list-decimal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-16
@@ -1,7 +1,5 @@
|
||||
import lamejs from '@breezystack/lamejs';
|
||||
|
||||
import { remuxWebmToOgg } from './webmOpusToOgg';
|
||||
|
||||
const writeString = (view, offset, string) => {
|
||||
// eslint-disable-next-line no-plusplus
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
@@ -141,20 +139,6 @@ export const convertAudio = async (inputBlob, outputFormat, bitrate = 128) => {
|
||||
audio = await convertToWav(inputBlob);
|
||||
} else if (outputFormat === 'audio/mp3') {
|
||||
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 {
|
||||
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"
|
||||
:status="currentContact.availability_status"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div
|
||||
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}`;
|
||||
},
|
||||
audioRecordFormat() {
|
||||
if (this.isAWhatsAppCloudChannel) {
|
||||
return AUDIO_FORMATS.OGG;
|
||||
}
|
||||
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
|
||||
return AUDIO_FORMATS.MP3;
|
||||
}
|
||||
@@ -1011,18 +1008,14 @@ export default {
|
||||
onFinishRecorder(file) {
|
||||
this.recordingAudioState = 'stopped';
|
||||
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
|
||||
const autoRecordedFile = {
|
||||
...file,
|
||||
isVoiceMessage: true,
|
||||
isRecordedAudio: true,
|
||||
};
|
||||
return file && this.onFileUpload(autoRecordedFile);
|
||||
},
|
||||
onRecordError() {
|
||||
this.toggleAudioRecorder();
|
||||
useAlert(this.$t('CONVERSATION.REPLYBOX.AUDIO_CONVERSION_FAILED'));
|
||||
},
|
||||
toggleTyping(status) {
|
||||
const conversationId = this.currentChat.id;
|
||||
const isPrivate = this.isPrivate;
|
||||
@@ -1049,7 +1042,7 @@ export default {
|
||||
isPrivate: this.isPrivate,
|
||||
thumb: reader.result,
|
||||
blobSignedId: blob ? blob.signed_id : undefined,
|
||||
isVoiceMessage: file?.isVoiceMessage || false,
|
||||
isRecordedAudio: file?.isRecordedAudio || false,
|
||||
});
|
||||
};
|
||||
},
|
||||
@@ -1085,7 +1078,6 @@ export default {
|
||||
private: false,
|
||||
message: caption,
|
||||
sender: this.sender,
|
||||
isVoiceMessage: attachment.isVoiceMessage || false,
|
||||
};
|
||||
|
||||
attachmentPayload = this.setReplyToInPayload(attachmentPayload);
|
||||
@@ -1135,9 +1127,6 @@ export default {
|
||||
this.attachedFiles.forEach(attachment => {
|
||||
if (this.globalConfig.directUploadsEnabled) {
|
||||
messagePayload.files.push(attachment.blobSignedId);
|
||||
if (attachment.isVoiceMessage) {
|
||||
messagePayload.isVoiceMessage = true;
|
||||
}
|
||||
} else {
|
||||
messagePayload.files.push(attachment.resource.file);
|
||||
}
|
||||
@@ -1226,7 +1215,7 @@ export default {
|
||||
this.hasRecordedAudio = false;
|
||||
// Only clear the recorded audio when we click toggle button.
|
||||
this.attachedFiles = this.attachedFiles.filter(
|
||||
file => !file?.isVoiceMessage
|
||||
file => !file?.isRecordedAudio
|
||||
);
|
||||
},
|
||||
toggleEditorSize() {
|
||||
@@ -1304,7 +1293,6 @@ export default {
|
||||
:audio-record-format="audioRecordFormat"
|
||||
@recorder-progress-changed="onRecordProgressChanged"
|
||||
@finish-record="onFinishRecorder"
|
||||
@record-error="onRecordError"
|
||||
@play="recordingAudioState = 'playing'"
|
||||
@pause="recordingAudioState = 'paused'"
|
||||
/>
|
||||
|
||||
@@ -46,14 +46,6 @@ const filterTypes = [
|
||||
filterOperators: OPERATOR_TYPES_2,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'contact_id',
|
||||
attributeI18nKey: 'CONTACT',
|
||||
inputType: 'search_select',
|
||||
dataType: 'number',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'display_id',
|
||||
attributeI18nKey: 'CONVERSATION_IDENTIFIER',
|
||||
@@ -141,10 +133,6 @@ export const filterAttributeGroups = [
|
||||
key: 'team_id',
|
||||
i18nKey: 'TEAM_NAME',
|
||||
},
|
||||
{
|
||||
key: 'contact_id',
|
||||
i18nKey: 'CONTACT',
|
||||
},
|
||||
{
|
||||
key: 'display_id',
|
||||
i18nKey: 'CONVERSATION_IDENTIFIER',
|
||||
|
||||
@@ -10,12 +10,10 @@ vi.mock('shared/helpers/mitt', () => ({
|
||||
|
||||
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
track: vi.fn(),
|
||||
},
|
||||
actual.default = {
|
||||
track: vi.fn(),
|
||||
};
|
||||
return actual;
|
||||
});
|
||||
|
||||
describe('useTrack', () => {
|
||||
|
||||
@@ -16,12 +16,10 @@ vi.mock('vue-i18n');
|
||||
vi.mock('dashboard/api/captain/tasks');
|
||||
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
track: vi.fn(),
|
||||
},
|
||||
actual.default = {
|
||||
track: vi.fn(),
|
||||
};
|
||||
return actual;
|
||||
});
|
||||
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
|
||||
CAPTAIN_EVENTS: {
|
||||
|
||||
@@ -1,149 +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:
|
||||
'pages_manage_metadata,business_management,pages_messaging,pages_show_list,pages_read_engagement',
|
||||
});
|
||||
});
|
||||
|
||||
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 {
|
||||
accountId,
|
||||
route,
|
||||
@@ -65,6 +61,5 @@ export function useAccount() {
|
||||
isCloudFeatureEnabled,
|
||||
isOnChatwootCloud,
|
||||
updateAccount,
|
||||
finishOnboarding,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { ref } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import ChannelApi from 'dashboard/api/channels';
|
||||
import { buildFacebookLoginScopes } from 'dashboard/helper/facebookScopes';
|
||||
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
|
||||
|
||||
// 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: buildFacebookLoginScopes() }
|
||||
);
|
||||
});
|
||||
|
||||
// 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 };
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// Single source of truth for billing currencies on the frontend.
|
||||
|
||||
export const DEFAULT_BILLING_CURRENCY = 'usd';
|
||||
|
||||
// Order here drives the order of the currency toggle in the UI.
|
||||
export const SUPPORTED_BILLING_CURRENCIES = ['usd', 'brl'];
|
||||
|
||||
export const BILLING_CURRENCY_CONFIG = {
|
||||
usd: {
|
||||
code: 'usd',
|
||||
intlLocale: 'en-US',
|
||||
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.USD',
|
||||
},
|
||||
brl: {
|
||||
code: 'brl',
|
||||
intlLocale: 'pt-BR',
|
||||
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.BRL',
|
||||
},
|
||||
};
|
||||
|
||||
export const getCurrencyConfig = code =>
|
||||
BILLING_CURRENCY_CONFIG[(code || DEFAULT_BILLING_CURRENCY).toLowerCase()] ||
|
||||
BILLING_CURRENCY_CONFIG[DEFAULT_BILLING_CURRENCY];
|
||||
|
||||
export const formatCurrencyAmount = (amount, code, options = {}) => {
|
||||
const { intlLocale, code: currencyCode } = getCurrencyConfig(code);
|
||||
return new Intl.NumberFormat(intlLocale, {
|
||||
style: 'currency',
|
||||
currency: currencyCode.toUpperCase(),
|
||||
...options,
|
||||
}).format(amount);
|
||||
};
|
||||
@@ -14,7 +14,6 @@ export const FORMATTING = {
|
||||
'link',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'imageUpload',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
@@ -31,7 +30,6 @@ export const FORMATTING = {
|
||||
'strike',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'imageUpload',
|
||||
'undo',
|
||||
'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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -7,5 +7,4 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
|
||||
MESSAGE_REPLY_TO: 'messageReplyTo',
|
||||
RECENT_SEARCHES: 'recentSearches',
|
||||
SIDEBAR_MINIMIZED_SECTIONS: 'sidebarMinimizedSections',
|
||||
};
|
||||
|
||||
@@ -154,10 +154,6 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
|
||||
SHARE_CLICKED: 'Year in Review: Share clicked',
|
||||
});
|
||||
|
||||
export const SESSION_EVENTS = Object.freeze({
|
||||
REVOKED_FROM_PROFILE: 'Revoked an active session',
|
||||
});
|
||||
|
||||
export const ONBOARDING_EVENTS = Object.freeze({
|
||||
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
|
||||
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
|
||||
|
||||
@@ -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 => {
|
||||
return values.map(value => ({ id: value, name: value }));
|
||||
};
|
||||
@@ -91,7 +84,6 @@ export const getValuesForFilter = (filter, params) => {
|
||||
campaigns,
|
||||
labels,
|
||||
priority,
|
||||
contacts,
|
||||
} = params;
|
||||
switch (attribute_key) {
|
||||
case 'status':
|
||||
@@ -102,8 +94,6 @@ export const getValuesForFilter = (filter, params) => {
|
||||
return getValuesName(values, inboxes, 'id', 'name');
|
||||
case 'team_id':
|
||||
return getValuesName(values, teams, 'id', 'name');
|
||||
case 'contact_id':
|
||||
return getValuesForContact(values, contacts);
|
||||
case 'campaign_id':
|
||||
return getValuesName(values, campaigns, 'id', 'title');
|
||||
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.
|
||||
* This ensures canned responses with rich formatting can be inserted into channels
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
export const FACEBOOK_PAGE_SCOPES = [
|
||||
'pages_manage_metadata',
|
||||
'business_management',
|
||||
'pages_messaging',
|
||||
'pages_show_list',
|
||||
'pages_read_engagement',
|
||||
];
|
||||
|
||||
export const INSTAGRAM_SCOPES = [
|
||||
'instagram_basic',
|
||||
'instagram_manage_messages',
|
||||
];
|
||||
|
||||
export const buildFacebookLoginScopes = ({
|
||||
includeInstagramScopes = false,
|
||||
} = {}) => {
|
||||
const scopes = [...FACEBOOK_PAGE_SCOPES];
|
||||
if (includeInstagramScopes) {
|
||||
scopes.push(...INSTAGRAM_SCOPES);
|
||||
}
|
||||
return scopes.join(',');
|
||||
};
|
||||
@@ -159,13 +159,6 @@ export const LOCALE_MENU_ITEMS = {
|
||||
value: 'publish',
|
||||
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: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
|
||||
action: 'delete',
|
||||
@@ -179,28 +172,20 @@ const disableLocaleMenuItems = menuItems =>
|
||||
|
||||
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
if (isDefault) {
|
||||
return [
|
||||
...disableLocaleMenuItems([
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
]),
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]),
|
||||
];
|
||||
return disableLocaleMenuItems([
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
]);
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.publishLocale,
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
return [LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.delete];
|
||||
}
|
||||
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -273,41 +273,6 @@ describe('customViewsHelper', () => {
|
||||
};
|
||||
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', () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
insertAtCursor,
|
||||
removeSignature,
|
||||
replaceSignature,
|
||||
setURLWithQueryAndSize,
|
||||
stripInlineBase64Images,
|
||||
stripUnsupportedFormatting,
|
||||
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', () => {
|
||||
let mockEditorView;
|
||||
|
||||
|
||||
@@ -64,25 +64,4 @@ describe('#filterQueryGenerator', () => {
|
||||
filterQueryGenerator(testData).payload.every(i => Array.isArray(i.values))
|
||||
).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', () => {
|
||||
it('disables other actions but keeps customize enabled 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();
|
||||
it('returns disabled actions for the default locale', () => {
|
||||
expect(
|
||||
items
|
||||
.filter(item => item.action !== 'customize-content')
|
||||
.every(item => item.disabled)
|
||||
).toBe(true);
|
||||
buildLocaleMenuItems({
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
})
|
||||
).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(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
}).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(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual([
|
||||
'change-default',
|
||||
'move-to-draft',
|
||||
'customize-content',
|
||||
'delete',
|
||||
]);
|
||||
).toEqual(['change-default', 'move-to-draft', 'delete']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
{
|
||||
"BULK_ACTION": {
|
||||
"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",
|
||||
"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}?",
|
||||
@@ -15,6 +20,7 @@
|
||||
"RESOLVE_SUCCESFUL": "Conversations resolved successfully.",
|
||||
"RESOLVE_FAILED": "Failed to resolve conversations. Please try again.",
|
||||
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
|
||||
"AGENT_LIST_LOADING": "Loading agents",
|
||||
"UPDATE": {
|
||||
"CHANGE_STATUS": "Change status",
|
||||
"SNOOZE_UNTIL": "Snooze",
|
||||
@@ -27,16 +33,16 @@
|
||||
},
|
||||
"LABELS": {
|
||||
"ASSIGN_LABELS": "Assign labels",
|
||||
"REMOVE_LABELS": "Remove labels",
|
||||
"NO_LABELS_FOUND": "No labels found",
|
||||
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
|
||||
"REMOVE_SELECTED_LABELS": "Remove selected labels",
|
||||
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
|
||||
"ASSIGN_FAILED": "Failed to assign labels. Please try again.",
|
||||
"REMOVE_SUCCESFUL": "Labels removed successfully.",
|
||||
"REMOVE_FAILED": "Failed to remove labels. Please try again."
|
||||
"ASSIGN_FAILED": "Failed to assign labels. Please try again."
|
||||
},
|
||||
"TEAMS": {
|
||||
"TEAM_SELECT_LABEL": "Select team",
|
||||
"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}?",
|
||||
"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.",
|
||||
|
||||
@@ -88,7 +88,6 @@
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
"PROCESSING": "በሂደት ላይ",
|
||||
"COMPLETED": "Completed",
|
||||
"SCHEDULED": "Scheduled"
|
||||
},
|
||||
@@ -147,7 +146,6 @@
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
"PROCESSING": "በሂደት ላይ",
|
||||
"COMPLETED": "Completed",
|
||||
"SCHEDULED": "Scheduled"
|
||||
},
|
||||
|
||||
@@ -63,7 +63,6 @@
|
||||
"CODE": "Code",
|
||||
"BULLET_LIST": "Bullet List",
|
||||
"ORDERED_LIST": "Ordered List",
|
||||
"TABLE": "Table",
|
||||
"IMAGE": "Image"
|
||||
"TABLE": "Table"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
"CALL": "ደውል",
|
||||
"CALL_INITIATED": "ለእውነተኛው እውቀት መደወል እየተከናወነ ነው…",
|
||||
"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",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "የድምፅ ኢንቦክስ ይምረጡ"
|
||||
@@ -585,11 +583,8 @@
|
||||
},
|
||||
"CONTACTS_BULK_ACTIONS": {
|
||||
"ASSIGN_LABELS": "መለያዎችን መሰጠት",
|
||||
"REMOVE_LABELS": "Remove Labels",
|
||||
"ASSIGN_LABELS_SUCCESS": "መለያዎች በተሳካ ሁኔታ ተመዝግበዋል።.",
|
||||
"ASSIGN_LABELS_FAILED": "መለያዎችን ማስመዝገብ አልተሳካም",
|
||||
"REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
|
||||
"REMOVE_LABELS_FAILED": "Failed to remove labels",
|
||||
"DESCRIPTION": "ለተመረጡት እውነተኛዎች የሚያክሉትን መለያዎች ይምረጡ።.",
|
||||
"NO_LABELS_FOUND": "እስካሁን መለያዎች አልተገኙም።.",
|
||||
"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_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_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
|
||||
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
|
||||
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
|
||||
"NO_RESPONSE": "No response",
|
||||
@@ -80,22 +79,13 @@
|
||||
"OUTGOING_CALL": "Outgoing call",
|
||||
"CALL_IN_PROGRESS": "Call in progress",
|
||||
"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_INBOUND_SUBTEXT": "No agent picked up",
|
||||
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
|
||||
"CALL_ENDED": "Call ended",
|
||||
"HANDLED_BY": "Handled by {agentName}",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"CALLING": "Calling…",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"AGENT_ANSWERED": "{agentName} answered",
|
||||
"JOIN_CALL": "Join call",
|
||||
"CALL_BACK": "Call back",
|
||||
"TRANSCRIPT_SHOW_MORE": "Show more",
|
||||
"TRANSCRIPT_SHOW_LESS": "Show less"
|
||||
"JOIN_CALL": "Join call"
|
||||
},
|
||||
"HEADER": {
|
||||
"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": {
|
||||
"FRT": "FRT {status}",
|
||||
"NRT": "NRT {status}",
|
||||
@@ -231,7 +215,6 @@
|
||||
"TIP_AUDIORECORDER_ICON": "Record audio",
|
||||
"TIP_AUDIORECORDER_PERMISSION": "Allow access to 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",
|
||||
"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": "Being handled in another tab",
|
||||
"REJECT_CALL": "Reject",
|
||||
"DISMISS_CALL": "Dismiss",
|
||||
"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"
|
||||
"END_CALL": "End call"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user