Merge branch 'develop' into chore/pr/4162

This commit is contained in:
Sojan Jose 2022-03-25 00:18:07 +05:30 committed by GitHub
commit 07e5420db4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
785 changed files with 12672 additions and 2389 deletions

View file

@ -50,3 +50,6 @@ exclude_patterns:
- 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js'
- 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'
- 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js'
- 'app/javascript/dashboard/i18n/index.js'
- 'app/javascript/widget/i18n/index.js'
- 'app/javascript/survey/i18n/index.js'

View file

@ -32,6 +32,11 @@ REDIS_SENTINELS=
# You can find list of master using "SENTINEL masters" command
REDIS_SENTINEL_MASTER_NAME=
# Redis premium breakage in heroku fix
# enable the following configuration
# ref: https://github.com/chatwoot/chatwoot/issues/2420
# REDIS_OPENSSL_VERIFY_MODE=none
# Postgres Database config variables
POSTGRES_HOST=postgres
POSTGRES_USERNAME=postgres

View file

@ -29,8 +29,8 @@ module.exports = {
'vue/html-self-closing': 'off',
"vue/no-v-html": 'off',
'vue/singleline-html-element-content-newline': 'off',
'import/extensions': ['off']
'import/extensions': ['off'],
'no-console': 'error'
},
settings: {
'import/resolver': {

View file

@ -16,6 +16,7 @@ Metrics/ClassLength:
- 'app/models/message.rb'
- 'app/builders/messages/facebook/message_builder.rb'
- 'app/controllers/api/v1/accounts/contacts_controller.rb'
- 'app/listeners/action_cable_listener.rb'
RSpec/ExampleLength:
Max: 25
Style/Documentation:

View file

@ -42,7 +42,7 @@ gem 'down', '~> 5.0'
gem 'aws-sdk-s3', require: false
gem 'azure-storage-blob', require: false
gem 'google-cloud-storage', require: false
gem 'image_processing'
gem 'image_processing', '~> 1.12.2'
##-- gems for database --#
gem 'groupdate'

View file

@ -9,63 +9,63 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (6.1.4.6)
actionpack (= 6.1.4.6)
activesupport (= 6.1.4.6)
actioncable (6.1.4.7)
actionpack (= 6.1.4.7)
activesupport (= 6.1.4.7)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.4.6)
actionpack (= 6.1.4.6)
activejob (= 6.1.4.6)
activerecord (= 6.1.4.6)
activestorage (= 6.1.4.6)
activesupport (= 6.1.4.6)
actionmailbox (6.1.4.7)
actionpack (= 6.1.4.7)
activejob (= 6.1.4.7)
activerecord (= 6.1.4.7)
activestorage (= 6.1.4.7)
activesupport (= 6.1.4.7)
mail (>= 2.7.1)
actionmailer (6.1.4.6)
actionpack (= 6.1.4.6)
actionview (= 6.1.4.6)
activejob (= 6.1.4.6)
activesupport (= 6.1.4.6)
actionmailer (6.1.4.7)
actionpack (= 6.1.4.7)
actionview (= 6.1.4.7)
activejob (= 6.1.4.7)
activesupport (= 6.1.4.7)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.4.6)
actionview (= 6.1.4.6)
activesupport (= 6.1.4.6)
actionpack (6.1.4.7)
actionview (= 6.1.4.7)
activesupport (= 6.1.4.7)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.4.6)
actionpack (= 6.1.4.6)
activerecord (= 6.1.4.6)
activestorage (= 6.1.4.6)
activesupport (= 6.1.4.6)
actiontext (6.1.4.7)
actionpack (= 6.1.4.7)
activerecord (= 6.1.4.7)
activestorage (= 6.1.4.7)
activesupport (= 6.1.4.7)
nokogiri (>= 1.8.5)
actionview (6.1.4.6)
activesupport (= 6.1.4.6)
actionview (6.1.4.7)
activesupport (= 6.1.4.7)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (6.1.4.6)
activesupport (= 6.1.4.6)
activejob (6.1.4.7)
activesupport (= 6.1.4.7)
globalid (>= 0.3.6)
activemodel (6.1.4.6)
activesupport (= 6.1.4.6)
activerecord (6.1.4.6)
activemodel (= 6.1.4.6)
activesupport (= 6.1.4.6)
activemodel (6.1.4.7)
activesupport (= 6.1.4.7)
activerecord (6.1.4.7)
activemodel (= 6.1.4.7)
activesupport (= 6.1.4.7)
activerecord-import (1.3.0)
activerecord (>= 4.2)
activestorage (6.1.4.6)
actionpack (= 6.1.4.6)
activejob (= 6.1.4.6)
activerecord (= 6.1.4.6)
activesupport (= 6.1.4.6)
activestorage (6.1.4.7)
actionpack (= 6.1.4.7)
activejob (= 6.1.4.7)
activerecord (= 6.1.4.7)
activesupport (= 6.1.4.7)
marcel (~> 1.0.0)
mini_mime (>= 1.1.0)
activesupport (6.1.4.6)
activesupport (6.1.4.7)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@ -135,7 +135,7 @@ GEM
byebug (11.1.3)
climate_control (1.0.1)
coderay (1.1.3)
commonmarker (0.23.2)
commonmarker (0.23.4)
concurrent-ruby (1.1.9)
connection_pool (2.2.5)
crack (0.4.5)
@ -303,7 +303,7 @@ GEM
httpclient (2.8.3)
i18n (1.10.0)
concurrent-ruby (~> 1.0)
image_processing (1.12.1)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
jbuilder (2.11.5)
@ -419,29 +419,29 @@ GEM
rack-test (1.1.0)
rack (>= 1.0, < 3)
rack-timeout (0.6.0)
rails (6.1.4.6)
actioncable (= 6.1.4.6)
actionmailbox (= 6.1.4.6)
actionmailer (= 6.1.4.6)
actionpack (= 6.1.4.6)
actiontext (= 6.1.4.6)
actionview (= 6.1.4.6)
activejob (= 6.1.4.6)
activemodel (= 6.1.4.6)
activerecord (= 6.1.4.6)
activestorage (= 6.1.4.6)
activesupport (= 6.1.4.6)
rails (6.1.4.7)
actioncable (= 6.1.4.7)
actionmailbox (= 6.1.4.7)
actionmailer (= 6.1.4.7)
actionpack (= 6.1.4.7)
actiontext (= 6.1.4.7)
actionview (= 6.1.4.7)
activejob (= 6.1.4.7)
activemodel (= 6.1.4.7)
activerecord (= 6.1.4.7)
activestorage (= 6.1.4.7)
activesupport (= 6.1.4.7)
bundler (>= 1.15.0)
railties (= 6.1.4.6)
railties (= 6.1.4.7)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.4.2)
loofah (~> 2.3)
railties (6.1.4.6)
actionpack (= 6.1.4.6)
activesupport (= 6.1.4.6)
railties (6.1.4.7)
actionpack (= 6.1.4.7)
activesupport (= 6.1.4.7)
method_source
rake (>= 0.13)
thor (~> 1.0)
@ -574,7 +574,7 @@ GEM
spring-watcher-listen (2.0.1)
listen (>= 2.7, < 4.0)
spring (>= 1.2, < 3.0)
sprockets (4.0.2)
sprockets (4.0.3)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.4.2)
@ -688,7 +688,7 @@ DEPENDENCIES
hairtrigger
hashie
html2text
image_processing
image_processing (~> 1.12.2)
jbuilder
json_refs
json_schemer
@ -751,4 +751,4 @@ RUBY VERSION
ruby 3.0.2p107
BUNDLED WITH
2.2.25
2.3.8

View file

@ -32,6 +32,10 @@
"INSTALLATION_ENV": {
"description": "Installation method used for Chatwoot.",
"value": "heroku"
},
"REDIS_OPENSSL_VERIFY_MODE":{
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
"value": "none"
}
},
"formation": {

View file

@ -52,12 +52,11 @@ class ContactIdentifyAction
end
def update_contact
custom_attributes = params[:custom_attributes] ? @contact.custom_attributes.merge(params[:custom_attributes]) : @contact.custom_attributes
# blank identifier or email will throw unique index error
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.update!(params.slice(:name, :email, :identifier, :phone_number).reject do |_k, v|
v.blank?
end.merge({ custom_attributes: custom_attributes }))
end.merge({ custom_attributes: custom_attributes, additional_attributes: additional_attributes }))
ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
end
@ -68,4 +67,16 @@ class ContactIdentifyAction
mergee_contact: merge_contact
).perform
end
def custom_attributes
params[:custom_attributes] ? @contact.custom_attributes.deep_merge(params[:custom_attributes].stringify_keys) : @contact.custom_attributes
end
def additional_attributes
if params[:additional_attributes]
@contact.additional_attributes.deep_merge(params[:additional_attributes].stringify_keys)
else
@contact.additional_attributes
end
end
end

View file

@ -1,10 +1,14 @@
class Messages::Messenger::MessageBuilder
include ::FileTypeHelper
def process_attachment(attachment)
return if attachment['type'].to_sym == :template
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
update_attachment_file_type(attachment_obj)
end
def attach_file(attachment, file_url)
@ -22,7 +26,7 @@ class Messages::Messenger::MessageBuilder
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video].include? file_type
if [:image, :file, :audio, :video, :share, :story_mention].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
@ -39,4 +43,31 @@ class Messages::Messenger::MessageBuilder
remote_file_url: attachment['payload']['url']
}
end
def update_attachment_file_type(attachment)
return unless attachment.file_type == 'share' || attachment.file_type == 'story_mention'
attachment.file_type = file_type(attachment.file&.content_type)
attachment.save!
end
def fetch_story_link(attachment)
message = attachment.message
begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(message.source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue StandardError => e
result = {}
Sentry.capture_exception(e)
end
story_id = result['story']['mention']['id']
story_sender = result['from']['username']
message.content_attributes[:story_sender] = story_sender
message.content_attributes[:story_id] = story_id
message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender)
message.save!
end
end

View file

@ -95,15 +95,15 @@ class V2::ReportBuilder
end
def avg_first_response_time
(get_grouped_values scope.events.where(name: 'first_response')).average(:value)
(get_grouped_values scope.reporting_events.where(name: 'first_response')).average(:value)
end
def avg_resolution_time
(get_grouped_values scope.events.where(name: 'conversation_resolved')).average(:value)
(get_grouped_values scope.reporting_events.where(name: 'conversation_resolved')).average(:value)
end
def avg_resolution_time_summary
avg_rt = scope.events
avg_rt = scope.reporting_events
.where(name: 'conversation_resolved', created_at: range)
.average(:value)
@ -113,7 +113,7 @@ class V2::ReportBuilder
end
def avg_first_response_time_summary
avg_frt = scope.events
avg_frt = scope.reporting_events
.where(name: 'first_response', created_at: range)
.average(:value)

View file

@ -1,5 +1,8 @@
class RoomChannel < ApplicationCable::Channel
def subscribed
# TODO: should we only do ensure stream if current account is present?
# for now going ahead with guard clauses in update_subscription and broadcast_presence
ensure_stream
current_user
current_account
@ -15,6 +18,8 @@ class RoomChannel < ApplicationCable::Channel
private
def broadcast_presence
return if @current_account.blank?
data = { account_id: @current_account.id, users: ::OnlineStatusTracker.get_available_users(@current_account.id) }
data[:contacts] = ::OnlineStatusTracker.get_available_contacts(@current_account.id) if @current_user.is_a? User
ActionCable.server.broadcast(@pubsub_token, { event: 'presence.update', data: data })
@ -26,6 +31,8 @@ class RoomChannel < ApplicationCable::Channel
end
def update_subscription
return if @current_account.blank?
::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id)
end

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
end
def destroy
@agent_bot.destroy
@agent_bot.destroy!
head :ok
end

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def destroy
@agent.current_account_user.destroy
@agent.current_account_user.destroy!
head :ok
end
@ -68,7 +68,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def agents
@agents ||= Current.account.users.order_by_full_name.includes({ avatar_attachment: [:blob] })
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
end
def validate_limit

View file

@ -3,7 +3,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
def index
@automation_rules = Current.account.automation_rules.active
@automation_rules = Current.account.automation_rules
end
def create
@ -32,9 +32,9 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
def automation_rules_permit
params.permit(
:name, :description, :event_name, :account_id,
:name, :description, :event_name, :account_id, :active,
conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }],
actions: [:action_name, { action_params: [] }]
actions: [:action_name, { action_params: [{}] }]
)
end

View file

@ -1,32 +1,6 @@
class Api::V1::Accounts::BaseController < Api::BaseController
include SwitchLocale
include EnsureCurrentAccountHelper
before_action :current_account
around_action :switch_locale_using_account_locale
private
def current_account
@current_account ||= ensure_current_account
Current.account = @current_account
end
def ensure_current_account
account = Account.find(params[:account_id])
if current_user
account_accessible_for_user?(account)
elsif @resource.is_a?(AgentBot)
account_accessible_for_bot?(account)
end
account
end
def account_accessible_for_user?(account)
@current_account_user = account.account_users.find_by(user_id: current_user.id)
Current.account_user = @current_account_user
render_unauthorized('You are not authorized to access this account') unless @current_account_user
end
def account_accessible_for_bot?(account)
render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
end
end

View file

@ -11,7 +11,7 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
end
def destroy
@campaign.destroy
@campaign.destroy!
head :ok
end

View file

@ -17,7 +17,7 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont
end
def destroy
@canned_response.destroy
@canned_response.destroy!
head :ok
end

View file

@ -10,7 +10,7 @@ class Api::V1::Accounts::Contacts::NotesController < Api::V1::Accounts::Contacts
end
def destroy
@note.destroy
@note.destroy!
head :ok
end

View file

@ -1,4 +1,5 @@
class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::BaseController
include EnsureCurrentAccountHelper
before_action :conversation
private

View file

@ -0,0 +1,17 @@
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
include EnsureCurrentAccountHelper
before_action :current_account
before_action :conversation
def create
return if @conversation.nil? || @current_account.nil?
super
end
private
def conversation
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
end
end

View file

@ -13,7 +13,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
def destroy
ActiveRecord::Base.transaction do
message.update!(content: I18n.t('conversations.messages.deleted'), deleted: true)
message.update!(content: I18n.t('conversations.messages.deleted'), content_attributes: { deleted: true })
message.attachments.destroy_all
end
end

View file

@ -30,8 +30,7 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
def set_csat_survey_responses
@csat_survey_responses = filtrate(
Current.account.csat_survey_responses.includes([:conversation, :assigned_agent, :contact])
)
@csat_survey_responses = @csat_survey_responses.where(created_at: range) if range.present?
).filter_by_created_at(range).filter_by_assigned_agent_id(params[:user_ids])
end
def set_current_page_surveys

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account
end
def destroy
@custom_attribute_definition.destroy
@custom_attribute_definition.destroy!
head :no_content
end

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
end
def destroy
@custom_filter.destroy
@custom_filter.destroy!
head :no_content
end

View file

@ -48,7 +48,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
# Inbox update doesn't necessarily need channel attributes
return if permitted_params(channel_attributes)[:channel].blank?
validate_email_channel(channel_attributes) if @inbox.inbox_type == 'Email'
if @inbox.inbox_type == 'Email'
validate_email_channel(channel_attributes)
@inbox.channel.reauthorized!
end
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
update_channel_feature_flags
@ -70,7 +73,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def destroy
@inbox.destroy
@inbox.destroy!
head :ok
end

View file

@ -11,7 +11,7 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
end
def destroy
@hook.destroy
@hook.destroy!
head :ok
end

View file

@ -20,7 +20,7 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base
end
def destroy
@hook.destroy
@hook.destroy!
head :ok
end

View file

@ -14,7 +14,7 @@ class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase:
end
def destroy
@category.destroy
@category.destroy!
head :ok
end

View file

@ -14,7 +14,7 @@ class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::Kbase::Ba
end
def destroy
@portal.destroy
@portal.destroy!
head :ok
end

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
end
def destroy
@label.destroy
@label.destroy!
head :ok
end

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
end
def destroy
@team.destroy
@team.destroy!
head :ok
end

View file

@ -16,7 +16,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
end
def destroy
@webhook.destroy
@webhook.destroy!
head :ok
end

View file

@ -9,7 +9,7 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
def destroy
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription.destroy
notification_subscription.destroy!
head :ok
end

View file

@ -1,5 +1,6 @@
class Api::V1::Widget::BaseController < ApplicationController
include SwitchLocale
include WebsiteTokenHelper
before_action :set_web_widget
before_action :set_contact
@ -19,23 +20,6 @@ class Api::V1::Widget::BaseController < ApplicationController
@conversation ||= conversations.last
end
def auth_token_params
@auth_token_params ||= ::Widget::TokenService.new(token: request.headers['X-Auth-Token']).decode_token
end
def set_web_widget
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
@current_account = @web_widget.account
end
def set_contact
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
source_id: auth_token_params[:source_id]
)
@contact = @contact_inbox&.contact
raise ActiveRecord::RecordNotFound unless @contact
end
def create_conversation
::Conversation.create!(conversation_params)
end
@ -94,10 +78,6 @@ class Api::V1::Widget::BaseController < ApplicationController
{ timestamp: permitted_params[:message][:timestamp] }
end
def permitted_params
params.permit(:website_token)
end
def message_params
{
account_id: conversation.account_id,

View file

@ -46,6 +46,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
end
def permitted_params
params.permit(:website_token, :identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {})
params.permit(:website_token, :identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {},
additional_attributes: {})
end
end

View file

@ -44,6 +44,15 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
head :ok
end
def toggle_status
head :not_found && return if conversation.nil?
unless conversation.resolved?
conversation.status = :resolved
conversation.save
end
head :ok
end
private
def trigger_typing_event(event)

View file

@ -0,0 +1,11 @@
class Api::V1::Widget::DirectUploadsController < ActiveStorage::DirectUploadsController
include WebsiteTokenHelper
before_action :set_web_widget
before_action :set_contact
def create
return if @contact.nil? || @current_account.nil?
super
end
end

View file

@ -41,12 +41,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
def summary_params
def current_summary_params
{
type: params[:type].to_sym,
since: params[:since],
until: params[:until],
id: params[:id],
since: range[:current][:since],
until: range[:current][:until],
group_by: params[:group_by]
}
end
def previous_summary_params
{
type: params[:type].to_sym,
id: params[:id],
since: range[:previous][:since],
until: range[:previous][:until],
group_by: params[:group_by]
}
end
@ -63,8 +73,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
}
end
def range
{
current: {
since: params[:since],
until: params[:until]
},
previous: {
since: (params[:since].to_i - (params[:until].to_i - params[:since].to_i)).to_s,
until: params[:since]
}
}
end
def summary_metrics
builder = V2::ReportBuilder.new(Current.account, summary_params)
builder.summary
summary = V2::ReportBuilder.new(Current.account, current_summary_params).summary
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary
summary
end
end

View file

@ -0,0 +1,28 @@
module EnsureCurrentAccountHelper
private
def current_account
@current_account ||= ensure_current_account
Current.account = @current_account
end
def ensure_current_account
account = Account.find(params[:account_id])
if current_user
account_accessible_for_user?(account)
elsif @resource.is_a?(AgentBot)
account_accessible_for_bot?(account)
end
account
end
def account_accessible_for_user?(account)
@current_account_user = account.account_users.find_by(user_id: current_user.id)
Current.account_user = @current_account_user
render_unauthorized('You are not authorized to access this account') unless @current_account_user
end
def account_accessible_for_bot?(account)
render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
end
end

View file

@ -0,0 +1,24 @@
module WebsiteTokenHelper
def auth_token_params
@auth_token_params ||= ::Widget::TokenService.new(token: request.headers['X-Auth-Token']).decode_token
end
def set_web_widget
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
@current_account = @web_widget.account
end
def set_contact
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
source_id: auth_token_params[:source_id]
)
@contact = @contact_inbox&.contact
raise ActiveRecord::RecordNotFound unless @contact
Current.contact = @contact
end
def permitted_params
params.permit(:website_token)
end
end

View file

@ -13,7 +13,7 @@ class Platform::Api::V1::AccountUsersController < PlatformController
end
def destroy
@resource.account_users.find_by(user_id: account_user_params[:user_id])&.destroy
@resource.account_users.find_by(user_id: account_user_params[:user_id])&.destroy!
head :ok
end

View file

@ -9,7 +9,7 @@ class Platform::Api::V1::UsersController < PlatformController
@resource = (User.find_by(email: user_params[:email]) || User.new(user_params))
@resource.save!
@resource.confirm
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
@platform_app.platform_app_permissibles.find_or_create_by!(permissible: @resource)
end
def login

View file

@ -12,10 +12,10 @@ class AsyncDispatcher < BaseDispatcher
[
CampaignListener.instance,
CsatSurveyListener.instance,
EventListener.instance,
HookListener.instance,
InstallationWebhookListener.instance,
NotificationListener.instance,
ReportingEventListener.instance,
WebhookListener.instance,
AutomationRuleListener.instance
]

View file

@ -1,2 +0,0 @@
module Api::V1::ReportsHelper
end

View file

@ -3,7 +3,7 @@ module FileTypeHelper
def file_type(content_type)
return :image if image_file?(content_type)
return :video if video_file?(content_type)
return :audio if content_type.include?('audio/')
return :audio if content_type&.include?('audio/')
:file
end
@ -14,7 +14,8 @@ module FileTypeHelper
'image/png',
'image/gif',
'image/bmp',
'image/webp'
'image/webp',
'image'
].include?(content_type)
end
@ -23,7 +24,8 @@ module FileTypeHelper
'video/ogg',
'video/mp4',
'video/webm',
'video/quicktime'
'video/quicktime',
'video'
].include?(content_type)
end
end

View file

@ -1,5 +1,6 @@
<template>
<div id="app" class="app-wrapper app-root">
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
<transition name="fade" mode="out-in">
<router-view></router-view>
</transition>
@ -13,24 +14,27 @@
</template>
<script>
import { accountIdFromPathname } from './helper/URLHelper';
import { mapGetters } from 'vuex';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal';
import WootSnackbarBox from './components/SnackbarContainer';
import NetworkNotification from './components/NetworkNotification';
import { accountIdFromPathname } from './helper/URLHelper';
import UpdateBanner from './components/app/UpdateBanner.vue';
import WootSnackbarBox from './components/SnackbarContainer';
export default {
name: 'App',
components: {
WootSnackbarBox,
AddAccountModal,
NetworkNotification,
UpdateBanner,
WootSnackbarBox,
},
data() {
return {
showAddAccountModal: false,
latestChatwootVersion: null,
};
},
@ -38,6 +42,7 @@ export default {
...mapGetters({
getAccount: 'accounts/getAccount',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
}),
hasAccounts() {
return (
@ -72,8 +77,12 @@ export default {
if (accountId) {
await this.$store.dispatch('accounts/get');
const { locale } = this.getAccount(accountId);
const {
locale,
latest_chatwoot_version: latestChatwootVersion,
} = this.getAccount(accountId);
this.setLocale(locale);
this.latestChatwootVersion = latestChatwootVersion;
}
},
},
@ -82,6 +91,11 @@ export default {
<style lang="scss">
@import './assets/scss/app';
.update-banner {
height: var(--space-larger);
align-items: center;
font-size: var(--font-size-small) !important;
}
</style>
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>

View file

@ -13,7 +13,7 @@ export default {
.post('auth/sign_in', creds)
.then(response => {
setAuthCredentials(response);
resolve();
resolve(response.data);
})
.catch(error => {
reject(error.response);

View file

@ -6,15 +6,21 @@ class CSATReportsAPI extends ApiClient {
super('csat_survey_responses', { accountScoped: true });
}
get({ page, from, to } = {}) {
get({ page, from, to, user_ids } = {}) {
return axios.get(this.url, {
params: { page, since: from, until: to, sort: '-created_at' },
params: {
page,
since: from,
until: to,
sort: '-created_at',
user_ids,
},
});
}
getMetrics({ from, to } = {}) {
getMetrics({ from, to, user_ids } = {}) {
return axios.get(`${this.url}/metrics`, {
params: { since: from, until: to },
params: { since: from, until: to, user_ids },
});
}
}

View file

@ -26,7 +26,8 @@
code {
border: 0;
font-family: 'Monaco', Verdana;
font-family: 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas',
'"Liberation Mono"', '"Courier New"', 'monospace';
font-size: $font-size-mini;
&.hljs {
@ -55,7 +56,6 @@ code {
padding-right: var(--space-normal);
}
.badge {
border-radius: var(--border-radius-normal);
}

View file

@ -10,10 +10,24 @@ body {
.app-wrapper {
@include full-height;
flex-grow: 0;
min-height: 0;
width: 100%;
}
.app-root {
.banner + .app-wrapper {
.button--fixed-right-top {
top: 5.6 * $space-one;
}
.off-canvas-content {
.button--fixed-right-top {
top: $space-small;
}
}
}
is-closed .app-root {
@include flex;
flex-direction: column;
}
@ -21,6 +35,7 @@ body {
.app-content {
@include flex;
@include full-height;
min-height: 0;
overflow: hidden;
}

View file

@ -20,9 +20,28 @@
color: $color-heading;
}
.metric-wrap {
align-items: baseline;
display: flex;
}
.metric {
font-size: $font-size-bigger;
font-size: $font-size-big;
font-weight: $font-weight-feather;
margin-top: $space-smaller;
}
.metric-trend {
font-size: $font-size-small;
margin-left: $space-small;
}
.metric-up {
color: $success-color;
}
.metric-down {
color: $alert-color;
}
.desc {

View file

@ -0,0 +1,74 @@
<template>
<banner
v-if="shouldShowBanner"
class="update-banner"
color-scheme="primary"
:banner-message="bannerMessage"
href-link="https://github.com/chatwoot/chatwoot/releases"
:href-link-text="$t('GENERAL_SETTINGS.LEARN_MORE')"
has-close-button
@close="dismissUpdateBanner"
/>
</template>
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import LocalStorage from '../../helper/localStorage';
import { mapGetters } from 'vuex';
import adminMixin from 'dashboard/mixins/isAdmin';
const semver = require('semver');
const dismissedUpdates = new LocalStorage('dismissedUpdates');
export default {
components: {
Banner,
},
mixins: [adminMixin],
props: {
latestChatwootVersion: {
type: String,
default: '',
},
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
hasAnUpdateAvailable() {
if (!semver.valid(this.latestChatwootVersion)) {
return false;
}
return semver.lt(
this.globalConfig.appVersion,
this.latestChatwootVersion
);
},
bannerMessage() {
return this.$t('GENERAL_SETTINGS.UPDATE_CHATWOOT', {
latestChatwootVersion: this.latestChatwootVersion,
});
},
shouldShowBanner() {
return (
this.globalConfig.displayManifest &&
this.hasAnUpdateAvailable &&
!this.isVersionNotificationDismissed(this.latestChatwootVersion) &&
this.isAdmin
);
},
},
methods: {
isVersionNotificationDismissed(version) {
return dismissedUpdates.get().includes(version);
},
dismissUpdateBanner() {
let updatedDismissedItems = dismissedUpdates.get();
if (updatedDismissedItems instanceof Array) {
updatedDismissedItems.push(this.latestChatwootVersion);
} else {
updatedDismissedItems = [this.latestChatwootVersion];
}
dismissedUpdates.store(updatedDismissedItems);
this.latestChatwootVersion = this.globalConfig.appVersion;
},
},
};
</script>

View file

@ -21,6 +21,7 @@ import SubmitButton from './buttons/FormSubmitButton';
import Tabs from './ui/Tabs/Tabs';
import TabsItem from './ui/Tabs/TabsItem';
import Thumbnail from './widgets/Thumbnail.vue';
import ConfirmModal from './widgets/modal/ConfirmationModal.vue';
const WootUIKit = {
AvatarUploader,
@ -45,6 +46,7 @@ const WootUIKit = {
Tabs,
TabsItem,
Thumbnail,
ConfirmModal,
install(Vue) {
const keys = Object.keys(this);
keys.pop(); // remove 'install' from keys

View file

@ -19,23 +19,6 @@
:current-role="currentRole"
@add-label="showAddLabelPopup"
/>
<woot-key-shortcut-modal
v-if="showShortcutModal"
@close="closeKeyShortcutModal"
@clickaway="closeKeyShortcutModal"
/>
<account-selector
:show-account-modal="showAccountModal"
@close-account-modal="toggleAccountModal"
@show-create-account-modal="openCreateAccountModal"
/>
<add-account-modal
:show="showCreateAccountModal"
@close-account-create-modal="closeCreateAccountModal"
/>
<woot-modal :show.sync="showAddLabelModal" :on-close="hideAddLabelPopup">
<add-label-modal @close="hideAddLabelPopup" />
</woot-modal>
</aside>
</template>
@ -46,12 +29,8 @@ import adminMixin from '../../mixins/isAdmin';
import { getSidebarItems } from './config/default-sidebar';
import alertMixin from 'shared/mixins/alertMixin';
import AccountSelector from './sidebarComponents/AccountSelector.vue';
import AddAccountModal from './sidebarComponents/AddAccountModal.vue';
import AddLabelModal from '../../routes/dashboard/settings/labels/AddLabel';
import PrimarySidebar from './sidebarComponents/Primary';
import SecondarySidebar from './sidebarComponents/Secondary';
import WootKeyShortcutModal from 'components/widgets/modal/WootKeyShortcutModal';
import {
hasPressedAltAndCKey,
hasPressedAltAndRKey,
@ -65,21 +44,13 @@ import router from '../../routes';
export default {
components: {
AccountSelector,
AddAccountModal,
AddLabelModal,
PrimarySidebar,
SecondarySidebar,
WootKeyShortcutModal,
},
mixins: [adminMixin, alertMixin, eventListenerMixins],
data() {
return {
showOptionsMenu: false,
showAccountModal: false,
showCreateAccountModal: false,
showAddLabelModal: false,
showShortcutModal: false,
};
},
@ -162,10 +133,10 @@ export default {
}
},
toggleKeyShortcutModal() {
this.showShortcutModal = true;
this.$emit('open-key-shortcut-modal');
},
closeKeyShortcutModal() {
this.showShortcutModal = false;
this.$emit('close-key-shortcut-modal');
},
handleKeyEvents(e) {
if (hasPressedCommandAndForwardSlash(e)) {
@ -200,20 +171,10 @@ export default {
window.$chatwoot.toggle();
},
toggleAccountModal() {
this.showAccountModal = !this.showAccountModal;
},
openCreateAccountModal() {
this.showAccountModal = false;
this.showCreateAccountModal = true;
},
closeCreateAccountModal() {
this.showCreateAccountModal = false;
this.$emit('toggle-account-modal');
},
showAddLabelPopup() {
this.showAddLabelModal = true;
},
hideAddLabelPopup() {
this.showAddLabelModal = false;
this.$emit('show-add-label-popup');
},
},
};
@ -223,6 +184,8 @@ export default {
.woot-sidebar {
background: var(--white);
display: flex;
min-height: 0;
height: 100%;
}
</style>

View file

@ -93,7 +93,7 @@ export default {
width: var(--space-jumbo);
border-right: 1px solid var(--s-50);
box-sizing: content-box;
height: 100vh;
height: 100%;
flex-shrink: 0;
}

View file

@ -225,7 +225,7 @@ export default {
.secondary-menu {
background: var(--white);
border-right: 1px solid var(--s-50);
height: 100vh;
height: 100%;
width: 19rem;
flex-shrink: 0;
overflow: hidden;

View file

@ -20,7 +20,8 @@
data-view-component="true"
label="Beta"
class="beta"
>Beta
>
{{ $t('SIDEBAR.BETA') }}
</span>
</router-link>
@ -233,7 +234,7 @@ export default {
padding-left: var(--space-smaller) !important;
margin-left: var(--space-half) !important;
display: inline-block;
font-size: var(--font-size-mini);
font-size: var(--font-size-micro);
font-weight: var(--font-weight-medium);
line-height: 18px;
border: 1px solid transparent;

View file

@ -1,6 +1,6 @@
<template>
<div class="banner" :class="bannerClasses">
<span>
<span class="banner-message">
{{ bannerMessage }}
<a
v-if="hrefLink"
@ -26,7 +26,7 @@
v-if="hasCloseButton"
size="small"
variant="link"
color-scheme="warning"
color-scheme="secondary"
icon="dismiss-circle"
class-names="banner-action__button"
@click="onClickClose"
@ -92,8 +92,19 @@ export default {
justify-content: center;
position: sticky;
&.primary {
background: var(--w-500);
.banner-action__button {
color: var(--white);
}
}
&.secondary {
background: var(--s-300);
background: var(--s-200);
color: var(--s-800);
a {
color: var(--s-800);
}
}
&.alert {
@ -110,9 +121,13 @@ export default {
&.gray {
background: var(--b-500);
.banner-action__button {
color: var(--white);
}
}
a {
margin-left: var(--space-smaller);
text-decoration: underline;
color: var(--white);
font-size: var(--font-size-mini);
@ -125,5 +140,10 @@ export default {
white-space: nowrap;
}
}
.banner-message {
display: flex;
align-items: center;
}
}
</style>

View file

@ -1,5 +1,5 @@
<template>
<div>
<div class="preview-item__wrap">
<div
v-for="(attachment, index) in attachments"
:key="attachment.id"
@ -19,12 +19,13 @@
</span>
</div>
<div class="file-size-wrap">
<span class="item">
<span class="item text-truncate">
{{ formatFileSize(attachment.resource) }}
</span>
</div>
<div class="remove-file-wrap">
<woot-button
v-if="!isTypeAudio(attachment.resource)"
class="remove--attachment clear secondary"
icon="dismiss"
@click="() => onRemoveAttachment(index)"
@ -58,6 +59,10 @@ export default {
const type = file.content_type || file.type;
return type.includes('image');
},
isTypeAudio(file) {
const type = file.content_type || file.type;
return type.includes('audio');
},
fileName(file) {
return file.filename || file.name;
},
@ -65,15 +70,23 @@ export default {
};
</script>
<style lang="scss" scoped>
.preview-item__wrap {
display: flex;
flex-direction: column;
overflow: auto;
margin-top: var(--space-normal);
max-height: 20rem;
}
.preview-item {
display: flex;
padding: var(--space-slab) 0 0;
background: var(--color-background-light);
background: var(--b-50);
border-radius: var(--border-radius-normal);
width: fit-content;
width: 24rem;
padding: var(--space-smaller);
margin-top: var(--space-normal);
margin-bottom: var(--space-one);
}
.thumb-wrap {
@ -109,6 +122,7 @@ export default {
> .item {
margin: 0;
overflow: hidden;
font-size: var(--font-size-mini);
font-weight: var(--font-weight-medium);
}
@ -119,7 +133,8 @@ export default {
}
.file-name-wrap {
max-width: 50%;
max-width: 60%;
min-width: 50%;
overflow: hidden;
text-overflow: ellipsis;
margin-left: var(--space-small);

View file

@ -69,7 +69,22 @@ export const OPERATOR_TYPES_4 = [
label: 'Is greater than',
},
{
value: 'is_lesser_than',
label: 'Is lesser than',
value: 'is_less_than',
label: 'Is less than',
},
];
export const OPERATOR_TYPES_5 = [
{
value: 'is_greater_than',
label: 'Is greater than',
},
{
value: 'is_less_than',
label: 'Is less than',
},
{
value: 'days_before',
label: 'Is x days before',
},
];

View file

@ -0,0 +1,15 @@
import {
OPERATOR_TYPES_1,
OPERATOR_TYPES_2,
OPERATOR_TYPES_3,
OPERATOR_TYPES_4,
} from './FilterOperatorTypes';
describe('#filterOperators', () => {
it('Matches the correct Operators', () => {
expect(OPERATOR_TYPES_1).toMatchObject(OPERATOR_TYPES_1);
expect(OPERATOR_TYPES_2).toMatchObject(OPERATOR_TYPES_2);
expect(OPERATOR_TYPES_3).toMatchObject(OPERATOR_TYPES_3);
expect(OPERATOR_TYPES_4).toMatchObject(OPERATOR_TYPES_4);
});
});

View file

@ -141,6 +141,10 @@ export default {
type: String,
default: 'plain_text',
},
dataType: {
type: String,
default: 'plain_text',
},
operators: {
type: Array,
default: () => [],

View file

@ -7,9 +7,12 @@
<h3 class="heading">
{{ heading }}
</h3>
<div class="metric-wrap">
<h4 class="metric">
{{ point }}
</h4>
<span v-if="trend !== 0" :class="trendClass">{{ trendValue }}</span>
</div>
<p class="desc">
{{ desc }}
</p>
@ -20,10 +23,27 @@ export default {
props: {
heading: { type: String, default: '' },
point: { type: [Number, String], default: '' },
trend: { type: Number, default: null },
index: { type: Number, default: null },
desc: { type: String, default: '' },
selected: Boolean,
onClick: { type: Function, default: () => {} },
},
computed: {
trendClass() {
if (this.trend > 0) {
return 'metric-trend metric-up';
}
return 'metric-trend metric-down';
},
trendValue() {
if (this.trend > 0) {
return `+${this.trend}%`;
}
return `${this.trend}%`;
},
},
};
</script>

View file

@ -0,0 +1,223 @@
<template>
<div class="audio-wave-wrapper">
<div id="audio-wave"></div>
</div>
</template>
<script>
import WaveSurfer from 'wavesurfer.js';
import MicrophonePlugin from 'wavesurfer.js/dist/plugin/wavesurfer.microphone.js';
import RecordRTC from 'recordrtc';
import inboxMixin from '../../../../shared/mixins/inboxMixin';
import alertMixin from '../../../../shared/mixins/alertMixin';
WaveSurfer.microphone = MicrophonePlugin;
export default {
name: 'WootAudioRecorder',
mixins: [inboxMixin, alertMixin],
data() {
return {
wavesurfer: false,
recorder: false,
recordingInterval: false,
recordingDateStarted: new Date().getTime(),
timeDuration: '00:00',
initialTimeDuration: '00:00',
options: {
container: '#audio-wave',
backend: 'WebAudio',
interact: true,
cursorWidth: 1,
plugins: [
WaveSurfer.microphone.create({
bufferSize: 4096,
numberOfInputChannels: 1,
numberOfOutputChannels: 1,
constraints: {
video: false,
audio: true,
},
}),
],
},
optionsRecorder: {
type: 'audio',
mimeType: 'audio/wav',
disableLogs: true,
recorderType: RecordRTC.StereoAudioRecorder,
sampleRate: 44100,
numberOfAudioChannels: 2,
checkForInactiveTracks: true,
bufferSize: 4096,
},
};
},
computed: {
isRecording() {
if (this.recorder) {
return this.recorder.getState() === 'recording';
}
return false;
},
},
mounted() {
this.wavesurfer = WaveSurfer.create(this.options);
this.wavesurfer.on('play', this.playingRecorder);
this.wavesurfer.on('pause', this.pausedRecorder);
this.wavesurfer.microphone.on('deviceReady', this.startRecording);
this.wavesurfer.microphone.on('deviceError', this.deviceError);
this.wavesurfer.microphone.start();
this.fireStateRecorderTimerChanged(this.initialTimeDuration);
},
beforeDestroy() {
if (this.recorder) {
this.recorder.destroy();
}
if (this.wavesurfer) {
this.wavesurfer.destroy();
}
},
methods: {
startRecording(stream) {
this.recorder = RecordRTC(stream, this.optionsRecorder);
this.recorder.onStateChanged = this.onStateRecorderChanged;
this.recorder.startRecording();
},
stopAudioRecording() {
if (this.isRecording) {
this.recorder.stopRecording(() => {
this.wavesurfer.microphone.stopDevice();
this.wavesurfer.loadBlob(this.recorder.getBlob());
this.wavesurfer.stop();
this.fireRecorderBlob(this.getAudioFile());
});
}
},
getAudioFile() {
if (this.hasAudio()) {
return new File([this.recorder.getBlob()], this.getAudioFileName(), {
type: 'audio/wav',
});
}
return false;
},
hasAudio() {
return !(this.isRecording || this.wavesurfer.isPlaying());
},
playingRecorder() {
this.fireStateRecorderChanged('playing');
},
pausedRecorder() {
this.fireStateRecorderChanged('paused');
},
deviceError(err) {
if (
err?.name &&
(err.name.toLowerCase().includes('notallowederror') ||
err.name.toLowerCase().includes('permissiondeniederror'))
) {
this.showAlert(
this.$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_PERMISSION')
);
this.fireStateRecorderChanged('notallowederror');
} else {
this.showAlert(
this.$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ERROR')
);
}
},
onStateRecorderChanged(state) {
// recording stopped inactive destroyed
switch (state) {
case 'recording':
this.timerDurationChanged();
break;
case 'stopped':
this.timerDurationChanged();
break;
default:
break;
}
this.fireStateRecorderChanged(state);
},
timerDurationChanged() {
if (this.isRecording) {
this.recordingInterval = setInterval(() => {
this.calculateTimeDuration(
(new Date().getTime() - this.recordingDateStarted) / 1000
);
this.fireStateRecorderTimerChanged(this.timeDuration);
}, 1000);
} else {
clearInterval(this.recordingInterval);
}
},
calculateTimeDuration(secs) {
let hr = Math.floor(secs / 3600);
let min = Math.floor((secs - hr * 3600) / 60);
let sec = Math.floor(secs - hr * 3600 - min * 60);
if (min < 10) {
min = '0' + min;
}
if (sec < 10) {
sec = '0' + sec;
}
if (hr <= 0) {
this.timeDuration = min + ':' + sec;
} else {
if (hr < 10) {
hr = '0' + hr;
}
this.timeDuration = hr + ':' + min + ':' + sec;
}
},
playPause() {
this.wavesurfer.playPause();
},
fireRecorderBlob(blob) {
this.$emit('recorder-blob', {
name: blob.name,
type: blob.type,
size: blob.size,
file: blob,
});
},
fireStateRecorderChanged(state) {
this.$emit('state-recorder-changed', state);
},
fireStateRecorderTimerChanged(duration) {
this.$emit('state-recorder-timer-changed', duration);
},
getAudioFileName() {
const d = new Date();
return `audio-${d.getFullYear()}-${d.getMonth()}-${d.getDate()}-${this.getRandomString()}.wav`;
},
getRandomString() {
if (
window.crypto &&
window.crypto.getRandomValues &&
navigator.userAgent.indexOf('Safari') === -1
) {
let a = window.crypto.getRandomValues(new Uint32Array(3));
let token = '';
for (let i = 0, l = a.length; i < l; i += 1) {
token += a[i].toString(36);
}
return token.toLowerCase();
}
return (Math.random() * new Date().getTime())
.toString(36)
.replace(/\./g, '');
},
},
};
</script>
<style lang="scss">
.audio-wave-wrapper {
min-height: 8rem;
max-height: 12rem;
overflow: hidden;
}
</style>

View file

@ -186,6 +186,12 @@ export default {
blur: () => {
this.onBlur();
},
paste: (view, event) => {
const data = event.clipboardData.files;
if (data.length > 0) {
event.preventDefault();
}
},
},
});
this.focusEditorInputField();

View file

@ -11,7 +11,6 @@
size="small"
@click="toggleEmojiPicker"
/>
<!-- ensure the same validations for attachment types are implemented in backend models as well -->
<file-upload
ref="upload"
@ -49,6 +48,27 @@
:title="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
@click="toggleFormatMode"
/>
<woot-button
v-if="showAudioRecorderButton"
:icon="!isRecordingAudio ? 'microphone' : 'microphone-off'"
emoji="🎤"
:color-scheme="!isRecordingAudio ? 'secondary' : 'alert'"
variant="smooth"
size="small"
:title="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
@click="toggleAudioRecorder"
/>
<woot-button
v-if="showAudioPlayStopButton"
:icon="audioRecorderPlayStopIcon"
emoji="🎤"
color-scheme="secondary"
variant="smooth"
size="small"
@click="toggleAudioRecorderPlayPause"
>
<span>{{ recordingAudioDurationText }}</span>
</woot-button>
<woot-button
v-if="showMessageSignatureButton"
v-tooltip.top-end="signatureToggleTooltip"
@ -126,6 +146,10 @@ export default {
type: String,
default: '',
},
recordingAudioDurationText: {
type: String,
default: '',
},
inbox: {
type: Object,
default: () => ({}),
@ -134,6 +158,10 @@ export default {
type: Boolean,
default: false,
},
showAudioRecorder: {
type: Boolean,
default: false,
},
onFileUpload: {
type: Function,
default: () => {},
@ -146,6 +174,22 @@ export default {
type: Function,
default: () => {},
},
toggleAudioRecorder: {
type: Function,
default: () => {},
},
toggleAudioRecorderPlayPause: {
type: Function,
default: () => {},
},
isRecordingAudio: {
type: Boolean,
default: false,
},
recordingAudioState: {
type: String,
default: '',
},
isSendDisabled: {
type: Boolean,
default: false,
@ -192,9 +236,28 @@ export default {
showAttachButton() {
return this.showFileUpload || this.isNote;
},
showAudioRecorderButton() {
return this.showAudioRecorder;
},
showAudioPlayStopButton() {
return this.showAudioRecorder && this.isRecordingAudio;
},
allowedFileTypes() {
return ALLOWED_FILE_TYPES;
},
audioRecorderPlayStopIcon() {
switch (this.recordingAudioState) {
// playing paused recording stopped inactive destroyed
case 'playing':
return 'microphone-pause';
case 'paused':
return 'microphone-play';
case 'stopped':
return 'microphone-play';
default:
return 'microphone-stop';
}
},
showMessageSignatureButton() {
return !this.isPrivate && this.isAnEmailChannel;
},

View file

@ -10,7 +10,12 @@
:key="i"
v-model="appliedFilters[i]"
:filter-groups="filterGroups"
:input-type="getInputType(appliedFilters[i].attribute_key)"
:input-type="
getInputType(
appliedFilters[i].attribute_key,
appliedFilters[i].filter_operator
)
"
:operators="getOperators(appliedFilters[i].attribute_key)"
:dropdown-values="getDropdownValues(appliedFilters[i].attribute_key)"
:show-query-operator="i !== appliedFilters.length - 1"
@ -56,6 +61,7 @@ import { mapGetters } from 'vuex';
import { filterAttributeGroups } from './advancedFilterItems';
import filterMixin from 'shared/mixins/filterMixin';
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
export default {
components: {
FilterInputBox,
@ -76,6 +82,12 @@ export default {
required,
$each: {
values: {
ensureBetween0to999(value, prop) {
if (prop.filter_operator === 'days_before') {
return parseInt(value, 10) > 0 && parseInt(value, 10) < 999;
}
return true;
},
required: requiredIf(prop => {
return !(
prop.filter_operator === 'is_present' ||
@ -141,6 +153,12 @@ export default {
switch (key) {
case 'date':
return 'date';
case 'text':
return 'plain_text';
case 'list':
return 'search_select';
case 'checkbox':
return 'search_select';
default:
return 'plain_text';
}
@ -149,7 +167,9 @@ export default {
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.attributeModel;
},
getInputType(key) {
getInputType(key, operator) {
if (key === 'created_at' || key === 'last_activity_at')
if (operator === 'days_before') return 'plain_text';
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.inputType;
},
@ -159,6 +179,47 @@ export default {
},
getDropdownValues(type) {
const statusFilters = this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS');
const allCustomAttributes = this.$store.getters[
'attributes/getAttributesByModel'
](this.attributeModel);
const isCustomAttributeCheckbox = allCustomAttributes.find(attr => {
return (
attr.attribute_key === type &&
attr.attribute_display_type === 'checkbox'
);
});
if (isCustomAttributeCheckbox) {
return [
{
id: true,
name: this.$t('FILTER.ATTRIBUTE_LABELS.TRUE'),
},
{
id: false,
name: this.$t('FILTER.ATTRIBUTE_LABELS.FALSE'),
},
];
}
const isCustomAttributeList = allCustomAttributes.find(attr => {
return (
attr.attribute_key === type && attr.attribute_display_type === 'list'
);
});
if (isCustomAttributeList) {
return allCustomAttributes
.find(attr => attr.attribute_key === type)
.attribute_values.map(item => {
return {
id: item,
name: item,
};
});
}
switch (type) {
case 'status':
return [

View file

@ -100,7 +100,8 @@ export default {
display: flex;
background: var(--color-background-light);
margin: 0;
height: calc(100vh - var(--space-jumbo));
height: 100%;
min-height: 0;
}
.conversation-sidebar-wrap {

View file

@ -50,7 +50,10 @@
<bubble-actions
:id="data.id"
:sender="data.sender"
:story-sender="storySender"
:story-id="storyId"
:is-a-tweet="isATweet"
:has-instagram-story="hasInstagramStory"
:is-email="isEmailContentType"
:is-private="data.private"
:message-type="data.message_type"
@ -146,6 +149,10 @@ export default {
type: Boolean,
default: false,
},
hasInstagramStory: {
type: Boolean,
default: false,
},
},
data() {
return {
@ -209,6 +216,12 @@ export default {
sender() {
return this.data.sender || {};
},
storySender() {
return this.contentAttributes.story_sender || null;
},
storyId() {
return this.contentAttributes.story_id || null;
},
contentType() {
const {
data: { content_type: contentType },

View file

@ -47,6 +47,7 @@
class="message--read"
:data="message"
:is-a-tweet="isATweet"
:has-instagram-story="hasInstagramStory"
/>
<li v-show="getUnreadCount != 0" class="unread--toast">
<span class="text-uppercase">
@ -64,6 +65,7 @@
class="message--unread"
:data="message"
:is-a-tweet="isATweet"
:has-instagram-story="hasInstagramStory"
/>
</ul>
<div
@ -215,6 +217,10 @@ export default {
return this.conversationType === 'tweet';
},
hasInstagramStory() {
return this.conversationType === 'instagram_direct_message';
},
selectedTweet() {
if (this.selectedTweetId) {
const { messages = [] } = this.getMessages;

View file

@ -33,8 +33,15 @@
:cc-emails.sync="ccEmails"
:bcc-emails.sync="bccEmails"
/>
<woot-audio-recorder
v-if="showAudioRecorderEditor"
ref="audioRecorderInput"
@state-recorder-timer-changed="onStateRecorderTimerChanged"
@state-recorder-changed="onStateRecorderChanged"
@recorder-blob="onRecorderBlob"
/>
<resizable-text-area
v-if="!showRichContentEditor"
v-else-if="!showRichContentEditor"
ref="messageInput"
v-model="message"
class="input"
@ -89,10 +96,16 @@
:send-button-text="replyButtonLabel"
:on-file-upload="onFileUpload"
:show-file-upload="showFileUpload"
:show-audio-recorder="showAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker"
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
:show-emoji-picker="showEmojiPicker"
:on-send="sendMessage"
:is-send-disabled="isReplyButtonDisabled"
:recording-audio-duration-text="recordingAudioDuration"
:recording-audio-state="recordingAudioState"
:is-recording-audio="isRecordingAudio"
:set-format-mode="setFormatMode"
:is-on-private-note="isOnPrivateNote"
:is-format-mode="showRichContentEditor"
@ -119,6 +132,7 @@ import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBotto
import Banner from 'dashboard/components/ui/Banner.vue';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
import WootAudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages';
@ -146,6 +160,7 @@ export default {
ReplyEmailHead,
ReplyBottomPanel,
WootMessageEditor,
WootAudioRecorder,
Banner,
},
mixins: [
@ -176,6 +191,9 @@ export default {
showEmojiPicker: false,
showMentions: false,
attachedFiles: [],
isRecordingAudio: false,
recordingAudioState: '',
recordingAudioDuration: '',
isUploading: false,
replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
@ -190,6 +208,7 @@ export default {
currentChat: 'getSelectedChat',
messageSignature: 'getMessageSignature',
currentUser: 'getCurrentUser',
lastEmail: 'getLastEmailInSelectedChat',
globalConfig: 'globalConfig/get',
accountId: 'getCurrentAccountId',
}),
@ -269,7 +288,7 @@ export default {
return true;
}
if (this.hasAttachments) return false;
if (this.hasAttachments || this.hasRecordedAudio) return false;
return (
this.isMessageEmpty ||
@ -292,7 +311,7 @@ export default {
if (this.isAWhatsappChannel) {
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
}
if (this.isATwilioSMSChannel) {
if (this.isASmsInbox) {
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
}
if (this.isATwitterInbox) {
@ -309,7 +328,7 @@ export default {
this.isAWhatsappChannel ||
this.isAPIInbox ||
this.isAnEmailChannel ||
this.isATwilioSMSChannel ||
this.isASmsInbox ||
this.isATelegramChannel
);
},
@ -331,9 +350,21 @@ export default {
hasAttachments() {
return this.attachedFiles.length;
},
hasRecordedAudio() {
return (
this.$refs.audioRecorderInput &&
this.$refs.audioRecorderInput.hasAudio()
);
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel;
},
showAudioRecorder() {
return !this.isOnPrivateNote && this.showFileUpload;
},
showAudioRecorderEditor() {
return this.showAudioRecorder && this.isRecordingAudio;
},
isOnPrivateNote() {
return this.replyType === REPLY_EDITOR_MODES.NOTE;
},
@ -388,6 +419,8 @@ export default {
} else {
this.replyType = REPLY_EDITOR_MODES.NOTE;
}
this.setCCEmailFromLastChat();
},
message(updatedMessage) {
this.hasSlashCommand =
@ -409,6 +442,8 @@ export default {
// working even if input/textarea is focussed.
document.addEventListener('keydown', this.handleKeyEvents);
document.addEventListener('paste', this.onPaste);
this.setCCEmailFromLastChat();
},
destroyed() {
document.removeEventListener('keydown', this.handleKeyEvents);
@ -417,12 +452,16 @@ export default {
methods: {
onPaste(e) {
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput.$el.blur();
}
if (!data.length || !data[0]) {
return;
}
const file = data[0];
data.forEach(file => {
const { name, type, size } = file;
this.onFileUpload({ name, type, size, file });
this.onFileUpload({ name, type, size, file: file });
});
},
toggleUserMention(currentMentionState) {
this.hasUserMention = currentMentionState;
@ -518,6 +557,9 @@ export default {
if (canReply || this.isAWhatsappChannel) this.replyType = mode;
if (this.showRichContentEditor) {
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
return;
}
this.$nextTick(() => this.$refs.messageInput.focus());
@ -530,10 +572,26 @@ export default {
this.attachedFiles = [];
this.ccEmails = '';
this.bccEmails = '';
this.isRecordingAudio = false;
},
toggleEmojiPicker() {
this.showEmojiPicker = !this.showEmojiPicker;
},
toggleAudioRecorder() {
this.isRecordingAudio = !this.isRecordingAudio;
this.isRecorderAudioStopped = !this.isRecordingAudio;
if (!this.isRecordingAudio) {
this.clearMessage();
}
},
toggleAudioRecorderPlayPause() {
if (this.isRecordingAudio && !this.isRecorderAudioStopped) {
this.isRecorderAudioStopped = true;
this.$refs.audioRecorderInput.stopAudioRecording();
} else if (this.isRecordingAudio && this.isRecorderAudioStopped) {
this.$refs.audioRecorderInput.playPause();
}
},
hideEmojiPicker() {
if (this.showEmojiPicker) {
this.toggleEmojiPicker();
@ -554,6 +612,20 @@ export default {
onFocus() {
this.isFocused = true;
},
onStateRecorderTimerChanged(time) {
this.recordingAudioDuration = time;
},
onStateRecorderChanged(state) {
this.recordingAudioState = state;
if (state.includes('notallowederror')) {
this.toggleAudioRecorder();
}
},
onRecorderBlob(file) {
if (file) {
this.onFileUpload(file);
}
},
toggleTyping(status) {
const conversationId = this.currentChat.id;
const isPrivate = this.isPrivate;
@ -577,10 +649,17 @@ export default {
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
const upload = new DirectUpload(
file.file,
'/rails/active_storage/direct_uploads',
null,
file.file.name
`/api/v1/accounts/${this.accountId}/conversations/${this.currentChat.id}/direct_uploads`,
{
directUploadWillCreateBlobWithXHR: xhr => {
xhr.setRequestHeader(
'api_access_token',
this.currentUser.access_token
);
},
}
);
upload.create((error, blob) => {
if (error) {
this.showAlert(error);
@ -650,11 +729,11 @@ export default {
});
}
if (this.ccEmails) {
if (this.ccEmails && !this.isOnPrivateNote) {
messagePayload.ccEmails = this.ccEmails;
}
if (this.bccEmails) {
if (this.bccEmails && !this.isOnPrivateNote) {
messagePayload.bccEmails = this.bccEmails;
}
@ -667,6 +746,17 @@ export default {
this.bccEmails = value.bccEmails;
this.ccEmails = value.ccEmails;
},
setCCEmailFromLastChat() {
if (this.lastEmail) {
const {
content_attributes: { email: emailAttributes = {} },
} = this.lastEmail;
const cc = emailAttributes.cc || [];
const bcc = emailAttributes.bcc || [];
this.ccEmails = cc.join(', ');
this.bccEmails = bcc.join(', ');
}
},
},
};
</script>
@ -684,6 +774,8 @@ export default {
justify-content: space-between;
border: 1px dashed var(--s-100);
border-radius: var(--border-radius-small);
max-height: 8vh;
overflow: auto;
&:hover {
background: var(--s-25);

View file

@ -83,6 +83,10 @@ export default {
}
},
},
mounted() {
this.ccEmailsVal = this.ccEmails;
this.bccEmailsVal = this.bccEmails;
},
validations: {
ccEmailsVal: {
hasValidEmails(value) {

View file

@ -0,0 +1,9 @@
import defaultFilters from './index';
import { filterAttributeGroups } from './index';
describe('#filterItems', () => {
it('Matches the correct filterItems', () => {
expect(defaultFilters).toMatchObject(defaultFilters);
expect(filterAttributeGroups).toMatchObject(filterAttributeGroups);
});
});

View file

@ -2,6 +2,7 @@ import {
OPERATOR_TYPES_1,
OPERATOR_TYPES_2,
OPERATOR_TYPES_3,
OPERATOR_TYPES_5,
} from '../../FilterInput/FilterOperatorTypes';
const filterTypes = [
@ -85,6 +86,30 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_3,
attributeModel: 'additional',
},
{
attributeKey: 'created_at',
attributeI18nKey: 'CREATED_AT',
inputType: 'date',
dataType: 'text',
filterOperators: OPERATOR_TYPES_5,
attributeModel: 'standard',
},
{
attributeKey: 'last_activity_at',
attributeI18nKey: 'LAST_ACTIVITY',
inputType: 'date',
dataType: 'text',
filterOperators: OPERATOR_TYPES_5,
attributeModel: 'standard',
},
{
attributeKey: 'referer',
attributeI18nKey: 'REFERER_LINK',
inputType: 'plain_text',
dataType: 'text',
filterOperators: OPERATOR_TYPES_5,
attributeModel: 'standard',
},
];
export const filterAttributeGroups = [
@ -120,6 +145,14 @@ export const filterAttributeGroups = [
key: 'labels',
i18nKey: 'LABELS',
},
{
key: 'created_at',
i18nKey: 'CREATED_AT',
},
{
key: 'last_activity_at',
i18nKey: 'LAST_ACTIVITY',
},
],
},
{

View file

@ -35,6 +35,19 @@
size="16"
/>
</button>
<a
v-if="hasInstagramStory && (isIncoming || isOutgoing) && linkToStory"
:href="linkToStory"
target="_blank"
rel="noopener noreferrer nofollow"
>
<fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.LINK_TO_STORY')"
icon="open"
class="action--icon cursor-pointer"
size="16"
/>
</a>
<a
v-if="isATweet && (isOutgoing || isIncoming) && linkToTweet"
:href="linkToTweet"
@ -67,6 +80,14 @@ export default {
type: String,
default: '',
},
storySender: {
type: String,
default: '',
},
storyId: {
type: String,
default: '',
},
isEmail: {
type: Boolean,
default: true,
@ -79,6 +100,10 @@ export default {
type: Boolean,
default: true,
},
hasInstagramStory: {
type: Boolean,
default: true,
},
messageType: {
type: Number,
default: 1,
@ -119,6 +144,13 @@ export default {
return `https://twitter.com/${screenName ||
this.inbox.name}/status/${sourceId}`;
},
linkToStory() {
if (!this.storyId || !this.storySender) {
return '';
}
const { storySender, storyId } = this;
return `https://www.instagram.com/stories/${storySender}/${storyId}`;
},
showSentIndicator() {
return this.isOutgoing && this.sourceId && this.isAnEmailChannel;
},

View file

@ -70,7 +70,7 @@ export default {
return this.emailAttributes.subject || '';
},
showHead() {
return this.toMails || this.ccMails || this.bccMails;
return this.toMails || this.ccMails || this.bccMails || this.fromMail;
},
},
};

View file

@ -0,0 +1,75 @@
<template>
<modal :show.sync="show" :on-close="cancel">
<div class="column content-box">
<woot-modal-header :header-title="title"> </woot-modal-header>
<div class="row modal-content">
<div class="medium-12 columns">
<p>
{{ description }}
</p>
</div>
<div class="modal-footer">
<div class="medium-12 columns">
<woot-button @click="confirm">
{{ confirmLabel }}
</woot-button>
<button class="button clear" @click="cancel">
{{ cancelLabel }}
</button>
</div>
</div>
</div>
</div>
</modal>
</template>
<script>
import Modal from '../../Modal';
export default {
components: {
Modal,
},
props: {
title: {
type: String,
default: 'This is a title',
},
description: {
type: String,
default: 'This is your description',
},
confirmLabel: {
type: String,
default: 'Yes',
},
cancelLabel: {
type: String,
default: 'No',
},
},
data: () => ({
show: false,
resolvePromise: undefined,
rejectPromise: undefined,
}),
methods: {
showConfirmation() {
this.show = true;
return new Promise((resolve, reject) => {
this.resolvePromise = resolve;
this.rejectPromise = reject;
});
},
confirm() {
this.resolvePromise(true);
this.show = false;
},
cancel() {
this.resolvePromise(false);
this.show = false;
},
},
};
</script>

View file

@ -1,10 +1,22 @@
import queryString from 'query-string';
import { DEFAULT_REDIRECT_URL } from '../constants';
export const frontendURL = (path, params) => {
const stringifiedParams = params ? `?${queryString.stringify(params)}` : '';
return `/app/${path}${stringifiedParams}`;
};
export const getLoginRedirectURL = (ssoAccountId, user) => {
const { accounts = [] } = user || {};
const ssoAccount = accounts.find(
account => account.id === Number(ssoAccountId)
);
if (ssoAccount) {
return frontendURL(`accounts/${ssoAccountId}/dashboard`);
}
return DEFAULT_REDIRECT_URL;
};
export const conversationUrl = ({
accountId,
activeInbox,

View file

@ -0,0 +1,17 @@
class LocalStorage {
constructor(key) {
this.key = key;
}
store(allItems) {
localStorage.setItem(this.key, JSON.stringify(allItems));
localStorage.setItem(this.key + ':ts', Date.now());
}
get() {
let stored = localStorage.getItem(this.key);
return JSON.parse(stored) || [];
}
}
export default LocalStorage;

View file

@ -3,6 +3,7 @@ import {
conversationUrl,
accountIdFromPathname,
isValidURL,
getLoginRedirectURL,
} from '../URLHelper';
describe('#URL Helpers', () => {
@ -58,4 +59,24 @@ describe('#URL Helpers', () => {
expect(isValidURL('alert.window')).toBe(false);
});
});
describe('getLoginRedirectURL', () => {
it('should return correct Account URL if account id is present', () => {
expect(
getLoginRedirectURL('7500', {
accounts: [{ id: 7500, name: 'Test Account 7500' }],
})
).toBe('/app/accounts/7500/dashboard');
});
it('should return default URL if account id is not present', () => {
expect(getLoginRedirectURL('7500', {})).toBe('/app/');
expect(
getLoginRedirectURL('7500', {
accounts: [{ id: '7501', name: 'Test Account 7501' }],
})
).toBe('/app/');
expect(getLoginRedirectURL('7500', null)).toBe('/app/');
});
});
});

View file

@ -21,7 +21,12 @@
"is_present": "موجود",
"is_not_present": "غير موجود",
"is_greater_than": "هو أكبر من",
"is_lesser_than": "هو أقل من"
"is_less_than": "هو أقل من",
"days_before": "قبل x أيام"
},
"ATTRIBUTE_LABELS": {
"TRUE": "صحيح",
"FALSE": "خاطئ"
},
"ATTRIBUTES": {
"STATUS": "الحالة",
@ -38,7 +43,9 @@
"CUSTOM_ATTRIBUTE_TEXT": "النص",
"CUSTOM_ATTRIBUTE_NUMBER": "العدد",
"CUSTOM_ATTRIBUTE_LINK": "الرابط",
"CUSTOM_ATTRIBUTE_CHECKBOX": "مربع"
"CUSTOM_ATTRIBUTE_CHECKBOX": "مربع",
"CREATED_AT": "تم إنشاؤها في",
"LAST_ACTIVITY": "آخر نشاط"
},
"GROUPS": {
"STANDARD_FILTERS": "الفلاتر القياسية",

View file

@ -64,7 +64,7 @@
},
"EDIT": {
"TITLE": "تعديل قاعدة الأتمتة",
"SUBMIT": عديل",
"SUBMIT": حديث",
"CANCEL_BUTTON_TEXT": "إلغاء",
"API": {
"SUCCESS_MESSAGE": "تم تحديث قاعدة الأتمتة بنجاح",
@ -84,6 +84,24 @@
"DELETE": "حذف",
"CANCEL": "إلغاء",
"RESET_MESSAGE": "تغيير نوع الحدث سوف يعيد تعيين الشروط والأحداث التي أضفتها أدناه"
},
"CONDITION": {
"DELETE_MESSAGE": "يجب أن يكون لديك على الأقل شرط واحد للحفظ"
},
"ACTION": {
"DELETE_MESSAGE": "يجب أن يكون لديك على الأقل شرط واحد للحفظ"
},
"TOGGLE": {
"ACTIVATION_TITLE": "تفعيل قاعدة الأتمتة",
"DEACTIVATION_TITLE": "تغطيل قاعدة الأتمتة",
"ACTIVATION_DESCRIPTION": "سيؤدي هذا الإجراء إلى تنشيط قاعدة الأتمتة '{automationName}'. هل أنت متأكد من أنك تريد المتابعة؟",
"DEACTIVATION_DESCRIPTION": "سيؤدي هذا الإجراء إلى إلغاء تنشيط قاعدة الأتمتة '{automationName}'. هل أنت متأكد من أنك تريد المتابعة؟",
"ACTIVATION_SUCCESFUL": "تم تفعيل قاعدة الأتمتة بنجاح",
"DEACTIVATION_SUCCESFUL": "تم تعطيل قاعدة الأتمتة بنجاح",
"ACTIVATION_ERROR": "تعذر تنشيط قاعدة الأتمتة، الرجاء المحاولة مرة أخرى لاحقاً",
"DEACTIVATION_ERROR": "تعذر إلغاء تنشيط قاعدة الأتمتة، الرجاء المحاولة مرة أخرى لاحقاً",
"CONFIRMATION_LABEL": "نعم",
"CANCEL_LABEL": "لا"
}
}
}

View file

@ -76,6 +76,7 @@
"RECEIVED_VIA_EMAIL": "تم تلقيه عبر البريد الإلكتروني",
"VIEW_TWEET_IN_TWITTER": "عرض التغريدة في تويتر",
"REPLY_TO_TWEET": "الرد على هذه التغريدة",
"LINK_TO_STORY": "الذهاب إلى قصة الإنستقرام",
"SENT": "Sent successfully",
"NO_MESSAGES": "لا توجد رسائل",
"NO_CONTENT": "لم يتم العثور على محتوى",

View file

@ -77,9 +77,8 @@
"CONFIRM": {
"TITLE": "تأكيد الحذف",
"MESSAGE": "هل أنت متأكد من الحذف ",
"PLACE_HOLDER": "الرجاء كتابة {contactName} للتأكيد",
"YES": "نعم، احذف ",
"NO": "لا، احتفظ "
"YES": "نعم، احذف",
"NO": "لا، احتفظ"
},
"API": {
"SUCCESS_MESSAGE": "تم حذف جهة الاتصال بنجاح",

View file

@ -22,7 +22,8 @@
"is_present": "موجود",
"is_not_present": "غير موجود",
"is_greater_than": "هو أكبر من",
"is_lesser_than": "هو أقل من"
"is_lesser_than": "هو أقل من",
"days_before": "قبل x أيام"
},
"ATTRIBUTES": {
"NAME": "الاسم",
@ -35,7 +36,9 @@
"CUSTOM_ATTRIBUTE_TEXT": "النص",
"CUSTOM_ATTRIBUTE_NUMBER": "العدد",
"CUSTOM_ATTRIBUTE_LINK": "الرابط",
"CUSTOM_ATTRIBUTE_CHECKBOX": "مربع"
"CUSTOM_ATTRIBUTE_CHECKBOX": "مربع",
"CREATED_AT": "تم إنشاؤها في",
"LAST_ACTIVITY": "آخر نشاط"
},
"GROUPS": {
"STANDARD_FILTERS": "الفلاتر القياسية",

View file

@ -57,11 +57,13 @@
}
},
"FOOTER": {
"MESSAGE_SIGN_TOOLTIP": "Message signature",
"ENABLE_SIGN_TOOLTIP": "Enable signature",
"DISABLE_SIGN_TOOLTIP": "Disable signature",
"MESSAGE_SIGN_TOOLTIP": "توقيع الرسالة",
"ENABLE_SIGN_TOOLTIP": "تمكين التوقيع",
"DISABLE_SIGN_TOOLTIP": "تعطيل التوقيع",
"MSG_INPUT": "زر Shift + Enter لإضافة سطر جديد. ابدأ بزر / للاختيار من الردود السريعة.",
"PRIVATE_MSG_INPUT": "زر Shift + Enter لإضافة سطر جديد. سيكون هذا مرئياً للموظفين فقط"
"PRIVATE_MSG_INPUT": "زر Shift + Enter لإضافة سطر جديد. سيكون هذا مرئياً للموظفين فقط",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "لم يتم تكوين توقيع الرسالة، الرجاء تكوينه في إعدادات الملف الشخصي.",
"CLICK_HERE": "انقر هنا للتحديث"
},
"REPLYBOX": {
"REPLY": "إضافة رد",
@ -72,8 +74,14 @@
"TIP_FORMAT_ICON": "عرض محرر النصوص",
"TIP_EMOJI_ICON": "إظهار قائمة الرموز التعبيرية",
"TIP_ATTACH_ICON": "إرفاق الملفات",
"TIP_AUDIORECORDER_ICON": "تسجيل الصوت",
"TIP_AUDIORECORDER_PERMISSION": "السماح بالوصول إلى الصوت",
"TIP_AUDIORECORDER_ERROR": "تعذر فتح الصوت",
"ENTER_TO_SEND": "زر الإدخل للإرسال",
"DRAG_DROP": "اسحب و أسقط هنا للإرفاق",
"START_AUDIO_RECORDING": "بدء التسجيل الصوتي",
"STOP_AUDIO_RECORDING": "إيقاف التسجيل الصوتي",
"": "",
"EMAIL_HEAD": {
"ADD_BCC": "إضافة bcc",
"CC": {

View file

@ -47,7 +47,8 @@
"CUSTOM_EMAIL_DOMAIN_ENABLED": "يمكنك تلقي رسائل البريد الإلكتروني في النطاق المخصص الخاص بك الآن."
}
},
"UPDATE_CHATWOOT": "يتوفر تحديث %{latestChatwootVersion} لـ Chatwoot. الرجاء التحديث."
"UPDATE_CHATWOOT": "يتوفر تحديث %{latestChatwootVersion} لـ Chatwoot. الرجاء التحديث.",
"LEARN_MORE": "اعرف المزيد"
},
"FORMS": {
"MULTISELECT": {

View file

@ -462,7 +462,8 @@
"HOURS": "ساعات",
"VALIDATION_ERROR": "يجب أن يكون وقت البدء قبل وقت الإغلاق.",
"CHOOSE": "اختر"
}
},
"ALL_DAY": "جميع الأيام"
},
"IMAP": {
"TITLE": "IMAP",

View file

@ -59,7 +59,56 @@
"CUSTOM_DATE_RANGE": {
"CONFIRM": "تطبيق",
"PLACEHOLDER": "اختر نطاق المدة"
},
"GROUP_BY_FILTER_DROPDOWN_LABEL": "تجميع بواسطة",
"GROUP_BY_DAY_OPTIONS": [
{
"id": 1,
"groupBy": "اليوم"
}
],
"GROUP_BY_WEEK_OPTIONS": [
{
"id": 1,
"groupBy": "اليوم"
},
{
"id": 2,
"groupBy": "الأسبوع"
}
],
"GROUP_BY_MONTH_OPTIONS": [
{
"id": 1,
"groupBy": "اليوم"
},
{
"id": 2,
"groupBy": "الأسبوع"
},
{
"id": 3,
"groupBy": "الشهر"
}
],
"GROUP_BY_YEAR_OPTIONS": [
{
"id": 1,
"groupBy": "اليوم"
},
{
"id": 2,
"groupBy": "الأسبوع"
},
{
"id": 3,
"groupBy": "الشهر"
},
{
"id": 4,
"groupBy": "السنة"
}
]
},
"AGENT_REPORTS": {
"HEADER": "نظرة عامة للوكلاء",
@ -316,6 +365,11 @@
"CSAT_REPORTS": {
"HEADER": "تقارير CSAT",
"NO_RECORDS": "لا توجد ردود متوفرة على الدراسة الاستقصائية CSAT.",
"FILTERS": {
"AGENTS": {
"PLACEHOLDER": "اختر الوكلاء"
}
},
"TABLE": {
"HEADER": {
"CONTACT_NAME": "جهات الاتصال",

View file

@ -20,16 +20,16 @@
"NOTE": "عنوان بريدك الإلكتروني هو المعرف الخاص بك الذي ستستخدمه لتسجيل الدخول."
},
"MESSAGE_SIGNATURE_SECTION": {
"TITLE": "Personal message signature",
"NOTE": "Create a personal message signature that would be added to all the messages you send from the platform. Use the rich content editor to create a highly personalised signature.",
"BTN_TEXT": "Save message signature",
"API_ERROR": "Couldn't save signature! Try again",
"API_SUCCESS": "Signature saved successfully"
"TITLE": "توقيع الرسالة الشخصية",
"NOTE": "إنشاء توقيع رسالة شخصية يتم إضافتها إلى جميع الرسائل التي ترسلها من المنصة. استخدم محرر المحتوى الغني لإنشاء توقيع شديد التخصيص.",
"BTN_TEXT": "حفظ توقيع الرسالة",
"API_ERROR": "تعذر إرسال الرسالة! حاول مرة أخرى",
"API_SUCCESS": "تم حفظ التوقيع بنجاح"
},
"MESSAGE_SIGNATURE": {
"LABEL": "Message Signature",
"ERROR": "Message Signature cannot be empty",
"PLACEHOLDER": "Insert your personal message signature here."
"LABEL": "توقيع الرسالة",
"ERROR": "توقيع الرسالة لا يمكن أن يكون فارغاً",
"PLACEHOLDER": "أدخل توقيع رسالتك الشخصية هنا."
},
"PASSWORD_SECTION": {
"TITLE": "كلمة المرور",
@ -146,6 +146,7 @@
}
},
"SIDEBAR": {
"CURRENTLY_VIEWING_ACCOUNT": "مشاهدة حاليا:",
"CONVERSATIONS": "المحادثات",
"ALL_CONVERSATIONS": "كل المحادثات",
"MENTIONED_CONVERSATIONS": "الإشارات",
@ -181,7 +182,8 @@
"REPORTS_LABEL": "الوسوم",
"REPORTS_INBOX": "صندوق الوارد",
"REPORTS_TEAM": "الفريق",
"SET_AVAILABILITY_TITLE": "تعيين نفسك كـ"
"SET_AVAILABILITY_TITLE": "تعيين نفسك كـ",
"BETA": "تجريبي"
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "أوه! لم نتمكن من العثور على الحساب. الرجاء إنشاء حساب جديد للمتابعة.",

View file

@ -21,7 +21,12 @@
"is_present": "Присъства",
"is_not_present": "Не присъства",
"is_greater_than": "Is greater than",
"is_lesser_than": "Is lesser than"
"is_less_than": "Is lesser than",
"days_before": "Is x days before"
},
"ATTRIBUTE_LABELS": {
"TRUE": "True",
"FALSE": "False"
},
"ATTRIBUTES": {
"STATUS": "Статус",
@ -38,7 +43,9 @@
"CUSTOM_ATTRIBUTE_TEXT": "Text",
"CUSTOM_ATTRIBUTE_NUMBER": "Number",
"CUSTOM_ATTRIBUTE_LINK": "Link",
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox"
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
"CREATED_AT": "Created At",
"LAST_ACTIVITY": "Последна активност"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard Filters",

View file

@ -1,6 +1,6 @@
{
"AUTOMATION": {
"HEADER": "Автоматизация",
"HEADER": "Automations",
"HEADER_BTN_TXT": "Добавяне правило за автоматизация",
"LOADING": "Fetching automation rules",
"SIDEBAR_TXT": "<p><b>Automation Rules</b> <p>Automation can replace and automate existing processes that require manual effort. You can do many things with automation, including adding labels and assigning conversation to the best agent. So the team focuses on what they do best and spends more little time on manual tasks.</p>",
@ -64,7 +64,7 @@
},
"EDIT": {
"TITLE": "Edit Automation Rule",
"SUBMIT": "Редактирай",
"SUBMIT": "Обновяване",
"CANCEL_BUTTON_TEXT": "Отмени",
"API": {
"SUCCESS_MESSAGE": "Automation rule updated successfully",
@ -84,6 +84,24 @@
"DELETE": "Изтрий",
"CANCEL": "Отмени",
"RESET_MESSAGE": "Changing event type will reset the conditions and events you have added below"
},
"CONDITION": {
"DELETE_MESSAGE": "You need to have atleast one condition to save"
},
"ACTION": {
"DELETE_MESSAGE": "You need to have atleast one action to save"
},
"TOGGLE": {
"ACTIVATION_TITLE": "Activate Automation Rule",
"DEACTIVATION_TITLE": "Deactivate Automation Rule",
"ACTIVATION_DESCRIPTION": "This action will activate the automation rule '{automationName}'. Are you sure you want to proceed?",
"DEACTIVATION_DESCRIPTION": "This action will deactivate the automation rule '{automationName}'. Are you sure you want to proceed?",
"ACTIVATION_SUCCESFUL": "Automation Rule Activated Successfully",
"DEACTIVATION_SUCCESFUL": "Automation Rule Deactivated Successfully",
"ACTIVATION_ERROR": "Could not Activate Automation, Please try again later",
"DEACTIVATION_ERROR": "Could not Deactivate Automation, Please try again later",
"CONFIRMATION_LABEL": "Yes",
"CANCEL_LABEL": "No"
}
}
}

View file

@ -76,6 +76,7 @@
"RECEIVED_VIA_EMAIL": "Получено чрез имейл",
"VIEW_TWEET_IN_TWITTER": "Виж туита в Twitter",
"REPLY_TO_TWEET": "Отговори на този туит",
"LINK_TO_STORY": "Go to instagram story",
"SENT": "Успено изпратено",
"NO_MESSAGES": "Няма съобщения",
"NO_CONTENT": "Няма налично съдържание",

View file

@ -77,9 +77,8 @@
"CONFIRM": {
"TITLE": "Потвърди изтриването",
"MESSAGE": "Сигурни ли сте за изтриването ",
"PLACE_HOLDER": "Моля, въведете {contactName} за потвърждение",
"YES": "Да, изтрий ",
"NO": "Не, запази "
"YES": "Да, изтрий",
"NO": "Не, запази"
},
"API": {
"SUCCESS_MESSAGE": "Контакта е изтрит успешно",

View file

@ -22,7 +22,8 @@
"is_present": "Присъства",
"is_not_present": "Не присъства",
"is_greater_than": "Is greater than",
"is_lesser_than": "Is lesser than"
"is_lesser_than": "Is lesser than",
"days_before": "Is x days before"
},
"ATTRIBUTES": {
"NAME": "Име",
@ -35,7 +36,9 @@
"CUSTOM_ATTRIBUTE_TEXT": "Text",
"CUSTOM_ATTRIBUTE_NUMBER": "Number",
"CUSTOM_ATTRIBUTE_LINK": "Link",
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox"
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
"CREATED_AT": "Created At",
"LAST_ACTIVITY": "Последна активност"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard Filters",

View file

@ -61,7 +61,9 @@
"ENABLE_SIGN_TOOLTIP": "Enable signature",
"DISABLE_SIGN_TOOLTIP": "Disable signature",
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents"
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
"CLICK_HERE": "Click here to update"
},
"REPLYBOX": {
"REPLY": "Reply",
@ -72,8 +74,14 @@
"TIP_FORMAT_ICON": "Show rich text editor",
"TIP_EMOJI_ICON": "Show emoji selector",
"TIP_ATTACH_ICON": "Attach files",
"TIP_AUDIORECORDER_ICON": "Record audio",
"TIP_AUDIORECORDER_PERMISSION": "Allow access to audio",
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
"ENTER_TO_SEND": "Enter to send",
"DRAG_DROP": "Drag and drop here to attach",
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",
"": "",
"EMAIL_HEAD": {
"ADD_BCC": "Add bcc",
"CC": {

View file

@ -47,7 +47,8 @@
"CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now."
}
},
"UPDATE_CHATWOOT": "An update %{latestChatwootVersion} for Chatwoot is available. Please update your instance."
"UPDATE_CHATWOOT": "An update %{latestChatwootVersion} for Chatwoot is available. Please update your instance.",
"LEARN_MORE": "Learn more"
},
"FORMS": {
"MULTISELECT": {

View file

@ -462,7 +462,8 @@
"HOURS": "hours",
"VALIDATION_ERROR": "Starting time should be before closing time.",
"CHOOSE": "Choose"
}
},
"ALL_DAY": "All-Day"
},
"IMAP": {
"TITLE": "IMAP",

View file

@ -59,7 +59,56 @@
"CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply",
"PLACEHOLDER": "Select date range"
},
"GROUP_BY_FILTER_DROPDOWN_LABEL": "Group By",
"GROUP_BY_DAY_OPTIONS": [
{
"id": 1,
"groupBy": "Day"
}
],
"GROUP_BY_WEEK_OPTIONS": [
{
"id": 1,
"groupBy": "Day"
},
{
"id": 2,
"groupBy": "Week"
}
],
"GROUP_BY_MONTH_OPTIONS": [
{
"id": 1,
"groupBy": "Day"
},
{
"id": 2,
"groupBy": "Week"
},
{
"id": 3,
"groupBy": "Month"
}
],
"GROUP_BY_YEAR_OPTIONS": [
{
"id": 1,
"groupBy": "Day"
},
{
"id": 2,
"groupBy": "Week"
},
{
"id": 3,
"groupBy": "Month"
},
{
"id": 4,
"groupBy": "Year"
}
]
},
"AGENT_REPORTS": {
"HEADER": "Agents Overview",
@ -316,6 +365,11 @@
"CSAT_REPORTS": {
"HEADER": "CSAT Reports",
"NO_RECORDS": "There are no CSAT survey responses available.",
"FILTERS": {
"AGENTS": {
"PLACEHOLDER": "Choose Agents"
}
},
"TABLE": {
"HEADER": {
"CONTACT_NAME": "Contact",

View file

@ -146,6 +146,7 @@
}
},
"SIDEBAR": {
"CURRENTLY_VIEWING_ACCOUNT": "Currently viewing:",
"CONVERSATIONS": "Разговори",
"ALL_CONVERSATIONS": "All Conversations",
"MENTIONED_CONVERSATIONS": "Споменавания",
@ -181,7 +182,8 @@
"REPORTS_LABEL": "Labels",
"REPORTS_INBOX": "Входяща кутия",
"REPORTS_TEAM": "Team",
"SET_AVAILABILITY_TITLE": "Set yourself as"
"SET_AVAILABILITY_TITLE": "Set yourself as",
"BETA": "Beta"
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",

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