Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69ed997db1 | ||
|
|
10076c6a3e | ||
|
|
1c44445088 | ||
|
|
d305c5fd0f | ||
|
|
1577288843 | ||
|
|
63b3686746 | ||
|
|
810f950e16 | ||
|
|
dd09d560d5 | ||
|
|
c60c523851 | ||
|
|
22c2235d90 | ||
|
|
35c26367da | ||
|
|
aaf4fee9a6 | ||
|
|
3c21f62485 | ||
|
|
50b2ca014e | ||
|
|
d731c972ad | ||
|
|
5845881b08 | ||
|
|
64138ef220 | ||
|
|
046ce68a45 | ||
|
|
e34ab5957f | ||
|
|
de8dc7d314 | ||
|
|
75a5492840 | ||
|
|
56fbbe92b4 | ||
|
|
dc4e13b300 | ||
|
|
2c7f93978e | ||
|
|
6e30064421 | ||
|
|
68062216e4 | ||
|
|
8a8f325f64 | ||
|
|
cf90b898f4 | ||
|
|
1f4cb0e1ca | ||
|
|
c6ecf80dff | ||
|
|
c29b77c214 | ||
|
|
c301845ade | ||
|
|
78fab7897d | ||
|
|
f48e014694 | ||
|
|
206433db32 | ||
|
|
1ddb73ea97 | ||
|
|
b5071e61d7 | ||
|
|
a80eff9aa3 | ||
|
|
a59fb90785 | ||
|
|
29478880ff | ||
|
|
c9f8cdb51b | ||
|
|
a2ebf92932 | ||
|
|
00eb5b152a | ||
|
|
0b2f539ad0 | ||
|
|
1ab3966b75 | ||
|
|
552d967c81 | ||
|
|
e5c8a68a76 | ||
|
|
15f50635e7 | ||
|
|
cf0d6dd7c6 | ||
|
|
fd1813949a | ||
|
|
6c480098f7 | ||
|
|
ae649a5b3f | ||
|
|
34675da6fa | ||
|
|
075b0292b5 | ||
|
|
0e9825f298 | ||
|
|
bf883794a4 | ||
|
|
3adaa2d602 | ||
|
|
60a312ace5 | ||
|
|
8c9a351c84 | ||
|
|
d28f512de4 | ||
|
|
376de685fb | ||
|
|
890515edfd | ||
|
|
79412ba2c6 | ||
|
|
03ebb6947b | ||
|
|
c53591f049 | ||
|
|
db9a32a4c0 | ||
|
|
2ae9bbb24e | ||
|
|
8dfe193461 | ||
|
|
27239ae14a | ||
|
|
80ff5e2d0a | ||
|
|
38240a68aa | ||
|
|
89710b406e | ||
|
|
f002870c6a | ||
|
|
b1a68759cf | ||
|
|
bf49a17efa | ||
|
|
6b0d1d77d3 | ||
|
|
db33b7d1dc | ||
|
|
76711d95ff | ||
|
|
17faa6c3b2 | ||
|
|
c1ac354c9b |
@@ -75,8 +75,8 @@ gem 'jwt'
|
||||
gem 'pundit'
|
||||
# super admin
|
||||
gem 'administrate', '>= 0.19.0'
|
||||
gem 'administrate-field-active_storage', '>= 1.0.0'
|
||||
gem 'administrate-field-belongs_to_search'
|
||||
gem 'administrate-field-active_storage', '>= 1.0.1'
|
||||
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
|
||||
|
||||
##--- gems for pubsub service ---##
|
||||
# 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-sidekiq-metrics', '>= 1.6.2', 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-sidekiq', '>= 5.13.0', require: false
|
||||
gem 'sentry-sidekiq', '>= 5.14.0', require: false
|
||||
|
||||
##-- background job processing --##
|
||||
gem 'sidekiq', '>= 7.1.3'
|
||||
# We want cron jobs
|
||||
gem 'sidekiq-cron', '>= 1.11.0'
|
||||
gem 'sidekiq-cron', '>= 1.12.0'
|
||||
|
||||
##-- Push notification service --##
|
||||
gem 'fcm'
|
||||
@@ -162,7 +162,7 @@ gem 'omniauth-oauth2'
|
||||
gem 'audited', '~> 5.4', '>= 5.4.1'
|
||||
|
||||
# need for google auth
|
||||
gem 'omniauth'
|
||||
gem 'omniauth', '>= 2.1.2'
|
||||
gem 'omniauth-google-oauth2'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 1.0'
|
||||
|
||||
@@ -198,7 +198,7 @@ group :development do
|
||||
gem 'squasher'
|
||||
|
||||
# profiling
|
||||
gem 'rack-mini-profiler', '>= 3.1.1', require: false
|
||||
gem 'rack-mini-profiler', '>= 3.2.0', require: false
|
||||
gem 'stackprof'
|
||||
# Should install the associated chrome extension to view query logs
|
||||
gem 'meta_request'
|
||||
@@ -224,7 +224,7 @@ group :development, :test do
|
||||
gem 'byebug', platform: :mri
|
||||
gem 'climate_control'
|
||||
gem 'debug', '~> 1.8'
|
||||
gem 'factory_bot_rails'
|
||||
gem 'factory_bot_rails', '>= 6.4.3'
|
||||
gem 'listen'
|
||||
gem 'mock_redis'
|
||||
gem 'pry-rails'
|
||||
|
||||
+32
-32
@@ -113,13 +113,13 @@ GEM
|
||||
kaminari (>= 1.0)
|
||||
sassc-rails (~> 2.1)
|
||||
selectize-rails (~> 0.6)
|
||||
administrate-field-active_storage (1.0.0)
|
||||
administrate-field-active_storage (1.0.1)
|
||||
administrate (>= 0.2.2)
|
||||
rails (>= 7.0)
|
||||
administrate-field-belongs_to_search (0.8.0)
|
||||
administrate-field-belongs_to_search (0.9.0)
|
||||
administrate (>= 0.3, < 1.0)
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.1)
|
||||
rails (>= 4.2, < 7.2)
|
||||
selectize-rails (~> 0.6)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
@@ -230,10 +230,10 @@ GEM
|
||||
facebook-messenger (2.0.1)
|
||||
httparty (~> 0.13, >= 0.13.7)
|
||||
rack (>= 1.4.5)
|
||||
factory_bot (6.2.1)
|
||||
factory_bot (6.4.5)
|
||||
activesupport (>= 5.0.0)
|
||||
factory_bot_rails (6.2.0)
|
||||
factory_bot (~> 6.2.0)
|
||||
factory_bot_rails (6.4.3)
|
||||
factory_bot (~> 6.4)
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
@@ -472,7 +472,7 @@ GEM
|
||||
activerecord (>= 5.2)
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.5)
|
||||
net-imap (0.4.9)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -487,15 +487,15 @@ GEM
|
||||
sidekiq
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.6.0)
|
||||
nokogiri (1.15.5)
|
||||
nio4r (2.7.0)
|
||||
nokogiri (1.16.0)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.15.5-arm64-darwin)
|
||||
nokogiri (1.16.0-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.15.5-x86_64-darwin)
|
||||
nokogiri (1.16.0-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.15.5-x86_64-linux)
|
||||
nokogiri (1.16.0-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
numo-narray (0.9.2.1)
|
||||
oauth (1.1.0)
|
||||
@@ -511,7 +511,7 @@ GEM
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
omniauth (2.1.1)
|
||||
omniauth (2.1.2)
|
||||
hashie (>= 3.4.6)
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
@@ -553,7 +553,7 @@ GEM
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (5.0.1)
|
||||
puma (6.3.1)
|
||||
puma (6.4.2)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.3.0)
|
||||
activesupport (>= 3.0.0)
|
||||
@@ -566,10 +566,10 @@ GEM
|
||||
rack (< 4)
|
||||
rack-cors (2.0.1)
|
||||
rack (>= 2.0.0)
|
||||
rack-mini-profiler (3.1.1)
|
||||
rack-mini-profiler (3.2.0)
|
||||
rack (>= 1.2.0)
|
||||
rack-protection (3.0.6)
|
||||
rack
|
||||
rack-protection (3.1.0)
|
||||
rack (~> 2.2, >= 2.2.4)
|
||||
rack-proxy (0.7.6)
|
||||
rack
|
||||
rack-test (2.1.0)
|
||||
@@ -610,7 +610,7 @@ GEM
|
||||
ffi (~> 1.0)
|
||||
redis (5.0.6)
|
||||
redis-client (>= 0.9.0)
|
||||
redis-client (0.18.0)
|
||||
redis-client (0.19.0)
|
||||
connection_pool
|
||||
redis-namespace (1.10.0)
|
||||
redis (>= 4)
|
||||
@@ -709,13 +709,13 @@ GEM
|
||||
activesupport (>= 4)
|
||||
selectize-rails (0.12.6)
|
||||
semantic_range (3.0.0)
|
||||
sentry-rails (5.13.0)
|
||||
sentry-rails (5.14.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.13.0)
|
||||
sentry-ruby (5.13.0)
|
||||
sentry-ruby (~> 5.14.0)
|
||||
sentry-ruby (5.14.0)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
sentry-sidekiq (5.13.0)
|
||||
sentry-ruby (~> 5.13.0)
|
||||
sentry-sidekiq (5.14.0)
|
||||
sentry-ruby (~> 5.14.0)
|
||||
sidekiq (>= 3.0)
|
||||
sexp_processor (4.17.0)
|
||||
shoulda-matchers (5.3.0)
|
||||
@@ -725,7 +725,7 @@ GEM
|
||||
connection_pool (>= 2.3.0)
|
||||
rack (>= 2.2.4)
|
||||
redis-client (>= 0.14.0)
|
||||
sidekiq-cron (1.11.0)
|
||||
sidekiq-cron (1.12.0)
|
||||
fugit (~> 1.8)
|
||||
globalid (>= 1.0.1)
|
||||
sidekiq (>= 6)
|
||||
@@ -841,8 +841,8 @@ DEPENDENCIES
|
||||
activerecord-import
|
||||
acts-as-taggable-on
|
||||
administrate (>= 0.19.0)
|
||||
administrate-field-active_storage (>= 1.0.0)
|
||||
administrate-field-belongs_to_search
|
||||
administrate-field-active_storage (>= 1.0.1)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
annotate
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
@@ -870,7 +870,7 @@ DEPENDENCIES
|
||||
elastic-apm
|
||||
email_reply_trimmer
|
||||
facebook-messenger
|
||||
factory_bot_rails
|
||||
factory_bot_rails (>= 6.4.3)
|
||||
faker
|
||||
fcm
|
||||
flag_shih_tzu
|
||||
@@ -905,7 +905,7 @@ DEPENDENCIES
|
||||
neighbor
|
||||
newrelic-sidekiq-metrics (>= 1.6.2)
|
||||
newrelic_rpm
|
||||
omniauth
|
||||
omniauth (>= 2.1.2)
|
||||
omniauth-google-oauth2
|
||||
omniauth-oauth2
|
||||
omniauth-rails_csrf_protection (~> 1.0)
|
||||
@@ -918,7 +918,7 @@ DEPENDENCIES
|
||||
pundit
|
||||
rack-attack (>= 6.7.0)
|
||||
rack-cors
|
||||
rack-mini-profiler (>= 3.1.1)
|
||||
rack-mini-profiler (>= 3.2.0)
|
||||
rack-timeout
|
||||
rails (~> 7.0.8.0)
|
||||
redis
|
||||
@@ -935,12 +935,12 @@ DEPENDENCIES
|
||||
scout_apm
|
||||
scss_lint
|
||||
seed_dump
|
||||
sentry-rails (>= 5.13.0)
|
||||
sentry-rails (>= 5.14.0)
|
||||
sentry-ruby
|
||||
sentry-sidekiq (>= 5.13.0)
|
||||
sentry-sidekiq (>= 5.14.0)
|
||||
shoulda-matchers
|
||||
sidekiq (>= 7.1.3)
|
||||
sidekiq-cron (>= 1.11.0)
|
||||
sidekiq-cron (>= 1.12.0)
|
||||
simplecov (= 0.17.1)
|
||||
slack-ruby-client (~> 2.2.0)
|
||||
spring
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
*Chatwoot* © 2017-2023, Chatwoot Inc - Released under the MIT License.
|
||||
*Chatwoot* © 2017-2024, Chatwoot Inc - Released under the MIT License.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.1.0
|
||||
3.3.1
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.6.0
|
||||
2.7.0
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
@import 'components/flashes';
|
||||
@import 'components/form-actions';
|
||||
@import 'components/main-content';
|
||||
@import 'components/navigation';
|
||||
@import 'components/pagination';
|
||||
@import 'components/search';
|
||||
@import 'components/reports';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
html {
|
||||
background-color: $color-white;
|
||||
box-sizing: border-box;
|
||||
font-size: 10px;
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
.attribute-data {
|
||||
float: left;
|
||||
margin-bottom: $base-spacing;
|
||||
margin-left: 2rem;
|
||||
width: calc(84% - 1rem);
|
||||
margin-left: 1.25rem;
|
||||
width: calc(84% - 0.625rem);
|
||||
}
|
||||
|
||||
.attribute--nested {
|
||||
|
||||
@@ -9,22 +9,22 @@
|
||||
|
||||
.field-unit__label {
|
||||
float: left;
|
||||
margin-left: 1rem;
|
||||
margin-left: 0.625rem;
|
||||
text-align: right;
|
||||
width: calc(15% - 1rem);
|
||||
width: calc(15% - 0.625rem);
|
||||
}
|
||||
|
||||
.field-unit__field {
|
||||
float: left;
|
||||
margin-left: 2rem;
|
||||
max-width: 50rem;
|
||||
margin-left: 1.25rem;
|
||||
max-width: 31.15rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-unit--nested {
|
||||
border: $base-border;
|
||||
margin-left: 7.5%;
|
||||
max-width: 60rem;
|
||||
max-width: 37.5rem;
|
||||
padding: $small-spacing;
|
||||
width: 100%;
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
.form-actions {
|
||||
margin-left: calc(15% + 2rem);
|
||||
margin-left: calc(15% + 1.25rem);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
table {
|
||||
font-size: $font-size-small;
|
||||
}
|
||||
|
||||
form {
|
||||
margin-top: $space-two;
|
||||
}
|
||||
}
|
||||
|
||||
.main-content__header {
|
||||
@@ -20,7 +24,7 @@
|
||||
background-color: $color-white;
|
||||
border-bottom: 1px solid $color-border;
|
||||
display: flex;
|
||||
min-height: 5.6rem;
|
||||
min-height: 3.5rem;
|
||||
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 {
|
||||
margin-left: auto;
|
||||
margin-right: 2rem;
|
||||
max-width: 44rem;
|
||||
margin-right: 1.25rem;
|
||||
max-width: 27.5rem;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// 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",
|
||||
sans-serif !default;
|
||||
$heading-font-family: $base-font-family !default;
|
||||
|
||||
$base-font-size: 14px !default;
|
||||
$base-font-size: 16px !default;
|
||||
|
||||
$base-line-height: 1.5 !default;
|
||||
$heading-line-height: 1.2 !default;
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
// Font sizes
|
||||
$font-size-nano: 0.8rem;
|
||||
$font-size-micro: 1.0rem;
|
||||
$font-size-mini: 1.2rem;
|
||||
$font-size-small: 1.4rem;
|
||||
$font-size-default: 1.6rem;
|
||||
$font-size-medium: 1.8rem;
|
||||
$font-size-large: 2.2rem;
|
||||
$font-size-big: 2.4rem;
|
||||
$font-size-bigger: 3.0rem;
|
||||
$font-size-mega: 3.4rem;
|
||||
$font-size-giga: 4.0rem;
|
||||
$font-size-nano: 0.5rem;
|
||||
$font-size-micro: 0.675rem;
|
||||
$font-size-mini: 0.75rem;
|
||||
$font-size-small: 0.875rem;
|
||||
$font-size-default: 1rem;
|
||||
$font-size-medium: 1.125rem;
|
||||
$font-size-large: 1.375rem;
|
||||
$font-size-big: 1.5rem;
|
||||
$font-size-bigger: 1.75rem;
|
||||
$font-size-mega: 2.125rem;
|
||||
$font-size-giga: 2.5rem;
|
||||
|
||||
// spaces
|
||||
$zero: 0;
|
||||
$space-micro: 0.2rem;
|
||||
$space-smaller: 0.4rem;
|
||||
$space-small: 0.8rem;
|
||||
$space-one: 1rem;
|
||||
$space-slab: 1.2rem;
|
||||
$space-normal: 1.6rem;
|
||||
$space-two: 2.0rem;
|
||||
$space-medium: 2.4rem;
|
||||
$space-large: 3.2rem;
|
||||
$space-larger: 4.8rem;
|
||||
$space-jumbo: 6.4rem;
|
||||
$space-mega: 10.0rem;
|
||||
$space-micro: 0.125rem;
|
||||
$space-smaller: 0.25rem;
|
||||
$space-small: 0.5rem;
|
||||
$space-one: 0.675rem;
|
||||
$space-slab: 0.75rem;
|
||||
$space-normal: 1rem;
|
||||
$space-two: 1.25rem;
|
||||
$space-medium: 1.5rem;
|
||||
$space-large: 2rem;
|
||||
$space-larger: 3rem;
|
||||
$space-jumbo: 4rem;
|
||||
$space-mega: 6.25rem;
|
||||
|
||||
// font-weight
|
||||
$font-weight-feather: 100;
|
||||
|
||||
@@ -25,7 +25,9 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
build_contact_inbox
|
||||
build_message
|
||||
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!
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||
@@ -108,11 +110,15 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
}
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/AbcSize
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def contact_params
|
||||
begin
|
||||
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
|
||||
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!
|
||||
raise
|
||||
rescue Koala::Facebook::ClientError => e
|
||||
@@ -130,4 +136,6 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
end
|
||||
process_contact_params_result(result)
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
end
|
||||
|
||||
@@ -20,7 +20,9 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
ActiveRecord::Base.transaction do
|
||||
build_message
|
||||
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!
|
||||
raise
|
||||
rescue StandardError => e
|
||||
@@ -46,6 +48,10 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
@outgoing_echo ? recipient_id : sender_id
|
||||
end
|
||||
|
||||
def message_is_unsupported?
|
||||
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
|
||||
end
|
||||
|
||||
def sender_id
|
||||
@messaging[:sender][:id]
|
||||
end
|
||||
@@ -116,7 +122,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
def message_params
|
||||
{
|
||||
params = {
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: message_type,
|
||||
@@ -127,6 +133,9 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
in_reply_to_external_id: message_reply_attributes
|
||||
}
|
||||
}
|
||||
|
||||
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
|
||||
params
|
||||
end
|
||||
|
||||
def already_sent_from_chatwoot?
|
||||
|
||||
@@ -149,7 +149,8 @@ class Messages::MessageBuilder
|
||||
content_type: @params[:content_type],
|
||||
items: @items,
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class NotificationBuilder
|
||||
pattr_initialize [:notification_type!, :user!, :account!, :primary_actor!]
|
||||
pattr_initialize [:notification_type!, :user!, :account!, :primary_actor!, :secondary_actor]
|
||||
|
||||
def perform
|
||||
return unless user_subscribed_to_notification?
|
||||
@@ -9,7 +9,7 @@ class NotificationBuilder
|
||||
|
||||
private
|
||||
|
||||
def secondary_actor
|
||||
def current_user
|
||||
Current.user
|
||||
end
|
||||
|
||||
@@ -29,7 +29,8 @@ class NotificationBuilder
|
||||
notification_type: notification_type,
|
||||
account: account,
|
||||
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
|
||||
|
||||
@@ -10,11 +10,18 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
||||
def show; end
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def destroy
|
||||
@@ -30,6 +37,10 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -83,14 +83,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
|
||||
@contact.save!
|
||||
@contact_inbox = build_contact_inbox
|
||||
process_avatar
|
||||
process_avatar_from_url
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@contact.assign_attributes(contact_update_params)
|
||||
@contact.save!
|
||||
process_avatar if permitted_params[:avatar].present? || permitted_params[:avatar_url].present?
|
||||
process_avatar_from_url
|
||||
end
|
||||
|
||||
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])
|
||||
end
|
||||
|
||||
def process_avatar
|
||||
def process_avatar_from_url
|
||||
::Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
|
||||
end
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
|
||||
|
||||
def set_agent
|
||||
@agent = Current.account.users.find_by(id: params[:assignee_id])
|
||||
@conversation.update_assignee(@agent)
|
||||
@conversation.assignee = @agent
|
||||
@conversation.save!
|
||||
render_agent
|
||||
end
|
||||
|
||||
|
||||
@@ -18,6 +18,15 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
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
|
||||
return head :ok if already_translated_content_available?
|
||||
|
||||
|
||||
@@ -60,13 +60,26 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def toggle_status
|
||||
if params[:status].present?
|
||||
# FIXME: move this logic into a service object
|
||||
if pending_to_open_by_bot?
|
||||
@conversation.bot_handoff!
|
||||
elsif params[:status].present?
|
||||
set_conversation_status
|
||||
@status = @conversation.save!
|
||||
else
|
||||
@status = @conversation.toggle_status
|
||||
end
|
||||
assign_conversation if @conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
|
||||
assign_conversation if should_assign_conversation?
|
||||
end
|
||||
|
||||
def pending_to_open_by_bot?
|
||||
return false unless Current.user.is_a?(AgentBot)
|
||||
|
||||
@conversation.status == 'pending' && params[:status] == 'open'
|
||||
end
|
||||
|
||||
def should_assign_conversation?
|
||||
@conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
|
||||
end
|
||||
|
||||
def toggle_priority
|
||||
@@ -110,8 +123,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def assign_conversation
|
||||
@agent = Current.account.users.find(current_user.id)
|
||||
@conversation.update_assignee(@agent)
|
||||
@conversation.assignee = current_user
|
||||
@conversation.save!
|
||||
end
|
||||
|
||||
def conversation
|
||||
|
||||
@@ -14,7 +14,7 @@ module AccessTokenAuthHelper
|
||||
render_unauthorized('Invalid Access Token') && return if @access_token.blank?
|
||||
|
||||
@resource = @access_token.owner
|
||||
Current.user = @resource if current_user.is_a?(User)
|
||||
Current.user = @resource if [User, AgentBot].include?(@resource.class)
|
||||
end
|
||||
|
||||
def validate_bot_access_token!
|
||||
|
||||
@@ -9,13 +9,15 @@ class Platform::Api::V1::AgentBotsController < PlatformController
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@resource = AgentBot.new(agent_bot_params)
|
||||
@resource = AgentBot.new(agent_bot_params.except(:avatar_url))
|
||||
@resource.save!
|
||||
process_avatar_from_url
|
||||
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
|
||||
end
|
||||
|
||||
def update
|
||||
@resource.update!(agent_bot_params)
|
||||
@resource.update!(agent_bot_params.except(:avatar_url))
|
||||
process_avatar_from_url
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -23,6 +25,11 @@ class Platform::Api::V1::AgentBotsController < PlatformController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def avatar
|
||||
@resource.avatar.purge if @resource.avatar.attached?
|
||||
@resource
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_resource
|
||||
@@ -30,6 +37,10 @@ class Platform::Api::V1::AgentBotsController < PlatformController
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -1,21 +1,38 @@
|
||||
class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
before_action :set_config
|
||||
before_action :allowed_configs
|
||||
def show
|
||||
@allowed_configs = %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET]
|
||||
# ref: https://github.com/rubocop/rubocop/issues/7767
|
||||
# rubocop:disable Style/HashTransformValues
|
||||
@fb_config = InstallationConfig.where(name: @allowed_configs)
|
||||
.pluck(:name, :serialized_value)
|
||||
.map { |name, serialized_value| [name, serialized_value['value']] }
|
||||
.to_h
|
||||
@app_config = InstallationConfig.where(name: @allowed_configs)
|
||||
.pluck(:name, :serialized_value)
|
||||
.map { |name, serialized_value| [name, serialized_value['value']] }
|
||||
.to_h
|
||||
# rubocop:enable Style/HashTransformValues
|
||||
end
|
||||
|
||||
def create
|
||||
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.value = value
|
||||
i.save!
|
||||
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
|
||||
|
||||
SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController')
|
||||
|
||||
@@ -20,4 +20,13 @@ class SuperAdmin::ApplicationController < Administrate::ApplicationController
|
||||
params.fetch(resource_name, {}).fetch(:direction, 'desc')
|
||||
)
|
||||
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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationController
|
||||
rescue_from ActiveRecord::RecordNotUnique, :with => :invalid_action_perfomed
|
||||
# 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.
|
||||
#
|
||||
|
||||
@@ -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
|
||||
@@ -30,11 +30,7 @@ class AccessTokenDashboard < Administrate::BaseDashboard
|
||||
# SHOW_PAGE_ATTRIBUTES
|
||||
# an array of attributes that will be displayed on the model's show page.
|
||||
SHOW_PAGE_ATTRIBUTES = %i[
|
||||
owner
|
||||
id
|
||||
token
|
||||
created_at
|
||||
updated_at
|
||||
].freeze
|
||||
|
||||
# FORM_ATTRIBUTES
|
||||
|
||||
@@ -46,6 +46,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
|
||||
name
|
||||
description
|
||||
outgoing_url
|
||||
access_token
|
||||
].freeze
|
||||
|
||||
# FORM_ATTRIBUTES
|
||||
|
||||
@@ -32,6 +32,7 @@ class PlatformAppDashboard < Administrate::BaseDashboard
|
||||
name
|
||||
created_at
|
||||
updated_at
|
||||
access_token
|
||||
].freeze
|
||||
|
||||
# FORM_ATTRIBUTES
|
||||
|
||||
@@ -36,7 +36,8 @@ class UserDashboard < Administrate::BaseDashboard
|
||||
updated_at: Field::DateTime,
|
||||
pubsub_token: Field::String,
|
||||
type: Field::Select.with_options(collection: [nil, 'SuperAdmin']),
|
||||
accounts: CountField
|
||||
accounts: CountField,
|
||||
access_token: Field::HasOne
|
||||
}.freeze
|
||||
|
||||
# COLLECTION_ATTRIBUTES
|
||||
@@ -67,6 +68,7 @@ class UserDashboard < Administrate::BaseDashboard
|
||||
updated_at
|
||||
confirmed_at
|
||||
account_users
|
||||
access_token
|
||||
].freeze
|
||||
|
||||
# FORM_ATTRIBUTES
|
||||
|
||||
@@ -18,4 +18,9 @@ class ContactDrop < BaseDrop
|
||||
def last_name
|
||||
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
|
||||
end
|
||||
|
||||
def custom_attribute
|
||||
custom_attributes = @obj.try(:custom_attributes) || {}
|
||||
custom_attributes.transform_keys(&:to_s)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,6 +19,11 @@ class ConversationDrop < BaseDrop
|
||||
end
|
||||
end
|
||||
|
||||
def custom_attribute
|
||||
custom_attributes = @obj.try(:custom_attributes) || {}
|
||||
custom_attributes.transform_keys(&:to_s)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def message_sender_name(sender)
|
||||
|
||||
@@ -3,13 +3,21 @@ class ConversationFinder
|
||||
|
||||
DEFAULT_STATUS = 'open'.freeze
|
||||
SORT_OPTIONS = {
|
||||
latest: 'latest',
|
||||
sort_on_created_at: 'sort_on_created_at',
|
||||
last_user_message_at: 'last_user_message_at',
|
||||
sort_on_priority: 'sort_on_priority',
|
||||
sort_on_waiting_since: 'sort_on_waiting_since'
|
||||
}.with_indifferent_access
|
||||
'last_activity_at_asc' => %w[sort_on_last_activity_at asc],
|
||||
'last_activity_at_desc' => %w[sort_on_last_activity_at desc],
|
||||
'created_at_asc' => %w[sort_on_created_at asc],
|
||||
'created_at_desc' => %w[sort_on_created_at desc],
|
||||
'priority_asc' => %w[sort_on_priority asc],
|
||||
'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
|
||||
# inbox_id if not given, take from all conversations, else specific to inbox
|
||||
# assignee_type if not given, take 'all'
|
||||
@@ -159,7 +167,8 @@ class ConversationFinder
|
||||
@conversations = @conversations.includes(
|
||||
: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
|
||||
|
||||
@@ -64,6 +64,8 @@ module PortalHelper
|
||||
|
||||
def thumbnail_bg_color(username)
|
||||
colors = ['#6D95BA', '#A4C3C3', '#E19191']
|
||||
return colors.sample if username.blank?
|
||||
|
||||
colors[username.length % colors.size]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -86,6 +86,12 @@ class MessageApi extends ApiClient {
|
||||
return axios.delete(`${this.url}/${conversationID}/messages/${messageId}`);
|
||||
}
|
||||
|
||||
retry(conversationID, messageId) {
|
||||
return axios.post(
|
||||
`${this.url}/${conversationID}/messages/${messageId}/retry`
|
||||
);
|
||||
}
|
||||
|
||||
getPreviousMessages({ conversationId, after, before }) {
|
||||
const params = { 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 {
|
||||
background: var(--color-background);
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
body {
|
||||
font-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
|
||||
}
|
||||
|
||||
.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';
|
||||
@@ -76,7 +76,11 @@
|
||||
|
||||
&.left {
|
||||
.bubble {
|
||||
@apply border border-slate-50 dark:border-slate-700 bg-white dark:bg-slate-700 text-black-900 dark:text-slate-50 rounded-r-lg rounded-l mr-auto break-words;
|
||||
@apply rounded-r-lg rounded-l mr-auto break-words;
|
||||
|
||||
&:not(.is-unsupported) {
|
||||
@apply border border-slate-50 dark:border-slate-700 bg-white dark:bg-slate-700 text-black-900 dark:text-slate-50
|
||||
}
|
||||
|
||||
&.is-image {
|
||||
@apply rounded-lg;
|
||||
|
||||
@@ -126,49 +126,33 @@
|
||||
@assign-team="onAssignTeamsForBulk"
|
||||
/>
|
||||
<div
|
||||
ref="activeConversation"
|
||||
ref="conversationList"
|
||||
class="conversations-list flex-1"
|
||||
:class="{ 'overflow-hidden': isContextMenuOpen }"
|
||||
>
|
||||
<div>
|
||||
<conversation-card
|
||||
v-for="chat in conversationList"
|
||||
:key="chat.id"
|
||||
:active-label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:chat="chat"
|
||||
: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"
|
||||
<virtual-list
|
||||
ref="conversationVirtualList"
|
||||
:data-key="'id'"
|
||||
:data-sources="conversationList"
|
||||
:data-component="itemComponent"
|
||||
:extra-props="virtualListExtraProps"
|
||||
class="w-full overflow-auto h-full"
|
||||
footer-tag="div"
|
||||
>
|
||||
{{ $t('CHAT_LIST.LOAD_MORE_CONVERSATIONS') }}
|
||||
</woot-button>
|
||||
|
||||
<p v-if="showEndOfListMessage" class="text-center text-muted p-4">
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<template #footer>
|
||||
<div v-if="chatListLoading" class="text-center">
|
||||
<span class="spinner mt-4 mb-4" />
|
||||
</div>
|
||||
<p v-if="showEndOfListMessage" class="text-center text-muted p-4">
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<intersection-observer
|
||||
v-if="!showEndOfListMessage && !chatListLoading"
|
||||
:options="infiniteLoaderOptions"
|
||||
@observed="loadMoreConversations"
|
||||
/>
|
||||
</template>
|
||||
</virtual-list>
|
||||
</div>
|
||||
<woot-modal
|
||||
:show.sync="showAdvancedFilters"
|
||||
@@ -191,11 +175,12 @@
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import VirtualList from 'vue-virtual-scroll-list';
|
||||
|
||||
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
|
||||
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
|
||||
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||
import ConversationCard from './widgets/conversation/ConversationCard.vue';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import timeMixin from '../mixins/time';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import conversationMixin from '../mixins/conversations';
|
||||
@@ -222,16 +207,20 @@ import {
|
||||
isOnUnattendedView,
|
||||
} from '../store/modules/conversations/helpers/actionHelpers';
|
||||
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
|
||||
import IntersectionObserver from './IntersectionObserver.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AddCustomViews,
|
||||
ChatTypeTabs,
|
||||
ConversationCard,
|
||||
// eslint-disable-next-line vue/no-unused-components
|
||||
ConversationItem,
|
||||
ConversationAdvancedFilter,
|
||||
DeleteCustomViews,
|
||||
ConversationBulkActions,
|
||||
ConversationBasicFilter,
|
||||
IntersectionObserver,
|
||||
VirtualList,
|
||||
},
|
||||
mixins: [
|
||||
timeMixin,
|
||||
@@ -241,6 +230,20 @@ export default {
|
||||
filterMixin,
|
||||
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: {
|
||||
conversationInbox: {
|
||||
type: [String, Number],
|
||||
@@ -275,7 +278,7 @@ export default {
|
||||
return {
|
||||
activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME,
|
||||
activeStatus: wootConstants.STATUS_TYPE.OPEN,
|
||||
activeSortBy: wootConstants.SORT_BY_TYPE.LATEST,
|
||||
activeSortBy: wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC,
|
||||
showAdvancedFilters: false,
|
||||
advancedFilterTypes: advancedFilterTypes.map(filter => ({
|
||||
...filter,
|
||||
@@ -291,6 +294,21 @@ export default {
|
||||
selectedInboxes: [],
|
||||
isContextMenuOpen: false,
|
||||
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: {
|
||||
@@ -509,16 +527,22 @@ export default {
|
||||
},
|
||||
label() {
|
||||
this.resetAndFetchData();
|
||||
this.updateVirtualListProps('label', this.label);
|
||||
},
|
||||
conversationType() {
|
||||
this.resetAndFetchData();
|
||||
this.updateVirtualListProps('conversationType', this.conversationType);
|
||||
},
|
||||
activeFolder() {
|
||||
this.resetAndFetchData();
|
||||
this.updateVirtualListProps('foldersId', this.foldersId);
|
||||
},
|
||||
chatLists() {
|
||||
this.chatsOnView = this.conversationList;
|
||||
},
|
||||
showAssigneeInConversationCard(newVal) {
|
||||
this.updateVirtualListProps('showAssignee', newVal);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setFiltersFromUISettings();
|
||||
@@ -535,6 +559,12 @@ export default {
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
updateVirtualListProps(key, value) {
|
||||
this.virtualListExtraProps = {
|
||||
...this.virtualListExtraProps,
|
||||
[key]: value,
|
||||
};
|
||||
},
|
||||
onApplyFilter(payload) {
|
||||
this.resetBulkActions();
|
||||
this.foldersQuery = filterQueryGenerator(payload);
|
||||
@@ -555,7 +585,10 @@ export default {
|
||||
const { conversations_filter_by: filterBy = {} } = this.uiSettings;
|
||||
const { status, order_by: orderBy } = filterBy;
|
||||
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() {
|
||||
this.showAddFoldersModal = true;
|
||||
@@ -635,10 +668,10 @@ export default {
|
||||
);
|
||||
},
|
||||
getKeyboardListenerParams() {
|
||||
const allConversations = this.$refs.activeConversation.querySelectorAll(
|
||||
const allConversations = this.$refs.conversationList.querySelectorAll(
|
||||
'div.conversations-list div.conversation'
|
||||
);
|
||||
const activeConversation = this.$refs.activeConversation.querySelector(
|
||||
const activeConversation = this.$refs.conversationList.querySelector(
|
||||
'div.conversations-list div.conversation.active'
|
||||
);
|
||||
const activeConversationIndex = [...allConversations].indexOf(
|
||||
@@ -694,9 +727,12 @@ export default {
|
||||
fetchConversations() {
|
||||
this.$store
|
||||
.dispatch('fetchAllConversations', this.conversationFilters)
|
||||
.then(() => this.$emit('conversation-load'));
|
||||
.then(this.emitConversationLoaded);
|
||||
},
|
||||
loadMoreConversations() {
|
||||
if (this.hasCurrentPageEndReached || this.chatListLoading) {
|
||||
return;
|
||||
}
|
||||
if (!this.hasAppliedFiltersOrActiveFolders) {
|
||||
this.fetchConversations();
|
||||
}
|
||||
@@ -715,7 +751,7 @@ export default {
|
||||
queryData: filterQueryGenerator(payload),
|
||||
page,
|
||||
})
|
||||
.then(() => this.$emit('conversation-load'));
|
||||
.then(this.emitConversationLoaded);
|
||||
this.showAdvancedFilters = false;
|
||||
},
|
||||
fetchSavedFilteredConversations(payload) {
|
||||
@@ -725,7 +761,7 @@ export default {
|
||||
queryData: payload,
|
||||
page,
|
||||
})
|
||||
.then(() => this.$emit('conversation-load'));
|
||||
.then(this.emitConversationLoaded);
|
||||
},
|
||||
updateAssigneeTab(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() {
|
||||
this.selectedConversations = [];
|
||||
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>
|
||||
<transition name="network-notification-fade" tag="div">
|
||||
<div v-show="showNotification" class="ui-notification-container">
|
||||
<div class="ui-notification">
|
||||
<fluent-icon icon="wifi-off" />
|
||||
<p class="ui-notification-text">
|
||||
{{
|
||||
useInstallationName(
|
||||
$t('NETWORK.NOTIFICATION.TEXT'),
|
||||
globalConfig.installationName
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<woot-button variant="clear" size="small" @click="refreshPage">
|
||||
{{ $t('NETWORK.BUTTON.REFRESH') }}
|
||||
</woot-button>
|
||||
<div v-show="showNotification" class="fixed top-4 left-2 z-50 group">
|
||||
<div
|
||||
class="flex items-center justify-between py-1 px-2 w-full rounded-lg shadow-lg bg-yellow-200 dark:bg-yellow-700 relative"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="wifi-off"
|
||||
class="text-yellow-700/50 dark:text-yellow-50"
|
||||
size="18"
|
||||
/>
|
||||
<span
|
||||
class="text-xs tracking-wide font-medium px-2 text-yellow-700/70 dark:text-yellow-50"
|
||||
>
|
||||
{{ $t('NETWORK.NOTIFICATION.OFFLINE') }}
|
||||
</span>
|
||||
<woot-button
|
||||
variant="smooth"
|
||||
:title="$t('NETWORK.BUTTON.REFRESH')"
|
||||
variant="clear"
|
||||
size="small"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -47,7 +56,12 @@ export default {
|
||||
mounted() {
|
||||
window.addEventListener('offline', this.updateOnlineStatus);
|
||||
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>
|
||||
|
||||
<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 uiSettingsMixin 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 { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||
@@ -140,6 +143,15 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
typingIndicator: createTypingIndicator(
|
||||
() => {
|
||||
this.$emit('typing-on');
|
||||
},
|
||||
() => {
|
||||
this.$emit('typing-off');
|
||||
},
|
||||
TYPING_INDICATOR_IDLE_TIME
|
||||
),
|
||||
showUserMentions: false,
|
||||
showCannedMenu: false,
|
||||
showVariables: false,
|
||||
@@ -638,15 +650,6 @@ export default {
|
||||
hideMentions() {
|
||||
this.showUserMentions = false;
|
||||
},
|
||||
resetTyping() {
|
||||
this.$emit('typing-off');
|
||||
this.idleTimer = null;
|
||||
},
|
||||
turnOffIdleTimer() {
|
||||
if (this.idleTimer) {
|
||||
clearTimeout(this.idleTimer);
|
||||
}
|
||||
},
|
||||
handleLineBreakWhenEnterToSendEnabled(event) {
|
||||
if (
|
||||
hasPressedEnterAndNotCmdOrShift(event) &&
|
||||
@@ -666,14 +669,7 @@ export default {
|
||||
}
|
||||
},
|
||||
onKeyup() {
|
||||
if (!this.idleTimer) {
|
||||
this.$emit('typing-on');
|
||||
}
|
||||
this.turnOffIdleTimer();
|
||||
this.idleTimer = setTimeout(
|
||||
() => this.resetTyping(),
|
||||
TYPING_INDICATOR_IDLE_TIME
|
||||
);
|
||||
this.typingIndicator.start();
|
||||
this.updateImgToolbarOnDelete();
|
||||
},
|
||||
onKeydown(event) {
|
||||
@@ -685,8 +681,7 @@ export default {
|
||||
}
|
||||
},
|
||||
onBlur() {
|
||||
this.turnOffIdleTimer();
|
||||
this.resetTyping();
|
||||
this.typingIndicator.stop();
|
||||
this.$emit('blur');
|
||||
},
|
||||
onFocus() {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<mention-box :items="items" @mention-select="handleMentionClick">
|
||||
<mention-box
|
||||
v-if="items.length"
|
||||
:items="items"
|
||||
@mention-select="handleMentionClick"
|
||||
>
|
||||
<template slot-scope="{ item }">
|
||||
<strong>{{ item.label }}</strong> - {{ item.description }}
|
||||
</template>
|
||||
|
||||
+5
-3
@@ -34,7 +34,7 @@
|
||||
type="sort"
|
||||
:selected-value="sortFilter"
|
||||
:items="chatSortItems"
|
||||
path-prefix="CHAT_LIST.CHAT_SORT_FILTER_ITEMS"
|
||||
path-prefix="CHAT_LIST.SORT_ORDER_ITEMS"
|
||||
@onChangeFilter="onChangeFilter"
|
||||
/>
|
||||
</div>
|
||||
@@ -58,7 +58,7 @@ export default {
|
||||
return {
|
||||
showActionsDropdown: false,
|
||||
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: {
|
||||
@@ -70,7 +70,9 @@ export default {
|
||||
return this.chatStatusFilter || wootConstants.STATUS_TYPE.OPEN;
|
||||
},
|
||||
sortFilter() {
|
||||
return this.chatSortFilter || wootConstants.SORT_BY_TYPE.LATEST;
|
||||
return (
|
||||
this.chatSortFilter || wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
size="40px"
|
||||
/>
|
||||
<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">
|
||||
<inbox-name v-if="showInboxName" :inbox="inbox" />
|
||||
@@ -175,6 +175,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
enableContextMenu: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -289,6 +293,7 @@ export default {
|
||||
this.$emit(action, this.chat.id, this.inbox.id);
|
||||
},
|
||||
openContextMenu(e) {
|
||||
if (!this.enableContextMenu) return;
|
||||
e.preventDefault();
|
||||
this.$emit('context-menu-toggle', true);
|
||||
this.contextMenu.x = e.pageX || e.clientX;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<li v-if="shouldRenderMessage" :id="`message${data.id}`" :class="alignBubble">
|
||||
<div :class="wrapClass">
|
||||
<div v-if="isFailed" class="message-failed--alert">
|
||||
<div v-if="isFailed && !hasOneDayPassed" class="message-failed--alert">
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')"
|
||||
size="tiny"
|
||||
@@ -28,10 +28,20 @@
|
||||
:message="inReplyTo"
|
||||
:message-type="data.message_type"
|
||||
:parent-has-attachments="hasAttachments"
|
||||
@click="navigateToMessage"
|
||||
/>
|
||||
<div v-if="isUnsupported">
|
||||
<template v-if="isAFacebookInbox && isInstagram">
|
||||
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM') }}
|
||||
</template>
|
||||
<template v-else-if="isAFacebookInbox">
|
||||
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE_FACEBOOK') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE') }}
|
||||
</template>
|
||||
</div>
|
||||
<bubble-text
|
||||
v-if="data.content"
|
||||
v-else-if="data.content"
|
||||
:message="message"
|
||||
:is-email="isEmailContentType"
|
||||
:display-quoted-button="displayQuotedButton"
|
||||
@@ -149,6 +159,7 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { getDayDifferenceFromNow } from 'shared/helpers/DateHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -176,6 +187,14 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isAFacebookInbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isInstagram: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isAWhatsAppChannel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -210,11 +229,16 @@ export default {
|
||||
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() {
|
||||
return (
|
||||
this.hasAttachments ||
|
||||
this.data.content ||
|
||||
this.isEmailContentType ||
|
||||
this.isUnsupported ||
|
||||
this.isAnIntegrationMessage
|
||||
);
|
||||
},
|
||||
@@ -403,6 +427,7 @@ export default {
|
||||
return {
|
||||
bubble: this.isBubble,
|
||||
'is-private': this.data.private,
|
||||
'is-unsupported': this.isUnsupported,
|
||||
'is-image': this.hasMediaAttachment('image'),
|
||||
'is-video': this.hasMediaAttachment('video'),
|
||||
'is-text': this.hasText,
|
||||
@@ -411,6 +436,9 @@ export default {
|
||||
'is-email': this.isEmailContentType,
|
||||
};
|
||||
},
|
||||
isUnsupported() {
|
||||
return this.contentAttributes.is_unsupported ?? false;
|
||||
},
|
||||
isPending() {
|
||||
return this.data.status === MESSAGE_STATUS.PROGRESS;
|
||||
},
|
||||
@@ -422,11 +450,7 @@ export default {
|
||||
return !this.sender.type || this.sender.type === 'agent_bot';
|
||||
},
|
||||
shouldShowContextMenu() {
|
||||
return !(this.isFailed || this.isPending);
|
||||
},
|
||||
errorMessage() {
|
||||
const { meta } = this.data;
|
||||
return meta ? meta.error : '';
|
||||
return !(this.isFailed || this.isPending || this.isUnsupported);
|
||||
},
|
||||
showAvatar() {
|
||||
if (this.isOutgoing || this.isTemplate) {
|
||||
@@ -520,13 +544,6 @@ export default {
|
||||
this.showBackgroundHighlight = false;
|
||||
}, HIGHLIGHT_TIMER);
|
||||
},
|
||||
async navigateToMessage() {
|
||||
this.$nextTick(() => {
|
||||
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
|
||||
messageId: this.inReplyToMessageId,
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -535,6 +552,14 @@ export default {
|
||||
> .bubble {
|
||||
@apply min-w-[128px];
|
||||
|
||||
&.is-unsupported {
|
||||
@apply text-xs max-w-[300px] border-dashed border border-slate-200 text-slate-600 dark:text-slate-200 bg-slate-50 dark:bg-slate-700 dark:border-slate-500;
|
||||
|
||||
.message-text--metadata .time {
|
||||
@apply text-slate-400 dark:text-slate-300;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-image,
|
||||
&.is-video {
|
||||
@apply p-0 overflow-hidden;
|
||||
@@ -547,10 +572,12 @@ export default {
|
||||
> video {
|
||||
@apply rounded-lg;
|
||||
}
|
||||
|
||||
> video {
|
||||
@apply h-full w-full object-cover;
|
||||
}
|
||||
}
|
||||
|
||||
.video {
|
||||
@apply h-[11.25rem];
|
||||
}
|
||||
@@ -565,9 +592,11 @@ export default {
|
||||
.file--icon {
|
||||
@apply text-woot-400 dark:text-woot-400;
|
||||
}
|
||||
|
||||
.text-block-title {
|
||||
@apply text-slate-700 dark:text-slate-700;
|
||||
}
|
||||
|
||||
.download.button {
|
||||
@apply text-woot-400 dark:text-woot-400;
|
||||
}
|
||||
@@ -576,6 +605,7 @@ export default {
|
||||
&.is-private.is-text > .message-text__wrap .link {
|
||||
@apply text-woot-600 dark:text-woot-200;
|
||||
}
|
||||
|
||||
&.is-private.is-text > .message-text__wrap .prosemirror-mention-node {
|
||||
@apply font-bold bg-none rounded-sm p-0 bg-yellow-100 dark:bg-yellow-700 text-slate-700 dark:text-slate-25 underline;
|
||||
}
|
||||
@@ -586,6 +616,7 @@ export default {
|
||||
.message-text--metadata .time {
|
||||
@apply text-violet-50 dark:text-violet-50;
|
||||
}
|
||||
|
||||
&.is-private .message-text--metadata .time {
|
||||
@apply text-slate-400 dark:text-slate-400;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,15 @@
|
||||
icon="info"
|
||||
/>
|
||||
</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 }}
|
||||
</span>
|
||||
<span v-else-if="message.attachments">
|
||||
@@ -88,6 +96,9 @@ export default {
|
||||
attachmentMessageContent() {
|
||||
return `CHAT_LIST.ATTACHMENTS.${this.lastMessageFileType}.CONTENT`;
|
||||
},
|
||||
isMessageSticker() {
|
||||
return this.message && this.message.content_type === 'sticker';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:is-web-widget-inbox="isAWebWidgetInbox"
|
||||
:is-a-facebook-inbox="isAFacebookInbox"
|
||||
:is-instagram="isInstagramDM"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
/>
|
||||
@@ -54,6 +56,8 @@
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:is-web-widget-inbox="isAWebWidgetInbox"
|
||||
:is-a-facebook-inbox="isAFacebookInbox"
|
||||
:is-instagram-dm="isInstagramDM"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
/>
|
||||
@@ -283,11 +287,16 @@ export default {
|
||||
unreadMessageCount() {
|
||||
return this.currentChat.unread_count || 0;
|
||||
},
|
||||
isInstagramDM() {
|
||||
return this.conversationType === 'instagram_direct_message';
|
||||
},
|
||||
inboxSupportsReplyTo() {
|
||||
return {
|
||||
incoming: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO),
|
||||
outgoing: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING),
|
||||
};
|
||||
const incoming = this.inboxHasFeature(INBOX_FEATURES.REPLY_TO);
|
||||
const outgoing =
|
||||
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING) &&
|
||||
!this.is360DialogWhatsAppChannel;
|
||||
|
||||
return { incoming, outgoing };
|
||||
},
|
||||
},
|
||||
|
||||
@@ -377,20 +386,12 @@ export default {
|
||||
removeBusListeners() {
|
||||
bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
|
||||
},
|
||||
async onScrollToMessage({ messageId = '' } = {}) {
|
||||
if (messageId) {
|
||||
await this.$store.dispatch('setActiveChat', {
|
||||
data: this.currentChat,
|
||||
after: messageId,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
onScrollToMessage({ messageId = '' } = {}) {
|
||||
this.$nextTick(() => {
|
||||
const messageElement = document.getElementById('message' + messageId);
|
||||
if (messageElement) {
|
||||
this.isProgrammaticScroll = true;
|
||||
messageElement.scrollIntoView();
|
||||
messageElement.scrollIntoView({ behavior: 'smooth' });
|
||||
this.fetchPreviousMessages();
|
||||
} else {
|
||||
this.scrollToBottom();
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
v-if="shouldShowReplyToMessage"
|
||||
:message="inReplyTo"
|
||||
@dismiss="resetReplyToMessage"
|
||||
@navigate-to-message="navigateToMessage"
|
||||
/>
|
||||
<canned-response
|
||||
v-if="showMentions && hasSlashCommand"
|
||||
@@ -272,11 +271,17 @@ export default {
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
currentContact() {
|
||||
return this.$store.getters['contacts/getContact'](
|
||||
this.currentChat.meta.sender.id
|
||||
);
|
||||
},
|
||||
shouldShowReplyToMessage() {
|
||||
return (
|
||||
this.inReplyTo?.id &&
|
||||
!this.isPrivate &&
|
||||
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO)
|
||||
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
|
||||
!this.is360DialogWhatsAppChannel
|
||||
);
|
||||
},
|
||||
showRichContentEditor() {
|
||||
@@ -497,7 +502,11 @@ export default {
|
||||
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
},
|
||||
audioRecordFormat() {
|
||||
if (this.isAWhatsAppChannel || this.isAPIInbox) {
|
||||
if (
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isAPIInbox ||
|
||||
this.isATelegramChannel
|
||||
) {
|
||||
return AUDIO_FORMATS.OGG;
|
||||
}
|
||||
return AUDIO_FORMATS.WAV;
|
||||
@@ -505,6 +514,7 @@ export default {
|
||||
messageVariables() {
|
||||
const variables = getMessageVariables({
|
||||
conversation: this.currentChat,
|
||||
contact: this.currentContact,
|
||||
});
|
||||
return variables;
|
||||
},
|
||||
@@ -1160,11 +1170,6 @@ export default {
|
||||
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
|
||||
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE);
|
||||
},
|
||||
navigateToMessage(messageId) {
|
||||
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
|
||||
messageId,
|
||||
});
|
||||
},
|
||||
onNewConversationModalActive(isActive) {
|
||||
// Issue is if the new conversation modal is open and we drag and drop the file
|
||||
// then the file is not getting attached to the new conversation modal
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="reply-editor bg-slate-50 dark:bg-slate-800 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2 cursor-pointer"
|
||||
@click="$emit('navigate-to-message', message.id)"
|
||||
class="reply-editor bg-slate-50 dark:bg-slate-800 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
|
||||
>
|
||||
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
|
||||
<div class="flex-grow gap-1 mt-px text-xs truncate">
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<mention-box :items="items" @mention-select="handleVariableClick">
|
||||
<mention-box
|
||||
type="variable"
|
||||
:items="items"
|
||||
@mention-select="handleVariableClick"
|
||||
>
|
||||
<template slot-scope="{ item }">
|
||||
<span class="text-capitalize variable--list-label">
|
||||
{{ item.description }}
|
||||
@@ -10,6 +14,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { MESSAGE_VARIABLES } from 'shared/constants/messages';
|
||||
import MentionBox from '../mentions/MentionBox.vue';
|
||||
|
||||
@@ -22,7 +27,16 @@ export default {
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
customAttributes: 'attributes/getAttributes',
|
||||
}),
|
||||
items() {
|
||||
return [
|
||||
...this.standardAttributeVariables,
|
||||
...this.customAttributeVariables,
|
||||
];
|
||||
},
|
||||
standardAttributeVariables() {
|
||||
return MESSAGE_VARIABLES.filter(variable => {
|
||||
return (
|
||||
variable.label.includes(this.searchKey) ||
|
||||
@@ -34,6 +48,20 @@ export default {
|
||||
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: {
|
||||
handleVariableClick(item = {}) {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
class="px-2 py-1.5 -mx-2 rounded-sm min-w-[15rem] mb-2 cursor-pointer"
|
||||
class="px-2 py-1.5 rounded-sm min-w-[10rem] mb-2"
|
||||
:class="{
|
||||
'bg-slate-50 dark:bg-slate-600 dark:text-slate-50':
|
||||
messageType === MESSAGE_TYPE.INCOMING,
|
||||
'bg-woot-600 text-woot-50': messageType === MESSAGE_TYPE.OUTGOING,
|
||||
'-mx-2': !parentHasAttachments,
|
||||
}"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<message-preview
|
||||
:message="message"
|
||||
|
||||
+39
-1
@@ -46,9 +46,25 @@
|
||||
</span>
|
||||
</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
|
||||
>
|
||||
<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
|
||||
size="large"
|
||||
color-scheme="secondary"
|
||||
@@ -89,6 +105,7 @@
|
||||
:key="activeAttachment.message_id"
|
||||
:src="activeAttachment.data_url"
|
||||
class="modal-image skip-context-menu my-0 mx-auto"
|
||||
:style="imageRotationStyle"
|
||||
@click.stop
|
||||
/>
|
||||
<video
|
||||
@@ -186,6 +203,7 @@ export default {
|
||||
this.allAttachments.findIndex(
|
||||
attachment => attachment.message_id === this.attachment.message_id
|
||||
) || 0,
|
||||
activeImageRotation: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -232,6 +250,11 @@ export default {
|
||||
const fileName = dataUrl?.split('/').pop();
|
||||
return fileName || '';
|
||||
},
|
||||
imageRotationStyle() {
|
||||
return {
|
||||
transform: `rotate(${this.activeImageRotation}deg)`,
|
||||
};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setImageAndVideoSrc(this.attachment);
|
||||
@@ -246,6 +269,7 @@ export default {
|
||||
}
|
||||
this.activeImageIndex = index;
|
||||
this.setImageAndVideoSrc(attachment);
|
||||
this.activeImageRotation = 0;
|
||||
},
|
||||
setImageAndVideoSrc(attachment) {
|
||||
const { file_type: type } = attachment;
|
||||
@@ -280,6 +304,20 @@ export default {
|
||||
link.download = `attachment.${type}`;
|
||||
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>
|
||||
|
||||
@@ -1,20 +1,40 @@
|
||||
<template>
|
||||
<div v-if="items.length" ref="mentionsListContainer" class="mention--box">
|
||||
<div
|
||||
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">
|
||||
<woot-dropdown-item
|
||||
v-for="(item, index) in items"
|
||||
:id="`mention-item-${index}`"
|
||||
:key="item.key"
|
||||
class="!mb-0"
|
||||
@mouseover="onHover(index)"
|
||||
>
|
||||
<woot-button
|
||||
class="canned-item__button"
|
||||
:variant="index === selectedIndex ? '' : 'clear'"
|
||||
:class="{ active: index === selectedIndex }"
|
||||
<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"
|
||||
:class="{
|
||||
' bg-woot-25 dark:bg-woot-800': index === selectedIndex,
|
||||
}"
|
||||
@click="onListItemSelection(index)"
|
||||
>
|
||||
<strong>{{ item.label }}</strong> - {{ item.description }}
|
||||
</woot-button>
|
||||
<p
|
||||
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>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -29,6 +49,10 @@ export default {
|
||||
type: Array,
|
||||
default: () => {},
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'canned',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -73,28 +97,17 @@ export default {
|
||||
onSelect() {
|
||||
this.$emit('mention-select', this.items[this.selectedIndex]);
|
||||
},
|
||||
variableKey(item = {}) {
|
||||
return this.type === 'variable' ? `{{${item.label}}}` : `/${item.label}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.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 {
|
||||
@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];
|
||||
.dropdown-menu__item:last-child > button {
|
||||
@apply border-0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,14 @@ export default {
|
||||
ALL: 'all',
|
||||
},
|
||||
SORT_BY_TYPE: {
|
||||
LATEST: 'latest',
|
||||
CREATED_AT: 'sort_on_created_at',
|
||||
PRIORITY: 'sort_on_priority',
|
||||
WATIING_SINCE: 'waiting_since',
|
||||
LAST_ACTIVITY_AT_ASC: 'last_activity_at_asc',
|
||||
LAST_ACTIVITY_AT_DESC: 'last_activity_at_desc',
|
||||
CREATED_AT_ASC: 'created_at_asc',
|
||||
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: {
|
||||
DRAFT: 0,
|
||||
|
||||
@@ -23,6 +23,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'contact.updated': this.onContactUpdate,
|
||||
'conversation.mentioned': this.onConversationMentioned,
|
||||
'notification.created': this.onNotificationCreated,
|
||||
'notification.deleted': this.onNotificationDeleted,
|
||||
'first.reply.created': this.onFirstReplyCreated,
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
@@ -195,6 +196,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('notifications/addNotification', data);
|
||||
};
|
||||
|
||||
onNotificationDeleted = data => {
|
||||
this.app.$store.dispatch('notifications/deleteNotification', data);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onFirstReplyCreated = () => {
|
||||
bus.$emit('fetch_overview_reports');
|
||||
|
||||
@@ -51,18 +51,30 @@
|
||||
"ACTIVE": "Last activity"
|
||||
}
|
||||
},
|
||||
"CHAT_SORT_FILTER_ITEMS": {
|
||||
"latest": {
|
||||
"TEXT": "Last activity"
|
||||
"SORT_ORDER_ITEMS": {
|
||||
"last_activity_at_asc": {
|
||||
"TEXT": "Last activity: Oldest first"
|
||||
},
|
||||
"sort_on_created_at": {
|
||||
"TEXT": "Created at"
|
||||
"last_activity_at_desc": {
|
||||
"TEXT": "Last activity: Newest first"
|
||||
},
|
||||
"sort_on_priority": {
|
||||
"TEXT": "Priority"
|
||||
"created_at_desc": {
|
||||
"TEXT": "Created at: Newest first"
|
||||
},
|
||||
"sort_on_waiting_since": {
|
||||
"TEXT": "Pending Response"
|
||||
"created_at_asc": {
|
||||
"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": {
|
||||
|
||||
@@ -41,6 +41,9 @@
|
||||
"SAVE_CONTACT": "Save",
|
||||
"UPLOADING_ATTACHMENTS": "Uploading attachments...",
|
||||
"REPLIED_TO_STORY": "Replied to your story",
|
||||
"UNSUPPORTED_MESSAGE": "This message is unsupported.",
|
||||
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
|
||||
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
|
||||
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
|
||||
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
|
||||
"NO_RESPONSE": "No response",
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
},
|
||||
"NETWORK": {
|
||||
"NOTIFICATION": {
|
||||
"TEXT": "Disconnected from Chatwoot"
|
||||
"OFFLINE": "Offline"
|
||||
},
|
||||
"BUTTON": {
|
||||
"REFRESH": "Refresh"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"HEADER": {
|
||||
"FILTER": "Filter by",
|
||||
"SORT": "Sort by",
|
||||
"LOCALE": "Locale",
|
||||
"SETTINGS_BUTTON": "Settings",
|
||||
"NEW_BUTTON": "New Article",
|
||||
"DROPDOWN_OPTIONS": {
|
||||
@@ -15,6 +16,12 @@
|
||||
"MINE": "My Articles",
|
||||
"DRAFT": "Draft Articles",
|
||||
"ARCHIVED": "Archived Articles"
|
||||
},
|
||||
"LOCALE_SELECT": {
|
||||
"TITLE": "Select locale",
|
||||
"PLACEHOLDER": "Select locale",
|
||||
"NO_RESULT": "No locale found",
|
||||
"SEARCH_PLACEHOLDER": "Search locale"
|
||||
}
|
||||
},
|
||||
"EDIT_HEADER": {
|
||||
|
||||
@@ -10,6 +10,17 @@ describe('#messageStamp', () => {
|
||||
});
|
||||
|
||||
describe('#messageTimestamp', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers('modern');
|
||||
|
||||
const mockDate = new Date(2023, 4, 5);
|
||||
jest.setSystemTime(mockDate);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
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(
|
||||
'Apr 6, 2023'
|
||||
|
||||
+78
-3
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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">
|
||||
<woot-sidemenu-icon />
|
||||
@@ -86,6 +86,45 @@
|
||||
size="small"
|
||||
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
|
||||
size="small"
|
||||
icon="add"
|
||||
@@ -103,12 +142,15 @@ import { mixin as clickaway } from 'vue-clickaway';
|
||||
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.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';
|
||||
export default {
|
||||
components: {
|
||||
FluentIcon,
|
||||
WootDropdownItem,
|
||||
WootDropdownMenu,
|
||||
MultiselectDropdownItems,
|
||||
},
|
||||
mixins: [clickaway],
|
||||
props: {
|
||||
@@ -124,16 +166,35 @@ export default {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
selectedLocale: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
shouldShowSettings: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allLocales: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showSortByDropdown: false,
|
||||
showLocaleDropdown: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
shouldShowLocaleDropdown() {
|
||||
return this.allLocales.length > 1;
|
||||
},
|
||||
switchableLocales() {
|
||||
return this.allLocales.filter(
|
||||
locale => locale.name !== this.selectedLocale
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
openFilterModal() {
|
||||
this.$emit('openModal');
|
||||
@@ -146,8 +207,22 @@ export default {
|
||||
this.$emit('close');
|
||||
this.showSortByDropdown = false;
|
||||
},
|
||||
openLocaleDropdown() {
|
||||
this.showLocaleDropdown = true;
|
||||
},
|
||||
closeLocaleDropdown() {
|
||||
this.showLocaleDropdown = false;
|
||||
},
|
||||
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">
|
||||
.dropdown-pane--open {
|
||||
@apply top-12 right-[9.25rem];
|
||||
@apply absolute top-10 right-0 z-50 min-w-[8rem];
|
||||
}
|
||||
.dropdown-arrow {
|
||||
@apply ml-1 rtl:ml-0 rtl:mr-1;
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@
|
||||
v-if="isHelpCenterEnabled"
|
||||
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 />
|
||||
<account-selector
|
||||
:show-account-modal="showAccountModal"
|
||||
|
||||
+4
@@ -91,6 +91,7 @@
|
||||
@search-change="handleSearchChange"
|
||||
@close="onBlur"
|
||||
@tag="addTagValue"
|
||||
@remove="removeTag"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -203,6 +204,9 @@ export default {
|
||||
this.metaTags.push(...this.formattedTags({ tags: [...new Set(tags)] }));
|
||||
this.saveArticle();
|
||||
},
|
||||
removeTag() {
|
||||
this.saveArticle();
|
||||
},
|
||||
handleSearchChange(value) {
|
||||
this.tagInputValue = value;
|
||||
},
|
||||
|
||||
+39
-2
@@ -5,8 +5,11 @@
|
||||
<article-header
|
||||
:header-title="headerTitle"
|
||||
:count="meta.count"
|
||||
:selected-locale="activeLocaleName"
|
||||
:all-locales="allowedLocales"
|
||||
selected-value="Published"
|
||||
@newArticlePage="newArticlePage"
|
||||
@new-article-page="newArticlePage"
|
||||
@change-locale="onChangeLocale"
|
||||
/>
|
||||
<article-table
|
||||
:articles="articles"
|
||||
@@ -32,6 +35,7 @@
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import allLocales from 'shared/constants/locales.js';
|
||||
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import ArticleHeader from 'dashboard/routes/dashboard/helpcenter/components/Header/ArticleHeader.vue';
|
||||
@@ -58,6 +62,7 @@ export default {
|
||||
meta: 'articles/getMeta',
|
||||
isFetching: 'articles/isFetching',
|
||||
currentUserId: 'getCurrentUserID',
|
||||
getPortalBySlug: 'portals/portalBySlug',
|
||||
}),
|
||||
selectedCategory() {
|
||||
return this.categories.find(
|
||||
@@ -118,6 +123,28 @@ export default {
|
||||
? 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: {
|
||||
$route() {
|
||||
@@ -137,7 +164,7 @@ export default {
|
||||
this.$store.dispatch('articles/index', {
|
||||
pageNumber: pageNumber || this.pageNumber,
|
||||
portalSlug: this.$route.params.portalSlug,
|
||||
locale: this.$route.params.locale,
|
||||
locale: this.activeLocale,
|
||||
status: this.status,
|
||||
authorId: this.author,
|
||||
categorySlug: this.selectedCategorySlug,
|
||||
@@ -152,6 +179,16 @@ export default {
|
||||
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>
|
||||
|
||||
+1
@@ -50,6 +50,7 @@
|
||||
:is-loading="uiFlags.isFetching"
|
||||
:on-click-notification="openConversation"
|
||||
:in-last-page="inLastPage"
|
||||
@close="closeNotificationPanel"
|
||||
/>
|
||||
<div
|
||||
v-if="records.length !== 0"
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div>
|
||||
<woot-button
|
||||
size="expanded"
|
||||
color-scheme="secondary"
|
||||
variant="link"
|
||||
@click="onClickOpenNotification()"
|
||||
>
|
||||
<div
|
||||
class="flex-row items-center p-2.5 leading-[1.4] border-b border-solid border-slate-50 dark:border-slate-700 flex w-full hover:bg-slate-75 dark:hover:bg-slate-900 hover:rounded-md"
|
||||
>
|
||||
<div
|
||||
v-if="!notificationItem.read_at"
|
||||
class="w-2 h-2 rounded-full bg-woot-500"
|
||||
/>
|
||||
<div v-else class="w-2 flex" />
|
||||
<div
|
||||
class="flex-col ml-2.5 overflow-hidden w-full flex justify-between"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<div class="items-center flex">
|
||||
<span class="font-bold text-slate-800 dark:text-slate-100">
|
||||
{{
|
||||
`#${
|
||||
notificationItem.primary_actor
|
||||
? notificationItem.primary_actor.id
|
||||
: $t(`NOTIFICATIONS_PAGE.DELETE_TITLE`)
|
||||
}`
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
class="text-xxs p-0.5 px-1 my-0 mx-2 bg-slate-50 dark:bg-slate-700 rounded-md"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
`NOTIFICATIONS_PAGE.TYPE_LABEL.${notificationItem.notification_type}`
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="hasNotificationAssignee">
|
||||
<thumbnail
|
||||
:src="notificationAssigneeThumbnail"
|
||||
size="16px"
|
||||
:username="notificationAssigneeName"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex">
|
||||
<span
|
||||
class="text-slate-700 dark:text-slate-200 font-normal overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ notificationItem.push_message_title }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="mt-1 text-slate-500 dark:text-slate-400 text-xxs font-semibold flex"
|
||||
>
|
||||
{{ dynamicTime(notificationItem.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</woot-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import timeMixin from 'dashboard/mixins/time';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
},
|
||||
mixins: [timeMixin],
|
||||
props: {
|
||||
notificationItem: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
notificationAssignee() {
|
||||
const { primary_actor: primaryActor } = this.notificationItem;
|
||||
return primaryActor?.meta?.assignee;
|
||||
},
|
||||
hasNotificationAssignee() {
|
||||
return !!this.notificationAssignee;
|
||||
},
|
||||
notificationAssigneeName() {
|
||||
return this.notificationAssignee?.name || '';
|
||||
},
|
||||
notificationAssigneeThumbnail() {
|
||||
return this.notificationAssignee?.thumbnail || '';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClickOpenNotification() {
|
||||
this.$emit('open-notification', this.notificationItem);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
+7
-69
@@ -1,70 +1,12 @@
|
||||
<template>
|
||||
<div class="flex-col py-2 px-2.5 overflow-auto h-full flex">
|
||||
<woot-button
|
||||
<notification-panel-item
|
||||
v-for="notificationItem in notifications"
|
||||
v-show="!isLoading"
|
||||
:key="notificationItem.id"
|
||||
size="expanded"
|
||||
color-scheme="secondary"
|
||||
variant="link"
|
||||
@click="() => onClickNotification(notificationItem)"
|
||||
>
|
||||
<div
|
||||
class="flex-row items-center p-2.5 leading-[1.4] border-b border-solid border-slate-50 dark:border-slate-700 flex w-full hover:bg-slate-75 dark:hover:bg-slate-900 hover:rounded-md"
|
||||
>
|
||||
<div
|
||||
v-if="!notificationItem.read_at"
|
||||
class="w-2 h-2 rounded-full bg-woot-500"
|
||||
/>
|
||||
<div v-else class="w-2 flex" />
|
||||
<div
|
||||
class="flex-col ml-2.5 overflow-hidden w-full flex justify-between"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<div class="items-center flex">
|
||||
<span class="font-bold text-slate-800 dark:text-slate-100">
|
||||
{{
|
||||
`#${
|
||||
notificationItem.primary_actor
|
||||
? notificationItem.primary_actor.id
|
||||
: $t(`NOTIFICATIONS_PAGE.DELETE_TITLE`)
|
||||
}`
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
class="text-xxs p-0.5 px-1 my-0 mx-2 bg-slate-50 dark:bg-slate-700 rounded-md"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
`NOTIFICATIONS_PAGE.TYPE_LABEL.${notificationItem.notification_type}`
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<thumbnail
|
||||
v-if="hasAssignee(notificationItem)"
|
||||
:src="notificationItem.primary_actor.meta.assignee.thumbnail"
|
||||
size="16px"
|
||||
:username="notificationItem.primary_actor.meta.assignee.name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex">
|
||||
<span
|
||||
class="text-slate-700 dark:text-slate-200 font-normal overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ notificationItem.push_message_title }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="mt-1 text-slate-500 dark:text-slate-400 text-xxs font-semibold flex"
|
||||
>
|
||||
{{ dynamicTime(notificationItem.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</woot-button>
|
||||
:notification-item="notificationItem"
|
||||
@open-notification="onClickNotification"
|
||||
/>
|
||||
<empty-state
|
||||
v-if="showEmptyResult"
|
||||
:title="$t('NOTIFICATIONS_PAGE.UNREAD_NOTIFICATION.EMPTY_MESSAGE')"
|
||||
@@ -94,18 +36,16 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
|
||||
import timeMixin from '../../../../mixins/time';
|
||||
import NotificationPanelItem from './NotificationPanelItem.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
NotificationPanelItem,
|
||||
Spinner,
|
||||
EmptyState,
|
||||
},
|
||||
mixins: [timeMixin],
|
||||
props: {
|
||||
notifications: {
|
||||
type: Array,
|
||||
@@ -139,9 +79,7 @@ export default {
|
||||
name: 'notifications_index',
|
||||
});
|
||||
}
|
||||
},
|
||||
hasAssignee(notification) {
|
||||
return notification.primary_actor.meta?.assignee;
|
||||
this.$emit('close');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,6 +11,19 @@ import {
|
||||
} from './helpers/actionHelpers';
|
||||
import messageReadActions from './actions/messageReadActions';
|
||||
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
|
||||
const actions = {
|
||||
getConversation: async ({ commit }, conversationId) => {
|
||||
@@ -160,10 +173,10 @@ const actions = {
|
||||
});
|
||||
},
|
||||
|
||||
async setActiveChat({ commit, dispatch }, { data, after, force = false }) {
|
||||
async setActiveChat({ commit, dispatch }, { data, after }) {
|
||||
commit(types.SET_CURRENT_CHAT_WINDOW, data);
|
||||
commit(types.CLEAR_ALL_MESSAGES_LOADED);
|
||||
if (data.dataFetched === undefined || force) {
|
||||
if (data.dataFetched === undefined) {
|
||||
try {
|
||||
await dispatch('fetchPreviousMessages', {
|
||||
after,
|
||||
@@ -242,12 +255,15 @@ const actions = {
|
||||
},
|
||||
|
||||
sendMessageWithData: async ({ commit }, pendingMessage) => {
|
||||
const { conversation_id: conversationId, id } = pendingMessage;
|
||||
try {
|
||||
commit(types.ADD_MESSAGE, {
|
||||
...pendingMessage,
|
||||
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, {
|
||||
...response.data,
|
||||
status: MESSAGE_STATUS.SENT,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import {
|
||||
MESSAGE_TYPE,
|
||||
CONVERSATION_PRIORITY_ORDER,
|
||||
} from 'shared/constants/messages';
|
||||
import { applyPageFilters } from './helpers';
|
||||
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||
import { applyPageFilters, sortComparator } from './helpers';
|
||||
|
||||
export const getSelectedChatConversation = ({
|
||||
allConversations,
|
||||
@@ -10,36 +7,9 @@ export const getSelectedChatConversation = ({
|
||||
}) =>
|
||||
allConversations.filter(conversation => conversation.id === selectedChatId);
|
||||
|
||||
const sortComparator = {
|
||||
latest: (a, b) => b.last_activity_at - a.last_activity_at,
|
||||
sort_on_created_at: (a, b) => a.created_at - b.created_at,
|
||||
sort_on_priority: (a, b) => {
|
||||
return (
|
||||
CONVERSATION_PRIORITY_ORDER[a.priority] -
|
||||
CONVERSATION_PRIORITY_ORDER[b.priority]
|
||||
);
|
||||
},
|
||||
sort_on_waiting_since: (a, b) => {
|
||||
if (!a.waiting_since && !b.waiting_since) {
|
||||
return a.created_at - b.created_at;
|
||||
}
|
||||
|
||||
if (!a.waiting_since) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!b.waiting_since) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return a.waiting_since - b.waiting_since;
|
||||
},
|
||||
};
|
||||
|
||||
// getters
|
||||
const getters = {
|
||||
getAllConversations: ({ allConversations, chatSortFilter }) => {
|
||||
return allConversations.sort(sortComparator[chatSortFilter]);
|
||||
getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => {
|
||||
return allConversations.sort((a, b) => sortComparator(a, b, sortKey));
|
||||
},
|
||||
getSelectedChat: ({ selectedChatId, allConversations }) => {
|
||||
const selectedChat = allConversations.find(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CONVERSATION_PRIORITY_ORDER } from 'shared/constants/messages';
|
||||
|
||||
export const findPendingMessageIndex = (chat, message) => {
|
||||
const { echo_id: tempMessageId } = message;
|
||||
return chat.messages.findIndex(
|
||||
@@ -59,3 +61,54 @@ export const applyPageFilters = (conversation, filters) => {
|
||||
|
||||
return shouldFilter;
|
||||
};
|
||||
|
||||
const SORT_OPTIONS = {
|
||||
last_activity_at_asc: ['sortOnLastActivityAt', 'asc'],
|
||||
last_activity_at_desc: ['sortOnLastActivityAt', 'desc'],
|
||||
created_at_asc: ['sortOnCreatedAt', 'asc'],
|
||||
created_at_desc: ['sortOnCreatedAt', 'desc'],
|
||||
priority_asc: ['sortOnPriority', 'asc'],
|
||||
priority_desc: ['sortOnPriority', 'desc'],
|
||||
waiting_since_asc: ['sortOnWaitingSince', 'asc'],
|
||||
waiting_since_desc: ['sortOnWaitingSince', 'desc'],
|
||||
};
|
||||
const sortAscending = (valueA, valueB) => valueA - valueB;
|
||||
const sortDescending = (valueA, valueB) => valueB - valueA;
|
||||
|
||||
const getSortOrderFunction = sortOrder =>
|
||||
sortOrder === 'asc' ? sortAscending : sortDescending;
|
||||
|
||||
const sortConfig = {
|
||||
sortOnLastActivityAt: (a, b, sortDirection) =>
|
||||
getSortOrderFunction(sortDirection)(a.last_activity_at, b.last_activity_at),
|
||||
|
||||
sortOnCreatedAt: (a, b, sortDirection) =>
|
||||
getSortOrderFunction(sortDirection)(a.created_at, b.created_at),
|
||||
|
||||
sortOnPriority: (a, b, sortDirection) => {
|
||||
const DEFAULT_FOR_NULL = sortDirection === 'asc' ? 5 : 0;
|
||||
|
||||
const p1 = CONVERSATION_PRIORITY_ORDER[a.priority] || DEFAULT_FOR_NULL;
|
||||
const p2 = CONVERSATION_PRIORITY_ORDER[b.priority] || DEFAULT_FOR_NULL;
|
||||
|
||||
return getSortOrderFunction(sortDirection)(p1, p2);
|
||||
},
|
||||
|
||||
sortOnWaitingSince: (a, b, sortDirection) => {
|
||||
const sortFunc = getSortOrderFunction(sortDirection);
|
||||
if (!a.waiting_since || !b.waiting_since) {
|
||||
if (!a.waiting_since && !b.waiting_since) {
|
||||
return sortFunc(a.created_at, b.created_at);
|
||||
}
|
||||
return sortFunc(a.waiting_since ? 0 : 1, b.waiting_since ? 0 : 1);
|
||||
}
|
||||
|
||||
return sortFunc(a.waiting_since, b.waiting_since);
|
||||
},
|
||||
};
|
||||
|
||||
export const sortComparator = (a, b, sortKey) => {
|
||||
const [sortMethod, sortDirection] =
|
||||
SORT_OPTIONS[sortKey] || SORT_OPTIONS.last_activity_at_desc;
|
||||
return sortConfig[sortMethod](a, b, sortDirection);
|
||||
};
|
||||
|
||||
@@ -56,4 +56,7 @@ export const actions = {
|
||||
addNotification({ commit }, data) {
|
||||
commit(types.ADD_NOTIFICATION, data);
|
||||
},
|
||||
deleteNotification({ commit }, data) {
|
||||
commit(types.DELETE_NOTIFICATION, data);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,4 +55,10 @@ export const mutations = {
|
||||
Vue.set($state.meta, 'unreadCount', unreadCount);
|
||||
Vue.set($state.meta, 'count', count);
|
||||
},
|
||||
[types.DELETE_NOTIFICATION]($state, data) {
|
||||
const { notification, unread_count: unreadCount, count } = data;
|
||||
Vue.delete($state.records, notification.id);
|
||||
Vue.set($state.meta, 'unreadCount', unreadCount);
|
||||
Vue.set($state.meta, 'count', count);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import actions from '../../conversations/actions';
|
||||
import actions, {
|
||||
hasMessageFailedWithExternalError,
|
||||
} from '../../conversations/actions';
|
||||
import types from '../../../mutation-types';
|
||||
const dataToSend = {
|
||||
payload: [
|
||||
@@ -18,6 +20,41 @@ const dispatch = jest.fn();
|
||||
global.axios = axios;
|
||||
jest.mock('axios');
|
||||
|
||||
describe('#hasMessageFailedWithExternalError', () => {
|
||||
it('returns false if message is sent', () => {
|
||||
const pendingMessage = {
|
||||
status: 'sent',
|
||||
content_attributes: {},
|
||||
};
|
||||
expect(hasMessageFailedWithExternalError(pendingMessage)).toBe(false);
|
||||
});
|
||||
it('returns false if status is not failed', () => {
|
||||
const pendingMessage = {
|
||||
status: 'progress',
|
||||
content_attributes: {},
|
||||
};
|
||||
expect(hasMessageFailedWithExternalError(pendingMessage)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if status is failed but no external error', () => {
|
||||
const pendingMessage = {
|
||||
status: 'failed',
|
||||
content_attributes: {},
|
||||
};
|
||||
expect(hasMessageFailedWithExternalError(pendingMessage)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true if status is failed and has external error', () => {
|
||||
const pendingMessage = {
|
||||
status: 'failed',
|
||||
content_attributes: {
|
||||
external_error: 'error',
|
||||
},
|
||||
};
|
||||
expect(hasMessageFailedWithExternalError(pendingMessage)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#actions', () => {
|
||||
describe('#getConversation', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
export default [
|
||||
{
|
||||
created_at: 1702411932, // Dec 12, 2023 12:12:12
|
||||
id: 1,
|
||||
last_activity_at: 1704408443, // Jan 04, 2024 14:47:23
|
||||
messages: [{ content: 'test1' }],
|
||||
priority: 'medium',
|
||||
waiting_since: 0, // not waiting
|
||||
},
|
||||
{
|
||||
created_at: 1699819932, // Nov 12, 2023 12:12:12
|
||||
id: 2,
|
||||
last_activity_at: 1704485532, // Jan 05, 2024 12:12:12
|
||||
messages: [{ content: 'test2' }],
|
||||
priority: 'low',
|
||||
waiting_since: 1683645800, // May 09 2023 15:23:20
|
||||
},
|
||||
{
|
||||
created_at: 1641413532, // Jan 05, 2022 12:12:12
|
||||
id: 3,
|
||||
last_activity_at: 1704408567, // Jan 04, 2024 14:49:27
|
||||
messages: [{ content: 'test3' }],
|
||||
priority: 'low',
|
||||
waiting_since: 0, // not waiting
|
||||
},
|
||||
{
|
||||
created_at: 1641413531, // Jan 05, 2022 12:12:11
|
||||
id: 4,
|
||||
last_activity_at: 1704408566, // Jan 04, 2024 14:49:26
|
||||
messages: [{ content: 'test4' }],
|
||||
priority: 'high',
|
||||
waiting_since: 1683645801, // May 09 2023 15:23:21
|
||||
},
|
||||
];
|
||||
@@ -1,243 +1,130 @@
|
||||
import commonHelpers from '../../../../helper/commons';
|
||||
import getters from '../../conversations/getters';
|
||||
/*
|
||||
Order of conversations in the fixture is as follows:
|
||||
- lastActivity: c0 < c3 < c2 < c1
|
||||
- createdAt: c3 < c2 < c1 < c0
|
||||
- priority: c1 < c2 < c0 < c3
|
||||
- waitingSince: c1 > c3 > c0 < c2
|
||||
*/
|
||||
import conversations from './conversations.fixtures';
|
||||
|
||||
// loads .last() helper
|
||||
commonHelpers();
|
||||
|
||||
describe('#getters', () => {
|
||||
describe('#getAllConversations', () => {
|
||||
it('order conversations based on last activity', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
content: 'test1',
|
||||
},
|
||||
],
|
||||
created_at: 2466424490,
|
||||
last_activity_at: 2466424490,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
messages: [{ content: 'test2' }],
|
||||
created_at: 1466424480,
|
||||
last_activity_at: 1466424480,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('returns conversations ordered by lastActivityAt in descending order if no sort order is available', () => {
|
||||
const state = { allConversations: [...conversations] };
|
||||
expect(getters.getAllConversations(state)).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
content: 'test1',
|
||||
},
|
||||
],
|
||||
created_at: 2466424490,
|
||||
last_activity_at: 2466424490,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
messages: [{ content: 'test2' }],
|
||||
created_at: 1466424480,
|
||||
last_activity_at: 1466424480,
|
||||
},
|
||||
conversations[1],
|
||||
conversations[2],
|
||||
conversations[3],
|
||||
conversations[0],
|
||||
]);
|
||||
});
|
||||
it('order conversations based on created at', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
content: 'test1',
|
||||
},
|
||||
],
|
||||
created_at: 1683645801, // Tuesday, 9 May 2023
|
||||
last_activity_at: 2466424490,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
messages: [{ content: 'test2' }],
|
||||
created_at: 1652109801, // Monday, 9 May 2022
|
||||
last_activity_at: 1466424480,
|
||||
},
|
||||
],
|
||||
chatSortFilter: 'sort_on_created_at',
|
||||
};
|
||||
|
||||
it('returns conversations ordered by lastActivityAt in descending order if invalid sort order is available', () => {
|
||||
const state = {
|
||||
allConversations: [...conversations],
|
||||
chatSortFilter: 'latest',
|
||||
};
|
||||
expect(getters.getAllConversations(state)).toEqual([
|
||||
{
|
||||
id: 2,
|
||||
messages: [{ content: 'test2' }],
|
||||
created_at: 1652109801,
|
||||
last_activity_at: 1466424480,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
content: 'test1',
|
||||
},
|
||||
],
|
||||
created_at: 1683645801,
|
||||
last_activity_at: 2466424490,
|
||||
},
|
||||
conversations[1],
|
||||
conversations[2],
|
||||
conversations[3],
|
||||
conversations[0],
|
||||
]);
|
||||
});
|
||||
it('order conversations based on default order', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
content: 'test1',
|
||||
},
|
||||
],
|
||||
created_at: 2466424490,
|
||||
last_activity_at: 2466424490,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
messages: [{ content: 'test2' }],
|
||||
created_at: 1466424480,
|
||||
last_activity_at: 1466424480,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('returns conversations ordered by lastActivityAt in descending order if chatStatusFilter = last_activity_at_desc', () => {
|
||||
const state = {
|
||||
allConversations: [...conversations],
|
||||
chatSortFilter: 'last_activity_at_desc',
|
||||
};
|
||||
expect(getters.getAllConversations(state)).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
content: 'test1',
|
||||
},
|
||||
],
|
||||
created_at: 2466424490,
|
||||
last_activity_at: 2466424490,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
messages: [{ content: 'test2' }],
|
||||
created_at: 1466424480,
|
||||
last_activity_at: 1466424480,
|
||||
},
|
||||
conversations[1],
|
||||
conversations[2],
|
||||
conversations[3],
|
||||
conversations[0],
|
||||
]);
|
||||
});
|
||||
it('order conversations based on priority', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
content: 'test1',
|
||||
},
|
||||
],
|
||||
priority: 'low',
|
||||
created_at: 1683645801,
|
||||
last_activity_at: 2466424490,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
messages: [{ content: 'test2' }],
|
||||
priority: 'urgent',
|
||||
created_at: 1652109801,
|
||||
last_activity_at: 1466424480,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
messages: [{ content: 'test3' }],
|
||||
priority: 'medium',
|
||||
created_at: 1652109801,
|
||||
last_activity_at: 1466421280,
|
||||
},
|
||||
],
|
||||
chatSortFilter: 'sort_on_priority',
|
||||
};
|
||||
|
||||
it('returns conversations ordered by lastActivityAt in ascending order if chatStatusFilter = last_activity_at_asc', () => {
|
||||
const state = {
|
||||
allConversations: [...conversations],
|
||||
chatSortFilter: 'last_activity_at_asc',
|
||||
};
|
||||
expect(getters.getAllConversations(state)).toEqual([
|
||||
{
|
||||
id: 2,
|
||||
messages: [{ content: 'test2' }],
|
||||
priority: 'urgent',
|
||||
created_at: 1652109801,
|
||||
last_activity_at: 1466424480,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
messages: [{ content: 'test3' }],
|
||||
priority: 'medium',
|
||||
created_at: 1652109801,
|
||||
last_activity_at: 1466421280,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
content: 'test1',
|
||||
},
|
||||
],
|
||||
priority: 'low',
|
||||
created_at: 1683645801,
|
||||
last_activity_at: 2466424490,
|
||||
},
|
||||
conversations[0],
|
||||
conversations[3],
|
||||
conversations[2],
|
||||
conversations[1],
|
||||
]);
|
||||
});
|
||||
it('order conversations based on waiting_since', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 3,
|
||||
created_at: 1683645800,
|
||||
waiting_since: 0,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
created_at: 1683645799,
|
||||
waiting_since: 0,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
created_at: 1683645801,
|
||||
waiting_since: 1683645802,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
created_at: 1683645803,
|
||||
waiting_since: 1683645800,
|
||||
},
|
||||
],
|
||||
chatSortFilter: 'sort_on_waiting_since',
|
||||
};
|
||||
|
||||
it('returns conversations ordered by createdAt in descending order if chatStatusFilter = created_at_desc', () => {
|
||||
const state = {
|
||||
allConversations: [...conversations],
|
||||
chatSortFilter: 'created_at_desc',
|
||||
};
|
||||
expect(getters.getAllConversations(state)).toEqual([
|
||||
{
|
||||
id: 2,
|
||||
created_at: 1683645803,
|
||||
waiting_since: 1683645800,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
created_at: 1683645801,
|
||||
waiting_since: 1683645802,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
created_at: 1683645799,
|
||||
waiting_since: 0,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
created_at: 1683645800,
|
||||
waiting_since: 0,
|
||||
},
|
||||
conversations[0],
|
||||
conversations[1],
|
||||
conversations[2],
|
||||
conversations[3],
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns conversations ordered by createdAt in ascending order if chatStatusFilter = created_at_asc', () => {
|
||||
const state = {
|
||||
allConversations: [...conversations],
|
||||
chatSortFilter: 'created_at_asc',
|
||||
};
|
||||
expect(getters.getAllConversations(state)).toEqual([
|
||||
conversations[3],
|
||||
conversations[2],
|
||||
conversations[1],
|
||||
conversations[0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns conversations ordered by priority in descending order if chatStatusFilter = priority_desc', () => {
|
||||
const state = {
|
||||
allConversations: [...conversations],
|
||||
chatSortFilter: 'priority_desc',
|
||||
};
|
||||
expect(getters.getAllConversations(state)).toEqual([
|
||||
conversations[3],
|
||||
conversations[0],
|
||||
conversations[1],
|
||||
conversations[2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns conversations ordered by priority in ascending order if chatStatusFilter = priority_asc', () => {
|
||||
const state = {
|
||||
allConversations: [...conversations],
|
||||
chatSortFilter: 'priority_asc',
|
||||
};
|
||||
expect(getters.getAllConversations(state)).toEqual([
|
||||
conversations[1],
|
||||
conversations[2],
|
||||
conversations[0],
|
||||
conversations[3],
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns conversations ordered by longest waiting if chatStatusFilter = waiting_since_asc', () => {
|
||||
const state = {
|
||||
allConversations: [...conversations],
|
||||
chatSortFilter: 'waiting_since_asc',
|
||||
};
|
||||
expect(getters.getAllConversations(state)).toEqual([
|
||||
conversations[1],
|
||||
conversations[3],
|
||||
conversations[2],
|
||||
conversations[0],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,4 +98,13 @@ describe('#actions', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#deleteNotification', () => {
|
||||
it('sends correct actions', async () => {
|
||||
await actions.deleteNotification({ commit }, { data: 1 });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.DELETE_NOTIFICATION, { data: 1 }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,4 +118,27 @@ describe('#mutations', () => {
|
||||
expect(state.meta.count).toEqual(232);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#DELETE_NOTIFICATION', () => {
|
||||
it('delete notification', () => {
|
||||
const state = {
|
||||
meta: { unreadCount: 4, count: 231 },
|
||||
records: {
|
||||
1: { id: 1, primary_actor_id: 1 },
|
||||
2: { id: 2, primary_actor_id: 2 },
|
||||
},
|
||||
};
|
||||
const data = {
|
||||
notification: { id: 1, primary_actor_id: 1 },
|
||||
unread_count: 5,
|
||||
count: 232,
|
||||
};
|
||||
mutations[types.DELETE_NOTIFICATION](state, data);
|
||||
expect(state.records).toEqual({
|
||||
2: { id: 2, primary_actor_id: 2 },
|
||||
});
|
||||
expect(state.meta.unreadCount).toEqual(5);
|
||||
expect(state.meta.count).toEqual(232);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,6 +133,7 @@ export default {
|
||||
SET_NOTIFICATIONS_UI_FLAG: 'SET_NOTIFICATIONS_UI_FLAG',
|
||||
UPDATE_NOTIFICATION: 'UPDATE_NOTIFICATION',
|
||||
ADD_NOTIFICATION: 'ADD_NOTIFICATION',
|
||||
DELETE_NOTIFICATION: 'DELETE_NOTIFICATION',
|
||||
UPDATE_ALL_NOTIFICATIONS: 'UPDATE_ALL_NOTIFICATIONS',
|
||||
SET_NOTIFICATIONS_ITEM: 'SET_NOTIFICATIONS_ITEM',
|
||||
SET_NOTIFICATIONS: 'SET_NOTIFICATIONS',
|
||||
|
||||
@@ -51,6 +51,9 @@ if (window.errorLoggingConfig) {
|
||||
/safari-extension:/i,
|
||||
],
|
||||
integrations: [new Integrations.BrowserTracing()],
|
||||
ignoreErrors: [
|
||||
'ResizeObserver loop completed with undelivered notifications',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
import '../dashboard/assets/scss/app.scss';
|
||||
import '../dashboard/assets/scss/super_admin/index.scss';
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
import '../dashboard/assets/scss/super_admin/pages.scss';
|
||||
import 'chart.js';
|
||||
|
||||
@@ -35,6 +35,9 @@ if (window.errorLoggingConfig) {
|
||||
/safari-extension:/i,
|
||||
],
|
||||
integrations: [new Integrations.BrowserTracing()],
|
||||
ignoreErrors: [
|
||||
'ResizeObserver loop completed with undelivered notifications',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
"arrow-trending-lines-outline": "M16.749 2h4.554l.1.014.099.028.06.026c.08.034.153.085.219.15l.04.044.044.057.054.09.039.09.019.064.014.064.009.095v4.532a.75.75 0 0 1-1.493.102l-.007-.102V4.559l-6.44 6.44a.75.75 0 0 1-.976.073L13 11 9.97 8.09l-5.69 5.689a.75.75 0 0 1-1.133-.977l.073-.084 6.22-6.22a.75.75 0 0 1 .976-.072l.084.072 3.03 2.91L19.438 3.5h-2.69a.75.75 0 0 1-.742-.648l-.007-.102a.75.75 0 0 1 .648-.743L16.75 2ZM3.75 17a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5a.75.75 0 0 1 .75-.75Zm5.75-3.25a.75.75 0 0 0-1.5 0v7.5a.75.75 0 0 0 1.5 0v-7.5ZM13.75 15a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0v-5.5a.75.75 0 0 1 .75-.75Zm5.75-4.25a.75.75 0 0 0-1.5 0v10.5a.75.75 0 0 0 1.5 0v-10.5Z",
|
||||
"arrow-up-outline": "M4.21 10.733a.75.75 0 0 0 1.086 1.034l5.954-6.251V20.25a.75.75 0 0 0 1.5 0V5.516l5.955 6.251a.75.75 0 0 0 1.086-1.034l-7.067-7.42a.995.995 0 0 0-.58-.3.754.754 0 0 0-.29.001.995.995 0 0 0-.578.3L4.21 10.733Z",
|
||||
"arrow-up-right-outline": "M12.748 3.001h7.554l.1.014.099.028.06.026a.72.72 0 0 1 .219.15l.04.044.061.082.037.065.04.09.018.064.014.064.01.093v7.534a.75.75 0 0 1-1.493.102l-.007-.102V5.559L4.28 20.784a.75.75 0 0 1-.977.073l-.084-.073a.75.75 0 0 1-.073-.976l.073-.084L18.439 4.5l-5.69.001a.75.75 0 0 1-.744-.648l-.007-.102a.75.75 0 0 1 .648-.743L12.748 3Z",
|
||||
"arrow-rotate-clockwise-outline": "M12 3a9 9 0 0 1 9 9 9.005 9.005 0 0 1-4.873 8.001L18 20a1 1 0 0 1 .117 1.993L18 22h-4a1 1 0 0 1-.993-.883L13 21v-4a1 1 0 0 1 1.993-.117L15 17l.001 1.327A7.006 7.006 0 0 0 19 12a7 7 0 1 0-14 0 1 1 0 1 1-2 0 9 9 0 0 1 9-9Zm0 6a3 3 0 1 1 0 6 3 3 0 0 1 0-6Zm0 2a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z",
|
||||
"arrow-rotate-counter-clockwise-outline": "M12 3a9 9 0 0 0-4.977 16.5H5.25a.75.75 0 0 0-.102 1.493L5.25 21h4a.75.75 0 0 0 .743-.648L10 20.25v-4a.75.75 0 0 0-1.493-.102l-.007.102-.001 2.385A7.501 7.501 0 0 1 12 4.5a7.5 7.5 0 0 1 7.5 7.5.75.75 0 0 0 1.5 0 9 9 0 0 0-9-9Zm0 6.25a2.75 2.75 0 1 0 0 5.5 2.75 2.75 0 0 0 0-5.5Zm0 1.5a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Z",
|
||||
"attach-outline": "M11.772 3.743a6 6 0 0 1 8.66 8.302l-.19.197-8.8 8.798-.036.03a3.723 3.723 0 0 1-5.489-4.973.764.764 0 0 1 .085-.13l.054-.06.086-.088.142-.148.002.003 7.436-7.454a.75.75 0 0 1 .977-.074l.084.073a.75.75 0 0 1 .074.976l-.073.084-7.594 7.613a2.23 2.23 0 0 0 3.174 3.106l8.832-8.83A4.502 4.502 0 0 0 13 4.644l-.168.16-.013.014-9.536 9.536a.75.75 0 0 1-1.133-.977l.072-.084 9.549-9.55h.002Z",
|
||||
"autocorrect-outline": "M13.461 4.934c.293.184.548.42.752.698l.117.171 2.945 4.696H21.5a.75.75 0 0 1 .743.649l.007.102a.75.75 0 0 1-.75.75l-3.284-.001.006.009-.009-.01a4.75 4.75 0 1 1-3.463-1.5h.756L13.059 6.6a1.25 1.25 0 0 0-2.04-.112l-.078.112-7.556 12.048a.75.75 0 0 1-1.322-.699l.052-.098L9.67 5.803a2.75 2.75 0 0 1 3.791-.869ZM14.751 12a3.25 3.25 0 1 0 0 6.5 3.25 3.25 0 0 0 0-6.5Z",
|
||||
"automation-outline": [
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
removeSignature,
|
||||
extractTextFromMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import { createTypingIndicator } from '@chatwoot/utils';
|
||||
|
||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
export default {
|
||||
@@ -54,7 +55,15 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
idleTimer: null,
|
||||
typingIndicator: createTypingIndicator(
|
||||
() => {
|
||||
this.$emit('typing-on');
|
||||
},
|
||||
() => {
|
||||
this.$emit('typing-off');
|
||||
},
|
||||
TYPING_INDICATOR_IDLE_TIME
|
||||
),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -137,28 +146,11 @@ export default {
|
||||
this.$emit('input', event.target.value);
|
||||
this.resizeTextarea();
|
||||
},
|
||||
resetTyping() {
|
||||
this.$emit('typing-off');
|
||||
this.idleTimer = null;
|
||||
},
|
||||
turnOffIdleTimer() {
|
||||
if (this.idleTimer) {
|
||||
clearTimeout(this.idleTimer);
|
||||
}
|
||||
},
|
||||
onKeyup() {
|
||||
if (!this.idleTimer) {
|
||||
this.$emit('typing-on');
|
||||
}
|
||||
this.turnOffIdleTimer();
|
||||
this.idleTimer = setTimeout(
|
||||
() => this.resetTyping(),
|
||||
TYPING_INDICATOR_IDLE_TIME
|
||||
);
|
||||
this.typingIndicator.start();
|
||||
},
|
||||
onBlur() {
|
||||
this.turnOffIdleTimer();
|
||||
this.resetTyping();
|
||||
this.typingIndicator.stop();
|
||||
this.$emit('blur');
|
||||
},
|
||||
onFocus() {
|
||||
|
||||
@@ -28,11 +28,10 @@ export const CONVERSATION_PRIORITY = {
|
||||
};
|
||||
|
||||
export const CONVERSATION_PRIORITY_ORDER = {
|
||||
urgent: 1,
|
||||
high: 2,
|
||||
medium: 3,
|
||||
low: 4,
|
||||
null: 5,
|
||||
urgent: 4,
|
||||
high: 3,
|
||||
medium: 2,
|
||||
low: 1,
|
||||
};
|
||||
|
||||
// Size in mega bytes
|
||||
|
||||
@@ -2,7 +2,7 @@ import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import format from 'date-fns/format';
|
||||
import isToday from 'date-fns/isToday';
|
||||
import isYesterday from 'date-fns/isYesterday';
|
||||
import { endOfDay, getUnixTime, startOfDay } from 'date-fns';
|
||||
import { endOfDay, getUnixTime, startOfDay, differenceInDays } from 'date-fns';
|
||||
|
||||
export const formatUnixDate = (date, dateFormat = 'MMM dd, yyyy') => {
|
||||
const unixDate = fromUnixTime(date);
|
||||
@@ -45,3 +45,8 @@ export const generateRelativeTime = (value, unit, languageCode) => {
|
||||
});
|
||||
return rtf.format(value, unit);
|
||||
};
|
||||
|
||||
export const getDayDifferenceFromNow = (now, timestampInSeconds) => {
|
||||
const date = new Date(timestampInSeconds * 1000);
|
||||
return differenceInDays(now, date);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
formatDigitToString,
|
||||
isTimeAfter,
|
||||
generateRelativeTime,
|
||||
getDayDifferenceFromNow,
|
||||
} from '../DateHelper';
|
||||
|
||||
describe('#DateHelper', () => {
|
||||
@@ -120,3 +121,25 @@ describe('generateRelativeTime', () => {
|
||||
expect(actualResult).toBe(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getDayDifferenceFromNow', () => {
|
||||
it('should return the difference if in same day', () => {
|
||||
const now = new Date('2023-12-08T00:00:00.000Z');
|
||||
const timestampInSeconds = 1702020305; // 08/12/2023, 12:55:05 (GMT+05:30)
|
||||
const expectedResult = 0;
|
||||
|
||||
const actualResult = getDayDifferenceFromNow(now, timestampInSeconds);
|
||||
|
||||
expect(actualResult).toBe(expectedResult);
|
||||
});
|
||||
|
||||
it('should return the difference if in different day', () => {
|
||||
const now = new Date('2023-12-11T00:00:00.000Z');
|
||||
const timestampInSeconds = 1702020305; // 08/12/2023, 12:55:05 (GMT+05:30)
|
||||
const expectedResult = 2;
|
||||
|
||||
const actualResult = getDayDifferenceFromNow(now, timestampInSeconds);
|
||||
|
||||
expect(actualResult).toBe(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 19 KiB |
@@ -19,11 +19,7 @@
|
||||
<reply-to-chip :reply-to="replyTo" />
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<drag-wrapper
|
||||
class="space-y-2"
|
||||
direction="right"
|
||||
@dragged="toggleReply"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<AgentMessageBubble
|
||||
v-if="shouldDisplayAgentMessage"
|
||||
:content-type="contentType"
|
||||
@@ -54,7 +50,7 @@
|
||||
<file-bubble v-else :url="attachment.data_url" />
|
||||
</div>
|
||||
</div>
|
||||
</drag-wrapper>
|
||||
</div>
|
||||
<div class="flex flex-col justify-end">
|
||||
<message-reply-button
|
||||
class="transition-opacity delay-75 opacity-0 group-hover:opacity-100 sm:opacity-0"
|
||||
@@ -96,7 +92,6 @@ import messageMixin from '../mixins/messageMixin';
|
||||
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
|
||||
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
|
||||
import ReplyToChip from 'widget/components/ReplyToChip.vue';
|
||||
import DragWrapper from 'widget/components/DragWrapper.vue';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
export default {
|
||||
@@ -109,7 +104,6 @@ export default {
|
||||
FileBubble,
|
||||
MessageReplyButton,
|
||||
ReplyToChip,
|
||||
DragWrapper,
|
||||
},
|
||||
mixins: [timeMixin, configMixin, messageMixin, darkModeMixin],
|
||||
props: {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="message-wrap mt-2">
|
||||
<div
|
||||
class="typing-bubble chat-bubble agent"
|
||||
:class="$dm('bg-white', 'dark:bg-slate-50')"
|
||||
:class="$dm('bg-white', 'dark:bg-slate-700')"
|
||||
>
|
||||
<img
|
||||
src="~widget/assets/images/typing.gif"
|
||||
|
||||
@@ -4,9 +4,21 @@ class Internal::CheckNewVersionsJob < ApplicationJob
|
||||
def perform
|
||||
return unless Rails.env.production?
|
||||
|
||||
latest_version = ChatwootHub.latest_version
|
||||
return unless latest_version
|
||||
instance_info = ChatwootHub.sync_with_hub
|
||||
return unless instance_info
|
||||
|
||||
::Redis::Alfred.set(::Redis::Alfred::LATEST_CHATWOOT_VERSION, latest_version)
|
||||
::Redis::Alfred.set(::Redis::Alfred::LATEST_CHATWOOT_VERSION, instance_info['version'])
|
||||
update_installation_config(key: 'INSTALLATION_PRICING_PLAN', value: instance_info['plan'])
|
||||
update_installation_config(key: 'INSTALLATION_PRICING_PLAN_QUANTITY', value: instance_info['plan_quantity'])
|
||||
update_installation_config(key: 'CHATWOOT_SUPPORT_WEBSITE_TOKEN', value: instance_info['chatwoot_support_website_token'])
|
||||
update_installation_config(key: 'CHATWOOT_SUPPORT_IDENTIFIER_HASH', value: instance_info['chatwoot_support_identifier_hash'])
|
||||
update_installation_config(key: 'CHATWOOT_SUPPORT_SCRIPT_URL', value: instance_info['chatwoot_support_script_url'])
|
||||
end
|
||||
|
||||
def update_installation_config(key:, value:)
|
||||
config = InstallationConfig.find_or_initialize_by(name: key)
|
||||
config.value = value
|
||||
config.locked = true
|
||||
config.save!
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
class Migration::ConversationBatchCacheLabelJob < ApplicationJob
|
||||
queue_as :async_database_migration
|
||||
|
||||
# To cache the label, we simply access it from the object and save it. Anytime the object is
|
||||
# saved in the future, ActsAsTaggable will automatically recompute it. This process is done
|
||||
# initially when the user has not performed any action.
|
||||
# Reference: https://github.com/mbleigh/acts-as-taggable-on/wiki/Caching
|
||||
def perform(conversation_batch)
|
||||
conversation_batch.each do |conversation|
|
||||
conversation.label_list
|
||||
conversation.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class Migration::ConversationCacheLabelJob < ApplicationJob
|
||||
queue_as :async_database_migration
|
||||
|
||||
def perform(account)
|
||||
account.conversations.find_in_batches(batch_size: 100) do |conversation_batch|
|
||||
Migration::ConversationBatchCacheLabelJob.perform_later(conversation_batch)
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user