Merge branch 'release/3.2.0'

This commit is contained in:
Sojan 2023-10-17 17:55:34 -07:00
commit 83231b9678
797 changed files with 17508 additions and 4788 deletions

View File

@ -199,7 +199,7 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
## Rack Attack configuration ## Rack Attack configuration
## To prevent and throttle abusive requests ## To prevent and throttle abusive requests
# ENABLE_RACK_ATTACK=true # ENABLE_RACK_ATTACK=true
# RACK_ATTACK_IP_LIMIT=3000 # RACK_ATTACK_LIMIT=300
# ENABLE_RACK_ATTACK_WIDGET_API=true # ENABLE_RACK_ATTACK_WIDGET_API=true
## Running chatwoot as an API only server ## Running chatwoot as an API only server

View File

@ -6,7 +6,7 @@ module.exports = {
'plugin:storybook/recommended', 'plugin:storybook/recommended',
], ],
parserOptions: { parserOptions: {
parser: 'babel-eslint', parser: '@babel/eslint-parser',
ecmaVersion: 2020, ecmaVersion: 2020,
sourceType: 'module', sourceType: 'module',
}, },
@ -24,13 +24,16 @@ module.exports = {
'jsx-a11y/label-has-for': 'off', 'jsx-a11y/label-has-for': 'off',
'jsx-a11y/anchor-is-valid': 'off', 'jsx-a11y/anchor-is-valid': 'off',
'import/no-unresolved': 'off', 'import/no-unresolved': 'off',
'vue/html-indent': 'off',
'vue/multi-word-component-names': 'off',
'vue/max-attributes-per-line': [ 'vue/max-attributes-per-line': [
'error', 'error',
{ {
singleline: 20, singleline: {
max: 20,
},
multiline: { multiline: {
max: 1, max: 1,
allowFirstLine: false,
}, },
}, },
], ],
@ -47,6 +50,7 @@ module.exports = {
}, },
], ],
'vue/no-v-html': 'off', 'vue/no-v-html': 'off',
'vue/component-definition-name-casing': 'off',
'vue/singleline-html-element-content-newline': 'off', 'vue/singleline-html-element-content-newline': 'off',
'import/extensions': ['off'], 'import/extensions': ['off'],
'no-console': 'error', 'no-console': 'error',

View File

@ -69,7 +69,12 @@ jobs:
# Run Response Bot specs # Run Response Bot specs
- name: Run backend tests - name: Run backend tests
run: | run: |
bundle exec rspec spec/enterprise/controllers/api/v1/accounts/response_sources_controller_spec.rb spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb:47 --profile=10 --format documentation bundle exec rspec \
spec/enterprise/controllers/api/v1/accounts/response_sources_controller_spec.rb \
spec/enterprise/services/enterprise/message_templates/response_bot_service_spec.rb \
spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb:47 \
--profile=10 \
--format documentation
- name: Upload rails log folder - name: Upload rails log folder
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3

View File

@ -58,6 +58,7 @@ Metrics/BlockLength:
Metrics/ModuleLength: Metrics/ModuleLength:
Exclude: Exclude:
- lib/seeders/message_seeder.rb - lib/seeders/message_seeder.rb
- spec/support/slack_stubs.rb
Rails/ApplicationController: Rails/ApplicationController:
Exclude: Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb' - 'app/controllers/api/v1/widget/messages_controller.rb'

14
Gemfile
View File

@ -15,7 +15,7 @@ gem 'browser'
gem 'hashie' gem 'hashie'
gem 'jbuilder' gem 'jbuilder'
gem 'kaminari' gem 'kaminari'
gem 'responders' gem 'responders', '>= 3.1.1'
gem 'rest-client' gem 'rest-client'
gem 'telephone_number' gem 'telephone_number'
gem 'time_diff' gem 'time_diff'
@ -67,7 +67,7 @@ gem 'webpacker'
gem 'barnes' gem 'barnes'
##--- gems for authentication & authorization ---## ##--- gems for authentication & authorization ---##
gem 'devise' gem 'devise', '>= 4.9.3'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot' gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth' gem 'devise_token_auth'
# authorization # authorization
@ -92,7 +92,7 @@ gem 'twitty', '~> 0.1.5'
# facebook client # facebook client
gem 'koala' gem 'koala'
# slack client # slack client
gem 'slack-ruby-client', '~> 2.0.0' gem 'slack-ruby-client', '~> 2.2.0'
# for dialogflow integrations # for dialogflow integrations
gem 'google-cloud-dialogflow-v2' gem 'google-cloud-dialogflow-v2'
gem 'grpc' gem 'grpc'
@ -109,9 +109,9 @@ gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', require: false gem 'newrelic-sidekiq-metrics', require: false
gem 'scout_apm', require: false gem 'scout_apm', require: false
gem 'sentry-rails', '>= 5.11.0', require: false gem 'sentry-rails', '>= 5.12.0', require: false
gem 'sentry-ruby', require: false gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.11.0', require: false gem 'sentry-sidekiq', '>= 5.12.0', require: false
##-- background job processing --## ##-- background job processing --##
gem 'sidekiq', '>= 7.1.3' gem 'sidekiq', '>= 7.1.3'
@ -154,12 +154,12 @@ gem 'stripe'
gem 'faker' gem 'faker'
# Include logrange conditionally in intializer using env variable # Include logrange conditionally in intializer using env variable
gem 'lograge', '~> 0.13.0', require: false gem 'lograge', '~> 0.14.0', require: false
# worked with microsoft refresh token # worked with microsoft refresh token
gem 'omniauth-oauth2' gem 'omniauth-oauth2'
gem 'audited', '~> 5.3' gem 'audited', '~> 5.4', '>= 5.4.0'
# need for google auth # need for google auth
gem 'omniauth' gem 'omniauth'

View File

@ -126,8 +126,8 @@ GEM
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
ast (2.4.2) ast (2.4.2)
attr_extras (7.1.0) attr_extras (7.1.0)
audited (5.3.3) audited (5.4.0)
activerecord (>= 5.0, < 7.1) activerecord (>= 5.0, < 7.2)
request_store (~> 1.2) request_store (~> 1.2)
aws-eventstream (1.2.0) aws-eventstream (1.2.0)
aws-partitions (1.760.0) aws-partitions (1.760.0)
@ -148,7 +148,7 @@ GEM
barnes (0.0.9) barnes (0.0.9)
multi_json (~> 1) multi_json (~> 1)
statsd-ruby (~> 1.1) statsd-ruby (~> 1.1)
bcrypt (3.1.18) bcrypt (3.1.19)
bindex (0.8.1) bindex (0.8.1)
blingfire (0.1.8) blingfire (0.1.8)
bootsnap (1.16.0) bootsnap (1.16.0)
@ -193,7 +193,7 @@ GEM
irb (>= 1.5.0) irb (>= 1.5.0)
reline (>= 0.3.1) reline (>= 0.3.1)
declarative (0.0.20) declarative (0.0.20)
devise (4.9.2) devise (4.9.3)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 4.1.0) railties (>= 4.1.0)
@ -273,7 +273,7 @@ GEM
googleauth (~> 1.0) googleauth (~> 1.0)
grpc (~> 1.36) grpc (~> 1.36)
geocoder (1.8.1) geocoder (1.8.1)
gli (2.21.0) gli (2.21.1)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
gmail_xoauth (0.4.2) gmail_xoauth (0.4.2)
@ -434,12 +434,12 @@ GEM
llhttp-ffi (0.4.0) llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0) ffi-compiler (~> 1.0)
rake (~> 13.0) rake (~> 13.0)
lograge (0.13.0) lograge (0.14.0)
actionpack (>= 4) actionpack (>= 4)
activesupport (>= 4) activesupport (>= 4)
railties (>= 4) railties (>= 4)
request_store (~> 1.0) request_store (~> 1.0)
loofah (2.21.3) loofah (2.21.4)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.8.1)
@ -616,7 +616,7 @@ GEM
uber (< 0.2.0) uber (< 0.2.0)
request_store (1.5.1) request_store (1.5.1)
rack (>= 1.4) rack (>= 1.4)
responders (3.1.0) responders (3.1.1)
actionpack (>= 5.2) actionpack (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
rest-client (2.1.0) rest-client (2.1.0)
@ -702,18 +702,18 @@ GEM
activesupport (>= 4) activesupport (>= 4)
selectize-rails (0.12.6) selectize-rails (0.12.6)
semantic_range (3.0.0) semantic_range (3.0.0)
sentry-rails (5.11.0) sentry-rails (5.12.0)
railties (>= 5.0) railties (>= 5.0)
sentry-ruby (~> 5.11.0) sentry-ruby (~> 5.12.0)
sentry-ruby (5.11.0) sentry-ruby (5.12.0)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.11.0) sentry-sidekiq (5.12.0)
sentry-ruby (~> 5.11.0) sentry-ruby (~> 5.12.0)
sidekiq (>= 3.0) sidekiq (>= 3.0)
sexp_processor (4.17.0) sexp_processor (4.17.0)
shoulda-matchers (5.3.0) shoulda-matchers (5.3.0)
activesupport (>= 5.2.0) activesupport (>= 5.2.0)
sidekiq (7.1.3) sidekiq (7.1.6)
concurrent-ruby (< 2) concurrent-ruby (< 2)
connection_pool (>= 2.3.0) connection_pool (>= 2.3.0)
rack (>= 2.2.4) rack (>= 2.2.4)
@ -732,13 +732,12 @@ GEM
json (>= 1.8, < 3) json (>= 1.8, < 3)
simplecov-html (~> 0.10.0) simplecov-html (~> 0.10.0)
simplecov-html (0.10.2) simplecov-html (0.10.2)
slack-ruby-client (2.0.0) slack-ruby-client (2.2.0)
faraday (>= 2.0) faraday (>= 2.0)
faraday-mashify faraday-mashify
faraday-multipart faraday-multipart
gli gli
hashie hashie
websocket-driver
snaky_hash (2.0.1) snaky_hash (2.0.1)
hashie hashie
version_gem (~> 1.1, >= 1.1.1) version_gem (~> 1.1, >= 1.1.1)
@ -817,7 +816,7 @@ GEM
working_hours (1.4.1) working_hours (1.4.1)
activesupport (>= 3.2) activesupport (>= 3.2)
tzinfo tzinfo
zeitwerk (2.6.11) zeitwerk (2.6.12)
PLATFORMS PLATFORMS
arm64-darwin-20 arm64-darwin-20
@ -839,7 +838,7 @@ DEPENDENCIES
administrate-field-belongs_to_search administrate-field-belongs_to_search
annotate annotate
attr_extras attr_extras
audited (~> 5.3) audited (~> 5.4, >= 5.4.0)
aws-sdk-s3 aws-sdk-s3
azure-storage-blob! azure-storage-blob!
barnes barnes
@ -856,7 +855,7 @@ DEPENDENCIES
database_cleaner database_cleaner
ddtrace ddtrace
debug (~> 1.8) debug (~> 1.8)
devise devise (>= 4.9.3)
devise-secure_password! devise-secure_password!
devise_token_auth devise_token_auth
dotenv-rails dotenv-rails
@ -892,7 +891,7 @@ DEPENDENCIES
line-bot-api line-bot-api
liquid liquid
listen listen
lograge (~> 0.13.0) lograge (~> 0.14.0)
maxminddb maxminddb
mock_redis mock_redis
neighbor neighbor
@ -916,7 +915,7 @@ DEPENDENCIES
rails (~> 7.0.8.0) rails (~> 7.0.8.0)
redis redis
redis-namespace redis-namespace
responders responders (>= 3.1.1)
rest-client rest-client
reverse_markdown reverse_markdown
rspec-rails rspec-rails
@ -928,14 +927,14 @@ DEPENDENCIES
scout_apm scout_apm
scss_lint scss_lint
seed_dump seed_dump
sentry-rails (>= 5.11.0) sentry-rails (>= 5.12.0)
sentry-ruby sentry-ruby
sentry-sidekiq (>= 5.11.0) sentry-sidekiq (>= 5.12.0)
shoulda-matchers shoulda-matchers
sidekiq (>= 7.1.3) sidekiq (>= 7.1.3)
sidekiq-cron (>= 1.10.1) sidekiq-cron (>= 1.10.1)
simplecov (= 0.17.1) simplecov (= 0.17.1)
slack-ruby-client (~> 2.0.0) slack-ruby-client (~> 2.2.0)
spring spring
spring-watcher-listen spring-watcher-listen
squasher squasher

View File

@ -1 +1 @@
2.4.0 2.6.0

View File

@ -24,14 +24,6 @@ select {
font-size: $base-font-size; font-size: $base-font-size;
} }
input,
select,
textarea {
display: block;
font-family: $base-font-family;
font-size: 16px;
}
[type="color"], [type="color"],
[type="date"], [type="date"],
[type="datetime-local"], [type="datetime-local"],
@ -51,6 +43,7 @@ textarea {
background-color: $white; background-color: $white;
border: $base-border; border: $base-border;
border-radius: $base-border-radius; border-radius: $base-border-radius;
font-family: $base-font-family;
padding: 0.5em; padding: 0.5em;
transition: border-color $base-duration $base-timing; transition: border-color $base-duration $base-timing;
width: 100%; width: 100%;

View File

@ -4,8 +4,8 @@
float: left; float: left;
margin-bottom: $base-spacing; margin-bottom: $base-spacing;
margin-top: 0.25em; margin-top: 0.25em;
text-align: right; text-align: left;
width: calc(15% - 1rem); width: calc(16% - 1rem);
} }
.preserve-whitespace { .preserve-whitespace {
@ -17,7 +17,7 @@
float: left; float: left;
margin-bottom: $base-spacing; margin-bottom: $base-spacing;
margin-left: 2rem; margin-left: 2rem;
width: calc(85% - 1rem); width: calc(84% - 1rem);
} }
.attribute--nested { .attribute--nested {

View File

@ -10,7 +10,7 @@ input[type="submit"],
color: $white; color: $white;
cursor: pointer; cursor: pointer;
display: inline-block; display: inline-block;
font-size: $font-size-default; font-size: $font-size-small;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
line-height: 1; line-height: 1;

View File

@ -1,13 +1,18 @@
.main-content { .main-content {
font-size: $font-size-default; font-size: $font-size-default;
left: 23rem; left: 21rem;
position: absolute; position: absolute;
right: 0; right: 0;
top: 0; top: 0;
} }
.main-content__body { .main-content__body {
font-size: $font-size-small;
padding: $space-two; padding: $space-two;
table {
font-size: $font-size-small;
}
} }
.main-content__header { .main-content__header {
@ -20,7 +25,7 @@
} }
.main-content__page-title { .main-content__page-title {
font-size: $font-size-large; font-size: $font-size-medium;
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
margin-right: auto; margin-right: auto;
} }

View File

@ -1,7 +1,12 @@
.logo-brand { .logo-brand {
margin-bottom: $space-normal; margin-bottom: $space-normal;
padding: $space-normal $space-smaller $space-small; padding: $space-normal $space-smaller $space-small;
text-align: center; text-align: left;
img {
margin-bottom: $space-smaller;
max-height: 3rem;
}
} }
.navigation { .navigation {
@ -19,12 +24,13 @@
padding: $space-normal; padding: $space-normal;
position: fixed; position: fixed;
top: 0; top: 0;
width: 23rem; width: 21rem;
z-index: 1023; z-index: 1023;
li { li {
align-items: center; align-items: center;
display: flex; display: flex;
font-size: $font-size-small;
a { a {
color: $color-gray; color: $color-gray;
@ -35,6 +41,10 @@
min-width: $space-medium; min-width: $space-medium;
} }
} }
hr {
margin: $space-slab;
}
} }
.navigation__link { .navigation__link {
@ -43,7 +53,7 @@
display: block; display: block;
line-height: 1; line-height: 1;
margin-bottom: $space-smaller; margin-bottom: $space-smaller;
padding: $space-one; padding: $space-small;
&:hover { &:hover {
color: $blue; color: $blue;

View File

@ -4,7 +4,7 @@ $base-font-family: PlusJakarta, Inter, -apple-system, BlinkMacSystemFont, "Segoe
sans-serif !default; sans-serif !default;
$heading-font-family: $base-font-family !default; $heading-font-family: $base-font-family !default;
$base-font-size: 16px !default; $base-font-size: 14px !default;
$base-line-height: 1.5 !default; $base-line-height: 1.5 !default;
$heading-line-height: 1.2 !default; $heading-line-height: 1.2 !default;

View File

@ -93,6 +93,9 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
message_type: @message_type, message_type: @message_type,
content: response.content, content: response.content,
source_id: response.identifier, source_id: response.identifier,
content_attributes: {
in_reply_to_external_id: response.in_reply_to_external_id
},
sender: @outgoing_echo ? nil : @contact_inbox.contact sender: @outgoing_echo ? nil : @contact_inbox.contact
} }
end end

View File

@ -15,7 +15,10 @@ class V2::ReportBuilder
end end
def timeseries def timeseries
send(params[:metric]) return send(params[:metric]) if metric_valid?
Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}"
{}
end end
# For backward compatible with old report # For backward compatible with old report
@ -53,6 +56,16 @@ class V2::ReportBuilder
private private
def metric_valid?
%w[conversations_count
incoming_messages_count
outgoing_messages_count
avg_first_response_time
avg_resolution_time reply_time
resolutions_count
reply_time].include?(params[:metric])
end
def inbox def inbox
@inbox ||= account.inboxes.find(params[:id]) @inbox ||= account.inboxes.find(params[:id])
end end

View File

@ -9,7 +9,6 @@ class Api::V1::Accounts::Actions::ContactMergesController < Api::V1::Accounts::B
mergee_contact: @mergee_contact mergee_contact: @mergee_contact
) )
contact_merge_action.perform contact_merge_action.perform
render json: @base_contact
end end
private private

View File

@ -0,0 +1,21 @@
class Api::V1::Accounts::ContactInboxesController < Api::V1::Accounts::BaseController
before_action :ensure_inbox
def filter
contact_inbox = @inbox.contact_inboxes.where(inbox_id: permitted_params[:inbox_id], source_id: permitted_params[:source_id])
return head :not_found if contact_inbox.empty?
@contact = contact_inbox.first.contact
end
private
def ensure_inbox
@inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
authorize @inbox, :show?
end
def permitted_params
params.permit(:inbox_id, :source_id)
end
end

View File

@ -60,7 +60,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def toggle_status def toggle_status
if params[:status] if params[:status].present?
set_conversation_status set_conversation_status
@status = @conversation.save! @status = @conversation.save!
else else

View File

@ -1,7 +1,15 @@
class Api::V1::Integrations::WebhooksController < ApplicationController class Api::V1::Integrations::WebhooksController < ApplicationController
def create def create
builder = Integrations::Slack::IncomingMessageBuilder.new(params) builder = Integrations::Slack::IncomingMessageBuilder.new(permitted_params)
response = builder.perform response = builder.perform
render json: response render json: response
end end
private
# TODO: This is a temporary solution to permit all params for slack unfurling job.
# We should only permit the params that we use. Handle all the params based on events and send it to the respective services.
def permitted_params
params.permit!
end
end end

View File

@ -1,5 +1,6 @@
class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
include Events::Types include Events::Types
before_action :render_not_found_if_empty, only: [:toggle_typing, :toggle_status, :set_custom_attributes, :destroy_custom_attributes]
def index def index
@conversation = conversation @conversation = conversation
@ -27,6 +28,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
conversation.contact_last_seen_at = DateTime.now.utc conversation.contact_last_seen_at = DateTime.now.utc
conversation.save! conversation.save!
::Conversations::MarkMessagesAsReadJob.perform_later(conversation)
head :ok head :ok
end end
@ -41,8 +43,6 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end end
def toggle_typing def toggle_typing
head :ok && return if conversation.nil?
case permitted_params[:typing_status] case permitted_params[:typing_status]
when 'on' when 'on'
trigger_typing_event(CONVERSATION_TYPING_ON) trigger_typing_event(CONVERSATION_TYPING_ON)
@ -54,8 +54,6 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end end
def toggle_status def toggle_status
return head :not_found if conversation.nil?
return head :forbidden unless @web_widget.end_conversation? return head :forbidden unless @web_widget.end_conversation?
unless conversation.resolved? unless conversation.resolved?
@ -81,6 +79,10 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact) Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact)
end end
def render_not_found_if_empty
return head :not_found if conversation.nil?
end
def permitted_params def permitted_params
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number], params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
message: [:content, :referer_url, :timestamp, :echo_id], message: [:content, :referer_url, :timestamp, :echo_id],

View File

@ -9,11 +9,14 @@ module RequestExceptionHandler
def handle_with_exception def handle_with_exception
yield yield
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound => e
log_handled_error(e)
render_not_found_error('Resource could not be found') render_not_found_error('Resource could not be found')
rescue Pundit::NotAuthorizedError rescue Pundit::NotAuthorizedError => e
log_handled_error(e)
render_unauthorized('You are not authorized to do this action') render_unauthorized('You are not authorized to do this action')
rescue ActionController::ParameterMissing => e rescue ActionController::ParameterMissing => e
log_handled_error(e)
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
ensure ensure
# to address the thread variable leak issues in Puma/Thin webserver # to address the thread variable leak issues in Puma/Thin webserver
@ -41,6 +44,7 @@ module RequestExceptionHandler
end end
def render_record_invalid(exception) def render_record_invalid(exception)
log_handled_error(exception)
render json: { render json: {
message: exception.record.errors.full_messages.join(', '), message: exception.record.errors.full_messages.join(', '),
attributes: exception.record.errors.attribute_names attributes: exception.record.errors.attribute_names
@ -48,6 +52,11 @@ module RequestExceptionHandler
end end
def render_error_response(exception) def render_error_response(exception)
log_handled_error(exception)
render json: exception.to_hash, status: exception.http_status render json: exception.to_hash, status: exception.http_status
end end
def log_handled_error(exception)
logger.info("Handled error: #{exception.inspect}")
end
end end

View File

@ -33,7 +33,7 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox
end end
def create_conversation def create_conversation
::Conversation.create!(conversation_params) ConversationBuilder.new(params: conversation_params, contact_inbox: @contact_inbox).perform
end end
def trigger_typing_event(event) def trigger_typing_event(event)
@ -41,11 +41,6 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox
end end
def conversation_params def conversation_params
{ params.permit(custom_attributes: {})
account_id: @contact_inbox.contact.account_id,
inbox_id: @contact_inbox.inbox_id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id
}
end end
end end

View File

@ -1,5 +1,6 @@
class Public::Api::V1::Portals::BaseController < PublicController class Public::Api::V1::Portals::BaseController < PublicController
before_action :show_plain_layout before_action :show_plain_layout
before_action :set_color_scheme
around_action :set_locale around_action :set_locale
after_action :allow_iframe_requests after_action :allow_iframe_requests
@ -9,6 +10,14 @@ class Public::Api::V1::Portals::BaseController < PublicController
@is_plain_layout_enabled = params[:show_plain_layout] == 'true' @is_plain_layout_enabled = params[:show_plain_layout] == 'true'
end end
def set_color_scheme
@theme = if %w[dark light].include?(params[:theme])
params[:theme]
else
''
end
end
def set_locale(&) def set_locale(&)
switch_locale_with_portal(&) if params[:locale].present? switch_locale_with_portal(&) if params[:locale].present?
switch_locale_with_article(&) if params[:article_slug].present? switch_locale_with_article(&) if params[:article_slug].present?

View File

@ -0,0 +1,27 @@
class SlackUploadsController < ApplicationController
include Rails.application.routes.url_helpers
before_action :set_blob, only: [:show]
def show
if @blob
redirect_to blob_url
else
redirect_to avatar_url
end
end
private
def set_blob
@blob = ActiveStorage::Blob.find_by(key: params[:blob_key])
end
def blob_url
url_for(@blob.representation(resize_to_fill: [250, nil]))
end
def avatar_url
base_url = ENV.fetch('FRONTEND_URL', nil)
"#{base_url}/integrations/slack/#{params[:sender_type]}.png"
end
end

View File

@ -0,0 +1,21 @@
class Twilio::DeliveryStatusController < ApplicationController
def create
::Twilio::DeliveryStatusService.new(params: permitted_params).perform
head :no_content
end
private
def permitted_params
params.permit(
:AccountSid,
:From,
:MessageSid,
:MessagingServiceSid,
:MessageStatus,
:ErrorCode,
:ErrorMessage
)
end
end

View File

@ -0,0 +1,11 @@
module PortalHelper
def generate_portal_bg_color(portal_color, theme)
base_color = theme == 'dark' ? 'black' : 'white'
"color-mix(in srgb, #{portal_color} 10%, #{base_color})"
end
def generate_portal_bg(portal_color, theme)
bg_image = theme == 'dark' ? 'grid_dark.svg' : 'grid.svg'
"background: url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}"
end
end

View File

@ -1,13 +1,13 @@
<template> <template>
<div <div
v-if="!authUIFlags.isFetching" v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app" id="app"
class="app-wrapper h-full flex-grow-0 min-h-0 w-full" class="app-wrapper h-full flex-grow-0 min-h-0 w-full"
:class="{ 'app-rtl--wrapper': isRTLView }" :class="{ 'app-rtl--wrapper': isRTLView }"
:dir="isRTLView ? 'rtl' : 'ltr'" :dir="isRTLView ? 'rtl' : 'ltr'"
> >
<update-banner :latest-chatwoot-version="latestChatwootVersion" /> <update-banner :latest-chatwoot-version="latestChatwootVersion" />
<template v-if="!accountUIFlags.isFetchingItem && currentAccountId"> <template v-if="currentAccountId">
<payment-pending-banner /> <payment-pending-banner />
<upgrade-banner /> <upgrade-banner />
</template> </template>
@ -111,10 +111,8 @@ export default {
this.$store.dispatch('setActiveAccount', { this.$store.dispatch('setActiveAccount', {
accountId: this.currentAccountId, accountId: this.currentAccountId,
}); });
const { const { locale, latest_chatwoot_version: latestChatwootVersion } =
locale, this.getAccount(this.currentAccountId);
latest_chatwoot_version: latestChatwootVersion,
} = this.getAccount(this.currentAccountId);
const { pubsub_token: pubsubToken } = this.currentUser || {}; const { pubsub_token: pubsubToken } = this.currentUser || {};
this.setLocale(locale); this.setLocale(locale);
this.updateRTLDirectionView(locale); this.updateRTLDirectionView(locale);

View File

@ -15,9 +15,8 @@ class ApiClient {
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
get accountIdFromRoute() { get accountIdFromRoute() {
const isInsideAccountScopedURLs = window.location.pathname.includes( const isInsideAccountScopedURLs =
'/app/accounts' window.location.pathname.includes('/app/accounts');
);
if (isInsideAccountScopedURLs) { if (isInsideAccountScopedURLs) {
return window.location.pathname.split('/')[3]; return window.location.pathname.split('/')[3];

View File

@ -84,6 +84,11 @@ export default {
return axios.delete(endPoints('deleteAvatar').url); return axios.delete(endPoints('deleteAvatar').url);
}, },
resetPassword({ email }) {
const urlData = endPoints('resetPassword');
return axios.post(urlData.url, { email });
},
setActiveAccount({ accountId }) { setActiveAccount({ accountId }) {
const urlData = endPoints('setActiveAccount'); const urlData = endPoints('setActiveAccount');
return axios.put(urlData.url, { return axios.put(urlData.url, {

View File

@ -53,6 +53,7 @@ class ContactAPI extends ApiClient {
return axios.get(requestURL); return axios.get(requestURL);
} }
// eslint-disable-next-line default-param-last
filter(page = 1, sortAttr = 'name', queryPayload) { filter(page = 1, sortAttr = 'name', queryPayload) {
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`; let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;
return axios.post(requestURL, queryPayload); return axios.post(requestURL, queryPayload);

View File

@ -1,6 +1,5 @@
import accountAPI from '../account'; import accountAPI from '../account';
import ApiClient from '../../ApiClient'; import ApiClient from '../../ApiClient';
import describeWithAPIMock from '../../specs/apiSpecHelper';
describe('#enterpriseAccountAPI', () => { describe('#enterpriseAccountAPI', () => {
it('creates correct instance', () => { it('creates correct instance', () => {
@ -13,17 +12,33 @@ describe('#enterpriseAccountAPI', () => {
expect(accountAPI).toHaveProperty('checkout'); expect(accountAPI).toHaveProperty('checkout');
}); });
describeWithAPIMock('API calls', context => { describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#checkout', () => { it('#checkout', () => {
accountAPI.checkout(); accountAPI.checkout();
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/checkout' '/enterprise/api/v1/checkout'
); );
}); });
it('#subscription', () => { it('#subscription', () => {
accountAPI.subscription(); accountAPI.subscription();
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/subscription' '/enterprise/api/v1/subscription'
); );
}); });

View File

@ -31,6 +31,7 @@ class ReportsAPI extends ApiClient {
}); });
} }
// eslint-disable-next-line default-param-last
getSummary(since, until, type = 'account', id, groupBy, businessHours) { getSummary(since, until, type = 'account', id, groupBy, businessHours) {
return axios.get(`${this.url}/summary`, { return axios.get(`${this.url}/summary`, {
params: { params: {

View File

@ -1,6 +1,5 @@
import accountAPI from '../account'; import accountAPI from '../account';
import ApiClient from '../ApiClient'; import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#accountAPI', () => { describe('#accountAPI', () => {
it('creates correct instance', () => { it('creates correct instance', () => {
@ -13,12 +12,28 @@ describe('#accountAPI', () => {
expect(accountAPI).toHaveProperty('createAccount'); expect(accountAPI).toHaveProperty('createAccount');
}); });
describeWithAPIMock('API calls', context => { describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#createAccount', () => { it('#createAccount', () => {
accountAPI.createAccount({ accountAPI.createAccount({
name: 'Chatwoot', name: 'Chatwoot',
}); });
expect(context.axiosMock.post).toHaveBeenCalledWith('/api/v1/accounts', { expect(axiosMock.post).toHaveBeenCalledWith('/api/v1/accounts', {
name: 'Chatwoot', name: 'Chatwoot',
}); });
}); });

View File

@ -1,6 +1,5 @@
import accountActionsAPI from '../accountActions'; import accountActionsAPI from '../accountActions';
import ApiClient from '../ApiClient'; import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#ContactsAPI', () => { describe('#ContactsAPI', () => {
it('creates correct instance', () => { it('creates correct instance', () => {
@ -8,10 +7,26 @@ describe('#ContactsAPI', () => {
expect(accountActionsAPI).toHaveProperty('merge'); expect(accountActionsAPI).toHaveProperty('merge');
}); });
describeWithAPIMock('API calls', context => { describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#merge', () => { it('#merge', () => {
accountActionsAPI.merge(1, 2); accountActionsAPI.merge(1, 2);
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/actions/contact_merge', '/api/v1/actions/contact_merge',
{ {
base_contact_id: 1, base_contact_id: 1,

View File

@ -1,27 +0,0 @@
function apiSpecHelper() {
beforeEach(() => {
this.originalAxios = window.axios;
this.axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
window.axios = this.axiosMock;
});
afterEach(() => {
window.axios = this.originalAxios;
});
}
// https://stackoverflow.com/a/59344023/3901856
const sharedWrapper = describe('sharedWrapper', () => {});
export default function describeWithAPIMock(skillName, testFn) {
return describe(skillName, function configureContext() {
function Context() {}
Context.prototype = sharedWrapper.ctx;
this.ctx = new Context();
apiSpecHelper.call(this);
testFn.call(this, this);
});
}

View File

@ -1,6 +1,5 @@
import articlesAPI from '../helpCenter/articles'; import articlesAPI from '../helpCenter/articles';
import ApiClient from 'dashboard/api/helpCenter/portals'; import ApiClient from 'dashboard/api/helpCenter/portals';
import describeWithAPIMock from './apiSpecHelper';
describe('#PortalAPI', () => { describe('#PortalAPI', () => {
it('creates correct instance', () => { it('creates correct instance', () => {
@ -12,7 +11,23 @@ describe('#PortalAPI', () => {
expect(articlesAPI).toHaveProperty('delete'); expect(articlesAPI).toHaveProperty('delete');
expect(articlesAPI).toHaveProperty('getArticles'); expect(articlesAPI).toHaveProperty('getArticles');
}); });
describeWithAPIMock('API calls', context => { describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#getArticles', () => { it('#getArticles', () => {
articlesAPI.getArticles({ articlesAPI.getArticles({
pageNumber: 1, pageNumber: 1,
@ -21,30 +36,62 @@ describe('#PortalAPI', () => {
status: 'published', status: 'published',
author_id: '1', author_id: '1',
}); });
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1' '/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1'
); );
}); });
}); });
describeWithAPIMock('API calls', context => { describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#getArticle', () => { it('#getArticle', () => {
articlesAPI.getArticle({ articlesAPI.getArticle({
id: 1, id: 1,
portalSlug: 'room-rental', portalSlug: 'room-rental',
}); });
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/portals/room-rental/articles/1' '/api/v1/portals/room-rental/articles/1'
); );
}); });
}); });
describeWithAPIMock('API calls', context => { describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#updateArticle', () => { it('#updateArticle', () => {
articlesAPI.updateArticle({ articlesAPI.updateArticle({
articleId: 1, articleId: 1,
portalSlug: 'room-rental', portalSlug: 'room-rental',
articleObj: { title: 'Update shipping address' }, articleObj: { title: 'Update shipping address' },
}); });
expect(context.axiosMock.patch).toHaveBeenCalledWith( expect(axiosMock.patch).toHaveBeenCalledWith(
'/api/v1/portals/room-rental/articles/1', '/api/v1/portals/room-rental/articles/1',
{ {
title: 'Update shipping address', title: 'Update shipping address',
@ -52,13 +99,29 @@ describe('#PortalAPI', () => {
); );
}); });
}); });
describeWithAPIMock('API calls', context => { describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#deleteArticle', () => { it('#deleteArticle', () => {
articlesAPI.deleteArticle({ articlesAPI.deleteArticle({
articleId: 1, articleId: 1,
portalSlug: 'room-rental', portalSlug: 'room-rental',
}); });
expect(context.axiosMock.delete).toHaveBeenCalledWith( expect(axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/portals/room-rental/articles/1' '/api/v1/portals/room-rental/articles/1'
); );
}); });

View File

@ -1,18 +1,30 @@
import assignableAgentsAPI from '../assignableAgents'; import assignableAgentsAPI from '../assignableAgents';
import describeWithAPIMock from './apiSpecHelper';
describe('#AssignableAgentsAPI', () => { describe('#AssignableAgentsAPI', () => {
describeWithAPIMock('API calls', context => { describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#getAssignableAgents', () => { it('#getAssignableAgents', () => {
assignableAgentsAPI.get([1]); assignableAgentsAPI.get([1]);
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
'/api/v1/assignable_agents', params: {
{ inbox_ids: [1],
params: { },
inbox_ids: [1], });
},
}
);
}); });
}); });
}); });

View File

@ -1,6 +1,5 @@
import fbChannel from '../../channel/fbChannel'; import fbChannel from '../../channel/fbChannel';
import ApiClient from '../../ApiClient'; import ApiClient from '../../ApiClient';
import describeWithAPIMock from '../apiSpecHelper';
describe('#FBChannel', () => { describe('#FBChannel', () => {
it('creates correct instance', () => { it('creates correct instance', () => {
@ -11,10 +10,26 @@ describe('#FBChannel', () => {
expect(fbChannel).toHaveProperty('update'); expect(fbChannel).toHaveProperty('update');
expect(fbChannel).toHaveProperty('delete'); expect(fbChannel).toHaveProperty('delete');
}); });
describeWithAPIMock('API calls', context => { describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#create', () => { it('#create', () => {
fbChannel.create({ omniauthToken: 'ASFM131CSF@#@$', appId: 'chatwoot' }); fbChannel.create({ omniauthToken: 'ASFM131CSF@#@$', appId: 'chatwoot' });
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/callbacks/register_facebook_page', '/api/v1/callbacks/register_facebook_page',
{ {
omniauthToken: 'ASFM131CSF@#@$', omniauthToken: 'ASFM131CSF@#@$',
@ -27,7 +42,7 @@ describe('#FBChannel', () => {
omniauthToken: 'ASFM131CSF@#@$', omniauthToken: 'ASFM131CSF@#@$',
inboxId: 1, inboxId: 1,
}); });
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/callbacks/reauthorize_page', '/api/v1/callbacks/reauthorize_page',
{ {
omniauth_token: 'ASFM131CSF@#@$', omniauth_token: 'ASFM131CSF@#@$',

View File

@ -1,6 +1,5 @@
import contactAPI, { buildContactParams } from '../contacts'; import contactAPI, { buildContactParams } from '../contacts';
import ApiClient from '../ApiClient'; import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#ContactsAPI', () => { describe('#ContactsAPI', () => {
it('creates correct instance', () => { it('creates correct instance', () => {
@ -15,56 +14,67 @@ describe('#ContactsAPI', () => {
expect(contactAPI).toHaveProperty('destroyAvatar'); expect(contactAPI).toHaveProperty('destroyAvatar');
}); });
describeWithAPIMock('API calls', context => { describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#get', () => { it('#get', () => {
contactAPI.get(1, 'name', 'customer-support'); contactAPI.get(1, 'name', 'customer-support');
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts?include_contact_inboxes=false&page=1&sort=name&labels[]=customer-support' '/api/v1/contacts?include_contact_inboxes=false&page=1&sort=name&labels[]=customer-support'
); );
}); });
it('#getConversations', () => { it('#getConversations', () => {
contactAPI.getConversations(1); contactAPI.getConversations(1);
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/1/conversations' '/api/v1/contacts/1/conversations'
); );
}); });
it('#getContactableInboxes', () => { it('#getContactableInboxes', () => {
contactAPI.getContactableInboxes(1); contactAPI.getContactableInboxes(1);
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/1/contactable_inboxes' '/api/v1/contacts/1/contactable_inboxes'
); );
}); });
it('#getContactLabels', () => { it('#getContactLabels', () => {
contactAPI.getContactLabels(1); contactAPI.getContactLabels(1);
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/contacts/1/labels');
'/api/v1/contacts/1/labels'
);
}); });
it('#updateContactLabels', () => { it('#updateContactLabels', () => {
const labels = ['support-query']; const labels = ['support-query'];
contactAPI.updateContactLabels(1, labels); contactAPI.updateContactLabels(1, labels);
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith('/api/v1/contacts/1/labels', {
'/api/v1/contacts/1/labels', labels,
{ });
labels,
}
);
}); });
it('#search', () => { it('#search', () => {
contactAPI.search('leads', 1, 'date', 'customer-support'); contactAPI.search('leads', 1, 'date', 'customer-support');
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support' '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support'
); );
}); });
it('#destroyCustomAttributes', () => { it('#destroyCustomAttributes', () => {
contactAPI.destroyCustomAttributes(1, ['cloudCustomer']); contactAPI.destroyCustomAttributes(1, ['cloudCustomer']);
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/contacts/1/destroy_custom_attributes', '/api/v1/contacts/1/destroy_custom_attributes',
{ {
custom_attributes: ['cloudCustomer'], custom_attributes: ['cloudCustomer'],
@ -75,7 +85,7 @@ describe('#ContactsAPI', () => {
it('#importContacts', () => { it('#importContacts', () => {
const file = 'file'; const file = 'file';
contactAPI.importContacts(file); contactAPI.importContacts(file);
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/contacts/import', '/api/v1/contacts/import',
expect.any(FormData), expect.any(FormData),
{ {
@ -96,7 +106,7 @@ describe('#ContactsAPI', () => {
], ],
}; };
contactAPI.filter(1, 'name', queryPayload); contactAPI.filter(1, 'name', queryPayload);
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/contacts/filter?include_contact_inboxes=false&page=1&sort=name', '/api/v1/contacts/filter?include_contact_inboxes=false&page=1&sort=name',
queryPayload queryPayload
); );
@ -104,7 +114,7 @@ describe('#ContactsAPI', () => {
it('#destroyAvatar', () => { it('#destroyAvatar', () => {
contactAPI.destroyAvatar(1); contactAPI.destroyAvatar(1);
expect(context.axiosMock.delete).toHaveBeenCalledWith( expect(axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/contacts/1/avatar' '/api/v1/contacts/1/avatar'
); );
}); });

View File

@ -1,6 +1,5 @@
import conversationsAPI from '../conversations'; import conversationsAPI from '../conversations';
import ApiClient from '../ApiClient'; import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#ConversationApi', () => { describe('#ConversationApi', () => {
it('creates correct instance', () => { it('creates correct instance', () => {
@ -14,10 +13,26 @@ describe('#ConversationApi', () => {
expect(conversationsAPI).toHaveProperty('updateLabels'); expect(conversationsAPI).toHaveProperty('updateLabels');
}); });
describeWithAPIMock('API calls', context => { describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#getLabels', () => { it('#getLabels', () => {
conversationsAPI.getLabels(1); conversationsAPI.getLabels(1);
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/conversations/1/labels' '/api/v1/conversations/1/labels'
); );
}); });
@ -25,7 +40,7 @@ describe('#ConversationApi', () => {
it('#updateLabels', () => { it('#updateLabels', () => {
const labels = ['support-query']; const labels = ['support-query'];
conversationsAPI.updateLabels(1, labels); conversationsAPI.updateLabels(1, labels);
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/conversations/1/labels', '/api/v1/conversations/1/labels',
{ {
labels, labels,

View File

@ -1,6 +1,5 @@
import csatReportsAPI from '../csatReports'; import csatReportsAPI from '../csatReports';
import ApiClient from '../ApiClient'; import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#Reports API', () => { describe('#Reports API', () => {
it('creates correct instance', () => { it('creates correct instance', () => {
@ -9,10 +8,26 @@ describe('#Reports API', () => {
expect(csatReportsAPI).toHaveProperty('get'); expect(csatReportsAPI).toHaveProperty('get');
expect(csatReportsAPI).toHaveProperty('getMetrics'); expect(csatReportsAPI).toHaveProperty('getMetrics');
}); });
describeWithAPIMock('API calls', context => { describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#get', () => { it('#get', () => {
csatReportsAPI.get({ page: 1, from: 1622485800, to: 1623695400 }); csatReportsAPI.get({ page: 1, from: 1622485800, to: 1623695400 });
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/csat_survey_responses', '/api/v1/csat_survey_responses',
{ {
params: { params: {
@ -26,7 +41,7 @@ describe('#Reports API', () => {
}); });
it('#getMetrics', () => { it('#getMetrics', () => {
csatReportsAPI.getMetrics({ from: 1622485800, to: 1623695400 }); csatReportsAPI.getMetrics({ from: 1622485800, to: 1623695400 });
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/csat_survey_responses/metrics', '/api/v1/csat_survey_responses/metrics',
{ {
params: { since: 1622485800, until: 1623695400 }, params: { since: 1622485800, until: 1623695400 },
@ -39,7 +54,7 @@ describe('#Reports API', () => {
to: 1623695400, to: 1623695400,
user_ids: 1, user_ids: 1,
}); });
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/csat_survey_responses/download', '/api/v1/csat_survey_responses/download',
{ {
params: { params: {

View File

@ -1,6 +1,5 @@
import conversationAPI from '../../inbox/conversation'; import conversationAPI from '../../inbox/conversation';
import ApiClient from '../../ApiClient'; import ApiClient from '../../ApiClient';
import describeWithAPIMock from '../apiSpecHelper';
describe('#ConversationAPI', () => { describe('#ConversationAPI', () => {
it('creates correct instance', () => { it('creates correct instance', () => {
@ -22,7 +21,23 @@ describe('#ConversationAPI', () => {
expect(conversationAPI).toHaveProperty('filter'); expect(conversationAPI).toHaveProperty('filter');
}); });
describeWithAPIMock('API calls', context => { describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#get conversations', () => { it('#get conversations', () => {
conversationAPI.get({ conversationAPI.get({
inboxId: 1, inboxId: 1,
@ -32,19 +47,16 @@ describe('#ConversationAPI', () => {
labels: [], labels: [],
teamId: 1, teamId: 1,
}); });
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/conversations', {
'/api/v1/conversations', params: {
{ inbox_id: 1,
params: { team_id: 1,
inbox_id: 1, status: 'open',
team_id: 1, assignee_type: 'me',
status: 'open', page: 1,
assignee_type: 'me', labels: [],
page: 1, },
labels: [], });
},
}
);
}); });
it('#search', () => { it('#search', () => {
@ -53,7 +65,7 @@ describe('#ConversationAPI', () => {
page: 1, page: 1,
}); });
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/conversations/search', '/api/v1/conversations/search',
{ {
params: { params: {
@ -66,7 +78,7 @@ describe('#ConversationAPI', () => {
it('#toggleStatus', () => { it('#toggleStatus', () => {
conversationAPI.toggleStatus({ conversationId: 12, status: 'online' }); conversationAPI.toggleStatus({ conversationId: 12, status: 'online' });
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/toggle_status`, `/api/v1/conversations/12/toggle_status`,
{ {
status: 'online', status: 'online',
@ -77,7 +89,7 @@ describe('#ConversationAPI', () => {
it('#assignAgent', () => { it('#assignAgent', () => {
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 }); conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments?assignee_id=34`, `/api/v1/conversations/12/assignments?assignee_id=34`,
{} {}
); );
@ -85,7 +97,7 @@ describe('#ConversationAPI', () => {
it('#assignTeam', () => { it('#assignTeam', () => {
conversationAPI.assignTeam({ conversationId: 12, teamId: 1 }); conversationAPI.assignTeam({ conversationId: 12, teamId: 1 });
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments`, `/api/v1/conversations/12/assignments`,
{ {
team_id: 1, team_id: 1,
@ -95,7 +107,7 @@ describe('#ConversationAPI', () => {
it('#markMessageRead', () => { it('#markMessageRead', () => {
conversationAPI.markMessageRead({ id: 12 }); conversationAPI.markMessageRead({ id: 12 });
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/update_last_seen` `/api/v1/conversations/12/update_last_seen`
); );
}); });
@ -105,7 +117,7 @@ describe('#ConversationAPI', () => {
conversationId: 12, conversationId: 12,
status: 'typing_on', status: 'typing_on',
}); });
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/toggle_typing_status`, `/api/v1/conversations/12/toggle_typing_status`,
{ {
typing_status: 'typing_on', typing_status: 'typing_on',
@ -115,14 +127,14 @@ describe('#ConversationAPI', () => {
it('#mute', () => { it('#mute', () => {
conversationAPI.mute(45); conversationAPI.mute(45);
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/conversations/45/mute' '/api/v1/conversations/45/mute'
); );
}); });
it('#unmute', () => { it('#unmute', () => {
conversationAPI.unmute(45); conversationAPI.unmute(45);
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/conversations/45/unmute' '/api/v1/conversations/45/unmute'
); );
}); });
@ -135,18 +147,15 @@ describe('#ConversationAPI', () => {
labels: [], labels: [],
teamId: 1, teamId: 1,
}); });
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/conversations/meta', {
'/api/v1/conversations/meta', params: {
{ inbox_id: 1,
params: { team_id: 1,
inbox_id: 1, status: 'open',
team_id: 1, assignee_type: 'me',
status: 'open', labels: [],
assignee_type: 'me', },
labels: [], });
},
}
);
}); });
it('#sendEmailTranscript', () => { it('#sendEmailTranscript', () => {
@ -154,7 +163,7 @@ describe('#ConversationAPI', () => {
conversationId: 45, conversationId: 45,
email: 'john@acme.inc', email: 'john@acme.inc',
}); });
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/conversations/45/transcript', '/api/v1/conversations/45/transcript',
{ {
email: 'john@acme.inc', email: 'john@acme.inc',
@ -167,7 +176,7 @@ describe('#ConversationAPI', () => {
conversationId: 45, conversationId: 45,
customAttributes: { order_d: '1001' }, customAttributes: { order_d: '1001' },
}); });
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/conversations/45/custom_attributes', '/api/v1/conversations/45/custom_attributes',
{ {
custom_attributes: { order_d: '1001' }, custom_attributes: { order_d: '1001' },
@ -202,9 +211,7 @@ describe('#ConversationAPI', () => {
}, },
}; };
conversationAPI.filter(payload); conversationAPI.filter(payload);
expect( expect(axiosMock.post).toHaveBeenCalledWith(
context.axiosMock.post
).toHaveBeenCalledWith(
'/api/v1/conversations/filter', '/api/v1/conversations/filter',
payload.queryData, payload.queryData,
{ params: { page: payload.page } } { params: { page: payload.page } }
@ -213,7 +220,7 @@ describe('#ConversationAPI', () => {
it('#getAllAttachments', () => { it('#getAllAttachments', () => {
conversationAPI.getAllAttachments(1); conversationAPI.getAllAttachments(1);
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/conversations/1/attachments' '/api/v1/conversations/1/attachments'
); );
}); });

View File

@ -1,6 +1,5 @@
import messageAPI, { buildCreatePayload } from '../../inbox/message'; import messageAPI, { buildCreatePayload } from '../../inbox/message';
import ApiClient from '../../ApiClient'; import ApiClient from '../../ApiClient';
import describeWithAPIMock from '../apiSpecHelper';
describe('#ConversationAPI', () => { describe('#ConversationAPI', () => {
it('creates correct instance', () => { it('creates correct instance', () => {
@ -13,13 +12,29 @@ describe('#ConversationAPI', () => {
expect(messageAPI).toHaveProperty('getPreviousMessages'); expect(messageAPI).toHaveProperty('getPreviousMessages');
}); });
describeWithAPIMock('API calls', context => { describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#getPreviousMessages', () => { it('#getPreviousMessages', () => {
messageAPI.getPreviousMessages({ messageAPI.getPreviousMessages({
conversationId: 12, conversationId: 12,
before: 4573, before: 4573,
}); });
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
`/api/v1/conversations/12/messages`, `/api/v1/conversations/12/messages`,
{ {
params: { params: {
@ -35,7 +50,6 @@ describe('#ConversationAPI', () => {
message: 'test content', message: 'test content',
echoId: 12, echoId: 12,
isPrivate: true, isPrivate: true,
files: [new Blob(['test-content'], { type: 'application/pdf' })], files: [new Blob(['test-content'], { type: 'application/pdf' })],
}); });
expect(formPayload).toBeInstanceOf(FormData); expect(formPayload).toBeInstanceOf(FormData);
@ -58,8 +72,10 @@ describe('#ConversationAPI', () => {
private: false, private: false,
echo_id: 12, echo_id: 12,
content_attributes: { in_reply_to: 12 }, content_attributes: { in_reply_to: 12 },
bcc_emails: '',
cc_emails: '', cc_emails: '',
bcc_emails: '',
to_emails: '',
template_params: undefined,
}); });
}); });
}); });

View File

@ -1,6 +1,5 @@
import inboxesAPI from '../inboxes'; import inboxesAPI from '../inboxes';
import ApiClient from '../ApiClient'; import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#InboxesAPI', () => { describe('#InboxesAPI', () => {
it('creates correct instance', () => { it('creates correct instance', () => {
@ -14,19 +13,32 @@ describe('#InboxesAPI', () => {
expect(inboxesAPI).toHaveProperty('getAgentBot'); expect(inboxesAPI).toHaveProperty('getAgentBot');
expect(inboxesAPI).toHaveProperty('setAgentBot'); expect(inboxesAPI).toHaveProperty('setAgentBot');
}); });
describeWithAPIMock('API calls', context => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#getCampaigns', () => { it('#getCampaigns', () => {
inboxesAPI.getCampaigns(2); inboxesAPI.getCampaigns(2);
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/inboxes/2/campaigns');
'/api/v1/inboxes/2/campaigns'
);
}); });
it('#deleteInboxAvatar', () => { it('#deleteInboxAvatar', () => {
inboxesAPI.deleteInboxAvatar(2); inboxesAPI.deleteInboxAvatar(2);
expect(context.axiosMock.delete).toHaveBeenCalledWith( expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
'/api/v1/inboxes/2/avatar'
);
}); });
}); });
}); });

View File

@ -1,6 +1,5 @@
import integrationAPI from '../integrations'; import integrationAPI from '../integrations';
import ApiClient from '../ApiClient'; import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#integrationAPI', () => { describe('#integrationAPI', () => {
it('creates correct instance', () => { it('creates correct instance', () => {
@ -16,11 +15,27 @@ describe('#integrationAPI', () => {
expect(integrationAPI).toHaveProperty('listAllSlackChannels'); expect(integrationAPI).toHaveProperty('listAllSlackChannels');
expect(integrationAPI).toHaveProperty('deleteHook'); expect(integrationAPI).toHaveProperty('deleteHook');
}); });
describeWithAPIMock('API calls', context => { describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#connectSlack', () => { it('#connectSlack', () => {
const code = 'SDNFJNSDFNDSJN'; const code = 'SDNFJNSDFNDSJN';
integrationAPI.connectSlack(code); integrationAPI.connectSlack(code);
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/slack', '/api/v1/integrations/slack',
{ {
code, code,
@ -31,7 +46,7 @@ describe('#integrationAPI', () => {
it('#updateSlack', () => { it('#updateSlack', () => {
const updateObj = { referenceId: 'SDFSDGSVE' }; const updateObj = { referenceId: 'SDFSDGSVE' };
integrationAPI.updateSlack(updateObj); integrationAPI.updateSlack(updateObj);
expect(context.axiosMock.patch).toHaveBeenCalledWith( expect(axiosMock.patch).toHaveBeenCalledWith(
'/api/v1/integrations/slack', '/api/v1/integrations/slack',
{ {
reference_id: updateObj.referenceId, reference_id: updateObj.referenceId,
@ -41,16 +56,14 @@ describe('#integrationAPI', () => {
it('#listAllSlackChannels', () => { it('#listAllSlackChannels', () => {
integrationAPI.listAllSlackChannels(); integrationAPI.listAllSlackChannels();
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/slack/list_all_channels' '/api/v1/integrations/slack/list_all_channels'
); );
}); });
it('#delete', () => { it('#delete', () => {
integrationAPI.delete(2); integrationAPI.delete(2);
expect(context.axiosMock.delete).toHaveBeenCalledWith( expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/integrations/2');
'/api/v1/integrations/2'
);
}); });
it('#createHook', () => { it('#createHook', () => {
@ -59,7 +72,7 @@ describe('#integrationAPI', () => {
settings: { api_key: 'SDFSDGSVE' }, settings: { api_key: 'SDFSDGSVE' },
}; };
integrationAPI.createHook(hookData); integrationAPI.createHook(hookData);
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/hooks', '/api/v1/integrations/hooks',
hookData hookData
); );
@ -67,7 +80,7 @@ describe('#integrationAPI', () => {
it('#deleteHook', () => { it('#deleteHook', () => {
integrationAPI.deleteHook(2); integrationAPI.deleteHook(2);
expect(context.axiosMock.delete).toHaveBeenCalledWith( expect(axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/integrations/hooks/2' '/api/v1/integrations/hooks/2'
); );
}); });

View File

@ -1,6 +1,5 @@
import DyteAPIClient from '../../integrations/dyte'; import DyteAPIClient from '../../integrations/dyte';
import ApiClient from '../../ApiClient'; import ApiClient from '../../ApiClient';
import describeWithAPIMock from '../apiSpecHelper';
describe('#accountAPI', () => { describe('#accountAPI', () => {
it('creates correct instance', () => { it('creates correct instance', () => {
@ -9,10 +8,26 @@ describe('#accountAPI', () => {
expect(DyteAPIClient).toHaveProperty('addParticipantToMeeting'); expect(DyteAPIClient).toHaveProperty('addParticipantToMeeting');
}); });
describeWithAPIMock('createAMeeting', context => { describe('createAMeeting', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => { it('creates a valid request', () => {
DyteAPIClient.createAMeeting(1); DyteAPIClient.createAMeeting(1);
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/dyte/create_a_meeting', '/api/v1/integrations/dyte/create_a_meeting',
{ {
conversation_id: 1, conversation_id: 1,
@ -21,10 +36,26 @@ describe('#accountAPI', () => {
}); });
}); });
describeWithAPIMock('addParticipantToMeeting', context => { describe('addParticipantToMeeting', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => { it('creates a valid request', () => {
DyteAPIClient.addParticipantToMeeting(1); DyteAPIClient.addParticipantToMeeting(1);
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/dyte/add_participant_to_meeting', '/api/v1/integrations/dyte/add_participant_to_meeting',
{ {
message_id: 1, message_id: 1,

View File

@ -1,6 +1,5 @@
import notificationsAPI from '../notifications'; import notificationsAPI from '../notifications';
import ApiClient from '../ApiClient'; import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#NotificationAPI', () => { describe('#NotificationAPI', () => {
it('creates correct instance', () => { it('creates correct instance', () => {
@ -11,31 +10,47 @@ describe('#NotificationAPI', () => {
expect(notificationsAPI).toHaveProperty('read'); expect(notificationsAPI).toHaveProperty('read');
expect(notificationsAPI).toHaveProperty('readAll'); expect(notificationsAPI).toHaveProperty('readAll');
}); });
describeWithAPIMock('API calls', context => { describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#get', () => { it('#get', () => {
notificationsAPI.get(1); notificationsAPI.get(1);
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/notifications?page=1' '/api/v1/notifications?page=1'
); );
}); });
it('#getNotifications', () => { it('#getNotifications', () => {
notificationsAPI.getNotifications(1); notificationsAPI.getNotifications(1);
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/notifications/1/notifications' '/api/v1/notifications/1/notifications'
); );
}); });
it('#getUnreadCount', () => { it('#getUnreadCount', () => {
notificationsAPI.getUnreadCount(); notificationsAPI.getUnreadCount();
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/notifications/unread_count' '/api/v1/notifications/unread_count'
); );
}); });
it('#read', () => { it('#read', () => {
notificationsAPI.read(48670, 'Conversation'); notificationsAPI.read(48670, 'Conversation');
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/notifications/read_all', '/api/v1/notifications/read_all',
{ {
primary_actor_id: 'Conversation', primary_actor_id: 'Conversation',
@ -46,7 +61,7 @@ describe('#NotificationAPI', () => {
it('#readAll', () => { it('#readAll', () => {
notificationsAPI.readAll(); notificationsAPI.readAll();
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/notifications/read_all' '/api/v1/notifications/read_all'
); );
}); });

View File

@ -1,6 +1,5 @@
import reportsAPI from '../reports'; import reportsAPI from '../reports';
import ApiClient from '../ApiClient'; import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#Reports API', () => { describe('#Reports API', () => {
it('creates correct instance', () => { it('creates correct instance', () => {
@ -18,14 +17,30 @@ describe('#Reports API', () => {
expect(reportsAPI).toHaveProperty('getInboxReports'); expect(reportsAPI).toHaveProperty('getInboxReports');
expect(reportsAPI).toHaveProperty('getTeamReports'); expect(reportsAPI).toHaveProperty('getTeamReports');
}); });
describeWithAPIMock('API calls', context => { describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#getAccountReports', () => { it('#getAccountReports', () => {
reportsAPI.getReports({ reportsAPI.getReports({
metric: 'conversations_count', metric: 'conversations_count',
from: 1621103400, from: 1621103400,
to: 1621621800, to: 1621621800,
}); });
expect(context.axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', { expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', {
params: { params: {
metric: 'conversations_count', metric: 'conversations_count',
since: 1621103400, since: 1621103400,
@ -38,20 +53,17 @@ describe('#Reports API', () => {
it('#getAccountSummary', () => { it('#getAccountSummary', () => {
reportsAPI.getSummary(1621103400, 1621621800); reportsAPI.getSummary(1621103400, 1621621800);
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/summary', {
'/api/v2/reports/summary', params: {
{ business_hours: undefined,
params: { group_by: undefined,
business_hours: undefined, id: undefined,
group_by: undefined, since: 1621103400,
id: undefined, timezone_offset: -0,
since: 1621103400, type: 'account',
timezone_offset: -0, until: 1621621800,
type: 'account', },
until: 1621621800, });
},
}
);
}); });
it('#getAgentReports', () => { it('#getAgentReports', () => {
@ -60,60 +72,48 @@ describe('#Reports API', () => {
to: 1621621800, to: 1621621800,
businessHours: true, businessHours: true,
}); });
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/agents', {
'/api/v2/reports/agents', params: {
{ since: 1621103400,
params: { until: 1621621800,
since: 1621103400, business_hours: true,
until: 1621621800, },
business_hours: true, });
},
}
);
}); });
it('#getLabelReports', () => { it('#getLabelReports', () => {
reportsAPI.getLabelReports({ from: 1621103400, to: 1621621800 }); reportsAPI.getLabelReports({ from: 1621103400, to: 1621621800 });
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/labels', {
'/api/v2/reports/labels', params: {
{ since: 1621103400,
params: { until: 1621621800,
since: 1621103400, },
until: 1621621800, });
},
}
);
}); });
it('#getInboxReports', () => { it('#getInboxReports', () => {
reportsAPI.getInboxReports({ from: 1621103400, to: 1621621800 }); reportsAPI.getInboxReports({ from: 1621103400, to: 1621621800 });
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/inboxes', {
'/api/v2/reports/inboxes', params: {
{ since: 1621103400,
params: { until: 1621621800,
since: 1621103400, },
until: 1621621800, });
},
}
);
}); });
it('#getTeamReports', () => { it('#getTeamReports', () => {
reportsAPI.getTeamReports({ from: 1621103400, to: 1621621800 }); reportsAPI.getTeamReports({ from: 1621103400, to: 1621621800 });
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/teams', {
'/api/v2/reports/teams', params: {
{ since: 1621103400,
params: { until: 1621621800,
since: 1621103400, },
until: 1621621800, });
},
}
);
}); });
it('#getConversationMetric', () => { it('#getConversationMetric', () => {
reportsAPI.getConversationMetric('account'); reportsAPI.getConversationMetric('account');
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/conversations', '/api/v2/reports/conversations',
{ {
params: { params: {

View File

@ -1,6 +1,5 @@
import teamsAPI from '../teams'; import teamsAPI from '../teams';
import ApiClient from '../ApiClient'; import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#TeamsAPI', () => { describe('#TeamsAPI', () => {
it('creates correct instance', () => { it('creates correct instance', () => {
@ -14,17 +13,33 @@ describe('#TeamsAPI', () => {
expect(teamsAPI).toHaveProperty('addAgents'); expect(teamsAPI).toHaveProperty('addAgents');
expect(teamsAPI).toHaveProperty('updateAgents'); expect(teamsAPI).toHaveProperty('updateAgents');
}); });
describeWithAPIMock('API calls', context => { describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#getAgents', () => { it('#getAgents', () => {
teamsAPI.getAgents({ teamId: 1 }); teamsAPI.getAgents({ teamId: 1 });
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/teams/1/team_members' '/api/v1/teams/1/team_members'
); );
}); });
it('#addAgents', () => { it('#addAgents', () => {
teamsAPI.addAgents({ teamId: 1, agentsList: { user_ids: [1, 10, 21] } }); teamsAPI.addAgents({ teamId: 1, agentsList: { user_ids: [1, 10, 21] } });
expect(context.axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/teams/1/team_members', '/api/v1/teams/1/team_members',
{ {
user_ids: { user_ids: [1, 10, 21] }, user_ids: { user_ids: [1, 10, 21] },
@ -38,7 +53,7 @@ describe('#TeamsAPI', () => {
teamId: 1, teamId: 1,
agentsList, agentsList,
}); });
expect(context.axiosMock.patch).toHaveBeenCalledWith( expect(axiosMock.patch).toHaveBeenCalledWith(
'/api/v1/teams/1/team_members', '/api/v1/teams/1/team_members',
{ {
user_ids: agentsList, user_ids: agentsList,

View File

@ -149,15 +149,11 @@
} }
.multiselect-wrap--small { .multiselect-wrap--small {
.multiselect__tags,
.multiselect__input {
@apply items-center flex;
}
.multiselect__tags, .multiselect__tags,
.multiselect__input, .multiselect__input,
.multiselect { .multiselect {
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 rounded-[5px] text-sm h-10 min-h-[2.5rem]; @apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 rounded-[5px] text-sm min-h-[2.5rem];
} }
.multiselect__input { .multiselect__input {
@ -165,11 +161,15 @@
} }
.multiselect__single { .multiselect__single {
@apply items-center flex m-0 text-sm max-h-[2.375rem] text-slate-800 dark:text-slate-100 bg-white dark:bg-slate-900 py-1 px-0.5; @apply items-center flex m-0 text-sm max-h-[2.375rem] text-slate-800 dark:text-slate-100 bg-white dark:bg-slate-900 py-3 px-0.5;
} }
.multiselect__placeholder { .multiselect__placeholder {
@apply m-0 py-1 px-0.5; @apply m-0 py-2 px-0.5;
}
.multiselect__tag {
@apply py-[6px] my-[1px];
} }
.multiselect__select { .multiselect__select {
@ -181,9 +181,6 @@
@apply bg-transparent; @apply bg-transparent;
} }
.multiselect__tags-wrap {
@apply flex-shrink-0;
}
} }
.multiselect-wrap--medium { .multiselect-wrap--medium {

View File

@ -34,26 +34,26 @@
} }
&.hollow { &.hollow {
@apply border border-slate-200 dark:border-slate-600 text-woot-700 dark:text-woot-100 hover:bg-woot-50 dark:hover:bg-woot-900; @apply border border-woot-500 dark:border-woot-500 text-woot-500 dark:text-woot-500 hover:bg-woot-50 dark:hover:bg-woot-900;
&.secondary { &.secondary {
@apply text-slate-700 border-slate-200 dark:border-slate-600 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700; @apply text-slate-700 border-slate-200 dark:border-slate-600 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700;
} }
&.success { &.success {
@apply text-green-700 dark:text-green-100 hover:bg-green-50 dark:hover:bg-green-800; @apply text-green-700 dark:text-green-400 border-green-100 dark:border-green-600 hover:bg-green-50 dark:hover:bg-green-800;
} }
&.alert { &.alert {
@apply text-red-700 dark:text-red-100 hover:bg-red-50 dark:hover:bg-red-800; @apply text-red-700 dark:text-red-400 border-red-100 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-800;
} }
&.warning { &.warning {
@apply text-yellow-700 dark:text-yellow-100 hover:bg-yellow-50 dark:hover:bg-yellow-800; @apply text-yellow-600 dark:text-yellow-600 border-yellow-600 dark:border-yellow-700 hover:bg-yellow-50 dark:hover:bg-yellow-800;
} }
&:hover { &:hover {
@apply bg-slate-75 dark:bg-slate-900 border-slate-100 dark:border-slate-700; @apply bg-woot-75 dark:bg-woot-800 border-slate-100 dark:border-woot-600 dark:text-woot-400;
&.secondary { &.secondary {
@apply border-slate-100 dark:border-slate-700 text-slate-800 dark:text-slate-100; @apply border-slate-100 dark:border-slate-700 text-slate-800 dark:text-slate-100;
@ -68,7 +68,7 @@
} }
&.warning { &.warning {
@apply border-slate-100 dark:border-slate-700 text-yellow-700 dark:text-yellow-700; @apply border-slate-100 dark:border-slate-700 text-yellow-700 dark:text-yellow-500;
} }
} }
} }

View File

@ -152,6 +152,10 @@
&.is-image { &.is-image {
@apply rounded-lg; @apply rounded-lg;
.message__mail-head {
@apply px-4 py-2;
}
} }
} }

View File

@ -4,10 +4,6 @@
@apply flex items-center justify-center bg-modal dark:bg-modal z-[9990] h-full left-0 fixed top-0 w-full; @apply flex items-center justify-center bg-modal dark:bg-modal z-[9990] h-full left-0 fixed top-0 w-full;
} }
.modal--close {
@apply absolute right-2 rtl:right-[unset] rtl:left-2 top-2;
}
.page-top-bar { .page-top-bar {
@apply px-8 pt-9 pb-0; @apply px-8 pt-9 pb-0;

View File

@ -10,7 +10,8 @@
<div <div
class="flex items-center justify-between py-0 px-4" class="flex items-center justify-between py-0 px-4"
:class="{ :class="{
'pb-3 border-b border-slate-75 dark:border-slate-700': hasAppliedFiltersOrActiveFolders, 'pb-3 border-b border-slate-75 dark:border-slate-700':
hasAppliedFiltersOrActiveFolders,
}" }"
> >
<div class="flex max-w-[85%] justify-center items-center"> <div class="flex max-w-[85%] justify-center items-center">
@ -24,9 +25,7 @@
v-if="!hasAppliedFiltersOrActiveFolders" v-if="!hasAppliedFiltersOrActiveFolders"
class="p-1 my-0.5 mx-1 rounded-md capitalize bg-slate-50 dark:bg-slate-800 text-xxs text-slate-600 dark:text-slate-300" class="p-1 my-0.5 mx-1 rounded-md capitalize bg-slate-50 dark:bg-slate-800 text-xxs text-slate-600 dark:text-slate-300"
> >
{{ {{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
this.$t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`)
}}
</span> </span>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@ -642,10 +641,8 @@ export default {
}, },
handleKeyEvents(e) { handleKeyEvents(e) {
if (hasPressedAltAndJKey(e)) { if (hasPressedAltAndJKey(e)) {
const { const { allConversations, activeConversationIndex } =
allConversations, this.getKeyboardListenerParams();
activeConversationIndex,
} = this.getKeyboardListenerParams();
if (activeConversationIndex === -1) { if (activeConversationIndex === -1) {
allConversations[0].click(); allConversations[0].click();
} }

View File

@ -13,10 +13,10 @@
/> />
<div class="flex flex-row justify-end gap-2 py-2 px-0 w-full"> <div class="flex flex-row justify-end gap-2 py-2 px-0 w-full">
<woot-button variant="clear" @click.prevent="onClose"> <woot-button variant="clear" @click.prevent="onClose">
{{ this.$t('CONVERSATION.CUSTOM_SNOOZE.CANCEL') }} {{ $t('CONVERSATION.CUSTOM_SNOOZE.CANCEL') }}
</woot-button> </woot-button>
<woot-button> <woot-button>
{{ this.$t('CONVERSATION.CUSTOM_SNOOZE.APPLY') }} {{ $t('CONVERSATION.CUSTOM_SNOOZE.APPLY') }}
</woot-button> </woot-button>
</div> </div>
</form> </form>

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="text--container"> <div class="text--container">
<woot-button size="small" class=" button--text" @click="onCopy"> <woot-button size="small" class="button--text" @click="onCopy">
{{ $t('COMPONENTS.CODE.BUTTON_TEXT') }} {{ $t('COMPONENTS.CODE.BUTTON_TEXT') }}
</woot-button> </woot-button>
<woot-button <woot-button

View File

@ -4,15 +4,19 @@
v-if="show" v-if="show"
:class="modalClassName" :class="modalClassName"
transition="modal" transition="modal"
@click="onBackDropClick" @mousedown="handleMouseDown"
> >
<div :class="modalContainerClassName" @click.stop> <div
:class="modalContainerClassName"
@mouse.stop
@mousedown="event => event.stopPropagation()"
>
<woot-button <woot-button
v-if="showCloseButton" v-if="showCloseButton"
color-scheme="secondary" color-scheme="secondary"
icon="dismiss" icon="dismiss"
variant="clear" variant="clear"
class="modal--close" class="absolute ltr:right-2 rtl:left-2 top-2 z-10"
@click="close" @click="close"
/> />
<slot /> <slot />
@ -50,6 +54,11 @@ export default {
default: '', default: '',
}, },
}, },
data() {
return {
mousedDownOnBackdrop: false,
};
},
computed: { computed: {
modalContainerClassName() { modalContainerClassName() {
let className = let className =
@ -66,9 +75,9 @@ export default {
'right-aligned': 'right-aligned', 'right-aligned': 'right-aligned',
}; };
return `modal-mask skip-context-menu ${modalClassNameMap[ return `modal-mask skip-context-menu ${
this.modalType modalClassNameMap[this.modalType] || ''
] || ''}`; }`;
}, },
}, },
mounted() { mounted() {
@ -77,13 +86,22 @@ export default {
this.onClose(); this.onClose();
} }
}); });
document.body.addEventListener('mouseup', this.onMouseUp);
},
beforeDestroy() {
document.body.removeEventListener('mouseup', this.onMouseUp);
}, },
methods: { methods: {
handleMouseDown() {
this.mousedDownOnBackdrop = true;
},
close() { close() {
this.onClose(); this.onClose();
}, },
onBackDropClick() { onMouseUp() {
if (this.closeOnBackdropClick) { if (this.mousedDownOnBackdrop) {
this.mousedDownOnBackdrop = false;
this.onClose(); this.onClose();
} }
}, },

View File

@ -2,7 +2,8 @@
<div <div
class="ml-0 mr-0 flex pt-0 pr-4 pb-4 pl-0" class="ml-0 mr-0 flex pt-0 pr-4 pb-4 pl-0"
:class="{ :class="{
'pt-4 border-b border-solid border-slate-50 dark:border-slate-700/30': showBorder, 'pt-4 border-b border-solid border-slate-50 dark:border-slate-700/30':
showBorder,
}" }"
> >
<div class="w-[30%] min-w-0 max-w-[30%] pr-12"> <div class="w-[30%] min-w-0 max-w-[30%] pr-12">

View File

@ -72,10 +72,8 @@ export default {
const { custom_attributes: subscription } = account; const { custom_attributes: subscription } = account;
if (!subscription) return EMPTY_SUBSCRIPTION_INFO; if (!subscription) return EMPTY_SUBSCRIPTION_INFO;
const { const { subscription_status: status, subscription_ends_on: endsOn } =
subscription_status: status, subscription;
subscription_ends_on: endsOn,
} = subscription;
return { status, endsOn: new Date(endsOn) }; return { status, endsOn: new Date(endsOn) };
}, },

View File

@ -1,4 +1,4 @@
const semver = require('semver'); import semver from 'semver';
export const hasAnUpdateAvailable = (latestVersion, currentVersion) => { export const hasAnUpdateAvailable = (latestVersion, currentVersion) => {
if (!semver.valid(latestVersion)) { if (!semver.valid(latestVersion)) {

View File

@ -10,7 +10,7 @@
:is-loading="isLoading" :is-loading="isLoading"
@click="onCmdResolveConversation" @click="onCmdResolveConversation"
> >
{{ this.$t('CONVERSATION.HEADER.RESOLVE_ACTION') }} {{ $t('CONVERSATION.HEADER.RESOLVE_ACTION') }}
</woot-button> </woot-button>
<woot-button <woot-button
v-else-if="isResolved" v-else-if="isResolved"
@ -21,7 +21,7 @@
:is-loading="isLoading" :is-loading="isLoading"
@click="onCmdOpenConversation" @click="onCmdOpenConversation"
> >
{{ this.$t('CONVERSATION.HEADER.REOPEN_ACTION') }} {{ $t('CONVERSATION.HEADER.REOPEN_ACTION') }}
</woot-button> </woot-button>
<woot-button <woot-button
v-else-if="showOpenButton" v-else-if="showOpenButton"
@ -31,7 +31,7 @@
:is-loading="isLoading" :is-loading="isLoading"
@click="onCmdOpenConversation" @click="onCmdOpenConversation"
> >
{{ this.$t('CONVERSATION.HEADER.OPEN_ACTION') }} {{ $t('CONVERSATION.HEADER.OPEN_ACTION') }}
</woot-button> </woot-button>
<woot-button <woot-button
v-if="showAdditionalActions" v-if="showAdditionalActions"
@ -57,7 +57,7 @@
icon="snooze" icon="snooze"
@click="() => openSnoozeModal()" @click="() => openSnoozeModal()"
> >
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE_UNTIL') }} {{ $t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE_UNTIL') }}
</woot-button> </woot-button>
</woot-dropdown-item> </woot-dropdown-item>
<woot-dropdown-item v-if="!isPending"> <woot-dropdown-item v-if="!isPending">
@ -68,7 +68,7 @@
icon="book-clock" icon="book-clock"
@click="() => toggleStatus(STATUS_TYPE.PENDING)" @click="() => toggleStatus(STATUS_TYPE.PENDING)"
> >
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }} {{ $t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }}
</woot-button> </woot-button>
</woot-dropdown-item> </woot-dropdown-item>
</woot-dropdown-menu> </woot-dropdown-menu>

View File

@ -11,7 +11,6 @@
@key-shortcut-modal="toggleKeyShortcutModal" @key-shortcut-modal="toggleKeyShortcutModal"
@open-notification-panel="openNotificationPanel" @open-notification-panel="openNotificationPanel"
/> />
<secondary-sidebar <secondary-sidebar
v-if="showSecondarySidebar" v-if="showSecondarySidebar"
:class="sidebarClassName" :class="sidebarClassName"
@ -114,7 +113,12 @@ export default {
if (!isAvailableForTheUser) { if (!isAvailableForTheUser) {
return false; return false;
} }
if (
menuItem.alwaysVisibleOnChatwootInstances &&
!this.isACustomBrandedInstance
) {
return true;
}
if (menuItem.featureFlag) { if (menuItem.featureFlag) {
return this.isFeatureEnabledonAccount( return this.isFeatureEnabledonAccount(
this.accountId, this.accountId,

View File

@ -42,6 +42,7 @@ const primaryMenuItems = accountId => [
key: 'helpcenter', key: 'helpcenter',
label: 'HELP_CENTER.TITLE', label: 'HELP_CENTER.TITLE',
featureFlag: FEATURE_FLAGS.HELP_CENTER, featureFlag: FEATURE_FLAGS.HELP_CENTER,
alwaysVisibleOnChatwootInstances: true,
toState: frontendURL(`accounts/${accountId}/portals`), toState: frontendURL(`accounts/${accountId}/portals`),
toStateName: 'default_portal_articles', toStateName: 'default_portal_articles',
roles: ['administrator'], roles: ['administrator'],

View File

@ -34,8 +34,8 @@
<woot-submit-button <woot-submit-button
:disabled=" :disabled="
$v.accountName.$invalid || $v.accountName.$invalid ||
$v.accountName.$invalid || $v.accountName.$invalid ||
uiFlags.isCreating uiFlags.isCreating
" "
:button-text="$t('CREATE_ACCOUNT.FORM.SUBMIT')" :button-text="$t('CREATE_ACCOUNT.FORM.SUBMIT')"
:loading="uiFlags.isCreating" :loading="uiFlags.isCreating"

View File

@ -3,7 +3,8 @@
<button <button
class="text-slate-600 dark:text-slate-100 w-10 h-10 my-2 flex items-center justify-center rounded-lg hover:bg-slate-25 dark:hover:bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600 relative" class="text-slate-600 dark:text-slate-100 w-10 h-10 my-2 flex items-center justify-center rounded-lg hover:bg-slate-25 dark:hover:bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600 relative"
:class="{ :class="{
'bg-woot-50 dark:bg-slate-800 text-woot-500 hover:bg-woot-50': isNotificationPanelActive, 'bg-woot-50 dark:bg-slate-800 text-woot-500 hover:bg-woot-50':
isNotificationPanelActive,
}" }"
@click="openNotificationPanel" @click="openNotificationPanel"
> >

View File

@ -9,7 +9,8 @@
class="font-medium h-7 my-1 hover:bg-slate-25 hover:text-bg-50 flex items-center px-2 rounded-md dark:hover:bg-slate-800" class="font-medium h-7 my-1 hover:bg-slate-25 hover:text-bg-50 flex items-center px-2 rounded-md dark:hover:bg-slate-800"
:class="{ :class="{
'bg-woot-25 dark:bg-slate-800': isActive, 'bg-woot-25 dark:bg-slate-800': isActive,
'text-ellipsis overflow-hidden whitespace-nowrap max-w-full': shouldTruncate, 'text-ellipsis overflow-hidden whitespace-nowrap max-w-full':
shouldTruncate,
}" }"
@click="navigate" @click="navigate"
> >
@ -44,7 +45,8 @@
class="text-sm text-slate-700 dark:text-slate-100" class="text-sm text-slate-700 dark:text-slate-100"
:class="{ :class="{
'text-woot-500 dark:text-woot-500': isActive, 'text-woot-500 dark:text-woot-500': isActive,
'text-ellipsis overflow-hidden whitespace-nowrap max-w-full': shouldTruncate, 'text-ellipsis overflow-hidden whitespace-nowrap max-w-full':
shouldTruncate,
}" }"
> >
{{ label }} {{ label }}

View File

@ -35,7 +35,8 @@
:class="{ :class="{
'text-slate-300 dark:text-slate-600': isCountZero && !isActiveView, 'text-slate-300 dark:text-slate-600': isCountZero && !isActiveView,
'text-slate-600 dark:text-slate-50': !isCountZero && !isActiveView, 'text-slate-600 dark:text-slate-50': !isCountZero && !isActiveView,
'bg-woot-75 dark:bg-woot-200 text-woot-600 dark:text-woot-600': isActiveView, 'bg-woot-75 dark:bg-woot-200 text-woot-600 dark:text-woot-600':
isActiveView,
'bg-slate-50 dark:bg-slate-700': !isActiveView, 'bg-slate-50 dark:bg-slate-700': !isActiveView,
}" }"
> >

View File

@ -53,16 +53,24 @@ export default {
const createdTimeDiff = Date.now() - this.createdAtTimestamp * 1000; const createdTimeDiff = Date.now() - this.createdAtTimestamp * 1000;
const isBeforeAMonth = createdTimeDiff > DAY_IN_MILLI_SECONDS * 30; const isBeforeAMonth = createdTimeDiff > DAY_IN_MILLI_SECONDS * 30;
return !isBeforeAMonth return !isBeforeAMonth
? `Created ${this.createdAtTimeAgo}` ? `${this.$t('CHAT_LIST.CHAT_TIME_STAMP.CREATED.LATEST')} ${
: `Created at: ${this.dateFormat(this.createdAtTimestamp)}`; this.createdAtTimeAgo
}`
: `${this.$t(
'CHAT_LIST.CHAT_TIME_STAMP.CREATED.OLDEST'
)} ${this.dateFormat(this.createdAtTimestamp)}`;
}, },
lastActivity() { lastActivity() {
const lastActivityTimeDiff = const lastActivityTimeDiff =
Date.now() - this.lastActivityTimestamp * 1000; Date.now() - this.lastActivityTimestamp * 1000;
const isNotActive = lastActivityTimeDiff > DAY_IN_MILLI_SECONDS * 30; const isNotActive = lastActivityTimeDiff > DAY_IN_MILLI_SECONDS * 30;
return !isNotActive return !isNotActive
? `Last activity ${this.lastActivityAtTimeAgo}` ? `${this.$t('CHAT_LIST.CHAT_TIME_STAMP.LAST_ACTIVITY.ACTIVE')} ${
: `Last activity: ${this.dateFormat(this.lastActivityTimestamp)}`; this.lastActivityAtTimeAgo
}`
: `${this.$t(
'CHAT_LIST.CHAT_TIME_STAMP.LAST_ACTIVITY.NOT_ACTIVE'
)} ${this.dateFormat(this.lastActivityTimestamp)}`;
}, },
tooltipText() { tooltipText() {
return `${this.createdAt} return `${this.createdAt}

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="animation-container margin-top-1"> <div class="animation-container margin-top-1">
<div class="ai-typing--wrap "> <div class="ai-typing--wrap">
<fluent-icon icon="wand" size="14" class="ai-typing--icon" /> <fluent-icon icon="wand" size="14" class="ai-typing--icon" />
<label> <label>
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.AI_WRITING') }} {{ $t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.AI_WRITING') }}

View File

@ -1,11 +1,9 @@
<template> <template>
<div <div class="preview-item__wrap flex overflow-auto max-h-[12.5rem]">
class="preview-item__wrap flex flex-col overflow-auto mt-4 max-h-[12.5rem]"
>
<div <div
v-for="(attachment, index) in attachments" v-for="(attachment, index) in attachments"
:key="attachment.id" :key="attachment.id"
class="preview-item flex p-1 bg-slate-50 dark:bg-slate-800 rounded-md w-[15rem] mb-1" class="preview-item flex items-center p-1 bg-slate-50 dark:bg-slate-800 gap-1 rounded-md w-[15rem] mb-1"
> >
<div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center"> <div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
<img <img
@ -17,7 +15,7 @@
📄 📄
</span> </span>
</div> </div>
<div class="max-w-[60%] min-w-[50%] overflow-hidden text-ellipsis ml-2"> <div class="max-w-[60%] min-w-[50%] overflow-hidden text-ellipsis">
<span <span
class="h-4 overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap" class="h-4 overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap"
> >

View File

@ -12,7 +12,7 @@
/> />
<iframe <iframe
v-if="configItem.type === 'frame' && configItem.url" v-if="configItem.type === 'frame' && configItem.url"
:id="`dashboard-app--frame-${index}`" :id="getFrameId(index)"
:src="configItem.url" :src="configItem.url"
@load="() => onIframeLoad(index)" @load="() => onIframeLoad(index)"
/> />
@ -39,6 +39,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
position: {
type: Number,
required: true,
},
}, },
data() { data() {
return { return {
@ -82,10 +86,14 @@ export default {
}; };
}, },
methods: { methods: {
getFrameId(index) {
return `dashboard-app--frame-${this.position}-${index}`;
},
onIframeLoad(index) { onIframeLoad(index) {
const frameElement = document.getElementById( // A possible alternative is to use ref instead of document.getElementById
`dashboard-app--frame-${index}` // However, when ref is used together with v-for, the ref you get will be
); // an array containing the child components mirroring the data source.
const frameElement = document.getElementById(this.getFrameId(index));
const eventData = { event: 'appContext', data: this.dashboardAppContext }; const eventData = { event: 'appContext', data: this.dashboardAppContext };
frameElement.contentWindow.postMessage(JSON.stringify(eventData), '*'); frameElement.contentWindow.postMessage(JSON.stringify(eventData), '*');
this.iframeLoading = false; this.iframeLoading = false;

View File

@ -1,3 +1,4 @@
<!-- eslint-disable vue/no-mutating-props -->
<template> <template>
<div> <div>
<div <div
@ -255,7 +256,9 @@ export default {
value === 'conversation_attribute' || value === 'conversation_attribute' ||
value === 'contact_attribute' value === 'contact_attribute'
) { ) {
// eslint-disable-next-line vue/no-mutating-props
this.value.custom_attribute_type = this.customAttributeType; this.value.custom_attribute_type = this.customAttributeType;
// eslint-disable-next-line vue/no-mutating-props
} else this.value.custom_attribute_type = ''; } else this.value.custom_attribute_type = '';
}, },
immediate: true, immediate: true,

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="editor-root"> <div ref="editorRoot" class="relative editor-root">
<tag-agents <tag-agents
v-if="showUserMentions && isPrivate" v-if="showUserMentions && isPrivate"
:search-key="mentionSearchKey" :search-key="mentionSearchKey"
@ -23,6 +23,24 @@
@change="onFileChange" @change="onFileChange"
/> />
<div ref="editor" /> <div ref="editor" />
<div
v-show="isImageNodeSelected && showImageResizeToolbar"
class="absolute shadow-md rounded-[4px] flex gap-1 py-1 px-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-50"
:style="{
top: toolbarPosition.top,
left: toolbarPosition.left,
}"
>
<button
v-for="size in sizes"
:key="size.name"
class="text-xs font-medium rounded-[4px] border border-solid border-slate-200 dark:border-slate-600 px-1.5 py-0.5 hover:bg-slate-100 dark:hover:bg-slate-800"
@click="setURLWithQueryAndImageSize(size)"
>
{{ size.name }}
</button>
</div>
<slot name="footer" />
</div> </div>
</template> </template>
@ -40,6 +58,7 @@ import {
suggestionsPlugin, suggestionsPlugin,
triggerCharacters, triggerCharacters,
} from '@chatwoot/prosemirror-schema/src/mentions/plugin'; } from '@chatwoot/prosemirror-schema/src/mentions/plugin';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import TagAgents from '../conversation/TagAgents.vue'; import TagAgents from '../conversation/TagAgents.vue';
import CannedResponse from '../conversation/CannedResponse.vue'; import CannedResponse from '../conversation/CannedResponse.vue';
@ -47,6 +66,10 @@ import VariableList from '../conversation/VariableList.vue';
import { import {
appendSignature, appendSignature,
removeSignature, removeSignature,
insertAtCursor,
scrollCursorIntoView,
findNodeToInsertImage,
setURLWithQueryAndSize,
} from 'dashboard/helper/editorHelper'; } from 'dashboard/helper/editorHelper';
const TYPING_INDICATOR_IDLE_TIME = 4000; const TYPING_INDICATOR_IDLE_TIME = 4000;
@ -66,13 +89,17 @@ import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper'; import { uploadFile } from 'dashboard/helper/uploadHelper';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import { findNodeToInsertImage } from 'dashboard/helper/messageEditorHelper'; import {
import { MESSAGE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor'; MESSAGE_EDITOR_MENU_OPTIONS,
MESSAGE_EDITOR_IMAGE_RESIZES,
} from 'dashboard/constants/editor';
const createState = ( const createState = (
content, content,
placeholder, placeholder,
// eslint-disable-next-line default-param-last
plugins = [], plugins = [],
// eslint-disable-next-line default-param-last
methods = {}, methods = {},
enabledMenuOptions enabledMenuOptions
) => { ) => {
@ -108,6 +135,7 @@ export default {
// allowSignature is a kill switch, ensuring no signature methods // allowSignature is a kill switch, ensuring no signature methods
// are triggered except when this flag is true // are triggered except when this flag is true
allowSignature: { type: Boolean, default: false }, allowSignature: { type: Boolean, default: false },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
}, },
data() { data() {
return { return {
@ -120,9 +148,16 @@ export default {
editorView: null, editorView: null,
range: null, range: null,
state: undefined, state: undefined,
isImageNodeSelected: false,
toolbarPosition: { top: 0, left: 0 },
sizes: MESSAGE_EDITOR_IMAGE_RESIZES,
selectedImageNode: null,
}; };
}, },
computed: { computed: {
editorRoot() {
return this.$refs.editorRoot;
},
contentFromEditor() { contentFromEditor() {
return MessageMarkdownSerializer.serialize(this.editorView.state.doc); return MessageMarkdownSerializer.serialize(this.editorView.state.doc);
}, },
@ -270,6 +305,7 @@ export default {
const tr = this.editorView.state.tr.replaceSelectionWith(node); const tr = this.editorView.state.tr.replaceSelectionWith(node);
this.editorView.focus(); this.editorView.focus();
this.state = this.editorView.state.apply(tr); this.state = this.editorView.state.apply(tr);
this.editorView.updateState(this.state);
this.emitOnChange(); this.emitOnChange();
this.$emit('clear-selection'); this.$emit('clear-selection');
} }
@ -295,7 +331,17 @@ export default {
mounted() { mounted() {
this.createEditorView(); this.createEditorView();
this.editorView.updateState(this.state); this.editorView.updateState(this.state);
this.focusEditor(this.value); this.focusEditorInputField();
// BUS Event to insert text or markdown into the editor at the
// current cursor position.
// Components using this
// 1. SearchPopover.vue
bus.$on(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.insertContentIntoEditor);
},
beforeDestroy() {
bus.$off(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.insertContentIntoEditor);
}, },
methods: { methods: {
reloadState(content = this.value) { reloadState(content = this.value) {
@ -382,6 +428,7 @@ export default {
state: this.state, state: this.state,
dispatchTransaction: tx => { dispatchTransaction: tx => {
this.state = this.state.apply(tx); this.state = this.state.apply(tx);
this.editorView.updateState(this.state);
this.emitOnChange(); this.emitOnChange();
}, },
handleDOMEvents: { handleDOMEvents: {
@ -394,6 +441,9 @@ export default {
focus: () => { focus: () => {
this.onFocus(); this.onFocus();
}, },
click: () => {
// this.isEditorMouseFocusedOnAnImage(); Enable it when the backend supports for message resize is done.
},
blur: () => { blur: () => {
this.onBlur(); this.onBlur();
}, },
@ -406,6 +456,50 @@ export default {
}, },
}); });
}, },
isEditorMouseFocusedOnAnImage() {
if (!this.showImageResizeToolbar) {
return;
}
this.selectedImageNode = document.querySelector(
'img.ProseMirror-selectednode'
);
if (this.selectedImageNode) {
this.isImageNodeSelected = !!this.selectedImageNode;
// Get the position of the selected node
this.setToolbarPosition();
} else {
this.isImageNodeSelected = false;
}
},
setToolbarPosition() {
const editorRect = this.editorRoot.getBoundingClientRect();
const rect = this.selectedImageNode.getBoundingClientRect();
this.toolbarPosition = {
top: `${rect.top - editorRect.top - 30}px`,
left: `${rect.left - editorRect.left - 4}px`,
};
},
setURLWithQueryAndImageSize(size) {
if (!this.showImageResizeToolbar) {
return;
}
setURLWithQueryAndSize(this.selectedImageNode, size, this.editorView);
this.isImageNodeSelected = false;
},
updateImgToolbarOnDelete() {
// check if the selected node is present or not on keyup
// this is needed because the user can select an image and then delete it
// in that case, the selected node will be null and we need to hide the toolbar
// otherwise, the toolbar will be visible even when the image is deleted and cause some errors
if (this.selectedImageNode) {
const hasImgSelectedNode = document.querySelector(
'img.ProseMirror-selectednode'
);
if (!hasImgSelectedNode) {
this.isImageNodeSelected = false;
}
}
},
isEnterToSendEnabled() { isEnterToSendEnabled() {
return isEditorHotKeyEnabled(this.uiSettings, 'enter'); return isEditorHotKeyEnabled(this.uiSettings, 'enter');
}, },
@ -438,11 +532,7 @@ export default {
userFullName: mentionItem.name, userFullName: mentionItem.name,
}); });
const tr = this.editorView.state.tr this.insertNodeIntoEditor(node, this.range.from, this.range.to);
.replaceWith(this.range.from, this.range.to, node)
.insertText(` `);
this.state = this.editorView.state.apply(tr);
this.emitOnChange();
this.$track(CONVERSATION_EVENTS.USED_MENTIONS); this.$track(CONVERSATION_EVENTS.USED_MENTIONS);
return false; return false;
@ -452,30 +542,22 @@ export default {
message: cannedItem, message: cannedItem,
variables: this.variables, variables: this.variables,
}); });
if (!this.editorView) { if (!this.editorView) {
return null; return null;
} }
let from = this.range.from - 1;
let node = new MessageMarkdownTransformer(messageSchema).parse( let node = new MessageMarkdownTransformer(messageSchema).parse(
updatedMessage updatedMessage
); );
if (node.textContent === updatedMessage) { const from =
node = this.editorView.state.schema.text(updatedMessage); node.textContent === updatedMessage
from = this.range.from; ? this.range.from
} : this.range.from - 1;
const tr = this.editorView.state.tr.replaceWith( this.insertNodeIntoEditor(node, from, this.range.to);
from,
this.range.to,
node
);
this.state = this.editorView.state.apply(tr);
this.emitOnChange();
tr.scrollIntoView();
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE); this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
return false; return false;
}, },
@ -483,23 +565,14 @@ export default {
if (!this.editorView) { if (!this.editorView) {
return null; return null;
} }
let node = this.editorView.state.schema.text(`{{${variable}}}`);
const from = this.range.from;
const tr = this.editorView.state.tr.replaceWith( const content = `{{${variable}}}`;
from, let node = this.editorView.state.schema.text(content);
this.range.to, const { from, to } = this.range;
node
);
this.state = this.editorView.state.apply(tr); this.insertNodeIntoEditor(node, from, to);
this.emitOnChange();
// The `{{ }}` are added to the message, but the cursor is placed
// and onExit of suggestionsPlugin is not called. So we need to manually hide
this.showVariables = false; this.showVariables = false;
this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE); this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
tr.scrollIntoView();
return false; return false;
}, },
openFileBrowser() { openFileBrowser() {
@ -555,8 +628,6 @@ export default {
}, },
emitOnChange() { emitOnChange() {
this.editorView.updateState(this.state);
this.$emit('input', this.contentFromEditor); this.$emit('input', this.contentFromEditor);
}, },
@ -599,6 +670,7 @@ export default {
() => this.resetTyping(), () => this.resetTyping(),
TYPING_INDICATOR_IDLE_TIME TYPING_INDICATOR_IDLE_TIME
); );
// this.updateImgToolbarOnDelete(); Enable it when the backend supports for message resize is done.
}, },
onKeydown(event) { onKeydown(event) {
if (this.isEnterToSendEnabled()) { if (this.isEnterToSendEnabled()) {
@ -616,6 +688,19 @@ export default {
onFocus() { onFocus() {
this.$emit('focus'); this.$emit('focus');
}, },
insertContentIntoEditor(content, defaultFrom = 0) {
const from = defaultFrom || this.editorView.state.selection.from || 0;
let node = new MessageMarkdownTransformer(messageSchema).parse(content);
this.insertNodeIntoEditor(node, from, undefined);
},
insertNodeIntoEditor(node, from = 0, to = 0) {
this.state = insertAtCursor(this.editorView, node, from, to);
this.emitOnChange();
this.$nextTick(() => {
scrollCursorIntoView(this.editorView);
});
},
}, },
}; };
</script> </script>
@ -709,6 +794,6 @@ export default {
} }
.editor-warning__message { .editor-warning__message {
@apply text-red-400 dark:text-red-400 font-normal pt-1 pb-0 px-0; @apply text-red-400 dark:text-red-400 font-normal text-sm pt-1 pb-0 px-0;
} }
</style> </style>

View File

@ -32,7 +32,9 @@ const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const createState = ( const createState = (
content, content,
placeholder, placeholder,
// eslint-disable-next-line default-param-last
plugins = [], plugins = [],
// eslint-disable-next-line default-param-last
methods = {}, methods = {},
enabledMenuOptions enabledMenuOptions
) => { ) => {

View File

@ -14,10 +14,11 @@
<file-upload <file-upload
ref="upload" ref="upload"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')" v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
input-id="conversationAttachment"
:size="4096 * 4096" :size="4096 * 4096"
:accept="allowedFileTypes" :accept="allowedFileTypes"
:multiple="enableMultipleFileUpload" :multiple="enableMultipleFileUpload"
:drop="true" :drop="enableDragAndDrop"
:drop-directory="false" :drop-directory="false"
:data="{ :data="{
direct_upload_url: '/rails/active_storage/direct_uploads', direct_upload_url: '/rails/active_storage/direct_uploads',
@ -100,9 +101,9 @@
<transition name="modal-fade"> <transition name="modal-fade">
<div <div
v-show="$refs.upload && $refs.upload.dropActive" v-show="$refs.upload && $refs.upload.dropActive"
class="modal-mask" class="flex items-center justify-center gap-2 fixed left-0 right-0 top-0 bottom-0 w-full h-full z-20 text-slate-600 dark:text-slate-200 bg-white_transparent dark:bg-black_transparent flex-col"
> >
<fluent-icon icon="cloud-backup" /> <fluent-icon icon="cloud-backup" size="40" />
<h4 class="page-sub-title text-slate-600 dark:text-slate-200"> <h4 class="page-sub-title text-slate-600 dark:text-slate-200">
{{ $t('CONVERSATION.REPLYBOX.DRAG_DROP') }} {{ $t('CONVERSATION.REPLYBOX.DRAG_DROP') }}
</h4> </h4>
@ -228,6 +229,10 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
newConversationModalActive: {
type: Boolean,
default: false,
},
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
@ -274,6 +279,9 @@ export default {
} }
return ALLOWED_FILE_TYPES; return ALLOWED_FILE_TYPES;
}, },
enableDragAndDrop() {
return !this.newConversationModalActive;
},
audioRecorderPlayStopIcon() { audioRecorderPlayStopIcon() {
switch (this.recordingAudioState) { switch (this.recordingAudioState) {
// playing paused recording stopped inactive destroyed // playing paused recording stopped inactive destroyed
@ -346,12 +354,4 @@ export default {
@apply dark:bg-slate-800 bg-slate-100; @apply dark:bg-slate-800 bg-slate-100;
} }
} }
.modal-mask {
@apply text-slate-600 dark:text-slate-200 bg-white_transparent dark:bg-black_transparent flex-col;
}
.icon {
@apply text-[5rem];
}
</style> </style>

View File

@ -1,8 +1,6 @@
<template> <template>
<div <div
:class=" :class="`status-badge status-badge__${status} rounded-full w-2.5 h-2.5 mr-0.5 rtl:mr-0 rtl:ml-0.5 inline-flex`"
`status-badge status-badge__${status} rounded-full w-2.5 h-2.5 mr-0.5 rtl:mr-0 rtl:ml-0.5 inline-flex`
"
/> />
</template> </template>
<script> <script>

View File

@ -16,7 +16,7 @@
> >
<div class="items-center flex justify-between last:mt-4"> <div class="items-center flex justify-between last:mt-4">
<span class="text-slate-800 dark:text-slate-100 text-xs font-medium">{{ <span class="text-slate-800 dark:text-slate-100 text-xs font-medium">{{
this.$t('CHAT_LIST.CHAT_SORT.STATUS') $t('CHAT_LIST.CHAT_SORT.STATUS')
}}</span> }}</span>
<filter-item <filter-item
type="status" type="status"
@ -28,7 +28,7 @@
</div> </div>
<div class="items-center flex justify-between last:mt-4"> <div class="items-center flex justify-between last:mt-4">
<span class="text-slate-800 dark:text-slate-100 text-xs font-medium">{{ <span class="text-slate-800 dark:text-slate-100 text-xs font-medium">{{
this.$t('CHAT_LIST.CHAT_SORT.ORDER_BY') $t('CHAT_LIST.CHAT_SORT.ORDER_BY')
}}</span> }}</span>
<filter-item <filter-item
type="sort" type="sort"

View File

@ -52,6 +52,7 @@
:key="currentChat.id + '-' + dashboardApp.id" :key="currentChat.id + '-' + dashboardApp.id"
:is-visible="activeIndex - 1 === index" :is-visible="activeIndex - 1 === index"
:config="dashboardApps[index].content" :config="dashboardApps[index].content"
:position="index"
:current-chat="currentChat" :current-chat="currentChat"
/> />
</div> </div>

View File

@ -1,6 +1,6 @@
<template> <template>
<div <div
class="conversation flex flex-shrink-0 flex-grow-0 w-auto max-w-full cursor-pointer relative py-0 px-4 border-transparent border-l-2 border-t-0 border-b-0 border-r-0 border-solid items-start hover:bg-slate-25 dark:hover:bg-slate-800 group" class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-slate-25 dark:hover:bg-slate-800 group"
:class="{ :class="{
'active bg-slate-25 dark:bg-slate-800 border-woot-500': isActiveChat, 'active bg-slate-25 dark:bg-slate-800 border-woot-500': isActiveChat,
'unread-chat': hasUnread, 'unread-chat': hasUnread,
@ -31,7 +31,7 @@
size="40px" size="40px"
/> />
<div <div
class="py-3 px-0 border-b group-last:border-transparent group-hover:border-transparent border-slate-50 dark:border-slate-800/75 columns" class="px-0 py-3 border-b group-last:border-transparent group-hover:border-transparent border-slate-50 dark:border-slate-800/75 columns"
> >
<div class="flex justify-between"> <div class="flex justify-between">
<inbox-name v-if="showInboxName" :inbox="inbox" /> <inbox-name v-if="showInboxName" :inbox="inbox" />
@ -55,44 +55,11 @@
> >
{{ currentContact.name }} {{ currentContact.name }}
</h4> </h4>
<p <message-preview
v-if="lastMessageInChat" v-if="lastMessageInChat"
class="conversation--message text-slate-700 dark:text-slate-200 text-sm my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] overflow-hidden text-ellipsis whitespace-nowrap" :message="lastMessageInChat"
> class="conversation--message my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] text-sm text-slate-700 dark:text-slate-200"
<fluent-icon />
v-if="isMessagePrivate"
size="16"
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
icon="lock-closed"
/>
<fluent-icon
v-else-if="messageByAgent"
size="16"
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
icon="arrow-reply"
/>
<fluent-icon
v-else-if="isMessageAnActivity"
size="16"
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
icon="info"
/>
<span v-if="lastMessageInChat.content">
{{ parsedLastMessage }}
</span>
<span v-else-if="lastMessageInChat.attachments">
<fluent-icon
v-if="attachmentIcon"
size="16"
class="-mt-0.5 align-middle inline-block text-slate-600 dark:text-slate-300"
:icon="attachmentIcon"
/>
{{ this.$t(`${attachmentMessageContent}`) }}
</span>
<span v-else>
{{ $t('CHAT_LIST.NO_CONTENT') }}
</span>
</p>
<p <p
v-else v-else
class="conversation--message text-slate-700 dark:text-slate-200 text-sm my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] overflow-hidden text-ellipsis whitespace-nowrap" class="conversation--message text-slate-700 dark:text-slate-200 text-sm my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] overflow-hidden text-ellipsis whitespace-nowrap"
@ -103,11 +70,11 @@
icon="info" icon="info"
/> />
<span> <span>
{{ this.$t(`CHAT_LIST.NO_MESSAGES`) }} {{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span> </span>
</p> </p>
<div class="conversation--meta flex flex-col absolute right-4 top-4"> <div class="absolute flex flex-col conversation--meta right-4 top-4">
<span class="text-black-600 text-xxs font-normal leading-4 ml-auto"> <span class="ml-auto font-normal leading-4 text-black-600 text-xxs">
<time-ago <time-ago
:last-activity-timestamp="chat.timestamp" :last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at" :created-at-timestamp="chat.created_at"
@ -145,9 +112,8 @@
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import Thumbnail from '../Thumbnail.vue'; import Thumbnail from '../Thumbnail.vue';
import MessagePreview from './MessagePreview.vue';
import conversationMixin from '../../../mixins/conversations'; import conversationMixin from '../../../mixins/conversations';
import timeMixin from '../../../mixins/time'; import timeMixin from '../../../mixins/time';
import router from '../../../routes'; import router from '../../../routes';
@ -159,14 +125,6 @@ import alertMixin from 'shared/mixins/alertMixin';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue'; import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue'; import CardLabels from './conversationCardComponents/CardLabels.vue';
import PriorityMark from './PriorityMark.vue'; import PriorityMark from './PriorityMark.vue';
const ATTACHMENT_ICONS = {
image: 'image',
audio: 'headphones-sound-wave',
video: 'video',
file: 'document',
location: 'location',
fallback: 'link',
};
export default { export default {
components: { components: {
@ -175,16 +133,11 @@ export default {
Thumbnail, Thumbnail,
ConversationContextMenu, ConversationContextMenu,
TimeAgo, TimeAgo,
MessagePreview,
PriorityMark, PriorityMark,
}, },
mixins: [ mixins: [inboxMixin, timeMixin, conversationMixin, alertMixin],
inboxMixin,
timeMixin,
conversationMixin,
messageFormatterMixin,
alertMixin,
],
props: { props: {
activeLabel: { activeLabel: {
type: String, type: String,
@ -258,20 +211,6 @@ export default {
); );
}, },
lastMessageFileType() {
const lastMessage = this.lastMessageInChat;
const [{ file_type: fileType } = {}] = lastMessage.attachments;
return fileType;
},
attachmentIcon() {
return ATTACHMENT_ICONS[this.lastMessageFileType];
},
attachmentMessageContent() {
return `CHAT_LIST.ATTACHMENTS.${this.lastMessageFileType}.CONTENT`;
},
isActiveChat() { isActiveChat() {
return this.currentChat.id === this.chat.id; return this.currentChat.id === this.chat.id;
}, },
@ -292,30 +231,6 @@ export default {
return this.lastMessage(this.chat); return this.lastMessage(this.chat);
}, },
messageByAgent() {
const lastMessage = this.lastMessageInChat;
const { message_type: messageType } = lastMessage;
return messageType === MESSAGE_TYPE.OUTGOING;
},
isMessageAnActivity() {
const lastMessage = this.lastMessageInChat;
const { message_type: messageType } = lastMessage;
return messageType === MESSAGE_TYPE.ACTIVITY;
},
isMessagePrivate() {
const lastMessage = this.lastMessageInChat;
const { private: isPrivate } = lastMessage;
return isPrivate;
},
parsedLastMessage() {
const { content_attributes: contentAttributes } = this.lastMessageInChat;
const { email: { subject } = {} } = contentAttributes || {};
return this.getPlainText(subject || this.lastMessageInChat.content);
},
inbox() { inbox() {
const { inbox_id: inboxId } = this.chat; const { inbox_id: inboxId } = this.chat;
const stateInbox = this.$store.getters['inboxes/getInbox'](inboxId); const stateInbox = this.$store.getters['inboxes/getInbox'](inboxId);

View File

@ -3,10 +3,10 @@
class="bg-white dark:bg-slate-900 flex justify-between items-center py-2 px-4 border-b border-slate-50 dark:border-slate-800/50 flex-col md:flex-row" class="bg-white dark:bg-slate-900 flex justify-between items-center py-2 px-4 border-b border-slate-50 dark:border-slate-800/50 flex-col md:flex-row"
> >
<div <div
class="flex-1 w-100 flex flex-col md:flex-row items-center justify-center" class="flex-1 w-full min-w-0 flex flex-col md:flex-row items-center justify-center"
> >
<div <div
class="flex justify-center items-center mr-4 rtl:mr-0 rtl:ml-4 min-w-0" class="flex justify-start items-center mr-4 rtl:mr-0 rtl:ml-4 min-w-0 w-[inherit]"
> >
<back-button <back-button
v-if="showBackButton" v-if="showBackButton"
@ -19,26 +19,31 @@
:username="currentContact.name" :username="currentContact.name"
:status="currentContact.availability_status" :status="currentContact.availability_status"
/> />
<div class="items-start flex flex-col ml-2 rtl:ml-0 rtl:mr-2 min-w-0"> <div
<woot-button class="items-start flex flex-col ml-2 rtl:ml-0 rtl:mr-2 min-w-0 w-[inherit] overflow-hidden"
variant="link" >
color-scheme="secondary" <div class="flex items-center flex-row gap-1 m-0 p-0 w-[inherit]">
class="overflow-hidden whitespace-nowrap text-ellipsis" <woot-button
@click.prevent="$emit('contact-panel-toggle')" variant="link"
> color-scheme="secondary"
<h3 class="[&>span]:overflow-hidden [&>span]:whitespace-nowrap [&>span]:text-ellipsis min-w-0"
class="text-base inline-block leading-tight capitalize m-0 p-0 text-ellipsis overflow-hidden whitespace-nowrap text-slate-900 dark:text-slate-100" @click.prevent="$emit('contact-panel-toggle')"
> >
<span>{{ currentContact.name }}</span> <span
<fluent-icon class="text-base leading-tight text-slate-900 dark:text-slate-100"
v-if="!isHMACVerified" >
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')" {{ currentContact.name }}
size="14" </span>
class="text-yellow-600 dark:text-yellow-500 my-0 mx-0.5" </woot-button>
icon="warning" <fluent-icon
/> v-if="!isHMACVerified"
</h3> v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
</woot-button> size="14"
class="text-yellow-600 dark:text-yellow-500 my-0 mx-0 min-w-[14px]"
icon="warning"
/>
</div>
<div <div
class="conversation--header--actions items-center flex text-xs gap-2 text-ellipsis overflow-hidden whitespace-nowrap" class="conversation--header--actions items-center flex text-xs gap-2 text-ellipsis overflow-hidden whitespace-nowrap"
> >

View File

@ -1,3 +1,4 @@
<!-- eslint-disable vue/no-mutating-props -->
<template> <template>
<woot-modal :show.sync="show" :on-close="onCancel"> <woot-modal :show.sync="show" :on-close="onCancel">
<div class="h-auto overflow-auto flex flex-col"> <div class="h-auto overflow-auto flex flex-col">

View File

@ -34,6 +34,11 @@
:url="storyUrl" :url="storyUrl"
/> />
</blockquote> </blockquote>
<bubble-reply-to
v-if="inReplyToMessageId && inboxSupportsReplyTo"
:message="inReplyTo"
:message-type="data.message_type"
/>
<bubble-text <bubble-text
v-if="data.content" v-if="data.content"
:message="message" :message="message"
@ -124,6 +129,7 @@
:message="data" :message="data"
@open="openContextMenu" @open="openContextMenu"
@close="closeContextMenu" @close="closeContextMenu"
@replyTo="handleReplyTo"
/> />
</div> </div>
</li> </li>
@ -140,6 +146,7 @@ import BubbleLocation from './bubble/Location.vue';
import BubbleMailHead from './bubble/MailHead.vue'; import BubbleMailHead from './bubble/MailHead.vue';
import BubbleText from './bubble/Text.vue'; import BubbleText from './bubble/Text.vue';
import BubbleContact from './bubble/Contact.vue'; import BubbleContact from './bubble/Contact.vue';
import BubbleReplyTo from './bubble/ReplyTo.vue';
import Spinner from 'shared/components/Spinner.vue'; import Spinner from 'shared/components/Spinner.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue'; import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
import instagramImageErrorPlaceholder from './instagramImageErrorPlaceholder.vue'; import instagramImageErrorPlaceholder from './instagramImageErrorPlaceholder.vue';
@ -149,6 +156,8 @@ import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
import { generateBotMessageContent } from './helpers/botMessageContentHelper'; import { generateBotMessageContent } from './helpers/botMessageContentHelper';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
export default { export default {
components: { components: {
@ -162,6 +171,7 @@ export default {
BubbleMailHead, BubbleMailHead,
BubbleText, BubbleText,
BubbleContact, BubbleContact,
BubbleReplyTo,
ContextMenu, ContextMenu,
Spinner, Spinner,
instagramImageErrorPlaceholder, instagramImageErrorPlaceholder,
@ -188,6 +198,14 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
inboxSupportsReplyTo: {
type: Boolean,
default: false,
},
inReplyTo: {
type: Object,
default: () => ({}),
},
}, },
data() { data() {
return { return {
@ -264,11 +282,19 @@ export default {
) + botMessageContent ) + botMessageContent
); );
}, },
inReplyToMessageId() {
// Why not use the inReplyTo object directly?
// Glad you asked! The inReplyTo object may or may not be available
// depending on the current scroll position of the message list
// since old messages are only loaded when the user scrolls up
return this.data.content_attributes?.in_reply_to;
},
contextMenuEnabledOptions() { contextMenuEnabledOptions() {
return { return {
copy: this.hasText, copy: this.hasText,
delete: this.hasText || this.hasAttachments, delete: this.hasText || this.hasAttachments,
cannedResponse: this.isOutgoing && this.hasText, cannedResponse: this.isOutgoing && this.hasText,
replyTo: !this.data.private && this.inboxSupportsReplyTo,
}; };
}, },
contentAttributes() { contentAttributes() {
@ -318,6 +344,8 @@ export default {
left: isLeftAligned, left: isLeftAligned,
right: isRightAligned, right: isRightAligned,
'has-context-menu': this.showContextMenu, 'has-context-menu': this.showContextMenu,
// this handles the offset required to align the context menu button
// extra alignment is required since a tweet message has a the user name and avatar below it
'has-tweet-menu': this.isATweet, 'has-tweet-menu': this.isATweet,
'has-bg': this.showBackgroundHighlight, 'has-bg': this.showBackgroundHighlight,
}; };
@ -494,6 +522,13 @@ export default {
this.showContextMenu = false; this.showContextMenu = false;
this.contextMenuPosition = { x: null, y: null }; this.contextMenuPosition = { x: null, y: null };
}, },
handleReplyTo() {
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
const { conversation_id: conversationId, id: replyTo } = this.data;
LocalStorage.updateJsonStore(replyStorageKey, conversationId, replyTo);
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.data);
},
setupHighlightTimer() { setupHighlightTimer() {
if (Number(this.$route.query.messageId) !== Number(this.data.id)) { if (Number(this.$route.query.messageId) !== Number(this.data.id)) {
return; return;
@ -613,6 +648,8 @@ li.right {
} }
li.left.has-tweet-menu .context-menu { li.left.has-tweet-menu .context-menu {
// this handles the offset required to align the context menu button
// extra alignment is required since a tweet message has a the user name and avatar below it
@apply mb-6; @apply mb-6;
} }

View File

@ -0,0 +1,93 @@
<template>
<div class="overflow-hidden text-ellipsis whitespace-nowrap">
<template v-if="showMessageType">
<fluent-icon
v-if="isMessagePrivate"
size="16"
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
icon="lock-closed"
/>
<fluent-icon
v-else-if="messageByAgent"
size="16"
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
icon="arrow-reply"
/>
<fluent-icon
v-else-if="isMessageAnActivity"
size="16"
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
icon="info"
/>
</template>
<span v-if="message.content">
{{ parsedLastMessage }}
</span>
<span v-else-if="message.attachments">
<fluent-icon
v-if="attachmentIcon && showMessageType"
size="16"
class="-mt-0.5 align-middle inline-block text-slate-600 dark:text-slate-300"
:icon="attachmentIcon"
/>
{{ $t(`${attachmentMessageContent}`) }}
</span>
<span v-else>
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
</span>
</div>
</template>
<script>
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { ATTACHMENT_ICONS } from 'shared/constants/messages';
export default {
name: 'MessagePreview',
mixins: [messageFormatterMixin],
props: {
message: {
type: Object,
required: true,
},
showMessageType: {
type: Boolean,
default: true,
},
defaultEmptyMessage: {
type: String,
default: '',
},
},
computed: {
messageByAgent() {
const { message_type: messageType } = this.message;
return messageType === MESSAGE_TYPE.OUTGOING;
},
isMessageAnActivity() {
const { message_type: messageType } = this.message;
return messageType === MESSAGE_TYPE.ACTIVITY;
},
isMessagePrivate() {
const { private: isPrivate } = this.message;
return isPrivate;
},
parsedLastMessage() {
const { content_attributes: contentAttributes } = this.message;
const { email: { subject } = {} } = contentAttributes || {};
return this.getPlainText(subject || this.message.content);
},
lastMessageFileType() {
const [{ file_type: fileType } = {}] = this.message.attachments;
return fileType;
},
attachmentIcon() {
return ATTACHMENT_ICONS[this.lastMessageFileType];
},
attachmentMessageContent() {
return `CHAT_LIST.ATTACHMENTS.${this.lastMessageFileType}.CONTENT`;
},
},
};
</script>

View File

@ -0,0 +1,28 @@
<template>
<div
class="my-0 mx-4 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-slate-25 dark:hover:bg-slate-800 border border-dashed border-slate-100 dark:border-slate-700 rounded-sm overflow-auto"
>
<p class="w-fit !m-0">
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
<woot-button
color-scheme="primary"
variant="link"
@click="openProfileSettings"
>
{{ $t('CONVERSATION.FOOTER.CLICK_HERE') }}
</woot-button>
</p>
</div>
</template>
<script>
export default {
methods: {
openProfileSettings() {
return this.$router.push({ name: 'profile_settings_index' });
},
},
};
</script>
<style></style>

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="m-0 flex flex-col justify-between h-full flex-grow min-w-0"> <div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0">
<banner <banner
v-if="!currentChat.can_reply" v-if="!currentChat.can_reply"
color-scheme="alert" color-scheme="alert"
@ -7,21 +7,12 @@
:href-link="replyWindowLink" :href-link="replyWindowLink"
:href-link-text="replyWindowLinkText" :href-link-text="replyWindowLinkText"
/> />
<banner
v-if="isATweet"
color-scheme="gray"
:banner-message="tweetBannerText"
:has-close-button="hasSelectedTweetId"
@close="removeTweetSelection"
/>
<div class="flex justify-end"> <div class="flex justify-end">
<woot-button <woot-button
variant="smooth" variant="smooth"
size="tiny" size="tiny"
color-scheme="secondary" color-scheme="secondary"
class="rounded-bl-calc rtl:rotate-180 rounded-tl-calc fixed top-[6.25rem] z-10 bg-white dark:bg-slate-700 border-slate-50 dark:border-slate-600 border-solid border border-r-0 box-border" class="rounded-bl-calc rtl:rotate-180 rounded-tl-calc fixed top-[9.5rem] md:top-[6.25rem] z-10 bg-white dark:bg-slate-700 border-slate-50 dark:border-slate-600 border-solid border border-r-0 box-border"
:icon="isRightOrLeftIcon" :icon="isRightOrLeftIcon"
@click="onToggleContactPanel" @click="onToggleContactPanel"
/> />
@ -42,6 +33,8 @@
:is-a-whatsapp-channel="isAWhatsAppChannel" :is-a-whatsapp-channel="isAWhatsAppChannel"
:has-instagram-story="hasInstagramStory" :has-instagram-story="hasInstagramStory"
:is-web-widget-inbox="isAWebWidgetInbox" :is-web-widget-inbox="isAWebWidgetInbox"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:in-reply-to="getInReplyToMessage(message)"
/> />
<li v-show="unreadMessageCount != 0" class="unread--toast"> <li v-show="unreadMessageCount != 0" class="unread--toast">
<span> <span>
@ -63,6 +56,8 @@
:is-a-whatsapp-channel="isAWhatsAppChannel" :is-a-whatsapp-channel="isAWhatsAppChannel"
:has-instagram-story="hasInstagramStory" :has-instagram-story="hasInstagramStory"
:is-web-widget-inbox="isAWebWidgetInbox" :is-web-widget-inbox="isAWebWidgetInbox"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:in-reply-to="getInReplyToMessage(message)"
/> />
<conversation-label-suggestion <conversation-label-suggestion
v-if="shouldShowLabelSuggestions" v-if="shouldShowLabelSuggestions"
@ -87,8 +82,6 @@
</div> </div>
<reply-box <reply-box
:conversation-id="currentChat.id" :conversation-id="currentChat.id"
:is-a-tweet="isATweet"
:selected-tweet="selectedTweet"
:popout-reply-box.sync="isPopoutReplyBox" :popout-reply-box.sync="isPopoutReplyBox"
@click="showPopoutReplyBox" @click="showPopoutReplyBox"
/> />
@ -110,7 +103,7 @@ import { mapGetters } from 'vuex';
import conversationMixin, { import conversationMixin, {
filterDuplicateSourceMessages, filterDuplicateSourceMessages,
} from '../../../mixins/conversations'; } from '../../../mixins/conversations';
import inboxMixin from 'shared/mixins/inboxMixin'; import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import configMixin from 'shared/mixins/configMixin'; import configMixin from 'shared/mixins/configMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import aiMixin from 'dashboard/mixins/aiMixin'; import aiMixin from 'dashboard/mixins/aiMixin';
@ -123,6 +116,7 @@ import { LocalStorage } from 'shared/helpers/localStorage';
// constants // constants
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { REPLY_POLICY } from 'shared/constants/links'; import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@ -153,7 +147,6 @@ export default {
isLoadingPrevious: true, isLoadingPrevious: true,
heightBeforeLoad: null, heightBeforeLoad: null,
conversationPanel: null, conversationPanel: null,
selectedTweetId: null,
hasUserScrolled: false, hasUserScrolled: false,
isProgrammaticScroll: false, isProgrammaticScroll: false,
isPopoutReplyBox: false, isPopoutReplyBox: false,
@ -164,12 +157,14 @@ export default {
computed: { computed: {
...mapGetters({ ...mapGetters({
accountId: 'getCurrentAccountId',
currentChat: 'getSelectedChat', currentChat: 'getSelectedChat',
allConversations: 'getAllConversations', allConversations: 'getAllConversations',
inboxesList: 'inboxes/getInboxes', inboxesList: 'inboxes/getInboxes',
listLoadingStatus: 'getAllMessagesLoaded', listLoadingStatus: 'getAllMessagesLoaded',
loadingChatList: 'getChatListLoadingStatus', loadingChatList: 'getChatListLoadingStatus',
appIntegrations: 'integrations/getAppIntegrations', appIntegrations: 'integrations/getAppIntegrations',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
currentAccountId: 'getCurrentAccountId', currentAccountId: 'getCurrentAccountId',
}), }),
isOpen() { isOpen() {
@ -189,16 +184,6 @@ export default {
inbox() { inbox() {
return this.$store.getters['inboxes/getInbox'](this.inboxId); return this.$store.getters['inboxes/getInbox'](this.inboxId);
}, },
hasSelectedTweetId() {
return !!this.selectedTweetId;
},
tweetBannerText() {
return !this.selectedTweetId
? this.$t('CONVERSATION.SELECT_A_TWEET_TO_REPLY')
: `
${this.$t('CONVERSATION.REPLYING_TO')}
${this.selectedTweet.content}` || '';
},
typingUsersList() { typingUsersList() {
const userList = this.$store.getters[ const userList = this.$store.getters[
'conversationTypingStatus/getUserList' 'conversationTypingStatus/getUserList'
@ -257,17 +242,6 @@ export default {
hasInstagramStory() { hasInstagramStory() {
return this.conversationType === 'instagram_direct_message'; return this.conversationType === 'instagram_direct_message';
}, },
selectedTweet() {
if (this.selectedTweetId) {
const { messages = [] } = this.currentChat;
const [selectedMessage] = messages.filter(
message => message.id === this.selectedTweetId
);
return selectedMessage || {};
}
return '';
},
isRightOrLeftIcon() { isRightOrLeftIcon() {
if (this.isContactPanelOpen) { if (this.isContactPanelOpen) {
return 'arrow-chevron-right'; return 'arrow-chevron-right';
@ -316,6 +290,15 @@ export default {
unreadMessageCount() { unreadMessageCount() {
return this.currentChat.unread_count || 0; return this.currentChat.unread_count || 0;
}, },
inboxSupportsReplyTo() {
return (
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.MESSAGE_REPLY_TO
)
);
},
}, },
watch: { watch: {
@ -326,7 +309,6 @@ export default {
this.fetchAllAttachmentsFromCurrentChat(); this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions(); this.fetchSuggestions();
this.messageSentSinceOpened = false; this.messageSentSinceOpened = false;
this.selectedTweetId = null;
}, },
}, },
@ -334,7 +316,6 @@ export default {
bus.$on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage); bus.$on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
// when a new message comes in, we refetch the label suggestions // when a new message comes in, we refetch the label suggestions
bus.$on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions); bus.$on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions);
bus.$on(BUS_EVENTS.SET_TWEET_REPLY, this.setSelectedTweet);
// when a message is sent we set the flag to true this hides the label suggestions, // when a message is sent we set the flag to true this hides the label suggestions,
// until the chat is changed and the flag is reset in the watch for currentChat // until the chat is changed and the flag is reset in the watch for currentChat
bus.$on(BUS_EVENTS.MESSAGE_SENT, () => { bus.$on(BUS_EVENTS.MESSAGE_SENT, () => {
@ -405,10 +386,6 @@ export default {
}, },
removeBusListeners() { removeBusListeners() {
bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage); bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
bus.$off(BUS_EVENTS.SET_TWEET_REPLY, this.setSelectedTweet);
},
setSelectedTweet(tweetId) {
this.selectedTweetId = tweetId;
}, },
onScrollToMessage({ messageId = '' } = {}) { onScrollToMessage({ messageId = '' } = {}) {
this.$nextTick(() => { this.$nextTick(() => {
@ -450,16 +427,14 @@ export default {
// label suggestions are not part of the messages list // label suggestions are not part of the messages list
// so we need to handle them separately // so we need to handle them separately
let labelSuggestions = this.conversationPanel.querySelector( let labelSuggestions =
'.label-suggestion' this.conversationPanel.querySelector('.label-suggestion');
);
// if there are unread messages, scroll to the first unread message // if there are unread messages, scroll to the first unread message
if (this.unreadMessageCount > 0) { if (this.unreadMessageCount > 0) {
// capturing only the unread messages // capturing only the unread messages
relevantMessages = this.conversationPanel.querySelectorAll( relevantMessages =
'.message--unread' this.conversationPanel.querySelectorAll('.message--unread');
);
} else if (labelSuggestions) { } else if (labelSuggestions) {
// when scrolling to the bottom, the label suggestions is below the last message // when scrolling to the bottom, the label suggestions is below the last message
// so we scroll there if there are no unread messages // so we scroll there if there are no unread messages
@ -533,8 +508,18 @@ export default {
makeMessagesRead() { makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id }); this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
}, },
removeTweetSelection() {
this.selectedTweetId = null; getInReplyToMessage(parentMessage) {
if (!parentMessage) return {};
const inReplyToMessageId = parentMessage.content_attributes?.in_reply_to;
if (!inReplyToMessageId) return {};
return this.currentChat?.messages.find(message => {
if (message.id === inReplyToMessageId) {
return true;
}
return false;
});
}, },
}, },
}; };

View File

@ -8,8 +8,10 @@
}" }"
class="shrink-0 rounded-sm inline-flex w-3.5 h-3.5" class="shrink-0 rounded-sm inline-flex w-3.5 h-3.5"
:class="{ :class="{
'bg-red-50 dark:bg-red-700 dark:bg-opacity-30 text-red-500 dark:text-red-600': isUrgent, 'bg-red-50 dark:bg-red-700 dark:bg-opacity-30 text-red-500 dark:text-red-600':
'bg-slate-50 dark:bg-slate-700 text-slate-600 dark:text-slate-200': !isUrgent, isUrgent,
'bg-slate-50 dark:bg-slate-700 text-slate-600 dark:text-slate-200':
!isUrgent,
}" }"
> >
<fluent-icon <fluent-icon

View File

@ -19,6 +19,11 @@
@click="$emit('click')" @click="$emit('click')"
/> />
<div class="reply-box__top"> <div class="reply-box__top">
<reply-to-message
v-if="shouldShowReplyToMessage"
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<canned-response <canned-response
v-if="showMentions && hasSlashCommand" v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideMentions" v-on-clickaway="hideMentions"
@ -86,21 +91,14 @@
</div> </div>
<div v-if="hasAttachments" class="attachment-preview-box" @paste="onPaste"> <div v-if="hasAttachments" class="attachment-preview-box" @paste="onPaste">
<attachment-preview <attachment-preview
class="flex-col mt-4"
:attachments="attachedFiles" :attachments="attachedFiles"
:remove-attachment="removeAttachment" :remove-attachment="removeAttachment"
/> />
</div> </div>
<div <message-signature-missing-alert
v-if="isSignatureEnabledForInbox && !isSignatureAvailable" v-if="isSignatureEnabledForInbox && !isSignatureAvailable"
class="message-signature-wrap" />
>
<p class="message-signature">
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
<router-link :to="profilePath">
{{ $t('CONVERSATION.FOOTER.CLICK_HERE') }}
</router-link>
</p>
</div>
<reply-bottom-panel <reply-bottom-panel
:conversation-id="conversationId" :conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload" :enable-multiple-file-upload="enableMultipleFileUpload"
@ -123,6 +121,7 @@
:toggle-audio-recorder="toggleAudioRecorder" :toggle-audio-recorder="toggleAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker" :toggle-emoji-picker="toggleEmojiPicker"
:message="message" :message="message"
:new-conversation-modal-active="newConversationModalActive"
@selectWhatsappTemplate="openWhatsappTemplateModal" @selectWhatsappTemplate="openWhatsappTemplateModal"
@toggle-editor="toggleRichContentEditor" @toggle-editor="toggleRichContentEditor"
@replace-text="replaceText" @replace-text="replaceText"
@ -149,22 +148,19 @@ import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import CannedResponse from './CannedResponse.vue'; import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue'; import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue'; import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue'; import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue'; import ReplyEmailHead from './ReplyEmailHead.vue';
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue'; import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert';
import Banner from 'dashboard/components/ui/Banner.vue'; import Banner from 'dashboard/components/ui/Banner.vue';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants'; import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import WootAudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue'; import WootAudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin'; import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { AUDIO_FORMATS } from 'shared/constants/messages';
import {
MAXIMUM_FILE_UPLOAD_SIZE,
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL,
AUDIO_FORMATS,
} from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { import {
getMessageVariables, getMessageVariables,
@ -174,21 +170,24 @@ import {
import WhatsappTemplates from './WhatsappTemplates/Modal.vue'; import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers'; import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper'; import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin from 'shared/mixins/inboxMixin'; import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { DirectUpload } from 'activestorage';
import { frontendURL } from '../../../helper/URLHelper';
import { trimContent, debounce } from '@chatwoot/utils'; import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings'; import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events'; import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import rtlMixin from 'shared/mixins/rtlMixin'; import rtlMixin from 'shared/mixins/rtlMixin';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import { import {
appendSignature, appendSignature,
removeSignature, removeSignature,
replaceSignature, replaceSignature,
extractTextFromMarkdown, extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper'; } from 'dashboard/helper/editorHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
const EmojiInput = () => import('shared/components/emoji/EmojiInput'); const EmojiInput = () => import('shared/components/emoji/EmojiInput');
@ -196,6 +195,7 @@ export default {
components: { components: {
EmojiInput, EmojiInput,
CannedResponse, CannedResponse,
ReplyToMessage,
ResizableTextArea, ResizableTextArea,
AttachmentPreview, AttachmentPreview,
ReplyTopPanel, ReplyTopPanel,
@ -205,6 +205,7 @@ export default {
WootAudioRecorder, WootAudioRecorder,
Banner, Banner,
WhatsappTemplates, WhatsappTemplates,
MessageSignatureMissingAlert,
}, },
mixins: [ mixins: [
clickaway, clickaway,
@ -213,16 +214,9 @@ export default {
alertMixin, alertMixin,
messageFormatterMixin, messageFormatterMixin,
rtlMixin, rtlMixin,
fileUploadMixin,
], ],
props: { props: {
selectedTweet: {
type: [Object, String],
default: () => ({}),
},
isATweet: {
type: Boolean,
default: false,
},
popoutReplyBox: { popoutReplyBox: {
type: Boolean, type: Boolean,
default: false, default: false,
@ -231,6 +225,7 @@ export default {
data() { data() {
return { return {
message: '', message: '',
inReplyTo: {},
isFocused: false, isFocused: false,
showEmojiPicker: false, showEmojiPicker: false,
attachedFiles: [], attachedFiles: [],
@ -252,6 +247,7 @@ export default {
showUserMentions: false, showUserMentions: false,
showCannedMenu: false, showCannedMenu: false,
showVariablesMenu: false, showVariablesMenu: false,
newConversationModalActive: false,
}; };
}, },
computed: { computed: {
@ -262,7 +258,19 @@ export default {
lastEmail: 'getLastEmailInSelectedChat', lastEmail: 'getLastEmailInSelectedChat',
globalConfig: 'globalConfig/get', globalConfig: 'globalConfig/get',
accountId: 'getCurrentAccountId', accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}), }),
shouldShowReplyToMessage() {
return (
this.inReplyTo?.id &&
!this.isPrivate &&
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.MESSAGE_REPLY_TO
)
);
},
showRichContentEditor() { showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) { if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true; return true;
@ -334,10 +342,7 @@ export default {
return this.maxLength - this.message.length; return this.maxLength - this.message.length;
}, },
isReplyButtonDisabled() { isReplyButtonDisabled() {
if (this.isATweet && !this.inReplyTo && !this.isOnPrivateNote) { if (this.isATwitterInbox) return true;
return true;
}
if (this.hasAttachments || this.hasRecordedAudio) return false; if (this.hasAttachments || this.hasRecordedAudio) return false;
return ( return (
@ -370,11 +375,6 @@ export default {
if (this.isASmsInbox) { if (this.isASmsInbox) {
return MESSAGE_MAX_LENGTH.TWILIO_SMS; return MESSAGE_MAX_LENGTH.TWILIO_SMS;
} }
if (this.isATwitterInbox) {
if (this.conversationType === 'tweet') {
return MESSAGE_MAX_LENGTH.TWEET - this.replyToUserLength - 2;
}
}
return MESSAGE_MAX_LENGTH.GENERAL; return MESSAGE_MAX_LENGTH.GENERAL;
}, },
showFileUpload() { showFileUpload() {
@ -392,8 +392,6 @@ export default {
let sendMessageText = this.$t('CONVERSATION.REPLYBOX.SEND'); let sendMessageText = this.$t('CONVERSATION.REPLYBOX.SEND');
if (this.isPrivate) { if (this.isPrivate) {
sendMessageText = this.$t('CONVERSATION.REPLYBOX.CREATE'); sendMessageText = this.$t('CONVERSATION.REPLYBOX.CREATE');
} else if (this.conversationType === 'tweet') {
sendMessageText = this.$t('CONVERSATION.REPLYBOX.TWEET');
} }
const keyLabel = isEditorHotKeyEnabled(this.uiSettings, 'cmd_enter') const keyLabel = isEditorHotKeyEnabled(this.uiSettings, 'cmd_enter')
? '(⌘ + ↵)' ? '(⌘ + ↵)'
@ -427,17 +425,12 @@ export default {
isOnPrivateNote() { isOnPrivateNote() {
return this.replyType === REPLY_EDITOR_MODES.NOTE; return this.replyType === REPLY_EDITOR_MODES.NOTE;
}, },
inReplyTo() {
const selectedTweet = this.selectedTweet || {};
return selectedTweet.id;
},
isOnExpandedLayout() { isOnExpandedLayout() {
const { const {
LAYOUT_TYPES: { CONDENSED }, LAYOUT_TYPES: { CONDENSED },
} = wootConstants; } = wootConstants;
const { const { conversation_display_type: conversationDisplayType = CONDENSED } =
conversation_display_type: conversationDisplayType = CONDENSED, this.uiSettings;
} = this.uiSettings;
return conversationDisplayType !== CONDENSED; return conversationDisplayType !== CONDENSED;
}, },
emojiDialogClassOnExpandedLayoutAndRTLView() { emojiDialogClassOnExpandedLayoutAndRTLView() {
@ -449,15 +442,6 @@ export default {
} }
return ''; return '';
}, },
replyToUserLength() {
const selectedTweet = this.selectedTweet || {};
const {
sender: {
additional_attributes: { screen_name: screenName = '' } = {},
} = {},
} = selectedTweet;
return screenName ? screenName.length : 0;
},
isMessageEmpty() { isMessageEmpty() {
if (!this.message) { if (!this.message) {
return true; return true;
@ -485,9 +469,6 @@ export default {
const { send_with_signature: isEnabled } = this.uiSettings; const { send_with_signature: isEnabled } = this.uiSettings;
return isEnabled; return isEnabled;
}, },
profilePath() {
return frontendURL(`accounts/${this.accountId}/profile/settings`);
},
editorMessageKey() { editorMessageKey() {
const { editor_message_key: isEnabled } = this.uiSettings; const { editor_message_key: isEnabled } = this.uiSettings;
return isEnabled; return isEnabled;
@ -541,6 +522,7 @@ export default {
} }
this.setCCAndToEmailsFromLastChat(); this.setCCAndToEmailsFromLastChat();
this.fetchAndSetReplyTo();
}, },
conversationIdByRoute(conversationId, oldConversationId) { conversationIdByRoute(conversationId, oldConversationId) {
if (conversationId !== oldConversationId) { if (conversationId !== oldConversationId) {
@ -570,7 +552,7 @@ export default {
mounted() { mounted() {
this.getFromDraft(); this.getFromDraft();
// Donot use the keyboard listener mixin here as the events here are supposed to be // Don't use the keyboard listener mixin here as the events here are supposed to be
// working even if input/textarea is focussed. // working even if input/textarea is focussed.
document.addEventListener('paste', this.onPaste); document.addEventListener('paste', this.onPaste);
document.addEventListener('keydown', this.handleKeyEvents); document.addEventListener('keydown', this.handleKeyEvents);
@ -582,10 +564,28 @@ export default {
500, 500,
true true
); );
this.fetchAndSetReplyTo();
bus.$on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
// A hacky fix to solve the drag and drop
// Is showing on top of new conversation modal drag and drop
// TODO need to find a better solution
bus.$on(
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive
);
}, },
destroyed() { destroyed() {
document.removeEventListener('paste', this.onPaste); document.removeEventListener('paste', this.onPaste);
document.removeEventListener('keydown', this.handleKeyEvents); document.removeEventListener('keydown', this.handleKeyEvents);
bus.$off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
},
beforeDestroy() {
bus.$off(
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive
);
}, },
methods: { methods: {
toggleRichContentEditor() { toggleRichContentEditor() {
@ -768,6 +768,7 @@ export default {
: this.$track(CONVERSATION_EVENTS.SENT_MESSAGE, { : this.$track(CONVERSATION_EVENTS.SENT_MESSAGE, {
channelType: this.channelType, channelType: this.channelType,
signatureEnabled: this.sendWithSignature, signatureEnabled: this.sendWithSignature,
hasReplyTo: !!this.inReplyTo?.id,
}); });
}, },
async onSendReply() { async onSendReply() {
@ -870,6 +871,7 @@ export default {
} }
this.attachedFiles = []; this.attachedFiles = [];
this.isRecordingAudio = false; this.isRecordingAudio = false;
this.resetReplyToMessage();
}, },
clearEmailField() { clearEmailField() {
this.ccEmails = ''; this.ccEmails = '';
@ -944,67 +946,6 @@ export default {
isPrivate, isPrivate,
}); });
}, },
onFileUpload(file) {
if (this.globalConfig.directUploadsEnabled) {
this.onDirectFileUpload(file);
} else {
this.onIndirectFileUpload(file);
}
},
onDirectFileUpload(file) {
const MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE = this.isATwilioSMSChannel
? MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
: MAXIMUM_FILE_UPLOAD_SIZE;
if (!file) {
return;
}
if (checkFileSizeLimit(file, MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE)) {
const upload = new DirectUpload(
file.file,
`/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);
} else {
this.attachFile({ file, blob });
}
});
} else {
this.showAlert(
this.$t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE,
})
);
}
},
onIndirectFileUpload(file) {
const MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE = this.isATwilioSMSChannel
? MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
: MAXIMUM_FILE_UPLOAD_SIZE;
if (!file) {
return;
}
if (checkFileSizeLimit(file, MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE)) {
this.attachFile({ file });
} else {
this.showAlert(
this.$t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE,
})
);
}
},
attachFile({ blob, file }) { attachFile({ blob, file }) {
const reader = new FileReader(); const reader = new FileReader();
reader.readAsDataURL(file.file); reader.readAsDataURL(file.file);
@ -1062,8 +1003,10 @@ export default {
sender: this.sender, sender: this.sender,
}; };
if (this.inReplyTo) { if (this.inReplyTo?.id) {
messagePayload.contentAttributes = { in_reply_to: this.inReplyTo }; messagePayload.contentAttributes = {
in_reply_to: this.inReplyTo.id,
};
} }
if (this.attachedFiles && this.attachedFiles.length) { if (this.attachedFiles && this.attachedFiles.length) {
@ -1136,6 +1079,33 @@ export default {
this.bccEmails = bcc.join(', '); this.bccEmails = bcc.join(', ');
this.toEmails = to.join(', '); this.toEmails = to.join(', ');
}, },
fetchAndSetReplyTo() {
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
const replyToMessageId = LocalStorage.getFromJsonStore(
replyStorageKey,
this.conversationId
);
this.inReplyTo = this.currentChat?.messages?.find(message => {
if (message.id === replyToMessageId) {
return true;
}
return false;
});
},
resetReplyToMessage() {
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE);
},
onNewConversationModalActive(isActive) {
// Issue is if the new conversation modal is open and we drag and drop the file
// then the file is not getting attached to the new conversation modal
// and it is getting attached to the current conversation reply box
// so to fix this we are removing the drag and drop event listener from the current conversation reply box
// When new conversation modal is open
this.newConversationModalActive = isActive;
},
}, },
}; };
</script> </script>
@ -1149,14 +1119,6 @@ export default {
@apply py-2; @apply py-2;
} }
.message-signature-wrap {
@apply my-0 mx-4 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-slate-25 dark:hover:bg-slate-800 border border-dashed border-slate-100 dark:border-slate-700 rounded-sm overflow-auto;
}
.message-signature {
@apply w-fit m-0;
}
.attachment-preview-box { .attachment-preview-box {
@apply bg-transparent py-0 px-4; @apply bg-transparent py-0 px-4;
} }
@ -1202,13 +1164,6 @@ export default {
@apply left-1 -bottom-2; @apply left-1 -bottom-2;
} }
} }
.message-signature {
@apply mb-0;
::v-deep p:last-child {
@apply mb-0;
}
}
.normal-editor__canned-box { .normal-editor__canned-box {
width: calc(100% - 2 * var(--space-normal)); width: calc(100% - 2 * var(--space-normal));

View File

@ -0,0 +1,49 @@
<template>
<div
class="reply-editor bg-slate-50 dark:bg-slate-800 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
>
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
<div class="flex-grow gap-1 mt-px text-xs truncate">
{{ $t('CONVERSATION.REPLYBOX.REPLYING_TO') }}
<message-preview
:message="message"
:show-message-type="false"
:default-empty-message="$t('CONVERSATION.REPLY_MESSAGE_NOT_FOUND')"
class="inline"
/>
</div>
<woot-button
v-tooltip="$t('CONVERSATION.REPLYBOX.DISMISS_REPLY')"
color-scheme="secondary"
icon="dismiss"
variant="clear"
size="tiny"
class="flex-shrink-0"
@click.stop="$emit('dismiss')"
/>
</div>
</template>
<script>
import MessagePreview from 'dashboard/components/widgets/conversation/MessagePreview.vue';
export default {
components: { MessagePreview },
props: {
message: {
type: Object,
required: true,
},
},
};
</script>
<style lang="scss">
// TODO: Remove this
// override for dashboard/assets/scss/widgets/_reply-box.scss
.reply-editor {
.icon {
margin-right: 0px !important;
}
}
</style>

View File

@ -1,3 +1,4 @@
<!-- eslint-disable vue/no-mutating-props -->
<template> <template>
<woot-modal :show.sync="show" :on-close="onClose" size="modal-big"> <woot-modal :show.sync="show" :on-close="onClose" size="modal-big">
<woot-modal-header <woot-modal-header

View File

@ -33,7 +33,7 @@
<woot-button variant="smooth" @click="$emit('resetTemplate')"> <woot-button variant="smooth" @click="$emit('resetTemplate')">
{{ $t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL') }} {{ $t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL') }}
</woot-button> </woot-button>
<woot-button @click="sendMessage"> <woot-button type="button" @click="sendMessage">
{{ $t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL') }} {{ $t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL') }}
</woot-button> </woot-button>
</footer> </footer>

View File

@ -57,17 +57,6 @@
@mouseenter="isHovered = true" @mouseenter="isHovered = true"
@mouseleave="isHovered = false" @mouseleave="isHovered = false"
/> />
<button
v-if="isATweet && (isIncoming || isOutgoing) && sourceId"
@click="onTweetReply"
>
<fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.REPLY_TO_TWEET')"
icon="arrow-reply"
class="action--icon cursor-pointer"
size="16"
/>
</button>
<a <a
v-if="isATweet && (isOutgoing || isIncoming) && linkToTweet" v-if="isATweet && (isOutgoing || isIncoming) && linkToTweet"
:href="linkToTweet" :href="linkToTweet"
@ -77,7 +66,7 @@
<fluent-icon <fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.VIEW_TWEET_IN_TWITTER')" v-tooltip.top-start="$t('CHAT_LIST.VIEW_TWEET_IN_TWITTER')"
icon="open" icon="open"
class="action--icon cursor-pointer" class="cursor-pointer action--icon"
size="16" size="16"
/> />
</a> </a>
@ -86,7 +75,6 @@
<script> <script>
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages'; import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import inboxMixin from 'shared/mixins/inboxMixin'; import inboxMixin from 'shared/mixins/inboxMixin';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import timeMixin from '../../../../mixins/time'; import timeMixin from '../../../../mixins/time';
@ -187,8 +175,9 @@ export default {
return ''; return '';
} }
const { screenName, sourceId } = this; const { screenName, sourceId } = this;
return `https://twitter.com/${screenName || return `https://twitter.com/${
this.inbox.name}/status/${sourceId}`; screenName || this.inbox.name
}/status/${sourceId}`;
}, },
linkToStory() { linkToStory() {
if (!this.storyId || !this.storySender) { if (!this.storyId || !this.storySender) {
@ -212,7 +201,7 @@ export default {
return !!this.sourceId; return !!this.sourceId;
} }
if (this.isAWhatsAppChannel) { if (this.isAWhatsAppChannel || this.isATwilioChannel) {
return this.sourceId && this.isSent; return this.sourceId && this.isSent;
} }
return false; return false;
@ -222,9 +211,13 @@ export default {
return false; return false;
} }
if (this.isAWhatsAppChannel) { if (this.isAWhatsAppChannel || this.isATwilioChannel) {
return this.sourceId && this.isDelivered; return this.sourceId && this.isDelivered;
} }
// We will consider messages as delivered for web widget inbox if they are sent
if (this.isAWebWidgetInbox) {
return this.isSent;
}
return false; return false;
}, },
@ -238,18 +231,13 @@ export default {
return contactLastSeenAt >= this.createdAt; return contactLastSeenAt >= this.createdAt;
} }
if (this.isAWhatsAppChannel) { if (this.isAWhatsAppChannel || this.isATwilioChannel) {
return this.sourceId && this.isRead; return this.sourceId && this.isRead;
} }
return false; return false;
}, },
}, },
methods: {
onTweetReply() {
bus.$emit(BUS_EVENTS.SET_TWEET_REPLY, this.id);
},
},
}; };
</script> </script>

View File

@ -0,0 +1,41 @@
<template>
<div
class="px-2 py-1.5 -mx-2 rounded-sm min-w-[15rem] mb-2"
:class="{
'bg-slate-100 dark:bg-slate-600 dark:text-slate-50':
messageType === MESSAGE_TYPE.INCOMING,
'bg-woot-600 text-woot-50': messageType === MESSAGE_TYPE.OUTGOING,
}"
>
<message-preview
:message="message"
:show-message-type="false"
:default-empty-message="$t('CONVERSATION.REPLY_MESSAGE_NOT_FOUND')"
/>
</div>
</template>
<script>
import MessagePreview from 'dashboard/components/widgets/conversation/MessagePreview.vue';
import { MESSAGE_TYPE } from 'shared/constants/messages';
export default {
name: 'ReplyTo',
components: {
MessagePreview,
},
props: {
message: {
type: Object,
required: true,
},
messageType: {
type: Number,
required: true,
},
},
data() {
return { MESSAGE_TYPE };
},
};
</script>

View File

@ -57,9 +57,8 @@ export default {
async joinTheCall() { async joinTheCall() {
this.isLoading = true; this.isLoading = true;
try { try {
const { const { data: { authResponse: { authToken } = {} } = {} } =
data: { authResponse: { authToken } = {} } = {}, await DyteAPI.addParticipantToMeeting(this.messageId);
} = await DyteAPI.addParticipantToMeeting(this.messageId);
this.dyteAuthToken = authToken; this.dyteAuthToken = authToken;
} catch (err) { } catch (err) {
this.showAlert(this.$t('INTEGRATION_SETTINGS.DYTE.JOIN_ERROR')); this.showAlert(this.$t('INTEGRATION_SETTINGS.DYTE.JOIN_ERROR'));

View File

@ -1,3 +1,4 @@
<!-- eslint-disable vue/no-mutating-props -->
<template> <template>
<woot-modal <woot-modal
full-width full-width
@ -212,8 +213,13 @@ export default {
return this.activeFileType === ALLOWED_FILE_TYPES.AUDIO; return this.activeFileType === ALLOWED_FILE_TYPES.AUDIO;
}, },
senderDetails() { senderDetails() {
const { name, available_name: availableName, avatar_url, thumbnail, id } = const {
this.activeAttachment?.sender || this.attachment?.sender; name,
available_name: availableName,
avatar_url,
thumbnail,
id,
} = this.activeAttachment?.sender || this.attachment?.sender || {};
const currentUserID = this.currentUser?.id; const currentUserID = this.currentUser?.id;
return { return {
name: currentUserID === id ? 'You' : name || availableName || '', name: currentUserID === id ? 'You' : name || availableName || '',

View File

@ -34,15 +34,13 @@
:option="labelMenuConfig" :option="labelMenuConfig"
:sub-menu-available="!!labels.length" :sub-menu-available="!!labels.length"
> >
<template> <menu-item
<menu-item v-for="label in labels"
v-for="label in labels" :key="label.id"
:key="label.id" :option="generateMenuLabelConfig(label, 'label')"
:option="generateMenuLabelConfig(label, 'label')" variant="label"
variant="label" @click="$emit('assign-label', label)"
@click="$emit('assign-label', label)" />
/>
</template>
</menu-item-with-submenu> </menu-item-with-submenu>
<menu-item-with-submenu <menu-item-with-submenu
:option="agentMenuConfig" :option="agentMenuConfig"

View File

@ -17,7 +17,7 @@
type="button" type="button"
@click="onAvatarDelete" @click="onAvatarDelete"
> >
{{ this.$t('INBOX_MGMT.DELETE.AVATAR_DELETE_BUTTON_TEXT') }} {{ $t('INBOX_MGMT.DELETE.AVATAR_DELETE_BUTTON_TEXT') }}
</woot-button> </woot-button>
</div> </div>
<label> <label>

View File

@ -63,8 +63,9 @@
<div v-if="filteredCountriesBySearch.length === 0"> <div v-if="filteredCountriesBySearch.length === 0">
<span <span
class="flex items-center justify-center text-sm text-slate-500 dark:text-slate-300 mt-4" class="flex items-center justify-center text-sm text-slate-500 dark:text-slate-300 mt-4"
>No results found</span
> >
No results found
</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,3 +1,4 @@
<!-- eslint-disable vue/no-mutating-props -->
<template> <template>
<modal :show.sync="show" :on-close="closeModal"> <modal :show.sync="show" :on-close="closeModal">
<woot-modal-header :header-title="title" :header-content="message" /> <woot-modal-header :header-title="title" :header-content="message" />

View File

@ -1,3 +1,4 @@
<!-- eslint-disable vue/no-mutating-props -->
<template> <template>
<modal :show.sync="show" :on-close="onClose"> <modal :show.sync="show" :on-close="onClose">
<woot-modal-header <woot-modal-header

View File

@ -30,9 +30,7 @@
<hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key"> <hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.ALT_OR_OPTION_KEY') }} {{ $t('KEYBOARD_SHORTCUTS.KEYS.ALT_OR_OPTION_KEY') }}
</hotkey> </hotkey>
<hotkey custom-class="min-h-[28px] w-9 key"> <hotkey custom-class="min-h-[28px] w-9 key"> J </hotkey>
J
</hotkey>
<span <span
class="flex items-center font-semibold text-sm text-slate-800 dark:text-slate-100" class="flex items-center font-semibold text-sm text-slate-800 dark:text-slate-100"
> >
@ -42,9 +40,7 @@
<hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key"> <hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.ALT_OR_OPTION_KEY') }} {{ $t('KEYBOARD_SHORTCUTS.KEYS.ALT_OR_OPTION_KEY') }}
</hotkey> </hotkey>
<hotkey custom-class="w-9 key"> <hotkey custom-class="w-9 key"> K </hotkey>
K
</hotkey>
</div> </div>
</div> </div>
@ -59,9 +55,7 @@
<hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key"> <hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.ALT_OR_OPTION_KEY') }} {{ $t('KEYBOARD_SHORTCUTS.KEYS.ALT_OR_OPTION_KEY') }}
</hotkey> </hotkey>
<hotkey custom-class="w-9 key"> <hotkey custom-class="w-9 key"> E </hotkey>
E
</hotkey>
</div> </div>
</div> </div>
<div <div

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