Compare commits

..
Author SHA1 Message Date
Muhsin d5496b7d35 chore: update header 2026-06-01 13:10:35 +04:00
Muhsin bddca26ee3 feat: mvp 2026-05-30 14:53:54 +04:00
801 changed files with 3889 additions and 19211 deletions
+1 -1
View File
@@ -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'
+6 -8
View File
@@ -593,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)
@@ -682,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)
@@ -996,13 +996,11 @@ GEM
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.4)
vite_rails (3.10.0)
railties (>= 5.1, < 9)
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)
@@ -1132,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)
@@ -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,
+7 -17
View File
@@ -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'
-14
View File
@@ -7,7 +7,6 @@ class RoomChannel < ApplicationCable::Channel
ensure_stream
update_subscription
broadcast_presence
transmit_cache_keys
end
def update_presence
@@ -25,19 +24,6 @@ class RoomChannel < ApplicationCable::Channel
ActionCable.server.broadcast(pubsub_token, { event: 'presence.update', data: data })
end
# Push the authoritative cache-key map to this subscriber on every
# (re)subscribe. Boot and reconnect cache freshness ride the same
# account.cache_invalidated event the dashboard already handles for live
# invalidations — the client never pulls /cache_keys itself.
def transmit_cache_keys
return if @current_account.blank? || !@current_user.is_a?(User)
transmit({
event: Events::Types::ACCOUNT_CACHE_INVALIDATED,
data: { account_id: @current_account.id, cache_keys: @current_account.cache_keys }
})
end
def ensure_stream
stream_from pubsub_token
stream_from "account_#{@current_account.id}" if @current_account.present? && @current_user.is_a?(User)
@@ -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,36 +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
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
end
@@ -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
@@ -50,6 +50,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def cache_keys
expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes
render json: { cache_keys: cache_keys_for_account }, status: :ok
end
@@ -57,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
@@ -92,7 +94,11 @@ class Api::V1::AccountsController < Api::BaseController
end
def cache_keys_for_account
@account.cache_keys
{
label: fetch_value_for_key(params[:id], Label.name.underscore),
inbox: fetch_value_for_key(params[:id], Inbox.name.underscore),
team: fetch_value_for_key(params[:id], Team.name.underscore)
}
end
def fetch_account
@@ -13,17 +13,11 @@ class Api::V1::ProfilesController < Api::BaseController
@user.assign_attributes(profile_params)
@user.custom_attributes.merge!(custom_attributes_params)
@user.save!
# Profile updates can change cached agent fields, including avatar-backed thumbnails.
@user.invalidate_avatar_cache
end
def avatar
@user.avatar.attachment.destroy! if @user.avatar.attached?
@user.reload
# Agent thumbnails are cached separately, and avatar attachment deletes do not dirty user columns.
@user.invalidate_avatar_cache
end
def auto_offline
@@ -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
@@ -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
+2 -25
View File
@@ -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
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+9 -16
View File
@@ -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
+3 -10
View File
@@ -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
-6
View File
@@ -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:
+9 -16
View File
@@ -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
+2 -10
View File
@@ -19,7 +19,6 @@ import {
verifyServiceWorkerExistence,
} from './helper/pushHelper';
import ReconnectService from 'dashboard/helper/ReconnectService';
import paintStoresFromCache from 'dashboard/helper/CacheHelper/paintStoresFromCache';
import { useUISettings } from 'dashboard/composables/useUISettings';
export default {
@@ -109,21 +108,14 @@ export default {
this.$store.dispatch('setActiveAccount', {
accountId: this.currentAccountId,
});
const { pubsub_token: pubsubToken } = this.currentUser || {};
vueActionCable.init(this.store, pubsubToken);
// Paint cached config from IndexedDB instantly while the cable
// connects. Freshness needs no orchestration here: RoomChannel pushes
// the cache-key map on every (re)subscribe and on every server-side
// change, all through the same account.cache_invalidated event.
await paintStoresFromCache(this.$store, this.currentAccountId);
const account = this.getAccount(this.currentAccountId);
const { locale, latest_chatwoot_version: latestChatwootVersion } =
account;
const { pubsub_token: pubsubToken } = this.currentUser || {};
// If user locale is set, use it; otherwise use account locale
this.setLocale(this.uiSettings?.locale || locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(this.store, pubsubToken);
this.reconnectService = new ReconnectService(this.store, this.router);
window.reconnectService = this.reconnectService;
@@ -5,15 +5,14 @@ import ApiClient from './ApiClient';
class CacheEnabledApiClient extends ApiClient {
constructor(resource, options = {}) {
super(resource, options);
// `cacheModel` is the Rails Model.name.underscore value — simultaneously
// the server cache-key name and the IDB object-store name.
this.cacheModelName = options.cacheModel;
// inbox/label endpoints wrap collections in { payload }; the rest return
// the bare array.
this.payloadEnvelope = options.payloadEnvelope || false;
this.dataManager = new DataManager(this.accountIdFromRoute);
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
throw new Error('cacheModelName is not defined');
}
get(cache = false) {
if (cache) {
return this.getFromCache();
@@ -26,14 +25,14 @@ class CacheEnabledApiClient extends ApiClient {
return axios.get(this.url);
}
// eslint-disable-next-line class-methods-use-this
extractDataFromResponse(response) {
return this.payloadEnvelope ? response.data.payload : response.data;
return response.data.payload;
}
// eslint-disable-next-line class-methods-use-this
marshallData(dataToParse) {
return this.payloadEnvelope
? { data: { payload: dataToParse } }
: { data: dataToParse };
return { data: { payload: dataToParse } };
}
async getFromCache() {
@@ -44,23 +43,24 @@ class CacheEnabledApiClient extends ApiClient {
return this.getFromNetwork();
}
// Trust the IDB cache. Freshness is maintained by the
// account.cache_invalidated event alone: RoomChannel pushes the cache-key
// map on every (re)subscribe — boot and reconnect included — and the
// server broadcasts it on every change. Skipping a per-call /cache_keys
// preflight eliminates N GET requests per cold settings-page load.
const localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
const { data } = await axios.get(
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
);
const cacheKeyFromApi = data.cache_keys[this.cacheModelName];
const isCacheValid = await this.validateCacheKey(cacheKeyFromApi);
if (localData.length > 0) {
return this.marshallData(localData);
let localData = [];
if (isCacheValid) {
localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
}
// Empty IDB (first load or wiped): fetch data without a cache key. The
// next pushed key map won't match the missing key and will refetch once,
// stamping the authoritative key — the client never pulls keys itself.
return this.refetchAndCommit(null);
if (localData.length === 0) {
return this.refetchAndCommit(cacheKeyFromApi);
}
return this.marshallData(localData);
}
async refetchAndCommit(newKey = null) {
@@ -69,9 +69,7 @@ class CacheEnabledApiClient extends ApiClient {
try {
await this.dataManager.initDb();
// Await replace so data is persisted before the cache key is — otherwise
// a concurrent reader could see a fresh key paired with stale data.
await this.dataManager.replace({
this.dataManager.replace({
modelName: this.cacheModelName,
data: this.extractDataFromResponse(response),
});
@@ -91,15 +89,8 @@ class CacheEnabledApiClient extends ApiClient {
await this.dataManager.initDb();
}
const cacheKey = await this.dataManager.getCacheKey(this.cacheModelName);
if (cacheKey === undefined) {
const localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
return localData.length === 0;
}
return cacheKeyFromApi === cacheKey;
const cachekey = await this.dataManager.getCacheKey(this.cacheModelName);
return cacheKeyFromApi === cachekey;
}
}
+7
View File
@@ -9,6 +9,13 @@ class AccountAPI extends ApiClient {
createAccount(data) {
return axios.post(`${this.apiVersion}/accounts`, data);
}
async getCacheKeys() {
const response = await axios.get(
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
);
return response.data.cache_keys;
}
}
export default new AccountAPI();
+3 -3
View File
@@ -1,10 +1,10 @@
/* global axios */
import CacheEnabledApiClient from './CacheEnabledApiClient';
import ApiClient from './ApiClient';
class Agents extends CacheEnabledApiClient {
class Agents extends ApiClient {
constructor() {
super('agents', { accountScoped: true, cacheModel: 'account_user' });
super('agents', { accountScoped: true });
}
bulkInvite({ emails }) {
+5 -7
View File
@@ -1,15 +1,13 @@
import CacheEnabledApiClient from './CacheEnabledApiClient';
/* global axios */
import ApiClient from './ApiClient';
class AttributeAPI extends CacheEnabledApiClient {
class AttributeAPI extends ApiClient {
constructor() {
super('custom_attribute_definitions', {
accountScoped: true,
cacheModel: 'custom_attribute_definition',
});
super('custom_attribute_definitions', { accountScoped: true });
}
getAttributesByModel() {
return super.get(true);
return axios.get(this.url);
}
}
+12
View File
@@ -1,9 +1,21 @@
/* global axios */
import ApiClient from './ApiClient';
class CampaignsAPI extends ApiClient {
constructor() {
super('campaigns', { accountScoped: true });
}
analyticsMetrics(id) {
return axios.get(`${this.url}/${id}/analytics/metrics`);
}
analyticsContacts(id, { status, page } = {}) {
return axios.get(`${this.url}/${id}/analytics/contacts`, {
params: { status, page },
});
}
}
export default new CampaignsAPI();
+6 -11
View File
@@ -1,20 +1,15 @@
/* global axios */
import CacheEnabledApiClient from './CacheEnabledApiClient';
import ApiClient from './ApiClient';
class CannedResponse extends CacheEnabledApiClient {
class CannedResponse extends ApiClient {
constructor() {
super('canned_responses', {
accountScoped: true,
cacheModel: 'canned_response',
});
super('canned_responses', { accountScoped: true });
}
get({ searchKey } = {}) {
if (searchKey) {
return axios.get(`${this.url}?search=${searchKey}`);
}
return super.get(true);
get({ searchKey }) {
const url = searchKey ? `${this.url}?search=${searchKey}` : this.url;
return axios.get(url);
}
}
@@ -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();
@@ -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,
}),
});
}
+6 -5
View File
@@ -3,11 +3,12 @@ import CacheEnabledApiClient from './CacheEnabledApiClient';
class Inboxes extends CacheEnabledApiClient {
constructor() {
super('inboxes', {
accountScoped: true,
cacheModel: 'inbox',
payloadEnvelope: true,
});
super('inboxes', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'inbox';
}
getCampaigns(inboxId) {
+6 -5
View File
@@ -2,11 +2,12 @@ import CacheEnabledApiClient from './CacheEnabledApiClient';
class LabelsAPI extends CacheEnabledApiClient {
constructor() {
super('labels', {
accountScoped: true,
cacheModel: 'label',
payloadEnvelope: true,
});
super('labels', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'label';
}
}
@@ -1,14 +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);
}
}
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();
});
});
});
+17 -1
View File
@@ -1,9 +1,25 @@
/* global axios */
// import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
export class TeamsAPI extends CacheEnabledApiClient {
constructor() {
super('teams', { accountScoped: true, cacheModel: 'team' });
super('teams', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'team';
}
// eslint-disable-next-line class-methods-use-this
extractDataFromResponse(response) {
return response.data;
}
// eslint-disable-next-line class-methods-use-this
marshallData(dataToParse) {
return { data: dataToParse };
}
getAgents({ teamId }) {
@@ -42,14 +42,17 @@ const props = defineProps({
type: Number,
default: 0,
},
showAnalytics: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['edit', 'delete']);
const emit = defineEmits(['edit', 'delete', 'analytics']);
const { t } = useI18n();
const STATUS_COMPLETED = 'completed';
const STATUS_PROCESSING = 'processing';
const { formatMessage } = useMessageFormatter();
@@ -69,15 +72,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 || '');
@@ -123,7 +120,17 @@ const inboxIcon = computed(() => {
/>
</div>
</div>
<div class="flex items-center justify-end w-20 gap-2">
<div class="flex items-center justify-end w-28 gap-2">
<Button
v-if="showAnalytics"
v-tooltip.top="t('CAMPAIGN.WHATSAPP.CARD.ANALYTICS')"
variant="faded"
size="sm"
color="slate"
icon="i-lucide-chart-no-axes-column"
:title="t('CAMPAIGN.WHATSAPP.CARD.ANALYTICS')"
@click="emit('analytics')"
/>
<Button
v-if="isLiveChatType"
variant="faded"
@@ -1,5 +1,7 @@
<script setup>
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { useConfig } from 'dashboard/composables/useConfig';
defineProps({
campaigns: {
@@ -12,10 +14,13 @@ defineProps({
},
});
const emit = defineEmits(['edit', 'delete']);
const emit = defineEmits(['edit', 'delete', 'analytics']);
const STATUS_COMPLETED = 'completed';
const { isEnterprise } = useConfig();
const handleEdit = campaign => emit('edit', campaign);
const handleDelete = campaign => emit('delete', campaign);
const handleAnalytics = campaign => emit('analytics', campaign);
</script>
<template>
@@ -31,8 +36,14 @@ const handleDelete = campaign => emit('delete', campaign);
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
:is-live-chat-type="isLiveChatType"
:show-analytics="
isEnterprise &&
campaign.inbox?.channel_type === INBOX_TYPES.WHATSAPP &&
campaign.campaign_status === STATUS_COMPLETED
"
@edit="handleEdit(campaign)"
@delete="handleDelete(campaign)"
@analytics="handleAnalytics(campaign)"
/>
</div>
</template>
@@ -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">
@@ -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
@@ -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>
@@ -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>
@@ -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;
@@ -1,37 +0,0 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import FormattedContent from './Text/FormattedContent.vue';
import { useMessageContext } from '../provider.js';
const { attachments, content } = useMessageContext();
const attachment = computed(() => attachments.value?.[0] || {});
const url = computed(
() => attachment.value.dataUrl || attachment.value.data_url
);
const title = computed(
() =>
attachment.value.fallbackTitle ||
attachment.value.fallback_title ||
url.value
);
</script>
<template>
<BaseBubble class="p-3" data-bubble-name="fallback">
<FormattedContent v-if="content" :content="content" class="mb-2" />
<a
v-if="url"
:href="url"
target="_blank"
rel="noopener noreferrer"
class="block max-w-[320px] truncate text-sm text-n-brand underline"
>
{{ title }}
</a>
<span v-else class="text-sm text-n-slate-11">
{{ title }}
</span>
</BaseBubble>
</template>
@@ -145,6 +145,7 @@ const allowedMenuItems = computed(() => {
:src="currentUser.avatar_url"
: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">
@@ -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;
}
}
}
@@ -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,10 +375,7 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
if (this.isAWhatsAppChannel) {
return AUDIO_FORMATS.OGG;
}
if (this.isATelegramChannel) {
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
return AUDIO_FORMATS.MP3;
}
if (this.isAPIInbox) {
@@ -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,148 +0,0 @@
import { useFacebookPageConnect } from '../useFacebookPageConnect';
import { useMapGetter } from 'dashboard/composables/store';
import ChannelApi from 'dashboard/api/channels';
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
vi.mock('dashboard/composables/store', () => ({ useMapGetter: vi.fn() }));
vi.mock('dashboard/api/channels', () => ({
default: { fetchFacebookPages: vi.fn() },
}));
vi.mock(
'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils',
() => ({ setupFacebookSdk: vi.fn() })
);
const flushPromises = () =>
new Promise(resolve => {
setTimeout(resolve, 0);
});
const createDeferred = () => {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const ACCOUNT_ID = 42;
const PAGES = [
{ id: 'p1', name: 'Page One', access_token: 'page-token-1' },
{ id: 'p2', name: 'Page Two', access_token: 'page-token-2', exists: true },
];
const pagesResponse = {
data: { data: { page_details: PAGES, user_access_token: 'long-token' } },
};
// FB.login invokes its callback with the given response.
const stubLogin = response => {
window.FB = { login: vi.fn(callback => callback(response)) };
};
const connected = {
status: 'connected',
authResponse: { accessToken: 'user-token' },
};
describe('useFacebookPageConnect', () => {
beforeEach(() => {
vi.clearAllMocks();
window.chatwootConfig = { fbAppId: 'fb-app', fbApiVersion: 'v22.0' };
useMapGetter.mockReturnValue({ value: ACCOUNT_ID });
setupFacebookSdk.mockResolvedValue();
ChannelApi.fetchFacebookPages.mockResolvedValue(pagesResponse);
stubLogin(connected);
});
it('resolves the user token and pages on a connected login', async () => {
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).resolves.toEqual({
userAccessToken: 'long-token',
pages: PAGES,
});
expect(setupFacebookSdk).toHaveBeenCalledWith('fb-app', 'v22.0');
expect(ChannelApi.fetchFacebookPages).toHaveBeenCalledWith(
'user-token',
ACCOUNT_ID
);
expect(window.FB.login).toHaveBeenCalledWith(expect.any(Function), {
scope: expect.stringContaining('pages_show_list'),
});
});
it('resolves null when the user is not authorized', async () => {
stubLogin({ status: 'not_authorized' });
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).resolves.toBeNull();
expect(ChannelApi.fetchFacebookPages).not.toHaveBeenCalled();
});
it('resolves null on an unknown login status', async () => {
stubLogin({ status: 'unknown' });
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).resolves.toBeNull();
});
it('rejects when fetching pages fails', async () => {
ChannelApi.fetchFacebookPages.mockRejectedValue(new Error('fetch failed'));
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).rejects.toThrow('fetch failed');
});
it('rejects when the SDK fails to load', async () => {
const error = new Error('script load failed');
error.name = 'ScriptLoaderError';
setupFacebookSdk.mockRejectedValue(error);
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).rejects.toThrow('script load failed');
});
it('ignores a second call while a run is in flight', async () => {
const pending = createDeferred();
ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise);
const { loginAndFetchPages } = useFacebookPageConnect();
const first = loginAndFetchPages();
const second = loginAndFetchPages();
await expect(second).resolves.toBeNull();
pending.resolve(pagesResponse);
await first;
expect(window.FB.login).toHaveBeenCalledTimes(1);
});
it('toggles isAuthenticating across a run', async () => {
const pending = createDeferred();
ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise);
const { isAuthenticating, loginAndFetchPages } = useFacebookPageConnect();
expect(isAuthenticating.value).toBe(false);
const result = loginAndFetchPages();
await flushPromises();
expect(isAuthenticating.value).toBe(true);
pending.resolve(pagesResponse);
await result;
expect(isAuthenticating.value).toBe(false);
});
it('preloads the SDK once and reuses it for login', async () => {
const { preloadSdk, loginAndFetchPages } = useFacebookPageConnect();
preloadSdk();
preloadSdk();
await loginAndFetchPages();
expect(setupFacebookSdk).toHaveBeenCalledTimes(1);
});
});
@@ -1,208 +0,0 @@
import { useWhatsappEmbeddedSignup } from '../useWhatsappEmbeddedSignup';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
vi.mock(
'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils',
() => ({
setupFacebookSdk: vi.fn(),
initWhatsAppEmbeddedSignup: vi.fn(),
createMessageHandler: vi.fn(),
isValidBusinessData: vi.fn(data =>
Boolean(data && data.business_id && data.waba_id)
),
})
);
const flushPromises = () =>
new Promise(resolve => {
setTimeout(resolve, 0);
});
const createDeferred = () => {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const VALID_BUSINESS = {
business_id: 'biz-1',
waba_id: 'waba-1',
phone_number_id: 'phone-1',
};
describe('useWhatsappEmbeddedSignup', () => {
// The mocked createMessageHandler captures the callback the composable
// registers, so tests can simulate Meta's WA_EMBEDDED_SIGNUP postMessages
// directly without the window-event + origin plumbing (that is covered by
// the utils' own tests).
let signupCallback;
let registeredListener;
const emit = data => signupCallback(data);
beforeEach(() => {
vi.clearAllMocks();
window.chatwootConfig = {
whatsappAppId: 'app-id',
whatsappConfigurationId: 'config-id',
whatsappApiVersion: 'v22.0',
};
setupFacebookSdk.mockResolvedValue();
createMessageHandler.mockImplementation(callback => {
signupCallback = callback;
registeredListener = () => {};
return registeredListener;
});
});
it('resolves credentials when the auth code arrives before the business data', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
await flushPromises(); // SDK setup + FB.login resolve the code first
emit({ event: 'FINISH', data: VALID_BUSINESS });
await expect(result).resolves.toEqual({
code: 'auth-code',
business_id: 'biz-1',
waba_id: 'waba-1',
phone_number_id: 'phone-1',
});
expect(setupFacebookSdk).toHaveBeenCalledWith('app-id', 'v22.0');
expect(initWhatsAppEmbeddedSignup).toHaveBeenCalledWith('config-id');
});
it('resolves credentials when the business data arrives before the auth code', async () => {
const code = createDeferred();
initWhatsAppEmbeddedSignup.mockReturnValue(code.promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
// Business data lands first, while FB.login is still pending.
emit({
event: 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING',
data: VALID_BUSINESS,
});
code.resolve('late-code');
await expect(result).resolves.toEqual({
code: 'late-code',
business_id: 'biz-1',
waba_id: 'waba-1',
phone_number_id: 'phone-1',
});
});
it('defaults phone_number_id to an empty string when absent', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
await flushPromises();
emit({
event: 'FINISH',
data: { business_id: 'biz-1', waba_id: 'waba-1' },
});
await expect(result).resolves.toMatchObject({ phone_number_id: '' });
});
it('resolves null when FB.login is cancelled', async () => {
initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('Login cancelled'));
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
await expect(runEmbeddedSignup()).resolves.toBeNull();
});
it('resolves null on a CANCEL event', async () => {
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
emit({ event: 'CANCEL' });
await expect(result).resolves.toBeNull();
});
it('rejects with the Meta error message on an error event', async () => {
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
emit({ event: 'error', error_message: 'WABA not eligible' });
await expect(result).rejects.toThrow('WABA not eligible');
});
it('rejects when the business data is invalid', async () => {
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
emit({ event: 'FINISH', data: { business_id: 'biz-1' } }); // no waba_id
await expect(result).rejects.toThrow('Invalid business data');
});
it('rejects when the SDK or login fails for a non-cancel reason', async () => {
initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('popup blocked'));
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
await expect(runEmbeddedSignup()).rejects.toThrow('popup blocked');
});
it('ignores a second call while a run is in flight', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const first = runEmbeddedSignup();
const second = runEmbeddedSignup();
await expect(second).resolves.toBeNull();
// Let the first run finish so it doesn't leak into the next test.
await flushPromises();
emit({ event: 'FINISH', data: VALID_BUSINESS });
await first;
expect(setupFacebookSdk).toHaveBeenCalledTimes(1);
});
it('toggles isAuthenticating and removes the listener once settled', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const removeSpy = vi.spyOn(window, 'removeEventListener');
const { isAuthenticating, runEmbeddedSignup } = useWhatsappEmbeddedSignup();
expect(isAuthenticating.value).toBe(false);
const result = runEmbeddedSignup();
expect(isAuthenticating.value).toBe(true);
await flushPromises();
emit({ event: 'FINISH', data: VALID_BUSINESS });
await result;
expect(isAuthenticating.value).toBe(false);
expect(removeSpy).toHaveBeenCalledWith('message', registeredListener);
removeSpy.mockRestore();
});
});
@@ -52,10 +52,6 @@ export function useAccount() {
});
};
const finishOnboarding = async data => {
await store.dispatch('accounts/finishOnboarding', data);
};
return {
accountId,
route,
@@ -65,6 +61,5 @@ export function useAccount() {
isCloudFeatureEnabled,
isOnChatwootCloud,
updateAccount,
finishOnboarding,
};
}
@@ -1,81 +0,0 @@
import { ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import ChannelApi from 'dashboard/api/channels';
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
// Page-management + messaging scopes required to list pages and create a
// Channel::FacebookPage inbox (mirrors the standalone settings flow).
const FB_PAGE_SCOPES =
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages';
// Headless half of the Facebook Page connect flow: load the Meta SDK, run
// FB.login for page scopes, and fetch the user's pages. The caller owns the
// page-picker UI and the channel creation, because choosing a page is an
// interactive step (a user can manage several pages).
//
// Split into preloadSdk() + loginAndFetchPages() for popup safety: FB.login
// opens a popup and needs the click's transient activation. Preloading the SDK
// when the picker opens means the click-time `await` resolves within that
// activation window; a cold load resolves on the script's `load` task seconds
// later, after activation has expired, and the popup gets blocked.
export function useFacebookPageConnect() {
const accountId = useMapGetter('getCurrentAccountId');
const isAuthenticating = ref(false);
let sdkSetupPromise = null;
// Idempotent — call this when the picker UI opens. A failed load clears the
// cache so a later attempt can retry instead of being stuck on a rejection.
const preloadSdk = () => {
if (!sdkSetupPromise) {
sdkSetupPromise = setupFacebookSdk(
window.chatwootConfig?.fbAppId,
window.chatwootConfig?.fbApiVersion
).catch(error => {
sdkSetupPromise = null;
throw error;
});
}
return sdkSetupPromise;
};
// FB.login never rejects; resolve the user access token on success and null
// for any other status (closed popup, not_authorized, unknown).
const login = () =>
new Promise(resolve => {
window.FB.login(
response => {
resolve(
response.status === 'connected'
? response.authResponse?.accessToken || null
: null
);
},
{ scope: FB_PAGE_SCOPES }
);
});
// Resolves { userAccessToken, pages } on success, null when the user cancels,
// and rejects on SDK-load or page-fetch failure (the caller maps it to UI).
const loginAndFetchPages = async () => {
if (isAuthenticating.value) return null;
isAuthenticating.value = true;
try {
await preloadSdk();
const token = await login();
if (!token) return null;
const response = await ChannelApi.fetchFacebookPages(
token,
accountId.value
);
const { page_details: pages, user_access_token: userAccessToken } =
response.data.data;
return { userAccessToken, pages };
} finally {
isAuthenticating.value = false;
}
};
return { isAuthenticating, preloadSdk, loginAndFetchPages };
}
@@ -1,96 +0,0 @@
import { ref } from 'vue';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
isValidBusinessData,
} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
// Drives Meta's WhatsApp embedded-signup popup (Facebook JS SDK). FB.login()
// resolves an auth `code` while the WABA identifiers (waba_id, phone_number_id)
// arrive separately over a postMessage event — order isn't guaranteed, so we
// hold both and resolve once both are present.
//
// `runEmbeddedSignup` returns the signup credentials; the caller exchanges them
// for an inbox via `inboxes/createWhatsAppEmbeddedSignup` and owns its own UX
// (alerts, navigation, etc). Resolves `null` when the user cancels the popup;
// rejects on SDK load or signup errors. The window listener is scoped to a
// single run, so this is safe to call from anywhere without lifecycle wiring.
export function useWhatsappEmbeddedSignup() {
const isAuthenticating = ref(false);
const runEmbeddedSignup = () => {
if (isAuthenticating.value) return Promise.resolve(null);
isAuthenticating.value = true;
return new Promise((resolve, reject) => {
let authCode = null;
let businessData = null;
let settled = false;
let messageHandler;
const settle = (fn, value) => {
if (settled) return;
settled = true;
window.removeEventListener('message', messageHandler);
isAuthenticating.value = false;
fn(value);
};
// Both the auth code and the business data arrive asynchronously and in
// no fixed order; only resolve once we're holding both.
const resolveIfReady = () => {
if (!authCode || !businessData) return;
settle(resolve, {
code: authCode,
business_id: businessData.business_id,
waba_id: businessData.waba_id,
phone_number_id: businessData.phone_number_id || '',
});
};
messageHandler = createMessageHandler(data => {
if (
data.event === 'FINISH' ||
data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING'
) {
if (!isValidBusinessData(data.data)) {
settle(reject, new Error('Invalid business data'));
return;
}
businessData = data.data;
resolveIfReady();
} else if (data.event === 'CANCEL') {
settle(resolve, null);
} else if (data.event === 'error') {
settle(reject, new Error(data.error_message || 'Signup error'));
}
});
window.addEventListener('message', messageHandler);
(async () => {
try {
await setupFacebookSdk(
window.chatwootConfig?.whatsappAppId,
window.chatwootConfig?.whatsappApiVersion
);
authCode = await initWhatsAppEmbeddedSignup(
window.chatwootConfig?.whatsappConfigurationId
);
resolveIfReady();
} catch (error) {
// FB.login() rejects with 'Login cancelled' when the user dismisses
// the popup — treat it as a cancel rather than an error.
if (error.message === 'Login cancelled') {
settle(resolve, null);
} else {
settle(reject, error);
}
}
})();
});
};
return { isAuthenticating, runEmbeddedSignup };
}
+20 -2
View File
@@ -14,7 +14,6 @@ export const FORMATTING = {
'link',
'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',
},
];
@@ -1,10 +1,9 @@
import { openDB } from 'idb';
import { DATA_VERSION } from './version';
import { cacheableModels, cacheableModelNames } from './cacheableModels';
export class DataManager {
constructor(accountId) {
this.modelsToSync = cacheableModelNames;
this.modelsToSync = ['inbox', 'label', 'team'];
this.accountId = accountId;
this.db = null;
}
@@ -12,26 +11,12 @@ export class DataManager {
async initDb() {
if (this.db) return this.db;
const dbName = `cw-store-${this.accountId}`;
this.db = await openDB(dbName, DATA_VERSION, {
upgrade(db, oldVersion, _newVersion, tx) {
// Flush data carried over from a previous schema version so a
// DATA_VERSION bump acts as a global cache reset. oldVersion === 0 on
// first install, so fresh devices skip this. Clearing before creating
// means we only ever clear stores that pre-existed this upgrade.
if (oldVersion > 0) {
for (let index = 0; index < db.objectStoreNames.length; index += 1) {
tx.objectStore(db.objectStoreNames.item(index)).clear();
}
}
if (!db.objectStoreNames.contains('cache-keys')) {
db.createObjectStore('cache-keys');
}
cacheableModels.forEach(model => {
if (!db.objectStoreNames.contains(model.name)) {
db.createObjectStore(model.name, { keyPath: 'id' });
}
});
this.db = await openDB(`cw-store-${this.accountId}`, DATA_VERSION, {
upgrade(db) {
db.createObjectStore('cache-keys');
db.createObjectStore('inbox', { keyPath: 'id' });
db.createObjectStore('label', { keyPath: 'id' });
db.createObjectStore('team', { keyPath: 'id' });
},
});
@@ -56,7 +41,7 @@ export class DataManager {
async replace({ modelName, data }) {
this.validateModel(modelName);
await this.db.clear(modelName);
this.db.clear(modelName);
return this.push({ modelName, data });
}
@@ -80,11 +65,9 @@ export class DataManager {
}
async setCacheKeys(cacheKeys) {
await Promise.all(
Object.entries(cacheKeys).map(([modelName, value]) =>
this.db.put('cache-keys', value, modelName)
)
);
Object.keys(cacheKeys).forEach(async modelName => {
this.db.put('cache-keys', cacheKeys[modelName], modelName);
});
}
async getCacheKey(modelName) {
@@ -1,23 +0,0 @@
// Single source of truth for IDB-cached workspace config.
//
// Each entry must keep `name` equal to the Rails `Model.name.underscore` value
// so the server's `cache_keys` payload (and the IDB object store name) lines up
// with what the client looks up.
//
// `setMutation` is the full commit path used to seed Vuex from IDB (boot
// paint) and to swap in refetched rows (event-driven revalidation). Every
// SET_* mutation must REPLACE its records (not merge) so rows deleted
// server-side never survive as phantoms.
export const cacheableModels = [
{ name: 'inbox', setMutation: 'inboxes/SET_INBOXES' },
{ name: 'label', setMutation: 'labels/SET_LABELS' },
{ name: 'team', setMutation: 'teams/SET_TEAMS' },
{ name: 'canned_response', setMutation: 'SET_CANNED' },
{ name: 'account_user', setMutation: 'agents/SET_AGENTS' },
{
name: 'custom_attribute_definition',
setMutation: 'attributes/SET_CUSTOM_ATTRIBUTE',
},
];
export const cacheableModelNames = cacheableModels.map(model => model.name);
@@ -1,42 +0,0 @@
import AgentAPI from 'dashboard/api/agents';
import AttributeAPI from 'dashboard/api/attributes';
import CannedResponseAPI from 'dashboard/api/cannedResponse';
import InboxesAPI from 'dashboard/api/inboxes';
import LabelsAPI from 'dashboard/api/labels';
import TeamsAPI from 'dashboard/api/teams';
import { cacheableModels } from './cacheableModels';
// model name → cache-enabled API client. Lives here rather than in
// cacheableModels to keep that module import-cycle-free: the API clients
// import DataManager, which imports cacheableModels.
const apiByModel = {
inbox: InboxesAPI,
label: LabelsAPI,
team: TeamsAPI,
canned_response: CannedResponseAPI,
account_user: AgentAPI,
custom_attribute_definition: AttributeAPI,
};
const revalidateModel = async (store, model, newKey) => {
try {
const api = apiByModel[model.name];
if (await api.validateCacheKey(newKey)) return;
const response = await api.refetchAndCommit(newKey);
store.commit(model.setMutation, api.extractDataFromResponse(response));
} catch {
// Ignore error — a failed refetch leaves the painted data in place; the
// next pushed key map retries.
}
};
// The single freshness engine: given a pushed { model_name => key } map
// (RoomChannel transmits one on every (re)subscribe, the server broadcasts
// one on every change), diff each key against IDB and refetch mismatches.
export const dispatchCacheRevalidations = (store, keys = {}) =>
Promise.all(
cacheableModels
.filter(model => keys[model.name] !== undefined)
.map(model => revalidateModel(store, model, keys[model.name]))
);
@@ -1,31 +0,0 @@
import { DataManager } from './DataManager';
import { cacheableModels } from './cacheableModels';
// Seed Vuex from IndexedDB before the dashboard renders so warm boots paint
// cached config instantly. This is purely local — zero network calls.
//
// Freshness is handled entirely by the account.cache_invalidated event:
// RoomChannel transmits the current cache-key map on every (re)subscribe, and
// the server broadcasts it on every change. dispatchCacheRevalidations diffs
// those keys against IDB and refetches mismatches — the client never pulls
// cache keys itself.
export default async function paintStoresFromCache(store, accountId) {
let dm;
try {
dm = new DataManager(accountId);
await dm.initDb();
} catch {
// IDB unsupported (e.g. Firefox private mode) — silent no-op. Components
// will fetch from the network normally via the cache-enabled API client.
return;
}
// Stale-while-revalidate paint: commit cached data into Vuex immediately.
await Promise.all(
cacheableModels.map(async model => {
const localData = await dm.get({ modelName: model.name });
if (localData.length === 0) return;
store.commit(model.setMutation, localData);
})
);
}
@@ -1,9 +1,3 @@
// Bump DATA_VERSION to (a) add new object stores to the IDB schema or (b)
// flush bad/stale cache globally. The `upgrade()` callback in DataManager runs
// only when the stored DB version is less than the requested version; on any
// such bump it clears every existing store (a full cache reset) and then
// idempotently creates any missing stores. So bump this whenever a cached
// model's serializer shape changes, or to force all clients to refetch.
//
// Thursday, 28 May 2026 — bumped to add canned_response + account_user stores + custom_attribute_definition store
export const DATA_VERSION = '1748390400';
// Monday, 13 March 2023
// Change this version if you want to invalidate old data
export const DATA_VERSION = '1678706392';
@@ -98,6 +98,17 @@ class ReconnectService {
await this.store.dispatch('notifications/index', { ...filter, page: 1 });
};
revalidateCaches = async () => {
const { label, inbox, team } = await this.store.dispatch(
'accounts/getCacheKeys'
);
await Promise.all([
this.store.dispatch('labels/revalidate', { newKey: label }),
this.store.dispatch('inboxes/revalidate', { newKey: inbox }),
this.store.dispatch('teams/revalidate', { newKey: team }),
]);
};
handleRouteSpecificFetch = async () => {
const currentRoute = this.router.currentRoute.value.name;
if (isAConversationRoute(currentRoute, true)) {
@@ -127,11 +138,9 @@ class ReconnectService {
this.setConversationLastMessageId();
};
// Cached workspace config needs no explicit revalidation here: ActionCable
// auto-resubscribes after a drop, and RoomChannel pushes the cache-key map
// on every subscribe via the account.cache_invalidated event.
onReconnect = async () => {
await this.handleRouteSpecificFetch();
await this.revalidateCaches();
emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED);
};
}
@@ -14,7 +14,6 @@ import {
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { dispatchCacheRevalidations } from './CacheHelper/dispatchCacheRevalidations';
const { isImpersonating } = useImpersonation();
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
@@ -270,7 +269,10 @@ class ActionCableConnector extends BaseActionCableConnector {
};
onCacheInvalidate = data => {
dispatchCacheRevalidations(this.app.$store, data.cache_keys);
const keys = data.cache_keys;
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
onVoiceCallIncoming = data => {
@@ -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
@@ -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,
];
};
@@ -1,108 +0,0 @@
import { dispatchCacheRevalidations } from '../../CacheHelper/dispatchCacheRevalidations';
import InboxesAPI from 'dashboard/api/inboxes';
import LabelsAPI from 'dashboard/api/labels';
import CannedResponseAPI from 'dashboard/api/cannedResponse';
import TeamsAPI from 'dashboard/api/teams';
vi.mock('dashboard/api/inboxes', () => ({
default: {
validateCacheKey: vi.fn(),
refetchAndCommit: vi.fn(),
extractDataFromResponse: vi.fn(),
},
}));
vi.mock('dashboard/api/labels', () => ({
default: {
validateCacheKey: vi.fn(),
refetchAndCommit: vi.fn(),
extractDataFromResponse: vi.fn(),
},
}));
vi.mock('dashboard/api/teams', () => ({
default: {
validateCacheKey: vi.fn(),
refetchAndCommit: vi.fn(),
extractDataFromResponse: vi.fn(),
},
}));
vi.mock('dashboard/api/cannedResponse', () => ({
default: {
validateCacheKey: vi.fn(),
refetchAndCommit: vi.fn(),
extractDataFromResponse: vi.fn(),
},
}));
vi.mock('dashboard/api/agents', () => ({
default: {
validateCacheKey: vi.fn(),
refetchAndCommit: vi.fn(),
extractDataFromResponse: vi.fn(),
},
}));
vi.mock('dashboard/api/attributes', () => ({
default: {
validateCacheKey: vi.fn(),
refetchAndCommit: vi.fn(),
extractDataFromResponse: vi.fn(),
},
}));
describe('dispatchCacheRevalidations', () => {
let store;
beforeEach(() => {
vi.clearAllMocks();
store = { commit: vi.fn() };
});
it('refetches stale models and commits via their setMutation', async () => {
InboxesAPI.validateCacheKey.mockResolvedValue(false);
InboxesAPI.refetchAndCommit.mockResolvedValue({ data: { payload: [] } });
InboxesAPI.extractDataFromResponse.mockReturnValue([{ id: 1 }]);
LabelsAPI.validateCacheKey.mockResolvedValue(true);
await dispatchCacheRevalidations(store, {
inbox: 'inbox-key',
label: 'label-key',
});
expect(InboxesAPI.refetchAndCommit).toHaveBeenCalledWith('inbox-key');
expect(store.commit).toHaveBeenCalledWith('inboxes/SET_INBOXES', [
{ id: 1 },
]);
expect(LabelsAPI.refetchAndCommit).not.toHaveBeenCalled();
expect(store.commit).toHaveBeenCalledTimes(1);
});
it('skips models absent from the key payload', async () => {
InboxesAPI.validateCacheKey.mockResolvedValue(true);
await dispatchCacheRevalidations(store, { inbox: 'inbox-key' });
expect(TeamsAPI.validateCacheKey).not.toHaveBeenCalled();
expect(store.commit).not.toHaveBeenCalled();
});
it('treats missing keys as an empty payload', async () => {
await dispatchCacheRevalidations(store);
expect(InboxesAPI.validateCacheKey).not.toHaveBeenCalled();
expect(store.commit).not.toHaveBeenCalled();
});
it('swallows per-model errors so one failure does not block the rest', async () => {
InboxesAPI.validateCacheKey.mockResolvedValue(false);
InboxesAPI.refetchAndCommit.mockRejectedValue(new Error('network down'));
CannedResponseAPI.validateCacheKey.mockResolvedValue(false);
CannedResponseAPI.refetchAndCommit.mockResolvedValue({ data: [] });
CannedResponseAPI.extractDataFromResponse.mockReturnValue([{ id: 7 }]);
await dispatchCacheRevalidations(store, {
inbox: 'inbox-key',
canned_response: 'canned-key',
});
expect(store.commit).toHaveBeenCalledWith('SET_CANNED', [{ id: 7 }]);
expect(store.commit).toHaveBeenCalledTimes(1);
});
});
@@ -1,79 +0,0 @@
import paintStoresFromCache from '../../CacheHelper/paintStoresFromCache';
import { DataManager } from '../../CacheHelper/DataManager';
describe('paintStoresFromCache', () => {
const accountId = 'paint-test-account';
const originalAxios = window.axios;
let axiosMock;
let dm;
let storeMock;
beforeEach(async () => {
axiosMock = {
get: vi.fn(),
};
window.axios = axiosMock;
storeMock = {
commit: vi.fn(),
dispatch: vi.fn(),
};
dm = new DataManager(accountId);
await dm.initDb();
});
afterEach(async () => {
const tx = dm.db.transaction(
[...dm.modelsToSync, 'cache-keys'],
'readwrite'
);
[...dm.modelsToSync, 'cache-keys'].forEach(name => {
tx.objectStore(name).clear();
});
await tx.done;
window.axios = originalAxios;
});
it('does nothing when IDB is empty (first ever load)', async () => {
await paintStoresFromCache(storeMock, accountId);
expect(storeMock.commit).not.toHaveBeenCalled();
expect(storeMock.dispatch).not.toHaveBeenCalled();
});
it('seeds Vuex from IDB without any network interaction', async () => {
await dm.push({
modelName: 'inbox',
data: [{ id: 1, name: 'Support' }],
});
await dm.push({
modelName: 'label',
data: [{ id: 9, title: 'Bug' }],
});
await paintStoresFromCache(storeMock, accountId);
expect(storeMock.commit).toHaveBeenCalledWith('inboxes/SET_INBOXES', [
{ id: 1, name: 'Support' },
]);
expect(storeMock.commit).toHaveBeenCalledWith('labels/SET_LABELS', [
{ id: 9, title: 'Bug' },
]);
expect(axiosMock.get).not.toHaveBeenCalled();
expect(storeMock.dispatch).not.toHaveBeenCalled();
});
it('seeds teams via SET_TEAMS', async () => {
await dm.push({
modelName: 'team',
data: [{ id: 1, name: 'Sales' }],
});
await paintStoresFromCache(storeMock, accountId);
expect(storeMock.commit).toHaveBeenCalledWith('teams/SET_TEAMS', [
{ id: 1, name: 'Sales' },
]);
});
});
@@ -253,6 +253,27 @@ describe('ReconnectService', () => {
});
});
describe('revalidateCaches', () => {
it('should dispatch revalidate actions for labels, inboxes, and teams', async () => {
storeMock.dispatch.mockResolvedValueOnce({
label: 'labelKey',
inbox: 'inboxKey',
team: 'teamKey',
});
await reconnectService.revalidateCaches();
expect(storeMock.dispatch).toHaveBeenCalledWith('accounts/getCacheKeys');
expect(storeMock.dispatch).toHaveBeenCalledWith('labels/revalidate', {
newKey: 'labelKey',
});
expect(storeMock.dispatch).toHaveBeenCalledWith('inboxes/revalidate', {
newKey: 'inboxKey',
});
expect(storeMock.dispatch).toHaveBeenCalledWith('teams/revalidate', {
newKey: 'teamKey',
});
});
});
describe('handleRouteSpecificFetch', () => {
it('should fetch conversations and messages if current route is a conversation route', async () => {
isAConversationRoute.mockReturnValue(true);
@@ -314,10 +335,12 @@ describe('ReconnectService', () => {
});
describe('onReconnect', () => {
it('should handle route-specific fetch and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => {
it('should handle route-specific fetch, revalidate caches, and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => {
reconnectService.handleRouteSpecificFetch = vi.fn();
reconnectService.revalidateCaches = vi.fn();
await reconnectService.onReconnect();
expect(reconnectService.handleRouteSpecificFetch).toHaveBeenCalled();
expect(reconnectService.revalidateCaches).toHaveBeenCalled();
expect(emitter.emit).toHaveBeenCalledWith(
BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED
);
@@ -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": {
@@ -607,12 +607,9 @@
"DRAFT": "እቅድ",
"ARCHIVE": "አርክቭ",
"TRANSLATE": "Translate",
"MOVE_TO_CATEGORY": "Category",
"DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"CATEGORY_SUCCESS": "Articles moved successfully",
"CATEGORY_ERROR": "Failed to move articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "Delete",
@@ -869,28 +866,6 @@
},
"EDIT_CONFIGURATION": "ቅንብር አርትዕ"
},
"LAYOUT_CONTENT": {
"HEADER": "Appearance",
"DESCRIPTION": "Pick the layout that fits how your visitors read.",
"LAYOUT": {
"CLASSIC": {
"TITLE": "Classic",
"DESCRIPTION": "A welcoming home page with search and featured topics."
},
"SIDEBAR": {
"TITLE": "Documentation",
"DESCRIPTION": "Side-by-side navigation that keeps every guide a click away."
}
},
"SOCIAL_LINKS": {
"HEADER": "Social links",
"DESCRIPTION": "Add the handle for each network and your help center builds the full link. Shown in the documentation layout footer.",
"PLACEHOLDER": "handle",
"ADD": "Add social link",
"REMOVE": "አስወግድ"
},
"SAVE": "ለውጦች አስቀምጥ"
},
"API": {
"CREATE_PORTAL": {
"SUCCESS_MESSAGE": "ፖርታል በተሳካ ሁኔታ ተፈጥሯል",

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