Compare commits
185 commits
foss-gh-ac
...
develop
Author | SHA1 | Date | |
---|---|---|---|
|
98c289dc3e | ||
|
3e91765472 | ||
|
1bf23055df | ||
|
2af337be10 | ||
|
dbb6c0a074 | ||
|
8c88344170 | ||
|
6a78254701 | ||
|
26ada8b342 | ||
|
3c6bd2c8fd | ||
|
2c2c47d7fd | ||
|
34f7405689 | ||
|
3ebfb3a140 | ||
|
2dfe38ae4d | ||
|
ca88eb55f4 | ||
|
d1a26e80f4 | ||
|
022d0b0ea3 | ||
|
5541d9e00b | ||
|
38587b3aa1 | ||
|
4d2b7c37a0 | ||
|
aaacf9d4d2 | ||
|
82d3398932 | ||
|
9106f6278d | ||
|
f8e6308caf | ||
|
72fcaa739c | ||
|
9292653bf9 | ||
|
2a1a38f986 | ||
|
2972319026 | ||
|
26e05de642 | ||
|
8222a47154 | ||
|
9d78f0d6c6 | ||
|
86958278cd | ||
|
823c836906 | ||
|
6200559123 | ||
|
7dc790a7e0 | ||
|
431e2931c4 | ||
|
52ea201070 | ||
|
779bcf5e0d | ||
|
6064aad38f | ||
|
caa45d1d92 | ||
|
f1d1bb84fd | ||
|
01cc3d7c9c | ||
|
89cfc5bbf3 | ||
|
a82b9991b3 | ||
|
06434bc655 | ||
|
b9fd1d88ea | ||
|
8004f67efe | ||
|
87ef39ad9c | ||
|
c3b6e1a732 | ||
|
c9cae01cb4 | ||
|
613fb0b064 | ||
|
0b5c82ad5f | ||
|
c8ec397c79 | ||
|
a08099bbcc | ||
|
e35638588a | ||
|
3083f74d45 | ||
|
85b52a1d3f | ||
|
c94ba16565 | ||
|
0cad3bed71 | ||
|
edcbd53425 | ||
|
a397f01692 | ||
|
4755031e1d | ||
|
fc9fc5a661 | ||
|
b05d06a28a | ||
|
8813c77907 | ||
|
8ea0660862 | ||
|
606fc9046a | ||
|
e593e516b8 | ||
|
b5f8524167 | ||
|
b765e17457 | ||
|
db37bfea06 | ||
|
16bfd68d95 | ||
|
33aacb3401 | ||
|
47676c3cce | ||
|
9bfbd528ef | ||
|
e85f998a08 | ||
|
66044a0dc3 | ||
|
9b9c019de0 | ||
|
86e0ff76c5 | ||
|
abbb6ac676 | ||
|
42b466bda2 | ||
|
956837ded5 | ||
|
f0ef497005 | ||
|
8e2da837d4 | ||
|
e7f1a9ab4d | ||
|
826a735cdb | ||
|
38ab3c36db | ||
|
b5f7be0cd2 | ||
|
efceaec950 | ||
|
6aba352e0d | ||
|
9eb861a3b7 | ||
|
3184c8964d | ||
|
865346223b | ||
|
4f82859bba | ||
|
47c90e2085 | ||
|
7352b928da | ||
|
5febdde938 | ||
|
d39ace5a6b | ||
|
e2059cfc5b | ||
|
2e42821c48 | ||
|
16d59f4bb0 | ||
|
0d9ed0674b | ||
|
6ff0c93659 | ||
|
20406dce01 | ||
|
b50890d1b5 | ||
|
48373628a1 | ||
|
479d88a480 | ||
|
526722dffa | ||
|
a23974d8b9 | ||
|
894234e777 | ||
|
dc3ee3464b | ||
|
4bb5e812ba | ||
|
8bd5ba187a | ||
|
c121b44df4 | ||
|
4c43330b15 | ||
|
8b659de73d | ||
|
936c2ec7e2 | ||
|
9c0cce0392 | ||
|
86ca7f4a8d | ||
|
6cfd594d85 | ||
|
e1190fd9bf | ||
|
f2753df8df | ||
|
00e06e5139 | ||
|
be516a5ea6 | ||
|
f8d9a27d7a | ||
|
92724576af | ||
|
a0606d36f6 | ||
|
2073a23d5c | ||
|
b20f5e5cef | ||
|
3b09840d39 | ||
|
2aa99ee137 | ||
|
89d7e4ead6 | ||
|
12cd15b6ad | ||
|
352558dd11 | ||
|
bedb2cab63 | ||
|
abe439594e | ||
|
bcde84b5b5 | ||
|
06e2219110 | ||
|
c3ec1d4f8a | ||
|
d54392cb53 | ||
|
3a71fe3260 | ||
|
af020f446e | ||
|
6823b04e5b | ||
|
8d5a9a9daa | ||
|
c3426929d7 | ||
|
4a299a9441 | ||
|
782165478b | ||
|
95cc55d043 | ||
|
a274a1702a | ||
|
4d0b302802 | ||
|
a8561cd798 | ||
|
fa73b5290c | ||
|
a3277b45af | ||
|
10d86fbb35 | ||
|
22d5703b92 | ||
|
1fb1be3ddc | ||
|
bce0bb8acb | ||
|
ceaffe862a | ||
|
199f462af4 | ||
|
c542d2e0ff | ||
|
6bc34db932 | ||
|
0343acdb7e | ||
|
f740727177 | ||
|
3de8f256cb | ||
|
2e7ab484bd | ||
|
e34e975776 | ||
|
a1ce188dab | ||
|
2f7a16ae16 | ||
|
71ca530292 | ||
|
e19c6d5671 | ||
|
2423def8e8 | ||
|
1c44e43c43 | ||
|
444809cc68 | ||
|
20b4a91122 | ||
|
73f5595762 | ||
|
704554d453 | ||
|
706ab872f3 | ||
|
252eda14c6 | ||
|
ce3730d640 | ||
|
db53af91e7 | ||
|
a6960dc2d3 | ||
|
e310230f62 | ||
|
1f271356ca | ||
|
bf4338ef9e | ||
|
a533f43fbf | ||
|
8f4944fda0 |
1197 changed files with 39354 additions and 5458 deletions
|
@ -54,3 +54,5 @@ exclude_patterns:
|
|||
- 'app/javascript/widget/i18n/index.js'
|
||||
- 'app/javascript/survey/i18n/index.js'
|
||||
- 'app/javascript/shared/constants/locales.js'
|
||||
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
|
||||
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'
|
||||
|
|
12
.env.example
12
.env.example
|
@ -34,6 +34,11 @@ REDIS_SENTINELS=
|
|||
# You can find list of master using "SENTINEL masters" command
|
||||
REDIS_SENTINEL_MASTER_NAME=
|
||||
|
||||
# By default Chatwoot will pass REDIS_PASSWORD as the password value for sentinels
|
||||
# Use the following environment variable to customize passwords for sentinels.
|
||||
# Use empty string if sentinels are configured with out passwords
|
||||
# REDIS_SENTINEL_PASSWORD=
|
||||
|
||||
# Redis premium breakage in heroku fix
|
||||
# enable the following configuration
|
||||
# ref: https://github.com/chatwoot/chatwoot/issues/2420
|
||||
|
@ -51,13 +56,14 @@ RAILS_MAX_THREADS=5
|
|||
|
||||
# The email from which all outgoing emails are sent
|
||||
# could user either `email@yourdomain.com` or `BrandName <email@yourdomain.com>`
|
||||
MAILER_SENDER_EMAIL="Chatwoot <accounts@chatwoot.com>"
|
||||
MAILER_SENDER_EMAIL=Chatwoot <accounts@chatwoot.com>
|
||||
|
||||
#SMTP domain key is set up for HELO checking
|
||||
SMTP_DOMAIN=chatwoot.com
|
||||
# the default value is set "mailhog" and is used by docker-compose for development environments,
|
||||
# Set the value to "mailhog" if using docker-compose for development environments,
|
||||
# Set the value as "localhost" or your SMTP address in other environments
|
||||
SMTP_ADDRESS=mailhog
|
||||
# If SMTP_ADDRESS is empty, Chatwoot would try to use sendmail(postfix)
|
||||
SMTP_ADDRESS=
|
||||
SMTP_PORT=1025
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
|
|
46
.github/ISSUE_TEMPLATE/bug_report.md
vendored
46
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -6,6 +6,7 @@ labels: 'Bug'
|
|||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
@ -16,11 +17,11 @@ Steps to reproduce the behavior:
|
|||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
4. See the error
|
||||
|
||||
**Expected behavior**
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
Share a clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
|
||||
|
@ -28,27 +29,50 @@ If applicable, add screenshots to help explain your problem.
|
|||
|
||||
**Browser logs**
|
||||
|
||||
Share the browser logs to debug the issue further
|
||||
Share the browser logs to debug the issue further.
|
||||
|
||||
**Server logs**
|
||||
|
||||
Share the server logs to debug the issue further
|
||||
Share the server logs to debug the issue further.
|
||||
|
||||
**Environment**
|
||||
|
||||
Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self hosted installation of Chatwoot. If you are using a self hosted installation of Chatwoot describe the type of deployment (Docker/Linux VM installation/Heroku)
|
||||
Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self-hosted installation of Chatwoot. If you are using a self-hosted installation of Chatwoot, describe the type of deployment (Docker/Linux VM installation/Heroku/Kubernetes/Other).
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- [ ] app.chatwoot.com (Chatwoot Cloud)
|
||||
- [ ] Self-hosted
|
||||
- - [ ] Linux VM
|
||||
- - [ ] Docker
|
||||
- - [ ] Kubernetes
|
||||
- - [ ] Heroku
|
||||
- - [ ] Other (Please specify)
|
||||
|
||||
|
||||
**Desktop (please complete the following information)** (If applicable)
|
||||
- OS: [e.g. Linux, Windows, MacOS]
|
||||
- Browser [e.g. chrome, firefox, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
**Smartphone (please complete the following information)** (If applicable)
|
||||
- Device: [e.g. iPhone6, Pixel7]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Browser [e.g. stock browser, firefox, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Docker** (If applicable)
|
||||
|
||||
Please share the output of the following.
|
||||
- `docker version`
|
||||
- `docker info`
|
||||
- `docker-compose version`
|
||||
|
||||
**Cloud Provider** (If applicable)
|
||||
- [ ] AWS
|
||||
- [ ] GCP
|
||||
- [ ] Azure
|
||||
- [ ] DigitalOcean
|
||||
- [ ] Others
|
||||
|
||||
**Additional context**
|
||||
|
||||
Add any other context about the problem here.
|
||||
|
|
9
.github/PULL_REQUEST_TEMPLATE.md
vendored
9
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
@ -2,8 +2,7 @@
|
|||
|
||||
## Description
|
||||
|
||||
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
|
||||
|
||||
Please include a summary of the change and issue(s) fixed. Also, mention relevant motivation, context, and any dependencies that this change requires.
|
||||
Fixes # (issue)
|
||||
|
||||
## Type of change
|
||||
|
@ -12,18 +11,18 @@ Please delete options that are not relevant.
|
|||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected)
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
## How Has This Been Tested?
|
||||
|
||||
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration
|
||||
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration.
|
||||
|
||||
|
||||
## Checklist:
|
||||
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have commented on my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
|
|
2
.github/workflows/publish_foss_docker.yml
vendored
2
.github/workflows/publish_foss_docker.yml
vendored
|
@ -58,6 +58,6 @@ jobs:
|
|||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.DOCKER_TAG }}
|
||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -60,3 +60,5 @@ test/cypress/videos/*
|
|||
|
||||
/config/master.key
|
||||
/config/*.enc
|
||||
|
||||
.vscode/settings.json
|
||||
|
|
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
{}
|
8
Gemfile
8
Gemfile
|
@ -4,7 +4,7 @@ ruby '3.0.4'
|
|||
|
||||
##-- base gems for rails --##
|
||||
gem 'rack-cors', require: 'rack/cors'
|
||||
gem 'rails', '~>6.1'
|
||||
gem 'rails', '~> 6.1', '>= 6.1.6.1'
|
||||
# Reduces boot times through caching; required in config/boot.rb
|
||||
gem 'bootsnap', require: false
|
||||
|
||||
|
@ -56,7 +56,7 @@ gem 'activerecord-import'
|
|||
gem 'dotenv-rails'
|
||||
gem 'foreman'
|
||||
gem 'puma'
|
||||
gem 'webpacker', '~> 5.x'
|
||||
gem 'webpacker', '~> 5.4', '>= 5.4.3'
|
||||
# metrics on heroku
|
||||
gem 'barnes'
|
||||
|
||||
|
@ -94,7 +94,7 @@ gem 'ddtrace'
|
|||
gem 'elastic-apm'
|
||||
gem 'newrelic_rpm'
|
||||
gem 'scout_apm'
|
||||
gem 'sentry-rails', '~> 5.3'
|
||||
gem 'sentry-rails', '~> 5.3', '>= 5.3.1'
|
||||
gem 'sentry-ruby', '~> 5.3'
|
||||
gem 'sentry-sidekiq', '~> 5.3'
|
||||
|
||||
|
@ -175,7 +175,7 @@ group :development, :test do
|
|||
gem 'mock_redis'
|
||||
gem 'pry-rails'
|
||||
gem 'rspec_junit_formatter'
|
||||
gem 'rspec-rails', '~> 5.0.0'
|
||||
gem 'rspec-rails', '~> 5.0.3'
|
||||
gem 'rubocop', require: false
|
||||
gem 'rubocop-performance', require: false
|
||||
gem 'rubocop-rails', require: false
|
||||
|
|
26
Gemfile.lock
26
Gemfile.lock
|
@ -398,7 +398,7 @@ GEM
|
|||
llhttp-ffi (0.4.0)
|
||||
ffi-compiler (~> 1.0)
|
||||
rake (~> 13.0)
|
||||
loofah (2.18.0)
|
||||
loofah (2.19.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.5.9)
|
||||
mail (2.7.1)
|
||||
|
@ -427,14 +427,14 @@ GEM
|
|||
netrc (0.11.0)
|
||||
newrelic_rpm (8.9.0)
|
||||
nio4r (2.5.8)
|
||||
nokogiri (1.13.7)
|
||||
nokogiri (1.13.10)
|
||||
mini_portile2 (~> 2.8.0)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.13.7-arm64-darwin)
|
||||
nokogiri (1.13.10-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.13.7-x86_64-darwin)
|
||||
nokogiri (1.13.10-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.13.7-x86_64-linux)
|
||||
nokogiri (1.13.10-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
oauth (0.5.10)
|
||||
orm_adapter (0.5.0)
|
||||
|
@ -459,7 +459,7 @@ GEM
|
|||
pundit (2.2.0)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.6.0)
|
||||
racc (1.6.1)
|
||||
rack (2.2.4)
|
||||
rack-attack (6.6.1)
|
||||
rack (>= 1.0, < 3)
|
||||
|
@ -488,8 +488,8 @@ GEM
|
|||
rails-dom-testing (2.0.3)
|
||||
activesupport (>= 4.2.0)
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.4.3)
|
||||
loofah (~> 2.3)
|
||||
rails-html-sanitizer (1.4.4)
|
||||
loofah (~> 2.19, >= 2.19.1)
|
||||
railties (6.1.6.1)
|
||||
actionpack (= 6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
|
@ -765,12 +765,12 @@ DEPENDENCIES
|
|||
rack-attack
|
||||
rack-cors
|
||||
rack-timeout
|
||||
rails (~> 6.1)
|
||||
rails (~> 6.1, >= 6.1.6.1)
|
||||
redis
|
||||
redis-namespace
|
||||
responders
|
||||
rest-client
|
||||
rspec-rails (~> 5.0.0)
|
||||
rspec-rails (~> 5.0.3)
|
||||
rspec_junit_formatter
|
||||
rubocop
|
||||
rubocop-performance
|
||||
|
@ -778,7 +778,7 @@ DEPENDENCIES
|
|||
rubocop-rspec
|
||||
scout_apm
|
||||
seed_dump
|
||||
sentry-rails (~> 5.3)
|
||||
sentry-rails (~> 5.3, >= 5.3.1)
|
||||
sentry-ruby (~> 5.3)
|
||||
sentry-sidekiq (~> 5.3)
|
||||
shoulda-matchers
|
||||
|
@ -799,7 +799,7 @@ DEPENDENCIES
|
|||
valid_email2
|
||||
web-console
|
||||
webmock
|
||||
webpacker (~> 5.x)
|
||||
webpacker (~> 5.4, >= 5.4.3)
|
||||
webpush
|
||||
wisper (= 2.0.0)
|
||||
working_hours
|
||||
|
@ -808,4 +808,4 @@ RUBY VERSION
|
|||
ruby 3.0.4p208
|
||||
|
||||
BUNDLED WITH
|
||||
2.3.18
|
||||
2.3.16
|
||||
|
|
14
app.json
14
app.json
|
@ -41,16 +41,24 @@
|
|||
"formation": {
|
||||
"web": {
|
||||
"quantity": 1,
|
||||
"size": "FREE"
|
||||
"size": "basic"
|
||||
},
|
||||
"worker": {
|
||||
"quantity": 1,
|
||||
"size": "FREE"
|
||||
"size": "basic"
|
||||
}
|
||||
},
|
||||
"stack": "heroku-20",
|
||||
"image": "heroku/ruby",
|
||||
"addons": [ "heroku-redis", "heroku-postgresql"],
|
||||
"addons": [
|
||||
{
|
||||
"plan": "heroku-redis:mini"
|
||||
},
|
||||
{
|
||||
"plan": "heroku-postgresql:mini"
|
||||
}
|
||||
],
|
||||
"stack": "heroku-20",
|
||||
"buildpacks": [
|
||||
{
|
||||
"url": "heroku/ruby"
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
# This Builder will create a contact inbox with specified attributes. If the contact inbox already exists, it will be returned.
|
||||
# For Specific Channels like whatsapp, email etc . it smartly generated appropriate the source id when none is provided.
|
||||
|
||||
class ContactInboxBuilder
|
||||
pattr_initialize [:contact_id!, :inbox_id!, :source_id]
|
||||
pattr_initialize [:contact, :inbox, :source_id, { hmac_verified: false }]
|
||||
|
||||
def perform
|
||||
@contact = Contact.find(contact_id)
|
||||
@inbox = @contact.account.inboxes.find(inbox_id)
|
||||
return unless ['Channel::TwilioSms', 'Channel::Sms', 'Channel::Email', 'Channel::Api', 'Channel::Whatsapp'].include? @inbox.channel_type
|
||||
|
||||
source_id = @source_id || generate_source_id
|
||||
create_contact_inbox(source_id) if source_id.present?
|
||||
@source_id ||= generate_source_id
|
||||
create_contact_inbox if source_id.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -19,23 +18,37 @@ class ContactInboxBuilder
|
|||
when 'Channel::Whatsapp'
|
||||
wa_source_id
|
||||
when 'Channel::Email'
|
||||
@contact.email
|
||||
email_source_id
|
||||
when 'Channel::Sms'
|
||||
@contact.phone_number
|
||||
when 'Channel::Api'
|
||||
phone_source_id
|
||||
when 'Channel::Api', 'Channel::WebWidget'
|
||||
SecureRandom.uuid
|
||||
else
|
||||
raise "Unsupported operation for this channel: #{@inbox.channel_type}"
|
||||
end
|
||||
end
|
||||
|
||||
def email_source_id
|
||||
raise ActionController::ParameterMissing, 'contact email' unless @contact.email
|
||||
|
||||
@contact.email
|
||||
end
|
||||
|
||||
def phone_source_id
|
||||
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
|
||||
|
||||
@contact.phone_number
|
||||
end
|
||||
|
||||
def wa_source_id
|
||||
return unless @contact.phone_number
|
||||
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
|
||||
|
||||
# whatsapp doesn't want the + in e164 format
|
||||
@contact.phone_number.delete('+').to_s
|
||||
end
|
||||
|
||||
def twilio_source_id
|
||||
return unless @contact.phone_number
|
||||
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
|
||||
|
||||
case @inbox.channel.medium
|
||||
when 'sms'
|
||||
|
@ -45,11 +58,11 @@ class ContactInboxBuilder
|
|||
end
|
||||
end
|
||||
|
||||
def create_contact_inbox(source_id)
|
||||
::ContactInbox.find_or_create_by!(
|
||||
def create_contact_inbox
|
||||
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
|
||||
contact_id: @contact.id,
|
||||
inbox_id: @inbox.id,
|
||||
source_id: source_id
|
||||
source_id: @source_id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,25 +1,47 @@
|
|||
class ContactBuilder
|
||||
pattr_initialize [:source_id!, :inbox!, :contact_attributes!, :hmac_verified]
|
||||
# This Builder will create a contact and contact inbox with specified attributes.
|
||||
# If an existing identified contact exisits, it will be returned.
|
||||
# for contact inbox logic it uses the contact inbox builder
|
||||
|
||||
class ContactInboxWithContactBuilder
|
||||
pattr_initialize [:inbox!, :contact_attributes!, :source_id, :hmac_verified]
|
||||
|
||||
def perform
|
||||
contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id)
|
||||
return contact_inbox if contact_inbox
|
||||
find_or_create_contact_and_contact_inbox
|
||||
# in case of race conditions where contact is created by another thread
|
||||
# we will try to find the contact and create a contact inbox
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
find_or_create_contact_and_contact_inbox
|
||||
end
|
||||
|
||||
build_contact_inbox
|
||||
def find_or_create_contact_and_contact_inbox
|
||||
@contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id) if source_id.present?
|
||||
return @contact_inbox if @contact_inbox
|
||||
|
||||
ActiveRecord::Base.transaction(requires_new: true) do
|
||||
build_contact_with_contact_inbox
|
||||
update_contact_avatar(@contact) unless @contact.avatar.attached?
|
||||
@contact_inbox
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_contact_with_contact_inbox
|
||||
@contact = find_contact || create_contact
|
||||
@contact_inbox = create_contact_inbox
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= inbox.account
|
||||
end
|
||||
|
||||
def create_contact_inbox(contact)
|
||||
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
|
||||
contact_id: contact.id,
|
||||
inbox_id: inbox.id,
|
||||
source_id: source_id
|
||||
)
|
||||
def create_contact_inbox
|
||||
ContactInboxBuilder.new(
|
||||
contact: @contact,
|
||||
inbox: @inbox,
|
||||
source_id: @source_id,
|
||||
hmac_verified: hmac_verified
|
||||
).perform
|
||||
end
|
||||
|
||||
def update_contact_avatar(contact)
|
||||
|
@ -61,16 +83,4 @@ class ContactBuilder
|
|||
|
||||
account.contacts.find_by(phone_number: phone_number)
|
||||
end
|
||||
|
||||
def build_contact_inbox
|
||||
ActiveRecord::Base.transaction do
|
||||
contact = find_contact || create_contact
|
||||
contact_inbox = create_contact_inbox(contact)
|
||||
update_contact_avatar(contact)
|
||||
contact_inbox
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
raise e
|
||||
end
|
||||
end
|
||||
end
|
40
app/builders/conversation_builder.rb
Normal file
40
app/builders/conversation_builder.rb
Normal file
|
@ -0,0 +1,40 @@
|
|||
class ConversationBuilder
|
||||
pattr_initialize [:params!, :contact_inbox!]
|
||||
|
||||
def perform
|
||||
look_up_exising_conversation || create_new_conversation
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def look_up_exising_conversation
|
||||
return unless @contact_inbox.inbox.lock_to_single_conversation?
|
||||
|
||||
@contact_inbox.conversations.last
|
||||
end
|
||||
|
||||
def create_new_conversation
|
||||
::Conversation.create!(conversation_params)
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
additional_attributes = params[:additional_attributes]&.permit! || {}
|
||||
custom_attributes = params[:custom_attributes]&.permit! || {}
|
||||
status = params[:status].present? ? { status: params[:status] } : {}
|
||||
|
||||
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
|
||||
# commenting this out to see if there are any errors, if not we can remove this in subsequent releases
|
||||
# status = { status: 'pending' } if status[:status] == 'bot'
|
||||
{
|
||||
account_id: @contact_inbox.inbox.account_id,
|
||||
inbox_id: @contact_inbox.inbox_id,
|
||||
contact_id: @contact_inbox.contact_id,
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: additional_attributes,
|
||||
custom_attributes: custom_attributes,
|
||||
snoozed_until: params[:snoozed_until],
|
||||
assignee_id: params[:assignee_id],
|
||||
team_id: params[:team_id]
|
||||
}.merge(status)
|
||||
end
|
||||
end
|
|
@ -22,10 +22,9 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
|||
return if @inbox.channel.reauthorization_required?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
build_contact
|
||||
build_contact_inbox
|
||||
build_message
|
||||
end
|
||||
ensure_contact_avatar
|
||||
rescue Koala::Facebook::AuthenticationError
|
||||
@inbox.channel.authorization_error!
|
||||
rescue StandardError => e
|
||||
|
@ -35,15 +34,12 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
|||
|
||||
private
|
||||
|
||||
def contact
|
||||
@contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact
|
||||
end
|
||||
|
||||
def build_contact
|
||||
return if contact.present?
|
||||
|
||||
@contact = Contact.create!(contact_params.except(:remote_avatar_url))
|
||||
@contact_inbox = ContactInbox.find_or_create_by!(contact: contact, inbox: @inbox, source_id: @sender_id)
|
||||
def build_contact_inbox
|
||||
@contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: @sender_id,
|
||||
inbox: @inbox,
|
||||
contact_attributes: contact_params
|
||||
).perform
|
||||
end
|
||||
|
||||
def build_message
|
||||
|
@ -54,19 +50,11 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
|||
end
|
||||
end
|
||||
|
||||
def ensure_contact_avatar
|
||||
return if contact_params[:remote_avatar_url].blank?
|
||||
return if @contact.avatar.attached?
|
||||
|
||||
Avatar::AvatarFromUrlJob.perform_later(@contact, contact_params[:remote_avatar_url])
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
|
||||
end
|
||||
|
||||
def build_conversation
|
||||
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: @sender_id)
|
||||
Conversation.create!(conversation_params.merge(
|
||||
contact_inbox_id: @contact_inbox.id
|
||||
))
|
||||
|
@ -94,7 +82,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
|||
{
|
||||
account_id: @inbox.account_id,
|
||||
inbox_id: @inbox.id,
|
||||
contact_id: contact.id
|
||||
contact_id: @contact_inbox.contact_id
|
||||
}
|
||||
end
|
||||
|
||||
|
@ -105,7 +93,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
|||
message_type: @message_type,
|
||||
content: response.content,
|
||||
source_id: response.identifier,
|
||||
sender: @outgoing_echo ? nil : contact
|
||||
sender: @outgoing_echo ? nil : @contact_inbox.contact
|
||||
}
|
||||
end
|
||||
|
||||
|
@ -113,7 +101,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
|||
{
|
||||
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
|
||||
account_id: @inbox.account_id,
|
||||
remote_avatar_url: result['profile_pic'] || ''
|
||||
avatar_url: result['profile_pic']
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
@ -72,6 +72,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
|||
|
||||
def build_message
|
||||
return if @outgoing_echo && already_sent_from_chatwoot?
|
||||
return if message_content.blank? && all_unsupported_files?
|
||||
|
||||
@message = conversation.messages.create!(message_params)
|
||||
|
||||
|
@ -117,6 +118,13 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
|||
cw_message.present?
|
||||
end
|
||||
|
||||
def all_unsupported_files?
|
||||
return if attachments.empty?
|
||||
|
||||
attachments_type = attachments.pluck(:type).uniq.first
|
||||
unsupported_file_type?(attachments_type)
|
||||
end
|
||||
|
||||
### Sample response
|
||||
# {
|
||||
# "object": "instagram",
|
||||
|
|
|
@ -35,7 +35,13 @@ class Messages::MessageBuilder
|
|||
file: uploaded_attachment
|
||||
)
|
||||
|
||||
attachment.file_type = file_type(uploaded_attachment&.content_type) if uploaded_attachment.is_a?(ActionDispatch::Http::UploadedFile)
|
||||
attachment.file_type = if uploaded_attachment.is_a?(String)
|
||||
file_type_by_signed_id(
|
||||
uploaded_attachment
|
||||
)
|
||||
else
|
||||
file_type(uploaded_attachment&.content_type)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -2,7 +2,8 @@ class Messages::Messenger::MessageBuilder
|
|||
include ::FileTypeHelper
|
||||
|
||||
def process_attachment(attachment)
|
||||
return if attachment['type'].to_sym == :template
|
||||
# This check handles very rare case if there are multiple files to attach with only one usupported file
|
||||
return if unsupported_file_type?(attachment['type'])
|
||||
|
||||
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
|
||||
attachment_obj.save!
|
||||
|
@ -45,6 +46,7 @@ class Messages::Messenger::MessageBuilder
|
|||
end
|
||||
|
||||
def update_attachment_file_type(attachment)
|
||||
return if @message.reload.attachments.blank?
|
||||
return unless attachment.file_type == 'share' || attachment.file_type == 'story_mention'
|
||||
|
||||
attachment.file_type = file_type(attachment.file&.content_type)
|
||||
|
@ -61,6 +63,7 @@ class Messages::Messenger::MessageBuilder
|
|||
story_sender = result['from']['username']
|
||||
message.content_attributes[:story_sender] = story_sender
|
||||
message.content_attributes[:story_id] = story_id
|
||||
message.content_attributes[:image_type] = 'story_mention'
|
||||
message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender)
|
||||
message.save!
|
||||
end
|
||||
|
@ -73,6 +76,7 @@ class Messages::Messenger::MessageBuilder
|
|||
raise
|
||||
rescue Koala::Facebook::ClientError => e
|
||||
# The exception occurs when we are trying fetch the deleted story or blocked story.
|
||||
@message.attachments.destroy_all
|
||||
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
|
||||
Rails.logger.error e
|
||||
{}
|
||||
|
@ -80,4 +84,10 @@ class Messages::Messenger::MessageBuilder
|
|||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||
{}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def unsupported_file_type?(attachment_type)
|
||||
[:template, :unsupported_type].include? attachment_type.to_sym
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,9 +5,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
|||
before_action :set_current_page, only: [:index]
|
||||
|
||||
def index
|
||||
@articles_count = @portal.articles.count
|
||||
@articles = @portal.articles
|
||||
@articles = @articles.search(list_params) if list_params.present?
|
||||
@portal_articles = @portal.articles
|
||||
@all_articles = @portal_articles.search(list_params)
|
||||
@articles_count = @all_articles.count
|
||||
@articles = @all_articles.page(@current_page)
|
||||
end
|
||||
|
||||
def create
|
||||
|
@ -37,7 +38,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
|||
end
|
||||
|
||||
def portal
|
||||
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
|
||||
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
|
||||
end
|
||||
|
||||
def article_params
|
||||
|
|
|
@ -5,6 +5,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
|||
before_action :set_current_page, only: [:index]
|
||||
|
||||
def index
|
||||
@current_locale = params[:locale]
|
||||
@categories = @portal.categories.search(params)
|
||||
end
|
||||
|
||||
|
|
|
@ -2,8 +2,11 @@ class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts:
|
|||
before_action :ensure_inbox, only: [:create]
|
||||
|
||||
def create
|
||||
source_id = params[:source_id] || SecureRandom.uuid
|
||||
@contact_inbox = ContactInbox.create!(contact: @contact, inbox: @inbox, source_id: source_id)
|
||||
@contact_inbox = ContactInboxBuilder.new(
|
||||
contact: @contact,
|
||||
inbox: @inbox,
|
||||
source_id: params[:source_id]
|
||||
).perform
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -2,7 +2,7 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
|
|||
def index
|
||||
@conversations = Current.account.conversations.includes(
|
||||
:assignee, :contact, :inbox, :taggings
|
||||
).where(inbox_id: inbox_ids, contact_id: @contact.id)
|
||||
).where(inbox_id: inbox_ids, contact_id: @contact.id).order(id: :desc).limit(20)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -134,8 +134,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||
return if params[:inbox_id].blank?
|
||||
|
||||
inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
source_id = params[:source_id] || SecureRandom.uuid
|
||||
ContactInbox.create!(contact: @contact, inbox: inbox, source_id: source_id)
|
||||
ContactInboxBuilder.new(
|
||||
contact: @contact,
|
||||
inbox: inbox,
|
||||
source_id: params[:source_id]
|
||||
).perform
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
|
|
@ -3,7 +3,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||
include DateRangeHelper
|
||||
|
||||
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
|
||||
before_action :contact_inbox, only: [:create]
|
||||
before_action :inbox, :contact, :contact_inbox, only: [:create]
|
||||
|
||||
def index
|
||||
result = conversation_finder.perform
|
||||
|
@ -24,7 +24,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||
|
||||
def create
|
||||
ActiveRecord::Base.transaction do
|
||||
@conversation = ::Conversation.create!(conversation_params)
|
||||
@conversation = ConversationBuilder.new(params: params, contact_inbox: @contact_inbox).perform
|
||||
Messages::MessageBuilder.new(Current.user, @conversation, params[:message]).perform if params[:message].present?
|
||||
end
|
||||
end
|
||||
|
@ -75,10 +75,13 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||
end
|
||||
|
||||
def update_last_seen
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
@conversation.update_column(:agent_last_seen_at, DateTime.now.utc)
|
||||
@conversation.update_column(:assignee_last_seen_at, DateTime.now.utc) if assignee?
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
update_last_seen_on_conversation(DateTime.now.utc, assignee?)
|
||||
end
|
||||
|
||||
def unread
|
||||
last_incoming_message = @conversation.messages.incoming.last
|
||||
last_seen_at = last_incoming_message.created_at - 1.second if last_incoming_message.present?
|
||||
update_last_seen_on_conversation(last_seen_at, true)
|
||||
end
|
||||
|
||||
def custom_attributes
|
||||
|
@ -88,9 +91,18 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||
|
||||
private
|
||||
|
||||
def update_last_seen_on_conversation(last_seen_at, update_assignee)
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
@conversation.update_column(:agent_last_seen_at, last_seen_at)
|
||||
@conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present?
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def set_conversation_status
|
||||
status = params[:status] == 'bot' ? 'pending' : params[:status]
|
||||
@conversation.status = status
|
||||
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
|
||||
# commenting this out to see if there are any errors, if not we can remove this in subsequent releases
|
||||
# status = params[:status] == 'bot' ? 'pending' : params[:status]
|
||||
@conversation.status = params[:status]
|
||||
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
|
||||
end
|
||||
|
||||
|
@ -109,51 +121,44 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||
authorize @conversation.inbox, :show?
|
||||
end
|
||||
|
||||
def inbox
|
||||
return if params[:inbox_id].blank?
|
||||
|
||||
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
authorize @inbox, :show?
|
||||
end
|
||||
|
||||
def contact
|
||||
return if params[:contact_id].blank?
|
||||
|
||||
@contact = Current.account.contacts.find(params[:contact_id])
|
||||
end
|
||||
|
||||
def contact_inbox
|
||||
@contact_inbox = build_contact_inbox
|
||||
|
||||
# fallback for the old case where we do look up only using source id
|
||||
# In future we need to change this and make sure we do look up on combination of inbox_id and source_id
|
||||
# and deprecate the support of passing only source_id as the param
|
||||
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
|
||||
authorize @contact_inbox.inbox, :show?
|
||||
end
|
||||
|
||||
def build_contact_inbox
|
||||
return if params[:contact_id].blank? || params[:inbox_id].blank?
|
||||
|
||||
inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
authorize inbox, :show?
|
||||
return if @inbox.blank? || @contact.blank?
|
||||
|
||||
ContactInboxBuilder.new(
|
||||
contact_id: params[:contact_id],
|
||||
inbox_id: inbox.id,
|
||||
contact: @contact,
|
||||
inbox: @inbox,
|
||||
source_id: params[:source_id]
|
||||
).perform
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
additional_attributes = params[:additional_attributes]&.permit! || {}
|
||||
custom_attributes = params[:custom_attributes]&.permit! || {}
|
||||
status = params[:status].present? ? { status: params[:status] } : {}
|
||||
|
||||
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
|
||||
status = { status: 'pending' } if status[:status] == 'bot'
|
||||
{
|
||||
account_id: Current.account.id,
|
||||
inbox_id: @contact_inbox.inbox_id,
|
||||
contact_id: @contact_inbox.contact_id,
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: additional_attributes,
|
||||
custom_attributes: custom_attributes,
|
||||
snoozed_until: params[:snoozed_until],
|
||||
assignee_id: params[:assignee_id],
|
||||
team_id: params[:team_id]
|
||||
}.merge(status)
|
||||
end
|
||||
|
||||
def conversation_finder
|
||||
@conversation_finder ||= ConversationFinder.new(current_user, params)
|
||||
@conversation_finder ||= ConversationFinder.new(Current.user, params)
|
||||
end
|
||||
|
||||
def assignee?
|
||||
@conversation.assignee_id? && current_user == @conversation.assignee
|
||||
@conversation.assignee_id? && Current.user == @conversation.assignee
|
||||
end
|
||||
end
|
||||
|
|
|
@ -113,7 +113,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
|
||||
def inbox_attributes
|
||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved]
|
||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||
:lock_to_single_conversation]
|
||||
end
|
||||
|
||||
def permitted_params(channel_attributes = [])
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
|
||||
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
|
||||
|
||||
def index
|
||||
@macros = Macro.with_visibility(current_user, params)
|
||||
|
@ -14,6 +14,8 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
|||
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
|
||||
|
||||
@macro.save!
|
||||
process_attachments
|
||||
@macro
|
||||
end
|
||||
|
||||
def show
|
||||
|
@ -25,10 +27,21 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
|||
head :ok
|
||||
end
|
||||
|
||||
def attach_file
|
||||
file_blob = ActiveStorage::Blob.create_and_upload!(
|
||||
key: nil,
|
||||
io: params[:attachment].tempfile,
|
||||
filename: params[:attachment].original_filename,
|
||||
content_type: params[:attachment].content_type
|
||||
)
|
||||
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
|
||||
end
|
||||
|
||||
def update
|
||||
ActiveRecord::Base.transaction do
|
||||
@macro.update!(macros_with_user)
|
||||
@macro.set_visibility(current_user, permitted_params)
|
||||
process_attachments
|
||||
@macro.save!
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
|
@ -42,6 +55,19 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
|||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_attachments
|
||||
actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
|
||||
return if actions.blank?
|
||||
|
||||
actions.each do |action|
|
||||
blob_id = action['action_params']
|
||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
||||
@macro.files.attach(blob)
|
||||
end
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(
|
||||
:name, :account_id, :visibility,
|
||||
|
@ -56,4 +82,8 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
|||
def fetch_macro
|
||||
@macro = Current.account.macros.find_by(id: params[:id])
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(@macro) if @macro.present?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -14,10 +14,14 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||
@portal.members << agents
|
||||
end
|
||||
|
||||
def show; end
|
||||
def show
|
||||
@all_articles = @portal.articles
|
||||
@articles = @all_articles.search(locale: params[:locale])
|
||||
end
|
||||
|
||||
def create
|
||||
@portal = Current.account.portals.build(portal_params)
|
||||
@portal.custom_domain = parsed_custom_domain
|
||||
@portal.save!
|
||||
process_attached_logo
|
||||
end
|
||||
|
@ -25,6 +29,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||
def update
|
||||
ActiveRecord::Base.transaction do
|
||||
@portal.update!(portal_params) if params[:portal].present?
|
||||
# @portal.custom_domain = parsed_custom_domain
|
||||
process_attached_logo
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
|
@ -70,4 +75,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||
def set_current_page
|
||||
@current_page = params[:page] || 1
|
||||
end
|
||||
|
||||
def parsed_custom_domain
|
||||
domain = URI.parse(@portal.custom_domain)
|
||||
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
|
||||
end
|
||||
end
|
||||
|
|
|
@ -18,6 +18,10 @@ class Api::V1::ProfilesController < Api::BaseController
|
|||
head :ok
|
||||
end
|
||||
|
||||
def auto_offline
|
||||
@user.account_users.find_by!(account_id: auto_offline_params[:account_id]).update!(auto_offline: auto_offline_params[:auto_offline] || false)
|
||||
end
|
||||
|
||||
def availability
|
||||
@user.account_users.find_by!(account_id: availability_params[:account_id]).update!(availability: availability_params[:availability])
|
||||
end
|
||||
|
@ -37,6 +41,10 @@ class Api::V1::ProfilesController < Api::BaseController
|
|||
params.require(:profile).permit(:account_id, :availability)
|
||||
end
|
||||
|
||||
def auto_offline_params
|
||||
params.require(:profile).permit(:account_id, :auto_offline)
|
||||
end
|
||||
|
||||
def profile_params
|
||||
params.require(:profile).permit(
|
||||
:email,
|
||||
|
|
|
@ -50,7 +50,9 @@ class Api::V1::Widget::BaseController < ApplicationController
|
|||
end
|
||||
|
||||
def contact_name
|
||||
params[:contact][:name] || contact_email.split('@')[0] if contact_email.present?
|
||||
return if @contact.email.present? || @contact.phone_number.present? || @contact.identifier.present?
|
||||
|
||||
permitted_params.dig(:contact, :name) || (contact_email.split('@')[0] if contact_email.present?)
|
||||
end
|
||||
|
||||
def contact_phone_number
|
||||
|
|
|
@ -17,7 +17,8 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
|||
@contact = ContactIdentifyAction.new(
|
||||
contact: @contact,
|
||||
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
|
||||
retain_original_contact_name: true
|
||||
retain_original_contact_name: true,
|
||||
discard_invalid_attrs: true
|
||||
).perform
|
||||
end
|
||||
|
||||
|
|
|
@ -13,6 +13,8 @@ module RequestExceptionHandler
|
|||
render_not_found_error('Resource could not be found')
|
||||
rescue Pundit::NotAuthorizedError
|
||||
render_unauthorized('You are not authorized to do this action')
|
||||
rescue ActionController::ParameterMissing => e
|
||||
render_could_not_create_error(e.message)
|
||||
ensure
|
||||
# to address the thread variable leak issues in Puma/Thin webserver
|
||||
Current.reset
|
||||
|
|
|
@ -16,8 +16,7 @@ class DashboardController < ActionController::Base
|
|||
@global_config = GlobalConfig.get(
|
||||
'LOGO', 'LOGO_THUMBNAIL',
|
||||
'INSTALLATION_NAME',
|
||||
'WIDGET_BRAND_URL',
|
||||
'TERMS_URL',
|
||||
'WIDGET_BRAND_URL', 'TERMS_URL',
|
||||
'PRIVACY_URL',
|
||||
'DISPLAY_MANIFEST',
|
||||
'CREATE_NEW_ACCOUNT_FROM_DASHBOARD',
|
||||
|
@ -25,12 +24,12 @@ class DashboardController < ActionController::Base
|
|||
'API_CHANNEL_NAME',
|
||||
'API_CHANNEL_THUMBNAIL',
|
||||
'ANALYTICS_TOKEN',
|
||||
'ANALYTICS_HOST',
|
||||
'DIRECT_UPLOADS_ENABLED',
|
||||
'HCAPTCHA_SITE_KEY',
|
||||
'LOGOUT_REDIRECT_LINK',
|
||||
'DISABLE_USER_PROFILE_UPDATE',
|
||||
'DEPLOYMENT_ENV'
|
||||
'DEPLOYMENT_ENV',
|
||||
'CSML_EDITOR_HOST'
|
||||
).merge(app_config)
|
||||
end
|
||||
|
||||
|
|
|
@ -1,18 +1,16 @@
|
|||
class Platform::Api::V1::AccountsController < PlatformController
|
||||
def create
|
||||
@resource = Account.new(account_params)
|
||||
@resource.save!
|
||||
@resource = Account.create!(account_params)
|
||||
update_resource_features
|
||||
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
|
||||
render json: @resource
|
||||
end
|
||||
|
||||
def show
|
||||
render json: @resource
|
||||
end
|
||||
def show; end
|
||||
|
||||
def update
|
||||
@resource.update!(account_params)
|
||||
render json: @resource
|
||||
@resource.assign_attributes(account_params)
|
||||
update_resource_features
|
||||
@resource.save!
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
@ -27,6 +25,18 @@ class Platform::Api::V1::AccountsController < PlatformController
|
|||
end
|
||||
|
||||
def account_params
|
||||
params.permit(:name, :locale)
|
||||
permitted_params.except(:features)
|
||||
end
|
||||
|
||||
def update_resource_features
|
||||
return if permitted_params[:features].blank?
|
||||
|
||||
permitted_params[:features].each do |key, value|
|
||||
value.present? ? @resource.enable_features(key) : @resource.disable_features(key)
|
||||
end
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:name, :locale, :domain, :support_email, :status, features: {}, limits: {}, custom_attributes: {})
|
||||
end
|
||||
end
|
||||
|
|
|
@ -4,10 +4,10 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
|
|||
|
||||
def create
|
||||
source_id = params[:source_id] || SecureRandom.uuid
|
||||
@contact_inbox = ::ContactBuilder.new(
|
||||
@contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: source_id,
|
||||
inbox: @inbox_channel.inbox,
|
||||
contact_attributes: permitted_params.except(:identifier, :identifier_hash)
|
||||
contact_attributes: permitted_params.except(:identifier_hash)
|
||||
).perform
|
||||
end
|
||||
|
||||
|
|
|
@ -3,9 +3,15 @@ class Public::Api::V1::InboxesController < PublicController
|
|||
before_action :set_contact_inbox
|
||||
before_action :set_conversation
|
||||
|
||||
def show
|
||||
@inbox_channel = ::Channel::Api.find_by!(identifier: params[:id])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_inbox_channel
|
||||
return if params[:inbox_id].blank?
|
||||
|
||||
@inbox_channel = ::Channel::Api.find_by!(identifier: params[:inbox_id])
|
||||
end
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
class Public::Api::V1::Portals::ArticlesController < PublicController
|
||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||
before_action :portal
|
||||
before_action :set_category
|
||||
before_action :set_category, except: [:index]
|
||||
before_action :set_article, only: [:show]
|
||||
layout 'portal'
|
||||
|
||||
|
@ -20,7 +20,7 @@ class Public::Api::V1::Portals::ArticlesController < PublicController
|
|||
end
|
||||
|
||||
def set_category
|
||||
@category = @portal.categories.find_by!(slug: params[:category_slug])
|
||||
@category = @portal.categories.find_by!(slug: params[:category_slug]) if params[:category_slug].present?
|
||||
end
|
||||
|
||||
def portal
|
||||
|
|
|
@ -56,7 +56,6 @@ class ConversationFinder
|
|||
filter_by_team if @team
|
||||
filter_by_labels if params[:labels]
|
||||
filter_by_query if params[:q]
|
||||
filter_by_reply_status
|
||||
end
|
||||
|
||||
def set_inboxes
|
||||
|
@ -76,12 +75,9 @@ class ConversationFinder
|
|||
end
|
||||
|
||||
def find_all_conversations
|
||||
if params[:conversation_type] == 'mention'
|
||||
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
|
||||
@conversations = current_account.conversations.where(id: conversation_ids)
|
||||
else
|
||||
@conversations = current_account.conversations.where(inbox_id: @inbox_ids)
|
||||
end
|
||||
@conversations = current_account.conversations.where(inbox_id: @inbox_ids)
|
||||
filter_by_conversation_type if params[:conversation_type]
|
||||
@conversations
|
||||
end
|
||||
|
||||
def filter_by_assignee_type
|
||||
|
@ -96,8 +92,15 @@ class ConversationFinder
|
|||
@conversations
|
||||
end
|
||||
|
||||
def filter_by_reply_status
|
||||
@conversations = @conversations.where(first_reply_created_at: nil) if params[:reply_status] == 'unattended'
|
||||
def filter_by_conversation_type
|
||||
case @params[:conversation_type]
|
||||
when 'mention'
|
||||
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
|
||||
@conversations = @conversations.where(id: conversation_ids)
|
||||
when 'unattended'
|
||||
@conversations = @conversations.where(first_reply_created_at: nil)
|
||||
end
|
||||
@conversations
|
||||
end
|
||||
|
||||
def filter_by_query
|
||||
|
|
|
@ -21,7 +21,9 @@ class MessageFinder
|
|||
end
|
||||
|
||||
def current_messages
|
||||
if @params[:before].present?
|
||||
if @params[:after].present?
|
||||
messages.reorder('created_at asc').where('id >= ?', @params[:before].to_i).limit(20)
|
||||
elsif @params[:before].present?
|
||||
messages.reorder('created_at desc').where('id < ?', @params[:before].to_i).limit(20).reverse
|
||||
else
|
||||
messages.reorder('created_at desc').limit(20).reverse
|
||||
|
|
|
@ -8,6 +8,12 @@ module FileTypeHelper
|
|||
:file
|
||||
end
|
||||
|
||||
# Used in case of DIRECT_UPLOADS_ENABLED=true
|
||||
def file_type_by_signed_id(signed_id)
|
||||
blob = ActiveStorage::Blob.find_signed(signed_id)
|
||||
file_type(blob&.content_type)
|
||||
end
|
||||
|
||||
def image_file?(content_type)
|
||||
[
|
||||
'image/jpeg',
|
||||
|
|
|
@ -144,6 +144,12 @@ export default {
|
|||
});
|
||||
},
|
||||
|
||||
updateAutoOffline(accountId, autoOffline = false) {
|
||||
return axios.post(endPoints('autoOffline').url, {
|
||||
profile: { account_id: accountId, auto_offline: autoOffline },
|
||||
});
|
||||
},
|
||||
|
||||
deleteAvatar() {
|
||||
return axios.delete(endPoints('deleteAvatar').url);
|
||||
},
|
||||
|
|
|
@ -16,6 +16,9 @@ const endPoints = {
|
|||
availabilityUpdate: {
|
||||
url: '/api/v1/profile/availability',
|
||||
},
|
||||
autoOffline: {
|
||||
url: '/api/v1/profile/auto_offline',
|
||||
},
|
||||
logout: {
|
||||
url: 'auth/sign_out',
|
||||
},
|
||||
|
|
|
@ -7,8 +7,8 @@ class CategoriesAPI extends PortalsAPI {
|
|||
super('categories', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ portalSlug }) {
|
||||
return axios.get(`${this.url}/${portalSlug}/categories`);
|
||||
get({ portalSlug, locale }) {
|
||||
return axios.get(`${this.url}/${portalSlug}/categories?locale=${locale}`);
|
||||
}
|
||||
|
||||
create({ portalSlug, categoryObj }) {
|
||||
|
|
|
@ -6,6 +6,10 @@ class PortalsAPI extends ApiClient {
|
|||
super('portals', { accountScoped: true });
|
||||
}
|
||||
|
||||
getPortal({ portalSlug, locale }) {
|
||||
return axios.get(`${this.url}/${portalSlug}?locale=${locale}`);
|
||||
}
|
||||
|
||||
updatePortal({ portalSlug, portalObj }) {
|
||||
return axios.patch(`${this.url}/${portalSlug}`, portalObj);
|
||||
}
|
||||
|
|
|
@ -68,6 +68,10 @@ class ConversationApi extends ApiClient {
|
|||
return axios.post(`${this.url}/${id}/update_last_seen`);
|
||||
}
|
||||
|
||||
markMessagesUnread({ id }) {
|
||||
return axios.post(`${this.url}/${id}/unread`);
|
||||
}
|
||||
|
||||
toggleTyping({ conversationId, status, isPrivate }) {
|
||||
return axios.post(`${this.url}/${conversationId}/toggle_typing_status`, {
|
||||
typing_status: status,
|
||||
|
@ -105,6 +109,16 @@ class ConversationApi extends ApiClient {
|
|||
custom_attributes: customAttributes,
|
||||
});
|
||||
}
|
||||
|
||||
fetchParticipants(conversationId) {
|
||||
return axios.get(`${this.url}/${conversationId}/participants`);
|
||||
}
|
||||
|
||||
updateParticipants({ conversationId, userIds }) {
|
||||
return axios.patch(`${this.url}/${conversationId}/participants`, {
|
||||
user_ids: userIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConversationApi();
|
||||
|
|
|
@ -13,6 +13,16 @@ class Inboxes extends ApiClient {
|
|||
deleteInboxAvatar(inboxId) {
|
||||
return axios.delete(`${this.url}/${inboxId}/avatar`);
|
||||
}
|
||||
|
||||
getAgentBot(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/agent_bot`);
|
||||
}
|
||||
|
||||
setAgentBot(inboxId, botId) {
|
||||
return axios.post(`${this.url}/${inboxId}/set_agent_bot`, {
|
||||
agent_bot: botId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
|
|
@ -11,6 +11,8 @@ describe('#InboxesAPI', () => {
|
|||
expect(inboxesAPI).toHaveProperty('update');
|
||||
expect(inboxesAPI).toHaveProperty('delete');
|
||||
expect(inboxesAPI).toHaveProperty('getCampaigns');
|
||||
expect(inboxesAPI).toHaveProperty('getAgentBot');
|
||||
expect(inboxesAPI).toHaveProperty('setAgentBot');
|
||||
});
|
||||
describeWithAPIMock('API calls', context => {
|
||||
it('#getCampaigns', () => {
|
||||
|
|
6
app/javascript/dashboard/api/testimonials.js
Normal file
6
app/javascript/dashboard/api/testimonials.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
/* global axios */
|
||||
import wootConstants from 'dashboard/constants';
|
||||
|
||||
export const getTestimonialContent = () => {
|
||||
return axios.get(wootConstants.TESTIMONIAL_URL);
|
||||
};
|
|
@ -74,8 +74,8 @@ Tahoma,
|
|||
Arial,
|
||||
sans-serif;
|
||||
$body-antialiased: true;
|
||||
$global-margin: $space-one;
|
||||
$global-padding: $space-one;
|
||||
$global-margin: $space-small;
|
||||
$global-padding: $space-micro;
|
||||
$global-weight-normal: normal;
|
||||
$global-weight-bold: bold;
|
||||
$global-radius: 0;
|
||||
|
|
|
@ -20,6 +20,24 @@
|
|||
|
||||
@include foundation-everything($flex: true);
|
||||
|
||||
@include foundation-prototype-text-utilities;
|
||||
@include foundation-prototype-text-transformation;
|
||||
@include foundation-prototype-text-decoration;
|
||||
@include foundation-prototype-font-styling;
|
||||
@include foundation-prototype-list-style-type;
|
||||
@include foundation-prototype-rounded;
|
||||
@include foundation-prototype-bordered;
|
||||
@include foundation-prototype-shadow;
|
||||
@include foundation-prototype-separator;
|
||||
@include foundation-prototype-overflow;
|
||||
@include foundation-prototype-display;
|
||||
@include foundation-prototype-position;
|
||||
@include foundation-prototype-border-box;
|
||||
@include foundation-prototype-border-none;
|
||||
@include foundation-prototype-sizing;
|
||||
@include foundation-prototype-spacing;
|
||||
|
||||
|
||||
@import 'typography';
|
||||
@import 'layout';
|
||||
@import 'animations';
|
||||
|
|
|
@ -113,9 +113,22 @@ $default-button-height: 4.0rem;
|
|||
}
|
||||
|
||||
&.clear {
|
||||
color: var(--w-700);
|
||||
|
||||
&.secondary {
|
||||
color: var(--s-700)
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: var(--g-700)
|
||||
}
|
||||
|
||||
&.alert {
|
||||
color: var(--r-700)
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: var(--y-600);
|
||||
color: var(--y-700)
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
@ -142,10 +155,20 @@ $default-button-height: 4.0rem;
|
|||
// Sizes
|
||||
&.tiny {
|
||||
height: var(--space-medium);
|
||||
|
||||
.icon+.button__content {
|
||||
padding-left: var(--space-micro);
|
||||
}
|
||||
}
|
||||
|
||||
&.small {
|
||||
height: var(--space-large);
|
||||
padding-bottom: var(--space-smaller);
|
||||
padding-top: var(--space-smaller);
|
||||
|
||||
.icon+.button__content {
|
||||
padding-left: var(--space-smaller);
|
||||
}
|
||||
}
|
||||
|
||||
&.large {
|
||||
|
@ -175,6 +198,10 @@ $default-button-height: 4.0rem;
|
|||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -14,15 +14,9 @@
|
|||
}
|
||||
|
||||
.modal--close {
|
||||
border-radius: 50%;
|
||||
color: $color-heading;
|
||||
cursor: pointer;
|
||||
font-size: $font-size-big;
|
||||
line-height: $space-normal;
|
||||
padding: $space-normal;
|
||||
position: absolute;
|
||||
right: $space-micro;
|
||||
top: $space-micro;
|
||||
right: $space-small;
|
||||
top: $space-small;
|
||||
|
||||
&:hover {
|
||||
background: $color-background;
|
||||
|
|
|
@ -59,12 +59,8 @@
|
|||
|
||||
.hamburger--menu {
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
display: block;
|
||||
margin-right: $space-normal;
|
||||
|
||||
@media screen and (max-width: 1200px) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.header--icon {
|
||||
|
|
|
@ -102,6 +102,7 @@
|
|||
@assign-agent="onAssignAgent"
|
||||
@update-conversations="onUpdateConversations"
|
||||
@assign-labels="onAssignLabels"
|
||||
@assign-team="onAssignTeamsForBulk"
|
||||
/>
|
||||
<div
|
||||
ref="activeConversation"
|
||||
|
@ -125,6 +126,7 @@
|
|||
@assign-label="onAssignLabels"
|
||||
@update-conversation-status="toggleConversationStatus"
|
||||
@context-menu-toggle="onContextMenuToggle"
|
||||
@mark-as-unread="markAsUnread"
|
||||
/>
|
||||
|
||||
<div v-if="chatListLoading" class="text-center">
|
||||
|
@ -184,6 +186,11 @@ import {
|
|||
hasPressedAltAndJKey,
|
||||
hasPressedAltAndKKey,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import { conversationListPageURL } from '../helper/URLHelper';
|
||||
import {
|
||||
isOnMentionsView,
|
||||
isOnUnattendedView,
|
||||
} from '../store/modules/conversations/helpers/actionHelpers';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
@ -332,14 +339,15 @@ export default {
|
|||
status: this.activeStatus,
|
||||
page: this.currentPage + 1,
|
||||
labels: this.label ? [this.label] : undefined,
|
||||
teamId: this.teamId ? this.teamId : undefined,
|
||||
conversationType: this.conversationType
|
||||
? this.conversationType
|
||||
: undefined,
|
||||
teamId: this.teamId || undefined,
|
||||
conversationType: this.conversationType || undefined,
|
||||
folders: this.hasActiveFolders ? this.savedFoldersValue : undefined,
|
||||
};
|
||||
},
|
||||
pageTitle() {
|
||||
if (this.hasAppliedFilters) {
|
||||
return this.$t('CHAT_LIST.TAB_HEADING');
|
||||
}
|
||||
if (this.inbox.name) {
|
||||
return this.inbox.name;
|
||||
}
|
||||
|
@ -352,6 +360,9 @@ export default {
|
|||
if (this.conversationType === 'mention') {
|
||||
return this.$t('CHAT_LIST.MENTION_HEADING');
|
||||
}
|
||||
if (this.conversationType === 'unattended') {
|
||||
return this.$t('CHAT_LIST.UNATTENDED_HEADING');
|
||||
}
|
||||
if (this.hasActiveFolders) {
|
||||
return this.activeFolder.name;
|
||||
}
|
||||
|
@ -431,9 +442,6 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
onApplyFilter(payload) {
|
||||
if (this.$route.name !== 'home') {
|
||||
this.$router.push({ name: 'home' });
|
||||
}
|
||||
this.resetBulkActions();
|
||||
this.foldersQuery = filterQueryGenerator(payload);
|
||||
this.$store.dispatch('conversationPage/reset');
|
||||
|
@ -636,6 +644,35 @@ export default {
|
|||
this.showAlert(this.$t('BULK_ACTION.ASSIGN_FAILED'));
|
||||
}
|
||||
},
|
||||
async markAsUnread(conversationId) {
|
||||
try {
|
||||
await this.$store.dispatch('markMessagesUnread', {
|
||||
id: conversationId,
|
||||
});
|
||||
const {
|
||||
params: { accountId, inbox_id: inboxId, label, teamId },
|
||||
name,
|
||||
} = this.$route;
|
||||
let conversationType = '';
|
||||
if (isOnMentionsView({ route: { name } })) {
|
||||
conversationType = 'mention';
|
||||
} else if (isOnUnattendedView({ route: { name } })) {
|
||||
conversationType = 'unattended';
|
||||
}
|
||||
this.$router.push(
|
||||
conversationListPageURL({
|
||||
accountId,
|
||||
conversationType: conversationType,
|
||||
customViewId: this.foldersId,
|
||||
inboxId,
|
||||
label,
|
||||
teamId,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
},
|
||||
async onAssignTeam(team, conversationId = null) {
|
||||
try {
|
||||
await this.$store.dispatch('assignTeam', {
|
||||
|
@ -685,6 +722,21 @@ export default {
|
|||
this.showAlert(this.$t('BULK_ACTION.LABELS.ASSIGN_FAILED'));
|
||||
}
|
||||
},
|
||||
async onAssignTeamsForBulk(team) {
|
||||
try {
|
||||
await this.$store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
ids: this.selectedConversations,
|
||||
fields: {
|
||||
team_id: team.id,
|
||||
},
|
||||
});
|
||||
this.selectedConversations = [];
|
||||
this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_SUCCESFUL'));
|
||||
} catch (err) {
|
||||
this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_FAILED'));
|
||||
}
|
||||
},
|
||||
async onUpdateConversations(status) {
|
||||
try {
|
||||
await this.$store.dispatch('bulkActions/process', {
|
||||
|
|
|
@ -7,9 +7,13 @@
|
|||
@click="onBackDropClick"
|
||||
>
|
||||
<div :class="modalContainerClassName" @click.stop>
|
||||
<button class="modal--close" @click="close">
|
||||
<fluent-icon icon="dismiss" />
|
||||
</button>
|
||||
<woot-button
|
||||
color-scheme="secondary"
|
||||
icon="dismiss"
|
||||
variant="clear"
|
||||
class="modal--close"
|
||||
@click="close"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
<template>
|
||||
<button @click="onMenuItemClick">
|
||||
<fluent-icon class="hamburger--menu" icon="list" />
|
||||
</button>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
icon="list"
|
||||
class="toggle-sidebar"
|
||||
@click="onMenuItemClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -16,13 +21,8 @@ export default {
|
|||
};
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.hamburger--menu {
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
margin-right: var(--space-normal);
|
||||
|
||||
@media screen and (max-width: 1200px) {
|
||||
display: block;
|
||||
}
|
||||
.toggle-sidebar {
|
||||
margin-right: var(--space-small);
|
||||
margin-left: var(--space-minus-small);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -5,9 +5,12 @@ import Button from './ui/WootButton';
|
|||
import Code from './Code';
|
||||
import ColorPicker from './widgets/ColorPicker';
|
||||
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
|
||||
import ConfirmModal from './widgets/modal/ConfirmationModal.vue';
|
||||
import ContextMenu from './ui/ContextMenu.vue';
|
||||
import DeleteModal from './widgets/modal/DeleteModal.vue';
|
||||
import DropdownItem from 'shared/components/ui/dropdown/DropdownItem';
|
||||
import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
|
||||
import FeatureToggle from './widgets/FeatureToggle';
|
||||
import HorizontalBar from './widgets/chart/HorizontalBarChart';
|
||||
import Input from './widgets/forms/Input.vue';
|
||||
import Label from './ui/Label';
|
||||
|
@ -21,8 +24,6 @@ import SubmitButton from './buttons/FormSubmitButton';
|
|||
import Tabs from './ui/Tabs/Tabs';
|
||||
import TabsItem from './ui/Tabs/TabsItem';
|
||||
import Thumbnail from './widgets/Thumbnail.vue';
|
||||
import ConfirmModal from './widgets/modal/ConfirmationModal.vue';
|
||||
import ContextMenu from './ui/ContextMenu.vue';
|
||||
|
||||
const WootUIKit = {
|
||||
AvatarUploader,
|
||||
|
@ -31,9 +32,12 @@ const WootUIKit = {
|
|||
Code,
|
||||
ColorPicker,
|
||||
ConfirmDeleteModal,
|
||||
ConfirmModal,
|
||||
ContextMenu,
|
||||
DeleteModal,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
FeatureToggle,
|
||||
HorizontalBar,
|
||||
Input,
|
||||
Label,
|
||||
|
@ -47,8 +51,6 @@ const WootUIKit = {
|
|||
Tabs,
|
||||
TabsItem,
|
||||
Thumbnail,
|
||||
ConfirmModal,
|
||||
ContextMenu,
|
||||
install(Vue) {
|
||||
const keys = Object.keys(this);
|
||||
keys.pop(); // remove 'install' from keys
|
||||
|
|
|
@ -18,12 +18,35 @@
|
|||
</woot-button>
|
||||
</woot-dropdown-item>
|
||||
<woot-dropdown-divider />
|
||||
<woot-dropdown-item class="auto-offline--toggle">
|
||||
<div class="info-wrap">
|
||||
<fluent-icon
|
||||
v-tooltip.right-start="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_TEXT')"
|
||||
icon="info"
|
||||
size="14"
|
||||
class="info-icon"
|
||||
/>
|
||||
|
||||
<span class="auto-offline--text">
|
||||
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<woot-switch
|
||||
size="small"
|
||||
class="auto-offline--switch"
|
||||
:value="currentUserAutoOffline"
|
||||
@input="updateAutoOffline"
|
||||
/>
|
||||
</woot-dropdown-item>
|
||||
<woot-dropdown-divider />
|
||||
</woot-dropdown-menu>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
|
||||
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader';
|
||||
|
@ -41,7 +64,7 @@ export default {
|
|||
AvailabilityStatusBadge,
|
||||
},
|
||||
|
||||
mixins: [clickaway],
|
||||
mixins: [clickaway, alertMixin],
|
||||
|
||||
data() {
|
||||
return {
|
||||
|
@ -54,6 +77,7 @@ export default {
|
|||
...mapGetters({
|
||||
getCurrentUserAvailability: 'getCurrentUserAvailability',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
currentUserAutoOffline: 'getCurrentUserAutoOffline',
|
||||
}),
|
||||
availabilityDisplayLabel() {
|
||||
const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex(
|
||||
|
@ -85,21 +109,30 @@ export default {
|
|||
closeStatusMenu() {
|
||||
this.isStatusMenuOpened = false;
|
||||
},
|
||||
updateAutoOffline(autoOffline) {
|
||||
this.$store.dispatch('updateAutoOffline', {
|
||||
accountId: this.currentAccountId,
|
||||
autoOffline,
|
||||
});
|
||||
},
|
||||
changeAvailabilityStatus(availability) {
|
||||
const accountId = this.currentAccountId;
|
||||
if (this.isUpdating) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isUpdating = true;
|
||||
this.$store
|
||||
.dispatch('updateAvailability', {
|
||||
availability: availability,
|
||||
account_id: accountId,
|
||||
})
|
||||
.finally(() => {
|
||||
this.isUpdating = false;
|
||||
try {
|
||||
this.$store.dispatch('updateAvailability', {
|
||||
availability,
|
||||
account_id: this.currentAccountId,
|
||||
});
|
||||
} catch (error) {
|
||||
this.showAlert(
|
||||
this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.SET_AVAILABILITY_ERROR')
|
||||
);
|
||||
} finally {
|
||||
this.isUpdating = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -143,4 +176,32 @@ export default {
|
|||
align-items: baseline;
|
||||
}
|
||||
}
|
||||
|
||||
.auto-offline--toggle {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-smaller) 0 var(--space-smaller) var(--space-small);
|
||||
margin: 0;
|
||||
|
||||
.info-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
.auto-offline--switch {
|
||||
margin: -1px var(--space-micro) 0;
|
||||
}
|
||||
|
||||
.auto-offline--text {
|
||||
margin: 0 var(--space-smaller);
|
||||
font-size: var(--font-size-mini);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--s-700);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -73,14 +73,14 @@ export default {
|
|||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentUser: 'getCurrentUser',
|
||||
globalConfig: 'globalConfig/get',
|
||||
isACustomBrandedInstance: 'globalConfig/isACustomBrandedInstance',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
inboxes: 'inboxes/getInboxes',
|
||||
accountId: 'getCurrentAccountId',
|
||||
currentRole: 'getCurrentRole',
|
||||
currentUser: 'getCurrentUser',
|
||||
globalConfig: 'globalConfig/get',
|
||||
inboxes: 'inboxes/getInboxes',
|
||||
isACustomBrandedInstance: 'globalConfig/isACustomBrandedInstance',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
labels: 'labels/getLabelsOnSidebar',
|
||||
teams: 'teams/getMyTeams',
|
||||
}),
|
||||
|
|
|
@ -16,6 +16,8 @@ const conversations = accountId => ({
|
|||
'conversation_through_mentions',
|
||||
'folder_conversations',
|
||||
'conversations_through_folders',
|
||||
'conversation_unattended',
|
||||
'conversation_through_unattended',
|
||||
],
|
||||
menuItems: [
|
||||
{
|
||||
|
@ -33,6 +35,13 @@ const conversations = accountId => ({
|
|||
toState: frontendURL(`accounts/${accountId}/mentions/conversations`),
|
||||
toStateName: 'conversation_mentions',
|
||||
},
|
||||
{
|
||||
icon: 'mail-unread',
|
||||
label: 'UNATTENDED_CONVERSATIONS',
|
||||
key: 'conversation_unattended',
|
||||
toState: frontendURL(`accounts/${accountId}/unattended/conversations`),
|
||||
toStateName: 'conversation_unattended',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
|
||||
const primaryMenuItems = accountId => [
|
||||
|
@ -13,6 +14,7 @@ const primaryMenuItems = accountId => [
|
|||
icon: 'book-contacts',
|
||||
key: 'contacts',
|
||||
label: 'CONTACTS',
|
||||
featureFlag: FEATURE_FLAGS.CRM,
|
||||
toState: frontendURL(`accounts/${accountId}/contacts`),
|
||||
toStateName: 'contacts_dashboard',
|
||||
roles: ['administrator', 'agent'],
|
||||
|
@ -21,6 +23,7 @@ const primaryMenuItems = accountId => [
|
|||
icon: 'arrow-trending-lines',
|
||||
key: 'reports',
|
||||
label: 'REPORTS',
|
||||
featureFlag: FEATURE_FLAGS.REPORTS,
|
||||
toState: frontendURL(`accounts/${accountId}/reports`),
|
||||
toStateName: 'settings_account_reports',
|
||||
roles: ['administrator'],
|
||||
|
@ -29,6 +32,7 @@ const primaryMenuItems = accountId => [
|
|||
icon: 'megaphone',
|
||||
key: 'campaigns',
|
||||
label: 'CAMPAIGNS',
|
||||
featureFlag: FEATURE_FLAGS.CAMPAIGNS,
|
||||
toState: frontendURL(`accounts/${accountId}/campaigns`),
|
||||
toStateName: 'settings_account_campaigns',
|
||||
roles: ['administrator'],
|
||||
|
@ -37,7 +41,7 @@ const primaryMenuItems = accountId => [
|
|||
icon: 'library',
|
||||
key: 'helpcenter',
|
||||
label: 'HELP_CENTER.TITLE',
|
||||
featureFlag: 'help_center',
|
||||
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
||||
toState: frontendURL(`accounts/${accountId}/portals`),
|
||||
toStateName: 'default_portal_articles',
|
||||
roles: ['administrator'],
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
|
||||
const settings = accountId => ({
|
||||
|
@ -38,12 +39,20 @@ const settings = accountId => ({
|
|||
'settings_teams_new',
|
||||
],
|
||||
menuItems: [
|
||||
{
|
||||
icon: 'briefcase',
|
||||
label: 'ACCOUNT_SETTINGS',
|
||||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/settings/general`),
|
||||
toStateName: 'general_settings_index',
|
||||
},
|
||||
{
|
||||
icon: 'people',
|
||||
label: 'AGENTS',
|
||||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/settings/agents/list`),
|
||||
toStateName: 'agent_list',
|
||||
featureFlag: FEATURE_FLAGS.AGENT_MANAGEMENT,
|
||||
},
|
||||
{
|
||||
icon: 'people-team',
|
||||
|
@ -51,6 +60,7 @@ const settings = accountId => ({
|
|||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/settings/teams/list`),
|
||||
toStateName: 'settings_teams_list',
|
||||
featureFlag: FEATURE_FLAGS.TEAM_MANAGEMENT,
|
||||
},
|
||||
{
|
||||
icon: 'mail-inbox-all',
|
||||
|
@ -58,6 +68,7 @@ const settings = accountId => ({
|
|||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/settings/inboxes/list`),
|
||||
toStateName: 'settings_inbox_list',
|
||||
featureFlag: FEATURE_FLAGS.INBOX_MANAGEMENT,
|
||||
},
|
||||
{
|
||||
icon: 'tag',
|
||||
|
@ -65,6 +76,7 @@ const settings = accountId => ({
|
|||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/settings/labels/list`),
|
||||
toStateName: 'labels_list',
|
||||
featureFlag: FEATURE_FLAGS.LABELS,
|
||||
},
|
||||
{
|
||||
icon: 'code',
|
||||
|
@ -74,6 +86,7 @@ const settings = accountId => ({
|
|||
`accounts/${accountId}/settings/custom-attributes/list`
|
||||
),
|
||||
toStateName: 'attributes_list',
|
||||
featureFlag: FEATURE_FLAGS.CUSTOM_ATTRIBUTES,
|
||||
},
|
||||
{
|
||||
icon: 'automation',
|
||||
|
@ -82,15 +95,17 @@ const settings = accountId => ({
|
|||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/settings/automation/list`),
|
||||
toStateName: 'automation_list',
|
||||
featureFlag: FEATURE_FLAGS.AUTOMATIONS,
|
||||
},
|
||||
{
|
||||
icon: 'bot',
|
||||
label: 'AGENT_BOTS',
|
||||
beta: true,
|
||||
hasSubMenu: false,
|
||||
globalConfigFlag: 'csmlEditorHost',
|
||||
toState: frontendURL(`accounts/${accountId}/settings/agent-bots`),
|
||||
toStateName: 'agent_bots',
|
||||
featureFlagKey: 'agent_bots',
|
||||
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
|
||||
},
|
||||
{
|
||||
icon: 'flash-settings',
|
||||
|
@ -99,7 +114,7 @@ const settings = accountId => ({
|
|||
toState: frontendURL(`accounts/${accountId}/settings/macros`),
|
||||
toStateName: 'macros_wrapper',
|
||||
beta: true,
|
||||
featureFlagKey: 'macros',
|
||||
featureFlag: FEATURE_FLAGS.MACROS,
|
||||
},
|
||||
{
|
||||
icon: 'chat-multiple',
|
||||
|
@ -109,6 +124,7 @@ const settings = accountId => ({
|
|||
`accounts/${accountId}/settings/canned-response/list`
|
||||
),
|
||||
toStateName: 'canned_list',
|
||||
featureFlag: FEATURE_FLAGS.CANNED_RESPONSES,
|
||||
},
|
||||
{
|
||||
icon: 'flash-on',
|
||||
|
@ -116,6 +132,7 @@ const settings = accountId => ({
|
|||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/settings/integrations`),
|
||||
toStateName: 'settings_integrations',
|
||||
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
|
||||
},
|
||||
{
|
||||
icon: 'star-emphasis',
|
||||
|
@ -123,6 +140,7 @@ const settings = accountId => ({
|
|||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/settings/applications`),
|
||||
toStateName: 'settings_applications',
|
||||
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
|
||||
},
|
||||
{
|
||||
icon: 'credit-card-person',
|
||||
|
@ -132,13 +150,6 @@ const settings = accountId => ({
|
|||
toStateName: 'billing_settings_index',
|
||||
showOnlyOnCloud: true,
|
||||
},
|
||||
{
|
||||
icon: 'settings',
|
||||
label: 'ACCOUNT_SETTINGS',
|
||||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/settings/general`),
|
||||
toStateName: 'general_settings_index',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
@ -61,6 +61,24 @@
|
|||
</a>
|
||||
</router-link>
|
||||
</woot-dropdown-item>
|
||||
<woot-dropdown-item v-if="currentUser.type === 'SuperAdmin'">
|
||||
<a
|
||||
href="/super_admin"
|
||||
class="button small clear secondary"
|
||||
target="_blank"
|
||||
rel="noopener nofollow noreferrer"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="content-settings"
|
||||
size="14"
|
||||
class="icon icon--font"
|
||||
/>
|
||||
<span class="button__content">
|
||||
{{ $t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE') }}
|
||||
</span>
|
||||
</a>
|
||||
</woot-dropdown-item>
|
||||
<woot-dropdown-item>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
|
@ -135,7 +153,7 @@ export default {
|
|||
.dropdown-pane {
|
||||
left: var(--space-slab);
|
||||
bottom: var(--space-larger);
|
||||
min-width: 16.8rem;
|
||||
z-index: var(--z-index-much-higher);
|
||||
min-width: 22rem;
|
||||
z-index: var(--z-index-low);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -20,6 +20,8 @@
|
|||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
import SecondaryNavItem from './SecondaryNavItem.vue';
|
||||
import AccountContext from './AccountContext.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
@ -61,6 +63,9 @@ export default {
|
|||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
hasSecondaryMenu() {
|
||||
return this.menuConfig.menuItems && this.menuConfig.menuItems.length;
|
||||
},
|
||||
|
@ -89,7 +94,7 @@ export default {
|
|||
icon: 'folder',
|
||||
label: 'INBOXES',
|
||||
hasSubMenu: true,
|
||||
newLink: true,
|
||||
newLink: this.showNewLink(FEATURE_FLAGS.INBOX_MANAGEMENT),
|
||||
newLinkTag: 'NEW_INBOX',
|
||||
key: 'inbox',
|
||||
toState: frontendURL(`accounts/${this.accountId}/settings/inboxes/new`),
|
||||
|
@ -117,7 +122,7 @@ export default {
|
|||
icon: 'number-symbol',
|
||||
label: 'LABELS',
|
||||
hasSubMenu: true,
|
||||
newLink: true,
|
||||
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
|
||||
newLinkTag: 'NEW_LABEL',
|
||||
key: 'label',
|
||||
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
|
||||
|
@ -141,7 +146,7 @@ export default {
|
|||
label: 'TAGGED_WITH',
|
||||
hasSubMenu: true,
|
||||
key: 'label',
|
||||
newLink: true,
|
||||
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
|
||||
newLinkTag: 'NEW_LABEL',
|
||||
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
|
||||
toStateName: 'labels_list',
|
||||
|
@ -163,7 +168,7 @@ export default {
|
|||
icon: 'people-team',
|
||||
label: 'TEAMS',
|
||||
hasSubMenu: true,
|
||||
newLink: true,
|
||||
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
|
||||
newLinkTag: 'NEW_TEAM',
|
||||
key: 'team',
|
||||
toState: frontendURL(`accounts/${this.accountId}/settings/teams/new`),
|
||||
|
@ -238,6 +243,9 @@ export default {
|
|||
toggleAccountModal() {
|
||||
this.$emit('toggle-accounts');
|
||||
},
|
||||
showNewLink(featureFlag) {
|
||||
return this.isFeatureEnabledonAccount(this.accountId, featureFlag);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -253,14 +261,7 @@ export default {
|
|||
width: 20rem;
|
||||
flex-shrink: 0;
|
||||
overflow-y: hidden;
|
||||
|
||||
@include breakpoint(xlarge down) {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@include breakpoint(xlarge up) {
|
||||
position: unset;
|
||||
}
|
||||
position: unset;
|
||||
|
||||
&:hover {
|
||||
overflow-y: hidden;
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
:class="{ 'text-truncate': shouldTruncate }"
|
||||
>
|
||||
{{ label }}
|
||||
<span v-if="isHelpCenterSidebar && childItemCount" class="count-view">
|
||||
<span v-if="showChildCount" class="count-view">
|
||||
{{ childItemCount }}
|
||||
</span>
|
||||
</span>
|
||||
|
@ -76,7 +76,7 @@ export default {
|
|||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isHelpCenterSidebar: {
|
||||
showChildCount: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
@ -112,6 +112,7 @@ $label-badge-size: var(--space-slab);
|
|||
padding: var(--space-smaller) var(--space-smaller);
|
||||
margin: var(--space-smaller) 0;
|
||||
text-align: left;
|
||||
line-height: 1.2;
|
||||
|
||||
&:hover {
|
||||
background: var(--s-25);
|
||||
|
@ -127,11 +128,14 @@ $label-badge-size: var(--space-slab);
|
|||
color: var(--w-500);
|
||||
border-color: var(--w-25);
|
||||
}
|
||||
&.is-active .count-view {
|
||||
background: var(--w-75);
|
||||
color: var(--w-500);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
flex-grow: 1;
|
||||
line-height: var(--space-two);
|
||||
}
|
||||
|
||||
.inbox-icon {
|
||||
|
@ -175,10 +179,6 @@ $label-badge-size: var(--space-slab);
|
|||
font-weight: var(--font-weight-bold);
|
||||
margin-left: var(--space-smaller);
|
||||
padding: var(--space-zero) var(--space-smaller);
|
||||
|
||||
&.is-active {
|
||||
background: var(--w-50);
|
||||
color: var(--w-500);
|
||||
}
|
||||
line-height: var(--font-size-small);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -4,16 +4,15 @@
|
|||
<span class="secondary-menu--header fs-small">
|
||||
{{ $t(`SIDEBAR.${menuItem.label}`) }}
|
||||
</span>
|
||||
<div v-if="isHelpCenterSidebar" class="submenu-icons">
|
||||
<div v-if="menuItem.showNewButton" class="submenu-icons">
|
||||
<woot-button
|
||||
size="tiny"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
icon="add"
|
||||
class="submenu-icon"
|
||||
@click="onClickOpen"
|
||||
>
|
||||
<fluent-icon icon="add" size="16" />
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<router-link
|
||||
|
@ -28,11 +27,7 @@
|
|||
size="14"
|
||||
/>
|
||||
{{ $t(`SIDEBAR.${menuItem.label}`) }}
|
||||
<span
|
||||
v-if="isHelpCenterSidebar"
|
||||
class="count-view"
|
||||
:class="computedClass"
|
||||
>
|
||||
<span v-if="showChildCount(menuItem.count)" class="count-view">
|
||||
{{ `${menuItem.count}` }}
|
||||
</span>
|
||||
<span
|
||||
|
@ -55,7 +50,7 @@
|
|||
:should-truncate="child.truncateLabel"
|
||||
:icon="computedInboxClass(child)"
|
||||
:warning-icon="computedInboxErrorClass(child)"
|
||||
:is-help-center-sidebar="isHelpCenterSidebar"
|
||||
:show-child-count="showChildCount(child.count)"
|
||||
:child-item-count="child.count"
|
||||
/>
|
||||
<router-link
|
||||
|
@ -64,10 +59,10 @@
|
|||
:to="menuItem.toState"
|
||||
custom
|
||||
>
|
||||
<li>
|
||||
<li class="menu-item--new">
|
||||
<a
|
||||
:href="href"
|
||||
class="button small clear menu-item--new secondary"
|
||||
class="button small link clear secondary"
|
||||
:class="{ 'is-active': isActive }"
|
||||
@click="e => newLinkClick(e, navigate)"
|
||||
>
|
||||
|
@ -78,9 +73,6 @@
|
|||
</a>
|
||||
</li>
|
||||
</router-link>
|
||||
<p v-if="isHelpCenterSidebar && isCategoryEmpty" class="empty-text">
|
||||
{{ $t('SIDEBAR.HELP_CENTER.CATEGORY_EMPTY_MESSAGE') }}
|
||||
</p>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
|
@ -95,6 +87,10 @@ import {
|
|||
} from 'dashboard/helper/inbox';
|
||||
|
||||
import SecondaryChildNavItem from './SecondaryChildNavItem';
|
||||
import {
|
||||
isOnMentionsView,
|
||||
isOnUnattendedView,
|
||||
} from '../../../store/modules/conversations/helpers/actionHelpers';
|
||||
|
||||
export default {
|
||||
components: { SecondaryChildNavItem },
|
||||
|
@ -104,46 +100,54 @@ export default {
|
|||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isHelpCenterSidebar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isCategoryEmpty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
activeInbox: 'getSelectedInbox',
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
globalConfig: 'globalConfig/get',
|
||||
}),
|
||||
hasSubMenu() {
|
||||
return !!this.menuItem.children;
|
||||
},
|
||||
isMenuItemVisible() {
|
||||
if (!this.menuItem.featureFlagKey) {
|
||||
return true;
|
||||
if (this.menuItem.globalConfigFlag) {
|
||||
return !!this.globalConfig[this.menuItem.globalConfigFlag];
|
||||
}
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
this.menuItem.featureFlagKey
|
||||
);
|
||||
if (this.menuItem.featureFlag) {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
this.menuItem.featureFlag
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
isInboxConversation() {
|
||||
isAllConversations() {
|
||||
return (
|
||||
this.$store.state.route.name === 'inbox_conversation' &&
|
||||
this.menuItem.toStateName === 'home'
|
||||
);
|
||||
},
|
||||
isMentions() {
|
||||
return (
|
||||
isOnMentionsView({ route: this.$route }) &&
|
||||
this.menuItem.toStateName === 'conversation_mentions'
|
||||
);
|
||||
},
|
||||
isUnattended() {
|
||||
return (
|
||||
isOnUnattendedView({ route: this.$route }) &&
|
||||
this.menuItem.toStateName === 'conversation_unattended'
|
||||
);
|
||||
},
|
||||
isTeamsSettings() {
|
||||
return (
|
||||
this.$store.state.route.name === 'settings_teams_edit' &&
|
||||
this.menuItem.toStateName === 'settings_teams_list'
|
||||
);
|
||||
},
|
||||
isInboxsSettings() {
|
||||
isInboxSettings() {
|
||||
return (
|
||||
this.$store.state.route.name === 'settings_inbox_show' &&
|
||||
this.menuItem.toStateName === 'settings_inbox_list'
|
||||
|
@ -161,19 +165,25 @@ export default {
|
|||
this.menuItem.toStateName === 'settings_applications'
|
||||
);
|
||||
},
|
||||
isArticlesView() {
|
||||
return this.$store.state.route.name === this.menuItem.toStateName;
|
||||
isCurrentRoute() {
|
||||
return this.$store.state.route.name.includes(this.menuItem.toStateName);
|
||||
},
|
||||
|
||||
computedClass() {
|
||||
// If active Inbox is present
|
||||
// donot highlight conversations
|
||||
// If active inbox is present, do not highlight conversations
|
||||
if (this.activeInbox) return ' ';
|
||||
if (
|
||||
this.isAllConversations ||
|
||||
this.isMentions ||
|
||||
this.isUnattended ||
|
||||
this.isCurrentRoute
|
||||
) {
|
||||
return 'is-active';
|
||||
}
|
||||
if (this.hasSubMenu) {
|
||||
if (
|
||||
this.isInboxConversation ||
|
||||
this.isTeamsSettings ||
|
||||
this.isInboxsSettings ||
|
||||
this.isInboxSettings ||
|
||||
this.isIntegrationsSettings ||
|
||||
this.isApplicationsSettings
|
||||
) {
|
||||
|
@ -181,12 +191,7 @@ export default {
|
|||
}
|
||||
return ' ';
|
||||
}
|
||||
if (this.isHelpCenterSidebar) {
|
||||
if (this.isArticlesView) {
|
||||
return 'is-active';
|
||||
}
|
||||
return ' ';
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
},
|
||||
|
@ -217,11 +222,14 @@ export default {
|
|||
}
|
||||
},
|
||||
showItem(item) {
|
||||
return this.isAdmin && item.newLink !== undefined;
|
||||
return this.isAdmin && !!item.newLink;
|
||||
},
|
||||
onClickOpen() {
|
||||
this.$emit('open');
|
||||
},
|
||||
showChildCount(count) {
|
||||
return Number.isInteger(count);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -277,6 +285,11 @@ export default {
|
|||
color: var(--w-500);
|
||||
border-color: var(--w-25);
|
||||
}
|
||||
|
||||
&.is-active .count-view {
|
||||
background: var(--w-75);
|
||||
color: var(--w-600);
|
||||
}
|
||||
}
|
||||
|
||||
.secondary-menu--icon {
|
||||
|
@ -306,22 +319,19 @@ export default {
|
|||
top: -1px;
|
||||
}
|
||||
|
||||
.sidebar-item .button.menu-item--new {
|
||||
display: inline-flex;
|
||||
height: var(--space-medium);
|
||||
margin: var(--space-smaller) 0;
|
||||
padding: var(--space-smaller);
|
||||
color: var(--s-500);
|
||||
.sidebar-item .menu-item--new {
|
||||
padding: var(--space-small) 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--w-500);
|
||||
.button {
|
||||
display: inline-flex;
|
||||
color: var(--s-500);
|
||||
}
|
||||
}
|
||||
|
||||
.beta {
|
||||
padding-right: var(--space-smaller) !important;
|
||||
padding-left: var(--space-smaller) !important;
|
||||
margin-left: var(--space-half) !important;
|
||||
margin-left: var(--space-smaller) !important;
|
||||
display: inline-block;
|
||||
font-size: var(--font-size-micro);
|
||||
font-weight: var(--font-weight-medium);
|
||||
|
@ -340,11 +350,6 @@ export default {
|
|||
font-weight: var(--font-weight-bold);
|
||||
margin-left: var(--space-smaller);
|
||||
padding: var(--space-zero) var(--space-smaller);
|
||||
|
||||
&.is-active {
|
||||
background: var(--w-50);
|
||||
color: var(--w-500);
|
||||
}
|
||||
}
|
||||
|
||||
.submenu-icons {
|
||||
|
@ -356,10 +361,4 @@ export default {
|
|||
margin-left: var(--space-small);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: var(--s-500);
|
||||
font-size: var(--font-size-small);
|
||||
margin: var(--space-smaller);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SidemenuIcon matches snapshot 1`] = `
|
||||
<button>
|
||||
<fluent-icon
|
||||
class="hamburger--menu"
|
||||
icon="list"
|
||||
/>
|
||||
</button>
|
||||
<woot-button
|
||||
class="toggle-sidebar"
|
||||
color-scheme="secondary"
|
||||
icon="list"
|
||||
size="small"
|
||||
variant="clear"
|
||||
/>
|
||||
`;
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<fluent-icon :icon="icon" size="12" class="label--icon" />
|
||||
</span>
|
||||
<span
|
||||
v-if="variant === 'smooth'"
|
||||
v-if="variant === 'smooth' && title && !icon"
|
||||
:style="{ background: color }"
|
||||
class="label-color-dot"
|
||||
/>
|
||||
|
@ -117,14 +117,16 @@ export default {
|
|||
height: var(--space-medium);
|
||||
|
||||
&.small {
|
||||
font-size: var(--font-size-micro);
|
||||
font-size: var(--font-size-mini);
|
||||
padding: var(--space-micro) var(--space-smaller);
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.15px;
|
||||
height: var(--space-two);
|
||||
}
|
||||
|
||||
.label--icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
.label-color-dot {
|
||||
margin-right: var(--space-smaller);
|
||||
}
|
||||
|
||||
|
@ -199,8 +201,8 @@ export default {
|
|||
|
||||
&.smooth {
|
||||
background: transparent;
|
||||
border: 1px solid var(--s-75);
|
||||
color: var(--s-800);
|
||||
border: 1px solid var(--s-100);
|
||||
color: var(--s-700);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -221,14 +223,22 @@ export default {
|
|||
}
|
||||
|
||||
.label-action--button {
|
||||
margin-bottom: var(--space-minus-micro);
|
||||
display: flex;
|
||||
margin-right: var(--space-smaller);
|
||||
}
|
||||
|
||||
.label-color-dot {
|
||||
display: inline-block;
|
||||
width: var(--space-one);
|
||||
height: var(--space-one);
|
||||
width: var(--space-slab);
|
||||
height: var(--space-slab);
|
||||
border-radius: var(--border-radius-small);
|
||||
margin-right: var(--space-smaller);
|
||||
box-shadow: var(--shadow-small);
|
||||
}
|
||||
.label.small .label-color-dot {
|
||||
width: var(--space-small);
|
||||
height: var(--space-small);
|
||||
border-radius: var(--border-radius-small);
|
||||
box-shadow: var(--shadow-small);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<button
|
||||
type="button"
|
||||
class="toggle-button"
|
||||
:class="{ active: value }"
|
||||
:class="{ active: value, small: size === 'small' }"
|
||||
role="switch"
|
||||
:aria-checked="value.toString()"
|
||||
@click="onClick"
|
||||
|
@ -15,6 +15,7 @@
|
|||
export default {
|
||||
props: {
|
||||
value: { type: Boolean, default: false },
|
||||
size: { type: String, default: '' },
|
||||
},
|
||||
methods: {
|
||||
onClick() {
|
||||
|
@ -45,6 +46,20 @@ export default {
|
|||
background-color: var(--w-500);
|
||||
}
|
||||
|
||||
&.small {
|
||||
width: 22px;
|
||||
height: 14px;
|
||||
|
||||
span {
|
||||
height: var(--space-one);
|
||||
width: var(--space-one);
|
||||
|
||||
&.active {
|
||||
transform: translate(var(--space-small), var(--space-zero));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
--space-one-point-five: 1.5rem;
|
||||
background-color: var(--white);
|
||||
|
|
|
@ -1,17 +1,15 @@
|
|||
<template>
|
||||
<span class="time-ago">
|
||||
<span> {{ timeAgo }}</span>
|
||||
<span>{{ timeAgo }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const ZERO = 0;
|
||||
const MINUTE_IN_MILLI_SECONDS = 60000;
|
||||
const HOUR_IN_MILLI_SECONDS = MINUTE_IN_MILLI_SECONDS * 60;
|
||||
const DAY_IN_MILLI_SECONDS = HOUR_IN_MILLI_SECONDS * 24;
|
||||
|
||||
import timeMixin from 'dashboard/mixins/time';
|
||||
import { differenceInMilliseconds } from 'date-fns';
|
||||
|
||||
export default {
|
||||
name: 'TimeAgo',
|
||||
|
@ -28,51 +26,40 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
timeAgo: '',
|
||||
timeAgo: this.dynamicTime(this.timestamp),
|
||||
timer: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
watch: {
|
||||
timestamp() {
|
||||
this.timeAgo = this.dynamicTime(this.timestamp);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.isAutoRefreshEnabled) {
|
||||
this.createTimer();
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
clearTimeout(this.timer);
|
||||
},
|
||||
methods: {
|
||||
createTimer() {
|
||||
this.timer = setTimeout(() => {
|
||||
this.timeAgo = this.dynamicTime(this.timestamp);
|
||||
this.createTimer();
|
||||
}, this.refreshTime());
|
||||
},
|
||||
refreshTime() {
|
||||
const timeDiff = differenceInMilliseconds(
|
||||
new Date(),
|
||||
new Date(this.timestamp * 1000)
|
||||
);
|
||||
const timeDiff = Date.now() - this.timestamp * 1000;
|
||||
if (timeDiff > DAY_IN_MILLI_SECONDS) {
|
||||
return DAY_IN_MILLI_SECONDS;
|
||||
}
|
||||
if (timeDiff > HOUR_IN_MILLI_SECONDS) {
|
||||
return HOUR_IN_MILLI_SECONDS;
|
||||
}
|
||||
if (timeDiff > MINUTE_IN_MILLI_SECONDS) {
|
||||
return MINUTE_IN_MILLI_SECONDS;
|
||||
}
|
||||
return ZERO;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.timeAgo = this.dynamicTime(this.timestamp);
|
||||
if (this.isAutoRefreshEnabled) {
|
||||
this.createTimer();
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.clearTimer();
|
||||
},
|
||||
methods: {
|
||||
createTimer() {
|
||||
const refreshTime = this.refreshTime;
|
||||
if (refreshTime > ZERO) {
|
||||
this.timer = setTimeout(() => {
|
||||
this.timeAgo = this.dynamicTime(this.timestamp);
|
||||
this.createTimer();
|
||||
}, refreshTime);
|
||||
}
|
||||
},
|
||||
clearTimer() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
|
||||
return MINUTE_IN_MILLI_SECONDS;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
<template>
|
||||
<div
|
||||
class="filter"
|
||||
:class="{ error: v.action_params.$dirty && v.action_params.$error }"
|
||||
>
|
||||
<div class="filter" :class="actionInputStyles">
|
||||
<div class="filter-inputs">
|
||||
<select
|
||||
v-model="action_name"
|
||||
|
@ -21,14 +18,32 @@
|
|||
<div v-if="showActionInput" class="filter__answer--wrap">
|
||||
<div v-if="inputType">
|
||||
<div
|
||||
v-if="inputType === 'multi_select'"
|
||||
v-if="inputType === 'search_select'"
|
||||
class="multiselect-wrap--small"
|
||||
>
|
||||
<multiselect
|
||||
v-model="action_params"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="'Select'"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:max-height="160"
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
:option-height="104"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="inputType === 'multi_select'"
|
||||
class="multiselect-wrap--small"
|
||||
>
|
||||
<multiselect
|
||||
v-model="action_params"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
|
||||
:multiple="true"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
|
@ -36,6 +51,7 @@
|
|||
:max-height="160"
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
:option-height="104"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
|
@ -60,6 +76,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<woot-button
|
||||
v-if="!isMacro"
|
||||
icon="dismiss"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
|
@ -120,6 +137,10 @@ export default {
|
|||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isMacro: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
action_name: {
|
||||
|
@ -146,6 +167,12 @@ export default {
|
|||
return this.actionTypes.find(action => action.key === this.action_name)
|
||||
.inputType;
|
||||
},
|
||||
actionInputStyles() {
|
||||
return {
|
||||
'has-error': this.v.action_params.$dirty && this.v.action_params.$error,
|
||||
'is-a-macro': this.isMacro,
|
||||
};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
removeAction() {
|
||||
|
@ -165,9 +192,21 @@ export default {
|
|||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-medium);
|
||||
margin-bottom: var(--space-small);
|
||||
|
||||
&.is-a-macro {
|
||||
margin-bottom: 0;
|
||||
background: var(--white);
|
||||
padding: var(--space-zero);
|
||||
border: unset;
|
||||
border-radius: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.filter.error {
|
||||
.no-margin-bottom {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter.has-error {
|
||||
background: var(--r-50);
|
||||
}
|
||||
|
||||
|
@ -240,6 +279,6 @@ export default {
|
|||
margin-bottom: var(--space-zero);
|
||||
}
|
||||
.action-message {
|
||||
margin: var(--space-small) 0 0;
|
||||
margin: var(--space-small) var(--space-zero) var(--space-zero);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -113,5 +113,6 @@ input[type='file'] {
|
|||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
<template>
|
||||
<div
|
||||
class="avatar-container"
|
||||
:style="[style, customStyle]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span>{{ userInitial }}</span>
|
||||
<div class="avatar-container" :style="style" aria-hidden="true">
|
||||
{{ userInitial }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -16,69 +12,26 @@ export default {
|
|||
type: String,
|
||||
default: '',
|
||||
},
|
||||
backgroundColor: {
|
||||
type: String,
|
||||
default: '#c2e1ff',
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '#1976cc',
|
||||
},
|
||||
customStyle: {
|
||||
type: Object,
|
||||
default: undefined,
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 40,
|
||||
},
|
||||
src: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
rounded: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'circle',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
style() {
|
||||
let style = {
|
||||
width: `${this.size}px`,
|
||||
height: `${this.size}px`,
|
||||
borderRadius:
|
||||
this.variant === 'square' ? 'var(--border-radius-large)' : '50%',
|
||||
lineHeight: `${this.size + Math.floor(this.size / 20)}px`,
|
||||
return {
|
||||
fontSize: `${Math.floor(this.size / 2.5)}px`,
|
||||
};
|
||||
|
||||
if (this.backgroundColor) {
|
||||
style = { ...style, backgroundColor: this.backgroundColor };
|
||||
}
|
||||
if (this.color) {
|
||||
style = { ...style, color: this.color };
|
||||
}
|
||||
return style;
|
||||
},
|
||||
userInitial() {
|
||||
return this.initials || this.initial(this.username);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
initial(username) {
|
||||
const parts = username ? username.split(/[ -]/) : [];
|
||||
let initials = '';
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
initials += parts[i].charAt(0);
|
||||
}
|
||||
const parts = this.username.split(/[ -]/);
|
||||
let initials = parts.reduce((acc, curr) => acc + curr.charAt(0), '');
|
||||
|
||||
if (initials.length > 2 && initials.search(/[A-Z]/) !== -1) {
|
||||
initials = initials.replace(/[a-z]+/g, '');
|
||||
}
|
||||
initials = initials.substring(0, 2).toUpperCase();
|
||||
|
||||
return initials;
|
||||
},
|
||||
},
|
||||
|
@ -88,11 +41,13 @@ export default {
|
|||
<style lang="scss" scoped>
|
||||
.avatar-container {
|
||||
display: flex;
|
||||
line-height: 100%;
|
||||
font-weight: 500;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
background-image: linear-gradient(to top, var(--w-100) 0%, var(--w-75) 100%);
|
||||
color: var(--w-600);
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -67,6 +67,9 @@ export default {
|
|||
if (Object.keys(this.enabledFeatures).length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (key === 'website') {
|
||||
return this.enabledFeatures.channel_website;
|
||||
}
|
||||
if (key === 'facebook') {
|
||||
return this.enabledFeatures.channel_facebook;
|
||||
}
|
||||
|
|
|
@ -61,6 +61,7 @@ export default {
|
|||
}
|
||||
|
||||
.colorpicker--selected {
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: $space-smaller;
|
||||
cursor: pointer;
|
||||
height: $space-large;
|
||||
|
|
|
@ -5,6 +5,11 @@
|
|||
:key="index"
|
||||
class="dashboard-app--list"
|
||||
>
|
||||
<loading-state
|
||||
v-if="iframeLoading"
|
||||
:message="$t('DASHBOARD_APPS.LOADING_MESSAGE')"
|
||||
class="dashboard-app_loading-container"
|
||||
/>
|
||||
<iframe
|
||||
v-if="configItem.type === 'frame' && configItem.url"
|
||||
:id="`dashboard-app--frame-${index}`"
|
||||
|
@ -16,7 +21,11 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState';
|
||||
export default {
|
||||
components: {
|
||||
LoadingState,
|
||||
},
|
||||
props: {
|
||||
config: {
|
||||
type: Array,
|
||||
|
@ -27,16 +36,26 @@ export default {
|
|||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
iframeLoading: true,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
dashboardAppContext() {
|
||||
return {
|
||||
conversation: this.currentChat,
|
||||
contact: this.$store.getters['contacts/getContact'](this.contactId),
|
||||
currentAgent: this.currentAgent,
|
||||
};
|
||||
},
|
||||
contactId() {
|
||||
return this.currentChat?.meta?.sender?.id;
|
||||
},
|
||||
currentAgent() {
|
||||
const { id, name, email } = this.$store.getters.getCurrentUser;
|
||||
return { id, name, email };
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
|
@ -57,6 +76,7 @@ export default {
|
|||
);
|
||||
const eventData = { event: 'appContext', data: this.dashboardAppContext };
|
||||
frameElement.contentWindow.postMessage(JSON.stringify(eventData), '*');
|
||||
this.iframeLoading = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -73,4 +93,11 @@ export default {
|
|||
.dashboard-app--list iframe {
|
||||
border: 0;
|
||||
}
|
||||
.dashboard-app_loading-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
<template>
|
||||
<div v-if="isFeatureEnabled">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
export default {
|
||||
props: {
|
||||
featureKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
accountId: 'getCurrentAccountId',
|
||||
}),
|
||||
isFeatureEnabled() {
|
||||
return this.isFeatureEnabledonAccount(this.accountId, this.featureKey);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -32,6 +32,7 @@
|
|||
v-for="attribute in filterAttributes"
|
||||
:key="attribute.key"
|
||||
:value="attribute.key"
|
||||
:disabled="attribute.disabled"
|
||||
>
|
||||
{{ attribute.name }}
|
||||
</option>
|
||||
|
@ -173,6 +174,10 @@ export default {
|
|||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
customAttributeType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
attributeKey: {
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
<template>
|
||||
<span>
|
||||
{{ textToBeDisplayed }}
|
||||
<button class="show-more--button" @click="toggleShowMore">
|
||||
<button
|
||||
v-if="text.length > limit"
|
||||
class="show-more--button"
|
||||
@click="toggleShowMore"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
</span>
|
||||
|
@ -25,7 +29,7 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
textToBeDisplayed() {
|
||||
if (this.showMore) {
|
||||
if (this.showMore || this.text.length <= this.limit) {
|
||||
return this.text;
|
||||
}
|
||||
|
||||
|
|
|
@ -83,75 +83,71 @@ export default {
|
|||
},
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 15,
|
||||
default: 25,
|
||||
},
|
||||
totalCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
onPageChange: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isFooterVisible() {
|
||||
return this.totalCount && !(this.firstIndex > this.totalCount);
|
||||
},
|
||||
firstIndex() {
|
||||
const firstIndex = this.pageSize * (this.currentPage - 1) + 1;
|
||||
return firstIndex;
|
||||
return this.pageSize * (this.currentPage - 1) + 1;
|
||||
},
|
||||
lastIndex() {
|
||||
const index = Math.min(this.totalCount, this.pageSize * this.currentPage);
|
||||
return index;
|
||||
return Math.min(this.totalCount, this.pageSize * this.currentPage);
|
||||
},
|
||||
searchButtonClass() {
|
||||
return this.searchQuery !== '' ? 'show' : '';
|
||||
},
|
||||
hasLastPage() {
|
||||
const isDisabled =
|
||||
this.currentPage === Math.ceil(this.totalCount / this.pageSize);
|
||||
return isDisabled;
|
||||
return !!Math.ceil(this.totalCount / this.pageSize);
|
||||
},
|
||||
hasFirstPage() {
|
||||
const isDisabled = this.currentPage === 1;
|
||||
return isDisabled;
|
||||
return this.currentPage === 1;
|
||||
},
|
||||
hasNextPage() {
|
||||
const isDisabled =
|
||||
this.currentPage === Math.ceil(this.totalCount / this.pageSize);
|
||||
return isDisabled;
|
||||
return this.currentPage === Math.ceil(this.totalCount / this.pageSize);
|
||||
},
|
||||
hasPrevPage() {
|
||||
const isDisabled = this.currentPage === 1;
|
||||
return isDisabled;
|
||||
return this.currentPage === 1;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onNextPage() {
|
||||
if (this.hasNextPage) return;
|
||||
if (this.hasNextPage) {
|
||||
return;
|
||||
}
|
||||
const newPage = this.currentPage + 1;
|
||||
this.onPageChange(newPage);
|
||||
},
|
||||
onPrevPage() {
|
||||
if (this.hasPrevPage) return;
|
||||
|
||||
if (this.hasPrevPage) {
|
||||
return;
|
||||
}
|
||||
const newPage = this.currentPage - 1;
|
||||
this.onPageChange(newPage);
|
||||
},
|
||||
onFirstPage() {
|
||||
if (this.hasFirstPage) return;
|
||||
|
||||
if (this.hasFirstPage) {
|
||||
return;
|
||||
}
|
||||
const newPage = 1;
|
||||
this.onPageChange(newPage);
|
||||
},
|
||||
onLastPage() {
|
||||
if (this.hasLastPage) return;
|
||||
|
||||
if (this.hasLastPage) {
|
||||
return;
|
||||
}
|
||||
const newPage = Math.ceil(this.totalCount / this.pageSize);
|
||||
this.onPageChange(newPage);
|
||||
},
|
||||
onPageChange(page) {
|
||||
this.$emit('page-change', page);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -2,49 +2,47 @@ import { mount } from '@vue/test-utils';
|
|||
import Avatar from './Avatar.vue';
|
||||
import Thumbnail from './Thumbnail.vue';
|
||||
|
||||
describe(`when there are NO errors loading the thumbnail`, () => {
|
||||
it(`should render the agent thumbnail`, () => {
|
||||
describe('Thumbnail.vue', () => {
|
||||
it('should render the agent thumbnail if valid image is passed', () => {
|
||||
const wrapper = mount(Thumbnail, {
|
||||
propsData: {
|
||||
src: 'https://some_valid_url.com',
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hasImageLoaded: true,
|
||||
imgError: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('#image').exists()).toBe(true);
|
||||
expect(wrapper.find('.user-thumbnail').exists()).toBe(true);
|
||||
const avatarComponent = wrapper.findComponent(Avatar);
|
||||
expect(avatarComponent.exists()).toBe(false);
|
||||
expect(avatarComponent.isVisible()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`when there ARE errors loading the thumbnail`, () => {
|
||||
it(`should render the agent avatar`, () => {
|
||||
it('should render the avatar component if invalid image is passed', () => {
|
||||
const wrapper = mount(Thumbnail, {
|
||||
propsData: {
|
||||
src: 'https://some_invalid_url.com',
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hasImageLoaded: true,
|
||||
imgError: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('#image').exists()).toBe(false);
|
||||
const avatarComponent = wrapper.findComponent(Avatar);
|
||||
expect(avatarComponent.exists()).toBe(true);
|
||||
expect(avatarComponent.isVisible()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`when Avatar shows`, () => {
|
||||
it(`initials shold correspond to username`, () => {
|
||||
it('should the initial of the name if no image is passed', () => {
|
||||
const wrapper = mount(Avatar, {
|
||||
propsData: {
|
||||
username: 'Angie Rojas',
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('span').text()).toBe('AR');
|
||||
expect(wrapper.find('div').text()).toBe('AR');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,74 +1,29 @@
|
|||
<template>
|
||||
<div class="user-thumbnail-box" :style="{ height: size, width: size }">
|
||||
<div
|
||||
:class="thumbnailBoxClass"
|
||||
:style="{ height: size, width: size }"
|
||||
:title="title"
|
||||
>
|
||||
<!-- Using v-show instead of v-if to avoid flickering as v-if removes dom elements. -->
|
||||
<img
|
||||
v-if="!imgError && Boolean(src)"
|
||||
id="image"
|
||||
v-show="shouldShowImage"
|
||||
:src="src"
|
||||
:class="thumbnailClass"
|
||||
@error="onImgError()"
|
||||
@load="onImgLoad"
|
||||
@error="onImgError"
|
||||
/>
|
||||
<Avatar
|
||||
v-else
|
||||
v-show="!shouldShowImage"
|
||||
:username="userNameWithoutEmoji"
|
||||
:class="thumbnailClass"
|
||||
:size="avatarSize"
|
||||
:variant="variant"
|
||||
/>
|
||||
<img
|
||||
v-if="badge === 'instagram_direct_message'"
|
||||
id="badge"
|
||||
v-if="badgeSrc"
|
||||
class="source-badge"
|
||||
:style="badgeStyle"
|
||||
src="/integrations/channels/badges/instagram-dm.png"
|
||||
/>
|
||||
<img
|
||||
v-else-if="badge === 'facebook'"
|
||||
id="badge"
|
||||
class="source-badge"
|
||||
:style="badgeStyle"
|
||||
src="/integrations/channels/badges/messenger.png"
|
||||
/>
|
||||
<img
|
||||
v-else-if="badge === 'twitter-tweet'"
|
||||
id="badge"
|
||||
class="source-badge"
|
||||
:style="badgeStyle"
|
||||
src="/integrations/channels/badges/twitter-tweet.png"
|
||||
/>
|
||||
<img
|
||||
v-else-if="badge === 'twitter-dm'"
|
||||
id="badge"
|
||||
class="source-badge"
|
||||
:style="badgeStyle"
|
||||
src="/integrations/channels/badges/twitter-dm.png"
|
||||
/>
|
||||
<img
|
||||
v-else-if="badge === 'whatsapp'"
|
||||
id="badge"
|
||||
class="source-badge"
|
||||
:style="badgeStyle"
|
||||
src="/integrations/channels/badges/whatsapp.png"
|
||||
/>
|
||||
<img
|
||||
v-else-if="badge === 'sms'"
|
||||
id="badge"
|
||||
class="source-badge"
|
||||
:style="badgeStyle"
|
||||
src="/integrations/channels/badges/sms.png"
|
||||
/>
|
||||
<img
|
||||
v-else-if="badge === 'Channel::Line'"
|
||||
id="badge"
|
||||
class="source-badge"
|
||||
:style="badgeStyle"
|
||||
src="/integrations/channels/badges/line.png"
|
||||
/>
|
||||
<img
|
||||
v-else-if="badge === 'Channel::Telegram'"
|
||||
id="badge"
|
||||
class="source-badge"
|
||||
:style="badgeStyle"
|
||||
src="/integrations/channels/badges/telegram.png"
|
||||
:src="`/integrations/channels/badges/${badgeSrc}.png`"
|
||||
alt="Badge"
|
||||
/>
|
||||
<div
|
||||
v-if="showStatusIndicator"
|
||||
|
@ -83,7 +38,7 @@
|
|||
* Src - source for round image
|
||||
* Size - Size of the thumbnail
|
||||
* Badge - Chat source indication { fb / telegram }
|
||||
* Username - User name for avatar
|
||||
* Username - Username for avatar
|
||||
*/
|
||||
import Avatar from './Avatar';
|
||||
import { removeEmoji } from 'shared/helpers/emoji';
|
||||
|
@ -103,7 +58,7 @@ export default {
|
|||
},
|
||||
badge: {
|
||||
type: String,
|
||||
default: 'fb',
|
||||
default: '',
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
|
@ -121,6 +76,10 @@ export default {
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'circle',
|
||||
|
@ -128,6 +87,7 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
hasImageLoaded: false,
|
||||
imgError: false,
|
||||
};
|
||||
},
|
||||
|
@ -142,6 +102,19 @@ export default {
|
|||
avatarSize() {
|
||||
return Number(this.size.replace(/\D+/g, ''));
|
||||
},
|
||||
badgeSrc() {
|
||||
return {
|
||||
instagram_direct_message: 'instagram-dm',
|
||||
facebook: 'messenger',
|
||||
'twitter-tweet': 'twitter-tweet',
|
||||
'twitter-dm': 'twitter-dm',
|
||||
whatsapp: 'whatsapp',
|
||||
sms: 'sms',
|
||||
'Channel::Line': 'line',
|
||||
'Channel::Telegram': 'telegram',
|
||||
'Channel::WebWidget': '',
|
||||
}[this.badge];
|
||||
},
|
||||
badgeStyle() {
|
||||
const size = Math.floor(this.avatarSize / 3);
|
||||
const badgeSize = `${size + 2}px`;
|
||||
|
@ -158,20 +131,34 @@ export default {
|
|||
this.variant === 'circle' ? 'thumbnail-rounded' : 'thumbnail-square';
|
||||
return `user-thumbnail ${classname} ${variant}`;
|
||||
},
|
||||
thumbnailBoxClass() {
|
||||
const boxClass = this.variant === 'circle' ? 'is-rounded' : '';
|
||||
return `user-thumbnail-box ${boxClass}`;
|
||||
},
|
||||
shouldShowImage() {
|
||||
if (!this.src) {
|
||||
return false;
|
||||
}
|
||||
if (this.hasImageLoaded) {
|
||||
return !this.imgError;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
src: {
|
||||
handler(value, oldValue) {
|
||||
if (value !== oldValue && this.imgError) {
|
||||
this.imgError = false;
|
||||
}
|
||||
},
|
||||
src(value, oldValue) {
|
||||
if (value !== oldValue && this.imgError) {
|
||||
this.imgError = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onImgError() {
|
||||
this.imgError = true;
|
||||
},
|
||||
onImgLoad() {
|
||||
this.hasImageLoaded = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -182,6 +169,10 @@ export default {
|
|||
max-width: 100%;
|
||||
position: relative;
|
||||
|
||||
&.is-rounded {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.user-thumbnail {
|
||||
border-radius: 50%;
|
||||
&.thumbnail-square {
|
||||
|
@ -191,6 +182,7 @@ export default {
|
|||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
object-fit: cover;
|
||||
vertical-align: initial;
|
||||
|
||||
&.border {
|
||||
border: 1px solid white;
|
||||
|
@ -229,9 +221,5 @@ export default {
|
|||
.user-online-status--offline {
|
||||
background: var(--s-500);
|
||||
}
|
||||
|
||||
.user-online-status--offline {
|
||||
background: var(--s-500);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
<template>
|
||||
<div class="overlapping-thumbnails">
|
||||
<thumbnail
|
||||
v-for="user in usersList"
|
||||
:key="user.id"
|
||||
v-tooltip="user.name"
|
||||
:title="user.name"
|
||||
:src="user.thumbnail"
|
||||
:username="user.name"
|
||||
:has-border="true"
|
||||
:size="size"
|
||||
:class="`overlapping-thumbnail gap-${gap}`"
|
||||
/>
|
||||
<span v-if="showMoreThumbnailsCount" class="thumbnail-more-text">
|
||||
{{ moreThumbnailsText }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import Thumbnail from './Thumbnail';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
},
|
||||
props: {
|
||||
usersList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: '24px',
|
||||
},
|
||||
showMoreThumbnailsCount: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
moreThumbnailsText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
gap: {
|
||||
type: String,
|
||||
default: 'normal',
|
||||
validator(value) {
|
||||
// The value must match one of these strings
|
||||
return ['normal', '', 'tight'].includes(value);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.overlapping-thumbnails {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.overlapping-thumbnail {
|
||||
position: relative;
|
||||
box-shadow: var(--shadow-small);
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: var(--space-minus-smaller);
|
||||
}
|
||||
|
||||
.gap-tight {
|
||||
margin-left: var(--space-minus-small);
|
||||
}
|
||||
}
|
||||
|
||||
.thumbnail-more-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
margin-left: var(--space-minus-small);
|
||||
padding: 0 var(--space-small);
|
||||
box-shadow: var(--shadow-small);
|
||||
background: var(--color-background);
|
||||
border-radius: var(--space-giga);
|
||||
border: 1px solid var(--white);
|
||||
|
||||
color: var(--s-600);
|
||||
font-size: var(--font-size-mini);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
</style>
|
|
@ -10,11 +10,11 @@ import 'videojs-record/dist/css/videojs.record.css';
|
|||
|
||||
import videojs from 'video.js';
|
||||
|
||||
import inboxMixin from '../../../../shared/mixins/inboxMixin';
|
||||
import alertMixin from '../../../../shared/mixins/alertMixin';
|
||||
|
||||
import Recorder from 'opus-recorder';
|
||||
import encoderWorker from 'opus-recorder/dist/encoderWorker.min';
|
||||
import waveWorker from 'opus-recorder/dist/waveWorker.min';
|
||||
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import MicrophonePlugin from 'wavesurfer.js/dist/plugin/wavesurfer.microphone.js';
|
||||
|
@ -23,19 +23,25 @@ import 'videojs-wavesurfer/dist/videojs.wavesurfer.js';
|
|||
import 'videojs-record/dist/videojs.record.js';
|
||||
import 'videojs-record/dist/plugins/videojs.record.opus-recorder.js';
|
||||
import { format, addSeconds } from 'date-fns';
|
||||
import { AUDIO_FORMATS } from 'shared/constants/messages';
|
||||
|
||||
WaveSurfer.microphone = MicrophonePlugin;
|
||||
|
||||
export default {
|
||||
name: 'WootAudioRecorder',
|
||||
mixins: [inboxMixin, alertMixin],
|
||||
mixins: [alertMixin],
|
||||
props: {
|
||||
audioRecordFormat: {
|
||||
type: String,
|
||||
default: AUDIO_FORMATS.WEBM,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
player: false,
|
||||
recordingDateStarted: new Date(0),
|
||||
initialTimeDuration: '00:00',
|
||||
recorderOptions: {
|
||||
debug: true,
|
||||
controls: true,
|
||||
bigPlayButton: false,
|
||||
fluid: false,
|
||||
|
@ -70,13 +76,28 @@ export default {
|
|||
record: {
|
||||
audio: true,
|
||||
video: false,
|
||||
displayMilliseconds: false,
|
||||
maxLength: 300,
|
||||
audioEngine: 'opus-recorder',
|
||||
audioWorkerURL: encoderWorker,
|
||||
audioChannels: 1,
|
||||
audioSampleRate: 48000,
|
||||
audioBitRate: 128,
|
||||
maxLength: 900,
|
||||
timeSlice: 1000,
|
||||
maxFileSize: 15 * 1024 * 1024,
|
||||
...(this.audioRecordFormat === AUDIO_FORMATS.WEBM && {
|
||||
monitorGain: 0,
|
||||
recordingGain: 1,
|
||||
numberOfChannels: 1,
|
||||
encoderSampleRate: 16000,
|
||||
originalSampleRateOverride: 16000,
|
||||
streamPages: true,
|
||||
maxFramesPerPage: 1,
|
||||
encoderFrameSize: 1,
|
||||
encoderPath: waveWorker,
|
||||
}),
|
||||
...(this.audioRecordFormat === AUDIO_FORMATS.OGG && {
|
||||
displayMilliseconds: false,
|
||||
audioEngine: 'opus-recorder',
|
||||
audioWorkerURL: encoderWorker,
|
||||
audioChannels: 1,
|
||||
audioSampleRate: 48000,
|
||||
audioBitRate: 128,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -39,10 +39,17 @@ const TYPING_INDICATOR_IDLE_TIME = 4000;
|
|||
|
||||
import '@chatwoot/prosemirror-schema/src/woot-editor.css';
|
||||
import {
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
hasPressedCommandAndEnter,
|
||||
hasPressedAltAndPKey,
|
||||
hasPressedAltAndLKey,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
||||
import AnalyticsHelper, {
|
||||
ANALYTICS_EVENTS,
|
||||
} from '../../../helper/AnalyticsHelper';
|
||||
|
||||
const createState = (content, placeholder, plugins = []) => {
|
||||
return EditorState.create({
|
||||
|
@ -58,13 +65,15 @@ const createState = (content, placeholder, plugins = []) => {
|
|||
export default {
|
||||
name: 'WootMessageEditor',
|
||||
components: { TagAgents, CannedResponse },
|
||||
mixins: [eventListenerMixins],
|
||||
mixins: [eventListenerMixins, uiSettingsMixin],
|
||||
props: {
|
||||
value: { type: String, default: '' },
|
||||
editorId: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
isPrivate: { type: Boolean, default: false },
|
||||
enableSuggestions: { type: Boolean, default: true },
|
||||
overrideLineBreaks: { type: Boolean, default: false },
|
||||
updateSelectionWith: { type: String, default: '' },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -162,6 +171,25 @@ export default {
|
|||
isPrivate() {
|
||||
this.reloadState();
|
||||
},
|
||||
|
||||
updateSelectionWith(newValue, oldValue) {
|
||||
if (!this.editorView) {
|
||||
return null;
|
||||
}
|
||||
if (newValue !== oldValue) {
|
||||
if (this.updateSelectionWith !== '') {
|
||||
const node = this.editorView.state.schema.text(
|
||||
this.updateSelectionWith
|
||||
);
|
||||
const tr = this.editorView.state.tr.replaceSelectionWith(node);
|
||||
this.editorView.focus();
|
||||
this.state = this.editorView.state.apply(tr);
|
||||
this.emitOnChange();
|
||||
this.$emit('clear-selection');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.state = createState(this.value, this.placeholder, this.plugins);
|
||||
|
@ -188,6 +216,9 @@ export default {
|
|||
keyup: () => {
|
||||
this.onKeyup();
|
||||
},
|
||||
keydown: (view, event) => {
|
||||
this.onKeydown(event);
|
||||
},
|
||||
focus: () => {
|
||||
this.onFocus();
|
||||
},
|
||||
|
@ -203,6 +234,12 @@ export default {
|
|||
},
|
||||
});
|
||||
},
|
||||
isEnterToSendEnabled() {
|
||||
return isEditorHotKeyEnabled(this.uiSettings, 'enter');
|
||||
},
|
||||
isCmdPlusEnterToSendEnabled() {
|
||||
return isEditorHotKeyEnabled(this.uiSettings, 'cmd_enter');
|
||||
},
|
||||
handleKeyEvents(e) {
|
||||
if (hasPressedAltAndPKey(e)) {
|
||||
this.focusEditorInputField();
|
||||
|
@ -233,7 +270,10 @@ export default {
|
|||
node
|
||||
);
|
||||
this.state = this.editorView.state.apply(tr);
|
||||
return this.emitOnChange();
|
||||
this.emitOnChange();
|
||||
AnalyticsHelper.track(ANALYTICS_EVENTS.USED_MENTIONS);
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
insertCannedResponse(cannedItem) {
|
||||
|
@ -241,22 +281,27 @@ export default {
|
|||
return null;
|
||||
}
|
||||
|
||||
const tr = this.editorView.state.tr.insertText(
|
||||
cannedItem,
|
||||
this.range.from,
|
||||
this.range.to
|
||||
let from = this.range.from - 1;
|
||||
let node = addMentionsToMarkdownParser(defaultMarkdownParser).parse(
|
||||
cannedItem
|
||||
);
|
||||
|
||||
if (node.childCount === 1) {
|
||||
node = this.editorView.state.schema.text(cannedItem);
|
||||
from = this.range.from;
|
||||
}
|
||||
|
||||
const tr = this.editorView.state.tr.replaceWith(
|
||||
from,
|
||||
this.range.to,
|
||||
node
|
||||
);
|
||||
|
||||
this.state = this.editorView.state.apply(tr);
|
||||
this.emitOnChange();
|
||||
|
||||
// Hacky fix for #5501
|
||||
this.state = createState(
|
||||
this.contentFromEditor,
|
||||
this.placeholder,
|
||||
this.plugins
|
||||
);
|
||||
this.editorView.updateState(this.state);
|
||||
this.focusEditorInputField();
|
||||
tr.scrollIntoView();
|
||||
AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE);
|
||||
return false;
|
||||
},
|
||||
|
||||
|
@ -278,6 +323,24 @@ export default {
|
|||
clearTimeout(this.idleTimer);
|
||||
}
|
||||
},
|
||||
handleLineBreakWhenEnterToSendEnabled(event) {
|
||||
if (
|
||||
hasPressedEnterAndNotCmdOrShift(event) &&
|
||||
this.isEnterToSendEnabled() &&
|
||||
!this.overrideLineBreaks
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
|
||||
if (
|
||||
hasPressedCommandAndEnter(event) &&
|
||||
this.isCmdPlusEnterToSendEnabled() &&
|
||||
!this.overrideLineBreaks
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
onKeyup() {
|
||||
if (!this.idleTimer) {
|
||||
this.$emit('typing-on');
|
||||
|
@ -288,6 +351,14 @@ export default {
|
|||
TYPING_INDICATOR_IDLE_TIME
|
||||
);
|
||||
},
|
||||
onKeydown(event) {
|
||||
if (this.isEnterToSendEnabled()) {
|
||||
this.handleLineBreakWhenEnterToSendEnabled(event);
|
||||
}
|
||||
if (this.isCmdPlusEnterToSendEnabled()) {
|
||||
this.handleLineBreakWhenCmdAndEnterToSendEnabled(event);
|
||||
}
|
||||
},
|
||||
onBlur() {
|
||||
this.turnOffIdleTimer();
|
||||
this.resetTyping();
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
size="small"
|
||||
@click="toggleEmojiPicker"
|
||||
/>
|
||||
<!-- ensure the same validations for attachment types are implemented in backend models as well -->
|
||||
<file-upload
|
||||
ref="upload"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
||||
|
@ -47,6 +46,16 @@
|
|||
:title="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
|
||||
@click="toggleAudioRecorder"
|
||||
/>
|
||||
<woot-button
|
||||
v-if="showEditorToggle"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
|
||||
icon="quote"
|
||||
emoji="🖊️"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
@click="$emit('toggle-editor')"
|
||||
/>
|
||||
<woot-button
|
||||
v-if="showAudioPlayStopButton"
|
||||
:icon="audioRecorderPlayStopIcon"
|
||||
|
@ -110,13 +119,15 @@ import { hasPressedAltAndAKey } from 'shared/helpers/KeyboardHelpers';
|
|||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import {
|
||||
ALLOWED_FILE_TYPES,
|
||||
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
|
||||
} from 'shared/constants/messages';
|
||||
|
||||
import { REPLY_EDITOR_MODES } from './constants';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ReplyBottomPanel',
|
||||
components: { FileUpload },
|
||||
|
@ -182,7 +193,7 @@ export default {
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isFormatMode: {
|
||||
showEditorToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
@ -200,6 +211,10 @@ export default {
|
|||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
isNote() {
|
||||
return this.mode === REPLY_EDITOR_MODES.NOTE;
|
||||
},
|
||||
|
@ -217,7 +232,19 @@ export default {
|
|||
return this.showFileUpload || this.isNote;
|
||||
},
|
||||
showAudioRecorderButton() {
|
||||
return this.showAudioRecorder;
|
||||
// Disable audio recorder for safari browser as recording is not supported
|
||||
const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
|
||||
return (
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.VOICE_RECORDER
|
||||
) &&
|
||||
this.showAudioRecorder &&
|
||||
!isSafari
|
||||
);
|
||||
},
|
||||
showAudioPlayStopButton() {
|
||||
return this.showAudioRecorder && this.isRecordingAudio;
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
</div>
|
||||
<dashboard-app-frame
|
||||
v-else
|
||||
:key="currentChat.id"
|
||||
:key="currentChat.id + '-' + activeIndex"
|
||||
:config="dashboardApps[activeIndex - 1].content"
|
||||
:current-chat="currentChat"
|
||||
/>
|
||||
|
|
|
@ -91,6 +91,7 @@
|
|||
</span>
|
||||
<span class="unread">{{ unreadCount > 9 ? '9+' : unreadCount }}</span>
|
||||
</div>
|
||||
<card-labels :conversation-id="chat.id" />
|
||||
</div>
|
||||
<woot-context-menu
|
||||
v-if="showContextMenu"
|
||||
|
@ -102,10 +103,12 @@
|
|||
<conversation-context-menu
|
||||
:status="chat.status"
|
||||
:inbox-id="inbox.id"
|
||||
:has-unread-messages="hasUnread"
|
||||
@update-conversation="onUpdateConversation"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-label="onAssignLabel"
|
||||
@assign-team="onAssignTeam"
|
||||
@mark-as-unread="markAsUnread"
|
||||
/>
|
||||
</woot-context-menu>
|
||||
</div>
|
||||
|
@ -123,8 +126,8 @@ import InboxName from '../InboxName';
|
|||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import ConversationContextMenu from './contextMenu/Index.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import timeAgo from 'dashboard/components/ui/TimeAgo';
|
||||
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo';
|
||||
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||
const ATTACHMENT_ICONS = {
|
||||
image: 'image',
|
||||
audio: 'headphones-sound-wave',
|
||||
|
@ -136,10 +139,11 @@ const ATTACHMENT_ICONS = {
|
|||
|
||||
export default {
|
||||
components: {
|
||||
CardLabels,
|
||||
InboxName,
|
||||
Thumbnail,
|
||||
ConversationContextMenu,
|
||||
timeAgo,
|
||||
TimeAgo,
|
||||
},
|
||||
|
||||
mixins: [
|
||||
|
@ -241,7 +245,7 @@ export default {
|
|||
},
|
||||
|
||||
unreadCount() {
|
||||
return this.unreadMessagesCount(this.chat);
|
||||
return this.chat.unread_count;
|
||||
},
|
||||
|
||||
hasUnread() {
|
||||
|
@ -359,16 +363,24 @@ export default {
|
|||
this.$emit('assign-team', team, this.chat.id);
|
||||
this.closeContextMenu();
|
||||
},
|
||||
async markAsUnread() {
|
||||
this.$emit('mark-as-unread', this.chat.id);
|
||||
this.closeContextMenu();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.conversation {
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-light);
|
||||
}
|
||||
|
||||
&::v-deep .user-thumbnail-box {
|
||||
margin-top: var(--space-normal);
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-selected {
|
||||
|
@ -377,8 +389,10 @@ export default {
|
|||
|
||||
.has-inbox-name {
|
||||
&::v-deep .user-thumbnail-box {
|
||||
margin-top: var(--space-normal);
|
||||
align-items: flex-start;
|
||||
margin-top: var(--space-large);
|
||||
}
|
||||
.checkbox-wrapper {
|
||||
margin-top: var(--space-large);
|
||||
}
|
||||
.conversation--meta {
|
||||
margin-top: var(--space-normal);
|
||||
|
@ -423,6 +437,7 @@ export default {
|
|||
margin-top: var(--space-minus-micro);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
|
@ -432,6 +447,7 @@ export default {
|
|||
border-radius: 100%;
|
||||
margin-top: var(--space-normal);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--w-100);
|
||||
}
|
||||
|
|
|
@ -21,7 +21,11 @@
|
|||
/>
|
||||
</h3>
|
||||
<div class="conversation--header--actions">
|
||||
<inbox-name :inbox="inbox" class="margin-right-small" />
|
||||
<inbox-name
|
||||
v-if="hasMultipleInboxes"
|
||||
:inbox="inbox"
|
||||
class="margin-right-small"
|
||||
/>
|
||||
<span
|
||||
v-if="isSnoozed"
|
||||
class="snoozed--display-text margin-right-small"
|
||||
|
@ -145,6 +149,9 @@ export default {
|
|||
const { inbox_id: inboxId } = this.chat;
|
||||
return this.$store.getters['inboxes/getInbox'](inboxId);
|
||||
},
|
||||
hasMultipleInboxes() {
|
||||
return this.$store.getters['inboxes/getInboxes'].length > 1;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
|
|
@ -15,7 +15,6 @@
|
|||
v-if="data.content"
|
||||
:message="message"
|
||||
:is-email="isEmailContentType"
|
||||
:readable-time="readableTime"
|
||||
:display-quoted-button="displayQuotedButton"
|
||||
/>
|
||||
<span
|
||||
|
@ -29,7 +28,6 @@
|
|||
<bubble-image
|
||||
v-if="attachment.file_type === 'image' && !hasImageError"
|
||||
:url="attachment.data_url"
|
||||
:readable-time="readableTime"
|
||||
@error="onImageLoadError"
|
||||
/>
|
||||
<audio v-else-if="attachment.file_type === 'audio'" controls>
|
||||
|
@ -38,13 +36,14 @@
|
|||
<bubble-video
|
||||
v-else-if="attachment.file_type === 'video'"
|
||||
:url="attachment.data_url"
|
||||
:readable-time="readableTime"
|
||||
/>
|
||||
<bubble-file
|
||||
v-else
|
||||
:url="attachment.data_url"
|
||||
:readable-time="readableTime"
|
||||
<bubble-location
|
||||
v-else-if="attachment.file_type === 'location'"
|
||||
:latitude="attachment.coordinates_lat"
|
||||
:longitude="attachment.coordinates_long"
|
||||
:name="attachment.fallback_title"
|
||||
/>
|
||||
<bubble-file v-else :url="attachment.data_url" />
|
||||
</div>
|
||||
</div>
|
||||
<bubble-actions
|
||||
|
@ -53,14 +52,15 @@
|
|||
:story-sender="storySender"
|
||||
:story-id="storyId"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:has-instagram-story="hasInstagramStory"
|
||||
:is-email="isEmailContentType"
|
||||
:is-private="data.private"
|
||||
:message-type="data.message_type"
|
||||
:readable-time="readableTime"
|
||||
:message-status="status"
|
||||
:source-id="data.source_id"
|
||||
:inbox-id="data.inbox_id"
|
||||
:message-read="showReadTicks"
|
||||
:created-at="createdAt"
|
||||
/>
|
||||
</div>
|
||||
<spinner v-if="isPending" size="tiny" />
|
||||
|
@ -111,14 +111,13 @@
|
|||
</template>
|
||||
<script>
|
||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import timeMixin from '../../../mixins/time';
|
||||
|
||||
import BubbleMailHead from './bubble/MailHead';
|
||||
import BubbleText from './bubble/Text';
|
||||
import BubbleImage from './bubble/Image';
|
||||
import BubbleFile from './bubble/File';
|
||||
import BubbleVideo from './bubble/Video.vue';
|
||||
import BubbleActions from './bubble/Actions';
|
||||
import BubbleLocation from './bubble/Location';
|
||||
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu';
|
||||
|
@ -136,10 +135,11 @@ export default {
|
|||
BubbleFile,
|
||||
BubbleVideo,
|
||||
BubbleMailHead,
|
||||
BubbleLocation,
|
||||
ContextMenu,
|
||||
Spinner,
|
||||
},
|
||||
mixins: [alertMixin, timeMixin, messageFormatterMixin, contentTypeMixin],
|
||||
mixins: [alertMixin, messageFormatterMixin, contentTypeMixin],
|
||||
props: {
|
||||
data: {
|
||||
type: Object,
|
||||
|
@ -149,11 +149,11 @@ export default {
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasInstagramStory: {
|
||||
isAWhatsAppChannel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasUserReadMessage: {
|
||||
hasInstagramStory: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
@ -223,6 +223,9 @@ export default {
|
|||
sender() {
|
||||
return this.data.sender || {};
|
||||
},
|
||||
status() {
|
||||
return this.data.status;
|
||||
},
|
||||
storySender() {
|
||||
return this.contentAttributes.story_sender || null;
|
||||
},
|
||||
|
@ -256,11 +259,8 @@ export default {
|
|||
'has-tweet-menu': this.isATweet,
|
||||
};
|
||||
},
|
||||
readableTime() {
|
||||
return this.messageStamp(
|
||||
this.contentAttributes.external_created_at || this.data.created_at,
|
||||
'LLL d, h:mm a'
|
||||
);
|
||||
createdAt() {
|
||||
return this.contentAttributes.external_created_at || this.data.created_at;
|
||||
},
|
||||
isBubble() {
|
||||
return [0, 1, 3].includes(this.data.message_type);
|
||||
|
@ -271,14 +271,6 @@ export default {
|
|||
isOutgoing() {
|
||||
return this.data.message_type === MESSAGE_TYPE.OUTGOING;
|
||||
},
|
||||
showReadTicks() {
|
||||
return (
|
||||
(this.isOutgoing || this.isTemplate) &&
|
||||
this.hasUserReadMessage &&
|
||||
this.isWebWidgetInbox &&
|
||||
!this.data.private
|
||||
);
|
||||
},
|
||||
isTemplate() {
|
||||
return this.data.message_type === MESSAGE_TYPE.TEMPLATE;
|
||||
},
|
||||
|
@ -420,6 +412,8 @@ export default {
|
|||
<style lang="scss">
|
||||
.wrap {
|
||||
> .bubble {
|
||||
min-width: 128px;
|
||||
|
||||
&.is-image,
|
||||
&.is-video {
|
||||
padding: 0;
|
||||
|
|
|
@ -35,20 +35,18 @@
|
|||
<message
|
||||
v-for="message in getReadMessages"
|
||||
:key="message.id"
|
||||
class="message--read"
|
||||
class="message--read ph-no-capture"
|
||||
:data="message"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:has-instagram-story="hasInstagramStory"
|
||||
:has-user-read-message="
|
||||
hasUserReadMessage(message.created_at, getLastSeenAt)
|
||||
"
|
||||
:is-web-widget-inbox="isAWebWidgetInbox"
|
||||
/>
|
||||
<li v-show="getUnreadCount != 0" class="unread--toast">
|
||||
<li v-show="unreadMessageCount != 0" class="unread--toast">
|
||||
<span class="text-uppercase">
|
||||
{{ getUnreadCount }}
|
||||
{{ unreadMessageCount }}
|
||||
{{
|
||||
getUnreadCount > 1
|
||||
unreadMessageCount > 1
|
||||
? $t('CONVERSATION.UNREAD_MESSAGES')
|
||||
: $t('CONVERSATION.UNREAD_MESSAGE')
|
||||
}}
|
||||
|
@ -57,13 +55,11 @@
|
|||
<message
|
||||
v-for="message in getUnReadMessages"
|
||||
:key="message.id"
|
||||
class="message--unread"
|
||||
class="message--unread ph-no-capture"
|
||||
:data="message"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:has-instagram-story="hasInstagramStory"
|
||||
:has-user-read-message="
|
||||
hasUserReadMessage(message.created_at, getLastSeenAt)
|
||||
"
|
||||
:is-web-widget-inbox="isAWebWidgetInbox"
|
||||
/>
|
||||
</ul>
|
||||
|
@ -137,9 +133,7 @@ export default {
|
|||
allConversations: 'getAllConversations',
|
||||
inboxesList: 'inboxes/getInboxes',
|
||||
listLoadingStatus: 'getAllMessagesLoaded',
|
||||
getUnreadCount: 'getUnreadCount',
|
||||
loadingChatList: 'getChatListLoadingStatus',
|
||||
conversationLastSeen: 'getConversationLastSeen',
|
||||
}),
|
||||
inboxId() {
|
||||
return this.currentChat.inbox_id;
|
||||
|
@ -234,7 +228,6 @@ export default {
|
|||
return 'arrow-chevron-left';
|
||||
},
|
||||
getLastSeenAt() {
|
||||
if (this.conversationLastSeen) return this.conversationLastSeen;
|
||||
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
|
||||
return contactLastSeenAt;
|
||||
},
|
||||
|
@ -273,6 +266,9 @@ export default {
|
|||
}
|
||||
return '';
|
||||
},
|
||||
unreadMessageCount() {
|
||||
return this.currentChat.unread_count;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
@ -333,7 +329,7 @@ export default {
|
|||
},
|
||||
scrollToBottom() {
|
||||
let relevantMessages = [];
|
||||
if (this.getUnreadCount > 0) {
|
||||
if (this.unreadMessageCount > 0) {
|
||||
// capturing only the unread messages
|
||||
relevantMessages = this.conversationPanel.querySelectorAll(
|
||||
'.message--unread'
|
||||
|
@ -431,12 +427,7 @@ export default {
|
|||
position: fixed;
|
||||
left: unset;
|
||||
position: absolute;
|
||||
|
||||
&::before {
|
||||
transform: rotate(0deg);
|
||||
left: var(--space-half);
|
||||
bottom: var(--space-minus-slab);
|
||||
}
|
||||
bottom: var(--space-smaller);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,6 +37,7 @@
|
|||
<woot-audio-recorder
|
||||
v-if="showAudioRecorderEditor"
|
||||
ref="audioRecorderInput"
|
||||
:audio-record-format="audioRecordFormat"
|
||||
@state-recorder-progress-changed="onStateProgressRecorderChanged"
|
||||
@state-recorder-changed="onStateRecorderChanged"
|
||||
@finish-record="onFinishRecorder"
|
||||
|
@ -60,6 +61,7 @@
|
|||
class="input"
|
||||
:is-private="isOnPrivateNote"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:update-selection-with="updateEditorSelectionWith"
|
||||
:min-height="4"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
|
@ -67,6 +69,7 @@
|
|||
@blur="onBlur"
|
||||
@toggle-user-mention="toggleUserMention"
|
||||
@toggle-canned-menu="toggleCannedMenu"
|
||||
@clear-selection="clearEditorSelection"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="hasAttachments" class="attachment-preview-box" @paste="onPaste">
|
||||
|
@ -109,10 +112,11 @@
|
|||
:recording-audio-state="recordingAudioState"
|
||||
:is-recording-audio="isRecordingAudio"
|
||||
:is-on-private-note="isOnPrivateNote"
|
||||
:is-format-mode="showRichContentEditor"
|
||||
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
|
||||
:enable-multiple-file-upload="enableMultipleFileUpload"
|
||||
:has-whatsapp-templates="hasWhatsappTemplates"
|
||||
@selectWhatsappTemplate="openWhatsappTemplateModal"
|
||||
@toggle-editor="toggleRichContentEditor"
|
||||
/>
|
||||
<whatsapp-templates
|
||||
:inbox-id="inbox.id"
|
||||
|
@ -129,7 +133,6 @@ import { mapGetters } from 'vuex';
|
|||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
import EmojiInput from 'shared/components/emoji/EmojiInput';
|
||||
import CannedResponse from './CannedResponse';
|
||||
import ResizableTextArea from 'shared/components/ResizableTextArea';
|
||||
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview';
|
||||
|
@ -145,6 +148,7 @@ import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
|||
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';
|
||||
|
||||
|
@ -159,6 +163,11 @@ import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../../helper/localStorage';
|
|||
import { trimContent, debounce } from '@chatwoot/utils';
|
||||
import wootConstants from 'dashboard/constants';
|
||||
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
||||
import AnalyticsHelper, {
|
||||
ANALYTICS_EVENTS,
|
||||
} from '../../../helper/AnalyticsHelper';
|
||||
|
||||
const EmojiInput = () => import('shared/components/emoji/EmojiInput');
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
@ -214,6 +223,7 @@ export default {
|
|||
ccEmails: '',
|
||||
doAutoSaveDraft: () => {},
|
||||
showWhatsAppTemplatesModal: false,
|
||||
updateEditorSelectionWith: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -230,6 +240,13 @@ export default {
|
|||
return true;
|
||||
}
|
||||
|
||||
if (this.isAPIInbox) {
|
||||
const {
|
||||
display_rich_content_editor: displayRichContentEditor = false,
|
||||
} = this.uiSettings;
|
||||
return displayRichContentEditor;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
assignedAgent: {
|
||||
|
@ -365,7 +382,7 @@ export default {
|
|||
);
|
||||
},
|
||||
isRichEditorEnabled() {
|
||||
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
|
||||
return this.isAWebWidgetInbox || this.isAnEmailChannel;
|
||||
},
|
||||
showAudioRecorder() {
|
||||
return !this.isOnPrivateNote && this.showFileUpload;
|
||||
|
@ -390,7 +407,7 @@ export default {
|
|||
return conversationDisplayType !== CONDENSED;
|
||||
},
|
||||
emojiDialogClassOnExpanedLayout() {
|
||||
return this.isOnExpandedLayout && !this.popoutReplyBox
|
||||
return this.isOnExpandedLayout || this.popoutReplyBox
|
||||
? 'emoji-dialog--expanded'
|
||||
: '';
|
||||
},
|
||||
|
@ -442,12 +459,17 @@ export default {
|
|||
return this.currentChat.id;
|
||||
},
|
||||
conversationIdByRoute() {
|
||||
const { conversation_id: conversationId } = this.$route.params;
|
||||
return conversationId;
|
||||
return this.conversationId;
|
||||
},
|
||||
editorStateId() {
|
||||
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
},
|
||||
audioRecordFormat() {
|
||||
if (this.isAWebWidgetInbox) {
|
||||
return AUDIO_FORMATS.WEBM;
|
||||
}
|
||||
return AUDIO_FORMATS.OGG;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentChat(conversation) {
|
||||
|
@ -511,6 +533,11 @@ export default {
|
|||
document.removeEventListener('keydown', this.handleKeyEvents);
|
||||
},
|
||||
methods: {
|
||||
toggleRichContentEditor() {
|
||||
this.updateUISettings({
|
||||
display_rich_content_editor: !this.showRichContentEditor,
|
||||
});
|
||||
},
|
||||
getSavedDraftMessages() {
|
||||
return LocalStorage.get(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES) || {};
|
||||
},
|
||||
|
@ -574,6 +601,7 @@ export default {
|
|||
e.preventDefault();
|
||||
} else if (keyCode === 'enter' && this.isAValidEvent('enter')) {
|
||||
this.onSendReply();
|
||||
e.preventDefault();
|
||||
} else if (
|
||||
['meta+enter', 'ctrl+enter'].includes(keyCode) &&
|
||||
this.isAValidEvent('cmd_enter')
|
||||
|
@ -681,6 +709,7 @@ export default {
|
|||
},
|
||||
replaceText(message) {
|
||||
setTimeout(() => {
|
||||
AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE);
|
||||
this.message = message;
|
||||
}, 100);
|
||||
},
|
||||
|
@ -695,8 +724,26 @@ export default {
|
|||
}
|
||||
this.$nextTick(() => this.$refs.messageInput.focus());
|
||||
},
|
||||
clearEditorSelection() {
|
||||
this.updateEditorSelectionWith = '';
|
||||
},
|
||||
insertEmoji(emoji, selectionStart, selectionEnd) {
|
||||
const { message } = this;
|
||||
const newMessage =
|
||||
message.slice(0, selectionStart) +
|
||||
emoji +
|
||||
message.slice(selectionEnd, message.length);
|
||||
this.message = newMessage;
|
||||
},
|
||||
emojiOnClick(emoji) {
|
||||
this.message = `${this.message}${emoji} `;
|
||||
if (this.showRichContentEditor) {
|
||||
this.updateEditorSelectionWith = emoji;
|
||||
this.onFocus();
|
||||
}
|
||||
if (!this.showRichContentEditor) {
|
||||
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
|
||||
this.insertEmoji(emoji, selectionStart, selectionEnd);
|
||||
}
|
||||
},
|
||||
clearMessage() {
|
||||
this.message = '';
|
||||
|
@ -951,13 +998,13 @@ export default {
|
|||
|
||||
.emoji-dialog {
|
||||
top: unset;
|
||||
bottom: 12px;
|
||||
bottom: var(--space-normal);
|
||||
left: -320px;
|
||||
right: unset;
|
||||
|
||||
&::before {
|
||||
right: -16px;
|
||||
bottom: 10px;
|
||||
right: var(--space-minus-normal);
|
||||
bottom: var(--space-small);
|
||||
transform: rotate(270deg);
|
||||
filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
@ -970,8 +1017,8 @@ export default {
|
|||
|
||||
&::before {
|
||||
transform: rotate(0deg);
|
||||
left: var(--space-half);
|
||||
bottom: var(--space-minus-slab);
|
||||
left: var(--space-smaller);
|
||||
bottom: var(--space-minus-small);
|
||||
}
|
||||
}
|
||||
.message-signature {
|
||||
|
|
|
@ -1,22 +1,38 @@
|
|||
<template>
|
||||
<div class="message-text--metadata">
|
||||
<span class="time" :class="{ delivered: messageRead }">{{
|
||||
readableTime
|
||||
}}</span>
|
||||
<span v-if="showSentIndicator" class="time">
|
||||
<span
|
||||
class="time"
|
||||
:class="{
|
||||
'has-status-icon':
|
||||
showSentIndicator || showDeliveredIndicator || showReadIndicator,
|
||||
}"
|
||||
>
|
||||
{{ readableTime }}
|
||||
</span>
|
||||
<span v-if="showReadIndicator" class="read-indicator-wrap">
|
||||
<fluent-icon
|
||||
v-tooltip.top-start="$t('CHAT_LIST.SENT')"
|
||||
icon="checkmark"
|
||||
v-tooltip.top-start="$t('CHAT_LIST.MESSAGE_READ')"
|
||||
icon="checkmark-double"
|
||||
class="action--icon read-tick read-indicator"
|
||||
size="14"
|
||||
/>
|
||||
</span>
|
||||
<span v-else-if="showDeliveredIndicator" class="read-indicator-wrap">
|
||||
<fluent-icon
|
||||
v-tooltip.top-start="$t('CHAT_LIST.DELIVERED')"
|
||||
icon="checkmark-double"
|
||||
class="action--icon read-tick"
|
||||
size="14"
|
||||
/>
|
||||
</span>
|
||||
<span v-else-if="showSentIndicator" class="read-indicator-wrap">
|
||||
<fluent-icon
|
||||
v-tooltip.top-start="$t('CHAT_LIST.SENT')"
|
||||
icon="checkmark"
|
||||
class="action--icon read-tick"
|
||||
size="14"
|
||||
/>
|
||||
</span>
|
||||
<fluent-icon
|
||||
v-if="messageRead"
|
||||
v-tooltip.top-start="$t('CHAT_LIST.MESSAGE_READ')"
|
||||
icon="checkmark-double"
|
||||
class="action--icon read-tick"
|
||||
size="12"
|
||||
/>
|
||||
<fluent-icon
|
||||
v-if="isEmail"
|
||||
v-tooltip.top-start="$t('CHAT_LIST.RECEIVED_VIA_EMAIL')"
|
||||
|
@ -44,19 +60,6 @@
|
|||
size="16"
|
||||
/>
|
||||
</button>
|
||||
<a
|
||||
v-if="hasInstagramStory && (isIncoming || isOutgoing) && linkToStory"
|
||||
:href="linkToStory"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<fluent-icon
|
||||
v-tooltip.top-start="$t('CHAT_LIST.LINK_TO_STORY')"
|
||||
icon="open"
|
||||
class="action--icon cursor-pointer"
|
||||
size="16"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
v-if="isATweet && (isOutgoing || isIncoming) && linkToTweet"
|
||||
:href="linkToTweet"
|
||||
|
@ -74,20 +77,22 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { MESSAGE_TYPE } 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 { mapGetters } from 'vuex';
|
||||
import timeMixin from '../../../../mixins/time';
|
||||
|
||||
export default {
|
||||
mixins: [inboxMixin],
|
||||
mixins: [inboxMixin, timeMixin],
|
||||
props: {
|
||||
sender: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
readableTime: {
|
||||
type: String,
|
||||
default: '',
|
||||
createdAt: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
storySender: {
|
||||
type: String,
|
||||
|
@ -117,6 +122,10 @@ export default {
|
|||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
messageStatus: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
sourceId: {
|
||||
type: String,
|
||||
default: '',
|
||||
|
@ -129,12 +138,9 @@ export default {
|
|||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
messageRead: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ currentChat: 'getSelectedChat' }),
|
||||
inbox() {
|
||||
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
||||
},
|
||||
|
@ -144,6 +150,21 @@ export default {
|
|||
isOutgoing() {
|
||||
return MESSAGE_TYPE.OUTGOING === this.messageType;
|
||||
},
|
||||
isTemplate() {
|
||||
return MESSAGE_TYPE.TEMPLATE === this.messageType;
|
||||
},
|
||||
isDelivered() {
|
||||
return MESSAGE_STATUS.DELIVERED === this.messageStatus;
|
||||
},
|
||||
isRead() {
|
||||
return MESSAGE_STATUS.READ === this.messageStatus;
|
||||
},
|
||||
isSent() {
|
||||
return MESSAGE_STATUS.SENT === this.messageStatus;
|
||||
},
|
||||
readableTime() {
|
||||
return this.messageStamp(this.createdAt, 'LLL d, h:mm a');
|
||||
},
|
||||
screenName() {
|
||||
const { additional_attributes: additionalAttributes = {} } =
|
||||
this.sender || {};
|
||||
|
@ -164,12 +185,52 @@ export default {
|
|||
const { storySender, storyId } = this;
|
||||
return `https://www.instagram.com/stories/${storySender}/${storyId}`;
|
||||
},
|
||||
showStatusIndicators() {
|
||||
if ((this.isOutgoing || this.isTemplate) && !this.private) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
showSentIndicator() {
|
||||
return (
|
||||
this.isOutgoing &&
|
||||
this.sourceId &&
|
||||
(this.isAnEmailChannel || this.isAWhatsAppChannel)
|
||||
);
|
||||
if (!this.showStatusIndicators) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isAnEmailChannel) {
|
||||
return !!this.sourceId;
|
||||
}
|
||||
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return this.sourceId && this.isSent;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
showDeliveredIndicator() {
|
||||
if (!this.showStatusIndicators) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return this.sourceId && this.isDelivered;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
showReadIndicator() {
|
||||
if (!this.showStatusIndicators) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isAWebWidgetInbox) {
|
||||
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
|
||||
return contactLastSeenAt >= this.createdAt;
|
||||
}
|
||||
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return this.sourceId && this.isRead;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
@ -185,16 +246,21 @@ export default {
|
|||
|
||||
.right {
|
||||
.message-text--metadata {
|
||||
align-items: center;
|
||||
.time {
|
||||
color: var(--w-100);
|
||||
}
|
||||
|
||||
.action--icon {
|
||||
color: var(--white);
|
||||
|
||||
&.read-tick {
|
||||
color: var(--v-100);
|
||||
margin-top: calc(var(--space-micro) + var(--space-micro) / 2);
|
||||
}
|
||||
color: var(--white);
|
||||
|
||||
&.read-indicator {
|
||||
color: var(--g-200);
|
||||
}
|
||||
}
|
||||
|
||||
.lock--icon--private {
|
||||
|
@ -258,8 +324,9 @@ export default {
|
|||
position: absolute;
|
||||
right: var(--space-small);
|
||||
white-space: nowrap;
|
||||
&.delivered {
|
||||
right: var(--space-medium);
|
||||
|
||||
&.has-status-icon {
|
||||
right: var(--space-large);
|
||||
line-height: 2;
|
||||
}
|
||||
}
|
||||
|
@ -296,4 +363,10 @@ export default {
|
|||
.delivered-icon {
|
||||
margin-left: -var(--space-normal);
|
||||
}
|
||||
|
||||
.read-indicator-wrap {
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
<template>
|
||||
<div class="location message-text__wrap">
|
||||
<div class="icon-wrap">
|
||||
<fluent-icon icon="location" class="file--icon" size="32" />
|
||||
</div>
|
||||
<div class="meta">
|
||||
<h5 class="text-block-title text-truncate">
|
||||
{{ name }}
|
||||
</h5>
|
||||
<div class="link-wrap">
|
||||
<a
|
||||
class="download clear link button small"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
:href="mapUrl"
|
||||
>
|
||||
{{ $t('COMPONENTS.LOCATION_BUBBLE.SEE_ON_MAP') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
latitude: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
longitude: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
mapUrl() {
|
||||
return `https://maps.google.com/?q=${this.latitude},${this.longitude}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.location {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: var(--space-smaller) 0;
|
||||
cursor: pointer;
|
||||
|
||||
.icon-wrap {
|
||||
color: var(--s-600);
|
||||
line-height: 1;
|
||||
margin: 0 var(--space-smaller);
|
||||
}
|
||||
|
||||
.text-block-title {
|
||||
margin: 0;
|
||||
color: var(--s-800);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding-right: var(--space-normal);
|
||||
}
|
||||
|
||||
.link-wrap {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -35,10 +35,6 @@ export default {
|
|||
type: String,
|
||||
default: '',
|
||||
},
|
||||
readableTime: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isEmail: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
<template>
|
||||
<div class="menu-container">
|
||||
<menu-item
|
||||
v-if="!hasUnreadMessages"
|
||||
:option="unreadOption"
|
||||
variant="icon"
|
||||
@click="$emit('mark-as-unread')"
|
||||
/>
|
||||
<template v-for="option in statusMenuConfig">
|
||||
<menu-item
|
||||
v-if="show(option.key)"
|
||||
|
@ -17,7 +23,10 @@
|
|||
@click="snoozeConversation(option.snoozedUntil)"
|
||||
/>
|
||||
</menu-item-with-submenu>
|
||||
<menu-item-with-submenu :option="labelMenuConfig">
|
||||
<menu-item-with-submenu
|
||||
:option="labelMenuConfig"
|
||||
:sub-menu-available="!!labels.length"
|
||||
>
|
||||
<template>
|
||||
<menu-item
|
||||
v-for="label in labels"
|
||||
|
@ -28,7 +37,10 @@
|
|||
/>
|
||||
</template>
|
||||
</menu-item-with-submenu>
|
||||
<menu-item-with-submenu :option="agentMenuConfig">
|
||||
<menu-item-with-submenu
|
||||
:option="agentMenuConfig"
|
||||
:sub-menu-available="!!assignableAgents.length"
|
||||
>
|
||||
<agent-loading-placeholder v-if="assignableAgentsUiFlags.isFetching" />
|
||||
<template v-else>
|
||||
<menu-item
|
||||
|
@ -40,7 +52,10 @@
|
|||
/>
|
||||
</template>
|
||||
</menu-item-with-submenu>
|
||||
<menu-item-with-submenu :option="teamMenuConfig">
|
||||
<menu-item-with-submenu
|
||||
:option="teamMenuConfig"
|
||||
:sub-menu-available="!!teams.length"
|
||||
>
|
||||
<menu-item
|
||||
v-for="team in teams"
|
||||
:key="team.id"
|
||||
|
@ -70,6 +85,10 @@ export default {
|
|||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasUnreadMessages: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
|
@ -78,6 +97,10 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
STATUS_TYPE: wootConstants.STATUS_TYPE,
|
||||
unreadOption: {
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'),
|
||||
icon: 'mail',
|
||||
},
|
||||
statusMenuConfig: [
|
||||
{
|
||||
key: wootConstants.STATUS_TYPE.RESOLVED,
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
size="20px"
|
||||
class="agent-thumbnail"
|
||||
/>
|
||||
<p class="menu-label truncate-text">{{ option.label }}</p>
|
||||
<p class="menu-label text-truncate">{{ option.label }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -50,7 +50,6 @@ export default {
|
|||
padding: var(--space-smaller);
|
||||
border-radius: var(--border-radius-small);
|
||||
overflow: hidden;
|
||||
|
||||
.menu-label {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-mini);
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
<template>
|
||||
<div class="menu-with-submenu flex-between">
|
||||
<div
|
||||
class="menu-with-submenu flex-between"
|
||||
:class="{ disabled: !subMenuAvailable }"
|
||||
>
|
||||
<div class="menu-left">
|
||||
<fluent-icon :icon="option.icon" size="14" class="menu-icon" />
|
||||
<p class="menu-label">{{ option.label }}</p>
|
||||
</div>
|
||||
<fluent-icon icon="chevron-right" size="12" />
|
||||
<div class="submenu">
|
||||
<div v-if="subMenuAvailable" class="submenu">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -18,6 +21,10 @@ export default {
|
|||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
subMenuAvailable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -55,6 +62,11 @@ export default {
|
|||
left: 100%;
|
||||
top: 0;
|
||||
display: none;
|
||||
min-height: min-content;
|
||||
max-height: var(--space-giga);
|
||||
overflow-y: auto;
|
||||
// Need this because Firefox adds a horizontal scrollbar, if a text is truncated inside.
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
@ -73,5 +85,10 @@ export default {
|
|||
clip-path: polygon(100% 0, 0% 0%, 100% 100%);
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 50%;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="bulk-action__agents">
|
||||
<div class="triangle">
|
||||
<div class="triangle" :style="cssVars">
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path
|
||||
d="M20 12l-8-8-12 12"
|
||||
|
@ -105,13 +105,14 @@ import { mapGetters } from 'vuex';
|
|||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import bulkActionsMixin from 'dashboard/mixins/bulkActionsMixin.js';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
Spinner,
|
||||
},
|
||||
mixins: [clickaway],
|
||||
mixins: [clickaway, bulkActionsMixin],
|
||||
props: {
|
||||
selectedInboxes: {
|
||||
type: Array,
|
||||
|
@ -233,7 +234,7 @@ export default {
|
|||
z-index: var(--z-index-one);
|
||||
position: absolute;
|
||||
top: calc(var(--space-slab) * -1);
|
||||
right: var(--space-micro);
|
||||
right: var(--triangle-position);
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,25 +43,26 @@
|
|||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="person-assign"
|
||||
class="margin-right-smaller"
|
||||
@click="toggleAgentList"
|
||||
/>
|
||||
<woot-button
|
||||
v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="people-team-add"
|
||||
@click="toggleTeamsList"
|
||||
/>
|
||||
</div>
|
||||
<transition name="popover-animation">
|
||||
<label-actions
|
||||
v-if="showLabelActions"
|
||||
triangle-position="8.5"
|
||||
@assign="assignLabels"
|
||||
@close="showLabelActions = false"
|
||||
/>
|
||||
</transition>
|
||||
<transition name="popover-animation">
|
||||
<agent-selector
|
||||
v-if="showAgentsList"
|
||||
:selected-inboxes="selectedInboxes"
|
||||
:conversation-count="conversations.length"
|
||||
@select="submit"
|
||||
@close="showAgentsList = false"
|
||||
/>
|
||||
</transition>
|
||||
<transition name="popover-animation">
|
||||
<update-actions
|
||||
v-if="showUpdateActions"
|
||||
|
@ -70,10 +71,29 @@
|
|||
:show-resolve="!showResolvedAction"
|
||||
:show-reopen="!showOpenAction"
|
||||
:show-snooze="!showSnoozedAction"
|
||||
triangle-position="5.6"
|
||||
@update="updateConversations"
|
||||
@close="showUpdateActions = false"
|
||||
/>
|
||||
</transition>
|
||||
<transition name="popover-animation">
|
||||
<agent-selector
|
||||
v-if="showAgentsList"
|
||||
:selected-inboxes="selectedInboxes"
|
||||
:conversation-count="conversations.length"
|
||||
triangle-position="2.8"
|
||||
@select="submit"
|
||||
@close="showAgentsList = false"
|
||||
/>
|
||||
</transition>
|
||||
<transition name="popover-animation">
|
||||
<team-actions
|
||||
v-if="showTeamsList"
|
||||
triangle-position="0.2"
|
||||
@assign-team="assignTeam"
|
||||
@close="showTeamsList = false"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
<div v-if="allConversationsSelected" class="bulk-action__alert">
|
||||
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
|
||||
|
@ -85,11 +105,13 @@
|
|||
import AgentSelector from './AgentSelector.vue';
|
||||
import UpdateActions from './UpdateActions.vue';
|
||||
import LabelActions from './LabelActions.vue';
|
||||
import TeamActions from './TeamActions.vue';
|
||||
export default {
|
||||
components: {
|
||||
AgentSelector,
|
||||
UpdateActions,
|
||||
LabelActions,
|
||||
TeamActions,
|
||||
},
|
||||
props: {
|
||||
conversations: {
|
||||
|
@ -122,6 +144,8 @@ export default {
|
|||
showAgentsList: false,
|
||||
showUpdateActions: false,
|
||||
showLabelActions: false,
|
||||
showTeamsList: false,
|
||||
popoverPositions: {},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
|
@ -137,6 +161,9 @@ export default {
|
|||
assignLabels(labels) {
|
||||
this.$emit('assign-labels', labels);
|
||||
},
|
||||
assignTeam(team) {
|
||||
this.$emit('assign-team', team);
|
||||
},
|
||||
resolveConversations() {
|
||||
this.$emit('resolve-conversations');
|
||||
},
|
||||
|
@ -149,6 +176,9 @@ export default {
|
|||
toggleAgentList() {
|
||||
this.showAgentsList = !this.showAgentsList;
|
||||
},
|
||||
toggleTeamsList() {
|
||||
this.showTeamsList = !this.showTeamsList;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -181,7 +211,7 @@ export default {
|
|||
color: var(--y-700);
|
||||
font-size: var(--font-size-mini);
|
||||
margin-top: var(--space-small);
|
||||
padding: var(--space-half) var(--space-one);
|
||||
padding: var(--space-smaller) var(--space-small);
|
||||
}
|
||||
|
||||
.popover-animation-enter-active,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div v-on-clickaway="onClose" class="labels-container">
|
||||
<div class="triangle">
|
||||
<div class="triangle" :style="cssVars">
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path
|
||||
d="M20 12l-8-8-12 12"
|
||||
|
@ -75,9 +75,10 @@
|
|||
<script>
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import { mapGetters } from 'vuex';
|
||||
import bulkActionsMixin from 'dashboard/mixins/bulkActionsMixin.js';
|
||||
|
||||
export default {
|
||||
mixins: [clickaway],
|
||||
mixins: [clickaway, bulkActionsMixin],
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
|
@ -160,7 +161,7 @@ export default {
|
|||
max-width: var(--space-giga);
|
||||
min-width: var(--space-giga);
|
||||
position: absolute;
|
||||
right: 4.5rem;
|
||||
right: var(--space-small);
|
||||
top: var(--space-larger);
|
||||
transform-origin: top right;
|
||||
width: auto;
|
||||
|
@ -204,7 +205,7 @@ export default {
|
|||
.triangle {
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: var(--space-two);
|
||||
right: var(--triangle-position);
|
||||
text-align: left;
|
||||
top: calc(var(--space-slab) * -1);
|
||||
z-index: var(--z-index-one);
|
||||
|
|
|
@ -0,0 +1,174 @@
|
|||
<template>
|
||||
<div v-on-clickaway="onClose" class="bulk-action__teams">
|
||||
<div class="triangle" :style="cssVars">
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path
|
||||
d="M20 12l-8-8-12 12"
|
||||
fill="var(--white)"
|
||||
fill-rule="evenodd"
|
||||
stroke="var(--s-50)"
|
||||
stroke-width="1px"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="header flex-between">
|
||||
<span>{{ $t('BULK_ACTION.TEAMS.TEAM_SELECT_LABEL') }}</span>
|
||||
<woot-button
|
||||
size="tiny"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
icon="dismiss"
|
||||
@click="onClose"
|
||||
/>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="team__list-container">
|
||||
<ul>
|
||||
<li class="search-container">
|
||||
<div class="agent-list-search flex-between">
|
||||
<fluent-icon icon="search" class="search-icon" size="16" />
|
||||
<input
|
||||
ref="search"
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Search"
|
||||
class="agent--search_input"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<template v-if="filteredTeams.length">
|
||||
<li v-for="team in filteredTeams" :key="team.id">
|
||||
<div class="team__list-item" @click="assignTeam(team)">
|
||||
<span class="reports-option__title">{{ team.name }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
<li v-else>
|
||||
<div class="team__list-item">
|
||||
<span class="reports-option__title">{{
|
||||
$t('BULK_ACTION.TEAMS.NO_TEAMS_AVAILABLE')
|
||||
}}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import { mapGetters } from 'vuex';
|
||||
import bulkActionsMixin from 'dashboard/mixins/bulkActionsMixin.js';
|
||||
export default {
|
||||
mixins: [clickaway, bulkActionsMixin],
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
selectedteams: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ teams: 'teams/getTeams' }),
|
||||
filteredTeams() {
|
||||
return [
|
||||
{ name: 'None', id: 0 },
|
||||
...this.teams.filter(team =>
|
||||
team.name.toLowerCase().includes(this.query.toLowerCase())
|
||||
),
|
||||
];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
assignTeam(key) {
|
||||
this.$emit('assign-team', key);
|
||||
},
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bulk-action__teams {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--border-radius-large);
|
||||
border: 1px solid var(--s-50);
|
||||
box-shadow: var(--shadow-dropdown-pane);
|
||||
max-width: 75%;
|
||||
position: absolute;
|
||||
right: var(--space-small);
|
||||
top: var(--space-larger);
|
||||
transform-origin: top right;
|
||||
width: auto;
|
||||
z-index: var(--z-index-twenty);
|
||||
min-width: var(--space-giga);
|
||||
.header {
|
||||
padding: var(--space-one);
|
||||
|
||||
span {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
max-height: var(--space-giga);
|
||||
overflow-y: auto;
|
||||
.team__list-container {
|
||||
height: 100%;
|
||||
}
|
||||
.agent-list-search {
|
||||
padding: 0 var(--space-one);
|
||||
border: 1px solid var(--s-100);
|
||||
border-radius: var(--border-radius-medium);
|
||||
background-color: var(--s-50);
|
||||
.search-icon {
|
||||
color: var(--s-400);
|
||||
}
|
||||
|
||||
.agent--search_input {
|
||||
border: 0;
|
||||
font-size: var(--font-size-mini);
|
||||
margin: 0;
|
||||
background-color: transparent;
|
||||
height: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
.triangle {
|
||||
display: block;
|
||||
z-index: var(--z-index-one);
|
||||
position: absolute;
|
||||
top: calc(var(--space-slab) * -1);
|
||||
right: var(--triangle-position);
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
ul {
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.team__list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-one);
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: var(--s-50);
|
||||
}
|
||||
span {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
padding: 0 var(--space-one);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-index-twenty);
|
||||
background-color: var(--white);
|
||||
}
|
||||
</style>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue