Merge branch 'release/1.1.0'
This commit is contained in:
@@ -13,6 +13,7 @@ defaults: &defaults
|
||||
# CircleCI maintains a library of pre-built images
|
||||
# documented at https://circleci.com/docs/2.0/circleci-images/
|
||||
- image: circleci/postgres:9.4
|
||||
- image: circleci/redis:5.0.7-alpine
|
||||
environment:
|
||||
- CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ plugins:
|
||||
scss-lint:
|
||||
enabled: true
|
||||
brakeman:
|
||||
enabled: true
|
||||
enabled: false
|
||||
checks:
|
||||
similar-code:
|
||||
enabled: false
|
||||
|
||||
10
.env.example
10
.env.example
@@ -19,10 +19,16 @@ FB_VERIFY_TOKEN=
|
||||
FB_APP_SECRET=
|
||||
FB_APP_ID=
|
||||
|
||||
#twitter app
|
||||
TWITTER_CONSUMER_KEY=
|
||||
TWITTER_CONSUMER_SECRET=
|
||||
|
||||
#mail
|
||||
MAILER_SENDER_EMAIL=accounts@chatwoot.com
|
||||
SMTP_PORT=1025
|
||||
SMTP_DOMAIN=chatwoot.com
|
||||
# if you are running docker-compose, set SMTP_ADDRESS value as "mailhog",
|
||||
# else set the value as "localhost"
|
||||
SMTP_ADDRESS=mailhog
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
@@ -42,6 +48,10 @@ AWS_REGION=
|
||||
#sentry
|
||||
SENTRY_DSN=
|
||||
|
||||
# Credentials to access sidekiq dashboard in production
|
||||
SIDEKIQ_AUTH_USERNAME=
|
||||
SIDEKIQ_AUTH_PASSWORD=
|
||||
|
||||
#### This environment variables are only required in hosted version which has billing
|
||||
ENABLE_BILLING=
|
||||
|
||||
|
||||
13
.rubocop.yml
13
.rubocop.yml
@@ -4,16 +4,25 @@ require:
|
||||
- rubocop-rspec
|
||||
inherit_from: .rubocop_todo.yml
|
||||
|
||||
Metrics/LineLength:
|
||||
Layout/LineLength:
|
||||
Max: 150
|
||||
Metrics/ClassLength:
|
||||
Max: 125
|
||||
RSpec/ExampleLength:
|
||||
Max: 15
|
||||
Documentation:
|
||||
Style/Documentation:
|
||||
Enabled: false
|
||||
Style/FrozenStringLiteralComment:
|
||||
Enabled: false
|
||||
Style/SymbolArray:
|
||||
Enabled: false
|
||||
Style/GlobalVars:
|
||||
Exclude:
|
||||
- 'config/initializers/redis.rb'
|
||||
- 'lib/redis/alfred.rb'
|
||||
- 'app/controllers/api/v1/webhooks_controller.rb'
|
||||
- 'app/services/twitter/send_reply_service.rb'
|
||||
- 'spec/services/twitter/send_reply_service_spec.rb'
|
||||
Metrics/BlockLength:
|
||||
Exclude:
|
||||
- spec/**/*
|
||||
|
||||
10
Gemfile
10
Gemfile
@@ -23,6 +23,9 @@ gem 'valid_email2'
|
||||
gem 'uglifier'
|
||||
|
||||
##-- for active storage --##
|
||||
gem 'aws-sdk-s3', require: false
|
||||
gem 'azure-storage', require: false
|
||||
gem 'google-cloud-storage', require: false
|
||||
gem 'mini_magick'
|
||||
|
||||
##-- gems for database --#
|
||||
@@ -58,6 +61,9 @@ gem 'chargebee'
|
||||
gem 'facebook-messenger'
|
||||
gem 'telegram-bot-ruby'
|
||||
gem 'twitter'
|
||||
# twitty will handle subscription of twitter account events
|
||||
gem 'twitty', git: 'https://github.com/chatwoot/twitty'
|
||||
|
||||
# facebook client
|
||||
gem 'koala'
|
||||
# Random name generator
|
||||
@@ -68,9 +74,7 @@ gem 'haikunator'
|
||||
gem 'brakeman'
|
||||
gem 'sentry-raven'
|
||||
|
||||
##-- TODO: move these gems to appropriate groups --##
|
||||
# remove this gem in favor of active storage - github #158
|
||||
gem 'carrierwave-aws'
|
||||
##-- background job processing --##
|
||||
gem 'sidekiq'
|
||||
|
||||
group :development do
|
||||
|
||||
171
Gemfile.lock
171
Gemfile.lock
@@ -1,3 +1,10 @@
|
||||
GIT
|
||||
remote: https://github.com/chatwoot/twitty
|
||||
revision: a005f8f6740fc8d2d3500701e1ab4ab0f1416c26
|
||||
specs:
|
||||
twitty (0.1.0)
|
||||
oauth
|
||||
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
@@ -66,15 +73,15 @@ GEM
|
||||
activerecord (>= 3.2, < 7.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
ast (2.4.0)
|
||||
attr_extras (6.2.1)
|
||||
attr_extras (6.2.2)
|
||||
aws-eventstream (1.0.3)
|
||||
aws-partitions (1.259.0)
|
||||
aws-sdk-core (3.86.0)
|
||||
aws-partitions (1.268.0)
|
||||
aws-sdk-core (3.89.1)
|
||||
aws-eventstream (~> 1.0, >= 1.0.2)
|
||||
aws-partitions (~> 1, >= 1.239.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
jmespath (~> 1.0)
|
||||
aws-sdk-kms (1.27.0)
|
||||
aws-sdk-kms (1.28.0)
|
||||
aws-sdk-core (~> 3, >= 3.71.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.60.1)
|
||||
@@ -87,15 +94,24 @@ GEM
|
||||
descendants_tracker (~> 0.0.4)
|
||||
ice_nine (~> 0.11.0)
|
||||
thread_safe (~> 0.3, >= 0.3.1)
|
||||
azure-core (0.1.15)
|
||||
faraday (~> 0.9)
|
||||
faraday_middleware (~> 0.10)
|
||||
nokogiri (~> 1.6)
|
||||
azure-storage (0.15.0.preview)
|
||||
azure-core (~> 0.1)
|
||||
faraday (~> 0.9)
|
||||
faraday_middleware (~> 0.10)
|
||||
nokogiri (~> 1.6, >= 1.6.8)
|
||||
bcrypt (3.1.13)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.4.5)
|
||||
msgpack (~> 1.0)
|
||||
brakeman (4.7.2)
|
||||
browser (2.7.1)
|
||||
browser (3.0.3)
|
||||
buftok (0.2.0)
|
||||
builder (3.2.4)
|
||||
bullet (6.0.2)
|
||||
bullet (6.1.0)
|
||||
activesupport (>= 3.0.0)
|
||||
uniform_notifier (~> 1.11)
|
||||
bundle-audit (0.1.0)
|
||||
@@ -103,18 +119,8 @@ GEM
|
||||
bundler-audit (0.6.1)
|
||||
bundler (>= 1.2.0, < 3)
|
||||
thor (~> 0.18)
|
||||
byebug (11.0.1)
|
||||
carrierwave (2.0.2)
|
||||
activemodel (>= 5.0.0)
|
||||
activesupport (>= 5.0.0)
|
||||
addressable (~> 2.6)
|
||||
image_processing (~> 1.1)
|
||||
mimemagic (>= 0.3.0)
|
||||
mini_mime (>= 0.1.3)
|
||||
carrierwave-aws (1.4.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
carrierwave (>= 0.7, < 2.1)
|
||||
chargebee (2.7.1)
|
||||
byebug (11.1.1)
|
||||
chargebee (2.7.3)
|
||||
json_pure (~> 2.1)
|
||||
rest-client (>= 1.8, < 3.0)
|
||||
coderay (1.1.2)
|
||||
@@ -122,7 +128,9 @@ GEM
|
||||
descendants_tracker (~> 0.0.1)
|
||||
concurrent-ruby (1.1.5)
|
||||
connection_pool (2.2.2)
|
||||
crass (1.0.5)
|
||||
crass (1.0.6)
|
||||
declarative (0.0.10)
|
||||
declarative-option (0.1.0)
|
||||
descendants_tracker (0.0.4)
|
||||
thread_safe (~> 0.3, >= 0.3.1)
|
||||
devise (4.7.1)
|
||||
@@ -136,6 +144,7 @@ GEM
|
||||
devise (> 3.5.2, < 5)
|
||||
rails (>= 4.2.0, < 6.1)
|
||||
diff-lcs (1.3)
|
||||
digest-crc (0.4.1)
|
||||
docile (1.3.2)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
@@ -154,14 +163,44 @@ GEM
|
||||
factory_bot_rails (5.1.1)
|
||||
factory_bot (~> 5.1.0)
|
||||
railties (>= 4.2.0)
|
||||
faker (2.9.0)
|
||||
i18n (>= 1.6, < 1.8)
|
||||
faraday (0.17.1)
|
||||
faker (2.10.1)
|
||||
i18n (>= 1.6, < 2)
|
||||
faraday (0.17.3)
|
||||
multipart-post (>= 1.2, < 3)
|
||||
ffi (1.11.3)
|
||||
foreman (0.86.0)
|
||||
faraday_middleware (0.14.0)
|
||||
faraday (>= 0.7.4, < 1.0)
|
||||
ffi (1.12.1)
|
||||
foreman (0.87.0)
|
||||
globalid (0.4.2)
|
||||
activesupport (>= 4.2.0)
|
||||
google-api-client (0.36.4)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (~> 0.9)
|
||||
httpclient (>= 2.8.1, < 3.0)
|
||||
mini_mime (~> 1.0)
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.0)
|
||||
signet (~> 0.12)
|
||||
google-cloud-core (1.5.0)
|
||||
google-cloud-env (~> 1.0)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.3.0)
|
||||
faraday (~> 0.11)
|
||||
google-cloud-errors (1.0.0)
|
||||
google-cloud-storage (1.25.1)
|
||||
addressable (~> 2.5)
|
||||
digest-crc (~> 0.4)
|
||||
google-api-client (~> 0.33)
|
||||
google-cloud-core (~> 1.2)
|
||||
googleauth (~> 0.9)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (0.10.0)
|
||||
faraday (~> 0.12)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
memoist (~> 0.16)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (~> 0.12)
|
||||
haikunator (1.1.0)
|
||||
hashie (4.0.0)
|
||||
http (3.3.0)
|
||||
@@ -172,17 +211,15 @@ GEM
|
||||
http-accept (1.7.0)
|
||||
http-cookie (1.0.3)
|
||||
domain_name (~> 0.5)
|
||||
http-form_data (2.1.1)
|
||||
http-form_data (2.2.0)
|
||||
http_parser.rb (0.6.0)
|
||||
httparty (0.17.3)
|
||||
mime-types (~> 3.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
i18n (1.7.0)
|
||||
httpclient (2.8.3)
|
||||
i18n (1.8.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
ice_nine (0.11.2)
|
||||
image_processing (1.10.0)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
ruby-vips (>= 2.0.13, < 3)
|
||||
inflecto (0.0.2)
|
||||
jaro_winkler (1.5.4)
|
||||
jbuilder (2.9.1)
|
||||
@@ -221,19 +258,21 @@ GEM
|
||||
mini_mime (>= 0.1.1)
|
||||
marcel (0.3.3)
|
||||
mimemagic (~> 0.3.2)
|
||||
memoist (0.16.2)
|
||||
memoizable (0.4.2)
|
||||
thread_safe (~> 0.3, >= 0.3.1)
|
||||
method_source (0.9.2)
|
||||
mime-types (3.3)
|
||||
mime-types (3.3.1)
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2019.1009)
|
||||
mimemagic (0.3.3)
|
||||
mini_magick (4.9.5)
|
||||
mini_magick (4.10.1)
|
||||
mini_mime (1.0.2)
|
||||
mini_portile2 (2.4.0)
|
||||
minitest (5.13.0)
|
||||
minitest (5.14.0)
|
||||
mock_redis (0.22.0)
|
||||
msgpack (1.3.1)
|
||||
multi_json (1.14.1)
|
||||
multi_xml (0.6.0)
|
||||
multipart-post (2.1.1)
|
||||
naught (1.1.0)
|
||||
@@ -242,27 +281,29 @@ GEM
|
||||
nio4r (2.5.2)
|
||||
nokogiri (1.10.7)
|
||||
mini_portile2 (~> 2.4.0)
|
||||
oauth (0.5.4)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.0.1)
|
||||
parallel (1.19.1)
|
||||
parser (2.6.5.0)
|
||||
parser (2.7.0.2)
|
||||
ast (~> 2.4.0)
|
||||
pg (1.1.4)
|
||||
pg (1.2.2)
|
||||
pry (0.12.2)
|
||||
coderay (~> 1.1.0)
|
||||
method_source (~> 0.9.0)
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (4.0.1)
|
||||
public_suffix (4.0.3)
|
||||
puma (4.3.1)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.1.0)
|
||||
activesupport (>= 3.0.0)
|
||||
rack (2.0.8)
|
||||
rack-cache (1.10.0)
|
||||
rack (2.1.1)
|
||||
rack-cache (1.11.0)
|
||||
rack (>= 0.4)
|
||||
rack-cors (1.1.0)
|
||||
rack-cors (1.1.1)
|
||||
rack (>= 2.0.0)
|
||||
rack-protection (2.0.7)
|
||||
rack-protection (2.0.8.1)
|
||||
rack
|
||||
rack-proxy (0.6.5)
|
||||
rack
|
||||
@@ -297,7 +338,7 @@ GEM
|
||||
rainbow (3.0.0)
|
||||
rake (13.0.1)
|
||||
rb-fsevent (0.10.3)
|
||||
rb-inotify (0.10.0)
|
||||
rb-inotify (0.10.1)
|
||||
ffi (~> 1.0)
|
||||
redis (4.1.3)
|
||||
redis-namespace (1.7.0)
|
||||
@@ -307,6 +348,10 @@ GEM
|
||||
redis-store (>= 1.6, < 2)
|
||||
redis-store (1.8.1)
|
||||
redis (>= 4, < 5)
|
||||
representable (3.0.4)
|
||||
declarative (< 0.1.0)
|
||||
declarative-option (< 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
responders (3.0.0)
|
||||
actionpack (>= 5.0)
|
||||
railties (>= 5.0)
|
||||
@@ -315,52 +360,56 @@ GEM
|
||||
http-cookie (>= 1.0.2, < 2.0)
|
||||
mime-types (>= 1.16, < 4.0)
|
||||
netrc (~> 0.8)
|
||||
rspec-core (3.9.0)
|
||||
rspec-support (~> 3.9.0)
|
||||
retriable (3.1.2)
|
||||
rspec-core (3.9.1)
|
||||
rspec-support (~> 3.9.1)
|
||||
rspec-expectations (3.9.0)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.9.0)
|
||||
rspec-mocks (3.9.0)
|
||||
rspec-mocks (3.9.1)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.9.0)
|
||||
rspec-rails (4.0.0.beta3)
|
||||
rspec-rails (4.0.0.beta4)
|
||||
actionpack (>= 4.2)
|
||||
activesupport (>= 4.2)
|
||||
railties (>= 4.2)
|
||||
rspec-core (~> 3.8)
|
||||
rspec-expectations (~> 3.8)
|
||||
rspec-mocks (~> 3.8)
|
||||
rspec-support (~> 3.8)
|
||||
rspec-support (3.9.0)
|
||||
rubocop (0.78.0)
|
||||
rspec-core (~> 3.9)
|
||||
rspec-expectations (~> 3.9)
|
||||
rspec-mocks (~> 3.9)
|
||||
rspec-support (~> 3.9)
|
||||
rspec-support (3.9.2)
|
||||
rubocop (0.79.0)
|
||||
jaro_winkler (~> 1.5.1)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 2.6)
|
||||
parser (>= 2.7.0.1)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 1.4.0, < 1.7)
|
||||
rubocop-performance (1.5.1)
|
||||
rubocop-performance (1.5.2)
|
||||
rubocop (>= 0.71.0)
|
||||
rubocop-rails (2.4.0)
|
||||
rubocop-rails (2.4.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 0.72.0)
|
||||
rubocop-rspec (1.37.1)
|
||||
rubocop (>= 0.68.1)
|
||||
ruby-progressbar (1.10.1)
|
||||
ruby-vips (2.0.16)
|
||||
ffi (~> 1.9)
|
||||
seed_dump (3.3.1)
|
||||
activerecord (>= 4)
|
||||
activesupport (>= 4)
|
||||
sentry-raven (2.13.0)
|
||||
faraday (>= 0.7.6, < 1.0)
|
||||
shoulda-matchers (4.1.2)
|
||||
shoulda-matchers (4.2.0)
|
||||
activesupport (>= 4.2.0)
|
||||
sidekiq (6.0.4)
|
||||
connection_pool (>= 2.2.2)
|
||||
rack (>= 2.0.0)
|
||||
rack-protection (>= 2.0.0)
|
||||
redis (>= 4.1.0)
|
||||
signet (0.12.0)
|
||||
addressable (~> 2.3)
|
||||
faraday (~> 0.9)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simple_oauth (0.3.1)
|
||||
simplecov (0.17.1)
|
||||
docile (~> 1.1)
|
||||
@@ -398,16 +447,17 @@ GEM
|
||||
multipart-post (~> 2.0)
|
||||
naught (~> 1.0)
|
||||
simple_oauth (~> 0.3.0)
|
||||
tzinfo (1.2.5)
|
||||
tzinfo (1.2.6)
|
||||
thread_safe (~> 0.1)
|
||||
tzinfo-data (1.2019.3)
|
||||
tzinfo (>= 1.0.0)
|
||||
uber (0.1.0)
|
||||
uglifier (4.2.0)
|
||||
execjs (>= 0.3.0, < 3)
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.7.6)
|
||||
unicode-display_width (1.6.0)
|
||||
unicode-display_width (1.6.1)
|
||||
uniform_notifier (1.13.0)
|
||||
valid_email2 (3.1.3)
|
||||
activemodel (>= 3.2)
|
||||
@@ -442,13 +492,14 @@ DEPENDENCIES
|
||||
acts-as-taggable-on
|
||||
annotate
|
||||
attr_extras
|
||||
aws-sdk-s3
|
||||
azure-storage
|
||||
bootsnap
|
||||
brakeman
|
||||
browser
|
||||
bullet
|
||||
bundle-audit
|
||||
byebug
|
||||
carrierwave-aws
|
||||
chargebee
|
||||
devise
|
||||
devise_token_auth
|
||||
@@ -457,6 +508,7 @@ DEPENDENCIES
|
||||
factory_bot_rails
|
||||
faker
|
||||
foreman
|
||||
google-cloud-storage
|
||||
haikunator
|
||||
hashie
|
||||
jbuilder
|
||||
@@ -493,6 +545,7 @@ DEPENDENCIES
|
||||
telegram-bot-ruby
|
||||
time_diff
|
||||
twitter
|
||||
twitty!
|
||||
tzinfo-data
|
||||
uglifier
|
||||
valid_email2
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
backend: bin/rails s -p 3000
|
||||
frontend: bin/webpack-dev-server
|
||||
worker: bundle exec sidekiq
|
||||
worker: bundle exec sidekiq -C config/sidekiq.yml
|
||||
|
||||
@@ -37,6 +37,11 @@ Detailed documentation is available at [www.chatwoot.com/docs](https://www.chatw
|
||||
|
||||
You can find the quick setup docs [here](https://www.chatwoot.com/docs/quick-setup).
|
||||
|
||||
## Branching model
|
||||
|
||||
We use [git-flow](https://nvie.com/posts/a-successful-git-branching-model/) branching model. The base branch is `develop`.
|
||||
If you are looking for a stable version, please use the `master` or tags labelled as `v1.x.x`.
|
||||
|
||||
## Heroku one-click deploy
|
||||
|
||||
Deploying chatwoot to heroku, it's a breeze. It's as simple as clicking this button.
|
||||
|
||||
@@ -6,125 +6,137 @@ require 'open-uri'
|
||||
# based on this we are showing "not sent from chatwoot" message in frontend
|
||||
# Hence there is no need to set user_id in message for outgoing echo messages.
|
||||
|
||||
module Messages
|
||||
class MessageBuilder
|
||||
attr_reader :response
|
||||
class Messages::MessageBuilder
|
||||
attr_reader :response
|
||||
|
||||
def initialize(response, inbox, outgoing_echo = false)
|
||||
@response = response
|
||||
@inbox = inbox
|
||||
@sender_id = (outgoing_echo ? @response.recipient_id : @response.sender_id)
|
||||
@message_type = (outgoing_echo ? :outgoing : :incoming)
|
||||
def initialize(response, inbox, outgoing_echo = false)
|
||||
@response = response
|
||||
@inbox = inbox
|
||||
@sender_id = (outgoing_echo ? @response.recipient_id : @response.sender_id)
|
||||
@message_type = (outgoing_echo ? :outgoing : :incoming)
|
||||
end
|
||||
|
||||
def perform
|
||||
ActiveRecord::Base.transaction do
|
||||
build_contact
|
||||
build_message
|
||||
end
|
||||
rescue StandardError => e
|
||||
Raven.capture_exception(e)
|
||||
true
|
||||
end
|
||||
|
||||
def perform
|
||||
ActiveRecord::Base.transaction do
|
||||
build_contact
|
||||
build_message
|
||||
end
|
||||
rescue StandardError => e
|
||||
Raven.capture_exception(e)
|
||||
true
|
||||
end
|
||||
private
|
||||
|
||||
private
|
||||
def contact
|
||||
@contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact
|
||||
end
|
||||
|
||||
def contact
|
||||
@contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact
|
||||
end
|
||||
def build_contact
|
||||
return if contact.present?
|
||||
|
||||
def build_contact
|
||||
return if contact.present?
|
||||
@contact = Contact.create!(contact_params.except(:remote_avatar_url))
|
||||
avatar_resource = LocalResource.new(contact_params[:remote_avatar_url])
|
||||
@contact.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding)
|
||||
|
||||
@contact = Contact.create!(contact_params)
|
||||
ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id)
|
||||
end
|
||||
@contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id)
|
||||
end
|
||||
|
||||
def build_message
|
||||
@message = conversation.messages.new(message_params)
|
||||
(response.attachments || []).each do |attachment|
|
||||
@message.build_attachment(attachment_params(attachment))
|
||||
end
|
||||
@message.save!
|
||||
end
|
||||
|
||||
def build_attachment; end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Conversation.find_by(conversation_params) || Conversation.create!(conversation_params)
|
||||
end
|
||||
|
||||
def attachment_params(attachment)
|
||||
file_type = attachment['type'].to_sym
|
||||
params = { file_type: file_type, account_id: @message.account_id }
|
||||
|
||||
if [:image, :file, :audio, :video].include? file_type
|
||||
params.merge!(file_type_params(attachment))
|
||||
elsif file_type == :location
|
||||
params.merge!(location_params(attachment))
|
||||
elsif file_type == :fallback
|
||||
params.merge!(fallback_params(attachment))
|
||||
end
|
||||
|
||||
params
|
||||
end
|
||||
|
||||
def file_type_params(attachment)
|
||||
{
|
||||
external_url: attachment['payload']['url'],
|
||||
remote_file_url: attachment['payload']['url']
|
||||
}
|
||||
end
|
||||
|
||||
def location_params(attachment)
|
||||
lat = attachment['payload']['coordinates']['lat']
|
||||
long = attachment['payload']['coordinates']['long']
|
||||
{
|
||||
external_url: attachment['url'],
|
||||
coordinates_lat: lat,
|
||||
coordinates_long: long,
|
||||
fallback_title: attachment['title']
|
||||
}
|
||||
end
|
||||
|
||||
def fallback_params(attachment)
|
||||
{
|
||||
fallback_title: attachment['title'],
|
||||
external_url: attachment['url']
|
||||
}
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
account_id: @inbox.account_id,
|
||||
inbox_id: @inbox.id,
|
||||
contact_id: contact.id
|
||||
}
|
||||
end
|
||||
|
||||
def message_params
|
||||
{
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: @message_type,
|
||||
content: response.content,
|
||||
fb_id: response.identifier
|
||||
}
|
||||
end
|
||||
|
||||
def contact_params
|
||||
begin
|
||||
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
|
||||
result = k.get_object(@sender_id) || {}
|
||||
rescue Exception => e
|
||||
result = {}
|
||||
Raven.capture_exception(e)
|
||||
end
|
||||
{
|
||||
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
|
||||
account_id: @inbox.account_id,
|
||||
remote_avatar_url: result['profile_pic'] || nil
|
||||
}
|
||||
def build_message
|
||||
@message = conversation.messages.create!(message_params)
|
||||
(response.attachments || []).each do |attachment|
|
||||
attachment_obj = @message.build_attachment(attachment_params(attachment).except(:remote_file_url))
|
||||
attachment_obj.save!
|
||||
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
||||
end
|
||||
end
|
||||
|
||||
def attach_file(attachment, file_url)
|
||||
file_resource = LocalResource.new(file_url)
|
||||
attachment.file.attach(io: file_resource.file, filename: file_resource.tmp_filename, content_type: file_resource.encoding)
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
|
||||
end
|
||||
|
||||
def build_conversation
|
||||
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: @sender_id)
|
||||
Conversation.create!(conversation_params.merge(
|
||||
contact_inbox_id: @contact_inbox.id
|
||||
))
|
||||
end
|
||||
|
||||
def attachment_params(attachment)
|
||||
file_type = attachment['type'].to_sym
|
||||
params = { file_type: file_type, account_id: @message.account_id }
|
||||
|
||||
if [:image, :file, :audio, :video].include? file_type
|
||||
params.merge!(file_type_params(attachment))
|
||||
elsif file_type == :location
|
||||
params.merge!(location_params(attachment))
|
||||
elsif file_type == :fallback
|
||||
params.merge!(fallback_params(attachment))
|
||||
end
|
||||
|
||||
params
|
||||
end
|
||||
|
||||
def file_type_params(attachment)
|
||||
{
|
||||
external_url: attachment['payload']['url'],
|
||||
remote_file_url: attachment['payload']['url']
|
||||
}
|
||||
end
|
||||
|
||||
def location_params(attachment)
|
||||
lat = attachment['payload']['coordinates']['lat']
|
||||
long = attachment['payload']['coordinates']['long']
|
||||
{
|
||||
external_url: attachment['url'],
|
||||
coordinates_lat: lat,
|
||||
coordinates_long: long,
|
||||
fallback_title: attachment['title']
|
||||
}
|
||||
end
|
||||
|
||||
def fallback_params(attachment)
|
||||
{
|
||||
fallback_title: attachment['title'],
|
||||
external_url: attachment['url']
|
||||
}
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
account_id: @inbox.account_id,
|
||||
inbox_id: @inbox.id,
|
||||
contact_id: contact.id
|
||||
}
|
||||
end
|
||||
|
||||
def message_params
|
||||
{
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: @message_type,
|
||||
content: response.content,
|
||||
fb_id: response.identifier
|
||||
}
|
||||
end
|
||||
|
||||
def contact_params
|
||||
begin
|
||||
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
|
||||
result = k.get_object(@sender_id) || {}
|
||||
rescue Exception => e
|
||||
result = {}
|
||||
Raven.capture_exception(e)
|
||||
end
|
||||
{
|
||||
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
|
||||
account_id: @inbox.account_id,
|
||||
remote_avatar_url: result['profile_pic'] || ''
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
class RoomChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
stream_from params[:pubsub_token]
|
||||
::OnlineStatusTracker.add_subscription(params[:pubsub_token])
|
||||
end
|
||||
|
||||
def unsubscribed
|
||||
::OnlineStatusTracker.remove_subscription(params[:pubsub_token])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,7 +4,7 @@ class Api::V1::AgentsController < Api::BaseController
|
||||
before_action :build_agent, only: [:create]
|
||||
|
||||
def index
|
||||
render json: agents
|
||||
@agents = agents
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
||||
@@ -12,8 +12,9 @@ class Api::V1::CallbacksController < ApplicationController
|
||||
inbox_name = params[:inbox_name]
|
||||
facebook_channel = current_account.facebook_pages.create!(
|
||||
name: page_name, page_id: page_id, user_access_token: user_access_token,
|
||||
page_access_token: page_access_token, remote_avatar_url: set_avatar(page_id)
|
||||
page_access_token: page_access_token
|
||||
)
|
||||
set_avatar(facebook_channel, page_id)
|
||||
inbox = current_account.inboxes.create!(name: inbox_name, channel: facebook_channel)
|
||||
render json: inbox
|
||||
end
|
||||
@@ -79,7 +80,12 @@ class Api::V1::CallbacksController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def set_avatar(page_id)
|
||||
def set_avatar(facebook_channel, page_id)
|
||||
avatar_resource = LocalResource.new(get_avatar_url(page_id))
|
||||
facebook_channel.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding)
|
||||
end
|
||||
|
||||
def get_avatar_url(page_id)
|
||||
begin
|
||||
url = 'http://graph.facebook.com/' << page_id << '/picture?type=large'
|
||||
uri = URI.parse(url)
|
||||
|
||||
23
app/controllers/api/v1/contacts/conversations_controller.rb
Normal file
23
app/controllers/api/v1/contacts/conversations_controller.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
class Api::V1::Contacts::ConversationsController < Api::BaseController
|
||||
def index
|
||||
@conversations = current_account.conversations.includes(
|
||||
:assignee, :contact, :inbox
|
||||
).where(inbox_id: inbox_ids, contact_id: permitted_params[:contact_id])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def inbox_ids
|
||||
if current_user.administrator?
|
||||
current_account.inboxes.pluck(:id)
|
||||
elsif current_user.agent?
|
||||
current_user.assigned_inboxes.pluck(:id)
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:contact_id)
|
||||
end
|
||||
end
|
||||
@@ -1,55 +1,51 @@
|
||||
module Api
|
||||
module V1
|
||||
class InboxMembersController < Api::BaseController
|
||||
before_action :fetch_inbox, only: [:create, :show]
|
||||
before_action :current_agents_ids, only: [:create]
|
||||
class Api::V1::InboxMembersController < Api::BaseController
|
||||
before_action :fetch_inbox, only: [:create, :show]
|
||||
before_action :current_agents_ids, only: [:create]
|
||||
|
||||
def create
|
||||
# update also done via same action
|
||||
if @inbox
|
||||
begin
|
||||
update_agents_list
|
||||
head :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.debug "Rescued: #{e.inspect}"
|
||||
render_could_not_create_error('Could not add agents to inbox')
|
||||
end
|
||||
else
|
||||
render_not_found_error('Agents or inbox not found')
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
@agents = current_account.users.where(id: @inbox.members.pluck(:user_id))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_agents_list
|
||||
# get all the user_ids which the inbox currently has as members.
|
||||
# get the list of user_ids from params
|
||||
# the missing ones are the agents which are to be deleted from the inbox
|
||||
# the new ones are the agents which are to be added to the inbox
|
||||
|
||||
agents_to_be_added_ids.each { |user_id| @inbox.add_member(user_id) }
|
||||
agents_to_be_removed_ids.each { |user_id| @inbox.remove_member(user_id) }
|
||||
end
|
||||
|
||||
def agents_to_be_added_ids
|
||||
params[:user_ids] - @current_agents_ids
|
||||
end
|
||||
|
||||
def agents_to_be_removed_ids
|
||||
@current_agents_ids - params[:user_ids]
|
||||
end
|
||||
|
||||
def current_agents_ids
|
||||
@current_agents_ids = @inbox.members.pluck(:id)
|
||||
end
|
||||
|
||||
def fetch_inbox
|
||||
@inbox = current_account.inboxes.find(params[:inbox_id])
|
||||
def create
|
||||
# update also done via same action
|
||||
if @inbox
|
||||
begin
|
||||
update_agents_list
|
||||
head :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.debug "Rescued: #{e.inspect}"
|
||||
render_could_not_create_error('Could not add agents to inbox')
|
||||
end
|
||||
else
|
||||
render_not_found_error('Agents or inbox not found')
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
@agents = current_account.users.where(id: @inbox.members.pluck(:user_id))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_agents_list
|
||||
# get all the user_ids which the inbox currently has as members.
|
||||
# get the list of user_ids from params
|
||||
# the missing ones are the agents which are to be deleted from the inbox
|
||||
# the new ones are the agents which are to be added to the inbox
|
||||
|
||||
agents_to_be_added_ids.each { |user_id| @inbox.add_member(user_id) }
|
||||
agents_to_be_removed_ids.each { |user_id| @inbox.remove_member(user_id) }
|
||||
end
|
||||
|
||||
def agents_to_be_added_ids
|
||||
params[:user_ids] - @current_agents_ids
|
||||
end
|
||||
|
||||
def agents_to_be_removed_ids
|
||||
@current_agents_ids - params[:user_ids]
|
||||
end
|
||||
|
||||
def current_agents_ids
|
||||
@current_agents_ids = @inbox.members.pluck(:id)
|
||||
end
|
||||
|
||||
def fetch_inbox
|
||||
@inbox = current_account.inboxes.find(params[:inbox_id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,4 +2,8 @@ class Api::V1::LabelsController < Api::BaseController
|
||||
def index # list all labels in account
|
||||
@labels = current_account.all_conversation_tags
|
||||
end
|
||||
|
||||
def most_used
|
||||
@labels = ActsAsTaggableOn::Tag.most_used(params[:count] || 10)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,7 +3,7 @@ class Api::V1::WebhooksController < ApplicationController
|
||||
skip_before_action :set_current_user
|
||||
skip_before_action :check_subscription
|
||||
|
||||
before_action :login_from_basic_auth
|
||||
before_action :login_from_basic_auth, only: [:chargebee]
|
||||
def chargebee
|
||||
chargebee_consumer.consume
|
||||
head :ok
|
||||
@@ -12,6 +12,18 @@ class Api::V1::WebhooksController < ApplicationController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def twitter_crc
|
||||
render json: { response_token: "sha256=#{$twitter.generate_crc(params[:crc_token])}" }
|
||||
end
|
||||
|
||||
def twitter_events
|
||||
twitter_consumer.consume
|
||||
head :ok
|
||||
rescue StandardError => e
|
||||
Raven.capture_exception(e)
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def login_from_basic_auth
|
||||
@@ -21,6 +33,10 @@ class Api::V1::WebhooksController < ApplicationController
|
||||
end
|
||||
|
||||
def chargebee_consumer
|
||||
@consumer ||= ::Webhooks::Chargebee.new(params)
|
||||
@chargebee_consumer ||= ::Webhooks::Chargebee.new(params)
|
||||
end
|
||||
|
||||
def twitter_consumer
|
||||
@twitter_consumer ||= ::Webhooks::Twitter.new(params)
|
||||
end
|
||||
end
|
||||
|
||||
29
app/controllers/api/v1/widget/base_controller.rb
Normal file
29
app/controllers/api/v1/widget/base_controller.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
class Api::V1::Widget::BaseController < ApplicationController
|
||||
private
|
||||
|
||||
def conversation
|
||||
@conversation ||= @contact_inbox.conversations.find_by(
|
||||
inbox_id: auth_token_params[:inbox_id]
|
||||
)
|
||||
end
|
||||
|
||||
def auth_token_params
|
||||
@auth_token_params ||= ::Widget::TokenService.new(token: request.headers[header_name]).decode_token
|
||||
end
|
||||
|
||||
def header_name
|
||||
'X-Auth-Token'
|
||||
end
|
||||
|
||||
def set_web_widget
|
||||
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
|
||||
@account = @web_widget.account
|
||||
end
|
||||
|
||||
def set_contact
|
||||
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
|
||||
source_id: auth_token_params[:source_id]
|
||||
)
|
||||
@contact = @contact_inbox.contact
|
||||
end
|
||||
end
|
||||
13
app/controllers/api/v1/widget/inbox_members_controller.rb
Normal file
13
app/controllers/api/v1/widget/inbox_members_controller.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
class Api::V1::Widget::InboxMembersController < Api::V1::Widget::BaseController
|
||||
before_action :set_web_widget
|
||||
|
||||
def index
|
||||
@inbox_members = @web_widget.inbox.inbox_members.includes(:user)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def permitted_params
|
||||
params.permit(:website_token)
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,8 @@
|
||||
class Api::V1::Widget::MessagesController < ActionController::Base
|
||||
skip_before_action :verify_authenticity_token
|
||||
class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
before_action :set_web_widget
|
||||
before_action :set_contact
|
||||
before_action :set_conversation, only: [:create]
|
||||
before_action :set_message, only: [:update]
|
||||
|
||||
def index
|
||||
@messages = conversation.nil? ? [] : message_finder.perform
|
||||
@@ -9,17 +11,19 @@ class Api::V1::Widget::MessagesController < ActionController::Base
|
||||
def create
|
||||
@message = conversation.messages.new(message_params)
|
||||
@message.save!
|
||||
render json: @message
|
||||
end
|
||||
|
||||
def update
|
||||
@message.update!(input_submitted_email: contact_email)
|
||||
update_contact(contact_email)
|
||||
head :no_content
|
||||
rescue StandardError => e
|
||||
render json: { error: @contact.errors, message: e.message }.to_json, status: 500
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def conversation
|
||||
@conversation ||= ::Conversation.find_by(
|
||||
contact_id: cookie_params[:contact_id],
|
||||
inbox_id: cookie_params[:inbox_id]
|
||||
)
|
||||
end
|
||||
|
||||
def set_conversation
|
||||
@conversation = ::Conversation.create!(conversation_params) if conversation.nil?
|
||||
end
|
||||
@@ -37,7 +41,8 @@ class Api::V1::Widget::MessagesController < ActionController::Base
|
||||
{
|
||||
account_id: inbox.account_id,
|
||||
inbox_id: inbox.id,
|
||||
contact_id: cookie_params[:contact_id],
|
||||
contact_id: @contact.id,
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: {
|
||||
browser: browser_params,
|
||||
referer: permitted_params[:message][:referer_url],
|
||||
@@ -63,13 +68,7 @@ class Api::V1::Widget::MessagesController < ActionController::Base
|
||||
end
|
||||
|
||||
def inbox
|
||||
@inbox ||= ::Inbox.find_by(id: cookie_params[:inbox_id])
|
||||
end
|
||||
|
||||
def cookie_params
|
||||
@cookie_params ||= JWT.decode(
|
||||
request.headers[header_name], secret_key, true, algorithm: 'HS256'
|
||||
).first.symbolize_keys
|
||||
@inbox ||= ::Inbox.find_by(id: auth_token_params[:inbox_id])
|
||||
end
|
||||
|
||||
def message_finder_params
|
||||
@@ -83,15 +82,31 @@ class Api::V1::Widget::MessagesController < ActionController::Base
|
||||
@message_finder ||= MessageFinder.new(conversation, message_finder_params)
|
||||
end
|
||||
|
||||
def header_name
|
||||
'X-Auth-Token'
|
||||
def update_contact(email)
|
||||
contact_with_email = @account.contacts.find_by(email: email)
|
||||
if contact_with_email
|
||||
::ContactMergeAction.new(account: @account, base_contact: contact_with_email, mergee_contact: @contact).perform
|
||||
else
|
||||
@contact.update!(
|
||||
email: email,
|
||||
name: contact_name
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def contact_email
|
||||
permitted_params[:contact][:email].downcase
|
||||
end
|
||||
|
||||
def contact_name
|
||||
contact_email.split('@')[0]
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:before, message: [:content, :referer_url, :timestamp])
|
||||
params.permit(:id, :before, :website_token, contact: [:email], message: [:content, :referer_url, :timestamp])
|
||||
end
|
||||
|
||||
def secret_key
|
||||
Rails.application.secrets.secret_key_base
|
||||
def set_message
|
||||
@message = @web_widget.inbox.messages.find(permitted_params[:id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class ConfirmationsController < Devise::ConfirmationsController
|
||||
class DeviseOverrides::ConfirmationsController < Devise::ConfirmationsController
|
||||
skip_before_action :require_no_authentication, raise: false
|
||||
skip_before_action :authenticate_user!, raise: false
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class PasswordsController < Devise::PasswordsController
|
||||
class DeviseOverrides::PasswordsController < Devise::PasswordsController
|
||||
include AuthHelper
|
||||
|
||||
skip_before_action :require_no_authentication, raise: false
|
||||
@@ -1,4 +1,4 @@
|
||||
class SessionsController < ::DeviseTokenAuth::SessionsController
|
||||
class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsController
|
||||
# Prevent session parameter from being passed
|
||||
# Unpermitted parameter: session
|
||||
wrap_parameters format: []
|
||||
@@ -0,0 +1,10 @@
|
||||
class DeviseOverrides::TokenValidationsController < ::DeviseTokenAuth::TokenValidationsController
|
||||
def validate_token
|
||||
# @resource will have been set by set_user_by_token concern
|
||||
if @resource
|
||||
render 'devise/token.json'
|
||||
else
|
||||
render_validate_token_error
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,66 +4,47 @@ class WidgetsController < ActionController::Base
|
||||
before_action :set_contact
|
||||
before_action :build_contact
|
||||
|
||||
def index
|
||||
render
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_contact
|
||||
return if cookie_params[:source_id].nil?
|
||||
|
||||
contact_inbox = ::ContactInbox.find_by(
|
||||
inbox_id: @web_widget.inbox.id,
|
||||
source_id: cookie_params[:source_id]
|
||||
)
|
||||
|
||||
@contact = contact_inbox ? contact_inbox.contact : nil
|
||||
end
|
||||
|
||||
def set_token
|
||||
@token = conversation_token
|
||||
end
|
||||
|
||||
def set_web_widget
|
||||
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
|
||||
end
|
||||
|
||||
def set_token
|
||||
@token = permitted_params[:cw_conversation]
|
||||
@auth_token_params = if @token.present?
|
||||
::Widget::TokenService.new(token: @token).decode_token
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
def set_contact
|
||||
return if @auth_token_params[:source_id].nil?
|
||||
|
||||
contact_inbox = ::ContactInbox.find_by(
|
||||
inbox_id: @web_widget.inbox.id,
|
||||
source_id: @auth_token_params[:source_id]
|
||||
)
|
||||
|
||||
@contact = contact_inbox ? contact_inbox.contact : nil
|
||||
end
|
||||
|
||||
def build_contact
|
||||
return if @contact.present?
|
||||
|
||||
contact_inbox = @web_widget.create_contact_inbox
|
||||
@contact = contact_inbox.contact
|
||||
|
||||
payload = {
|
||||
source_id: contact_inbox.source_id,
|
||||
contact_id: @contact.id,
|
||||
inbox_id: @web_widget.inbox.id
|
||||
}
|
||||
@token = JWT.encode payload, secret_key, 'HS256'
|
||||
end
|
||||
|
||||
def cookie_params
|
||||
return @cookie_params if @cookie_params.present?
|
||||
|
||||
if conversation_token.present?
|
||||
begin
|
||||
@cookie_params = JWT.decode(
|
||||
conversation_token, secret_key, true, algorithm: 'HS256'
|
||||
).first.symbolize_keys
|
||||
rescue StandardError
|
||||
@cookie_params = {}
|
||||
end
|
||||
return @cookie_params
|
||||
end
|
||||
{}
|
||||
end
|
||||
|
||||
def conversation_token
|
||||
permitted_params[:cw_conversation]
|
||||
payload = { source_id: contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
|
||||
@token = ::Widget::TokenService.new(payload: payload).generate_token
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:website_token, :cw_conversation)
|
||||
end
|
||||
|
||||
def secret_key
|
||||
Rails.application.secrets.secret_key_base
|
||||
end
|
||||
end
|
||||
|
||||
@@ -31,6 +31,7 @@ class ConversationFinder
|
||||
|
||||
find_all_conversations
|
||||
filter_by_status
|
||||
filter_by_labels if params[:labels]
|
||||
|
||||
mine_count, unassigned_count, all_count = set_count_for_all_conversations
|
||||
|
||||
@@ -62,7 +63,6 @@ class ConversationFinder
|
||||
|
||||
def set_assignee_type
|
||||
@assignee_type_id = ASSIGNEE_TYPES[ASSIGNEE_TYPES_BY_ID[params[:assignee_type_id].to_i]]
|
||||
# ente budhiparamaya neekam kandit enthu tonunu? ;)
|
||||
end
|
||||
|
||||
def find_all_conversations
|
||||
@@ -86,6 +86,10 @@ class ConversationFinder
|
||||
@conversations = @conversations.where(status: params[:status] || DEFAULT_STATUS)
|
||||
end
|
||||
|
||||
def filter_by_labels
|
||||
@conversations = @conversations.tagged_with(params[:labels], any: true)
|
||||
end
|
||||
|
||||
def set_count_for_all_conversations
|
||||
[
|
||||
@conversations.assigned_to(current_user).count,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class ContactAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('contacts');
|
||||
}
|
||||
|
||||
getConversations(contactId) {
|
||||
return axios.get(`${this.url}/${contactId}/conversations`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ContactAPI();
|
||||
|
||||
18
app/javascript/dashboard/api/conversations.js
Normal file
18
app/javascript/dashboard/api/conversations.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class ConversationApi extends ApiClient {
|
||||
constructor() {
|
||||
super('conversations');
|
||||
}
|
||||
|
||||
getLabels(conversationID) {
|
||||
return axios.get(`${this.url}/${conversationID}/labels`);
|
||||
}
|
||||
|
||||
createLabels(conversationID) {
|
||||
return axios.get(`${this.url}/${conversationID}/labels`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConversationApi();
|
||||
14
app/javascript/dashboard/api/specs/contacts.spec.js
Normal file
14
app/javascript/dashboard/api/specs/contacts.spec.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import agents from '../contacts';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#ContactsAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(agents).toBeInstanceOf(ApiClient);
|
||||
expect(agents).toHaveProperty('get');
|
||||
expect(agents).toHaveProperty('show');
|
||||
expect(agents).toHaveProperty('create');
|
||||
expect(agents).toHaveProperty('update');
|
||||
expect(agents).toHaveProperty('delete');
|
||||
expect(agents).toHaveProperty('getConversations');
|
||||
});
|
||||
});
|
||||
15
app/javascript/dashboard/api/specs/conversations.spec.js
Normal file
15
app/javascript/dashboard/api/specs/conversations.spec.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import conversations from '../conversations';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#ConversationApi', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(conversations).toBeInstanceOf(ApiClient);
|
||||
expect(conversations).toHaveProperty('get');
|
||||
expect(conversations).toHaveProperty('show');
|
||||
expect(conversations).toHaveProperty('create');
|
||||
expect(conversations).toHaveProperty('update');
|
||||
expect(conversations).toHaveProperty('delete');
|
||||
expect(conversations).toHaveProperty('getLabels');
|
||||
expect(conversations).toHaveProperty('createLabels');
|
||||
});
|
||||
});
|
||||
BIN
app/javascript/dashboard/assets/images/Mask.png
Normal file
BIN
app/javascript/dashboard/assets/images/Mask.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
app/javascript/dashboard/assets/images/chatwoot_bot.png
Normal file
BIN
app/javascript/dashboard/assets/images/chatwoot_bot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
app/javascript/dashboard/assets/images/twitter-badge.png
Normal file
BIN
app/javascript/dashboard/assets/images/twitter-badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -41,6 +41,7 @@
|
||||
color: $color-body;
|
||||
width: 27rem;
|
||||
white-space: nowrap;
|
||||
max-width: 96%;
|
||||
}
|
||||
|
||||
.conversation--meta {
|
||||
@@ -91,4 +92,12 @@
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
|
||||
&.compact {
|
||||
padding-left: 0;
|
||||
|
||||
.conversation--details {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ table {
|
||||
tr {
|
||||
.show-if-hover {
|
||||
opacity: 0;
|
||||
transition: all .2s $ease-in-out-cubic;
|
||||
transition: all 0.2s $ease-in-out-cubic;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<span class="spinner small"></span>
|
||||
</template>
|
||||
@@ -12,7 +12,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Spinner from '../Spinner';
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
/* eslint no-console: 0 */
|
||||
/* global bus */
|
||||
import { mapGetters } from 'vuex';
|
||||
import Spinner from '../Spinner';
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
|
||||
export default {
|
||||
props: ['conversationId'],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint no-plusplus: 0 */
|
||||
/* eslint-env browser */
|
||||
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
import Bar from './widgets/chart/BarChart';
|
||||
import Code from './Code';
|
||||
import LoadingState from './widgets/LoadingState';
|
||||
@@ -8,7 +9,6 @@ import Modal from './Modal';
|
||||
import ModalHeader from './ModalHeader';
|
||||
import ReportStatsCard from './widgets/ReportStatsCard';
|
||||
import SidemenuIcon from './SidemenuIcon';
|
||||
import Spinner from './Spinner';
|
||||
import SubmitButton from './buttons/FormSubmitButton';
|
||||
import Tabs from './ui/Tabs/Tabs';
|
||||
import TabsItem from './ui/Tabs/TabsItem';
|
||||
|
||||
@@ -16,12 +16,24 @@
|
||||
:size="avatarSize"
|
||||
/>
|
||||
<img
|
||||
v-if="badge === 'Channel::FacebookPage'"
|
||||
v-if="badge === 'Channel::FacebookPage' && status !== ''"
|
||||
id="badge"
|
||||
class="source-badge"
|
||||
:style="badgeStyle"
|
||||
src="~dashboard/assets/images/fb-badge.png"
|
||||
/>
|
||||
<div
|
||||
v-else-if="status === 'online'"
|
||||
class="source-badge user--online"
|
||||
:style="statusStyle"
|
||||
></div>
|
||||
<img
|
||||
v-if="badge === 'Channel::TwitterProfile' && status !== ''"
|
||||
id="badge"
|
||||
class="source-badge"
|
||||
:style="badgeStyle"
|
||||
src="~dashboard/assets/images/twitter-badge.png"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
@@ -41,6 +53,7 @@ export default {
|
||||
props: {
|
||||
src: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
@@ -52,6 +65,11 @@ export default {
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
@@ -67,6 +85,10 @@ export default {
|
||||
const badgeSize = `${this.avatarSize / 3}px`;
|
||||
return { width: badgeSize, height: badgeSize };
|
||||
},
|
||||
statusStyle() {
|
||||
const statusSize = `${this.avatarSize / 4}px`;
|
||||
return { width: statusSize, height: statusSize };
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onImgError() {
|
||||
@@ -78,6 +100,7 @@ export default {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~dashboard/assets/scss/variables';
|
||||
@import '~dashboard/assets/scss/foundation-settings';
|
||||
@import '~dashboard/assets/scss/mixins';
|
||||
|
||||
.user-thumbnail-box {
|
||||
@@ -91,11 +114,21 @@ export default {
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
bottom: -$space-micro / 2;
|
||||
bottom: -$space-micro;
|
||||
height: $space-slab;
|
||||
position: absolute;
|
||||
right: $zero;
|
||||
width: $space-slab;
|
||||
}
|
||||
|
||||
.user--online {
|
||||
background: $success-color;
|
||||
border-radius: 50%;
|
||||
bottom: $space-micro;
|
||||
|
||||
&:after {
|
||||
content: ' ';
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
@click="cardClick(chat)"
|
||||
>
|
||||
<Thumbnail
|
||||
v-if="!hideThumbnail"
|
||||
:src="chat.meta.sender.thumbnail"
|
||||
:badge="chat.meta.sender.channel"
|
||||
class="columns"
|
||||
@@ -15,7 +16,7 @@
|
||||
<h4 class="conversation--user">
|
||||
{{ chat.meta.sender.name }}
|
||||
<span
|
||||
v-if="isInboxNameVisible"
|
||||
v-if="!hideInboxName && isInboxNameVisible"
|
||||
v-tooltip.bottom="inboxName(chat.inbox_id)"
|
||||
class="label"
|
||||
>
|
||||
@@ -58,6 +59,14 @@ export default {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
hideInboxName: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideThumbnail: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
@@ -74,13 +74,13 @@ export default {
|
||||
return this.formatMessage(this.data.content);
|
||||
},
|
||||
alignBubble() {
|
||||
return this.data.message_type === 1 ? 'right' : 'left';
|
||||
return !this.data.message_type ? 'left' : 'right';
|
||||
},
|
||||
readableTime() {
|
||||
return this.messageStamp(this.data.created_at);
|
||||
},
|
||||
isBubble() {
|
||||
return this.data.message_type === 1 || this.data.message_type === 0;
|
||||
return [0, 1, 3].includes(this.data.message_type);
|
||||
},
|
||||
isPrivate() {
|
||||
return this.data.private;
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
"BROWSER": "Browser",
|
||||
"OS": "Operating System",
|
||||
"INITIATED_FROM": "Initiated from",
|
||||
"INITIATED_AT": "Initiated at"
|
||||
"INITIATED_AT": "Initiated at",
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "There are no previous conversations associated to this contact.",
|
||||
"TITLE": "Previous Conversations"
|
||||
},
|
||||
"LABELS": {
|
||||
"NO_RECORDS_FOUND": "There are no labels associated to this conversation.",
|
||||
"TITLE": "Labels"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="contact-conversation--panel">
|
||||
<contact-details-item
|
||||
icon="ion-chatbubbles"
|
||||
:title="$t('CONTACT_PANEL.CONVERSATIONS.TITLE')"
|
||||
/>
|
||||
<div v-if="!uiFlags.isFetching">
|
||||
<i v-if="!previousConversations.length">
|
||||
{{ $t('CONTACT_PANEL.CONVERSATIONS.NO_RECORDS_FOUND') }}
|
||||
</i>
|
||||
<div v-else class="contact-conversation--list">
|
||||
<conversation-card
|
||||
v-for="conversation in previousConversations"
|
||||
:key="conversation.id"
|
||||
:chat="conversation"
|
||||
:hide-inbox-name="true"
|
||||
:hide-thumbnail="true"
|
||||
class="compact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<spinner v-else></spinner>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ConversationCard from 'dashboard/components/widgets/conversation/ConversationCard.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import ContactDetailsItem from './ContactDetailsItem.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ConversationCard,
|
||||
ContactDetailsItem,
|
||||
Spinner,
|
||||
},
|
||||
props: {
|
||||
contactId: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
conversationId: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
conversations() {
|
||||
return this.$store.getters['contactConversations/getContactConversation'](
|
||||
this.contactId
|
||||
);
|
||||
},
|
||||
previousConversations() {
|
||||
return this.conversations.filter(
|
||||
conversation => conversation.id !== Number(this.conversationId)
|
||||
);
|
||||
},
|
||||
...mapGetters({
|
||||
uiFlags: 'contactConversations/getUIFlags',
|
||||
}),
|
||||
},
|
||||
watch: {
|
||||
contactId(newContactId, prevContactId) {
|
||||
if (newContactId && newContactId !== prevContactId) {
|
||||
this.$store.dispatch('contactConversations/get', newContactId);
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('contactConversations/get', this.contactId);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~dashboard/assets/scss/variables';
|
||||
@import '~dashboard/assets/scss/mixins';
|
||||
|
||||
.contact-conversation--panel {
|
||||
@include border-normal-top;
|
||||
padding: $space-medium;
|
||||
}
|
||||
|
||||
.contact-conversation--list {
|
||||
margin-top: -$space-normal;
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@
|
||||
<i :class="icon" class="conv-details--item__icon"></i>
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="conv-details--item__value">
|
||||
<div v-if="value" class="conv-details--item__value">
|
||||
{{ value }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
<div class="contact--profile">
|
||||
<div class="contact--info">
|
||||
<thumbnail
|
||||
:src="contact.avatar_url"
|
||||
:src="contact.thumbnail"
|
||||
size="56px"
|
||||
:badge="contact.channel"
|
||||
:username="contact.name"
|
||||
:status="contact.availability_status"
|
||||
/>
|
||||
<div class="contact--details">
|
||||
<div class="contact--name">
|
||||
@@ -54,16 +55,27 @@
|
||||
icon="ion-clock"
|
||||
/>
|
||||
</div>
|
||||
<contact-conversations
|
||||
v-if="contact.id"
|
||||
:contact-id="contact.id"
|
||||
:conversation-id="conversationId"
|
||||
/>
|
||||
|
||||
<conversation-labels :conversation-id="conversationId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import ContactConversations from './ContactConversations.vue';
|
||||
import ContactDetailsItem from './ContactDetailsItem.vue';
|
||||
import ConversationLabels from './ConversationLabels.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ContactConversations,
|
||||
ContactDetailsItem,
|
||||
ConversationLabels,
|
||||
Thumbnail,
|
||||
},
|
||||
props: {
|
||||
@@ -179,7 +191,7 @@ export default {
|
||||
}
|
||||
|
||||
.conversation--details {
|
||||
padding: $space-normal $space-medium;
|
||||
padding: $space-medium;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="contact-conversation--panel">
|
||||
<contact-details-item
|
||||
icon="ion-pricetags"
|
||||
:title="$t('CONTACT_PANEL.LABELS.TITLE')"
|
||||
/>
|
||||
<div v-if="!uiFlags.isFetching">
|
||||
<i v-if="!labels.length">
|
||||
{{ $t('CONTACT_PANEL.LABELS.NO_RECORDS_FOUND') }}
|
||||
</i>
|
||||
<div v-else class="contact-conversation--list">
|
||||
<span
|
||||
v-for="label in labels"
|
||||
:key="label"
|
||||
class="conversation--label label primary"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<spinner v-else></spinner>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import ContactDetailsItem from './ContactDetailsItem.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ContactDetailsItem,
|
||||
Spinner,
|
||||
},
|
||||
props: {
|
||||
conversationId: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
labels() {
|
||||
return this.$store.getters['conversationLabels/getConversationLabels'](
|
||||
this.conversationId
|
||||
);
|
||||
},
|
||||
...mapGetters({
|
||||
uiFlags: 'contactConversations/getUIFlags',
|
||||
}),
|
||||
},
|
||||
watch: {
|
||||
conversationId(newConversationId, prevConversationId) {
|
||||
if (newConversationId && newConversationId !== prevConversationId) {
|
||||
this.$store.dispatch('conversationLabels/get', newConversationId);
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('conversationLabels/get', this.conversationId);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~dashboard/assets/scss/variables';
|
||||
@import '~dashboard/assets/scss/mixins';
|
||||
|
||||
.contact-conversation--panel {
|
||||
@include border-normal-top;
|
||||
padding: $space-medium;
|
||||
}
|
||||
|
||||
.contact-conversation--list {
|
||||
margin-top: -$space-normal;
|
||||
}
|
||||
|
||||
.conversation--label {
|
||||
color: $color-white;
|
||||
margin-right: $space-small;
|
||||
font-size: $font-size-small;
|
||||
padding: $space-smaller;
|
||||
}
|
||||
</style>
|
||||
@@ -26,10 +26,11 @@
|
||||
<!-- Gravtar Image -->
|
||||
<td>
|
||||
<thumbnail
|
||||
:src="gravatarUrl(agent.email)"
|
||||
:src="agent.thumbnail"
|
||||
class="columns"
|
||||
:username="agent.name"
|
||||
size="40px"
|
||||
:status="agent.availability_status"
|
||||
/>
|
||||
</td>
|
||||
<!-- Agent Name + Email -->
|
||||
|
||||
@@ -7,7 +7,9 @@ import billing from './modules/billing';
|
||||
import cannedResponse from './modules/cannedResponse';
|
||||
import Channel from './modules/channels';
|
||||
import contacts from './modules/contacts';
|
||||
import contactConversations from './modules/contactConversations';
|
||||
import conversationMetadata from './modules/conversationMetadata';
|
||||
import conversationLabels from './modules/conversationLabels';
|
||||
import conversations from './modules/conversations';
|
||||
import inboxes from './modules/inboxes';
|
||||
import inboxMembers from './modules/inboxMembers';
|
||||
@@ -22,6 +24,8 @@ export default new Vuex.Store({
|
||||
cannedResponse,
|
||||
Channel,
|
||||
contacts,
|
||||
contactConversations,
|
||||
conversationLabels,
|
||||
conversationMetadata,
|
||||
conversations,
|
||||
inboxes,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import Vue from 'vue';
|
||||
import * as types from '../mutation-types';
|
||||
import ContactAPI from '../../api/contacts';
|
||||
|
||||
const state = {
|
||||
records: {},
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
getUIFlags($state) {
|
||||
return $state.uiFlags;
|
||||
},
|
||||
getContactConversation: $state => id => {
|
||||
return $state.records[Number(id)] || [];
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
get: async ({ commit }, contactId) => {
|
||||
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
|
||||
isFetching: true,
|
||||
});
|
||||
try {
|
||||
const response = await ContactAPI.getConversations(contactId);
|
||||
commit(types.default.SET_CONTACT_CONVERSATIONS, {
|
||||
id: contactId,
|
||||
data: response.data.payload,
|
||||
});
|
||||
commit(types.default.SET_ALL_CONVERSATION, response.data.payload, {
|
||||
root: true,
|
||||
});
|
||||
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
|
||||
isFetching: false,
|
||||
});
|
||||
} catch (error) {
|
||||
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
|
||||
isFetching: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG]($state, data) {
|
||||
$state.uiFlags = {
|
||||
...$state.uiFlags,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
[types.default.SET_CONTACT_CONVERSATIONS]: ($state, { id, data }) => {
|
||||
Vue.set($state.records, id, data);
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
61
app/javascript/dashboard/store/modules/conversationLabels.js
Normal file
61
app/javascript/dashboard/store/modules/conversationLabels.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import Vue from 'vue';
|
||||
import * as types from '../mutation-types';
|
||||
import ConversationAPI from '../../api/conversations';
|
||||
|
||||
const state = {
|
||||
records: {},
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
getUIFlags($state) {
|
||||
return $state.uiFlags;
|
||||
},
|
||||
getConversationLabels: $state => id => {
|
||||
return $state.records[Number(id)] || [];
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
get: async ({ commit }, conversationId) => {
|
||||
commit(types.default.SET_CONVERSATION_LABELS_UI_FLAG, {
|
||||
isFetching: true,
|
||||
});
|
||||
try {
|
||||
const response = await ConversationAPI.getLabels(conversationId);
|
||||
commit(types.default.SET_CONVERSATION_LABELS, {
|
||||
id: conversationId,
|
||||
data: response.data.payload,
|
||||
});
|
||||
commit(types.default.SET_CONVERSATION_LABELS_UI_FLAG, {
|
||||
isFetching: false,
|
||||
});
|
||||
} catch (error) {
|
||||
commit(types.default.SET_CONVERSATION_LABELS_UI_FLAG, {
|
||||
isFetching: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
[types.default.SET_CONVERSATION_LABELS_UI_FLAG]($state, data) {
|
||||
$state.uiFlags = {
|
||||
...$state.uiFlags,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
[types.default.SET_CONVERSATION_LABELS]: ($state, { id, data }) => {
|
||||
Vue.set($state.records, id, data);
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import axios from 'axios';
|
||||
import { actions } from '../../contactConversations';
|
||||
import * as types from '../../../mutation-types';
|
||||
import conversationList from './fixtures';
|
||||
|
||||
const commit = jest.fn();
|
||||
global.axios = axios;
|
||||
jest.mock('axios');
|
||||
|
||||
describe('#actions', () => {
|
||||
describe('#get', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.get.mockResolvedValue({ data: { payload: conversationList } });
|
||||
await actions.get({ commit }, 1);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isFetching: true }],
|
||||
|
||||
[
|
||||
types.default.SET_CONTACT_CONVERSATIONS,
|
||||
{ id: 1, data: conversationList },
|
||||
],
|
||||
[types.default.SET_ALL_CONVERSATION, conversationList, { root: true }],
|
||||
[
|
||||
types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG,
|
||||
{ isFetching: false },
|
||||
],
|
||||
]);
|
||||
});
|
||||
it('sends correct actions if API is error', async () => {
|
||||
axios.get.mockRejectedValue({ message: 'Incorrect header' });
|
||||
await actions.get({ commit });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isFetching: true }],
|
||||
[
|
||||
types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG,
|
||||
{ isFetching: false },
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
export default [
|
||||
{
|
||||
meta: {
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'sender1',
|
||||
thumbnail: '',
|
||||
channel: 'Channel::WebWidget',
|
||||
},
|
||||
assignee: null,
|
||||
},
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
content: 'Hello',
|
||||
account_id: 1,
|
||||
inbox_id: 1,
|
||||
conversation_id: 1,
|
||||
message_type: 1,
|
||||
created_at: 1578555084,
|
||||
updated_at: '2020-01-09T07:31:24.419Z',
|
||||
private: false,
|
||||
user_id: 1,
|
||||
status: 'sent',
|
||||
fb_id: null,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
sender: {
|
||||
name: 'Sender 1',
|
||||
avatar_url: 'random_url',
|
||||
},
|
||||
},
|
||||
],
|
||||
inbox_id: 1,
|
||||
status: 0,
|
||||
timestamp: 1578555084,
|
||||
user_last_seen_at: 0,
|
||||
agent_last_seen_at: 1578555084,
|
||||
unread_count: 0,
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
sender: {
|
||||
id: 2,
|
||||
name: 'sender1',
|
||||
thumbnail: '',
|
||||
channel: 'Channel::WebWidget',
|
||||
},
|
||||
assignee: null,
|
||||
},
|
||||
id: 2,
|
||||
messages: [
|
||||
{
|
||||
id: 2,
|
||||
content: 'Hello',
|
||||
account_id: 1,
|
||||
inbox_id: 2,
|
||||
conversation_id: 2,
|
||||
message_type: 1,
|
||||
created_at: 1578555084,
|
||||
updated_at: '2020-01-09T07:31:24.419Z',
|
||||
private: false,
|
||||
user_id: 2,
|
||||
status: 'sent',
|
||||
fb_id: null,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
sender: {
|
||||
name: 'Sender 1',
|
||||
avatar_url: 'random_url',
|
||||
},
|
||||
},
|
||||
],
|
||||
inbox_id: 2,
|
||||
status: 0,
|
||||
timestamp: 1578555084,
|
||||
user_last_seen_at: 0,
|
||||
agent_last_seen_at: 1578555084,
|
||||
unread_count: 0,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getters } from '../../contactConversations';
|
||||
|
||||
describe('#getters', () => {
|
||||
it('getContactConversation', () => {
|
||||
const state = {
|
||||
records: { 1: [{ id: 1, contact_id: 1, message: 'Hello' }] },
|
||||
};
|
||||
expect(getters.getContactConversation(state)(1)).toEqual([
|
||||
{ id: 1, contact_id: 1, message: 'Hello' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('getUIFlags', () => {
|
||||
const state = {
|
||||
uiFlags: {
|
||||
isFetching: true,
|
||||
},
|
||||
};
|
||||
expect(getters.getUIFlags(state)).toEqual({
|
||||
isFetching: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as types from '../../../mutation-types';
|
||||
import { mutations } from '../../contactConversations';
|
||||
|
||||
describe('#mutations', () => {
|
||||
describe('#SET_CONTACT_CONVERSATIONS_UI_FLAG', () => {
|
||||
it('set ui flags', () => {
|
||||
const state = { uiFlags: { isFetching: true } };
|
||||
mutations[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG](state, {
|
||||
isFetching: false,
|
||||
});
|
||||
expect(state.uiFlags).toEqual({
|
||||
isFetching: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_CONTACT_CONVERSATIONS', () => {
|
||||
it('set contact conversation records', () => {
|
||||
const state = { records: {} };
|
||||
mutations[types.default.SET_CONTACT_CONVERSATIONS](state, {
|
||||
id: 1,
|
||||
data: [{ id: 1, contact_id: 1, message: 'hello' }],
|
||||
});
|
||||
expect(state.records).toEqual({
|
||||
1: [{ id: 1, contact_id: 1, message: 'hello' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import axios from 'axios';
|
||||
import { actions } from '../../conversationLabels';
|
||||
import * as types from '../../../mutation-types';
|
||||
|
||||
const commit = jest.fn();
|
||||
global.axios = axios;
|
||||
jest.mock('axios');
|
||||
|
||||
describe('#actions', () => {
|
||||
describe('#get', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
data: { payload: ['customer-success', 'on-hold'] },
|
||||
});
|
||||
await actions.get({ commit }, 1);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_CONVERSATION_LABELS_UI_FLAG, { isFetching: true }],
|
||||
|
||||
[
|
||||
types.default.SET_CONVERSATION_LABELS,
|
||||
{ id: 1, data: ['customer-success', 'on-hold'] },
|
||||
],
|
||||
[types.default.SET_CONVERSATION_LABELS_UI_FLAG, { isFetching: false }],
|
||||
]);
|
||||
});
|
||||
it('sends correct actions if API is error', async () => {
|
||||
axios.get.mockRejectedValue({ message: 'Incorrect header' });
|
||||
await actions.get({ commit });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_CONVERSATION_LABELS_UI_FLAG, { isFetching: true }],
|
||||
[types.default.SET_CONVERSATION_LABELS_UI_FLAG, { isFetching: false }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { getters } from '../../conversationLabels';
|
||||
|
||||
describe('#getters', () => {
|
||||
it('getConversationLabels', () => {
|
||||
const state = {
|
||||
records: { 1: ['customer-success', 'on-hold'] },
|
||||
};
|
||||
expect(getters.getConversationLabels(state)(1)).toEqual([
|
||||
'customer-success',
|
||||
'on-hold',
|
||||
]);
|
||||
});
|
||||
|
||||
it('getUIFlags', () => {
|
||||
const state = {
|
||||
uiFlags: {
|
||||
isFetching: true,
|
||||
},
|
||||
};
|
||||
expect(getters.getUIFlags(state)).toEqual({
|
||||
isFetching: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as types from '../../../mutation-types';
|
||||
import { mutations } from '../../conversationLabels';
|
||||
|
||||
describe('#mutations', () => {
|
||||
describe('#SET_CONVERSATION_LABELS_UI_FLAG', () => {
|
||||
it('set ui flags', () => {
|
||||
const state = { uiFlags: { isFetching: true } };
|
||||
mutations[types.default.SET_CONVERSATION_LABELS_UI_FLAG](state, {
|
||||
isFetching: false,
|
||||
});
|
||||
expect(state.uiFlags).toEqual({
|
||||
isFetching: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_CONVERSATION_LABELS', () => {
|
||||
it('set contact conversation records', () => {
|
||||
const state = { records: {} };
|
||||
mutations[types.default.SET_CONVERSATION_LABELS](state, {
|
||||
id: 1,
|
||||
data: ['customer-success', 'on-hold'],
|
||||
});
|
||||
expect(state.records).toEqual({
|
||||
1: ['customer-success', 'on-hold'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,14 @@ export default {
|
||||
SET_CONTACTS: 'SET_CONTACTS',
|
||||
EDIT_CONTACT: 'EDIT_CONTACT',
|
||||
|
||||
// Contact Conversation
|
||||
SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG',
|
||||
SET_CONTACT_CONVERSATIONS: 'SET_CONTACT_CONVERSATIONS',
|
||||
|
||||
// Conversation Label
|
||||
SET_CONVERSATION_LABELS_UI_FLAG: 'SET_CONVERSATION_LABELS_UI_FLAG',
|
||||
SET_CONVERSATION_LABELS: 'SET_CONVERSATION_LABELS',
|
||||
|
||||
// Reports
|
||||
SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS',
|
||||
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
|
||||
|
||||
@@ -4,8 +4,6 @@ import { SDK_CSS } from '../widget/assets/scss/sdk';
|
||||
/* eslint-disable no-param-reassign */
|
||||
const bubbleImg =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAUVBMVEUAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////8IN+deAAAAGnRSTlMAAwgJEBk0TVheY2R5eo+ut8jb5OXs8fX2+cjRDTIAAADsSURBVHgBldZbkoMgFIThRgQv8SKKgGf/C51UnJqaRI30/9zfe+NQUQ3TvG7bOk9DVeCmshmj/CuOTYnrdBfkUOg0zlOtl9OWVuEk4+QyZ3DIevmSt/ioTvK1VH/s5bY3YdM9SBZ/mUUyWgx+U06ycgp7D8msxSvtc4HXL9BLdj2elSEfhBJAI0QNgJEBI1BEBsQClVBVGDgwYOLAhJkDM1YOrNg4sLFAsLJgZsHEgoEFFQt0JAFGFjQsKAMJ0LFAexKgZYFyJIDxJIBNJEDNAtSJBLCeBDCOBFAPzwFA94ED+zmhwDO9358r8ANtIsMXi7qVAwAAAABJRU5ErkJggg==';
|
||||
const closeImg =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAP1BMVEUAAAD///////////////////////////////////////////////////////////////////////////////9Du/pqAAAAFHRSTlMACBstLi8wMVB+mcbT2err7O3w8n+sjtQAAAEuSURBVHgBtNLdcoMgGITh1SCGH9DId//X2mnTg7hYxj0oh8w8r+MqgDnmlsIE6UwhtRxnAHge9n2KV7wvP+h4AvPbm73W+359/aJjRjQTCuTNIrJJBfKW0UwqkLeGZJ8Ff2O/T28JwZQCewuYilJgX6buavdDv188br1RIE+jc2H5yy+9VwrXXij0nsflwth7YFRw7N3Y88BcYL+z7wubO/lt6AcFwQMLF9irP8r2eF8/ei8VLrxUkDzguMDejX03WK3dsGJB9lxgrxd0T8PTRxUL5OUCealQz76KXg/or/CvI36VXgcEAAAgCMP6t16IZVDg3zPuI+0rb5g2zlsoW2lbqlvrOyw7bTuuO+8LGIs4C1mLeQuai7oL2437LRytPC1drX0tnq2+Ld+r/wDPIIIJkfdlbQAAAABJRU5ErkJggg==';
|
||||
|
||||
const body = document.getElementsByTagName('body')[0];
|
||||
const holder = document.createElement('div');
|
||||
@@ -120,7 +118,7 @@ const IFrameHelper = {
|
||||
createFrame: ({ baseUrl, websiteToken }) => {
|
||||
const iframe = document.createElement('iframe');
|
||||
const cwCookie = Cookies.get('cw_conversation');
|
||||
let widgetUrl = `${baseUrl}/widgets?website_token=${websiteToken}`;
|
||||
let widgetUrl = `${baseUrl}/widget?website_token=${websiteToken}`;
|
||||
if (cwCookie) {
|
||||
widgetUrl = `${widgetUrl}&cw_conversation=${cwCookie}`;
|
||||
}
|
||||
@@ -142,6 +140,20 @@ const IFrameHelper = {
|
||||
'*'
|
||||
);
|
||||
},
|
||||
events: {
|
||||
loaded: message => {
|
||||
Cookies.set('cw_conversation', message.config.authToken);
|
||||
IFrameHelper.sendMessage('config-set', {});
|
||||
IFrameHelper.onLoad(message.config.channelConfig);
|
||||
IFrameHelper.setCurrentUrl();
|
||||
},
|
||||
set_auth_token: message => {
|
||||
Cookies.set('cw_conversation', message.authToken);
|
||||
},
|
||||
toggleBubble: () => {
|
||||
bubbleClickCallback();
|
||||
},
|
||||
},
|
||||
initPostMessageCommunication: () => {
|
||||
window.onmessage = e => {
|
||||
if (
|
||||
@@ -151,11 +163,8 @@ const IFrameHelper = {
|
||||
return;
|
||||
}
|
||||
const message = JSON.parse(e.data.replace('chatwoot-widget:', ''));
|
||||
if (message.event === 'loaded') {
|
||||
Cookies.set('cw_conversation', message.config.authToken);
|
||||
IFrameHelper.sendMessage('config-set', {});
|
||||
IFrameHelper.onLoad(message.config.channelConfig);
|
||||
IFrameHelper.setCurrentUrl();
|
||||
if (typeof IFrameHelper.events[message.event] === 'function') {
|
||||
IFrameHelper.events[message.event](message);
|
||||
}
|
||||
};
|
||||
},
|
||||
@@ -180,11 +189,8 @@ const IFrameHelper = {
|
||||
target: chatBubble,
|
||||
});
|
||||
|
||||
const closeIcon = createBubbleIcon({
|
||||
className: 'woot-widget-bubble woot--close woot--hide',
|
||||
src: closeImg,
|
||||
target: closeBubble,
|
||||
});
|
||||
const closeIcon = closeBubble;
|
||||
closeIcon.className = 'woot-widget-bubble woot--close woot--hide';
|
||||
|
||||
chatIcon.style.background = widgetColor;
|
||||
closeIcon.style.background = widgetColor;
|
||||
@@ -195,7 +201,6 @@ const IFrameHelper = {
|
||||
onClickChatBubble();
|
||||
},
|
||||
setCurrentUrl: () => {
|
||||
console.log(IFrameHelper.getAppFrame(), document);
|
||||
IFrameHelper.sendMessage('set-current-url', {
|
||||
refererURL: window.location.href,
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import Vue from 'vue';
|
||||
import Vuelidate from 'vuelidate';
|
||||
import store from '../widget/store';
|
||||
import App from '../widget/App.vue';
|
||||
import router from '../widget/router';
|
||||
import ActionCableConnector from '../widget/helpers/actionCable';
|
||||
|
||||
Vue.use(Vuelidate);
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
window.onload = () => {
|
||||
window.WOOT_WIDGET = new Vue({
|
||||
|
||||
@@ -6,17 +6,8 @@
|
||||
|
||||
<script>
|
||||
import { mapActions } from 'vuex';
|
||||
import { setHeader } from './helpers/axios';
|
||||
|
||||
export const IFrameHelper = {
|
||||
isIFrame: () => window.self !== window.top,
|
||||
sendMessage: msg => {
|
||||
window.parent.postMessage(
|
||||
`chatwoot-widget:${JSON.stringify({ ...msg })}`,
|
||||
'*'
|
||||
);
|
||||
},
|
||||
};
|
||||
import { setHeader } from 'widget/helpers/axios';
|
||||
import { IFrameHelper } from 'widget/helpers/utils';
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import authEndPoint from 'widget/api/endPoints';
|
||||
import { API } from 'widget/helpers/axios';
|
||||
|
||||
const createContact = async (inboxId, accountId) => {
|
||||
const urlData = authEndPoint.createContact(inboxId, accountId);
|
||||
const result = await API.post(urlData.url, urlData.params);
|
||||
return result;
|
||||
};
|
||||
|
||||
export default {
|
||||
createContact,
|
||||
};
|
||||
10
app/javascript/widget/api/contact.js
Executable file
10
app/javascript/widget/api/contact.js
Executable file
@@ -0,0 +1,10 @@
|
||||
import authEndPoint from 'widget/api/endPoints';
|
||||
import { API } from 'widget/helpers/axios';
|
||||
|
||||
export const updateContact = async ({ messageId, email }) => {
|
||||
const urlData = authEndPoint.updateContact(messageId);
|
||||
const result = await API.patch(urlData.url, {
|
||||
contact: { email },
|
||||
});
|
||||
return result;
|
||||
};
|
||||
@@ -14,7 +14,12 @@ const getConversation = ({ before }) => ({
|
||||
params: { before },
|
||||
});
|
||||
|
||||
const updateContact = id => ({
|
||||
url: `/api/v1/widget/messages/${id}${window.location.search}`,
|
||||
});
|
||||
|
||||
export default {
|
||||
sendMessage,
|
||||
getConversation,
|
||||
updateContact,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ $input-height: $space-two * 2;
|
||||
|
||||
appearance: none;
|
||||
background: $color-white;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $border-radius;
|
||||
box-sizing: border-box;
|
||||
color: $color-body;
|
||||
|
||||
@@ -4,6 +4,11 @@ $shadow-color-2: rgba(0, 0, 0, 0.07);
|
||||
$shadow-color-3: rgba(50, 50, 93, .08);
|
||||
$shadow-color-4: rgba(0, 0, 0, .05);
|
||||
|
||||
$color-shadow-medium: rgba(0, 0, 0, 0.1);
|
||||
$color-shadow-light: rgba(0, 0, 0, 0.06);
|
||||
$color-shadow-large: rgba(0, 0, 0, 0.25);
|
||||
$color-shadow-outline: rgba(66, 153, 225, 0.5);
|
||||
|
||||
@mixin normal-shadow {
|
||||
box-shadow: 0 $space-small $space-normal $shadow-color-1, 0 $space-smaller $space-slab $shadow-color-2;
|
||||
}
|
||||
@@ -13,8 +18,59 @@ $shadow-color-4: rgba(0, 0, 0, .05);
|
||||
}
|
||||
|
||||
@mixin placeholder {
|
||||
&::-webkit-input-placeholder {@content}
|
||||
&:-moz-placeholder {@content}
|
||||
&::-moz-placeholder {@content}
|
||||
&:-ms-input-placeholder {@content}
|
||||
&::-webkit-input-placeholder {
|
||||
@content
|
||||
}
|
||||
|
||||
&:-moz-placeholder {
|
||||
@content
|
||||
}
|
||||
|
||||
&::-moz-placeholder {
|
||||
@content
|
||||
}
|
||||
|
||||
&:-ms-input-placeholder {
|
||||
@content
|
||||
}
|
||||
}
|
||||
|
||||
@mixin shadow {
|
||||
box-shadow: 0 1px 10px -4 $color-shadow-medium,
|
||||
0 1px 5px 2px $color-shadow-light;
|
||||
}
|
||||
|
||||
|
||||
@mixin shadow-medium {
|
||||
box-shadow: 0 4px 6px -8px $color-shadow-medium,
|
||||
0 2px 4px -4px $color-shadow-light;
|
||||
}
|
||||
|
||||
|
||||
@mixin shadow-large {
|
||||
box-shadow: 0 10px 15px -16px $color-shadow-medium,
|
||||
0 4px 6px -8px $color-shadow-light;
|
||||
}
|
||||
|
||||
|
||||
@mixin shadow-big {
|
||||
box-shadow: 0 20px 25px -20px $color-shadow-medium,
|
||||
0 10px 10px -10px $color-shadow-light;
|
||||
}
|
||||
|
||||
|
||||
@mixin shadow-mega {
|
||||
box-shadow: 0 25px 50px -12px $color-shadow-big;
|
||||
}
|
||||
|
||||
@mixin shadow-inner {
|
||||
box-shadow: inset 0 2px 4px 0 $color-shadow-light;
|
||||
}
|
||||
|
||||
@mixin shadow-outline {
|
||||
box-shadow: 0 0 0 3px $color-shadow-outline;
|
||||
}
|
||||
|
||||
@mixin shadow-none {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@ $color-background-light: #fafafa;
|
||||
$color-white: #fff;
|
||||
$color-body: #3c4858;
|
||||
$color-heading: #1f2d3d;
|
||||
$color-error: #ff4949;
|
||||
|
||||
|
||||
// Thumbnail
|
||||
$thumbnail-radius: 4rem;
|
||||
|
||||
@@ -88,4 +91,20 @@ $line-height: 1;
|
||||
$footer-height: 11.2rem;
|
||||
$header-expanded-height: $space-medium * 10;
|
||||
|
||||
$font-family: 'Inter', -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
$font-family: 'Inter',
|
||||
-apple-system,
|
||||
system-ui,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
$ionicons-font-path: '~ionicons/fonts';
|
||||
|
||||
$spinkit-spinner-color: $color-white !default;
|
||||
$spinkit-spinner-margin: 0 0 0 1.6rem !default;
|
||||
$spinkit-size: 1.6rem !default;
|
||||
|
||||
// Break points
|
||||
$break-point-medium: 667px;
|
||||
|
||||
@@ -1,44 +1,47 @@
|
||||
export const SDK_CSS = `
|
||||
.woot-widget-holder {
|
||||
z-index: 2147483000!important;
|
||||
position: fixed!important;
|
||||
-moz-box-shadow: 0 5px 40px rgba(0,0,0,.16)!important;
|
||||
-o-box-shadow: 0 5px 40px rgba(0,0,0,.16)!important;
|
||||
-webkit-box-shadow: 0 5px 40px rgba(0,0,0,.16)!important;
|
||||
box-shadow: 0 5px 40px rgba(0,0,0,.16)!important;
|
||||
overflow: hidden!important;
|
||||
export const SDK_CSS = ` .woot-widget-holder {
|
||||
z-index: 2147483000 !important;
|
||||
position: fixed !important;
|
||||
-moz-box-shadow: 0 5px 40px rgba(0, 0, 0, .16) !important;
|
||||
-o-box-shadow: 0 5px 40px rgba(0, 0, 0, .16) !important;
|
||||
-webkit-box-shadow: 0 5px 40px rgba(0, 0, 0, .16) !important;
|
||||
box-shadow: 0 5px 40px rgba(0, 0, 0, .16) !important;
|
||||
overflow: hidden !important;
|
||||
opacity: 1;
|
||||
transition-property: opacity, bottom;
|
||||
transition-duration: 0.5s, 0.5s;
|
||||
}
|
||||
|
||||
.woot-widget-holder iframe { width: 100% !important; height: 100% !important; border: 0; }
|
||||
.woot-widget-holder iframe {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.woot-widget-bubble {
|
||||
z-index: 2147483000!important;
|
||||
-moz-box-shadow: 0 8px 24px rgba(0,0,0,.16)!important;
|
||||
-o-box-shadow: 0 8px 24px rgba(0,0,0,.16)!important;
|
||||
-webkit-box-shadow: 0 8px 24px rgba(0,0,0,.16)!important;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.16)!important;
|
||||
-o-border-radius: 100px!important;
|
||||
-moz-border-radius: 100px!important;
|
||||
-webkit-border-radius: 100px!important;
|
||||
border-radius: 100px!important;
|
||||
z-index: 2147483000 !important;
|
||||
-moz-box-shadow: 0 8px 24px rgba(0, 0, 0, .16) !important;
|
||||
-o-box-shadow: 0 8px 24px rgba(0, 0, 0, .16) !important;
|
||||
-webkit-box-shadow: 0 8px 24px rgba(0, 0, 0, .16) !important;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, .16) !important;
|
||||
-o-border-radius: 100px !important;
|
||||
-moz-border-radius: 100px !important;
|
||||
-webkit-border-radius: 100px !important;
|
||||
border-radius: 100px !important;
|
||||
background: #1f93ff;
|
||||
position: fixed;
|
||||
cursor: pointer;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
width: 64px!important;
|
||||
height: 64px!important;
|
||||
width: 64px !important;
|
||||
height: 64px !important;
|
||||
}
|
||||
|
||||
.woot-widget-bubble:hover {
|
||||
background: #1f93ff;
|
||||
-moz-box-shadow: 0 8px 32px rgba(0,0,0,.4)!important;
|
||||
-o-box-shadow: 0 8px 32px rgba(0,0,0,.4)!important;
|
||||
-webkit-box-shadow: 0 8px 32px rgba(0,0,0,.4)!important;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.4)!important;
|
||||
-moz-box-shadow: 0 8px 32px rgba(0, 0, 0, .4) !important;
|
||||
-o-box-shadow: 0 8px 32px rgba(0, 0, 0, .4) !important;
|
||||
-webkit-box-shadow: 0 8px 32px rgba(0, 0, 0, .4) !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, .4) !important;
|
||||
}
|
||||
|
||||
.woot-widget-bubble img {
|
||||
@@ -47,15 +50,29 @@ export const SDK_CSS = `
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.woot-widget-bubble.woot--close img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 24px;
|
||||
.woot--close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.woot--close:before, .woot--close:after {
|
||||
position: absolute;
|
||||
left: 32px;
|
||||
top: 20px;
|
||||
content: ' ';
|
||||
height: 24px;
|
||||
width: 2px;
|
||||
background-color: white;
|
||||
}
|
||||
.woot--close:before {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.woot--close:after {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
|
||||
.woot--hide {
|
||||
visibility: hidden !important;
|
||||
z-index: -1!important;
|
||||
z-index: -1 !important;
|
||||
opacity: 0;
|
||||
bottom: 60px;
|
||||
}
|
||||
@@ -69,12 +86,10 @@ export const SDK_CSS = `
|
||||
}
|
||||
|
||||
.woot-widget-bubble.woot--close {
|
||||
top: 0;
|
||||
right: 0;
|
||||
box-shadow: none !important;
|
||||
-moz-box-shadow: none !important;
|
||||
-o-box-shadow: none !important;
|
||||
-webkit-box-shadow: none !important;
|
||||
visibility: hidden !important;
|
||||
z-index: -1 !important;
|
||||
opacity: 0;
|
||||
bottom: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,13 +98,13 @@ export const SDK_CSS = `
|
||||
bottom: 104px;
|
||||
right: 20px;
|
||||
height: calc(85% - 64px - 20px);
|
||||
width: 370px!important;
|
||||
min-height: 250px!important;
|
||||
max-height: 590px!important;
|
||||
-o-border-radius: 8px!important;
|
||||
-moz-border-radius: 8px!important;
|
||||
-webkit-border-radius: 8px!important;
|
||||
border-radius: 8px!important;
|
||||
width: 400px !important;
|
||||
min-height: 250px !important;
|
||||
max-height: 590px !important;
|
||||
-o-border-radius: 8px !important;
|
||||
-moz-border-radius: 8px !important;
|
||||
-webkit-border-radius: 8px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
@import 'mixins';
|
||||
@import 'forms';
|
||||
@import 'shared/assets/fonts/inter';
|
||||
@import '~ionicons/scss/ionicons';
|
||||
@import '~spinkit/scss/spinners/7-three-bounce';
|
||||
|
||||
html,
|
||||
body {
|
||||
|
||||
@@ -9,7 +9,13 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="message-wrap">
|
||||
<AgentMessageBubble :message="message" />
|
||||
<AgentMessageBubble
|
||||
:content-type="contentType"
|
||||
:message-content-attributes="messageContentAttributes"
|
||||
:message-id="messageId"
|
||||
:message-type="messageType"
|
||||
:message="message"
|
||||
/>
|
||||
<p v-if="showAvatar" class="agent-name">
|
||||
{{ agentName }}
|
||||
</p>
|
||||
@@ -32,7 +38,22 @@ export default {
|
||||
avatarUrl: String,
|
||||
agentName: String,
|
||||
showAvatar: Boolean,
|
||||
createdAt: Number,
|
||||
contentType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
messageContentAttributes: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
messageType: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
messageId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -50,7 +71,7 @@ export default {
|
||||
max-width: 88%;
|
||||
|
||||
& + .agent-message {
|
||||
margin-bottom: $space-smaller;
|
||||
margin-bottom: $space-micro;
|
||||
|
||||
.chat-bubble {
|
||||
border-top-left-radius: $space-smaller;
|
||||
@@ -80,10 +101,10 @@ export default {
|
||||
|
||||
.agent-name {
|
||||
color: $color-body;
|
||||
font-size: $font-size-default;
|
||||
font-size: $font-size-small;
|
||||
font-weight: $font-weight-medium;
|
||||
margin-bottom: $space-small;
|
||||
margin-top: $space-small;
|
||||
margin: $space-small 0;
|
||||
padding-left: $space-micro;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,42 @@
|
||||
<template>
|
||||
<div class="chat-bubble agent" v-html="formatMessage(message)"></div>
|
||||
<div class="chat-bubble agent">
|
||||
<span v-html="formatMessage(message)"></span>
|
||||
<email-input
|
||||
v-if="shouldShowInput"
|
||||
:message-id="messageId"
|
||||
:message-content-attributes="messageContentAttributes"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import EmailInput from './template/EmailInput';
|
||||
|
||||
export default {
|
||||
name: 'AgentMessageBubble',
|
||||
components: {
|
||||
EmailInput,
|
||||
},
|
||||
mixins: [messageFormatterMixin],
|
||||
props: {
|
||||
message: String,
|
||||
contentType: String,
|
||||
messageType: Number,
|
||||
messageId: Number,
|
||||
messageContentAttributes: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
shouldShowInput() {
|
||||
return this.contentType === 'input_email' && this.messageType === 3;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style lang="scss">
|
||||
@import '~widget/assets/scss/variables.scss';
|
||||
|
||||
|
||||
@@ -16,22 +16,26 @@
|
||||
|
||||
.branding {
|
||||
align-items: center;
|
||||
color: $color-gray;
|
||||
color: $color-light-gray;
|
||||
opacity: 0.9;
|
||||
display: flex;
|
||||
filter: grayscale(1);
|
||||
font-size: $font-size-default;
|
||||
font-size: $font-size-small;
|
||||
justify-content: center;
|
||||
padding: $space-one;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
padding: $space-normal 0 $space-slab;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
filter: grayscale(0);
|
||||
opacity: 1;
|
||||
color: $color-gray;
|
||||
}
|
||||
|
||||
img {
|
||||
margin-right: $space-small;
|
||||
max-width: $space-two;
|
||||
margin-right: $space-smaller;
|
||||
max-width: $space-slab;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,8 +12,14 @@ export default {
|
||||
ChatInputWrap,
|
||||
},
|
||||
props: {
|
||||
msg: String,
|
||||
onSendMessage: Function,
|
||||
msg: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
onSendMessage: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -21,14 +27,15 @@ export default {
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
@import '~widget/assets/scss/variables.scss';
|
||||
@import '~widget/assets/scss/mixins.scss';
|
||||
|
||||
.footer {
|
||||
background: $color-white;
|
||||
box-shadow: 0 -$space-micro 3px rgba(50, 50, 93, 0.04),
|
||||
0 -1px 2px rgba(0, 0, 0, 0.03);
|
||||
box-sizing: border-box;
|
||||
padding: $space-small;
|
||||
padding: $space-small $space-slab;
|
||||
width: 100%;
|
||||
border-radius: 7px;
|
||||
@include shadow-big;
|
||||
}
|
||||
|
||||
.branding {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<header class="header-collapsed" :style="{ background: widgetColor }">
|
||||
<div>
|
||||
<h2 class="title">
|
||||
{{ title }}
|
||||
</h2>
|
||||
</div>
|
||||
<header class="header-collapsed">
|
||||
<h2 class="title">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<span class="close" @click="closeWindow"></span>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { IFrameHelper } from 'widget/helpers/utils';
|
||||
|
||||
export default {
|
||||
name: 'ChatHeader',
|
||||
@@ -24,22 +24,60 @@ export default {
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
closeWindow() {
|
||||
if (IFrameHelper.isIFrame()) {
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'toggleBubble',
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~widget/assets/scss/variables.scss';
|
||||
@import '~widget/assets/scss/mixins.scss';
|
||||
|
||||
.header-collapsed {
|
||||
background: $color-woot;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background: $color-white;
|
||||
padding: $space-two $space-medium;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
color: $color-white;
|
||||
border-bottom-left-radius: $space-small;
|
||||
border-bottom-right-radius: $space-small;
|
||||
@include shadow-large;
|
||||
|
||||
.title {
|
||||
font-size: $font-size-big;
|
||||
font-size: $font-size-large;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $color-heading;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: relative;
|
||||
margin-right: $space-small;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: $space-smaller;
|
||||
content: ' ';
|
||||
height: $space-normal;
|
||||
width: 2px;
|
||||
background-color: $color-heading;
|
||||
}
|
||||
&:before {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
&:after {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<header class="header-expanded" :style="{ background: widgetColor }">
|
||||
<header class="header-expanded">
|
||||
<div>
|
||||
<!-- <img
|
||||
class="logo"
|
||||
src="http://www.hennigcompany.com/wp-content/uploads/2014/06/starbucks-logo.png"
|
||||
/> -->
|
||||
<h2 class="title">
|
||||
{{ introHeading }}
|
||||
</h2>
|
||||
@@ -37,23 +41,37 @@ export default {
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~widget/assets/scss/variables.scss';
|
||||
@import '~widget/assets/scss/mixins.scss';
|
||||
|
||||
.header-expanded {
|
||||
background: $color-woot;
|
||||
padding: $space-large;
|
||||
background: $color-white;
|
||||
padding: $space-larger $space-medium $space-large;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
color: $color-white;
|
||||
border-radius: $space-normal;
|
||||
@include shadow-large;
|
||||
|
||||
@media only screen and (min-device-width: 320px) and (max-device-width: 480px) {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: $color-heading;
|
||||
font-size: $font-size-mega;
|
||||
font-weight: $font-weight-medium;
|
||||
margin-bottom: $space-two;
|
||||
font-weight: $font-weight-normal;
|
||||
margin-bottom: $space-slab;
|
||||
margin-top: $space-large;
|
||||
}
|
||||
|
||||
.body {
|
||||
font-size: $font-size-medium;
|
||||
line-height: 1.5;
|
||||
color: $color-body;
|
||||
font-size: 1.8rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
<template>
|
||||
<textarea
|
||||
class="form-input user-message-input"
|
||||
:placeholder="placeholder"
|
||||
:value="value"
|
||||
@input="$emit('input', $event.target.value)"
|
||||
/>
|
||||
<resizable-textarea>
|
||||
<textarea
|
||||
class="form-input user-message-input"
|
||||
:placeholder="placeholder"
|
||||
:value="value"
|
||||
@input="$emit('input', $event.target.value)"
|
||||
/>
|
||||
</resizable-textarea>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ResizableTextarea from 'widget/components/ResizableTextarea.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ResizableTextarea,
|
||||
},
|
||||
props: {
|
||||
placeholder: String,
|
||||
value: String,
|
||||
@@ -24,5 +31,6 @@ export default {
|
||||
border: 0;
|
||||
height: $space-large;
|
||||
resize: none;
|
||||
padding-top: $space-small;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
<ChatSendButton
|
||||
:on-click="handleButtonClick"
|
||||
:disabled="!userInput.length"
|
||||
:color="widgetColor"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import ChatSendButton from 'widget/components/ChatSendButton.vue';
|
||||
import ChatInputArea from 'widget/components/ChatInputArea.vue';
|
||||
|
||||
@@ -42,6 +44,11 @@ export default {
|
||||
document.addEventListener('keypress', this.handleEnterKeyPress);
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
widgetColor: 'appConfig/getWidgetColor',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
handleButtonClick() {
|
||||
if (this.userInput && this.userInput.trim()) {
|
||||
|
||||
@@ -7,9 +7,13 @@
|
||||
<AgentMessage
|
||||
v-else
|
||||
:agent-name="agentName"
|
||||
:avatar-url="avatarUrl"
|
||||
:content-type="message.content_type"
|
||||
:message-content-attributes="message.content_attributes"
|
||||
:message-id="message.id"
|
||||
:message-type="message.message_type"
|
||||
:message="message.content"
|
||||
:show-avatar="message.showAvatar"
|
||||
:avatar-url="avatarUrl"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -32,9 +36,18 @@ export default {
|
||||
return this.message.message_type === MESSAGE_TYPE.INCOMING;
|
||||
},
|
||||
agentName() {
|
||||
if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) {
|
||||
return 'Bot';
|
||||
}
|
||||
|
||||
return this.message.sender ? this.message.sender.name : '';
|
||||
},
|
||||
avatarUrl() {
|
||||
if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) {
|
||||
// eslint-disable-next-line
|
||||
return require('dashboard/assets/images/chatwoot_bot.png');
|
||||
}
|
||||
|
||||
return this.message.sender ? this.message.sender.avatar_url : '';
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
class="send-button"
|
||||
@click="onClick"
|
||||
>
|
||||
<span v-if="!loading" class="icon-holder">
|
||||
<img src="~widget/assets/images/message-send.svg" />
|
||||
</span>
|
||||
<span
|
||||
v-if="!loading"
|
||||
:style="`background-color: ${color}`"
|
||||
class="icon-holder"
|
||||
></span>
|
||||
<spinner v-else size="small" />
|
||||
</button>
|
||||
</template>
|
||||
@@ -32,6 +34,10 @@ export default {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '#6e6f73',
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -45,13 +51,19 @@ export default {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
padding-right: $space-smaller;
|
||||
|
||||
.icon-holder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
fill: $color-white;
|
||||
font-size: $font-size-big;
|
||||
font-weight: $font-weight-medium;
|
||||
width: $space-two;
|
||||
height: $space-two;
|
||||
-webkit-mask-image: url("data:image/svg+xml;charset=utf8,%3Csvg width='20' height='20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18.34 7.32l-14-7a3 3 0 00-4.08 3.9l2.4 5.37c.11.262.11.558 0 .82l-2.4 5.37A3 3 0 003 20a3.14 3.14 0 001.35-.32l14-7a3 3 0 000-5.36h-.01zm-.89 3.57l-14 7a1 1 0 01-1.35-1.3l2.39-5.37a2 2 0 00.08-.22h6.89a1 1 0 000-2H4.57a2 2 0 00-.08-.22L2.1 3.41a1 1 0 011.35-1.3l14 7a1 1 0 010 1.78z' fill='%23999A9B' fill-rule='nonzero'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml;charset=utf8,%3Csvg width='20' height='20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18.34 7.32l-14-7a3 3 0 00-4.08 3.9l2.4 5.37c.11.262.11.558 0 .82l-2.4 5.37A3 3 0 003 20a3.14 3.14 0 001.35-.32l14-7a3 3 0 000-5.36h-.01zm-.89 3.57l-14 7a1 1 0 01-1.35-1.3l2.39-5.37a2 2 0 00.08-.22h6.89a1 1 0 000-2H4.57a2 2 0 00-.08-.22L2.1 3.41a1 1 0 011.35-1.3l14 7a1 1 0 010 1.78z' fill='%23999A9B' fill-rule='nonzero'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,12 +13,10 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<branding></branding>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Branding from 'widget/components/Branding.vue';
|
||||
import ChatMessage from 'widget/components/ChatMessage.vue';
|
||||
import DateSeparator from 'shared/components/DateSeparator.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
@@ -27,7 +25,6 @@ import { mapActions, mapGetters } from 'vuex';
|
||||
export default {
|
||||
name: 'ConversationWrap',
|
||||
components: {
|
||||
Branding,
|
||||
ChatMessage,
|
||||
DateSeparator,
|
||||
Spinner,
|
||||
|
||||
26
app/javascript/widget/components/ResizableTextarea.vue
Normal file
26
app/javascript/widget/components/ResizableTextarea.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script>
|
||||
export default {
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
this.$el.setAttribute(
|
||||
'style',
|
||||
`height: ${this.$el.scrollHeight}px;overflow-y:hidden;`
|
||||
);
|
||||
});
|
||||
|
||||
this.$el.addEventListener('input', this.resizeTextarea);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$el.removeEventListener('input', this.resizeTextarea);
|
||||
},
|
||||
methods: {
|
||||
resizeTextarea(event) {
|
||||
event.target.style.height = '3.2rem';
|
||||
event.target.style.height = `${event.target.scrollHeight}px`;
|
||||
},
|
||||
},
|
||||
render() {
|
||||
return this.$slots.default[0];
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -46,6 +46,7 @@ export default {
|
||||
}
|
||||
& + .agent-message {
|
||||
margin-top: $space-normal;
|
||||
margin-bottom: $space-micro;
|
||||
}
|
||||
.message-wrap {
|
||||
margin-right: $space-small;
|
||||
|
||||
@@ -44,7 +44,7 @@ export default {
|
||||
font-size: $font-size-default;
|
||||
line-height: 1.5;
|
||||
max-width: 80%;
|
||||
padding: $space-small $space-two;
|
||||
padding: $space-small $space-normal;
|
||||
text-align: left;
|
||||
|
||||
a {
|
||||
|
||||
115
app/javascript/widget/components/template/EmailInput.vue
Normal file
115
app/javascript/widget/components/template/EmailInput.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div>
|
||||
<form
|
||||
v-if="!hasSubmitted"
|
||||
class="email-input-group"
|
||||
@submit.prevent="onSubmit()"
|
||||
>
|
||||
<input
|
||||
v-model.trim="email"
|
||||
class="form-input small"
|
||||
placeholder="Please enter your email"
|
||||
:class="{ error: $v.email.$error }"
|
||||
@input="$v.email.$touch"
|
||||
/>
|
||||
<button
|
||||
class="button"
|
||||
:disabled="$v.email.$invalid"
|
||||
:style="{ background: widgetColor, borderColor: widgetColor }"
|
||||
>
|
||||
<i v-if="!uiFlags.isUpdating" class="ion-ios-arrow-forward" />
|
||||
<spinner v-else />
|
||||
</button>
|
||||
</form>
|
||||
<span v-else>
|
||||
<i>{{ messageContentAttributes.submitted_email }}</i>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
import { required, email } from 'vuelidate/lib/validators';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Spinner,
|
||||
},
|
||||
props: {
|
||||
messageId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
messageContentAttributes: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
email: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'contact/getUIFlags',
|
||||
widgetColor: 'appConfig/getWidgetColor',
|
||||
}),
|
||||
hasSubmitted() {
|
||||
return (
|
||||
this.messageContentAttributes &&
|
||||
this.messageContentAttributes.submitted_email
|
||||
);
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
email: {
|
||||
required,
|
||||
email,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onSubmit() {
|
||||
this.$store.dispatch('contact/updateContactAttributes', {
|
||||
email: this.email,
|
||||
messageId: this.messageId,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~widget/assets/scss/variables.scss';
|
||||
|
||||
.email-input-group {
|
||||
display: flex;
|
||||
margin: $space-small 0;
|
||||
min-width: 200px;
|
||||
|
||||
input {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
|
||||
&.error {
|
||||
border-color: $color-error;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
font-size: $font-size-large;
|
||||
height: auto;
|
||||
margin-left: -1px;
|
||||
|
||||
.spinner {
|
||||
display: block;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,4 +9,6 @@ export const MESSAGE_STATUS = {
|
||||
export const MESSAGE_TYPE = {
|
||||
INCOMING: 0,
|
||||
OUTGOING: 1,
|
||||
ACTIVITY: 2,
|
||||
TEMPLATE: 3,
|
||||
};
|
||||
|
||||
@@ -8,3 +8,13 @@ export const arrayToHashById = array =>
|
||||
newMap[obj.id] = obj;
|
||||
return newMap;
|
||||
}, {});
|
||||
|
||||
export const IFrameHelper = {
|
||||
isIFrame: () => window.self !== window.top,
|
||||
sendMessage: msg => {
|
||||
window.parent.postMessage(
|
||||
`chatwoot-widget:${JSON.stringify({ ...msg })}`,
|
||||
'*'
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import conversation from 'widget/store/modules/conversation';
|
||||
import appConfig from 'widget/store/modules/appConfig';
|
||||
import contact from 'widget/store/modules/contact';
|
||||
import conversation from 'widget/store/modules/conversation';
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
export default new Vuex.Store({
|
||||
modules: {
|
||||
appConfig,
|
||||
contact,
|
||||
conversation,
|
||||
},
|
||||
});
|
||||
|
||||
45
app/javascript/widget/store/modules/contact.js
Normal file
45
app/javascript/widget/store/modules/contact.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { updateContact } from 'widget/api/contact';
|
||||
|
||||
const state = {
|
||||
uiFlags: {
|
||||
isUpdating: false,
|
||||
},
|
||||
};
|
||||
|
||||
const getters = {
|
||||
getUIFlags: $state => $state.uiFlags,
|
||||
};
|
||||
|
||||
const actions = {
|
||||
updateContactAttributes: async ({ commit }, { email, messageId }) => {
|
||||
commit('toggleUpdateStatus', true);
|
||||
try {
|
||||
await updateContact({ email, messageId });
|
||||
commit(
|
||||
'conversation/updateMessage',
|
||||
{
|
||||
id: messageId,
|
||||
content_attributes: { submitted_email: email },
|
||||
},
|
||||
{ root: true }
|
||||
);
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
commit('toggleUpdateStatus', false);
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
toggleUpdateStatus($state, status) {
|
||||
$state.uiFlags.isUpdating = status;
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
@@ -49,7 +49,6 @@ export const getters = {
|
||||
Object.values(_state.conversations),
|
||||
message => new DateHelper(message.created_at).format()
|
||||
);
|
||||
|
||||
return Object.keys(conversationGroupedByDate).map(date => {
|
||||
const messages = conversationGroupedByDate[date].map((message, index) => {
|
||||
let showAvatar = false;
|
||||
@@ -59,12 +58,11 @@ export const getters = {
|
||||
const nextMessage = conversationGroupedByDate[date][index + 1];
|
||||
const currentSender = message.sender ? message.sender.name : '';
|
||||
const nextSender = nextMessage.sender ? nextMessage.sender.name : '';
|
||||
showAvatar = currentSender !== nextSender;
|
||||
showAvatar =
|
||||
currentSender !== nextSender ||
|
||||
message.message_type !== nextMessage.message_type;
|
||||
}
|
||||
return {
|
||||
showAvatar,
|
||||
...message,
|
||||
};
|
||||
return { showAvatar, ...message };
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -135,6 +133,13 @@ export const mutations = {
|
||||
|
||||
payload.map(message => Vue.set($state.conversations, message.id, message));
|
||||
},
|
||||
|
||||
updateMessage($state, { id, content_attributes }) {
|
||||
$state.conversations[id] = {
|
||||
...$state.conversations[id],
|
||||
content_attributes,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -61,19 +61,27 @@ describe('#getters', () => {
|
||||
id: 1,
|
||||
content: 'Thanks for the help',
|
||||
created_at: 1574075964,
|
||||
message_type: 0,
|
||||
},
|
||||
2: {
|
||||
id: 2,
|
||||
content: 'Yes, It makes sense',
|
||||
created_at: 1574092218,
|
||||
message_type: 0,
|
||||
},
|
||||
3: {
|
||||
id: 3,
|
||||
content: 'Hey',
|
||||
created_at: 1576340623,
|
||||
created_at: 1574092218,
|
||||
message_type: 1,
|
||||
},
|
||||
4: {
|
||||
id: 4,
|
||||
content: 'Hey',
|
||||
created_at: 1576340623,
|
||||
},
|
||||
5: {
|
||||
id: 5,
|
||||
content: 'How may I help you',
|
||||
created_at: 1576340626,
|
||||
},
|
||||
@@ -88,12 +96,21 @@ describe('#getters', () => {
|
||||
content: 'Thanks for the help',
|
||||
created_at: 1574075964,
|
||||
showAvatar: false,
|
||||
message_type: 0,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content: 'Yes, It makes sense',
|
||||
created_at: 1574092218,
|
||||
showAvatar: true,
|
||||
message_type: 0,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
content: 'Hey',
|
||||
created_at: 1574092218,
|
||||
showAvatar: true,
|
||||
message_type: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -101,13 +118,13 @@ describe('#getters', () => {
|
||||
date: 'Dec 14, 2019',
|
||||
messages: [
|
||||
{
|
||||
id: 3,
|
||||
id: 4,
|
||||
content: 'Hey',
|
||||
created_at: 1576340623,
|
||||
showAvatar: false,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
id: 5,
|
||||
content: 'How may I help you',
|
||||
created_at: 1576340626,
|
||||
showAvatar: true,
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
</div>
|
||||
<ConversationWrap :grouped-messages="groupedMessages" />
|
||||
<div class="footer-wrap">
|
||||
<ChatFooter :on-send-message="handleSendMessage" />
|
||||
<div class="input-wrap">
|
||||
<ChatFooter :on-send-message="handleSendMessage" />
|
||||
</div>
|
||||
<branding></branding>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -14,6 +17,7 @@
|
||||
<script>
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
|
||||
import Branding from 'widget/components/Branding.vue';
|
||||
import ChatFooter from 'widget/components/ChatFooter.vue';
|
||||
import ChatHeaderExpanded from 'widget/components/ChatHeaderExpanded.vue';
|
||||
import ChatHeader from 'widget/components/ChatHeader.vue';
|
||||
@@ -26,6 +30,7 @@ export default {
|
||||
ChatHeaderExpanded,
|
||||
ConversationWrap,
|
||||
ChatHeader,
|
||||
Branding,
|
||||
},
|
||||
methods: {
|
||||
...mapActions('conversation', ['sendMessage']),
|
||||
@@ -67,6 +72,29 @@ export default {
|
||||
|
||||
.footer-wrap {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -$space-normal;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: $space-normal;
|
||||
opacity: 0.1;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
$color-background,
|
||||
rgba($color-background, 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
padding: 0 $space-medium;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
9
app/jobs/action_cable_broadcast_job.rb
Normal file
9
app/jobs/action_cable_broadcast_job.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
class ActionCableBroadcastJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(members, event_name, data)
|
||||
members.each do |member|
|
||||
ActionCable.server.broadcast(member, event: event_name, data: data)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -45,9 +45,7 @@ class ActionCableListener < BaseListener
|
||||
def send_to_members(members, event_name, data)
|
||||
return if members.blank?
|
||||
|
||||
members.each do |member|
|
||||
ActionCable.server.broadcast(member, event: event_name, data: data)
|
||||
end
|
||||
::ActionCableBroadcastJob.perform_later(members, event_name, data)
|
||||
end
|
||||
|
||||
def send_to_contact(contact, event_name, message)
|
||||
@@ -55,7 +53,7 @@ class ActionCableListener < BaseListener
|
||||
return if message.activity?
|
||||
return if contact.nil?
|
||||
|
||||
ActionCable.server.broadcast(contact.pubsub_token, event: event_name, data: message.push_event_data)
|
||||
::ActionCableBroadcastJob.perform_later([contact.pubsub_token], event_name, message.push_event_data)
|
||||
end
|
||||
|
||||
def push(pubsub_token, data)
|
||||
|
||||
@@ -25,10 +25,10 @@ class ReportingListener < BaseListener
|
||||
|
||||
def message_created(event)
|
||||
message, account, timestamp = extract_message_and_account(event)
|
||||
if message.outgoing?
|
||||
::Reports::UpdateAccountIdentity.new(account, timestamp).incr_outgoing_messages_count
|
||||
else
|
||||
::Reports::UpdateAccountIdentity.new(account, timestamp).incr_incoming_messages_count
|
||||
end
|
||||
|
||||
return unless message.reportable?
|
||||
|
||||
identity = ::Reports::UpdateAccountIdentity.new(account, timestamp)
|
||||
message.outgoing? ? identity.incr_outgoing_messages_count : identity.incr_incoming_messages_count
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,4 +4,8 @@ class ApplicationMailer < ActionMailer::Base
|
||||
|
||||
# helpers
|
||||
helper :frontend_urls
|
||||
|
||||
def smtp_config_set_or_development?
|
||||
ENV.fetch('SMTP_ADDRESS', nil).present? || Rails.env.development?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,7 +3,7 @@ class AssignmentMailer < ApplicationMailer
|
||||
layout 'mailer'
|
||||
|
||||
def conversation_assigned(conversation, agent)
|
||||
return if ENV.fetch('SMTP_ADDRESS', nil).blank?
|
||||
return unless smtp_config_set_or_development?
|
||||
|
||||
@agent = agent
|
||||
@conversation = conversation
|
||||
|
||||
26
app/mailers/conversation_mailer.rb
Normal file
26
app/mailers/conversation_mailer.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
class ConversationMailer < ApplicationMailer
|
||||
default from: ENV.fetch('MAILER_SENDER_EMAIL', 'accounts@chatwoot.com')
|
||||
layout 'mailer'
|
||||
|
||||
def new_message(conversation, message_queued_time)
|
||||
return unless smtp_config_set_or_development?
|
||||
|
||||
@conversation = conversation
|
||||
@contact = @conversation.contact
|
||||
@agent = @conversation.assignee
|
||||
|
||||
recap_messages = @conversation.messages.where('created_at < ?', message_queued_time).order(created_at: :asc).last(10)
|
||||
new_messages = @conversation.messages.where('created_at >= ?', message_queued_time)
|
||||
|
||||
@messages = recap_messages + new_messages
|
||||
@messages = @messages.select(&:reportable?)
|
||||
|
||||
mail(to: @contact&.email, reply_to: @agent&.email, subject: mail_subject(@messages.last))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mail_subject(last_message, trim_length = 30)
|
||||
"[##{@conversation.display_id}] #{last_message.content.truncate(trim_length)}"
|
||||
end
|
||||
end
|
||||
@@ -8,7 +8,6 @@
|
||||
# extension :string
|
||||
# external_url :string
|
||||
# fallback_title :string
|
||||
# file :string
|
||||
# file_type :integer default("image")
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
@@ -19,12 +18,12 @@
|
||||
require 'uri'
|
||||
require 'open-uri'
|
||||
class Attachment < ApplicationRecord
|
||||
include Rails.application.routes.url_helpers
|
||||
belongs_to :account
|
||||
belongs_to :message
|
||||
mount_uploader :file, AttachmentUploader # used for images
|
||||
enum file_type: [:image, :audio, :video, :file, :location, :fallback]
|
||||
has_one_attached :file
|
||||
|
||||
before_create :set_file_extension
|
||||
enum file_type: [:image, :audio, :video, :file, :location, :fallback]
|
||||
|
||||
def push_event_data
|
||||
return base_data.merge(location_metadata) if file_type.to_sym == :location
|
||||
@@ -68,13 +67,7 @@ class Attachment < ApplicationRecord
|
||||
}
|
||||
end
|
||||
|
||||
def set_file_extension
|
||||
if external_url && !fallback?
|
||||
self.extension = begin
|
||||
Pathname.new(URI(external_url).path).extname
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
end
|
||||
def file_url
|
||||
file.attached? ? url_for(file) : ''
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
# Table name: channel_facebook_pages
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# avatar :string
|
||||
# name :string not null
|
||||
# page_access_token :string not null
|
||||
# user_access_token :string not null
|
||||
@@ -18,29 +17,29 @@
|
||||
# index_channel_facebook_pages_on_page_id_and_account_id (page_id,account_id) UNIQUE
|
||||
#
|
||||
|
||||
module Channel
|
||||
class FacebookPage < ApplicationRecord
|
||||
self.table_name = 'channel_facebook_pages'
|
||||
class Channel::FacebookPage < ApplicationRecord
|
||||
include Avatarable
|
||||
|
||||
validates :account_id, presence: true
|
||||
validates :page_id, uniqueness: { scope: :account_id }
|
||||
mount_uploader :avatar, AvatarUploader
|
||||
belongs_to :account
|
||||
self.table_name = 'channel_facebook_pages'
|
||||
|
||||
has_one :inbox, as: :channel, dependent: :destroy
|
||||
validates :account_id, presence: true
|
||||
validates :page_id, uniqueness: { scope: :account_id }
|
||||
has_one_attached :avatar
|
||||
belongs_to :account
|
||||
|
||||
before_destroy :unsubscribe
|
||||
has_one :inbox, as: :channel, dependent: :destroy
|
||||
|
||||
def name
|
||||
'Facebook'
|
||||
end
|
||||
before_destroy :unsubscribe
|
||||
|
||||
private
|
||||
def name
|
||||
'Facebook'
|
||||
end
|
||||
|
||||
def unsubscribe
|
||||
Facebook::Messenger::Subscriptions.unsubscribe(access_token: page_access_token)
|
||||
rescue => e
|
||||
true
|
||||
end
|
||||
private
|
||||
|
||||
def unsubscribe
|
||||
Facebook::Messenger::Subscriptions.unsubscribe(access_token: page_access_token)
|
||||
rescue => e
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user