Compare commits

...
Author SHA1 Message Date
Vishnu Narayanan 03524672e8 fix: force utf8 encoding in email channel [cw-2913] 2024-01-08 14:18:18 +05:30
Muhsin KelothandGitHub 68062216e4 feat: Add a job to reopen snoozed notifications (#8545) 2024-01-05 15:13:22 +05:30
Vishnu NarayananandGitHub 8a8f325f64 fix: messageTimestamp test (#8644) 2024-01-05 12:36:26 +05:30
Kevin LinandGitHub cf90b898f4 feat: enable new relic log decorations (#8616)
This enables local decorating of logs when using a new relic. It can be used when you use a log forwarding agent to do additional enrichment on APM logs before forwarding to the new relic.
2024-01-04 15:34:55 -08:00
Sojan JoseandGitHub 1f4cb0e1ca chore: add config for Darkmode logo for whitelabeling (#8597)
-  Add config for dark mode logo
2024-01-04 15:30:55 -08:00
c6ecf80dff chore(synk): Upgrade administrate to fix 2 vulnerabilities (#8625)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2024-01-04 13:38:03 -08:00
c29b77c214 chore(snyk): Security upgrade factory_bot_rails from 6.4.2 to 6.4.3 (#8628)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2024-01-04 10:47:48 -08:00
Pranav Raj SandGitHub c301845ade chore: 2023 -> 2024 (#8635) 2024-01-03 12:49:36 -08:00
Sivin VargheseandGitHub 78fab7897d feat: Use typing status from utils (#8589) 2023-12-22 13:47:41 +05:30
f48e014694 feat: Updates the design of the network connection warning snack bar (#8509)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-12-21 14:16:21 -08:00
Nithin David ThomasandGitHub 206433db32 feat: Adds dropdown to switch locales from articles list page (#8402) 2023-12-21 13:16:59 -08:00
1ddb73ea97 chore(security): Security upgrade omniauth from 2.1.1 to 2.1.2 (#8591)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-12-20 13:38:34 -08:00
Muhsin KelothandGitHub b5071e61d7 feat: Add last_activity_at field to Notification model (#8585) 2023-12-20 13:02:37 +05:30
Muhsin KelothandGitHub a80eff9aa3 feat: Enable custom attributes in the suggestion for variables. (#8520) 2023-12-20 12:20:09 +05:30
Sojan a59fb90785 Merge branch 'release/3.4.0' into develop 2023-12-18 21:23:02 -08:00
Sojan 29478880ff Bump version to 3.4.0 2023-12-18 21:22:06 -08:00
Vishnu NarayananandGitHub c9f8cdb51b fix: switch to nodejs ppa for linux installations (#8337)
Nodesource script-based installation is deprecated. Switch to a different ppa for nodejs.

Fixes: https://linear.app/chatwoot/issue/CW-2686/upgrade-warning
Fixes: #8151
2023-12-18 21:18:42 -08:00
Pranav Raj SandGitHub a2ebf92932 fix: Fix the issue "Undefined method `inbox' for Instagram::MessageText" (#8582) 2023-12-19 08:43:43 +05:30
Pranav Raj SandGitHub 00eb5b152a chore(migration): Re-run database migration job for caching. (#8581)
A support request that came to Chatwoot Cloud revealed that the job was timed prematurely. The default timeout for Sidekiq queues was set as 25 minutes in sidekiq.yml file. The cache was not created properly for the accounts with more than 100k conversations.

This change removes the cache logic in the previous migration and creates a new migration with a new job which processes conversations in batch.
2023-12-18 17:29:02 -08:00
Sojan JoseandGitHub 0b2f539ad0 chore: Update self-hosted billing redirect (#8580) 2023-12-18 16:34:47 -08:00
Shivam MishraandGitHub 1ab3966b75 fix: @inbox usage in logs (#8578) 2023-12-18 19:30:41 +05:30
552d967c81 fix: Use ogg format for Telegram audio messages (#8505)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-12-16 11:55:47 -08:00
Sivin VargheseandGitHub e5c8a68a76 fix: Typing indicator to support dark mode (#8564) 2023-12-15 09:57:13 +05:30
Sivin VargheseandGitHub 15f50635e7 fix: undefined method `available_name' for nil:NilClass (#8563) 2023-12-15 09:41:35 +05:30
LiamandGitHub cf0d6dd7c6 feat: Implement the ability to be able to rotate an image on agent conversation. (#8559) 2023-12-15 09:20:24 +05:30
Sivin VargheseandGitHub fd1813949a fix: Undefined method `name' for nil:NilClass (#8562) 2023-12-15 09:03:16 +05:30
Shivam MishraandGitHub 6c480098f7 chore: log authentication error for FB and Instagram (#8551) 2023-12-14 16:18:23 +05:30
Shivam MishraandGitHub ae649a5b3f refactor: add better logs to Instagram::MessageText (#8538) 2023-12-14 14:03:40 +05:30
34675da6fa feat: Settings screen for custom branding (#8543)
- Ability to configure Chatwoot custom branding from UI when using paid plans

Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
2023-12-13 13:16:24 -08:00
075b0292b5 feat: Conversation list virtualization (#8540)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-12-13 17:28:30 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Sivin VargheseShivam Mishra
0e9825f298 chore(deps): bump axios from 0.21.2 to 1.6.0 (#8339)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2023-12-13 16:24:10 +05:30
bf883794a4 fix: accepting external source_id (#8359)
Co-authored-by: Sojan <sojan@pepalo.com>
2023-12-13 16:21:55 +05:30
Sivin VargheseandGitHub 3adaa2d602 fix: Retry message not working if the conversation has an external issue (#8529) 2023-12-13 15:46:10 +05:30
60a312ace5 feat: Advanced conversation sort options (#8532)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-12-12 19:59:31 -08:00
Pranav Raj SandGitHub 8c9a351c84 feat: Add support for ascending and descending options in sort (#8542) 2023-12-12 17:15:48 -08:00
Pranav Raj SandGitHub d28f512de4 fix: Update the label name to use .strip to avoid unnecessary whitespace characters (#8541)
- The cache stores the labels as label1, label2. Without removing the whitespace, the output of cached_label_list_array would be [label1, [whitespace]label2], which doesn't match with the existing labels. This list is returned with proper label names.
2023-12-12 15:04:29 -08:00
Sojan JoseandGitHub 376de685fb chore: Adds API for agent bot avatar upload (#8533)
Adds API for agent bot avatar upload
- accounts/agent_bot
- platform/agent_bot
2023-12-11 19:02:11 -08:00
Pranav Raj SandGitHub 890515edfd feat(perf): Cache labels on the conversation model (#8527) 2023-12-11 18:27:55 -08:00
Shivam MishraandGitHub 79412ba2c6 feat: disable reply to message in WhatsApp via 360 dialog (#8500) 2023-12-11 16:54:47 +05:30
03ebb6947b chore(synk): Upgrade sentry-rails from 5.13.0 to 5.14.0 (#8420)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-12-10 22:00:17 -08:00
c53591f049 chore(snyk): Security upgrade sidekiq-cron from 1.11.0 to 1.12.0 (#8523)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-12-10 21:59:12 -08:00
Muhsin KelothandGitHub db9a32a4c0 feat: Add sticker support in Line channel (#8488) 2023-12-10 20:45:44 -08:00
2ae9bbb24e chore(snyk): Security upgrade factory_bot_rails from 6.2.0 to 6.4.2 (#8371)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-12-10 20:43:58 -08:00
8dfe193461 feat: Add infinite loader, option for increasing page size (#8525)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-12-10 20:09:17 -08:00
Sivin VargheseandGitHub 27239ae14a feat: Adds API for retry messages in conversation (#8518) 2023-12-11 09:33:39 +05:30
Muhsin KelothandGitHub 80ff5e2d0a chore: Change the RemoveMessageNotifications job implementation. (#8521) 2023-12-11 08:27:30 +05:30
38240a68aa [Snyk] Security upgrade rack-mini-profiler from 3.1.1 to 3.2.0 (#8508)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-12-10 15:02:59 -08:00
Sojan JoseandGitHub 89710b406e chore: Remove missing styles for super admin (#8519)
- Remove missing stylesheet references for super admin
2023-12-08 20:47:57 -08:00
f002870c6a feat: Super admin design improvements (#8517)
- Revamp the super admin design
- Introduce a new settings page for support and billing settings
- Move the access tokens into users, agent bots and platform app show pages


Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-12-08 19:40:35 -08:00
Muhsin KelothandGitHub b1a68759cf feat: Add the support for custom attributes in message variables (#8511) 2023-12-08 14:13:35 -08:00
Muhsin KelothandGitHub bf49a17efa feat: Handle notification.deleted action cable event (#8501) 2023-12-06 16:49:00 +05:30
Muhsin KelothandGitHub 6b0d1d77d3 fix: Email notifications (#8502) 2023-12-06 15:50:23 +05:30
Muhsin KelothandGitHub db33b7d1dc feat: Clear all previous notifications if a new notification is added to a conversation (#8490) 2023-12-06 14:03:43 +05:30
Muhsin KelothandGitHub 76711d95ff chore: Change primary actor to Conversation for all the notification types. (#8435) 2023-12-06 10:43:09 +05:30
Sivin VargheseandGitHub 17faa6c3b2 fix: Undefined method `length' for nil:NilClass (#8491) 2023-12-05 16:34:29 +05:30
Sivin VargheseandGitHub c1ac354c9b fix: Remove article meta tag is not working (#8489) 2023-12-05 16:17:41 +05:30
Muhsin KelothandGitHub 449503bb94 feat: Snooze notification API (#8439) 2023-12-04 12:32:35 +05:30
Muhsin KelothandGitHub aad18e1ca4 feat: Add notification_deleted action cable event (#8431) 2023-12-01 23:06:22 +05:30
Muhsin KelothandGitHub fdc1123b18 feat: Add support for attachments(image and video) in LINE channel (#8425) 2023-12-01 07:23:35 +05:30
Anthony MeirlaenandGitHub 0974b1e705 fix: Install node 20.x in non-production docker (#8441) (#8442) 2023-11-30 16:22:34 -08:00
Muhsin KelothandGitHub bc6e3e598f feat: Delete notification by id API (#8430) 2023-11-29 22:45:11 +05:30
683f9d9a28 fix: article meta does not show up on reload (#8415)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-11-29 16:15:31 +05:30
Martín GómezandGitHub f9bf0883c6 feat: Allow more image formats in avatar uploads form (#8419) 2023-11-28 11:18:16 +05:30
ad8ba299c9 feat: Author sections in category block and category list (#8414)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-11-27 15:13:38 +05:30
Muhsin KelothandGitHub 3ae64822e1 chore: Enforce HTTP Basic Authentication for Twilio medias. (#8396) 2023-11-27 13:24:53 +05:30
Sivin VargheseandGitHub a49aaee9cf fix: Email messages in dark mode (#8300) 2023-11-24 12:14:02 +05:30
Sivin VargheseandGitHub 2d1f70eb79 feat: Update public portal colors with new design (#8230) 2023-11-23 08:16:52 +05:30
Sojan JoseandGitHub 4fc5f765de chore: Make super admin auth error readable (#8406)
- Remove the cryptic error for super admin auth and make it more readable
2023-11-22 17:26:08 -08:00
Nithin David ThomasandGitHub 7efc2726d9 fix: Updates the text alignment on articles table (#8405) 2023-11-23 02:58:00 +05:30
Pranav Raj SandGitHub 29a778697c fix: Remove validation for message signature (#8404) 2023-11-22 13:09:17 -08:00
Pranav Raj SandGitHub 90649e72bb fix: Add a .present? check for sentiment file path to avoid false positives (#8401) 2023-11-22 10:35:51 -08:00
Pranav Raj SandGitHub 1904ec7df4 fix: Update campaign routes to fix the rendering issue (#8400) 2023-11-22 09:39:41 -08:00
Nithin David ThomasandGitHub 96add30331 chore: Add analytics event for insert article feature (#8393) 2023-11-22 08:18:58 -08:00
9c7148e2ad feat: Split dashboard to chunks for build performance (#8394)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-11-21 14:51:05 -08:00
31c709be5c fix: Opens foreign links from article page in new tab [cw-2725] (#8304)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-11-22 01:57:58 +05:30
e750ee6d28 chore: [Snyk] Security upgrade administrate-field-active_storage from 0.4.2 to 1.0.0 (#8382)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-11-20 19:42:54 -08:00
Vishnu NarayananandGitHub 5b3f9ac1cd chore: skip node upgrade if latest on cwctl upgrade (#8336) 2023-11-20 12:48:39 +05:30
Muhsin KelothandGitHub 6c8dacfa0d feat: Facebook delivery reports (#8136) 2023-11-20 12:22:45 +05:30
Sojan feead30b0b Merge branch 'release/3.3.1' into develop 2023-11-18 10:24:35 -08:00
232 changed files with 4005 additions and 1661 deletions
+8 -8
View File
@@ -75,8 +75,8 @@ gem 'jwt'
gem 'pundit' gem 'pundit'
# super admin # super admin
gem 'administrate', '>= 0.19.0' gem 'administrate', '>= 0.19.0'
gem 'administrate-field-active_storage' gem 'administrate-field-active_storage', '>= 1.0.1'
gem 'administrate-field-belongs_to_search' gem 'administrate-field-belongs_to_search', '>= 0.9.0'
##--- gems for pubsub service ---## ##--- gems for pubsub service ---##
# https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/ # https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/
@@ -109,14 +109,14 @@ gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
gem 'scout_apm', require: false gem 'scout_apm', require: false
gem 'sentry-rails', '>= 5.13.0', require: false gem 'sentry-rails', '>= 5.14.0', require: false
gem 'sentry-ruby', require: false gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.13.0', require: false gem 'sentry-sidekiq', '>= 5.14.0', require: false
##-- background job processing --## ##-- background job processing --##
gem 'sidekiq', '>= 7.1.3' gem 'sidekiq', '>= 7.1.3'
# We want cron jobs # We want cron jobs
gem 'sidekiq-cron', '>= 1.11.0' gem 'sidekiq-cron', '>= 1.12.0'
##-- Push notification service --## ##-- Push notification service --##
gem 'fcm' gem 'fcm'
@@ -162,7 +162,7 @@ gem 'omniauth-oauth2'
gem 'audited', '~> 5.4', '>= 5.4.1' gem 'audited', '~> 5.4', '>= 5.4.1'
# need for google auth # need for google auth
gem 'omniauth' gem 'omniauth', '>= 2.1.2'
gem 'omniauth-google-oauth2' gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection', '~> 1.0' gem 'omniauth-rails_csrf_protection', '~> 1.0'
@@ -198,7 +198,7 @@ group :development do
gem 'squasher' gem 'squasher'
# profiling # profiling
gem 'rack-mini-profiler', '>= 3.1.1', require: false gem 'rack-mini-profiler', '>= 3.2.0', require: false
gem 'stackprof' gem 'stackprof'
# Should install the associated chrome extension to view query logs # Should install the associated chrome extension to view query logs
gem 'meta_request' gem 'meta_request'
@@ -224,7 +224,7 @@ group :development, :test do
gem 'byebug', platform: :mri gem 'byebug', platform: :mri
gem 'climate_control' gem 'climate_control'
gem 'debug', '~> 1.8' gem 'debug', '~> 1.8'
gem 'factory_bot_rails' gem 'factory_bot_rails', '>= 6.4.3'
gem 'listen' gem 'listen'
gem 'mock_redis' gem 'mock_redis'
gem 'pry-rails' gem 'pry-rails'
+39 -39
View File
@@ -113,13 +113,13 @@ GEM
kaminari (>= 1.0) kaminari (>= 1.0)
sassc-rails (~> 2.1) sassc-rails (~> 2.1)
selectize-rails (~> 0.6) selectize-rails (~> 0.6)
administrate-field-active_storage (0.4.2) administrate-field-active_storage (1.0.1)
administrate (>= 0.2.2) administrate (>= 0.2.2)
rails (>= 7.0) rails (>= 7.0)
administrate-field-belongs_to_search (0.8.0) administrate-field-belongs_to_search (0.9.0)
administrate (>= 0.3, < 1.0) administrate (>= 0.3, < 1.0)
jbuilder (~> 2) jbuilder (~> 2)
rails (>= 4.2, < 7.1) rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6) selectize-rails (~> 0.6)
annotate (3.2.0) annotate (3.2.0)
activerecord (>= 3.2, < 8.0) activerecord (>= 3.2, < 8.0)
@@ -183,7 +183,7 @@ GEM
activerecord (>= 5.a) activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0) database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1) database_cleaner-core (2.0.1)
date (3.3.3) date (3.3.4)
ddtrace (1.11.1) ddtrace (1.11.1)
debase-ruby_core_source (>= 0.10.16, <= 3.2.0) debase-ruby_core_source (>= 0.10.16, <= 3.2.0)
libdatadog (~> 2.0.0.1.0) libdatadog (~> 2.0.0.1.0)
@@ -230,10 +230,10 @@ GEM
facebook-messenger (2.0.1) facebook-messenger (2.0.1)
httparty (~> 0.13, >= 0.13.7) httparty (~> 0.13, >= 0.13.7)
rack (>= 1.4.5) rack (>= 1.4.5)
factory_bot (6.2.1) factory_bot (6.4.5)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
factory_bot_rails (6.2.0) factory_bot_rails (6.4.3)
factory_bot (~> 6.2.0) factory_bot (~> 6.4)
railties (>= 5.0.0) railties (>= 5.0.0)
faker (3.2.0) faker (3.2.0)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
@@ -256,7 +256,7 @@ GEM
fcm (1.0.8) fcm (1.0.8)
faraday (>= 1.0.0, < 3.0) faraday (>= 1.0.0, < 3.0)
googleauth (~> 1) googleauth (~> 1)
ffi (1.15.5) ffi (1.16.3)
ffi-compiler (1.0.1) ffi-compiler (1.0.1)
ffi (>= 1.0.0) ffi (>= 1.0.0)
rake rake
@@ -440,7 +440,7 @@ GEM
activesupport (>= 4) activesupport (>= 4)
railties (>= 4) railties (>= 4)
request_store (~> 1.0) request_store (~> 1.0)
loofah (2.21.4) loofah (2.22.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.8.1)
@@ -472,14 +472,14 @@ GEM
activerecord (>= 5.2) activerecord (>= 5.2)
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.3.7) net-imap (0.4.9)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
net-protocol net-protocol
net-protocol (0.2.1) net-protocol (0.2.2)
timeout timeout
net-smtp (0.3.3) net-smtp (0.4.0)
net-protocol net-protocol
netrc (0.11.0) netrc (0.11.0)
newrelic-sidekiq-metrics (1.6.2) newrelic-sidekiq-metrics (1.6.2)
@@ -487,15 +487,15 @@ GEM
sidekiq sidekiq
newrelic_rpm (9.6.0) newrelic_rpm (9.6.0)
base64 base64
nio4r (2.5.9) nio4r (2.7.0)
nokogiri (1.15.4) nokogiri (1.16.0)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.15.4-arm64-darwin) nokogiri (1.16.0-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.15.4-x86_64-darwin) nokogiri (1.16.0-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.15.4-x86_64-linux) nokogiri (1.16.0-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
numo-narray (0.9.2.1) numo-narray (0.9.2.1)
oauth (1.1.0) oauth (1.1.0)
@@ -511,7 +511,7 @@ GEM
rack (>= 1.2, < 4) rack (>= 1.2, < 4)
snaky_hash (~> 2.0) snaky_hash (~> 2.0)
version_gem (~> 1.1) version_gem (~> 1.1)
omniauth (2.1.1) omniauth (2.1.2)
hashie (>= 3.4.6) hashie (>= 3.4.6)
rack (>= 2.2.3) rack (>= 2.2.3)
rack-protection rack-protection
@@ -566,10 +566,10 @@ GEM
rack (< 4) rack (< 4)
rack-cors (2.0.1) rack-cors (2.0.1)
rack (>= 2.0.0) rack (>= 2.0.0)
rack-mini-profiler (3.1.1) rack-mini-profiler (3.2.0)
rack (>= 1.2.0) rack (>= 1.2.0)
rack-protection (3.0.6) rack-protection (3.1.0)
rack rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.6) rack-proxy (0.7.6)
rack rack
rack-test (2.1.0) rack-test (2.1.0)
@@ -610,7 +610,7 @@ GEM
ffi (~> 1.0) ffi (~> 1.0)
redis (5.0.6) redis (5.0.6)
redis-client (>= 0.9.0) redis-client (>= 0.9.0)
redis-client (0.18.0) redis-client (0.19.0)
connection_pool connection_pool
redis-namespace (1.10.0) redis-namespace (1.10.0)
redis (>= 4) redis (>= 4)
@@ -709,13 +709,13 @@ GEM
activesupport (>= 4) activesupport (>= 4)
selectize-rails (0.12.6) selectize-rails (0.12.6)
semantic_range (3.0.0) semantic_range (3.0.0)
sentry-rails (5.13.0) sentry-rails (5.14.0)
railties (>= 5.0) railties (>= 5.0)
sentry-ruby (~> 5.13.0) sentry-ruby (~> 5.14.0)
sentry-ruby (5.13.0) sentry-ruby (5.14.0)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.13.0) sentry-sidekiq (5.14.0)
sentry-ruby (~> 5.13.0) sentry-ruby (~> 5.14.0)
sidekiq (>= 3.0) sidekiq (>= 3.0)
sexp_processor (4.17.0) sexp_processor (4.17.0)
shoulda-matchers (5.3.0) shoulda-matchers (5.3.0)
@@ -725,7 +725,7 @@ GEM
connection_pool (>= 2.3.0) connection_pool (>= 2.3.0)
rack (>= 2.2.4) rack (>= 2.2.4)
redis-client (>= 0.14.0) redis-client (>= 0.14.0)
sidekiq-cron (1.11.0) sidekiq-cron (1.12.0)
fugit (~> 1.8) fugit (~> 1.8)
globalid (>= 1.0.1) globalid (>= 1.0.1)
sidekiq (>= 6) sidekiq (>= 6)
@@ -752,7 +752,7 @@ GEM
spring-watcher-listen (2.1.0) spring-watcher-listen (2.1.0)
listen (>= 2.7, < 4.0) listen (>= 2.7, < 4.0)
spring (>= 4) spring (>= 4)
sprockets (4.2.0) sprockets (4.2.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
rack (>= 2.2.4, < 4) rack (>= 2.2.4, < 4)
sprockets-rails (3.4.2) sprockets-rails (3.4.2)
@@ -766,11 +766,11 @@ GEM
telephone_number (1.4.20) telephone_number (1.4.20)
test-prof (1.2.1) test-prof (1.2.1)
thor (1.3.0) thor (1.3.0)
tilt (2.2.0) tilt (2.3.0)
time_diff (0.3.0) time_diff (0.3.0)
activesupport activesupport
i18n i18n
timeout (0.4.0) timeout (0.4.1)
trailblazer-option (0.1.2) trailblazer-option (0.1.2)
twilio-ruby (5.77.0) twilio-ruby (5.77.0)
faraday (>= 0.9, < 3.0) faraday (>= 0.9, < 3.0)
@@ -841,8 +841,8 @@ DEPENDENCIES
activerecord-import activerecord-import
acts-as-taggable-on acts-as-taggable-on
administrate (>= 0.19.0) administrate (>= 0.19.0)
administrate-field-active_storage administrate-field-active_storage (>= 1.0.1)
administrate-field-belongs_to_search administrate-field-belongs_to_search (>= 0.9.0)
annotate annotate
attr_extras attr_extras
audited (~> 5.4, >= 5.4.1) audited (~> 5.4, >= 5.4.1)
@@ -870,7 +870,7 @@ DEPENDENCIES
elastic-apm elastic-apm
email_reply_trimmer email_reply_trimmer
facebook-messenger facebook-messenger
factory_bot_rails factory_bot_rails (>= 6.4.3)
faker faker
fcm fcm
flag_shih_tzu flag_shih_tzu
@@ -905,7 +905,7 @@ DEPENDENCIES
neighbor neighbor
newrelic-sidekiq-metrics (>= 1.6.2) newrelic-sidekiq-metrics (>= 1.6.2)
newrelic_rpm newrelic_rpm
omniauth omniauth (>= 2.1.2)
omniauth-google-oauth2 omniauth-google-oauth2
omniauth-oauth2 omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0) omniauth-rails_csrf_protection (~> 1.0)
@@ -918,7 +918,7 @@ DEPENDENCIES
pundit pundit
rack-attack (>= 6.7.0) rack-attack (>= 6.7.0)
rack-cors rack-cors
rack-mini-profiler (>= 3.1.1) rack-mini-profiler (>= 3.2.0)
rack-timeout rack-timeout
rails (~> 7.0.8.0) rails (~> 7.0.8.0)
redis redis
@@ -935,12 +935,12 @@ DEPENDENCIES
scout_apm scout_apm
scss_lint scss_lint
seed_dump seed_dump
sentry-rails (>= 5.13.0) sentry-rails (>= 5.14.0)
sentry-ruby sentry-ruby
sentry-sidekiq (>= 5.13.0) sentry-sidekiq (>= 5.14.0)
shoulda-matchers shoulda-matchers
sidekiq (>= 7.1.3) sidekiq (>= 7.1.3)
sidekiq-cron (>= 1.11.0) sidekiq-cron (>= 1.12.0)
simplecov (= 0.17.1) simplecov (= 0.17.1)
slack-ruby-client (~> 2.2.0) slack-ruby-client (~> 2.2.0)
spring spring
+1 -1
View File
@@ -118,4 +118,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a> <a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2023, Chatwoot Inc - Released under the MIT License. *Chatwoot* &copy; 2017-2024, Chatwoot Inc - Released under the MIT License.
+1 -1
View File
@@ -1 +1 @@
3.1.0 3.3.1
+1 -1
View File
@@ -1 +1 @@
2.6.0 2.7.0
@@ -25,7 +25,6 @@
@import 'components/flashes'; @import 'components/flashes';
@import 'components/form-actions'; @import 'components/form-actions';
@import 'components/main-content'; @import 'components/main-content';
@import 'components/navigation';
@import 'components/pagination'; @import 'components/pagination';
@import 'components/search'; @import 'components/search';
@import 'components/reports'; @import 'components/reports';
@@ -1,7 +1,7 @@
html { html {
background-color: $color-white; background-color: $color-white;
box-sizing: border-box; box-sizing: border-box;
font-size: 10px; font-size: 16px;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
@@ -16,8 +16,8 @@
.attribute-data { .attribute-data {
float: left; float: left;
margin-bottom: $base-spacing; margin-bottom: $base-spacing;
margin-left: 2rem; margin-left: 1.25rem;
width: calc(84% - 1rem); width: calc(84% - 0.625rem);
} }
.attribute--nested { .attribute--nested {
@@ -9,22 +9,22 @@
.field-unit__label { .field-unit__label {
float: left; float: left;
margin-left: 1rem; margin-left: 0.625rem;
text-align: right; text-align: right;
width: calc(15% - 1rem); width: calc(15% - 0.625rem);
} }
.field-unit__field { .field-unit__field {
float: left; float: left;
margin-left: 2rem; margin-left: 1.25rem;
max-width: 50rem; max-width: 31.15rem;
width: 100%; width: 100%;
} }
.field-unit--nested { .field-unit--nested {
border: $base-border; border: $base-border;
margin-left: 7.5%; margin-left: 7.5%;
max-width: 60rem; max-width: 37.5rem;
padding: $small-spacing; padding: $small-spacing;
width: 100%; width: 100%;
@@ -1,3 +1,3 @@
.form-actions { .form-actions {
margin-left: calc(15% + 2rem); margin-left: calc(15% + 1.25rem);
} }
@@ -13,6 +13,10 @@
table { table {
font-size: $font-size-small; font-size: $font-size-small;
} }
form {
margin-top: $space-two;
}
} }
.main-content__header { .main-content__header {
@@ -20,7 +24,7 @@
background-color: $color-white; background-color: $color-white;
border-bottom: 1px solid $color-border; border-bottom: 1px solid $color-border;
display: flex; display: flex;
min-height: 5.6rem; min-height: 3.5rem;
padding: $space-small $space-normal; padding: $space-small $space-normal;
} }
@@ -1,88 +0,0 @@
.logo-brand {
margin-bottom: $space-normal;
padding: $space-normal $space-smaller $space-small;
text-align: left;
img {
margin-bottom: $space-smaller;
max-height: 3rem;
}
}
.navigation {
background: $white;
border-right: 1px solid $color-border;
display: flex;
flex-direction: column;
font-size: $font-size-default;
font-weight: $font-weight-medium;
height: 100%;
justify-content: flex-start;
left: 0;
margin: 0;
overflow: auto;
padding: $space-normal;
position: fixed;
top: 0;
width: 21rem;
z-index: 1023;
li {
align-items: center;
display: flex;
font-size: $font-size-small;
a {
color: $color-gray;
text-decoration: none;
}
i {
min-width: $space-medium;
}
}
hr {
margin: $space-slab;
}
}
.navigation__link {
background-color: transparent;
color: $color-gray;
display: block;
line-height: 1;
margin-bottom: $space-smaller;
padding: $space-small;
&:hover {
color: $blue;
a {
color: $blue;
}
}
&.navigation__link--active {
background-color: $color-background;
border-radius: $base-border-radius;
color: $blue;
a {
color: $blue;
}
}
}
.logout {
bottom: $space-normal;
left: $space-normal;
position: fixed;
}
.app-version {
color: $color-gray;
font-size: $font-size-small;
padding-top: $space-smaller;
}
@@ -1,7 +1,7 @@
.search { .search {
margin-left: auto; margin-left: auto;
margin-right: 2rem; margin-right: 1.25rem;
max-width: 44rem; max-width: 27.5rem;
position: relative; position: relative;
width: 100%; width: 100%;
} }
@@ -1,10 +1,10 @@
// Typography // Typography
$base-font-family: PlusJakarta, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", $base-font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif !default; sans-serif !default;
$heading-font-family: $base-font-family !default; $heading-font-family: $base-font-family !default;
$base-font-size: 14px !default; $base-font-size: 16px !default;
$base-line-height: 1.5 !default; $base-line-height: 1.5 !default;
$heading-line-height: 1.2 !default; $heading-line-height: 1.2 !default;
@@ -1,30 +1,30 @@
// Font sizes // Font sizes
$font-size-nano: 0.8rem; $font-size-nano: 0.5rem;
$font-size-micro: 1.0rem; $font-size-micro: 0.675rem;
$font-size-mini: 1.2rem; $font-size-mini: 0.75rem;
$font-size-small: 1.4rem; $font-size-small: 0.875rem;
$font-size-default: 1.6rem; $font-size-default: 1rem;
$font-size-medium: 1.8rem; $font-size-medium: 1.125rem;
$font-size-large: 2.2rem; $font-size-large: 1.375rem;
$font-size-big: 2.4rem; $font-size-big: 1.5rem;
$font-size-bigger: 3.0rem; $font-size-bigger: 1.75rem;
$font-size-mega: 3.4rem; $font-size-mega: 2.125rem;
$font-size-giga: 4.0rem; $font-size-giga: 2.5rem;
// spaces // spaces
$zero: 0; $zero: 0;
$space-micro: 0.2rem; $space-micro: 0.125rem;
$space-smaller: 0.4rem; $space-smaller: 0.25rem;
$space-small: 0.8rem; $space-small: 0.5rem;
$space-one: 1rem; $space-one: 0.675rem;
$space-slab: 1.2rem; $space-slab: 0.75rem;
$space-normal: 1.6rem; $space-normal: 1rem;
$space-two: 2.0rem; $space-two: 1.25rem;
$space-medium: 2.4rem; $space-medium: 1.5rem;
$space-large: 3.2rem; $space-large: 2rem;
$space-larger: 4.8rem; $space-larger: 3rem;
$space-jumbo: 6.4rem; $space-jumbo: 4rem;
$space-mega: 10.0rem; $space-mega: 6.25rem;
// font-weight // font-weight
$font-weight-feather: 100; $font-weight-feather: 100;
@@ -25,7 +25,9 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
build_contact_inbox build_contact_inbox
build_message build_message
end end
rescue Koala::Facebook::AuthenticationError rescue Koala::Facebook::AuthenticationError => e
Rails.logger.warn("Facebook authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
@inbox.channel.authorization_error! @inbox.channel.authorization_error!
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
@@ -108,11 +110,15 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
} }
end end
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/MethodLength
def contact_params def contact_params
begin begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(@sender_id) || {} result = k.get_object(@sender_id) || {}
rescue Koala::Facebook::AuthenticationError rescue Koala::Facebook::AuthenticationError => e
Rails.logger.warn("Facebook authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
@inbox.channel.authorization_error! @inbox.channel.authorization_error!
raise raise
rescue Koala::Facebook::ClientError => e rescue Koala::Facebook::ClientError => e
@@ -130,4 +136,6 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end end
process_contact_params_result(result) process_contact_params_result(result)
end end
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/MethodLength
end end
@@ -20,7 +20,9 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
build_message build_message
end end
rescue Koala::Facebook::AuthenticationError rescue Koala::Facebook::AuthenticationError => e
Rails.logger.warn("Instagram authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
@inbox.channel.authorization_error! @inbox.channel.authorization_error!
raise raise
rescue StandardError => e rescue StandardError => e
+2 -1
View File
@@ -149,7 +149,8 @@ class Messages::MessageBuilder
content_type: @params[:content_type], content_type: @params[:content_type],
items: @items, items: @items,
in_reply_to: @in_reply_to, in_reply_to: @in_reply_to,
echo_id: @params[:echo_id] echo_id: @params[:echo_id],
source_id: @params[:source_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params) }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
end end
end end
+4 -3
View File
@@ -1,5 +1,5 @@
class NotificationBuilder class NotificationBuilder
pattr_initialize [:notification_type!, :user!, :account!, :primary_actor!] pattr_initialize [:notification_type!, :user!, :account!, :primary_actor!, :secondary_actor]
def perform def perform
return unless user_subscribed_to_notification? return unless user_subscribed_to_notification?
@@ -9,7 +9,7 @@ class NotificationBuilder
private private
def secondary_actor def current_user
Current.user Current.user
end end
@@ -29,7 +29,8 @@ class NotificationBuilder
notification_type: notification_type, notification_type: notification_type,
account: account, account: account,
primary_actor: primary_actor, primary_actor: primary_actor,
secondary_actor: secondary_actor # secondary_actor is secondary_actor if present, else current_user
secondary_actor: secondary_actor || current_user
) )
end end
end end
@@ -10,11 +10,18 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
def show; end def show; end
def create def create
@agent_bot = Current.account.agent_bots.create!(permitted_params) @agent_bot = Current.account.agent_bots.create!(permitted_params.except(:avatar_url))
process_avatar_from_url
end end
def update def update
@agent_bot.update!(permitted_params) @agent_bot.update!(permitted_params.except(:avatar_url))
process_avatar_from_url
end
def avatar
@agent_bot.avatar.purge if @agent_bot.avatar.attached?
@agent_bot
end end
def destroy def destroy
@@ -30,6 +37,10 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
end end
def permitted_params def permitted_params
params.permit(:name, :description, :outgoing_url, :bot_type, bot_config: [:csml_content]) params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: [:csml_content])
end
def process_avatar_from_url
::Avatar::AvatarFromUrlJob.perform_later(@agent_bot, params[:avatar_url]) if params[:avatar_url].present?
end end
end end
@@ -83,14 +83,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url)) @contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
@contact.save! @contact.save!
@contact_inbox = build_contact_inbox @contact_inbox = build_contact_inbox
process_avatar process_avatar_from_url
end end
end end
def update def update
@contact.assign_attributes(contact_update_params) @contact.assign_attributes(contact_update_params)
@contact.save! @contact.save!
process_avatar if permitted_params[:avatar].present? || permitted_params[:avatar_url].present? process_avatar_from_url
end end
def destroy def destroy
@@ -174,7 +174,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id]) @contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
end end
def process_avatar def process_avatar_from_url
::Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? ::Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
end end
@@ -14,7 +14,8 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
def set_agent def set_agent
@agent = Current.account.users.find_by(id: params[:assignee_id]) @agent = Current.account.users.find_by(id: params[:assignee_id])
@conversation.update_assignee(@agent) @conversation.assignee = @agent
@conversation.save!
render_agent render_agent
end end
@@ -18,6 +18,15 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end end
end end
def retry
return if message.blank?
message.update!(status: :sent, content_attributes: {})
::SendReplyJob.perform_later(message.id)
rescue StandardError => e
render_could_not_create_error(e.message)
end
def translate def translate
return head :ok if already_translated_content_available? return head :ok if already_translated_content_available?
@@ -110,8 +110,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def assign_conversation def assign_conversation
@agent = Current.account.users.find(current_user.id) @conversation.assignee = current_user
@conversation.update_assignee(@agent) @conversation.save!
end end
def conversation def conversation
@@ -1,7 +1,8 @@
class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseController
RESULTS_PER_PAGE = 15 RESULTS_PER_PAGE = 15
include DateRangeHelper
before_action :fetch_notification, only: [:update] before_action :fetch_notification, only: [:update, :destroy, :snooze]
before_action :set_primary_actor, only: [:read_all] before_action :set_primary_actor, only: [:read_all]
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
@@ -28,11 +29,21 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
render json: @notification render json: @notification
end end
def destroy
@notification.destroy
head :ok
end
def unread_count def unread_count
@unread_count = current_user.notifications.where(account_id: current_account.id, read_at: nil).count @unread_count = current_user.notifications.where(account_id: current_account.id, read_at: nil).count
render json: @unread_count render json: @unread_count
end end
def snooze
@notification.update(snoozed_until: parse_date_time(params[:snoozed_until].to_s)) if params[:snoozed_until]
render json: @notification
end
private private
def set_primary_actor def set_primary_actor
@@ -9,13 +9,15 @@ class Platform::Api::V1::AgentBotsController < PlatformController
def show; end def show; end
def create def create
@resource = AgentBot.new(agent_bot_params) @resource = AgentBot.new(agent_bot_params.except(:avatar_url))
@resource.save! @resource.save!
process_avatar_from_url
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource) @platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end end
def update def update
@resource.update!(agent_bot_params) @resource.update!(agent_bot_params.except(:avatar_url))
process_avatar_from_url
end end
def destroy def destroy
@@ -23,6 +25,11 @@ class Platform::Api::V1::AgentBotsController < PlatformController
head :ok head :ok
end end
def avatar
@resource.avatar.purge if @resource.avatar.attached?
@resource
end
private private
def set_resource def set_resource
@@ -30,6 +37,10 @@ class Platform::Api::V1::AgentBotsController < PlatformController
end end
def agent_bot_params def agent_bot_params
params.permit(:name, :description, :account_id, :outgoing_url) params.permit(:name, :description, :account_id, :outgoing_url, :avatar, :avatar_url)
end
def process_avatar_from_url
::Avatar::AvatarFromUrlJob.perform_later(@resource, params[:avatar_url]) if params[:avatar_url].present?
end end
end end
@@ -11,11 +11,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
end end
def set_color_scheme def set_color_scheme
@theme = if %w[dark light].include?(params[:theme]) @theme_from_params = params[:theme] if %w[dark light].include?(params[:theme])
params[:theme]
else
'system'
end
end end
def portal def portal
@@ -1,21 +1,38 @@
class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
before_action :set_config
before_action :allowed_configs
def show def show
@allowed_configs = %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET]
# ref: https://github.com/rubocop/rubocop/issues/7767 # ref: https://github.com/rubocop/rubocop/issues/7767
# rubocop:disable Style/HashTransformValues # rubocop:disable Style/HashTransformValues
@fb_config = InstallationConfig.where(name: @allowed_configs) @app_config = InstallationConfig.where(name: @allowed_configs)
.pluck(:name, :serialized_value) .pluck(:name, :serialized_value)
.map { |name, serialized_value| [name, serialized_value['value']] } .map { |name, serialized_value| [name, serialized_value['value']] }
.to_h .to_h
# rubocop:enable Style/HashTransformValues # rubocop:enable Style/HashTransformValues
end end
def create def create
params['app_config'].each do |key, value| params['app_config'].each do |key, value|
next unless @allowed_configs.include?(key)
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false) i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
i.value = value i.value = value
i.save! i.save!
end end
redirect_to super_admin_app_config_url # rubocop:disable Rails/I18nLocaleTexts
redirect_to super_admin_settings_path, notice: 'App Configs updated successfully'
# rubocop:enable Rails/I18nLocaleTexts
end
private
def set_config
@config = params[:config]
end
def allowed_configs
@allowed_configs = %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET]
end end
end end
SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController')
@@ -20,4 +20,13 @@ class SuperAdmin::ApplicationController < Administrate::ApplicationController
params.fetch(resource_name, {}).fetch(:direction, 'desc') params.fetch(resource_name, {}).fetch(:direction, 'desc')
) )
end end
private
def invalid_action_perfomed
# rubocop:disable Rails/I18nLocaleTexts
flash[:error] = 'Invalid action performed'
# rubocop:enable Rails/I18nLocaleTexts
redirect_back(fallback_location: root_path)
end
end end
@@ -27,7 +27,8 @@ class SuperAdmin::Devise::SessionsController < Devise::SessionsController
true true
rescue StandardError => e rescue StandardError => e
@error_message = e.message Rails.logger.error e.message
@error_message = 'Invalid credentials. Please try again.'
false false
end end
end end
@@ -1,4 +1,5 @@
class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationController class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationController
rescue_from ActiveRecord::RecordNotUnique, :with => :invalid_action_perfomed
# Overwrite any of the RESTful controller actions to implement custom behavior # Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated. # For example, you may want to send an email after a foo is updated.
# #
@@ -0,0 +1,10 @@
class SuperAdmin::SettingsController < SuperAdmin::ApplicationController
def show; end
def refresh
Internal::CheckNewVersionsJob.perform_now
# rubocop:disable Rails/I18nLocaleTexts
redirect_to super_admin_settings_path, notice: 'Instance status refreshed'
# rubocop:enable Rails/I18nLocaleTexts
end
end
-4
View File
@@ -30,11 +30,7 @@ class AccessTokenDashboard < Administrate::BaseDashboard
# SHOW_PAGE_ATTRIBUTES # SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page. # an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = %i[ SHOW_PAGE_ATTRIBUTES = %i[
owner
id
token token
created_at
updated_at
].freeze ].freeze
# FORM_ATTRIBUTES # FORM_ATTRIBUTES
+1
View File
@@ -46,6 +46,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
name name
description description
outgoing_url outgoing_url
access_token
].freeze ].freeze
# FORM_ATTRIBUTES # FORM_ATTRIBUTES
+1
View File
@@ -32,6 +32,7 @@ class PlatformAppDashboard < Administrate::BaseDashboard
name name
created_at created_at
updated_at updated_at
access_token
].freeze ].freeze
# FORM_ATTRIBUTES # FORM_ATTRIBUTES
+3 -1
View File
@@ -36,7 +36,8 @@ class UserDashboard < Administrate::BaseDashboard
updated_at: Field::DateTime, updated_at: Field::DateTime,
pubsub_token: Field::String, pubsub_token: Field::String,
type: Field::Select.with_options(collection: [nil, 'SuperAdmin']), type: Field::Select.with_options(collection: [nil, 'SuperAdmin']),
accounts: CountField accounts: CountField,
access_token: Field::HasOne
}.freeze }.freeze
# COLLECTION_ATTRIBUTES # COLLECTION_ATTRIBUTES
@@ -67,6 +68,7 @@ class UserDashboard < Administrate::BaseDashboard
updated_at updated_at
confirmed_at confirmed_at
account_users account_users
access_token
].freeze ].freeze
# FORM_ATTRIBUTES # FORM_ATTRIBUTES
+5
View File
@@ -18,4 +18,9 @@ class ContactDrop < BaseDrop
def last_name def last_name
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1 @obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end end
def custom_attribute
custom_attributes = @obj.try(:custom_attributes) || {}
custom_attributes.transform_keys(&:to_s)
end
end end
+5
View File
@@ -19,6 +19,11 @@ class ConversationDrop < BaseDrop
end end
end end
def custom_attribute
custom_attributes = @obj.try(:custom_attributes) || {}
custom_attributes.transform_keys(&:to_s)
end
private private
def message_sender_name(sender) def message_sender_name(sender)
+17 -8
View File
@@ -3,13 +3,21 @@ class ConversationFinder
DEFAULT_STATUS = 'open'.freeze DEFAULT_STATUS = 'open'.freeze
SORT_OPTIONS = { SORT_OPTIONS = {
latest: 'latest', 'last_activity_at_asc' => %w[sort_on_last_activity_at asc],
sort_on_created_at: 'sort_on_created_at', 'last_activity_at_desc' => %w[sort_on_last_activity_at desc],
last_user_message_at: 'last_user_message_at', 'created_at_asc' => %w[sort_on_created_at asc],
sort_on_priority: 'sort_on_priority', 'created_at_desc' => %w[sort_on_created_at desc],
sort_on_waiting_since: 'sort_on_waiting_since' 'priority_asc' => %w[sort_on_priority asc],
}.with_indifferent_access 'priority_desc' => %w[sort_on_priority desc],
'waiting_since_asc' => %w[sort_on_waiting_since asc],
'waiting_since_desc' => %w[sort_on_waiting_since desc],
# To be removed in v3.5.0
'latest' => %w[sort_on_last_activity_at desc],
'sort_on_created_at' => %w[sort_on_created_at asc],
'sort_on_priority' => %w[sort_on_priority desc],
'sort_on_waiting_since' => %w[sort_on_waiting_since asc]
}.with_indifferent_access
# assumptions # assumptions
# inbox_id if not given, take from all conversations, else specific to inbox # inbox_id if not given, take from all conversations, else specific to inbox
# assignee_type if not given, take 'all' # assignee_type if not given, take 'all'
@@ -159,7 +167,8 @@ class ConversationFinder
@conversations = @conversations.includes( @conversations = @conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox :taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
) )
sort_by = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['latest']
@conversations.send(sort_by).page(current_page) sort_by, sort_order = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['last_activity_at_desc']
@conversations.send(sort_by, sort_order).page(current_page).per(ENV.fetch('CONVERSATION_RESULTS_PER_PAGE', '25').to_i)
end end
end end
+42 -19
View File
@@ -6,7 +6,17 @@ module PortalHelper
def generate_portal_bg(portal_color, theme) def generate_portal_bg(portal_color, theme)
bg_image = theme == 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg' bg_image = theme == 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg'
"background: url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}" "url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}"
end
def generate_gradient_to_bottom(theme)
base_color = theme == 'dark' ? '#151718' : 'white'
"linear-gradient(to bottom, transparent, #{base_color})"
end
def generate_portal_hover_color(portal_color, theme)
base_color = theme == 'dark' ? '#1B1B1B' : '#F9F9F9'
"color-mix(in srgb, #{portal_color} 5%, #{base_color})"
end end
def language_name(locale) def language_name(locale)
@@ -14,35 +24,48 @@ module PortalHelper
language_map[locale] || locale language_map[locale] || locale
end end
def get_theme_names(theme) def theme_query_string(theme)
if theme == 'light' theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
I18n.t('public_portal.header.appearance.light') end
elsif theme == 'dark'
I18n.t('public_portal.header.appearance.dark') def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled)
if is_plain_layout_enabled
"/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}"
else else
I18n.t('public_portal.header.appearance.system') "/hc/#{portal_slug}/#{portal_locale}"
end end
end end
def get_theme_icon(theme) def generate_category_link(params)
if theme == 'light' portal_slug = params[:portal_slug]
'icons/sun' category_locale = params[:category_locale]
elsif theme == 'dark' category_slug = params[:category_slug]
'icons/moon' theme = params[:theme]
is_plain_layout_enabled = params[:is_plain_layout_enabled]
if is_plain_layout_enabled
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}"
else else
'icons/monitor' "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}"
end end
end end
def generate_gradient_to_bottom(theme) def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled)
"background-image: linear-gradient(to bottom, transparent, #{theme == 'dark' ? '#151718' : 'white'})" if is_plain_layout_enabled
end "/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}"
else
def generate_article_link(portal_slug, article_slug, theme) "/hc/#{portal_slug}/articles/#{article_slug}"
"/hc/#{portal_slug}/articles/#{article_slug}#{theme.present? && theme != 'system' ? "?theme=#{theme}" : ''}" end
end end
def render_category_content(content) def render_category_content(content)
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
end end
def thumbnail_bg_color(username)
colors = ['#6D95BA', '#A4C3C3', '#E19191']
return colors.sample if username.blank?
colors[username.length % colors.size]
end
end end
@@ -86,6 +86,12 @@ class MessageApi extends ApiClient {
return axios.delete(`${this.url}/${conversationID}/messages/${messageId}`); return axios.delete(`${this.url}/${conversationID}/messages/${messageId}`);
} }
retry(conversationID, messageId) {
return axios.post(
`${this.url}/${conversationID}/messages/${messageId}/retry`
);
}
getPreviousMessages({ conversationId, after, before }) { getPreviousMessages({ conversationId, after, before }) {
const params = { before }; const params = { before };
if (after && Number(after) !== Number(before)) { if (after && Number(after) !== Number(before)) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

@@ -1,39 +1,8 @@
@import '../variables'; @import 'shared/assets/fonts/inter';
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
.superadmin-body { body {
background: var(--color-background); font-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
.hero--title {
font-size: var(--font-size-mega);
font-weight: var(--font-weight-light);
margin-top: var(--space-large);
}
.update-subscription--checkbox {
display: flex;
input {
line-height: 1.5;
margin-right: var(--space-one);
margin-top: var(--space-smaller);
}
label {
font-size: var(--font-size-small);
line-height: 1.5;
margin-bottom: var(--space-normal);
}
}
} }
.alert-box {
background-color: var(--r-500);
border-radius: 5px;
color: var(--color-white);
font-size: 14px;
margin-bottom: 14px;
padding: 10px;
text-align: center;
}
@@ -1,3 +0,0 @@
@import 'shared/assets/fonts/plus-jakarta';
@import '../variables';
@import '~shared/assets/stylesheets/ionicons';
@@ -126,49 +126,33 @@
@assign-team="onAssignTeamsForBulk" @assign-team="onAssignTeamsForBulk"
/> />
<div <div
ref="activeConversation" ref="conversationList"
class="conversations-list flex-1" class="conversations-list flex-1"
:class="{ 'overflow-hidden': isContextMenuOpen }" :class="{ 'overflow-hidden': isContextMenuOpen }"
> >
<div> <virtual-list
<conversation-card ref="conversationVirtualList"
v-for="chat in conversationList" :data-key="'id'"
:key="chat.id" :data-sources="conversationList"
:active-label="label" :data-component="itemComponent"
:team-id="teamId" :extra-props="virtualListExtraProps"
:folders-id="foldersId" class="w-full overflow-auto h-full"
:chat="chat" footer-tag="div"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
:selected="isConversationSelected(chat.id)"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
@assign-agent="onAssignAgent"
@assign-team="onAssignTeam"
@assign-label="onAssignLabels"
@update-conversation-status="toggleConversationStatus"
@context-menu-toggle="onContextMenuToggle"
@mark-as-unread="markAsUnread"
@assign-priority="assignPriority"
/>
</div>
<div v-if="chatListLoading" class="text-center">
<span class="spinner mt-4 mb-4" />
</div>
<woot-button
v-if="!hasCurrentPageEndReached && !chatListLoading"
variant="clear"
size="expanded"
class="load-more--button"
@click="loadMoreConversations"
> >
{{ $t('CHAT_LIST.LOAD_MORE_CONVERSATIONS') }} <template #footer>
</woot-button> <div v-if="chatListLoading" class="text-center">
<span class="spinner mt-4 mb-4" />
<p v-if="showEndOfListMessage" class="text-center text-muted p-4"> </div>
{{ $t('CHAT_LIST.EOF') }} <p v-if="showEndOfListMessage" class="text-center text-muted p-4">
</p> {{ $t('CHAT_LIST.EOF') }}
</p>
<intersection-observer
v-if="!showEndOfListMessage && !chatListLoading"
:options="infiniteLoaderOptions"
@observed="loadMoreConversations"
/>
</template>
</virtual-list>
</div> </div>
<woot-modal <woot-modal
:show.sync="showAdvancedFilters" :show.sync="showAdvancedFilters"
@@ -191,11 +175,12 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import VirtualList from 'vue-virtual-scroll-list';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue'; import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue'; import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue'; import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationCard from './widgets/conversation/ConversationCard.vue'; import ConversationItem from './ConversationItem.vue';
import timeMixin from '../mixins/time'; import timeMixin from '../mixins/time';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import conversationMixin from '../mixins/conversations'; import conversationMixin from '../mixins/conversations';
@@ -222,16 +207,20 @@ import {
isOnUnattendedView, isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers'; } from '../store/modules/conversations/helpers/actionHelpers';
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events'; import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
import IntersectionObserver from './IntersectionObserver.vue';
export default { export default {
components: { components: {
AddCustomViews, AddCustomViews,
ChatTypeTabs, ChatTypeTabs,
ConversationCard, // eslint-disable-next-line vue/no-unused-components
ConversationItem,
ConversationAdvancedFilter, ConversationAdvancedFilter,
DeleteCustomViews, DeleteCustomViews,
ConversationBulkActions, ConversationBulkActions,
ConversationBasicFilter, ConversationBasicFilter,
IntersectionObserver,
VirtualList,
}, },
mixins: [ mixins: [
timeMixin, timeMixin,
@@ -241,6 +230,20 @@ export default {
filterMixin, filterMixin,
uiSettingsMixin, uiSettingsMixin,
], ],
provide() {
return {
// Actions to be performed on virtual list item and context menu.
selectConversation: this.selectConversation,
deSelectConversation: this.deSelectConversation,
assignAgent: this.onAssignAgent,
assignTeam: this.onAssignTeam,
assignLabels: this.onAssignLabels,
updateConversationStatus: this.toggleConversationStatus,
toggleContextMenu: this.onContextMenuToggle,
markAsUnread: this.markAsUnread,
assignPriority: this.assignPriority,
};
},
props: { props: {
conversationInbox: { conversationInbox: {
type: [String, Number], type: [String, Number],
@@ -275,7 +278,7 @@ export default {
return { return {
activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME, activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME,
activeStatus: wootConstants.STATUS_TYPE.OPEN, activeStatus: wootConstants.STATUS_TYPE.OPEN,
activeSortBy: wootConstants.SORT_BY_TYPE.LATEST, activeSortBy: wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC,
showAdvancedFilters: false, showAdvancedFilters: false,
advancedFilterTypes: advancedFilterTypes.map(filter => ({ advancedFilterTypes: advancedFilterTypes.map(filter => ({
...filter, ...filter,
@@ -291,6 +294,21 @@ export default {
selectedInboxes: [], selectedInboxes: [],
isContextMenuOpen: false, isContextMenuOpen: false,
appliedFilter: [], appliedFilter: [],
infiniteLoaderOptions: {
root: this.$refs.conversationList,
rootMargin: '100px 0px 100px 0px',
},
itemComponent: ConversationItem,
// virtualListExtraProps is to pass the props to the conversationItem component.
virtualListExtraProps: {
label: this.label,
teamId: this.teamId,
foldersId: this.foldersId,
conversationType: this.conversationType,
showAssignee: false,
isConversationSelected: this.isConversationSelected,
},
}; };
}, },
computed: { computed: {
@@ -509,16 +527,22 @@ export default {
}, },
label() { label() {
this.resetAndFetchData(); this.resetAndFetchData();
this.updateVirtualListProps('label', this.label);
}, },
conversationType() { conversationType() {
this.resetAndFetchData(); this.resetAndFetchData();
this.updateVirtualListProps('conversationType', this.conversationType);
}, },
activeFolder() { activeFolder() {
this.resetAndFetchData(); this.resetAndFetchData();
this.updateVirtualListProps('foldersId', this.foldersId);
}, },
chatLists() { chatLists() {
this.chatsOnView = this.conversationList; this.chatsOnView = this.conversationList;
}, },
showAssigneeInConversationCard(newVal) {
this.updateVirtualListProps('showAssignee', newVal);
},
}, },
mounted() { mounted() {
this.setFiltersFromUISettings(); this.setFiltersFromUISettings();
@@ -535,6 +559,12 @@ export default {
}); });
}, },
methods: { methods: {
updateVirtualListProps(key, value) {
this.virtualListExtraProps = {
...this.virtualListExtraProps,
[key]: value,
};
},
onApplyFilter(payload) { onApplyFilter(payload) {
this.resetBulkActions(); this.resetBulkActions();
this.foldersQuery = filterQueryGenerator(payload); this.foldersQuery = filterQueryGenerator(payload);
@@ -555,7 +585,10 @@ export default {
const { conversations_filter_by: filterBy = {} } = this.uiSettings; const { conversations_filter_by: filterBy = {} } = this.uiSettings;
const { status, order_by: orderBy } = filterBy; const { status, order_by: orderBy } = filterBy;
this.activeStatus = status || wootConstants.STATUS_TYPE.OPEN; this.activeStatus = status || wootConstants.STATUS_TYPE.OPEN;
this.activeSortBy = orderBy || wootConstants.SORT_BY_TYPE.LATEST; this.activeSortBy =
Object.keys(wootConstants.SORT_BY_TYPE).find(
sortField => sortField === orderBy
) || wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC;
}, },
onClickOpenAddFoldersModal() { onClickOpenAddFoldersModal() {
this.showAddFoldersModal = true; this.showAddFoldersModal = true;
@@ -635,10 +668,10 @@ export default {
); );
}, },
getKeyboardListenerParams() { getKeyboardListenerParams() {
const allConversations = this.$refs.activeConversation.querySelectorAll( const allConversations = this.$refs.conversationList.querySelectorAll(
'div.conversations-list div.conversation' 'div.conversations-list div.conversation'
); );
const activeConversation = this.$refs.activeConversation.querySelector( const activeConversation = this.$refs.conversationList.querySelector(
'div.conversations-list div.conversation.active' 'div.conversations-list div.conversation.active'
); );
const activeConversationIndex = [...allConversations].indexOf( const activeConversationIndex = [...allConversations].indexOf(
@@ -694,9 +727,12 @@ export default {
fetchConversations() { fetchConversations() {
this.$store this.$store
.dispatch('fetchAllConversations', this.conversationFilters) .dispatch('fetchAllConversations', this.conversationFilters)
.then(() => this.$emit('conversation-load')); .then(this.emitConversationLoaded);
}, },
loadMoreConversations() { loadMoreConversations() {
if (this.hasCurrentPageEndReached || this.chatListLoading) {
return;
}
if (!this.hasAppliedFiltersOrActiveFolders) { if (!this.hasAppliedFiltersOrActiveFolders) {
this.fetchConversations(); this.fetchConversations();
} }
@@ -715,7 +751,7 @@ export default {
queryData: filterQueryGenerator(payload), queryData: filterQueryGenerator(payload),
page, page,
}) })
.then(() => this.$emit('conversation-load')); .then(this.emitConversationLoaded);
this.showAdvancedFilters = false; this.showAdvancedFilters = false;
}, },
fetchSavedFilteredConversations(payload) { fetchSavedFilteredConversations(payload) {
@@ -725,7 +761,7 @@ export default {
queryData: payload, queryData: payload,
page, page,
}) })
.then(() => this.$emit('conversation-load')); .then(this.emitConversationLoaded);
}, },
updateAssigneeTab(selectedTab) { updateAssigneeTab(selectedTab) {
if (this.activeAssigneeTab !== selectedTab) { if (this.activeAssigneeTab !== selectedTab) {
@@ -737,6 +773,20 @@ export default {
} }
} }
}, },
emitConversationLoaded() {
this.$emit('conversation-load');
this.$nextTick(() => {
// Addressing a known issue in the virtual list library where dynamically added items
// might not render correctly. This workaround involves a slight manual adjustment
// to the scroll position, triggering the list to refresh its rendering.
const virtualList = this.$refs.conversationVirtualList;
const scrollToOffset = virtualList?.scrollToOffset;
const currentOffset = virtualList?.getOffset() || 0;
if (scrollToOffset) {
scrollToOffset(currentOffset + 1);
}
});
},
resetBulkActions() { resetBulkActions() {
this.selectedConversations = []; this.selectedConversations = [];
this.selectedInboxes = []; this.selectedInboxes = [];
@@ -0,0 +1,72 @@
<template>
<conversation-card
:key="source.id"
:active-label="label"
:team-id="teamId"
:folders-id="foldersId"
:chat="source"
:conversation-type="conversationType"
:selected="isConversationSelected(source.id)"
:show-assignee="showAssignee"
:enable-context-menu="true"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
@assign-agent="assignAgent"
@assign-team="assignTeam"
@assign-label="assignLabels"
@update-conversation-status="updateConversationStatus"
@context-menu-toggle="toggleContextMenu"
@mark-as-unread="markAsUnread"
@assign-priority="assignPriority"
/>
</template>
<script>
import ConversationCard from './widgets/conversation/ConversationCard.vue';
export default {
components: {
ConversationCard,
},
inject: [
'selectConversation',
'deSelectConversation',
'assignAgent',
'assignTeam',
'assignLabels',
'updateConversationStatus',
'toggleContextMenu',
'markAsUnread',
'assignPriority',
],
props: {
source: {
type: Object,
required: true,
},
teamId: {
type: [String, Number],
default: 0,
},
label: {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
foldersId: {
type: [String, Number],
default: 0,
},
isConversationSelected: {
type: Function,
default: () => {},
},
showAssignee: {
type: Boolean,
default: false,
},
},
};
</script>
@@ -0,0 +1,34 @@
<template>
<div ref="observedElement" class="h-6 w-full" />
</template>
<script>
export default {
props: {
options: {
type: Object,
default: () => ({ root: document, rootMargin: '100px 0 100px 0)' }),
},
},
mounted() {
this.intersectionObserver = null;
this.registerInfiniteLoader();
},
beforeDestroy() {
this.unobserveInfiniteLoadObserver();
},
methods: {
registerInfiniteLoader() {
this.intersectionObserver = new IntersectionObserver(entries => {
if (entries && entries[0].isIntersecting) {
this.$emit('observed');
}
}, this.options);
this.intersectionObserver.observe(this.$refs.observedElement);
},
unobserveInfiniteLoadObserver() {
this.intersectionObserver.unobserve(this.$refs.observedElement);
},
},
};
</script>
@@ -1,24 +1,33 @@
<template> <template>
<transition name="network-notification-fade" tag="div"> <transition name="network-notification-fade" tag="div">
<div v-show="showNotification" class="ui-notification-container"> <div v-show="showNotification" class="fixed top-4 left-2 z-50 group">
<div class="ui-notification"> <div
<fluent-icon icon="wifi-off" /> class="flex items-center justify-between py-1 px-2 w-full rounded-lg shadow-lg bg-yellow-200 dark:bg-yellow-700 relative"
<p class="ui-notification-text"> >
{{ <fluent-icon
useInstallationName( icon="wifi-off"
$t('NETWORK.NOTIFICATION.TEXT'), class="text-yellow-700/50 dark:text-yellow-50"
globalConfig.installationName size="18"
) />
}} <span
</p> class="text-xs tracking-wide font-medium px-2 text-yellow-700/70 dark:text-yellow-50"
<woot-button variant="clear" size="small" @click="refreshPage"> >
{{ $t('NETWORK.BUTTON.REFRESH') }} {{ $t('NETWORK.NOTIFICATION.OFFLINE') }}
</woot-button> </span>
<woot-button <woot-button
variant="smooth" :title="$t('NETWORK.BUTTON.REFRESH')"
variant="clear"
size="small" size="small"
color-scheme="warning" color-scheme="warning"
icon="dismiss-circle" icon="arrow-clockwise"
class="visible transition-all duration-500 ease-in-out ml-1"
@click="refreshPage"
/>
<woot-button
variant="clear"
size="small"
color-scheme="warning"
icon="dismiss"
@click="closeNotification" @click="closeNotification"
/> />
</div> </div>
@@ -47,7 +56,12 @@ export default {
mounted() { mounted() {
window.addEventListener('offline', this.updateOnlineStatus); window.addEventListener('offline', this.updateOnlineStatus);
window.bus.$on(BUS_EVENTS.WEBSOCKET_DISCONNECT, () => { window.bus.$on(BUS_EVENTS.WEBSOCKET_DISCONNECT, () => {
this.updateOnlineStatus({ type: 'offline' }); // TODO: Remove this after completing the conversation list refetching
// TODO: DIRTY FIX : CLEAN UP THIS WITH PROPER FIX, DELAYING THE RECONNECT FOR NOW
// THE CABLE IS FIRING IS VERY COMMON AND THUS INTERFERING USER EXPERIENCE
setTimeout(() => {
this.updateOnlineStatus({ type: 'offline' });
}, 4000);
}); });
}, },
@@ -72,33 +86,3 @@ export default {
}, },
}; };
</script> </script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/mixins';
.ui-notification-container {
max-width: 25rem;
position: absolute;
right: var(--space-normal);
top: var(--space-normal);
z-index: var(--z-index-very-high);
}
.ui-notification {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
background-color: var(--y-100);
border-radius: var(--border-radius-medium);
box-shadow: var(--shadow-large);
min-width: 15rem;
padding: var(--space-normal);
}
.ui-notification-text {
margin: 0 var(--space-small);
}
</style>
@@ -84,7 +84,10 @@ import {
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings'; import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import { replaceVariablesInMessage } from '@chatwoot/utils'; import {
replaceVariablesInMessage,
createTypingIndicator,
} from '@chatwoot/utils';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events'; import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper'; import { uploadFile } from 'dashboard/helper/uploadHelper';
@@ -140,6 +143,15 @@ export default {
}, },
data() { data() {
return { return {
typingIndicator: createTypingIndicator(
() => {
this.$emit('typing-on');
},
() => {
this.$emit('typing-off');
},
TYPING_INDICATOR_IDLE_TIME
),
showUserMentions: false, showUserMentions: false,
showCannedMenu: false, showCannedMenu: false,
showVariables: false, showVariables: false,
@@ -638,15 +650,6 @@ export default {
hideMentions() { hideMentions() {
this.showUserMentions = false; this.showUserMentions = false;
}, },
resetTyping() {
this.$emit('typing-off');
this.idleTimer = null;
},
turnOffIdleTimer() {
if (this.idleTimer) {
clearTimeout(this.idleTimer);
}
},
handleLineBreakWhenEnterToSendEnabled(event) { handleLineBreakWhenEnterToSendEnabled(event) {
if ( if (
hasPressedEnterAndNotCmdOrShift(event) && hasPressedEnterAndNotCmdOrShift(event) &&
@@ -666,14 +669,7 @@ export default {
} }
}, },
onKeyup() { onKeyup() {
if (!this.idleTimer) { this.typingIndicator.start();
this.$emit('typing-on');
}
this.turnOffIdleTimer();
this.idleTimer = setTimeout(
() => this.resetTyping(),
TYPING_INDICATOR_IDLE_TIME
);
this.updateImgToolbarOnDelete(); this.updateImgToolbarOnDelete();
}, },
onKeydown(event) { onKeydown(event) {
@@ -685,8 +681,7 @@ export default {
} }
}, },
onBlur() { onBlur() {
this.turnOffIdleTimer(); this.typingIndicator.stop();
this.resetTyping();
this.$emit('blur'); this.$emit('blur');
}, },
onFocus() { onFocus() {
@@ -144,6 +144,7 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { import {
ALLOWED_FILE_TYPES, ALLOWED_FILE_TYPES,
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP, ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
ALLOWED_FILE_TYPES_FOR_LINE,
} from 'shared/constants/messages'; } from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue'; import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue'; import AIAssistanceButton from '../AIAssistanceButton.vue';
@@ -270,6 +271,9 @@ export default {
return this.showFileUpload || this.isNote; return this.showFileUpload || this.isNote;
}, },
showAudioRecorderButton() { showAudioRecorderButton() {
if (this.isALineChannel) {
return false;
}
// Disable audio recorder for safari browser as recording is not supported // Disable audio recorder for safari browser as recording is not supported
const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test( const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(
navigator.userAgent navigator.userAgent
@@ -291,6 +295,9 @@ export default {
if (this.isATwilioWhatsAppChannel) { if (this.isATwilioWhatsAppChannel) {
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP; return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
} }
if (this.isALineChannel) {
return ALLOWED_FILE_TYPES_FOR_LINE;
}
return ALLOWED_FILE_TYPES; return ALLOWED_FILE_TYPES;
}, },
enableDragAndDrop() { enableDragAndDrop() {
@@ -34,7 +34,7 @@
type="sort" type="sort"
:selected-value="sortFilter" :selected-value="sortFilter"
:items="chatSortItems" :items="chatSortItems"
path-prefix="CHAT_LIST.CHAT_SORT_FILTER_ITEMS" path-prefix="CHAT_LIST.SORT_ORDER_ITEMS"
@onChangeFilter="onChangeFilter" @onChangeFilter="onChangeFilter"
/> />
</div> </div>
@@ -58,7 +58,7 @@ export default {
return { return {
showActionsDropdown: false, showActionsDropdown: false,
chatStatusItems: this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS'), chatStatusItems: this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS'),
chatSortItems: this.$t('CHAT_LIST.CHAT_SORT_FILTER_ITEMS'), chatSortItems: this.$t('CHAT_LIST.SORT_ORDER_ITEMS'),
}; };
}, },
computed: { computed: {
@@ -70,7 +70,9 @@ export default {
return this.chatStatusFilter || wootConstants.STATUS_TYPE.OPEN; return this.chatStatusFilter || wootConstants.STATUS_TYPE.OPEN;
}, },
sortFilter() { sortFilter() {
return this.chatSortFilter || wootConstants.SORT_BY_TYPE.LATEST; return (
this.chatSortFilter || wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC
);
}, },
}, },
methods: { methods: {
@@ -31,7 +31,7 @@
size="40px" size="40px"
/> />
<div <div
class="px-0 py-3 border-b group-last:border-transparent group-hover:border-transparent border-slate-50 dark:border-slate-800/75 columns" class="px-0 py-3 border-b group-hover:border-transparent border-slate-50 dark:border-slate-800/75 columns"
> >
<div class="flex justify-between"> <div class="flex justify-between">
<inbox-name v-if="showInboxName" :inbox="inbox" /> <inbox-name v-if="showInboxName" :inbox="inbox" />
@@ -175,6 +175,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
enableContextMenu: {
type: Boolean,
default: false,
},
}, },
data() { data() {
return { return {
@@ -289,6 +293,7 @@ export default {
this.$emit(action, this.chat.id, this.inbox.id); this.$emit(action, this.chat.id, this.inbox.id);
}, },
openContextMenu(e) { openContextMenu(e) {
if (!this.enableContextMenu) return;
e.preventDefault(); e.preventDefault();
this.$emit('context-menu-toggle', true); this.$emit('context-menu-toggle', true);
this.contextMenu.x = e.pageX || e.clientX; this.contextMenu.x = e.pageX || e.clientX;
@@ -1,7 +1,7 @@
<template> <template>
<li v-if="shouldRenderMessage" :id="`message${data.id}`" :class="alignBubble"> <li v-if="shouldRenderMessage" :id="`message${data.id}`" :class="alignBubble">
<div :class="wrapClass"> <div :class="wrapClass">
<div v-if="isFailed" class="message-failed--alert"> <div v-if="isFailed && !hasOneDayPassed" class="message-failed--alert">
<woot-button <woot-button
v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')" v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')"
size="tiny" size="tiny"
@@ -148,6 +148,7 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage'; import { LocalStorage } from 'shared/helpers/localStorage';
import { getDayDifferenceFromNow } from 'shared/helpers/DateHelper';
export default { export default {
components: { components: {
@@ -209,6 +210,10 @@ export default {
created_at: this.data.created_at || '', created_at: this.data.created_at || '',
})); }));
}, },
hasOneDayPassed() {
// Disable retry button if the message is failed and the message is older than 24 hours
return getDayDifferenceFromNow(new Date(), this.data?.created_at) >= 1;
},
shouldRenderMessage() { shouldRenderMessage() {
return ( return (
this.hasAttachments || this.hasAttachments ||
@@ -20,7 +20,15 @@
icon="info" icon="info"
/> />
</template> </template>
<span v-if="message.content"> <span v-if="message.content && isMessageSticker">
<fluent-icon
size="16"
class="-mt-0.5 align-middle inline-block text-slate-600 dark:text-slate-300"
icon="image"
/>
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
</span>
<span v-else-if="message.content">
{{ parsedLastMessage }} {{ parsedLastMessage }}
</span> </span>
<span v-else-if="message.attachments"> <span v-else-if="message.attachments">
@@ -88,6 +96,9 @@ export default {
attachmentMessageContent() { attachmentMessageContent() {
return `CHAT_LIST.ATTACHMENTS.${this.lastMessageFileType}.CONTENT`; return `CHAT_LIST.ATTACHMENTS.${this.lastMessageFileType}.CONTENT`;
}, },
isMessageSticker() {
return this.message && this.message.content_type === 'sticker';
},
}, },
}; };
</script> </script>
@@ -284,10 +284,12 @@ export default {
return this.currentChat.unread_count || 0; return this.currentChat.unread_count || 0;
}, },
inboxSupportsReplyTo() { inboxSupportsReplyTo() {
return { const incoming = this.inboxHasFeature(INBOX_FEATURES.REPLY_TO);
incoming: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO), const outgoing =
outgoing: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING), this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING) &&
}; !this.is360DialogWhatsAppChannel;
return { incoming, outgoing };
}, },
}, },
@@ -271,11 +271,17 @@ export default {
accountId: 'getCurrentAccountId', accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}), }),
currentContact() {
return this.$store.getters['contacts/getContact'](
this.currentChat.meta.sender.id
);
},
shouldShowReplyToMessage() { shouldShowReplyToMessage() {
return ( return (
this.inReplyTo?.id && this.inReplyTo?.id &&
!this.isPrivate && !this.isPrivate &&
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
!this.is360DialogWhatsAppChannel
); );
}, },
showRichContentEditor() { showRichContentEditor() {
@@ -392,7 +398,8 @@ export default {
this.isAPIInbox || this.isAPIInbox ||
this.isAnEmailChannel || this.isAnEmailChannel ||
this.isASmsInbox || this.isASmsInbox ||
this.isATelegramChannel this.isATelegramChannel ||
this.isALineChannel
); );
}, },
replyButtonLabel() { replyButtonLabel() {
@@ -495,7 +502,11 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`; return `draft-${this.conversationIdByRoute}-${this.replyType}`;
}, },
audioRecordFormat() { audioRecordFormat() {
if (this.isAWhatsAppChannel || this.isAPIInbox) { if (
this.isAWhatsAppChannel ||
this.isAPIInbox ||
this.isATelegramChannel
) {
return AUDIO_FORMATS.OGG; return AUDIO_FORMATS.OGG;
} }
return AUDIO_FORMATS.WAV; return AUDIO_FORMATS.WAV;
@@ -503,6 +514,7 @@ export default {
messageVariables() { messageVariables() {
const variables = getMessageVariables({ const variables = getMessageVariables({
conversation: this.currentChat, conversation: this.currentChat,
contact: this.currentContact,
}); });
return variables; return variables;
}, },
@@ -624,6 +636,8 @@ export default {
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}` `${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
); );
} }
this.$track(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
}, },
toggleRichContentEditor() { toggleRichContentEditor() {
this.updateUISettings({ this.updateUISettings({
@@ -1,5 +1,9 @@
<template> <template>
<mention-box :items="items" @mention-select="handleVariableClick"> <mention-box
type="variable"
:items="items"
@mention-select="handleVariableClick"
>
<template slot-scope="{ item }"> <template slot-scope="{ item }">
<span class="text-capitalize variable--list-label"> <span class="text-capitalize variable--list-label">
{{ item.description }} {{ item.description }}
@@ -10,6 +14,7 @@
</template> </template>
<script> <script>
import { mapGetters } from 'vuex';
import { MESSAGE_VARIABLES } from 'shared/constants/messages'; import { MESSAGE_VARIABLES } from 'shared/constants/messages';
import MentionBox from '../mentions/MentionBox.vue'; import MentionBox from '../mentions/MentionBox.vue';
@@ -22,7 +27,16 @@ export default {
}, },
}, },
computed: { computed: {
...mapGetters({
customAttributes: 'attributes/getAttributes',
}),
items() { items() {
return [
...this.standardAttributeVariables,
...this.customAttributeVariables,
];
},
standardAttributeVariables() {
return MESSAGE_VARIABLES.filter(variable => { return MESSAGE_VARIABLES.filter(variable => {
return ( return (
variable.label.includes(this.searchKey) || variable.label.includes(this.searchKey) ||
@@ -34,6 +48,20 @@ export default {
description: variable.label, description: variable.label,
})); }));
}, },
customAttributeVariables() {
return this.customAttributes.map(attribute => {
const attributePrefix =
attribute.attribute_model === 'conversation_attribute'
? 'conversation'
: 'contact';
return {
label: `${attributePrefix}.custom_attribute.${attribute.attribute_key}`,
key: `${attributePrefix}.custom_attribute.${attribute.attribute_key}`,
description: attribute.attribute_description,
};
});
},
}, },
methods: { methods: {
handleVariableClick(item = {}) { handleVariableClick(item = {}) {
@@ -7,7 +7,11 @@
}" }"
> >
<div v-if="!isEmail" v-dompurify-html="message" class="text-content" /> <div v-if="!isEmail" v-dompurify-html="message" class="text-content" />
<letter v-else class="text-content" :html="message" /> <letter
v-else
class="text-content bg-white dark:bg-white text-slate-900 dark:text-slate-900 p-2 rounded-[4px]"
:html="message"
/>
<button <button
v-if="showQuoteToggle" v-if="showQuoteToggle"
class="text-slate-300 dark:text-slate-300 cursor-pointer text-xs py-1" class="text-slate-300 dark:text-slate-300 cursor-pointer text-xs py-1"
@@ -46,9 +46,25 @@
</span> </span>
</div> </div>
<div <div
class="items-center flex gap-2 justify-end min-w-[15rem]" class="items-center flex gap-2 justify-end min-w-[8rem] sm:min-w-[15rem]"
@click.stop @click.stop
> >
<woot-button
v-if="isImage"
size="large"
color-scheme="secondary"
variant="clear"
icon="arrow-rotate-counter-clockwise"
@click="onRotate('counter-clockwise')"
/>
<woot-button
v-if="isImage"
size="large"
color-scheme="secondary"
variant="clear"
icon="arrow-rotate-clockwise"
@click="onRotate('clockwise')"
/>
<woot-button <woot-button
size="large" size="large"
color-scheme="secondary" color-scheme="secondary"
@@ -89,6 +105,7 @@
:key="activeAttachment.message_id" :key="activeAttachment.message_id"
:src="activeAttachment.data_url" :src="activeAttachment.data_url"
class="modal-image skip-context-menu my-0 mx-auto" class="modal-image skip-context-menu my-0 mx-auto"
:style="imageRotationStyle"
@click.stop @click.stop
/> />
<video <video
@@ -186,6 +203,7 @@ export default {
this.allAttachments.findIndex( this.allAttachments.findIndex(
attachment => attachment.message_id === this.attachment.message_id attachment => attachment.message_id === this.attachment.message_id
) || 0, ) || 0,
activeImageRotation: 0,
}; };
}, },
computed: { computed: {
@@ -232,6 +250,11 @@ export default {
const fileName = dataUrl?.split('/').pop(); const fileName = dataUrl?.split('/').pop();
return fileName || ''; return fileName || '';
}, },
imageRotationStyle() {
return {
transform: `rotate(${this.activeImageRotation}deg)`,
};
},
}, },
mounted() { mounted() {
this.setImageAndVideoSrc(this.attachment); this.setImageAndVideoSrc(this.attachment);
@@ -246,6 +269,7 @@ export default {
} }
this.activeImageIndex = index; this.activeImageIndex = index;
this.setImageAndVideoSrc(attachment); this.setImageAndVideoSrc(attachment);
this.activeImageRotation = 0;
}, },
setImageAndVideoSrc(attachment) { setImageAndVideoSrc(attachment) {
const { file_type: type } = attachment; const { file_type: type } = attachment;
@@ -280,6 +304,20 @@ export default {
link.download = `attachment.${type}`; link.download = `attachment.${type}`;
link.click(); link.click();
}, },
onRotate(type) {
if (!this.isImage) {
return;
}
const rotation = type === 'clockwise' ? 90 : -90;
// Reset rotation if it is 360
if (Math.abs(this.activeImageRotation) === 360) {
this.activeImageRotation = rotation;
} else {
this.activeImageRotation += rotation;
}
},
}, },
}; };
</script> </script>
@@ -25,7 +25,7 @@
id="file" id="file"
ref="file" ref="file"
type="file" type="file"
accept="image/png, image/jpeg, image/gif" accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
@change="handleImageUpload" @change="handleImageUpload"
/> />
<slot /> <slot />
@@ -1,20 +1,41 @@
<template> <template>
<div v-if="items.length" ref="mentionsListContainer" class="mention--box"> <div
v-if="items.length"
ref="mentionsListContainer"
class="bg-white dark:bg-slate-800 rounded-md overflow-auto absolute w-full z-20 pb-0 shadow-md left-0 bottom-full max-h-[9.75rem] border border-solid border-slate-100 dark:border-slate-700 mention--box"
>
<ul class="vertical dropdown menu"> <ul class="vertical dropdown menu">
<woot-dropdown-item <woot-dropdown-item
v-for="(item, index) in items" v-for="(item, index) in items"
:id="`mention-item-${index}`" :id="`mention-item-${index}`"
:key="item.key" :key="item.key"
class="!mb-0"
@mouseover="onHover(index)" @mouseover="onHover(index)"
> >
<woot-button <button
class="canned-item__button" class="flex group flex-col gap-0.5 overflow-hidden cursor-pointer items-start py-2.5 px-2.5 justify-center w-full h-full text-left hover:bg-woot-50 dark:hover:bg-woot-800 border-b border-solid border-slate-100 dark:border-slate-700"
:variant="index === selectedIndex ? '' : 'clear'" :class="{
:class="{ active: index === selectedIndex }" ' bg-woot-25 dark:bg-woot-800': index === selectedIndex,
}"
@click="onListItemSelection(index)" @click="onListItemSelection(index)"
> >
<strong>{{ item.label }}</strong> - {{ item.description }} <p
</woot-button> class="text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 font-medium mb-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap min-w-0 max-w-full"
:class="{
'text-woot-500 dark:text-woot-500': index === selectedIndex,
}"
>
{{ item.description }}
</p>
<p
class="text-slate-500 dark:text-slate-300 group-hover:text-woot-500 dark:group-hover:text-woot-500 mb-0 text-xs overflow-hidden text-ellipsis whitespace-nowrap min-w-0 max-w-full"
:class="{
'text-woot-500 dark:text-woot-500': index === selectedIndex,
}"
>
{{ variableKey(item) }}
</p>
</button>
</woot-dropdown-item> </woot-dropdown-item>
</ul> </ul>
</div> </div>
@@ -29,6 +50,10 @@ export default {
type: Array, type: Array,
default: () => {}, default: () => {},
}, },
type: {
type: String,
default: 'canned',
},
}, },
data() { data() {
return { return {
@@ -73,28 +98,17 @@ export default {
onSelect() { onSelect() {
this.$emit('mention-select', this.items[this.selectedIndex]); this.$emit('mention-select', this.items[this.selectedIndex]);
}, },
variableKey(item = {}) {
return this.type === 'variable' ? `{{${item.label}}}` : `/${item.label}`;
},
}, },
}; };
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.mention--box { .mention--box {
@apply bg-white dark:bg-slate-700 rounded-md overflow-auto absolute w-full z-20 pt-2 px-2 pb-0 shadow-md left-0 bottom-full max-h-[9.75rem] border-t border-solid border-slate-75 dark:border-slate-800; .dropdown-menu__item:last-child > button {
@apply border-0;
.dropdown-menu__item:last-child {
@apply pb-1;
}
.active {
@apply text-white dark:text-white;
&:hover {
@apply bg-woot-700 dark:bg-woot-700;
}
}
.button {
@apply transition-none h-8 leading-[1.4];
} }
} }
@@ -13,10 +13,14 @@ export default {
ALL: 'all', ALL: 'all',
}, },
SORT_BY_TYPE: { SORT_BY_TYPE: {
LATEST: 'latest', LAST_ACTIVITY_AT_ASC: 'last_activity_at_asc',
CREATED_AT: 'sort_on_created_at', LAST_ACTIVITY_AT_DESC: 'last_activity_at_desc',
PRIORITY: 'sort_on_priority', CREATED_AT_ASC: 'created_at_asc',
WATIING_SINCE: 'waiting_since', CREATED_AT_DESC: 'created_at_desc',
PRIORITY_ASC: 'priority_asc',
PRIORITY_DESC: 'priority_desc',
WAITING_SINCE_ASC: 'waiting_since_asc',
WAITING_SINCE_DESC: 'waiting_since_desc',
}, },
ARTICLE_STATUS_TYPES: { ARTICLE_STATUS_TYPES: {
DRAFT: 0, DRAFT: 0,
@@ -9,6 +9,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
SEARCH_CONVERSATION: 'Searched conversations', SEARCH_CONVERSATION: 'Searched conversations',
APPLY_FILTER: 'Applied filters in the conversation list', APPLY_FILTER: 'Applied filters in the conversation list',
CHANGE_PRIORITY: 'Assigned priority to a conversation', CHANGE_PRIORITY: 'Assigned priority to a conversation',
INSERT_ARTICLE_LINK: 'Inserted article into reply via article search',
}); });
export const ACCOUNT_EVENTS = Object.freeze({ export const ACCOUNT_EVENTS = Object.freeze({
@@ -23,6 +23,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'contact.updated': this.onContactUpdate, 'contact.updated': this.onContactUpdate,
'conversation.mentioned': this.onConversationMentioned, 'conversation.mentioned': this.onConversationMentioned,
'notification.created': this.onNotificationCreated, 'notification.created': this.onNotificationCreated,
'notification.deleted': this.onNotificationDeleted,
'first.reply.created': this.onFirstReplyCreated, 'first.reply.created': this.onFirstReplyCreated,
'conversation.read': this.onConversationRead, 'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated, 'conversation.updated': this.onConversationUpdated,
@@ -195,6 +196,10 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('notifications/addNotification', data); this.app.$store.dispatch('notifications/addNotification', data);
}; };
onNotificationDeleted = data => {
this.app.$store.dispatch('notifications/deleteNotification', data);
};
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
onFirstReplyCreated = () => { onFirstReplyCreated = () => {
bus.$emit('fetch_overview_reports'); bus.$emit('fetch_overview_reports');
@@ -51,18 +51,30 @@
"ACTIVE": "Last activity" "ACTIVE": "Last activity"
} }
}, },
"CHAT_SORT_FILTER_ITEMS": { "SORT_ORDER_ITEMS": {
"latest": { "last_activity_at_asc": {
"TEXT": "Last activity" "TEXT": "Last activity: Oldest first"
}, },
"sort_on_created_at": { "last_activity_at_desc": {
"TEXT": "Created at" "TEXT": "Last activity: Newest first"
}, },
"sort_on_priority": { "created_at_desc": {
"TEXT": "Priority" "TEXT": "Created at: Newest first"
}, },
"sort_on_waiting_since": { "created_at_asc": {
"TEXT": "Pending Response" "TEXT": "Created at: Oldest first"
},
"priority_desc": {
"TEXT": "Priority: Highest first"
},
"priority_asc": {
"TEXT": "Priority: Lowest first"
},
"waiting_since_asc": {
"TEXT": "Pending Response: Longest first"
},
"waiting_since_desc": {
"TEXT": "Pending Response: Shortest first"
} }
}, },
"ATTACHMENTS": { "ATTACHMENTS": {
@@ -92,7 +92,7 @@
}, },
"NETWORK": { "NETWORK": {
"NOTIFICATION": { "NOTIFICATION": {
"TEXT": "Disconnected from Chatwoot" "OFFLINE": "Offline"
}, },
"BUTTON": { "BUTTON": {
"REFRESH": "Refresh" "REFRESH": "Refresh"
@@ -3,6 +3,7 @@
"HEADER": { "HEADER": {
"FILTER": "Filter by", "FILTER": "Filter by",
"SORT": "Sort by", "SORT": "Sort by",
"LOCALE": "Locale",
"SETTINGS_BUTTON": "Settings", "SETTINGS_BUTTON": "Settings",
"NEW_BUTTON": "New Article", "NEW_BUTTON": "New Article",
"DROPDOWN_OPTIONS": { "DROPDOWN_OPTIONS": {
@@ -15,6 +16,12 @@
"MINE": "My Articles", "MINE": "My Articles",
"DRAFT": "Draft Articles", "DRAFT": "Draft Articles",
"ARCHIVED": "Archived Articles" "ARCHIVED": "Archived Articles"
},
"LOCALE_SELECT": {
"TITLE": "Select locale",
"PLACEHOLDER": "Select locale",
"NO_RESULT": "No locale found",
"SEARCH_PLACEHOLDER": "Search locale"
} }
}, },
"EDIT_HEADER": { "EDIT_HEADER": {
@@ -1,4 +1,5 @@
import TimeMixin from '../time'; import TimeMixin from '../time';
import { format } from 'date-fns';
describe('#messageStamp', () => { describe('#messageStamp', () => {
it('returns correct value', () => { it('returns correct value', () => {
@@ -11,8 +12,9 @@ describe('#messageStamp', () => {
describe('#messageTimestamp', () => { describe('#messageTimestamp', () => {
it('should return the message date in the specified format if the message was sent in the current year', () => { it('should return the message date in the specified format if the message was sent in the current year', () => {
expect(TimeMixin.methods.messageTimestamp(1680777464)).toEqual( const currentEpochTime = Math.floor(new Date().getTime() / 1000);
'Apr 6, 2023' expect(TimeMixin.methods.messageTimestamp(currentEpochTime)).toEqual(
format(new Date(currentEpochTime * 1000), 'MMM d, yyyy')
); );
}); });
it('should return the message date and time in a different format if the message was sent in a different year', () => { it('should return the message date and time in a different format if the message was sent in a different year', () => {
@@ -1,7 +1,8 @@
/* eslint-disable storybook/default-exports */ /* eslint-disable storybook/default-exports */
import SearchView from './components/SearchView.vue';
import { frontendURL } from '../../helper/URLHelper'; import { frontendURL } from '../../helper/URLHelper';
const SearchView = () => import('./components/SearchView.vue');
export const routes = [ export const routes = [
{ {
path: frontendURL('accounts/:accountId/search'), path: frontendURL('accounts/:accountId/search'),
@@ -1,7 +1,7 @@
/* eslint arrow-body-style: 0 */ /* eslint arrow-body-style: 0 */
import ContactsView from './components/ContactsView';
import ContactManageView from './pages/ContactManageView';
import { frontendURL } from '../../../helper/URLHelper'; import { frontendURL } from '../../../helper/URLHelper';
const ContactsView = () => import('./components/ContactsView.vue');
const ContactManageView = () => import('./pages/ContactManageView.vue');
export const routes = [ export const routes = [
{ {
@@ -1,6 +1,6 @@
/* eslint arrow-body-style: 0 */ /* eslint arrow-body-style: 0 */
import ConversationView from './ConversationView';
import { frontendURL } from '../../../helper/URLHelper'; import { frontendURL } from '../../../helper/URLHelper';
const ConversationView = () => import('./ConversationView');
export default { export default {
routes: [ routes: [
@@ -1,4 +1,3 @@
import AppContainer from './Dashboard';
import settings from './settings/settings.routes'; import settings from './settings/settings.routes';
import conversation from './conversation/conversation.routes'; import conversation from './conversation/conversation.routes';
import { routes as searchRoutes } from '../../modules/search/search.routes'; import { routes as searchRoutes } from '../../modules/search/search.routes';
@@ -7,7 +6,8 @@ import { routes as notificationRoutes } from './notifications/routes';
import { frontendURL } from '../../helper/URLHelper'; import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes'; import helpcenterRoutes from './helpcenter/helpcenter.routes';
const Suspended = () => import('./suspended/Index'); const AppContainer = () => import('./Dashboard.vue');
const Suspended = () => import('./suspended/Index.vue');
export default { export default {
routes: [ routes: [
@@ -161,7 +161,7 @@ export default {
} }
span.article-column { span.article-column {
@apply text-slate-700 dark:text-slate-100 text-sm font-semibold py-2 px-0 text-right capitalize; @apply text-slate-700 dark:text-slate-100 text-sm font-semibold py-2 px-0 text-left capitalize last:text-right;
&.article-title { &.article-title {
@apply items-start flex gap-2 col-span-4 text-left; @apply items-start flex gap-2 col-span-4 text-left;
@@ -10,17 +10,17 @@
{{ $t('HELP_CENTER.TABLE.HEADERS.TITLE') }} {{ $t('HELP_CENTER.TABLE.HEADERS.TITLE') }}
</div> </div>
<div <div
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-right" class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-left"
> >
{{ $t('HELP_CENTER.TABLE.HEADERS.CATEGORY') }} {{ $t('HELP_CENTER.TABLE.HEADERS.CATEGORY') }}
</div> </div>
<div <div
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-right hidden lg:block" class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-left hidden lg:block"
> >
{{ $t('HELP_CENTER.TABLE.HEADERS.READ_COUNT') }} {{ $t('HELP_CENTER.TABLE.HEADERS.READ_COUNT') }}
</div> </div>
<div <div
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-right" class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-left"
> >
{{ $t('HELP_CENTER.TABLE.HEADERS.STATUS') }} {{ $t('HELP_CENTER.TABLE.HEADERS.STATUS') }}
</div> </div>
@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="flex px-4 items-center justify-between w-full h-16 pt-2 sticky top-0 bg-white dark:bg-slate-900" class="flex px-4 items-center justify-between w-full h-16 pt-2 sticky top-0 z-10 bg-white dark:bg-slate-900"
> >
<div class="flex items-center"> <div class="flex items-center">
<woot-sidemenu-icon /> <woot-sidemenu-icon />
@@ -86,6 +86,45 @@
size="small" size="small"
color-scheme="secondary" color-scheme="secondary"
/> />
<div class="relative">
<woot-button
v-if="shouldShowLocaleDropdown"
icon="globe"
color-scheme="secondary"
size="small"
variant="hollow"
@click="openLocaleDropdown"
>
<div class="flex justify-between w-full min-w-0 items-center">
<span
class="inline-flex ml-1 rtl:ml-0 rtl:mr-1 items-center text-slate-800 dark:text-slate-100"
>
{{ selectedLocale }}
<Fluent-icon
class="dropdown-arrow"
icon="chevron-down"
size="14"
/>
</span>
</div>
</woot-button>
<div
v-if="showLocaleDropdown"
v-on-clickaway="closeLocaleDropdown"
class="dropdown-pane dropdown-pane--open"
>
<multiselect-dropdown-items
:options="switchableLocales"
:has-thumbnail="false"
:selected-items="[selectedLocale]"
:input-placeholder="
$t('HELP_CENTER.HEADER.LOCALE_SELECT.SEARCH_PLACEHOLDER')
"
:no-search-result="$t('HELP_CENTER.HEADER.LOCALE_SELECT.NO_RESULT')"
@click="onClickSelectItem"
/>
</div>
</div>
<woot-button <woot-button
size="small" size="small"
icon="add" icon="add"
@@ -103,12 +142,15 @@ import { mixin as clickaway } from 'vue-clickaway';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue'; import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
export default { export default {
components: { components: {
FluentIcon, FluentIcon,
WootDropdownItem, WootDropdownItem,
WootDropdownMenu, WootDropdownMenu,
MultiselectDropdownItems,
}, },
mixins: [clickaway], mixins: [clickaway],
props: { props: {
@@ -124,16 +166,35 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
selectedLocale: {
type: String,
default: '',
},
shouldShowSettings: { shouldShowSettings: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
allLocales: {
type: Array,
default: () => [],
},
}, },
data() { data() {
return { return {
showSortByDropdown: false, showSortByDropdown: false,
showLocaleDropdown: false,
}; };
}, },
computed: {
shouldShowLocaleDropdown() {
return this.allLocales.length > 1;
},
switchableLocales() {
return this.allLocales.filter(
locale => locale.name !== this.selectedLocale
);
},
},
methods: { methods: {
openFilterModal() { openFilterModal() {
this.$emit('openModal'); this.$emit('openModal');
@@ -146,8 +207,22 @@ export default {
this.$emit('close'); this.$emit('close');
this.showSortByDropdown = false; this.showSortByDropdown = false;
}, },
openLocaleDropdown() {
this.showLocaleDropdown = true;
},
closeLocaleDropdown() {
this.showLocaleDropdown = false;
},
onClickNewArticlePage() { onClickNewArticlePage() {
this.$emit('newArticlePage'); this.$emit('new-article-page');
},
onClickSelectItem(value) {
const { name, code } = value;
this.closeLocaleDropdown();
if (!name || name === this.selectedLocale) {
return;
}
this.$emit('change-locale', code);
}, },
}, },
}; };
@@ -155,7 +230,7 @@ export default {
<style scoped lang="scss"> <style scoped lang="scss">
.dropdown-pane--open { .dropdown-pane--open {
@apply top-12 right-[9.25rem]; @apply absolute top-10 right-0 z-50 min-w-[8rem];
} }
.dropdown-arrow { .dropdown-arrow {
@apply ml-1 rtl:ml-0 rtl:mr-1; @apply ml-1 rtl:ml-0 rtl:mr-1;
@@ -22,7 +22,7 @@
v-if="isHelpCenterEnabled" v-if="isHelpCenterEnabled"
class="flex h-full min-h-0 overflow-hidden flex-1 px-0 bg-white dark:bg-slate-900" class="flex h-full min-h-0 overflow-hidden flex-1 px-0 bg-white dark:bg-slate-900"
> >
<router-view /> <router-view @reload-locale="fetchPortalAndItsCategories" />
<command-bar /> <command-bar />
<account-selector <account-selector
:show-account-modal="showAccountModal" :show-account-modal="showAccountModal"
@@ -91,6 +91,7 @@
@search-change="handleSearchChange" @search-change="handleSearchChange"
@close="onBlur" @close="onBlur"
@tag="addTagValue" @tag="addTagValue"
@remove="removeTag"
/> />
</label> </label>
</div> </div>
@@ -157,16 +158,23 @@ export default {
return this.metaTags.map(item => item.name); return this.metaTags.map(item => item.name);
}, },
}, },
watch: {
article: {
handler() {
if (!isEmptyObject(this.article.meta || {})) {
const {
meta: { title = '', description = '', tags = [] },
} = this.article;
this.metaTitle = title;
this.metaDescription = description;
this.metaTags = this.formattedTags({ tags });
}
},
deep: true,
immediate: true,
},
},
mounted() { mounted() {
if (!isEmptyObject(this.article.meta || {})) {
const {
meta: { title = '', description = '', tags = [] },
} = this.article;
this.metaTitle = title;
this.metaDescription = description;
this.metaTags = this.formattedTags({ tags });
}
this.saveArticle = debounce( this.saveArticle = debounce(
() => { () => {
this.$emit('save-article', { this.$emit('save-article', {
@@ -196,6 +204,9 @@ export default {
this.metaTags.push(...this.formattedTags({ tags: [...new Set(tags)] })); this.metaTags.push(...this.formattedTags({ tags: [...new Set(tags)] }));
this.saveArticle(); this.saveArticle();
}, },
removeTag() {
this.saveArticle();
},
handleSearchChange(value) { handleSearchChange(value) {
this.tagInputValue = value; this.tagInputValue = value;
}, },
@@ -5,8 +5,11 @@
<article-header <article-header
:header-title="headerTitle" :header-title="headerTitle"
:count="meta.count" :count="meta.count"
:selected-locale="activeLocaleName"
:all-locales="allowedLocales"
selected-value="Published" selected-value="Published"
@newArticlePage="newArticlePage" @new-article-page="newArticlePage"
@change-locale="onChangeLocale"
/> />
<article-table <article-table
:articles="articles" :articles="articles"
@@ -32,6 +35,7 @@
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import allLocales from 'shared/constants/locales.js';
import Spinner from 'shared/components/Spinner.vue'; import Spinner from 'shared/components/Spinner.vue';
import ArticleHeader from 'dashboard/routes/dashboard/helpcenter/components/Header/ArticleHeader.vue'; import ArticleHeader from 'dashboard/routes/dashboard/helpcenter/components/Header/ArticleHeader.vue';
@@ -58,6 +62,7 @@ export default {
meta: 'articles/getMeta', meta: 'articles/getMeta',
isFetching: 'articles/isFetching', isFetching: 'articles/isFetching',
currentUserId: 'getCurrentUserID', currentUserId: 'getCurrentUserID',
getPortalBySlug: 'portals/portalBySlug',
}), }),
selectedCategory() { selectedCategory() {
return this.categories.find( return this.categories.find(
@@ -118,6 +123,28 @@ export default {
? this.selectedCategory.name ? this.selectedCategory.name
: ''; : '';
}, },
activeLocale() {
return this.$route.params.locale;
},
activeLocaleName() {
return allLocales[this.activeLocale];
},
portal() {
return this.getPortalBySlug(this.selectedPortalSlug);
},
allowedLocales() {
if (!this.portal) {
return [];
}
const { allowed_locales: allowedLocales } = this.portal.config;
return allowedLocales.map(locale => {
return {
id: locale.code,
name: allLocales[locale.code],
code: locale.code,
};
});
},
}, },
watch: { watch: {
$route() { $route() {
@@ -137,7 +164,7 @@ export default {
this.$store.dispatch('articles/index', { this.$store.dispatch('articles/index', {
pageNumber: pageNumber || this.pageNumber, pageNumber: pageNumber || this.pageNumber,
portalSlug: this.$route.params.portalSlug, portalSlug: this.$route.params.portalSlug,
locale: this.$route.params.locale, locale: this.activeLocale,
status: this.status, status: this.status,
authorId: this.author, authorId: this.author,
categorySlug: this.selectedCategorySlug, categorySlug: this.selectedCategorySlug,
@@ -152,6 +179,16 @@ export default {
portalSlug: this.$route.params.portalSlug, portalSlug: this.$route.params.portalSlug,
}); });
}, },
onChangeLocale(locale) {
this.$router.push({
name: 'list_all_locale_articles',
params: {
portalSlug: this.$route.params.portalSlug,
locale,
},
});
this.$emit('reload-locale');
},
}, },
}; };
</script> </script>
@@ -1,7 +1,7 @@
/* eslint arrow-body-style: 0 */ /* eslint arrow-body-style: 0 */
import NotificationsView from './components/NotificationsView.vue';
import { frontendURL } from '../../../helper/URLHelper'; import { frontendURL } from '../../../helper/URLHelper';
import SettingsWrapper from '../settings/Wrapper'; const SettingsWrapper = () => import('../settings/Wrapper.vue');
const NotificationsView = () => import('./components/NotificationsView.vue');
export const routes = [ export const routes = [
{ {
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import Index from './Index.vue';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
@@ -1,8 +1,8 @@
import SettingsContent from '../Wrapper';
const Bot = () => import('./Index.vue'); const Bot = () => import('./Index.vue');
const CsmlEditBot = () => import('./csml/Edit.vue'); const CsmlEditBot = () => import('./csml/Edit.vue');
const CsmlNewBot = () => import('./csml/New.vue'); const CsmlNewBot = () => import('./csml/New.vue');
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
export default { export default {
routes: [ routes: [
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import AgentHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const AgentHome = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import AttributesHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const AttributesHome = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
@@ -1,7 +1,8 @@
import SettingsContent from '../Wrapper';
import AuditLogsHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const AuditLogsHome = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
{ {
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import Automation from './Index';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Automation = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import Index from './Index.vue';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
@@ -1,6 +1,6 @@
import Index from './Index';
import SettingsContent from '../Wrapper';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
@@ -20,7 +20,7 @@ export default {
path: 'ongoing', path: 'ongoing',
name: 'settings_account_campaigns', name: 'settings_account_campaigns',
roles: ['administrator'], roles: ['administrator'],
component: { ...Index }, component: Index,
}, },
], ],
}, },
@@ -36,7 +36,7 @@ export default {
path: 'one_off', path: 'one_off',
name: 'one_off', name: 'one_off',
roles: ['administrator'], roles: ['administrator'],
component: { ...Index }, component: Index,
}, },
], ],
}, },
@@ -1,7 +1,8 @@
import SettingsContent from '../Wrapper';
import CannedHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const CannedHome = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
{ {
@@ -1,13 +1,14 @@
/* eslint arrow-body-style: 0 */ /* eslint arrow-body-style: 0 */
import SettingsContent from '../Wrapper';
import Settings from './Settings';
import InboxHome from './Index';
import InboxChannel from './InboxChannels';
import ChannelList from './ChannelList';
import channelFactory from './channel-factory';
import AddAgents from './AddAgents';
import FinishSetup from './FinishSetup';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
import channelFactory from './channel-factory';
const SettingsContent = () => import('../Wrapper.vue');
const InboxHome = () => import('./Index.vue');
const Settings = () => import('./Settings.vue');
const InboxChannel = () => import('./InboxChannels.vue');
const ChannelList = () => import('./ChannelList.vue');
const AddAgents = () => import('./AddAgents.vue');
const FinishSetup = () => import('./FinishSetup.vue');
export default { export default {
routes: [ routes: [
@@ -1,7 +1,7 @@
import Index from './Index';
import SettingsContent from '../Wrapper';
import IntegrationHooks from './IntegrationHooks';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const IntegrationHooks = () => import('./IntegrationHooks.vue');
const Index = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
@@ -1,11 +1,12 @@
import Index from './Index';
import SettingsContent from '../Wrapper';
import Webhook from './Webhooks/Index';
import DashboardApps from './DashboardApps/Index';
import ShowIntegration from './ShowIntegration';
import Slack from './Slack';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Webhook = () => import('./Webhooks/Index.vue');
const DashboardApps = () => import('./DashboardApps/Index.vue');
const ShowIntegration = () => import('./ShowIntegration.vue');
const Slack = () => import('./Slack.vue');
const Index = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
{ {
@@ -1,7 +1,8 @@
import SettingsContent from '../Wrapper';
import Index from './Index';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
{ {
@@ -1,6 +1,8 @@
<template> <template>
<div class="flex flex-row h-full"> <div class="flex flex-col md:flex-row h-auto md:h-full w-full">
<div class="w-[60%] macros-canvas"> <div
class="flex-1 w-full md:w-auto macro-gradient-radial dark:macro-dark-gradient-radial macro-gradient-radial-size h-full max-h-full py-4 px-12 overflow-y-auto"
>
<macro-nodes <macro-nodes
v-model="macro.actions" v-model="macro.actions"
:files="files" :files="files"
@@ -9,7 +11,7 @@
@resetAction="resetNode" @resetAction="resetNode"
/> />
</div> </div>
<div class="w-[34%]"> <div class="w-full md:w-1/3">
<macro-properties <macro-properties
:macro-name="macro.name" :macro-name="macro.name"
:macro-visibility="macro.visibility" :macro-visibility="macro.visibility"
@@ -138,7 +140,4 @@ export default {
background-size: 1rem 1rem; background-size: 1rem 1rem;
} }
} }
.macros-canvas {
@apply macro-gradient-radial dark:macro-dark-gradient-radial macro-gradient-radial-size h-full max-h-full py-4 px-12 overflow-y-auto;
}
</style> </style>
@@ -18,7 +18,7 @@
> >
{{ $t('MACROS.EDITOR.VISIBILITY.LABEL') }} {{ $t('MACROS.EDITOR.VISIBILITY.LABEL') }}
</p> </p>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<button <button
class="p-2 relative rounded-md border border-solid text-left cursor-default" class="p-2 relative rounded-md border border-solid text-left cursor-default"
:class="isActive('global')" :class="isActive('global')"
@@ -1,8 +1,9 @@
import SettingsContent from '../Wrapper';
import Macros from './Index';
const MacroEditor = () => import('./MacroEditor');
import { frontendURL } from 'dashboard/helper/URLHelper'; import { frontendURL } from 'dashboard/helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Macros = () => import('./Index.vue');
const MacroEditor = () => import('./MacroEditor.vue');
export default { export default {
routes: [ routes: [
{ {
@@ -22,14 +22,12 @@
:enabled-menu-options="customEditorMenuList" :enabled-menu-options="customEditorMenuList"
:enable-suggestions="false" :enable-suggestions="false"
:show-image-resize-toolbar="true" :show-image-resize-toolbar="true"
@blur="$v.messageSignature.$touch"
/> />
</div> </div>
<woot-button <woot-button
:is-loading="isUpdating" :is-loading="isUpdating"
type="button" type="button"
:is-disabled="$v.messageSignature.$invalid" @click.prevent="updateSignature"
@click.prevent="updateSignature()"
> >
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT') }} {{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT') }}
</woot-button> </woot-button>
@@ -38,7 +36,6 @@
</template> </template>
<script> <script>
import { required } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
@@ -59,11 +56,6 @@ export default {
customEditorMenuList: MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS, customEditorMenuList: MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS,
}; };
}, },
validations: {
messageSignature: {
required,
},
},
computed: { computed: {
...mapGetters({ ...mapGetters({
currentUser: 'getCurrentUser', currentUser: 'getCurrentUser',
@@ -79,15 +71,9 @@ export default {
this.messageSignature = messageSignature || ''; this.messageSignature = messageSignature || '';
}, },
async updateSignature() { async updateSignature() {
this.$v.$touch();
if (this.$v.$invalid) {
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.ERROR'));
return;
}
try { try {
await this.$store.dispatch('updateProfile', { await this.$store.dispatch('updateProfile', {
message_signature: this.messageSignature, message_signature: this.messageSignature || '',
}); });
this.errorMessage = this.$t( this.errorMessage = this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_SUCCESS' 'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_SUCCESS'
@@ -1,7 +1,8 @@
import SettingsContent from '../Wrapper';
import Index from './Index.vue';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
{ {
@@ -1,13 +1,14 @@
import Index from './Index';
import AgentReports from './AgentReports';
import LabelReports from './LabelReports';
import InboxReports from './InboxReports';
import TeamReports from './TeamReports';
import CsatResponses from './CsatResponses';
import LiveReports from './LiveReports';
import SettingsContent from '../Wrapper';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
const AgentReports = () => import('./AgentReports.vue');
const LabelReports = () => import('./LabelReports.vue');
const InboxReports = () => import('./InboxReports.vue');
const TeamReports = () => import('./TeamReports.vue');
const CsatResponses = () => import('./CsatResponses.vue');
const LiveReports = () => import('./LiveReports.vue');
export default { export default {
routes: [ routes: [
{ {
@@ -1,15 +1,16 @@
/* eslint arrow-body-style: 0 */ /* eslint arrow-body-style: 0 */
import SettingsContent from '../Wrapper';
import TeamsHome from './Index';
import CreateStepWrap from './Create/Index';
import EditStepWrap from './Edit/Index';
import CreateTeam from './Create/CreateTeam';
import EditTeam from './Edit/EditTeam';
import AddAgents from './Create/AddAgents';
import EditAgents from './Edit/EditAgents';
import FinishSetup from './FinishSetup';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const CreateStepWrap = () => import('./Create/Index.vue');
const EditStepWrap = () => import('./Edit/Index.vue');
const CreateTeam = () => import('./Create/CreateTeam.vue');
const EditTeam = () => import('./Edit/EditTeam.vue');
const AddAgents = () => import('./Create/AddAgents.vue');
const EditAgents = () => import('./Edit/EditAgents.vue');
const FinishSetup = () => import('./FinishSetup.vue');
const SettingsContent = () => import('../Wrapper.vue');
const TeamsHome = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
{ {
@@ -11,6 +11,19 @@ import {
} from './helpers/actionHelpers'; } from './helpers/actionHelpers';
import messageReadActions from './actions/messageReadActions'; import messageReadActions from './actions/messageReadActions';
import messageTranslateActions from './actions/messageTranslateActions'; import messageTranslateActions from './actions/messageTranslateActions';
export const hasMessageFailedWithExternalError = pendingMessage => {
// This helper is used to check if the message has failed with an external error.
// We have two cases
// 1. Messages that fail from the UI itself (due to large attachments or a failed network):
// In this case, the message will have a status of failed but no external error. So we need to create that message again
// 2. Messages sent from Chatwoot but failed to deliver to the customer for some reason (user blocking or client system down):
// In this case, the message will have a status of failed and an external error. So we need to retry that message
const { content_attributes: contentAttributes, status } = pendingMessage;
const externalError = contentAttributes?.external_error ?? '';
return status === MESSAGE_STATUS.FAILED && externalError !== '';
};
// actions // actions
const actions = { const actions = {
getConversation: async ({ commit }, conversationId) => { getConversation: async ({ commit }, conversationId) => {
@@ -242,12 +255,15 @@ const actions = {
}, },
sendMessageWithData: async ({ commit }, pendingMessage) => { sendMessageWithData: async ({ commit }, pendingMessage) => {
const { conversation_id: conversationId, id } = pendingMessage;
try { try {
commit(types.ADD_MESSAGE, { commit(types.ADD_MESSAGE, {
...pendingMessage, ...pendingMessage,
status: MESSAGE_STATUS.PROGRESS, status: MESSAGE_STATUS.PROGRESS,
}); });
const response = await MessageApi.create(pendingMessage); const response = hasMessageFailedWithExternalError(pendingMessage)
? await MessageApi.retry(conversationId, id)
: await MessageApi.create(pendingMessage);
commit(types.ADD_MESSAGE, { commit(types.ADD_MESSAGE, {
...response.data, ...response.data,
status: MESSAGE_STATUS.SENT, status: MESSAGE_STATUS.SENT,

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