Merge branch 'release/1.1.0'

This commit is contained in:
Sojan
2020-02-03 01:01:03 +05:45
197 changed files with 3468 additions and 936 deletions

View File

@@ -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

View File

@@ -10,7 +10,7 @@ plugins:
scss-lint:
enabled: true
brakeman:
enabled: true
enabled: false
checks:
similar-code:
enabled: false

View File

@@ -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=

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -1,4 +1,4 @@
class PasswordsController < Devise::PasswordsController
class DeviseOverrides::PasswordsController < Devise::PasswordsController
include AuthHelper
skip_before_action :require_no_authentication, raise: false

View File

@@ -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: []

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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();

View 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();

View 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');
});
});

View 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');
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -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;
}
}
}

View File

@@ -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 {

View File

@@ -1,3 +0,0 @@
<template>
<span class="spinner small"></span>
</template>

View File

@@ -12,7 +12,7 @@
</template>
<script>
import Spinner from '../Spinner';
import Spinner from 'shared/components/Spinner';
export default {
components: {

View File

@@ -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'],

View File

@@ -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';

View File

@@ -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>

View File

@@ -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: {

View File

@@ -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;

View File

@@ -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"
}
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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%;
}

View File

@@ -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>

View File

@@ -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 -->

View File

@@ -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,

View File

@@ -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,
};

View 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,
};

View File

@@ -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 },
],
]);
});
});
});

View File

@@ -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,
},
];

View File

@@ -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,
});
});
});

View File

@@ -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' }],
});
});
});
});

View File

@@ -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 }],
]);
});
});
});

View File

@@ -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,
});
});
});

View File

@@ -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'],
});
});
});
});

View File

@@ -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',

View File

@@ -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,
});

View File

@@ -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({

View File

@@ -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',

View File

@@ -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,
};

View 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;
};

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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';

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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()) {

View File

@@ -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 : '';
},
},

View File

@@ -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>

View File

@@ -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,

View 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>

View File

@@ -46,6 +46,7 @@ export default {
}
& + .agent-message {
margin-top: $space-normal;
margin-bottom: $space-micro;
}
.message-wrap {
margin-right: $space-small;

View File

@@ -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 {

View 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>

View File

@@ -9,4 +9,6 @@ export const MESSAGE_STATUS = {
export const MESSAGE_TYPE = {
INCOMING: 0,
OUTGOING: 1,
ACTIVITY: 2,
TEMPLATE: 3,
};

View File

@@ -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 })}`,
'*'
);
},
};

View File

@@ -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,
},
});

View 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,
};

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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>

View 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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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