Compare commits

..
Author SHA1 Message Date
Muhsin Keloth a68e736cfc Fix STORAGE_BUSY error in firefox 2023-06-15 16:30:48 +05:30
Sivin VargheseandGitHub b1828d462d Merge branch 'develop' into feat/CW-1873 2023-06-15 13:01:29 +05:30
iamsivin 5efe2f6599 chore: Minor fixes 2023-06-15 12:58:26 +05:30
Shivam MishraandGitHub 9985f1c143 refactor: remove redundant taggable declaration (#7310) 2023-06-15 12:57:56 +05:30
iamsivin b3585b5001 feat: Attachment view improvements 2023-06-15 12:50:27 +05:30
Tejaswini ChileandGitHub 1ee6a8fe90 chore: Add validation for processed content field (#7306)
* logging the messages id for message validation exception

* Update the processed_message_content validation over length

* codeclimate

* specs failing for contacts
2023-06-14 18:28:42 +05:30
Muhsin KelothandGitHub 6eb4fa41ff feat: Add sender details and message create date in the attachment API (#7308) 2023-06-14 17:29:13 +05:30
8b91f0ffac fix: Gemfile.lock to reduce vulnerabilities (#7280)
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-RUBY-RACK-1061917

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-06-14 15:40:24 +05:30
d2aa19579e feat: Adds support for superscript in help center articles (#7279)
- Adds support for superscript when rendering article markdown
- Chatwoot Markdown Render to render markdown everywhere

Co-authored-by: Sojan <sojan@pepalo.com>
2023-06-14 15:39:00 +05:30
Shivam MishraandGitHub 2e79a32db7 fix: calculation for resolution count (#7293)
* fix: calculation for resolution count

* test: resolution count bug fix

- ensure enqueued jobs are run
- fix the dates check, conversations resolved today should show up in today

* feat: ensure conversations are resolved

* test: do not count extra events if the conversation is not resolved currently

* fix: typo
2023-06-14 13:50:10 +05:30
Shivam MishraandGitHub a86e236d19 feat: update cache headers for cache_keys (#7283)
Update the cache headers for cache_keys to max-age=10, private, stale-while-revalidate=300

1. The cache will be fresh for 10 seconds (max-age=10). During this time, the browser will use the cached version without checking with the server.
2. After the initial 10 seconds, the browser can continue to serve the stale cache for up to 5 minutes (stale-while-revalidate=300). During this period, it will also try to revalidate and update the cache in the background.
3. After 310 seconds in total (10 seconds fresh, 300 seconds stale), if the browser has not been able to revalidate the cache, it will attempt to fetch the fresh resource directly from the server for subsequent requests, causing potential latency equivalent to a network request.

This means that the data will be directly revalidated only every 5 mins. Other times, it will stay fresh for 10 seconds and revalidate in the background. In most cases, we won't have to rely on a cache validation check because there is a WebSocket event for revalidation, so we know if something changes.

Right now the stale-while-revalidate is 5 minutes, we can then move it to 15 minutes.

> The stale-while-revalidate header is not supported in Safari, for Safari the cache keys will only stay in memory for 10 seconds before being marked stale
2023-06-14 13:21:51 +05:30
c755978352 feat: update banner design [CW-1491] (#7296)
* refactor: banner styling

* refactor: update button style for reply assign banner

* feat: remove update-banner classes

---------

Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
2023-06-14 12:56:42 +05:30
Tejaswini ChileandGitHub 23ca6d56f9 feat: Contact Exports (#7258) 2023-06-13 09:18:43 +05:30
Shivam MishraandGitHub 429ec7194f feat: add index to reporting events [CW-1960] (#7294)
* fix: annotation

* feat: add better index for reporting_events
2023-06-12 18:23:42 +05:30
Tejaswini ChileandGitHub c99d9f9557 fix: search for nil in-reply-to messages (#7286) 2023-06-12 16:01:56 +05:30
Vishnu NarayananandGitHub 9ff5978d6e feat: Add audit trail events for team (#7285) 2023-06-09 17:30:36 -07:00
Muhsin KelothandGitHub cd28f401ba fix: Custom attribute date is not working in some time zone (#7224) 2023-06-09 16:55:03 -07:00
Tejaswini ChileandGitHub 879a244f93 fix: Automations condition based quoted text (#7272) 2023-06-09 17:00:05 +05:30
Tejaswini ChileandGitHub f64f2138db fix: Update mail check for html_part (#7273) 2023-06-09 16:08:40 +05:30
Sojan JoseandGitHub 48f2e58e59 feat: Ability to update avatars from super admin (#7264)
- Ability to update user avatars from super admin
- Ability to update bot avatars from super admin

fixes: #7060
2023-06-09 15:32:24 +05:30
c715e396f0 feat: added input_select type message support for whatsapp (#6886)
- Added input_select message type support for Whatsapp Cloud API and Whatsapp 360dialog.

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-06-09 15:20:45 +05:30
Pranav Raj SandGitHub c8fac08e0b fix: Cannot read properties of undefined (reading 'some') (#7278)
Fixes https://linear.app/chatwoot/issue/CW-2041/typeerror-cannot-read-properties-of-undefined-reading-some
Fixes https://linear.app/chatwoot/issue/CW-2042/typeerror-cannot-read-properties-of-undefined-reading-filter
2023-06-08 20:12:14 -07:00
7fd220c177 feat: Adds the ability to edit saved segments (#7254)
* feat: Ability to edit saved filters

* chore: Adds edit contact segment

* chore: Minor fixes

* fix: code climate

* chore: Minor fixes

* chore: Adds ability to custom view name

* chore: Minor fixes

* chore: Adds spec for helper

* chore: Revert contact filter to split to new PR

* Delete editSegmentMixin.js

* chore: Revert fixes

* Update app/javascript/dashboard/i18n/locale/en/advancedFilters.json

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>

* Update app/javascript/dashboard/i18n/locale/en/advancedFilters.json

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>

* chore: Moved from mixin to helper for reusing in segments

* Delete editFolderMixin.js

* chore: Fix specs and added new specs

* chore: review comment fixes

* chore: Minor fixes

* fix: Not resetting applied filter

* feat: Adds the ability to edit saved segments

* feat: Adds specs for API part

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2023-06-08 21:25:51 +05:30
Pranav Raj SandGitHub b3b76d00ff fix: Do not use the default value if the out of office message is empty (#7268) 2023-06-08 08:22:10 -07:00
Shivam MishraandGitHub 2f2ae88cba fix: unattended count mismatch in report and list (#7263) 2023-06-08 17:58:13 +05:30
Tejaswini ChileandGitHub 2b7989f52b fix: reply mailer with sender name (#7232) 2023-06-08 17:07:08 +05:30
d7314079c9 feat: Ability to edit saved folders (#7236)
* feat: Ability to edit saved filters

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2023-06-08 14:58:57 +05:30
1017903ee1 fix: Adds domain option to user cookies set by SDK [CW-352] (#7070)
* fix: Adds domain option to user cookies set by SDK

* Adds domain to init event from chatwootSettings variable

* Testing multiple domains on heroku

* Updates with sdk from staging

* Removes sdk init code

* Testing why cookie is not getting set

* Cleans up testing code

* Refactors code to fix codeclimate issues

* Update app/javascript/sdk/cookieHelpers.js

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>

* Adds test cases for setCookieWithDomain

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2023-06-08 14:27:49 +05:30
Tejaswini ChileandGitHub d25e7fd54e fix: add email logging for source-id email-id and message-id (#7262)
* fix: mail logger for source-id email-id and message-id

* added mail provided to the tracking

* change the logger verb from info to error

* app/jobs/inboxes/fetch_imap_emails_job.rb

* codeclimate fix

* remove extra filter

* fixes: specs
2023-06-07 18:24:37 +05:30
Sojan JoseandGitHub 7e89e3a34a chore: Ensure template_sync timestamp is updated (#7265)
Fix for the cases where there are multiple channels with invalid provider config, which results in the templates sync scheduler failing to schedule jobs for valid channels.

fixes: https://linear.app/chatwoot/issue/CW-2032/bug-whatsapp-template-sync-failing-in-cloud
2023-06-07 17:18:24 +05:30
Pranav Raj SandGitHub 39eaed7766 fix: Cannot read properties of undefined (reading 'always_play_audio_alert') (#7260) 2023-06-06 15:16:25 -07:00
Pranav Raj SandGitHub b1a4907ede fix: around_action not yielding is params are present (#7259) 2023-06-06 14:55:08 -07:00
a01d81a7e1 feat: Update display text for auditlog entries (#7226)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-06-05 12:51:45 -07:00
Sojan JoseandGitHub 117d5301b4 chore: API documentation updates for CSAT surveys (#7255)
- Expose conversation uuid in APIs
- swagger documentation for cast survey update via public/message/update endpoints
- swagger documentation for survey/responses/conversation_uuid endpoint

Fixes: #6328
2023-06-05 21:14:01 +05:30
Tejaswini ChileandGitHub aae6081d73 fix: check content disposition, for inline messages in mail (#7231) 2023-06-05 20:28:32 +05:30
Sojan JoseandGitHub d03924b846 feat: Enable sending template messages in webhooks (#7252)
fixes: https://github.com/chatwoot/chatwoot/issues/5291
2023-06-05 20:27:28 +05:30
b333d0c986 feat: Attachments view (#7156)
* feat: Attachments view with key shortcuts and dynamically updates when user delete or sent new attachments

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-06-05 19:21:47 +05:30
Shivam MishraandGitHub 9f3d155822 feat: Add size limit action (#7253)
* chore: update browserlist

* feat: add size limit action

* feat: set limits

* feat: add size limit in CI

* chore: remove railsenv

* chore: update limits
2023-06-05 18:56:15 +05:30
TOMMYandGitHub 9203b097d2 chore: Show chatwoot_edition in Instance details (#7200)
- Displays the chatwoot edition in the instance details
2023-06-05 17:32:21 +05:30
Marcel StörandGitHub 12cb97db5f fix: Quote database env variables (#7216)
Database credentials, and passwords, in particular, can easily contain characters that are reserved in YAML and must be quoted. 
Example: a case with a password starting with ']'.
2023-06-05 14:19:35 +05:30
Tejaswini ChileandGitHub 09971fd613 fix: Wrap references string into array (#7243) 2023-06-03 07:33:36 +05:30
Pranav Raj SandGitHub ca9d108741 fix: Update documentation of filter APIs (#7245) 2023-06-02 15:36:41 -07:00
Shivam MishraandGitHub f28533bc47 feat(perf): update query to reduce N+1 impact [CW-1926] (#7228)
* feat: update query to reduce N+1 impact

* feat: remove channel eager loading

fixes the error: ActiveRecord::EagerLoadPolymorphicError Exception: Cannot eagerly load the polymorphic association :channel

* test: fix search test

* chore: revert change
2023-06-02 11:11:55 +05:30
Pranav Raj SandGitHub 68f82c11a5 Revert "fix: Updates editor to fix the extra slash issue [cw-1505][cw-1526] (#7091)" (#7238)
This reverts commit 1d0930ef63.
2023-06-01 13:26:02 -07:00
156 changed files with 9686 additions and 663 deletions
+38
View File
@@ -0,0 +1,38 @@
name: Run Size Limit Check
on:
pull_request:
branches:
- develop
jobs:
test:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: actions/setup-node@v3
with:
node-version: 16
- name: yarn
run: yarn install
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Run asset compile
run: bundle exec rake assets:precompile
- name: Size Check
run: yarn run size
+1
View File
@@ -75,6 +75,7 @@ gem 'jwt'
gem 'pundit'
# super admin
gem 'administrate'
gem 'administrate-field-active_storage'
##--- gems for pubsub service ---##
# https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/
+73 -68
View File
@@ -33,70 +33,70 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.4.3)
actionpack (= 7.0.4.3)
activesupport (= 7.0.4.3)
actioncable (7.0.5)
actionpack (= 7.0.5)
activesupport (= 7.0.5)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.4.3)
actionpack (= 7.0.4.3)
activejob (= 7.0.4.3)
activerecord (= 7.0.4.3)
activestorage (= 7.0.4.3)
activesupport (= 7.0.4.3)
actionmailbox (7.0.5)
actionpack (= 7.0.5)
activejob (= 7.0.5)
activerecord (= 7.0.5)
activestorage (= 7.0.5)
activesupport (= 7.0.5)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.4.3)
actionpack (= 7.0.4.3)
actionview (= 7.0.4.3)
activejob (= 7.0.4.3)
activesupport (= 7.0.4.3)
actionmailer (7.0.5)
actionpack (= 7.0.5)
actionview (= 7.0.5)
activejob (= 7.0.5)
activesupport (= 7.0.5)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.4.3)
actionview (= 7.0.4.3)
activesupport (= 7.0.4.3)
rack (~> 2.0, >= 2.2.0)
actionpack (7.0.5)
actionview (= 7.0.5)
activesupport (= 7.0.5)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.4.3)
actionpack (= 7.0.4.3)
activerecord (= 7.0.4.3)
activestorage (= 7.0.4.3)
activesupport (= 7.0.4.3)
actiontext (7.0.5)
actionpack (= 7.0.5)
activerecord (= 7.0.5)
activestorage (= 7.0.5)
activesupport (= 7.0.5)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.4.3)
activesupport (= 7.0.4.3)
actionview (7.0.5)
activesupport (= 7.0.5)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (7.0.4.3)
activesupport (= 7.0.4.3)
activejob (7.0.5)
activesupport (= 7.0.5)
globalid (>= 0.3.6)
activemodel (7.0.4.3)
activesupport (= 7.0.4.3)
activerecord (7.0.4.3)
activemodel (= 7.0.4.3)
activesupport (= 7.0.4.3)
activemodel (7.0.5)
activesupport (= 7.0.5)
activerecord (7.0.5)
activemodel (= 7.0.5)
activesupport (= 7.0.5)
activerecord-import (1.4.1)
activerecord (>= 4.2)
activestorage (7.0.4.3)
actionpack (= 7.0.4.3)
activejob (= 7.0.4.3)
activerecord (= 7.0.4.3)
activesupport (= 7.0.4.3)
activestorage (7.0.5)
actionpack (= 7.0.5)
activejob (= 7.0.5)
activerecord (= 7.0.5)
activesupport (= 7.0.5)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.4.3)
activesupport (7.0.5)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -113,6 +113,9 @@ GEM
kaminari (>= 1.0)
sassc-rails (~> 2.1)
selectize-rails (~> 0.6)
administrate-field-active_storage (0.4.2)
administrate (>= 0.2.2)
rails (>= 7.0)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
@@ -353,7 +356,7 @@ GEM
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.13.0)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
@@ -420,9 +423,9 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.20.0)
loofah (2.21.3)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
nokogiri (>= 1.12.0)
mail (2.8.1)
mini_mime (>= 0.1.1)
net-imap
@@ -462,14 +465,14 @@ GEM
sidekiq
newrelic_rpm (8.16.0)
nio4r (2.5.9)
nokogiri (1.14.3)
mini_portile2 (~> 2.8.0)
nokogiri (1.15.2)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.14.3-arm64-darwin)
nokogiri (1.15.2-arm64-darwin)
racc (~> 1.4)
nokogiri (1.14.3-x86_64-darwin)
nokogiri (1.15.2-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.14.3-x86_64-linux)
nokogiri (1.15.2-x86_64-linux)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -522,7 +525,7 @@ GEM
pundit (2.3.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.6.2)
racc (1.7.0)
rack (2.2.7)
rack-attack (6.6.1)
rack (>= 1.0, < 3)
@@ -537,28 +540,29 @@ GEM
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (7.0.4.3)
actioncable (= 7.0.4.3)
actionmailbox (= 7.0.4.3)
actionmailer (= 7.0.4.3)
actionpack (= 7.0.4.3)
actiontext (= 7.0.4.3)
actionview (= 7.0.4.3)
activejob (= 7.0.4.3)
activemodel (= 7.0.4.3)
activerecord (= 7.0.4.3)
activestorage (= 7.0.4.3)
activesupport (= 7.0.4.3)
rails (7.0.5)
actioncable (= 7.0.5)
actionmailbox (= 7.0.5)
actionmailer (= 7.0.5)
actionpack (= 7.0.5)
actiontext (= 7.0.5)
actionview (= 7.0.5)
activejob (= 7.0.5)
activemodel (= 7.0.5)
activerecord (= 7.0.5)
activestorage (= 7.0.5)
activesupport (= 7.0.5)
bundler (>= 1.15.0)
railties (= 7.0.4.3)
railties (= 7.0.5)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.5.0)
loofah (~> 2.19, >= 2.19.1)
railties (7.0.4.3)
actionpack (= 7.0.4.3)
activesupport (= 7.0.4.3)
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.0.5)
actionpack (= 7.0.5)
activesupport (= 7.0.5)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -722,8 +726,8 @@ GEM
stripe (8.5.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.2.1)
tilt (2.1.0)
thor (1.2.2)
tilt (2.2.0)
time_diff (0.3.0)
activesupport
i18n
@@ -798,6 +802,7 @@ DEPENDENCIES
activerecord-import
acts-as-taggable-on
administrate
administrate-field-active_storage
annotate
attr_extras
audited (~> 5.3)
@@ -916,4 +921,4 @@ RUBY VERSION
ruby 3.2.2p185
BUNDLED WITH
2.4.10
2.4.6
+1
View File
@@ -1,5 +1,6 @@
//= link_tree ../images
//= link administrate/application.css
//= link administrate/application.js
//= link administrate-field-active_storage/application.css
//= link dashboardChart.js
//= link secretField.js
+1 -2
View File
@@ -96,10 +96,9 @@ class V2::ReportBuilder
def conversations
@open_conversations = scope.conversations.where(account_id: @account.id).open
first_response_count = @account.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count
metric = {
open: @open_conversations.count,
unattended: @open_conversations.count - first_response_count
unattended: @open_conversations.unattended.count
}
metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account)
metric
@@ -42,6 +42,12 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
head :ok
end
def export
column_names = params['column_names']
Account::ContactsExportJob.perform_later(Current.account.id, column_names)
head :ok, message: I18n.t('errors.contacts.export.success')
end
# returns online contacts
def active
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
@@ -37,6 +37,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def cache_keys
expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes
render json: { cache_keys: get_cache_keys }, status: :ok
end
@@ -43,8 +43,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end
def render_article_content(content)
# rubocop:disable Rails/OutputSafety
CommonMarker.render_html(content).html_safe
# rubocop:enable Rails/OutputSafety
ChatwootMarkdownRenderer.new(content).render_article
end
end
@@ -6,6 +6,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
def set_locale(&)
switch_locale_with_portal(&) if params[:locale].present?
switch_locale_with_article(&) if params[:article_slug].present?
yield
end
def switch_locale_with_portal(&)
@@ -41,4 +41,14 @@ class SuperAdmin::AgentBotsController < SuperAdmin::ApplicationController
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
# for more information
def destroy_avatar
avatar = requested_resource.avatar
avatar.purge
redirect_back(fallback_location: super_admin_agent_bots_path)
end
def scoped_resource
resource_class.with_attached_avatar
end
end
@@ -5,6 +5,17 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
sha
postgres_status
redis_metrics
chatwoot_edition
end
def chatwoot_edition
@metrics['Chatwoot edition'] = if ChatwootApp.enterprise?
'Enterprise'
elsif ChatwootApp.custom?
'Custom'
else
'Community'
end
end
def chatwoot_version
@@ -45,6 +45,17 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController
# empty values into nil values. It uses other APIs such as `resource_class`
# and `dashboard`:
#
def destroy_avatar
avatar = requested_resource.avatar
avatar.purge
redirect_back(fallback_location: super_admin_users_path)
end
def scoped_resource
resource_class.with_attached_avatar
end
def resource_params
permitted_params = super
permitted_params.delete(:password) if permitted_params[:password].blank?
+7
View File
@@ -10,6 +10,11 @@ class AgentBotDashboard < Administrate::BaseDashboard
ATTRIBUTE_TYPES = {
access_token: Field::HasOne,
avatar_url: AvatarField,
avatar: Field::ActiveStorage.with_options(
destroy_url: proc do |_namespace, _resource, attachment|
[:avatar_super_admin_agent_bot, { attachment_id: attachment.id }]
end
),
id: Field::Number,
name: Field::String,
account: Field::BelongsTo.with_options(searchable: true, searchable_field: 'name', order: 'id DESC'),
@@ -36,6 +41,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
# an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = %i[
id
avatar_url
account
name
description
@@ -47,6 +53,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
# on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[
name
avatar
account
description
outgoing_url
+6
View File
@@ -11,6 +11,11 @@ class UserDashboard < Administrate::BaseDashboard
account_users: Field::HasMany,
id: Field::Number,
avatar_url: AvatarField,
avatar: Field::ActiveStorage.with_options(
destroy_url: proc do |_namespace, _resource, attachment|
[:avatar_super_admin_user, { attachment_id: attachment.id }]
end
),
provider: Field::String,
uid: Field::String,
password: Field::Password,
@@ -69,6 +74,7 @@ class UserDashboard < Administrate::BaseDashboard
# on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[
name
avatar
display_name
email
password
+4 -4
View File
@@ -102,7 +102,7 @@ class ConversationFinder
when 'participating'
@conversations = current_user.participating_conversations.where(account_id: current_account.id)
when 'unattended'
@conversations = @conversations.where(first_reply_created_at: nil)
@conversations = @conversations.unattended
end
@conversations
end
@@ -155,9 +155,9 @@ class ConversationFinder
end
def conversations
@conversations = @conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
)
@conversations = @conversations.includes(:taggings, :inbox,
{ assignee: [{ account_users: [:account] }, { avatar_attachment: [:blob] }] },
{ contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox, :messages)
sort_by = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['latest']
@conversations.send(sort_by).page(current_page)
end
+1 -3
View File
@@ -7,8 +7,6 @@ module MessageFormatHelper
end
def render_message_content(message_content)
# rubocop:disable Rails/OutputSafety
CommonMarker.render_html(message_content).html_safe
# rubocop:enable Rails/OutputSafety
ChatwootMarkdownRenderer.new(message_content).render_message
end
end
+3 -1
View File
@@ -29,7 +29,9 @@ module ReportHelper
end
def resolutions_count
(get_grouped_values scope.conversations.where(account_id: account.id).resolved).count
object_scope = scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_resolved,
conversations: { status: :resolved }).distinct
(get_grouped_values object_scope).count
end
def avg_first_response_time
-5
View File
@@ -115,11 +115,6 @@ export default {
<style lang="scss">
@import './assets/scss/app';
.update-banner {
height: var(--space-larger);
align-items: center;
font-size: var(--font-size-small) !important;
}
</style>
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
+4
View File
@@ -75,6 +75,10 @@ class ContactAPI extends ApiClient {
destroyAvatar(contactId) {
return axios.delete(`${this.url}/${contactId}/avatar`);
}
exportContacts() {
return axios.get(`${this.url}/export`);
}
}
export default new ContactAPI();
@@ -127,6 +127,10 @@ class ConversationApi extends ApiClient {
user_ids: userIds,
});
}
getAllAttachments(conversationId) {
return axios.get(`${this.url}/${conversationId}/attachments`);
}
}
export default new ConversationApi();
@@ -210,5 +210,12 @@ describe('#ConversationAPI', () => {
{ params: { page: payload.page } }
);
});
it('#getAllAttachments', () => {
conversationAPI.getAllAttachments(1);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/conversations/1/attachments'
);
});
});
});
@@ -29,13 +29,13 @@
}
.modal-image {
max-height: 90%;
max-width: 90%;
max-height: 80vh;
max-width: 80vw;
}
.modal-video {
max-height: 75vh;
max-width: 100%;
max-height: 80vh;
max-width: 80vw;
}
&::before {
@@ -53,16 +53,6 @@
width: 100%;
}
}
.video {
.modal-container {
width: auto;
.modal--close {
z-index: var(--z-index-low);
}
}
}
}
.conversations-list-wrap {
@@ -400,4 +390,3 @@
margin-bottom: 0;
}
}
@@ -47,6 +47,14 @@
/>
</div>
<div v-if="hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="onToggleAdvanceFiltersModal"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
size="tiny"
@@ -168,8 +176,11 @@
v-if="showAdvancedFilters"
:initial-filter-types="advancedFilterTypes"
:initial-applied-filters="appliedFilter"
:active-folder-name="activeFolderName"
:on-close="closeAdvanceFiltersModal"
:is-folder-view="hasActiveFolders"
@applyFilter="onApplyFilter"
@updateFolder="onUpdateSavedFilter"
/>
</woot-modal>
</div>
@@ -193,6 +204,9 @@ import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCust
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import alertMixin from 'shared/mixins/alertMixin';
import filterMixin from 'shared/mixins/filterMixin';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
import {
hasPressedAltAndJKey,
@@ -289,6 +303,11 @@ export default {
appliedFilters: 'getAppliedConversationFilters',
folders: 'customViews/getCustomViews',
inboxes: 'inboxes/getInboxes',
agentList: 'agents/getAgents',
teamsList: 'teams/getTeams',
inboxesList: 'inboxes/getInboxes',
campaigns: 'campaigns/getAllCampaigns',
labels: 'labels/getLabels',
}),
hasAppliedFilters() {
return this.appliedFilters.length !== 0;
@@ -451,6 +470,9 @@ export default {
}
return undefined;
},
activeFolderName() {
return this.activeFolder?.name;
},
activeTeam() {
if (this.teamId) {
return this.$store.getters['teams/getTeam'](this.teamId);
@@ -483,9 +505,7 @@ export default {
this.resetAndFetchData();
},
activeFolder() {
if (!this.hasAppliedFilters) {
this.resetAndFetchData();
}
this.resetAndFetchData();
},
chatLists() {
this.chatsOnView = this.conversationList;
@@ -496,6 +516,10 @@ export default {
this.$store.dispatch('setChatSortFilter', this.activeSortBy);
this.resetAndFetchData();
if (this.hasActiveFolders) {
this.$store.dispatch('campaigns/get');
}
bus.$on('fetch_conversation_stats', () => {
this.$store.dispatch('conversationStats/get', this.conversationFilters);
});
@@ -508,6 +532,15 @@ export default {
this.$store.dispatch('emptyAllConversations');
this.fetchFilteredConversations(payload);
},
onUpdateSavedFilter(payload, folderName) {
const payloadData = {
...this.activeFolder,
name: folderName,
query: filterQueryGenerator(payload),
};
this.$store.dispatch('customViews/update', payloadData);
this.closeAdvanceFiltersModal();
},
onClickOpenAddFoldersModal() {
this.showAddFoldersModal = true;
},
@@ -521,15 +554,70 @@ export default {
this.showDeleteFoldersModal = false;
},
onToggleAdvanceFiltersModal() {
if (!this.hasAppliedFilters) {
if (!this.hasAppliedFilters && !this.hasActiveFolders) {
this.initializeExistingFilterToModal();
}
if (this.hasActiveFolders) {
this.initializeFolderToFilterModal(this.activeFolder);
}
this.showAdvancedFilters = true;
},
closeAdvanceFiltersModal() {
this.showAdvancedFilters = false;
this.appliedFilter = [];
},
setParamsForEditFolderModal() {
// Here we are setting the params for edit folder modal to show the existing values.
// For agent, team, inboxes,and campaigns we get only the id's from the query.
// So we are mapping the id's to the actual values.
// For labels we get the name of the label from the query.
// If we delete the label from the label list then we will not be able to show the label name.
// For custom attributes we get only attribute key.
// So we are mapping it to find the input type of the attribute to show in the edit folder modal.
const params = {
agents: this.agentList,
teams: this.teamsList,
inboxes: this.inboxesList,
labels: this.labels,
campaigns: this.campaigns,
languages: languages,
countries: countries,
filterTypes: advancedFilterTypes,
allCustomAttributes: this.$store.getters[
'attributes/getAttributesByModel'
]('conversation_attribute'),
};
return params;
},
initializeFolderToFilterModal(activeFolder) {
// Here we are setting the params for edit folder modal.
// To show the existing values. when we click on edit folder button.
// Here we get the query from the active folder.
// And we are mapping the query to the actual values.
// To show in the edit folder modal by the help of generateValuesForEditCustomViews helper.
const query = activeFolder?.query?.payload;
if (!Array.isArray(query)) return;
this.appliedFilter.push(
...query.map(filter => ({
attribute_key: filter.attribute_key,
attribute_model: filter.attribute_model,
filter_operator: filter.filter_operator,
values: Array.isArray(filter.values)
? generateValuesForEditCustomViews(
filter,
this.setParamsForEditFolderModal()
)
: [],
query_operator: filter.query_operator,
custom_attribute_type: filter.custom_attribute_type,
}))
);
},
getKeyboardListenerParams() {
const allConversations = this.$refs.activeConversation.querySelectorAll(
'div.conversations-list div.conversation'
@@ -575,6 +663,7 @@ export default {
}
},
resetAndFetchData() {
this.appliedFilter = [];
this.resetBulkActions();
this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations');
@@ -587,7 +676,6 @@ export default {
return;
}
this.fetchConversations();
this.appliedFilter = [];
},
fetchConversations() {
this.$store
@@ -115,7 +115,7 @@
</template>
<script>
import format from 'date-fns/format';
import { format, parseISO } from 'date-fns';
import { required, url } from 'vuelidate/lib/validators';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
@@ -143,15 +143,20 @@ export default {
},
computed: {
formattedValue() {
displayValue() {
if (this.isAttributeTypeDate) {
return format(new Date(this.value || new Date()), DATE_FORMAT);
return new Date(this.value || new Date()).toLocaleDateString();
}
if (this.isAttributeTypeCheckbox) {
return this.value === 'false' ? false : this.value;
}
return this.value;
},
formattedValue() {
return this.isAttributeTypeDate
? format(this.value ? new Date(this.value) : new Date(), DATE_FORMAT)
: this.value;
},
listOptions() {
return this.values.map((value, index) => ({
id: index + 1,
@@ -192,17 +197,11 @@ export default {
}
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
},
displayValue() {
if (this.attributeType === 'date') {
return format(new Date(this.editedValue), 'dd-MM-yyyy');
}
return this.editedValue;
},
},
watch: {
value() {
this.isEditing = false;
this.editedValue = this.value;
this.editedValue = this.formattedValue;
},
},
@@ -249,9 +248,8 @@ export default {
onUpdate() {
const updatedValue =
this.attributeType === 'date'
? format(new Date(this.editedValue), DATE_FORMAT)
? parseISO(this.editedValue)
: this.editedValue;
this.$v.$touch();
if (this.$v.$invalid) {
return;
@@ -8,6 +8,7 @@
>
<div :class="modalContainerClassName" @click.stop>
<woot-button
v-if="showCloseButton"
color-scheme="secondary"
icon="dismiss"
variant="clear"
@@ -28,6 +29,10 @@ export default {
default: true,
},
show: Boolean,
showCloseButton: {
type: Boolean,
default: true,
},
onClose: {
type: Function,
required: true,
@@ -1,7 +1,6 @@
<template>
<banner
v-if="shouldShowBanner"
class="update-banner"
color-scheme="primary"
:banner-message="bannerMessage"
href-link="https://github.com/chatwoot/chatwoot/releases"
@@ -11,26 +11,29 @@
{{ hrefLinkText }}
</a>
</span>
<woot-button
v-if="hasActionButton"
size="small"
variant="link"
icon="arrow-right"
color-scheme="primary"
class-names="banner-action__button"
@click="onClick"
>
{{ actionButtonLabel }}
</woot-button>
<woot-button
v-if="hasCloseButton"
size="small"
variant="link"
color-scheme="secondary"
icon="dismiss-circle"
class-names="banner-action__button"
@click="onClickClose"
/>
<div class="actions">
<woot-button
v-if="hasActionButton"
size="tiny"
icon="arrow-right"
:variant="actionButtonVariant"
color-scheme="primary"
class-names="banner-action__button"
@click="onClick"
>
{{ actionButtonLabel }}
</woot-button>
<woot-button
v-if="hasCloseButton"
size="tiny"
:color-scheme="colorScheme"
icon="dismiss-circle"
class-names="banner-action__button"
@click="onClickClose"
>
{{ $t('GENERAL_SETTINGS.DISMISS') }}
</woot-button>
</div>
</div>
</template>
@@ -53,6 +56,10 @@ export default {
type: Boolean,
default: false,
},
actionButtonVariant: {
type: String,
default: '',
},
actionButtonLabel: {
type: String,
default: '',
@@ -68,7 +75,12 @@ export default {
},
computed: {
bannerClasses() {
return [this.colorScheme];
const classList = [this.colorScheme, `banner-align-${this.align}`];
if (this.hasActionButton || this.hasCloseButton) {
classList.push('has-button');
}
return classList;
},
},
methods: {
@@ -84,17 +96,26 @@ export default {
<style lang="scss" scoped>
.banner {
--x-padding: var(--space-normal);
--y-padding: var(--space-slab);
display: flex;
gap: var(--x-padding);
color: var(--white);
font-size: var(--font-size-mini);
padding: var(--space-slab) var(--space-normal);
padding: var(--y-padding) var(--x-padding);
justify-content: center;
position: sticky;
&.primary {
background: var(--w-500);
.banner-action__button {
background: var(--w-600);
border: none;
color: var(--white);
&:hover {
background: var(--w-800);
}
}
}
@@ -107,7 +128,16 @@ export default {
}
&.alert {
background: var(--r-400);
background: var(--r-500);
.banner-action__button {
background: var(--r-700);
border: none;
color: var(--white);
&:hover {
background: var(--r-800);
}
}
}
&.warning {
@@ -133,8 +163,6 @@ export default {
}
.banner-action__button {
margin: 0 var(--space-smaller);
::v-deep .button__content {
white-space: nowrap;
}
@@ -144,5 +172,11 @@ export default {
display: flex;
align-items: center;
}
.actions {
display: flex;
gap: var(--space-smaller);
right: var(--y-padding);
}
}
</style>
@@ -1,9 +1,21 @@
<template>
<div class="column">
<woot-modal-header :header-title="$t('FILTER.TITLE')">
<p>{{ $t('FILTER.SUBTITLE') }}</p>
<woot-modal-header :header-title="filterModalHeaderTitle">
<p>{{ filterModalSubTitle }}</p>
</woot-modal-header>
<div class="row modal-content">
<div class="column modal-content">
<div v-if="isFolderView" class="columns">
<label class="input-label" :class="{ error: !activeFolderNewName }">
{{ $t('FILTER.FOLDER_LABEL') }}
<input v-model="activeFolderNewName" type="text" class="name-input" />
<span v-if="!activeFolderNewName" class="message">
{{ $t('FILTER.EMPTY_VALUE_ERROR') }}
</span>
</label>
<label class="input-label">
{{ $t('FILTER.FOLDER_QUERY_LABEL') }}
</label>
</div>
<div class="medium-12 columns filters-wrap">
<filter-input-box
v-for="(filter, i) in appliedFilters"
@@ -42,7 +54,14 @@
<woot-button class="button clear" @click.prevent="onClose">
{{ $t('FILTER.CANCEL_BUTTON_LABEL') }}
</woot-button>
<woot-button @click="submitFilterQuery">
<woot-button
v-if="isFolderView"
:disabled="!activeFolderNewName"
@click="updateSavedCustomViews"
>
{{ $t('FILTER.UPDATE_BUTTON_LABEL') }}
</woot-button>
<woot-button v-else @click="submitFilterQuery">
{{ $t('FILTER.SUBMIT_BUTTON_LABEL') }}
</woot-button>
</div>
@@ -81,6 +100,14 @@ export default {
type: Array,
default: () => [],
},
activeFolderName: {
type: String,
default: '',
},
isFolderView: {
type: Boolean,
default: false,
},
},
validations: {
appliedFilters: {
@@ -107,6 +134,7 @@ export default {
return {
show: true,
appliedFilters: this.initialAppliedFilters,
activeFolderNewName: this.activeFolderName,
filterTypes: this.initialFilterTypes,
filterAttributeGroups,
filterGroups: [],
@@ -119,6 +147,16 @@ export default {
...mapGetters({
getAppliedConversationFilters: 'getAppliedConversationFilters',
}),
filterModalHeaderTitle() {
return !this.isFolderView
? this.$t('FILTER.TITLE')
: this.$t('FILTER.EDIT_CUSTOM_FILTER');
},
filterModalSubTitle() {
return !this.isFolderView
? this.$t('FILTER.SUBTITLE')
: this.$t('FILTER.CUSTOM_VIEWS_SUBTITLE');
},
},
mounted() {
this.setFilterAttributes();
@@ -126,7 +164,7 @@ export default {
if (this.getAppliedConversationFilters.length) {
this.appliedFilters = [];
this.appliedFilters = [...this.getAppliedConversationFilters];
} else {
} else if (!this.isFolderView) {
this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
@@ -177,11 +215,11 @@ export default {
if (key === 'created_at' || key === 'last_activity_at')
if (operator === 'days_before') return 'plain_text';
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.inputType;
return type?.inputType;
},
getOperators(key) {
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.filterOperators;
return type?.filterOperators;
},
getDropdownValues(type) {
const statusFilters = this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS');
@@ -267,11 +305,30 @@ export default {
}
},
appendNewFilter() {
this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
if (this.isFolderView) {
this.setQueryOperatorOnLastQuery();
} else {
this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
}
},
setQueryOperatorOnLastQuery() {
const lastItemIndex = this.appliedFilters.length - 1;
this.appliedFilters[lastItemIndex] = {
...this.appliedFilters[lastItemIndex],
query_operator: 'and',
};
this.$nextTick(() => {
this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
});
},
removeFilter(index) {
@@ -296,6 +353,9 @@ export default {
})),
});
},
updateSavedCustomViews() {
this.$emit('updateFolder', this.appliedFilters, this.activeFolderNewName);
},
resetFilter(index, currentFilter) {
this.appliedFilters[index].filter_operator = this.filterTypes.find(
filter => filter.attributeKey === currentFilter.attribute_key
@@ -322,4 +382,12 @@ export default {
.filter-actions {
margin-top: var(--space-normal);
}
.input-label {
margin-bottom: var(--space-smaller);
.name-input {
width: 50%;
}
}
</style>
@@ -52,23 +52,12 @@
{{ $t('CONVERSATION.UPLOADING_ATTACHMENTS') }}
</span>
<div v-if="!isPending && hasAttachments">
<div v-for="attachment in data.attachments" :key="attachment.id">
<bubble-image
v-if="attachment.file_type === 'image' && !hasImageError"
:url="attachment.data_url"
<div v-for="attachment in attachments" :key="attachment.id">
<bubble-image-audio-video
v-if="isAttachmentImageVideoAudio(attachment.file_type)"
:attachment="attachment"
@error="onImageLoadError"
/>
<audio
v-else-if="attachment.file_type === 'audio'"
controls
class="skip-context-menu"
>
<source :src="`${attachment.data_url}?t=${Date.now()}`" />
</audio>
<bubble-video
v-else-if="attachment.file_type === 'video'"
:url="attachment.data_url"
/>
<bubble-location
v-else-if="attachment.file_type === 'location'"
:latitude="attachment.coordinates_lat"
@@ -144,11 +133,12 @@ import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import BubbleActions from './bubble/Actions';
import BubbleFile from './bubble/File';
import BubbleImage from './bubble/Image';
import BubbleVideo from './bubble/Video';
import BubbleImageAudioVideo from './bubble/ImageAudioVideo';
import BubbleIntegration from './bubble/Integration.vue';
import BubbleLocation from './bubble/Location';
import BubbleMailHead from './bubble/MailHead';
import BubbleText from './bubble/Text';
import BubbleVideo from './bubble/Video.vue';
import BubbleContact from './bubble/Contact';
import Spinner from 'shared/components/Spinner';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu';
@@ -165,11 +155,12 @@ export default {
BubbleActions,
BubbleFile,
BubbleImage,
BubbleVideo,
BubbleImageAudioVideo,
BubbleIntegration,
BubbleLocation,
BubbleMailHead,
BubbleText,
BubbleVideo,
BubbleContact,
ContextMenu,
Spinner,
@@ -208,6 +199,14 @@ export default {
};
},
computed: {
attachments() {
// Here it is used to get sender and created_at for each attachment
return this.data?.attachments.map(attachment => ({
...attachment,
sender: this.data.sender || {},
created_at: this.data.created_at || '',
}));
},
shouldRenderMessage() {
return (
this.hasAttachments ||
@@ -447,6 +446,9 @@ export default {
clearTimeout(this.higlightTimeout);
},
methods: {
isAttachmentImageVideoAudio(fileType) {
return ['image', 'audio', 'video'].includes(fileType);
},
hasMediaAttachment(type) {
if (this.hasAttachments && this.data.attachments.length > 0) {
const { attachments = [{}] } = this.data;
@@ -279,6 +279,7 @@ export default {
if (newChat.id === oldChat.id) {
return;
}
this.fetchAllAttachmentsFromCurrentChat();
this.selectedTweetId = null;
},
},
@@ -290,6 +291,7 @@ export default {
mounted() {
this.addScrollListener();
this.fetchAllAttachmentsFromCurrentChat();
},
beforeDestroy() {
@@ -298,6 +300,9 @@ export default {
},
methods: {
fetchAllAttachmentsFromCurrentChat() {
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
},
removeBusListeners() {
bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
bus.$off(BUS_EVENTS.SET_TWEET_REPLY, this.setSelectedTweet);
@@ -2,6 +2,7 @@
<div class="reply-box" :class="replyBoxClass">
<banner
v-if="showSelfAssignBanner"
action-button-variant="link"
color-scheme="secondary"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
:has-action-button="true"
@@ -0,0 +1,106 @@
<template>
<div class="message-text__wrap" :class="attachmentTypeClasses">
<img
v-if="isImage && !isImageError"
:src="attachment.data_url"
@click="onClick"
@error="onImgError()"
/>
<video
v-if="isVideo"
:src="attachment.data_url"
muted
playsInline
@click="onClick"
/>
<audio v-else-if="isAudio" controls class="skip-context-menu">
<source :src="`${attachment.data_url}?t=${Date.now()}`" />
</audio>
<gallery-view
v-if="show"
:show.sync="show"
:attachment="attachment"
:all-attachments="filteredCurrentChatAttachments"
@error="onImgError()"
@close="onClose"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { hasPressedCommand } from 'shared/helpers/KeyboardHelpers';
import GalleryView from '../components/GalleryView';
const ALLOWED_FILE_TYPES = {
IMAGE: 'image',
VIDEO: 'video',
AUDIO: 'audio',
};
export default {
components: {
GalleryView,
},
props: {
attachment: {
type: Object,
required: true,
},
},
data() {
return {
show: false,
isImageError: false,
};
},
computed: {
...mapGetters({
currentChatAttachments: 'getSelectedChatAttachments',
}),
isImage() {
return this.attachment.file_type === ALLOWED_FILE_TYPES.IMAGE;
},
isVideo() {
return this.attachment.file_type === ALLOWED_FILE_TYPES.VIDEO;
},
isAudio() {
return this.attachment.file_type === ALLOWED_FILE_TYPES.AUDIO;
},
attachmentTypeClasses() {
return {
image: this.isImage,
video: this.isVideo,
};
},
filteredCurrentChatAttachments() {
const attachments = this.currentChatAttachments.filter(attachment =>
['image', 'video', 'audio'].includes(attachment.file_type)
);
return attachments;
},
},
watch: {
attachment() {
this.isImageError = false;
},
},
methods: {
onClose() {
this.show = false;
},
onClick(e) {
if (hasPressedCommand(e)) {
window.open(this.attachment.data_url, '_blank');
return;
}
this.show = true;
},
onImgError() {
this.isImageError = true;
this.$emit('error');
},
},
};
</script>
@@ -0,0 +1,330 @@
<template>
<woot-modal
full-width
:show.sync="show"
:show-close-button="false"
:on-close="onClose"
>
<div v-on-clickaway="onClose" class="gallery-modal--wrap" @click="onClose">
<div class="gallery-modal--header">
<div class="header-info--wrap" @click.stop>
<thumbnail
:username="senderDetails.name"
:src="senderDetails.avatar"
size="40px"
/>
<div class="header-info">
<h3 class="sub-block-title sender-name text-truncate">
<span>{{ senderDetails.name }}</span>
</h3>
<span class="time-stamp">{{ readableTime }}</span>
</div>
</div>
<div class="header-actions" @click.stop>
<woot-button
size="large"
color-scheme="secondary"
variant="clear"
icon="arrow-download"
@click="onClickDownload"
/>
<woot-button
size="large"
color-scheme="secondary"
variant="clear"
icon="dismiss"
@click="onClose"
/>
</div>
</div>
<div class="gallery-modal--body">
<div class="attachment-toggle--button">
<woot-button
v-if="hasMoreThanOneAttachment"
size="large"
variant="smooth"
color-scheme="primary"
icon="chevron-left"
:disabled="activeImageIndex === 0"
@click.stop="
onClickChangeAttachment(
allAttachments[activeImageIndex - 1],
activeImageIndex - 1
)
"
/>
</div>
<div class="attachments-viewer">
<div class="attachment-view">
<img
v-if="isImage"
:key="activeAttachment.message_id"
:src="activeAttachment.data_url"
class="modal-image skip-context-menu"
@click.stop
/>
<video
v-if="isVideo"
:key="activeAttachment.message_id"
:src="activeAttachment.data_url"
controls
playsInline
class="modal-video skip-context-menu"
@click.stop
/>
<audio
v-if="isAudio"
:key="activeAttachment.message_id"
controls
class="skip-context-menu"
@click.stop
>
<source :src="`${activeAttachment.data_url}?t=${Date.now()}`" />
</audio>
</div>
</div>
<div class="attachment-toggle--button">
<woot-button
v-if="hasMoreThanOneAttachment"
size="large"
variant="smooth"
color-scheme="primary"
:disabled="activeImageIndex === allAttachments.length - 1"
icon="chevron-right"
@click.stop="
onClickChangeAttachment(
allAttachments[activeImageIndex + 1],
activeImageIndex + 1
)
"
/>
</div>
</div>
</div>
</woot-modal>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import {
isEscape,
hasPressedArrowLeftKey,
hasPressedArrowRightKey,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import timeMixin from 'dashboard/mixins/time';
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
const ALLOWED_FILE_TYPES = {
IMAGE: 'image',
VIDEO: 'video',
AUDIO: 'audio',
};
export default {
components: {
Thumbnail,
},
mixins: [eventListenerMixins, clickaway, timeMixin],
props: {
show: {
type: Boolean,
required: true,
},
attachment: {
type: Object,
required: true,
},
allAttachments: {
type: Array,
required: true,
},
},
data() {
return {
activeAttachment: {},
activeFileType: '',
activeImageIndex:
this.allAttachments.findIndex(
attachment => attachment.message_id === this.attachment.message_id
) || 0,
};
},
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
}),
hasMoreThanOneAttachment() {
return this.allAttachments.length > 1;
},
readableTime() {
if (!this.activeAttachment.created_at) return '';
return this.messageTimestamp(
this.activeAttachment.created_at,
'LLL d yyyy, h:mm a'
);
},
isImage() {
return this.activeFileType === ALLOWED_FILE_TYPES.IMAGE;
},
isVideo() {
return this.activeFileType === ALLOWED_FILE_TYPES.VIDEO;
},
isAudio() {
return this.activeFileType === ALLOWED_FILE_TYPES.AUDIO;
},
senderDetails() {
const { name, available_name: availableName, avatar_url, thumbnail, id } =
this.activeAttachment?.sender || this.attachment?.sender;
const currentUserID = this.currentUser?.id;
return {
name: currentUserID === id ? 'You' : name || availableName,
avatar: thumbnail || avatar_url,
};
},
},
mounted() {
this.setImageAndVideoSrc(this.attachment);
},
methods: {
onClose() {
this.$emit('close');
},
onClickChangeAttachment(attachment, index) {
if (!attachment) {
return;
}
this.activeImageIndex = index;
this.setImageAndVideoSrc(attachment);
},
setImageAndVideoSrc(attachment) {
const { file_type: type } = attachment;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
this.activeAttachment = attachment;
this.activeFileType = type;
},
onKeyDownHandler(e) {
if (isEscape(e)) {
this.onClose();
} else if (hasPressedArrowLeftKey(e)) {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex - 1],
this.activeImageIndex - 1
);
} else if (hasPressedArrowRightKey(e)) {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex + 1],
this.activeImageIndex + 1
);
}
},
onClickDownload() {
const { file_type: type, data_url: url } = this.activeAttachment;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
const link = document.createElement('a');
link.href = url;
link.download = `attachment.${type}`;
link.click();
},
},
};
</script>
<style lang="scss" scoped>
.gallery-modal--wrap {
display: flex;
flex-direction: column;
align-items: center;
width: inherit;
height: inherit;
.gallery-modal--header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--space-small) var(--space-medium);
.header-info--wrap {
display: flex;
align-items: center;
justify-content: flex-start;
.header-info {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
margin-left: var(--space-small);
.sender-name {
display: inline-block;
line-height: 1.4;
text-transform: capitalize;
margin: 0;
padding: 0;
}
.time-stamp {
color: var(--s-400);
font-size: var(--font-size-mini);
margin: 0;
padding: 0;
}
}
}
.header-actions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
margin-left: auto;
gap: var(--space-small);
}
}
.gallery-modal--body {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
height: 86%;
}
.attachments-viewer {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
height: 100%;
.attachment-view {
display: flex;
align-items: center;
justify-content: center;
img {
margin: 0 auto;
}
video {
margin: 0 auto;
}
}
}
.attachment-toggle--button {
width: var(--space-mega);
min-width: var(--space-mega);
display: flex;
justify-content: center;
}
}
</style>
@@ -49,10 +49,6 @@ export class AnalyticsHelper {
avatar: this.user.avatar_url,
});
if (window.clarity) {
window.clarity('identify', this.user.email);
}
const { accounts, account_id: accountId } = this.user;
const [currentAccount] = accounts.filter(
account => account.id === accountId
@@ -0,0 +1,119 @@
export const getInputType = (key, operator, filterTypes) => {
if (key === 'created_at' || key === 'last_activity_at')
if (operator === 'days_before') return 'plain_text';
const type = filterTypes.find(filter => filter.attributeKey === key);
return type?.inputType;
};
export const generateCustomAttributesInputType = type => {
const filterInputTypes = {
text: 'string',
number: 'string',
date: 'string',
checkbox: 'multi_select',
list: 'multi_select',
link: 'string',
};
return filterInputTypes[type];
};
export const getAttributeInputType = (key, allCustomAttributes) => {
const customAttribute = allCustomAttributes.find(
attr => attr.attribute_key === key
);
const { attribute_display_type } = customAttribute;
const filterInputTypes = generateCustomAttributesInputType(
attribute_display_type
);
return filterInputTypes;
};
export const getValuesName = (values, list, idKey, nameKey) => {
const item = list?.find(v => v[idKey] === values[0]);
return {
id: values[0],
name: item ? item[nameKey] : values[0],
};
};
const getValuesForLabels = (values, labels) => {
const selectedLabels = labels.filter(label => values.includes(label.title));
return selectedLabels.map(({ title }) => ({
id: title,
name: title,
}));
};
const getValuesForLanguages = (values, languages) => {
const selectedLanguages = languages.filter(language =>
values.includes(language.id)
);
return selectedLanguages.map(({ id, name }) => ({
id: id.toLowerCase(),
name: name,
}));
};
const getValuesForCountries = (values, countries) => {
const selectedCountries = countries.filter(country =>
values.includes(country.id)
);
return selectedCountries.map(({ id, name }) => ({
id: id,
name: name,
}));
};
export const getValuesForFilter = (filter, params) => {
const { attribute_key, values } = filter;
const {
languages,
countries,
agents,
inboxes,
teams,
campaigns,
labels,
} = params;
switch (attribute_key) {
case 'assignee_id':
return getValuesName(values, agents, 'id', 'name');
case 'inbox_id':
return getValuesName(values, inboxes, 'id', 'name');
case 'team_id':
return getValuesName(values, teams, 'id', 'name');
case 'campaign_id':
return getValuesName(values, campaigns, 'id', 'title');
case 'labels': {
return getValuesForLabels(values, labels);
}
case 'browser_language': {
return getValuesForLanguages(values, languages);
}
case 'country_code': {
return getValuesForCountries(values, countries);
}
default:
return { id: values[0], name: values[0] };
}
};
export const generateValuesForEditCustomViews = (filter, params) => {
const { attribute_key, filter_operator, values } = filter;
const { filterTypes, allCustomAttributes } = params;
const inboxType = getInputType(attribute_key, filter_operator, filterTypes);
if (inboxType === undefined) {
const filterInputTypes = getAttributeInputType(
attribute_key,
allCustomAttributes
);
return filterInputTypes === 'string'
? values[0].toString()
: { id: values[0], name: values[0] };
}
return inboxType === 'multi_select' || inboxType === 'search_select'
? getValuesForFilter(filter, params)
: values[0].toString();
};
@@ -1,9 +1,12 @@
const setArrayValues = item => {
return item.values[0]?.id ? item.values.map(val => val.id) : item.values;
};
const generatePayload = data => {
// Make a copy of data to avoid vue data reactivity issues
const filters = JSON.parse(JSON.stringify(data));
let payload = filters.map(item => {
if (Array.isArray(item.values)) {
item.values = item.values.map(val => val.id);
item.values = setArrayValues(item);
} else if (typeof item.values === 'object') {
item.values = [item.values.id];
} else if (!item.values) {
@@ -18,18 +18,18 @@ export const initializeAnalyticsEvents = () => {
});
}
});
window.bus.$on(ANALYTICS_RESET, () => {});
};
const initializeAudioAlerts = user => {
// InitializeAudioNotifications
const { ui_settings: uiSettings } = user || {};
const {
always_play_audio_alert: alwaysPlayAudioAlert,
enable_audio_alerts: audioAlertType,
alert_if_unread_assigned_conversation_exist: alertIfUnreadConversationExist,
notification_tone: audioAlertTone,
} = uiSettings;
// UI Settings can be undefined initally as we don't send the
// entire payload for the user during the signup process.
} = uiSettings || {};
DashboardAudioNotificationHelper.setInstanceValues({
currentUserId: user.id,
@@ -0,0 +1,278 @@
import {
getAttributeInputType,
getInputType,
getValuesName,
getValuesForFilter,
generateValuesForEditCustomViews,
generateCustomAttributesInputType,
} from '../customViewsHelper';
import advancedFilterTypes from 'dashboard/components/widgets/conversation/advancedFilterItems/index';
describe('customViewsHelper', () => {
describe('#getInputType', () => {
it('should return plain_text if key is created_at or last_activity_at and operator is days_before', () => {
const filterTypes = [
{ attributeKey: 'created_at', inputType: 'date' },
{ attributeKey: 'last_activity_at', inputType: 'date' },
];
expect(getInputType('created_at', 'days_before', filterTypes)).toEqual(
'plain_text'
);
expect(
getInputType('last_activity_at', 'days_before', filterTypes)
).toEqual('plain_text');
});
it('should return inputType if key is not created_at or last_activity_at', () => {
const filterTypes = [
{ attributeKey: 'created_at', inputType: 'date' },
{ attributeKey: 'last_activity_at', inputType: 'date' },
{ attributeKey: 'test', inputType: 'string' },
];
expect(getInputType('test', 'days_before', filterTypes)).toEqual(
'string'
);
});
it('should return undefined if key is not created_at or last_activity_at and inputType is not present', () => {
const filterTypes = [
{ attributeKey: 'created_at', inputType: 'date' },
{ attributeKey: 'last_activity_at', inputType: 'date' },
{ attributeKey: 'test', inputType: 'string' },
];
expect(getInputType('test', 'days_before', filterTypes)).toEqual(
'string'
);
});
});
describe('#getAttributeInputType', () => {
it('should return multi_select if attribute_display_type is checkbox or list', () => {
const allCustomAttributes = [
{ attribute_key: 'test', attribute_display_type: 'checkbox' },
{ attribute_key: 'test2', attribute_display_type: 'list' },
];
expect(getAttributeInputType('test', allCustomAttributes)).toEqual(
'multi_select'
);
expect(getAttributeInputType('test2', allCustomAttributes)).toEqual(
'multi_select'
);
});
it('should return string if attribute_display_type is text, number, date or link', () => {
const allCustomAttributes = [
{ attribute_key: 'test', attribute_display_type: 'text' },
{ attribute_key: 'test2', attribute_display_type: 'number' },
{ attribute_key: 'test3', attribute_display_type: 'date' },
{ attribute_key: 'test4', attribute_display_type: 'link' },
];
expect(getAttributeInputType('test', allCustomAttributes)).toEqual(
'string'
);
expect(getAttributeInputType('test2', allCustomAttributes)).toEqual(
'string'
);
expect(getAttributeInputType('test3', allCustomAttributes)).toEqual(
'string'
);
expect(getAttributeInputType('test4', allCustomAttributes)).toEqual(
'string'
);
});
});
describe('#getValuesName', () => {
it('should return id and name if item is present', () => {
const list = [{ id: 1, name: 'test' }];
const idKey = 'id';
const nameKey = 'name';
const values = [1];
expect(getValuesName(values, list, idKey, nameKey)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and value if item is not present', () => {
const list = [{ id: 1, name: 'test' }];
const idKey = 'id';
const nameKey = 'name';
const values = [2];
expect(getValuesName(values, list, idKey, nameKey)).toEqual({
id: 2,
name: 2,
});
});
});
describe('#getValuesForFilter', () => {
it('should return id and name if attribute_key is assignee_id', () => {
const filter = { attribute_key: 'assignee_id', values: [1] };
const params = { agents: [{ id: 1, name: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if attribute_key is inbox_id', () => {
const filter = { attribute_key: 'inbox_id', values: [1] };
const params = { inboxes: [{ id: 1, name: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if attribute_key is team_id', () => {
const filter = { attribute_key: 'team_id', values: [1] };
const params = { teams: [{ id: 1, name: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and title if attribute_key is campaign_id', () => {
const filter = { attribute_key: 'campaign_id', values: [1] };
const params = { campaigns: [{ id: 1, title: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and title if attribute_key is labels', () => {
const filter = { attribute_key: 'labels', values: ['test'] };
const params = { labels: [{ title: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual([
{ id: 'test', name: 'test' },
]);
});
it('should return id and name if attribute_key is browser_language', () => {
const filter = { attribute_key: 'browser_language', values: ['en'] };
const params = { languages: [{ id: 'en', name: 'English' }] };
expect(getValuesForFilter(filter, params)).toEqual([
{ id: 'en', name: 'English' },
]);
});
it('should return id and name if attribute_key is country_code', () => {
const filter = { attribute_key: 'country_code', values: ['IN'] };
const params = { countries: [{ id: 'IN', name: 'India' }] };
expect(getValuesForFilter(filter, params)).toEqual([
{ id: 'IN', name: 'India' },
]);
});
it('should return id and name if attribute_key is not present', () => {
const filter = { attribute_key: 'test', values: [1] };
const params = {};
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 1,
});
});
});
describe('#generateValuesForEditCustomViews', () => {
it('should return id and name if inboxType is multi_select or search_select', () => {
const filter = {
attribute_key: 'assignee_id',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
agents: [{ id: 1, name: 'test' }],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if inboxType is not multi_select or search_select', () => {
const filter = {
attribute_key: 'assignee_id',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
agents: [{ id: 1, name: 'test' }],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if inboxType is undefined', () => {
const filter = {
attribute_key: 'test2',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [
{ attribute_key: 'test', attribute_display_type: 'checkbox' },
{ attribute_key: 'test2', attribute_display_type: 'list' },
],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 1,
name: 1,
});
});
it('should return value as string if filterInputTypes is string', () => {
const filter = {
attribute_key: 'test',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [
{ attribute_key: 'test', attribute_display_type: 'date' },
{ attribute_key: 'test2', attribute_display_type: 'list' },
],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual('1');
});
});
describe('#generateCustomAttributesInputType', () => {
it('should return string if type is text', () => {
expect(generateCustomAttributesInputType('text')).toEqual('string');
});
it('should return string if type is number', () => {
expect(generateCustomAttributesInputType('number')).toEqual('string');
});
it('should return string if type is date', () => {
expect(generateCustomAttributesInputType('date')).toEqual('string');
});
it('should return multi_select if type is checkbox', () => {
expect(generateCustomAttributesInputType('checkbox')).toEqual(
'multi_select'
);
});
it('should return multi_select if type is list', () => {
expect(generateCustomAttributesInputType('list')).toEqual('multi_select');
});
it('should return string if type is link', () => {
expect(generateCustomAttributesInputType('link')).toEqual('string');
});
});
});
@@ -2,12 +2,17 @@
"FILTER": {
"TITLE": "Filter Conversations",
"SUBTITLE": "Add filters below and hit 'Apply filters' to filter conversations.",
"EDIT_CUSTOM_FILTER": "Edit Folder",
"CUSTOM_VIEWS_SUBTITLE": "Add or remove filters and update your folder.",
"ADD_NEW_FILTER": "Add Filter",
"FILTER_DELETE_ERROR": "You should have atleast one filter to save",
"SUBMIT_BUTTON_LABEL": "Apply filters",
"UPDATE_BUTTON_LABEL": "Update folder",
"CANCEL_BUTTON_LABEL": "Cancel",
"CLEAR_BUTTON_LABEL": "Clear Filters",
"EMPTY_VALUE_ERROR": "Value is required",
"FOLDER_LABEL": "Folder Name",
"FOLDER_QUERY_LABEL": "Folder Query",
"TOOLTIP_LABEL": "Filter conversations",
"QUERY_DROPDOWN_LABELS": {
"AND": "AND",
@@ -71,6 +76,9 @@
"ERROR_MESSAGE": "Error while creating segment"
}
},
"EDIT": {
"EDIT_BUTTON": "Edit folder"
},
"DELETE": {
"DELETE_BUTTON": "Delete filter",
"MODAL": {
@@ -19,13 +19,30 @@
"SUCCESS_MESSAGE": "AuditLogs retrieved successfully",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
},
"ACTION": {
"ADD": "created",
"EDIT": "updated",
"DELETE": "deleted",
"SIGN_IN": "signed in",
"SIGN_OUT": "signed out",
"SYSTEM": "System"
}
"DEFAULT_USER": "System",
"AUTOMATION_RULE": {
"ADD": "%{agentName} created a new automation rule (#%{id})",
"EDIT": "%{agentName} updated an automation rule (#%{id})",
"DELETE": "%{agentName} deleted an automation rule (#%{id})"
},
"INBOX": {
"ADD": "%{agentName} created a new inbox (#%{id})",
"EDIT": "%{agentName} updated an inbox (#%{id})",
"DELETE": "%{agentName} deleted an inbox (#%{id})"
},
"WEBHOOK": {
"ADD": "%{agentName} created a new webhook (#%{id})",
"EDIT": "%{agentName} updated a webhook (#%{id})",
"DELETE": "%{agentName} deleted a webhook (#%{id})"
},
"USER_ACTION": {
"SIGN_IN": "%{agentName} signed in",
"SIGN_OUT": "%{agentName} signed out"
},
"TEAM": {
"ADD": "%{agentName} created a new team (#%{id})",
"EDIT": "%{agentName} updated a team (#%{id})",
"DELETE": "%{agentName} deleted a team (#%{id})"
}
}
}
@@ -73,6 +73,13 @@
"SUCCESS_MESSAGE": "Contacts saved successfully",
"ERROR_MESSAGE": "There was an error, please try again"
},
"EXPORT_CONTACTS": {
"BUTTON_LABEL": "Export",
"TITLE": "Export Contacts",
"DESC": "Export contacts to a CSV file.",
"SUCCESS_MESSAGE": "Export is in progress, You will be notified via email when export file is ready to dowanlod.",
"ERROR_MESSAGE": "There was an error, please try again"
},
"DELETE_NOTE": {
"CONFIRM": {
"TITLE": "Confirm Deletion",
@@ -211,6 +218,7 @@
"FILTER_CONTACTS": "Filter",
"FILTER_CONTACTS_SAVE": "Save filter",
"FILTER_CONTACTS_DELETE": "Delete filter",
"FILTER_CONTACTS_EDIT": "Edit segment",
"LIST": {
"LOADING_MESSAGE": "Loading contacts...",
"404": "No contacts matches your search 🔍",
@@ -1,50 +1,55 @@
{
"CONTACTS_FILTER": {
"TITLE": "Filter Contacts",
"SUBTITLE": "Add filters below and hit 'Submit' to filter contacts.",
"ADD_NEW_FILTER": "Add Filter",
"CLEAR_ALL_FILTERS": "Clear All Filters",
"FILTER_DELETE_ERROR": "You should have atleast one filter to save",
"SUBMIT_BUTTON_LABEL": "Submit",
"CANCEL_BUTTON_LABEL": "Cancel",
"CLEAR_BUTTON_LABEL": "Clear Filters",
"EMPTY_VALUE_ERROR": "Value is required",
"TOOLTIP_LABEL": "Filter contacts",
"QUERY_DROPDOWN_LABELS": {
"AND": "AND",
"OR": "OR"
},
"OPERATOR_LABELS": {
"equal_to": "Equal to",
"not_equal_to": "Not equal to",
"contains": "Contains",
"does_not_contain": "Does not contain",
"is_present": "Is present",
"is_not_present": "Is not present",
"is_greater_than": "Is greater than",
"is_lesser_than": "Is lesser than",
"days_before": "Is x days before"
},
"ATTRIBUTES": {
"NAME": "Name",
"EMAIL": "Email",
"PHONE_NUMBER": "Phone number",
"IDENTIFIER": "Identifier",
"CITY": "City",
"COUNTRY": "Country",
"CUSTOM_ATTRIBUTE_LIST": "List",
"CUSTOM_ATTRIBUTE_TEXT": "Text",
"CUSTOM_ATTRIBUTE_NUMBER": "Number",
"CUSTOM_ATTRIBUTE_LINK": "Link",
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
"CREATED_AT": "Created At",
"LAST_ACTIVITY": "Last Activity",
"REFERER_LINK": "Referrer link"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard Filters",
"ADDITIONAL_FILTERS": "Additional Filters",
"CUSTOM_ATTRIBUTES": "Custom Attributes"
}
"CONTACTS_FILTER": {
"TITLE": "Filter Contacts",
"SUBTITLE": "Add filters below and hit 'Submit' to filter contacts.",
"EDIT_CUSTOM_SEGMENT": "Edit Segment",
"CUSTOM_VIEWS_SUBTITLE": "Add or remove filters and update your segment.",
"ADD_NEW_FILTER": "Add Filter",
"CLEAR_ALL_FILTERS": "Clear All Filters",
"FILTER_DELETE_ERROR": "You should have atleast one filter to save",
"SUBMIT_BUTTON_LABEL": "Submit",
"UPDATE_BUTTON_LABEL": "Update Segment",
"CANCEL_BUTTON_LABEL": "Cancel",
"CLEAR_BUTTON_LABEL": "Clear Filters",
"EMPTY_VALUE_ERROR": "Value is required",
"SEGMENT_LABEL": "Segment Name",
"SEGMENT_QUERY_LABEL": "Segment Query",
"TOOLTIP_LABEL": "Filter contacts",
"QUERY_DROPDOWN_LABELS": {
"AND": "AND",
"OR": "OR"
},
"OPERATOR_LABELS": {
"equal_to": "Equal to",
"not_equal_to": "Not equal to",
"contains": "Contains",
"does_not_contain": "Does not contain",
"is_present": "Is present",
"is_not_present": "Is not present",
"is_greater_than": "Is greater than",
"is_lesser_than": "Is lesser than",
"days_before": "Is x days before"
},
"ATTRIBUTES": {
"NAME": "Name",
"EMAIL": "Email",
"PHONE_NUMBER": "Phone number",
"IDENTIFIER": "Identifier",
"CITY": "City",
"COUNTRY": "Country",
"CUSTOM_ATTRIBUTE_LIST": "List",
"CUSTOM_ATTRIBUTE_TEXT": "Text",
"CUSTOM_ATTRIBUTE_NUMBER": "Number",
"CUSTOM_ATTRIBUTE_LINK": "Link",
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
"CREATED_AT": "Created At",
"LAST_ACTIVITY": "Last Activity",
"REFERER_LINK": "Referrer link"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard Filters",
"ADDITIONAL_FILTERS": "Additional Filters",
"CUSTOM_ATTRIBUTES": "Custom Attributes"
}
}
}
@@ -535,7 +535,6 @@
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {
"ENABLE": "Enable availability for this day",
@@ -1,9 +1,25 @@
<template>
<div class="column">
<woot-modal-header :header-title="$t('CONTACTS_FILTER.TITLE')">
<p>{{ $t('CONTACTS_FILTER.SUBTITLE') }}</p>
<woot-modal-header :header-title="filterModalHeaderTitle">
<p>{{ filterModalSubTitle }}</p>
</woot-modal-header>
<div class="row modal-content">
<div class="column modal-content">
<div v-if="isSegmentsView" class="columns">
<label class="input-label" :class="{ error: !activeSegmentNewName }">
{{ $t('CONTACTS_FILTER.SEGMENT_LABEL') }}
<input
v-model="activeSegmentNewName"
type="text"
class="name-input"
/>
<span v-if="!activeSegmentNewName" class="message">
{{ $t('CONTACTS_FILTER.EMPTY_VALUE_ERROR') }}
</span>
</label>
<label class="input-label">
{{ $t('CONTACTS_FILTER.SEGMENT_QUERY_LABEL') }}
</label>
</div>
<div class="medium-12 columns filters-wrap">
<filter-input-box
v-for="(filter, i) in appliedFilters"
@@ -36,7 +52,7 @@
{{ $t('CONTACTS_FILTER.ADD_NEW_FILTER') }}
</woot-button>
<woot-button
v-if="hasAppliedFilters"
v-if="hasAppliedFilters && !isSegmentsView"
icon="subtract"
color-scheme="alert"
variant="smooth"
@@ -52,7 +68,14 @@
<woot-button class="button clear" @click.prevent="onClose">
{{ $t('CONTACTS_FILTER.CANCEL_BUTTON_LABEL') }}
</woot-button>
<woot-button @click="submitFilterQuery">
<woot-button
v-if="isSegmentsView"
:disabled="!activeSegmentNewName"
@click="updateSegment"
>
{{ $t('CONTACTS_FILTER.UPDATE_BUTTON_LABEL') }}
</woot-button>
<woot-button v-else @click="submitFilterQuery">
{{ $t('CONTACTS_FILTER.SUBMIT_BUTTON_LABEL') }}
</woot-button>
</div>
@@ -85,6 +108,18 @@ export default {
type: Array,
default: () => [],
},
initialAppliedFilters: {
type: Array,
default: () => [],
},
isSegmentsView: {
type: Boolean,
default: false,
},
activeSegmentName: {
type: String,
default: '',
},
},
validations: {
appliedFilters: {
@@ -105,7 +140,8 @@ export default {
data() {
return {
show: true,
appliedFilters: [],
appliedFilters: this.initialAppliedFilters,
activeSegmentNewName: this.activeSegmentName,
filterTypes: this.initialFilterTypes,
filterGroups: [],
allCustomAttributes: [],
@@ -121,12 +157,22 @@ export default {
hasAppliedFilters() {
return this.getAppliedContactFilters.length;
},
filterModalHeaderTitle() {
return !this.isSegmentsView
? this.$t('CONTACTS_FILTER.TITLE')
: this.$t('CONTACTS_FILTER.EDIT_CUSTOM_SEGMENT');
},
filterModalSubTitle() {
return !this.isSegmentsView
? this.$t('CONTACTS_FILTER.SUBTITLE')
: this.$t('CONTACTS_FILTER.CUSTOM_VIEWS_SUBTITLE');
},
},
mounted() {
this.setFilterAttributes();
if (this.getAppliedContactFilters.length) {
this.appliedFilters = [...this.getAppliedContactFilters];
} else {
} else if (!this.isSegmentsView) {
this.appliedFilters.push({
attribute_key: 'name',
filter_operator: 'equal_to',
@@ -177,11 +223,11 @@ export default {
if (key === 'created_at' || key === 'last_activity_at')
if (operator === 'days_before') return 'plain_text';
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.inputType;
return type?.inputType;
},
getOperators(key) {
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.filterOperators;
return type?.filterOperators;
},
getDropdownValues(type) {
const allCustomAttributes = this.$store.getters[
@@ -230,11 +276,30 @@ export default {
}
},
appendNewFilter() {
this.appliedFilters.push({
attribute_key: 'name',
filter_operator: 'equal_to',
values: '',
if (this.isSegmentsView) {
this.setQueryOperatorOnLastQuery();
} else {
this.appliedFilters.push({
attribute_key: 'name',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
}
},
setQueryOperatorOnLastQuery() {
const lastItemIndex = this.appliedFilters.length - 1;
this.appliedFilters[lastItemIndex] = {
...this.appliedFilters[lastItemIndex],
query_operator: 'and',
};
this.$nextTick(() => {
this.appliedFilters.push({
attribute_key: 'name',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
});
},
removeFilter(index) {
@@ -259,6 +324,13 @@ export default {
})),
});
},
updateSegment() {
this.$emit(
'updateSegment',
this.appliedFilters,
this.activeSegmentNewName
);
},
resetFilter(index, currentFilter) {
this.appliedFilters[index].filter_operator = this.filterTypes.find(
filter => filter.attributeKey === currentFilter.attribute_key
@@ -1,10 +1,11 @@
<template>
<div class="contacts-page row">
<div class="left-wrap" :class="wrapClas">
<div class="left-wrap" :class="wrapClass">
<contacts-header
:search-query="searchQuery"
:segments-id="segmentsId"
:on-search-submit="onSearchSubmit"
:on-export-submit="onExportSubmit"
this-selected-contact-id=""
:on-input-search="onInputSearch"
:on-toggle-create="onToggleCreate"
@@ -13,6 +14,7 @@
:header-title="pageTitle"
@on-toggle-save-filter="onToggleSaveFilters"
@on-toggle-delete-filter="onToggleDeleteFilters"
@on-toggle-edit-filter="onToggleFilters"
/>
<contacts-table
:contacts="records"
@@ -58,14 +60,18 @@
</woot-modal>
<woot-modal
:show.sync="showFiltersModal"
:on-close="onToggleFilters"
:on-close="closeAdvanceFiltersModal"
size="medium"
>
<contacts-advanced-filters
v-if="showFiltersModal"
:on-close="onToggleFilters"
:on-close="closeAdvanceFiltersModal"
:initial-filter-types="contactFilterItems"
:initial-applied-filters="appliedFilter"
:active-segment-name="activeSegmentName"
:is-segments-view="hasActiveSegments"
@applyFilter="onApplyFilter"
@updateSegment="onUpdateSegment"
@clearFilters="clearFilters"
/>
</woot-modal>
@@ -87,6 +93,9 @@ import filterQueryGenerator from '../../../../helper/filterQueryGenerator';
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews';
import { CONTACTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import alertMixin from 'shared/mixins/alertMixin';
import countries from 'shared/constants/countries.js';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
const DEFAULT_PAGE = 1;
const FILTER_TYPE_CONTACT = 1;
@@ -103,6 +112,7 @@ export default {
AddCustomViews,
DeleteCustomViews,
},
mixins: [alertMixin],
props: {
label: { type: String, default: '' },
segmentsId: {
@@ -128,6 +138,7 @@ export default {
filterType: FILTER_TYPE_CONTACT,
showAddSegmentsModal: false,
showDeleteSegmentsModal: false,
appliedFilter: [],
};
},
computed: {
@@ -175,7 +186,7 @@ export default {
showContactViewPane() {
return this.selectedContactId !== '';
},
wrapClas() {
wrapClass() {
return this.showContactViewPane ? 'medium-9' : 'medium-12';
},
pageParameter() {
@@ -194,6 +205,9 @@ export default {
}
return undefined;
},
activeSegmentName() {
return this.activeSegment?.name;
},
},
watch: {
label() {
@@ -345,7 +359,14 @@ export default {
});
},
onToggleFilters() {
this.showFiltersModal = !this.showFiltersModal;
if (this.hasActiveSegments) {
this.initializeSegmentToFilterModal(this.activeSegment);
}
this.showFiltersModal = true;
},
closeAdvanceFiltersModal() {
this.showFiltersModal = false;
this.appliedFilter = [];
},
onApplyFilter(payload) {
this.closeContactInfoPanel();
@@ -355,10 +376,69 @@ export default {
});
this.showFiltersModal = false;
},
onUpdateSegment(payload, segmentName) {
const payloadData = {
...this.activeSegment,
name: segmentName,
query: filterQueryGenerator(payload),
};
this.$store.dispatch('customViews/update', payloadData);
this.closeAdvanceFiltersModal();
},
clearFilters() {
this.$store.dispatch('contacts/clearContactFilters');
this.fetchContacts(this.pageParameter);
},
onExportSubmit() {
try {
this.$store.dispatch('contacts/export');
this.showAlert(this.$t('EXPORT_CONTACTS.SUCCESS_MESSAGE'));
} catch (error) {
this.showAlert(
error.message || this.$t('EXPORT_CONTACTS.ERROR_MESSAGE')
);
}
},
setParamsForEditSegmentModal() {
// Here we are setting the params for edit segment modal to show the existing values.
// For custom attributes we get only attribute key.
// So we are mapping it to find the input type of the attribute to show in the edit segment modal.
const params = {
countries: countries,
filterTypes: contactFilterItems,
allCustomAttributes: this.$store.getters[
'attributes/getAttributesByModel'
]('contact_attribute'),
};
return params;
},
initializeSegmentToFilterModal(activeSegment) {
// Here we are setting the params for edit segment modal.
// To show the existing values. when we click on edit segment button.
// Here we get the query from the active segment.
// And we are mapping the query to the actual values.
// To show in the edit segment modal by the help of generateValuesForEditCustomViews helper.
const query = activeSegment?.query?.payload;
if (!Array.isArray(query)) return;
this.appliedFilter.push(
...query.map(filter => ({
attribute_key: filter.attribute_key,
attribute_model: filter.attribute_model,
filter_operator: filter.filter_operator,
values: Array.isArray(filter.values)
? generateValuesForEditCustomViews(
filter,
this.setParamsForEditSegmentModal()
)
: [],
query_operator: filter.query_operator,
custom_attribute_type: filter.custom_attribute_type,
}))
);
},
openSavedItemInSegment() {
const lastItemInSegments = this.segments[this.segments.length - 1];
const lastItemId = lastItemInSegments.id;
@@ -27,15 +27,24 @@
{{ $t('CONTACTS_PAGE.SEARCH_BUTTON') }}
</woot-button>
</div>
<woot-button
v-if="hasActiveSegments"
class="margin-right-1 clear"
color-scheme="alert"
icon="delete"
@click="onToggleDeleteSegmentsModal"
>
{{ $t('CONTACTS_PAGE.FILTER_CONTACTS_DELETE') }}
</woot-button>
<div v-if="hasActiveSegments">
<woot-button
class="margin-right-1 clear"
color-scheme="secondary"
icon="edit"
@click="onToggleEditSegmentsModal"
>
{{ $t('CONTACTS_PAGE.FILTER_CONTACTS_EDIT') }}
</woot-button>
<woot-button
class="margin-right-1 clear"
color-scheme="alert"
icon="delete"
@click="onToggleDeleteSegmentsModal"
>
{{ $t('CONTACTS_PAGE.FILTER_CONTACTS_DELETE') }}
</woot-button>
</div>
<div v-if="!hasActiveSegments" class="filters__button-wrap">
<div v-if="hasAppliedFilters" class="filters__applied-indicator" />
<woot-button
@@ -78,6 +87,16 @@
>
{{ $t('IMPORT_CONTACTS.BUTTON_LABEL') }}
</woot-button>
<woot-button
v-if="isAdmin"
color-scheme="info"
icon="upload"
class="clear"
@click="onExportSubmit"
>
{{ $t('EXPORT_CONTACTS.BUTTON_LABEL') }}
</woot-button>
</div>
</div>
</header>
@@ -118,6 +137,10 @@ export default {
type: Function,
default: () => {},
},
onExportSubmit: {
type: Function,
default: () => {},
},
onToggleFilter: {
type: Function,
default: () => {},
@@ -147,6 +170,9 @@ export default {
onToggleSegmentsModal() {
this.$emit('on-toggle-save-filter');
},
onToggleEditSegmentsModal() {
this.$emit('on-toggle-edit-filter');
},
onToggleDeleteSegmentsModal() {
this.$emit('on-toggle-delete-filter');
},
@@ -96,7 +96,7 @@ export default {
methods: {
getAgentName(email) {
if (email === null) {
return this.$t('AUDIT_LOGS.ACTION.SYSTEM');
return this.$t('AUDIT_LOGS.DEFAULT_USER');
}
const agentName = this.agentList.find(agent => agent.email === email)
?.name;
@@ -104,28 +104,35 @@ export default {
return agentName || email;
},
generateLogText(auditLogItem) {
const username = this.getAgentName(auditLogItem.username);
const agentName = this.getAgentName(auditLogItem.username);
const auditableType = auditLogItem.auditable_type.toLowerCase();
const action = auditLogItem.action.toLowerCase();
const auditId = auditLogItem.auditable_id;
const logActionKey = `${auditableType}:${action}`;
const logActions = {
create: this.$t('AUDIT_LOGS.ACTION.ADD'),
destroy: this.$t('AUDIT_LOGS.ACTION.DELETE'),
update: this.$t('AUDIT_LOGS.ACTION.EDIT'),
sign_in: this.$t('AUDIT_LOGS.ACTION.SIGN_IN'),
sign_out: this.$t('AUDIT_LOGS.ACTION.SIGN_OUT'),
const translationPayload = {
agentName,
id: auditId,
};
// detect if the action is custom user action, which involves
// only the user, such as signing in, signing out etc.
// if it is, then do not show the auditable type
const userActions = this.getUserActions(action);
return `${username} ${logActions[action] || action} ${
userActions ? '' : auditableType
}`;
},
getUserActions(action) {
return ['sign_in', 'sign_out'].includes(action);
const translationKeys = {
'automationrule:create': `AUDIT_LOGS.AUTOMATION_RULE.ADD`,
'automationrule:update': `AUDIT_LOGS.AUTOMATION_RULE.EDIT`,
'automationrule:destroy': `AUDIT_LOGS.AUTOMATION_RULE.DELETE`,
'webhook:create': `AUDIT_LOGS.WEBHOOK.ADD`,
'webhook:update': `AUDIT_LOGS.WEBHOOK.EDIT`,
'webhook:destroy': `AUDIT_LOGS.WEBHOOK.DELETE`,
'inbox:create': `AUDIT_LOGS.INBOX.ADD`,
'inbox:update': `AUDIT_LOGS.INBOX.EDIT`,
'inbox:destroy': `AUDIT_LOGS.INBOX.DELETE`,
'user:sign_in': `AUDIT_LOGS.USER_ACTION.SIGN_IN`,
'user:sign_out': `AUDIT_LOGS.USER_ACTION.SIGN_OUT`,
'team:create': `AUDIT_LOGS.TEAM.ADD`,
'team:update': `AUDIT_LOGS.TEAM.EDIT`,
'team:destroy': `AUDIT_LOGS.TEAM.DELETE`,
};
return this.$t(translationKeys[logActionKey] || '', translationPayload);
},
onPageChange(page) {
window.history.pushState({}, null, `${this.$route.path}?page=${page}`);
@@ -102,9 +102,7 @@ export default {
data() {
return {
isBusinessHoursEnabled: false,
unavailableMessage: this.$t(
'INBOX_MGMT.BUSINESS_HOURS.UNAVAILABLE_MESSAGE_DEFAULT'
),
unavailableMessage: '',
timeZone: DEFAULT_TIMEZONE,
dayNames: {
0: 'Sunday',
@@ -157,9 +155,7 @@ export default {
? timeSlotParse(timeSlots)
: defaultTimeSlot;
this.isBusinessHoursEnabled = isEnabled;
this.unavailableMessage =
unavailableMessage ||
this.$t('INBOX_MGMT.BUSINESS_HOURS.UNAVAILABLE_MESSAGE_DEFAULT');
this.unavailableMessage = unavailableMessage || '';
this.timeSlots = slots;
this.timeZone =
this.timeZones.find(item => timeZone === item.value) ||
@@ -140,6 +140,20 @@ export const actions = {
}
},
export: async ({ commit }) => {
try {
await ContactAPI.exportContacts();
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
if (error.response?.data?.message) {
throw new Error(error.response.data.message);
} else {
throw new Error(error);
}
}
},
delete: async ({ commit }, id) => {
commit(types.SET_CONTACT_UI_FLAG, { isDeleting: true });
try {
@@ -86,6 +86,18 @@ const actions = {
}
},
fetchAllAttachments: async ({ commit }, conversationId) => {
try {
const { data } = await ConversationApi.getAllAttachments(conversationId);
commit(types.SET_ALL_ATTACHMENTS, {
id: conversationId,
data: data.payload,
});
} catch (error) {
// Handle error
}
},
syncActiveConversationMessages: async (
{ commit, state, dispatch },
{ conversationId }
@@ -247,6 +259,10 @@ const actions = {
...response.data,
status: MESSAGE_STATUS.SENT,
});
commit(types.ADD_CONVERSATION_ATTACHMENTS, {
...response.data,
status: MESSAGE_STATUS.SENT,
});
} catch (error) {
const errorMessage = error.response
? error.response.data.error
@@ -269,6 +285,7 @@ const actions = {
conversationId: message.conversation_id,
canReply: true,
});
commit(types.ADD_CONVERSATION_ATTACHMENTS, message);
}
},
@@ -283,6 +300,7 @@ const actions = {
try {
const { data } = await MessageApi.delete(conversationId, messageId);
commit(types.ADD_MESSAGE, data);
commit(types.DELETE_CONVERSATION_ATTACHMENTS, data);
} catch (error) {
throw new Error(error);
}
@@ -32,6 +32,11 @@ const getters = {
);
return selectedChat || {};
},
getSelectedChatAttachments: (_state, _getters) => {
const selectedChat = _getters.getSelectedChat;
const { attachments } = selectedChat;
return attachments;
},
getLastEmailInSelectedChat: (stage, _getters) => {
const selectedChat = _getters.getSelectedChat;
const { messages = [] } = selectedChat;
@@ -3,6 +3,7 @@ import types from '../../mutation-types';
import getters, { getSelectedChatConversation } from './getters';
import actions from './actions';
import { findPendingMessageIndex } from './helpers';
import { MESSAGE_STATUS } from 'shared/constants/messages';
import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from '../../../../shared/constants/busEvents';
@@ -56,6 +57,12 @@ export const mutations = {
chat.messages.unshift(...data);
}
},
[types.SET_ALL_ATTACHMENTS](_state, { id, data }) {
const [chat] = _state.allConversations.filter(c => c.id === id);
if (!chat) return;
Vue.set(chat, 'attachments', []);
chat.attachments.push(...data);
},
[types.SET_MISSING_MESSAGES](_state, { id, data }) {
const [chat] = _state.allConversations.filter(c => c.id === id);
if (!chat) return;
@@ -115,6 +122,44 @@ export const mutations = {
Vue.set(chat, 'muted', false);
},
[types.ADD_CONVERSATION_ATTACHMENTS]({ allConversations }, message) {
const { conversation_id: conversationId } = message;
const [chat] = getSelectedChatConversation({
allConversations,
selectedChatId: conversationId,
});
if (!chat) return;
const isMessageSent =
message.status === MESSAGE_STATUS.SENT && message.attachments;
if (isMessageSent) {
message.attachments.forEach(attachment => {
if (!chat.attachments.some(a => a.id === attachment.id)) {
chat.attachments.push(attachment);
}
});
}
},
[types.DELETE_CONVERSATION_ATTACHMENTS]({ allConversations }, message) {
const { conversation_id: conversationId } = message;
const [chat] = getSelectedChatConversation({
allConversations,
selectedChatId: conversationId,
});
if (!chat) return;
const isMessageSent = message.status === MESSAGE_STATUS.SENT;
if (isMessageSent) {
const attachmentIndex = chat.attachments.findIndex(
a => a.message_id === message.id
);
if (attachmentIndex !== -1) chat.attachments.splice(attachmentIndex, 1);
}
},
[types.ADD_MESSAGE]({ allConversations, selectedChatId }, message) {
const { conversation_id: conversationId } = message;
const [chat] = getSelectedChatConversation({
@@ -49,6 +49,18 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false });
}
},
update: async function updateCustomViews({ commit }, obj) {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true });
try {
const response = await CustomViewsAPI.update(obj.id, obj);
commit(types.UPDATE_CUSTOM_VIEW, response.data);
} catch (error) {
const errorMessage = error?.response?.data?.message;
throw new Error(errorMessage);
} finally {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false });
}
},
delete: async ({ commit }, { id, filterType }) => {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: true });
try {
@@ -72,6 +84,7 @@ export const mutations = {
[types.ADD_CUSTOM_VIEW]: MutationHelpers.create,
[types.SET_CUSTOM_VIEW]: MutationHelpers.set,
[types.UPDATE_CUSTOM_VIEW]: MutationHelpers.update,
[types.DELETE_CUSTOM_VIEW]: MutationHelpers.destroy,
};
@@ -204,6 +204,7 @@ describe('#actions', () => {
]);
});
});
describe('#addMessage', () => {
it('sends correct mutations if message is incoming', () => {
const message = {
@@ -218,6 +219,7 @@ describe('#actions', () => {
types.SET_CONVERSATION_CAN_REPLY,
{ conversationId: 1, canReply: true },
],
[types.ADD_CONVERSATION_ATTACHMENTS, message],
]);
});
it('sends correct mutations if message is not an incoming message', () => {
@@ -436,10 +438,13 @@ describe('#actions', () => {
describe('#deleteMessage', () => {
it('sends correct actions if API is success', async () => {
const [conversationId, messageId] = [1, 1];
axios.delete.mockResolvedValue({ data: { id: 1, content: 'deleted' } });
axios.delete.mockResolvedValue({
data: { id: 1, content: 'deleted' },
});
await actions.deleteMessage({ commit }, { conversationId, messageId });
expect(commit.mock.calls).toEqual([
[types.ADD_MESSAGE, { id: 1, content: 'deleted' }],
[types.DELETE_CONVERSATION_ATTACHMENTS, { id: 1, content: 'deleted' }],
]);
});
it('sends no actions if API is error', async () => {
@@ -554,4 +559,40 @@ describe('#addMentions', () => {
],
]);
});
describe('#fetchAllAttachments', () => {
it('fetches all attachments', async () => {
axios.get.mockResolvedValue({
data: {
payload: [
{
id: 1,
message_id: 1,
file_type: 'image',
data_url: '',
thumb_url: '',
},
],
},
});
await actions.fetchAllAttachments({ commit }, 1);
expect(commit.mock.calls).toEqual([
[
types.SET_ALL_ATTACHMENTS,
{
id: 1,
data: [
{
id: 1,
message_id: 1,
file_type: 'image',
data_url: '',
thumb_url: '',
},
],
},
],
]);
});
});
});
@@ -305,4 +305,34 @@ describe('#getters', () => {
});
});
});
describe('#getSelectedChatAttachments', () => {
it('Returns attachments in selected chat', () => {
const state = {};
const getSelectedChat = {
attachments: [
{
id: 1,
file_name: 'test1',
},
{
id: 2,
file_name: 'test2',
},
],
};
expect(
getters.getSelectedChatAttachments(state, { getSelectedChat })
).toEqual([
{
id: 1,
file_name: 'test1',
},
{
id: 2,
file_name: 'test2',
},
]);
});
});
});
@@ -278,4 +278,129 @@ describe('#mutations', () => {
expect(state.appliedFilters).toEqual([]);
});
});
describe('#SET_ALL_ATTACHMENTS', () => {
it('set all attachments', () => {
const state = {
allConversations: [{ id: 1 }],
};
const data = [{ id: 1, name: 'test' }];
mutations[types.SET_ALL_ATTACHMENTS](state, { id: 1, data });
expect(state.allConversations[0].attachments).toEqual(data);
});
it('set attachments key even if the attachments are empty', () => {
const state = {
allConversations: [{ id: 1 }],
};
const data = [];
mutations[types.SET_ALL_ATTACHMENTS](state, { id: 1, data });
expect(state.allConversations[0].attachments).toEqual([]);
});
});
describe('#ADD_CONVERSATION_ATTACHMENTS', () => {
it('add conversation attachments', () => {
const state = {
allConversations: [{ id: 1, attachments: [] }],
};
const message = {
conversation_id: 1,
status: 'sent',
attachments: [{ id: 1, name: 'test' }],
};
mutations[types.ADD_CONVERSATION_ATTACHMENTS](state, message);
expect(state.allConversations[0].attachments).toEqual(
message.attachments
);
});
it('should not add duplicate attachments', () => {
const state = {
allConversations: [
{
id: 1,
attachments: [{ id: 1, name: 'existing' }],
},
],
};
const message = {
conversation_id: 1,
status: 'sent',
attachments: [
{ id: 1, name: 'existing' },
{ id: 2, name: 'new' },
],
};
mutations[types.ADD_CONVERSATION_ATTACHMENTS](state, message);
expect(state.allConversations[0].attachments).toHaveLength(2);
expect(state.allConversations[0].attachments).toContainEqual({
id: 1,
name: 'existing',
});
expect(state.allConversations[0].attachments).toContainEqual({
id: 2,
name: 'new',
});
});
it('should not add attachments if chat not found', () => {
const state = {
allConversations: [{ id: 1, attachments: [] }],
};
const message = {
conversation_id: 2,
status: 'sent',
attachments: [{ id: 1, name: 'test' }],
};
mutations[types.ADD_CONVERSATION_ATTACHMENTS](state, message);
expect(state.allConversations[0].attachments).toHaveLength(0);
});
});
describe('#DELETE_CONVERSATION_ATTACHMENTS', () => {
it('delete conversation attachments', () => {
const state = {
allConversations: [{ id: 1, attachments: [{ id: 1, message_id: 1 }] }],
};
const message = {
conversation_id: 1,
status: 'sent',
id: 1,
};
mutations[types.DELETE_CONVERSATION_ATTACHMENTS](state, message);
expect(state.allConversations[0].attachments).toHaveLength(0);
});
it('should not delete attachments for non-matching message id', () => {
const state = {
allConversations: [{ id: 1, attachments: [{ id: 1, message_id: 1 }] }],
};
const message = {
conversation_id: 1,
status: 'sent',
id: 2,
};
mutations[types.DELETE_CONVERSATION_ATTACHMENTS](state, message);
expect(state.allConversations[0].attachments).toHaveLength(1);
});
it('should not delete attachments if chat not found', () => {
const state = {
allConversations: [{ id: 1, attachments: [{ id: 1, message_id: 1 }] }],
};
const message = {
conversation_id: 2,
status: 'sent',
id: 1,
};
mutations[types.DELETE_CONVERSATION_ATTACHMENTS](state, message);
expect(state.allConversations[0].attachments).toHaveLength(1);
});
});
});
@@ -1,7 +1,7 @@
import axios from 'axios';
import { actions } from '../../customViews';
import * as types from '../../../mutation-types';
import customViewList from './fixtures';
import { customViewList, updateCustomViewList } from './fixtures';
const commit = jest.fn();
global.axios = axios;
@@ -67,4 +67,28 @@ describe('#actions', () => {
]);
});
});
describe('#update', () => {
it('sends correct actions if API is success', async () => {
axios.patch.mockResolvedValue({ data: updateCustomViewList[0] });
await actions.update(
{ commit },
updateCustomViewList[0].id,
updateCustomViewList[0]
);
expect(commit.mock.calls).toEqual([
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true }],
[types.default.UPDATE_CUSTOM_VIEW, updateCustomViewList[0]],
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.update({ commit }, 1)).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true }],
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }],
]);
});
});
});
@@ -1,4 +1,4 @@
export default [
export const customViewList = [
{
name: 'Custom view',
filter_type: 0,
@@ -34,3 +34,31 @@ export default [
},
},
];
export const updateCustomViewList = [
{
id: 1,
name: 'Open',
filter_type: 'conversation',
query: {
payload: [
{
attribute_key: 'status',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'and',
custom_attribute_type: '',
},
{
attribute_key: 'assignee_id',
filter_operator: 'equal_to',
values: [52],
custom_attribute_type: '',
},
],
},
created_at: '2022-02-08T03:17:38.761Z',
updated_at: '2023-06-05T13:57:48.478Z',
},
];
@@ -1,5 +1,5 @@
import { getters } from '../../customViews';
import customViewList from './fixtures';
import { customViewList } from './fixtures';
describe('#getters', () => {
it('getCustomViews', () => {
@@ -1,6 +1,6 @@
import types from '../../../mutation-types';
import { mutations } from '../../customViews';
import customViewList from './fixtures';
import { customViewList, updateCustomViewList } from './fixtures';
describe('#mutations', () => {
describe('#SET_CUSTOM_VIEW', () => {
@@ -26,4 +26,12 @@ describe('#mutations', () => {
expect(state.records).toEqual([customViewList[0]]);
});
});
describe('#UPDATE_CUSTOM_VIEW', () => {
it('update custom view record', () => {
const state = { records: [updateCustomViewList[0]] };
mutations[types.UPDATE_CUSTOM_VIEW](state, updateCustomViewList[0]);
expect(state.records).toEqual(updateCustomViewList);
});
});
});
@@ -49,6 +49,10 @@ export default {
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS',
DELETE_CONVERSATION_ATTACHMENTS: 'DELETE_CONVERSATION_ATTACHMENTS',
SET_CONVERSATION_CAN_REPLY: 'SET_CONVERSATION_CAN_REPLY',
// Inboxes
@@ -217,6 +221,7 @@ export default {
SET_CUSTOM_VIEW_UI_FLAG: 'SET_CUSTOM_VIEW_UI_FLAG',
SET_CUSTOM_VIEW: 'SET_CUSTOM_VIEW',
ADD_CUSTOM_VIEW: 'ADD_CUSTOM_VIEW',
UPDATE_CUSTOM_VIEW: 'UPDATE_CUSTOM_VIEW',
DELETE_CUSTOM_VIEW: 'DELETE_CUSTOM_VIEW',
// Bulk Actions
+6 -3
View File
@@ -11,6 +11,7 @@ import {
hasUserKeys,
} from '../sdk/cookieHelpers';
import { addClasses, removeClasses } from '../sdk/DOMHelpers';
import { setCookieWithDomain } from '../sdk/cookieHelpers';
import { SDK_SET_BUBBLE_VISIBILITY } from 'shared/constants/sharedFrameEvents';
const runSDK = ({ baseUrl, websiteToken }) => {
if (window.$chatwoot) {
@@ -19,6 +20,7 @@ const runSDK = ({ baseUrl, websiteToken }) => {
const chatwootSettings = window.chatwootSettings || {};
let locale = chatwootSettings.locale;
let baseDomain = chatwootSettings.baseDomain;
if (chatwootSettings.useBrowserLanguage) {
locale = window.navigator.language.replace('-', '_');
@@ -26,6 +28,7 @@ const runSDK = ({ baseUrl, websiteToken }) => {
window.$chatwoot = {
baseUrl,
baseDomain,
hasLoaded: false,
hideMessageBubble: chatwootSettings.hideMessageBubble || false,
isOpen: false,
@@ -90,9 +93,9 @@ const runSDK = ({ baseUrl, websiteToken }) => {
window.$chatwoot.identifier = identifier;
window.$chatwoot.user = user;
IFrameHelper.sendMessage('set-user', { identifier, user });
Cookies.set(userCookieName, hashToBeStored, {
expires: 365,
sameSite: 'Lax',
setCookieWithDomain(userCookieName, hashToBeStored, {
baseDomain,
});
},
+10 -11
View File
@@ -29,7 +29,7 @@ import {
CHATWOOT_READY,
} from '../widget/constants/sdkEvents';
import { SET_USER_ERROR } from '../widget/constants/errorTypes';
import { getUserCookieName } from './cookieHelpers';
import { getUserCookieName, setCookieWithDomain } from './cookieHelpers';
import {
getAlertAudio,
initOnEvents,
@@ -38,17 +38,16 @@ import { isFlatWidgetStyle } from './settingsHelper';
import { popoutChatWindow } from '../widget/helpers/popoutHelper';
import addHours from 'date-fns/addHours';
const updateAuthCookie = cookieContent =>
Cookies.set('cw_conversation', cookieContent, {
expires: 365,
sameSite: 'Lax',
const updateAuthCookie = (cookieContent, baseDomain = '') =>
setCookieWithDomain('cw_conversation', cookieContent, {
baseDomain,
});
const updateCampaignReadStatus = () => {
const updateCampaignReadStatus = baseDomain => {
const expireBy = addHours(new Date(), 1);
Cookies.set('cw_snooze_campaigns_till', Number(expireBy), {
setCookieWithDomain('cw_snooze_campaigns_till', Number(expireBy), {
expires: expireBy,
sameSite: 'Lax',
baseDomain,
});
};
@@ -154,7 +153,7 @@ export const IFrameHelper = {
events: {
loaded: message => {
updateAuthCookie(message.config.authToken);
updateAuthCookie(message.config.authToken, window.$chatwoot.baseDomain);
window.$chatwoot.hasLoaded = true;
const campaignsSnoozedTill = Cookies.get('cw_snooze_campaigns_till');
IFrameHelper.sendMessage('config-set', {
@@ -200,11 +199,11 @@ export const IFrameHelper = {
},
setAuthCookie({ data: { widgetAuthToken } }) {
updateAuthCookie(widgetAuthToken);
updateAuthCookie(widgetAuthToken, window.$chatwoot.baseDomain);
},
setCampaignReadOn() {
updateCampaignReadStatus();
updateCampaignReadStatus(window.$chatwoot.baseDomain);
},
toggleBubble: state => {
+15
View File
@@ -1,4 +1,5 @@
import md5 from 'md5';
import Cookies from 'js-cookie';
const REQUIRED_USER_KEYS = ['avatar_url', 'email', 'name'];
const ALLOWED_USER_ATTRIBUTES = [...REQUIRED_USER_KEYS, 'identifier_hash'];
@@ -21,3 +22,17 @@ export const computeHashForUserData = (...args) => md5(getUserString(...args));
export const hasUserKeys = user =>
REQUIRED_USER_KEYS.reduce((acc, key) => acc || !!user[key], false);
export const setCookieWithDomain = (
name,
value,
{ expires = 365, baseDomain = undefined } = {}
) => {
const cookieOptions = {
expires,
sameSite: 'Lax',
domain: baseDomain,
};
Cookies.set(name, value, cookieOptions);
};
@@ -1,7 +1,9 @@
import Cookies from 'js-cookie';
import {
getUserCookieName,
getUserString,
hasUserKeys,
setCookieWithDomain,
} from '../cookieHelpers';
describe('#getUserCookieName', () => {
@@ -47,3 +49,78 @@ describe('#hasUserKeys', () => {
expect(hasUserKeys({ avatar_url: 'randomValue' })).toBe(true);
});
});
// Mock the 'set' method of the 'Cookies' object
jest.mock('js-cookie', () => ({
set: jest.fn(),
}));
describe('setCookieWithDomain', () => {
afterEach(() => {
// Clear mock calls after each test
Cookies.set.mockClear();
});
it('should set a cookie with default parameters', () => {
setCookieWithDomain('myCookie', 'cookieValue');
expect(Cookies.set).toHaveBeenCalledWith(
'myCookie',
'cookieValue',
expect.objectContaining({
expires: 365,
sameSite: 'Lax',
domain: undefined,
})
);
});
it('should set a cookie with custom expiration and sameSite attribute', () => {
setCookieWithDomain('myCookie', 'cookieValue', {
expires: 30,
});
expect(Cookies.set).toHaveBeenCalledWith(
'myCookie',
'cookieValue',
expect.objectContaining({
expires: 30,
sameSite: 'Lax',
domain: undefined,
})
);
});
it('should set a cookie with a specific base domain', () => {
setCookieWithDomain('myCookie', 'cookieValue', {
baseDomain: 'example.com',
});
expect(Cookies.set).toHaveBeenCalledWith(
'myCookie',
'cookieValue',
expect.objectContaining({
expires: 365,
sameSite: 'Lax',
domain: 'example.com',
})
);
});
it('should set a cookie with custom expiration, sameSite attribute, and specific base domain', () => {
setCookieWithDomain('myCookie', 'cookieValue', {
expires: 7,
baseDomain: 'example.com',
});
expect(Cookies.set).toHaveBeenCalledWith(
'myCookie',
'cookieValue',
expect.objectContaining({
expires: 7,
sameSite: 'Lax',
domain: 'example.com',
})
);
});
});
@@ -94,6 +94,14 @@ export const hasPressedArrowDownKey = e => {
return e.keyCode === 40;
};
export const hasPressedArrowLeftKey = e => {
return e.keyCode === 37;
};
export const hasPressedArrowRightKey = e => {
return e.keyCode === 39;
};
export const hasPressedCommandPlusKKey = e => {
return e.metaKey && e.keyCode === 75;
};
@@ -4,11 +4,11 @@ export const LocalStorage = {
},
get(key) {
const value = window.localStorage.getItem(key);
try {
const value = window.localStorage.getItem(key);
return typeof value === 'string' ? JSON.parse(value) : value;
} catch (error) {
return value;
return undefined;
}
},
set(key, value) {
+47
View File
@@ -0,0 +1,47 @@
class Account::ContactsExportJob < ApplicationJob
queue_as :low
def perform(account_id, column_names)
account = Account.find(account_id)
headers = valid_headers(column_names)
generate_csv(account, headers)
file_url = account_contact_export_url(account)
AdministratorNotifications::ChannelNotificationsMailer.with(account: account).contact_export_complete(file_url)&.deliver_later
end
def generate_csv(account, headers)
csv_data = CSV.generate do |csv|
csv << headers
account.contacts.each do |contact|
csv << headers.map { |header| contact.send(header) }
end
end
attach_export_file(account, csv_data)
end
def valid_headers(column_names)
columns = (column_names.presence || default_columns)
headers = columns.map { |column| column if Contact.column_names.include?(column) }
headers.compact
end
def attach_export_file(account, csv_data)
return if csv_data.blank?
account.contacts_export.attach(
io: StringIO.new(csv_data),
filename: "#{account.name}_#{account.id}_contacts.csv",
content_type: 'text/csv'
)
end
def account_contact_export_url(account)
Rails.application.routes.url_helpers.rails_blob_url(account.contacts_export)
end
def default_columns
%w[id name email phone_number]
end
end
+15 -2
View File
@@ -40,14 +40,16 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
received_mails(imap_inbox).each do |message_id|
inbound_mail = Mail.read_from_string imap_inbox.fetch(message_id, 'RFC822')[0].attr['RFC822']
mail_info_logger(channel, inbound_mail, message_id)
next if email_already_present?(channel, inbound_mail, last_email_time)
process_mail(inbound_mail, channel)
end
end
def email_already_present?(channel, inbound_mail, last_email_time)
processed_email?(inbound_mail, last_email_time) || channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?
def email_already_present?(channel, inbound_mail, _last_email_time)
channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?
end
def received_mails(imap_inbox)
@@ -76,12 +78,21 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
received_mails(imap_inbox).each do |message_id|
inbound_mail = Mail.read_from_string imap_inbox.fetch(message_id, 'RFC822')[0].attr['RFC822']
mail_info_logger(channel, inbound_mail, message_id)
next if channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?
process_mail(inbound_mail, channel)
end
end
def mail_info_logger(channel, inbound_mail, message_id)
return if Rails.env.test?
Rails.logger.info("
#{channel.provider} Email id: #{inbound_mail.from} and message_source_id: #{inbound_mail.message_id}, message_id: #{message_id}")
end
def authenticated_imap_inbox(channel, access_token, auth_method)
imap = Net::IMAP.new(channel.imap_address, channel.imap_port, true)
imap.authenticate(auth_method, channel.imap_login, access_token)
@@ -113,6 +124,8 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
Imap::ImapMailbox.new.process(inbound_mail, channel)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
Rails.logger.error("
#{channel.provider} Email dropped: #{inbound_mail.from} and message_source_id: #{inbound_mail.message_id}")
end
# Making sure the access token is valid for microsoft provider
+29 -17
View File
@@ -35,9 +35,9 @@ class Imap::ImapMailbox
end
def find_conversation_by_in_reply_to
return if in_reply_to.blank? && @inbound_mail.references.blank?
return if in_reply_to.blank?
message = @inbox.messages.find_by(source_id: in_reply_to) || find_message_by_references
message = @inbox.messages.find_by(source_id: in_reply_to)
if message.nil?
@inbox.conversations.where("additional_attributes->>'in_reply_to' = ?", in_reply_to).first
else
@@ -45,6 +45,16 @@ class Imap::ImapMailbox
end
end
def find_conversation_by_reference_ids
return if @inbound_mail.references.blank? && in_reply_to.present?
message = find_message_by_references
return if message.nil?
@inbox.conversations.find(message.conversation_id)
end
def in_reply_to
@inbound_mail.in_reply_to
end
@@ -52,9 +62,7 @@ class Imap::ImapMailbox
def find_message_by_references
message_to_return = nil
return if @inbound_mail.references.blank?
references = @inbound_mail.references
references = Array.wrap(@inbound_mail.references)
references.each do |message_id|
message = @inbox.messages.find_by(source_id: message_id)
@@ -64,18 +72,22 @@ class Imap::ImapMailbox
end
def find_or_create_conversation
@conversation = find_conversation_by_in_reply_to || ::Conversation.create!({ account_id: @account.id,
inbox_id: @inbox.id,
contact_id: @contact.id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: {
source: 'email',
in_reply_to: in_reply_to,
mail_subject: @processed_mail.subject,
initiated_at: {
timestamp: Time.now.utc
}
} })
@conversation = find_conversation_by_in_reply_to || find_conversation_by_reference_ids || ::Conversation.create!(
{
account_id: @account.id,
inbox_id: @inbox.id,
contact_id: @contact.id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: {
source: 'email',
in_reply_to: in_reply_to,
mail_subject: @processed_mail.subject,
initiated_at: {
timestamp: Time.now.utc
}
}
}
)
end
def find_or_create_contact
+43 -5
View File
@@ -24,15 +24,53 @@ module MailboxHelper
return if @message.blank?
processed_mail.attachments.last(Message::NUMBER_OF_PERMITTED_ATTACHMENTS).each do |mail_attachment|
attachment = @message.attachments.new(
account_id: @conversation.account_id,
file_type: 'file'
)
attachment.file.attach(mail_attachment[:blob])
if inline_attachment?(mail_attachment)
embed_inline_image_source(mail_attachment)
else
attachment = @message.attachments.new(
account_id: @conversation.account_id,
file_type: 'file'
)
attachment.file.attach(mail_attachment[:blob])
end
end
@message.save!
end
def embed_inline_image_source(mail_attachment)
if processed_mail.serialized_data[:html_content].present?
upload_inline_image(mail_attachment)
elsif processed_mail.serialized_data[:text_content].present?
embed_plain_text_email_with_inline_image(mail_attachment)
end
end
def upload_inline_image(mail_attachment)
content_id = mail_attachment[:original].cid
@message.content_attributes[:email][:html_content][:full] = processed_mail.serialized_data[:html_content][:full].gsub(
"cid:#{content_id}", inline_image_url(mail_attachment[:blob]).to_s
)
@message.save!
end
def inline_attachment?(mail_attachment)
mail_attachment[:original].inline?
end
def embed_plain_text_email_with_inline_image(mail_attachment)
attachment_name = mail_attachment[:original].filename
@message.content_attributes[:email][:text_content][:full] = processed_mail.serialized_data[:text_content][:reply].gsub(
"[image: #{attachment_name}]", "<img src=\"#{inline_image_url(mail_attachment[:blob])}\" alt=\"#{attachment_name}>\""
)
@message.save!
end
def inline_image_url(blob)
Rails.application.routes.url_helpers.url_for(blob)
end
def create_contact
@contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: processed_mail.original_sender,
@@ -43,6 +43,14 @@ class AdministratorNotifications::ChannelNotificationsMailer < ApplicationMailer
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
def contact_export_complete(file_url)
return unless smtp_config_set_or_development?
@action_url = file_url
subject = "Your contact's export file is available to download."
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
private
def admin_emails
+11 -11
View File
@@ -79,8 +79,12 @@ class ConversationReplyMailer < ApplicationMailer
@conversation.messages.chat.where.not(message_type: :incoming)&.last
end
def assignee_name
@assignee_name ||= @agent&.available_name || 'Notifications'
def sender_name
@sender_name ||= current_message&.sender&.name || @agent&.available_name || 'Notifications'
end
def current_message
@message || @messages.last
end
def mail_subject
@@ -97,7 +101,7 @@ class ConversationReplyMailer < ApplicationMailer
def reply_email
if should_use_conversation_email_address?
I18n.t('conversations.reply.email.header.reply_with_name', assignee_name: assignee_name, inbox_name: @inbox.name,
I18n.t('conversations.reply.email.header.reply_with_name', assignee_name: sender_name, inbox_name: @inbox.name,
reply_email: "#{@conversation.uuid}@#{@account.inbound_email_domain}")
else
@inbox.email_address || @agent&.email
@@ -106,21 +110,17 @@ class ConversationReplyMailer < ApplicationMailer
def from_email_with_name
if should_use_conversation_email_address?
I18n.t('conversations.reply.email.header.from_with_name', assignee_name: assignee_name, inbox_name: @inbox.name,
I18n.t('conversations.reply.email.header.from_with_name', assignee_name: sender_name, inbox_name: @inbox.name,
from_email: parse_email(@account.support_email))
else
I18n.t('conversations.reply.email.header.from_with_name', assignee_name: assignee_name, inbox_name: @inbox.name,
I18n.t('conversations.reply.email.header.from_with_name', assignee_name: sender_name, inbox_name: @inbox.name,
from_email: parse_email(inbox_from_email_address))
end
end
def channel_email_with_name
if @conversation.assignee.present?
I18n.t('conversations.reply.channel_email.header.reply_with_name', assignee_name: assignee_name, inbox_name: @inbox.name,
from_email: @channel.email)
else
I18n.t('conversations.reply.channel_email.header.reply_with_inbox_name', inbox_name: @inbox.name, from_email: @channel.email)
end
I18n.t('conversations.reply.channel_email.header.reply_with_name', assignee_name: sender_name, inbox_name: @inbox.name,
from_email: @channel.email)
end
def parse_email(email_string)
+2
View File
@@ -77,6 +77,8 @@ class Account < ApplicationRecord
has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp'
has_many :working_hours, dependent: :destroy_async
has_one_attached :contacts_export
enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h
enum status: { active: 0, suspended: 1 }
+6
View File
@@ -50,6 +50,12 @@ class Channel::Whatsapp < ApplicationRecord
true
end
def mark_message_templates_updated
# rubocop:disable Rails/SkipsModelValidations
update_column(:message_templates_last_updated, Time.zone.now)
# rubocop:enable Rails/SkipsModelValidations
end
delegate :send_message, to: :provider_service
delegate :send_template, to: :provider_service
delegate :sync_templates, to: :provider_service
-1
View File
@@ -2,7 +2,6 @@ module Liquidable
extend ActiveSupport::Concern
included do
acts_as_taggable_on :labels
before_create :process_liquid_in_content
end
@@ -6,7 +6,7 @@ module MessageFilterHelpers
end
def webhook_sendable?
incoming? || outgoing?
incoming? || outgoing? || template?
end
def slack_hook_sendable?
+1 -1
View File
@@ -19,7 +19,7 @@
# index_contacts_on_account_id (account_id)
# index_contacts_on_lower_email_account_id (lower((email)::text), account_id)
# index_contacts_on_name_email_phone_number_identifier (name,email,phone_number,identifier) USING gin
# index_contacts_on_identifiable_fields (account_id,email,phone_number,identifier) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text)) # rubocop:disable Layout/LineLength
# index_contacts_on_nonempty_fields (account_id,email,phone_number,identifier) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text)) # rubocop:disable Layout/LineLength
# index_contacts_on_phone_number_and_account_id (phone_number,account_id)
# uniq_email_per_account_contact (email,account_id) UNIQUE
# uniq_identifier_per_account_contact (identifier,account_id) UNIQUE
+1
View File
@@ -69,6 +69,7 @@ class Conversation < ApplicationRecord
scope :unassigned, -> { where(assignee_id: nil) }
scope :assigned, -> { where.not(assignee_id: nil) }
scope :assigned_to, ->(agent) { where(assignee_id: agent.id) }
scope :unattended, -> { where(first_reply_created_at: nil) }
scope :resolvable, lambda { |auto_resolve_duration|
return none if auto_resolve_duration.to_i.zero?
+28 -17
View File
@@ -2,23 +2,24 @@
#
# Table name: messages
#
# id :integer not null, primary key
# additional_attributes :jsonb
# content :text
# content_attributes :json
# content_type :integer default("text"), not null
# external_source_ids :jsonb
# message_type :integer not null
# private :boolean default(FALSE)
# sender_type :string
# status :integer default("sent")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# conversation_id :integer not null
# inbox_id :integer not null
# sender_id :bigint
# source_id :string
# id :integer not null, primary key
# additional_attributes :jsonb
# content :text
# content_attributes :json
# content_type :integer default("text"), not null
# external_source_ids :jsonb
# message_type :integer not null
# private :boolean default(FALSE)
# processed_message_content :text
# sender_type :string
# status :integer default("sent")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# conversation_id :integer not null
# inbox_id :integer not null
# sender_id :bigint
# source_id :string
#
# Indexes
#
@@ -57,6 +58,7 @@ class Message < ApplicationRecord
}.to_json.freeze
before_validation :ensure_content_type
before_save :ensure_processed_message_content
validates :account_id, presence: true
validates :inbox_id, presence: true
@@ -68,6 +70,7 @@ class Message < ApplicationRecord
validates :content_type, presence: true
validates :content, length: { maximum: 150_000 }
validates :processed_message_content, length: { maximum: 150_000 }
# when you have a temperory id in your frontend and want it echoed back via action cable
attr_accessor :echo_id
@@ -214,6 +217,14 @@ class Message < ApplicationRecord
private
def ensure_processed_message_content
text_content_quoted = content_attributes.dig(:email, :text_content, :quoted)
html_content_quoted = content_attributes.dig(:email, :html_content, :quoted)
message_content = text_content_quoted || html_content_quoted || content
self.processed_message_content = message_content&.truncate(150_000)
end
def ensure_content_type
self.content_type ||= Message.content_types[:text]
end
+7 -6
View File
@@ -17,12 +17,13 @@
#
# Indexes
#
# index_reporting_events_on_account_id (account_id)
# index_reporting_events_on_conversation_id (conversation_id)
# index_reporting_events_on_created_at (created_at)
# index_reporting_events_on_inbox_id (inbox_id)
# index_reporting_events_on_name (name)
# index_reporting_events_on_user_id (user_id)
# index_reporting_events_on_account_id (account_id)
# index_reporting_events_on_conversation_id (conversation_id)
# index_reporting_events_on_created_at (created_at)
# index_reporting_events_on_inbox_id (inbox_id)
# index_reporting_events_on_name (name)
# index_reporting_events_on_user_id (user_id)
# reporting_events__account_id__name__created_at (account_id,name,created_at)
#
class ReportingEvent < ApplicationRecord
+2
View File
@@ -54,3 +54,5 @@ class Team < ApplicationRecord
}
end
end
Team.include_mod_with('Audit::Team')
+4
View File
@@ -11,6 +11,10 @@ class ContactPolicy < ApplicationPolicy
@account_user.administrator?
end
def export?
@account_user.administrator?
end
def search?
true
end
+2
View File
@@ -70,6 +70,8 @@ class MailPresenter < SimpleDelegator
}
end
# check content disposition check
# if inline, upload to AWS and and take the URL
def attachments
# ref : https://github.com/gorails-screencasts/action-mailbox-action-text/blob/master/app/mailboxes/posts_mailbox.rb
mail.attachments.map do |attachment|
@@ -27,6 +27,7 @@ class AutomationRules::ConditionsFilterService < FilterService
end
records = base_relation.where(@query_string, @filter_values.with_indifferent_access)
records = perform_attribute_changed_filter(records) if @attribute_changed_query_filter.any?
records.any?
@@ -93,6 +94,8 @@ class AutomationRules::ConditionsFilterService < FilterService
attribute_key = query_hash['attribute_key']
query_operator = query_hash['query_operator']
attribute_key = 'processed_message_content' if attribute_key == 'content'
filter_operator_value = filter_operation(query_hash, current_index)
case current_filter['attribute_type']
@@ -26,4 +26,54 @@ class Whatsapp::Providers::BaseService
def validate_provider_config
raise 'Overwrite this method in child class'
end
def create_buttons(items)
buttons = []
items.each do |item|
button = { :type => 'reply', 'reply' => { 'id' => item['value'], 'title' => item['title'] } }
buttons << button
end
buttons
end
def create_rows(items)
rows = []
items.each do |item|
row = { 'id' => item['value'], 'title' => item['title'] }
rows << row
end
rows
end
def create_payload(type, message_content, action)
{
'type': type,
'body': {
'text': message_content
},
'action': action
}
end
def create_payload_based_on_items(message)
if message.content_attributes['items'].length <= 3
create_button_payload(message)
else
create_list_payload(message)
end
end
def create_button_payload(message)
buttons = create_buttons(message.content_attributes['items'])
json_hash = { 'buttons' => buttons }
create_payload('button', message.content, JSON.generate(json_hash))
end
def create_list_payload(message)
rows = create_rows(message.content_attributes['items'])
section1 = { 'rows' => rows }
sections = [section1]
json_hash = { :button => 'Choose an item', 'sections' => sections }
create_payload('list', message.content, JSON.generate(json_hash))
end
end
@@ -2,6 +2,8 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
def send_message(phone_number, message)
if message.attachments.present?
send_attachment_message(phone_number, message)
elsif message.content_type == 'input_select'
send_interactive_text_message(phone_number, message)
else
send_text_message(phone_number, message)
end
@@ -22,9 +24,10 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
end
def sync_templates
# ensuring that channels with wrong provider config wouldn't keep trying to sync templates
whatsapp_channel.mark_message_templates_updated
response = HTTParty.get("#{api_base_path}/configs/templates", headers: api_headers)
whatsapp_channel[:message_templates] = response['waba_templates'] if response.success?
whatsapp_channel.update(message_templates_last_updated: Time.now.utc)
whatsapp_channel.update(message_templates: response['waba_templates'], message_templates_last_updated: Time.now.utc) if response.success?
end
def validate_provider_config?
@@ -111,4 +114,20 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
}]
}
end
def send_interactive_text_message(phone_number, message)
payload = create_payload_based_on_items(message)
response = HTTParty.post(
"#{api_base_path}/messages",
headers: api_headers,
body: {
to: phone_number,
interactive: payload,
type: 'interactive'
}.to_json
)
process_response(response)
end
end
@@ -2,6 +2,8 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
def send_message(phone_number, message)
if message.attachments.present?
send_attachment_message(phone_number, message)
elsif message.content_type == 'input_select'
send_interactive_text_message(phone_number, message)
else
send_text_message(phone_number, message)
end
@@ -23,9 +25,10 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
end
def sync_templates
# ensuring that channels with wrong provider config wouldn't keep trying to sync templates
whatsapp_channel.mark_message_templates_updated
templates = fetch_whatsapp_templates("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}")
whatsapp_channel[:message_templates] = templates if templates.present?
whatsapp_channel.update(message_templates_last_updated: Time.now.utc)
whatsapp_channel.update(message_templates: templates, message_templates_last_updated: Time.now.utc) if templates.present?
end
def fetch_whatsapp_templates(url)
@@ -128,4 +131,21 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
}]
}
end
def send_interactive_text_message(phone_number, message)
payload = create_payload_based_on_items(message)
response = HTTParty.post(
"#{phone_id_path}/messages",
headers: api_headers,
body: {
messaging_product: 'whatsapp',
to: phone_number,
interactive: payload,
type: 'interactive'
}.to_json
)
process_response(response)
end
end
@@ -1 +1,10 @@
json.payload @attachments.map(&:push_event_data)
json.payload @attachments do |attachment|
json.message_id attachment.push_event_data[:message_id]
json.thumb_url attachment.push_event_data[:thumb_url]
json.data_url attachment.push_event_data[:data_url]
json.file_size attachment.push_event_data[:file_size]
json.file_type attachment.push_event_data[:file_type]
json.extension attachment.push_event_data[:extension]
json.created_at attachment.message.created_at.to_i
json.sender attachment.message.sender.push_event_data if attachment.message.sender
end
@@ -6,6 +6,7 @@ json.data do
json.display_name resource.display_name
json.email resource.email
json.account_id @account.id
json.created_at resource.created_at
json.pubsub_token resource.pubsub_token
json.role resource.active_account_user&.role
json.inviter_id resource.active_account_user&.inviter_id
@@ -22,10 +22,11 @@ if conversation.messages.first.blank?
elsif conversation.unread_incoming_messages.count.zero?
json.messages [conversation.messages.includes([{ attachments: [{ file_attachment: [:blob] }] }]).last.try(:push_event_data)]
else
json.messages conversation.unread_messages.includes([:user, { attachments: [{ file_attachment: [:blob] }] }]).last(10).map(&:push_event_data)
json.messages conversation.unread_messages.includes([{ attachments: [{ file_attachment: [:blob] }] }]).last(10).map(&:push_event_data)
end
json.account_id conversation.account_id
json.uuid conversation.uuid
json.additional_attributes conversation.additional_attributes
json.agent_last_seen_at conversation.agent_last_seen_at.to_i
json.assignee_last_seen_at conversation.assignee_last_seen_at.to_i
@@ -1,4 +1,5 @@
json.id conversation.display_id
json.uuid conversation.uuid
json.created_at conversation.created_at.to_i
json.contact do
json.id conversation.contact.id
@@ -0,0 +1,8 @@
<p>Hi</p>
<p>Your contact export file is ready to download.</p>
<p>
Click <a href="{{ action_url }}">here</a> to download the export file.
</p>
@@ -7,7 +7,7 @@
<tr>
<td style="padding-bottom: 16px;">
<% if message.content %>
<%= CommonMarker.render_html(message.content).html_safe %>
<%= ChatwootMarkdownRenderer.new(message_content).render_message %>
<% end %>
<% if message.attachments %>
<% message.attachments.each do |attachment| %>
@@ -1,5 +1,5 @@
<% if @message.content %>
<%= CommonMarker.render_html(@message.content).html_safe %>
<%= ChatwootMarkdownRenderer.new(@message.content).render_message %>
<% end %>
<% if @message.attachments %>
<% @message.attachments.each do |attachment| %>
@@ -13,7 +13,7 @@
<% if (message.content_type == 'input_csat' && message.message_type == 'template') %>
<p>Click <a href="<%= message.conversation.csat_survey_link %>" _target="blank">here</a> to rate the conversation.</p>
<% elsif message.content.present? %>
<%= CommonMarker.render_html(message.content).html_safe %>
<%= ChatwootMarkdownRenderer.new(message.content).render_message %>
<% end %>
<% if message.attachments.count.positive? %>
<p>
@@ -1,7 +1,7 @@
<% @messages.each do |message| %>
<p style="font-family: Roboto,"Helvetica Neue",Tahoma,Arial,sans-serif; text-align: start; unicode-bidi: plaintext;">
<% if message.content %>
<%= CommonMarker.render_html(message.content).html_safe %>
<%= ChatwootMarkdownRenderer.new(message.content).render_message %>
<% end %>
<% if message.attachments %>
<% message.attachments.each do |attachment| %>
@@ -1,4 +1,5 @@
json.id resource.display_id
json.uuid resource.uuid
json.inbox_id resource.inbox_id
json.contact_last_seen_at resource.contact_last_seen_at.to_i
json.status resource.status
+1
View File
@@ -14,6 +14,7 @@
window.chatwootSettings = {
hideMessageBubble: false,
// baseDomain: '.loca.lt',
position: '<%= @widget_position %>',
locale: 'en',
useBrowserLanguage: true,
+10 -10
View File
@@ -8,21 +8,21 @@ default: &default
variables:
# we are setting this value to be close to the racktimeout value. we will iterate and reduce this value going forward
statement_timeout: <%= ENV["POSTGRES_STATEMENT_TIMEOUT"] || "14s" %>
development:
<<: *default
database: <%= ENV.fetch('POSTGRES_DATABASE', 'chatwoot_dev') %>
username: <%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %>
password: <%= ENV.fetch('POSTGRES_PASSWORD', '') %>
database: "<%= ENV.fetch('POSTGRES_DATABASE', 'chatwoot_dev') %>"
username: "<%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %>"
password: "<%= ENV.fetch('POSTGRES_PASSWORD', '') %>"
test:
<<: *default
database: <%= ENV.fetch('POSTGRES_DATABASE', 'chatwoot_test') %>
username: <%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %>
password: <%= ENV.fetch('POSTGRES_PASSWORD', '') %>
database: "<%= ENV.fetch('POSTGRES_DATABASE', 'chatwoot_test') %>"
username: "<%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %>"
password: "<%= ENV.fetch('POSTGRES_PASSWORD', '') %>"
production:
<<: *default
database: <%= ENV.fetch('POSTGRES_DATABASE', 'chatwoot_production') %>
username: <%= ENV.fetch('POSTGRES_USERNAME', 'chatwoot_prod') %>
password: <%= ENV.fetch('POSTGRES_PASSWORD', 'chatwoot_prod') %>
database: "<%= ENV.fetch('POSTGRES_DATABASE', 'chatwoot_production') %>"
username: "<%= ENV.fetch('POSTGRES_USERNAME', 'chatwoot_prod') %>"
password: "<%= ENV.fetch('POSTGRES_PASSWORD', 'chatwoot_prod') %>"

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