Merge branch 'develop' of https://github.com/chatwoot/chatwoot into spike/multiple-conv-widget

This commit is contained in:
Nithin David 2021-08-18 11:24:30 +05:30
commit 430c0b1569
31 changed files with 252 additions and 94 deletions

View file

@ -161,3 +161,7 @@ USE_INBOX_AVATAR_FOR_BOT=true
# LETTER_OPENER=true
# meant to be used in github codespaces
# WEBPACKER_DEV_SERVER_PUBLIC=
# If you want to use official mobile app,
# the notifications would be relayed via a Chatwoot server
ENABLE_PUSH_RELAY_SERVER=true

View file

@ -28,6 +28,10 @@
"FRONTEND_URL": {
"description": "Public root URL of the Chatwoot installation. This will be used in the emails.",
"value": "https://CHANGE.herokuapp.com"
},
"INSTALLATION_ENV": {
"description": "Installation method used for Chatwoot.",
"value": "heroku"
}
},
"formation": {

View file

@ -3,8 +3,9 @@ class ContactIdentifyAction
def perform
ActiveRecord::Base.transaction do
@contact = merge_contact(existing_identified_contact, @contact) if merge_contacts?(existing_identified_contact, @contact)
@contact = merge_contact(existing_email_contact, @contact) if merge_contacts?(existing_email_contact, @contact)
merge_if_existing_identified_contact
merge_if_existing_email_contact
merge_if_existing_phone_number_contact
update_contact
end
@contact
@ -16,6 +17,18 @@ class ContactIdentifyAction
@account ||= @contact.account
end
def merge_if_existing_identified_contact
@contact = merge_contact(existing_identified_contact, @contact) if merge_contacts?(existing_identified_contact, @contact)
end
def merge_if_existing_email_contact
@contact = merge_contact(existing_email_contact, @contact) if merge_contacts?(existing_email_contact, @contact)
end
def merge_if_existing_phone_number_contact
@contact = merge_contact(existing_phone_number_contact, @contact) if merge_contacts?(existing_phone_number_contact, @contact)
end
def existing_identified_contact
return if params[:identifier].blank?
@ -28,6 +41,12 @@ class ContactIdentifyAction
@existing_email_contact ||= Contact.where(account_id: account.id).find_by(email: params[:email])
end
def existing_phone_number_contact
return if params[:phone_number].blank?
@existing_phone_number_contact ||= Contact.where(account_id: account.id).find_by(phone_number: params[:phone_number])
end
def merge_contacts?(existing_contact, _contact)
existing_contact && existing_contact.id != @contact.id
end
@ -36,7 +55,9 @@ class ContactIdentifyAction
custom_attributes = params[:custom_attributes] ? @contact.custom_attributes.merge(params[:custom_attributes]) : @contact.custom_attributes
# blank identifier or email will throw unique index error
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.update!(params.slice(:name, :email, :identifier).reject { |_k, v| v.blank? }.merge({ custom_attributes: custom_attributes }))
@contact.update!(params.slice(:name, :email, :identifier, :phone_number).reject do |_k, v|
v.blank?
end.merge({ custom_attributes: custom_attributes }))
ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
end

View file

@ -22,7 +22,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
contacts = resolved_contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search',
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
search: "%#{params[:q]}%"
)
@contacts_count = contacts.count
@ -108,7 +108,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def contact_params
params.require(:contact).permit(:name, :email, :phone_number, additional_attributes: {}, custom_attributes: {})
params.require(:contact).permit(:name, :identifier, :email, :phone_number, additional_attributes: {}, custom_attributes: {})
end
def contact_custom_attributes

View file

@ -29,6 +29,6 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
end
def permitted_params
params.permit(:website_token, :identifier, :identifier_hash, :email, :name, :avatar_url, custom_attributes: {})
params.permit(:website_token, :identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {})
end
end

View file

@ -8,6 +8,22 @@
font-weight: var(--font-weight-light);
margin-top: var(--space-large);
}
.update-subscription--checkbox {
display: flex;
input {
line-height: 1.5;
margin-right: var(--space-one);
margin-top: var(--space-smaller);
}
label {
font-size: var(--font-size-small);
line-height: 1.5;
margin-bottom: var(--space-normal);
}
}
}
.alert-box {
@ -20,17 +36,4 @@
text-align: center;
}
.update-subscription--checkbox {
display: flex;
input {
line-height: 1.5;
margin-right: var(--space-one);
}
div {
font-size: var(--font-size-small);
line-height: 1.5;
margin-bottom: var(--space-normal);
}
}

View file

@ -36,7 +36,7 @@ export default {
methods: {
openLink() {
const win = window.open(this.url, '_blank', 'noopener');
win.focus();
if (win) win.focus();
},
},
};

View file

@ -45,9 +45,20 @@ export default {
},
methods: {
valueWithLink(attribute) {
const messageFormatter = new MessageFormatter(attribute);
const parsedAttribute = this.parseAttributeToString(attribute);
const messageFormatter = new MessageFormatter(parsedAttribute);
return messageFormatter.formattedMessage;
},
parseAttributeToString(attribute) {
switch (typeof attribute) {
case 'string':
return attribute;
case 'object':
return JSON.stringify(attribute);
default:
return `${attribute}`;
}
},
},
};
</script>

View file

@ -97,6 +97,15 @@ export const IFrameHelper = {
}
});
},
setFrameHeightToFitContent: (extraHeight, isFixedHeight) => {
const iframe = IFrameHelper.getAppFrame();
const updatedIframeHeight = isFixedHeight ? `${extraHeight}px` : '100%';
if (iframe)
iframe.setAttribute('style', `height: ${updatedIframeHeight} !important`);
},
events: {
loaded: message => {
Cookies.set('cw_conversation', message.config.authToken, {
@ -169,6 +178,13 @@ export const IFrameHelper = {
}
},
updateIframeHeight: message => {
const { extraHeight = 0, isFixedHeight } = message;
if (!extraHeight) return;
IFrameHelper.setFrameHeightToFitContent(extraHeight, isFixedHeight);
},
resetUnreadMode: () => {
IFrameHelper.sendMessage('unset-unread-view');
IFrameHelper.events.removeUnreadClass();
@ -178,22 +194,6 @@ export const IFrameHelper = {
const holderEl = document.querySelector('.woot-widget-holder');
removeClass(holderEl, 'has-unread-view');
},
updateIframeHeight: message => {
setTimeout(() => {
const iframe = IFrameHelper.getAppFrame();
const scrollableMessageHeight =
iframe.contentWindow.document.querySelector('.unread-messages')
.scrollHeight + 40;
const updatedIframeHeight = message.isFixedHeight
? `${scrollableMessageHeight}px`
: '100%';
iframe.setAttribute(
'style',
`height: ${updatedIframeHeight} !important`
);
}, 100);
},
},
pushEvent: eventName => {
IFrameHelper.sendMessage('push-event', { eventName });

View file

@ -1,10 +1,11 @@
export const SDK_CSS = `.woot-widget-holder {
box-shadow: 0 5px 40px rgba(0, 0, 0, .16) !important;
opacity: 1;
will-change: transform, opacity;
transform: translateY(0);
overflow: hidden !important;
position: fixed !important;
transition-duration: 0.5s, 0.5s;
transition-property: opacity, bottom;
transition: opacity 0.2s linear, transform 0.25s linear;
z-index: 2147483000 !important;
}
@ -73,6 +74,7 @@ export const SDK_CSS = `.woot-widget-holder {
}
.woot-widget-bubble img {
all: revert;
height: 24px;
margin: 20px;
width: 24px;
@ -110,7 +112,8 @@ export const SDK_CSS = `.woot-widget-holder {
}
.woot--hide {
bottom: -20000px !important;
bottom: -100vh;
transform: translateY(40px);
top: unset !important;
opacity: 0;
visibility: hidden !important;

View file

@ -25,47 +25,42 @@ export default {
default: 'top',
},
},
mounted() {
this.focusItem();
},
methods: {
focusItem() {
this.$refs.dropdownMenu
.querySelector('ul.dropdown li.dropdown-menu__item .button')
.focus();
dropdownMenuButtons() {
return this.$refs.dropdownMenu.querySelectorAll(
'ul.dropdown li.dropdown-menu__item .button'
);
},
activeElementIndex() {
const menuButtons = this.dropdownMenuButtons();
const focusedButton = this.$refs.dropdownMenu.querySelector(
'ul.dropdown li.dropdown-menu__item .button:focus'
);
const activeIndex = [...menuButtons].indexOf(focusedButton);
return activeIndex;
},
handleKeyEvents(e) {
if (hasPressedArrowUpKey(e)) {
const items = this.$refs.dropdownMenu.querySelectorAll(
'ul.dropdown li.dropdown-menu__item .button'
);
const focusItems = this.$refs.dropdownMenu.querySelector(
'ul.dropdown li.dropdown-menu__item .button:focus'
);
const activeElementIndex = [...items].indexOf(focusItems);
const lastElementIndex = items.length - 1;
const menuButtons = this.dropdownMenuButtons();
const lastElementIndex = menuButtons.length - 1;
if (activeElementIndex >= 1) {
items[activeElementIndex - 1].focus();
if (menuButtons.length === 0) return;
if (hasPressedArrowUpKey(e)) {
const activeIndex = this.activeElementIndex();
if (activeIndex >= 1) {
menuButtons[activeIndex - 1].focus();
} else {
items[lastElementIndex].focus();
menuButtons[lastElementIndex].focus();
}
}
if (hasPressedArrowDownKey(e)) {
const items = this.$refs.dropdownMenu.querySelectorAll(
'li.dropdown-menu__item .button'
);
const focusItems = this.$refs.dropdownMenu.querySelector(
'li.dropdown-menu__item .button:focus'
);
const activeElementIndex = [...items].indexOf(focusItems);
const lastElementIndex = items.length - 1;
const activeIndex = this.activeElementIndex();
if (activeElementIndex === lastElementIndex) {
items[0].focus();
if (activeIndex === lastElementIndex) {
menuButtons[0].focus();
} else {
items[activeElementIndex + 1].focus();
menuButtons[activeIndex + 1].focus();
}
}
},

View file

@ -59,6 +59,16 @@ export default {
activeCampaign() {
this.setCampaignView();
},
showUnreadView(newVal) {
if (newVal) {
this.setIframeHeight(this.isMobile);
}
},
showCampaignView(newVal) {
if (newVal) {
this.setIframeHeight(this.isMobile);
}
},
},
mounted() {
const { websiteToken, locale } = window.chatwootWebChannel;
@ -98,9 +108,13 @@ export default {
});
},
setIframeHeight(isFixedHeight) {
IFrameHelper.sendMessage({
event: 'updateIframeHeight',
isFixedHeight,
this.$nextTick(() => {
const extraHeight = this.getExtraSpaceToscroll();
IFrameHelper.sendMessage({
event: 'updateIframeHeight',
isFixedHeight,
extraHeight,
});
});
},
setLocale(locale) {
@ -256,6 +270,23 @@ export default {
},
});
},
getExtraSpaceToscroll: () => {
// This function calculates the extra space needed for the view to
// accomodate the height of close button + height of
// read messages button. So that scrollbar won't appear
const unreadMessageWrap = document.querySelector('.unread-messages');
const unreadCloseWrap = document.querySelector('.close-unread-wrap');
const readViewWrap = document.querySelector('.open-read-view-wrap');
if (!unreadMessageWrap) return 0;
// 24px to compensate the paddings
let extraHeight = 24 + unreadMessageWrap.scrollHeight;
if (unreadCloseWrap) extraHeight += unreadCloseWrap.scrollHeight;
if (readViewWrap) extraHeight += readViewWrap.scrollHeight;
return extraHeight;
},
},
};
</script>

View file

@ -29,6 +29,7 @@ export const actions = {
name: userObject.name,
avatar_url: userObject.avatar_url,
identifier_hash: userObject.identifier_hash,
phone_number: userObject.phone_number,
};
const {
data: { pubsub_token: pubsubToken },

View file

@ -23,7 +23,7 @@
/>
</div>
<div>
<div class="open-read-view-wrap">
<button
v-if="unreadMessageCount"
class="button clear-button"

View file

@ -9,6 +9,7 @@ class Notification::PushNotificationService
notification_subscriptions.each do |subscription|
send_browser_push(subscription)
send_fcm_push(subscription)
send_push_via_chatwoot_hub(subscription)
end
end
@ -74,6 +75,18 @@ class Notification::PushNotificationService
fcm = FCM.new(ENV['FCM_SERVER_KEY'])
response = fcm.send([subscription.subscription_attributes['push_token']], fcm_options)
remove_subscription_if_error(subscription, response)
end
def send_push_via_chatwoot_hub(subscription)
return if ENV['FCM_SERVER_KEY']
return unless ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_PUSH_RELAY_SERVER', true))
return unless subscription.fcm?
ChatwootHub.send_browser_push([subscription.subscription_attributes['push_token']], fcm_options)
end
def remove_subscription_if_error(subscription, response)
subscription.destroy! if JSON.parse(response[:body])['results']&.first&.keys&.include?('error')
end

View file

@ -43,9 +43,9 @@
</label>
<div class="update-subscription--checkbox">
<%= check_box_tag "subscribe_to_updates", 'true', true %>
<div for="subscribe_to_updates">
Subscribe to release notes, newsletters & product feedback surveys.
</div>
<label for="subscribe_to_updates">
Subscribe to release notes, newsletters & product feedback surveys.
</label>
</div>
<button type="submit" class="button nice large expanded">
Finish Setup

View file

@ -39,7 +39,8 @@ window.addEventListener('chatwoot:ready', function() {
window.$chatwoot.setUser('<%= user_id %>', {
identifier_hash: '<%= user_hash %>',
email: 'jane@example.com',
name: 'Jane Doe'
name: 'Jane Doe',
phone_number: ''
});
}
})

View file

@ -1,5 +1,5 @@
shared: &shared
version: '1.18.1'
version: '1.19.0'
development:
<<: *shared

View file

@ -63,6 +63,7 @@ sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
sed -i -e '/RAILS_ENV/ s/=.*/=$RAILS_ENV/' .env
echo -en "\nINSTALLATION_ENV=LINUX_SCRIPT" >> ".env"
RAILS_ENV=production bundle exec rake db:create
RAILS_ENV=production bundle exec rake db:reset

View file

@ -70,6 +70,7 @@ sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
sed -i -e '/RAILS_ENV/ s/=.*/=$RAILS_ENV/' .env
echo -en "\nINSTALLATION_ENV=LINUX_SCRIPT" >> ".env"
RAILS_ENV=production bundle exec rake db:create
RAILS_ENV=production bundle exec rake db:reset

View file

@ -15,6 +15,7 @@ services:
environment:
- NODE_ENV=production
- RAILS_ENV=production
- INSTALLATION_ENV=docker
entrypoint: docker/entrypoints/rails.sh
command: ['bundle', 'exec', 'rails', 's', '-p', '3000', '-b', '0.0.0.0']
@ -26,6 +27,7 @@ services:
environment:
- NODE_ENV=production
- RAILS_ENV=production
- INSTALLATION_ENV=docker
command: ['bundle', 'exec', 'sidekiq', '-C', 'config/sidekiq.yml']
postgres:

View file

@ -2,6 +2,7 @@ class ChatwootHub
BASE_URL = ENV['CHATWOOT_HUB_URL'] || 'https://hub.2.chatwoot.com'
PING_URL = "#{BASE_URL}/ping".freeze
REGISTRATION_URL = "#{BASE_URL}/instances".freeze
PUSH_NOTIFICATION_URL = "#{BASE_URL}/send_push".freeze
EVENTS_URL = "#{BASE_URL}/events".freeze
def self.installation_identifier
@ -14,7 +15,8 @@ class ChatwootHub
{
installation_identifier: installation_identifier,
installation_version: Chatwoot.config[:version],
installation_host: URI.parse(ENV.fetch('FRONTEND_URL', '')).host
installation_host: URI.parse(ENV.fetch('FRONTEND_URL', '')).host,
installation_env: ENV.fetch('INSTALLATION_ENV', '')
}
end
@ -50,7 +52,16 @@ class ChatwootHub
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.info "Exception: #{e.message}"
rescue StandardError => e
Raven.capture_exception(e)
Sentry.capture_exception(e)
end
def self.send_browser_push(fcm_token_list, fcm_options)
info = { fcm_token_list: fcm_token_list, fcm_options: fcm_options }
RestClient.post(PUSH_NOTIFICATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.info "Exception: #{e.message}"
rescue StandardError => e
Sentry.capture_exception(e)
end
def self.emit_event(event_name, event_data)

View file

@ -1,7 +1,8 @@
module ExceptionList
REST_CLIENT_EXCEPTIONS = [RestClient::NotFound, RestClient::GatewayTimeout, RestClient::BadRequest,
RestClient::MethodNotAllowed, RestClient::Forbidden, RestClient::InternalServerError,
RestClient::Exceptions::OpenTimeout, RestClient::Exceptions::ReadTimeout, SocketError].freeze
RestClient::Exceptions::OpenTimeout, RestClient::Exceptions::ReadTimeout,
RestClient::MovedPermanently, SocketError].freeze
SMTP_EXCEPTIONS = [
Net::SMTPSyntaxError
].freeze

View file

@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "1.18.1",
"version": "1.19.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/javascript --fix",

View file

@ -45,6 +45,17 @@ describe ::ContactIdentifyAction do
end
end
context 'when contact with same phone_number exists' do
it 'merges the current contact to phone_number contact' do
existing_phone_number_contact = create(:contact, account: account, phone_number: '+919999888877')
params = { phone_number: '+919999888877' }
result = described_class.new(contact: contact, params: params).perform
expect(result.id).to eq existing_phone_number_contact.id
expect(result.name).to eq existing_phone_number_contact.name
expect { contact.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'when contacts with blank identifiers exist and identify action is called with blank identifier' do
it 'updates the attributes of contact passed in to identify action' do
create(:contact, account: account, identifier: '')

View file

@ -188,6 +188,19 @@ RSpec.describe 'Contacts API', type: :request do
expect(response.body).to include(contact2.email)
expect(response.body).not_to include(contact1.email)
end
it 'matches the contact respecting the identifier character casing' do
contact_normal = create(:contact, name: 'testcontact', account: account, identifier: 'testidentifer')
contact_special = create(:contact, name: 'testcontact', account: account, identifier: 'TestIdentifier')
get "/api/v1/accounts/#{account.id}/contacts/search",
params: { q: 'TestIdentifier' },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.body).to include(contact_special.identifier)
expect(response.body).not_to include(contact_normal.identifier)
end
end
end
@ -284,7 +297,7 @@ RSpec.describe 'Contacts API', type: :request do
expect(json_response['payload']['contact']['custom_attributes']).to eq({ 'test' => 'test', 'test1' => 'test1' })
end
it 'creates the contact identifier when inbox id is passed' do
it 'creates the contact inbox when inbox id is passed' do
expect do
post "/api/v1/accounts/#{account.id}/contacts", headers: admin.create_new_auth_token,
params: valid_params.merge({ inbox_id: inbox.id })

View file

@ -20,17 +20,20 @@ describe Notification::PushNotificationService do
described_class.new(notification: notification).perform
expect(Webpush).to have_received(:payload_send)
expect(FCM).not_to have_received(:new)
ENV['ENABLE_ACCOUNT_SIGNUP'] = nil
ENV['VAPID_PUBLIC_KEY'] = nil
end
it 'sends a fcm notification for firebase subscription' do
ENV['FCM_SERVER_KEY'] = 'test'
ENV['ENABLE_PUSH_RELAY_SERVER'] = 'false'
create(:notification_subscription, user: notification.user, subscription_type: 'fcm')
described_class.new(notification: notification).perform
expect(FCM).to have_received(:new)
expect(Webpush).not_to have_received(:payload_send)
ENV['ENABLE_ACCOUNT_SIGNUP'] = nil
ENV['FCM_SERVER_KEY'] = nil
ENV['ENABLE_PUSH_RELAY_SERVER'] = nil
end
end
end

View file

@ -5,7 +5,13 @@ properties:
required: true
name:
type: string
description: name of the contact
email:
type: string
description: email of the contact
phone_number:
type: string
description: phone number of the contact
identifier:
type: string
description: A unique identifier for the contact in external system

View file

@ -2,7 +2,13 @@ type: object
properties:
name:
type: string
description: name of the contact
email:
type: string
description: email of the contact
phone_number:
type: string
description: phone number of the contact
identifier:
type: string
description: A unique identifier for the contact in external system

View file

@ -8,6 +8,7 @@ get:
- name: q
in: query
type: string
description: Search using contact `name`, `identifier`, `email` or `phone number`
- $ref: '#/parameters/contact_sort_param'
- $ref: '#/parameters/page'
responses:

View file

@ -1281,7 +1281,8 @@
{
"name": "q",
"in": "query",
"type": "string"
"type": "string",
"description": "Search using contact `name`, `identifier`, `email` or `phone number`"
},
{
"$ref": "#/parameters/contact_sort_param"
@ -3376,13 +3377,20 @@
"required": true
},
"name": {
"type": "string"
"type": "string",
"description": "name of the contact"
},
"email": {
"type": "string"
"type": "string",
"description": "email of the contact"
},
"phone_number": {
"type": "string"
"type": "string",
"description": "phone number of the contact"
},
"identifier": {
"type": "string",
"description": "A unique identifier for the contact in external system"
}
}
},
@ -3390,13 +3398,20 @@
"type": "object",
"properties": {
"name": {
"type": "string"
"type": "string",
"description": "name of the contact"
},
"email": {
"type": "string"
"type": "string",
"description": "email of the contact"
},
"phone_number": {
"type": "string"
"type": "string",
"description": "phone number of the contact"
},
"identifier": {
"type": "string",
"description": "A unique identifier for the contact in external system"
}
}
},