Compare commits
219
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce0388d9de | ||
|
|
17052113f5 | ||
|
|
c4dd3df6ad | ||
|
|
ad278c5d60 | ||
|
|
d66c95d0b6 | ||
|
|
6adc1f31e9 | ||
|
|
1db4850a5d | ||
|
|
d67c541943 | ||
|
|
e89e213245 | ||
|
|
c57a108f13 | ||
|
|
49ef4e54ca | ||
|
|
db0d32f01d | ||
|
|
7b8bcaba11 | ||
|
|
3f39b7ad1f | ||
|
|
a3547c5a1f | ||
|
|
0f776a173c | ||
|
|
d320605556 | ||
|
|
ca85d4e1c6 | ||
|
|
b994706265 | ||
|
|
d9f2cd0a6e | ||
|
|
f2a34ad8aa | ||
|
|
65e91f6a6f | ||
|
|
7dd1562b40 | ||
|
|
c70367bbd7 | ||
|
|
5a8eff35b5 | ||
|
|
1936369b12 | ||
|
|
354010a6e1 | ||
|
|
385eab6b96 | ||
|
|
198cd9b28d | ||
|
|
abdf00d2cf | ||
|
|
2c3160cfee | ||
|
|
708bddf4db | ||
|
|
020dcc4dc7 | ||
|
|
271263bcc2 | ||
|
|
c97d6021e0 | ||
|
|
d99997d17d | ||
|
|
9c5d062efc | ||
|
|
87f758ee1f | ||
|
|
d0a1ad746a | ||
|
|
86384bf876 | ||
|
|
55e5fa2205 | ||
|
|
65bad90b69 | ||
|
|
07aaa046c1 | ||
|
|
520bdabefe | ||
|
|
662967b5d3 | ||
|
|
6137a45214 | ||
|
|
5e0ce7793c | ||
|
|
ce3e38df0f | ||
|
|
2b88ecdfc4 | ||
|
|
683f259771 | ||
|
|
51fb3b7e8e | ||
|
|
3dedfee350 | ||
|
|
022383d942 | ||
|
|
59433d9d3c | ||
|
|
85e57c2e94 | ||
|
|
b081fe08b8 | ||
|
|
0d014d5d4b | ||
|
|
937338e3ea | ||
|
|
6e506b070e | ||
|
|
257a294166 | ||
|
|
12f121f0d8 | ||
|
|
847d7ea082 | ||
|
|
c8041392dc | ||
|
|
be3c75e858 | ||
|
|
02c467b6db | ||
|
|
13fe439d9f | ||
|
|
2313edd244 | ||
|
|
09c9300c21 | ||
|
|
c0e905b5d8 | ||
|
|
04da8aa8dc | ||
|
|
32f7342cd6 | ||
|
|
74afb785c2 | ||
|
|
cae1e30160 | ||
|
|
99dfe1d5af | ||
|
|
3fa654f5c6 | ||
|
|
37fde64c67 | ||
|
|
3c2d6faf68 | ||
|
|
5d30dabf97 | ||
|
|
cef1f97d18 | ||
|
|
92705723f1 | ||
|
|
af971c9b18 | ||
|
|
2f2cdd7e7c | ||
|
|
d95283f5c2 | ||
|
|
402428fb4d | ||
|
|
5600b518ac | ||
|
|
0bbb28c432 | ||
|
|
cf91e9eb58 | ||
|
|
b529baa5eb | ||
|
|
c071f66cdd | ||
|
|
92fa9c4fdc | ||
|
|
f6e0453bb2 | ||
|
|
1a07828b1b | ||
|
|
54880b2342 | ||
|
|
e32f3e71e4 | ||
|
|
0874aeee2d | ||
|
|
f1fc658a0d | ||
|
|
e3193dcabc | ||
|
|
f825a22997 | ||
|
|
815322b27a | ||
|
|
474e65f4c8 | ||
|
|
3a35281b3f | ||
|
|
ac6de50b4d | ||
|
|
a34729c153 | ||
|
|
6b2736aa63 | ||
|
|
d1584eea72 | ||
|
|
527042afd1 | ||
|
|
bd1e69e4b4 | ||
|
|
76d4c22c2d | ||
|
|
5cdc7d654a | ||
|
|
821d49943a | ||
|
|
9c0259da6b | ||
|
|
ea2c442328 | ||
|
|
b93b8cdab6 | ||
|
|
e6505fc7a4 | ||
|
|
c5c36af529 | ||
|
|
026e03c307 | ||
|
|
37b7098673 | ||
|
|
7bd400772d | ||
|
|
cb2f86b983 | ||
|
|
905e77048f | ||
|
|
4505c5dda3 | ||
|
|
c9ce9e5b8f | ||
|
|
9e2f991484 | ||
|
|
80dcd17f6e | ||
|
|
a38ecf3dde | ||
|
|
9090eabb8a | ||
|
|
ca2506a941 | ||
|
|
1886d4ce08 | ||
|
|
4d49b81f1c | ||
|
|
2b736f4698 | ||
|
|
9ca21df9fd | ||
|
|
44837aa657 | ||
|
|
d04344c094 | ||
|
|
d45512df72 | ||
|
|
2731c2a5be | ||
|
|
610463c980 | ||
|
|
09ce85b30d | ||
|
|
17ff1f11a7 | ||
|
|
f4e121cc44 | ||
|
|
cf934450ab | ||
|
|
ad75a79135 | ||
|
|
d49989ace1 | ||
|
|
e69e0bc984 | ||
|
|
e877b01e00 | ||
|
|
da11feb39b | ||
|
|
138afd9af6 | ||
|
|
e753365493 | ||
|
|
91dc7733b0 | ||
|
|
463c09184c | ||
|
|
040e9a732f | ||
|
|
71c5a1e1d4 | ||
|
|
a521762dd6 | ||
|
|
406e8405eb | ||
|
|
37dd898c9a | ||
|
|
be2356724e | ||
|
|
ab6276327a | ||
|
|
ee131011f9 | ||
|
|
2ca8726005 | ||
|
|
b39e5bb642 | ||
|
|
ebd5fbef17 | ||
|
|
b7d0016d99 | ||
|
|
a0eafc94d7 | ||
|
|
110e28d08e | ||
|
|
a040aee96b | ||
|
|
6a0ca35de4 | ||
|
|
268eababa3 | ||
|
|
d2d6d271c3 | ||
|
|
363ffdbde3 | ||
|
|
44f73e044a | ||
|
|
ebc144683a | ||
|
|
44e4eee28b | ||
|
|
2e9eead20c | ||
|
|
d1ac33e98c | ||
|
|
21da03fe5b | ||
|
|
bd5ff7a8bf | ||
|
|
fd41529b82 | ||
|
|
7bd830ebfe | ||
|
|
7930902ec8 | ||
|
|
d8604107aa | ||
|
|
cef44bc557 | ||
|
|
7e3a4d2c20 | ||
|
|
a4c9a2b2f2 | ||
|
|
d1b65b6c9e | ||
|
|
4ed35cf461 | ||
|
|
803015b7f8 | ||
|
|
aa75666ad9 | ||
|
|
2b7ff48bcd | ||
|
|
6002394fcf | ||
|
|
bc8e8f3bb5 | ||
|
|
54a809ea54 | ||
|
|
fdb067a352 | ||
|
|
d082aa50a8 | ||
|
|
3535a1a708 | ||
|
|
4c10845acd | ||
|
|
5b7bed9640 | ||
|
|
b3850cb4fa | ||
|
|
7de89b6f9b | ||
|
|
00ee0478eb | ||
|
|
6000028f64 | ||
|
|
1e8881577a | ||
|
|
4b83bcb5ca | ||
|
|
70e7530cb4 | ||
|
|
d666afd757 | ||
|
|
a6e7737c56 | ||
|
|
856d9067b0 | ||
|
|
d94f195284 | ||
|
|
1370cf3c07 | ||
|
|
1dd7cfc25d | ||
|
|
f3aba84063 | ||
|
|
9c040af028 | ||
|
|
4cae5c7d51 | ||
|
|
e5134c9ef5 | ||
|
|
4f936aada5 | ||
|
|
0c9f129c74 | ||
|
|
d2aa5f4c69 | ||
|
|
e76b63f91d | ||
|
|
5c5764ca85 | ||
|
|
cf487c76a0 | ||
|
|
6848433a4c |
@@ -7,7 +7,7 @@ defaults: &defaults
|
||||
working_directory: ~/build
|
||||
docker:
|
||||
# specify the version you desire here
|
||||
- image: cimg/ruby:3.1.3-browsers
|
||||
- image: cimg/ruby:3.2.2-browsers
|
||||
|
||||
# Specify service dependencies here if necessary
|
||||
# CircleCI maintains a library of pre-built images
|
||||
|
||||
@@ -30,7 +30,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
npm
|
||||
|
||||
# Install rbenv and ruby
|
||||
ARG RUBY_VERSION="3.1.3"
|
||||
ARG RUBY_VERSION="3.2.2"
|
||||
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
|
||||
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
|
||||
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
.bundle
|
||||
.env
|
||||
.env.*
|
||||
.git
|
||||
.gitignore
|
||||
docker-compose.*
|
||||
docker/Dockerfile
|
||||
docker/dockerfiles
|
||||
|
||||
+2
-3
@@ -110,6 +110,8 @@ AWS_REGION=
|
||||
RAILS_LOG_TO_STDOUT=true
|
||||
LOG_LEVEL=info
|
||||
LOG_SIZE=500
|
||||
# Configure this environment variable if you want to use lograge instead of rails logger
|
||||
#LOGRAGE_ENABLED=true
|
||||
|
||||
### This environment variables are only required if you are setting up social media channels
|
||||
|
||||
@@ -159,9 +161,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
||||
# for mobile apps
|
||||
# FCM_SERVER_KEY=
|
||||
|
||||
## Bot Customizations
|
||||
USE_INBOX_AVATAR_FOR_BOT=true
|
||||
|
||||
### APM and Error Monitoring configurations
|
||||
## Elastic APM
|
||||
## https://www.elastic.co/guide/en/apm/agent/ruby/current/getting-started-rails.html
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: 'Bug'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See the error
|
||||
|
||||
**Expected behavior**
|
||||
|
||||
Share a clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Browser logs**
|
||||
|
||||
Share the browser logs to debug the issue further.
|
||||
|
||||
**Server logs**
|
||||
|
||||
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/Kubernetes/Other).
|
||||
|
||||
- [ ] 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)** (If applicable)
|
||||
- Device: [e.g. iPhone6, Pixel7]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- 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.
|
||||
@@ -0,0 +1,78 @@
|
||||
name: 🐞 Bug report
|
||||
description: Create a report to help us improve
|
||||
labels: 'Bug'
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A concise description of what you expected to happen along with screenshots if applicable.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: To Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. In this environment...
|
||||
2. With this config...
|
||||
3. Run '...'
|
||||
4. See error...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: A concise description of what you expected to happen.
|
||||
- type: dropdown
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: 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).
|
||||
options:
|
||||
- app.chatwoot.com
|
||||
- Linux VM
|
||||
- Docker
|
||||
- Kubernetes
|
||||
- Heroku
|
||||
- Other [please specify in the description]
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: provider
|
||||
attributes:
|
||||
label: Cloud Provider
|
||||
description:
|
||||
options:
|
||||
- AWS
|
||||
- GCP
|
||||
- Azure
|
||||
- DigitalOcean
|
||||
- Other [please specify in the description]
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Describe the platform you are using
|
||||
options:
|
||||
- Browser
|
||||
- Mobile
|
||||
- type: input
|
||||
attributes:
|
||||
label: Operating system
|
||||
description: The operating system and the version you are using.
|
||||
- type: input
|
||||
attributes:
|
||||
label: Browser and version
|
||||
description: The name of the browser and version you are using.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Docker (if applicable)
|
||||
description: |
|
||||
Please share the output of the following.
|
||||
- `docker version`
|
||||
- `docker info`
|
||||
- `docker-compose version`
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the problem here.
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Report a security issue
|
||||
url: https://www.chatwoot.com/docs/contributing-guide/security-reports/
|
||||
about: Guidelines and steps to report a security vulnerability. Please report security vulnerabilities here.
|
||||
- name: Product Documentation
|
||||
url: https://www.chatwoot.com/help-center
|
||||
about: If you have questions, are confused, or just want to understand our product better, please check out our documentation.
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Enhancement request
|
||||
about: Suggest any enhancements for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your enhancement request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the enhancement request here.
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,28 @@
|
||||
name: 🧙 Feature request
|
||||
description: Suggest an idea for this project
|
||||
labels: 'feature-request'
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Is your feature or enhancement related to a problem? Please describe.
|
||||
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
+10
-2
@@ -61,8 +61,16 @@ test/cypress/videos/*
|
||||
/config/master.key
|
||||
/config/*.enc
|
||||
|
||||
.vscode/settings.json
|
||||
#ignore files under .vscode directory
|
||||
.vscode
|
||||
|
||||
# yalc for local testing
|
||||
.yalc
|
||||
yalc.lock
|
||||
yalc.lock
|
||||
|
||||
/public/packs
|
||||
/public/packs-test
|
||||
/node_modules
|
||||
/yarn-error.log
|
||||
yarn-debug.log*
|
||||
.yarn-integrity
|
||||
|
||||
@@ -71,6 +71,10 @@ Rails/ApplicationController:
|
||||
- 'app/controllers/platform_controller.rb'
|
||||
- 'app/controllers/public_controller.rb'
|
||||
- 'app/controllers/survey/responses_controller.rb'
|
||||
Rails/FindEach:
|
||||
Enabled: true
|
||||
Include:
|
||||
- 'app/**/*.rb'
|
||||
Rails/CompactBlank:
|
||||
Enabled: false
|
||||
Rails/EnvironmentVariableAccess:
|
||||
@@ -160,6 +164,39 @@ Performance/CollectionLiteralInLoop:
|
||||
- 'db/migrate/20210315101919_enable_email_channel.rb'
|
||||
RSpec/NamedSubject:
|
||||
Enabled: false
|
||||
Style/RedundantConstantBase:
|
||||
Enabled: false
|
||||
Rails/RootPathnameMethods:
|
||||
Enabled: false
|
||||
RSpec/Rails/MinitestAssertions:
|
||||
Enabled: false
|
||||
RSpec/Rails/InferredSpecType:
|
||||
Enabled: false
|
||||
RSpec/IndexedLet:
|
||||
Enabled: false
|
||||
RSpec/MatchArray:
|
||||
Enabled: false
|
||||
Rails/ResponseParsedBody:
|
||||
Enabled: false
|
||||
RSpec/FactoryBot/ConsistentParenthesesStyle:
|
||||
Enabled: false
|
||||
Rails/ThreeStateBooleanColumn:
|
||||
Enabled: false
|
||||
Rails/Pluck:
|
||||
Enabled: false
|
||||
Rails/TopLevelHashWithIndifferentAccess:
|
||||
Enabled: false
|
||||
Rails/ActionOrder:
|
||||
Enabled: false
|
||||
Style/ArrayIntersect:
|
||||
Enabled: false
|
||||
RSpec/NoExpectationExample:
|
||||
Enabled: false
|
||||
Style/RedundantReturn:
|
||||
Enabled: false
|
||||
Rails/I18nLocaleTexts:
|
||||
Enabled: false
|
||||
|
||||
# we should bring this down
|
||||
RSpec/MultipleMemoizedHelpers:
|
||||
Max: 14
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.1.3
|
||||
3.2.2
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/spec
|
||||
@@ -1,10 +1,10 @@
|
||||
source 'https://rubygems.org'
|
||||
|
||||
ruby '3.1.3'
|
||||
ruby '3.2.2'
|
||||
|
||||
##-- base gems for rails --##
|
||||
gem 'rack-cors', require: 'rack/cors'
|
||||
gem 'rails', '~> 6.1', '>= 6.1.7.3'
|
||||
gem 'rails', '~> 7'
|
||||
# Reduces boot times through caching; required in config/boot.rb
|
||||
gem 'bootsnap', require: false
|
||||
|
||||
@@ -36,19 +36,19 @@ gem 'json_schemer'
|
||||
# Rack middleware for blocking & throttling abusive requests
|
||||
gem 'rack-attack'
|
||||
# a utility tool for streaming, flexible and safe downloading of remote files
|
||||
gem 'down', '~> 5.0'
|
||||
gem 'down'
|
||||
# authentication type to fetch and send mail over oauth2.0
|
||||
gem 'gmail_xoauth'
|
||||
# Prevent CSV injection
|
||||
gem 'csv-safe'
|
||||
# Support message translation
|
||||
gem 'google-cloud-translate'
|
||||
|
||||
##-- for active storage --##
|
||||
gem 'aws-sdk-s3', require: false
|
||||
gem 'azure-storage-blob', require: false
|
||||
# original gem isn't maintained actively
|
||||
# we wanted updated version of faraday which is a dependency for slack-ruby-client
|
||||
gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false
|
||||
gem 'google-cloud-storage', require: false
|
||||
gem 'image_processing', '~> 1.12.2'
|
||||
gem 'image_processing'
|
||||
|
||||
##-- gems for database --#
|
||||
gem 'groupdate'
|
||||
@@ -62,13 +62,13 @@ gem 'activerecord-import'
|
||||
gem 'dotenv-rails'
|
||||
gem 'foreman'
|
||||
gem 'puma'
|
||||
gem 'webpacker', '~> 5.4', '>= 5.4.3'
|
||||
gem 'webpacker'
|
||||
# metrics on heroku
|
||||
gem 'barnes'
|
||||
|
||||
##--- gems for authentication & authorization ---##
|
||||
gem 'devise'
|
||||
gem 'devise-secure_password', '~> 2.0', git: 'https://github.com/chatwoot/devise-secure_password'
|
||||
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
|
||||
gem 'devise_token_auth'
|
||||
# authorization
|
||||
gem 'jwt'
|
||||
@@ -81,19 +81,23 @@ gem 'administrate'
|
||||
gem 'wisper', '2.0.0'
|
||||
|
||||
##--- gems for channels ---##
|
||||
# TODO: bump up gem to 2.0
|
||||
gem 'facebook-messenger'
|
||||
gem 'line-bot-api'
|
||||
gem 'twilio-ruby', '~> 5.66'
|
||||
# twitty will handle subscription of twitter account events
|
||||
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
|
||||
gem 'twitty'
|
||||
gem 'twitty', '~> 0.1.5'
|
||||
# facebook client
|
||||
gem 'koala'
|
||||
# slack client
|
||||
gem 'slack-ruby-client'
|
||||
gem 'slack-ruby-client', '~> 2.0.0'
|
||||
# for dialogflow integrations
|
||||
gem 'google-cloud-dialogflow'
|
||||
gem 'google-cloud-dialogflow-v2'
|
||||
gem 'grpc'
|
||||
# Translate integrations
|
||||
# 'google-cloud-translate' gem depends on faraday 2.0 version
|
||||
# this dependency breaks the slack-ruby-client gem
|
||||
gem 'google-cloud-translate-v3'
|
||||
|
||||
##-- apm and error monitoring ---#
|
||||
# loaded only when environment variables are set.
|
||||
@@ -108,9 +112,9 @@ gem 'sentry-ruby', require: false
|
||||
gem 'sentry-sidekiq', require: false
|
||||
|
||||
##-- background job processing --##
|
||||
gem 'sidekiq', '~> 6.4.2'
|
||||
gem 'sidekiq'
|
||||
# We want cron jobs
|
||||
gem 'sidekiq-cron', '~> 1.6', '>= 1.6.0'
|
||||
gem 'sidekiq-cron'
|
||||
|
||||
##-- Push notification service --##
|
||||
gem 'fcm'
|
||||
@@ -129,7 +133,10 @@ gem 'procore-sift'
|
||||
|
||||
# parse email
|
||||
gem 'email_reply_trimmer'
|
||||
gem 'html2text'
|
||||
|
||||
# TODO: we might have to fork this gem since 0.3.1 has hard depency on nokogir 1.10.
|
||||
# and this gem hasn't been updated for a while.
|
||||
gem 'html2text', git: 'https://github.com/chatwoot/html2text_ruby', branch: 'chatwoot'
|
||||
|
||||
# to calculate working hours
|
||||
gem 'working_hours'
|
||||
@@ -144,12 +151,23 @@ gem 'stripe'
|
||||
## to populate db with sample data
|
||||
gem 'faker'
|
||||
|
||||
# Can remove this in rails 7
|
||||
gem 'net-imap', require: false
|
||||
gem 'net-pop', require: false
|
||||
gem 'net-smtp', require: false
|
||||
# Include logrange conditionally in intializer using env variable
|
||||
gem 'lograge', '~> 0.12.0', require: false
|
||||
|
||||
group :production, :staging do
|
||||
# worked with microsoft refresh token
|
||||
gem 'omniauth-oauth2'
|
||||
|
||||
gem 'audited', '~> 5.3'
|
||||
|
||||
# need for google auth
|
||||
gem 'omniauth'
|
||||
gem 'omniauth-google-oauth2'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 1.0'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
##############################################################
|
||||
|
||||
group :production do
|
||||
# we dont want request timing out in development while using byebug
|
||||
gem 'rack-timeout'
|
||||
end
|
||||
@@ -158,6 +176,7 @@ group :development do
|
||||
gem 'annotate'
|
||||
gem 'bullet'
|
||||
gem 'letter_opener'
|
||||
gem 'scss_lint', require: false
|
||||
gem 'web-console'
|
||||
|
||||
# used in swagger build
|
||||
@@ -165,11 +184,15 @@ group :development do
|
||||
|
||||
# When we want to squash migrations
|
||||
gem 'squasher'
|
||||
|
||||
# profiling
|
||||
gem 'rack-mini-profiler', require: false
|
||||
gem 'stackprof'
|
||||
end
|
||||
|
||||
group :test do
|
||||
# Cypress in rails.
|
||||
gem 'cypress-on-rails', '~> 1.13', '>= 1.13.1'
|
||||
gem 'cypress-on-rails'
|
||||
# fast cleaning of database
|
||||
gem 'database_cleaner'
|
||||
# mock http calls
|
||||
@@ -191,7 +214,7 @@ group :development, :test do
|
||||
gem 'mock_redis'
|
||||
gem 'pry-rails'
|
||||
gem 'rspec_junit_formatter'
|
||||
gem 'rspec-rails', '~> 5.0.3'
|
||||
gem 'rspec-rails'
|
||||
gem 'rubocop', require: false
|
||||
gem 'rubocop-performance', require: false
|
||||
gem 'rubocop-rails', require: false
|
||||
@@ -202,13 +225,3 @@ group :development, :test do
|
||||
gem 'spring'
|
||||
gem 'spring-watcher-listen'
|
||||
end
|
||||
|
||||
# worked with microsoft refresh token
|
||||
gem 'omniauth-oauth2'
|
||||
|
||||
gem 'audited', '~> 5.2'
|
||||
|
||||
# need for google auth
|
||||
gem 'omniauth'
|
||||
gem 'omniauth-google-oauth2'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 1.0'
|
||||
|
||||
+358
-328
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
# Variables
|
||||
APP_NAME := chatwoot
|
||||
RAILS_ENV ?= development
|
||||
|
||||
# Targets
|
||||
setup:
|
||||
gem install bundler
|
||||
bundle install
|
||||
yarn install
|
||||
|
||||
db_create:
|
||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:create
|
||||
|
||||
db_migrate:
|
||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:migrate
|
||||
|
||||
db_seed:
|
||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:seed
|
||||
|
||||
db:
|
||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:chatwoot_prepare
|
||||
|
||||
console:
|
||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails console
|
||||
|
||||
server:
|
||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails server -b 0.0.0.0 -p 3000
|
||||
|
||||
burn:
|
||||
bundle && yarn
|
||||
|
||||
run:
|
||||
overmind start -f Procfile.dev
|
||||
|
||||
docker:
|
||||
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
|
||||
|
||||
.PHONY: setup db_create db_migrate db_seed db console server burn docker run
|
||||
@@ -1,3 +1,3 @@
|
||||
release: POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rails db:chatwoot_prepare
|
||||
release: POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rails db:chatwoot_prepare && echo $SOURCE_VERSION > .git_sha
|
||||
web: bundle exec rails ip_lookup:setup && bin/rails server -p $PORT -e $RAILS_ENV
|
||||
worker: bundle exec rails ip_lookup:setup && bundle exec sidekiq -C config/sidekiq.yml
|
||||
|
||||
@@ -63,28 +63,42 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
|
||||
@conversation ||= Conversation.where(
|
||||
"additional_attributes ->> 'type' = 'instagram_direct_message'"
|
||||
).find_by(conversation_params) || build_conversation
|
||||
end
|
||||
|
||||
def message_content
|
||||
@messaging[:message][:text]
|
||||
end
|
||||
|
||||
def story_reply_attributes
|
||||
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
|
||||
end
|
||||
|
||||
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)
|
||||
save_story_id
|
||||
|
||||
attachments.each do |attachment|
|
||||
process_attachment(attachment)
|
||||
end
|
||||
end
|
||||
|
||||
def save_story_id
|
||||
return if story_reply_attributes.blank?
|
||||
|
||||
@message.save_story_info(story_reply_attributes)
|
||||
end
|
||||
|
||||
def build_conversation
|
||||
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
|
||||
Conversation.create!(conversation_params.merge(
|
||||
contact_inbox_id: @contact_inbox.id
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: { type: 'instagram_direct_message' }
|
||||
))
|
||||
end
|
||||
|
||||
@@ -92,10 +106,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
{
|
||||
account_id: @inbox.account_id,
|
||||
inbox_id: @inbox.id,
|
||||
contact_id: contact.id,
|
||||
additional_attributes: {
|
||||
type: 'instagram_direct_message'
|
||||
}
|
||||
contact_id: contact.id
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -49,10 +49,10 @@ class Messages::MessageBuilder
|
||||
return unless @conversation.inbox&.inbox_type == 'Email'
|
||||
|
||||
cc_emails = []
|
||||
cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails].present?
|
||||
cc_emails = @params[:cc_emails].gsub(/\s+/, '').split(',') if @params[:cc_emails].present?
|
||||
|
||||
bcc_emails = []
|
||||
bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails].present?
|
||||
bcc_emails = @params[:bcc_emails].gsub(/\s+/, '').split(',') if @params[:bcc_emails].present?
|
||||
|
||||
all_email_addresses = cc_emails + bcc_emails
|
||||
validate_email_addresses(all_email_addresses)
|
||||
|
||||
@@ -19,6 +19,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def destroy
|
||||
@agent.current_account_user.destroy!
|
||||
delete_user_record(@agent)
|
||||
head :ok
|
||||
end
|
||||
|
||||
@@ -74,4 +75,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
def validate_limit
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents]
|
||||
end
|
||||
|
||||
def delete_user_record(agent)
|
||||
DeleteObjectJob.perform_later(agent) if agent.reload.account_users.blank?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
before_action :check_authorization
|
||||
before_action :fetch_article, except: [:index, :create, :attach_file]
|
||||
before_action :fetch_article, except: [:index, :create, :attach_file, :reorder]
|
||||
before_action :set_current_page, only: [:index]
|
||||
|
||||
def index
|
||||
@portal_articles = @portal.articles
|
||||
@all_articles = @portal_articles.search(list_params)
|
||||
@articles_count = @all_articles.count
|
||||
@articles = @all_articles.order_by_updated_at.page(@current_page)
|
||||
|
||||
@articles = if list_params[:category_slug].present?
|
||||
@all_articles.order_by_position.page(@current_page).per(50)
|
||||
else
|
||||
@all_articles.order_by_updated_at.page(@current_page)
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -43,6 +48,11 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
render json: { file_url: url_for(file_blob) }
|
||||
end
|
||||
|
||||
def reorder
|
||||
Article.update_positions(params[:positions_hash])
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_article
|
||||
|
||||
@@ -119,11 +119,12 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def fetch_contacts_with_conversation_count(contacts)
|
||||
contacts_with_conversation_count = filtrate(contacts).left_outer_joins(:conversations)
|
||||
.select('contacts.*, COUNT(conversations.id) as conversations_count')
|
||||
.group('contacts.id')
|
||||
.includes([{ avatar_attachment: [:blob] }])
|
||||
.page(@current_page).per(RESULTS_PER_PAGE)
|
||||
conversation_count_sub_query = 'SELECT COUNT(*) FROM "conversations" WHERE "conversations"."contact_id" = "contacts"."id"'
|
||||
contacts_with_conversation_count = filtrate(contacts)
|
||||
.select("contacts.*, (#{conversation_count_sub_query}) as conversations_count")
|
||||
.group('contacts.id')
|
||||
.includes([{ avatar_attachment: [:blob] }])
|
||||
.page(@current_page).per(RESULTS_PER_PAGE)
|
||||
|
||||
return contacts_with_conversation_count.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
@conversations_count = result[:count]
|
||||
end
|
||||
|
||||
def attachments
|
||||
@attachments = @conversation.attachments
|
||||
end
|
||||
|
||||
def create
|
||||
ActiveRecord::Base.transaction do
|
||||
@conversation = ConversationBuilder.new(params: params, contact_inbox: @contact_inbox).perform
|
||||
@@ -64,13 +68,14 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
assign_conversation if @conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
|
||||
end
|
||||
|
||||
def toggle_priority
|
||||
@conversation.toggle_priority(params[:priority])
|
||||
head :ok
|
||||
end
|
||||
|
||||
def toggle_typing_status
|
||||
case params[:typing_status]
|
||||
when 'on'
|
||||
trigger_typing_event(CONVERSATION_TYPING_ON, params[:is_private])
|
||||
when 'off'
|
||||
trigger_typing_event(CONVERSATION_TYPING_OFF, params[:is_private])
|
||||
end
|
||||
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params)
|
||||
typing_status_manager.toggle_typing_status
|
||||
head :ok
|
||||
end
|
||||
|
||||
@@ -111,11 +116,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
@conversation.update_assignee(@agent)
|
||||
end
|
||||
|
||||
def trigger_typing_event(event, is_private)
|
||||
user = current_user.presence || @resource
|
||||
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user, is_private: is_private)
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Current.account.conversations.find_by!(display_id: params[:id])
|
||||
authorize @conversation.inbox, :show?
|
||||
|
||||
@@ -82,8 +82,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@inbox.destroy!
|
||||
head :ok
|
||||
::DeleteObjectJob.perform_later(@inbox) if @inbox.present?
|
||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||
end
|
||||
|
||||
private
|
||||
@@ -114,10 +114,13 @@ 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,
|
||||
:lock_to_single_conversation]
|
||||
:lock_to_single_conversation, :portal_id]
|
||||
end
|
||||
|
||||
def permitted_params(channel_attributes = [])
|
||||
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
|
||||
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
|
||||
|
||||
params.permit(
|
||||
*inbox_attributes,
|
||||
channel: [:type, *channel_attributes]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_hook, only: [:update, :destroy]
|
||||
before_action :fetch_hook, except: [:create]
|
||||
before_action :check_authorization
|
||||
|
||||
def create
|
||||
@@ -10,6 +10,10 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
|
||||
@hook.update!(permitted_params.slice(:status, :settings))
|
||||
end
|
||||
|
||||
def process_event
|
||||
render json: { message: @hook.process_event(params[:event]) }
|
||||
end
|
||||
|
||||
def destroy
|
||||
@hook.destroy!
|
||||
head :ok
|
||||
|
||||
@@ -7,11 +7,12 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
|
||||
redirect_url = microsoft_client.auth_code.authorize_url(
|
||||
{
|
||||
redirect_uri: "#{base_url}/microsoft/callback",
|
||||
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid',
|
||||
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile',
|
||||
prompt: 'consent'
|
||||
}
|
||||
)
|
||||
if redirect_url
|
||||
email = email.downcase
|
||||
::Redis::Alfred.setex(email, Current.account.id, 5.minutes)
|
||||
render json: { success: true, url: redirect_url }
|
||||
else
|
||||
|
||||
@@ -30,7 +30,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
ActiveRecord::Base.transaction do
|
||||
@portal.update!(portal_params) if params[:portal].present?
|
||||
# @portal.custom_domain = parsed_custom_domain
|
||||
process_attached_logo
|
||||
process_attached_logo if params[:blob_id].present?
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Api::V1::AccountsController < Api::BaseController
|
||||
include AuthHelper
|
||||
include CacheKeysHelper
|
||||
|
||||
skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception,
|
||||
only: [:create], raise: false
|
||||
@@ -30,6 +31,10 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
end
|
||||
|
||||
def cache_keys
|
||||
render json: { cache_keys: get_cache_keys }, status: :ok
|
||||
end
|
||||
|
||||
def show
|
||||
@latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION)
|
||||
render 'api/v1/accounts/show', format: :json
|
||||
@@ -47,6 +52,14 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
|
||||
private
|
||||
|
||||
def get_cache_keys
|
||||
{
|
||||
label: fetch_value_for_key(params[:id], Label.name.underscore),
|
||||
inbox: fetch_value_for_key(params[:id], Inbox.name.underscore),
|
||||
team: fetch_value_for_key(params[:id], Team.name.underscore)
|
||||
}
|
||||
end
|
||||
|
||||
def fetch_account
|
||||
@account = current_user.accounts.find(params[:id])
|
||||
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
|
||||
|
||||
@@ -65,6 +65,16 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def set_custom_attributes
|
||||
conversation.update!(custom_attributes: permitted_params[:custom_attributes])
|
||||
end
|
||||
|
||||
def destroy_custom_attributes
|
||||
conversation.custom_attributes = conversation.custom_attributes.excluding(params[:custom_attribute])
|
||||
conversation.save!
|
||||
render json: conversation
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def trigger_typing_event(event)
|
||||
|
||||
@@ -48,7 +48,8 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
def message_finder_params
|
||||
{
|
||||
filter_internal_messages: true,
|
||||
before: permitted_params[:before]
|
||||
before: permitted_params[:before],
|
||||
after: permitted_params[:after]
|
||||
}
|
||||
end
|
||||
|
||||
@@ -62,7 +63,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
|
||||
def permitted_params
|
||||
# timestamp parameter is used in create conversation method
|
||||
params.permit(:id, :before, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
|
||||
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
|
||||
end
|
||||
|
||||
def set_message
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
include Api::V2::Accounts::ReportsHelper
|
||||
include Api::V2::Accounts::HeatmapHelper
|
||||
|
||||
before_action :check_authorization
|
||||
|
||||
def index
|
||||
@@ -32,6 +34,14 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
|
||||
end
|
||||
|
||||
def conversation_traffic
|
||||
@report_data = generate_conversations_heatmap_report
|
||||
timezone_offset = (params[:timezone_offset] || 0).to_f
|
||||
@timezone = ActiveSupport::TimeZone[timezone_offset]
|
||||
|
||||
generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic')
|
||||
end
|
||||
|
||||
def conversations
|
||||
return head :unprocessable_entity if params[:type].blank?
|
||||
|
||||
@@ -62,14 +72,16 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
def current_summary_params
|
||||
common_params.merge({
|
||||
since: range[:current][:since],
|
||||
until: range[:current][:until]
|
||||
until: range[:current][:until],
|
||||
timezone_offset: params[:timezone_offset]
|
||||
})
|
||||
end
|
||||
|
||||
def previous_summary_params
|
||||
common_params.merge({
|
||||
since: range[:previous][:since],
|
||||
until: range[:previous][:until]
|
||||
until: range[:previous][:until],
|
||||
timezone_offset: params[:timezone_offset]
|
||||
})
|
||||
end
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ class ApiController < ApplicationController
|
||||
|
||||
def index
|
||||
render json: { version: Chatwoot.config[:version],
|
||||
timestamp: Time.now.utc.to_formatted_s(:db),
|
||||
timestamp: Time.now.utc.to_fs(:db),
|
||||
queue_services: redis_status,
|
||||
data_services: postgres_status }
|
||||
end
|
||||
|
||||
@@ -56,7 +56,8 @@ class DashboardController < ActionController::Base
|
||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||
FACEBOOK_API_VERSION: 'v14.0',
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||
AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', '')
|
||||
AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', ''),
|
||||
GIT_SHA: GIT_HASH
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,7 +8,7 @@ class Microsoft::CallbacksController < ApplicationController
|
||||
)
|
||||
|
||||
inbox = find_or_create_inbox
|
||||
::Redis::Alfred.delete(users_data['email'])
|
||||
::Redis::Alfred.delete(users_data['email'].downcase)
|
||||
redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
@@ -31,7 +31,7 @@ class Microsoft::CallbacksController < ApplicationController
|
||||
end
|
||||
|
||||
def account_id
|
||||
::Redis::Alfred.get(users_data['email'])
|
||||
::Redis::Alfred.get(users_data['email'].downcase)
|
||||
end
|
||||
|
||||
def account
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||
before_action :portal
|
||||
before_action :set_category, except: [:index]
|
||||
before_action :set_category, except: [:index, :show]
|
||||
before_action :set_article, only: [:show]
|
||||
layout 'portal'
|
||||
|
||||
@@ -16,7 +16,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
private
|
||||
|
||||
def set_article
|
||||
@article = @category.articles.find(permitted_params[:id])
|
||||
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
||||
@article.increment_view_count
|
||||
@parsed_content = render_article_content(@article.content)
|
||||
end
|
||||
@@ -39,7 +39,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:slug, :category_slug, :locale, :id)
|
||||
params.permit(:slug, :category_slug, :locale, :id, :article_slug)
|
||||
end
|
||||
|
||||
def render_article_content(content)
|
||||
|
||||
@@ -5,6 +5,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
|
||||
def set_locale(&)
|
||||
switch_locale_with_portal(&) if params[:locale].present?
|
||||
switch_locale_with_article(&) if params[:article_slug].present?
|
||||
end
|
||||
|
||||
def switch_locale_with_portal(&)
|
||||
@@ -19,4 +20,16 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
|
||||
I18n.with_locale(@locale, &)
|
||||
end
|
||||
|
||||
def switch_locale_with_article(&)
|
||||
article = Article.find_by(slug: params[:article_slug])
|
||||
|
||||
@locale = if article.category.present?
|
||||
article.category.locale
|
||||
else
|
||||
'en'
|
||||
end
|
||||
|
||||
I18n.with_locale(@locale, &)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -47,4 +47,11 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
|
||||
Internal::SeedAccountJob.perform_later(requested_resource)
|
||||
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account seeding triggered')
|
||||
end
|
||||
|
||||
def destroy
|
||||
account = Account.find(params[:id])
|
||||
|
||||
DeleteObjectJob.perform_later(account) if account.present?
|
||||
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account deletion is in progress.')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
class SuperAdmin::UsersController < SuperAdmin::ApplicationController
|
||||
# Overwrite any of the RESTful controller actions to implement custom behavior
|
||||
# For example, you may want to send an email after a foo is updated.
|
||||
|
||||
def create
|
||||
resource = resource_class.new(resource_params)
|
||||
authorize_resource(resource)
|
||||
|
||||
if resource.save
|
||||
redirect_to super_admin_user_path(resource), notice: translate_with_resource('create.success')
|
||||
else
|
||||
notice = resource.errors.full_messages.first
|
||||
redirect_to new_super_admin_user_path, notice: notice
|
||||
end
|
||||
end
|
||||
#
|
||||
# def update
|
||||
# super
|
||||
|
||||
@@ -14,7 +14,7 @@ class Twitter::CallbacksController < Twitter::BaseController
|
||||
redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
redirect_to twitter_app_redirect_url
|
||||
end
|
||||
|
||||
@@ -49,10 +49,22 @@ class Twitter::CallbacksController < Twitter::BaseController
|
||||
twitter_access_token_secret: parsed_body['oauth_token_secret'],
|
||||
profile_id: parsed_body['user_id']
|
||||
)
|
||||
account.inboxes.create!(
|
||||
inbox = account.inboxes.create!(
|
||||
name: parsed_body['screen_name'],
|
||||
channel: twitter_profile
|
||||
)
|
||||
save_profile_image(inbox)
|
||||
inbox
|
||||
end
|
||||
|
||||
def save_profile_image(inbox)
|
||||
response = twitter_client.user_show(screen_name: inbox.name)
|
||||
|
||||
return unless response.status.to_i == 200
|
||||
|
||||
parsed_user_profile = response.body
|
||||
|
||||
::Avatar::AvatarFromUrlJob.perform_later(inbox, parsed_user_profile['profile_image_url_https'])
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
||||
@@ -41,7 +41,7 @@ class WidgetsController < ActionController::Base
|
||||
source_id: @auth_token_params[:source_id]
|
||||
)
|
||||
|
||||
@contact = @contact_inbox ? @contact_inbox.contact : nil
|
||||
@contact = @contact_inbox&.contact
|
||||
end
|
||||
|
||||
def build_contact
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
class AccountDrop < BaseDrop
|
||||
def name
|
||||
@obj.try(:name)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
class ContactDrop < BaseDrop
|
||||
def name
|
||||
@obj.try(:name).try(:split).try(:map, &:capitalize).try(:join, ' ')
|
||||
end
|
||||
|
||||
def email
|
||||
@obj.try(:email)
|
||||
end
|
||||
@@ -8,10 +12,10 @@ class ContactDrop < BaseDrop
|
||||
end
|
||||
|
||||
def first_name
|
||||
@obj.try(:name).try(:split).try(:first)
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
|
||||
end
|
||||
|
||||
def last_name
|
||||
@obj.try(:name).try(:split).try(:last) if @obj.try(:name).try(:split).try(:size) > 1
|
||||
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
class UserDrop < BaseDrop
|
||||
def name
|
||||
@obj.try(:name).try(:split).try(:map, &:capitalize).try(:join, ' ')
|
||||
end
|
||||
|
||||
def available_name
|
||||
@obj.try(:available_name)
|
||||
end
|
||||
|
||||
def first_name
|
||||
@obj.try(:name).try(:split).try(:first)
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
|
||||
end
|
||||
|
||||
def last_name
|
||||
@obj.try(:name).try(:split).try(:last) if @obj.try(:name).try(:split).try(:size) > 1
|
||||
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,7 +5,8 @@ class ConversationFinder
|
||||
SORT_OPTIONS = {
|
||||
latest: 'latest',
|
||||
sort_on_created_at: 'sort_on_created_at',
|
||||
last_user_message_at: 'last_user_message_at'
|
||||
last_user_message_at: 'last_user_message_at',
|
||||
sort_on_priority: 'sort_on_priority'
|
||||
}.with_indifferent_access
|
||||
|
||||
# assumptions
|
||||
@@ -53,9 +54,10 @@ class ConversationFinder
|
||||
|
||||
find_all_conversations
|
||||
filter_by_status unless params[:q]
|
||||
filter_by_team if @team
|
||||
filter_by_labels if params[:labels]
|
||||
filter_by_query if params[:q]
|
||||
filter_by_team
|
||||
filter_by_labels
|
||||
filter_by_query
|
||||
filter_by_source_id
|
||||
end
|
||||
|
||||
def set_inboxes
|
||||
@@ -106,6 +108,8 @@ class ConversationFinder
|
||||
end
|
||||
|
||||
def filter_by_query
|
||||
return unless params[:q]
|
||||
|
||||
allowed_message_types = [Message.message_types[:incoming], Message.message_types[:outgoing]]
|
||||
@conversations = conversations.joins(:messages).where('messages.content ILIKE :search', search: "%#{params[:q]}%")
|
||||
.where(messages: { message_type: allowed_message_types }).includes(:messages)
|
||||
@@ -120,13 +124,24 @@ class ConversationFinder
|
||||
end
|
||||
|
||||
def filter_by_team
|
||||
return unless @team
|
||||
|
||||
@conversations = @conversations.where(team: @team)
|
||||
end
|
||||
|
||||
def filter_by_labels
|
||||
return unless params[:labels]
|
||||
|
||||
@conversations = @conversations.tagged_with(params[:labels], any: true)
|
||||
end
|
||||
|
||||
def filter_by_source_id
|
||||
return unless params[:source_id]
|
||||
|
||||
@conversations = @conversations.joins(:contact_inbox)
|
||||
@conversations = @conversations.where(contact_inboxes: { source_id: params[:source_id] })
|
||||
end
|
||||
|
||||
def set_count_for_all_conversations
|
||||
[
|
||||
@conversations.assigned_to(current_user).count,
|
||||
|
||||
@@ -7,12 +7,18 @@ class EmailChannelFinder
|
||||
|
||||
def perform
|
||||
channel = nil
|
||||
recipient_mails = @email_object.to.to_a + @email_object.cc.to_a
|
||||
|
||||
recipient_mails.each do |email|
|
||||
normalized_email = normalize_email_with_plus_addressing(email)
|
||||
channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email)
|
||||
|
||||
break if channel.present?
|
||||
end
|
||||
channel
|
||||
end
|
||||
|
||||
def recipient_mails
|
||||
recipient_addresses = @email_object.to.to_a + @email_object.cc.to_a + @email_object.bcc.to_a + [@email_object['X-Original-To'].try(:value)]
|
||||
recipient_addresses.flatten.compact
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,12 +21,30 @@ class MessageFinder
|
||||
end
|
||||
|
||||
def current_messages
|
||||
if @params[:after].present?
|
||||
messages.reorder('created_at asc').where('id >= ?', @params[:before].to_i).limit(20)
|
||||
if @params[:after].present? && @params[:before].present?
|
||||
messages_between(@params[:after].to_i, @params[:before].to_i)
|
||||
elsif @params[:before].present?
|
||||
messages.reorder('created_at desc').where('id < ?', @params[:before].to_i).limit(20).reverse
|
||||
messages_before(@params[:before].to_i)
|
||||
elsif @params[:after].present?
|
||||
messages_after(@params[:after].to_i)
|
||||
else
|
||||
messages.reorder('created_at desc').limit(20).reverse
|
||||
messages_latest
|
||||
end
|
||||
end
|
||||
|
||||
def messages_after(after_id)
|
||||
messages.reorder('created_at asc').where('id > ?', after_id).limit(100)
|
||||
end
|
||||
|
||||
def messages_before(before_id)
|
||||
messages.reorder('created_at desc').where('id < ?', before_id).limit(20).reverse
|
||||
end
|
||||
|
||||
def messages_between(after_id, before_id)
|
||||
messages.reorder('created_at asc').where('id >= ? AND id < ?', after_id, before_id).limit(1000)
|
||||
end
|
||||
|
||||
def messages_latest
|
||||
messages.reorder('created_at desc').limit(20).reverse
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
module Api::V2::Accounts::HeatmapHelper
|
||||
def generate_conversations_heatmap_report
|
||||
timezone_data = generate_heatmap_data_for_timezone(params[:timezone_offset])
|
||||
|
||||
group_traffic_data(timezone_data)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def group_traffic_data(data)
|
||||
# start with an empty array
|
||||
result_arr = []
|
||||
|
||||
# pick all the unique dates from the data in ascending order
|
||||
dates = data.pluck(:date).uniq.sort
|
||||
|
||||
# add the dates as the first row, leave an empty cell for the hour column
|
||||
# e.g. [nil, '2023-01-01', '2023-1-02', '2023-01-03']
|
||||
result_arr << ([nil] + dates)
|
||||
|
||||
# group the data by hour, we do not need to sort it, because the data is already sorted
|
||||
# given it starts from the beginning of the day
|
||||
# here each hour is a key, and the value is an array of all the items for that hour at each date
|
||||
# e.g. hour = 1
|
||||
# value = [{date: 2023-01-01, value: 1}, {date: 2023-01-02, value: 1}, {date: 2023-01-03, value: 1}, ...]
|
||||
data.group_by { |d| d[:hour] }.each do |hour, items|
|
||||
# create a new row for each hour
|
||||
row = [hour]
|
||||
|
||||
# group the items by date, so we can easily access the value for each date
|
||||
# grouped values will be a hasg with the date as the key, and the value as the value
|
||||
# e.g. { '2023-01-01' => [{date: 2023-01-01, value: 1}], '2023-01-02' => [{date: 2023-01-02, value: 1}], ... }
|
||||
grouped_values = items.group_by { |d| d[:date] }
|
||||
|
||||
# now for each unique date we have, we can access the value for that date and append it to the array
|
||||
dates.each do |date|
|
||||
row << (grouped_values[date][0][:value] if grouped_values[date].is_a?(Array))
|
||||
end
|
||||
|
||||
# row will look like [22, 0, 0, 1, 4, 6, 7, 4]
|
||||
# add the row to the result array
|
||||
|
||||
result_arr << row
|
||||
end
|
||||
|
||||
# return the resultant array
|
||||
# the result looks like this
|
||||
# [
|
||||
# [nil, '2023-01-01', '2023-1-02', '2023-01-03'],
|
||||
# [0, 0, 0, 0],
|
||||
# [1, 0, 0, 0],
|
||||
# [2, 0, 0, 0],
|
||||
# [3, 0, 0, 0],
|
||||
# [4, 0, 0, 0],
|
||||
# ]
|
||||
result_arr
|
||||
end
|
||||
|
||||
def generate_heatmap_data_for_timezone(offset)
|
||||
timezone = ActiveSupport::TimeZone[offset]&.name
|
||||
timezone_today = DateTime.now.in_time_zone(timezone).beginning_of_day
|
||||
|
||||
timezone_data_raw = generate_heatmap_data(timezone_today, offset)
|
||||
|
||||
transform_data(timezone_data_raw, false)
|
||||
end
|
||||
|
||||
def generate_heatmap_data(date, offset)
|
||||
report_params = {
|
||||
type: :account,
|
||||
group_by: 'hour',
|
||||
metric: 'conversations_count',
|
||||
business_hours: false
|
||||
}
|
||||
|
||||
V2::ReportBuilder.new(Current.account, report_params.merge({
|
||||
since: since_timestamp(date),
|
||||
until: until_timestamp(date),
|
||||
timezone_offset: offset
|
||||
})).build
|
||||
end
|
||||
|
||||
def transform_data(data, zone_transform)
|
||||
# rubocop:disable Rails/TimeZone
|
||||
data.map do |d|
|
||||
date = zone_transform ? Time.zone.at(d[:timestamp]) : Time.at(d[:timestamp])
|
||||
{
|
||||
date: date.to_date.to_s,
|
||||
hour: date.hour,
|
||||
value: d[:value]
|
||||
}
|
||||
end
|
||||
# rubocop:enable Rails/TimeZone
|
||||
end
|
||||
|
||||
def since_timestamp(date)
|
||||
(date - 6.days).to_i.to_s
|
||||
end
|
||||
|
||||
def until_timestamp(date)
|
||||
date.to_i.to_s
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
module CacheKeysHelper
|
||||
def get_prefixed_cache_key(account_id, key)
|
||||
"idb-cache-key-account-#{account_id}-#{key}"
|
||||
end
|
||||
|
||||
def fetch_value_for_key(account_id, key)
|
||||
prefixed_cache_key = get_prefixed_cache_key(account_id, key)
|
||||
value_from_cache = Redis::Alfred.get(prefixed_cache_key)
|
||||
|
||||
return value_from_cache if value_from_cache.present?
|
||||
|
||||
# zero epoch time: 1970-01-01 00:00:00 UTC
|
||||
'0000000000'
|
||||
end
|
||||
end
|
||||
@@ -7,7 +7,7 @@ module DateRangeHelper
|
||||
def range
|
||||
return if params[:since].blank? || params[:until].blank?
|
||||
|
||||
parse_date_time(params[:since])..parse_date_time(params[:until])
|
||||
parse_date_time(params[:since])...parse_date_time(params[:until])
|
||||
end
|
||||
|
||||
def parse_date_time(datetime)
|
||||
|
||||
@@ -10,4 +10,26 @@ module EmailHelper
|
||||
def normalize_email_with_plus_addressing(email)
|
||||
"#{email.split('@').first.split('+').first}@#{email.split('@').last}".downcase
|
||||
end
|
||||
|
||||
def parse_email_variables(conversation, email)
|
||||
case email
|
||||
when modified_liquid_content(email)
|
||||
template = Liquid::Template.parse(modified_liquid_content(email))
|
||||
template.render(message_drops(conversation))
|
||||
when URI::MailTo::EMAIL_REGEXP
|
||||
email
|
||||
end
|
||||
end
|
||||
|
||||
def modified_liquid_content(email)
|
||||
# This regex is used to match the code blocks in the content
|
||||
# We don't want to process liquid in code blocks
|
||||
email.gsub(/`(.*?)`/m, '{% raw %}`\\1`{% endraw %}')
|
||||
end
|
||||
|
||||
def message_drops(conversation)
|
||||
{
|
||||
'contact' => ContactDrop.new(conversation.contact)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -72,13 +72,6 @@ export default {
|
||||
if (!this.hasAccounts) {
|
||||
this.showAddAccountModal = true;
|
||||
}
|
||||
verifyServiceWorkerExistence(registration =>
|
||||
registration.pushManager.getSubscription().then(subscription => {
|
||||
if (subscription) {
|
||||
registerSubscription();
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
currentAccountId() {
|
||||
if (this.currentAccountId) {
|
||||
@@ -107,6 +100,14 @@ export default {
|
||||
this.updateRTLDirectionView(locale);
|
||||
this.latestChatwootVersion = latestChatwootVersion;
|
||||
vueActionCable.init(pubsubToken);
|
||||
|
||||
verifyServiceWorkerExistence(registration =>
|
||||
registration.pushManager.getSubscription().then(subscription => {
|
||||
if (subscription) {
|
||||
registerSubscription();
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,19 @@ class ApiClient {
|
||||
return `${this.baseUrl()}/${this.resource}`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get accountIdFromRoute() {
|
||||
const isInsideAccountScopedURLs = window.location.pathname.includes(
|
||||
'/app/accounts'
|
||||
);
|
||||
|
||||
if (isInsideAccountScopedURLs) {
|
||||
return window.location.pathname.split('/')[3];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
baseUrl() {
|
||||
let url = this.apiVersion;
|
||||
|
||||
@@ -20,15 +33,8 @@ class ApiClient {
|
||||
url = `/enterprise${url}`;
|
||||
}
|
||||
|
||||
if (this.options.accountScoped) {
|
||||
const isInsideAccountScopedURLs = window.location.pathname.includes(
|
||||
'/app/accounts'
|
||||
);
|
||||
|
||||
if (isInsideAccountScopedURLs) {
|
||||
const accountId = window.location.pathname.split('/')[3];
|
||||
url = `${url}/accounts/${accountId}`;
|
||||
}
|
||||
if (this.options.accountScoped && this.accountIdFromRoute) {
|
||||
url = `${url}/accounts/${this.accountIdFromRoute}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/* global axios */
|
||||
import { DataManager } from '../helper/CacheHelper/DataManager';
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class CacheEnabledApiClient extends ApiClient {
|
||||
constructor(resource, options = {}) {
|
||||
super(resource, options);
|
||||
this.dataManager = new DataManager(this.accountIdFromRoute);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
throw new Error('cacheModelName is not defined');
|
||||
}
|
||||
|
||||
get(cache = false) {
|
||||
if (cache) {
|
||||
return this.getFromCache();
|
||||
}
|
||||
|
||||
return axios.get(this.url);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
extractDataFromResponse(response) {
|
||||
return response.data.payload;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
marshallData(dataToParse) {
|
||||
return { data: { payload: dataToParse } };
|
||||
}
|
||||
|
||||
async getFromCache() {
|
||||
await this.dataManager.initDb();
|
||||
|
||||
const { data } = await axios.get(
|
||||
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
|
||||
);
|
||||
const cacheKeyFromApi = data.cache_keys[this.cacheModelName];
|
||||
const isCacheValid = await this.validateCacheKey(cacheKeyFromApi);
|
||||
|
||||
let localData = [];
|
||||
if (isCacheValid) {
|
||||
localData = await this.dataManager.get({
|
||||
modelName: this.cacheModelName,
|
||||
});
|
||||
}
|
||||
|
||||
if (localData.length === 0) {
|
||||
return this.refetchAndCommit(cacheKeyFromApi);
|
||||
}
|
||||
|
||||
return this.marshallData(localData);
|
||||
}
|
||||
|
||||
async refetchAndCommit(newKey = null) {
|
||||
await this.dataManager.initDb();
|
||||
const response = await axios.get(this.url);
|
||||
this.dataManager.replace({
|
||||
modelName: this.cacheModelName,
|
||||
data: this.extractDataFromResponse(response),
|
||||
});
|
||||
|
||||
await this.dataManager.setCacheKeys({
|
||||
[this.cacheModelName]: newKey,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async validateCacheKey(cacheKeyFromApi) {
|
||||
if (!this.dataManager.db) {
|
||||
await this.dataManager.initDb();
|
||||
}
|
||||
|
||||
const cachekey = await this.dataManager.getCacheKey(this.cacheModelName);
|
||||
return cacheKeyFromApi === cachekey;
|
||||
}
|
||||
}
|
||||
|
||||
export default CacheEnabledApiClient;
|
||||
@@ -0,0 +1,16 @@
|
||||
/* global axios */
|
||||
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class AuditLogs extends ApiClient {
|
||||
constructor() {
|
||||
super('audit_logs', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page }) {
|
||||
const url = page ? `${this.url}?page=${page}` : this.url;
|
||||
return axios.get(url);
|
||||
}
|
||||
}
|
||||
|
||||
export default new AuditLogs();
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import Cookies from 'js-cookie';
|
||||
import endPoints from './endPoints';
|
||||
import { setAuthCredentials, clearCookiesOnLogout } from '../store/utils/api';
|
||||
import {
|
||||
setAuthCredentials,
|
||||
clearCookiesOnLogout,
|
||||
deleteIndexedDBOnLogout,
|
||||
} from '../store/utils/api';
|
||||
|
||||
export default {
|
||||
login(creds) {
|
||||
@@ -50,6 +54,7 @@ export default {
|
||||
axios
|
||||
.delete(urlData.url)
|
||||
.then(response => {
|
||||
deleteIndexedDBOnLogout();
|
||||
clearCookiesOnLogout();
|
||||
resolve(response);
|
||||
})
|
||||
|
||||
@@ -60,6 +60,13 @@ class ArticlesAPI extends PortalsAPI {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
reorderArticles({ portalSlug, reorderedGroup, categorySlug }) {
|
||||
return axios.post(`${this.url}/${portalSlug}/articles/reorder`, {
|
||||
positions_hash: reorderedGroup,
|
||||
category_slug: categorySlug,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ArticlesAPI();
|
||||
|
||||
@@ -14,6 +14,7 @@ class ConversationApi extends ApiClient {
|
||||
labels,
|
||||
teamId,
|
||||
conversationType,
|
||||
sortBy,
|
||||
}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
@@ -24,6 +25,7 @@ class ConversationApi extends ApiClient {
|
||||
page,
|
||||
labels,
|
||||
conversation_type: conversationType,
|
||||
sort_by: sortBy,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -52,6 +54,12 @@ class ConversationApi extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
togglePriority({ conversationId, priority }) {
|
||||
return axios.post(`${this.url}/${conversationId}/toggle_priority`, {
|
||||
priority,
|
||||
});
|
||||
}
|
||||
|
||||
assignAgent({ conversationId, agentId }) {
|
||||
return axios.post(
|
||||
`${this.url}/${conversationId}/assignments?assignee_id=${agentId}`,
|
||||
|
||||
@@ -75,10 +75,12 @@ class MessageApi extends ApiClient {
|
||||
return axios.delete(`${this.url}/${conversationID}/messages/${messageId}`);
|
||||
}
|
||||
|
||||
getPreviousMessages({ conversationId, before }) {
|
||||
return axios.get(`${this.url}/${conversationId}/messages`, {
|
||||
params: { before },
|
||||
});
|
||||
getPreviousMessages({ conversationId, after, before }) {
|
||||
const params = { before };
|
||||
if (after && Number(after) !== Number(before)) {
|
||||
params.after = after;
|
||||
}
|
||||
return axios.get(`${this.url}/${conversationId}/messages`, { params });
|
||||
}
|
||||
|
||||
translateMessage(conversationId, messageId, targetLanguage) {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class Inboxes extends ApiClient {
|
||||
class Inboxes extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('inboxes', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'inbox';
|
||||
}
|
||||
|
||||
getCampaigns(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/campaigns`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/* global axios */
|
||||
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class OpenAIAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('integrations', { accountScoped: true });
|
||||
}
|
||||
|
||||
processEvent({ type = 'rephrase', content, tone, conversationId, hookId }) {
|
||||
let data = {
|
||||
tone,
|
||||
content,
|
||||
};
|
||||
|
||||
if (type === 'reply_suggestion' || type === 'summarize') {
|
||||
data = {
|
||||
conversation_display_id: conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
return axios.post(`${this.url}/hooks/${hookId}/process_event`, {
|
||||
event: {
|
||||
name: type,
|
||||
data,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new OpenAIAPI();
|
||||
@@ -1,9 +1,14 @@
|
||||
import ApiClient from './ApiClient';
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class LabelsAPI extends ApiClient {
|
||||
class LabelsAPI extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('labels', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'label';
|
||||
}
|
||||
}
|
||||
|
||||
export default new LabelsAPI();
|
||||
|
||||
@@ -14,8 +14,8 @@ class ReportsAPI extends ApiClient {
|
||||
to,
|
||||
type = 'account',
|
||||
id,
|
||||
group_by,
|
||||
business_hours,
|
||||
groupBy,
|
||||
businessHours,
|
||||
}) {
|
||||
return axios.get(`${this.url}`, {
|
||||
params: {
|
||||
@@ -24,22 +24,23 @@ class ReportsAPI extends ApiClient {
|
||||
until: to,
|
||||
type,
|
||||
id,
|
||||
group_by,
|
||||
business_hours,
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getSummary(since, until, type = 'account', id, group_by, business_hours) {
|
||||
getSummary(since, until, type = 'account', id, groupBy, businessHours) {
|
||||
return axios.get(`${this.url}/summary`, {
|
||||
params: {
|
||||
since,
|
||||
until,
|
||||
type,
|
||||
id,
|
||||
group_by,
|
||||
business_hours,
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -59,6 +60,12 @@ class ReportsAPI extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
getConversationTrafficCSV() {
|
||||
return axios.get(`${this.url}/conversation_traffic`, {
|
||||
params: { timezone_offset: getTimeOffset() },
|
||||
});
|
||||
}
|
||||
|
||||
getLabelReports({ from: since, to: until, businessHours }) {
|
||||
return axios.get(`${this.url}/labels`, {
|
||||
params: { since, until, business_hours: businessHours },
|
||||
|
||||
@@ -13,6 +13,30 @@ class SearchAPI extends ApiClient {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
contacts({ q }) {
|
||||
return axios.get(`${this.url}/contacts`, {
|
||||
params: {
|
||||
q,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
conversations({ q }) {
|
||||
return axios.get(`${this.url}/conversations`, {
|
||||
params: {
|
||||
q,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
messages({ q }) {
|
||||
return axios.get(`${this.url}/messages`, {
|
||||
params: {
|
||||
q,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new SearchAPI();
|
||||
|
||||
@@ -42,9 +42,13 @@ describe('#Reports API', () => {
|
||||
'/api/v2/reports/summary',
|
||||
{
|
||||
params: {
|
||||
business_hours: undefined,
|
||||
group_by: undefined,
|
||||
id: undefined,
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
timezone_offset: -0,
|
||||
type: 'account',
|
||||
until: 1621621800,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
// import ApiClient from './ApiClient';
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
export class TeamsAPI extends ApiClient {
|
||||
export class TeamsAPI extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('teams', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'team';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
extractDataFromResponse(response) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
marshallData(dataToParse) {
|
||||
return { data: dataToParse };
|
||||
}
|
||||
|
||||
getAgents({ teamId }) {
|
||||
return axios.get(`${this.url}/${teamId}/team_members`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* global axios */
|
||||
import wootConstants from 'dashboard/constants';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
export const getTestimonialContent = () => {
|
||||
return axios.get(wootConstants.TESTIMONIAL_URL);
|
||||
|
||||
@@ -254,7 +254,7 @@ $button-sizes: (tiny: var(--font-size-micro),
|
||||
default: var(--font-size-small),
|
||||
large: var(--font-size-medium));
|
||||
$button-palette: $foundation-palette;
|
||||
$button-opacity-disabled: 0.25;
|
||||
$button-opacity-disabled: 0.4;
|
||||
$button-background-hover-lightness: -20%;
|
||||
$button-hollow-hover-lightness: -50%;
|
||||
$button-transition: background-color 0.25s ease-out,
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.settings.back-button {
|
||||
.header-section.back-button {
|
||||
direction: initial;
|
||||
margin-left: var(--space-normal);
|
||||
margin-right: var(--space-smaller);
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
.multiselect--active {
|
||||
>.multiselect__tags {
|
||||
border-color: $color-woot;
|
||||
border-color: var(--w-500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,16 +75,13 @@
|
||||
}
|
||||
|
||||
&.multiselect__option--selected {
|
||||
background: var(--w-400);
|
||||
color: var(--white);
|
||||
background: var(--w-75);
|
||||
|
||||
&.multiselect__option--highlight:hover {
|
||||
background: var(--w-600);
|
||||
color: var(--white);
|
||||
background: var(--w-75);
|
||||
|
||||
&::after {
|
||||
background: transparent;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
&::after:hover {
|
||||
@@ -196,6 +193,7 @@
|
||||
display: flex;
|
||||
font-size: var(--font-size-small);
|
||||
margin: 0;
|
||||
max-height: 3.8rem;
|
||||
padding: var(--space-smaller) var(--space-micro);
|
||||
}
|
||||
|
||||
|
||||
@@ -95,10 +95,6 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--space-normal);
|
||||
|
||||
.page-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content-box {
|
||||
|
||||
@@ -86,6 +86,12 @@
|
||||
margin-left: var(--space-small);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
.button {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.justify-content-end {
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
@@ -11,15 +11,23 @@
|
||||
class="chat-list__top"
|
||||
:class="{ filter__applied: hasAppliedFiltersOrActiveFolders }"
|
||||
>
|
||||
<h1 class="page-title text-truncate" :title="pageTitle">
|
||||
{{ pageTitle }}
|
||||
</h1>
|
||||
|
||||
<div class="filter--actions">
|
||||
<chat-filter
|
||||
<div class="flex-center chat-list__title">
|
||||
<h1
|
||||
class="page-sub-title text-truncate margin-bottom-0"
|
||||
:title="pageTitle"
|
||||
>
|
||||
{{ pageTitle }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||
@statusFilterChange="updateStatusType"
|
||||
/>
|
||||
class="conversation--status-pill"
|
||||
>
|
||||
{{
|
||||
this.$t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="filter--actions">
|
||||
<div v-if="hasAppliedFilters && !hasActiveFolders">
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
|
||||
@@ -45,11 +53,9 @@
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
icon="delete"
|
||||
class="delete-custom-view__button"
|
||||
@click="onClickOpenDeleteFoldersModal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<woot-button
|
||||
v-else
|
||||
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
|
||||
@@ -59,6 +65,10 @@
|
||||
size="tiny"
|
||||
@click="onToggleAdvanceFiltersModal"
|
||||
/>
|
||||
<conversation-basic-filter
|
||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||
@changeFilter="onBasicFilterChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -126,6 +136,7 @@
|
||||
@update-conversation-status="toggleConversationStatus"
|
||||
@context-menu-toggle="onContextMenuToggle"
|
||||
@mark-as-unread="markAsUnread"
|
||||
@assign-priority="assignPriority"
|
||||
/>
|
||||
|
||||
<div v-if="chatListLoading" class="text-center">
|
||||
@@ -167,14 +178,14 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import ChatFilter from './widgets/conversation/ChatFilter';
|
||||
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter';
|
||||
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter';
|
||||
import ChatTypeTabs from './widgets/ChatTypeTabs';
|
||||
import ConversationCard from './widgets/conversation/ConversationCard';
|
||||
import timeMixin from '../mixins/time';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import conversationMixin from '../mixins/conversations';
|
||||
import wootConstants from '../constants';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
|
||||
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
|
||||
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews';
|
||||
@@ -192,16 +203,17 @@ import {
|
||||
isOnMentionsView,
|
||||
isOnUnattendedView,
|
||||
} from '../store/modules/conversations/helpers/actionHelpers';
|
||||
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AddCustomViews,
|
||||
ChatTypeTabs,
|
||||
ConversationCard,
|
||||
ChatFilter,
|
||||
ConversationAdvancedFilter,
|
||||
DeleteCustomViews,
|
||||
ConversationBulkActions,
|
||||
ConversationBasicFilter,
|
||||
},
|
||||
mixins: [
|
||||
timeMixin,
|
||||
@@ -244,11 +256,15 @@ export default {
|
||||
return {
|
||||
activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME,
|
||||
activeStatus: wootConstants.STATUS_TYPE.OPEN,
|
||||
activeSortBy: wootConstants.SORT_BY_TYPE.LATEST,
|
||||
showAdvancedFilters: false,
|
||||
advancedFilterTypes: advancedFilterTypes.map(filter => ({
|
||||
...filter,
|
||||
attributeName: this.$t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
|
||||
})),
|
||||
// chatsOnView is to store the chats that are currently visible on the screen,
|
||||
// which mirrors the conversationList.
|
||||
chatsOnView: [],
|
||||
foldersQuery: {},
|
||||
showAddFoldersModal: false,
|
||||
showDeleteFoldersModal: false,
|
||||
@@ -348,18 +364,40 @@ export default {
|
||||
this.currentPageFilterKey
|
||||
);
|
||||
},
|
||||
activeAssigneeTabCount() {
|
||||
const { activeAssigneeTab } = this;
|
||||
const count = this.assigneeTabItems.find(
|
||||
item => item.key === activeAssigneeTab
|
||||
).count;
|
||||
return count;
|
||||
},
|
||||
conversationFilters() {
|
||||
return {
|
||||
inboxId: this.conversationInbox ? this.conversationInbox : undefined,
|
||||
assigneeType: this.activeAssigneeTab,
|
||||
status: this.activeStatus,
|
||||
page: this.currentPage + 1,
|
||||
sortBy: this.activeSortBy,
|
||||
page: this.conversationListPagination,
|
||||
labels: this.label ? [this.label] : undefined,
|
||||
teamId: this.teamId || undefined,
|
||||
conversationType: this.conversationType || undefined,
|
||||
folders: this.hasActiveFolders ? this.savedFoldersValue : undefined,
|
||||
};
|
||||
},
|
||||
conversationListPagination() {
|
||||
const conversationsPerPage = 25;
|
||||
const isNoFiltersOrFoldersAndChatListNotEmpty =
|
||||
!this.hasAppliedFiltersOrActiveFolders && this.chatsOnView !== [];
|
||||
const isUnderPerPage =
|
||||
this.chatsOnView.length < conversationsPerPage &&
|
||||
this.activeAssigneeTabCount < conversationsPerPage &&
|
||||
this.activeAssigneeTabCount > this.chatsOnView.length;
|
||||
|
||||
if (isNoFiltersOrFoldersAndChatListNotEmpty && isUnderPerPage) {
|
||||
return 1;
|
||||
}
|
||||
return this.currentPage + 1;
|
||||
},
|
||||
pageTitle() {
|
||||
if (this.hasAppliedFilters) {
|
||||
return this.$t('CHAT_LIST.TAB_HEADING');
|
||||
@@ -401,7 +439,6 @@ export default {
|
||||
} else {
|
||||
conversationList = [...this.chatLists];
|
||||
}
|
||||
|
||||
return conversationList;
|
||||
},
|
||||
activeFolder() {
|
||||
@@ -450,9 +487,13 @@ export default {
|
||||
this.resetAndFetchData();
|
||||
}
|
||||
},
|
||||
chatLists() {
|
||||
this.chatsOnView = this.conversationList;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('setChatFilter', this.activeStatus);
|
||||
this.$store.dispatch('setChatStatusFilter', this.activeStatus);
|
||||
this.$store.dispatch('setChatSortFilter', this.activeSortBy);
|
||||
this.resetAndFetchData();
|
||||
|
||||
bus.$on('fetch_conversation_stats', () => {
|
||||
@@ -598,11 +639,13 @@ export default {
|
||||
this.selectedConversations = [];
|
||||
this.selectedInboxes = [];
|
||||
},
|
||||
updateStatusType(index) {
|
||||
if (this.activeStatus !== index) {
|
||||
this.activeStatus = index;
|
||||
this.resetAndFetchData();
|
||||
onBasicFilterChange(value, type) {
|
||||
if (type === 'status') {
|
||||
this.activeStatus = value;
|
||||
} else {
|
||||
this.activeSortBy = value;
|
||||
}
|
||||
this.resetAndFetchData();
|
||||
},
|
||||
openLastSavedItemInFolder() {
|
||||
const lastItemOfFolder = this.folders[this.folders.length - 1];
|
||||
@@ -671,6 +714,26 @@ export default {
|
||||
this.showAlert(this.$t('BULK_ACTION.ASSIGN_FAILED'));
|
||||
}
|
||||
},
|
||||
async assignPriority(priority, conversationId = null) {
|
||||
this.$store.dispatch('setCurrentChatPriority', {
|
||||
priority,
|
||||
conversationId,
|
||||
});
|
||||
this.$store
|
||||
.dispatch('assignPriority', { conversationId, priority })
|
||||
.then(() => {
|
||||
this.$track(CONVERSATION_EVENTS.CHANGE_PRIORITY, {
|
||||
newValue: priority,
|
||||
from: 'Context menu',
|
||||
});
|
||||
this.showAlert(
|
||||
this.$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SUCCESSFUL', {
|
||||
priority,
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
});
|
||||
},
|
||||
async markAsUnread(conversationId) {
|
||||
try {
|
||||
await this.$store.dispatch('markMessagesUnread', {
|
||||
@@ -831,21 +894,22 @@ export default {
|
||||
&.list--full-width {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.page-sub-title {
|
||||
font-size: var(--font-size-two);
|
||||
}
|
||||
}
|
||||
.filter--actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-smaller);
|
||||
}
|
||||
|
||||
.filter__applied {
|
||||
padding: 0 0 var(--space-slab) 0 !important;
|
||||
padding-bottom: var(--space-slab) !important;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.delete-custom-view__button {
|
||||
margin-right: var(--space-normal);
|
||||
}
|
||||
|
||||
.tab--chat-type {
|
||||
padding: 0 var(--space-normal);
|
||||
|
||||
@@ -855,4 +919,19 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.conversation--status-pill {
|
||||
background: var(--color-background);
|
||||
border-radius: var(--border-radius-small);
|
||||
color: var(--color-medium-gray);
|
||||
font-size: var(--font-size-micro);
|
||||
font-weight: var(--font-weight-medium);
|
||||
margin: var(--space-micro) var(--space-small) 0;
|
||||
padding: var(--space-smaller);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.chat-list__title {
|
||||
max-width: 85%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
<template>
|
||||
<div class="code--container">
|
||||
<button class="button small button--copy-code" @click="onCopy">
|
||||
{{ $t('COMPONENTS.CODE.BUTTON_TEXT') }}
|
||||
</button>
|
||||
<div class="code--action-area">
|
||||
<form
|
||||
v-if="enableCodePen"
|
||||
class="code--codeopen-form"
|
||||
action="https://codepen.io/pen/define"
|
||||
method="POST"
|
||||
target="_blank"
|
||||
>
|
||||
<input type="hidden" name="data" :value="codepenScriptValue" />
|
||||
|
||||
<button type="submit" class="button secondary tiny">
|
||||
{{ $t('COMPONENTS.CODE.CODEPEN') }}
|
||||
</button>
|
||||
</form>
|
||||
<button class="button secondary tiny" @click="onCopy">
|
||||
{{ $t('COMPONENTS.CODE.BUTTON_TEXT') }}
|
||||
</button>
|
||||
</div>
|
||||
<highlightjs v-if="script" :language="lang" :code="script" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -21,6 +36,24 @@ export default {
|
||||
type: String,
|
||||
default: 'javascript',
|
||||
},
|
||||
enableCodePen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
codepenTitle: {
|
||||
type: String,
|
||||
default: 'Chatwoot Codepen',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
codepenScriptValue() {
|
||||
const lang = this.lang === 'javascript' ? 'js' : this.lang;
|
||||
return JSON.stringify({
|
||||
title: this.codepenTitle,
|
||||
private: true,
|
||||
[lang]: this.script,
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async onCopy(e) {
|
||||
@@ -37,10 +70,14 @@ export default {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
|
||||
.button--copy-code {
|
||||
margin-top: 0;
|
||||
.code--action-area {
|
||||
top: var(--space-small);
|
||||
position: absolute;
|
||||
right: 0;
|
||||
right: var(--space-small);
|
||||
}
|
||||
|
||||
.code--codeopen-form {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -218,13 +218,17 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.editedValue = this.formattedValue;
|
||||
bus.$on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, focusAttributeKey => {
|
||||
bus.$on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
|
||||
},
|
||||
destroyed() {
|
||||
bus.$off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
|
||||
},
|
||||
methods: {
|
||||
onFocusAttribute(focusAttributeKey) {
|
||||
if (this.attributeKey === focusAttributeKey) {
|
||||
this.onEdit();
|
||||
}
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
},
|
||||
focusInput() {
|
||||
if (this.$refs.inputfield) {
|
||||
this.$refs.inputfield.focus();
|
||||
|
||||
@@ -47,7 +47,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
modalContainerClassName() {
|
||||
let className = 'modal-container';
|
||||
let className = 'modal-container skip-context-menu';
|
||||
if (this.fullWidth) {
|
||||
return `${className} modal-container--full-width`;
|
||||
}
|
||||
@@ -60,7 +60,9 @@ export default {
|
||||
'right-aligned': 'right-aligned',
|
||||
};
|
||||
|
||||
return `modal-mask ${modalClassNameMap[this.modalType] || ''}`;
|
||||
return `modal-mask skip-context-menu ${modalClassNameMap[
|
||||
this.modalType
|
||||
] || ''}`;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
{{ title }}
|
||||
</p>
|
||||
<p class="sub-head">
|
||||
{{ subTitle }}
|
||||
<slot name="subTitle">
|
||||
{{ subTitle }}
|
||||
</slot>
|
||||
</p>
|
||||
<p v-if="note">
|
||||
<span class="note">{{ $t('INBOX_MGMT.NOTE') }}</span>
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
</template>
|
||||
<script>
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../helper/localStorage';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { mapGetters } from 'vuex';
|
||||
import adminMixin from 'dashboard/mixins/isAdmin';
|
||||
import { hasAnUpdateAvailable } from './versionCheckHelper';
|
||||
|
||||
@@ -126,7 +126,7 @@ import WootDropdownSubMenu from 'shared/components/ui/dropdown/DropdownSubMenu.v
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
|
||||
|
||||
import wootConstants from '../../constants';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import {
|
||||
CMD_REOPEN_CONVERSATION,
|
||||
CMD_RESOLVE_CONVERSATION,
|
||||
|
||||
@@ -13,6 +13,7 @@ import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
|
||||
import FeatureToggle from './widgets/FeatureToggle';
|
||||
import HorizontalBar from './widgets/chart/HorizontalBarChart';
|
||||
import Input from './widgets/forms/Input.vue';
|
||||
import PhoneInput from './widgets/forms/PhoneInput.vue';
|
||||
import Label from './ui/Label';
|
||||
import LoadingState from './widgets/LoadingState';
|
||||
import Modal from './Modal';
|
||||
@@ -40,6 +41,7 @@ const WootUIKit = {
|
||||
FeatureToggle,
|
||||
HorizontalBar,
|
||||
Input,
|
||||
PhoneInput,
|
||||
Label,
|
||||
LoadingState,
|
||||
Modal,
|
||||
|
||||
@@ -52,8 +52,9 @@ import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
|
||||
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader';
|
||||
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
|
||||
import AvailabilityStatusBadge from '../widgets/conversation/AvailabilityStatusBadge';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
const AVAILABILITY_STATUS_KEYS = ['online', 'busy', 'offline'];
|
||||
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -8,6 +8,7 @@ const settings = accountId => ({
|
||||
'agent_list',
|
||||
'attributes_list',
|
||||
'automation_list',
|
||||
'auditlogs_list',
|
||||
'billing_settings_index',
|
||||
'canned_list',
|
||||
'general_settings_index',
|
||||
@@ -150,6 +151,15 @@ const settings = accountId => ({
|
||||
toStateName: 'billing_settings_index',
|
||||
showOnlyOnCloud: true,
|
||||
},
|
||||
{
|
||||
icon: 'key',
|
||||
label: 'AUDIT_LOGS',
|
||||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/settings/audit-log/list`),
|
||||
toStateName: 'auditlogs_list',
|
||||
featureFlag: FEATURE_FLAGS.AUDIT_LOGS,
|
||||
beta: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ import PrimaryNavItem from './PrimaryNavItem';
|
||||
import OptionsMenu from './OptionsMenu';
|
||||
import AgentDetails from './AgentDetails';
|
||||
import NotificationBell from './NotificationBell';
|
||||
import wootConstants from 'dashboard/constants';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="show"
|
||||
ref="context"
|
||||
class="context-menu-container"
|
||||
:style="style"
|
||||
tabindex="0"
|
||||
@@ -39,7 +37,6 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => this.$el.focus());
|
||||
this.show = true;
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
:disabled="isDisabled || isLoading"
|
||||
@click="handleClick"
|
||||
>
|
||||
<spinner v-if="isLoading" size="small" />
|
||||
<spinner
|
||||
v-if="isLoading"
|
||||
size="small"
|
||||
:color-scheme="showDarkSpinner ? 'dark' : ''"
|
||||
/>
|
||||
<emoji-or-icon
|
||||
v-else-if="icon || emoji"
|
||||
class="icon"
|
||||
@@ -108,6 +112,14 @@ export default {
|
||||
return 16;
|
||||
}
|
||||
},
|
||||
showDarkSpinner() {
|
||||
return (
|
||||
this.colorScheme === 'secondary' ||
|
||||
this.variant === 'clear' ||
|
||||
this.variant === 'link' ||
|
||||
this.variant === 'hollow'
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleClick(evt) {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<div v-if="isAIIntegrationEnabled" class="position-relative">
|
||||
<div v-if="!message">
|
||||
<woot-button
|
||||
v-if="isPrivateNote"
|
||||
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.SUMMARY_TITLE')"
|
||||
icon="book-pulse"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
:is-loading="uiFlags.summarize"
|
||||
@click="processEvent('summarize')"
|
||||
/>
|
||||
<woot-button
|
||||
v-else
|
||||
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_TITLE')"
|
||||
icon="wand"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
:is-loading="uiFlags.reply_suggestion"
|
||||
@click="processEvent('reply_suggestion')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.TITLE')"
|
||||
icon="text-grammar-wand"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
@click="toggleDropdown"
|
||||
/>
|
||||
<div
|
||||
v-if="showDropdown"
|
||||
v-on-clickaway="closeDropdown"
|
||||
class="dropdown-pane dropdown-pane--open ai-modal"
|
||||
>
|
||||
<h4 class="sub-block-title margin-top-1">
|
||||
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TITLE') }}
|
||||
</h4>
|
||||
<p>
|
||||
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.SUBTITLE') }}
|
||||
</p>
|
||||
<label>
|
||||
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TONE.TITLE') }}
|
||||
</label>
|
||||
<div class="tone__item">
|
||||
<select v-model="activeTone" class="status--filter small">
|
||||
<option v-for="tone in tones" :key="tone.key" :value="tone.key">
|
||||
{{ tone.value }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-footer flex-container align-right">
|
||||
<woot-button variant="clear" size="small" @click="closeDropdown">
|
||||
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.CANCEL') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
:is-loading="uiFlags.rephrase"
|
||||
size="small"
|
||||
@click="processEvent('rephrase')"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import OpenAPI from 'dashboard/api/integrations/openapi';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
export default {
|
||||
mixins: [alertMixin, clickaway],
|
||||
props: {
|
||||
conversationId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isPrivateNote: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
uiFlags: {
|
||||
rephrase: false,
|
||||
reply_suggestion: false,
|
||||
summarize: false,
|
||||
},
|
||||
showDropdown: false,
|
||||
activeTone: 'professional',
|
||||
tones: [
|
||||
{
|
||||
key: 'professional',
|
||||
value: this.$t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.TONE.OPTIONS.PROFESSIONAL'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'friendly',
|
||||
value: this.$t('INTEGRATION_SETTINGS.OPEN_AI.TONE.OPTIONS.FRIENDLY'),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ appIntegrations: 'integrations/getAppIntegrations' }),
|
||||
isAIIntegrationEnabled() {
|
||||
return this.appIntegrations.find(
|
||||
integration => integration.id === 'openai' && !!integration.hooks.length
|
||||
);
|
||||
},
|
||||
hookId() {
|
||||
return this.appIntegrations.find(
|
||||
integration => integration.id === 'openai' && !!integration.hooks.length
|
||||
).hooks[0].id;
|
||||
},
|
||||
buttonText() {
|
||||
return this.uiFlags.isRephrasing
|
||||
? this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATING')
|
||||
: this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATE');
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (!this.appIntegrations.length) {
|
||||
this.$store.dispatch('integrations/get');
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleDropdown() {
|
||||
this.showDropdown = !this.showDropdown;
|
||||
},
|
||||
closeDropdown() {
|
||||
this.showDropdown = false;
|
||||
},
|
||||
async recordAnalytics({ type, tone }) {
|
||||
const event = OPEN_AI_EVENTS[type.toUpperCase()];
|
||||
if (event) {
|
||||
this.$track(event, {
|
||||
type,
|
||||
tone,
|
||||
});
|
||||
}
|
||||
},
|
||||
async processEvent(type = 'rephrase') {
|
||||
this.uiFlags[type] = true;
|
||||
try {
|
||||
const result = await OpenAPI.processEvent({
|
||||
hookId: this.hookId,
|
||||
type,
|
||||
content: this.message,
|
||||
tone: this.activeTone,
|
||||
conversationId: this.conversationId,
|
||||
});
|
||||
const {
|
||||
data: { message: generatedMessage },
|
||||
} = result;
|
||||
this.$emit('replace-text', generatedMessage || this.message);
|
||||
this.closeDropdown();
|
||||
this.recordAnalytics({ type, tone: this.activeTone });
|
||||
} catch (error) {
|
||||
this.showAlert(this.$t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR'));
|
||||
} finally {
|
||||
this.uiFlags[type] = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ai-modal {
|
||||
width: 400px;
|
||||
right: 0;
|
||||
left: 0;
|
||||
padding: var(--space-normal);
|
||||
bottom: 34px;
|
||||
position: absolute;
|
||||
span {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--s-600);
|
||||
}
|
||||
|
||||
label {
|
||||
margin-bottom: var(--space-smaller);
|
||||
}
|
||||
|
||||
.status--filter {
|
||||
background-color: var(--color-background-light);
|
||||
border: 1px solid var(--color-border);
|
||||
font-size: var(--font-size-small);
|
||||
height: var(--space-large);
|
||||
padding: 0 var(--space-medium) 0 var(--space-small);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
gap: var(--space-smaller);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<button class="settings back-button" @click.capture="goBack">
|
||||
<button class="header-section back-button" @click.capture="goBack">
|
||||
<fluent-icon icon="chevron-left" />
|
||||
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
|
||||
</button>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</woot-tabs>
|
||||
</template>
|
||||
<script>
|
||||
import wootConstants from '../../constants';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import { hasPressedAltAndNKey } from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="dashboard-app--container">
|
||||
<div v-if="hasOpenedAtleastOnce" class="dashboard-app--container">
|
||||
<div
|
||||
v-for="(configItem, index) in config"
|
||||
:key="index"
|
||||
@@ -35,9 +35,14 @@ export default {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isVisible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hasOpenedAtleastOnce: false,
|
||||
iframeLoading: true,
|
||||
};
|
||||
},
|
||||
@@ -57,6 +62,13 @@ export default {
|
||||
return { id, name, email };
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
isVisible() {
|
||||
if (this.isVisible) {
|
||||
this.hasOpenedAtleastOnce = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
window.onmessage = e => {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import InboxDropdownItem from './InboxDropdownItem';
|
||||
|
||||
export default {
|
||||
title: 'Components/DropDowns/InboxDropdownItem',
|
||||
component: InboxDropdownItem,
|
||||
argTypes: {
|
||||
name: {
|
||||
defaultValue: 'My new inbox',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
|
||||
inboxIdentifier: {
|
||||
defaultValue: 'nithin@mail.com',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
|
||||
channelType: {
|
||||
defaultValue: 'email',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
props: Object.keys(argTypes),
|
||||
components: { InboxDropdownItem },
|
||||
template: '<inbox-dropdown-item v-bind="$props" ></inbox-dropdown-item>',
|
||||
});
|
||||
|
||||
export const Banner = Template.bind({});
|
||||
Banner.args = {};
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="option-item--inbox">
|
||||
<span class="badge--icon">
|
||||
<fluent-icon :icon="computedInboxIcon" size="14" />
|
||||
</span>
|
||||
<div class="option__user-data">
|
||||
<h5 class="option__title">
|
||||
{{ name }}
|
||||
</h5>
|
||||
<p class="option__body text-truncate" :title="inboxIdentifier">
|
||||
{{ inboxIdentifier || computedInboxType }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
getInboxClassByType,
|
||||
getReadableInboxByType,
|
||||
} from 'dashboard/helper/inbox';
|
||||
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inboxIdentifier: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
channelType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
computedInboxIcon() {
|
||||
if (!this.channelType) return 'chat';
|
||||
const classByType = getInboxClassByType(
|
||||
this.channelType,
|
||||
this.inboxIdentifier
|
||||
);
|
||||
return classByType;
|
||||
},
|
||||
computedInboxType() {
|
||||
if (!this.channelType) return 'chat';
|
||||
const classByType = getReadableInboxByType(
|
||||
this.channelType,
|
||||
this.inboxIdentifier
|
||||
);
|
||||
return classByType;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.option-item--inbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 3.8rem;
|
||||
min-width: 0;
|
||||
padding: 0 var(--space-smaller);
|
||||
}
|
||||
.badge--icon {
|
||||
display: inline-flex;
|
||||
border-radius: var(--border-radius-small);
|
||||
margin-right: var(--space-smaller);
|
||||
background: var(--s-25);
|
||||
padding: var(--space-micro);
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
width: var(--space-medium);
|
||||
height: var(--space-medium);
|
||||
}
|
||||
|
||||
.option__user-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-left: var(--space-smaller);
|
||||
margin-right: var(--space-smaller);
|
||||
}
|
||||
.option__body {
|
||||
display: inline-block;
|
||||
color: var(--s-600);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: 1.3;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.option__title {
|
||||
line-height: 1.1;
|
||||
font-size: var(--font-size-mini);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -13,15 +13,19 @@ import videojs from 'video.js';
|
||||
import alertMixin from '../../../../shared/mixins/alertMixin';
|
||||
|
||||
import Recorder from 'opus-recorder';
|
||||
|
||||
// Workers to record Audio .ogg and .wav
|
||||
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';
|
||||
import 'videojs-wavesurfer/dist/videojs.wavesurfer.js';
|
||||
|
||||
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 OpusRecorderEngine from 'videojs-record/dist/plugins/videojs.record.opus-recorder.js';
|
||||
|
||||
import { format, addSeconds } from 'date-fns';
|
||||
import { AUDIO_FORMATS } from 'shared/constants/messages';
|
||||
|
||||
@@ -33,7 +37,7 @@ export default {
|
||||
props: {
|
||||
audioRecordFormat: {
|
||||
type: String,
|
||||
default: AUDIO_FORMATS.WEBM,
|
||||
default: AUDIO_FORMATS.WAV,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
@@ -79,24 +83,18 @@ export default {
|
||||
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,
|
||||
displayMilliseconds: false,
|
||||
audioChannels: 1,
|
||||
audioSampleRate: 48000,
|
||||
audioBitRate: 128,
|
||||
audioEngine: 'opus-recorder',
|
||||
...(this.audioRecordFormat === AUDIO_FORMATS.WAV && {
|
||||
audioMimeType: 'audio/wav',
|
||||
audioWorkerURL: waveWorker,
|
||||
}),
|
||||
...(this.audioRecordFormat === AUDIO_FORMATS.OGG && {
|
||||
displayMilliseconds: false,
|
||||
audioEngine: 'opus-recorder',
|
||||
audioMimeType: 'audio/ogg',
|
||||
audioWorkerURL: encoderWorker,
|
||||
audioChannels: 1,
|
||||
audioSampleRate: 48000,
|
||||
audioBitRate: 128,
|
||||
}),
|
||||
},
|
||||
},
|
||||
@@ -134,6 +132,11 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
deviceReady() {
|
||||
if (this.player.record().engine instanceof OpusRecorderEngine) {
|
||||
if (this.audioRecordFormat === AUDIO_FORMATS.WAV) {
|
||||
this.player.record().engine.audioType = 'audio/wav';
|
||||
}
|
||||
}
|
||||
this.player.record().start();
|
||||
},
|
||||
startRecord() {
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
||||
import { replaceVariablesInMessage } from 'dashboard/helper/messageHelper';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
|
||||
const createState = (content, placeholder, plugins = []) => {
|
||||
|
||||
@@ -38,12 +38,12 @@
|
||||
</file-upload>
|
||||
<woot-button
|
||||
v-if="showAudioRecorderButton"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
|
||||
:icon="!isRecordingAudio ? 'microphone' : 'microphone-off'"
|
||||
emoji="🎤"
|
||||
:color-scheme="!isRecordingAudio ? 'secondary' : 'alert'"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
:title="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
|
||||
@click="toggleAudioRecorder"
|
||||
/>
|
||||
<woot-button
|
||||
@@ -91,6 +91,12 @@
|
||||
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
|
||||
:conversation-id="conversationId"
|
||||
/>
|
||||
<AIAssistanceButton
|
||||
:conversation-id="conversationId"
|
||||
:is-private-note="isOnPrivateNote"
|
||||
:message="message"
|
||||
@replace-text="replaceText"
|
||||
/>
|
||||
<transition name="modal-fade">
|
||||
<div
|
||||
v-show="$refs.upload && $refs.upload.dropActive"
|
||||
@@ -129,12 +135,13 @@ import {
|
||||
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
|
||||
} from 'shared/constants/messages';
|
||||
import VideoCallButton from '../VideoCallButton';
|
||||
import AIAssistanceButton from '../AIAssistanceButton.vue';
|
||||
import { REPLY_EDITOR_MODES } from './constants';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ReplyBottomPanel',
|
||||
components: { FileUpload, VideoCallButton },
|
||||
components: { FileUpload, VideoCallButton, AIAssistanceButton },
|
||||
mixins: [eventListenerMixins, uiSettingsMixin, inboxMixin],
|
||||
props: {
|
||||
mode: {
|
||||
@@ -217,6 +224,10 @@ export default {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
@@ -303,6 +314,9 @@ export default {
|
||||
send_with_signature: !this.sendWithSignature,
|
||||
});
|
||||
},
|
||||
replaceText(text) {
|
||||
this.$emit('replace-text', text);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import wootConstants from '../../../constants';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import { hasPressedAltAndBKey } from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
@@ -38,8 +38,8 @@ export default {
|
||||
this.onTabChange();
|
||||
},
|
||||
onTabChange() {
|
||||
this.$store.dispatch('setChatFilter', this.activeStatus);
|
||||
this.$emit('statusFilterChange', this.activeStatus);
|
||||
this.$store.dispatch('setChatStatusFilter', this.activeStatus);
|
||||
this.$emit('onChangeFilter', this.activeStatus);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="position-relative">
|
||||
<woot-button
|
||||
v-tooltip.right="$t('CHAT_LIST.SORT_TOOLTIP_LABEL')"
|
||||
variant="smooth"
|
||||
size="tiny"
|
||||
color-scheme="secondary"
|
||||
class="selector-button"
|
||||
icon="sort-icon"
|
||||
@click="toggleDropdown"
|
||||
/>
|
||||
<div
|
||||
v-if="showActionsDropdown"
|
||||
v-on-clickaway="closeDropdown"
|
||||
class="dropdown-pane dropdown-pane--open basic-filter"
|
||||
>
|
||||
<div class="filter__item">
|
||||
<span>{{ this.$t('CHAT_LIST.CHAT_SORT.STATUS') }}</span>
|
||||
<filter-item
|
||||
type="status"
|
||||
:selected-value="chatStatus"
|
||||
:items="chatStatusItems"
|
||||
path-prefix="CHAT_LIST.CHAT_STATUS_FILTER_ITEMS"
|
||||
@onChangeFilter="onChangeFilter"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter__item">
|
||||
<span>{{ this.$t('CHAT_LIST.CHAT_SORT.ORDER_BY') }}</span>
|
||||
<filter-item
|
||||
type="sort"
|
||||
:selected-value="chatSortFilter"
|
||||
:items="chatSortItems"
|
||||
path-prefix="CHAT_LIST.CHAT_SORT_FILTER_ITEMS"
|
||||
@onChangeFilter="onChangeFilter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import FilterItem from './FilterItem';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FilterItem,
|
||||
},
|
||||
mixins: [clickaway],
|
||||
data() {
|
||||
return {
|
||||
showActionsDropdown: false,
|
||||
chatStatusItems: this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS'),
|
||||
chatSortItems: this.$t('CHAT_LIST.CHAT_SORT_FILTER_ITEMS'),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
chatStatusFilter: 'getChatStatusFilter',
|
||||
chatSortFilter: 'getChatSortFilter',
|
||||
}),
|
||||
chatStatus() {
|
||||
return this.chatStatusFilter || wootConstants.STATUS_TYPE.OPEN;
|
||||
},
|
||||
sortFilter() {
|
||||
return this.chatSortFilter || wootConstants.SORT_BY_TYPE.LATEST;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onTabChange(value) {
|
||||
this.$emit('changeFilter', value);
|
||||
this.closeDropdown();
|
||||
},
|
||||
toggleDropdown() {
|
||||
this.showActionsDropdown = !this.showActionsDropdown;
|
||||
},
|
||||
closeDropdown() {
|
||||
this.showActionsDropdown = false;
|
||||
},
|
||||
onChangeFilter(type, value) {
|
||||
this.$emit('changeFilter', type, value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.basic-filter {
|
||||
margin-top: var(--space-smaller);
|
||||
padding: var(--space-normal);
|
||||
right: 0;
|
||||
width: 21rem;
|
||||
|
||||
span {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.filter__item {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
&:last-child {
|
||||
margin-top: var(--space-normal);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: var(--font-size-mini);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: var(--space-smaller);
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
margin-left: var(--space-smaller);
|
||||
}
|
||||
</style>
|
||||
@@ -23,7 +23,7 @@
|
||||
:show-badge="false"
|
||||
/>
|
||||
</woot-tabs>
|
||||
<div v-if="!activeIndex" class="messages-and-sidebar">
|
||||
<div v-show="!activeIndex" class="messages-and-sidebar">
|
||||
<messages-view
|
||||
v-if="currentChat.id"
|
||||
:inbox-id="inboxId"
|
||||
@@ -41,9 +41,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<dashboard-app-frame
|
||||
v-else
|
||||
:key="currentChat.id + '-' + activeIndex"
|
||||
:config="dashboardApps[activeIndex - 1].content"
|
||||
v-for="(dashboardApp, index) in dashboardApps"
|
||||
v-show="activeIndex - 1 === index"
|
||||
:key="currentChat.id + '-' + dashboardApp.id"
|
||||
:is-visible="activeIndex - 1 === index"
|
||||
:config="dashboardApps[index].content"
|
||||
:current-chat="currentChat"
|
||||
/>
|
||||
</div>
|
||||
@@ -112,6 +114,7 @@ export default {
|
||||
},
|
||||
'currentChat.id'() {
|
||||
this.fetchLabels();
|
||||
this.activeIndex = 0;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
}"
|
||||
@mouseenter="onCardHover"
|
||||
@mouseleave="onCardLeave"
|
||||
@click="cardClick(chat)"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
<label v-if="hovered || selected" class="checkbox-wrapper" @click.stop>
|
||||
@@ -33,13 +33,16 @@
|
||||
<div class="conversation--details columns">
|
||||
<div class="conversation--metadata">
|
||||
<inbox-name v-if="showInboxName" :inbox="inbox" />
|
||||
<span
|
||||
v-if="showAssignee && assignee.name"
|
||||
class="label assignee-label text-truncate"
|
||||
>
|
||||
<fluent-icon icon="person" size="12" />
|
||||
{{ assignee.name }}
|
||||
</span>
|
||||
<div class="conversation-metadata-attributes">
|
||||
<span
|
||||
v-if="showAssignee && assignee.name"
|
||||
class="label assignee-label text-truncate"
|
||||
>
|
||||
<fluent-icon icon="person" size="12" />
|
||||
{{ assignee.name }}
|
||||
</span>
|
||||
<priority-mark :priority="chat.priority" />
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="conversation--user">
|
||||
{{ currentContact.name }}
|
||||
@@ -106,12 +109,14 @@
|
||||
<conversation-context-menu
|
||||
:status="chat.status"
|
||||
:inbox-id="inbox.id"
|
||||
:priority="chat.priority"
|
||||
:has-unread-messages="hasUnread"
|
||||
@update-conversation="onUpdateConversation"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-label="onAssignLabel"
|
||||
@assign-team="onAssignTeam"
|
||||
@mark-as-unread="markAsUnread"
|
||||
@assign-priority="assignPriority"
|
||||
/>
|
||||
</woot-context-menu>
|
||||
</div>
|
||||
@@ -131,6 +136,7 @@ import ConversationContextMenu from './contextMenu/Index.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo';
|
||||
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||
import PriorityMark from './PriorityMark.vue';
|
||||
const ATTACHMENT_ICONS = {
|
||||
image: 'image',
|
||||
audio: 'headphones-sound-wave',
|
||||
@@ -147,6 +153,7 @@ export default {
|
||||
Thumbnail,
|
||||
ConversationContextMenu,
|
||||
TimeAgo,
|
||||
PriorityMark,
|
||||
},
|
||||
|
||||
mixins: [
|
||||
@@ -306,21 +313,33 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
cardClick(chat) {
|
||||
const { activeInbox } = this;
|
||||
const path = conversationUrl({
|
||||
accountId: this.accountId,
|
||||
activeInbox,
|
||||
id: chat.id,
|
||||
label: this.activeLabel,
|
||||
teamId: this.teamId,
|
||||
foldersId: this.foldersId,
|
||||
conversationType: this.conversationType,
|
||||
});
|
||||
onCardClick(e) {
|
||||
const { activeInbox, chat } = this;
|
||||
const path = frontendURL(
|
||||
conversationUrl({
|
||||
accountId: this.accountId,
|
||||
activeInbox,
|
||||
id: chat.id,
|
||||
label: this.activeLabel,
|
||||
teamId: this.teamId,
|
||||
foldersId: this.foldersId,
|
||||
conversationType: this.conversationType,
|
||||
})
|
||||
);
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
window.open(
|
||||
window.chatwootConfig.hostURL + path,
|
||||
'_blank',
|
||||
'noopener noreferrer nofollow'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.isActiveChat) {
|
||||
return;
|
||||
}
|
||||
router.push({ path: frontendURL(path) });
|
||||
|
||||
router.push({ path });
|
||||
},
|
||||
onCardHover() {
|
||||
this.hovered = !this.hideThumbnail;
|
||||
@@ -370,6 +389,10 @@ export default {
|
||||
this.$emit('mark-as-unread', this.chat.id);
|
||||
this.closeContextMenu();
|
||||
},
|
||||
async assignPriority(priority) {
|
||||
this.$emit('assign-priority', priority, this.chat.id);
|
||||
this.closeContextMenu();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -429,9 +452,14 @@ export default {
|
||||
padding: var(--space-micro) 0 var(--space-micro) 0;
|
||||
}
|
||||
|
||||
.conversation-metadata-attributes {
|
||||
display: flex;
|
||||
gap: var(--space-small);
|
||||
margin-left: var(--space-small);
|
||||
}
|
||||
|
||||
.assignee-label {
|
||||
display: inline-flex;
|
||||
margin-left: var(--space-small);
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import InboxName from '../InboxName';
|
||||
import MoreActions from './MoreActions';
|
||||
import Thumbnail from '../Thumbnail';
|
||||
import wootConstants from '../../../constants';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<select v-model="activeValue" class="status--filter" @change="onTabChange()">
|
||||
<option v-for="(value, status) in items" :key="status" :value="status">
|
||||
{{ $t(`${pathPrefix}.${status}.TEXT`) }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
selectedValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
pathPrefix: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeValue: this.selectedValue,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onTabChange() {
|
||||
if (this.type === 'status') {
|
||||
this.$store.dispatch('setChatStatusFilter', this.activeValue);
|
||||
} else {
|
||||
this.$store.dispatch('setChatSortFilter', this.activeValue);
|
||||
}
|
||||
this.$emit('onChangeFilter', this.activeValue, this.type);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.status--filter {
|
||||
background-color: var(--color-background-light);
|
||||
border: 1px solid var(--color-border);
|
||||
font-size: var(--font-size-mini);
|
||||
height: var(--space-medium);
|
||||
margin: 0 var(--space-smaller);
|
||||
padding: 0 var(--space-medium) 0 var(--space-small);
|
||||
width: 126px;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user