Merge branch 'release/2.16.0'
This commit is contained in:
commit
7bd400772d
@ -1,8 +1,6 @@
|
||||
.bundle
|
||||
.env
|
||||
.env.*
|
||||
.git
|
||||
.gitignore
|
||||
docker-compose.*
|
||||
docker/Dockerfile
|
||||
docker/dockerfiles
|
||||
|
||||
@ -110,6 +110,8 @@ AWS_REGION=
|
||||
RAILS_LOG_TO_STDOUT=true
|
||||
LOG_LEVEL=info
|
||||
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
|
||||
|
||||
@ -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
|
||||
# FCM_SERVER_KEY=
|
||||
|
||||
## Bot Customizations
|
||||
USE_INBOX_AVATAR_FOR_BOT=true
|
||||
|
||||
### APM and Error Monitoring configurations
|
||||
## Elastic APM
|
||||
## https://www.elastic.co/guide/en/apm/agent/ruby/current/getting-started-rails.html
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -61,8 +61,9 @@ test/cypress/videos/*
|
||||
/config/master.key
|
||||
/config/*.enc
|
||||
|
||||
.vscode/settings.json
|
||||
#ignore files under .vscode directory
|
||||
.vscode
|
||||
|
||||
# yalc for local testing
|
||||
.yalc
|
||||
yalc.lock
|
||||
yalc.lock
|
||||
|
||||
34
Gemfile
34
Gemfile
@ -87,7 +87,7 @@ gem 'line-bot-api'
|
||||
gem 'twilio-ruby', '~> 5.66'
|
||||
# twitty will handle subscription of twitter account events
|
||||
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
|
||||
gem 'twitty'
|
||||
gem 'twitty', '~> 0.1.5'
|
||||
# facebook client
|
||||
gem 'koala'
|
||||
# slack client
|
||||
@ -149,7 +149,23 @@ gem 'net-imap', require: false
|
||||
gem 'net-pop', 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
|
||||
gem 'rack-timeout'
|
||||
end
|
||||
@ -165,6 +181,10 @@ group :development do
|
||||
|
||||
# When we want to squash migrations
|
||||
gem 'squasher'
|
||||
|
||||
# profiling
|
||||
gem 'rack-mini-profiler', require: false
|
||||
gem 'stackprof'
|
||||
end
|
||||
|
||||
group :test do
|
||||
@ -202,13 +222,3 @@ group :development, :test do
|
||||
gem 'spring'
|
||||
gem 'spring-watcher-listen'
|
||||
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'
|
||||
|
||||
27
Gemfile.lock
27
Gemfile.lock
@ -137,7 +137,7 @@ GEM
|
||||
byebug (11.1.3)
|
||||
climate_control (1.1.1)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.7)
|
||||
commonmarker (0.23.9)
|
||||
concurrent-ruby (1.2.2)
|
||||
connection_pool (2.2.5)
|
||||
crack (0.4.5)
|
||||
@ -416,6 +416,11 @@ GEM
|
||||
llhttp-ffi (0.4.0)
|
||||
ffi-compiler (~> 1.0)
|
||||
rake (~> 13.0)
|
||||
lograge (0.12.0)
|
||||
actionpack (>= 4)
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
request_store (~> 1.0)
|
||||
loofah (2.19.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.5.9)
|
||||
@ -459,14 +464,14 @@ GEM
|
||||
sidekiq
|
||||
newrelic_rpm (8.15.0)
|
||||
nio4r (2.5.8)
|
||||
nokogiri (1.14.2)
|
||||
nokogiri (1.14.3)
|
||||
mini_portile2 (~> 2.8.0)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.14.2-arm64-darwin)
|
||||
nokogiri (1.14.3-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.14.2-x86_64-darwin)
|
||||
nokogiri (1.14.3-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.14.2-x86_64-linux)
|
||||
nokogiri (1.14.3-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
oauth (0.5.10)
|
||||
oauth2 (2.0.9)
|
||||
@ -520,6 +525,8 @@ GEM
|
||||
rack (>= 1.0, < 3)
|
||||
rack-cors (1.1.1)
|
||||
rack (>= 2.0.0)
|
||||
rack-mini-profiler (3.0.0)
|
||||
rack (>= 1.2.0)
|
||||
rack-protection (3.0.5)
|
||||
rack
|
||||
rack-proxy (0.7.2)
|
||||
@ -566,6 +573,8 @@ GEM
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
request_store (1.5.1)
|
||||
rack (>= 1.4)
|
||||
responders (3.0.1)
|
||||
actionpack (>= 5.0)
|
||||
railties (>= 5.0)
|
||||
@ -692,6 +701,7 @@ GEM
|
||||
activesupport (>= 5.2)
|
||||
sprockets (>= 3.0.0)
|
||||
squasher (0.6.2)
|
||||
stackprof (0.2.24)
|
||||
statsd-ruby (1.5.0)
|
||||
stripe (6.5.0)
|
||||
telephone_number (1.4.16)
|
||||
@ -707,7 +717,7 @@ GEM
|
||||
faraday (>= 0.9, < 3.0)
|
||||
jwt (>= 1.5, <= 2.5)
|
||||
nokogiri (>= 1.6, < 2.0)
|
||||
twitty (0.1.4)
|
||||
twitty (0.1.5)
|
||||
oauth
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
@ -821,6 +831,7 @@ DEPENDENCIES
|
||||
line-bot-api
|
||||
liquid
|
||||
listen
|
||||
lograge (~> 0.12.0)
|
||||
maxminddb
|
||||
mock_redis
|
||||
net-imap
|
||||
@ -840,6 +851,7 @@ DEPENDENCIES
|
||||
pundit
|
||||
rack-attack
|
||||
rack-cors
|
||||
rack-mini-profiler
|
||||
rack-timeout
|
||||
rails (~> 6.1, >= 6.1.7.3)
|
||||
redis
|
||||
@ -865,12 +877,13 @@ DEPENDENCIES
|
||||
spring
|
||||
spring-watcher-listen
|
||||
squasher
|
||||
stackprof
|
||||
stripe
|
||||
telephone_number
|
||||
test-prof
|
||||
time_diff
|
||||
twilio-ruby (~> 5.66)
|
||||
twitty
|
||||
twitty (~> 0.1.5)
|
||||
tzinfo-data
|
||||
uglifier
|
||||
valid_email2
|
||||
|
||||
2
Procfile
2
Procfile
@ -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
|
||||
worker: bundle exec rails ip_lookup:setup && bundle exec sidekiq -C config/sidekiq.yml
|
||||
|
||||
@ -19,6 +19,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def destroy
|
||||
@agent.current_account_user.destroy!
|
||||
delete_user_record(@agent)
|
||||
head :ok
|
||||
end
|
||||
|
||||
@ -74,4 +75,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
def validate_limit
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents]
|
||||
end
|
||||
|
||||
def delete_user_record(agent)
|
||||
DeleteObjectJob.perform_later(agent) if agent.reload.account_users.blank?
|
||||
end
|
||||
end
|
||||
|
||||
@ -1,14 +1,19 @@
|
||||
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
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]
|
||||
|
||||
def index
|
||||
@portal_articles = @portal.articles
|
||||
@all_articles = @portal_articles.search(list_params)
|
||||
@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
|
||||
|
||||
def create
|
||||
@ -43,6 +48,11 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
render json: { file_url: url_for(file_blob) }
|
||||
end
|
||||
|
||||
def reorder
|
||||
Article.update_positions(params[:positions_hash])
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_article
|
||||
|
||||
@ -119,11 +119,12 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def fetch_contacts_with_conversation_count(contacts)
|
||||
contacts_with_conversation_count = filtrate(contacts).left_outer_joins(:conversations)
|
||||
.select('contacts.*, COUNT(conversations.id) as conversations_count')
|
||||
.group('contacts.id')
|
||||
.includes([{ avatar_attachment: [:blob] }])
|
||||
.page(@current_page).per(RESULTS_PER_PAGE)
|
||||
conversation_count_sub_query = 'SELECT COUNT(*) FROM "conversations" WHERE "conversations"."contact_id" = "contacts"."id"'
|
||||
contacts_with_conversation_count = filtrate(contacts)
|
||||
.select("contacts.*, (#{conversation_count_sub_query}) as conversations_count")
|
||||
.group('contacts.id')
|
||||
.includes([{ avatar_attachment: [:blob] }])
|
||||
.page(@current_page).per(RESULTS_PER_PAGE)
|
||||
|
||||
return contacts_with_conversation_count.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
|
||||
|
||||
|
||||
@ -82,8 +82,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@inbox.destroy!
|
||||
head :ok
|
||||
::DeleteObjectJob.perform_later(@inbox) if @inbox.present?
|
||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@ -7,11 +7,12 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
|
||||
redirect_url = microsoft_client.auth_code.authorize_url(
|
||||
{
|
||||
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'
|
||||
}
|
||||
)
|
||||
if redirect_url
|
||||
email = email.downcase
|
||||
::Redis::Alfred.setex(email, Current.account.id, 5.minutes)
|
||||
render json: { success: true, url: redirect_url }
|
||||
else
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
class Api::V1::AccountsController < Api::BaseController
|
||||
include AuthHelper
|
||||
include CacheKeysHelper
|
||||
|
||||
skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception,
|
||||
only: [:create], raise: false
|
||||
@ -30,6 +31,10 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
end
|
||||
|
||||
def cache_keys
|
||||
render json: { cache_keys: get_cache_keys }, status: :ok
|
||||
end
|
||||
|
||||
def show
|
||||
@latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION)
|
||||
render 'api/v1/accounts/show', format: :json
|
||||
@ -47,6 +52,14 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
|
||||
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
|
||||
@account = current_user.accounts.find(params[:id])
|
||||
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
|
||||
|
||||
@ -65,6 +65,16 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
head :ok
|
||||
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
|
||||
|
||||
def trigger_typing_event(event)
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
include Api::V2::Accounts::ReportsHelper
|
||||
include Api::V2::Accounts::HeatmapHelper
|
||||
|
||||
before_action :check_authorization
|
||||
|
||||
def index
|
||||
@ -32,6 +34,14 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
|
||||
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
|
||||
return head :unprocessable_entity if params[:type].blank?
|
||||
|
||||
|
||||
@ -56,7 +56,8 @@ class DashboardController < ActionController::Base
|
||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||
FACEBOOK_API_VERSION: 'v14.0',
|
||||
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
|
||||
|
||||
@ -8,7 +8,7 @@ class Microsoft::CallbacksController < ApplicationController
|
||||
)
|
||||
|
||||
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)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
@ -31,7 +31,7 @@ class Microsoft::CallbacksController < ApplicationController
|
||||
end
|
||||
|
||||
def account_id
|
||||
::Redis::Alfred.get(users_data['email'])
|
||||
::Redis::Alfred.get(users_data['email'].downcase)
|
||||
end
|
||||
|
||||
def account
|
||||
|
||||
@ -47,4 +47,11 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
|
||||
Internal::SeedAccountJob.perform_later(requested_resource)
|
||||
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account seeding triggered')
|
||||
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
|
||||
|
||||
@ -1,6 +1,18 @@
|
||||
class SuperAdmin::UsersController < SuperAdmin::ApplicationController
|
||||
# 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.
|
||||
|
||||
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
|
||||
# super
|
||||
|
||||
@ -14,7 +14,7 @@ class Twitter::CallbacksController < Twitter::BaseController
|
||||
redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
redirect_to twitter_app_redirect_url
|
||||
end
|
||||
|
||||
@ -49,10 +49,22 @@ class Twitter::CallbacksController < Twitter::BaseController
|
||||
twitter_access_token_secret: parsed_body['oauth_token_secret'],
|
||||
profile_id: parsed_body['user_id']
|
||||
)
|
||||
account.inboxes.create!(
|
||||
inbox = account.inboxes.create!(
|
||||
name: parsed_body['screen_name'],
|
||||
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
|
||||
|
||||
def permitted_params
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
class ContactDrop < BaseDrop
|
||||
def name
|
||||
@obj.try(:name).try(:split).try(:map, &:capitalize).try(:join, ' ')
|
||||
end
|
||||
|
||||
def email
|
||||
@obj.try(:email)
|
||||
end
|
||||
@ -8,10 +12,10 @@ class ContactDrop < BaseDrop
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
class UserDrop < BaseDrop
|
||||
def name
|
||||
@obj.try(:name).try(:split).try(:map, &:capitalize).try(:join, ' ')
|
||||
end
|
||||
|
||||
def available_name
|
||||
@obj.try(:available_name)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@ -7,12 +7,18 @@ class EmailChannelFinder
|
||||
|
||||
def perform
|
||||
channel = nil
|
||||
recipient_mails = @email_object.to.to_a + @email_object.cc.to_a
|
||||
|
||||
recipient_mails.each do |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)
|
||||
|
||||
break if channel.present?
|
||||
end
|
||||
channel
|
||||
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
|
||||
|
||||
@ -21,12 +21,30 @@ class MessageFinder
|
||||
end
|
||||
|
||||
def current_messages
|
||||
if @params[:after].present?
|
||||
messages.reorder('created_at asc').where('id >= ?', @params[:before].to_i).limit(20)
|
||||
if @params[:after].present? && @params[:before].present?
|
||||
messages_between(@params[:after].to_i, @params[:before].to_i)
|
||||
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
|
||||
messages.reorder('created_at desc').limit(20).reverse
|
||||
messages_latest
|
||||
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
|
||||
|
||||
103
app/helpers/api/v2/accounts/heatmap_helper.rb
Normal file
103
app/helpers/api/v2/accounts/heatmap_helper.rb
Normal 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
|
||||
15
app/helpers/cache_keys_helper.rb
Normal file
15
app/helpers/cache_keys_helper.rb
Normal 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
|
||||
@ -72,13 +72,6 @@ export default {
|
||||
if (!this.hasAccounts) {
|
||||
this.showAddAccountModal = true;
|
||||
}
|
||||
verifyServiceWorkerExistence(registration =>
|
||||
registration.pushManager.getSubscription().then(subscription => {
|
||||
if (subscription) {
|
||||
registerSubscription();
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
currentAccountId() {
|
||||
if (this.currentAccountId) {
|
||||
@ -107,6 +100,14 @@ export default {
|
||||
this.updateRTLDirectionView(locale);
|
||||
this.latestChatwootVersion = latestChatwootVersion;
|
||||
vueActionCable.init(pubsubToken);
|
||||
|
||||
verifyServiceWorkerExistence(registration =>
|
||||
registration.pushManager.getSubscription().then(subscription => {
|
||||
if (subscription) {
|
||||
registerSubscription();
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -13,6 +13,19 @@ class ApiClient {
|
||||
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() {
|
||||
let url = this.apiVersion;
|
||||
|
||||
@ -20,15 +33,8 @@ class ApiClient {
|
||||
url = `/enterprise${url}`;
|
||||
}
|
||||
|
||||
if (this.options.accountScoped) {
|
||||
const isInsideAccountScopedURLs = window.location.pathname.includes(
|
||||
'/app/accounts'
|
||||
);
|
||||
|
||||
if (isInsideAccountScopedURLs) {
|
||||
const accountId = window.location.pathname.split('/')[3];
|
||||
url = `${url}/accounts/${accountId}`;
|
||||
}
|
||||
if (this.options.accountScoped && this.accountIdFromRoute) {
|
||||
url = `${url}/accounts/${this.accountIdFromRoute}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
|
||||
82
app/javascript/dashboard/api/CacheEnabledApiClient.js
Normal file
82
app/javascript/dashboard/api/CacheEnabledApiClient.js
Normal 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;
|
||||
16
app/javascript/dashboard/api/auditLogs.js
Normal file
16
app/javascript/dashboard/api/auditLogs.js
Normal 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();
|
||||
@ -2,7 +2,11 @@
|
||||
|
||||
import Cookies from 'js-cookie';
|
||||
import endPoints from './endPoints';
|
||||
import { setAuthCredentials, clearCookiesOnLogout } from '../store/utils/api';
|
||||
import {
|
||||
setAuthCredentials,
|
||||
clearCookiesOnLogout,
|
||||
deleteIndexedDBOnLogout,
|
||||
} from '../store/utils/api';
|
||||
|
||||
export default {
|
||||
login(creds) {
|
||||
@ -50,6 +54,7 @@ export default {
|
||||
axios
|
||||
.delete(urlData.url)
|
||||
.then(response => {
|
||||
deleteIndexedDBOnLogout();
|
||||
clearCookiesOnLogout();
|
||||
resolve(response);
|
||||
})
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -75,10 +75,12 @@ class MessageApi extends ApiClient {
|
||||
return axios.delete(`${this.url}/${conversationID}/messages/${messageId}`);
|
||||
}
|
||||
|
||||
getPreviousMessages({ conversationId, before }) {
|
||||
return axios.get(`${this.url}/${conversationId}/messages`, {
|
||||
params: { before },
|
||||
});
|
||||
getPreviousMessages({ conversationId, after, before }) {
|
||||
const params = { before };
|
||||
if (after && Number(after) !== Number(before)) {
|
||||
params.after = after;
|
||||
}
|
||||
return axios.get(`${this.url}/${conversationId}/messages`, { params });
|
||||
}
|
||||
|
||||
translateMessage(conversationId, messageId, targetLanguage) {
|
||||
|
||||
@ -1,11 +1,16 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class Inboxes extends ApiClient {
|
||||
class Inboxes extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('inboxes', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'inbox';
|
||||
}
|
||||
|
||||
getCampaigns(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/campaigns`);
|
||||
}
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import ApiClient from './ApiClient';
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class LabelsAPI extends ApiClient {
|
||||
class LabelsAPI extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('labels', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'label';
|
||||
}
|
||||
}
|
||||
|
||||
export default new LabelsAPI();
|
||||
|
||||
@ -14,8 +14,8 @@ class ReportsAPI extends ApiClient {
|
||||
to,
|
||||
type = 'account',
|
||||
id,
|
||||
group_by,
|
||||
business_hours,
|
||||
groupBy,
|
||||
businessHours,
|
||||
}) {
|
||||
return axios.get(`${this.url}`, {
|
||||
params: {
|
||||
@ -24,22 +24,22 @@ class ReportsAPI extends ApiClient {
|
||||
until: to,
|
||||
type,
|
||||
id,
|
||||
group_by,
|
||||
business_hours,
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
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`, {
|
||||
params: {
|
||||
since,
|
||||
until,
|
||||
type,
|
||||
id,
|
||||
group_by,
|
||||
business_hours,
|
||||
group_by: groupBy,
|
||||
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 }) {
|
||||
return axios.get(`${this.url}/labels`, {
|
||||
params: { since, until, business_hours: businessHours },
|
||||
|
||||
@ -1,11 +1,27 @@
|
||||
/* 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() {
|
||||
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 }) {
|
||||
return axios.get(`${this.url}/${teamId}/team_members`);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/* global axios */
|
||||
import wootConstants from 'dashboard/constants';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
export const getTestimonialContent = () => {
|
||||
return axios.get(wootConstants.TESTIMONIAL_URL);
|
||||
|
||||
@ -86,6 +86,12 @@
|
||||
margin-left: var(--space-small);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
.button {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.justify-content-end {
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
@ -45,7 +45,6 @@
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
icon="delete"
|
||||
class="delete-custom-view__button"
|
||||
@click="onClickOpenDeleteFoldersModal"
|
||||
/>
|
||||
</div>
|
||||
@ -174,7 +173,7 @@ import ConversationCard from './widgets/conversation/ConversationCard';
|
||||
import timeMixin from '../mixins/time';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import conversationMixin from '../mixins/conversations';
|
||||
import wootConstants from '../constants';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
|
||||
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
|
||||
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews';
|
||||
@ -835,17 +834,14 @@ export default {
|
||||
.filter--actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-micro);
|
||||
}
|
||||
|
||||
.filter__applied {
|
||||
padding: 0 0 var(--space-slab) 0 !important;
|
||||
padding-bottom: var(--space-slab) !important;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.delete-custom-view__button {
|
||||
margin-right: var(--space-normal);
|
||||
}
|
||||
|
||||
.tab--chat-type {
|
||||
padding: 0 var(--space-normal);
|
||||
|
||||
|
||||
@ -1,8 +1,23 @@
|
||||
<template>
|
||||
<div class="code--container">
|
||||
<button class="button small button--copy-code" @click="onCopy">
|
||||
{{ $t('COMPONENTS.CODE.BUTTON_TEXT') }}
|
||||
</button>
|
||||
<div class="code--action-area">
|
||||
<form
|
||||
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" />
|
||||
</div>
|
||||
</template>
|
||||
@ -21,6 +36,24 @@ export default {
|
||||
type: String,
|
||||
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: {
|
||||
async onCopy(e) {
|
||||
@ -37,10 +70,14 @@ export default {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
|
||||
.button--copy-code {
|
||||
margin-top: 0;
|
||||
.code--action-area {
|
||||
top: var(--space-small);
|
||||
position: absolute;
|
||||
right: 0;
|
||||
right: var(--space-small);
|
||||
}
|
||||
|
||||
.code--codeopen-form {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -218,13 +218,17 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
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) {
|
||||
this.onEdit();
|
||||
}
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
},
|
||||
focusInput() {
|
||||
if (this.$refs.inputfield) {
|
||||
this.$refs.inputfield.focus();
|
||||
|
||||
@ -47,7 +47,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
modalContainerClassName() {
|
||||
let className = 'modal-container';
|
||||
let className = 'modal-container skip-context-menu';
|
||||
if (this.fullWidth) {
|
||||
return `${className} modal-container--full-width`;
|
||||
}
|
||||
@ -60,7 +60,9 @@ export default {
|
||||
'right-aligned': 'right-aligned',
|
||||
};
|
||||
|
||||
return `modal-mask ${modalClassNameMap[this.modalType] || ''}`;
|
||||
return `modal-mask skip-context-menu ${modalClassNameMap[
|
||||
this.modalType
|
||||
] || ''}`;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@ -12,7 +12,8 @@
|
||||
</template>
|
||||
<script>
|
||||
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 adminMixin from 'dashboard/mixins/isAdmin';
|
||||
import { hasAnUpdateAvailable } from './versionCheckHelper';
|
||||
|
||||
@ -126,7 +126,7 @@ import WootDropdownSubMenu from 'shared/components/ui/dropdown/DropdownSubMenu.v
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
|
||||
|
||||
import wootConstants from '../../constants';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import {
|
||||
CMD_REOPEN_CONVERSATION,
|
||||
CMD_RESOLVE_CONVERSATION,
|
||||
|
||||
@ -13,6 +13,7 @@ import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
|
||||
import FeatureToggle from './widgets/FeatureToggle';
|
||||
import HorizontalBar from './widgets/chart/HorizontalBarChart';
|
||||
import Input from './widgets/forms/Input.vue';
|
||||
import PhoneInput from './widgets/forms/PhoneInput.vue';
|
||||
import Label from './ui/Label';
|
||||
import LoadingState from './widgets/LoadingState';
|
||||
import Modal from './Modal';
|
||||
@ -40,6 +41,7 @@ const WootUIKit = {
|
||||
FeatureToggle,
|
||||
HorizontalBar,
|
||||
Input,
|
||||
PhoneInput,
|
||||
Label,
|
||||
LoadingState,
|
||||
Modal,
|
||||
|
||||
@ -52,8 +52,9 @@ import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
|
||||
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader';
|
||||
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
|
||||
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 {
|
||||
components: {
|
||||
|
||||
@ -8,6 +8,7 @@ const settings = accountId => ({
|
||||
'agent_list',
|
||||
'attributes_list',
|
||||
'automation_list',
|
||||
'auditlogs_list',
|
||||
'billing_settings_index',
|
||||
'canned_list',
|
||||
'general_settings_index',
|
||||
@ -150,6 +151,14 @@ const settings = accountId => ({
|
||||
toStateName: 'billing_settings_index',
|
||||
showOnlyOnCloud: true,
|
||||
},
|
||||
{
|
||||
icon: 'key',
|
||||
label: 'AUDIT_LOGS',
|
||||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/settings/audit-log/list`),
|
||||
toStateName: 'auditlogs_list',
|
||||
beta: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@ -41,7 +41,7 @@ import PrimaryNavItem from './PrimaryNavItem';
|
||||
import OptionsMenu from './OptionsMenu';
|
||||
import AgentDetails from './AgentDetails';
|
||||
import NotificationBell from './NotificationBell';
|
||||
import wootConstants from 'dashboard/constants';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
export default {
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="show"
|
||||
ref="context"
|
||||
class="context-menu-container"
|
||||
:style="style"
|
||||
tabindex="0"
|
||||
@ -39,7 +37,6 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => this.$el.focus());
|
||||
this.show = true;
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
</woot-tabs>
|
||||
</template>
|
||||
<script>
|
||||
import wootConstants from '../../constants';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import { hasPressedAltAndNKey } from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import wootConstants from '../../../constants';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import { hasPressedAltAndBKey } from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
|
||||
@ -63,7 +63,7 @@ import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import InboxName from '../InboxName';
|
||||
import MoreActions from './MoreActions';
|
||||
import Thumbnail from '../Thumbnail';
|
||||
import wootConstants from '../../../constants';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@ -1,7 +1,21 @@
|
||||
<template>
|
||||
<li v-if="shouldRenderMessage" :class="alignBubble">
|
||||
<li v-if="shouldRenderMessage" :id="`message${data.id}`" :class="alignBubble">
|
||||
<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
|
||||
:email-attributes="contentAttributes.email"
|
||||
:cc="emailHeadAttributes.cc"
|
||||
@ -32,7 +46,11 @@
|
||||
:url="attachment.data_url"
|
||||
@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" />
|
||||
</audio>
|
||||
<bubble-video
|
||||
@ -60,6 +78,7 @@
|
||||
:id="data.id"
|
||||
:sender="data.sender"
|
||||
:story-sender="storySender"
|
||||
:external-error="externalError"
|
||||
:story-id="storyId"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
@ -73,39 +92,6 @@
|
||||
:created-at="createdAt"
|
||||
/>
|
||||
</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" />
|
||||
<div
|
||||
v-if="showAvatar"
|
||||
@ -127,29 +113,16 @@
|
||||
{{ sender.name }}
|
||||
</a>
|
||||
</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 v-if="shouldShowContextMenu" class="context-menu-wrap">
|
||||
<context-menu
|
||||
v-if="isBubble && !isMessageDeleted"
|
||||
:context-menu-position="contextMenuPosition"
|
||||
:is-open="showContextMenu"
|
||||
:show-copy="hasText"
|
||||
:show-delete="hasText || hasAttachments"
|
||||
:show-canned-response-option="isOutgoing && hasText"
|
||||
:menu-position="contextMenuPosition"
|
||||
:message-content="data.content"
|
||||
@toggle="handleContextMenuClick"
|
||||
@delete="handleDelete"
|
||||
@translate="handleTranslate"
|
||||
:enabled-options="contextMenuEnabledOptions"
|
||||
:message="data"
|
||||
@open="openContextMenu"
|
||||
@close="closeContextMenu"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
@ -172,7 +145,8 @@ import alertMixin from 'shared/mixins/alertMixin';
|
||||
import contentTypeMixin from 'shared/mixins/contentTypeMixin';
|
||||
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
|
||||
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 {
|
||||
components: {
|
||||
@ -216,14 +190,11 @@ export default {
|
||||
return {
|
||||
showContextMenu: false,
|
||||
hasImageError: false,
|
||||
showTranslateModal: false,
|
||||
contextMenuPosition: {},
|
||||
showBackgroundHighlight: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
getAccount: 'accounts/getAccount',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
}),
|
||||
shouldRenderMessage() {
|
||||
return (
|
||||
this.hasAttachments ||
|
||||
@ -239,9 +210,6 @@ export default {
|
||||
} = this.contentAttributes.email || {};
|
||||
return fullHTMLContent || fullTextContent || '';
|
||||
},
|
||||
translations() {
|
||||
return this.contentAttributes.translations || {};
|
||||
},
|
||||
displayQuotedButton() {
|
||||
if (this.emailMessageContent.includes('<blockquote')) {
|
||||
return true;
|
||||
@ -253,9 +221,6 @@ export default {
|
||||
|
||||
return false;
|
||||
},
|
||||
translationsAvailable() {
|
||||
return !!Object.keys(this.translations).length;
|
||||
},
|
||||
message() {
|
||||
// If the message is an email, emailMessageContent would be present
|
||||
// In that case, we would use letter package to render the email
|
||||
@ -287,9 +252,19 @@ export default {
|
||||
) + botMessageContent
|
||||
);
|
||||
},
|
||||
contextMenuEnabledOptions() {
|
||||
return {
|
||||
copy: this.hasText,
|
||||
delete: this.hasText || this.hasAttachments,
|
||||
cannedResponse: this.isOutgoing && this.hasText,
|
||||
};
|
||||
},
|
||||
contentAttributes() {
|
||||
return this.data.content_attributes || {};
|
||||
},
|
||||
externalError() {
|
||||
return this.contentAttributes.external_error || null;
|
||||
},
|
||||
sender() {
|
||||
return this.data.sender || {};
|
||||
},
|
||||
@ -320,13 +295,13 @@ export default {
|
||||
const isRightAligned =
|
||||
messageType === MESSAGE_TYPE.OUTGOING ||
|
||||
messageType === MESSAGE_TYPE.TEMPLATE;
|
||||
|
||||
return {
|
||||
center: isCentered,
|
||||
left: isLeftAligned,
|
||||
right: isRightAligned,
|
||||
'has-context-menu': this.showContextMenu,
|
||||
'has-tweet-menu': this.isATweet,
|
||||
'has-bg': this.showBackgroundHighlight,
|
||||
};
|
||||
},
|
||||
createdAt() {
|
||||
@ -380,7 +355,7 @@ export default {
|
||||
return false;
|
||||
}
|
||||
if (this.isFailed) {
|
||||
return this.$t(`CONVERSATION.SEND_FAILED`);
|
||||
return this.externalError ? '' : this.$t(`CONVERSATION.SEND_FAILED`);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
@ -415,10 +390,6 @@ export default {
|
||||
if (this.isPending || this.isFailed) return false;
|
||||
return !this.sender.type || this.sender.type === 'agent_bot';
|
||||
},
|
||||
contextMenuPosition() {
|
||||
const { message_type: messageType } = this.data;
|
||||
return messageType ? 'right' : 'left';
|
||||
},
|
||||
shouldShowContextMenu() {
|
||||
return !(this.isFailed || this.isPending);
|
||||
},
|
||||
@ -447,6 +418,12 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
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: {
|
||||
hasMediaAttachment(type) {
|
||||
@ -460,37 +437,44 @@ export default {
|
||||
handleContextMenuClick() {
|
||||
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() {
|
||||
await this.$store.dispatch('sendMessageWithData', this.data);
|
||||
},
|
||||
onImageLoadError() {
|
||||
this.hasImageError = true;
|
||||
},
|
||||
handleTranslate() {
|
||||
const { locale } = this.getAccount(this.currentAccountId);
|
||||
const { conversation_id: conversationId, id: messageId } = this.data;
|
||||
this.$store.dispatch('translateMessage', {
|
||||
conversationId,
|
||||
messageId,
|
||||
targetLanguage: locale || 'en',
|
||||
});
|
||||
this.showTranslateModal = true;
|
||||
openContextMenu(e) {
|
||||
const shouldSkipContextMenu =
|
||||
e.target?.classList.contains('skip-context-menu') ||
|
||||
e.target?.tagName.toLowerCase() === 'a';
|
||||
if (shouldSkipContextMenu || getSelection().toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
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() {
|
||||
this.showTranslateModal = false;
|
||||
closeContextMenu() {
|
||||
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;
|
||||
padding: var(--space-small) var(--space-normal);
|
||||
}
|
||||
@ -612,24 +597,20 @@ export default {
|
||||
margin-top: var(--space-smaller) var(--space-smaller) 0 0;
|
||||
}
|
||||
|
||||
.button--delete-message {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
li.left,
|
||||
li.right {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
&:hover .button--delete-message {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
li.left.has-tweet-menu .context-menu {
|
||||
margin-bottom: var(--space-medium);
|
||||
}
|
||||
|
||||
li.has-bg {
|
||||
background: var(--w-75);
|
||||
}
|
||||
|
||||
li.right .context-menu-wrap {
|
||||
margin-left: auto;
|
||||
}
|
||||
@ -644,7 +625,6 @@ li.right {
|
||||
|
||||
.wrap.is-failed {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
@ -652,9 +632,6 @@ li.right {
|
||||
|
||||
.has-context-menu {
|
||||
background: var(--color-background);
|
||||
.button--delete-message {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
|
||||
@ -192,11 +192,6 @@ export default {
|
||||
(!this.listLoadingStatus && this.isLoadingPrevious)
|
||||
);
|
||||
},
|
||||
|
||||
shouldLoadMoreChats() {
|
||||
return !this.listLoadingStatus && !this.isLoadingPrevious;
|
||||
},
|
||||
|
||||
conversationType() {
|
||||
const { additional_attributes: additionalAttributes } = this.currentChat;
|
||||
const type = additionalAttributes ? additionalAttributes.type : '';
|
||||
@ -302,8 +297,16 @@ export default {
|
||||
setSelectedTweet(tweetId) {
|
||||
this.selectedTweetId = tweetId;
|
||||
},
|
||||
onScrollToMessage() {
|
||||
this.$nextTick(() => this.scrollToBottom());
|
||||
onScrollToMessage({ messageId = '' } = {}) {
|
||||
this.$nextTick(() => {
|
||||
const messageElement = document.getElementById('message' + messageId);
|
||||
if (messageElement) {
|
||||
messageElement.scrollIntoView({ behavior: 'smooth' });
|
||||
this.fetchPreviousMessages();
|
||||
} else {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
});
|
||||
this.makeMessagesRead();
|
||||
},
|
||||
showPopoutReplyBox() {
|
||||
@ -354,33 +357,42 @@ export default {
|
||||
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
|
||||
},
|
||||
|
||||
handleScroll(e) {
|
||||
async fetchPreviousMessages(scrollTop = 0) {
|
||||
this.setScrollParams();
|
||||
const shouldLoadMoreMessages =
|
||||
this.getMessages.dataFetched === true &&
|
||||
!this.listLoadingStatus &&
|
||||
!this.isLoadingPrevious;
|
||||
|
||||
const dataFetchCheck =
|
||||
this.getMessages.dataFetched === true && this.shouldLoadMoreChats;
|
||||
if (
|
||||
e.target.scrollTop < 100 &&
|
||||
scrollTop < 100 &&
|
||||
!this.isLoadingPrevious &&
|
||||
dataFetchCheck
|
||||
shouldLoadMoreMessages
|
||||
) {
|
||||
this.isLoadingPrevious = true;
|
||||
this.$store
|
||||
.dispatch('fetchPreviousMessages', {
|
||||
try {
|
||||
await this.$store.dispatch('fetchPreviousMessages', {
|
||||
conversationId: this.currentChat.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() {
|
||||
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
|
||||
},
|
||||
|
||||
@ -174,9 +174,10 @@ import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import { DirectUpload } from 'activestorage';
|
||||
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 wootConstants from 'dashboard/constants';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
import rtlMixin from 'shared/mixins/rtlMixin';
|
||||
@ -336,6 +337,12 @@ export default {
|
||||
this.message.length > this.maxLength
|
||||
);
|
||||
},
|
||||
sender() {
|
||||
return {
|
||||
name: this.currentUser.name,
|
||||
thumbnail: this.currentUser.avatar_url,
|
||||
};
|
||||
},
|
||||
conversationType() {
|
||||
const { additional_attributes: additionalAttributes } = this.currentChat;
|
||||
const type = additionalAttributes ? additionalAttributes.type : '';
|
||||
@ -452,7 +459,12 @@ export default {
|
||||
return !this.isOnPrivateNote && this.isAnEmailChannel;
|
||||
},
|
||||
enableMultipleFileUpload() {
|
||||
return this.isAnEmailChannel || this.isAWebWidgetInbox || this.isAPIInbox;
|
||||
return (
|
||||
this.isAnEmailChannel ||
|
||||
this.isAWebWidgetInbox ||
|
||||
this.isAPIInbox ||
|
||||
this.isAWhatsAppChannel
|
||||
);
|
||||
},
|
||||
isSignatureEnabledForInbox() {
|
||||
return !this.isPrivate && this.isAnEmailChannel && this.sendWithSignature;
|
||||
@ -991,6 +1003,7 @@ export default {
|
||||
files: [attachedFile],
|
||||
private: false,
|
||||
message: caption,
|
||||
sender: this.sender,
|
||||
};
|
||||
multipleMessagePayload.push(attachmentPayload);
|
||||
caption = '';
|
||||
@ -1000,6 +1013,7 @@ export default {
|
||||
conversationId: this.currentChat.id,
|
||||
message,
|
||||
private: false,
|
||||
sender: this.sender,
|
||||
};
|
||||
multipleMessagePayload.push(messagePayload);
|
||||
}
|
||||
@ -1011,6 +1025,7 @@ export default {
|
||||
conversationId: this.currentChat.id,
|
||||
message,
|
||||
private: this.isPrivate,
|
||||
sender: this.sender,
|
||||
};
|
||||
|
||||
if (this.inReplyTo) {
|
||||
|
||||
@ -9,6 +9,14 @@
|
||||
>
|
||||
{{ readableTime }}
|
||||
</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">
|
||||
<fluent-icon
|
||||
v-tooltip.top-start="$t('CHAT_LIST.MESSAGE_READ')"
|
||||
@ -98,6 +106,10 @@ export default {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
externalError: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
storyId: {
|
||||
type: String,
|
||||
default: '',
|
||||
@ -163,7 +175,7 @@ export default {
|
||||
return MESSAGE_STATUS.SENT === this.messageStatus;
|
||||
},
|
||||
readableTime() {
|
||||
return this.messageStamp(this.createdAt, 'LLL d, h:mm a');
|
||||
return this.messageTimestamp(this.createdAt, 'LLL d, h:mm a');
|
||||
},
|
||||
screenName() {
|
||||
const { additional_attributes: additionalAttributes = {} } =
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<div class="image message-text__wrap">
|
||||
<img :src="url" @click="onClick" @error="onImgError()" />
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -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>
|
||||
@ -2,7 +2,12 @@
|
||||
<div class="video message-text__wrap">
|
||||
<video :src="url" muted playsInline @click="onClick" />
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -69,7 +69,7 @@
|
||||
<script>
|
||||
import MenuItem from './menuItem.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 { mapGetters } from 'vuex';
|
||||
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="menu" @click.stop="$emit('click')">
|
||||
<div class="menu" role="button" @click.stop="$emit('click')">
|
||||
<fluent-icon
|
||||
v-if="variant === 'icon' && option.icon"
|
||||
:icon="option.icon"
|
||||
|
||||
@ -67,6 +67,7 @@ export default {
|
||||
|
||||
&:hover {
|
||||
background-color: var(--w-75);
|
||||
|
||||
.submenu {
|
||||
display: block;
|
||||
}
|
||||
@ -83,7 +84,7 @@ export default {
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 50%;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
361
app/javascript/dashboard/components/widgets/forms/PhoneInput.vue
Normal file
361
app/javascript/dashboard/components/widgets/forms/PhoneInput.vue
Normal 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>
|
||||
@ -24,5 +24,6 @@ export default {
|
||||
DOCS_URL: '//www.chatwoot.com/docs/product/',
|
||||
TESTIMONIAL_URL: 'https://testimonials.cdn.chatwoot.com/content.json',
|
||||
SMALL_SCREEN_BREAKPOINT: 1024,
|
||||
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
|
||||
};
|
||||
export const DEFAULT_REDIRECT_URL = '/app/';
|
||||
5
app/javascript/dashboard/constants/localStorage.js
Normal file
5
app/javascript/dashboard/constants/localStorage.js
Normal file
@ -0,0 +1,5 @@
|
||||
export const LOCAL_STORAGE_KEYS = {
|
||||
DISMISSED_UPDATES: 'dismissedUpdates',
|
||||
WIDGET_BUILDER: 'widgetBubble_',
|
||||
DRAFT_MESSAGES: 'draftMessages',
|
||||
};
|
||||
@ -13,6 +13,7 @@ export const ACCOUNT_EVENTS = Object.freeze({
|
||||
ADDED_TO_CANNED_RESPONSE: 'Used added to canned response option',
|
||||
ADDED_A_CUSTOM_ATTRIBUTE: 'Added a custom attribute',
|
||||
ADDED_AN_INBOX: 'Added an inbox',
|
||||
OPEN_MESSAGE_CONTEXT_MENU: 'Opened message context menu',
|
||||
});
|
||||
|
||||
export const LABEL_EVENTS = Object.freeze({
|
||||
|
||||
70
app/javascript/dashboard/helper/CacheHelper/DataManager.js
Normal file
70
app/javascript/dashboard/helper/CacheHelper/DataManager.js
Normal 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);
|
||||
}
|
||||
}
|
||||
3
app/javascript/dashboard/helper/CacheHelper/version.js
Normal file
3
app/javascript/dashboard/helper/CacheHelper/version.js
Normal file
@ -0,0 +1,3 @@
|
||||
// Monday, 13 March 2023
|
||||
// Change this version if you want to invalidate old data
|
||||
export const DATA_VERSION = '1678706392';
|
||||
@ -1,4 +1,4 @@
|
||||
import { DEFAULT_REDIRECT_URL } from '../constants';
|
||||
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
|
||||
|
||||
export const frontendURL = (path, params) => {
|
||||
const stringifiedParams = params ? `?${new URLSearchParams(params)}` : '';
|
||||
|
||||
@ -26,6 +26,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'first.reply.created': this.onFirstReplyCreated,
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
};
|
||||
}
|
||||
|
||||
@ -156,6 +157,13 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
onFirstReplyCreated = () => {
|
||||
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 {
|
||||
|
||||
@ -136,6 +136,7 @@ export const getConditionOptions = ({
|
||||
team_id: teams,
|
||||
campaigns: generateConditionOptions(campaigns),
|
||||
browser_language: languages,
|
||||
conversation_language: languages,
|
||||
country_code: countries,
|
||||
message_type: MESSAGE_CONDITION_VALUES,
|
||||
};
|
||||
|
||||
@ -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, '');
|
||||
|
||||
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 }) => {
|
||||
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 '';
|
||||
};
|
||||
@ -27,14 +34,14 @@ export const getMessageVariables = ({ conversation }) => {
|
||||
} = conversation;
|
||||
|
||||
return {
|
||||
'contact.name': sender?.name,
|
||||
'contact.name': capitalizeName(sender?.name),
|
||||
'contact.first_name': getFirstName({ user: sender }),
|
||||
'contact.last_name': getLastName({ user: sender }),
|
||||
'contact.email': sender?.email,
|
||||
'contact.phone': sender?.phone_number,
|
||||
'contact.id': sender?.id,
|
||||
'conversation.id': id,
|
||||
'agent.name': assignee?.name ? assignee?.name : '',
|
||||
'agent.name': capitalizeName(assignee?.name || ''),
|
||||
'agent.first_name': getFirstName({ user: assignee }),
|
||||
'agent.last_name': getLastName({ user: assignee }),
|
||||
'agent.email': assignee?.email ?? '',
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -4,6 +4,7 @@ import {
|
||||
getLastName,
|
||||
getMessageVariables,
|
||||
getUndefinedVariablesInMessage,
|
||||
capitalizeName,
|
||||
} from '../messageHelper';
|
||||
|
||||
const variables = {
|
||||
@ -87,11 +88,11 @@ describe('#getMessageVariables', () => {
|
||||
const conversation = {
|
||||
meta: {
|
||||
assignee: {
|
||||
name: 'Samuel Smith',
|
||||
name: 'samuel Smith',
|
||||
email: 'samuel@example.com',
|
||||
},
|
||||
sender: {
|
||||
name: 'John Doe',
|
||||
name: 'john Doe',
|
||||
email: 'john.doe@gmail.com',
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -22,7 +22,8 @@
|
||||
"is_not_present": "غير موجود",
|
||||
"is_greater_than": "هو أكبر من",
|
||||
"is_less_than": "هو أقل من",
|
||||
"days_before": "قبل x أيام"
|
||||
"days_before": "قبل x أيام",
|
||||
"starts_with": "Starts with"
|
||||
},
|
||||
"ATTRIBUTE_LABELS": {
|
||||
"TRUE": "صحيح",
|
||||
|
||||
@ -74,6 +74,11 @@
|
||||
"LABEL": "عنوان البريد الإلكتروني",
|
||||
"PLACEHOLDER": "الرجاء إدخال عنوان البريد الإلكتروني للموظف"
|
||||
},
|
||||
"AGENT_AVAILABILITY": {
|
||||
"LABEL": "التوفر",
|
||||
"PLACEHOLDER": "Please select an availability status",
|
||||
"ERROR": "Availability is required"
|
||||
},
|
||||
"SUBMIT": "تعديل حساب الموظف"
|
||||
},
|
||||
"BUTTON_TEXT": "تعديل",
|
||||
|
||||
@ -132,6 +132,17 @@
|
||||
"PLACEHOLDER": "أدخل اسم الشركة",
|
||||
"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": {
|
||||
"FACEBOOK": {
|
||||
"PLACEHOLDER": "أدخل اسم مستخدم فيسبوك",
|
||||
|
||||
@ -166,7 +166,15 @@
|
||||
"COPY": "نسخ",
|
||||
"DELETE": "حذف",
|
||||
"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": {
|
||||
|
||||
@ -439,7 +439,8 @@
|
||||
"LABEL": "الخصائص",
|
||||
"DISPLAY_FILE_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": {
|
||||
"MESSENGER_HEADING": "كود \"الماسنجر\"",
|
||||
|
||||
@ -164,6 +164,7 @@
|
||||
"COMPONENTS": {
|
||||
"CODE": {
|
||||
"BUTTON_TEXT": "نسخ",
|
||||
"CODEPEN": "Open in CodePen",
|
||||
"COPY_SUCCESSFUL": "تم نسخ الكود إلى الحافظة بنجاح"
|
||||
},
|
||||
"SHOW_MORE_BLOCK": {
|
||||
|
||||
@ -22,7 +22,8 @@
|
||||
"is_not_present": "Не присъства",
|
||||
"is_greater_than": "Is greater 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": {
|
||||
"TRUE": "True",
|
||||
|
||||
@ -74,6 +74,11 @@
|
||||
"LABEL": "Имейл адрес",
|
||||
"PLACEHOLDER": "Моля, въведете имейл адрес на агента"
|
||||
},
|
||||
"AGENT_AVAILABILITY": {
|
||||
"LABEL": "Availability",
|
||||
"PLACEHOLDER": "Please select an availability status",
|
||||
"ERROR": "Availability is required"
|
||||
},
|
||||
"SUBMIT": "Редактирай агента"
|
||||
},
|
||||
"BUTTON_TEXT": "Редактирай",
|
||||
|
||||
@ -132,6 +132,17 @@
|
||||
"PLACEHOLDER": "Въведете име на фирма",
|
||||
"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": {
|
||||
"FACEBOOK": {
|
||||
"PLACEHOLDER": "Въведете Facebook потребителско име",
|
||||
|
||||
@ -166,7 +166,15 @@
|
||||
"COPY": "Copy",
|
||||
"DELETE": "Изтрий",
|
||||
"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": {
|
||||
|
||||
@ -439,7 +439,8 @@
|
||||
"LABEL": "Features",
|
||||
"DISPLAY_FILE_PICKER": "Display file 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": {
|
||||
"MESSENGER_HEADING": "Messenger Script",
|
||||
|
||||
@ -164,6 +164,7 @@
|
||||
"COMPONENTS": {
|
||||
"CODE": {
|
||||
"BUTTON_TEXT": "Copy",
|
||||
"CODEPEN": "Open in CodePen",
|
||||
"COPY_SUCCESSFUL": "Code copied to clipboard successfully"
|
||||
},
|
||||
"SHOW_MORE_BLOCK": {
|
||||
|
||||
@ -22,7 +22,8 @@
|
||||
"is_not_present": "No és present",
|
||||
"is_greater_than": "És més gran 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": {
|
||||
"TRUE": "Cert",
|
||||
|
||||
@ -74,6 +74,11 @@
|
||||
"LABEL": "Adreça de correu electrònic",
|
||||
"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"
|
||||
},
|
||||
"BUTTON_TEXT": "Edita",
|
||||
|
||||
@ -132,6 +132,17 @@
|
||||
"PLACEHOLDER": "Introdueix el 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": {
|
||||
"FACEBOOK": {
|
||||
"PLACEHOLDER": "Introduïu el nom d'usuari de Facebook",
|
||||
|
||||
@ -166,7 +166,15 @@
|
||||
"COPY": "Copia",
|
||||
"DELETE": "Esborrar",
|
||||
"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": {
|
||||
|
||||
@ -439,7 +439,8 @@
|
||||
"LABEL": "Característiques",
|
||||
"DISPLAY_FILE_PICKER": "Mostra el selector de fitxers 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": {
|
||||
"MESSENGER_HEADING": "Script del missatger",
|
||||
|
||||
@ -164,6 +164,7 @@
|
||||
"COMPONENTS": {
|
||||
"CODE": {
|
||||
"BUTTON_TEXT": "Copia",
|
||||
"CODEPEN": "Open in CodePen",
|
||||
"COPY_SUCCESSFUL": "El codi s'ha copiat al porta-retalls amb èxit"
|
||||
},
|
||||
"SHOW_MORE_BLOCK": {
|
||||
|
||||
@ -22,7 +22,8 @@
|
||||
"is_not_present": "Is not present",
|
||||
"is_greater_than": "Is greater than",
|
||||
"is_less_than": "Je menší než",
|
||||
"days_before": "Is x days before"
|
||||
"days_before": "Is x days before",
|
||||
"starts_with": "Starts with"
|
||||
},
|
||||
"ATTRIBUTE_LABELS": {
|
||||
"TRUE": "True",
|
||||
|
||||
@ -74,6 +74,11 @@
|
||||
"LABEL": "E-mailová adresa",
|
||||
"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"
|
||||
},
|
||||
"BUTTON_TEXT": "Upravit",
|
||||
|
||||
@ -132,6 +132,17 @@
|
||||
"PLACEHOLDER": "Zadejte 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": {
|
||||
"FACEBOOK": {
|
||||
"PLACEHOLDER": "Zadejte uživatelské jméno na Facebooku",
|
||||
|
||||
@ -166,7 +166,15 @@
|
||||
"COPY": "Kopírovat",
|
||||
"DELETE": "Vymazat",
|
||||
"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": {
|
||||
|
||||
@ -439,7 +439,8 @@
|
||||
"LABEL": "Funkce",
|
||||
"DISPLAY_FILE_PICKER": "Display file 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": {
|
||||
"MESSENGER_HEADING": "Messenger skript",
|
||||
|
||||
@ -164,6 +164,7 @@
|
||||
"COMPONENTS": {
|
||||
"CODE": {
|
||||
"BUTTON_TEXT": "Kopírovat",
|
||||
"CODEPEN": "Open in CodePen",
|
||||
"COPY_SUCCESSFUL": "Kód byl úspěšně zkopírován do schránky"
|
||||
},
|
||||
"SHOW_MORE_BLOCK": {
|
||||
|
||||
@ -22,7 +22,8 @@
|
||||
"is_not_present": "Er ikke til stede",
|
||||
"is_greater_than": "Er større 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": {
|
||||
"TRUE": "Sandt",
|
||||
|
||||
@ -74,6 +74,11 @@
|
||||
"LABEL": "E-Mail Adresse",
|
||||
"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"
|
||||
},
|
||||
"BUTTON_TEXT": "Rediger",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user