Compare commits
37 Commits
develop
...
codex/webs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c2b10a63f | ||
|
|
b682f3979e | ||
|
|
f78e5648b0 | ||
|
|
a5c99d37a6 | ||
|
|
a6960a4f7d | ||
|
|
e577bb2205 | ||
|
|
903997f49c | ||
|
|
9e6ff8d815 | ||
|
|
42336dd2bf | ||
|
|
a112f59c3c | ||
|
|
b27d7dcec1 | ||
|
|
b713952dfe | ||
|
|
9a2c215b22 | ||
|
|
72b9894de0 | ||
|
|
9263dfb57e | ||
|
|
ffdbabaaa1 | ||
|
|
fba5bdf6c2 | ||
|
|
b91570449b | ||
|
|
6f88206393 | ||
|
|
f1fdac30d5 | ||
|
|
05a1c3f54c | ||
|
|
8f129d7a3f | ||
|
|
4daf9bcfe2 | ||
|
|
36be62d1af | ||
|
|
1ab20f1179 | ||
|
|
7a9a9e95e6 | ||
|
|
3604101150 | ||
|
|
80901ddaf9 | ||
|
|
e99722e2c4 | ||
|
|
da15b3ad55 | ||
|
|
b8f1089337 | ||
|
|
4b078b1919 | ||
|
|
b4170a16e0 | ||
|
|
829653b478 | ||
|
|
59c857047f | ||
|
|
625c82326c | ||
|
|
e54f72e252 |
1
Gemfile
1
Gemfile
@@ -144,6 +144,7 @@ gem 'sidekiq_alive'
|
||||
gem 'speedshop-cloudwatch', '~> 0.2.1', require: false
|
||||
|
||||
##-- Push notification service --##
|
||||
gem 'apnotic', '~> 1.8'
|
||||
gem 'fcm'
|
||||
gem 'web-push', '>= 3.0.1'
|
||||
|
||||
|
||||
@@ -103,6 +103,10 @@ GEM
|
||||
annotaterb (4.20.0)
|
||||
activerecord (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
apnotic (1.8.0)
|
||||
base64
|
||||
connection_pool (>= 2, < 4)
|
||||
net-http2 (>= 0.18.3, < 2)
|
||||
ast (2.4.3)
|
||||
attr_extras (7.1.0)
|
||||
audited (5.4.1)
|
||||
@@ -424,6 +428,7 @@ GEM
|
||||
logger
|
||||
html2text (0.4.0)
|
||||
nokogiri (>= 1.0, < 2.0)
|
||||
http-2 (1.2.3)
|
||||
http (5.1.1)
|
||||
addressable (~> 2.8)
|
||||
http-cookie (~> 1.0)
|
||||
@@ -567,6 +572,8 @@ GEM
|
||||
activerecord (>= 5.2)
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
net-http2 (0.19.0)
|
||||
http-2 (>= 1.0)
|
||||
net-imap (0.6.4.1)
|
||||
date
|
||||
net-protocol
|
||||
@@ -1065,6 +1072,7 @@ DEPENDENCIES
|
||||
administrate-field-belongs_to_search (>= 0.10.0)
|
||||
ai-agents (>= 0.12.0)
|
||||
annotaterb
|
||||
apnotic (~> 1.8)
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
aws-actionmailbox-ses (~> 0)
|
||||
|
||||
@@ -8,8 +8,7 @@ class Campaigns::CampaignConversationBuilder
|
||||
ActiveRecord::Base.transaction do
|
||||
@contact_inbox.lock!
|
||||
|
||||
# We won't send campaigns if a conversation is already present
|
||||
raise 'Conversation already present' if @contact_inbox.reload.conversations.present?
|
||||
raise 'Conversation already present' if single_conversation_widget? && @contact_inbox.reload.conversations.present?
|
||||
|
||||
@conversation = ::Conversation.create!(conversation_params)
|
||||
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
|
||||
@@ -22,6 +21,10 @@ class Campaigns::CampaignConversationBuilder
|
||||
|
||||
private
|
||||
|
||||
def single_conversation_widget?
|
||||
!@contact_inbox.inbox.channel.multiple_conversations?
|
||||
end
|
||||
|
||||
def message_params
|
||||
ActionController::Parameters.new({
|
||||
content: @campaign.message,
|
||||
|
||||
@@ -21,7 +21,7 @@ class ContactInboxBuilder
|
||||
email_source_id
|
||||
when 'Channel::Sms'
|
||||
phone_source_id
|
||||
when 'Channel::Api', 'Channel::WebWidget'
|
||||
when 'Channel::Api', 'Channel::WebWidget', 'Channel::MobileApp'
|
||||
SecureRandom.uuid
|
||||
else
|
||||
raise "Unsupported operation for this channel: #{@inbox.channel_type}"
|
||||
|
||||
@@ -8,7 +8,7 @@ module Api::V1::Accounts::Concerns::InboxSecretManagement
|
||||
end
|
||||
|
||||
def rotate_hmac_token
|
||||
return head :not_found unless @inbox.web_widget? || @inbox.api?
|
||||
return head :not_found unless @inbox.customer_chat? || @inbox.api?
|
||||
|
||||
@inbox.channel.regenerate_hmac_token
|
||||
render :show
|
||||
|
||||
@@ -71,7 +71,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
next false unless message.failed?
|
||||
|
||||
Messages::StatusUpdateService.new(message, 'sent').perform
|
||||
clear_source_id unless @conversation.inbox.api? || @conversation.inbox.web_widget?
|
||||
clear_source_id unless @conversation.inbox.api? || @conversation.inbox.customer_chat?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -100,7 +100,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def allowed_channel_types
|
||||
%w[web_widget api email line telegram whatsapp sms]
|
||||
%w[mobile_app web_widget api email line telegram whatsapp sms]
|
||||
end
|
||||
|
||||
def update_inbox_working_hours
|
||||
@@ -133,10 +133,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def update_channel_feature_flags
|
||||
return unless @inbox.web_widget?
|
||||
return unless permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel].key? :selected_feature_flags
|
||||
return unless @inbox.customer_chat?
|
||||
return unless permitted_params(@inbox.channel.class::EDITABLE_ATTRS)[:channel].key? :selected_feature_flags
|
||||
|
||||
@inbox.channel.selected_feature_flags = permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel][:selected_feature_flags]
|
||||
@inbox.channel.selected_feature_flags = permitted_params(@inbox.channel.class::EDITABLE_ATTRS)[:channel][:selected_feature_flags]
|
||||
@inbox.channel.save!
|
||||
end
|
||||
|
||||
@@ -204,6 +204,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
|
||||
def channel_type_from_params
|
||||
{
|
||||
'mobile_app' => Channel::MobileApp,
|
||||
'web_widget' => Channel::WebWidget,
|
||||
'api' => Channel::Api,
|
||||
'email' => Channel::Email,
|
||||
|
||||
106
app/controllers/api/v1/accounts/sdk_apps_controller.rb
Normal file
106
app/controllers/api/v1/accounts/sdk_apps_controller.rb
Normal file
@@ -0,0 +1,106 @@
|
||||
class Api::V1::Accounts::SdkAppsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_admin_authorization?
|
||||
before_action :set_sdk_app, only: [:show, :update]
|
||||
before_action :validate_attributes, only: [:update]
|
||||
|
||||
def show
|
||||
render json: payload(@sdk_app)
|
||||
end
|
||||
|
||||
def update
|
||||
@sdk_app.update!(@attributes)
|
||||
render json: payload(@sdk_app)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_sdk_app
|
||||
inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
raise ActiveRecord::RecordNotFound unless inbox.mobile_app?
|
||||
|
||||
@sdk_app = inbox.sdk_app
|
||||
end
|
||||
|
||||
def validate_attributes
|
||||
attributes = params.require(:sdk_app)
|
||||
unless attributes.is_a?(ActionController::Parameters)
|
||||
render_could_not_create_error('SDK app must be an object')
|
||||
return
|
||||
end
|
||||
|
||||
@attributes = {}
|
||||
assign_push_attributes(attributes)
|
||||
end
|
||||
|
||||
def assign_push_attributes(attributes)
|
||||
assign_ios_attributes(attributes) if attributes.key?(:ios_configuration)
|
||||
return if performed?
|
||||
|
||||
assign_android_attributes(attributes) if attributes.key?(:android_configuration)
|
||||
end
|
||||
|
||||
def assign_ios_attributes(attributes)
|
||||
ios = attributes[:ios_configuration]
|
||||
existing = @sdk_app&.ios_configuration
|
||||
if ios.nil?
|
||||
remove_ios_configuration(existing)
|
||||
return
|
||||
end
|
||||
unless valid_configuration_attributes?(ios)
|
||||
render_could_not_create_error('iOS configuration fields must be strings and enabled must be a boolean')
|
||||
return
|
||||
end
|
||||
unless Chatwoot.encryption_configured?
|
||||
render_could_not_create_error('Configure Active Record encryption before saving push credentials')
|
||||
return
|
||||
end
|
||||
|
||||
@attributes[:ios_configuration_attributes] = ios.permit(:enabled, :bundle_id, :team_id, :key_id, :private_key).to_h
|
||||
@attributes[:ios_configuration_attributes][:id] = existing.id if existing
|
||||
end
|
||||
|
||||
def remove_ios_configuration(existing)
|
||||
return unless existing
|
||||
|
||||
@attributes[:ios_configuration_attributes] = { id: existing.id, _destroy: true }
|
||||
end
|
||||
|
||||
def assign_android_attributes(attributes)
|
||||
android = attributes[:android_configuration]
|
||||
existing = @sdk_app&.android_configuration
|
||||
if android.nil?
|
||||
remove_android_configuration(existing)
|
||||
return
|
||||
end
|
||||
unless valid_configuration_attributes?(android)
|
||||
render_could_not_create_error('Android configuration fields must be strings and enabled must be a boolean')
|
||||
return
|
||||
end
|
||||
unless Chatwoot.encryption_configured?
|
||||
render_could_not_create_error('Configure Active Record encryption before saving push credentials')
|
||||
return
|
||||
end
|
||||
|
||||
@attributes[:android_configuration_attributes] = android.permit(:enabled, :package_name, :project_id, :service_account).to_h
|
||||
@attributes[:android_configuration_attributes][:id] = existing.id if existing
|
||||
end
|
||||
|
||||
def remove_android_configuration(existing)
|
||||
return unless existing
|
||||
|
||||
@attributes[:android_configuration_attributes] = { id: existing.id, _destroy: true }
|
||||
end
|
||||
|
||||
def valid_configuration_attributes?(attributes)
|
||||
attributes.is_a?(ActionController::Parameters) && attributes.except(:enabled).values.all?(String) &&
|
||||
(!attributes.key?(:enabled) || [true, false].include?(attributes[:enabled]))
|
||||
end
|
||||
|
||||
def payload(app)
|
||||
ios = app.ios_configuration
|
||||
app.slice(:id, :app_id, :name, :inbox_id).merge(
|
||||
ios_configuration: ios&.slice(:enabled, :bundle_id, :team_id, :key_id)&.merge(credentials_configured: true),
|
||||
android_configuration: app.android_configuration&.slice(:enabled, :package_name, :project_id)&.merge(credentials_configured: true)
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -4,9 +4,23 @@ class Api::V1::Widget::BaseController < ApplicationController
|
||||
|
||||
before_action :set_web_widget
|
||||
before_action :set_contact
|
||||
before_action :validate_conversation_id
|
||||
|
||||
private
|
||||
|
||||
def validate_conversation_id
|
||||
return unless params.key?(:conversation_id)
|
||||
|
||||
valid_id = case params[:conversation_id]
|
||||
when nil, '' then !@sdk_app
|
||||
when Integer then params[:conversation_id].positive?
|
||||
when String then params[:conversation_id].match?(/\A[1-9]\d*\z/)
|
||||
end
|
||||
return if valid_id
|
||||
|
||||
render json: { error: 'Invalid conversation ID' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def conversations
|
||||
if @contact_inbox.hmac_verified?
|
||||
verified_contact_inbox_ids = @contact.contact_inboxes.where(inbox_id: auth_token_params[:inbox_id], hmac_verified: true).map(&:id)
|
||||
@@ -17,7 +31,17 @@ class Api::V1::Widget::BaseController < ApplicationController
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= conversations.last
|
||||
@conversation ||= if params[:conversation_id].present?
|
||||
conversations.find_by!(display_id: params[:conversation_id])
|
||||
elsif !@sdk_app && !new_conversation_requested?
|
||||
conversations.last
|
||||
end
|
||||
end
|
||||
|
||||
# Widgets running multiple conversations always send conversation_id, blank for a new one. Widgets
|
||||
# loaded before the inbox enabled it send none and keep acting on the latest conversation.
|
||||
def new_conversation_requested?
|
||||
@web_widget.multiple_conversations? && params.key?(:conversation_id)
|
||||
end
|
||||
|
||||
def create_conversation
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
include Events::Types
|
||||
before_action :validate_list_page, only: [:list]
|
||||
before_action :render_not_found_if_empty, only: [:toggle_typing, :toggle_status, :set_custom_attributes, :destroy_custom_attributes]
|
||||
|
||||
RESULTS_PER_PAGE = 25
|
||||
|
||||
def index
|
||||
@conversation = conversation
|
||||
end
|
||||
|
||||
def list
|
||||
@unread_conversation_count = unread_messages_for(conversations.select(:id)).distinct.count(:conversation_id)
|
||||
@conversations = conversations.includes(:assignee).order(last_activity_at: :desc, id: :desc).page(permitted_params[:page]).per(RESULTS_PER_PAGE)
|
||||
@unread_counts = unread_messages_for(@conversations.map(&:id)).group(:conversation_id).count
|
||||
@last_messages = last_messages_for(@conversations)
|
||||
end
|
||||
|
||||
def create
|
||||
ActiveRecord::Base.transaction do
|
||||
process_update_contact
|
||||
@@ -90,13 +100,41 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact)
|
||||
end
|
||||
|
||||
def validate_list_page
|
||||
return unless params.key?(:page)
|
||||
return if params[:page].is_a?(Integer) && params[:page].positive?
|
||||
return if params[:page].is_a?(String) && params[:page].match?(/\A[1-9]\d*\z/)
|
||||
|
||||
render json: { error: 'Invalid conversation page' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def unread_messages_for(conversation_ids)
|
||||
Message.joins(:conversation)
|
||||
.where(conversation_id: conversation_ids)
|
||||
.outgoing
|
||||
.where(private: false)
|
||||
.not_deleted
|
||||
.where('messages.created_at > COALESCE(conversations.contact_last_seen_at, to_timestamp(0))')
|
||||
.reorder(nil)
|
||||
end
|
||||
|
||||
def last_messages_for(conversations)
|
||||
Message.where(conversation_id: conversations.map(&:id))
|
||||
.chat
|
||||
.not_deleted
|
||||
.select('DISTINCT ON (conversation_id) messages.*')
|
||||
.reorder(:conversation_id, created_at: :desc, id: :desc)
|
||||
.includes(:attachments, :sender, :conversation)
|
||||
.index_by(&:conversation_id)
|
||||
end
|
||||
|
||||
def render_not_found_if_empty
|
||||
return head :not_found if conversation.nil?
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number, { custom_attributes: {} }],
|
||||
message: [:content, :referer_url, :timestamp, :echo_id],
|
||||
custom_attributes: {})
|
||||
params.permit(:id, :page, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number, { custom_attributes: {} }],
|
||||
message: [:content, :referer_url, :timestamp, :echo_id],
|
||||
custom_attributes: {})
|
||||
end
|
||||
end
|
||||
|
||||
56
app/controllers/api/v1/widget/sdk_push_devices_controller.rb
Normal file
56
app/controllers/api/v1/widget/sdk_push_devices_controller.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
class Api::V1::Widget::SdkPushDevicesController < Api::V1::Widget::BaseController
|
||||
before_action :validate_session
|
||||
before_action :validate_device_fields, only: :create
|
||||
before_action :validate_platform_configuration, only: :create
|
||||
|
||||
def create
|
||||
attributes = params.permit(:platform, :device_token, :environment, :name)
|
||||
configuration = @sdk_app
|
||||
|
||||
device = if params.key?(:device_id)
|
||||
@contact_inbox.sdk_push_devices.where(sdk_app: configuration).find(params[:device_id])
|
||||
else
|
||||
configuration.sdk_push_devices.find_or_initialize_by(device_token: attributes[:device_token],
|
||||
environment: attributes[:environment], platform: attributes[:platform])
|
||||
end
|
||||
if device.persisted? && device.contact_inbox_id != @contact_inbox.id
|
||||
render_could_not_create_error('Unregister this device from its previous session before registering it again')
|
||||
return
|
||||
end
|
||||
|
||||
device.update!(attributes.merge(contact_inbox: @contact_inbox, contact: @contact, registered_at: Time.current, invalidated_at: nil))
|
||||
render json: { id: device.id }, status: :created
|
||||
end
|
||||
|
||||
def destroy
|
||||
@contact_inbox.sdk_push_devices.where(sdk_app: @sdk_app).find(params[:id]).destroy!
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_platform_configuration
|
||||
configurations = { 'ios' => @sdk_app.ios_configuration, 'android' => @sdk_app.android_configuration }
|
||||
return if configurations[params[:platform]]
|
||||
|
||||
render_could_not_create_error('Configure push notifications for the requested platform first')
|
||||
end
|
||||
|
||||
def validate_device_fields
|
||||
unless params.permit(:platform, :device_token, :environment, :name).values.all?(String)
|
||||
render_could_not_create_error('Device fields must be strings')
|
||||
return
|
||||
end
|
||||
return unless params.key?(:device_id) && !params[:device_id].is_a?(Integer)
|
||||
|
||||
render_could_not_create_error('Device ID must be an integer')
|
||||
end
|
||||
|
||||
def validate_session
|
||||
raise ActiveRecord::RecordNotFound unless @sdk_app
|
||||
|
||||
return if auth_token_params[:inbox_id] == @web_widget.inbox.id
|
||||
|
||||
render json: { error: 'Invalid customer session' }, status: :unauthorized
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,17 @@ module WebsiteTokenHelper
|
||||
end
|
||||
|
||||
def set_web_widget
|
||||
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
|
||||
if params.key?(:sdk_app_id)
|
||||
unless params[:sdk_app_id].is_a?(String) && params[:sdk_app_id].present?
|
||||
render json: { error: 'SDK app ID must be a non-empty string' }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
@sdk_app = SdkApp.find_by!(app_id: params[:sdk_app_id])
|
||||
@web_widget = @sdk_app.inbox.channel
|
||||
else
|
||||
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
|
||||
end
|
||||
@current_account = @web_widget.inbox.account
|
||||
|
||||
render json: { error: 'Account is suspended' }, status: :unauthorized unless @current_account.active?
|
||||
|
||||
@@ -105,6 +105,7 @@ module Api::V1::InboxesHelper
|
||||
|
||||
def account_channels_method
|
||||
{
|
||||
'mobile_app' => Current.account.mobile_app_channels,
|
||||
'web_widget' => Current.account.web_widgets,
|
||||
'api' => Current.account.api_channels,
|
||||
'email' => Current.account.email_channels,
|
||||
|
||||
18
app/javascript/dashboard/api/sdkApps.js
Normal file
18
app/javascript/dashboard/api/sdkApps.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class SdkAppsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('inboxes', { accountScoped: true });
|
||||
}
|
||||
|
||||
show(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/sdk`);
|
||||
}
|
||||
|
||||
update(inboxId, data) {
|
||||
return axios.patch(`${this.url}/${inboxId}/sdk`, data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new SdkAppsAPI();
|
||||
@@ -15,6 +15,7 @@ const channelTypeIconMap = {
|
||||
'Channel::Telegram': 'i-woot-telegram',
|
||||
'Channel::TwilioSms': 'i-woot-sms',
|
||||
'Channel::TwitterProfile': 'i-woot-x',
|
||||
'Channel::MobileApp': 'i-lucide-smartphone',
|
||||
'Channel::WebWidget': 'i-woot-website',
|
||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||
'Channel::Instagram': 'i-woot-instagram',
|
||||
|
||||
@@ -59,6 +59,7 @@ const isActive = computed(() => {
|
||||
}
|
||||
|
||||
return [
|
||||
'mobile_app',
|
||||
'website',
|
||||
'twilio',
|
||||
'api',
|
||||
|
||||
@@ -380,7 +380,7 @@ export default {
|
||||
const tiktokAttachmentSupported = imageSend ?? true;
|
||||
|
||||
return (
|
||||
this.isAWebWidgetInbox ||
|
||||
this.isCustomerChatInbox ||
|
||||
this.isAFacebookInbox ||
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isAPIInbox ||
|
||||
@@ -437,7 +437,7 @@ export default {
|
||||
enableMultipleFileUpload() {
|
||||
return (
|
||||
this.isAnEmailChannel ||
|
||||
this.isAWebWidgetInbox ||
|
||||
this.isCustomerChatInbox ||
|
||||
this.isAPIInbox ||
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isATelegramChannel
|
||||
|
||||
@@ -38,7 +38,10 @@ export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => {
|
||||
|
||||
const channelType = inbox?.channel_type;
|
||||
|
||||
if (!channelType || channelType === INBOX_TYPES.WEB) {
|
||||
if (
|
||||
!channelType ||
|
||||
[INBOX_TYPES.WEB, INBOX_TYPES.MOBILE].includes(channelType)
|
||||
) {
|
||||
return installationLimit;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export const INBOX_FEATURE_MAP = {
|
||||
[INBOX_FEATURES.REPLY_TO]: [
|
||||
INBOX_TYPES.FB,
|
||||
INBOX_TYPES.WEB,
|
||||
INBOX_TYPES.MOBILE,
|
||||
INBOX_TYPES.TWITTER,
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
@@ -26,6 +27,7 @@ export const INBOX_FEATURE_MAP = {
|
||||
],
|
||||
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
|
||||
INBOX_TYPES.WEB,
|
||||
INBOX_TYPES.MOBILE,
|
||||
INBOX_TYPES.TWITTER,
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
const CUSTOMER_CHAT_FORMATTING = {
|
||||
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
|
||||
menu: [
|
||||
'copilot',
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
'link',
|
||||
'strike',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'imageUpload',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
};
|
||||
|
||||
// Formatting rules for different contexts (channels and special contexts)
|
||||
// marks: inline formatting (strong, em, code, link, strike)
|
||||
// nodes: block structures (bulletList, orderedList, codeBlock, blockquote)
|
||||
@@ -27,23 +45,8 @@ export const FORMATTING = {
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Channel::WebWidget': {
|
||||
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
|
||||
menu: [
|
||||
'copilot',
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
'link',
|
||||
'strike',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'imageUpload',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Channel::WebWidget': CUSTOMER_CHAT_FORMATTING,
|
||||
'Channel::MobileApp': CUSTOMER_CHAT_FORMATTING,
|
||||
'Channel::Api': {
|
||||
marks: ['strong', 'em'],
|
||||
nodes: [],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { picoSearch } from '@chatwoot/pico-search';
|
||||
|
||||
export const INBOX_TYPES = {
|
||||
MOBILE: 'Channel::MobileApp',
|
||||
WEB: 'Channel::WebWidget',
|
||||
FB: 'Channel::FacebookPage',
|
||||
TWITTER: 'Channel::TwitterProfile',
|
||||
@@ -18,6 +19,7 @@ export const INBOX_TYPES = {
|
||||
// Short channel-type slugs used to identify a channel without leaning on its
|
||||
// Channel:: class name — e.g. onboarding channel cards and OAuth provider maps.
|
||||
export const CHANNEL_TYPES = {
|
||||
MOBILE: 'mobile_app',
|
||||
WEBSITE: 'website',
|
||||
WHATSAPP: 'whatsapp',
|
||||
FACEBOOK: 'facebook',
|
||||
@@ -82,6 +84,7 @@ export const getInboxVoiceIcon = (channelType, medium) => {
|
||||
};
|
||||
|
||||
const INBOX_ICON_MAP_FILL = {
|
||||
[INBOX_TYPES.MOBILE]: 'i-ri-smartphone-fill',
|
||||
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
|
||||
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
|
||||
[INBOX_TYPES.TWITTER]: 'i-ri-twitter-x-fill',
|
||||
@@ -97,6 +100,7 @@ const INBOX_ICON_MAP_FILL = {
|
||||
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
|
||||
|
||||
const INBOX_ICON_MAP_LINE = {
|
||||
[INBOX_TYPES.MOBILE]: 'i-lucide-smartphone',
|
||||
[INBOX_TYPES.WEB]: 'i-woot-website',
|
||||
[INBOX_TYPES.FB]: 'i-woot-messenger',
|
||||
[INBOX_TYPES.TWITTER]: 'i-woot-x',
|
||||
@@ -148,6 +152,8 @@ export const searchInboxes = (inboxes, query) => {
|
||||
|
||||
export const getReadableInboxByType = (type, phoneNumber) => {
|
||||
switch (type) {
|
||||
case INBOX_TYPES.MOBILE:
|
||||
return 'mobile_app';
|
||||
case INBOX_TYPES.WEB:
|
||||
return 'livechat';
|
||||
|
||||
@@ -182,6 +188,8 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
||||
|
||||
export const getInboxClassByType = (type, phoneNumber) => {
|
||||
switch (type) {
|
||||
case INBOX_TYPES.MOBILE:
|
||||
return 'smartphone';
|
||||
case INBOX_TYPES.WEB:
|
||||
return 'globe-desktop';
|
||||
|
||||
|
||||
@@ -1091,7 +1091,8 @@
|
||||
"DISPLAY_FILE_PICKER": "Display file picker on the widget",
|
||||
"DISPLAY_EMOJI_PICKER": "Display emoji picker on the widget",
|
||||
"ALLOW_END_CONVERSATION": "Allow users to end conversation from the widget",
|
||||
"USE_INBOX_AVATAR_FOR_BOT": "Use inbox name and avatar for the bot"
|
||||
"USE_INBOX_AVATAR_FOR_BOT": "Use inbox name and avatar for the bot",
|
||||
"ALLOW_MULTIPLE_CONVERSATIONS": "Allow users to have multiple conversations from the widget"
|
||||
},
|
||||
"SETTINGS_POPUP": {
|
||||
"MESSENGER_HEADING": "Messenger Script",
|
||||
@@ -1633,7 +1634,66 @@
|
||||
"API": "API Channel",
|
||||
"INSTAGRAM": "Instagram",
|
||||
"TIKTOK": "TikTok",
|
||||
"VOICE": "Voice"
|
||||
"VOICE": "Voice",
|
||||
"MOBILE_APP": "Mobile app"
|
||||
},
|
||||
"SDK_APPS": {
|
||||
"FIELDS": {
|
||||
"BUNDLE_ID": "Bundle ID",
|
||||
"TEAM_ID": "Apple Team ID",
|
||||
"KEY_ID": "APNs Key ID"
|
||||
},
|
||||
"PRIVATE_KEY": "APNs private key (.p8)",
|
||||
"KEY_HELP": "Upload the APNs signing key from your Apple Developer account. It is encrypted and stored only on the server.",
|
||||
"KEY_SAVED": "A signing key is saved. Upload a new file only to replace it.",
|
||||
"SAVE": "Save changes",
|
||||
"SAVED": "Push notification settings saved.",
|
||||
"LOADING": "Loading SDK settings…",
|
||||
"ERROR": "Unable to load or save SDK settings.",
|
||||
"KEY_TOO_LARGE": "Choose an APNs key file smaller than 8 KB.",
|
||||
"APP_ID": "SDK app ID",
|
||||
"PUSH_TITLE": "iOS push notifications",
|
||||
"INSTALL_STEP": "1. Install the SDK",
|
||||
"OPEN_STEP": "2. Open support",
|
||||
"APP_ID_HELP": "This public identifier connects the SDK to this inbox. Use the same ID for iOS and Android. It is not an API access token.",
|
||||
"ENABLE_IOS": "Enable iOS push notifications",
|
||||
"PUSH_OPTIONAL": "Optional. Chat works without push notifications. Configure Apple credentials to notify customers when your team replies.",
|
||||
"DISABLE_IOS_HELP": "Saving will pause iOS push notifications. Credentials and registered devices are kept so you can turn notifications on again.",
|
||||
"GET_STARTED": "Add chat to your iOS app",
|
||||
"INSTALL_HELP": "In Xcode, add this repository through Swift Package Manager and select the ChatwootSDK product.",
|
||||
"INIT_HELP": "Create one client for the signed-in customer. Present ChatwootView in a SwiftUI sheet, or use UIHostingController in UIKit.",
|
||||
"IDENTITY_HELP": "For signed-in customers, use identify with a signature generated by your backend. Call reset when the customer signs out.",
|
||||
"APPLE_HELP": "For push, enable the Push Notifications capability in Xcode, request notification permission, and forward the APNs device token and notification taps to the SDK. Add Apple credentials in the Push notifications tab; never include the private key in your app.",
|
||||
"DOCS": "Read the iOS SDK integration guide",
|
||||
"TABS": {
|
||||
"SETUP": "Setup",
|
||||
"SETTINGS": "Settings",
|
||||
"PUSH": "Push notifications"
|
||||
},
|
||||
"IDENTITY_TITLE": "Identify signed-in customers",
|
||||
"IOS": "iOS",
|
||||
"ANDROID": {
|
||||
"TITLE": "Android",
|
||||
"PUSH_TITLE": "Android push notifications",
|
||||
"ENABLE": "Enable Android push notifications",
|
||||
"HELP": "Add Firebase to your Android app and enable the Firebase Cloud Messaging API. Upload a service account JSON key here; keep it out of your Android app. The app uses google-services.json, a separate Firebase configuration file.",
|
||||
"DISABLE_HELP": "Saving will pause Android push notifications. Credentials and registered devices are kept so you can turn notifications on again.",
|
||||
"PACKAGE_NAME": "Android package name",
|
||||
"PROJECT_ID": "Firebase project ID",
|
||||
"SERVICE_ACCOUNT": "Firebase service account (.json)",
|
||||
"KEY_SAVED": "Credentials are saved. Upload a new file only to replace them.",
|
||||
"KEY_TOO_LARGE": "Select a service account JSON file smaller than 16 KB.",
|
||||
"INSTALL_HELP": "Add the Android SDK library to your app. See the repository README for local installation and Firebase setup.",
|
||||
"GET_STARTED": "Add chat to your Android app",
|
||||
"INIT_HELP": "Create and reuse one client for your customer. Call Chatwoot.showSupport from your activity to open the native support screen.",
|
||||
"DOCS": "Read the Android SDK integration guide"
|
||||
}
|
||||
},
|
||||
"MOBILE_APP": {
|
||||
"TITLE": "Mobile app",
|
||||
"DESCRIPTION": "Add in-app support to your iOS, Android, or React Native app.",
|
||||
"CREATE": "Create inbox",
|
||||
"APPEARANCE": "Chat appearance"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { defineComponent, h } from 'vue';
|
||||
import Facebook from './channels/Facebook.vue';
|
||||
import Website from './channels/Website.vue';
|
||||
import MobileApp from './channels/MobileApp.vue';
|
||||
import Twitter from './channels/Twitter.vue';
|
||||
import Api from './channels/Api.vue';
|
||||
import Email from './channels/Email.vue';
|
||||
@@ -17,6 +18,7 @@ import Voice from './channels/Voice.vue';
|
||||
const channelViewList = {
|
||||
facebook: Facebook,
|
||||
website: Website,
|
||||
mobile_app: MobileApp,
|
||||
twitter: Twitter,
|
||||
api: Api,
|
||||
email: Email,
|
||||
|
||||
@@ -23,6 +23,12 @@ const hasTiktokConfigured = computed(() => {
|
||||
const channelList = computed(() => {
|
||||
const { apiChannelName } = globalConfig.value;
|
||||
const channels = [
|
||||
{
|
||||
key: 'mobile_app',
|
||||
title: t('INBOX_MGMT.MOBILE_APP.TITLE'),
|
||||
description: t('INBOX_MGMT.MOBILE_APP.DESCRIPTION'),
|
||||
icon: 'i-lucide-smartphone',
|
||||
},
|
||||
{
|
||||
key: 'website',
|
||||
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WEBSITE.TITLE'),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import QRCode from 'qrcode';
|
||||
import MobileAppSetup from './settingsPage/MobileAppSetup.vue';
|
||||
import EmptyState from '../../../../components/widgets/EmptyState.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
|
||||
@@ -189,7 +190,20 @@ watch(
|
||||
v-if="hasDuplicateInstagramInbox"
|
||||
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.NEW_INBOX_SUGGESTION')"
|
||||
/>
|
||||
<div v-if="currentInbox.channel_type === INBOX_TYPES.MOBILE">
|
||||
<MobileAppSetup :inbox="currentInbox" />
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'settings_inbox_show',
|
||||
params: { inboxId: $route.params.inbox_id },
|
||||
}"
|
||||
class="inline-flex mt-6"
|
||||
>
|
||||
<NextButton :label="$t('INBOX_MGMT.FINISH.MORE_SETTINGS')" />
|
||||
</router-link>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else
|
||||
:title="$t('INBOX_MGMT.FINISH.TITLE')"
|
||||
:message="isAnEmailChannel && !currentInbox.provider ? '' : message"
|
||||
:button-text="$t('INBOX_MGMT.FINISH.BUTTON_TEXT')"
|
||||
|
||||
@@ -23,6 +23,7 @@ import PreChatFormSettings from './PreChatForm/Settings.vue';
|
||||
import WeeklyAvailability from './components/WeeklyAvailability.vue';
|
||||
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
|
||||
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
|
||||
import MobileAppSetup from './settingsPage/MobileAppSetup.vue';
|
||||
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
|
||||
import WhatsappCallingPage from './settingsPage/WhatsappCallingPage.vue';
|
||||
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
|
||||
@@ -58,6 +59,7 @@ export default {
|
||||
BotConfiguration,
|
||||
CollaboratorsPage,
|
||||
ConfigurationPage,
|
||||
MobileAppSetup,
|
||||
VoiceConfigurationPage,
|
||||
WhatsappCallingPage,
|
||||
CustomerSatisfactionPage,
|
||||
@@ -208,7 +210,7 @@ export default {
|
||||
},
|
||||
];
|
||||
|
||||
if (this.isAWebWidgetInbox) {
|
||||
if (this.isCustomerChatInbox) {
|
||||
visibleToAllChannelTabs = [
|
||||
...visibleToAllChannelTabs,
|
||||
{
|
||||
@@ -218,6 +220,16 @@ export default {
|
||||
];
|
||||
}
|
||||
|
||||
if (this.isAMobileAppInbox) {
|
||||
visibleToAllChannelTabs.push(
|
||||
{ key: 'sdk-setup', name: this.$t('INBOX_MGMT.SDK_APPS.TABS.SETUP') },
|
||||
{
|
||||
key: 'push-notifications',
|
||||
name: this.$t('INBOX_MGMT.SDK_APPS.TABS.PUSH'),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.isATwilioChannel ||
|
||||
this.isALineChannel ||
|
||||
@@ -1070,6 +1082,45 @@ export default {
|
||||
</template>
|
||||
</SettingsFieldSection>
|
||||
|
||||
<SettingsAccordion
|
||||
v-if="isAMobileAppInbox"
|
||||
:title="$t('INBOX_MGMT.MOBILE_APP.APPEARANCE')"
|
||||
class="mt-6"
|
||||
>
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.WIDGET_COLOR.LABEL')"
|
||||
>
|
||||
<ColorPicker v-model="inbox.widget_color" />
|
||||
</SettingsFieldSection>
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.REPLY_TIME.TITLE')"
|
||||
>
|
||||
<SelectInput
|
||||
v-model="replyTime"
|
||||
:options="[
|
||||
{
|
||||
value: 'in_a_few_minutes',
|
||||
label: $t(
|
||||
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.REPLY_TIME.IN_A_FEW_MINUTES'
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'in_a_few_hours',
|
||||
label: $t(
|
||||
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.REPLY_TIME.IN_A_FEW_HOURS'
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'in_a_day',
|
||||
label: $t(
|
||||
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.REPLY_TIME.IN_A_DAY'
|
||||
),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</SettingsFieldSection>
|
||||
</SettingsAccordion>
|
||||
|
||||
<SettingsAccordion
|
||||
v-if="isAWebWidgetInbox"
|
||||
:title="$t('INBOX_MGMT.WIDGET_FEATURES')"
|
||||
@@ -1281,6 +1332,19 @@ export default {
|
||||
{{ $t('INBOX_MGMT.FEATURES.USE_INBOX_AVATAR_FOR_BOT') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-2 py-0.5">
|
||||
<input
|
||||
v-model="selectedFeatureFlags"
|
||||
type="checkbox"
|
||||
value="multiple_conversations"
|
||||
@input="handleFeatureFlag"
|
||||
/>
|
||||
<label for="multiple_conversations">
|
||||
{{
|
||||
$t('INBOX_MGMT.FEATURES.ALLOW_MULTIPLE_CONVERSATIONS')
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsFieldSection>
|
||||
</SettingsAccordion>
|
||||
@@ -1321,7 +1385,7 @@ export default {
|
||||
</SettingsToggleSection>
|
||||
|
||||
<SettingsToggleSection
|
||||
v-if="isAWebWidgetInbox"
|
||||
v-if="isCustomerChatInbox"
|
||||
v-model="emailCollectEnabled"
|
||||
:header="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_EMAIL_COLLECT_BOX')
|
||||
@@ -1334,7 +1398,7 @@ export default {
|
||||
/>
|
||||
|
||||
<SettingsToggleSection
|
||||
v-if="isAWebWidgetInbox"
|
||||
v-if="isCustomerChatInbox"
|
||||
v-model="allowMessagesAfterResolved"
|
||||
:header="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.ALLOW_MESSAGES_AFTER_RESOLVED')
|
||||
@@ -1404,6 +1468,16 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MobileAppSetup
|
||||
v-if="
|
||||
isAMobileAppInbox &&
|
||||
['sdk-setup', 'push-notifications'].includes(selectedTabKey)
|
||||
"
|
||||
:key="inbox.id"
|
||||
:inbox="inbox"
|
||||
:section="selectedTabKey === 'sdk-setup' ? 'setup' : 'push'"
|
||||
class="px-6 max-w-4xl"
|
||||
/>
|
||||
<div v-if="selectedTabKey === 'collaborators'" class="mx-6 max-w-4xl">
|
||||
<CollaboratorsPage :inbox="inbox" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const name = ref('');
|
||||
const busy = ref(false);
|
||||
|
||||
async function createInbox() {
|
||||
if (!name.value.trim() || busy.value) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
const inbox = await store.dispatch('inboxes/createChannel', {
|
||||
name: name.value.trim(),
|
||||
channel: { type: 'mobile_app' },
|
||||
});
|
||||
await router.replace({
|
||||
name: 'settings_inboxes_add_agents',
|
||||
params: { page: 'new', inbox_id: inbox.id },
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(error.message || t('INBOX_MGMT.SDK_APPS.ERROR'));
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full p-6 col-span-6">
|
||||
<PageHeader
|
||||
:header-title="t('INBOX_MGMT.MOBILE_APP.TITLE')"
|
||||
:header-content="t('INBOX_MGMT.MOBILE_APP.DESCRIPTION')"
|
||||
/>
|
||||
<form class="flex flex-col gap-4 max-w-xl" @submit.prevent="createInbox">
|
||||
<Input v-model="name" :label="t('INBOX_MGMT.ADD.CHANNEL_NAME.LABEL')" />
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="!name.trim()"
|
||||
:is-loading="busy"
|
||||
:label="t('INBOX_MGMT.MOBILE_APP.CREATE')"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -24,6 +24,7 @@ const globalConfig = computed(() => getters['globalConfig/get'].value);
|
||||
const i18nMap = {
|
||||
'Channel::FacebookPage': 'MESSENGER',
|
||||
'Channel::WebWidget': 'WEB_WIDGET',
|
||||
'Channel::MobileApp': 'MOBILE_APP',
|
||||
'Channel::TwitterProfile': 'TWITTER_PROFILE',
|
||||
'Channel::TwilioSms': 'TWILIO_SMS',
|
||||
'Channel::Whatsapp': 'WHATSAPP',
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import SdkAppsAPI from 'dashboard/api/sdkApps';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import HmacSecretKey from './components/HmacSecretKey.vue';
|
||||
import Code from 'dashboard/components/Code.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inbox: { type: Object, required: true },
|
||||
section: { type: String, default: 'setup' },
|
||||
});
|
||||
const appId = ref('');
|
||||
const activeTab = computed(() => props.section);
|
||||
const { t } = useI18n();
|
||||
const form = reactive({
|
||||
bundle_id: '',
|
||||
team_id: '',
|
||||
key_id: '',
|
||||
});
|
||||
const privateKey = ref('');
|
||||
const fileInput = ref(null);
|
||||
const configured = ref(false);
|
||||
const platform = ref('ios');
|
||||
const platformTabs = computed(() => [
|
||||
{ key: 'ios', label: t('INBOX_MGMT.SDK_APPS.IOS') },
|
||||
{ key: 'android', label: t('INBOX_MGMT.SDK_APPS.ANDROID.TITLE') },
|
||||
]);
|
||||
const androidEnabled = ref(false);
|
||||
const androidConfigured = ref(false);
|
||||
const serviceAccount = ref('');
|
||||
const androidFileInput = ref(null);
|
||||
const androidForm = reactive({ package_name: '', project_id: '' });
|
||||
const iosEnabled = ref(false);
|
||||
const iosConfigured = ref(false);
|
||||
const integrationCode = computed(
|
||||
() => `import Foundation
|
||||
import SwiftUI
|
||||
import ChatwootSDK
|
||||
|
||||
let client = ChatwootClient(configuration: .init(
|
||||
baseURL: URL(string: ${JSON.stringify(window.location.origin)})!,
|
||||
sdkAppID: ${JSON.stringify(appId.value)}
|
||||
))
|
||||
|
||||
ChatwootView(client: client)`
|
||||
);
|
||||
const androidCode = computed(
|
||||
() => `val client = ChatwootClient(
|
||||
context = applicationContext,
|
||||
baseUrl = ${JSON.stringify(window.location.origin)},
|
||||
sdkAppId = ${JSON.stringify(appId.value)}
|
||||
)
|
||||
Chatwoot.showSupport(this, client)`
|
||||
);
|
||||
const loading = ref(true);
|
||||
const loadFailed = ref(false);
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
const pushFields = ['bundle_id', 'team_id', 'key_id'];
|
||||
|
||||
function reportError(exception) {
|
||||
error.value =
|
||||
exception.response?.data?.error ||
|
||||
exception.response?.data?.message ||
|
||||
t('INBOX_MGMT.SDK_APPS.ERROR');
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
loadFailed.value = false;
|
||||
privateKey.value = '';
|
||||
serviceAccount.value = '';
|
||||
notice.value = '';
|
||||
error.value = '';
|
||||
try {
|
||||
const { data: app } = await SdkAppsAPI.show(props.inbox.id);
|
||||
appId.value = app.app_id;
|
||||
configured.value = true;
|
||||
androidEnabled.value = app?.android_configuration?.enabled ?? false;
|
||||
androidConfigured.value = !!app?.android_configuration;
|
||||
androidForm.package_name = app?.android_configuration?.package_name || '';
|
||||
androidForm.project_id = app?.android_configuration?.project_id || '';
|
||||
iosEnabled.value = app?.ios_configuration?.enabled ?? false;
|
||||
iosConfigured.value = !!app?.ios_configuration;
|
||||
pushFields.forEach(field => {
|
||||
form[field] = app?.ios_configuration?.[field] || '';
|
||||
});
|
||||
} catch (exception) {
|
||||
loadFailed.value = true;
|
||||
reportError(exception);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readKey(event) {
|
||||
privateKey.value = '';
|
||||
error.value = '';
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
if (file.size > 8192) {
|
||||
error.value = t('INBOX_MGMT.SDK_APPS.KEY_TOO_LARGE');
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
privateKey.value = await file.text();
|
||||
}
|
||||
|
||||
async function readServiceAccount(event) {
|
||||
serviceAccount.value = '';
|
||||
error.value = '';
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
if (file.size > 16384) {
|
||||
error.value = t('INBOX_MGMT.SDK_APPS.ANDROID.KEY_TOO_LARGE');
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
serviceAccount.value = await file.text();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const savingTab = activeTab.value;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const attributes =
|
||||
savingTab === 'push'
|
||||
? {
|
||||
...(platform.value === 'android'
|
||||
? {
|
||||
android_configuration:
|
||||
androidEnabled.value || androidConfigured.value
|
||||
? {
|
||||
enabled: androidEnabled.value,
|
||||
...androidForm,
|
||||
...(serviceAccount.value
|
||||
? { service_account: serviceAccount.value }
|
||||
: {}),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: {
|
||||
ios_configuration:
|
||||
iosEnabled.value || iosConfigured.value
|
||||
? {
|
||||
enabled: iosEnabled.value,
|
||||
bundle_id: form.bundle_id,
|
||||
team_id: form.team_id,
|
||||
key_id: form.key_id,
|
||||
...(privateKey.value
|
||||
? { private_key: privateKey.value }
|
||||
: {}),
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
}
|
||||
: {};
|
||||
const { data } = await SdkAppsAPI.update(props.inbox.id, {
|
||||
sdk_app: attributes,
|
||||
});
|
||||
appId.value = data.app_id;
|
||||
configured.value = true;
|
||||
if (savingTab === 'push') privateKey.value = '';
|
||||
if (savingTab === 'push' && fileInput.value) fileInput.value.value = '';
|
||||
iosConfigured.value = !!data.ios_configuration;
|
||||
androidConfigured.value = !!data.android_configuration;
|
||||
serviceAccount.value = '';
|
||||
if (androidFileInput.value) androidFileInput.value.value = '';
|
||||
notice.value = t('INBOX_MGMT.SDK_APPS.SAVED');
|
||||
} catch (exception) {
|
||||
reportError(exception);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
watch(() => props.inbox.id, load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col w-full gap-4 py-4">
|
||||
<p v-if="loading" class="text-n-slate-11">
|
||||
{{ t('INBOX_MGMT.SDK_APPS.LOADING') }}
|
||||
</p>
|
||||
<p v-if="error" role="alert" class="text-n-ruby-9">{{ error }}</p>
|
||||
<p v-if="notice" role="status" class="text-n-teal-10">{{ notice }}</p>
|
||||
<div
|
||||
v-if="configured && !loading && !loadFailed"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<TabBar
|
||||
:tabs="platformTabs"
|
||||
:initial-active-tab="platform === 'ios' ? 0 : 1"
|
||||
@tab-changed="platform = $event.key"
|
||||
/>
|
||||
</div>
|
||||
<form
|
||||
v-if="!loading && !loadFailed && activeTab !== 'setup'"
|
||||
class="flex flex-col gap-4 max-w-xl"
|
||||
@submit.prevent="save"
|
||||
>
|
||||
<template v-if="activeTab === 'push' && platform === 'ios'">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3 class="text-heading-3 text-n-slate-12">
|
||||
{{ t('INBOX_MGMT.SDK_APPS.PUSH_TITLE') }}
|
||||
</h3>
|
||||
<Switch
|
||||
v-model="iosEnabled"
|
||||
:aria-label="t('INBOX_MGMT.SDK_APPS.ENABLE_IOS')"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-body-small text-n-slate-11">
|
||||
{{ t('INBOX_MGMT.SDK_APPS.PUSH_OPTIONAL') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="iosConfigured && !iosEnabled"
|
||||
class="text-body-small text-n-ruby-11"
|
||||
>
|
||||
{{ t('INBOX_MGMT.SDK_APPS.DISABLE_IOS_HELP') }}
|
||||
</p>
|
||||
<template v-if="iosEnabled || iosConfigured">
|
||||
<Input
|
||||
v-for="field in pushFields"
|
||||
:key="field"
|
||||
v-model="form[field]"
|
||||
:label="t(`INBOX_MGMT.SDK_APPS.FIELDS.${field.toUpperCase()}`)"
|
||||
/>
|
||||
<label class="flex flex-col gap-2 text-body-main text-n-slate-12">
|
||||
{{ t('INBOX_MGMT.SDK_APPS.PRIVATE_KEY') }}
|
||||
<input ref="fileInput" type="file" accept=".p8" @change="readKey" />
|
||||
</label>
|
||||
<p class="text-body-small text-n-slate-11">
|
||||
{{
|
||||
t(
|
||||
iosConfigured
|
||||
? 'INBOX_MGMT.SDK_APPS.KEY_SAVED'
|
||||
: 'INBOX_MGMT.SDK_APPS.KEY_HELP'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
</template>
|
||||
<template v-if="activeTab === 'push' && platform === 'android'">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3 class="text-heading-3 text-n-slate-12">
|
||||
{{ t('INBOX_MGMT.SDK_APPS.ANDROID.PUSH_TITLE') }}
|
||||
</h3>
|
||||
<Switch
|
||||
v-model="androidEnabled"
|
||||
:aria-label="t('INBOX_MGMT.SDK_APPS.ANDROID.ENABLE')"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-body-small text-n-slate-11">
|
||||
{{ t('INBOX_MGMT.SDK_APPS.ANDROID.HELP') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="androidConfigured && !androidEnabled"
|
||||
class="text-body-small text-n-ruby-11"
|
||||
>
|
||||
{{ t('INBOX_MGMT.SDK_APPS.ANDROID.DISABLE_HELP') }}
|
||||
</p>
|
||||
<template v-if="androidEnabled || androidConfigured">
|
||||
<Input
|
||||
v-model="androidForm.package_name"
|
||||
:label="t('INBOX_MGMT.SDK_APPS.ANDROID.PACKAGE_NAME')"
|
||||
/>
|
||||
<Input
|
||||
v-model="androidForm.project_id"
|
||||
:label="t('INBOX_MGMT.SDK_APPS.ANDROID.PROJECT_ID')"
|
||||
/>
|
||||
<label class="flex flex-col gap-2 text-body-main text-n-slate-12">
|
||||
{{ t('INBOX_MGMT.SDK_APPS.ANDROID.SERVICE_ACCOUNT') }}
|
||||
<input
|
||||
ref="androidFileInput"
|
||||
type="file"
|
||||
accept=".json"
|
||||
@change="readServiceAccount"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="androidConfigured" class="text-body-small text-n-slate-11">
|
||||
{{ t('INBOX_MGMT.SDK_APPS.ANDROID.KEY_SAVED') }}
|
||||
</p>
|
||||
</template>
|
||||
</template>
|
||||
<Button
|
||||
type="submit"
|
||||
:is-loading="busy"
|
||||
:disabled="busy"
|
||||
:label="t('INBOX_MGMT.SDK_APPS.SAVE')"
|
||||
class="self-start"
|
||||
/>
|
||||
</form>
|
||||
<section
|
||||
v-if="appId && !loading && !loadFailed && activeTab === 'setup'"
|
||||
class="flex flex-col gap-4 max-w-2xl"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-body-small text-n-slate-11">{{
|
||||
t('INBOX_MGMT.SDK_APPS.APP_ID')
|
||||
}}</span>
|
||||
<Code :script="appId" lang="plaintext" />
|
||||
<p class="text-body-small text-n-slate-11">
|
||||
{{ t('INBOX_MGMT.SDK_APPS.APP_ID_HELP') }}
|
||||
</p>
|
||||
</div>
|
||||
<h2 class="text-heading-3 text-n-slate-12">
|
||||
{{ t('INBOX_MGMT.SDK_APPS.INSTALL_STEP') }}
|
||||
</h2>
|
||||
<p class="text-body-main text-n-slate-11">
|
||||
{{
|
||||
t(
|
||||
platform === 'ios'
|
||||
? 'INBOX_MGMT.SDK_APPS.INSTALL_HELP'
|
||||
: 'INBOX_MGMT.SDK_APPS.ANDROID.INSTALL_HELP'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<Code
|
||||
:script="`https://github.com/chatwoot/${platform}-sdk`"
|
||||
lang="plaintext"
|
||||
/>
|
||||
<h2 class="text-heading-3 text-n-slate-12">
|
||||
{{ t('INBOX_MGMT.SDK_APPS.OPEN_STEP') }}
|
||||
</h2>
|
||||
<p class="text-body-main text-n-slate-11">
|
||||
{{
|
||||
platform === 'ios'
|
||||
? t('INBOX_MGMT.SDK_APPS.INIT_HELP')
|
||||
: t('INBOX_MGMT.SDK_APPS.ANDROID.INIT_HELP')
|
||||
}}
|
||||
</p>
|
||||
<Code
|
||||
:script="platform === 'ios' ? integrationCode : androidCode"
|
||||
:lang="platform === 'ios' ? 'swift' : 'kotlin'"
|
||||
/>
|
||||
<details class="group border-t border-n-weak pt-3">
|
||||
<summary class="cursor-pointer text-body-main text-n-slate-12">
|
||||
{{ t('INBOX_MGMT.SDK_APPS.IDENTITY_TITLE') }}
|
||||
</summary>
|
||||
<p class="mt-2 text-body-small text-n-slate-11">
|
||||
{{ t('INBOX_MGMT.SDK_APPS.IDENTITY_HELP') }}
|
||||
</p>
|
||||
<HmacSecretKey :inbox="inbox" />
|
||||
</details>
|
||||
<details class="group border-t border-n-weak pt-3">
|
||||
<summary class="cursor-pointer text-body-main text-n-slate-12">
|
||||
{{ t('INBOX_MGMT.SDK_APPS.TABS.PUSH') }}
|
||||
</summary>
|
||||
<p class="mt-2 text-body-small text-n-slate-11">
|
||||
{{
|
||||
t(
|
||||
platform === 'ios'
|
||||
? 'INBOX_MGMT.SDK_APPS.APPLE_HELP'
|
||||
: 'INBOX_MGMT.SDK_APPS.ANDROID.HELP'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</details>
|
||||
<a
|
||||
:href="`https://github.com/chatwoot/${platform}-sdk#readme`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-n-blue-11 hover:underline"
|
||||
>
|
||||
{{
|
||||
platform === 'ios'
|
||||
? t('INBOX_MGMT.SDK_APPS.DOCS')
|
||||
: t('INBOX_MGMT.SDK_APPS.ANDROID.DOCS')
|
||||
}}
|
||||
</a>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
@@ -11,6 +11,7 @@ export const INBOX_FEATURE_MAP = {
|
||||
[INBOX_FEATURES.REPLY_TO]: [
|
||||
INBOX_TYPES.FB,
|
||||
INBOX_TYPES.WEB,
|
||||
INBOX_TYPES.MOBILE,
|
||||
INBOX_TYPES.TWITTER,
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
@@ -19,6 +20,7 @@ export const INBOX_FEATURE_MAP = {
|
||||
],
|
||||
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
|
||||
INBOX_TYPES.WEB,
|
||||
INBOX_TYPES.MOBILE,
|
||||
INBOX_TYPES.TWITTER,
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
@@ -53,6 +55,12 @@ export default {
|
||||
isAWebWidgetInbox() {
|
||||
return this.channelType === INBOX_TYPES.WEB;
|
||||
},
|
||||
isAMobileAppInbox() {
|
||||
return this.channelType === INBOX_TYPES.MOBILE;
|
||||
},
|
||||
isCustomerChatInbox() {
|
||||
return this.isAWebWidgetInbox || this.isAMobileAppInbox;
|
||||
},
|
||||
isATwilioChannel() {
|
||||
return this.channelType === INBOX_TYPES.TWILIO;
|
||||
},
|
||||
|
||||
@@ -52,9 +52,22 @@ export default {
|
||||
isWidgetOpen: 'appConfig/getIsWidgetOpen',
|
||||
messageCount: 'conversation/getMessageCount',
|
||||
unreadMessageCount: 'conversation/getUnreadMessageCount',
|
||||
otherConversationsUnreadCount: 'conversationList/getUnreadCount',
|
||||
latestConversation: 'conversationList/getLatestConversation',
|
||||
isWidgetStyleFlat: 'appConfig/isWidgetStyleFlat',
|
||||
showUnreadMessagesDialog: 'appConfig/getShowUnreadMessagesDialog',
|
||||
}),
|
||||
totalUnreadCount() {
|
||||
return this.unreadMessageCount + this.otherConversationsUnreadCount;
|
||||
},
|
||||
// Only a single-conversation widget takes over the screen while its first thread loads.
|
||||
isLoadingFirstThread() {
|
||||
return (
|
||||
!this.hasMultipleConversationsEnabled &&
|
||||
!this.conversationSize &&
|
||||
this.isFetchingList
|
||||
);
|
||||
},
|
||||
isIFrame() {
|
||||
return IFrameHelper.isIFrame();
|
||||
},
|
||||
@@ -76,7 +89,7 @@ export default {
|
||||
},
|
||||
// Keep the notification dot in sync with the unread count. The SDK ignores
|
||||
// dot updates while the bubble is hidden, so refresh it when it is back.
|
||||
unreadMessageCount() {
|
||||
totalUnreadCount() {
|
||||
this.handleUnreadNotificationDot();
|
||||
},
|
||||
hideMessageBubble() {
|
||||
@@ -99,7 +112,7 @@ export default {
|
||||
this.registerListeners();
|
||||
this.sendLoadedEvent();
|
||||
} else {
|
||||
this.fetchOldConversations();
|
||||
this.loadConversations();
|
||||
this.fetchAvailableAgents(websiteToken);
|
||||
this.setLocale(getLocale(window.location.search));
|
||||
}
|
||||
@@ -107,7 +120,9 @@ export default {
|
||||
this.registerListeners();
|
||||
this.sendRNWebViewLoadedEvent();
|
||||
}
|
||||
this.$store.dispatch('conversationAttributes/getAttributes');
|
||||
if (!this.hasMultipleConversationsEnabled) {
|
||||
this.$store.dispatch('conversationAttributes/getAttributes');
|
||||
}
|
||||
this.registerUnreadEvents();
|
||||
this.registerCampaignEvents();
|
||||
},
|
||||
@@ -126,6 +141,22 @@ export default {
|
||||
'resetCampaign',
|
||||
]),
|
||||
...mapActions('agent', ['fetchAvailableAgents']),
|
||||
// Decide the opening screen while the widget is still closed so it does not jump from Home.
|
||||
// Only Home is replaced; a campaign, unread preview or the visitor may have moved on meanwhile.
|
||||
async loadConversations() {
|
||||
if (!this.hasMultipleConversationsEnabled) {
|
||||
return this.fetchOldConversations();
|
||||
}
|
||||
await this.$store.dispatch('conversationList/load');
|
||||
if (
|
||||
this.$route.name === 'home' &&
|
||||
this.latestConversation?.status !== 'resolved' &&
|
||||
this.messageCount
|
||||
) {
|
||||
this.router.replace({ name: 'messages' });
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
setWidgetColorVariable(widgetColor) {
|
||||
if (widgetColor) {
|
||||
document.documentElement.style.setProperty(
|
||||
@@ -186,7 +217,10 @@ export default {
|
||||
});
|
||||
},
|
||||
registerCampaignEvents() {
|
||||
emitter.on(ON_CAMPAIGN_MESSAGE_CLICK, () => {
|
||||
emitter.on(ON_CAMPAIGN_MESSAGE_CLICK, async () => {
|
||||
if (this.hasMultipleConversationsEnabled) {
|
||||
await this.$store.dispatch('conversationList/startNew');
|
||||
}
|
||||
if (this.shouldShowPreChatForm) {
|
||||
this.router.replace({ name: 'prechat-form' });
|
||||
} else {
|
||||
@@ -214,7 +248,7 @@ export default {
|
||||
this.campaignsSnoozedTill && this.campaignsSnoozedTill > Date.now();
|
||||
const isCampaignReadyToExecute =
|
||||
!isEmptyObject(activeCampaign) &&
|
||||
!messageCount &&
|
||||
(this.hasMultipleConversationsEnabled || !messageCount) &&
|
||||
!shouldSnoozeCampaign;
|
||||
if (this.isIFrame && isCampaignReadyToExecute) {
|
||||
this.router.replace({ name: 'campaigns' }).then(() => {
|
||||
@@ -248,11 +282,10 @@ export default {
|
||||
}
|
||||
},
|
||||
handleUnreadNotificationDot() {
|
||||
const { unreadMessageCount } = this;
|
||||
if (this.isIFrame) {
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'handleNotificationDot',
|
||||
unreadMessageCount,
|
||||
unreadMessageCount: this.totalUnreadCount,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -277,7 +310,7 @@ export default {
|
||||
if (message.event === 'config-set') {
|
||||
this.setLocale(message.locale);
|
||||
this.setBubbleLabel();
|
||||
this.fetchOldConversations().then(() => this.setUnreadView());
|
||||
this.loadConversations().then(() => this.setUnreadView());
|
||||
this.fetchAvailableAgents(websiteToken);
|
||||
this.setAppConfig(message);
|
||||
this.$store.dispatch('contacts/get');
|
||||
@@ -332,6 +365,7 @@ export default {
|
||||
this.$store.dispatch('appConfig/toggleWidgetOpen', message.isOpen);
|
||||
|
||||
const shouldShowMessageView =
|
||||
!this.hasMultipleConversationsEnabled &&
|
||||
['home'].includes(this.$route.name) &&
|
||||
message.isOpen &&
|
||||
this.messageCount;
|
||||
@@ -372,8 +406,8 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!conversationSize && isFetchingList"
|
||||
class="flex items-center justify-center flex-1 h-full bg-n-background"
|
||||
v-if="isLoadingFirstThread"
|
||||
class="flex items-center justify-center flex-1 h-full bg-n-surface-1"
|
||||
:class="{ dark: prefersDarkMode }"
|
||||
>
|
||||
<Spinner size="" />
|
||||
@@ -386,7 +420,7 @@ export default {
|
||||
'is-widget-right': isRightAligned,
|
||||
'is-bubble-hidden': hideMessageBubble,
|
||||
'is-flat-design': isWidgetStyleFlat,
|
||||
'bg-n-slate-2 dark:bg-n-solid-1': !isUnreadOrCampaignView,
|
||||
'bg-n-surface-1': !isUnreadOrCampaignView,
|
||||
dark: prefersDarkMode,
|
||||
}"
|
||||
>
|
||||
|
||||
@@ -9,24 +9,28 @@ const createConversationAPI = async content => {
|
||||
const sendMessageAPI = async (
|
||||
content,
|
||||
replyTo = null,
|
||||
{ customAttributes, labels } = {}
|
||||
{ customAttributes, labels, conversationId } = {}
|
||||
) => {
|
||||
const urlData = endPoints.sendMessage(content, replyTo, {
|
||||
customAttributes,
|
||||
labels,
|
||||
});
|
||||
return API.post(urlData.url, urlData.params);
|
||||
return API.post(urlData.url, urlData.params, {
|
||||
params: { conversation_id: conversationId },
|
||||
});
|
||||
};
|
||||
|
||||
const sendAttachmentAPI = async (
|
||||
attachment,
|
||||
{ customAttributes, labels } = {}
|
||||
{ customAttributes, labels, conversationId } = {}
|
||||
) => {
|
||||
const urlData = endPoints.sendAttachment(attachment, {
|
||||
customAttributes,
|
||||
labels,
|
||||
});
|
||||
return API.post(urlData.url, urlData.params);
|
||||
return API.post(urlData.url, urlData.params, {
|
||||
params: { conversation_id: conversationId },
|
||||
});
|
||||
};
|
||||
|
||||
const getMessagesAPI = async ({ before, after }) => {
|
||||
@@ -38,6 +42,12 @@ const getConversationAPI = async () => {
|
||||
return API.get(`/api/v1/widget/conversations${window.location.search}`);
|
||||
};
|
||||
|
||||
const getConversationsAPI = async ({ page }) => {
|
||||
return API.get(`/api/v1/widget/conversations/list${window.location.search}`, {
|
||||
params: { page },
|
||||
});
|
||||
};
|
||||
|
||||
const toggleTyping = async ({ typingStatus }) => {
|
||||
return API.post(
|
||||
`/api/v1/widget/conversations/toggle_typing${window.location.search}`,
|
||||
@@ -84,6 +94,7 @@ export {
|
||||
createConversationAPI,
|
||||
sendMessageAPI,
|
||||
getConversationAPI,
|
||||
getConversationsAPI,
|
||||
getMessagesAPI,
|
||||
sendAttachmentAPI,
|
||||
toggleTyping,
|
||||
|
||||
@@ -86,7 +86,7 @@ label {
|
||||
input:not(.reset-base),
|
||||
textarea:not(.reset-base) {
|
||||
font-family: inherit;
|
||||
@apply rounded-lg box-border bg-n-background dark:bg-n-alpha-2 border-none outline outline-1 outline-offset-[-1px] outline-n-weak block text-base leading-[1.5] p-2.5 w-full text-n-slate-12 focus:outline-n-brand focus:ring-1 focus:ring-n-brand;
|
||||
@apply rounded-lg box-border bg-n-surface-2 dark:bg-n-solid-2 border-none outline outline-1 outline-offset-[-1px] outline-n-weak block text-base leading-[1.5] p-2.5 w-full text-n-slate-12 focus:outline-n-brand focus:ring-1 focus:ring-n-brand;
|
||||
|
||||
&:disabled {
|
||||
@apply opacity-40 cursor-not-allowed;
|
||||
@@ -102,7 +102,7 @@ textarea {
|
||||
}
|
||||
|
||||
select:not(.reset-base) {
|
||||
@apply bg-n-background dark:bg-n-alpha-2 w-full p-2.5 border-none outline outline-1 outline-offset-[-1px] outline-n-weak rounded-lg text-n-slate-12 text-base ltr:pr-10 rtl:pl-10 font-normal ltr:bg-[right_-1.6rem_center] rtl:bg-[left_-1.6rem_center] focus:outline-n-brand focus:ring-1 focus:ring-n-brand;
|
||||
@apply bg-n-surface-2 dark:bg-n-solid-2 w-full p-2.5 border-none outline outline-1 outline-offset-[-1px] outline-n-weak rounded-lg text-n-slate-12 text-base ltr:pr-10 rtl:pl-10 font-normal ltr:bg-[right_-1.6rem_center] rtl:bg-[left_-1.6rem_center] focus:outline-n-brand focus:ring-1 focus:ring-n-brand;
|
||||
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
@@ -139,6 +139,12 @@ blockquote {
|
||||
@apply ltr:border-l-4 rtl:border-r-4 border-n-slate-3 dark:border-n-alpha-2 border-solid my-1 px-0 text-n-slate-11 py-1 ltr:pr-2 rtl:pr-4 ltr:pl-4 rtl:pl-2;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.surface-card {
|
||||
@apply rounded-xl bg-n-solid-1 dark:bg-n-solid-2 outline outline-1 outline-n-weak dark:outline-n-strong shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:shadow-none;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
@apply appearance-none bg-n-brand border border-solid border-n-brand text-white cursor-pointer inline-block text-sm h-10 leading-none outline-none outline-0 py-1 px-4 text-center no-underline select-none align-middle whitespace-nowrap;
|
||||
|
||||
@@ -187,7 +193,6 @@ blockquote {
|
||||
@layer base {
|
||||
// NEXT COLORS START
|
||||
:root {
|
||||
// slate
|
||||
--slate-1: 252 252 253;
|
||||
--slate-2: 249 249 251;
|
||||
--slate-3: 240 240 243;
|
||||
@@ -200,22 +205,18 @@ blockquote {
|
||||
--slate-10: 128 131 141;
|
||||
--slate-11: 96 100 108;
|
||||
--slate-12: 28 32 36;
|
||||
|
||||
// iris
|
||||
--iris-1: 253 253 255;
|
||||
--iris-2: 248 248 255;
|
||||
--iris-3: 240 241 254;
|
||||
--iris-4: 230 231 255;
|
||||
--iris-5: 218 220 255;
|
||||
--iris-6: 203 205 255;
|
||||
--iris-7: 184 186 248;
|
||||
--iris-8: 155 158 240;
|
||||
--iris-9: 91 91 214;
|
||||
--iris-10: 81 81 205;
|
||||
--iris-11: 87 83 198;
|
||||
--iris-12: 39 41 98;
|
||||
|
||||
// ruby
|
||||
--blue-1: 251 253 255;
|
||||
--blue-2: 245 249 255;
|
||||
--blue-3: 233 243 255;
|
||||
--blue-4: 218 236 255;
|
||||
--blue-5: 201 226 255;
|
||||
--blue-6: 181 213 255;
|
||||
--blue-7: 155 195 252;
|
||||
--blue-8: 117 171 247;
|
||||
--blue-9: 39 129 246;
|
||||
--blue-10: 16 115 233;
|
||||
--blue-11: 8 109 224;
|
||||
--blue-12: 11 50 101;
|
||||
--ruby-1: 255 252 253;
|
||||
--ruby-2: 255 247 248;
|
||||
--ruby-3: 254 234 237;
|
||||
@@ -228,8 +229,6 @@ blockquote {
|
||||
--ruby-10: 220 59 93;
|
||||
--ruby-11: 202 36 77;
|
||||
--ruby-12: 100 23 43;
|
||||
|
||||
// amber
|
||||
--amber-1: 254 253 251;
|
||||
--amber-2: 254 251 233;
|
||||
--amber-3: 255 247 194;
|
||||
@@ -242,8 +241,6 @@ blockquote {
|
||||
--amber-10: 255 186 24;
|
||||
--amber-11: 171 100 0;
|
||||
--amber-12: 79 52 34;
|
||||
|
||||
// teal
|
||||
--teal-1: 250 254 253;
|
||||
--teal-2: 243 251 249;
|
||||
--teal-3: 224 248 243;
|
||||
@@ -256,8 +253,6 @@ blockquote {
|
||||
--teal-10: 13 155 138;
|
||||
--teal-11: 0 133 115;
|
||||
--teal-12: 13 61 56;
|
||||
|
||||
// gray
|
||||
--gray-1: 252 252 252;
|
||||
--gray-2: 249 249 249;
|
||||
--gray-3: 240 240 240;
|
||||
@@ -270,34 +265,25 @@ blockquote {
|
||||
--gray-10: 131 131 131;
|
||||
--gray-11: 100 100 100;
|
||||
--gray-12: 32 32 32;
|
||||
|
||||
--background-color: 253 253 253;
|
||||
--surface-1: 254 254 254;
|
||||
--surface-1: 253 253 253;
|
||||
--surface-2: 255 255 255;
|
||||
--surface-active: 255 255 255;
|
||||
--text-blue: 8 109 224;
|
||||
--border-container: 236 236 236;
|
||||
--border-strong: 235 235 235;
|
||||
--border-weak: 234 234 234;
|
||||
--border-strong: 226 227 231;
|
||||
--border-weak: 240 240 240;
|
||||
--solid-1: 255 255 255;
|
||||
--solid-2: 255 255 255;
|
||||
--solid-3: 255 255 255;
|
||||
--solid-active: 255 255 255;
|
||||
--solid-amber: 252 232 193;
|
||||
--solid-blue: 218 236 255;
|
||||
--solid-iris: 230 231 255;
|
||||
|
||||
--alpha-1: 67, 67, 67, 0.06;
|
||||
--alpha-2: 201, 202, 207, 0.15;
|
||||
--alpha-1: 215, 215, 215, 0.22;
|
||||
--alpha-2: 196, 197, 198, 0.22;
|
||||
--alpha-3: 255, 255, 255, 0.96;
|
||||
--black-alpha-1: 0, 0, 0, 0.12;
|
||||
--black-alpha-2: 0, 0, 0, 0.04;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--white-alpha: 255, 255, 255, 0.8;
|
||||
}
|
||||
|
||||
.dark {
|
||||
// slate
|
||||
--slate-1: 17 17 19;
|
||||
--slate-2: 24 25 27;
|
||||
--slate-3: 33 34 37;
|
||||
@@ -310,22 +296,18 @@ blockquote {
|
||||
--slate-10: 119 123 132;
|
||||
--slate-11: 176 180 186;
|
||||
--slate-12: 237 238 240;
|
||||
|
||||
// iris
|
||||
--iris-1: 19 19 30;
|
||||
--iris-2: 23 22 37;
|
||||
--iris-3: 32 34 72;
|
||||
--iris-4: 38 42 101;
|
||||
--iris-5: 48 51 116;
|
||||
--iris-6: 61 62 130;
|
||||
--iris-7: 74 74 149;
|
||||
--iris-8: 89 88 177;
|
||||
--iris-9: 91 91 214;
|
||||
--iris-10: 84 114 228;
|
||||
--iris-11: 158 177 255;
|
||||
--iris-12: 224 223 254;
|
||||
|
||||
// ruby
|
||||
--blue-1: 10 17 28;
|
||||
--blue-2: 15 24 38;
|
||||
--blue-3: 15 39 72;
|
||||
--blue-4: 10 49 99;
|
||||
--blue-5: 18 61 117;
|
||||
--blue-6: 29 84 134;
|
||||
--blue-7: 40 89 156;
|
||||
--blue-8: 48 106 186;
|
||||
--blue-9: 39 129 246;
|
||||
--blue-10: 21 116 231;
|
||||
--blue-11: 126 182 255;
|
||||
--blue-12: 205 227 255;
|
||||
--ruby-1: 25 17 19;
|
||||
--ruby-2: 30 21 23;
|
||||
--ruby-3: 58 20 30;
|
||||
@@ -338,8 +320,6 @@ blockquote {
|
||||
--ruby-10: 236 90 114;
|
||||
--ruby-11: 255 148 157;
|
||||
--ruby-12: 254 210 225;
|
||||
|
||||
// amber
|
||||
--amber-1: 22 18 12;
|
||||
--amber-2: 29 24 15;
|
||||
--amber-3: 48 32 8;
|
||||
@@ -352,8 +332,6 @@ blockquote {
|
||||
--amber-10: 255 214 10;
|
||||
--amber-11: 255 202 22;
|
||||
--amber-12: 255 231 179;
|
||||
|
||||
// teal
|
||||
--teal-1: 13 21 20;
|
||||
--teal-2: 17 28 27;
|
||||
--teal-3: 13 45 42;
|
||||
@@ -366,8 +344,6 @@ blockquote {
|
||||
--teal-10: 14 179 158;
|
||||
--teal-11: 11 216 182;
|
||||
--teal-12: 173 240 221;
|
||||
|
||||
// gray
|
||||
--gray-1: 17 17 17;
|
||||
--gray-2: 25 25 25;
|
||||
--gray-3: 34 34 34;
|
||||
@@ -380,29 +356,21 @@ blockquote {
|
||||
--gray-10: 123 123 123;
|
||||
--gray-11: 180 180 180;
|
||||
--gray-12: 238 238 238;
|
||||
|
||||
--background-color: 18 18 19;
|
||||
--surface-1: 20 21 23;
|
||||
--surface-2: 22 23 26;
|
||||
--background-color: 13 13 13;
|
||||
--surface-1: 13 13 13;
|
||||
--surface-2: 24 24 24;
|
||||
--surface-active: 53 57 66;
|
||||
--border-strong: 52 52 52;
|
||||
--border-weak: 38 38 42;
|
||||
--solid-1: 23 23 26;
|
||||
--solid-2: 29 30 36;
|
||||
--solid-3: 44 45 54;
|
||||
--border-strong: 42 42 42;
|
||||
--border-weak: 33 33 33;
|
||||
--solid-1: 24 24 24;
|
||||
--solid-2: 27 27 27;
|
||||
--solid-3: 48 48 48;
|
||||
--solid-active: 53 57 66;
|
||||
--solid-amber: 42 37 30;
|
||||
--solid-blue: 16 49 91;
|
||||
--solid-iris: 38 42 101;
|
||||
--text-blue: 126 182 255;
|
||||
|
||||
--alpha-1: 36, 36, 36, 0.8;
|
||||
--alpha-2: 139, 147, 182, 0.15;
|
||||
--alpha-3: 36, 38, 45, 0.9;
|
||||
--alpha-1: 35, 36, 42, 0.8;
|
||||
--alpha-2: 147, 153, 176, 0.12;
|
||||
--alpha-3: 33, 34, 38, 0.95;
|
||||
--black-alpha-1: 0, 0, 0, 0.3;
|
||||
--black-alpha-2: 0, 0, 0, 0.2;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--border-container: 236, 236, 236, 0;
|
||||
--white-alpha: 255, 255, 255, 0.1;
|
||||
--border-container: 255, 255, 255, 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ export default {
|
||||
/>
|
||||
<div
|
||||
v-if="hasAttachments"
|
||||
class="space-y-2 chat-bubble has-attachment agent bg-n-background dark:bg-n-solid-3"
|
||||
class="space-y-2 chat-bubble has-attachment agent bg-n-solid-1 dark:bg-n-solid-3"
|
||||
:class="wrapClass"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -95,7 +95,7 @@ export default {
|
||||
v-if="
|
||||
!isCards && !isOptions && !isForm && !isArticle && !isCards && !isCSAT
|
||||
"
|
||||
class="chat-bubble agent bg-n-background dark:bg-n-solid-3 text-n-slate-12"
|
||||
class="chat-bubble agent bg-n-solid-1 dark:bg-n-solid-3 text-n-slate-12"
|
||||
>
|
||||
<div
|
||||
v-dompurify-html="formatMessage(message, false)"
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
<div class="avatar-wrap" />
|
||||
<div class="message-wrap mt-2">
|
||||
<div
|
||||
class="chat-bubble agent typing-bubble bg-n-background dark:bg-n-solid-3"
|
||||
class="chat-bubble agent typing-bubble bg-n-solid-1 dark:bg-n-solid-3"
|
||||
>
|
||||
<img
|
||||
src="assets/images/typing.gif"
|
||||
|
||||
@@ -80,7 +80,7 @@ const headerText = computed(() =>
|
||||
:is-online="isOnline"
|
||||
:is-in-working-hours="isInWorkingHours"
|
||||
:class="textClasses"
|
||||
class="text-n-slate-11"
|
||||
class="text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import ChatInputWrap from 'widget/components/ChatInputWrap.vue';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { sendEmailTranscript } from 'widget/api/conversation';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { IFrameHelper } from '../helpers/utils';
|
||||
import { IFrameHelper, isMultipleConversationsEnabled } from '../helpers/utils';
|
||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
@@ -87,7 +87,10 @@ export default {
|
||||
});
|
||||
this.inReplyTo = null;
|
||||
},
|
||||
startNewConversation() {
|
||||
async startNewConversation() {
|
||||
if (isMultipleConversationsEnabled()) {
|
||||
await this.$store.dispatch('conversationList/startNew');
|
||||
}
|
||||
this.router.replace({ name: 'prechat-form' });
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'onEvent',
|
||||
|
||||
@@ -11,6 +11,7 @@ const props = defineProps({
|
||||
title: { type: String, default: '' },
|
||||
showPopoutButton: { type: Boolean, default: false },
|
||||
showBackButton: { type: Boolean, default: false },
|
||||
backRouteName: { type: String, default: 'home' },
|
||||
availableAgents: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
@@ -20,16 +21,19 @@ const router = useRouter();
|
||||
const { isOnline } = useAvailability(availableAgents);
|
||||
|
||||
const onBackButtonClick = () => {
|
||||
router.replace({ name: 'home' });
|
||||
router.replace({ name: props.backRouteName });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="flex justify-between w-full p-5 bg-n-background gap-2">
|
||||
<header
|
||||
class="flex justify-between w-full p-5 gap-2 bg-n-solid-1 border-b border-n-weak dark:border-n-strong"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
v-if="showBackButton"
|
||||
class="px-2 ltr:-ml-3 rtl:-mr-3"
|
||||
:aria-label="$t('BACK')"
|
||||
@click="onBackButtonClick"
|
||||
>
|
||||
<FluentIcon
|
||||
|
||||
@@ -30,9 +30,7 @@ const containerClasses = computed(() => [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="header-expanded pt-6 pb-4 px-5 relative box-border w-full bg-transparent"
|
||||
>
|
||||
<header class="header-expanded pt-6 pb-5 px-5 relative box-border w-full">
|
||||
<div class="flex items-start" :class="containerClasses">
|
||||
<img
|
||||
v-if="avatarUrl"
|
||||
@@ -47,11 +45,11 @@ const containerClasses = computed(() => [
|
||||
</div>
|
||||
<h2
|
||||
v-dompurify-html="introHeading"
|
||||
class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12 line-clamp-4"
|
||||
class="mt-5 text-[1.75rem] leading-tight tracking-tight mb-2 font-semibold text-n-slate-12 line-clamp-4"
|
||||
/>
|
||||
<p
|
||||
v-dompurify-html="formatMessage(introBody)"
|
||||
class="text-lg leading-normal text-n-slate-11 [&_a]:underline line-clamp-6"
|
||||
class="text-base leading-relaxed text-n-slate-12 [&_a]:underline line-clamp-6"
|
||||
/>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -134,7 +134,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="items-center flex ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2 rounded-[7px] transition-all duration-200 bg-n-background !shadow-[0_0_0_1px,0_0_2px_3px]"
|
||||
class="items-center flex ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2 rounded-lg transition-all duration-200 bg-n-solid-1 dark:bg-n-solid-2 !shadow-[0_0_0_1px,0_0_2px_3px]"
|
||||
:class="{
|
||||
'!shadow-[var(--widget-color,#2781f6)]': isFocused,
|
||||
'!shadow-n-strong dark:!shadow-n-strong': !isFocused,
|
||||
@@ -193,6 +193,6 @@ export default {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-message-input {
|
||||
@apply border-none outline-none w-full placeholder:text-n-slate-10 resize-none h-8 min-h-8 max-h-60 py-1 px-0 my-2 bg-n-background text-n-slate-12 transition-all duration-200;
|
||||
@apply border-none outline-none w-full placeholder:text-n-slate-10 resize-none h-8 min-h-8 max-h-60 py-1 px-0 my-2 bg-transparent text-n-slate-12 transition-all duration-200;
|
||||
}
|
||||
</style>
|
||||
|
||||
115
app/javascript/widget/components/ConversationListItem.vue
Normal file
115
app/javascript/widget/components/ConversationListItem.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getContrastingTextColor } from '@chatwoot/utils';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import { relativeDayTimestamp } from 'shared/helpers/timeHelper';
|
||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: { type: Object, required: true },
|
||||
});
|
||||
|
||||
defineEmits(['select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
const widgetColor = useMapGetter('appConfig/getWidgetColor');
|
||||
|
||||
const channelConfig = window.chatwootWebChannel;
|
||||
const useInboxAvatarForBot = channelConfig.enabledFeatures.includes(
|
||||
'use_inbox_avatar_for_bot'
|
||||
);
|
||||
const botAvatarUrl = useInboxAvatarForBot
|
||||
? channelConfig.avatarUrl
|
||||
: '/assets/images/chatwoot_bot.png';
|
||||
|
||||
const isResolved = computed(() => props.conversation.status === 'resolved');
|
||||
const unreadCount = computed(() => props.conversation.unread_count);
|
||||
const lastMessage = computed(() => props.conversation.last_message);
|
||||
const isVisitorMessage = computed(
|
||||
() => lastMessage.value?.message_type === MESSAGE_TYPE.INCOMING
|
||||
);
|
||||
|
||||
// Same identity rules as the agent bubbles on the messages screen.
|
||||
const senderName = computed(() => {
|
||||
const { sender } = lastMessage.value || {};
|
||||
if (sender) return sender.available_name || sender.name;
|
||||
return useInboxAvatarForBot
|
||||
? channelConfig.websiteName
|
||||
: t('UNREAD_VIEW.BOT');
|
||||
});
|
||||
const senderAvatarUrl = computed(() => {
|
||||
const message = lastMessage.value || {};
|
||||
if (message.message_type === MESSAGE_TYPE.TEMPLATE) return botAvatarUrl;
|
||||
return message.sender?.avatar_url || botAvatarUrl;
|
||||
});
|
||||
|
||||
const preview = computed(() => {
|
||||
const message = lastMessage.value;
|
||||
if (!message) return '';
|
||||
const content = message.content
|
||||
? getPlainText(message.content)
|
||||
: t('CONVERSATIONS.ATTACHMENT');
|
||||
return isVisitorMessage.value ? `${t('YOU')}: ${content}` : content;
|
||||
});
|
||||
|
||||
const timestamp = computed(() =>
|
||||
relativeDayTimestamp(props.conversation.last_activity_at, t('YESTERDAY'))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col w-full gap-0.5 outline-none text-start focus-visible:bg-n-alpha-2"
|
||||
@click="$emit('select', conversation.id)"
|
||||
>
|
||||
<span class="flex items-center w-full gap-3">
|
||||
<span class="flex items-center flex-1 min-w-0 gap-2">
|
||||
<span class="shrink-0 text-sm font-medium leading-5 text-n-slate-12">
|
||||
{{ `#${conversation.id}` }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isResolved"
|
||||
class="inline-flex items-center gap-0.5 px-1.5 rounded-full shrink-0 text-xxs font-medium leading-4 bg-n-teal-3 text-n-teal-11"
|
||||
>
|
||||
<i class="i-lucide-check size-2.5" aria-hidden="true" />
|
||||
{{ $t('CONVERSATIONS.RESOLVED') }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-sm leading-5 shrink-0 text-n-slate-11">
|
||||
{{ timestamp }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="flex items-center w-full gap-2">
|
||||
<span class="flex-1 min-w-0 text-sm leading-5 truncate text-n-slate-12">
|
||||
{{ preview }}
|
||||
</span>
|
||||
<span class="flex items-center h-5 gap-1.5 shrink-0">
|
||||
<span
|
||||
v-if="unreadCount"
|
||||
class="flex items-center justify-center h-5 px-1.5 text-xs font-semibold rounded-full min-w-5"
|
||||
:style="{
|
||||
backgroundColor: widgetColor,
|
||||
color: getContrastingTextColor(widgetColor),
|
||||
}"
|
||||
>
|
||||
<span aria-hidden="true">{{ unreadCount }}</span>
|
||||
<span class="sr-only">
|
||||
{{ $t('CONVERSATIONS.UNREAD_COUNT', unreadCount) }}
|
||||
</span>
|
||||
</span>
|
||||
<Avatar
|
||||
v-if="lastMessage && !isVisitorMessage"
|
||||
:src="senderAvatarUrl"
|
||||
:name="senderName"
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -166,7 +166,7 @@ function onSelect() {
|
||||
<template>
|
||||
<div class="relative mt-2 phone-input--wrap">
|
||||
<div
|
||||
class="flex items-center justify-start outline-none phone-input rounded-lg box-border bg-n-background dark:bg-n-alpha-2 border-none outline outline-1 outline-offset-[-1px] text-sm w-full text-n-slate-12 focus-within:outline-n-brand focus-within:ring-1 focus-within:ring-n-brand"
|
||||
class="flex items-center justify-start outline-none phone-input rounded-lg box-border bg-n-surface-2 dark:bg-n-alpha-2 border-none outline outline-1 outline-offset-[-1px] text-sm w-full text-n-slate-12 focus-within:outline-n-brand focus-within:ring-1 focus-within:ring-n-brand"
|
||||
:class="{
|
||||
'outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9':
|
||||
hasErrorInPhoneInput,
|
||||
@@ -203,14 +203,12 @@ function onSelect() {
|
||||
v-if="showDropdown"
|
||||
ref="dropdownRef"
|
||||
v-on-clickaway="closeDropdown"
|
||||
class="country-dropdown absolute bg-n-background text-n-slate-12 dark:bg-n-solid-3 z-10 h-48 px-0 pt-0 pb-1 pl-1 pr-1 overflow-y-auto rounded-lg shadow-lg top-12 w-full min-w-24 max-w-[14.8rem]"
|
||||
class="country-dropdown absolute bg-n-solid-1 text-n-slate-12 dark:bg-n-solid-3 z-10 h-48 px-0 pt-0 pb-1 pl-1 pr-1 overflow-y-auto rounded-lg shadow-lg top-12 w-full min-w-24 max-w-[14.8rem]"
|
||||
@keydown.up="moveSelectionUp"
|
||||
@keydown.down="moveSelectionDown"
|
||||
@keydown.enter="onSelect"
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 bg-n-background text-n-slate-12 dark:bg-n-solid-3"
|
||||
>
|
||||
<div class="sticky top-0 bg-n-solid-1 text-n-slate-12 dark:bg-n-solid-3">
|
||||
<input
|
||||
ref="searchbarRef"
|
||||
v-model="searchCountry"
|
||||
|
||||
78
app/javascript/widget/components/TabBar.vue
Normal file
78
app/javascript/widget/components/TabBar.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getContrastingTextColor } from '@chatwoot/utils';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
const MESSAGE_ROUTES = ['conversations', 'messages', 'prechat-form'];
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const widgetColor = useMapGetter('appConfig/getWidgetColor');
|
||||
const activeUnreadCount = useMapGetter('conversation/getUnreadMessageCount');
|
||||
const otherUnreadCount = useMapGetter('conversationList/getUnreadCount');
|
||||
|
||||
const unreadCount = computed(
|
||||
() => otherUnreadCount.value + (activeUnreadCount.value > 0 ? 1 : 0)
|
||||
);
|
||||
const activeTab = computed(() =>
|
||||
MESSAGE_ROUTES.includes(route.name) ? 'conversations' : 'home'
|
||||
);
|
||||
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
name: 'home',
|
||||
icon: 'i-ph-house',
|
||||
label: t('TABS.HOME'),
|
||||
},
|
||||
{
|
||||
name: 'conversations',
|
||||
icon: 'i-ph-chats',
|
||||
label: t('TABS.MESSAGES'),
|
||||
count: unreadCount.value,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="absolute inset-x-0 bottom-0 z-50 flex h-16 bg-n-surface-1 border-t border-solid border-n-weak shadow-[0_-8px_24px_-12px_rgba(0,0,0,0.12)] dark:shadow-[0_-8px_24px_-12px_rgba(0,0,0,0.6)]"
|
||||
>
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
type="button"
|
||||
class="flex items-center justify-center flex-1 outline-none focus-visible:bg-n-alpha-2"
|
||||
:class="activeTab === tab.name ? '' : 'text-n-slate-11'"
|
||||
:style="activeTab === tab.name ? { color: widgetColor } : {}"
|
||||
:aria-label="tab.label"
|
||||
:aria-current="activeTab === tab.name ? 'page' : null"
|
||||
@click="router.replace({ name: tab.name })"
|
||||
>
|
||||
<span class="relative flex items-center">
|
||||
<i :class="tab.icon" class="size-6" aria-hidden="true" />
|
||||
<span
|
||||
v-if="tab.count"
|
||||
class="absolute flex items-center justify-center h-4 px-1 rounded-full -top-1.5 start-3.5 min-w-4 text-xxs font-semibold leading-4"
|
||||
:class="{ 'bg-n-slate-12 text-n-slate-1': activeTab !== tab.name }"
|
||||
:style="
|
||||
activeTab === tab.name
|
||||
? {
|
||||
backgroundColor: widgetColor,
|
||||
color: getContrastingTextColor(widgetColor),
|
||||
}
|
||||
: {}
|
||||
"
|
||||
>
|
||||
<span aria-hidden="true">{{ tab.count }}</span>
|
||||
<span class="sr-only">
|
||||
{{ $t('TABS.UNREAD_CONVERSATIONS', tab.count) }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -26,14 +26,16 @@ const startConversation = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-3 w-full shadow outline-1 outline outline-n-container rounded-xl bg-n-background dark:bg-n-solid-2 px-5 py-4"
|
||||
>
|
||||
<AvailabilityContainer :agents="availableAgents" show-header show-avatars />
|
||||
|
||||
<div class="w-full surface-card divide-y divide-n-weak dark:divide-n-strong">
|
||||
<div class="px-4 py-3">
|
||||
<AvailabilityContainer
|
||||
:agents="availableAgents"
|
||||
show-header
|
||||
show-avatars
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 font-medium text-n-slate-12"
|
||||
:style="{ color: widgetColor }"
|
||||
class="flex items-center justify-between w-full gap-2 px-4 py-3 text-base font-medium rounded-b-xl outline-none text-n-slate-12 transition-colors hover:bg-n-alpha-2 focus-visible:bg-n-alpha-2"
|
||||
@click="startConversation"
|
||||
>
|
||||
<span>
|
||||
@@ -43,7 +45,11 @@ const startConversation = () => {
|
||||
: $t('START_CONVERSATION')
|
||||
}}
|
||||
</span>
|
||||
<i class="i-lucide-chevron-right size-5 mt-px rtl:rotate-180" />
|
||||
<i
|
||||
class="i-lucide-arrow-right size-5 rtl:rotate-180"
|
||||
:style="{ color: widgetColor }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import Banner from '../Banner.vue';
|
||||
import Branding from 'shared/components/Branding.vue';
|
||||
import ChatHeader from '../ChatHeader.vue';
|
||||
import ChatHeaderExpanded from '../ChatHeaderExpanded.vue';
|
||||
import TabBar from '../TabBar.vue';
|
||||
import configMixin from '../../mixins/configMixin';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { IFrameHelper } from 'widget/helpers/utils';
|
||||
@@ -13,6 +14,7 @@ export default {
|
||||
Branding,
|
||||
ChatHeader,
|
||||
ChatHeaderExpanded,
|
||||
TabBar,
|
||||
},
|
||||
mixins: [configMixin],
|
||||
data() {
|
||||
@@ -48,12 +50,21 @@ export default {
|
||||
this.$route.name
|
||||
);
|
||||
},
|
||||
backRouteName() {
|
||||
const isOnMessages = this.$route.name === 'messages';
|
||||
return isOnMessages && this.hasMultipleConversationsEnabled
|
||||
? 'conversations'
|
||||
: 'home';
|
||||
},
|
||||
isOnArticleViewer() {
|
||||
return ['article-viewer'].includes(this.$route.name);
|
||||
},
|
||||
isOnHomeView() {
|
||||
return ['home'].includes(this.$route.name);
|
||||
},
|
||||
isOnTabScreen() {
|
||||
return ['home', 'conversations'].includes(this.$route.name);
|
||||
},
|
||||
opacityClass() {
|
||||
if (this.isHeaderCollapsed) {
|
||||
return {};
|
||||
@@ -76,11 +87,14 @@ export default {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$el.addEventListener('scroll', this.updateScrollPosition);
|
||||
watch: {
|
||||
'$route.name'() {
|
||||
this.scrollPosition = 0;
|
||||
this.$el.scrollTop = 0;
|
||||
this.$refs.content.scrollTop = 0;
|
||||
},
|
||||
},
|
||||
unmounted() {
|
||||
this.$el.removeEventListener('scroll', this.updateScrollPosition);
|
||||
cancelAnimationFrame(this.requestID);
|
||||
},
|
||||
methods: {
|
||||
@@ -88,6 +102,7 @@ export default {
|
||||
IFrameHelper.sendMessage({ event: 'closeWindow' });
|
||||
},
|
||||
updateScrollPosition(event) {
|
||||
if (!this.isOnHomeView) return;
|
||||
this.scrollPosition = event.target.scrollTop;
|
||||
if (!this.ticking) {
|
||||
this.requestID = window.requestAnimationFrame(() => {
|
||||
@@ -103,42 +118,64 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full h-full bg-n-slate-2 dark:bg-n-solid-1"
|
||||
:class="{ 'overflow-auto': isOnHomeView }"
|
||||
class="w-full h-full bg-n-surface-1"
|
||||
:class="{
|
||||
'overflow-auto': isOnHomeView && !hasMultipleConversationsEnabled,
|
||||
}"
|
||||
@keydown.esc="closeWindow"
|
||||
@scroll.capture="updateScrollPosition"
|
||||
>
|
||||
<div class="relative flex flex-col h-full">
|
||||
<div
|
||||
:class="{
|
||||
expanded: !isHeaderCollapsed,
|
||||
collapsed: isHeaderCollapsed,
|
||||
'shadow-[0_10px_15px_-16px_rgba(50,50,93,0.08),0_4px_6px_-8px_rgba(50,50,93,0.04)]':
|
||||
isHeaderCollapsed,
|
||||
...opacityClass,
|
||||
}"
|
||||
ref="content"
|
||||
:class="
|
||||
hasMultipleConversationsEnabled
|
||||
? [
|
||||
'relative flex flex-col flex-1 min-h-0',
|
||||
{ 'overflow-auto pb-24': isOnHomeView },
|
||||
]
|
||||
: 'contents'
|
||||
"
|
||||
>
|
||||
<ChatHeaderExpanded
|
||||
v-if="!isHeaderCollapsed"
|
||||
:intro-heading="appConfig.welcomeTitle || channelConfig.welcomeTitle"
|
||||
:intro-body="
|
||||
appConfig.welcomeDescription || channelConfig.welcomeTagline
|
||||
"
|
||||
:avatar-url="channelConfig.avatarUrl"
|
||||
:show-popout-button="appConfig.showPopoutButton"
|
||||
/>
|
||||
<ChatHeader
|
||||
v-if="isHeaderCollapsed"
|
||||
:title="channelConfig.websiteName"
|
||||
:avatar-url="channelConfig.avatarUrl"
|
||||
:show-popout-button="appConfig.showPopoutButton"
|
||||
:available-agents="availableAgents"
|
||||
:show-back-button="showBackButton"
|
||||
<div
|
||||
:class="{
|
||||
expanded: !isHeaderCollapsed,
|
||||
'collapsed relative z-10': isHeaderCollapsed,
|
||||
'shadow-[0_10px_15px_-16px_rgba(50,50,93,0.08),0_4px_6px_-8px_rgba(50,50,93,0.04)]':
|
||||
isHeaderCollapsed,
|
||||
...opacityClass,
|
||||
}"
|
||||
>
|
||||
<ChatHeaderExpanded
|
||||
v-if="!isHeaderCollapsed"
|
||||
:intro-heading="
|
||||
appConfig.welcomeTitle || channelConfig.welcomeTitle
|
||||
"
|
||||
:intro-body="
|
||||
appConfig.welcomeDescription || channelConfig.welcomeTagline
|
||||
"
|
||||
:avatar-url="channelConfig.avatarUrl"
|
||||
:show-popout-button="appConfig.showPopoutButton"
|
||||
/>
|
||||
<ChatHeader
|
||||
v-if="isHeaderCollapsed"
|
||||
:title="channelConfig.websiteName"
|
||||
:avatar-url="channelConfig.avatarUrl"
|
||||
:show-popout-button="appConfig.showPopoutButton"
|
||||
:available-agents="availableAgents"
|
||||
:show-back-button="showBackButton"
|
||||
:back-route-name="backRouteName"
|
||||
/>
|
||||
</div>
|
||||
<Banner />
|
||||
<router-view />
|
||||
|
||||
<Branding
|
||||
v-if="!isOnArticleViewer"
|
||||
:disable-branding="disableBranding"
|
||||
/>
|
||||
</div>
|
||||
<Banner />
|
||||
<router-view />
|
||||
|
||||
<Branding v-if="!isOnArticleViewer" :disable-branding="disableBranding" />
|
||||
<TabBar v-if="hasMultipleConversationsEnabled && isOnTabScreen" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -22,11 +22,20 @@ const onArticleClick = link => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<h3 class="font-medium text-n-slate-12">
|
||||
{{ $t('PORTAL.POPULAR_ARTICLES') }}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="divide-y divide-n-weak dark:divide-n-strong">
|
||||
<div class="flex items-center justify-between gap-2 px-4 py-3">
|
||||
<h3 class="font-medium text-n-slate-12">
|
||||
{{ $t('PORTAL.POPULAR_ARTICLES') }}
|
||||
</h3>
|
||||
<button
|
||||
class="text-sm font-medium shrink-0"
|
||||
:style="{ color: widgetColor }"
|
||||
@click="$emit('viewAll')"
|
||||
>
|
||||
{{ $t('PORTAL.VIEW_ALL_ARTICLES') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col px-2 py-1">
|
||||
<ArticleListItem
|
||||
v-for="article in articlesToDisplay"
|
||||
:key="article.slug"
|
||||
@@ -35,14 +44,5 @@ const onArticleClick = link => {
|
||||
@select-article="onArticleClick"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="font-medium tracking-wide inline-flex"
|
||||
:style="{ color: widgetColor }"
|
||||
@click="$emit('viewAll')"
|
||||
>
|
||||
<span>{{ $t('PORTAL.VIEW_ALL_ARTICLES') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -77,7 +77,7 @@ onMounted(() => fetchArticles());
|
||||
<template>
|
||||
<div
|
||||
v-if="portal && (articleUiFlags.isFetching || !!popularArticles.length)"
|
||||
class="w-full shadow outline-1 outline outline-n-container rounded-xl bg-n-background dark:bg-n-solid-2 px-5 py-4"
|
||||
class="w-full surface-card"
|
||||
>
|
||||
<ArticleBlock
|
||||
v-if="hasArticles"
|
||||
@@ -85,7 +85,7 @@ onMounted(() => fetchArticles());
|
||||
@view="openArticleInArticleViewer"
|
||||
@view-all="viewAllArticles"
|
||||
/>
|
||||
<ArticleCardSkeletonLoader v-if="articleUiFlags.isFetching" />
|
||||
<ArticleCardSkeletonLoader v-if="articleUiFlags.isFetching" class="p-4" />
|
||||
</div>
|
||||
<div v-else class="hidden" />
|
||||
</template>
|
||||
|
||||
@@ -17,7 +17,7 @@ const onClick = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between rounded cursor-pointer text-n-slate-11 hover:text-n-slate-12 gap-2"
|
||||
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg cursor-pointer text-n-slate-12 transition-colors hover:bg-n-alpha-2"
|
||||
role="button"
|
||||
@click="onClick"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import ConversationListItem from '../ConversationListItem.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const store = createStore({
|
||||
modules: {
|
||||
appConfig: {
|
||||
namespaced: true,
|
||||
getters: { getWidgetColor: () => '#1f93ff' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const conversation = {
|
||||
id: 7,
|
||||
status: 'open',
|
||||
unread_count: 0,
|
||||
last_activity_at: Math.floor(Date.now() / 1000),
|
||||
last_message: {
|
||||
content: 'Sure, sending it now',
|
||||
message_type: 1,
|
||||
sender: { available_name: 'Jane Agent', avatar_url: '/jane.png' },
|
||||
},
|
||||
};
|
||||
|
||||
const mountItem = props =>
|
||||
mount(ConversationListItem, {
|
||||
props: { conversation, ...props },
|
||||
global: { plugins: [store], stubs: { Avatar: true } },
|
||||
});
|
||||
|
||||
describe('ConversationListItem', () => {
|
||||
beforeEach(() => {
|
||||
window.chatwootWebChannel = {
|
||||
websiteName: 'Acme',
|
||||
avatarUrl: '/inbox.png',
|
||||
enabledFeatures: [],
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.chatwootWebChannel;
|
||||
});
|
||||
|
||||
it('titles the row with the conversation number and shows the last agent as the sender', () => {
|
||||
const wrapper = mountItem();
|
||||
expect(wrapper.text()).toContain('#7');
|
||||
expect(wrapper.text()).toContain('Sure, sending it now');
|
||||
expect(wrapper.findComponent(Avatar).props()).toMatchObject({
|
||||
src: '/jane.png',
|
||||
name: 'Jane Agent',
|
||||
});
|
||||
expect(wrapper.find('.sr-only').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the bot identity for template messages', () => {
|
||||
const wrapper = mountItem({
|
||||
conversation: {
|
||||
...conversation,
|
||||
last_message: { content: 'Rate your conversation', message_type: 3 },
|
||||
},
|
||||
});
|
||||
expect(wrapper.findComponent(Avatar).props()).toMatchObject({
|
||||
src: '/assets/images/chatwoot_bot.png',
|
||||
name: 'UNREAD_VIEW.BOT',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the inbox identity for bots when the inbox avatar is enabled for them', () => {
|
||||
window.chatwootWebChannel.enabledFeatures = ['use_inbox_avatar_for_bot'];
|
||||
const wrapper = mountItem({
|
||||
conversation: {
|
||||
...conversation,
|
||||
last_message: { content: 'Hi there', message_type: 1 },
|
||||
},
|
||||
});
|
||||
expect(wrapper.findComponent(Avatar).props()).toMatchObject({
|
||||
src: '/inbox.png',
|
||||
name: 'Acme',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows no sender avatar and prefixes the preview for visitor messages', () => {
|
||||
const wrapper = mountItem({
|
||||
conversation: {
|
||||
...conversation,
|
||||
last_message: {
|
||||
content: 'Is it [shipped](https://a.b)?',
|
||||
message_type: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(wrapper.findComponent(Avatar).exists()).toBe(false);
|
||||
expect(wrapper.text()).toContain('YOU: Is it shipped?');
|
||||
});
|
||||
|
||||
it('announces unread messages to assistive technology', () => {
|
||||
const wrapper = mountItem({
|
||||
conversation: { ...conversation, unread_count: 3 },
|
||||
});
|
||||
expect(wrapper.find('.sr-only').text()).toBe('CONVERSATIONS.UNREAD_COUNT');
|
||||
expect(wrapper.text()).toContain('3');
|
||||
});
|
||||
|
||||
it('labels resolved conversations', () => {
|
||||
const wrapper = mountItem({
|
||||
conversation: { ...conversation, status: 'resolved' },
|
||||
});
|
||||
expect(wrapper.text()).toContain('CONVERSATIONS.RESOLVED');
|
||||
});
|
||||
|
||||
it('is a button that selects the conversation', async () => {
|
||||
const wrapper = mountItem();
|
||||
expect(wrapper.element.tagName).toBe('BUTTON');
|
||||
await wrapper.trigger('click');
|
||||
expect(wrapper.emitted('select')).toEqual([[7]]);
|
||||
});
|
||||
});
|
||||
86
app/javascript/widget/components/specs/TabBar.spec.js
Normal file
86
app/javascript/widget/components/specs/TabBar.spec.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import TabBar from '../TabBar.vue';
|
||||
|
||||
const replace = vi.fn();
|
||||
let routeName = 'home';
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ replace }),
|
||||
useRoute: () => ({ name: routeName }),
|
||||
}));
|
||||
|
||||
const buildStore = ({ activeUnread = 0, otherUnread = 0 } = {}) =>
|
||||
createStore({
|
||||
modules: {
|
||||
appConfig: {
|
||||
namespaced: true,
|
||||
getters: { getWidgetColor: () => '#1f93ff' },
|
||||
},
|
||||
conversation: {
|
||||
namespaced: true,
|
||||
getters: { getUnreadMessageCount: () => activeUnread },
|
||||
},
|
||||
conversationList: {
|
||||
namespaced: true,
|
||||
getters: { getUnreadCount: () => otherUnread },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mountBar = options =>
|
||||
mount(TabBar, { global: { plugins: [buildStore(options)] } });
|
||||
|
||||
describe('TabBar', () => {
|
||||
beforeEach(() => {
|
||||
routeName = 'home';
|
||||
});
|
||||
|
||||
it('marks the tab of the current screen as the current page', () => {
|
||||
const wrapper = mountBar();
|
||||
const [home, messages] = wrapper.findAll('button');
|
||||
expect(home.attributes('aria-current')).toBe('page');
|
||||
expect(messages.attributes('aria-current')).toBe(undefined);
|
||||
});
|
||||
|
||||
it('treats the conversation screens as the messages tab', () => {
|
||||
routeName = 'messages';
|
||||
const [, messages] = mountBar().findAll('button');
|
||||
expect(messages.attributes('aria-current')).toBe('page');
|
||||
});
|
||||
|
||||
it('counts the unread conversations on the messages tab', () => {
|
||||
const wrapper = mountBar({ activeUnread: 5, otherUnread: 2 });
|
||||
const badge = wrapper.find('.sr-only').element.parentElement;
|
||||
expect(wrapper.find('[aria-hidden="true"] + .sr-only').text()).toBe(
|
||||
'TABS.UNREAD_CONVERSATIONS'
|
||||
);
|
||||
expect(wrapper.text()).toContain('3');
|
||||
expect(badge.classList).toContain('bg-n-slate-12');
|
||||
expect(badge.style.backgroundColor).toBe('');
|
||||
});
|
||||
|
||||
it('paints the badge in the widget colour while the messages tab is active', () => {
|
||||
routeName = 'conversations';
|
||||
const badge = mountBar({ otherUnread: 1 }).find('.sr-only').element
|
||||
.parentElement;
|
||||
expect(badge.classList).not.toContain('bg-n-slate-12');
|
||||
expect(badge.style.backgroundColor).toBe('rgb(31, 147, 255)');
|
||||
});
|
||||
|
||||
it('shows no badge without unread messages', () => {
|
||||
expect(mountBar().find('.sr-only').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('names each icon-only tab for screen readers', () => {
|
||||
const [home, messages] = mountBar().findAll('button');
|
||||
expect(home.attributes('aria-label')).toBe('TABS.HOME');
|
||||
expect(messages.attributes('aria-label')).toBe('TABS.MESSAGES');
|
||||
});
|
||||
|
||||
it('navigates to the tab screen', async () => {
|
||||
const [, messages] = mountBar().findAll('button');
|
||||
await messages.trigger('click');
|
||||
expect(replace).toBeCalledWith({ name: 'conversations' });
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ export default {
|
||||
<template>
|
||||
<div
|
||||
v-if="!!items.length"
|
||||
class="chat-bubble agent bg-n-background dark:bg-n-solid-3"
|
||||
class="chat-bubble agent bg-n-solid-1 dark:bg-n-solid-3"
|
||||
>
|
||||
<div
|
||||
v-for="item in items"
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
|
||||
import {
|
||||
IFrameHelper,
|
||||
isMultipleConversationsEnabled,
|
||||
} from 'widget/helpers/utils';
|
||||
import { playNewMessageNotificationInWidget } from 'widget/helpers/WidgetAudioNotificationHelper';
|
||||
import { ON_AGENT_MESSAGE_RECEIVED } from '../constants/widgetBusEvents';
|
||||
import { IFrameHelper } from 'widget/helpers/utils';
|
||||
import { shouldTriggerMessageUpdateEvent } from './IframeEventHelper';
|
||||
import { CHATWOOT_ON_MESSAGE } from '../constants/sdkEvents';
|
||||
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
|
||||
import { emitter } from '../../shared/helpers/mitt';
|
||||
import { CHATWOOT_ON_MESSAGE } from '../constants/sdkEvents';
|
||||
import { ON_AGENT_MESSAGE_RECEIVED } from '../constants/widgetBusEvents';
|
||||
import { shouldTriggerMessageUpdateEvent } from './IframeEventHelper';
|
||||
import { MESSAGE_TYPE } from './constants';
|
||||
|
||||
const isMessageInActiveConversation = (getters, message) => {
|
||||
const { conversation_id: conversationId } = message;
|
||||
const activeConversationId =
|
||||
getters['conversationAttributes/getConversationParams'].id;
|
||||
if (isMultipleConversationsEnabled()) {
|
||||
return conversationId !== activeConversationId;
|
||||
}
|
||||
return activeConversationId && conversationId !== activeConversationId;
|
||||
};
|
||||
|
||||
@@ -39,6 +46,40 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
// Re-fetch conversation attributes so a status change (e.g. auto-resolve)
|
||||
// that happened while disconnected is reflected, keeping the reply box state correct.
|
||||
this.app.$store.dispatch('conversationAttributes/getAttributes');
|
||||
this.refreshConversationList();
|
||||
};
|
||||
|
||||
refreshConversationList = () => {
|
||||
if (!isMultipleConversationsEnabled()) return;
|
||||
this.app.$store.dispatch('conversationList/fetch');
|
||||
};
|
||||
|
||||
// A message for another conversation takes over the screen only while the widget is hidden.
|
||||
showsInActiveConversation = message => {
|
||||
const { getters, dispatch } = this.app.$store;
|
||||
const activeConversationId =
|
||||
getters['conversationAttributes/getConversationParams'].id;
|
||||
if (message.conversation_id === activeConversationId) return true;
|
||||
if (!isMultipleConversationsEnabled()) return !activeConversationId;
|
||||
|
||||
// The campaign the visitor clicked creates its conversation later, in a job.
|
||||
const isAwaitedCampaignMessage =
|
||||
!activeConversationId &&
|
||||
getters['conversation/getIsAwaitingCampaign'] &&
|
||||
message.additional_attributes?.campaign_id;
|
||||
if (isAwaitedCampaignMessage) {
|
||||
dispatch('conversationList/attach', message.conversation_id);
|
||||
return true;
|
||||
}
|
||||
// Only replies take over the screen; the visitor's own messages from elsewhere never do.
|
||||
const isWidgetVisible =
|
||||
getters['appConfig/getIsWidgetOpen'] || !IFrameHelper.isIFrame();
|
||||
if (isWidgetVisible || message.message_type === MESSAGE_TYPE.INCOMING) {
|
||||
dispatch('conversationList/fetch');
|
||||
return false;
|
||||
}
|
||||
dispatch('conversationList/open', message.conversation_id);
|
||||
return true;
|
||||
};
|
||||
|
||||
setLastMessageId = () => {
|
||||
@@ -54,16 +95,19 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('campaign/resetCampaign');
|
||||
}
|
||||
this.app.$store.dispatch('conversationAttributes/update', data);
|
||||
this.refreshConversationList();
|
||||
};
|
||||
|
||||
onMessageCreated = data => {
|
||||
if (isMessageInActiveConversation(this.app.$store.getters, data)) {
|
||||
return;
|
||||
}
|
||||
const showsInActiveConversation = this.showsInActiveConversation(data);
|
||||
if (!showsInActiveConversation && !isMultipleConversationsEnabled()) return;
|
||||
|
||||
this.app.$store
|
||||
.dispatch('conversation/addOrUpdateMessage', data)
|
||||
.then(() => emitter.emit(ON_AGENT_MESSAGE_RECEIVED));
|
||||
if (showsInActiveConversation) {
|
||||
this.app.$store.dispatch('conversationList/updateLastMessage', data);
|
||||
this.app.$store
|
||||
.dispatch('conversation/addOrUpdateMessage', data)
|
||||
.then(() => emitter.emit(ON_AGENT_MESSAGE_RECEIVED));
|
||||
}
|
||||
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'onEvent',
|
||||
@@ -76,6 +120,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
};
|
||||
|
||||
onMessageUpdated = data => {
|
||||
if (data.content_attributes?.deleted) this.refreshConversationList();
|
||||
if (isMessageInActiveConversation(this.app.$store.getters, data)) {
|
||||
return;
|
||||
}
|
||||
@@ -92,6 +137,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
};
|
||||
|
||||
onConversationCreated = () => {
|
||||
if (isMultipleConversationsEnabled()) {
|
||||
this.refreshConversationList();
|
||||
return;
|
||||
}
|
||||
this.app.$store.dispatch('conversationAttributes/getAttributes');
|
||||
};
|
||||
|
||||
@@ -105,14 +154,15 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
ActionCableConnector.refreshConnector(pubsubToken);
|
||||
};
|
||||
|
||||
onTypingOn = data => {
|
||||
isTypingInAnotherConversation = data => {
|
||||
const activeConversationId =
|
||||
this.app.$store.getters['conversationAttributes/getConversationParams']
|
||||
.id;
|
||||
const isUserTypingOnAnotherConversation =
|
||||
data.conversation && data.conversation.id !== activeConversationId;
|
||||
return data?.conversation && data.conversation.id !== activeConversationId;
|
||||
};
|
||||
|
||||
if (isUserTypingOnAnotherConversation || data.is_private) {
|
||||
onTypingOn = data => {
|
||||
if (this.isTypingInAnotherConversation(data) || data.is_private) {
|
||||
return;
|
||||
}
|
||||
this.clearTimer();
|
||||
@@ -122,7 +172,8 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.initTimer();
|
||||
};
|
||||
|
||||
onTypingOff = () => {
|
||||
onTypingOff = data => {
|
||||
if (this.isTypingInAnotherConversation(data)) return;
|
||||
this.clearTimer();
|
||||
this.app.$store.dispatch('conversation/toggleAgentTyping', {
|
||||
status: 'off',
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import axios from 'axios';
|
||||
import { APP_BASE_URL } from 'widget/helpers/constants';
|
||||
import { isMultipleConversationsEnabled } from 'widget/helpers/utils';
|
||||
|
||||
export const API = axios.create({
|
||||
baseURL: APP_BASE_URL,
|
||||
withCredentials: false,
|
||||
});
|
||||
|
||||
let activeConversationId = null;
|
||||
|
||||
// With multiple conversations every request names its conversation, blank for a new one, so the
|
||||
// server can tell this widget apart from one loaded before the inbox enabled the feature.
|
||||
export const setActiveConversationId = id => {
|
||||
activeConversationId = id || null;
|
||||
};
|
||||
|
||||
API.interceptors.request.use(config => {
|
||||
if (!isMultipleConversationsEnabled() || config.params?.conversation_id) {
|
||||
return config;
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
params: { ...config.params, conversation_id: activeConversationId ?? '' },
|
||||
};
|
||||
});
|
||||
|
||||
export const setHeader = (value, key = 'X-Auth-Token') => {
|
||||
API.defaults.headers.common[key] = value;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
import ActionCableConnector from '../actionCable';
|
||||
import { IFrameHelper } from 'widget/helpers/utils';
|
||||
|
||||
vi.mock('@rails/actioncable', () => ({
|
||||
createConsumer: () => ({
|
||||
@@ -8,6 +9,18 @@ vi.mock('@rails/actioncable', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('widget/helpers/utils', async importOriginal => {
|
||||
const utils = await importOriginal();
|
||||
return {
|
||||
...utils,
|
||||
IFrameHelper: {
|
||||
...utils.IFrameHelper,
|
||||
isIFrame: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('Widget ActionCableConnector', () => {
|
||||
let app;
|
||||
let mockDispatch;
|
||||
@@ -40,6 +53,268 @@ describe('Widget ActionCableConnector', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('with multiple conversations enabled', () => {
|
||||
const message = { id: 10, conversation_id: 2, sender_type: 'Contact' };
|
||||
const getters = {
|
||||
'conversationAttributes/getConversationParams': { id: 1 },
|
||||
'appConfig/getIsWidgetOpen': false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['multiple_conversations'],
|
||||
};
|
||||
mockDispatch.mockResolvedValue(undefined);
|
||||
app.$store.getters = getters;
|
||||
IFrameHelper.isIFrame.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.chatwootWebChannel;
|
||||
});
|
||||
|
||||
it('renders a message for the conversation on screen and keeps its row current', () => {
|
||||
connector.onMessageCreated({ ...message, conversation_id: 1 });
|
||||
|
||||
expect(mockDispatch).toBeCalledWith('conversation/addOrUpdateMessage', {
|
||||
...message,
|
||||
conversation_id: 1,
|
||||
});
|
||||
expect(mockDispatch).toBeCalledWith(
|
||||
'conversationList/updateLastMessage',
|
||||
{
|
||||
...message,
|
||||
conversation_id: 1,
|
||||
}
|
||||
);
|
||||
expect(mockDispatch).not.toBeCalledWith('conversationList/fetch');
|
||||
});
|
||||
|
||||
it('applies message updates only to the conversation on screen', () => {
|
||||
connector.onMessageUpdated({ ...message, conversation_id: 1 });
|
||||
connector.onMessageUpdated(message);
|
||||
|
||||
expect(mockDispatch.mock.calls).toEqual([
|
||||
['conversation/addOrUpdateMessage', { ...message, conversation_id: 1 }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores message updates of earlier conversations on a new thread', () => {
|
||||
app.$store.getters = {
|
||||
...getters,
|
||||
'conversationAttributes/getConversationParams': { id: '' },
|
||||
};
|
||||
|
||||
connector.onMessageUpdated(message);
|
||||
|
||||
expect(mockDispatch).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('switches to the conversation of a new message while the widget is closed', () => {
|
||||
connector.onMessageCreated(message);
|
||||
|
||||
expect(mockDispatch).toBeCalledWith('conversationList/open', 2);
|
||||
expect(mockDispatch).toBeCalledWith(
|
||||
'conversation/addOrUpdateMessage',
|
||||
message
|
||||
);
|
||||
expect(IFrameHelper.sendMessage).toBeCalledWith(
|
||||
expect.objectContaining({ event: 'onEvent', data: message })
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a new thread on screen when an earlier conversation gets a reply', () => {
|
||||
app.$store.getters = {
|
||||
...getters,
|
||||
'conversationAttributes/getConversationParams': { id: '' },
|
||||
'appConfig/getIsWidgetOpen': true,
|
||||
'conversation/getIsCreating': true,
|
||||
};
|
||||
const reply = { ...message, message_type: 1 };
|
||||
|
||||
connector.onMessageCreated(reply);
|
||||
|
||||
expect(mockDispatch.mock.calls).toEqual([['conversationList/fetch']]);
|
||||
});
|
||||
|
||||
it('attaches the conversation of the campaign the visitor clicked', () => {
|
||||
app.$store.getters = {
|
||||
...getters,
|
||||
'conversationAttributes/getConversationParams': { id: '' },
|
||||
'appConfig/getIsWidgetOpen': true,
|
||||
'conversation/getIsAwaitingCampaign': true,
|
||||
};
|
||||
const campaignMessage = {
|
||||
...message,
|
||||
message_type: 1,
|
||||
additional_attributes: { campaign_id: 7 },
|
||||
};
|
||||
|
||||
connector.onMessageCreated(campaignMessage);
|
||||
|
||||
expect(mockDispatch).toBeCalledWith('conversationList/attach', 2);
|
||||
expect(mockDispatch).toBeCalledWith(
|
||||
'conversation/addOrUpdateMessage',
|
||||
campaignMessage
|
||||
);
|
||||
});
|
||||
|
||||
it('does not take a reply of an earlier conversation for the awaited campaign', () => {
|
||||
app.$store.getters = {
|
||||
...getters,
|
||||
'conversationAttributes/getConversationParams': { id: '' },
|
||||
'appConfig/getIsWidgetOpen': true,
|
||||
'conversation/getIsAwaitingCampaign': true,
|
||||
};
|
||||
|
||||
connector.onMessageCreated({ ...message, message_type: 1 });
|
||||
|
||||
expect(mockDispatch.mock.calls).toEqual([['conversationList/fetch']]);
|
||||
});
|
||||
|
||||
it('never lets the visitor own messages from elsewhere take over a new thread', () => {
|
||||
app.$store.getters = {
|
||||
...getters,
|
||||
'conversationAttributes/getConversationParams': { id: '' },
|
||||
};
|
||||
const ownMessage = { ...message, message_type: 0 };
|
||||
|
||||
connector.onMessageCreated(ownMessage);
|
||||
|
||||
expect(mockDispatch).toBeCalledWith('conversationList/fetch');
|
||||
expect(mockDispatch).not.toBeCalledWith('conversationList/open', 2);
|
||||
expect(mockDispatch).not.toBeCalledWith(
|
||||
'conversation/addOrUpdateMessage',
|
||||
ownMessage
|
||||
);
|
||||
});
|
||||
|
||||
it('only refreshes the list when the visitor is viewing another conversation', () => {
|
||||
app.$store.getters = { ...getters, 'appConfig/getIsWidgetOpen': true };
|
||||
|
||||
connector.onMessageCreated(message);
|
||||
|
||||
expect(mockDispatch).toBeCalledWith('conversationList/fetch');
|
||||
expect(mockDispatch).not.toBeCalledWith(
|
||||
'conversation/addOrUpdateMessage',
|
||||
message
|
||||
);
|
||||
expect(IFrameHelper.sendMessage).toBeCalledWith(
|
||||
expect.objectContaining({ event: 'onEvent', data: message })
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes the list instead of moving to a newly created conversation', () => {
|
||||
connector.onConversationCreated();
|
||||
|
||||
expect(mockDispatch.mock.calls).toEqual([['conversationList/fetch']]);
|
||||
});
|
||||
|
||||
it('refreshes the list when a message is deleted', () => {
|
||||
connector.onMessageUpdated({
|
||||
...message,
|
||||
content_attributes: { deleted: true },
|
||||
});
|
||||
|
||||
expect(mockDispatch).toBeCalledWith('conversationList/fetch');
|
||||
});
|
||||
|
||||
it('refreshes the list on status changes and reconnects', () => {
|
||||
connector.onStatusChange({ id: 2, status: 'resolved' });
|
||||
connector.onReconnect();
|
||||
|
||||
expect(mockDispatch).toBeCalledWith('conversationAttributes/update', {
|
||||
id: 2,
|
||||
status: 'resolved',
|
||||
});
|
||||
expect(
|
||||
mockDispatch.mock.calls.filter(
|
||||
([action]) => action === 'conversationList/fetch'
|
||||
)
|
||||
).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with multiple conversations disabled', () => {
|
||||
beforeEach(() => {
|
||||
mockDispatch.mockResolvedValue(undefined);
|
||||
app.$store.getters = {
|
||||
'conversationAttributes/getConversationParams': { id: 1 },
|
||||
};
|
||||
});
|
||||
|
||||
it('ignores messages of other conversations entirely', () => {
|
||||
connector.onMessageCreated({ id: 10, conversation_id: 2 });
|
||||
|
||||
expect(mockDispatch).not.toBeCalled();
|
||||
expect(IFrameHelper.sendMessage).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('applies message updates while no conversation is known yet', () => {
|
||||
app.$store.getters = {
|
||||
'conversationAttributes/getConversationParams': { id: '' },
|
||||
};
|
||||
|
||||
connector.onMessageUpdated({ id: 10, conversation_id: 2 });
|
||||
|
||||
expect(mockDispatch).toBeCalledWith('conversation/addOrUpdateMessage', {
|
||||
id: 10,
|
||||
conversation_id: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('moves to a newly created conversation', () => {
|
||||
connector.onConversationCreated();
|
||||
|
||||
expect(mockDispatch.mock.calls).toEqual([
|
||||
['conversationAttributes/getAttributes'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('typing indicator', () => {
|
||||
const typing = conversationId => ({
|
||||
conversation: { id: conversationId },
|
||||
is_private: false,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
app.$store.getters = {
|
||||
'conversationAttributes/getConversationParams': { id: 1 },
|
||||
};
|
||||
});
|
||||
|
||||
it('keeps it when an agent stops typing in another conversation', () => {
|
||||
connector.onTypingOn(typing(1));
|
||||
mockDispatch.mockClear();
|
||||
|
||||
connector.onTypingOff(typing(2));
|
||||
|
||||
expect(mockDispatch).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('clears it when the agent stops typing in the conversation on screen', () => {
|
||||
connector.onTypingOn(typing(1));
|
||||
connector.onTypingOff(typing(1));
|
||||
|
||||
expect(mockDispatch).toHaveBeenLastCalledWith(
|
||||
'conversation/toggleAgentTyping',
|
||||
{ status: 'off' }
|
||||
);
|
||||
});
|
||||
|
||||
it('clears it on its own after 30 seconds', () => {
|
||||
connector.onTypingOn(typing(1));
|
||||
mockDispatch.mockClear();
|
||||
|
||||
vi.advanceTimersByTime(30000);
|
||||
|
||||
expect(mockDispatch).toBeCalledWith('conversation/toggleAgentTyping', {
|
||||
status: 'off',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('re-fetches conversation attributes on reconnect so a status change missed while disconnected is reflected', () => {
|
||||
connector.onReconnect();
|
||||
|
||||
|
||||
61
app/javascript/widget/helpers/specs/axios.spec.js
Normal file
61
app/javascript/widget/helpers/specs/axios.spec.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { API, setActiveConversationId } from '../axios';
|
||||
|
||||
describe('widget API client', () => {
|
||||
beforeEach(() => {
|
||||
API.defaults.adapter = async config => ({
|
||||
data: {},
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActiveConversationId(null);
|
||||
delete window.chatwootWebChannel;
|
||||
});
|
||||
|
||||
it('names the active conversation on every request when multiple conversations are enabled', async () => {
|
||||
window.chatwootWebChannel = { enabledFeatures: ['multiple_conversations'] };
|
||||
setActiveConversationId(42);
|
||||
|
||||
const { config } = await API.get('/api/v1/widget/messages', {
|
||||
params: { before: 7 },
|
||||
});
|
||||
|
||||
expect(config.params).toEqual({ before: 7, conversation_id: 42 });
|
||||
});
|
||||
|
||||
it('keeps the conversation a request already names', async () => {
|
||||
window.chatwootWebChannel = { enabledFeatures: ['multiple_conversations'] };
|
||||
setActiveConversationId(42);
|
||||
|
||||
const { config } = await API.post(
|
||||
'/api/v1/widget/messages',
|
||||
{},
|
||||
{ params: { conversation_id: 7 } }
|
||||
);
|
||||
|
||||
expect(config.params).toEqual({ conversation_id: 7 });
|
||||
});
|
||||
|
||||
it('leaves requests untouched when multiple conversations are disabled', async () => {
|
||||
setActiveConversationId(42);
|
||||
|
||||
const { config } = await API.get('/api/v1/widget/messages', {
|
||||
params: { before: 7 },
|
||||
});
|
||||
|
||||
expect(config.params).toEqual({ before: 7 });
|
||||
});
|
||||
|
||||
it('asks for a new conversation when none is active', async () => {
|
||||
window.chatwootWebChannel = { enabledFeatures: ['multiple_conversations'] };
|
||||
setActiveConversationId('');
|
||||
|
||||
const { config } = await API.post('/api/v1/widget/messages', {});
|
||||
|
||||
expect(config.params).toEqual({ conversation_id: '' });
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,13 @@ export const RNHelper = {
|
||||
},
|
||||
};
|
||||
|
||||
export const isMultipleConversationsEnabled = () =>
|
||||
Boolean(
|
||||
window.chatwootWebChannel?.enabledFeatures?.includes(
|
||||
'multiple_conversations'
|
||||
)
|
||||
);
|
||||
|
||||
export const groupBy = (array, predicate) => {
|
||||
return array.reduce((acc, value) => {
|
||||
(acc[predicate(value)] ||= []).push(value);
|
||||
|
||||
@@ -47,6 +47,20 @@
|
||||
"CONTINUE_CONVERSATION": "Continue conversation",
|
||||
"YOU": "You",
|
||||
"START_NEW_CONVERSATION": "Start a new conversation",
|
||||
"BACK": "Go back",
|
||||
"TABS": {
|
||||
"HOME": "Home",
|
||||
"MESSAGES": "Messages",
|
||||
"UNREAD_CONVERSATIONS": "{count} unread conversation | {count} unread conversations"
|
||||
},
|
||||
"CONVERSATIONS": {
|
||||
"TITLE": "All conversations",
|
||||
"EMPTY_TITLE": "No conversations yet",
|
||||
"EMPTY_DESCRIPTION": "Start a conversation and we will get back to you here.",
|
||||
"RESOLVED": "Resolved",
|
||||
"UNREAD_COUNT": "{count} unread message | {count} unread messages",
|
||||
"ATTACHMENT": "Attachment"
|
||||
},
|
||||
"VIEW_UNREAD_MESSAGES": "You have unread messages",
|
||||
"UNREAD_VIEW": {
|
||||
"VIEW_MESSAGES_BUTTON": "See new messages",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { isMultipleConversationsEnabled } from 'widget/helpers/utils';
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
hasMultipleConversationsEnabled() {
|
||||
return isMultipleConversationsEnabled();
|
||||
},
|
||||
useInboxAvatarForBot() {
|
||||
return this.channelConfig.enabledFeatures.includes(
|
||||
'use_inbox_avatar_for_bot'
|
||||
|
||||
@@ -34,6 +34,11 @@ const router = createRouter({
|
||||
name: 'messages',
|
||||
component: () => import('./views/Messages.vue'),
|
||||
},
|
||||
{
|
||||
path: '/conversations',
|
||||
name: 'conversations',
|
||||
component: () => import('./views/ConversationList.vue'),
|
||||
},
|
||||
{
|
||||
path: '/article',
|
||||
name: 'article-viewer',
|
||||
|
||||
@@ -5,6 +5,7 @@ import appConfig from 'widget/store/modules/appConfig';
|
||||
import contacts from 'widget/store/modules/contacts';
|
||||
import conversation from 'widget/store/modules/conversation';
|
||||
import conversationAttributes from 'widget/store/modules/conversationAttributes';
|
||||
import conversationList from 'widget/store/modules/conversationList';
|
||||
import conversationLabels from 'widget/store/modules/conversationLabels';
|
||||
import events from 'widget/store/modules/events';
|
||||
import globalConfig from 'shared/store/globalConfig';
|
||||
@@ -20,6 +21,7 @@ export default createStore({
|
||||
conversation,
|
||||
conversationAttributes,
|
||||
conversationLabels,
|
||||
conversationList,
|
||||
events,
|
||||
globalConfig,
|
||||
message,
|
||||
|
||||
@@ -127,9 +127,10 @@ export const actions = {
|
||||
{ campaignId, websiteToken, customAttributes }
|
||||
) => {
|
||||
try {
|
||||
// The campaign message can arrive before this request returns, so it is awaited up front.
|
||||
commit(
|
||||
'conversation/setConversationUIFlag',
|
||||
{ isCreating: true },
|
||||
{ isCreating: true, isAwaitingCampaign: true },
|
||||
{ root: true }
|
||||
);
|
||||
await triggerCampaign({ campaignId, websiteToken, customAttributes });
|
||||
@@ -137,6 +138,11 @@ export const actions = {
|
||||
commit('setActiveCampaign', {});
|
||||
} catch (error) {
|
||||
commit('setError', true);
|
||||
commit(
|
||||
'conversation/setConversationUIFlag',
|
||||
{ isAwaitingCampaign: false },
|
||||
{ root: true }
|
||||
);
|
||||
} finally {
|
||||
commit(
|
||||
'conversation/setConversationUIFlag',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { sendMessage } from 'widget/helpers/utils';
|
||||
import {
|
||||
sendMessage,
|
||||
isMultipleConversationsEnabled,
|
||||
} from 'widget/helpers/utils';
|
||||
import ContactsAPI from '../../api/contacts';
|
||||
import { SET_USER_ERROR } from '../../constants/errorTypes';
|
||||
import { setHeader } from '../../helpers/axios';
|
||||
@@ -78,9 +81,13 @@ export const actions = {
|
||||
updateWidgetAuthToken(widgetAuthToken);
|
||||
dispatch('get');
|
||||
if (identifierHash || widgetAuthToken) {
|
||||
dispatch('conversation/clearConversations', {}, { root: true });
|
||||
dispatch('conversation/fetchOldConversations', {}, { root: true });
|
||||
dispatch('conversationAttributes/getAttributes', {}, { root: true });
|
||||
if (isMultipleConversationsEnabled()) {
|
||||
dispatch('conversationList/load', {}, { root: true });
|
||||
} else {
|
||||
dispatch('conversation/clearConversations', {}, { root: true });
|
||||
dispatch('conversation/fetchOldConversations', {}, { root: true });
|
||||
dispatch('conversationAttributes/getAttributes', {}, { root: true });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const data = parseErrorData(error);
|
||||
|
||||
@@ -11,23 +11,64 @@ import {
|
||||
} from 'widget/api/conversation';
|
||||
|
||||
import { ON_CONVERSATION_CREATED } from 'widget/constants/widgetBusEvents';
|
||||
import { createTemporaryMessage, getNonDeletedMessages } from './helpers';
|
||||
import {
|
||||
createTemporaryMessage,
|
||||
getNonDeletedMessages,
|
||||
hasLeftConversation,
|
||||
} from './helpers';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { isMultipleConversationsEnabled } from 'widget/helpers/utils';
|
||||
|
||||
// A new thread has no id until its first message creates the conversation, so messages sent
|
||||
// meanwhile from the same thread wait for that request and post to the conversation it created.
|
||||
let pendingCreation = null;
|
||||
|
||||
const postToThread = ({ commit, rootState }, post) => {
|
||||
const { conversationAttributes, conversationList } = rootState;
|
||||
if (!isMultipleConversationsEnabled() || conversationAttributes.id) {
|
||||
return post();
|
||||
}
|
||||
if (pendingCreation?.thread === conversationList.thread) {
|
||||
return pendingCreation.request.then(({ data }) =>
|
||||
post(data.conversation_id)
|
||||
);
|
||||
}
|
||||
const creation = { thread: conversationList.thread, request: post() };
|
||||
pendingCreation = creation;
|
||||
commit('setConversationUIFlag', { isCreating: true });
|
||||
const clear = () => {
|
||||
if (pendingCreation !== creation) return;
|
||||
pendingCreation = null;
|
||||
commit('setConversationUIFlag', { isCreating: false });
|
||||
};
|
||||
creation.request.then(clear, clear);
|
||||
return creation.request;
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
createConversation: async ({ commit, dispatch }, params) => {
|
||||
createConversation: async ({ commit, dispatch, rootState }, params) => {
|
||||
const { thread } = rootState.conversationList;
|
||||
commit('setConversationUIFlag', { isCreating: true });
|
||||
try {
|
||||
const { data } = await createConversationAPI(params);
|
||||
if (hasLeftConversation(rootState, thread)) return;
|
||||
const { messages } = data;
|
||||
const [message = {}] = messages;
|
||||
commit('pushMessageToConversation', message);
|
||||
dispatch('conversationAttributes/getAttributes', {}, { root: true });
|
||||
if (isMultipleConversationsEnabled()) {
|
||||
dispatch('conversationList/adopt', data.id, { root: true });
|
||||
} else {
|
||||
dispatch('conversationAttributes/getAttributes', {}, { root: true });
|
||||
}
|
||||
// Emit event to notify that conversation is created and show the chat screen
|
||||
emitter.emit(ON_CONVERSATION_CREATED);
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
} finally {
|
||||
commit('setConversationUIFlag', { isCreating: false });
|
||||
// Leaving the thread already reset it; another thread may be creating by now.
|
||||
if (!hasLeftConversation(rootState, thread)) {
|
||||
commit('setConversationUIFlag', { isCreating: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
sendMessage: async ({ dispatch, state: conversationState }, params) => {
|
||||
@@ -41,23 +82,34 @@ export const actions = {
|
||||
});
|
||||
},
|
||||
sendMessageWithData: async (
|
||||
{ commit },
|
||||
{ commit, dispatch, rootState },
|
||||
{ message, pendingCustomAttributes = {}, pendingLabels = [] }
|
||||
) => {
|
||||
const { id, content, replyTo, meta = {} } = message;
|
||||
const hasPendingMetadata =
|
||||
Object.keys(pendingCustomAttributes).length > 0 ||
|
||||
pendingLabels.length > 0;
|
||||
const { thread } = rootState.conversationList;
|
||||
|
||||
commit('pushMessageToConversation', message);
|
||||
// A retried message arrives marked failed; in progress again, the sent copy replaces it.
|
||||
commit('pushMessageToConversation', { ...message, status: 'in_progress' });
|
||||
commit('updateMessageMeta', { id, meta: { ...meta, error: '' } });
|
||||
try {
|
||||
const { data } = await sendMessageAPI(content, replyTo, {
|
||||
customAttributes: hasPendingMetadata
|
||||
? pendingCustomAttributes
|
||||
: undefined,
|
||||
labels: hasPendingMetadata ? pendingLabels : undefined,
|
||||
});
|
||||
const { data } = await postToThread(
|
||||
{ commit, rootState },
|
||||
conversationId =>
|
||||
sendMessageAPI(content, replyTo, {
|
||||
customAttributes: hasPendingMetadata
|
||||
? pendingCustomAttributes
|
||||
: undefined,
|
||||
labels: hasPendingMetadata ? pendingLabels : undefined,
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
if (hasLeftConversation(rootState, thread)) {
|
||||
commit('deleteMessage', id);
|
||||
return;
|
||||
}
|
||||
if (hasPendingMetadata) {
|
||||
commit('clearPendingConversationMetadata');
|
||||
}
|
||||
@@ -65,7 +117,19 @@ export const actions = {
|
||||
// [VITE] Don't delete this manually, since `pushMessageToConversation` does the replacement for us anyway
|
||||
// commit('deleteMessage', message.id);
|
||||
commit('pushMessageToConversation', { ...data, status: 'sent' });
|
||||
if (
|
||||
isMultipleConversationsEnabled() &&
|
||||
!rootState.conversationAttributes.id
|
||||
) {
|
||||
dispatch('conversationList/adopt', data.conversation_id, {
|
||||
root: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (hasLeftConversation(rootState, thread)) {
|
||||
commit('deleteMessage', id);
|
||||
return;
|
||||
}
|
||||
commit('pushMessageToConversation', { ...message, status: 'failed' });
|
||||
commit('updateMessageMeta', {
|
||||
id,
|
||||
@@ -78,7 +142,10 @@ export const actions = {
|
||||
commit('setLastMessageId');
|
||||
},
|
||||
|
||||
sendAttachment: async ({ commit, state: conversationState }, params) => {
|
||||
sendAttachment: async (
|
||||
{ commit, dispatch, rootState, state: conversationState },
|
||||
params
|
||||
) => {
|
||||
const {
|
||||
attachment: { thumbUrl, fileType },
|
||||
meta = {},
|
||||
@@ -97,15 +164,25 @@ export const actions = {
|
||||
const hasPendingMetadata =
|
||||
Object.keys(pendingCustomAttributes).length > 0 ||
|
||||
pendingLabels.length > 0;
|
||||
const { thread } = rootState.conversationList;
|
||||
|
||||
commit('pushMessageToConversation', tempMessage);
|
||||
try {
|
||||
const { data } = await sendAttachmentAPI(params, {
|
||||
customAttributes: hasPendingMetadata
|
||||
? pendingCustomAttributes
|
||||
: undefined,
|
||||
labels: hasPendingMetadata ? pendingLabels : undefined,
|
||||
});
|
||||
const { data } = await postToThread(
|
||||
{ commit, rootState },
|
||||
conversationId =>
|
||||
sendAttachmentAPI(params, {
|
||||
customAttributes: hasPendingMetadata
|
||||
? pendingCustomAttributes
|
||||
: undefined,
|
||||
labels: hasPendingMetadata ? pendingLabels : undefined,
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
if (hasLeftConversation(rootState, thread)) {
|
||||
commit('deleteMessage', tempMessage.id);
|
||||
return;
|
||||
}
|
||||
if (hasPendingMetadata) {
|
||||
commit('clearPendingConversationMetadata');
|
||||
}
|
||||
@@ -114,7 +191,19 @@ export const actions = {
|
||||
tempId: tempMessage.id,
|
||||
});
|
||||
commit('pushMessageToConversation', { ...data, status: 'sent' });
|
||||
if (
|
||||
isMultipleConversationsEnabled() &&
|
||||
!rootState.conversationAttributes.id
|
||||
) {
|
||||
dispatch('conversationList/adopt', data.conversation_id, {
|
||||
root: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (hasLeftConversation(rootState, thread)) {
|
||||
commit('deleteMessage', tempMessage.id);
|
||||
return;
|
||||
}
|
||||
commit('pushMessageToConversation', { ...tempMessage, status: 'failed' });
|
||||
commit('updateMessageMeta', {
|
||||
id: tempMessage.id,
|
||||
@@ -123,29 +212,34 @@ export const actions = {
|
||||
// Show error
|
||||
}
|
||||
},
|
||||
fetchOldConversations: async ({ commit }, { before } = {}) => {
|
||||
fetchOldConversations: async ({ commit, rootState }, { before } = {}) => {
|
||||
const { thread } = rootState.conversationList;
|
||||
try {
|
||||
commit('setConversationListLoading', true);
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await getMessagesAPI({ before });
|
||||
if (hasLeftConversation(rootState, thread)) return;
|
||||
const { contact_last_seen_at: lastSeen } = meta;
|
||||
const formattedMessages = getNonDeletedMessages({ messages: payload });
|
||||
commit('conversation/setMetaUserLastSeenAt', lastSeen, { root: true });
|
||||
commit('setMessagesInConversation', formattedMessages);
|
||||
commit('setConversationListLoading', false);
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
} finally {
|
||||
commit('setConversationListLoading', false);
|
||||
}
|
||||
},
|
||||
|
||||
syncLatestMessages: async ({ state, commit }) => {
|
||||
syncLatestMessages: async ({ state, commit, rootState }) => {
|
||||
const { thread } = rootState.conversationList;
|
||||
try {
|
||||
const { lastMessageId, conversations } = state;
|
||||
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await getMessagesAPI({ after: lastMessageId });
|
||||
if (hasLeftConversation(rootState, thread)) return;
|
||||
|
||||
const { contact_last_seen_at: lastSeen } = meta;
|
||||
const formattedMessages = getNonDeletedMessages({ messages: payload });
|
||||
@@ -194,7 +288,7 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
setUserLastSeen: async ({ commit, getters: appGetters }) => {
|
||||
setUserLastSeen: async ({ commit, getters: appGetters, rootState }) => {
|
||||
if (!appGetters.getConversationSize) {
|
||||
return;
|
||||
}
|
||||
@@ -202,6 +296,9 @@ export const actions = {
|
||||
const lastSeen = Date.now() / 1000;
|
||||
try {
|
||||
commit('setMetaUserLastSeenAt', lastSeen);
|
||||
commit('conversationList/markRead', rootState.conversationAttributes.id, {
|
||||
root: true,
|
||||
});
|
||||
await setUserLastSeenAt({ lastSeen });
|
||||
} catch (error) {
|
||||
// IgnoreError
|
||||
|
||||
@@ -6,6 +6,7 @@ import { formatUnixDate } from 'shared/helpers/DateHelper';
|
||||
export const getters = {
|
||||
getAllMessagesLoaded: _state => _state.uiFlags.allMessagesLoaded,
|
||||
getIsCreating: _state => _state.uiFlags.isCreating,
|
||||
getIsAwaitingCampaign: _state => _state.uiFlags.isAwaitingCampaign,
|
||||
getIsAgentTyping: _state => _state.uiFlags.isAgentTyping,
|
||||
getConversation: _state => _state.conversations,
|
||||
getConversationSize: _state => Object.keys(_state.conversations).length,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
|
||||
import { isMultipleConversationsEnabled } from 'widget/helpers/utils';
|
||||
|
||||
import getUuid from '../../../helpers/uuid';
|
||||
export const createTemporaryMessage = ({ attachments, content, replyTo }) => {
|
||||
@@ -53,3 +54,8 @@ export const getNonDeletedMessages = ({ messages }) => {
|
||||
item => !(item.content_attributes && item.content_attributes.deleted)
|
||||
);
|
||||
};
|
||||
|
||||
// A response can arrive after the visitor moved to another conversation.
|
||||
export const hasLeftConversation = (rootState, thread) =>
|
||||
isMultipleConversationsEnabled() &&
|
||||
rootState.conversationList.thread !== thread;
|
||||
|
||||
@@ -12,6 +12,7 @@ const state = {
|
||||
isFetchingList: false,
|
||||
isAgentTyping: false,
|
||||
isCreating: false,
|
||||
isAwaitingCampaign: false,
|
||||
},
|
||||
lastMessageId: null,
|
||||
pendingCustomAttributes: {},
|
||||
|
||||
@@ -4,6 +4,10 @@ import { findUndeliveredMessage } from './helpers';
|
||||
export const mutations = {
|
||||
clearConversations($state) {
|
||||
$state.conversations = {};
|
||||
$state.uiFlags.allMessagesLoaded = false;
|
||||
$state.uiFlags.isAgentTyping = false;
|
||||
$state.uiFlags.isCreating = false;
|
||||
$state.uiFlags.isAwaitingCampaign = false;
|
||||
$state.pendingCustomAttributes = {};
|
||||
$state.pendingLabels = [];
|
||||
},
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
CLEAR_CONVERSATION_ATTRIBUTES,
|
||||
} from '../types';
|
||||
import { getConversationAPI } from '../../api/conversation';
|
||||
import { setActiveConversationId } from '../../helpers/axios';
|
||||
import { isMultipleConversationsEnabled } from '../../helpers/utils';
|
||||
|
||||
const state = {
|
||||
id: '',
|
||||
@@ -15,9 +17,10 @@ export const getters = {
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
getAttributes: async ({ commit }) => {
|
||||
getAttributes: async ({ commit, state: attributes }) => {
|
||||
try {
|
||||
const { data } = await getConversationAPI();
|
||||
if (isMultipleConversationsEnabled() && data.id !== attributes.id) return;
|
||||
const { contact_last_seen_at: lastSeen } = data;
|
||||
commit(SET_CONVERSATION_ATTRIBUTES, data);
|
||||
commit('conversation/setMetaUserLastSeenAt', lastSeen, { root: true });
|
||||
@@ -37,6 +40,7 @@ export const mutations = {
|
||||
[SET_CONVERSATION_ATTRIBUTES]($state, data) {
|
||||
$state.id = data.id;
|
||||
$state.status = data.status;
|
||||
setActiveConversationId(data.id);
|
||||
},
|
||||
[UPDATE_CONVERSATION_ATTRIBUTES]($state, data) {
|
||||
if (data.id === $state.id) {
|
||||
@@ -47,6 +51,7 @@ export const mutations = {
|
||||
[CLEAR_CONVERSATION_ATTRIBUTES]($state) {
|
||||
$state.id = '';
|
||||
$state.status = '';
|
||||
setActiveConversationId(null);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
171
app/javascript/widget/store/modules/conversationList.js
Normal file
171
app/javascript/widget/store/modules/conversationList.js
Normal file
@@ -0,0 +1,171 @@
|
||||
import { getConversationsAPI } from '../../api/conversation';
|
||||
import { MESSAGE_TYPE } from '../../helpers/constants';
|
||||
|
||||
const state = {
|
||||
records: [],
|
||||
page: 0,
|
||||
hasNextPage: false,
|
||||
unreadCount: 0,
|
||||
// Bumped whenever another thread takes the screen, so late responses can tell they are stale.
|
||||
thread: 0,
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
},
|
||||
};
|
||||
|
||||
const findRecord = ($state, id) =>
|
||||
$state.records.find(record => record.id === id);
|
||||
|
||||
export const getters = {
|
||||
getRecords: $state => $state.records,
|
||||
getLatestConversation: $state => $state.records[0],
|
||||
getHasNextPage: $state => $state.hasNextPage,
|
||||
getIsFetching: $state => $state.uiFlags.isFetching,
|
||||
// The conversation on screen reports its own unread state from the thread.
|
||||
getUnreadCount: ($state, _getters, rootState) => {
|
||||
const active = findRecord($state, rootState.conversationAttributes.id);
|
||||
return $state.unreadCount - (active?.unread_count > 0 ? 1 : 0);
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
fetch: async ({ commit }, { page = 1 } = {}) => {
|
||||
commit('setFetching', true);
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await getConversationsAPI({ page });
|
||||
commit('setRecords', {
|
||||
payload,
|
||||
page,
|
||||
hasNextPage: meta.has_next_page,
|
||||
unreadCount: meta.unread_count,
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
} finally {
|
||||
commit('setFetching', false);
|
||||
}
|
||||
},
|
||||
|
||||
fetchMore: ({ state: listState, dispatch }) => {
|
||||
if (!listState.hasNextPage || listState.uiFlags.isFetching) return;
|
||||
dispatch('fetch', { page: listState.page + 1 });
|
||||
},
|
||||
|
||||
// Records are newest-activity first, so the first match is the most recent one.
|
||||
load: async ({ state: listState, dispatch }) => {
|
||||
await dispatch('startNew');
|
||||
const { thread } = listState;
|
||||
await dispatch('fetch');
|
||||
if (listState.thread !== thread) return;
|
||||
const conversation =
|
||||
listState.records.find(record => record.unread_count > 0) ||
|
||||
listState.records.find(record => record.status !== 'resolved') ||
|
||||
listState.records[0];
|
||||
if (conversation) await dispatch('open', conversation.id);
|
||||
},
|
||||
|
||||
open: async ({ state: listState, commit, dispatch, rootState }, id) => {
|
||||
if (rootState.conversationAttributes.id === id) return;
|
||||
commit('switchThread');
|
||||
const { thread } = listState;
|
||||
dispatch('conversation/clearConversations', {}, { root: true });
|
||||
await dispatch('attach', id);
|
||||
if (listState.thread !== thread) return;
|
||||
await dispatch('conversation/fetchOldConversations', {}, { root: true });
|
||||
},
|
||||
|
||||
// Points the thread on screen at a conversation, which is also how a new thread takes the
|
||||
// conversation its first message created.
|
||||
attach: async ({ state: listState, commit, dispatch }, id) => {
|
||||
const { thread } = listState;
|
||||
if (!findRecord(listState, id)) {
|
||||
commit(
|
||||
'conversationAttributes/SET_CONVERSATION_ATTRIBUTES',
|
||||
{ id },
|
||||
{ root: true }
|
||||
);
|
||||
await dispatch('fetch');
|
||||
if (listState.thread !== thread) return;
|
||||
}
|
||||
const record = findRecord(listState, id) || { id };
|
||||
commit('conversationAttributes/SET_CONVERSATION_ATTRIBUTES', record, {
|
||||
root: true,
|
||||
});
|
||||
commit('conversation/setMetaUserLastSeenAt', record.contact_last_seen_at, {
|
||||
root: true,
|
||||
});
|
||||
commit('markRead', id);
|
||||
},
|
||||
|
||||
// A new thread cannot receive its conversation's messages until it knows the id.
|
||||
adopt: async ({ state: listState, dispatch }, id) => {
|
||||
const { thread } = listState;
|
||||
await dispatch('attach', id);
|
||||
if (listState.thread !== thread) return;
|
||||
await dispatch('conversation/syncLatestMessages', {}, { root: true });
|
||||
},
|
||||
|
||||
updateLastMessage: ({ commit }, message) => {
|
||||
commit('setLastMessage', message);
|
||||
},
|
||||
|
||||
startNew: async ({ commit, dispatch }) => {
|
||||
commit('switchThread');
|
||||
await dispatch(
|
||||
'conversationAttributes/clearConversationAttributes',
|
||||
{},
|
||||
{ root: true }
|
||||
);
|
||||
await dispatch('conversation/clearConversations', {}, { root: true });
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
setRecords($state, { payload, page, hasNextPage, unreadCount }) {
|
||||
const known = new Set($state.records.map(record => record.id));
|
||||
$state.records =
|
||||
page === 1
|
||||
? payload
|
||||
: [...$state.records, ...payload.filter(r => !known.has(r.id))];
|
||||
$state.page = page;
|
||||
$state.hasNextPage = hasNextPage;
|
||||
$state.unreadCount = unreadCount;
|
||||
},
|
||||
// Ids only grow, so an older message arriving late cannot replace a newer preview.
|
||||
setLastMessage($state, message) {
|
||||
const record = findRecord($state, message.conversation_id);
|
||||
if (!record || record.last_message?.id > message.id) return;
|
||||
record.last_message = message;
|
||||
record.last_activity_at = message.created_at;
|
||||
if (message.message_type === MESSAGE_TYPE.OUTGOING) {
|
||||
if (!record.unread_count) $state.unreadCount += 1;
|
||||
record.unread_count += 1;
|
||||
}
|
||||
$state.records = [
|
||||
record,
|
||||
...$state.records.filter(item => item !== record),
|
||||
];
|
||||
},
|
||||
switchThread($state) {
|
||||
$state.thread += 1;
|
||||
},
|
||||
setFetching($state, isFetching) {
|
||||
$state.uiFlags.isFetching = isFetching;
|
||||
},
|
||||
markRead($state, id) {
|
||||
const record = findRecord($state, id);
|
||||
if (!record?.unread_count) return;
|
||||
record.unread_count = 0;
|
||||
$state.unreadCount -= 1;
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
@@ -214,6 +214,7 @@ describe('#actions', () => {
|
||||
'conversation/setConversationUIFlag',
|
||||
{
|
||||
isCreating: true,
|
||||
isAwaitingCampaign: true,
|
||||
},
|
||||
{
|
||||
root: true,
|
||||
@@ -241,12 +242,22 @@ describe('#actions', () => {
|
||||
'conversation/setConversationUIFlag',
|
||||
{
|
||||
isCreating: true,
|
||||
isAwaitingCampaign: true,
|
||||
},
|
||||
{
|
||||
root: true,
|
||||
},
|
||||
],
|
||||
['setError', true],
|
||||
[
|
||||
'conversation/setConversationUIFlag',
|
||||
{
|
||||
isAwaitingCampaign: false,
|
||||
},
|
||||
{
|
||||
root: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'conversation/setConversationUIFlag',
|
||||
{
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { API } from 'widget/helpers/axios';
|
||||
import { sendMessage } from 'widget/helpers/utils';
|
||||
import {
|
||||
sendMessage,
|
||||
isMultipleConversationsEnabled,
|
||||
} from 'widget/helpers/utils';
|
||||
import { actions } from '../../contacts';
|
||||
|
||||
const commit = vi.fn();
|
||||
@@ -8,6 +11,7 @@ const dispatch = vi.fn();
|
||||
vi.mock('widget/helpers/axios');
|
||||
vi.mock('widget/helpers/utils', () => ({
|
||||
sendMessage: vi.fn(),
|
||||
isMultipleConversationsEnabled: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
describe('#actions', () => {
|
||||
@@ -53,6 +57,21 @@ describe('#actions', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('reloads the conversation list when multiple conversations are enabled', async () => {
|
||||
isMultipleConversationsEnabled.mockReturnValueOnce(true);
|
||||
vi.spyOn(API, 'patch').mockResolvedValue({
|
||||
data: { widget_auth_token: 'token' },
|
||||
});
|
||||
await actions.setUser(
|
||||
{ commit, dispatch },
|
||||
{ identifier: 1, user: { identifier_hash: 'hash' } }
|
||||
);
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
['get'],
|
||||
['conversationList/load', {}, { root: true }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not call sendMessage if contact object is not refreshed ', async () => {
|
||||
const user = {
|
||||
email: 'thoma@sphadikam.com',
|
||||
|
||||
@@ -7,6 +7,10 @@ vi.mock('widget/helpers/axios');
|
||||
|
||||
const commit = vi.fn();
|
||||
const dispatch = vi.fn();
|
||||
const rootState = {
|
||||
conversationAttributes: { id: 1 },
|
||||
conversationList: { thread: 0 },
|
||||
};
|
||||
|
||||
describe('#actions', () => {
|
||||
describe('#createConversation', () => {
|
||||
@@ -32,7 +36,7 @@ describe('#actions', () => {
|
||||
},
|
||||
}));
|
||||
await actions.createConversation(
|
||||
{ commit },
|
||||
{ commit, rootState },
|
||||
{ contact: {}, message: 'This is a test message' }
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
@@ -47,6 +51,41 @@ describe('#actions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#createConversation with multiple conversations', () => {
|
||||
beforeEach(() => {
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['multiple_conversations'],
|
||||
};
|
||||
window.WOOT_WIDGET = { $root: { $i18n: { locale: 'en' } } };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.chatwootWebChannel;
|
||||
delete window.WOOT_WIDGET;
|
||||
});
|
||||
|
||||
it('leaves a thread the visitor already moved away from alone', async () => {
|
||||
const movingRootState = {
|
||||
conversationAttributes: { id: '' },
|
||||
conversationList: { thread: 0 },
|
||||
};
|
||||
API.post.mockImplementationOnce(async () => {
|
||||
movingRootState.conversationList.thread = 1;
|
||||
return { data: { id: 7, messages: [{ id: 1, content: 'hi' }] } };
|
||||
});
|
||||
|
||||
await actions.createConversation(
|
||||
{ commit, dispatch, rootState: movingRootState },
|
||||
{ message: 'hi' }
|
||||
);
|
||||
|
||||
expect(commit.mock.calls).toEqual([
|
||||
['setConversationUIFlag', { isCreating: true }],
|
||||
]);
|
||||
expect(dispatch).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#addOrUpdateMessage', () => {
|
||||
it('sends correct actions for non-deleted message', () => {
|
||||
actions.addOrUpdateMessage(
|
||||
@@ -164,7 +203,7 @@ describe('#actions', () => {
|
||||
const state = { pendingCustomAttributes: {}, pendingLabels: [] };
|
||||
|
||||
actions.sendAttachment(
|
||||
{ commit, dispatch, state },
|
||||
{ commit, dispatch, rootState, state },
|
||||
{ attachment, replyTo: 135 }
|
||||
);
|
||||
spy.mockRestore();
|
||||
@@ -192,14 +231,19 @@ describe('#actions', () => {
|
||||
API.post.mockResolvedValue({ data: { success: true } });
|
||||
await actions.setUserLastSeen({
|
||||
commit,
|
||||
rootState,
|
||||
getters: { getConversationSize: 2 },
|
||||
});
|
||||
expect(commit.mock.calls[0][0]).toEqual('setMetaUserLastSeenAt');
|
||||
expect(commit).toBeCalledWith('conversationList/markRead', 1, {
|
||||
root: true,
|
||||
});
|
||||
});
|
||||
it('sends correct mutations', async () => {
|
||||
API.post.mockResolvedValue({ data: { success: true } });
|
||||
await actions.setUserLastSeen({
|
||||
commit,
|
||||
rootState,
|
||||
getters: { getConversationSize: 0 },
|
||||
});
|
||||
expect(commit.mock.calls).toEqual([]);
|
||||
@@ -265,7 +309,258 @@ describe('#actions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#sendMessageWithData', () => {
|
||||
const message = { id: 'temp-1', content: 'hello', meta: {} };
|
||||
let windowSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
windowSpy = vi.spyOn(window, 'window', 'get');
|
||||
windowSpy.mockImplementation(() => ({
|
||||
WOOT_WIDGET: { $root: { $i18n: { locale: 'en' } } },
|
||||
location: { search: '' },
|
||||
chatwootWebChannel: { enabledFeatures: ['multiple_conversations'] },
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => windowSpy.mockRestore());
|
||||
|
||||
it('adopts the conversation created by the first message', async () => {
|
||||
API.post.mockResolvedValue({ data: { id: 9, conversation_id: 55 } });
|
||||
const draftRootState = {
|
||||
conversationAttributes: { id: '' },
|
||||
conversationList: { thread: 0 },
|
||||
};
|
||||
|
||||
await actions.sendMessageWithData(
|
||||
{ commit, dispatch, rootState: draftRootState },
|
||||
{ message }
|
||||
);
|
||||
|
||||
expect(commit).toBeCalledWith('pushMessageToConversation', {
|
||||
id: 9,
|
||||
conversation_id: 55,
|
||||
status: 'sent',
|
||||
});
|
||||
expect(dispatch).toBeCalledWith('conversationList/adopt', 55, {
|
||||
root: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('posts messages sent before the first one is saved to the conversation it created', async () => {
|
||||
const draftRootState = {
|
||||
conversationAttributes: { id: '' },
|
||||
conversationList: { thread: 0 },
|
||||
};
|
||||
let saveFirst;
|
||||
API.post
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
saveFirst = () =>
|
||||
resolve({ data: { id: 9, conversation_id: 55 } });
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ data: { id: 10, conversation_id: 55 } });
|
||||
dispatch.mockImplementation(action => {
|
||||
if (action === 'conversationList/adopt') {
|
||||
draftRootState.conversationAttributes.id = 55;
|
||||
}
|
||||
});
|
||||
|
||||
const first = actions.sendMessageWithData(
|
||||
{ commit, dispatch, rootState: draftRootState },
|
||||
{ message }
|
||||
);
|
||||
const second = actions.sendMessageWithData(
|
||||
{ commit, dispatch, rootState: draftRootState },
|
||||
{ message: { id: 'temp-2', content: 'are you there?', meta: {} } }
|
||||
);
|
||||
expect(API.post).toHaveBeenCalledTimes(1);
|
||||
expect(commit).toBeCalledWith('setConversationUIFlag', {
|
||||
isCreating: true,
|
||||
});
|
||||
|
||||
saveFirst();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(API.post).toHaveBeenCalledTimes(2);
|
||||
expect(API.post.mock.calls[0][2]).toEqual({
|
||||
params: { conversation_id: undefined },
|
||||
});
|
||||
expect(API.post.mock.calls[1][2]).toEqual({
|
||||
params: { conversation_id: 55 },
|
||||
});
|
||||
expect(commit).toBeCalledWith('setConversationUIFlag', {
|
||||
isCreating: false,
|
||||
});
|
||||
expect(commit).toBeCalledWith('pushMessageToConversation', {
|
||||
id: 10,
|
||||
conversation_id: 55,
|
||||
status: 'sent',
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not let a later draft join the first draft that is still being saved', async () => {
|
||||
const draftRootState = {
|
||||
conversationAttributes: { id: '' },
|
||||
conversationList: { thread: 0 },
|
||||
};
|
||||
let saveFirst;
|
||||
API.post
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
saveFirst = () =>
|
||||
resolve({ data: { id: 9, conversation_id: 55 } });
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ data: { id: 10, conversation_id: 56 } });
|
||||
|
||||
const first = actions.sendMessageWithData(
|
||||
{ commit, dispatch, rootState: draftRootState },
|
||||
{ message }
|
||||
);
|
||||
draftRootState.conversationList.thread = 1;
|
||||
const second = actions.sendMessageWithData(
|
||||
{ commit, dispatch, rootState: draftRootState },
|
||||
{ message: { id: 'temp-2', content: 'another question', meta: {} } }
|
||||
);
|
||||
|
||||
expect(API.post).toHaveBeenCalledTimes(2);
|
||||
expect(API.post.mock.calls[1][2]).toEqual({
|
||||
params: { conversation_id: undefined },
|
||||
});
|
||||
saveFirst();
|
||||
await Promise.all([first, second]);
|
||||
expect(commit).toBeCalledWith('deleteMessage', 'temp-1');
|
||||
expect(dispatch).toBeCalledWith('conversationList/adopt', 56, {
|
||||
root: true,
|
||||
});
|
||||
expect(dispatch).not.toBeCalledWith('conversationList/adopt', 55, {
|
||||
root: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails the waiting messages when the first one could not be saved', async () => {
|
||||
const draftRootState = {
|
||||
conversationAttributes: { id: '' },
|
||||
conversationList: { thread: 0 },
|
||||
};
|
||||
API.post.mockRejectedValueOnce(new Error('offline'));
|
||||
const waiting = { id: 'temp-2', content: 'are you there?', meta: {} };
|
||||
|
||||
await Promise.all([
|
||||
actions.sendMessageWithData(
|
||||
{ commit, dispatch, rootState: draftRootState },
|
||||
{ message }
|
||||
),
|
||||
actions.sendMessageWithData(
|
||||
{ commit, dispatch, rootState: draftRootState },
|
||||
{ message: waiting }
|
||||
),
|
||||
]);
|
||||
|
||||
expect(API.post).toHaveBeenCalledTimes(1);
|
||||
expect(commit).toBeCalledWith('pushMessageToConversation', {
|
||||
...waiting,
|
||||
status: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces a retried message instead of leaving its failed copy behind', async () => {
|
||||
API.post.mockResolvedValue({
|
||||
data: { id: 9, content: 'hello', message_type: 0, conversation_id: 1 },
|
||||
});
|
||||
|
||||
await actions.sendMessageWithData(
|
||||
{ commit, dispatch, rootState },
|
||||
{ message: { ...message, message_type: 0, status: 'failed' } }
|
||||
);
|
||||
|
||||
expect(commit.mock.calls[0]).toEqual([
|
||||
'pushMessageToConversation',
|
||||
{ ...message, message_type: 0, status: 'in_progress' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the reply when the visitor moved to another conversation meanwhile', async () => {
|
||||
const movingRootState = {
|
||||
conversationAttributes: { id: 1 },
|
||||
conversationList: { thread: 0 },
|
||||
};
|
||||
API.post.mockImplementationOnce(async () => {
|
||||
movingRootState.conversationList.thread = 1;
|
||||
return { data: { id: 9, conversation_id: 1 } };
|
||||
});
|
||||
|
||||
await actions.sendMessageWithData(
|
||||
{ commit, dispatch, rootState: movingRootState },
|
||||
{ message }
|
||||
);
|
||||
|
||||
expect(commit).toBeCalledWith('deleteMessage', 'temp-1');
|
||||
expect(commit).not.toBeCalledWith('pushMessageToConversation', {
|
||||
id: 9,
|
||||
conversation_id: 1,
|
||||
status: 'sent',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the reply in the thread with multiple conversations disabled', async () => {
|
||||
windowSpy.mockImplementation(() => ({
|
||||
WOOT_WIDGET: { $root: { $i18n: { locale: 'en' } } },
|
||||
location: { search: '' },
|
||||
}));
|
||||
const movingRootState = {
|
||||
conversationAttributes: { id: 1 },
|
||||
conversationList: { thread: 0 },
|
||||
};
|
||||
API.post.mockImplementationOnce(async () => {
|
||||
movingRootState.conversationList.thread = 1;
|
||||
return { data: { id: 9, conversation_id: 1 } };
|
||||
});
|
||||
|
||||
await actions.sendMessageWithData(
|
||||
{ commit, dispatch, rootState: movingRootState },
|
||||
{ message }
|
||||
);
|
||||
|
||||
expect(commit).toBeCalledWith('pushMessageToConversation', {
|
||||
id: 9,
|
||||
conversation_id: 1,
|
||||
status: 'sent',
|
||||
});
|
||||
expect(dispatch).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#fetchOldConversations', () => {
|
||||
it('ignores a page that arrives after moving to another conversation', async () => {
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['multiple_conversations'],
|
||||
};
|
||||
const movingRootState = {
|
||||
conversationAttributes: { id: 1 },
|
||||
conversationList: { thread: 0 },
|
||||
};
|
||||
API.get.mockImplementationOnce(async () => {
|
||||
movingRootState.conversationList.thread = 1;
|
||||
return { data: { payload: [{ id: 1 }], meta: {} } };
|
||||
});
|
||||
|
||||
await actions.fetchOldConversations(
|
||||
{ commit, rootState: movingRootState },
|
||||
{}
|
||||
);
|
||||
|
||||
expect(commit.mock.calls).toEqual([
|
||||
['setConversationListLoading', true],
|
||||
['setConversationListLoading', false],
|
||||
]);
|
||||
delete window.chatwootWebChannel;
|
||||
});
|
||||
|
||||
it('sends correct actions', async () => {
|
||||
API.get.mockResolvedValue({
|
||||
data: {
|
||||
@@ -286,7 +581,7 @@ describe('#actions', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await actions.fetchOldConversations({ commit }, {});
|
||||
await actions.fetchOldConversations({ commit, rootState }, {});
|
||||
expect(commit.mock.calls).toEqual([
|
||||
['setConversationListLoading', true],
|
||||
['conversation/setMetaUserLastSeenAt', 1466424490, { root: true }],
|
||||
@@ -349,7 +644,7 @@ describe('#actions', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await actions.syncLatestMessages({ state, commit }, {});
|
||||
await actions.syncLatestMessages({ state, commit, rootState }, {});
|
||||
expect(commit.mock.calls).toEqual([
|
||||
['conversation/setMetaUserLastSeenAt', 1466424490, { root: true }],
|
||||
[
|
||||
@@ -431,7 +726,7 @@ describe('#actions', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await actions.syncLatestMessages({ state, commit }, {});
|
||||
await actions.syncLatestMessages({ state, commit, rootState }, {});
|
||||
|
||||
expect(commit.mock.calls).toEqual([
|
||||
['conversation/setMetaUserLastSeenAt', 14664223490, { root: true }],
|
||||
@@ -504,7 +799,7 @@ describe('#actions', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await actions.syncLatestMessages({ state, commit }, {});
|
||||
await actions.syncLatestMessages({ state, commit, rootState }, {});
|
||||
|
||||
expect(commit.mock.calls).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
findUndeliveredMessage,
|
||||
createTemporaryMessage,
|
||||
getNonDeletedMessages,
|
||||
hasLeftConversation,
|
||||
} from '../../conversation/helpers';
|
||||
|
||||
describe('#findUndeliveredMessage', () => {
|
||||
@@ -79,3 +80,21 @@ describe('#getNonDeletedMessages', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#hasLeftConversation', () => {
|
||||
afterEach(() => {
|
||||
delete window.chatwootWebChannel;
|
||||
});
|
||||
|
||||
it('is true when another thread took the screen with multiple conversations enabled', () => {
|
||||
window.chatwootWebChannel = { enabledFeatures: ['multiple_conversations'] };
|
||||
const rootState = { conversationList: { thread: 2 } };
|
||||
expect(hasLeftConversation(rootState, 1)).toBe(true);
|
||||
expect(hasLeftConversation(rootState, 2)).toBe(false);
|
||||
});
|
||||
|
||||
it('is false with multiple conversations disabled', () => {
|
||||
const rootState = { conversationList: { thread: 2 } };
|
||||
expect(hasLeftConversation(rootState, 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,14 +169,24 @@ describe('#mutations', () => {
|
||||
});
|
||||
|
||||
describe('#clearConversations', () => {
|
||||
it('clears conversations and pending metadata', () => {
|
||||
it('clears conversations, the typing indicator and pending metadata', () => {
|
||||
const state = {
|
||||
conversations: { 1: { id: 1 } },
|
||||
uiFlags: {
|
||||
allMessagesLoaded: true,
|
||||
isAgentTyping: true,
|
||||
isCreating: true,
|
||||
isAwaitingCampaign: true,
|
||||
},
|
||||
pendingCustomAttributes: { plan: 'enterprise' },
|
||||
pendingLabels: ['vip'],
|
||||
};
|
||||
mutations.clearConversations(state);
|
||||
expect(state.conversations).toEqual({});
|
||||
expect(state.uiFlags.allMessagesLoaded).toBe(false);
|
||||
expect(state.uiFlags.isAgentTyping).toBe(false);
|
||||
expect(state.uiFlags.isCreating).toBe(false);
|
||||
expect(state.uiFlags.isAwaitingCampaign).toBe(false);
|
||||
expect(state.pendingCustomAttributes).toEqual({});
|
||||
expect(state.pendingLabels).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { actions } from '../../conversationAttributes';
|
||||
import { API } from 'widget/helpers/axios';
|
||||
import { actions } from '../../conversationAttributes';
|
||||
|
||||
const commit = vi.fn();
|
||||
vi.mock('widget/helpers/axios');
|
||||
@@ -8,15 +8,24 @@ describe('#actions', () => {
|
||||
describe('#get attributes', () => {
|
||||
it('sends mutation if api is success', async () => {
|
||||
API.get.mockResolvedValue({ data: { id: 1, status: 'pending' } });
|
||||
await actions.getAttributes({ commit });
|
||||
await actions.getAttributes({ commit, state: { id: '' } });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
['SET_CONVERSATION_ATTRIBUTES', { id: 1, status: 'pending' }],
|
||||
['conversation/setMetaUserLastSeenAt', undefined, { root: true }],
|
||||
]);
|
||||
});
|
||||
it('ignores a response for another conversation when multiple conversations are enabled', async () => {
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['multiple_conversations'],
|
||||
};
|
||||
API.get.mockResolvedValue({ data: { id: 2, status: 'open' } });
|
||||
await actions.getAttributes({ commit, state: { id: 1 } });
|
||||
expect(commit.mock.calls).toEqual([]);
|
||||
delete window.chatwootWebChannel;
|
||||
});
|
||||
it('doesnot send mutation if api is error', async () => {
|
||||
API.get.mockRejectedValue({ message: 'Invalid Headers' });
|
||||
await actions.getAttributes({ commit });
|
||||
await actions.getAttributes({ commit, state: { id: '' } });
|
||||
expect(commit.mock.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { mutations } from '../../conversationAttributes';
|
||||
import { setActiveConversationId } from 'widget/helpers/axios';
|
||||
|
||||
vi.mock('widget/helpers/axios');
|
||||
|
||||
describe('#mutations', () => {
|
||||
describe('#SET_CONVERSATION_ATTRIBUTES', () => {
|
||||
@@ -9,6 +12,7 @@ describe('#mutations', () => {
|
||||
status: 'open',
|
||||
});
|
||||
expect(state).toEqual({ id: 1, status: 'open' });
|
||||
expect(setActiveConversationId).toBeCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +43,7 @@ describe('#mutations', () => {
|
||||
status: 'open',
|
||||
});
|
||||
expect(state).toEqual({ id: '', status: '' });
|
||||
expect(setActiveConversationId).toBeCalledWith(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { actions } from '../../conversationList';
|
||||
import { API } from 'widget/helpers/axios';
|
||||
|
||||
vi.mock('widget/helpers/axios');
|
||||
|
||||
const commit = vi.fn();
|
||||
const dispatch = vi.fn();
|
||||
|
||||
const records = [
|
||||
{ id: 3, status: 'resolved', contact_last_seen_at: 30, unread_count: 0 },
|
||||
{ id: 2, status: 'open', contact_last_seen_at: 20, unread_count: 2 },
|
||||
];
|
||||
|
||||
describe('#actions', () => {
|
||||
describe('#fetch', () => {
|
||||
it('stores the page returned by the API', async () => {
|
||||
API.get.mockResolvedValue({
|
||||
data: {
|
||||
payload: records,
|
||||
meta: { has_next_page: true, unread_count: 1 },
|
||||
},
|
||||
});
|
||||
await actions.fetch({ commit });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
['setFetching', true],
|
||||
[
|
||||
'setRecords',
|
||||
{ payload: records, page: 1, hasNextPage: true, unreadCount: 1 },
|
||||
],
|
||||
['setFetching', false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears the loading flag when the API fails', async () => {
|
||||
API.get.mockRejectedValue(new Error('offline'));
|
||||
await actions.fetch({ commit });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
['setFetching', true],
|
||||
['setFetching', false],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#fetchMore', () => {
|
||||
it('requests the next page', () => {
|
||||
actions.fetchMore({
|
||||
dispatch,
|
||||
state: { page: 1, hasNextPage: true, uiFlags: { isFetching: false } },
|
||||
});
|
||||
expect(dispatch).toBeCalledWith('fetch', { page: 2 });
|
||||
});
|
||||
|
||||
it('does nothing when there is no next page or a fetch is running', () => {
|
||||
actions.fetchMore({
|
||||
dispatch,
|
||||
state: { page: 1, hasNextPage: false, uiFlags: { isFetching: false } },
|
||||
});
|
||||
actions.fetchMore({
|
||||
dispatch,
|
||||
state: { page: 1, hasNextPage: true, uiFlags: { isFetching: true } },
|
||||
});
|
||||
expect(dispatch).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#load', () => {
|
||||
it('opens the most recent conversation that is not resolved', async () => {
|
||||
const state = { records };
|
||||
await actions.load({ state, dispatch });
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
['startNew'],
|
||||
['fetch'],
|
||||
['open', 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefers the most recent conversation with unread messages, even when resolved', async () => {
|
||||
const state = {
|
||||
records: [
|
||||
{ id: 4, status: 'open', unread_count: 0 },
|
||||
{ id: 3, status: 'resolved', unread_count: 2 },
|
||||
{ id: 2, status: 'open', unread_count: 1 },
|
||||
],
|
||||
};
|
||||
await actions.load({ state, dispatch });
|
||||
expect(dispatch).toBeCalledWith('open', 3);
|
||||
});
|
||||
|
||||
it('falls back to the most recent conversation when all are resolved', async () => {
|
||||
const state = { records: [records[0]] };
|
||||
await actions.load({ state, dispatch });
|
||||
expect(dispatch).toBeCalledWith('open', 3);
|
||||
});
|
||||
|
||||
it('opens nothing for a visitor without conversations', async () => {
|
||||
await actions.load({ state: { records: [] }, dispatch });
|
||||
expect(dispatch.mock.calls).toEqual([['startNew'], ['fetch']]);
|
||||
});
|
||||
|
||||
it('opens nothing when the visitor switched threads while the list loaded', async () => {
|
||||
const state = { records, thread: 1 };
|
||||
dispatch.mockImplementation(async action => {
|
||||
if (action === 'fetch') state.thread += 1;
|
||||
});
|
||||
|
||||
await actions.load({ state, dispatch });
|
||||
|
||||
expect(dispatch).not.toBeCalledWith('open', 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#open', () => {
|
||||
it('clears the thread on screen, attaches the selected conversation and loads it', async () => {
|
||||
const rootState = { conversationAttributes: { id: '' } };
|
||||
await actions.open(
|
||||
{ state: { thread: 1 }, commit, dispatch, rootState },
|
||||
2
|
||||
);
|
||||
|
||||
expect(commit.mock.calls).toEqual([['switchThread']]);
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
['conversation/clearConversations', {}, { root: true }],
|
||||
['attach', 2],
|
||||
['conversation/fetchOldConversations', {}, { root: true }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips loading messages when another thread took the screen meanwhile', async () => {
|
||||
const state = { thread: 1 };
|
||||
const rootState = { conversationAttributes: { id: '' } };
|
||||
dispatch.mockImplementation(async action => {
|
||||
if (action === 'attach') state.thread += 1;
|
||||
});
|
||||
|
||||
await actions.open({ state, commit, dispatch, rootState }, 2);
|
||||
|
||||
expect(dispatch).not.toBeCalledWith(
|
||||
'conversation/fetchOldConversations',
|
||||
{},
|
||||
{ root: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when the conversation is already open', async () => {
|
||||
const rootState = { conversationAttributes: { id: 2 } };
|
||||
await actions.open({ state: {}, commit, dispatch, rootState }, 2);
|
||||
expect(commit).not.toBeCalled();
|
||||
expect(dispatch).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#attach', () => {
|
||||
it('shows the conversation in the thread on screen', async () => {
|
||||
await actions.attach({ state: { records }, commit, dispatch }, 2);
|
||||
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[
|
||||
'conversationAttributes/SET_CONVERSATION_ATTRIBUTES',
|
||||
records[1],
|
||||
{ root: true },
|
||||
],
|
||||
['conversation/setMetaUserLastSeenAt', 20, { root: true }],
|
||||
['markRead', 2],
|
||||
]);
|
||||
expect(dispatch).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('refreshes the list before attaching a conversation it does not know yet', async () => {
|
||||
const state = { records: [] };
|
||||
dispatch.mockImplementation(async action => {
|
||||
if (action === 'fetch') state.records = records;
|
||||
});
|
||||
|
||||
await actions.attach({ state, commit, dispatch }, 2);
|
||||
|
||||
expect(commit.mock.calls[0]).toEqual([
|
||||
'conversationAttributes/SET_CONVERSATION_ATTRIBUTES',
|
||||
{ id: 2 },
|
||||
{ root: true },
|
||||
]);
|
||||
expect(dispatch).toBeCalledWith('fetch');
|
||||
expect(commit).toBeCalledWith(
|
||||
'conversationAttributes/SET_CONVERSATION_ATTRIBUTES',
|
||||
records[1],
|
||||
{ root: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('stops when another thread took the screen while the list loaded', async () => {
|
||||
const state = { records: [], thread: 1 };
|
||||
dispatch.mockImplementation(async action => {
|
||||
if (action === 'fetch') {
|
||||
state.records = records;
|
||||
state.thread += 1;
|
||||
}
|
||||
});
|
||||
|
||||
await actions.attach({ state, commit, dispatch }, 2);
|
||||
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[
|
||||
'conversationAttributes/SET_CONVERSATION_ATTRIBUTES',
|
||||
{ id: 2 },
|
||||
{ root: true },
|
||||
],
|
||||
]);
|
||||
expect(dispatch.mock.calls).toEqual([['fetch']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#adopt', () => {
|
||||
it('attaches the conversation and catches up on the messages it missed', async () => {
|
||||
await actions.adopt({ state: { thread: 1 }, dispatch }, 2);
|
||||
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
['attach', 2],
|
||||
['conversation/syncLatestMessages', {}, { root: true }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips catching up when another thread took the screen meanwhile', async () => {
|
||||
const state = { thread: 1 };
|
||||
dispatch.mockImplementation(async () => {
|
||||
state.thread += 1;
|
||||
});
|
||||
|
||||
await actions.adopt({ state, dispatch }, 2);
|
||||
|
||||
expect(dispatch.mock.calls).toEqual([['attach', 2]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#updateLastMessage', () => {
|
||||
it('updates the row of the message conversation', () => {
|
||||
const message = { id: 40, conversation_id: 2 };
|
||||
actions.updateLastMessage({ commit }, message);
|
||||
expect(commit).toBeCalledWith('setLastMessage', message);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#startNew', () => {
|
||||
it('clears the active conversation and its thread', async () => {
|
||||
await actions.startNew({ commit, dispatch });
|
||||
expect(commit.mock.calls).toEqual([['switchThread']]);
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
[
|
||||
'conversationAttributes/clearConversationAttributes',
|
||||
{},
|
||||
{ root: true },
|
||||
],
|
||||
['conversation/clearConversations', {}, { root: true }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { getters } from '../../conversationList';
|
||||
|
||||
const records = [
|
||||
{ id: 4, status: 'resolved', unread_count: 1 },
|
||||
{ id: 3, status: 'open', unread_count: 2 },
|
||||
{ id: 2, status: 'pending', unread_count: 3 },
|
||||
{ id: 1, status: 'resolved', unread_count: 0 },
|
||||
];
|
||||
|
||||
describe('#getters', () => {
|
||||
it('returns the most recent conversation', () => {
|
||||
expect(getters.getLatestConversation({ records })).toEqual(records[0]);
|
||||
expect(getters.getLatestConversation({ records: [] })).toBe(undefined);
|
||||
});
|
||||
|
||||
it('counts unread conversations, leaving the one on screen to the thread', () => {
|
||||
const state = { records, unreadCount: 3 };
|
||||
expect(
|
||||
getters.getUnreadCount(state, {}, { conversationAttributes: { id: 3 } })
|
||||
).toBe(2);
|
||||
expect(
|
||||
getters.getUnreadCount(state, {}, { conversationAttributes: { id: 1 } })
|
||||
).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { mutations } from '../../conversationList';
|
||||
|
||||
describe('#mutations', () => {
|
||||
describe('#setRecords', () => {
|
||||
it('replaces the list with the first page', () => {
|
||||
const state = {
|
||||
records: [{ id: 9 }],
|
||||
page: 2,
|
||||
hasNextPage: true,
|
||||
unreadCount: 0,
|
||||
};
|
||||
mutations.setRecords(state, {
|
||||
payload: [{ id: 2 }, { id: 1 }],
|
||||
page: 1,
|
||||
hasNextPage: false,
|
||||
unreadCount: 1,
|
||||
});
|
||||
expect(state).toEqual({
|
||||
records: [{ id: 2 }, { id: 1 }],
|
||||
page: 1,
|
||||
hasNextPage: false,
|
||||
unreadCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('appends later pages without repeating conversations already listed', () => {
|
||||
const state = { records: [{ id: 3 }, { id: 2 }], page: 1 };
|
||||
mutations.setRecords(state, {
|
||||
payload: [{ id: 2 }, { id: 1 }],
|
||||
page: 2,
|
||||
hasNextPage: false,
|
||||
});
|
||||
expect(state.records).toEqual([{ id: 3 }, { id: 2 }, { id: 1 }]);
|
||||
expect(state.page).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setLastMessage', () => {
|
||||
it('shows the message on its row and moves the row to the top', () => {
|
||||
const state = {
|
||||
records: [
|
||||
{ id: 3, last_message: { id: 30 } },
|
||||
{ id: 2, last_message: { id: 20 }, last_activity_at: 1 },
|
||||
],
|
||||
};
|
||||
mutations.setLastMessage(state, {
|
||||
id: 40,
|
||||
conversation_id: 2,
|
||||
created_at: 9,
|
||||
});
|
||||
expect(state.records.map(record => record.id)).toEqual([2, 3]);
|
||||
expect(state.records[0].last_message.id).toBe(40);
|
||||
expect(state.records[0].last_activity_at).toBe(9);
|
||||
});
|
||||
|
||||
it('counts an agent reply as unread for its conversation', () => {
|
||||
const state = {
|
||||
records: [{ id: 2, last_message: { id: 20 }, unread_count: 0 }],
|
||||
unreadCount: 0,
|
||||
};
|
||||
mutations.setLastMessage(state, {
|
||||
id: 40,
|
||||
conversation_id: 2,
|
||||
message_type: 1,
|
||||
});
|
||||
expect(state.records[0].unread_count).toBe(1);
|
||||
expect(state.unreadCount).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps a newer preview and ignores unknown conversations', () => {
|
||||
const state = { records: [{ id: 2, last_message: { id: 40 } }] };
|
||||
mutations.setLastMessage(state, { id: 30, conversation_id: 2 });
|
||||
mutations.setLastMessage(state, { id: 50, conversation_id: 9 });
|
||||
expect(state.records).toEqual([{ id: 2, last_message: { id: 40 } }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#switchThread', () => {
|
||||
it('moves to the next thread', () => {
|
||||
const state = { thread: 1 };
|
||||
mutations.switchThread(state);
|
||||
expect(state.thread).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#markRead', () => {
|
||||
it('clears the conversation and drops it from the unread total once', () => {
|
||||
const state = { records: [{ id: 1, unread_count: 4 }], unreadCount: 2 };
|
||||
mutations.markRead(state, 1);
|
||||
mutations.markRead(state, 1);
|
||||
mutations.markRead(state, 2);
|
||||
expect(state.records).toEqual([{ id: 1, unread_count: 0 }]);
|
||||
expect(state.unreadCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setFetching', () => {
|
||||
it('sets the loading flag', () => {
|
||||
const state = { uiFlags: { isFetching: false } };
|
||||
mutations.setFetching(state, true);
|
||||
expect(state.uiFlags.isFetching).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
113
app/javascript/widget/views/ConversationList.vue
Normal file
113
app/javascript/widget/views/ConversationList.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { getContrastingTextColor } from '@chatwoot/utils';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store.js';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import ConversationListItem from 'widget/components/ConversationListItem.vue';
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
|
||||
const conversations = useMapGetter('conversationList/getRecords');
|
||||
const hasNextPage = useMapGetter('conversationList/getHasNextPage');
|
||||
const isFetching = useMapGetter('conversationList/getIsFetching');
|
||||
const widgetColor = useMapGetter('appConfig/getWidgetColor');
|
||||
|
||||
const { preChatFormEnabled } = window.chatwootWebChannel;
|
||||
const LOAD_MORE_THRESHOLD_PX = 160;
|
||||
|
||||
onMounted(() => store.dispatch('conversationList/fetch'));
|
||||
|
||||
const onScroll = ({ target }) => {
|
||||
const remaining =
|
||||
target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||
if (remaining < LOAD_MORE_THRESHOLD_PX) {
|
||||
store.dispatch('conversationList/fetchMore');
|
||||
}
|
||||
};
|
||||
|
||||
const openConversation = async id => {
|
||||
await store.dispatch('conversationList/open', id);
|
||||
router.replace({ name: 'messages' });
|
||||
};
|
||||
|
||||
const startNewConversation = async () => {
|
||||
await store.dispatch('conversationList/startNew');
|
||||
router.replace({ name: preChatFormEnabled ? 'prechat-form' : 'messages' });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex flex-col flex-1 overflow-hidden">
|
||||
<div class="flex-1 p-4 overflow-auto pb-40" @scroll="onScroll">
|
||||
<div
|
||||
v-if="isFetching && !conversations.length"
|
||||
class="flex justify-center py-8"
|
||||
role="status"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!conversations.length"
|
||||
class="flex flex-col items-center gap-3 px-6 py-12 text-center"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center size-14 surface-card rounded-full"
|
||||
>
|
||||
<i
|
||||
class="i-lucide-messages-square size-6 text-n-slate-11"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<h2 class="font-medium text-n-slate-12">
|
||||
{{ $t('CONVERSATIONS.EMPTY_TITLE') }}
|
||||
</h2>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('CONVERSATIONS.EMPTY_DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<section v-else :aria-label="$t('CONVERSATIONS.TITLE')">
|
||||
<h2 class="px-1 pb-3 text-sm font-medium text-n-slate-11">
|
||||
{{ $t('CONVERSATIONS.TITLE') }}
|
||||
</h2>
|
||||
<ul class="flex flex-col gap-3">
|
||||
<li
|
||||
v-for="conversation in conversations"
|
||||
:key="conversation.id"
|
||||
class="overflow-hidden surface-card"
|
||||
>
|
||||
<ConversationListItem
|
||||
:conversation="conversation"
|
||||
class="px-4 py-2.5 transition-colors hover:bg-n-alpha-2"
|
||||
@select="openConversation"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
v-if="hasNextPage && isFetching"
|
||||
class="flex justify-center py-4"
|
||||
role="status"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-x-0 z-50 flex justify-center pointer-events-none bottom-20"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-base font-medium rounded-lg pointer-events-auto outline-none shadow-[0_0_24px_10px_rgb(var(--surface-1)/0.9),0_14px_28px_-10px_rgba(50,50,93,0.45),0_4px_10px_-4px_rgba(50,50,93,0.25)] dark:shadow-[0_0_24px_10px_rgb(var(--surface-1)/0.9),0_14px_28px_-10px_rgba(0,0,0,0.8),0_4px_10px_-4px_rgba(0,0,0,0.5)] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-n-slate-7"
|
||||
:style="{
|
||||
backgroundColor: widgetColor,
|
||||
color: getContrastingTextColor(widgetColor),
|
||||
}"
|
||||
@click="startNewConversation"
|
||||
>
|
||||
<span>{{ $t('START_NEW_CONVERSATION') }}</span>
|
||||
<i class="i-lucide-pencil-line size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -20,15 +20,40 @@ export default {
|
||||
availableAgents: 'agent/availableAgents',
|
||||
conversationSize: 'conversation/getConversationSize',
|
||||
unreadMessageCount: 'conversation/getUnreadMessageCount',
|
||||
latestConversation: 'conversationList/getLatestConversation',
|
||||
}),
|
||||
hasConversation() {
|
||||
if (this.hasMultipleConversationsEnabled) {
|
||||
return (
|
||||
!!this.latestConversation &&
|
||||
this.latestConversation.status !== 'resolved'
|
||||
);
|
||||
}
|
||||
return !!this.conversationSize;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
startConversation() {
|
||||
if (this.hasMultipleConversationsEnabled) {
|
||||
return this.hasConversation
|
||||
? this.openConversation(this.latestConversation.id)
|
||||
: this.startNewConversation();
|
||||
}
|
||||
if (this.preChatFormEnabled && !this.conversationSize) {
|
||||
return this.router.replace({ name: 'prechat-form' });
|
||||
}
|
||||
return this.router.replace({ name: 'messages' });
|
||||
},
|
||||
async openConversation(id) {
|
||||
await this.$store.dispatch('conversationList/open', id);
|
||||
this.router.replace({ name: 'messages' });
|
||||
},
|
||||
async startNewConversation() {
|
||||
await this.$store.dispatch('conversationList/startNew');
|
||||
this.router.replace({
|
||||
name: this.preChatFormEnabled ? 'prechat-form' : 'messages',
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -37,7 +62,7 @@ export default {
|
||||
<div class="z-50 flex flex-col justify-end flex-1 w-full p-4 gap-4">
|
||||
<TeamAvailability
|
||||
:available-agents="availableAgents"
|
||||
:has-conversation="!!conversationSize"
|
||||
:has-conversation="hasConversation"
|
||||
:unread-count="unreadMessageCount"
|
||||
@start-conversation="startConversation"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { IFrameHelper } from 'widget/helpers/utils';
|
||||
|
||||
import ChatFooter from '../components/ChatFooter.vue';
|
||||
import ConversationWrap from '../components/ConversationWrap.vue';
|
||||
@@ -9,18 +10,26 @@ export default {
|
||||
computed: {
|
||||
...mapGetters({
|
||||
groupedMessages: 'conversation/getGroupedConversation',
|
||||
isWidgetOpen: 'appConfig/getIsWidgetOpen',
|
||||
}),
|
||||
isVisible() {
|
||||
return this.isWidgetOpen || !IFrameHelper.isIFrame();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('conversation/setUserLastSeen');
|
||||
watch: {
|
||||
// The thread can be on screen before the widget is opened; it is seen once it is visible.
|
||||
isVisible: {
|
||||
immediate: true,
|
||||
handler(isVisible) {
|
||||
if (isVisible) this.$store.dispatch('conversation/setUserLastSeen');
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col flex-1 overflow-hidden rounded-b-lg bg-n-slate-2 dark:bg-n-solid-1"
|
||||
>
|
||||
<div class="flex flex-col flex-1 overflow-hidden rounded-b-lg bg-n-surface-1">
|
||||
<div class="flex flex-1 overflow-auto">
|
||||
<ConversationWrap :grouped-messages="groupedMessages" />
|
||||
</div>
|
||||
|
||||
62
app/javascript/widget/views/specs/Messages.spec.js
Normal file
62
app/javascript/widget/views/specs/Messages.spec.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import { IFrameHelper } from 'widget/helpers/utils';
|
||||
import Messages from '../Messages.vue';
|
||||
|
||||
vi.mock('widget/helpers/utils', async importOriginal => {
|
||||
const utils = await importOriginal();
|
||||
return {
|
||||
...utils,
|
||||
IFrameHelper: { ...utils.IFrameHelper, isIFrame: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
describe('Messages', () => {
|
||||
const setUserLastSeen = vi.fn();
|
||||
const mountWith = isWidgetOpen => {
|
||||
const store = createStore({
|
||||
modules: {
|
||||
appConfig: {
|
||||
namespaced: true,
|
||||
state: { isWidgetOpen },
|
||||
getters: { getIsWidgetOpen: state => state.isWidgetOpen },
|
||||
mutations: {
|
||||
open(state) {
|
||||
state.isWidgetOpen = true;
|
||||
},
|
||||
},
|
||||
},
|
||||
conversation: {
|
||||
namespaced: true,
|
||||
getters: { getGroupedConversation: () => [] },
|
||||
actions: { setUserLastSeen },
|
||||
},
|
||||
},
|
||||
});
|
||||
shallowMount(Messages, { global: { plugins: [store] } });
|
||||
return store;
|
||||
};
|
||||
|
||||
it('marks the conversation as seen only once the widget is opened', async () => {
|
||||
IFrameHelper.isIFrame.mockReturnValue(true);
|
||||
const store = mountWith(false);
|
||||
expect(setUserLastSeen).not.toBeCalled();
|
||||
|
||||
store.commit('appConfig/open');
|
||||
await Promise.resolve();
|
||||
|
||||
expect(setUserLastSeen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks the conversation as seen right away when it is visible', () => {
|
||||
IFrameHelper.isIFrame.mockReturnValue(true);
|
||||
mountWith(true);
|
||||
expect(setUserLastSeen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks the conversation as seen right away outside an iframe', () => {
|
||||
IFrameHelper.isIFrame.mockReturnValue(false);
|
||||
mountWith(false);
|
||||
expect(setUserLastSeen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,12 @@ class Inboxes::SyncWidgetPreChatCustomFieldsJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(account, field_name)
|
||||
account.web_widgets.all.find_each do |web_widget|
|
||||
pre_chat_fields = web_widget.pre_chat_form_options['pre_chat_fields']
|
||||
web_widget.pre_chat_form_options['pre_chat_fields'] = pre_chat_fields.reject { |field| field['name'] == field_name }
|
||||
web_widget.save!
|
||||
[account.web_widgets, account.mobile_app_channels].each do |channels|
|
||||
channels.find_each do |web_widget|
|
||||
pre_chat_fields = web_widget.pre_chat_form_options['pre_chat_fields']
|
||||
web_widget.pre_chat_form_options['pre_chat_fields'] = pre_chat_fields.reject { |field| field['name'] == field_name }
|
||||
web_widget.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,21 +3,23 @@ class Inboxes::UpdateWidgetPreChatCustomFieldsJob < ApplicationJob
|
||||
|
||||
def perform(account, custom_attribute)
|
||||
attribute_key = custom_attribute['attribute_key']
|
||||
account.web_widgets.all.find_each do |web_widget|
|
||||
pre_chat_fields = web_widget.pre_chat_form_options['pre_chat_fields']
|
||||
pre_chat_fields.each_with_index do |pre_chat_field, index|
|
||||
next unless pre_chat_field['name'] == attribute_key
|
||||
[account.web_widgets, account.mobile_app_channels].each do |channels|
|
||||
channels.find_each do |web_widget|
|
||||
pre_chat_fields = web_widget.pre_chat_form_options['pre_chat_fields']
|
||||
pre_chat_fields.each_with_index do |pre_chat_field, index|
|
||||
next unless pre_chat_field['name'] == attribute_key
|
||||
|
||||
web_widget.pre_chat_form_options['pre_chat_fields'][index] =
|
||||
pre_chat_field.deep_merge({
|
||||
'label' => custom_attribute['attribute_display_name'],
|
||||
'placeholder' => custom_attribute['attribute_display_name'],
|
||||
'values' => custom_attribute['attribute_values'],
|
||||
'regex_pattern' => custom_attribute['regex_pattern'],
|
||||
'regex_cue' => custom_attribute['regex_cue']
|
||||
})
|
||||
web_widget.pre_chat_form_options['pre_chat_fields'][index] =
|
||||
pre_chat_field.deep_merge({
|
||||
'label' => custom_attribute['attribute_display_name'],
|
||||
'placeholder' => custom_attribute['attribute_display_name'],
|
||||
'values' => custom_attribute['attribute_values'],
|
||||
'regex_pattern' => custom_attribute['regex_pattern'],
|
||||
'regex_cue' => custom_attribute['regex_cue']
|
||||
})
|
||||
end
|
||||
web_widget.save!
|
||||
end
|
||||
web_widget.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
33
app/jobs/sdk_push/delivery_job.rb
Normal file
33
app/jobs/sdk_push/delivery_job.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
class SdkPush::DeliveryJob < ApplicationJob
|
||||
queue_as :default
|
||||
discard_on ActiveRecord::RecordNotFound
|
||||
retry_on CustomExceptions::SdkPushError, wait: :polynomially_longer, attempts: 5 do |job, error|
|
||||
SdkPushDelivery.find_by(id: job.arguments.first)&.update!(status: 'rejected', reason: error.message)
|
||||
end
|
||||
|
||||
def perform(delivery_id)
|
||||
delivery = SdkPushDelivery.find(delivery_id)
|
||||
delivery.with_lock do
|
||||
return unless delivery.status == 'pending'
|
||||
|
||||
device = delivery.sdk_push_device
|
||||
unless current_customer_session?(device, delivery.message)
|
||||
delivery.update!(status: 'rejected', reason: 'CustomerSessionChanged')
|
||||
return
|
||||
end
|
||||
unless device.push_enabled?
|
||||
delivery.update!(status: 'rejected', reason: 'PushNotificationsDisabled')
|
||||
return
|
||||
end
|
||||
service = device.platform == 'ios' ? SdkPush::ApnsService : SdkPush::FcmService
|
||||
service.new(delivery: delivery).perform
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def current_customer_session?(device, message)
|
||||
device.invalidated_at.nil? && device.contact_inbox.contact_id == device.contact_id &&
|
||||
(!message || message.conversation.contact_id == device.contact_id)
|
||||
end
|
||||
end
|
||||
@@ -52,5 +52,6 @@ class NotificationListener < BaseListener
|
||||
|
||||
Messages::MentionService.new(message: message).perform
|
||||
Messages::NewMessageNotificationService.new(message: message).perform
|
||||
SdkPush::DispatchService.new(message: message).perform
|
||||
end
|
||||
end
|
||||
|
||||
@@ -88,6 +88,7 @@ class Account < ApplicationRecord
|
||||
has_many :tiktok_channels, dependent: :destroy_async, class_name: '::Channel::Tiktok'
|
||||
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
|
||||
has_many :inboxes, dependent: :destroy_async
|
||||
has_many :sdk_apps, dependent: :destroy_async
|
||||
has_many :labels, dependent: :destroy_async
|
||||
has_many :line_channels, dependent: :destroy_async, class_name: '::Channel::Line'
|
||||
has_many :mentions, dependent: :destroy_async
|
||||
@@ -102,6 +103,7 @@ class Account < ApplicationRecord
|
||||
has_many :twilio_sms, dependent: :destroy_async, class_name: '::Channel::TwilioSms'
|
||||
has_many :twitter_profiles, dependent: :destroy_async, class_name: '::Channel::TwitterProfile'
|
||||
has_many :users, through: :account_users
|
||||
has_many :mobile_app_channels, dependent: :destroy_async, class_name: '::Channel::MobileApp'
|
||||
has_many :web_widgets, dependent: :destroy_async, class_name: '::Channel::WebWidget'
|
||||
has_many :webhooks, dependent: :destroy_async
|
||||
has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp'
|
||||
|
||||
@@ -185,8 +185,8 @@ class Attachment < ApplicationRecord
|
||||
|
||||
def should_validate_file?
|
||||
return unless file.attached?
|
||||
# we are only limiting attachment types in case of website widget
|
||||
return unless message.inbox.channel_type == 'Channel::WebWidget'
|
||||
# Limit attachments for customer-facing chat channels.
|
||||
return unless message.inbox.customer_chat?
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
12
app/models/channel/mobile_app.rb
Normal file
12
app/models/channel/mobile_app.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
class Channel::MobileApp < ApplicationRecord
|
||||
include Channelable
|
||||
include FlagShihTzu
|
||||
include CustomerChatChannel
|
||||
|
||||
self.table_name = 'channel_mobile_apps'
|
||||
EDITABLE_ATTRS = CustomerChatChannel::CHAT_ATTRIBUTES
|
||||
|
||||
def name
|
||||
'Mobile app'
|
||||
end
|
||||
end
|
||||
@@ -29,33 +29,14 @@
|
||||
class Channel::WebWidget < ApplicationRecord
|
||||
include Channelable
|
||||
include FlagShihTzu
|
||||
include CustomerChatChannel
|
||||
|
||||
self.table_name = 'channel_web_widgets'
|
||||
EDITABLE_ATTRS = [:website_url, :widget_color, :welcome_title, :welcome_tagline, :reply_time, :pre_chat_form_enabled,
|
||||
:continuity_via_email, :hmac_mandatory, :allowed_domains,
|
||||
{ pre_chat_form_options: [:pre_chat_message, :require_email,
|
||||
{ pre_chat_fields:
|
||||
[:field_type, :label, :placeholder, :name, :enabled, :type, :enabled, :required,
|
||||
:locale, { values: [] }, :regex_pattern, :regex_cue] }] },
|
||||
{ selected_feature_flags: [] }].freeze
|
||||
EDITABLE_ATTRS = (CustomerChatChannel::CHAT_ATTRIBUTES + [:website_url, :continuity_via_email, :allowed_domains]).freeze
|
||||
|
||||
before_validation :validate_pre_chat_options
|
||||
validates :website_url, presence: true
|
||||
validates :widget_color, presence: true
|
||||
has_many :portals, foreign_key: 'channel_web_widget_id', dependent: :nullify, inverse_of: :channel_web_widget
|
||||
|
||||
has_secure_token :website_token
|
||||
has_secure_token :hmac_token
|
||||
|
||||
has_flags 1 => :attachments,
|
||||
2 => :emoji_picker,
|
||||
3 => :end_conversation,
|
||||
4 => :use_inbox_avatar_for_bot,
|
||||
5 => :allow_mobile_webview,
|
||||
:column => 'feature_flags',
|
||||
:check_for_column => false
|
||||
|
||||
enum reply_time: { in_a_few_minutes: 0, in_a_few_hours: 1, in_a_day: 2 }
|
||||
|
||||
def name
|
||||
'Website'
|
||||
@@ -80,30 +61,4 @@ class Channel::WebWidget < ApplicationRecord
|
||||
</script>
|
||||
"
|
||||
end
|
||||
|
||||
def validate_pre_chat_options
|
||||
return if pre_chat_form_options.with_indifferent_access['pre_chat_fields'].present?
|
||||
|
||||
self.pre_chat_form_options = {
|
||||
pre_chat_message: 'Share your queries or comments here.',
|
||||
pre_chat_fields: [
|
||||
{
|
||||
'field_type': 'standard', 'label': 'Email Id', 'name': 'emailAddress', 'type': 'email', 'required': true, 'enabled': false
|
||||
},
|
||||
{
|
||||
'field_type': 'standard', 'label': 'Full name', 'name': 'fullName', 'type': 'text', 'required': false, 'enabled': false
|
||||
},
|
||||
{
|
||||
'field_type': 'standard', 'label': 'Phone number', 'name': 'phoneNumber', 'type': 'text', 'required': false, 'enabled': false
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def create_contact_inbox(additional_attributes = {})
|
||||
::ContactInboxWithContactBuilder.new({
|
||||
inbox: inbox,
|
||||
contact_attributes: { additional_attributes: additional_attributes }
|
||||
}).perform
|
||||
end
|
||||
end
|
||||
|
||||
55
app/models/concerns/customer_chat_channel.rb
Normal file
55
app/models/concerns/customer_chat_channel.rb
Normal file
@@ -0,0 +1,55 @@
|
||||
module CustomerChatChannel
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
CHAT_ATTRIBUTES = [:widget_color, :welcome_title, :welcome_tagline, :reply_time, :pre_chat_form_enabled,
|
||||
:hmac_mandatory,
|
||||
{ pre_chat_form_options: [:pre_chat_message, :require_email,
|
||||
{ pre_chat_fields:
|
||||
[:field_type, :label, :placeholder, :name, :enabled, :type, :enabled, :required,
|
||||
:locale, { values: [] }, :regex_pattern, :regex_cue] }] },
|
||||
{ selected_feature_flags: [] }].freeze
|
||||
|
||||
included do
|
||||
before_validation :validate_pre_chat_options
|
||||
validates :widget_color, presence: true
|
||||
|
||||
has_secure_token :hmac_token
|
||||
|
||||
has_flags 1 => :attachments,
|
||||
2 => :emoji_picker,
|
||||
3 => :end_conversation,
|
||||
4 => :use_inbox_avatar_for_bot,
|
||||
5 => :allow_mobile_webview,
|
||||
6 => :multiple_conversations,
|
||||
:column => 'feature_flags',
|
||||
:check_for_column => false
|
||||
|
||||
enum reply_time: { in_a_few_minutes: 0, in_a_few_hours: 1, in_a_day: 2 }
|
||||
end
|
||||
|
||||
def validate_pre_chat_options
|
||||
return if pre_chat_form_options.with_indifferent_access['pre_chat_fields'].present?
|
||||
|
||||
self.pre_chat_form_options = {
|
||||
pre_chat_message: 'Share your queries or comments here.',
|
||||
pre_chat_fields: [
|
||||
{
|
||||
'field_type': 'standard', 'label': 'Email Id', 'name': 'emailAddress', 'type': 'email', 'required': true, 'enabled': false
|
||||
},
|
||||
{
|
||||
'field_type': 'standard', 'label': 'Full name', 'name': 'fullName', 'type': 'text', 'required': false, 'enabled': false
|
||||
},
|
||||
{
|
||||
'field_type': 'standard', 'label': 'Phone number', 'name': 'phoneNumber', 'type': 'text', 'required': false, 'enabled': false
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def create_contact_inbox(additional_attributes = {})
|
||||
::ContactInboxWithContactBuilder.new({
|
||||
inbox: inbox,
|
||||
contact_attributes: { additional_attributes: additional_attributes }
|
||||
}).perform
|
||||
end
|
||||
end
|
||||
23
app/models/concerns/mobile_app_inbox.rb
Normal file
23
app/models/concerns/mobile_app_inbox.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
module MobileAppInbox
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_one :sdk_app, dependent: :destroy
|
||||
after_create :create_mobile_sdk_app, if: :mobile_app?
|
||||
after_update :update_mobile_sdk_app_name, if: :mobile_app?
|
||||
end
|
||||
|
||||
def mobile_app?
|
||||
channel_type == 'Channel::MobileApp'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_mobile_sdk_app
|
||||
create_sdk_app!(account: account, name: name)
|
||||
end
|
||||
|
||||
def update_mobile_sdk_app_name
|
||||
sdk_app.update!(name: name) if saved_change_to_name?
|
||||
end
|
||||
end
|
||||
@@ -58,6 +58,7 @@ class Contact < ApplicationRecord
|
||||
belongs_to :account
|
||||
has_many :conversations, dependent: :destroy_async
|
||||
has_many :contact_inboxes, dependent: :destroy_async
|
||||
has_many :sdk_push_devices, dependent: :destroy
|
||||
has_many :csat_survey_responses, dependent: :destroy_async
|
||||
has_many :inboxes, through: :contact_inboxes
|
||||
has_many :messages, as: :sender, dependent: :destroy_async
|
||||
|
||||
@@ -32,6 +32,7 @@ class ContactInbox < ApplicationRecord
|
||||
belongs_to :inbox
|
||||
|
||||
has_many :conversations, dependent: :destroy_async
|
||||
has_many :sdk_push_devices, dependent: :destroy
|
||||
|
||||
# contact_inboxes that are not associated with any conversation
|
||||
scope :stale_without_conversations, lambda { |time_period|
|
||||
|
||||
@@ -71,6 +71,7 @@ class Inbox < ApplicationRecord
|
||||
has_many :messages, dependent: :destroy_async
|
||||
has_many :email_templates, dependent: :destroy_async
|
||||
|
||||
include MobileAppInbox
|
||||
has_one :inbox_assignment_policy, dependent: :destroy
|
||||
has_one :assignment_policy, through: :inbox_assignment_policy
|
||||
has_one :agent_bot_inbox, dependent: :destroy_async
|
||||
@@ -143,6 +144,10 @@ class Inbox < ApplicationRecord
|
||||
channel_type == 'Channel::WebWidget'
|
||||
end
|
||||
|
||||
def customer_chat?
|
||||
web_widget? || mobile_app?
|
||||
end
|
||||
|
||||
def api?
|
||||
channel_type == 'Channel::Api'
|
||||
end
|
||||
@@ -274,7 +279,7 @@ class Inbox < ApplicationRecord
|
||||
end
|
||||
|
||||
def check_channel_type?
|
||||
['Channel::Email', 'Channel::Api', 'Channel::WebWidget'].include?(channel_type)
|
||||
['Channel::Email', 'Channel::Api', 'Channel::WebWidget', 'Channel::MobileApp'].include?(channel_type)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ class Message < ApplicationRecord
|
||||
|
||||
scope :created_since, ->(datetime) { where('created_at > ?', datetime) }
|
||||
scope :chat, -> { where.not(message_type: :activity).where(private: false) }
|
||||
scope :not_deleted, -> { where("((messages.content_attributes #>> '{}')::jsonb ->> 'deleted') IS DISTINCT FROM 'true'") }
|
||||
scope :non_activity_messages, -> { where.not(message_type: :activity).reorder('created_at desc') }
|
||||
scope :today, -> { where("date_trunc('day', created_at) = ?", Date.current) }
|
||||
scope :not_forwarded, -> { where("(messages.content_attributes #>> '{}')::jsonb -> 'forwarded_message_id' IS NULL") }
|
||||
@@ -135,6 +136,7 @@ class Message < ApplicationRecord
|
||||
has_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
|
||||
has_one :csat_survey_response, dependent: :destroy_async
|
||||
has_many :notifications, as: :primary_actor, dependent: :destroy_async
|
||||
has_many :sdk_push_deliveries, dependent: :destroy
|
||||
|
||||
after_create_commit :execute_after_create_commit_callbacks
|
||||
|
||||
|
||||
47
app/models/sdk_android_configuration.rb
Normal file
47
app/models/sdk_android_configuration.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
class SdkAndroidConfiguration < ApplicationRecord
|
||||
belongs_to :sdk_app
|
||||
encrypts :service_account
|
||||
|
||||
validates :enabled, inclusion: { in: [true, false] }
|
||||
validates :sdk_app_id, uniqueness: true
|
||||
validates :package_name, :project_id, :service_account, presence: true
|
||||
validates :package_name, format: { with: /\A[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+\z/ }, length: { maximum: 255 }
|
||||
validates :project_id, format: { with: /\A[a-z][a-z0-9-]{4,28}[a-z0-9]\z/ }
|
||||
validates :service_account, length: { maximum: 16_384 }
|
||||
validate :valid_service_account
|
||||
validate :unchanged_destination
|
||||
before_destroy :clear_devices
|
||||
|
||||
private
|
||||
|
||||
def clear_devices
|
||||
sdk_app.sdk_push_devices.where(platform: 'android').destroy_all
|
||||
end
|
||||
|
||||
def unchanged_destination
|
||||
return unless persisted? && (package_name_changed? || project_id_changed?) && sdk_app.sdk_push_devices.exists?(platform: 'android')
|
||||
|
||||
errors.add(:base, 'Remove the Android configuration before changing the package or Firebase project while devices are registered')
|
||||
end
|
||||
|
||||
def matching_service_account?(credentials)
|
||||
return false unless credentials.is_a?(Hash)
|
||||
|
||||
expected = { 'type' => 'service_account', 'project_id' => project_id, 'token_uri' => 'https://oauth2.googleapis.com/token' }
|
||||
expected.all? { |key, value| credentials[key] == value } && credentials['client_email'].to_s.end_with?('.iam.gserviceaccount.com')
|
||||
end
|
||||
|
||||
def valid_service_account
|
||||
return if service_account.blank?
|
||||
|
||||
credentials = JSON.parse(service_account)
|
||||
unless matching_service_account?(credentials)
|
||||
errors.add(:service_account, 'must be a Firebase service account for this project')
|
||||
return
|
||||
end
|
||||
key = OpenSSL::PKey.read(credentials.fetch('private_key'))
|
||||
errors.add(:service_account, 'must contain a private RSA key') unless key.is_a?(OpenSSL::PKey::RSA) && key.private?
|
||||
rescue JSON::ParserError, OpenSSL::PKey::PKeyError, KeyError, ArgumentError, TypeError
|
||||
errors.add(:service_account, 'must be a valid Firebase service account JSON file')
|
||||
end
|
||||
end
|
||||
37
app/models/sdk_app.rb
Normal file
37
app/models/sdk_app.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
class SdkApp < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :inbox
|
||||
has_many :sdk_push_devices, dependent: :destroy
|
||||
|
||||
has_one :ios_configuration, class_name: 'SdkIosConfiguration', dependent: :destroy
|
||||
accepts_nested_attributes_for :ios_configuration, allow_destroy: true
|
||||
|
||||
has_one :android_configuration, class_name: 'SdkAndroidConfiguration', dependent: :destroy
|
||||
accepts_nested_attributes_for :android_configuration, allow_destroy: true
|
||||
|
||||
validates :inbox_id, uniqueness: true
|
||||
validates :name, presence: true, length: { maximum: 100 }
|
||||
before_validation :assign_app_id, on: :create
|
||||
validates :app_id, presence: true, uniqueness: true
|
||||
validate :matching_account
|
||||
before_update :clear_devices, if: :inbox_id_changed?
|
||||
validate :supported_inbox
|
||||
|
||||
private
|
||||
|
||||
def assign_app_id
|
||||
self.app_id ||= SecureRandom.uuid
|
||||
end
|
||||
|
||||
def matching_account
|
||||
errors.add(:inbox, 'must belong to this account') if inbox.account_id != account_id
|
||||
end
|
||||
|
||||
def clear_devices
|
||||
sdk_push_devices.destroy_all
|
||||
end
|
||||
|
||||
def supported_inbox
|
||||
errors.add(:inbox, 'must be a Mobile app inbox') unless inbox.mobile_app?
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user