Merge branch 'release/2.16.0'

This commit is contained in:
Sojan 2023-04-18 01:04:19 +05:30
commit 7bd400772d
724 changed files with 19555 additions and 3394 deletions

View File

@ -1,8 +1,6 @@
.bundle .bundle
.env .env
.env.* .env.*
.git
.gitignore
docker-compose.* docker-compose.*
docker/Dockerfile docker/Dockerfile
docker/dockerfiles docker/dockerfiles

View File

@ -110,6 +110,8 @@ AWS_REGION=
RAILS_LOG_TO_STDOUT=true RAILS_LOG_TO_STDOUT=true
LOG_LEVEL=info LOG_LEVEL=info
LOG_SIZE=500 LOG_SIZE=500
# Configure this environment variable if you want to use lograge instead of rails logger
#LOGRAGE_ENABLED=true
### This environment variables are only required if you are setting up social media channels ### This environment variables are only required if you are setting up social media channels
@ -159,9 +161,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# for mobile apps # for mobile apps
# FCM_SERVER_KEY= # FCM_SERVER_KEY=
## Bot Customizations
USE_INBOX_AVATAR_FOR_BOT=true
### APM and Error Monitoring configurations ### APM and Error Monitoring configurations
## Elastic APM ## Elastic APM
## https://www.elastic.co/guide/en/apm/agent/ruby/current/getting-started-rails.html ## https://www.elastic.co/guide/en/apm/agent/ruby/current/getting-started-rails.html

5
.gitignore vendored
View File

@ -61,8 +61,9 @@ test/cypress/videos/*
/config/master.key /config/master.key
/config/*.enc /config/*.enc
.vscode/settings.json #ignore files under .vscode directory
.vscode
# yalc for local testing # yalc for local testing
.yalc .yalc
yalc.lock yalc.lock

34
Gemfile
View File

@ -87,7 +87,7 @@ gem 'line-bot-api'
gem 'twilio-ruby', '~> 5.66' gem 'twilio-ruby', '~> 5.66'
# twitty will handle subscription of twitter account events # twitty will handle subscription of twitter account events
# gem 'twitty', git: 'https://github.com/chatwoot/twitty' # gem 'twitty', git: 'https://github.com/chatwoot/twitty'
gem 'twitty' gem 'twitty', '~> 0.1.5'
# facebook client # facebook client
gem 'koala' gem 'koala'
# slack client # slack client
@ -149,7 +149,23 @@ gem 'net-imap', require: false
gem 'net-pop', require: false gem 'net-pop', require: false
gem 'net-smtp', require: false gem 'net-smtp', require: false
group :production, :staging do # Include logrange conditionally in intializer using env variable
gem 'lograge', '~> 0.12.0', require: false
# worked with microsoft refresh token
gem 'omniauth-oauth2'
gem 'audited', '~> 5.2'
# need for google auth
gem 'omniauth'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection', '~> 1.0'
### Gems required only in specific deployment environments ###
##############################################################
group :production do
# we dont want request timing out in development while using byebug # we dont want request timing out in development while using byebug
gem 'rack-timeout' gem 'rack-timeout'
end end
@ -165,6 +181,10 @@ group :development do
# When we want to squash migrations # When we want to squash migrations
gem 'squasher' gem 'squasher'
# profiling
gem 'rack-mini-profiler', require: false
gem 'stackprof'
end end
group :test do group :test do
@ -202,13 +222,3 @@ group :development, :test do
gem 'spring' gem 'spring'
gem 'spring-watcher-listen' gem 'spring-watcher-listen'
end end
# worked with microsoft refresh token
gem 'omniauth-oauth2'
gem 'audited', '~> 5.2'
# need for google auth
gem 'omniauth'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection', '~> 1.0'

View File

@ -137,7 +137,7 @@ GEM
byebug (11.1.3) byebug (11.1.3)
climate_control (1.1.1) climate_control (1.1.1)
coderay (1.1.3) coderay (1.1.3)
commonmarker (0.23.7) commonmarker (0.23.9)
concurrent-ruby (1.2.2) concurrent-ruby (1.2.2)
connection_pool (2.2.5) connection_pool (2.2.5)
crack (0.4.5) crack (0.4.5)
@ -416,6 +416,11 @@ GEM
llhttp-ffi (0.4.0) llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0) ffi-compiler (~> 1.0)
rake (~> 13.0) rake (~> 13.0)
lograge (0.12.0)
actionpack (>= 4)
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.19.1) loofah (2.19.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.5.9) nokogiri (>= 1.5.9)
@ -459,14 +464,14 @@ GEM
sidekiq sidekiq
newrelic_rpm (8.15.0) newrelic_rpm (8.15.0)
nio4r (2.5.8) nio4r (2.5.8)
nokogiri (1.14.2) nokogiri (1.14.3)
mini_portile2 (~> 2.8.0) mini_portile2 (~> 2.8.0)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.14.2-arm64-darwin) nokogiri (1.14.3-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.14.2-x86_64-darwin) nokogiri (1.14.3-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.14.2-x86_64-linux) nokogiri (1.14.3-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
oauth (0.5.10) oauth (0.5.10)
oauth2 (2.0.9) oauth2 (2.0.9)
@ -520,6 +525,8 @@ GEM
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rack-cors (1.1.1) rack-cors (1.1.1)
rack (>= 2.0.0) rack (>= 2.0.0)
rack-mini-profiler (3.0.0)
rack (>= 1.2.0)
rack-protection (3.0.5) rack-protection (3.0.5)
rack rack
rack-proxy (0.7.2) rack-proxy (0.7.2)
@ -566,6 +573,8 @@ GEM
declarative (< 0.1.0) declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0) trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0) uber (< 0.2.0)
request_store (1.5.1)
rack (>= 1.4)
responders (3.0.1) responders (3.0.1)
actionpack (>= 5.0) actionpack (>= 5.0)
railties (>= 5.0) railties (>= 5.0)
@ -692,6 +701,7 @@ GEM
activesupport (>= 5.2) activesupport (>= 5.2)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
squasher (0.6.2) squasher (0.6.2)
stackprof (0.2.24)
statsd-ruby (1.5.0) statsd-ruby (1.5.0)
stripe (6.5.0) stripe (6.5.0)
telephone_number (1.4.16) telephone_number (1.4.16)
@ -707,7 +717,7 @@ GEM
faraday (>= 0.9, < 3.0) faraday (>= 0.9, < 3.0)
jwt (>= 1.5, <= 2.5) jwt (>= 1.5, <= 2.5)
nokogiri (>= 1.6, < 2.0) nokogiri (>= 1.6, < 2.0)
twitty (0.1.4) twitty (0.1.5)
oauth oauth
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
@ -821,6 +831,7 @@ DEPENDENCIES
line-bot-api line-bot-api
liquid liquid
listen listen
lograge (~> 0.12.0)
maxminddb maxminddb
mock_redis mock_redis
net-imap net-imap
@ -840,6 +851,7 @@ DEPENDENCIES
pundit pundit
rack-attack rack-attack
rack-cors rack-cors
rack-mini-profiler
rack-timeout rack-timeout
rails (~> 6.1, >= 6.1.7.3) rails (~> 6.1, >= 6.1.7.3)
redis redis
@ -865,12 +877,13 @@ DEPENDENCIES
spring spring
spring-watcher-listen spring-watcher-listen
squasher squasher
stackprof
stripe stripe
telephone_number telephone_number
test-prof test-prof
time_diff time_diff
twilio-ruby (~> 5.66) twilio-ruby (~> 5.66)
twitty twitty (~> 0.1.5)
tzinfo-data tzinfo-data
uglifier uglifier
valid_email2 valid_email2

View File

@ -1,3 +1,3 @@
release: POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rails db:chatwoot_prepare release: POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rails db:chatwoot_prepare && echo $SOURCE_VERSION > .git_sha
web: bundle exec rails ip_lookup:setup && bin/rails server -p $PORT -e $RAILS_ENV web: bundle exec rails ip_lookup:setup && bin/rails server -p $PORT -e $RAILS_ENV
worker: bundle exec rails ip_lookup:setup && bundle exec sidekiq -C config/sidekiq.yml worker: bundle exec rails ip_lookup:setup && bundle exec sidekiq -C config/sidekiq.yml

View File

@ -19,6 +19,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def destroy def destroy
@agent.current_account_user.destroy! @agent.current_account_user.destroy!
delete_user_record(@agent)
head :ok head :ok
end end
@ -74,4 +75,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def validate_limit def validate_limit
render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents] render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents]
end end
def delete_user_record(agent)
DeleteObjectJob.perform_later(agent) if agent.reload.account_users.blank?
end
end end

View File

@ -1,14 +1,19 @@
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
before_action :portal before_action :portal
before_action :check_authorization before_action :check_authorization
before_action :fetch_article, except: [:index, :create, :attach_file] before_action :fetch_article, except: [:index, :create, :attach_file, :reorder]
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
def index def index
@portal_articles = @portal.articles @portal_articles = @portal.articles
@all_articles = @portal_articles.search(list_params) @all_articles = @portal_articles.search(list_params)
@articles_count = @all_articles.count @articles_count = @all_articles.count
@articles = @all_articles.order_by_updated_at.page(@current_page)
@articles = if list_params[:category_slug].present?
@all_articles.order_by_position.page(@current_page).per(50)
else
@all_articles.order_by_updated_at.page(@current_page)
end
end end
def create def create
@ -43,6 +48,11 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
render json: { file_url: url_for(file_blob) } render json: { file_url: url_for(file_blob) }
end end
def reorder
Article.update_positions(params[:positions_hash])
head :ok
end
private private
def fetch_article def fetch_article

View File

@ -119,11 +119,12 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end end
def fetch_contacts_with_conversation_count(contacts) def fetch_contacts_with_conversation_count(contacts)
contacts_with_conversation_count = filtrate(contacts).left_outer_joins(:conversations) conversation_count_sub_query = 'SELECT COUNT(*) FROM "conversations" WHERE "conversations"."contact_id" = "contacts"."id"'
.select('contacts.*, COUNT(conversations.id) as conversations_count') contacts_with_conversation_count = filtrate(contacts)
.group('contacts.id') .select("contacts.*, (#{conversation_count_sub_query}) as conversations_count")
.includes([{ avatar_attachment: [:blob] }]) .group('contacts.id')
.page(@current_page).per(RESULTS_PER_PAGE) .includes([{ avatar_attachment: [:blob] }])
.page(@current_page).per(RESULTS_PER_PAGE)
return contacts_with_conversation_count.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes return contacts_with_conversation_count.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes

View File

@ -82,8 +82,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end end
def destroy def destroy
@inbox.destroy! ::DeleteObjectJob.perform_later(@inbox) if @inbox.present?
head :ok render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
end end
private private

View File

@ -7,11 +7,12 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
redirect_url = microsoft_client.auth_code.authorize_url( redirect_url = microsoft_client.auth_code.authorize_url(
{ {
redirect_uri: "#{base_url}/microsoft/callback", redirect_uri: "#{base_url}/microsoft/callback",
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid', scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile',
prompt: 'consent' prompt: 'consent'
} }
) )
if redirect_url if redirect_url
email = email.downcase
::Redis::Alfred.setex(email, Current.account.id, 5.minutes) ::Redis::Alfred.setex(email, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url } render json: { success: true, url: redirect_url }
else else

View File

@ -1,5 +1,6 @@
class Api::V1::AccountsController < Api::BaseController class Api::V1::AccountsController < Api::BaseController
include AuthHelper include AuthHelper
include CacheKeysHelper
skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception, skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception,
only: [:create], raise: false only: [:create], raise: false
@ -30,6 +31,10 @@ class Api::V1::AccountsController < Api::BaseController
end end
end end
def cache_keys
render json: { cache_keys: get_cache_keys }, status: :ok
end
def show def show
@latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION) @latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION)
render 'api/v1/accounts/show', format: :json render 'api/v1/accounts/show', format: :json
@ -47,6 +52,14 @@ class Api::V1::AccountsController < Api::BaseController
private private
def get_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 def fetch_account
@account = current_user.accounts.find(params[:id]) @account = current_user.accounts.find(params[:id])
@current_account_user = @account.account_users.find_by(user_id: current_user.id) @current_account_user = @account.account_users.find_by(user_id: current_user.id)

View File

@ -65,6 +65,16 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
head :ok head :ok
end end
def set_custom_attributes
conversation.update!(custom_attributes: permitted_params[:custom_attributes])
end
def destroy_custom_attributes
conversation.custom_attributes = conversation.custom_attributes.excluding(params[:custom_attribute])
conversation.save!
render json: conversation
end
private private
def trigger_typing_event(event) def trigger_typing_event(event)

View File

@ -1,5 +1,7 @@
class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
include Api::V2::Accounts::ReportsHelper include Api::V2::Accounts::ReportsHelper
include Api::V2::Accounts::HeatmapHelper
before_action :check_authorization before_action :check_authorization
def index def index
@ -32,6 +34,14 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
generate_csv('teams_report', 'api/v2/accounts/reports/teams') generate_csv('teams_report', 'api/v2/accounts/reports/teams')
end end
def conversation_traffic
@report_data = generate_conversations_heatmap_report
timezone_offset = (params[:timezone_offset] || 0).to_f
@timezone = ActiveSupport::TimeZone[timezone_offset]
generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic')
end
def conversations def conversations
return head :unprocessable_entity if params[:type].blank? return head :unprocessable_entity if params[:type].blank?

View File

@ -56,7 +56,8 @@ class DashboardController < ActionController::Base
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
FACEBOOK_API_VERSION: 'v14.0', FACEBOOK_API_VERSION: 'v14.0',
IS_ENTERPRISE: ChatwootApp.enterprise?, IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', '') AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH
} }
end end
end end

View File

@ -8,7 +8,7 @@ class Microsoft::CallbacksController < ApplicationController
) )
inbox = find_or_create_inbox inbox = find_or_create_inbox
::Redis::Alfred.delete(users_data['email']) ::Redis::Alfred.delete(users_data['email'].downcase)
redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id) redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception ChatwootExceptionTracker.new(e).capture_exception
@ -31,7 +31,7 @@ class Microsoft::CallbacksController < ApplicationController
end end
def account_id def account_id
::Redis::Alfred.get(users_data['email']) ::Redis::Alfred.get(users_data['email'].downcase)
end end
def account def account

View File

@ -47,4 +47,11 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
Internal::SeedAccountJob.perform_later(requested_resource) Internal::SeedAccountJob.perform_later(requested_resource)
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account seeding triggered') redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account seeding triggered')
end end
def destroy
account = Account.find(params[:id])
DeleteObjectJob.perform_later(account) if account.present?
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account deletion is in progress.')
end
end end

View File

@ -1,6 +1,18 @@
class SuperAdmin::UsersController < SuperAdmin::ApplicationController class SuperAdmin::UsersController < SuperAdmin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior # Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated. # For example, you may want to send an email after a foo is updated.
def create
resource = resource_class.new(resource_params)
authorize_resource(resource)
if resource.save
redirect_to super_admin_user_path(resource), notice: translate_with_resource('create.success')
else
notice = resource.errors.full_messages.first
redirect_to new_super_admin_user_path, notice: notice
end
end
# #
# def update # def update
# super # super

View File

@ -14,7 +14,7 @@ class Twitter::CallbacksController < Twitter::BaseController
redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id) redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
end end
rescue StandardError => e rescue StandardError => e
Rails.logger.error e ChatwootExceptionTracker.new(e).capture_exception
redirect_to twitter_app_redirect_url redirect_to twitter_app_redirect_url
end end
@ -49,10 +49,22 @@ class Twitter::CallbacksController < Twitter::BaseController
twitter_access_token_secret: parsed_body['oauth_token_secret'], twitter_access_token_secret: parsed_body['oauth_token_secret'],
profile_id: parsed_body['user_id'] profile_id: parsed_body['user_id']
) )
account.inboxes.create!( inbox = account.inboxes.create!(
name: parsed_body['screen_name'], name: parsed_body['screen_name'],
channel: twitter_profile channel: twitter_profile
) )
save_profile_image(inbox)
inbox
end
def save_profile_image(inbox)
response = twitter_client.user_show(screen_name: inbox.name)
return unless response.status.to_i == 200
parsed_user_profile = response.body
::Avatar::AvatarFromUrlJob.perform_later(inbox, parsed_user_profile['profile_image_url_https'])
end end
def permitted_params def permitted_params

View File

@ -1,4 +1,8 @@
class ContactDrop < BaseDrop class ContactDrop < BaseDrop
def name
@obj.try(:name).try(:split).try(:map, &:capitalize).try(:join, ' ')
end
def email def email
@obj.try(:email) @obj.try(:email)
end end
@ -8,10 +12,10 @@ class ContactDrop < BaseDrop
end end
def first_name def first_name
@obj.try(:name).try(:split).try(:first) @obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end end
def last_name def last_name
@obj.try(:name).try(:split).try(:last) if @obj.try(:name).try(:split).try(:size) > 1 @obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end end
end end

View File

@ -1,13 +1,17 @@
class UserDrop < BaseDrop class UserDrop < BaseDrop
def name
@obj.try(:name).try(:split).try(:map, &:capitalize).try(:join, ' ')
end
def available_name def available_name
@obj.try(:available_name) @obj.try(:available_name)
end end
def first_name def first_name
@obj.try(:name).try(:split).try(:first) @obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end end
def last_name def last_name
@obj.try(:name).try(:split).try(:last) if @obj.try(:name).try(:split).try(:size) > 1 @obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end end
end end

View File

@ -7,12 +7,18 @@ class EmailChannelFinder
def perform def perform
channel = nil channel = nil
recipient_mails = @email_object.to.to_a + @email_object.cc.to_a
recipient_mails.each do |email| recipient_mails.each do |email|
normalized_email = normalize_email_with_plus_addressing(email) normalized_email = normalize_email_with_plus_addressing(email)
channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email) channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email)
break if channel.present? break if channel.present?
end end
channel channel
end end
def recipient_mails
recipient_addresses = @email_object.to.to_a + @email_object.cc.to_a + @email_object.bcc.to_a + [@email_object['X-Original-To'].try(:value)]
recipient_addresses.flatten.compact
end
end end

View File

@ -21,12 +21,30 @@ class MessageFinder
end end
def current_messages def current_messages
if @params[:after].present? if @params[:after].present? && @params[:before].present?
messages.reorder('created_at asc').where('id >= ?', @params[:before].to_i).limit(20) messages_between(@params[:after].to_i, @params[:before].to_i)
elsif @params[:before].present? elsif @params[:before].present?
messages.reorder('created_at desc').where('id < ?', @params[:before].to_i).limit(20).reverse messages_before(@params[:before].to_i)
elsif @params[:after].present?
messages_after(@params[:after].to_i)
else else
messages.reorder('created_at desc').limit(20).reverse messages_latest
end end
end end
def messages_after(after_id)
messages.reorder('created_at asc').where('id > ?', after_id).limit(100)
end
def messages_before(before_id)
messages.reorder('created_at desc').where('id < ?', before_id).limit(20).reverse
end
def messages_between(after_id, before_id)
messages.reorder('created_at asc').where('id >= ? AND id < ?', after_id, before_id).limit(1000)
end
def messages_latest
messages.reorder('created_at desc').limit(20).reverse
end
end end

View File

@ -0,0 +1,103 @@
module Api::V2::Accounts::HeatmapHelper
def generate_conversations_heatmap_report
timezone_data = generate_heatmap_data_for_timezone(params[:timezone_offset])
group_traffic_data(timezone_data)
end
private
def group_traffic_data(data)
# start with an empty array
result_arr = []
# pick all the unique dates from the data in ascending order
dates = data.pluck(:date).uniq.sort
# add the dates as the first row, leave an empty cell for the hour column
# e.g. [nil, '2023-01-01', '2023-1-02', '2023-01-03']
result_arr << ([nil] + dates)
# group the data by hour, we do not need to sort it, because the data is already sorted
# given it starts from the beginning of the day
# here each hour is a key, and the value is an array of all the items for that hour at each date
# e.g. hour = 1
# value = [{date: 2023-01-01, value: 1}, {date: 2023-01-02, value: 1}, {date: 2023-01-03, value: 1}, ...]
data.group_by { |d| d[:hour] }.each do |hour, items|
# create a new row for each hour
row = [hour]
# group the items by date, so we can easily access the value for each date
# grouped values will be a hasg with the date as the key, and the value as the value
# e.g. { '2023-01-01' => [{date: 2023-01-01, value: 1}], '2023-01-02' => [{date: 2023-01-02, value: 1}], ... }
grouped_values = items.group_by { |d| d[:date] }
# now for each unique date we have, we can access the value for that date and append it to the array
dates.each do |date|
row << (grouped_values[date][0][:value] if grouped_values[date].is_a?(Array))
end
# row will look like [22, 0, 0, 1, 4, 6, 7, 4]
# add the row to the result array
result_arr << row
end
# return the resultant array
# the result looks like this
# [
# [nil, '2023-01-01', '2023-1-02', '2023-01-03'],
# [0, 0, 0, 0],
# [1, 0, 0, 0],
# [2, 0, 0, 0],
# [3, 0, 0, 0],
# [4, 0, 0, 0],
# ]
result_arr
end
def generate_heatmap_data_for_timezone(offset)
timezone = ActiveSupport::TimeZone[offset]&.name
timezone_today = DateTime.now.in_time_zone(timezone).beginning_of_day
timezone_data_raw = generate_heatmap_data(timezone_today, offset)
transform_data(timezone_data_raw, false)
end
def generate_heatmap_data(date, offset)
report_params = {
type: :account,
group_by: 'hour',
metric: 'conversations_count',
business_hours: false
}
V2::ReportBuilder.new(Current.account, report_params.merge({
since: since_timestamp(date),
until: until_timestamp(date),
timezone_offset: offset
})).build
end
def transform_data(data, zone_transform)
# rubocop:disable Rails/TimeZone
data.map do |d|
date = zone_transform ? Time.zone.at(d[:timestamp]) : Time.at(d[:timestamp])
{
date: date.to_date.to_s,
hour: date.hour,
value: d[:value]
}
end
# rubocop:enable Rails/TimeZone
end
def since_timestamp(date)
(date - 6.days).to_i.to_s
end
def until_timestamp(date)
date.to_i.to_s
end
end

View File

@ -0,0 +1,15 @@
module CacheKeysHelper
def get_prefixed_cache_key(account_id, key)
"idb-cache-key-account-#{account_id}-#{key}"
end
def fetch_value_for_key(account_id, key)
prefixed_cache_key = get_prefixed_cache_key(account_id, key)
value_from_cache = Redis::Alfred.get(prefixed_cache_key)
return value_from_cache if value_from_cache.present?
# zero epoch time: 1970-01-01 00:00:00 UTC
'0000000000'
end
end

View File

@ -72,13 +72,6 @@ export default {
if (!this.hasAccounts) { if (!this.hasAccounts) {
this.showAddAccountModal = true; this.showAddAccountModal = true;
} }
verifyServiceWorkerExistence(registration =>
registration.pushManager.getSubscription().then(subscription => {
if (subscription) {
registerSubscription();
}
})
);
}, },
currentAccountId() { currentAccountId() {
if (this.currentAccountId) { if (this.currentAccountId) {
@ -107,6 +100,14 @@ export default {
this.updateRTLDirectionView(locale); this.updateRTLDirectionView(locale);
this.latestChatwootVersion = latestChatwootVersion; this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(pubsubToken); vueActionCable.init(pubsubToken);
verifyServiceWorkerExistence(registration =>
registration.pushManager.getSubscription().then(subscription => {
if (subscription) {
registerSubscription();
}
})
);
}, },
}, },
}; };

View File

@ -13,6 +13,19 @@ class ApiClient {
return `${this.baseUrl()}/${this.resource}`; return `${this.baseUrl()}/${this.resource}`;
} }
// eslint-disable-next-line class-methods-use-this
get accountIdFromRoute() {
const isInsideAccountScopedURLs = window.location.pathname.includes(
'/app/accounts'
);
if (isInsideAccountScopedURLs) {
return window.location.pathname.split('/')[3];
}
return '';
}
baseUrl() { baseUrl() {
let url = this.apiVersion; let url = this.apiVersion;
@ -20,15 +33,8 @@ class ApiClient {
url = `/enterprise${url}`; url = `/enterprise${url}`;
} }
if (this.options.accountScoped) { if (this.options.accountScoped && this.accountIdFromRoute) {
const isInsideAccountScopedURLs = window.location.pathname.includes( url = `${url}/accounts/${this.accountIdFromRoute}`;
'/app/accounts'
);
if (isInsideAccountScopedURLs) {
const accountId = window.location.pathname.split('/')[3];
url = `${url}/accounts/${accountId}`;
}
} }
return url; return url;

View File

@ -0,0 +1,82 @@
/* global axios */
import { DataManager } from '../helper/CacheHelper/DataManager';
import ApiClient from './ApiClient';
class CacheEnabledApiClient extends ApiClient {
constructor(resource, options = {}) {
super(resource, options);
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();
}
return axios.get(this.url);
}
// eslint-disable-next-line class-methods-use-this
extractDataFromResponse(response) {
return response.data.payload;
}
// eslint-disable-next-line class-methods-use-this
marshallData(dataToParse) {
return { data: { payload: dataToParse } };
}
async getFromCache() {
await this.dataManager.initDb();
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);
let localData = [];
if (isCacheValid) {
localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
}
if (localData.length === 0) {
return this.refetchAndCommit(cacheKeyFromApi);
}
return this.marshallData(localData);
}
async refetchAndCommit(newKey = null) {
await this.dataManager.initDb();
const response = await axios.get(this.url);
this.dataManager.replace({
modelName: this.cacheModelName,
data: this.extractDataFromResponse(response),
});
await this.dataManager.setCacheKeys({
[this.cacheModelName]: newKey,
});
return response;
}
async validateCacheKey(cacheKeyFromApi) {
if (!this.dataManager.db) {
await this.dataManager.initDb();
}
const cachekey = await this.dataManager.getCacheKey(this.cacheModelName);
return cacheKeyFromApi === cachekey;
}
}
export default CacheEnabledApiClient;

View File

@ -0,0 +1,16 @@
/* global axios */
import ApiClient from './ApiClient';
class AuditLogs extends ApiClient {
constructor() {
super('audit_logs', { accountScoped: true });
}
get({ page }) {
const url = page ? `${this.url}?page=${page}` : this.url;
return axios.get(url);
}
}
export default new AuditLogs();

View File

@ -2,7 +2,11 @@
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import endPoints from './endPoints'; import endPoints from './endPoints';
import { setAuthCredentials, clearCookiesOnLogout } from '../store/utils/api'; import {
setAuthCredentials,
clearCookiesOnLogout,
deleteIndexedDBOnLogout,
} from '../store/utils/api';
export default { export default {
login(creds) { login(creds) {
@ -50,6 +54,7 @@ export default {
axios axios
.delete(urlData.url) .delete(urlData.url)
.then(response => { .then(response => {
deleteIndexedDBOnLogout();
clearCookiesOnLogout(); clearCookiesOnLogout();
resolve(response); resolve(response);
}) })

View File

@ -60,6 +60,13 @@ class ArticlesAPI extends PortalsAPI {
} }
); );
} }
reorderArticles({ portalSlug, reorderedGroup, categorySlug }) {
return axios.post(`${this.url}/${portalSlug}/articles/reorder`, {
positions_hash: reorderedGroup,
category_slug: categorySlug,
});
}
} }
export default new ArticlesAPI(); export default new ArticlesAPI();

View File

@ -75,10 +75,12 @@ class MessageApi extends ApiClient {
return axios.delete(`${this.url}/${conversationID}/messages/${messageId}`); return axios.delete(`${this.url}/${conversationID}/messages/${messageId}`);
} }
getPreviousMessages({ conversationId, before }) { getPreviousMessages({ conversationId, after, before }) {
return axios.get(`${this.url}/${conversationId}/messages`, { const params = { before };
params: { before }, if (after && Number(after) !== Number(before)) {
}); params.after = after;
}
return axios.get(`${this.url}/${conversationId}/messages`, { params });
} }
translateMessage(conversationId, messageId, targetLanguage) { translateMessage(conversationId, messageId, targetLanguage) {

View File

@ -1,11 +1,16 @@
/* global axios */ /* global axios */
import ApiClient from './ApiClient'; import CacheEnabledApiClient from './CacheEnabledApiClient';
class Inboxes extends ApiClient { class Inboxes extends CacheEnabledApiClient {
constructor() { constructor() {
super('inboxes', { accountScoped: true }); super('inboxes', { accountScoped: true });
} }
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'inbox';
}
getCampaigns(inboxId) { getCampaigns(inboxId) {
return axios.get(`${this.url}/${inboxId}/campaigns`); return axios.get(`${this.url}/${inboxId}/campaigns`);
} }

View File

@ -1,9 +1,14 @@
import ApiClient from './ApiClient'; import CacheEnabledApiClient from './CacheEnabledApiClient';
class LabelsAPI extends ApiClient { class LabelsAPI extends CacheEnabledApiClient {
constructor() { constructor() {
super('labels', { accountScoped: true }); super('labels', { accountScoped: true });
} }
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'label';
}
} }
export default new LabelsAPI(); export default new LabelsAPI();

View File

@ -14,8 +14,8 @@ class ReportsAPI extends ApiClient {
to, to,
type = 'account', type = 'account',
id, id,
group_by, groupBy,
business_hours, businessHours,
}) { }) {
return axios.get(`${this.url}`, { return axios.get(`${this.url}`, {
params: { params: {
@ -24,22 +24,22 @@ class ReportsAPI extends ApiClient {
until: to, until: to,
type, type,
id, id,
group_by, group_by: groupBy,
business_hours, business_hours: businessHours,
timezone_offset: getTimeOffset(), timezone_offset: getTimeOffset(),
}, },
}); });
} }
getSummary(since, until, type = 'account', id, group_by, business_hours) { getSummary(since, until, type = 'account', id, groupBy, businessHours) {
return axios.get(`${this.url}/summary`, { return axios.get(`${this.url}/summary`, {
params: { params: {
since, since,
until, until,
type, type,
id, id,
group_by, group_by: groupBy,
business_hours, business_hours: businessHours,
}, },
}); });
} }
@ -59,6 +59,12 @@ class ReportsAPI extends ApiClient {
}); });
} }
getConversationTrafficCSV() {
return axios.get(`${this.url}/conversation_traffic`, {
params: { timezone_offset: getTimeOffset() },
});
}
getLabelReports({ from: since, to: until, businessHours }) { getLabelReports({ from: since, to: until, businessHours }) {
return axios.get(`${this.url}/labels`, { return axios.get(`${this.url}/labels`, {
params: { since, until, business_hours: businessHours }, params: { since, until, business_hours: businessHours },

View File

@ -1,11 +1,27 @@
/* global axios */ /* global axios */
import ApiClient from './ApiClient'; // import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
export class TeamsAPI extends ApiClient { export class TeamsAPI extends CacheEnabledApiClient {
constructor() { constructor() {
super('teams', { accountScoped: true }); 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 }) { getAgents({ teamId }) {
return axios.get(`${this.url}/${teamId}/team_members`); return axios.get(`${this.url}/${teamId}/team_members`);
} }

View File

@ -1,5 +1,5 @@
/* global axios */ /* global axios */
import wootConstants from 'dashboard/constants'; import wootConstants from 'dashboard/constants/globals';
export const getTestimonialContent = () => { export const getTestimonialContent = () => {
return axios.get(wootConstants.TESTIMONIAL_URL); return axios.get(wootConstants.TESTIMONIAL_URL);

View File

@ -86,6 +86,12 @@
margin-left: var(--space-small); margin-left: var(--space-small);
} }
&:first-child {
.button {
margin-left: 0;
}
}
&.justify-content-end { &.justify-content-end {
justify-content: end; justify-content: end;
} }

View File

@ -45,7 +45,6 @@
variant="smooth" variant="smooth"
color-scheme="alert" color-scheme="alert"
icon="delete" icon="delete"
class="delete-custom-view__button"
@click="onClickOpenDeleteFoldersModal" @click="onClickOpenDeleteFoldersModal"
/> />
</div> </div>
@ -174,7 +173,7 @@ import ConversationCard from './widgets/conversation/ConversationCard';
import timeMixin from '../mixins/time'; import timeMixin from '../mixins/time';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import conversationMixin from '../mixins/conversations'; import conversationMixin from '../mixins/conversations';
import wootConstants from '../constants'; import wootConstants from 'dashboard/constants/globals';
import advancedFilterTypes from './widgets/conversation/advancedFilterItems'; import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
import filterQueryGenerator from '../helper/filterQueryGenerator.js'; import filterQueryGenerator from '../helper/filterQueryGenerator.js';
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews'; import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews';
@ -835,17 +834,14 @@ export default {
.filter--actions { .filter--actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-micro);
} }
.filter__applied { .filter__applied {
padding: 0 0 var(--space-slab) 0 !important; padding-bottom: var(--space-slab) !important;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
.delete-custom-view__button {
margin-right: var(--space-normal);
}
.tab--chat-type { .tab--chat-type {
padding: 0 var(--space-normal); padding: 0 var(--space-normal);

View File

@ -1,8 +1,23 @@
<template> <template>
<div class="code--container"> <div class="code--container">
<button class="button small button--copy-code" @click="onCopy"> <div class="code--action-area">
{{ $t('COMPONENTS.CODE.BUTTON_TEXT') }} <form
</button> v-if="enableCodePen"
class="code--codeopen-form"
action="https://codepen.io/pen/define"
method="POST"
target="_blank"
>
<input type="hidden" name="data" :value="codepenScriptValue" />
<button type="submit" class="button secondary tiny">
{{ $t('COMPONENTS.CODE.CODEPEN') }}
</button>
</form>
<button class="button secondary tiny" @click="onCopy">
{{ $t('COMPONENTS.CODE.BUTTON_TEXT') }}
</button>
</div>
<highlightjs v-if="script" :language="lang" :code="script" /> <highlightjs v-if="script" :language="lang" :code="script" />
</div> </div>
</template> </template>
@ -21,6 +36,24 @@ export default {
type: String, type: String,
default: 'javascript', default: 'javascript',
}, },
enableCodePen: {
type: Boolean,
default: false,
},
codepenTitle: {
type: String,
default: 'Chatwoot Codepen',
},
},
computed: {
codepenScriptValue() {
const lang = this.lang === 'javascript' ? 'js' : this.lang;
return JSON.stringify({
title: this.codepenTitle,
private: true,
[lang]: this.script,
});
},
}, },
methods: { methods: {
async onCopy(e) { async onCopy(e) {
@ -37,10 +70,14 @@ export default {
position: relative; position: relative;
text-align: left; text-align: left;
.button--copy-code { .code--action-area {
margin-top: 0; top: var(--space-small);
position: absolute; position: absolute;
right: 0; right: var(--space-small);
}
.code--codeopen-form {
display: inline-block;
} }
} }
</style> </style>

View File

@ -218,13 +218,17 @@ export default {
}, },
mounted() { mounted() {
this.editedValue = this.formattedValue; this.editedValue = this.formattedValue;
bus.$on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, focusAttributeKey => { bus.$on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
},
destroyed() {
bus.$off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
},
methods: {
onFocusAttribute(focusAttributeKey) {
if (this.attributeKey === focusAttributeKey) { if (this.attributeKey === focusAttributeKey) {
this.onEdit(); this.onEdit();
} }
}); },
},
methods: {
focusInput() { focusInput() {
if (this.$refs.inputfield) { if (this.$refs.inputfield) {
this.$refs.inputfield.focus(); this.$refs.inputfield.focus();

View File

@ -47,7 +47,7 @@ export default {
}, },
computed: { computed: {
modalContainerClassName() { modalContainerClassName() {
let className = 'modal-container'; let className = 'modal-container skip-context-menu';
if (this.fullWidth) { if (this.fullWidth) {
return `${className} modal-container--full-width`; return `${className} modal-container--full-width`;
} }
@ -60,7 +60,9 @@ export default {
'right-aligned': 'right-aligned', 'right-aligned': 'right-aligned',
}; };
return `modal-mask ${modalClassNameMap[this.modalType] || ''}`; return `modal-mask skip-context-menu ${modalClassNameMap[
this.modalType
] || ''}`;
}, },
}, },
mounted() { mounted() {

View File

@ -12,7 +12,8 @@
</template> </template>
<script> <script>
import Banner from 'dashboard/components/ui/Banner.vue'; import Banner from 'dashboard/components/ui/Banner.vue';
import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../helper/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import adminMixin from 'dashboard/mixins/isAdmin'; import adminMixin from 'dashboard/mixins/isAdmin';
import { hasAnUpdateAvailable } from './versionCheckHelper'; import { hasAnUpdateAvailable } from './versionCheckHelper';

View File

@ -126,7 +126,7 @@ import WootDropdownSubMenu from 'shared/components/ui/dropdown/DropdownSubMenu.v
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider'; import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
import wootConstants from '../../constants'; import wootConstants from 'dashboard/constants/globals';
import { import {
CMD_REOPEN_CONVERSATION, CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION, CMD_RESOLVE_CONVERSATION,

View File

@ -13,6 +13,7 @@ import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import FeatureToggle from './widgets/FeatureToggle'; import FeatureToggle from './widgets/FeatureToggle';
import HorizontalBar from './widgets/chart/HorizontalBarChart'; import HorizontalBar from './widgets/chart/HorizontalBarChart';
import Input from './widgets/forms/Input.vue'; import Input from './widgets/forms/Input.vue';
import PhoneInput from './widgets/forms/PhoneInput.vue';
import Label from './ui/Label'; import Label from './ui/Label';
import LoadingState from './widgets/LoadingState'; import LoadingState from './widgets/LoadingState';
import Modal from './Modal'; import Modal from './Modal';
@ -40,6 +41,7 @@ const WootUIKit = {
FeatureToggle, FeatureToggle,
HorizontalBar, HorizontalBar,
Input, Input,
PhoneInput,
Label, Label,
LoadingState, LoadingState,
Modal, Modal,

View File

@ -52,8 +52,9 @@ import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader'; import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader';
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider'; import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
import AvailabilityStatusBadge from '../widgets/conversation/AvailabilityStatusBadge'; import AvailabilityStatusBadge from '../widgets/conversation/AvailabilityStatusBadge';
import wootConstants from 'dashboard/constants/globals';
const AVAILABILITY_STATUS_KEYS = ['online', 'busy', 'offline']; const { AVAILABILITY_STATUS_KEYS } = wootConstants;
export default { export default {
components: { components: {

View File

@ -8,6 +8,7 @@ const settings = accountId => ({
'agent_list', 'agent_list',
'attributes_list', 'attributes_list',
'automation_list', 'automation_list',
'auditlogs_list',
'billing_settings_index', 'billing_settings_index',
'canned_list', 'canned_list',
'general_settings_index', 'general_settings_index',
@ -150,6 +151,14 @@ const settings = accountId => ({
toStateName: 'billing_settings_index', toStateName: 'billing_settings_index',
showOnlyOnCloud: true, showOnlyOnCloud: true,
}, },
{
icon: 'key',
label: 'AUDIT_LOGS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/audit-log/list`),
toStateName: 'auditlogs_list',
beta: true,
},
], ],
}); });

View File

@ -41,7 +41,7 @@ import PrimaryNavItem from './PrimaryNavItem';
import OptionsMenu from './OptionsMenu'; import OptionsMenu from './OptionsMenu';
import AgentDetails from './AgentDetails'; import AgentDetails from './AgentDetails';
import NotificationBell from './NotificationBell'; import NotificationBell from './NotificationBell';
import wootConstants from 'dashboard/constants'; import wootConstants from 'dashboard/constants/globals';
import { frontendURL } from 'dashboard/helper/URLHelper'; import { frontendURL } from 'dashboard/helper/URLHelper';
export default { export default {

View File

@ -1,7 +1,5 @@
<template> <template>
<div <div
v-show="show"
ref="context"
class="context-menu-container" class="context-menu-container"
:style="style" :style="style"
tabindex="0" tabindex="0"
@ -39,7 +37,6 @@ export default {
}, },
mounted() { mounted() {
this.$nextTick(() => this.$el.focus()); this.$nextTick(() => this.$el.focus());
this.show = true;
}, },
}; };
</script> </script>

View File

@ -9,7 +9,7 @@
</woot-tabs> </woot-tabs>
</template> </template>
<script> <script>
import wootConstants from '../../constants'; import wootConstants from 'dashboard/constants/globals';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { hasPressedAltAndNKey } from 'shared/helpers/KeyboardHelpers'; import { hasPressedAltAndNKey } from 'shared/helpers/KeyboardHelpers';

View File

@ -11,7 +11,7 @@
</template> </template>
<script> <script>
import wootConstants from '../../../constants'; import wootConstants from 'dashboard/constants/globals';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { hasPressedAltAndBKey } from 'shared/helpers/KeyboardHelpers'; import { hasPressedAltAndBKey } from 'shared/helpers/KeyboardHelpers';

View File

@ -63,7 +63,7 @@ import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName'; import InboxName from '../InboxName';
import MoreActions from './MoreActions'; import MoreActions from './MoreActions';
import Thumbnail from '../Thumbnail'; import Thumbnail from '../Thumbnail';
import wootConstants from '../../../constants'; import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper'; import { conversationListPageURL } from 'dashboard/helper/URLHelper';
export default { export default {
components: { components: {

View File

@ -1,7 +1,21 @@
<template> <template>
<li v-if="shouldRenderMessage" :class="alignBubble"> <li v-if="shouldRenderMessage" :id="`message${data.id}`" :class="alignBubble">
<div :class="wrapClass"> <div :class="wrapClass">
<div v-tooltip.top-start="messageToolTip" :class="bubbleClass"> <div v-if="isFailed" class="message-failed--alert">
<woot-button
v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')"
size="tiny"
color-scheme="alert"
variant="clear"
icon="arrow-clockwise"
@click="retrySendMessage"
/>
</div>
<div
v-tooltip.top-start="messageToolTip"
:class="bubbleClass"
@contextmenu="openContextMenu($event)"
>
<bubble-mail-head <bubble-mail-head
:email-attributes="contentAttributes.email" :email-attributes="contentAttributes.email"
:cc="emailHeadAttributes.cc" :cc="emailHeadAttributes.cc"
@ -32,7 +46,11 @@
:url="attachment.data_url" :url="attachment.data_url"
@error="onImageLoadError" @error="onImageLoadError"
/> />
<audio v-else-if="attachment.file_type === 'audio'" controls> <audio
v-else-if="attachment.file_type === 'audio'"
controls
class="skip-context-menu"
>
<source :src="attachment.data_url" /> <source :src="attachment.data_url" />
</audio> </audio>
<bubble-video <bubble-video
@ -60,6 +78,7 @@
:id="data.id" :id="data.id"
:sender="data.sender" :sender="data.sender"
:story-sender="storySender" :story-sender="storySender"
:external-error="externalError"
:story-id="storyId" :story-id="storyId"
:is-a-tweet="isATweet" :is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel" :is-a-whatsapp-channel="isAWhatsAppChannel"
@ -73,39 +92,6 @@
:created-at="createdAt" :created-at="createdAt"
/> />
</div> </div>
<woot-modal
v-if="showTranslateModal"
modal-type="right-aligned"
show
:on-close="onCloseTranslateModal"
>
<div class="column content">
<p>
<b>{{ $t('TRANSLATE_MODAL.ORIGINAL_CONTENT') }}</b>
</p>
<p v-dompurify-html="data.content" />
<br />
<hr />
<div v-if="translationsAvailable">
<p>
<b>{{ $t('TRANSLATE_MODAL.TRANSLATED_CONTENT') }}</b>
</p>
<div
v-for="(translation, language) in translations"
:key="language"
>
<p>
<strong>{{ language }}:</strong>
</p>
<p v-dompurify-html="translation" />
<br />
</div>
</div>
<p v-else>
{{ $t('TRANSLATE_MODAL.NO_TRANSLATIONS_AVAILABLE') }}
</p>
</div>
</woot-modal>
<spinner v-if="isPending" size="tiny" /> <spinner v-if="isPending" size="tiny" />
<div <div
v-if="showAvatar" v-if="showAvatar"
@ -127,29 +113,16 @@
{{ sender.name }} {{ sender.name }}
</a> </a>
</div> </div>
<div v-if="isFailed" class="message-failed--alert">
<woot-button
v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')"
size="tiny"
color-scheme="alert"
variant="clear"
icon="arrow-clockwise"
@click="retrySendMessage"
/>
</div>
</div> </div>
<div v-if="shouldShowContextMenu" class="context-menu-wrap"> <div v-if="shouldShowContextMenu" class="context-menu-wrap">
<context-menu <context-menu
v-if="isBubble && !isMessageDeleted" v-if="isBubble && !isMessageDeleted"
:context-menu-position="contextMenuPosition"
:is-open="showContextMenu" :is-open="showContextMenu"
:show-copy="hasText" :enabled-options="contextMenuEnabledOptions"
:show-delete="hasText || hasAttachments" :message="data"
:show-canned-response-option="isOutgoing && hasText" @open="openContextMenu"
:menu-position="contextMenuPosition" @close="closeContextMenu"
:message-content="data.content"
@toggle="handleContextMenuClick"
@delete="handleDelete"
@translate="handleTranslate"
/> />
</div> </div>
</li> </li>
@ -172,7 +145,8 @@ import alertMixin from 'shared/mixins/alertMixin';
import contentTypeMixin from 'shared/mixins/contentTypeMixin'; import contentTypeMixin from 'shared/mixins/contentTypeMixin';
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages'; import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
import { generateBotMessageContent } from './helpers/botMessageContentHelper'; import { generateBotMessageContent } from './helpers/botMessageContentHelper';
import { mapGetters } from 'vuex'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default { export default {
components: { components: {
@ -216,14 +190,11 @@ export default {
return { return {
showContextMenu: false, showContextMenu: false,
hasImageError: false, hasImageError: false,
showTranslateModal: false, contextMenuPosition: {},
showBackgroundHighlight: false,
}; };
}, },
computed: { computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
currentAccountId: 'getCurrentAccountId',
}),
shouldRenderMessage() { shouldRenderMessage() {
return ( return (
this.hasAttachments || this.hasAttachments ||
@ -239,9 +210,6 @@ export default {
} = this.contentAttributes.email || {}; } = this.contentAttributes.email || {};
return fullHTMLContent || fullTextContent || ''; return fullHTMLContent || fullTextContent || '';
}, },
translations() {
return this.contentAttributes.translations || {};
},
displayQuotedButton() { displayQuotedButton() {
if (this.emailMessageContent.includes('<blockquote')) { if (this.emailMessageContent.includes('<blockquote')) {
return true; return true;
@ -253,9 +221,6 @@ export default {
return false; return false;
}, },
translationsAvailable() {
return !!Object.keys(this.translations).length;
},
message() { message() {
// If the message is an email, emailMessageContent would be present // If the message is an email, emailMessageContent would be present
// In that case, we would use letter package to render the email // In that case, we would use letter package to render the email
@ -287,9 +252,19 @@ export default {
) + botMessageContent ) + botMessageContent
); );
}, },
contextMenuEnabledOptions() {
return {
copy: this.hasText,
delete: this.hasText || this.hasAttachments,
cannedResponse: this.isOutgoing && this.hasText,
};
},
contentAttributes() { contentAttributes() {
return this.data.content_attributes || {}; return this.data.content_attributes || {};
}, },
externalError() {
return this.contentAttributes.external_error || null;
},
sender() { sender() {
return this.data.sender || {}; return this.data.sender || {};
}, },
@ -320,13 +295,13 @@ export default {
const isRightAligned = const isRightAligned =
messageType === MESSAGE_TYPE.OUTGOING || messageType === MESSAGE_TYPE.OUTGOING ||
messageType === MESSAGE_TYPE.TEMPLATE; messageType === MESSAGE_TYPE.TEMPLATE;
return { return {
center: isCentered, center: isCentered,
left: isLeftAligned, left: isLeftAligned,
right: isRightAligned, right: isRightAligned,
'has-context-menu': this.showContextMenu, 'has-context-menu': this.showContextMenu,
'has-tweet-menu': this.isATweet, 'has-tweet-menu': this.isATweet,
'has-bg': this.showBackgroundHighlight,
}; };
}, },
createdAt() { createdAt() {
@ -380,7 +355,7 @@ export default {
return false; return false;
} }
if (this.isFailed) { if (this.isFailed) {
return this.$t(`CONVERSATION.SEND_FAILED`); return this.externalError ? '' : this.$t(`CONVERSATION.SEND_FAILED`);
} }
return false; return false;
}, },
@ -415,10 +390,6 @@ export default {
if (this.isPending || this.isFailed) return false; if (this.isPending || this.isFailed) return false;
return !this.sender.type || this.sender.type === 'agent_bot'; return !this.sender.type || this.sender.type === 'agent_bot';
}, },
contextMenuPosition() {
const { message_type: messageType } = this.data;
return messageType ? 'right' : 'left';
},
shouldShowContextMenu() { shouldShowContextMenu() {
return !(this.isFailed || this.isPending); return !(this.isFailed || this.isPending);
}, },
@ -447,6 +418,12 @@ export default {
}, },
mounted() { mounted() {
this.hasImageError = false; this.hasImageError = false;
bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
this.setupHighlightTimer();
},
beforeDestroy() {
bus.$off(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
clearTimeout(this.higlightTimeout);
}, },
methods: { methods: {
hasMediaAttachment(type) { hasMediaAttachment(type) {
@ -460,37 +437,44 @@ export default {
handleContextMenuClick() { handleContextMenuClick() {
this.showContextMenu = !this.showContextMenu; this.showContextMenu = !this.showContextMenu;
}, },
async handleDelete() {
const { conversation_id: conversationId, id: messageId } = this.data;
try {
await this.$store.dispatch('deleteMessage', {
conversationId,
messageId,
});
this.showAlert(this.$t('CONVERSATION.SUCCESS_DELETE_MESSAGE'));
this.showContextMenu = false;
} catch (error) {
this.showAlert(this.$t('CONVERSATION.FAIL_DELETE_MESSSAGE'));
}
},
async retrySendMessage() { async retrySendMessage() {
await this.$store.dispatch('sendMessageWithData', this.data); await this.$store.dispatch('sendMessageWithData', this.data);
}, },
onImageLoadError() { onImageLoadError() {
this.hasImageError = true; this.hasImageError = true;
}, },
handleTranslate() { openContextMenu(e) {
const { locale } = this.getAccount(this.currentAccountId); const shouldSkipContextMenu =
const { conversation_id: conversationId, id: messageId } = this.data; e.target?.classList.contains('skip-context-menu') ||
this.$store.dispatch('translateMessage', { e.target?.tagName.toLowerCase() === 'a';
conversationId, if (shouldSkipContextMenu || getSelection().toString()) {
messageId, return;
targetLanguage: locale || 'en', }
});
this.showTranslateModal = true; e.preventDefault();
if (e.type === 'contextmenu') {
this.$track(ACCOUNT_EVENTS.OPEN_MESSAGE_CONTEXT_MENU);
}
this.contextMenuPosition = {
x: e.pageX || e.clientX,
y: e.pageY || e.clientY,
};
this.showContextMenu = true;
}, },
onCloseTranslateModal() { closeContextMenu() {
this.showTranslateModal = false; this.showContextMenu = false;
this.contextMenuPosition = { x: null, y: null };
},
setupHighlightTimer() {
if (Number(this.$route.query.messageId) !== Number(this.data.id)) {
return;
}
this.showBackgroundHighlight = true;
const HIGHLIGHT_TIMER = 1000;
this.higlightTimeout = setTimeout(() => {
this.showBackgroundHighlight = false;
}, HIGHLIGHT_TIMER);
}, },
}, },
}; };
@ -525,7 +509,8 @@ export default {
} }
} }
&.is-image.is-text > .message-text__wrap { &.is-image.is-text > .message-text__wrap,
&.is-video.is-text > .message-text__wrap {
max-width: 32rem; max-width: 32rem;
padding: var(--space-small) var(--space-normal); padding: var(--space-small) var(--space-normal);
} }
@ -612,24 +597,20 @@ export default {
margin-top: var(--space-smaller) var(--space-smaller) 0 0; margin-top: var(--space-smaller) var(--space-smaller) 0 0;
} }
.button--delete-message {
visibility: hidden;
}
li.left, li.left,
li.right { li.right {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
&:hover .button--delete-message {
visibility: visible;
}
} }
li.left.has-tweet-menu .context-menu { li.left.has-tweet-menu .context-menu {
margin-bottom: var(--space-medium); margin-bottom: var(--space-medium);
} }
li.has-bg {
background: var(--w-75);
}
li.right .context-menu-wrap { li.right .context-menu-wrap {
margin-left: auto; margin-left: auto;
} }
@ -644,7 +625,6 @@ li.right {
.wrap.is-failed { .wrap.is-failed {
display: flex; display: flex;
flex-direction: row-reverse;
align-items: flex-end; align-items: flex-end;
margin-left: auto; margin-left: auto;
} }
@ -652,9 +632,6 @@ li.right {
.has-context-menu { .has-context-menu {
background: var(--color-background); background: var(--color-background);
.button--delete-message {
visibility: visible;
}
} }
.context-menu { .context-menu {

View File

@ -192,11 +192,6 @@ export default {
(!this.listLoadingStatus && this.isLoadingPrevious) (!this.listLoadingStatus && this.isLoadingPrevious)
); );
}, },
shouldLoadMoreChats() {
return !this.listLoadingStatus && !this.isLoadingPrevious;
},
conversationType() { conversationType() {
const { additional_attributes: additionalAttributes } = this.currentChat; const { additional_attributes: additionalAttributes } = this.currentChat;
const type = additionalAttributes ? additionalAttributes.type : ''; const type = additionalAttributes ? additionalAttributes.type : '';
@ -302,8 +297,16 @@ export default {
setSelectedTweet(tweetId) { setSelectedTweet(tweetId) {
this.selectedTweetId = tweetId; this.selectedTweetId = tweetId;
}, },
onScrollToMessage() { onScrollToMessage({ messageId = '' } = {}) {
this.$nextTick(() => this.scrollToBottom()); this.$nextTick(() => {
const messageElement = document.getElementById('message' + messageId);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth' });
this.fetchPreviousMessages();
} else {
this.scrollToBottom();
}
});
this.makeMessagesRead(); this.makeMessagesRead();
}, },
showPopoutReplyBox() { showPopoutReplyBox() {
@ -354,33 +357,42 @@ export default {
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop; this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
}, },
handleScroll(e) { async fetchPreviousMessages(scrollTop = 0) {
this.setScrollParams(); this.setScrollParams();
const shouldLoadMoreMessages =
this.getMessages.dataFetched === true &&
!this.listLoadingStatus &&
!this.isLoadingPrevious;
const dataFetchCheck =
this.getMessages.dataFetched === true && this.shouldLoadMoreChats;
if ( if (
e.target.scrollTop < 100 && scrollTop < 100 &&
!this.isLoadingPrevious && !this.isLoadingPrevious &&
dataFetchCheck shouldLoadMoreMessages
) { ) {
this.isLoadingPrevious = true; this.isLoadingPrevious = true;
this.$store try {
.dispatch('fetchPreviousMessages', { await this.$store.dispatch('fetchPreviousMessages', {
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
before: this.getMessages.messages[0].id, before: this.getMessages.messages[0].id,
})
.then(() => {
const heightDifference =
this.conversationPanel.scrollHeight - this.heightBeforeLoad;
this.conversationPanel.scrollTop =
this.scrollTopBeforeLoad + heightDifference;
this.isLoadingPrevious = false;
this.setScrollParams();
}); });
const heightDifference =
this.conversationPanel.scrollHeight - this.heightBeforeLoad;
this.conversationPanel.scrollTop =
this.scrollTopBeforeLoad + heightDifference;
this.setScrollParams();
} catch (error) {
// Ignore Error
} finally {
this.isLoadingPrevious = false;
}
} }
}, },
handleScroll(e) {
bus.$emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
this.fetchPreviousMessages(e.target.scrollTop);
},
makeMessagesRead() { makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id }); this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
}, },

View File

@ -174,9 +174,10 @@ import inboxMixin from 'shared/mixins/inboxMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { DirectUpload } from 'activestorage'; import { DirectUpload } from 'activestorage';
import { frontendURL } from '../../../helper/URLHelper'; import { frontendURL } from '../../../helper/URLHelper';
import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../../helper/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { trimContent, debounce } from '@chatwoot/utils'; import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants'; import wootConstants from 'dashboard/constants/globals';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings'; import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events'; import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import rtlMixin from 'shared/mixins/rtlMixin'; import rtlMixin from 'shared/mixins/rtlMixin';
@ -336,6 +337,12 @@ export default {
this.message.length > this.maxLength this.message.length > this.maxLength
); );
}, },
sender() {
return {
name: this.currentUser.name,
thumbnail: this.currentUser.avatar_url,
};
},
conversationType() { conversationType() {
const { additional_attributes: additionalAttributes } = this.currentChat; const { additional_attributes: additionalAttributes } = this.currentChat;
const type = additionalAttributes ? additionalAttributes.type : ''; const type = additionalAttributes ? additionalAttributes.type : '';
@ -452,7 +459,12 @@ export default {
return !this.isOnPrivateNote && this.isAnEmailChannel; return !this.isOnPrivateNote && this.isAnEmailChannel;
}, },
enableMultipleFileUpload() { enableMultipleFileUpload() {
return this.isAnEmailChannel || this.isAWebWidgetInbox || this.isAPIInbox; return (
this.isAnEmailChannel ||
this.isAWebWidgetInbox ||
this.isAPIInbox ||
this.isAWhatsAppChannel
);
}, },
isSignatureEnabledForInbox() { isSignatureEnabledForInbox() {
return !this.isPrivate && this.isAnEmailChannel && this.sendWithSignature; return !this.isPrivate && this.isAnEmailChannel && this.sendWithSignature;
@ -991,6 +1003,7 @@ export default {
files: [attachedFile], files: [attachedFile],
private: false, private: false,
message: caption, message: caption,
sender: this.sender,
}; };
multipleMessagePayload.push(attachmentPayload); multipleMessagePayload.push(attachmentPayload);
caption = ''; caption = '';
@ -1000,6 +1013,7 @@ export default {
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
message, message,
private: false, private: false,
sender: this.sender,
}; };
multipleMessagePayload.push(messagePayload); multipleMessagePayload.push(messagePayload);
} }
@ -1011,6 +1025,7 @@ export default {
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
message, message,
private: this.isPrivate, private: this.isPrivate,
sender: this.sender,
}; };
if (this.inReplyTo) { if (this.inReplyTo) {

View File

@ -9,6 +9,14 @@
> >
{{ readableTime }} {{ readableTime }}
</span> </span>
<span v-if="externalError" class="read-indicator-wrap">
<fluent-icon
v-tooltip.top-start="externalError"
icon="error-circle"
class="action--icon"
size="14"
/>
</span>
<span v-if="showReadIndicator" class="read-indicator-wrap"> <span v-if="showReadIndicator" class="read-indicator-wrap">
<fluent-icon <fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.MESSAGE_READ')" v-tooltip.top-start="$t('CHAT_LIST.MESSAGE_READ')"
@ -98,6 +106,10 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
externalError: {
type: String,
default: '',
},
storyId: { storyId: {
type: String, type: String,
default: '', default: '',
@ -163,7 +175,7 @@ export default {
return MESSAGE_STATUS.SENT === this.messageStatus; return MESSAGE_STATUS.SENT === this.messageStatus;
}, },
readableTime() { readableTime() {
return this.messageStamp(this.createdAt, 'LLL d, h:mm a'); return this.messageTimestamp(this.createdAt, 'LLL d, h:mm a');
}, },
screenName() { screenName() {
const { additional_attributes: additionalAttributes = {} } = const { additional_attributes: additionalAttributes = {} } =

View File

@ -2,7 +2,7 @@
<div class="image message-text__wrap"> <div class="image message-text__wrap">
<img :src="url" @click="onClick" @error="onImgError()" /> <img :src="url" @click="onClick" @error="onImgError()" />
<woot-modal :full-width="true" :show.sync="show" :on-close="onClose"> <woot-modal :full-width="true" :show.sync="show" :on-close="onClose">
<img :src="url" class="modal-image" /> <img :src="url" class="modal-image skip-context-menu" />
</woot-modal> </woot-modal>
</div> </div>
</template> </template>

View File

@ -0,0 +1,59 @@
<template>
<woot-modal
modal-type="right-aligned"
class="text-left"
show
:on-close="onClose"
>
<div class="column content">
<p>
<b>{{ $t('TRANSLATE_MODAL.ORIGINAL_CONTENT') }}</b>
</p>
<p v-dompurify-html="content" />
<br />
<hr />
<div v-if="translationsAvailable">
<p>
<b>{{ $t('TRANSLATE_MODAL.TRANSLATED_CONTENT') }}</b>
</p>
<div v-for="(translation, language) in translations" :key="language">
<p>
<strong>{{ language }}:</strong>
</p>
<p v-dompurify-html="translation" />
<br />
</div>
</div>
<p v-else>
{{ $t('TRANSLATE_MODAL.NO_TRANSLATIONS_AVAILABLE') }}
</p>
</div>
</woot-modal>
</template>
<script>
export default {
props: {
contentAttributes: {
type: Object,
default: () => ({}),
},
content: {
type: String,
default: '',
},
},
computed: {
translationsAvailable() {
return !!Object.keys(this.translations).length;
},
translations() {
return this.contentAttributes.translations || {};
},
},
methods: {
onClose() {
this.$emit('close');
},
},
};
</script>

View File

@ -2,7 +2,12 @@
<div class="video message-text__wrap"> <div class="video message-text__wrap">
<video :src="url" muted playsInline @click="onClick" /> <video :src="url" muted playsInline @click="onClick" />
<woot-modal :show.sync="show" :on-close="onClose"> <woot-modal :show.sync="show" :on-close="onClose">
<video :src="url" controls playsInline class="modal-video" /> <video
:src="url"
controls
playsInline
class="modal-video skip-context-menu"
/>
</woot-modal> </woot-modal>
</div> </div>
</template> </template>

View File

@ -69,7 +69,7 @@
<script> <script>
import MenuItem from './menuItem.vue'; import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue'; import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants.js'; import wootConstants from 'dashboard/constants/globals';
import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin'; import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue'; import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="menu" @click.stop="$emit('click')"> <div class="menu" role="button" @click.stop="$emit('click')">
<fluent-icon <fluent-icon
v-if="variant === 'icon' && option.icon" v-if="variant === 'icon' && option.icon"
:icon="option.icon" :icon="option.icon"

View File

@ -67,6 +67,7 @@ export default {
&:hover { &:hover {
background-color: var(--w-75); background-color: var(--w-75);
.submenu { .submenu {
display: block; display: block;
} }
@ -83,7 +84,7 @@ export default {
} }
&.disabled { &.disabled {
opacity: 50%; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
} }

View File

@ -0,0 +1,361 @@
<template>
<div class="phone-input--wrap">
<div class="phone-input" :class="{ 'has-error': error }">
<div class="country-emoji--wrap" @click="toggleCountryDropdown">
<h5 v-if="activeCountry.emoji">{{ activeCountry.emoji }}</h5>
<fluent-icon v-else icon="globe" class="fluent-icon" size="16" />
<fluent-icon icon="chevron-down" class="fluent-icon" size="12" />
</div>
<span v-if="activeDialCode" class="country-dial--code">
{{ activeDialCode }}
</span>
<input
:value="phoneNumber"
type="tel"
class="phone-input--field"
:placeholder="placeholder"
:readonly="readonly"
:style="styles"
@input="onChange"
@blur="onBlur"
/>
</div>
<div v-if="showDropdown" ref="dropdown" class="country-dropdown">
<div class="dropdown-search--wrap">
<input
ref="searchbar"
v-model="searchCountry"
type="text"
placeholder="Search"
class="dropdown-search"
/>
</div>
<div
v-for="(country, index) in filteredCountriesBySearch"
ref="dropdownItem"
:key="index"
class="country-dropdown--item"
:class="{
active: country.id === activeCountryCode,
focus: index === selectedIndex,
}"
@click="onSelectCountry(country)"
>
<span class="country-emoji">{{ country.emoji }}</span>
<span class="country-name">
{{ country.name }}
</span>
<span class="country-dial-code">{{ country.dial_code }}</span>
</div>
<div v-if="filteredCountriesBySearch.length === 0">
<span class="no-results">No results found</span>
</div>
</div>
</div>
</template>
<script>
import countries from 'shared/constants/countries.js';
import parsePhoneNumber from 'libphonenumber-js';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import {
hasPressedArrowUpKey,
hasPressedArrowDownKey,
isEnter,
} from 'shared/helpers/KeyboardHelpers';
export default {
mixins: [eventListenerMixins],
props: {
value: {
type: [String, Number],
default: '',
},
placeholder: {
type: String,
default: '',
},
readonly: {
type: Boolean,
default: false,
},
styles: {
type: Object,
default: () => {},
},
error: {
type: Boolean,
default: false,
},
},
data() {
return {
countries: [
{
name: 'Select Country',
dial_code: '',
emoji: '',
id: '',
},
...countries,
],
selectedIndex: -1,
showDropdown: false,
searchCountry: '',
activeCountryCode: '',
activeDialCode: '',
phoneNumber: this.value,
};
},
computed: {
filteredCountriesBySearch() {
return this.countries.filter(country => {
const { name, dial_code, id } = country;
const search = this.searchCountry.toLowerCase();
return (
name.toLowerCase().includes(search) ||
dial_code.toLowerCase().includes(search) ||
id.toLowerCase().includes(search)
);
});
},
activeCountry() {
if (this.activeCountryCode) {
return this.countries.find(
country => country.id === this.activeCountryCode
);
}
return '';
},
},
watch: {
value() {
const number = parsePhoneNumber(this.value);
if (number) {
this.activeCountryCode = number.country;
this.activeDialCode = `+${number.countryCallingCode}`;
this.phoneNumber = this.value.replace(
`+${number.countryCallingCode}`,
''
);
}
},
},
mounted() {
window.addEventListener('mouseup', this.onOutsideClick);
this.setActiveCountry();
},
beforeDestroy() {
window.removeEventListener('mouseup', this.onOutsideClick);
},
methods: {
onOutsideClick(e) {
if (
this.showDropdown &&
e.target !== this.$refs.dropdown &&
!this.$refs.dropdown.contains(e.target)
) {
this.closeDropdown();
}
},
onChange(e) {
this.phoneNumber = e.target.value;
this.$emit('input', e.target.value, this.activeDialCode);
},
onBlur(e) {
this.$emit('blur', e.target.value);
},
dropdownItem() {
return Array.from(
this.$refs.dropdown.querySelectorAll(
'div.country-dropdown div.country-dropdown--item'
)
);
},
focusedItem() {
return Array.from(
this.$refs.dropdown.querySelectorAll('div.country-dropdown div.focus')
);
},
focusedItemIndex() {
return Array.from(this.dropdownItem()).indexOf(this.focusedItem()[0]);
},
onKeyDownHandler(e) {
const { showDropdown, filteredCountriesBySearch, onSelectCountry } = this;
const { selectedIndex } = this;
if (showDropdown) {
if (hasPressedArrowDownKey(e)) {
e.preventDefault();
this.selectedIndex = Math.min(
selectedIndex + 1,
filteredCountriesBySearch.length - 1
);
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28;
} else if (hasPressedArrowUpKey(e)) {
e.preventDefault();
this.selectedIndex = Math.max(selectedIndex - 1, 0);
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
} else if (isEnter(e)) {
e.preventDefault();
onSelectCountry(filteredCountriesBySearch[selectedIndex]);
}
}
},
onSelectCountry(country) {
this.activeCountryCode = country.id;
this.searchCountry = '';
this.activeDialCode = country.dial_code;
this.$emit('setCode', country.dial_code);
this.closeDropdown();
},
setActiveCountry() {
const { phoneNumber } = this;
if (!phoneNumber) return;
const number = parsePhoneNumber(phoneNumber);
if (number) {
this.activeCountryCode = number.country;
this.activeDialCode = number.countryCallingCode;
}
},
toggleCountryDropdown() {
this.showDropdown = !this.showDropdown;
this.selectedIndex = -1;
if (this.showDropdown) {
this.$nextTick(() => {
this.$refs.searchbar.focus();
});
}
},
closeDropdown() {
this.selectedIndex = -1;
this.showDropdown = false;
},
},
};
</script>
<style scoped lang="scss">
.phone-input--wrap {
position: relative;
.phone-input {
display: flex;
align-items: center;
justify-content: flex-start;
margin-bottom: var(--space-normal);
border: 1px solid var(--s-200);
border-radius: var(--border-radius-normal);
&.has-error {
border: 1px solid var(--r-400);
}
}
.country-emoji--wrap {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-small);
background: var(--s-25);
height: 4rem;
width: 5.2rem;
border-radius: var(--border-radius-normal) 0 0 var(--border-radius-normal);
padding: var(--space-small) var(--space-smaller) var(--space-small)
var(--space-small);
h5 {
margin-bottom: 0;
}
}
.country-dial--code {
display: flex;
color: var(--s-300);
font-size: var(--space-normal);
font-weight: normal;
line-height: 1.5;
padding: var(--space-small) 0 var(--space-small) var(--space-small);
}
.phone-input--field {
margin-bottom: 0;
border: 0;
}
.country-dropdown {
z-index: var(--z-index-low);
position: absolute;
height: var(--space-giga);
width: 20rem;
overflow-y: auto;
top: 4rem;
border-radius: var(--border-radius-default);
padding: 0 0 var(--space-smaller) 0;
background-color: var(--white);
box-shadow: var(--shadow-context-menu);
border-radius: var(--border-radius-normal);
.dropdown-search--wrap {
top: 0;
position: sticky;
background-color: var(--white);
padding: var(--space-smaller);
.dropdown-search {
height: var(--space-large);
margin-bottom: 0;
font-size: var(--font-size-small);
border: 1px solid var(--s-200) !important;
}
}
.country-dropdown--item {
display: flex;
align-items: center;
height: 2.8rem;
padding: 0 var(--space-smaller);
cursor: pointer;
&.active {
background-color: var(--s-50);
}
&.focus {
background-color: var(--s-25);
}
&:hover {
background-color: var(--s-50);
}
.country-emoji {
font-size: var(--font-size-default);
margin-right: var(--space-smaller);
}
.country-name {
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.country-dial-code {
margin-left: var(--space-smaller);
color: var(--s-300);
font-size: var(--font-size-mini);
}
}
.no-results {
display: flex;
align-items: center;
justify-content: center;
color: var(--s-500);
margin-top: var(--space-normal);
}
}
}
</style>

View File

@ -24,5 +24,6 @@ export default {
DOCS_URL: '//www.chatwoot.com/docs/product/', DOCS_URL: '//www.chatwoot.com/docs/product/',
TESTIMONIAL_URL: 'https://testimonials.cdn.chatwoot.com/content.json', TESTIMONIAL_URL: 'https://testimonials.cdn.chatwoot.com/content.json',
SMALL_SCREEN_BREAKPOINT: 1024, SMALL_SCREEN_BREAKPOINT: 1024,
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
}; };
export const DEFAULT_REDIRECT_URL = '/app/'; export const DEFAULT_REDIRECT_URL = '/app/';

View File

@ -0,0 +1,5 @@
export const LOCAL_STORAGE_KEYS = {
DISMISSED_UPDATES: 'dismissedUpdates',
WIDGET_BUILDER: 'widgetBubble_',
DRAFT_MESSAGES: 'draftMessages',
};

View File

@ -13,6 +13,7 @@ export const ACCOUNT_EVENTS = Object.freeze({
ADDED_TO_CANNED_RESPONSE: 'Used added to canned response option', ADDED_TO_CANNED_RESPONSE: 'Used added to canned response option',
ADDED_A_CUSTOM_ATTRIBUTE: 'Added a custom attribute', ADDED_A_CUSTOM_ATTRIBUTE: 'Added a custom attribute',
ADDED_AN_INBOX: 'Added an inbox', ADDED_AN_INBOX: 'Added an inbox',
OPEN_MESSAGE_CONTEXT_MENU: 'Opened message context menu',
}); });
export const LABEL_EVENTS = Object.freeze({ export const LABEL_EVENTS = Object.freeze({

View File

@ -0,0 +1,70 @@
import { openDB } from 'idb';
import { DATA_VERSION } from './version';
export class DataManager {
constructor(accountId) {
this.modelsToSync = ['inbox', 'label', 'team'];
this.accountId = accountId;
this.db = null;
}
async initDb() {
if (this.db) return this.db;
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' });
},
});
return this.db;
}
validateModel(name) {
if (!name) throw new Error('Model name is not defined');
if (!this.modelsToSync.includes(name)) {
throw new Error(`Model ${name} is not defined`);
}
return true;
}
async replace({ modelName, data }) {
this.validateModel(modelName);
this.db.clear(modelName);
return this.push({ modelName, data });
}
async push({ modelName, data }) {
this.validateModel(modelName);
if (Array.isArray(data)) {
const tx = this.db.transaction(modelName, 'readwrite');
data.forEach(item => {
tx.store.add(item);
});
await tx.done;
} else {
await this.db.add(modelName, data);
}
}
async get({ modelName }) {
this.validateModel(modelName);
return this.db.getAll(modelName);
}
async setCacheKeys(cacheKeys) {
Object.keys(cacheKeys).forEach(async modelName => {
this.db.put('cache-keys', cacheKeys[modelName], modelName);
});
}
async getCacheKey(modelName) {
this.validateModel(modelName);
return this.db.get('cache-keys', modelName);
}
}

View File

@ -0,0 +1,3 @@
// Monday, 13 March 2023
// Change this version if you want to invalidate old data
export const DATA_VERSION = '1678706392';

View File

@ -1,4 +1,4 @@
import { DEFAULT_REDIRECT_URL } from '../constants'; import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
export const frontendURL = (path, params) => { export const frontendURL = (path, params) => {
const stringifiedParams = params ? `?${new URLSearchParams(params)}` : ''; const stringifiedParams = params ? `?${new URLSearchParams(params)}` : '';

View File

@ -26,6 +26,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'first.reply.created': this.onFirstReplyCreated, 'first.reply.created': this.onFirstReplyCreated,
'conversation.read': this.onConversationRead, 'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated, 'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate,
}; };
} }
@ -156,6 +157,13 @@ class ActionCableConnector extends BaseActionCableConnector {
onFirstReplyCreated = () => { onFirstReplyCreated = () => {
bus.$emit('fetch_overview_reports'); bus.$emit('fetch_overview_reports');
}; };
onCacheInvalidate = data => {
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 });
};
} }
export default { export default {

View File

@ -136,6 +136,7 @@ export const getConditionOptions = ({
team_id: teams, team_id: teams,
campaigns: generateConditionOptions(campaigns), campaigns: generateConditionOptions(campaigns),
browser_language: languages, browser_language: languages,
conversation_language: languages,
country_code: countries, country_code: countries,
message_type: MESSAGE_CONDITION_VALUES, message_type: MESSAGE_CONDITION_VALUES,
}; };

View File

@ -7,15 +7,22 @@ export const replaceVariablesInMessage = ({ message, variables }) => {
}); });
}; };
export const capitalizeName = name => {
return name.replace(/\b(\w)/g, s => s.toUpperCase());
};
const skipCodeBlocks = str => str.replace(/```(?:.|\n)+?```/g, ''); const skipCodeBlocks = str => str.replace(/```(?:.|\n)+?```/g, '');
export const getFirstName = ({ user }) => { export const getFirstName = ({ user }) => {
return user?.name ? user.name.split(' ').shift() : ''; const firstName = user?.name ? user.name.split(' ').shift() : '';
return capitalizeName(firstName);
}; };
export const getLastName = ({ user }) => { export const getLastName = ({ user }) => {
if (user && user.name) { if (user && user.name) {
return user.name.split(' ').length > 1 ? user.name.split(' ').pop() : ''; const lastName =
user.name.split(' ').length > 1 ? user.name.split(' ').pop() : '';
return capitalizeName(lastName);
} }
return ''; return '';
}; };
@ -27,14 +34,14 @@ export const getMessageVariables = ({ conversation }) => {
} = conversation; } = conversation;
return { return {
'contact.name': sender?.name, 'contact.name': capitalizeName(sender?.name),
'contact.first_name': getFirstName({ user: sender }), 'contact.first_name': getFirstName({ user: sender }),
'contact.last_name': getLastName({ user: sender }), 'contact.last_name': getLastName({ user: sender }),
'contact.email': sender?.email, 'contact.email': sender?.email,
'contact.phone': sender?.phone_number, 'contact.phone': sender?.phone_number,
'contact.id': sender?.id, 'contact.id': sender?.id,
'conversation.id': id, 'conversation.id': id,
'agent.name': assignee?.name ? assignee?.name : '', 'agent.name': capitalizeName(assignee?.name || ''),
'agent.first_name': getFirstName({ user: assignee }), 'agent.first_name': getFirstName({ user: assignee }),
'agent.last_name': getLastName({ user: assignee }), 'agent.last_name': getLastName({ user: assignee }),
'agent.email': assignee?.email ?? '', 'agent.email': assignee?.email ?? '',

View File

@ -0,0 +1,114 @@
import { DataManager } from '../../CacheHelper/DataManager';
describe('DataManager', () => {
const accountId = 'test-account';
let dataManager;
beforeAll(async () => {
dataManager = new DataManager(accountId);
await dataManager.initDb();
});
afterEach(async () => {
const tx = dataManager.db.transaction(
dataManager.modelsToSync,
'readwrite'
);
dataManager.modelsToSync.forEach(modelName => {
tx.objectStore(modelName).clear();
});
await tx.done;
});
describe('initDb', () => {
it('should initialize the database', async () => {
expect(dataManager.db).not.toBeNull();
});
it('should return the same instance of the database', async () => {
const db1 = await dataManager.initDb();
const db2 = await dataManager.initDb();
expect(db1).toBe(db2);
});
});
describe('validateModel', () => {
it('should throw an error for empty input', async () => {
expect(() => {
dataManager.validateModel();
}).toThrow();
});
it('should throw an error for invalid model', async () => {
expect(() => {
dataManager.validateModel('invalid-model');
}).toThrow();
});
it('should not throw an error for valid model', async () => {
expect(dataManager.validateModel('label')).toBeTruthy();
});
});
describe('replace', () => {
it('should replace existing data in the specified model', async () => {
const inboxData = [
{ id: 1, name: 'inbox-1' },
{ id: 2, name: 'inbox-2' },
];
const newData = [
{ id: 3, name: 'inbox-3' },
{ id: 4, name: 'inbox-4' },
];
await dataManager.push({ modelName: 'inbox', data: inboxData });
await dataManager.replace({ modelName: 'inbox', data: newData });
const result = await dataManager.get({ modelName: 'inbox' });
expect(result).toEqual(newData);
});
});
describe('push', () => {
it('should add data to the specified model', async () => {
const inboxData = { id: 1, name: 'inbox-1' };
await dataManager.push({ modelName: 'inbox', data: inboxData });
const result = await dataManager.get({ modelName: 'inbox' });
expect(result).toEqual([inboxData]);
});
it('should add multiple items to the specified model if an array of data is provided', async () => {
const inboxData = [
{ id: 1, name: 'inbox-1' },
{ id: 2, name: 'inbox-2' },
];
await dataManager.push({ modelName: 'inbox', data: inboxData });
const result = await dataManager.get({ modelName: 'inbox' });
expect(result).toEqual(inboxData);
});
});
describe('get', () => {
it('should return all data in the specified model', async () => {
const inboxData = [
{ id: 1, name: 'inbox-1' },
{ id: 2, name: 'inbox-2' },
];
await dataManager.push({ modelName: 'inbox', data: inboxData });
const result = await dataManager.get({ modelName: 'inbox' });
expect(result).toEqual(inboxData);
});
});
describe('setCacheKeys', () => {
it('should add cache keys for each model', async () => {
const cacheKeys = { inbox: 'cache-key-1', label: 'cache-key-2' };
await dataManager.setCacheKeys(cacheKeys);
const result = await dataManager.getCacheKey('inbox');
expect(result).toEqual(cacheKeys.inbox);
});
});
});

View File

@ -4,6 +4,7 @@ import {
getLastName, getLastName,
getMessageVariables, getMessageVariables,
getUndefinedVariablesInMessage, getUndefinedVariablesInMessage,
capitalizeName,
} from '../messageHelper'; } from '../messageHelper';
const variables = { const variables = {
@ -87,11 +88,11 @@ describe('#getMessageVariables', () => {
const conversation = { const conversation = {
meta: { meta: {
assignee: { assignee: {
name: 'Samuel Smith', name: 'samuel Smith',
email: 'samuel@example.com', email: 'samuel@example.com',
}, },
sender: { sender: {
name: 'John Doe', name: 'john Doe',
email: 'john.doe@gmail.com', email: 'john.doe@gmail.com',
phone_number: '1234567890', phone_number: '1234567890',
}, },
@ -136,3 +137,30 @@ describe('#getUndefinedVariablesInMessage', () => {
); );
}); });
}); });
describe('#capitalizeName', () => {
it('capitalize name if name is passed', () => {
const string = 'john peter';
expect(capitalizeName(string)).toBe('John Peter');
});
it('capitalize first name if full name is passed', () => {
const string = 'john Doe';
expect(capitalizeName(string)).toBe('John Doe');
});
it('returns empty string if the string is empty', () => {
const string = '';
expect(capitalizeName(string)).toBe('');
});
it('capitalize last name if last name is passed', () => {
const string = 'john doe';
expect(capitalizeName(string)).toBe('John Doe');
});
it('capitalize first name if first name is passed', () => {
const string = 'john';
expect(capitalizeName(string)).toBe('John');
});
it('capitalize last name if last name is passed', () => {
const string = 'doe';
expect(capitalizeName(string)).toBe('Doe');
});
});

View File

@ -22,7 +22,8 @@
"is_not_present": "غير موجود", "is_not_present": "غير موجود",
"is_greater_than": "هو أكبر من", "is_greater_than": "هو أكبر من",
"is_less_than": "هو أقل من", "is_less_than": "هو أقل من",
"days_before": "قبل x أيام" "days_before": "قبل x أيام",
"starts_with": "Starts with"
}, },
"ATTRIBUTE_LABELS": { "ATTRIBUTE_LABELS": {
"TRUE": "صحيح", "TRUE": "صحيح",

View File

@ -74,6 +74,11 @@
"LABEL": "عنوان البريد الإلكتروني", "LABEL": "عنوان البريد الإلكتروني",
"PLACEHOLDER": "الرجاء إدخال عنوان البريد الإلكتروني للموظف" "PLACEHOLDER": "الرجاء إدخال عنوان البريد الإلكتروني للموظف"
}, },
"AGENT_AVAILABILITY": {
"LABEL": "التوفر",
"PLACEHOLDER": "Please select an availability status",
"ERROR": "Availability is required"
},
"SUBMIT": "تعديل حساب الموظف" "SUBMIT": "تعديل حساب الموظف"
}, },
"BUTTON_TEXT": "تعديل", "BUTTON_TEXT": "تعديل",

View File

@ -132,6 +132,17 @@
"PLACEHOLDER": "أدخل اسم الشركة", "PLACEHOLDER": "أدخل اسم الشركة",
"LABEL": "اسم الشركة" "LABEL": "اسم الشركة"
}, },
"COUNTRY": {
"PLACEHOLDER": "Enter the country name",
"LABEL": "اسم الدولة",
"SELECT_PLACEHOLDER": "اختر",
"REMOVE": "حذف",
"SELECT_COUNTRY": "Select Country"
},
"CITY": {
"PLACEHOLDER": "Enter the city name",
"LABEL": "City Name"
},
"SOCIAL_PROFILES": { "SOCIAL_PROFILES": {
"FACEBOOK": { "FACEBOOK": {
"PLACEHOLDER": "أدخل اسم مستخدم فيسبوك", "PLACEHOLDER": "أدخل اسم مستخدم فيسبوك",

View File

@ -166,7 +166,15 @@
"COPY": "نسخ", "COPY": "نسخ",
"DELETE": "حذف", "DELETE": "حذف",
"CREATE_A_CANNED_RESPONSE": "إضافة إلى الردود السريعة", "CREATE_A_CANNED_RESPONSE": "إضافة إلى الردود السريعة",
"TRANSLATE": "ترجم" "TRANSLATE": "ترجم",
"COPY_PERMALINK": "Copy link to the message",
"LINK_COPIED": "Message URL copied to the clipboard",
"DELETE_CONFIRMATION": {
"TITLE": "Are you sure you want to delete this message?",
"MESSAGE": "You cannot undo this action",
"DELETE": "حذف",
"CANCEL": "إلغاء"
}
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {

View File

@ -439,7 +439,8 @@
"LABEL": "الخصائص", "LABEL": "الخصائص",
"DISPLAY_FILE_PICKER": "عرض أداة انتقاء الملفات في الـ widget", "DISPLAY_FILE_PICKER": "عرض أداة انتقاء الملفات في الـ widget",
"DISPLAY_EMOJI_PICKER": "عرض منتقي الرموز التعبيرية على الـ widget", "DISPLAY_EMOJI_PICKER": "عرض منتقي الرموز التعبيرية على الـ widget",
"ALLOW_END_CONVERSATION": "السماح للمستخدمين بإنهاء المحادثة من عنصر واجهة المستخدم" "ALLOW_END_CONVERSATION": "السماح للمستخدمين بإنهاء المحادثة من عنصر واجهة المستخدم",
"USE_INBOX_AVATAR_FOR_BOT": "Use inbox name and avatar for the bot"
}, },
"SETTINGS_POPUP": { "SETTINGS_POPUP": {
"MESSENGER_HEADING": "كود \"الماسنجر\"", "MESSENGER_HEADING": "كود \"الماسنجر\"",

View File

@ -164,6 +164,7 @@
"COMPONENTS": { "COMPONENTS": {
"CODE": { "CODE": {
"BUTTON_TEXT": "نسخ", "BUTTON_TEXT": "نسخ",
"CODEPEN": "Open in CodePen",
"COPY_SUCCESSFUL": "تم نسخ الكود إلى الحافظة بنجاح" "COPY_SUCCESSFUL": "تم نسخ الكود إلى الحافظة بنجاح"
}, },
"SHOW_MORE_BLOCK": { "SHOW_MORE_BLOCK": {

View File

@ -22,7 +22,8 @@
"is_not_present": "Не присъства", "is_not_present": "Не присъства",
"is_greater_than": "Is greater than", "is_greater_than": "Is greater than",
"is_less_than": "Is lesser than", "is_less_than": "Is lesser than",
"days_before": "Is x days before" "days_before": "Is x days before",
"starts_with": "Starts with"
}, },
"ATTRIBUTE_LABELS": { "ATTRIBUTE_LABELS": {
"TRUE": "True", "TRUE": "True",

View File

@ -74,6 +74,11 @@
"LABEL": "Имейл адрес", "LABEL": "Имейл адрес",
"PLACEHOLDER": "Моля, въведете имейл адрес на агента" "PLACEHOLDER": "Моля, въведете имейл адрес на агента"
}, },
"AGENT_AVAILABILITY": {
"LABEL": "Availability",
"PLACEHOLDER": "Please select an availability status",
"ERROR": "Availability is required"
},
"SUBMIT": "Редактирай агента" "SUBMIT": "Редактирай агента"
}, },
"BUTTON_TEXT": "Редактирай", "BUTTON_TEXT": "Редактирай",

View File

@ -132,6 +132,17 @@
"PLACEHOLDER": "Въведете име на фирма", "PLACEHOLDER": "Въведете име на фирма",
"LABEL": "Име на фирма" "LABEL": "Име на фирма"
}, },
"COUNTRY": {
"PLACEHOLDER": "Enter the country name",
"LABEL": "Име на държавата",
"SELECT_PLACEHOLDER": "Select",
"REMOVE": "Remove",
"SELECT_COUNTRY": "Select Country"
},
"CITY": {
"PLACEHOLDER": "Enter the city name",
"LABEL": "City Name"
},
"SOCIAL_PROFILES": { "SOCIAL_PROFILES": {
"FACEBOOK": { "FACEBOOK": {
"PLACEHOLDER": "Въведете Facebook потребителско име", "PLACEHOLDER": "Въведете Facebook потребителско име",

View File

@ -166,7 +166,15 @@
"COPY": "Copy", "COPY": "Copy",
"DELETE": "Изтрий", "DELETE": "Изтрий",
"CREATE_A_CANNED_RESPONSE": "Add to canned responses", "CREATE_A_CANNED_RESPONSE": "Add to canned responses",
"TRANSLATE": "Translate" "TRANSLATE": "Translate",
"COPY_PERMALINK": "Copy link to the message",
"LINK_COPIED": "Message URL copied to the clipboard",
"DELETE_CONFIRMATION": {
"TITLE": "Are you sure you want to delete this message?",
"MESSAGE": "You cannot undo this action",
"DELETE": "Изтрий",
"CANCEL": "Отмени"
}
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {

View File

@ -439,7 +439,8 @@
"LABEL": "Features", "LABEL": "Features",
"DISPLAY_FILE_PICKER": "Display file picker on the widget", "DISPLAY_FILE_PICKER": "Display file picker on the widget",
"DISPLAY_EMOJI_PICKER": "Display emoji picker on the widget", "DISPLAY_EMOJI_PICKER": "Display emoji picker on the widget",
"ALLOW_END_CONVERSATION": "Allow users to end conversation from the widget" "ALLOW_END_CONVERSATION": "Allow users to end conversation from the widget",
"USE_INBOX_AVATAR_FOR_BOT": "Use inbox name and avatar for the bot"
}, },
"SETTINGS_POPUP": { "SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script", "MESSENGER_HEADING": "Messenger Script",

View File

@ -164,6 +164,7 @@
"COMPONENTS": { "COMPONENTS": {
"CODE": { "CODE": {
"BUTTON_TEXT": "Copy", "BUTTON_TEXT": "Copy",
"CODEPEN": "Open in CodePen",
"COPY_SUCCESSFUL": "Code copied to clipboard successfully" "COPY_SUCCESSFUL": "Code copied to clipboard successfully"
}, },
"SHOW_MORE_BLOCK": { "SHOW_MORE_BLOCK": {

View File

@ -22,7 +22,8 @@
"is_not_present": "No és present", "is_not_present": "No és present",
"is_greater_than": "És més gran que", "is_greater_than": "És més gran que",
"is_less_than": "És més petit que", "is_less_than": "És més petit que",
"days_before": "Is x days before" "days_before": "Is x days before",
"starts_with": "Starts with"
}, },
"ATTRIBUTE_LABELS": { "ATTRIBUTE_LABELS": {
"TRUE": "Cert", "TRUE": "Cert",

View File

@ -74,6 +74,11 @@
"LABEL": "Adreça de correu electrònic", "LABEL": "Adreça de correu electrònic",
"PLACEHOLDER": "Introduïu l'adreça de correu electrònic de l'agent" "PLACEHOLDER": "Introduïu l'adreça de correu electrònic de l'agent"
}, },
"AGENT_AVAILABILITY": {
"LABEL": "Disponibilitat",
"PLACEHOLDER": "Please select an availability status",
"ERROR": "Availability is required"
},
"SUBMIT": "Editar l'agent" "SUBMIT": "Editar l'agent"
}, },
"BUTTON_TEXT": "Edita", "BUTTON_TEXT": "Edita",

View File

@ -132,6 +132,17 @@
"PLACEHOLDER": "Introdueix el nom de la companyia", "PLACEHOLDER": "Introdueix el nom de la companyia",
"LABEL": "Nom de la companyia" "LABEL": "Nom de la companyia"
}, },
"COUNTRY": {
"PLACEHOLDER": "Enter the country name",
"LABEL": "Nom del país",
"SELECT_PLACEHOLDER": "Select",
"REMOVE": "Suprimeix",
"SELECT_COUNTRY": "Select Country"
},
"CITY": {
"PLACEHOLDER": "Enter the city name",
"LABEL": "City Name"
},
"SOCIAL_PROFILES": { "SOCIAL_PROFILES": {
"FACEBOOK": { "FACEBOOK": {
"PLACEHOLDER": "Introduïu el nom d'usuari de Facebook", "PLACEHOLDER": "Introduïu el nom d'usuari de Facebook",

View File

@ -166,7 +166,15 @@
"COPY": "Copia", "COPY": "Copia",
"DELETE": "Esborrar", "DELETE": "Esborrar",
"CREATE_A_CANNED_RESPONSE": "Add to canned responses", "CREATE_A_CANNED_RESPONSE": "Add to canned responses",
"TRANSLATE": "Translate" "TRANSLATE": "Translate",
"COPY_PERMALINK": "Copy link to the message",
"LINK_COPIED": "Message URL copied to the clipboard",
"DELETE_CONFIRMATION": {
"TITLE": "Are you sure you want to delete this message?",
"MESSAGE": "You cannot undo this action",
"DELETE": "Esborrar",
"CANCEL": "Cancel·la"
}
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {

View File

@ -439,7 +439,8 @@
"LABEL": "Característiques", "LABEL": "Característiques",
"DISPLAY_FILE_PICKER": "Mostra el selector de fitxers al widget", "DISPLAY_FILE_PICKER": "Mostra el selector de fitxers al widget",
"DISPLAY_EMOJI_PICKER": "Mostra el selector d'emoji al widget", "DISPLAY_EMOJI_PICKER": "Mostra el selector d'emoji al widget",
"ALLOW_END_CONVERSATION": "Allow users to end conversation from the widget" "ALLOW_END_CONVERSATION": "Allow users to end conversation from the widget",
"USE_INBOX_AVATAR_FOR_BOT": "Use inbox name and avatar for the bot"
}, },
"SETTINGS_POPUP": { "SETTINGS_POPUP": {
"MESSENGER_HEADING": "Script del missatger", "MESSENGER_HEADING": "Script del missatger",

View File

@ -164,6 +164,7 @@
"COMPONENTS": { "COMPONENTS": {
"CODE": { "CODE": {
"BUTTON_TEXT": "Copia", "BUTTON_TEXT": "Copia",
"CODEPEN": "Open in CodePen",
"COPY_SUCCESSFUL": "El codi s'ha copiat al porta-retalls amb èxit" "COPY_SUCCESSFUL": "El codi s'ha copiat al porta-retalls amb èxit"
}, },
"SHOW_MORE_BLOCK": { "SHOW_MORE_BLOCK": {

View File

@ -22,7 +22,8 @@
"is_not_present": "Is not present", "is_not_present": "Is not present",
"is_greater_than": "Is greater than", "is_greater_than": "Is greater than",
"is_less_than": "Je menší než", "is_less_than": "Je menší než",
"days_before": "Is x days before" "days_before": "Is x days before",
"starts_with": "Starts with"
}, },
"ATTRIBUTE_LABELS": { "ATTRIBUTE_LABELS": {
"TRUE": "True", "TRUE": "True",

View File

@ -74,6 +74,11 @@
"LABEL": "E-mailová adresa", "LABEL": "E-mailová adresa",
"PLACEHOLDER": "Zadejte prosím e-mailovou adresu agenta" "PLACEHOLDER": "Zadejte prosím e-mailovou adresu agenta"
}, },
"AGENT_AVAILABILITY": {
"LABEL": "Dostupnost",
"PLACEHOLDER": "Please select an availability status",
"ERROR": "Availability is required"
},
"SUBMIT": "Upravit agenta" "SUBMIT": "Upravit agenta"
}, },
"BUTTON_TEXT": "Upravit", "BUTTON_TEXT": "Upravit",

View File

@ -132,6 +132,17 @@
"PLACEHOLDER": "Zadejte název společnosti", "PLACEHOLDER": "Zadejte název společnosti",
"LABEL": "Název společnosti" "LABEL": "Název společnosti"
}, },
"COUNTRY": {
"PLACEHOLDER": "Enter the country name",
"LABEL": "Country Name",
"SELECT_PLACEHOLDER": "Select",
"REMOVE": "Odebrat",
"SELECT_COUNTRY": "Select Country"
},
"CITY": {
"PLACEHOLDER": "Enter the city name",
"LABEL": "City Name"
},
"SOCIAL_PROFILES": { "SOCIAL_PROFILES": {
"FACEBOOK": { "FACEBOOK": {
"PLACEHOLDER": "Zadejte uživatelské jméno na Facebooku", "PLACEHOLDER": "Zadejte uživatelské jméno na Facebooku",

View File

@ -166,7 +166,15 @@
"COPY": "Kopírovat", "COPY": "Kopírovat",
"DELETE": "Vymazat", "DELETE": "Vymazat",
"CREATE_A_CANNED_RESPONSE": "Add to canned responses", "CREATE_A_CANNED_RESPONSE": "Add to canned responses",
"TRANSLATE": "Translate" "TRANSLATE": "Translate",
"COPY_PERMALINK": "Copy link to the message",
"LINK_COPIED": "Message URL copied to the clipboard",
"DELETE_CONFIRMATION": {
"TITLE": "Are you sure you want to delete this message?",
"MESSAGE": "You cannot undo this action",
"DELETE": "Vymazat",
"CANCEL": "Zrušit"
}
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {

View File

@ -439,7 +439,8 @@
"LABEL": "Funkce", "LABEL": "Funkce",
"DISPLAY_FILE_PICKER": "Display file picker on the widget", "DISPLAY_FILE_PICKER": "Display file picker on the widget",
"DISPLAY_EMOJI_PICKER": "Display emoji picker on the widget", "DISPLAY_EMOJI_PICKER": "Display emoji picker on the widget",
"ALLOW_END_CONVERSATION": "Allow users to end conversation from the widget" "ALLOW_END_CONVERSATION": "Allow users to end conversation from the widget",
"USE_INBOX_AVATAR_FOR_BOT": "Use inbox name and avatar for the bot"
}, },
"SETTINGS_POPUP": { "SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger skript", "MESSENGER_HEADING": "Messenger skript",

View File

@ -164,6 +164,7 @@
"COMPONENTS": { "COMPONENTS": {
"CODE": { "CODE": {
"BUTTON_TEXT": "Kopírovat", "BUTTON_TEXT": "Kopírovat",
"CODEPEN": "Open in CodePen",
"COPY_SUCCESSFUL": "Kód byl úspěšně zkopírován do schránky" "COPY_SUCCESSFUL": "Kód byl úspěšně zkopírován do schránky"
}, },
"SHOW_MORE_BLOCK": { "SHOW_MORE_BLOCK": {

View File

@ -22,7 +22,8 @@
"is_not_present": "Er ikke til stede", "is_not_present": "Er ikke til stede",
"is_greater_than": "Er større end", "is_greater_than": "Er større end",
"is_less_than": "Er mindre end", "is_less_than": "Er mindre end",
"days_before": "Er x dage før" "days_before": "Er x dage før",
"starts_with": "Starts with"
}, },
"ATTRIBUTE_LABELS": { "ATTRIBUTE_LABELS": {
"TRUE": "Sandt", "TRUE": "Sandt",

View File

@ -74,6 +74,11 @@
"LABEL": "E-Mail Adresse", "LABEL": "E-Mail Adresse",
"PLACEHOLDER": "Indtast venligst en e-mail adresse på agenten" "PLACEHOLDER": "Indtast venligst en e-mail adresse på agenten"
}, },
"AGENT_AVAILABILITY": {
"LABEL": "Tilgængelighed",
"PLACEHOLDER": "Please select an availability status",
"ERROR": "Availability is required"
},
"SUBMIT": "Rediger Agent" "SUBMIT": "Rediger Agent"
}, },
"BUTTON_TEXT": "Rediger", "BUTTON_TEXT": "Rediger",

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