Compare commits

...
Author SHA1 Message Date
Sivin VargheseandGitHub 14c2048c1c Merge branch 'develop' into chore/split-vuex-actions-PR-#7913 2023-09-27 13:28:48 +05:30
Chatwoot BotandGitHub e8b7e791a5 chore: Update translations (#7969) 2023-09-27 12:47:03 +05:30
iamsivin a236d4da3a chore: Updated the contact conversation actions to support attachments 2023-09-27 11:17:37 +05:30
b18cac77fb chore: Moved file upload methods to mixin (#7987)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-09-27 11:05:19 +05:30
Sojan JoseandGitHub 647161121e chore: pass to agent if there is error parsing json (#7990)
- GPT bot should pass the conversation to the agent if AI returns invalid json or any other error
2023-09-26 21:05:21 -07:00
Muhsin KelothandGitHub cbbe939662 fix: Set avatar for users(agent/contact) in slack channels (#7960) 2023-09-26 09:38:14 +05:30
Sojan 30cb141e5e Merge branch 'master' into develop 2023-09-22 19:55:11 -07:00
Sojan b0d5c438ac Merge branch 'release/3.1.1' into develop 2023-09-22 19:54:48 -07:00
Sojan 3eefa4daee Merge branch 'release/3.1.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-09-22 19:54:43 -07:00
Sojan ddce999385 Bump version to 3.1.1 2023-09-22 19:54:20 -07:00
ff915dd2ea fix: Avoid joining tables to fix the distinct value query (#7965)
DISTINCT query with custom attributes return an error. To avoid the error, this PR refactors the query to include tags only when it is required.

Fixes #7931
Fixes #7836

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-09-22 19:48:56 -07:00
dd0930d75e chore: Improve search in super admin panel (#7952)
Co-authored-by: Vishnu Narayanan <vishnu@chatwoot.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-09-22 19:41:13 +05:30
Shivam MishraandGitHub 3f0d96c24d chore: Add better error messages for Facebook unauthorized (#7936) 2023-09-22 16:41:04 +05:30
Pranav Raj SandGitHub c16b801562 chore: Update the filter for articles in the widget (#7961) 2023-09-21 19:40:46 +05:30
018c468303 chore: Refactors widget to use i18n locale for articles (#7958)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-09-21 18:24:03 +05:30
48bf8d08e5 feat: Update dependencies and fix import syntax for Vite migration (#7959)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-09-21 17:55:54 +05:30
Nithin David ThomasandGitHub fabb3c8da4 fix: Help Center articles are not available on the widget [CW-2534] (#7954) 2023-09-21 17:06:15 +05:30
bdeb2f9812 feat: Searching contacts by company name (#7867)
Implement the functionality of being able to search the contacts page by company name.
Fixes: #7818

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-09-21 00:53:12 -07:00
53dc38e650 fix: Renders youtube and vimeo links within blank lines as embeds (#7422)
Within the article we are now rendering every link for youtube and vimeo as embeds. This isn't a good solution, as users might need to have plain links as well. This fix will render only links within two blank lines as embeds.

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-09-21 00:16:03 -07:00
27fc24375d fix: Handling markdown before replacing or appending signature [CW-2532] (#7944)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-09-21 11:28:29 +05:30
f999777a2d chore: Update message signature description (#7943)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-09-20 21:09:25 -07:00
Pranav Raj SandGitHub 71d1e98765 chore: Remove spinkit from dependencies (#7948) 2023-09-20 18:18:55 +05:30
Sivin VargheseandGitHub 2c93d563c1 fix: Update colors to fix dark mode in the editor add-link modal header (#7947) 2023-09-20 17:29:27 +05:30
Pranav Raj SandGitHub f1b556d4a3 fix: Downcase content filter values to fix the query (#7942) 2023-09-20 13:14:00 +05:30
Chatwoot BotandGitHub 73c119cd3d chore: Update translations (#7937) 2023-09-19 21:19:46 -07:00
Smit BosamiyaandGitHub 0ad2f94151 fix: Error when attempting to Delete User-Associated Accounts via Super Admin Console (#7810)
This pull request addresses the issue where an error is encountered while attempting to delete user-associated accounts through the Super Admin Console. The problem arises from an error that occurs when the "destroy" action is triggered on an associated account.

Fixes: #7809
2023-09-19 21:16:11 -07:00
f8cb806548 fix: remove trailing spaces before pushing signature (#7935)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-09-19 13:29:44 +05:30
9ba5adfd60 chore: Support multiple values for automation message content (#7871)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-09-19 13:04:58 +05:30
Muhsin KelothandGitHub 0dd5104acb chore: Move slack message concern to the helper (#7912) 2023-09-19 11:09:23 +05:30
Sojan JoseandGitHub 0968aead6c chore: Migration status in super admin (#7938) 2023-09-19 10:31:41 +05:30
Shivam MishraandGitHub 53d530b815 feat: Add upload under account scope (#7914) 2023-09-19 09:51:54 +05:30
2429daa45c feat: cwctl upgrade node for existing installations (#7772)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-09-18 23:49:29 +05:30
Sojan 86894b27b5 Merge branch 'release/3.1.0' into develop 2023-09-15 16:46:47 -07:00
Sojan 05df4d4212 Merge branch 'release/3.1.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-09-15 16:46:37 -07:00
Sojan 47fd0ea2b2 Bump version to 3.1.0 2023-09-15 16:45:59 -07:00
Sojan JoseandGitHub 30b8a365dc chore: Improve the feature management styles in super admin (#7920) 2023-09-15 15:55:15 -07:00
29110ffd6b feat: Allow signature in the editor directly (#7881)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-09-15 18:46:40 +05:30
Sojan JoseandGitHub e9950afb1a refactor: Optimise article query in chat widget (#7919)
- optimise article query in chatwidget
2023-09-14 17:19:42 -07:00
Pranav Raj SandGitHub f21298aeb9 chore: Increase the click area for the article list item (#7918) 2023-09-14 20:44:08 +05:30
Vishnu NarayananandGitHub 7b6a56714f fix: double rendor error on message create api for suspended accounts (#7917) 2023-09-14 20:06:48 +05:30
94a20af9db fix: Render articles in widget if it is available (#7654)
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sojan <sojan@pepalo.com>
2023-09-14 19:56:17 +05:30
Sojan JoseandGitHub 616371adbb chore: Improve the behavior of lock to single conversation (#7899) 2023-09-14 12:32:57 +05:30
Muhsin KelothandGitHub 2d4ef0c328 feat: Add None option in automation assign agent actions (#7900) 2023-09-14 09:01:58 +05:30
5e6e234afe feat (perf): Remove conversation count in contacts list view (#7915)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-09-14 08:49:37 +05:30
Muhsin KelothandGitHub 18e9e771b5 fix: Check body exists before deleting null bytes in Twilio (#7910) 2023-09-13 13:40:26 +05:30
0245e01763 fix: Enable serbian translation (#7905)
Enable language: Serbian.

Co-authored-by: BikashSah999 <51731962+BikashSah999@users.noreply.github.com>
2023-09-13 00:36:34 -07:00
fcd40e9636 chore: Update translations (#7887)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-09-12 18:09:47 -07:00
Jordan BroughandGitHub 4f5c5e9f85 fix: Erase null bytes from incoming Twilio messages (#7901)
We've had some messages come in from a few different phone numbers that had null bytes in them. I don't know how this happens. They don't seem to be malicious.

They currently cause the Postgres gem to raise an error when Chatwoot attempts to save the message body to the database:

ArgumentError (string contains null byte)

Related Rails GitHub issue: rails/rails#26891
2023-09-12 18:07:18 -07:00
cb07ac16d1 chore: [Snyk] Fix for 1 vulnerabilities (#7906)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-09-12 18:03:58 -07:00
Muhsin KelothandGitHub 4fa299ff15 chore: Disable archived channels from the slack integration (#7904) 2023-09-12 22:21:50 +05:30
Pranav Raj SandGitHub 71e9566854 Revert "feat: Ability to send attachment in new conversation (#7698)" (#7903) 2023-09-12 20:57:49 +05:30
272f920811 chore: [Snyk] Security upgrade rails from 7.0.7.2 to 7.0.8 (#7888)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-09-11 19:12:38 -07:00
e39d19b1e8 feat: Adds image support for message signature (#7827)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2023-09-11 10:23:45 +05:30
Nithin David ThomasandGitHub 6c39aed882 fix: Refactor components in widget to show articles (#7874) 2023-09-11 09:31:50 +05:30
Nithin David ThomasandGitHub 8d43101892 chore: Improvements to dark mode usage in widget (#7873) 2023-09-11 09:31:11 +05:30
Pranav Raj SandGitHub f05535c5ad Revert "chore: Improve the behaviour of lock to single conversation (… (#7886) 2023-09-09 09:59:21 +05:30
Pranav Raj SandGitHub 4503ba018d chore: Increase the limit of greeting message (#7883) 2023-09-08 15:33:09 +05:30
8bc2bc6d40 feat: Ability to send attachment in new conversation (#7698)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-09-08 12:12:24 +05:30
Sojan JoseandGitHub bfd192ebb2 chore: Improve the behaviour of lock to single conversation (#7863) 2023-09-08 11:16:24 +05:30
Sojan JoseandGitHub 6e13d22ff7 chore: Updated prompt for Article Bot (#7872) 2023-09-08 11:11:07 +05:30
Muhsin KelothandGitHub 3e8b6e1aee fix: Disable AI assist if the AI integration not enabled (#7876) 2023-09-07 19:17:10 +05:30
Sivin VargheseandGitHub 3038aaef67 fix: Editor menu option is not showing (#7879) 2023-09-07 17:15:19 +05:30
Muhsin KelothandGitHub 57a68666cb fix: Integration page responsiveness issues (#7875) 2023-09-07 14:16:44 +05:30
4acb824857 chore: [Snyk] Fix for 1 vulnerabilities (#7870)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-09-06 18:36:32 -07:00
5c74674c2b feat: Update rack attack IP limit (#7866)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-09-06 18:56:42 +05:30
Nithin David ThomasandGitHub 52e49d5f9d feat: Creates component to show article in an iFrame (#7857) 2023-09-06 18:55:39 +05:30
Shivam MishraandGitHub e529e1206e fix: Update inline image processing logic to fix missing images when multiple inline images present (#7861) 2023-09-06 14:35:19 +05:30
Nithin David ThomasandGitHub a8009c7b39 fix: Allow Iframes requests on portal controller (#7858) 2023-09-06 14:25:03 +05:30
99bb074656 chore: Security upgrade web-console from 4.2.0 to 4.2.1 (#7864)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-09-06 11:31:05 +05:30
Nithin David ThomasandGitHub 9322112481 chore: Improvements to utils to load article in widget (#7859) 2023-09-05 21:37:51 +05:30
Muhsin KelothandGitHub ce4cfee8bd fix: Attachment sent from slack doesn't reach to chatwoot (#7852) 2023-09-05 17:23:58 +05:30
Sivin VargheseandGitHub f31fc2b375 fix: Avoid XSS in custom attributes (#7800) 2023-09-05 09:49:54 +05:30
e5f7807833 fix: Redirect after contact delete [CW-2397] (#7740)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-09-04 18:48:30 +05:30
Onur YAŞARandGitHub 2483d99d3c fix: Update Portal Turkish Translations (#7833) 2023-09-04 18:37:34 +05:30
Shivam MishraandGitHub 336584c95a feat: mutex for InstagramEventsJob [CW-2447] (#7828) 2023-09-04 15:02:13 +05:30
Shivam MishraandGitHub 9ebabb9832 feat: common attachment endpoint follow-up changes (#7826) 2023-09-01 15:18:48 +07:00
Shivam MishraandGitHub 2acf09b3eb refactor: use conversation_id instead of sender_id (#7831) 2023-08-31 16:10:37 +07:00
Pranav Raj SandGitHub d18d3e9e2e fix: Re-arrange plugins to fix canned-responses/mentions not working (#7830) 2023-08-31 15:42:40 +07:00
deec1d213b feat: add a common upload endpoint (#7806)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-08-31 10:36:02 +07:00
Shivam MishraandGitHub 25c3fb3c36 feat: Track the usage of query operator (#7821) 2023-08-30 08:28:05 +05:30
e2a6dc3e04 chore: Upgrade Node.js to v20 (#7759)
Co-authored-by: Vishnu Narayanan <vishnu@chatwoot.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-08-28 19:20:35 +05:30
57feedbf25 fix: Fixes extra backslash while breaking paragraphs [CW-1505] (#7457)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-08-28 15:46:10 +05:30
Pranav Raj SandGitHub 71310c21ad chore: Update translations (#7807) 2023-08-28 09:02:22 +05:30
623cd72897 fix: Update the order of display of the CSAT ratings (#7805)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-08-27 07:39:06 +05:30
04aa13e8aa feat: Disable options from the message signature editor (#7777)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-08-26 08:51:39 +05:30
Sojan fe8a67b76a Merge branch 'release/3.0.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-08-15 22:41:04 -07:00
Sojan e204fe1c1f Merge branch 'release/2.18.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-06-15 18:44:51 +05:30
Sojan 08fa370261 Merge branch 'hotfix/2.17.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-05-19 20:56:06 +05:30
572 changed files with 6246 additions and 2411 deletions
+7 -8
View File
@@ -15,9 +15,9 @@ defaults: &defaults
- image: cimg/postgres:15.3
- image: cimg/redis:6.2.6
environment:
- RAILS_LOG_TO_STDOUT: false
- COVERAGE: true
- LOG_LEVEL: warn
- RAILS_LOG_TO_STDOUT: false
- COVERAGE: true
- LOG_LEVEL: warn
parallelism: 4
resource_class: large
@@ -46,7 +46,7 @@ jobs:
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
nvm install v16
nvm install v20
echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV
echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $BASH_ENV
@@ -64,7 +64,6 @@ jobs:
- ~/.bundle
key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
# Only necessary if app uses webpacker or yarn in some other way
- restore_cache:
keys:
@@ -82,7 +81,7 @@ jobs:
- ~/.cache/yarn
- run:
name: Download cc-test-reporter
name: Download cc-test-reporter
command: |
mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
@@ -182,7 +181,7 @@ jobs:
- attach_workspace:
at: ~/build
- run:
name: Download cc-test-reporter
name: Download cc-test-reporter
command: |
mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
@@ -204,4 +203,4 @@ workflows:
- build
- upload-coverage:
requires:
- build
- build
+2
View File
@@ -199,6 +199,8 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
## Rack Attack configuration
## To prevent and throttle abusive requests
# ENABLE_RACK_ATTACK=true
# RACK_ATTACK_IP_LIMIT=3000
# ENABLE_RACK_ATTACK_WIDGET_API=true
## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints
+4 -1
View File
@@ -52,7 +52,8 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 16
node-version: 20
cache: yarn
- name: yarn
run: yarn install
@@ -75,6 +76,8 @@ jobs:
- name: Run backend tests
run: |
bundle exec rspec --profile=10 --format documentation
env:
NODE_OPTIONS: --openssl-legacy-provider
- name: Upload rails log folder
uses: actions/upload-artifact@v3
+8 -2
View File
@@ -51,7 +51,8 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 16
node-version: 20
cache: yarn
- name: yarn
run: yarn install
@@ -68,7 +69,12 @@ jobs:
# Run Response Bot specs
- name: Run backend tests
run: |
bundle exec rspec spec/enterprise/controllers/api/v1/accounts/response_sources_controller_spec.rb spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb:47 --profile=10 --format documentation
bundle exec rspec \
spec/enterprise/controllers/api/v1/accounts/response_sources_controller_spec.rb \
spec/enterprise/services/enterprise/message_templates/response_bot_service_spec.rb \
spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb:47 \
--profile=10 \
--format documentation
- name: Upload rails log folder
uses: actions/upload-artifact@v3
+5 -2
View File
@@ -21,7 +21,8 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 16
node-version: 20
cache: 'yarn'
- name: yarn
run: yarn install
@@ -30,9 +31,11 @@ jobs:
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Run asset compile
run: bundle exec rake assets:precompile
env:
NODE_OPTIONS: --openssl-legacy-provider
- name: Size Check
run: yarn run size
+1 -1
View File
@@ -1 +1 @@
16.20.1
20.5.1
+3 -7
View File
@@ -8,15 +8,11 @@ Layout/LineLength:
Max: 150
Metrics/ClassLength:
Max: 125
Max: 175
Exclude:
- 'app/models/conversation.rb'
- 'app/models/contact.rb'
- 'app/mailers/conversation_reply_mailer.rb'
- 'app/models/message.rb'
- 'app/builders/messages/facebook/message_builder.rb'
- 'app/controllers/api/v1/accounts/contacts_controller.rb'
- 'app/listeners/action_cable_listener.rb'
- 'app/models/conversation.rb'
RSpec/ExampleLength:
Max: 25
Style/Documentation:
+7 -6
View File
@@ -4,7 +4,7 @@ ruby '3.2.2'
##-- base gems for rails --##
gem 'rack-cors', require: 'rack/cors'
gem 'rails', '~> 7.0.7.2'
gem 'rails', '~> 7.0.8.0'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false
@@ -76,6 +76,7 @@ gem 'pundit'
# super admin
gem 'administrate', '>= 0.19.0'
gem 'administrate-field-active_storage'
gem 'administrate-field-belongs_to_search'
##--- gems for pubsub service ---##
# https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/
@@ -108,14 +109,14 @@ gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', require: false
gem 'scout_apm', require: false
gem 'sentry-rails', '>= 5.10.0', require: false
gem 'sentry-rails', '>= 5.11.0', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.10.0', require: false
gem 'sentry-sidekiq', '>= 5.11.0', require: false
##-- background job processing --##
gem 'sidekiq'
gem 'sidekiq', '>= 7.1.3'
# We want cron jobs
gem 'sidekiq-cron'
gem 'sidekiq-cron', '>= 1.10.1'
##-- Push notification service --##
gem 'fcm'
@@ -188,7 +189,7 @@ group :development do
gem 'bullet'
gem 'letter_opener'
gem 'scss_lint', require: false
gem 'web-console'
gem 'web-console', '>= 4.2.1'
# used in swagger build
gem 'json_refs'
+86 -80
View File
@@ -33,70 +33,70 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.7.2)
actionpack (= 7.0.7.2)
activesupport (= 7.0.7.2)
actioncable (7.0.8)
actionpack (= 7.0.8)
activesupport (= 7.0.8)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.7.2)
actionpack (= 7.0.7.2)
activejob (= 7.0.7.2)
activerecord (= 7.0.7.2)
activestorage (= 7.0.7.2)
activesupport (= 7.0.7.2)
actionmailbox (7.0.8)
actionpack (= 7.0.8)
activejob (= 7.0.8)
activerecord (= 7.0.8)
activestorage (= 7.0.8)
activesupport (= 7.0.8)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.7.2)
actionpack (= 7.0.7.2)
actionview (= 7.0.7.2)
activejob (= 7.0.7.2)
activesupport (= 7.0.7.2)
actionmailer (7.0.8)
actionpack (= 7.0.8)
actionview (= 7.0.8)
activejob (= 7.0.8)
activesupport (= 7.0.8)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.7.2)
actionview (= 7.0.7.2)
activesupport (= 7.0.7.2)
actionpack (7.0.8)
actionview (= 7.0.8)
activesupport (= 7.0.8)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.7.2)
actionpack (= 7.0.7.2)
activerecord (= 7.0.7.2)
activestorage (= 7.0.7.2)
activesupport (= 7.0.7.2)
actiontext (7.0.8)
actionpack (= 7.0.8)
activerecord (= 7.0.8)
activestorage (= 7.0.8)
activesupport (= 7.0.8)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.7.2)
activesupport (= 7.0.7.2)
actionview (7.0.8)
activesupport (= 7.0.8)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (7.0.7.2)
activesupport (= 7.0.7.2)
activejob (7.0.8)
activesupport (= 7.0.8)
globalid (>= 0.3.6)
activemodel (7.0.7.2)
activesupport (= 7.0.7.2)
activerecord (7.0.7.2)
activemodel (= 7.0.7.2)
activesupport (= 7.0.7.2)
activemodel (7.0.8)
activesupport (= 7.0.8)
activerecord (7.0.8)
activemodel (= 7.0.8)
activesupport (= 7.0.8)
activerecord-import (1.4.1)
activerecord (>= 4.2)
activestorage (7.0.7.2)
actionpack (= 7.0.7.2)
activejob (= 7.0.7.2)
activerecord (= 7.0.7.2)
activesupport (= 7.0.7.2)
activestorage (7.0.8)
actionpack (= 7.0.8)
activejob (= 7.0.8)
activerecord (= 7.0.8)
activesupport (= 7.0.8)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.7.2)
activesupport (7.0.8)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -116,6 +116,11 @@ GEM
administrate-field-active_storage (0.4.2)
administrate (>= 0.2.2)
rails (>= 7.0)
administrate-field-belongs_to_search (0.8.0)
administrate (>= 0.3, < 1.0)
jbuilder (~> 2)
rails (>= 4.2, < 7.1)
selectize-rails (~> 0.6)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
@@ -269,8 +274,8 @@ GEM
grpc (~> 1.36)
geocoder (1.8.1)
gli (2.21.0)
globalid (1.1.0)
activesupport (>= 5.0)
globalid (1.2.1)
activesupport (>= 6.1)
gmail_xoauth (0.4.2)
oauth (>= 0.3.6)
google-apis-core (0.11.0)
@@ -450,9 +455,9 @@ GEM
mime-types-data (~> 3.2015)
mime-types-data (3.2023.0218.1)
mini_magick (4.12.0)
mini_mime (1.1.2)
mini_mime (1.1.5)
mini_portile2 (2.8.4)
minitest (5.19.0)
minitest (5.20.0)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.7.0)
@@ -463,7 +468,7 @@ GEM
activerecord (>= 5.2)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.3.6)
net-imap (0.3.7)
date
net-protocol
net-pop (0.1.2)
@@ -478,14 +483,14 @@ GEM
sidekiq
newrelic_rpm (8.16.0)
nio4r (2.5.9)
nokogiri (1.15.3)
nokogiri (1.15.4)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.15.3-arm64-darwin)
nokogiri (1.15.4-arm64-darwin)
racc (~> 1.4)
nokogiri (1.15.3-x86_64-darwin)
nokogiri (1.15.4-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.15.3-x86_64-linux)
nokogiri (1.15.4-x86_64-linux)
racc (~> 1.4)
numo-narray (0.9.2.1)
oauth (1.1.0)
@@ -563,30 +568,30 @@ GEM
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (7.0.7.2)
actioncable (= 7.0.7.2)
actionmailbox (= 7.0.7.2)
actionmailer (= 7.0.7.2)
actionpack (= 7.0.7.2)
actiontext (= 7.0.7.2)
actionview (= 7.0.7.2)
activejob (= 7.0.7.2)
activemodel (= 7.0.7.2)
activerecord (= 7.0.7.2)
activestorage (= 7.0.7.2)
activesupport (= 7.0.7.2)
rails (7.0.8)
actioncable (= 7.0.8)
actionmailbox (= 7.0.8)
actionmailer (= 7.0.8)
actionpack (= 7.0.8)
actiontext (= 7.0.8)
actionview (= 7.0.8)
activejob (= 7.0.8)
activemodel (= 7.0.8)
activerecord (= 7.0.8)
activestorage (= 7.0.8)
activesupport (= 7.0.8)
bundler (>= 1.15.0)
railties (= 7.0.7.2)
rails-dom-testing (2.1.1)
railties (= 7.0.8)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.0.7.2)
actionpack (= 7.0.7.2)
activesupport (= 7.0.7.2)
railties (7.0.8)
actionpack (= 7.0.8)
activesupport (= 7.0.8)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -598,7 +603,7 @@ GEM
ffi (~> 1.0)
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.14.1)
redis-client (0.17.0)
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
@@ -697,23 +702,23 @@ GEM
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.10.0)
sentry-rails (5.11.0)
railties (>= 5.0)
sentry-ruby (~> 5.10.0)
sentry-ruby (5.10.0)
sentry-ruby (~> 5.11.0)
sentry-ruby (5.11.0)
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.10.0)
sentry-ruby (~> 5.10.0)
sentry-sidekiq (5.11.0)
sentry-ruby (~> 5.11.0)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
sidekiq (7.1.2)
sidekiq (7.1.3)
concurrent-ruby (< 2)
connection_pool (>= 2.3.0)
rack (>= 2.2.4)
redis-client (>= 0.14.0)
sidekiq-cron (1.10.0)
sidekiq-cron (1.10.1)
fugit (~> 1.8)
globalid (>= 1.0.1)
sidekiq (>= 6)
@@ -786,7 +791,7 @@ GEM
version_gem (1.1.2)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.0)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
@@ -805,14 +810,14 @@ GEM
railties (>= 5.2)
semantic_range (>= 2.3.0)
webrick (1.8.1)
websocket-driver (0.7.5)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
wisper (2.0.0)
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.6.9)
zeitwerk (2.6.11)
PLATFORMS
arm64-darwin-20
@@ -831,6 +836,7 @@ DEPENDENCIES
acts-as-taggable-on
administrate (>= 0.19.0)
administrate-field-active_storage
administrate-field-belongs_to_search
annotate
attr_extras
audited (~> 5.3)
@@ -907,7 +913,7 @@ DEPENDENCIES
rack-cors
rack-mini-profiler (>= 3.1.1)
rack-timeout
rails (~> 7.0.7.2)
rails (~> 7.0.8.0)
redis
redis-namespace
responders
@@ -922,12 +928,12 @@ DEPENDENCIES
scout_apm
scss_lint
seed_dump
sentry-rails (>= 5.10.0)
sentry-rails (>= 5.11.0)
sentry-ruby
sentry-sidekiq (>= 5.10.0)
sentry-sidekiq (>= 5.11.0)
shoulda-matchers
sidekiq
sidekiq-cron
sidekiq (>= 7.1.3)
sidekiq-cron (>= 1.10.1)
simplecov (= 0.17.1)
slack-ruby-client (~> 2.0.0)
spring
@@ -943,7 +949,7 @@ DEPENDENCIES
tzinfo-data
uglifier
valid_email2
web-console
web-console (>= 4.2.1)
web-push
webmock
webpacker
+1 -1
View File
@@ -1,4 +1,4 @@
backend: bin/rails s -p 3000
frontend: bin/webpack-dev-server
frontend: export NODE_OPTIONS=--openssl-legacy-provider && bin/webpack-dev-server
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
+1 -1
View File
@@ -1 +1 @@
2.17.0
3.1.0
+1 -1
View File
@@ -1 +1 @@
2.3.0
2.4.0
+3
View File
@@ -60,6 +60,9 @@
],
"stack": "heroku-20",
"buildpacks": [
{
"url": "heroku/nodejs"
},
{
"url": "heroku/ruby"
}
@@ -29,3 +29,5 @@
@import 'components/pagination';
@import 'components/search';
@import 'components/reports';
@import 'custom_styles';
@@ -0,0 +1,24 @@
// custom styles for the dashboard
.feature-cell {
background: $color-extra-light-blue;
border-radius: 10px;
float: left;
margin-left: 8px;
margin-top: 6px;
padding: 4px 12px;
.icon-container {
margin-right: 4px;
}
.value-container {
margin-left: 6px;
}
}
.feature-container {
max-width: 100rem;
}
@@ -78,11 +78,6 @@ $conv-header-height: 4rem;
$inbox-thumb-size: 4.8rem;
// Spinner
$spinkit-spinner-color: $color-white !default;
$spinkit-spinner-margin: 0 0 0 1.6rem !default;
$spinkit-size: 1.6rem !default;
// Snackbar default
$woot-snackbar-bg: #323232;
$woot-snackbar-button: #ffeb3b;
@@ -1,7 +1,7 @@
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, :reorder]
before_action :fetch_article, except: [:index, :create, :reorder]
before_action :set_current_page, only: [:index]
def index
@@ -36,17 +36,6 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
head :ok
end
def attach_file
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:background_image].tempfile,
filename: params[:background_image].original_filename,
content_type: params[:background_image].content_type
)
file_blob.save!
render json: { file_url: url_for(file_blob) }
end
def reorder
Article.update_positions(params[:positions_hash])
head :ok
@@ -20,16 +20,6 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rule
end
def attach_file
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:attachment].tempfile,
filename: params[:attachment].original_filename,
content_type: params[:attachment].content_type
)
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end
def update
ActiveRecord::Base.transaction do
automation_rule_update
@@ -18,18 +18,19 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def index
@contacts_count = resolved_contacts.count
@contacts = fetch_contacts_with_conversation_count(resolved_contacts)
@contacts = fetch_contacts(resolved_contacts)
end
def search
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
contacts = resolved_contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
search: "%#{params[:q].strip}%"
)
@contacts_count = contacts.count
@contacts = fetch_contacts_with_conversation_count(contacts)
@contacts = fetch_contacts(contacts)
end
def import
@@ -63,7 +64,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
result = ::Contacts::FilterService.new(params.permit!, current_user).perform
contacts = result[:contacts]
@contacts_count = result[:count]
@contacts = fetch_contacts_with_conversation_count(contacts)
@contacts = fetch_contacts(contacts)
end
def contactable_inboxes
@@ -125,17 +126,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@current_page = params[:page] || 1
end
def fetch_contacts_with_conversation_count(contacts)
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)
def fetch_contacts(contacts)
contacts_with_avatar = filtrate(contacts)
.includes([{ avatar_attachment: [:blob] }])
.page(@current_page).per(RESULTS_PER_PAGE)
return contacts_with_conversation_count.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
return contacts_with_avatar.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
contacts_with_conversation_count
contacts_with_avatar
end
def build_contact_inbox
@@ -39,16 +39,6 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
head :ok
end
def attach_file
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:attachment].tempfile,
filename: params[:attachment].original_filename,
content_type: params[:attachment].content_type
)
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end
def execute
::MacrosExecutionJob.perform_later(@macro, conversation_ids: params[:conversation_ids], user: Current.user)
@@ -1,7 +1,7 @@
class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
include ::FileTypeHelper
before_action :fetch_portal, except: [:index, :create, :attach_file]
before_action :fetch_portal, except: [:index, :create]
before_action :check_authorization
before_action :set_current_page, only: [:index]
@@ -53,16 +53,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portal.logo.attach(blob)
end
def attach_file
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:logo].tempfile,
filename: params[:logo].original_filename,
content_type: params[:logo].content_type
)
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end
private
def fetch_portal
@@ -0,0 +1,13 @@
class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
def create
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:attachment].tempfile,
filename: params[:attachment].original_filename,
content_type: params[:attachment].content_type
)
file_blob.save!
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
end
end
@@ -8,7 +8,7 @@ module EnsureCurrentAccountHelper
def ensure_current_account
account = Account.find(params[:account_id])
ensure_account_is_active?(account)
render_unauthorized('Account is suspended') and return unless account.active?
if current_user
account_accessible_for_user?(account)
@@ -27,8 +27,4 @@ module EnsureCurrentAccountHelper
def account_accessible_for_bot?(account)
render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
end
def ensure_account_is_active?(account)
render_unauthorized('Account is suspended') unless account.active?
end
end
@@ -44,7 +44,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end
def list_params
params.permit(:query, :locale, :sort)
params.permit(:query, :locale, :sort, :status)
end
def permitted_params
@@ -1,6 +1,7 @@
class Public::Api::V1::Portals::BaseController < PublicController
before_action :show_plain_layout
around_action :set_locale
after_action :allow_iframe_requests
private
@@ -39,4 +40,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
I18n.with_locale(@locale, &)
end
def allow_iframe_requests
response.headers.delete('X-Frame-Options') if @is_plain_layout_enabled
end
end
@@ -0,0 +1,27 @@
class SlackUploadsController < ApplicationController
include Rails.application.routes.url_helpers
before_action :set_blob, only: [:show]
def show
if @blob
redirect_to blob_url
else
redirect_to avatar_url
end
end
private
def set_blob
@blob = ActiveStorage::Blob.find_by(key: params[:blob_key])
end
def blob_url
url_for(@blob.representation(resize_to_fill: [250, nil]))
end
def avatar_url
base_url = ENV.fetch('FRONTEND_URL', nil)
"#{base_url}/integrations/slack/#{params[:sender_type]}.png"
end
end
@@ -6,6 +6,7 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
postgres_status
redis_metrics
chatwoot_edition
instance_meta
end
def chatwoot_edition
@@ -18,6 +19,10 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
end
end
def instance_meta
@metrics['Database Migrations'] = ActiveRecord::Base.connection.migration_context.needs_migration? ? 'pending' : 'completed'
end
def chatwoot_version
@metrics['Chatwoot version'] = Chatwoot.config[:version]
end
+2 -2
View File
@@ -18,8 +18,8 @@ class AccountDashboard < Administrate::BaseDashboard
end
ATTRIBUTE_TYPES = {
id: Field::Number,
name: Field::String,
id: Field::Number.with_options(searchable: true),
name: Field::String.with_options(searchable: true),
created_at: Field::DateTime,
updated_at: Field::DateTime,
users: CountField,
+3 -3
View File
@@ -8,9 +8,9 @@ class AccountUserDashboard < Administrate::BaseDashboard
# which determines how the attribute is displayed
# on pages throughout the dashboard.
ATTRIBUTE_TYPES = {
account: Field::BelongsTo.with_options(searchable: true, searchable_field: 'name', order: 'id DESC'),
user: Field::BelongsTo.with_options(searchable: true, searchable_field: 'name', order: 'id DESC'),
inviter: Field::BelongsTo.with_options(class_name: 'User', searchable: true, searchable_field: 'name'),
account: Field::BelongsToSearch.with_options(class_name: 'Account', searchable: true, searchable_field: [:name, :id], order: 'id DESC'),
user: Field::BelongsToSearch.with_options(class_name: 'User', searchable: true, searchable_field: [:name, :email, :id], order: 'id DESC'),
inviter: Field::BelongsToSearch.with_options(class_name: 'User', searchable: true, searchable_field: [:name, :email, :id], order: 'id DESC'),
id: Field::Number,
role: Field::Select.with_options(collection: AccountUser.roles.keys),
created_at: Field::DateTime,
+3 -3
View File
@@ -9,7 +9,7 @@ class UserDashboard < Administrate::BaseDashboard
# on pages throughout the dashboard.
ATTRIBUTE_TYPES = {
account_users: Field::HasMany,
id: Field::Number,
id: Field::Number.with_options(searchable: true),
avatar_url: AvatarField,
avatar: Field::ActiveStorage.with_options(
destroy_url: proc do |_namespace, _resource, attachment|
@@ -28,9 +28,9 @@ class UserDashboard < Administrate::BaseDashboard
confirmed_at: Field::DateTime,
confirmation_sent_at: Field::DateTime,
unconfirmed_email: Field::String,
name: Field::String,
name: Field::String.with_options(searchable: true),
display_name: Field::String,
email: Field::String,
email: Field::String.with_options(searchable: true),
tokens: Field::String.with_options(searchable: false),
created_at: Field::DateTime,
updated_at: Field::DateTime,
+3 -3
View File
@@ -26,14 +26,14 @@
<script>
import { mapGetters } from 'vuex';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal.vue';
import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification';
import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue';
import UpgradeBanner from './components/app/UpgradeBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import vueActionCable from './helper/actionCable';
import WootSnackbarBox from './components/SnackbarContainer';
import WootSnackbarBox from './components/SnackbarContainer.vue';
import rtlMixin from 'shared/mixins/rtlMixin';
import { setColorTheme } from './helper/themeHelper';
import {
@@ -9,14 +9,6 @@ class AutomationsAPI extends ApiClient {
clone(automationId) {
return axios.post(`${this.url}/${automationId}/clone`);
}
attachment(file) {
return axios.post(`${this.url}/attach_file`, file, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
}
export default new AutomationsAPI();
@@ -47,20 +47,6 @@ class ArticlesAPI extends PortalsAPI {
return axios.delete(`${this.url}/${portalSlug}/articles/${articleId}`);
}
uploadImage({ portalSlug, file }) {
let formData = new FormData();
formData.append('background_image', file);
return axios.post(
`${this.url}/${portalSlug}/articles/attach_file`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
}
reorderArticles({ portalSlug, reorderedGroup, categorySlug }) {
return axios.post(`${this.url}/${portalSlug}/articles/reorder`, {
positions_hash: reorderedGroup,
@@ -81,11 +81,6 @@ $conv-header-height: 2.5rem;
$inbox-thumb-size: 3rem;
// Spinner
$spinkit-spinner-color: $color-white !default;
$spinkit-spinner-margin: 0 0 0 1rem !default;
$spinkit-size: 1rem !default;
// Snackbar default
$woot-snackbar-bg: #323232;
$woot-snackbar-button: #ffeb3b;
@@ -9,7 +9,6 @@
@import 'shared/assets/stylesheets/border-radius';
@import 'variables';
@import '~spinkit/scss/spinners/7-three-bounce';
@import 'vue-multiselect/dist/vue-multiselect.min.css';
@import '~shared/assets/stylesheets/ionicons';
@@ -31,7 +31,7 @@
</template>
<script>
import EmojiOrIcon from 'shared/components/EmojiOrIcon';
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
export default {
components: {
@@ -193,17 +193,17 @@
<script>
import { mapGetters } from 'vuex';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter';
import ChatTypeTabs from './widgets/ChatTypeTabs';
import ConversationCard from './widgets/conversation/ConversationCard';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationCard from './widgets/conversation/ConversationCard.vue';
import timeMixin from '../mixins/time';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import conversationMixin from '../mixins/conversations';
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';
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews.vue';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import alertMixin from 'shared/mixins/alertMixin';
@@ -61,7 +61,7 @@
>
<a
v-if="isAttributeTypeLink"
:href="value"
:href="hrefURL"
target="_blank"
rel="noopener noreferrer"
class="value inline-block rounded-sm mb-0 break-all py-0.5 px-1"
@@ -188,6 +188,9 @@ export default {
urlValue() {
return isValidURL(this.value) ? this.value : '---';
},
hrefURL() {
return isValidURL(this.value) ? this.value : '';
},
notAttributeTypeCheckboxAndList() {
return !this.isAttributeTypeCheckbox && !this.isAttributeTypeList;
},
@@ -10,7 +10,7 @@
</template>
<script>
import WootSnackbar from './Snackbar';
import WootSnackbar from './Snackbar.vue';
export default {
components: {
@@ -13,7 +13,7 @@
</template>
<script>
import Spinner from 'shared/components/Spinner';
import Spinner from 'shared/components/Spinner.vue';
export default {
components: {
@@ -90,7 +90,7 @@ import { getUnixTime } from 'date-fns';
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import {
hasPressedAltAndEKey,
@@ -49,11 +49,11 @@
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader';
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
import AvailabilityStatusBadge from '../widgets/conversation/AvailabilityStatusBadge';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider.vue';
import AvailabilityStatusBadge from '../widgets/conversation/AvailabilityStatusBadge.vue';
import wootConstants from 'dashboard/constants/globals';
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
@@ -35,8 +35,8 @@ import adminMixin from '../../mixins/isAdmin';
import { getSidebarItems } from './config/default-sidebar';
import alertMixin from 'shared/mixins/alertMixin';
import PrimarySidebar from './sidebarComponents/Primary';
import SecondarySidebar from './sidebarComponents/Secondary';
import PrimarySidebar from './sidebarComponents/Primary.vue';
import SecondarySidebar from './sidebarComponents/Secondary.vue';
import {
hasPressedAltAndCKey,
hasPressedAltAndRKey,
@@ -16,7 +16,7 @@
</template>
<script>
import { mapGetters } from 'vuex';
import Thumbnail from '../../widgets/Thumbnail';
import Thumbnail from '../../widgets/Thumbnail.vue';
export default {
components: {
@@ -109,9 +109,9 @@
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import Auth from '../../../api/auth';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import AvailabilityStatus from 'dashboard/components/layout/AvailabilityStatus';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import AvailabilityStatus from 'dashboard/components/layout/AvailabilityStatus.vue';
export default {
components: {
@@ -39,11 +39,11 @@
</div>
</template>
<script>
import Logo from './Logo';
import PrimaryNavItem from './PrimaryNavItem';
import OptionsMenu from './OptionsMenu';
import AgentDetails from './AgentDetails';
import NotificationBell from './NotificationBell';
import Logo from './Logo.vue';
import PrimaryNavItem from './PrimaryNavItem.vue';
import OptionsMenu from './OptionsMenu.vue';
import AgentDetails from './AgentDetails.vue';
import NotificationBell from './NotificationBell.vue';
import wootConstants from 'dashboard/constants/globals';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { ACCOUNT_EVENTS } from '../../../helper/AnalyticsHelper/events';
@@ -97,7 +97,7 @@ import {
getInboxWarningIconClass,
} from 'dashboard/helper/inbox';
import SecondaryChildNavItem from './SecondaryChildNavItem';
import SecondaryChildNavItem from './SecondaryChildNavItem.vue';
import {
isOnMentionsView,
isOnUnattendedView,
@@ -28,8 +28,8 @@
</button>
</template>
<script>
import Spinner from 'shared/components/Spinner';
import EmojiOrIcon from 'shared/components/EmojiOrIcon';
import Spinner from 'shared/components/Spinner.vue';
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
export default {
name: 'WootButton',
@@ -108,7 +108,7 @@
<script>
import AutomationActionTeamMessageInput from './AutomationActionTeamMessageInput.vue';
import AutomationActionFileInput from './AutomationFileInput.vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
export default {
components: {
AutomationActionTeamMessageInput,
@@ -26,7 +26,7 @@
</template>
<script>
import Spinner from 'shared/components/Spinner';
import Spinner from 'shared/components/Spinner.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
@@ -61,11 +61,9 @@ export default {
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADING');
try {
const file = event.target.files[0];
const formData = new FormData();
formData.append('attachment', file, file.name);
const id = await this.$store.dispatch(
'automations/uploadAttachment',
formData
file
);
this.$emit('input', [id]);
this.uploadState = 'uploaded';
@@ -7,7 +7,7 @@
/>
</template>
<script>
import ChannelSelector from '../ChannelSelector';
import ChannelSelector from '../ChannelSelector.vue';
export default {
components: { ChannelSelector },
props: {
@@ -21,7 +21,7 @@
</template>
<script>
import LoadingState from 'dashboard/components/widgets/LoadingState';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
export default {
components: {
LoadingState,
@@ -30,9 +30,9 @@
</template>
<script>
import AddLabel from 'shared/components/ui/dropdown/AddLabel';
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import { mixin as clickaway } from 'vue-clickaway';
import adminMixin from 'dashboard/mixins/isAdmin';
import {
@@ -42,7 +42,7 @@
* Badge - Chat source indication { fb / telegram }
* Username - Username for avatar
*/
import Avatar from './Avatar';
import Avatar from './Avatar.vue';
import { removeEmoji } from 'shared/helpers/emoji';
export default {
@@ -17,7 +17,7 @@
</div>
</template>
<script>
import Thumbnail from './Thumbnail';
import Thumbnail from './Thumbnail.vue';
export default {
components: {
@@ -1,5 +1,5 @@
<template>
<div class="flex items-center text-left">
<div class="flex items-center gap-1.5 text-left">
<thumbnail
:src="user.thumbnail"
:size="size"
@@ -7,7 +7,7 @@
:status="user.availability_status"
/>
<h6
class="my-0 mx-2 dark:text-slate-100 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
class="my-0 dark:text-slate-100 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
:class="textClass"
>
{{ user.name }}
@@ -15,6 +15,13 @@
:search-key="variableSearchTerm"
@click="insertVariable"
/>
<input
ref="imageUpload"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
hidden
@change="onFileChange"
/>
<div ref="editor" />
</div>
</template>
@@ -22,7 +29,7 @@
<script>
import {
messageSchema,
wootMessageWriterSetup,
buildEditor,
EditorView,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
@@ -34,11 +41,16 @@ import {
triggerCharacters,
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
import TagAgents from '../conversation/TagAgents';
import CannedResponse from '../conversation/CannedResponse';
import VariableList from '../conversation/VariableList';
import TagAgents from '../conversation/TagAgents.vue';
import CannedResponse from '../conversation/CannedResponse.vue';
import VariableList from '../conversation/VariableList.vue';
import {
appendSignature,
removeSignature,
} from 'dashboard/helper/editorHelper';
const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
import {
hasPressedEnterAndNotCmdOrShift,
@@ -51,14 +63,27 @@ import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import alertMixin from 'shared/mixins/alertMixin';
import { findNodeToInsertImage } from 'dashboard/helper/messageEditorHelper';
import { MESSAGE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
const createState = (content, placeholder, plugins = []) => {
const createState = (
content,
placeholder,
plugins = [],
methods = {},
enabledMenuOptions
) => {
return EditorState.create({
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
plugins: wootMessageWriterSetup({
plugins: buildEditor({
schema: messageSchema,
placeholder,
methods,
plugins,
enabledMenuOptions,
}),
});
};
@@ -66,7 +91,7 @@ const createState = (content, placeholder, plugins = []) => {
export default {
name: 'WootMessageEditor',
components: { TagAgents, CannedResponse, VariableList },
mixins: [eventListenerMixins, uiSettingsMixin],
mixins: [eventListenerMixins, uiSettingsMixin, alertMixin],
props: {
value: { type: String, default: '' },
editorId: { type: String, default: '' },
@@ -78,6 +103,11 @@ export default {
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
variables: { type: Object, default: () => ({}) },
enabledMenuOptions: { type: Array, default: () => [] },
signature: { type: String, default: '' },
// allowSignature is a kill switch, ensuring no signature methods
// are triggered except when this flag is true
allowSignature: { type: Boolean, default: false },
},
data() {
return {
@@ -104,6 +134,11 @@ export default {
this.enableCannedResponses && this.showCannedMenu && !this.isPrivate
);
},
editorMenuOptions() {
return this.enabledMenuOptions.length
? this.enabledMenuOptions
: MESSAGE_EDITOR_MENU_OPTIONS;
},
plugins() {
if (!this.enableSuggestions) {
return [];
@@ -193,6 +228,12 @@ export default {
}),
];
},
sendWithSignature() {
// this is considered the source of truth, we watch this property
// on change, we toggle the signature in the editor
const { send_with_signature: isEnabled } = this.uiSettings;
return isEnabled && this.allowSignature && !this.isPrivate;
},
},
watch: {
showUserMentions(updatedValue) {
@@ -204,20 +245,19 @@ export default {
showVariables(updatedValue) {
this.$emit('toggle-variables-menu', !this.isPrivate && updatedValue);
},
value(newValue = '') {
if (newValue !== this.contentFromEditor) {
this.reloadState();
value(newVal = '') {
if (newVal !== this.contentFromEditor) {
this.reloadState(newVal);
}
},
editorId() {
this.showCannedMenu = false;
this.cannedSearchTerm = '';
this.reloadState();
this.reloadState(this.value);
},
isPrivate() {
this.reloadState();
this.reloadState(this.value);
},
updateSelectionWith(newValue, oldValue) {
if (!this.editorView) {
return null;
@@ -236,20 +276,106 @@ export default {
}
return null;
},
sendWithSignature(newValue) {
// see if the allowSignature flag is true
if (this.allowSignature) {
this.toggleSignatureInEditor(newValue);
}
},
},
created() {
this.state = createState(this.value, this.placeholder, this.plugins);
this.state = createState(
this.value,
this.placeholder,
this.plugins,
{ onImageUpload: this.openFileBrowser },
this.editorMenuOptions
);
},
mounted() {
this.createEditorView();
this.editorView.updateState(this.state);
this.focusEditorInputField();
this.focusEditor(this.value);
},
methods: {
reloadState() {
this.state = createState(this.value, this.placeholder, this.plugins);
reloadState(content = this.value) {
this.state = createState(
content,
this.placeholder,
this.plugins,
{ onImageUpload: this.openFileBrowser },
this.editorMenuOptions
);
this.editorView.updateState(this.state);
this.focusEditorInputField();
this.focusEditor(content);
},
focusEditor(content) {
if (this.isBodyEmpty(content) && this.sendWithSignature) {
// reload state can be called when switching between conversations, or when drafts is loaded
// these drafts can also have a signature, so we need to check if the body is empty
// and handle things accordingly
this.handleEmptyBodyWithSignature();
} else {
// this is in the else block, handleEmptyBodyWithSignature also has a call to the focus method
// the position is set to start, because the signature is added at the end of the body
this.focusEditorInputField('end');
}
},
toggleSignatureInEditor(signatureEnabled) {
// The toggleSignatureInEditor gets the new value from the
// watcher, this means that if the value is true, the signature
// is supposed to be added, else we remove it.
if (signatureEnabled) {
this.addSignature();
} else {
this.removeSignature();
}
},
addSignature() {
let content = this.value;
// see if the content is empty, if it is before appending the signature
// we need to add a paragraph node and move the cursor at the start of the editor
const contentWasEmpty = this.isBodyEmpty(content);
content = appendSignature(content, this.signature);
// need to reload first, ensuring that the editorView is updated
this.reloadState(content);
if (contentWasEmpty) {
this.handleEmptyBodyWithSignature();
}
},
removeSignature() {
if (!this.signature) return;
let content = this.value;
content = removeSignature(content, this.signature);
// reload the state, ensuring that the editorView is updated
this.reloadState(content);
},
isBodyEmpty(content) {
// if content is undefined, we assume that the body is empty
if (!content) return true;
// if the signature is present, we need to remove it before checking
// note that we don't update the editorView, so this is safe
const bodyWithoutSignature = this.signature
? removeSignature(content, this.signature)
: content;
// trimming should remove all the whitespaces, so we can check the length
return bodyWithoutSignature.trim().length === 0;
},
handleEmptyBodyWithSignature() {
const { schema, tr } = this.state;
// create a paragraph node and
// start a transaction to append it at the end
const paragraph = schema.nodes.paragraph.create();
const paragraphTransaction = tr.insert(0, paragraph);
this.editorView.dispatch(paragraphTransaction);
// Set the focus at the start of the input field
this.focusEditorInputField('start');
},
createEditorView() {
this.editorView = new EditorView(this.$refs.editor, {
@@ -294,9 +420,11 @@ export default {
this.focusEditorInputField();
}
},
focusEditorInputField() {
focusEditorInputField(pos = 'end') {
const { tr } = this.editorView.state;
const selection = Selection.atEnd(tr.doc);
const selection =
pos === 'end' ? Selection.atEnd(tr.doc) : Selection.atStart(tr.doc);
this.editorView.dispatch(tr.setSelection(selection));
this.editorView.focus();
@@ -374,6 +502,57 @@ export default {
tr.scrollIntoView();
return false;
},
openFileBrowser() {
this.$refs.imageUpload.click();
},
onFileChange() {
const file = this.$refs.imageUpload.files[0];
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
this.uploadImageToStorage(file);
} else {
this.showAlert(
this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SIZE_ERROR',
{
size: MAXIMUM_FILE_UPLOAD_SIZE,
}
)
);
}
this.$refs.imageUpload.value = '';
},
async uploadImageToStorage(file) {
try {
const { fileUrl } = await uploadFile(file);
if (fileUrl) {
this.onImageInsertInEditor(fileUrl);
}
this.showAlert(
this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS'
)
);
} catch (error) {
this.showAlert(
this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_ERROR'
)
);
}
},
onImageInsertInEditor(fileUrl) {
const { tr } = this.editorView.state;
const insertData = findNodeToInsertImage(this.editorView.state, fileUrl);
if (insertData) {
this.editorView.dispatch(
tr.insert(insertData.pos, insertData.node).scrollIntoView()
);
this.focusEditorInputField();
}
},
emitOnChange() {
this.editorView.updateState(this.state);
@@ -491,6 +670,10 @@ export default {
.ProseMirror-prompt {
@apply z-50 bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800;
h5 {
@apply dark:text-slate-25 text-slate-800;
}
}
.is-private {
@@ -16,7 +16,7 @@
<script>
import {
fullSchema,
wootArticleWriterSetup,
buildEditor,
EditorView,
ArticleMarkdownSerializer,
ArticleMarkdownTransformer,
@@ -33,15 +33,17 @@ const createState = (
content,
placeholder,
plugins = [],
onImageUpload = () => {}
methods = {},
enabledMenuOptions
) => {
return EditorState.create({
doc: new ArticleMarkdownTransformer(fullSchema).parse(content),
plugins: wootArticleWriterSetup({
plugins: buildEditor({
schema: fullSchema,
placeholder,
methods,
plugins,
onImageUpload,
enabledMenuOptions,
}),
});
};
@@ -52,6 +54,7 @@ export default {
value: { type: String, default: '' },
editorId: { type: String, default: '' },
placeholder: { type: String, default: '' },
enabledMenuOptions: { type: Array, default: () => [] },
},
data() {
return {
@@ -83,7 +86,8 @@ export default {
this.value,
this.placeholder,
this.plugins,
this.openFileBrowser
{ onImageUpload: this.openFileBrowser },
this.enabledMenuOptions
);
},
mounted() {
@@ -152,7 +156,8 @@ export default {
this.value,
this.placeholder,
this.plugins,
this.openFileBrowser
{ onImageUpload: this.openFileBrowser },
this.enabledMenuOptions
);
this.editorView.updateState(this.state);
this.focusEditorInputField();
@@ -134,7 +134,7 @@ import {
ALLOWED_FILE_TYPES,
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
} from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton';
import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue';
import { REPLY_EDITOR_MODES } from './constants';
import { mapGetters } from 'vuex';
@@ -288,7 +288,7 @@ export default {
}
},
showMessageSignatureButton() {
return !this.isOnPrivateNote && this.isAnEmailChannel;
return !this.isOnPrivateNote;
},
sendWithSignature() {
const { send_with_signature: isEnabled } = this.uiSettings;
@@ -358,6 +358,7 @@ export default {
applied_filters: this.appliedFilters.map(filter => ({
key: filter.attribute_key,
operator: filter.filter_operator,
query_operator: filter.query_operator,
})),
});
},
@@ -46,7 +46,7 @@
import wootConstants from 'dashboard/constants/globals';
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import FilterItem from './FilterItem';
import FilterItem from './FilterItem.vue';
export default {
components: {
@@ -58,11 +58,11 @@
</template>
<script>
import { mapGetters } from 'vuex';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel';
import ConversationHeader from './ConversationHeader';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import ConversationHeader from './ConversationHeader.vue';
import DashboardAppFrame from '../DashboardApp/Frame.vue';
import EmptyState from './EmptyState/EmptyState';
import MessagesView from './MessagesView';
import EmptyState from './EmptyState/EmptyState.vue';
import MessagesView from './MessagesView.vue';
export default {
components: {
@@ -147,16 +147,16 @@
import { mapGetters } from 'vuex';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import Thumbnail from '../Thumbnail';
import Thumbnail from '../Thumbnail.vue';
import conversationMixin from '../../../mixins/conversations';
import timeMixin from '../../../mixins/time';
import router from '../../../routes';
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
import InboxName from '../InboxName';
import InboxName from '../InboxName.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import ConversationContextMenu from './contextMenu/Index.vue';
import alertMixin from 'shared/mixins/alertMixin';
import TimeAgo from 'dashboard/components/ui/TimeAgo';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue';
import PriorityMark from './PriorityMark.vue';
const ATTACHMENT_ICONS = {
@@ -73,12 +73,12 @@
import { hasPressedAltAndOKey } from 'shared/helpers/KeyboardHelpers';
import { mapGetters } from 'vuex';
import agentMixin from '../../../mixins/agentMixin.js';
import BackButton from '../BackButton';
import BackButton from '../BackButton.vue';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName';
import MoreActions from './MoreActions';
import Thumbnail from '../Thumbnail';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
import Thumbnail from '../Thumbnail.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { conversationReopenTime } from 'dashboard/helper/snoozeHelpers';
@@ -37,8 +37,8 @@
import { mapGetters } from 'vuex';
import adminMixin from 'dashboard/mixins/isAdmin';
import accountMixin from 'dashboard/mixins/account';
import OnboardingView from '../OnboardingView';
import EmptyStateMessage from './EmptyStateMessage';
import OnboardingView from '../OnboardingView.vue';
import EmptyStateMessage from './EmptyStateMessage.vue';
export default {
components: {
@@ -27,7 +27,7 @@
</template>
<script>
import Hotkey from 'dashboard/components/base/Hotkey';
import Hotkey from 'dashboard/components/base/Hotkey.vue';
export default {
components: {
@@ -130,18 +130,18 @@
</template>
<script>
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import BubbleActions from './bubble/Actions';
import BubbleFile from './bubble/File';
import BubbleImage from './bubble/Image';
import BubbleVideo from './bubble/Video';
import BubbleImageAudioVideo from './bubble/ImageAudioVideo';
import BubbleActions from './bubble/Actions.vue';
import BubbleFile from './bubble/File.vue';
import BubbleImage from './bubble/Image.vue';
import BubbleVideo from './bubble/Video.vue';
import BubbleImageAudioVideo from './bubble/ImageAudioVideo.vue';
import BubbleIntegration from './bubble/Integration.vue';
import BubbleLocation from './bubble/Location';
import BubbleMailHead from './bubble/MailHead';
import BubbleText from './bubble/Text';
import BubbleContact from './bubble/Contact';
import Spinner from 'shared/components/Spinner';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu';
import BubbleLocation from './bubble/Location.vue';
import BubbleMailHead from './bubble/MailHead.vue';
import BubbleText from './bubble/Text.vue';
import BubbleContact from './bubble/Contact.vue';
import Spinner from 'shared/components/Spinner.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
import instagramImageErrorPlaceholder from './instagramImageErrorPlaceholder.vue';
import alertMixin from 'shared/mixins/alertMixin';
import contentTypeMixin from 'shared/mixins/contentTypeMixin';
@@ -98,9 +98,9 @@
<script>
// components
import ReplyBox from './ReplyBox';
import Message from './Message';
import ConversationLabelSuggestion from './conversation/LabelSuggestion';
import ReplyBox from './ReplyBox.vue';
import Message from './Message.vue';
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
// stores and apis
@@ -39,8 +39,8 @@
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import EmailTranscriptModal from './EmailTranscriptModal';
import ResolveAction from '../../buttons/ResolveAction';
import EmailTranscriptModal from './EmailTranscriptModal.vue';
import ResolveAction from '../../buttons/ResolveAction.vue';
import {
CMD_MUTE_CONVERSATION,
CMD_SEND_TRANSCRIPT,
@@ -53,6 +53,9 @@
class="input"
:placeholder="messagePlaceHolder"
:min-height="4"
:signature="signatureToApply"
:allow-signature="true"
:send-with-signature="sendWithSignature"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@@ -69,6 +72,8 @@
:min-height="4"
:enable-variables="true"
:variables="messageVariables"
:signature="signatureToApply"
:allow-signature="true"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@@ -86,16 +91,10 @@
/>
</div>
<div
v-if="isSignatureEnabledForInbox"
v-tooltip="$t('CONVERSATION.FOOTER.MESSAGE_SIGN_TOOLTIP')"
v-if="isSignatureEnabledForInbox && !isSignatureAvailable"
class="message-signature-wrap"
>
<p
v-if="isSignatureAvailable"
v-dompurify-html="formatMessage(messageSignature)"
class="message-signature"
/>
<p v-else class="message-signature">
<p class="message-signature">
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
<router-link :to="profilePath">
{{ $t('CONVERSATION.FOOTER.CLICK_HERE') }}
@@ -149,23 +148,18 @@ import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import CannedResponse from './CannedResponse';
import ResizableTextArea from 'shared/components/ResizableTextArea';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel';
import ReplyEmailHead from './ReplyEmailHead';
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel';
import CannedResponse from './CannedResponse.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue';
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
import WootAudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import WootAudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import {
MAXIMUM_FILE_UPLOAD_SIZE,
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL,
AUDIO_FORMATS,
} from 'shared/constants/messages';
import { AUDIO_FORMATS } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
getMessageVariables,
@@ -177,13 +171,19 @@ import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin from 'shared/mixins/inboxMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { DirectUpload } from 'activestorage';
import { frontendURL } from '../../../helper/URLHelper';
import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import rtlMixin from 'shared/mixins/rtlMixin';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
appendSignature,
removeSignature,
replaceSignature,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
const EmojiInput = () => import('shared/components/emoji/EmojiInput');
@@ -208,6 +208,7 @@ export default {
alertMixin,
messageFormatterMixin,
rtlMixin,
fileUploadMixin,
],
props: {
selectedTweet: {
@@ -471,10 +472,10 @@ export default {
);
},
isSignatureEnabledForInbox() {
return !this.isPrivate && this.isAnEmailChannel && this.sendWithSignature;
return !this.isPrivate && this.sendWithSignature;
},
isSignatureAvailable() {
return !!this.messageSignature;
return !!this.signatureToApply;
},
sendWithSignature() {
const { send_with_signature: isEnabled } = this.uiSettings;
@@ -514,6 +515,12 @@ export default {
});
return variables;
},
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
},
},
watch: {
currentChat(conversation) {
@@ -581,6 +588,23 @@ export default {
this.updateUISettings({
display_rich_content_editor: !this.showRichContentEditor,
});
const plainTextSignature = extractTextFromMarkdown(this.messageSignature);
if (!this.showRichContentEditor && this.messageSignature) {
// remove the old signature -> extract text from markdown -> attach new signature
let message = removeSignature(this.message, this.messageSignature);
message = extractTextFromMarkdown(message);
message = appendSignature(message, plainTextSignature);
this.message = message;
} else {
this.message = replaceSignature(
this.message,
plainTextSignature,
this.messageSignature
);
}
},
saveDraft(conversationId, replyType) {
if (this.message || this.message === '') {
@@ -600,9 +624,22 @@ export default {
getFromDraft() {
if (this.conversationIdByRoute) {
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
this.message = this.$store.getters['draftMessages/get'](key) || '';
const messageFromStore =
this.$store.getters['draftMessages/get'](key) || '';
// ensure that the message has signature set based on the ui setting
this.message = this.toggleSignatureForDraft(messageFromStore);
}
},
toggleSignatureForDraft(message) {
if (this.isPrivate) {
return message;
}
return this.sendWithSignature
? appendSignature(message, this.signatureToApply)
: removeSignature(message, this.signatureToApply);
},
removeFromDraft() {
if (this.conversationIdByRoute) {
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
@@ -694,19 +731,14 @@ export default {
return;
}
if (!this.showMentions) {
let newMessage = this.message;
if (this.isSignatureEnabledForInbox && this.messageSignature) {
newMessage += '\n\n' + this.messageSignature;
}
const isOnWhatsApp =
this.isATwilioWhatsAppChannel ||
this.isAWhatsAppCloudChannel ||
this.is360DialogWhatsAppChannel;
if (isOnWhatsApp && !this.isPrivate) {
this.sendMessageAsMultipleMessages(newMessage);
this.sendMessageAsMultipleMessages(this.message);
} else {
const messagePayload = this.getMessagePayload(newMessage);
const messagePayload = this.getMessagePayload(this.message);
this.sendMessage(messagePayload);
}
@@ -828,6 +860,10 @@ export default {
},
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
this.message = appendSignature(this.message, this.signatureToApply);
}
this.attachedFiles = [];
this.isRecordingAudio = false;
},
@@ -904,67 +940,6 @@ export default {
isPrivate,
});
},
onFileUpload(file) {
if (this.globalConfig.directUploadsEnabled) {
this.onDirectFileUpload(file);
} else {
this.onIndirectFileUpload(file);
}
},
onDirectFileUpload(file) {
const MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE = this.isATwilioSMSChannel
? MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
: MAXIMUM_FILE_UPLOAD_SIZE;
if (!file) {
return;
}
if (checkFileSizeLimit(file, MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE)) {
const upload = new DirectUpload(
file.file,
`/api/v1/accounts/${this.accountId}/conversations/${this.currentChat.id}/direct_uploads`,
{
directUploadWillCreateBlobWithXHR: xhr => {
xhr.setRequestHeader(
'api_access_token',
this.currentUser.access_token
);
},
}
);
upload.create((error, blob) => {
if (error) {
this.showAlert(error);
} else {
this.attachFile({ file, blob });
}
});
} else {
this.showAlert(
this.$t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE,
})
);
}
},
onIndirectFileUpload(file) {
const MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE = this.isATwilioSMSChannel
? MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
: MAXIMUM_FILE_UPLOAD_SIZE;
if (!file) {
return;
}
if (checkFileSizeLimit(file, MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE)) {
this.attachFile({ file });
} else {
this.showAlert(
this.$t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE,
})
);
}
},
attachFile({ blob, file }) {
const reader = new FileReader();
reader.readAsDataURL(file.file);
@@ -31,7 +31,7 @@
import { mapGetters } from 'vuex';
import { hasPressedCommand } from 'shared/helpers/KeyboardHelpers';
import GalleryView from '../components/GalleryView';
import GalleryView from '../components/GalleryView.vue';
const ALLOWED_FILE_TYPES = {
IMAGE: 'image',
@@ -150,7 +150,7 @@ import {
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import timeMixin from 'dashboard/mixins/time';
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
const ALLOWED_FILE_TYPES = {
IMAGE: 'image',
@@ -6,7 +6,7 @@
</template>
<script>
import Spinner from 'shared/components/Spinner';
import Spinner from 'shared/components/Spinner.vue';
export default {
components: {
@@ -96,7 +96,7 @@
<script>
import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Spinner from 'shared/components/Spinner';
import Spinner from 'shared/components/Spinner.vue';
import { mixin as clickaway } from 'vue-clickaway';
export default {
@@ -23,7 +23,7 @@
<script>
import { required } from 'vuelidate/lib/validators';
import Modal from '../../Modal';
import Modal from '../../Modal.vue';
export default {
components: {
@@ -14,7 +14,7 @@
</modal>
</template>
<script>
import Modal from '../../Modal';
import Modal from '../../Modal.vue';
export default {
components: {
@@ -22,7 +22,7 @@
</template>
<script>
import Modal from '../../Modal';
import Modal from '../../Modal.vue';
export default {
components: {
@@ -95,7 +95,7 @@
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { SHORTCUT_KEYS } from './constants';
import Hotkey from 'dashboard/components/base/Hotkey';
import Hotkey from 'dashboard/components/base/Hotkey.vue';
export default {
components: {
@@ -0,0 +1,34 @@
export const MESSAGE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
'bulletList',
'orderedList',
'code',
];
export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
'imageUpload',
];
export const ARTICLE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
'bulletList',
'orderedList',
'h1',
'h2',
'h3',
'imageUpload',
'code',
];
@@ -120,9 +120,18 @@ export const generateConditionOptions = (options, key = 'id') => {
});
};
// Add the "None" option to the agent list
export const agentList = agents => [
{
id: 'nil',
name: 'None',
},
...(agents || []),
];
export const getActionOptions = ({ agents, teams, labels, type }) => {
const actionsMap = {
assign_agent: agents,
assign_agent: agentList(agents),
assign_team: teams,
send_email_to_team: teams,
add_label: generateConditionOptions(labels, 'title'),
@@ -0,0 +1,141 @@
import {
messageSchema,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
} from '@chatwoot/prosemirror-schema';
/**
* The delimiter used to separate the signature from the rest of the body.
* @type {string}
*/
export const SIGNATURE_DELIMITER = '--';
/**
* Parse and Serialize the markdown text to remove any extra spaces or new lines
*/
export function cleanSignature(signature) {
// convert from markdown to common mark format
const nodes = new MessageMarkdownTransformer(messageSchema).parse(signature);
return MessageMarkdownSerializer.serialize(nodes);
}
/**
* Adds the signature delimiter to the beginning of the signature.
*
* @param {string} signature - The signature to add the delimiter to.
* @returns {string} - The signature with the delimiter added.
*/
function appendDelimiter(signature) {
return `${SIGNATURE_DELIMITER}\n\n${cleanSignature(signature)}`;
}
/**
* Check if there's an unedited signature at the end of the body
* If there is, return the index of the signature, If there isn't, return -1
*
* @param {string} body - The body to search for the signature.
* @param {string} signature - The signature to search for.
* @returns {number} - The index of the last occurrence of the signature in the body, or -1 if not found.
*/
export function findSignatureInBody(body, signature) {
const trimmedBody = body.trimEnd();
const cleanedSignature = cleanSignature(signature);
// check if body ends with signature
if (trimmedBody.endsWith(cleanedSignature)) {
return body.lastIndexOf(cleanedSignature);
}
return -1;
}
/**
* Appends the signature to the body, separated by the signature delimiter.
*
* @param {string} body - The body to append the signature to.
* @param {string} signature - The signature to append.
* @returns {string} - The body with the signature appended.
*/
export function appendSignature(body, signature) {
const cleanedSignature = cleanSignature(signature);
// if signature is already present, return body
if (findSignatureInBody(body, cleanedSignature) > -1) {
return body;
}
return `${body.trimEnd()}\n\n${appendDelimiter(cleanedSignature)}`;
}
/**
* Removes the signature from the body, along with the signature delimiter.
*
* @param {string} body - The body to remove the signature from.
* @param {string} signature - The signature to remove.
* @returns {string} - The body with the signature removed.
*/
export function removeSignature(body, signature) {
// this will find the index of the signature if it exists
// Regardless of extra spaces or new lines after the signature, the index will be the same if present
const cleanedSignature = cleanSignature(signature);
const signatureIndex = findSignatureInBody(body, cleanedSignature);
// no need to trim the ends here, because it will simply be removed in the next method
let newBody = body;
// if signature is present, remove it and trim it
// trimming will ensure any spaces or new lines before the signature are removed
// This means we will have the delimiter at the end
if (signatureIndex > -1) {
newBody = newBody.substring(0, signatureIndex).trimEnd();
}
// let's find the delimiter and remove it
const delimiterIndex = newBody.lastIndexOf(SIGNATURE_DELIMITER);
if (
delimiterIndex !== -1 &&
delimiterIndex === newBody.length - SIGNATURE_DELIMITER.length // this will ensure the delimiter is at the end
) {
// if the delimiter is at the end, remove it
newBody = newBody.substring(0, delimiterIndex);
}
// return the value
return newBody;
}
/**
* Replaces the old signature with the new signature.
* If the old signature is not present, it will append the new signature.
*
* @param {string} body - The body to replace the signature in.
* @param {string} oldSignature - The signature to replace.
* @param {string} newSignature - The signature to replace the old signature with.
* @returns {string} - The body with the old signature replaced with the new signature.
*
*/
export function replaceSignature(body, oldSignature, newSignature) {
const withoutSignature = removeSignature(body, oldSignature);
return appendSignature(withoutSignature, newSignature);
}
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
* Links will be converted to text, and not removed.
*
* @param {string} markdown - markdown text to be extracted
* @returns
*/
export function extractTextFromMarkdown(markdown) {
return markdown
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/`.*?`/g, '') // Remove inline code
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n') // Trim each line & remove any lines only having spaces
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
.trim(); // Trim any extra space
}
@@ -1,21 +1,34 @@
const setArrayValues = item => {
return item.values[0]?.id ? item.values.map(val => val.id) : item.values;
};
const generateValues = item => {
if (item.attribute_key === 'content') {
const values = item.values || '';
return values.split(',');
}
if (Array.isArray(item.values)) {
return setArrayValues(item);
}
if (typeof item.values === 'object') {
return [item.values.id];
}
if (!item.values) {
return [];
}
return [item.values];
};
const generatePayload = data => {
// Make a copy of data to avoid vue data reactivity issues
const filters = JSON.parse(JSON.stringify(data));
let payload = filters.map(item => {
if (Array.isArray(item.values)) {
item.values = setArrayValues(item);
} else if (typeof item.values === 'object') {
item.values = [item.values.id];
} else if (!item.values) {
item.values = [];
} else {
item.values = [item.values];
}
// If item key is content, we will split it using comma and return as array
// FIX ME: Make this generic option instead of using the key directly here
item.values = generateValues(item);
return item;
});
// For every query added, the query_operator is set default to and so the
// last query will have an extra query_operator, this would break the api.
// Setting this to null for all query payload
@@ -0,0 +1,40 @@
/**
* Determines the appropriate node and position to insert an image in the editor.
*
* Based on the current editor state and the provided image URL, this function finds out the correct node (either
* a standalone image node or an image wrapped in a paragraph) and its respective position in the editor.
*
* 1. If the current node is a paragraph and doesn't contain an image or text, the image is inserted directly into it.
* 2. If the current node isn't a paragraph or it's a paragraph containing text, the image will be wrapped
* in a new paragraph and then inserted.
* 3. If the current node is a paragraph containing an image, the new image will be inserted directly into it.
*
* @param {Object} editorState - The current state of the editor. It provides necessary details like selection, schema, etc.
* @param {string} fileUrl - The URL of the image to be inserted into the editor.
* @returns {Object|null} An object containing details about the node to be inserted and its position. It returns null if no image node can be created.
* @property {Node} node - The ProseMirror node to be inserted (either an image node or a paragraph containing the image).
* @property {number} pos - The position where the new node should be inserted in the editor.
*/
export const findNodeToInsertImage = (editorState, fileUrl) => {
const { selection, schema } = editorState;
const { nodes } = schema;
const currentNode = selection.$from.node();
const {
type: { name: typeName },
content: { size, content },
} = currentNode;
const imageNode = nodes.image.create({ src: fileUrl });
if (!imageNode) return null;
const isInParagraph = typeName === 'paragraph';
const needsNewLine =
!content.some(n => n.type.name === 'image') && size !== 0 ? 1 : 0;
return {
node: isInParagraph ? imageNode : nodes.paragraph.create({}, imageNode),
pos: selection.from + needsNewLine,
};
};
@@ -49,3 +49,38 @@ export const validateLoggedInRoutes = (to, user, roleWiseRoutes) => {
// Proceed to the route if none of the above conditions are met
return null;
};
export const isAConversationRoute = routeName =>
[
'inbox_conversation',
'conversation_through_mentions',
'conversation_through_unattended',
'conversation_through_inbox',
'conversations_through_label',
'conversations_through_team',
'conversations_through_folders',
'conversation_through_participating',
].includes(routeName);
export const getConversationDashboardRoute = routeName => {
switch (routeName) {
case 'inbox_conversation':
return 'home';
case 'conversation_through_mentions':
return 'conversation_mentions';
case 'conversation_through_unattended':
return 'conversation_unattended';
case 'conversations_through_label':
return 'label_conversations';
case 'conversations_through_team':
return 'team_conversations';
case 'conversations_through_folders':
return 'folder_conversations';
case 'conversation_through_participating':
return 'conversation_participating';
case 'conversation_through_inbox':
return 'inbox_dashboard';
default:
return null;
}
};
@@ -0,0 +1,171 @@
import {
findSignatureInBody,
appendSignature,
removeSignature,
replaceSignature,
cleanSignature,
extractTextFromMarkdown,
} from '../editorHelper';
const NEW_SIGNATURE = 'This is a new signature';
const DOES_NOT_HAVE_SIGNATURE = {
'no signature': {
body: 'This is a test',
signature: 'This is a signature',
},
'text after signature': {
body: 'This is a test\n\n--\n\nThis is a signature\n\nThis is more text',
signature: 'This is a signature',
},
'signature has images': {
body: 'This is a test',
signature:
'Testing\n![](http://localhost:3000/rails/active_storage/blobs/redirect/some-hash/image.png)',
},
'signature has non commonmark syntax': {
body: 'This is a test',
signature: '- Signature',
},
'signature has trailing spaces': {
body: 'This is a test',
signature: '**hello** \n**world**',
},
};
const HAS_SIGNATURE = {
'signature at end': {
body: 'This is a test\n\n--\n\nThis is a signature',
signature: 'This is a signature',
},
'signature at end with spaces and new lines': {
body: 'This is a test\n\n--\n\nThis is a signature \n\n',
signature: 'This is a signature ',
},
'no text before signature': {
body: '\n\n--\n\nThis is a signature',
signature: 'This is a signature',
},
'signature has non-commonmark syntax': {
body: '\n\n--\n\n* Signature',
signature: '- Signature',
},
};
describe('findSignatureInBody', () => {
it('returns -1 if there is no signature', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
expect(findSignatureInBody(body, signature)).toBe(-1);
});
});
it('returns the index of the signature if there is one', () => {
Object.keys(HAS_SIGNATURE).forEach(key => {
const { body, signature } = HAS_SIGNATURE[key];
expect(findSignatureInBody(body, signature)).toBeGreaterThan(0);
});
});
});
describe('appendSignature', () => {
it('appends the signature if it is not present', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
const cleanedSignature = cleanSignature(signature);
expect(
appendSignature(body, signature).includes(cleanedSignature)
).toBeTruthy();
});
});
it('does not append signature if already present', () => {
Object.keys(HAS_SIGNATURE).forEach(key => {
const { body, signature } = HAS_SIGNATURE[key];
expect(appendSignature(body, signature)).toBe(body);
});
});
});
describe('removeSignature', () => {
it('does not remove signature if not present', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
expect(removeSignature(body, signature)).toBe(body);
});
});
it('removes signature if present at the end', () => {
const { body, signature } = HAS_SIGNATURE['signature at end'];
expect(removeSignature(body, signature)).toBe('This is a test\n\n');
});
it('removes signature if present with spaces and new lines', () => {
const { body, signature } = HAS_SIGNATURE[
'signature at end with spaces and new lines'
];
expect(removeSignature(body, signature)).toBe('This is a test\n\n');
});
it('removes signature if present without text before it', () => {
const { body, signature } = HAS_SIGNATURE['no text before signature'];
expect(removeSignature(body, signature)).toBe('\n\n');
});
it('removes just the delimiter if no signature is present', () => {
expect(removeSignature('This is a test\n\n--', 'This is a signature')).toBe(
'This is a test\n\n'
);
});
});
describe('replaceSignature', () => {
it('appends the new signature if not present', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
`${body}\n\n--\n\n${NEW_SIGNATURE}`
);
});
});
it('removes signature if present at the end', () => {
const { body, signature } = HAS_SIGNATURE['signature at end'];
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
`This is a test\n\n--\n\n${NEW_SIGNATURE}`
);
});
it('removes signature if present with spaces and new lines', () => {
const { body, signature } = HAS_SIGNATURE[
'signature at end with spaces and new lines'
];
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
`This is a test\n\n--\n\n${NEW_SIGNATURE}`
);
});
it('removes signature if present without text before it', () => {
const { body, signature } = HAS_SIGNATURE['no text before signature'];
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
`\n\n--\n\n${NEW_SIGNATURE}`
);
});
});
describe('extractTextFromMarkdown', () => {
it('should extract text from markdown and remove all images, code blocks, links, headers, bold, italic, lists etc.', () => {
const markdown = `
# Hello World
This is a **bold** text with a [link](https://example.com).
\`\`\`javascript
const foo = 'bar';
console.log(foo);
\`\`\`
Here's an image: ![alt text](https://example.com/image.png)
- List item 1
- List item 2
*Italic text*
`;
const expected =
"Hello World\nThis is a bold text with a link.\nHere's an image:\nList item 1\nList item 2\nItalic text";
expect(extractTextFromMarkdown(markdown)).toEqual(expected);
});
});
@@ -0,0 +1,100 @@
import { findNodeToInsertImage } from '../messageEditorHelper';
describe('findNodeToInsertImage', () => {
let mockEditorState;
beforeEach(() => {
mockEditorState = {
selection: {
$from: {
node: jest.fn(() => ({})),
},
from: 0,
},
schema: {
nodes: {
image: {
create: jest.fn(attrs => ({ type: { name: 'image' }, attrs })),
},
paragraph: {
create: jest.fn((_, node) => ({
type: { name: 'paragraph' },
content: [node],
})),
},
},
},
};
});
it('should insert image directly into an empty paragraph', () => {
const mockNode = {
type: { name: 'paragraph' },
content: { size: 0, content: [] },
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result).toEqual({
node: { type: { name: 'image' }, attrs: { src: 'image-url' } },
pos: 0,
});
});
it('should insert image directly into a paragraph without an image but with other content', () => {
const mockNode = {
type: { name: 'paragraph' },
content: {
size: 1,
content: [
{
type: { name: 'text' },
},
],
},
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
mockEditorState.selection.from = 1;
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result).toEqual({
node: { type: { name: 'image' }, attrs: { src: 'image-url' } },
pos: 2, // Because it should insert after the text, on a new line.
});
});
it("should wrap image in a new paragraph when the current node isn't a paragraph", () => {
const mockNode = {
type: { name: 'not-a-paragraph' },
content: { size: 0, content: [] },
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result.node.type.name).toBe('paragraph');
expect(result.node.content[0].type.name).toBe('image');
expect(result.node.content[0].attrs.src).toBe('image-url');
expect(result.pos).toBe(0);
});
it('should insert a new image directly into the paragraph that already contains an image', () => {
const mockNode = {
type: { name: 'paragraph' },
content: {
size: 1,
content: [
{
type: { name: 'image', attrs: { src: 'existing-image-url' } },
},
],
},
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
mockEditorState.selection.from = 1;
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result.node.type.name).toBe('image');
expect(result.node.attrs.src).toBe('image-url');
expect(result.pos).toBe(1);
});
});
@@ -1,6 +1,8 @@
import {
getConversationDashboardRoute,
getCurrentAccount,
getUserRole,
isAConversationRoute,
routeIsAccessibleFor,
validateLoggedInRoutes,
} from '../routeHelpers';
@@ -94,3 +96,41 @@ describe('#validateLoggedInRoutes', () => {
});
});
});
describe('isAConversationRoute', () => {
it('returns true if conversation route name is provided', () => {
expect(isAConversationRoute('inbox_conversation')).toBe(true);
expect(isAConversationRoute('conversation_through_inbox')).toBe(true);
expect(isAConversationRoute('conversations_through_label')).toBe(true);
expect(isAConversationRoute('conversations_through_team')).toBe(true);
expect(isAConversationRoute('dashboard')).toBe(false);
});
});
describe('getConversationDashboardRoute', () => {
it('returns dashboard route for conversation', () => {
expect(getConversationDashboardRoute('inbox_conversation')).toEqual('home');
expect(
getConversationDashboardRoute('conversation_through_mentions')
).toEqual('conversation_mentions');
expect(
getConversationDashboardRoute('conversation_through_unattended')
).toEqual('conversation_unattended');
expect(
getConversationDashboardRoute('conversations_through_label')
).toEqual('label_conversations');
expect(getConversationDashboardRoute('conversations_through_team')).toEqual(
'team_conversations'
);
expect(
getConversationDashboardRoute('conversations_through_folders')
).toEqual('folder_conversations');
expect(
getConversationDashboardRoute('conversation_through_participating')
).toEqual('conversation_participating');
expect(getConversationDashboardRoute('conversation_through_inbox')).toEqual(
'inbox_dashboard'
);
expect(getConversationDashboardRoute('non_existent_route')).toBeNull();
});
});
@@ -0,0 +1,53 @@
import { uploadFile } from '../uploadHelper';
import axios from 'axios';
// Mocking axios using jest-mock-axios
global.axios = axios;
jest.mock('axios');
describe('#Upload Helpers', () => {
afterEach(() => {
// Cleaning up the mock after each test
axios.post.mockReset();
});
it('should send a POST request with correct data', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
const mockResponse = {
data: {
file_url: 'https://example.com/fileUrl',
blob_key: 'blobKey123',
blob_id: 'blobId456',
},
};
axios.post.mockResolvedValueOnce(mockResponse);
const result = await uploadFile(mockFile, '1602');
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/accounts/1602/upload',
expect.any(FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
expect(result).toEqual({
fileUrl: 'https://example.com/fileUrl',
blobKey: 'blobKey123',
blobId: 'blobId456',
});
});
it('should handle errors', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
const mockError = new Error('Failed to upload');
axios.post.mockRejectedValueOnce(mockError);
await expect(uploadFile(mockFile)).rejects.toThrow('Failed to upload');
});
});
@@ -0,0 +1,49 @@
/* global axios */
/**
* Constants and Configuration
*/
// Version for the API endpoint.
const API_VERSION = 'v1';
// Default headers to be used in the axios request.
const HEADERS = {
'Content-Type': 'multipart/form-data',
};
/**
* Uploads a file to the server.
*
* This function sends a POST request to a given API endpoint and uploads the specified file.
* The function uses FormData to wrap the file and axios to send the request.
*
* @param {File} file - The file to be uploaded. It should be a File object (typically coming from a file input element).
* @returns {Promise} A promise that resolves with the server's response when the upload is successful, or rejects if there's an error.
*/
export async function uploadFile(file, accountId) {
// Create a new FormData instance.
let formData = new FormData();
if (!accountId) {
accountId = window.location.pathname.split('/')[3];
}
// Append the file to the FormData instance under the key 'attachment'.
formData.append('attachment', file);
// Use axios to send a POST request to the upload endpoint.
const { data } = await axios.post(
`/api/${API_VERSION}/accounts/${accountId}/upload`,
formData,
{
headers: HEADERS,
}
);
return {
fileUrl: data.file_url,
blobKey: data.blob_key,
blobId: data.blob_id,
};
}
+2
View File
@@ -26,6 +26,7 @@ import pt_BR from './locale/pt_BR';
import ro from './locale/ro';
import ru from './locale/ru';
import sk from './locale/sk';
import sr from './locale/sr';
import sv from './locale/sv';
import ta from './locale/ta';
import th from './locale/th';
@@ -66,6 +67,7 @@ export default {
ro,
ru,
sk,
sr,
sv,
ta,
th,
@@ -371,6 +371,8 @@
"DETAILS": {
"LOADING_FB": "Authenticating you with Facebook...",
"ERROR_FB_AUTH": "Something went wrong, Please refresh page...",
"ERROR_FB_UNAUTHORIZED": "You're not authorized to perform this action. ",
"ERROR_FB_UNAUTHORIZED_HELP": "Please ensure you have access to the Facebook page with full control. You can read more about Facebook roles <a href=\" https://www.facebook.com/help/187316341316631\">here</a>.",
"CREATING_CHANNEL": "Creating your Inbox...",
"TITLE": "Configure Inbox Details",
"DESC": ""
@@ -36,10 +36,13 @@
},
"MESSAGE_SIGNATURE_SECTION": {
"TITLE": "Personal message signature",
"NOTE": "Create a personal message signature that would be added to all the messages you send from your email inbox. Use the rich content editor to create a highly personalised signature.",
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
"BTN_TEXT": "Save message signature",
"API_ERROR": "Couldn't save signature! Try again",
"API_SUCCESS": "Signature saved successfully"
"API_SUCCESS": "Signature saved successfully",
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
},
"MESSAGE_SIGNATURE": {
"LABEL": "Message Signature",
@@ -371,6 +371,8 @@
"DETAILS": {
"LOADING_FB": "جار المصادقة والربط مع الفيسبوك...",
"ERROR_FB_AUTH": "حدث خطأ ما، الرجاء تحديث الصفحة...",
"ERROR_FB_UNAUTHORIZED": "You're not authorized to perform this action. ",
"ERROR_FB_UNAUTHORIZED_HELP": "Please ensure you have access to the Facebook page with full control. You can read more about Facebook roles <a href=\" https://www.facebook.com/help/187316341316631\">here</a>.",
"CREATING_CHANNEL": "جار إنشاء قناة التواصل...",
"TITLE": "تهيئة إعدادات قناة التواصل",
"DESC": ""
@@ -36,10 +36,13 @@
},
"MESSAGE_SIGNATURE_SECTION": {
"TITLE": "توقيع الرسالة الشخصية",
"NOTE": "إنشاء توقيع رسالة شخصية يتم إضافتها إلى جميع الرسائل التي ترسلها من المنصة. استخدم محرر المحتوى الغني لإنشاء توقيع شديد التخصيص.",
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
"BTN_TEXT": "حفظ توقيع الرسالة",
"API_ERROR": "تعذر إرسال الرسالة! حاول مرة أخرى",
"API_SUCCESS": "تم حفظ التوقيع بنجاح"
"API_SUCCESS": "تم حفظ التوقيع بنجاح",
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
},
"MESSAGE_SIGNATURE": {
"LABEL": "توقيع الرسالة",
@@ -371,6 +371,8 @@
"DETAILS": {
"LOADING_FB": "Authenticating you with Facebook...",
"ERROR_FB_AUTH": "Something went wrong, Please refresh page...",
"ERROR_FB_UNAUTHORIZED": "You're not authorized to perform this action. ",
"ERROR_FB_UNAUTHORIZED_HELP": "Please ensure you have access to the Facebook page with full control. You can read more about Facebook roles <a href=\" https://www.facebook.com/help/187316341316631\">here</a>.",
"CREATING_CHANNEL": "Creating your Inbox...",
"TITLE": "Configure Inbox Details",
"DESC": ""
@@ -36,10 +36,13 @@
},
"MESSAGE_SIGNATURE_SECTION": {
"TITLE": "Personal message signature",
"NOTE": "Create a personal message signature that would be added to all the messages you send from your email inbox. Use the rich content editor to create a highly personalised signature.",
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
"BTN_TEXT": "Save message signature",
"API_ERROR": "Couldn't save signature! Try again",
"API_SUCCESS": "Signature saved successfully"
"API_SUCCESS": "Signature saved successfully",
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
},
"MESSAGE_SIGNATURE": {
"LABEL": "Message Signature",
@@ -371,6 +371,8 @@
"DETAILS": {
"LOADING_FB": "S'està autenticant amb Facebook...",
"ERROR_FB_AUTH": "Alguna cosa ha anat malament, actualitza la pàgina...",
"ERROR_FB_UNAUTHORIZED": "You're not authorized to perform this action. ",
"ERROR_FB_UNAUTHORIZED_HELP": "Please ensure you have access to the Facebook page with full control. You can read more about Facebook roles <a href=\" https://www.facebook.com/help/187316341316631\">here</a>.",
"CREATING_CHANNEL": "S'està creant la safata d'entrada...",
"TITLE": "Configura els detalls de la safata d'entrada",
"DESC": ""
@@ -36,10 +36,13 @@
},
"MESSAGE_SIGNATURE_SECTION": {
"TITLE": "Personal message signature",
"NOTE": "Create a personal message signature that would be added to all the messages you send from your email inbox. Use the rich content editor to create a highly personalised signature.",
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
"BTN_TEXT": "Save message signature",
"API_ERROR": "Couldn't save signature! Try again",
"API_SUCCESS": "Signature saved successfully"
"API_SUCCESS": "Signature saved successfully",
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
},
"MESSAGE_SIGNATURE": {
"LABEL": "Message Signature",
@@ -371,6 +371,8 @@
"DETAILS": {
"LOADING_FB": "Ověřování pomocí Facebooku...",
"ERROR_FB_AUTH": "Něco se pokazilo, prosím obnovte stránku...",
"ERROR_FB_UNAUTHORIZED": "You're not authorized to perform this action. ",
"ERROR_FB_UNAUTHORIZED_HELP": "Please ensure you have access to the Facebook page with full control. You can read more about Facebook roles <a href=\" https://www.facebook.com/help/187316341316631\">here</a>.",
"CREATING_CHANNEL": "Vytvářím vaši doručenou poštu...",
"TITLE": "Konfigurace detailů doručené pošty",
"DESC": ""
@@ -36,10 +36,13 @@
},
"MESSAGE_SIGNATURE_SECTION": {
"TITLE": "Personal message signature",
"NOTE": "Create a personal message signature that would be added to all the messages you send from your email inbox. Use the rich content editor to create a highly personalised signature.",
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
"BTN_TEXT": "Save message signature",
"API_ERROR": "Couldn't save signature! Try again",
"API_SUCCESS": "Signature saved successfully"
"API_SUCCESS": "Signature saved successfully",
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
},
"MESSAGE_SIGNATURE": {
"LABEL": "Message Signature",

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