Compare commits

...

37 Commits

Author SHA1 Message Date
Muhsin Keloth
9c2b10a63f Merge branch 'feat/CW-8283' into codex/website-inbox-mobile-push 2026-10-01 17:41:41 +04:00
Sivin Varghese
b682f3979e Merge branch 'develop' into feat/CW-8283 2026-09-30 15:42:34 -07:00
Muhsin
f78e5648b0 style(inboxes): use modifier condition for sdk app id 2026-09-30 12:43:32 +04:00
Muhsin
a5c99d37a6 chore(inboxes): merge latest multiple conversation changes 2026-09-30 12:29:39 +04:00
Muhsin
a6960a4f7d chore(db): exclude unrelated schema dump formatting 2026-09-30 12:20:42 +04:00
Muhsin
e577bb2205 feat(inboxes): move native sdk setup into a mobile app channel 2026-09-30 12:19:08 +04:00
Muhsin Keloth
903997f49c Merge branch 'develop' into feat/CW-8283 2026-09-30 11:52:11 +04:00
iamsivin
9e6ff8d815 chore: Review fix 2026-09-29 15:06:13 -07:00
iamsivin
42336dd2bf chore: Review fix 2026-09-29 14:56:12 -07:00
Sivin Varghese
a112f59c3c Merge branch 'develop' into feat/CW-8283 2026-09-29 14:29:17 -07:00
Muhsin Keloth
b27d7dcec1 Merge branch 'develop' into feat/CW-8283 2026-09-29 20:56:29 +04:00
Muhsin
b713952dfe refactor(sdks): use rails associations for deletion cleanup 2026-09-29 16:34:12 +04:00
Muhsin Keloth
9a2c215b22 Merge branch 'feat/CW-8283' into codex/website-inbox-mobile-push 2026-09-29 12:04:38 +04:00
Sivin Varghese
72b9894de0 Merge branch 'develop' into feat/CW-8283 2026-09-28 17:02:43 -07:00
Sivin Varghese
9263dfb57e Merge branch 'develop' into feat/CW-8283 2026-09-28 17:01:24 -07:00
iamsivin
ffdbabaaa1 chore: Review fix 2026-09-28 12:47:15 -07:00
iamsivin
fba5bdf6c2 chore: Review fix 2026-09-28 12:46:57 -07:00
Sivin Varghese
b91570449b Merge branch 'develop' into feat/CW-8283 2026-09-28 11:21:33 -07:00
Muhsin
6f88206393 fix(sdks): allow saving push notification settings 2026-09-28 20:16:33 +04:00
Muhsin
f1fdac30d5 refactor(sdks): simplify integration setup and app listing 2026-09-26 13:56:03 +04:00
Muhsin
05a1c3f54c feat(sdks): pause push notifications without removing configuration 2026-09-26 13:32:01 +04:00
Muhsin
8f129d7a3f feat(sdks): deliver android support notifications through fcm 2026-09-26 13:04:20 +04:00
Muhsin
4daf9bcfe2 fix(sdks): reconcile updated widget conversation behavior 2026-09-26 11:55:43 +04:00
Muhsin Keloth
36be62d1af Merge branch 'develop' into feat/CW-8283 2026-09-26 11:49:31 +04:00
iamsivin
1ab20f1179 chore: Review fix 2026-09-25 14:32:39 -07:00
Sivin Varghese
7a9a9e95e6 Merge branch 'develop' into feat/CW-8283 2026-09-25 13:31:28 -07:00
iamsivin
3604101150 chore: Review fix 2026-09-25 13:30:45 -07:00
Sivin Varghese
80901ddaf9 Merge branch 'develop' into feat/CW-8283 2026-09-25 12:27:55 -07:00
Muhsin
e99722e2c4 refactor(sdks): rename mobile push resources to sdk push 2026-09-25 17:48:07 +04:00
Muhsin
da15b3ad55 fix(sdks): accept numeric widget conversation parameters 2026-09-25 17:33:08 +04:00
Muhsin
b8f1089337 refactor(sdks): share widget conversation listing for website inboxes 2026-09-25 17:29:20 +04:00
Muhsin
4b078b1919 feat(sdks): manage sdk apps through integrations 2026-09-25 17:28:36 +04:00
Muhsin
b4170a16e0 feat(inboxes): enable ios push and conversation history for website inboxes 2026-09-25 17:28:16 +04:00
iamsivin
829653b478 chore: Review fix 2026-09-25 00:43:38 -07:00
Sivin Varghese
59c857047f Merge branch 'develop' into feat/CW-8283 2026-09-24 23:08:58 -07:00
Sivin Varghese
625c82326c Merge branch 'develop' into feat/CW-8283 2026-09-24 12:42:41 -07:00
iamsivin
e54f72e252 feat: support multiple conversations in the widget 2026-09-24 12:32:04 -07:00
131 changed files with 4209 additions and 362 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View 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

View File

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

View File

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

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

View File

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

View File

@@ -59,6 +59,7 @@ const isActive = computed(() => {
}
return [
'mobile_app',
'website',
'twilio',
'api',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ const state = {
isFetchingList: false,
isAgentTyping: false,
isCreating: false,
isAwaitingCampaign: false,
},
lastMessageId: null,
pendingCustomAttributes: {},

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View 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

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

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