Compare commits

..
147 changed files with 987 additions and 2321 deletions
+1 -7
View File
@@ -44,12 +44,6 @@ force_run:
rm -f tmp/pids/*.pid rm -f tmp/pids/*.pid
overmind start -f Procfile.dev overmind start -f Procfile.dev
force_run_tunnel:
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.tunnel
debug: debug:
overmind connect backend overmind connect backend
@@ -59,4 +53,4 @@ debug_worker:
docker: docker:
docker build -t $(APP_NAME) -f ./docker/Dockerfile . docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker .PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker
-4
View File
@@ -1,4 +0,0 @@
backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
vite: bin/vite build --watch
@@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter] before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes] before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update] before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update]
def index def index
@contacts_count = resolved_contacts.count @contacts_count = resolved_contacts.count
@@ -56,7 +56,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
.get_available_contact_ids(Current.account.id)) .get_available_contact_ids(Current.account.id))
@contacts_count = contacts.count @contacts_count = contacts.count
@contacts = fetch_contacts(contacts) @contacts = contacts.page(@current_page)
end end
def show; end def show; end
@@ -124,12 +124,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.save! @conversation.save!
end end
def destroy
authorize @conversation, :destroy?
::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
head :ok
end
private private
def permitted_update_params def permitted_update_params
@@ -1,32 +1,23 @@
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include GoogleConcern include GoogleConcern
before_action :check_authorization
def create def create
email = params[:authorization][:email]
redirect_url = google_client.auth_code.authorize_url( redirect_url = google_client.auth_code.authorize_url(
{ {
redirect_uri: "#{base_url}/google/callback", redirect_uri: "#{base_url}/google/callback",
scope: 'email profile https://mail.google.com/', scope: scope,
response_type: 'code', response_type: 'code',
prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it
access_type: 'offline', # the default is 'online' access_type: 'offline', # the default is 'online'
state: state,
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil) client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
} }
) )
if redirect_url if redirect_url
cache_key = "google::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url } render json: { success: true, url: redirect_url }
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -1,7 +1,6 @@
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include InstagramConcern include InstagramConcern
include Instagram::IntegrationHelper include Instagram::IntegrationHelper
before_action :check_authorization
def create def create
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization
@@ -21,10 +20,4 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -1,28 +1,19 @@
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include MicrosoftConcern include MicrosoftConcern
before_action :check_authorization
def create def create
email = params[:authorization][:email]
redirect_url = microsoft_client.auth_code.authorize_url( redirect_url = microsoft_client.auth_code.authorize_url(
{ {
redirect_uri: "#{base_url}/microsoft/callback", redirect_uri: "#{base_url}/microsoft/callback",
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile', scope: scope,
state: state,
prompt: 'consent' prompt: 'consent'
} }
) )
if redirect_url if redirect_url
cache_key = "microsoft::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url } render json: { success: true, url: redirect_url }
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -0,0 +1,23 @@
class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseController
before_action :check_authorization
protected
def scope
''
end
def state
Current.account.to_sgid(expires_in: 15.minutes).to_s
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -92,7 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
end end
def settings_params def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label) params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting)
end end
def check_signup_enabled def check_signup_enabled
+2 -2
View File
@@ -14,7 +14,7 @@ module GoogleConcern
private private
def base_url def scope
ENV.fetch('FRONTEND_URL', 'http://localhost:3000') 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email https://mail.google.com/'
end end
end end
@@ -15,7 +15,7 @@ module MicrosoftConcern
private private
def base_url def scope
ENV.fetch('FRONTEND_URL', 'http://localhost:3000') 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile'
end end
end end
@@ -12,6 +12,16 @@ class Google::CallbacksController < OauthCallbackController
private private
def verify_scopes
granted_scopes = parsed_body['scope']&.split || []
required_scopes = scope.split
missing_scopes = required_scopes - granted_scopes
return if missing_scopes.empty?
raise StandardError, I18n.t('errors.oauth.insufficient_scopes', scopes: missing_scopes.join(', '))
end
def provider_name def provider_name
'google' 'google'
end end
@@ -24,4 +34,13 @@ class Google::CallbacksController < OauthCallbackController
# from GoogleConcern # from GoogleConcern
google_client google_client
end end
def handle_error(exception)
ChatwootExceptionTracker.new(exception).capture_exception
error_message = exception.message || I18n.t('errors.oauth.authorization_failed')
redirect_url = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/inboxes/new/email"
redirect_to "#{redirect_url}?error=#{CGI.escape(error_message)}"
end
end end
+18 -9
View File
@@ -5,11 +5,10 @@ class OauthCallbackController < ApplicationController
redirect_uri: "#{base_url}/#{provider_name}/callback" redirect_uri: "#{base_url}/#{provider_name}/callback"
) )
verify_scopes
handle_response handle_response
::Redis::Alfred.delete(cache_key)
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception handle_error(e)
redirect_to '/'
end end
private private
@@ -64,14 +63,22 @@ class OauthCallbackController < ApplicationController
raise NotImplementedError raise NotImplementedError
end end
def cache_key def verify_scopes
"#{provider_name}::#{users_data['email'].downcase}" true
end
def failure_redirect_url
'/'
end
def handle_error(exception)
ChatwootExceptionTracker.new(exception).capture_exception
redirect_to failure_redirect_url
end end
def create_channel_with_inbox def create_channel_with_inbox
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account) channel_email = Channel::Email.create!(email: users_data['email'], account: account)
account.inboxes.create!( account.inboxes.create!(
account: account, account: account,
channel: channel_email, channel: channel_email,
@@ -86,12 +93,14 @@ class OauthCallbackController < ApplicationController
decoded_token[0] decoded_token[0]
end end
def account_id def account_from_signed_id
::Redis::Alfred.get(cache_key) raise 'Missing state variable' if params[:state].blank?
GlobalID::Locator.locate_signed(params[:state])
end end
def account def account
@account ||= Account.find(account_id) @account ||= account_from_signed_id
end end
# Fallback name, for when name field is missing from users_data # Fallback name, for when name field is missing from users_data
@@ -2,13 +2,6 @@ class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior # Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated. # For example, you may want to send an email after a foo is updated.
# #
# Since account/user page - account user role attribute links to the show page
# Handle with a redirect to the user show page
def show
redirect_to super_admin_user_path(requested_resource.user)
end
def create def create
resource = resource_class.new(resource_params) resource = resource_class.new(resource_params)
authorize_resource(resource) authorize_resource(resource)
-5
View File
@@ -61,11 +61,6 @@ class ContactAPI extends ApiClient {
return axios.get(requestURL); return axios.get(requestURL);
} }
active(page = 1, sortAttr = 'name') {
let requestURL = `${this.url}/active?${buildContactParams(page, sortAttr)}`;
return axios.get(requestURL);
}
// eslint-disable-next-line default-param-last // eslint-disable-next-line default-param-last
filter(page = 1, sortAttr = 'name', queryPayload) { filter(page = 1, sortAttr = 'name', queryPayload) {
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`; let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;
@@ -134,12 +134,12 @@ class ConversationApi extends ApiClient {
return axios.get(`${this.url}/${conversationId}/attachments`); return axios.get(`${this.url}/${conversationId}/attachments`);
} }
getInboxAssistant(conversationId) { requestCopilot(conversationId, body) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`); return axios.post(`${this.url}/${conversationId}/copilot`, body);
} }
delete(conversationId) { getInboxAssistant(conversationId) {
return axios.delete(`${this.url}/${conversationId}`); return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
} }
} }
@@ -17,7 +17,6 @@ const props = defineProps({
additionalAttributes: { type: Object, default: () => ({}) }, additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' }, phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' }, thumbnail: { type: String, default: '' },
availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false }, isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false }, isUpdating: { type: Boolean, default: false },
}); });
@@ -93,13 +92,7 @@ const onClickViewDetails = () => emit('showContact', props.id);
<template> <template>
<CardLayout :key="id" layout="row"> <CardLayout :key="id" layout="row">
<div class="flex items-center justify-start flex-1 gap-4"> <div class="flex items-center justify-start flex-1 gap-4">
<Avatar <Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
:name="name"
:src="thumbnail"
:size="48"
:status="availabilityStatus"
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1"> <div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1"> <div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12"> <span class="text-base font-medium truncate text-n-slate-12">
@@ -7,16 +7,42 @@ import ContactMoreActions from './components/ContactMoreActions.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue'; import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
defineProps({ defineProps({
showSearch: { type: Boolean, default: true }, showSearch: {
searchValue: { type: String, default: '' }, type: Boolean,
headerTitle: { type: String, required: true }, default: true,
buttonLabel: { type: String, default: '' }, },
activeSort: { type: String, default: 'last_activity_at' }, searchValue: {
activeOrdering: { type: String, default: '' }, type: String,
isSegmentsView: { type: Boolean, default: false }, default: '',
hasActiveFilters: { type: Boolean, default: false }, },
isLabelView: { type: Boolean, default: false }, headerTitle: {
isActiveView: { type: Boolean, default: false }, type: String,
required: true,
},
buttonLabel: {
type: String,
default: '',
},
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
isSegmentsView: {
type: Boolean,
default: false,
},
hasActiveFilters: {
type: Boolean,
default: false,
},
isLabelView: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits([ const emit = defineEmits([
@@ -59,7 +85,7 @@ const emit = defineEmits([
</Input> </Input>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div v-if="!isLabelView && !isActiveView" class="relative"> <div v-if="!isLabelView" class="relative">
<Button <Button
id="toggleContactsFilterButton" id="toggleContactsFilterButton"
:icon=" :icon="
@@ -79,12 +105,7 @@ const emit = defineEmits([
<slot name="filter" /> <slot name="filter" />
</div> </div>
<Button <Button
v-if=" v-if="hasActiveFilters && !isSegmentsView && !isLabelView"
hasActiveFilters &&
!isSegmentsView &&
!isLabelView &&
!isActiveView
"
icon="i-lucide-save" icon="i-lucide-save"
color="slate" color="slate"
size="sm" size="sm"
@@ -92,7 +113,7 @@ const emit = defineEmits([
@click="emit('createSegment')" @click="emit('createSegment')"
/> />
<Button <Button
v-if="isSegmentsView && !isLabelView && !isActiveView" v-if="isSegmentsView && !isLabelView"
icon="i-lucide-trash" icon="i-lucide-trash"
color="slate" color="slate"
size="sm" size="sm"
@@ -36,7 +36,6 @@ const props = defineProps({
activeSegment: { type: Object, default: null }, activeSegment: { type: Object, default: null },
hasAppliedFilters: { type: Boolean, default: false }, hasAppliedFilters: { type: Boolean, default: false },
isLabelView: { type: Boolean, default: false }, isLabelView: { type: Boolean, default: false },
isActiveView: { type: Boolean, default: false },
}); });
const emit = defineEmits([ const emit = defineEmits([
@@ -278,7 +277,6 @@ defineExpose({
:header-title="headerTitle" :header-title="headerTitle"
:is-segments-view="hasActiveSegments" :is-segments-view="hasActiveSegments"
:is-label-view="isLabelView" :is-label-view="isLabelView"
:is-active-view="isActiveView"
:has-active-filters="hasAppliedFilters" :has-active-filters="hasAppliedFilters"
:button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')" :button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
@search="emit('search', $event)" @search="emit('search', $event)"
@@ -6,7 +6,7 @@ import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/Contact
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue'; import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue'; import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
const props = defineProps({ defineProps({
searchValue: { type: String, default: '' }, searchValue: { type: String, default: '' },
headerTitle: { type: String, default: '' }, headerTitle: { type: String, default: '' },
showPaginationFooter: { type: Boolean, default: true }, showPaginationFooter: { type: Boolean, default: true },
@@ -37,23 +37,10 @@ const isNotSegmentView = computed(() => {
return route.name !== 'contacts_dashboard_segments_index'; return route.name !== 'contacts_dashboard_segments_index';
}); });
const isActiveView = computed(() => {
return route.name === 'contacts_dashboard_active';
});
const isLabelView = computed( const isLabelView = computed(
() => route.name === 'contacts_dashboard_labels_index' () => route.name === 'contacts_dashboard_labels_index'
); );
const showActiveFiltersPreview = computed(() => {
return (
(props.hasAppliedFilters || !isNotSegmentView.value) &&
!props.isFetchingList &&
!isLabelView.value &&
!isActiveView.value
);
});
const updateCurrentPage = page => { const updateCurrentPage = page => {
emit('update:currentPage', page); emit('update:currentPage', page);
}; };
@@ -70,7 +57,7 @@ const openFilter = () => {
<div class="flex flex-col w-full h-full transition-all duration-300"> <div class="flex flex-col w-full h-full transition-all duration-300">
<ContactListHeaderWrapper <ContactListHeaderWrapper
ref="contactListHeaderWrapper" ref="contactListHeaderWrapper"
:show-search="isNotSegmentView && !isActiveView" :show-search="isNotSegmentView"
:search-value="searchValue" :search-value="searchValue"
:active-sort="activeSort" :active-sort="activeSort"
:active-ordering="activeOrdering" :active-ordering="activeOrdering"
@@ -79,7 +66,6 @@ const openFilter = () => {
:segments-id="segmentsId" :segments-id="segmentsId"
:has-applied-filters="hasAppliedFilters" :has-applied-filters="hasAppliedFilters"
:is-label-view="isLabelView" :is-label-view="isLabelView"
:is-active-view="isActiveView"
@update:sort="emit('update:sort', $event)" @update:sort="emit('update:sort', $event)"
@search="emit('search', $event)" @search="emit('search', $event)"
@apply-filter="emit('applyFilter', $event)" @apply-filter="emit('applyFilter', $event)"
@@ -88,7 +74,11 @@ const openFilter = () => {
<main class="flex-1 overflow-y-auto"> <main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]"> <div class="w-full mx-auto max-w-[60rem]">
<ContactsActiveFiltersPreview <ContactsActiveFiltersPreview
v-if="showActiveFiltersPreview" v-if="
(hasAppliedFilters || !isNotSegmentView) &&
!isFetchingList &&
!isLabelView
"
:active-segment="activeSegment" :active-segment="activeSegment"
@clear-filters="emit('clearFilters')" @clear-filters="emit('clearFilters')"
@open-filter="openFilter" @open-filter="openFilter"
@@ -71,7 +71,6 @@ const toggleExpanded = id => {
:thumbnail="contact.thumbnail" :thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber" :phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes" :additional-attributes="contact.additionalAttributes"
:availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id" :is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating" :is-updating="isUpdating"
@toggle="toggleExpanded(contact.id)" @toggle="toggleExpanded(contact.id)"
@@ -56,7 +56,7 @@ useKeyboardEvents(keyboardEvents);
<template> <template>
<div <div
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1" class="flex flex-col justify-center items-center absolute top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
> >
<Button <Button
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')" v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
@@ -4,14 +4,38 @@ import { computed, ref, watch, useSlots } from 'vue';
import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const props = defineProps({ const props = defineProps({
modelValue: { type: String, default: '' }, modelValue: {
label: { type: String, default: '' }, type: String,
placeholder: { type: String, default: '' }, default: '',
focusOnMount: { type: Boolean, default: false }, },
maxLength: { type: Number, default: 200 }, label: {
showCharacterCount: { type: Boolean, default: true }, type: String,
disabled: { type: Boolean, default: false }, default: '',
message: { type: String, default: '' }, },
placeholder: {
type: String,
default: '',
},
focusOnMount: {
type: Boolean,
default: false,
},
maxLength: {
type: Number,
default: 200,
},
showCharacterCount: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
message: {
type: String,
default: '',
},
messageType: { messageType: {
type: String, type: String,
default: 'info', default: 'info',
@@ -19,7 +43,6 @@ const props = defineProps({
}, },
enableVariables: { type: Boolean, default: false }, enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true }, enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
@@ -97,7 +120,6 @@ watch(
:disabled="disabled" :disabled="disabled"
:enable-variables="enableVariables" :enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses" :enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
@input="handleInput" @input="handleInput"
@focus="handleFocus" @focus="handleFocus"
@blur="handleBlur" @blur="handleBlur"
@@ -76,7 +76,7 @@ const handlePageChange = event => {
<template> <template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background"> <section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6"> <header class="sticky top-0 z-10 px-6 xl:px-0">
<div class="w-full max-w-[60rem] mx-auto"> <div class="w-full max-w-[60rem] mx-auto">
<div <div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row" class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
@@ -116,7 +116,7 @@ const handlePageChange = event => {
</div> </div>
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto"> <main class="flex-1 px-6 overflow-y-auto xl:px-0">
<div class="w-full max-w-[60rem] h-full mx-auto py-4"> <div class="w-full max-w-[60rem] h-full mx-auto py-4">
<slot v-if="!showPaywall" name="controls" /> <slot v-if="!showPaywall" name="controls" />
<div <div
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { nextTick, ref, watch, computed } from 'vue'; import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables'; import { useTrack } from 'dashboard/composables';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -8,17 +9,23 @@ import CopilotInput from './CopilotInput.vue';
import CopilotLoader from './CopilotLoader.vue'; import CopilotLoader from './CopilotLoader.vue';
import CopilotAgentMessage from './CopilotAgentMessage.vue'; import CopilotAgentMessage from './CopilotAgentMessage.vue';
import CopilotAssistantMessage from './CopilotAssistantMessage.vue'; import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue'; import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
import CopilotEmptyState from './CopilotEmptyState.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue';
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue'; import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({ const props = defineProps({
supportAgent: {
type: Object,
default: () => ({}),
},
messages: { messages: {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
isCaptainTyping: {
type: Boolean,
default: false,
},
conversationInboxType: { conversationInboxType: {
type: String, type: String,
required: true, required: true,
@@ -37,11 +44,18 @@ const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']);
const { t } = useI18n(); const { t } = useI18n();
const COPILOT_USER_ROLES = ['assistant', 'system'];
const sendMessage = message => { const sendMessage = message => {
emit('sendMessage', message); emit('sendMessage', message);
useTrack(COPILOT_EVENTS.SEND_MESSAGE); useTrack(COPILOT_EVENTS.SEND_MESSAGE);
}; };
const useSuggestion = opt => {
emit('sendMessage', t(opt.prompt));
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
};
const chatContainer = ref(null); const chatContainer = ref(null);
const scrollToBottom = async () => { const scrollToBottom = async () => {
@@ -51,40 +65,20 @@ const scrollToBottom = async () => {
} }
}; };
const groupedMessages = computed(() => { const promptOptions = [
const result = []; {
let thinkingGroup = []; label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
props.messages.forEach(message => { prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
if (message.message_type === 'assistant_thinking') { },
thinkingGroup.push(message); {
} else { label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
if (thinkingGroup.length > 0) { prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
result.push({ },
id: thinkingGroup[0].id, {
message_type: 'thinking_group', label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
messages: thinkingGroup, prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
}); },
thinkingGroup = []; ];
}
result.push(message);
}
});
if (thinkingGroup.length > 0) {
result.push({
id: thinkingGroup[0].id,
message_type: 'thinking_group',
messages: thinkingGroup,
});
}
return result;
});
const isLastMessageFromAssistant = computed(() => {
return (
groupedMessages.value[groupedMessages.value.length - 1].message_type ===
'assistant'
);
});
const { updateUISettings } = useUISettings(); const { updateUISettings } = useUISettings();
@@ -101,22 +95,8 @@ const handleSidebarAction = action => {
} }
}; };
const hasAssistants = computed(() => props.assistants.length > 0);
const hasMessages = computed(() => props.messages.length > 0);
const copilotButtons = computed(() => {
if (hasMessages.value) {
return [
{
key: 'reset',
icon: 'i-lucide-refresh-ccw',
tooltip: t('CAPTAIN.COPILOT.RESET'),
},
];
}
return [];
});
watch( watch(
[() => props.messages], [() => props.messages, () => props.isCaptainTyping],
() => { () => {
scrollToBottom(); scrollToBottom();
}, },
@@ -128,57 +108,64 @@ watch(
<div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full"> <div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full">
<SidebarActionsHeader <SidebarActionsHeader
:title="$t('CAPTAIN.COPILOT.TITLE')" :title="$t('CAPTAIN.COPILOT.TITLE')"
:buttons="copilotButtons" :buttons="[
{
key: 'reset',
icon: 'i-lucide-refresh-ccw',
tooltip: $t('CAPTAIN.COPILOT.RESET'),
},
]"
@click="handleSidebarAction" @click="handleSidebarAction"
@close="closeCopilotPanel" @close="closeCopilotPanel"
/> />
<div <div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
ref="chatContainer" <template v-for="message in messages" :key="message.id">
class="flex-1 flex px-4 py-4 overflow-y-auto items-start" <CopilotAgentMessage
> v-if="message.role === 'user'"
<div v-if="hasMessages" class="space-y-6 flex-1 flex flex-col w-full"> :support-agent="supportAgent"
<template v-for="(item, index) in groupedMessages" :key="item.id"> :message="message"
<CopilotAgentMessage />
v-if="item.message_type === 'user'" <CopilotAssistantMessage
:message="item.message" v-else-if="COPILOT_USER_ROLES.includes(message.role)"
/> :message="message"
<CopilotAssistantMessage :conversation-inbox-type="conversationInboxType"
v-else-if="item.message_type === 'assistant'" />
:message="item.message" </template>
:is-last-message="index === groupedMessages.length - 1"
:conversation-inbox-type="conversationInboxType"
/>
<CopilotThinkingGroup
v-else
:messages="item.messages"
:default-collapsed="isLastMessageFromAssistant"
/>
</template>
<CopilotLoader v-if="!isLastMessageFromAssistant" /> <CopilotLoader v-if="isCaptainTyping" />
</div>
<div
v-if="!messages.length"
class="h-full w-full flex items-center justify-center"
>
<div class="h-fit px-3 py-3 space-y-1">
<span class="text-xs text-n-slate-10">
{{ $t('COPILOT.TRY_THESE_PROMPTS') }}
</span>
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
@click="() => useSuggestion(prompt)"
>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</div> </div>
<CopilotEmptyState
v-else
:has-assistants="hasAssistants"
@use-suggestion="sendMessage"
/>
</div> </div>
<div class="mx-3 mt-px mb-2"> <div class="mx-3 mt-px mb-2">
<div class="flex items-center gap-2 justify-between w-full mb-1"> <div class="flex items-center gap-2 justify-between w-full mb-1">
<ToggleCopilotAssistant <ToggleCopilotAssistant
v-if="assistants.length > 1" v-if="assistants.length"
:assistants="assistants" :assistants="assistants"
:active-assistant="activeAssistant" :active-assistant="activeAssistant"
@set-assistant="$event => emit('setAssistant', $event)" @set-assistant="$event => emit('setAssistant', $event)"
/> />
<div v-else /> <div v-else />
</div> </div>
<CopilotInput <CopilotInput class="mb-1 w-full" @send="sendMessage" />
v-if="hasAssistants"
class="mb-1 w-full"
@send="sendMessage"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -11,10 +11,6 @@ import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({ const props = defineProps({
isLastMessage: {
type: Boolean,
default: false,
},
message: { message: {
type: Object, type: Object,
required: true, required: true,
@@ -24,15 +20,6 @@ const props = defineProps({
required: true, required: true,
}, },
}); });
const hasEmptyMessageContent = computed(() => !props.message?.content);
const showUseButton = computed(() => {
return (
!hasEmptyMessageContent.value &&
props.message.reply_suggestion &&
props.isLastMessage
);
});
const messageContent = computed(() => { const messageContent = computed(() => {
const formatter = new MessageFormatter(props.message.content); const formatter = new MessageFormatter(props.message.content);
@@ -45,6 +32,8 @@ const insertIntoRichEditor = computed(() => {
); );
}); });
const hasEmptyMessageContent = computed(() => !props.message?.content);
const useCopilotResponse = () => { const useCopilotResponse = () => {
if (insertIntoRichEditor.value) { if (insertIntoRichEditor.value) {
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content); emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
@@ -68,7 +57,7 @@ const useCopilotResponse = () => {
/> />
<div class="flex flex-row mt-1"> <div class="flex flex-row mt-1">
<Button <Button
v-if="showUseButton" v-if="!hasEmptyMessageContent"
:label="$t('CAPTAIN.COPILOT.USE')" :label="$t('CAPTAIN.COPILOT.USE')"
faded faded
sm sm
@@ -1,106 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Icon from '../icon/Icon.vue';
defineProps({
hasAssistants: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['useSuggestion']);
const { t } = useI18n();
const route = useRoute();
const routePromptMap = {
conversations: [
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
},
],
dashboard: [
{
label: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.CONTENT',
},
],
};
const getCurrentRoute = () => {
const path = route.path;
if (path.includes('/conversations')) return 'conversations';
if (path.includes('/dashboard')) return 'dashboard';
return 'dashboard';
};
const promptOptions = computed(() => {
const currentRoute = getCurrentRoute();
return routePromptMap[currentRoute] || routePromptMap.conversations;
});
const handleSuggestion = opt => {
emit('useSuggestion', t(opt.prompt));
};
</script>
<template>
<div class="flex-1 flex flex-col gap-6 px-2">
<div class="flex flex-col space-y-4 py-4">
<Icon icon="i-woot-captain" class="text-n-slate-9 text-4xl" />
<div class="space-y-1">
<h3 class="text-base font-medium text-n-slate-12 leading-8">
{{ $t('CAPTAIN.COPILOT.PANEL_TITLE') }}
</h3>
<p class="text-sm text-n-slate-11 leading-6">
{{ $t('CAPTAIN.COPILOT.KICK_OFF_MESSAGE') }}
</p>
</div>
</div>
<div v-if="!hasAssistants" class="w-full space-y-2">
<p class="text-sm text-n-slate-11 leading-6">
{{ $t('CAPTAIN.ASSISTANTS.NO_ASSISTANTS_AVAILABLE') }}
</p>
<router-link
:to="{
name: 'captain_assistants_index',
params: { accountId: route.params.accountId },
}"
class="text-n-slate-11 underline hover:text-n-slate-12"
>
{{ $t('CAPTAIN.ASSISTANTS.ADD_NEW') }}
</router-link>
</div>
<div v-else class="w-full space-y-2">
<span class="text-xs text-n-slate-10 block">
{{ $t('CAPTAIN.COPILOT.TRY_THESE_PROMPTS') }}
</span>
<div class="space-y-1">
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="w-full px-3 py-2 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center justify-between hover:bg-n-slate-3 transition-colors"
@click="handleSuggestion(prompt)"
>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</div>
</div>
</div>
</template>
@@ -1,62 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const isConversationRoute = computed(() => {
const CONVERSATION_ROUTES = [
'inbox_conversation',
'conversation_through_inbox',
'conversations_through_label',
'team_conversations_through_label',
'conversations_through_folders',
'conversation_through_mentions',
'conversation_through_unattended',
'conversation_through_participating',
];
return CONVERSATION_ROUTES.includes(route.name);
});
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotLauncher = computed(() => {
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
);
return (
isCaptainEnabled &&
!uiSettings.value.is_copilot_panel_open &&
!isConversationRoute.value
);
});
const toggleSidebar = () => {
updateUISettings({
is_copilot_panel_open: !uiSettings.value.is_copilot_panel_open,
is_contact_sidebar_open: false,
});
};
</script>
<template>
<div v-if="showCopilotLauncher" class="fixed bottom-4 right-4 z-50">
<div class="rounded-full bg-n-alpha-2 p-1">
<Button
icon="i-woot-captain"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
lg
@click="toggleSidebar"
/>
</div>
</div>
<template v-else />
</template>
@@ -17,7 +17,7 @@ defineProps({
icon="i-lucide-sparkles" icon="i-lucide-sparkles"
class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9" class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9"
/> />
<div class="text-sm text-n-slate-12"> <div class="text-sm text-n-slate-11">
{{ content }} {{ content }}
</div> </div>
</div> </div>
@@ -51,10 +51,10 @@ watch(
}" }"
> >
<CopilotThinkingBlock <CopilotThinkingBlock
v-for="copilotMessage in messages" v-for="message in messages"
:key="copilotMessage.id" :key="message.id"
:content="copilotMessage.message.content" :content="message.content"
:reasoning="copilotMessage.message.reasoning" :reasoning="message.reasoning"
/> />
</div> </div>
</div> </div>
@@ -74,7 +74,6 @@ const updateSelected = newValue => {
<slot name="trigger" :toggle="toggle"> <slot name="trigger" :toggle="toggle">
<Button <Button
ref="triggerRef" ref="triggerRef"
type="button"
sm sm
slate slate
:variant :variant
@@ -9,14 +9,7 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue'; import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue'; import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { const { options } = defineProps({
options,
disableSearch,
placeholderIcon,
placeholder,
placeholderTrailingIcon,
searchPlaceholder,
} = defineProps({
options: { options: {
type: Array, type: Array,
required: true, required: true,
@@ -25,22 +18,6 @@ const {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
placeholderIcon: {
type: String,
default: 'i-lucide-plus',
},
placeholder: {
type: String,
default: '',
},
placeholderTrailingIcon: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
}); });
const { t } = useI18n(); const { t } = useI18n();
@@ -92,26 +69,15 @@ const toggleSelected = option => {
sm sm
slate slate
faded faded
type="button"
:icon="selectedItem.icon" :icon="selectedItem.icon"
:label="selectedItem.name" :label="selectedItem.name"
@click="toggle" @click="toggle"
/> />
<Button <Button v-else sm slate faded @click="toggle">
v-else
sm
slate
faded
type="button"
:trailing-icon="placeholderTrailingIcon"
@click="toggle"
>
<template #icon> <template #icon>
<Icon :icon="placeholderIcon" class="text-n-slate-11" /> <Icon icon="i-lucide-plus" class="text-n-slate-11" />
</template> </template>
<span class="text-n-slate-11">{{ <span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
placeholder || t('COMBOBOX.PLACEHOLDER')
}}</span>
</Button> </Button>
</template> </template>
<DropdownBody class="top-0 min-w-56 z-50" strong> <DropdownBody class="top-0 min-w-56 z-50" strong>
@@ -121,7 +87,7 @@ const toggleSelected = option => {
v-model="searchTerm" v-model="searchTerm"
autofocus autofocus
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full" class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')" :placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')"
/> />
</div> </div>
<DropdownSection class="max-h-80 overflow-scroll"> <DropdownSection class="max-h-80 overflow-scroll">
@@ -1,8 +1,7 @@
<script setup> <script setup>
import { computed, watch } from 'vue'; import { computed, ref } from 'vue';
import Input from './Input.vue'; import Input from './Input.vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { DURATION_UNITS } from './constants';
const props = defineProps({ const props = defineProps({
min: { type: Number, default: 0 }, min: { type: Number, default: 0 },
@@ -12,50 +11,36 @@ const props = defineProps({
const { t } = useI18n(); const { t } = useI18n();
const duration = defineModel('modelValue', { type: Number, default: null }); const duration = defineModel('modelValue', { type: Number, default: null });
const unit = defineModel('unit', {
type: String,
default: DURATION_UNITS.MINUTES,
validate(value) {
return Object.values(DURATION_UNITS).includes(value);
},
});
const convertToMinutes = newValue => { const UNIT_TYPES = {
if (unit.value === DURATION_UNITS.MINUTES) { MINUTES: 'minutes',
return Math.floor(newValue); HOURS: 'hours',
} DAYS: 'days',
if (unit.value === DURATION_UNITS.HOURS) {
return Math.floor(newValue) * 60;
}
return Math.floor(newValue) * 24 * 60;
}; };
const unit = ref(UNIT_TYPES.MINUTES);
const transformedValue = computed({ const transformedValue = computed({
get() { get() {
if (unit.value === DURATION_UNITS.MINUTES) return duration.value; if (unit.value === UNIT_TYPES.MINUTES) return duration.value;
if (unit.value === DURATION_UNITS.HOURS) if (unit.value === UNIT_TYPES.HOURS) return Math.floor(duration.value / 60);
return Math.floor(duration.value / 60); if (unit.value === UNIT_TYPES.DAYS)
if (unit.value === DURATION_UNITS.DAYS)
return Math.floor(duration.value / 24 / 60); return Math.floor(duration.value / 24 / 60);
return 0; return 0;
}, },
set(newValue) { set(newValue) {
let minuteValue = convertToMinutes(newValue); let minuteValue;
if (unit.value === UNIT_TYPES.MINUTES) {
minuteValue = Math.floor(newValue);
} else if (unit.value === UNIT_TYPES.HOURS) {
minuteValue = Math.floor(newValue * 60);
} else if (unit.value === UNIT_TYPES.DAYS) {
minuteValue = Math.floor(newValue * 24 * 60);
}
duration.value = Math.min(Math.max(minuteValue, props.min), props.max); duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
}, },
}); });
// when unit is changed set the nearest value to that unit
// so if the minute is set to 900, and the user changes the unit to "days"
// the transformed value will show 0, but the real value will still be 900
// this might create some confusion, especially when saving
// this watcher fixes it by rounding the duration basically, to the nearest unit value
watch(unit, () => {
let adjustedValue = convertToMinutes(transformedValue.value);
duration.value = Math.min(Math.max(adjustedValue, props.min), props.max);
});
</script> </script>
<template> <template>
@@ -72,12 +57,10 @@ watch(unit, () => {
:disabled="disabled" :disabled="disabled"
class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40" class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40"
> >
<option :value="DURATION_UNITS.MINUTES"> <option :value="UNIT_TYPES.MINUTES">
{{ t('DURATION_INPUT.MINUTES') }} {{ t('DURATION_INPUT.MINUTES') }}
</option> </option>
<option :value="DURATION_UNITS.HOURS"> <option :value="UNIT_TYPES.HOURS">{{ t('DURATION_INPUT.HOURS') }}</option>
{{ t('DURATION_INPUT.HOURS') }} <option :value="UNIT_TYPES.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
</option>
<option :value="DURATION_UNITS.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
</select> </select>
</template> </template>
@@ -1,5 +0,0 @@
export const DURATION_UNITS = {
MINUTES: 'minutes',
HOURS: 'hours',
DAYS: 'days',
};
@@ -109,58 +109,49 @@ const downloadAudio = async () => {
</audio> </audio>
<div <div
v-bind="$attrs" v-bind="$attrs"
class="rounded-xl w-full gap-2 p-1.5 bg-n-alpha-white flex flex-col items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]" class="rounded-xl w-full gap-1 p-1.5 bg-n-alpha-white flex items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]"
> >
<div class="flex gap-1 w-full flex-1 items-center justify-start"> <button class="p-0 border-0 size-8" @click="playOrPause">
<button class="p-0 border-0 size-8" @click="playOrPause"> <Icon
<Icon v-if="isPlaying"
v-if="isPlaying" class="size-8"
class="size-8" icon="i-teenyicons-pause-small-solid"
icon="i-teenyicons-pause-small-solid" />
/> <Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" /> </button>
</button> <div class="tabular-nums text-xs">
<div class="tabular-nums text-xs"> {{ formatTime(currentTime) }} / {{ formatTime(duration) }}
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
<div class="flex-1 items-center flex px-2">
<input
type="range"
min="0"
:max="duration"
:value="currentTime"
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
</div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
>
<span class="text-xs text-n-slate-11 font-medium">
{{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
</div> </div>
<div class="flex-1 items-center flex px-2">
<div <input
v-if="attachment.transcribedText" type="range"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words" min="0"
:max="duration"
:value="currentTime"
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
</div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
> >
{{ attachment.transcribedText }} <span class="text-xs text-n-slate-11 font-medium">
</div> {{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
</div> </div>
</template> </template>
@@ -235,12 +235,6 @@ const menuItems = computed(() => {
), ),
activeOn: ['contacts_dashboard_index', 'contacts_edit'], activeOn: ['contacts_dashboard_index', 'contacts_edit'],
}, },
{
name: 'Active',
label: t('SIDEBAR.ACTIVE'),
to: accountScopedRoute('contacts_dashboard_active'),
activeOn: ['contacts_dashboard_active'],
},
{ {
name: 'Segments', name: 'Segments',
icon: 'i-lucide-group', icon: 'i-lucide-group',
@@ -22,7 +22,6 @@ import {
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable // https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'; import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import ChatListHeader from './ChatListHeader.vue'; import ChatListHeader from './ChatListHeader.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue'; import ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue'; import SaveCustomView from 'next/filter/SaveCustomView.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue'; import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
@@ -83,7 +82,6 @@ const emit = defineEmits(['conversationLoad']);
const { uiSettings } = useUISettings(); const { uiSettings } = useUISettings();
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const route = useRoute();
const store = useStore(); const store = useStore();
const conversationListRef = ref(null); const conversationListRef = ref(null);
@@ -648,30 +646,6 @@ function openLastItemAfterDeleteInFolder() {
} }
} }
function redirectToConversationList() {
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = route;
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
router.push(
conversationListPageURL({
accountId,
conversationType: conversationType,
customViewId: props.foldersId,
inboxId,
label,
teamId,
})
);
}
async function assignPriority(priority, conversationId = null) { async function assignPriority(priority, conversationId = null) {
store.dispatch('setCurrentChatPriority', { store.dispatch('setCurrentChatPriority', {
priority, priority,
@@ -696,7 +670,26 @@ async function markAsUnread(conversationId) {
await store.dispatch('markMessagesUnread', { await store.dispatch('markMessagesUnread', {
id: conversationId, id: conversationId,
}); });
redirectToConversationList(); const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = useRoute();
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
router.push(
conversationListPageURL({
accountId,
conversationType: conversationType,
customViewId: props.foldersId,
inboxId,
label,
teamId,
})
);
} catch (error) { } catch (error) {
// Ignore error // Ignore error
} }
@@ -710,7 +703,6 @@ async function markAsRead(conversationId) {
// Ignore error // Ignore error
} }
} }
async function onAssignTeam(team, conversationId = null) { async function onAssignTeam(team, conversationId = null) {
try { try {
await store.dispatch('assignTeam', { await store.dispatch('assignTeam', {
@@ -772,26 +764,6 @@ onMounted(() => {
} }
}); });
const deleteConversationDialogRef = ref(null);
const selectedConversationId = ref(null);
async function deleteConversation() {
try {
await store.dispatch('deleteConversation', selectedConversationId.value);
redirectToConversationList();
selectedConversationId.value = null;
deleteConversationDialogRef.value.close();
useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION'));
} catch (error) {
useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION'));
}
}
const handleDelete = conversationId => {
selectedConversationId.value = conversationId;
deleteConversationDialogRef.value.open();
};
provide('selectConversation', selectConversation); provide('selectConversation', selectConversation);
provide('deSelectConversation', deSelectConversation); provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent); provide('assignAgent', onAssignAgent);
@@ -803,7 +775,6 @@ provide('markAsUnread', markAsUnread);
provide('markAsRead', markAsRead); provide('markAsRead', markAsRead);
provide('assignPriority', assignPriority); provide('assignPriority', assignPriority);
provide('isConversationSelected', isConversationSelected); provide('isConversationSelected', isConversationSelected);
provide('deleteConversation', handleDelete);
watch(activeTeam, () => resetAndFetchData()); watch(activeTeam, () => resetAndFetchData());
@@ -967,19 +938,6 @@ watch(conversationFilters, (newVal, oldVal) => {
</template> </template>
</DynamicScroller> </DynamicScroller>
</div> </div>
<Dialog
ref="deleteConversationDialogRef"
type="alert"
:title="
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
conversationId: selectedConversationId,
})
"
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
@confirm="deleteConversation"
@close="selectedConversationId = null"
/>
<TeleportWithDirection <TeleportWithDirection
v-if="showAdvancedFilters" v-if="showAdvancedFilters"
to="#conversationFilterTeleportTarget" to="#conversationFilterTeleportTarget"
@@ -16,7 +16,6 @@ export default {
'markAsRead', 'markAsRead',
'assignPriority', 'assignPriority',
'isConversationSelected', 'isConversationSelected',
'deleteConversation',
], ],
props: { props: {
source: { source: {
@@ -68,6 +67,5 @@ export default {
@mark-as-unread="markAsUnread" @mark-as-unread="markAsUnread"
@mark-as-read="markAsRead" @mark-as-read="markAsRead"
@assign-priority="assignPriority" @assign-priority="assignPriority"
@delete-conversation="deleteConversation"
/> />
</template> </template>
@@ -134,7 +134,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<template> <template>
<div class="relative flex items-center justify-end resolve-actions"> <div class="relative flex items-center justify-end resolve-actions">
<div <div
class="rounded-lg shadow outline-1 outline flex-shrink-0" class="rounded-lg shadow outline-1 outline"
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'" :class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
> >
<Button <Button
@@ -178,7 +178,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<div <div
v-if="showActionsDropdown" v-if="showActionsDropdown"
v-on-clickaway="closeDropdown" v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open left-auto top-full mt-0.5 start-0 xl:start-auto xl:end-0 max-w-[12.5rem] min-w-[9.75rem]" class="dropdown-pane dropdown-pane--open left-auto top-full mt-0.5 ltr:right-0 rtl:left-0 max-w-[12.5rem] min-w-[9.75rem]"
> >
<WootDropdownMenu class="mb-0"> <WootDropdownMenu class="mb-0">
<WootDropdownItem v-if="!isPending"> <WootDropdownItem v-if="!isPending">
@@ -1,11 +1,16 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted, watchEffect } from 'vue';
import { useStore } from 'dashboard/composables/store'; import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue'; import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import ConversationAPI from 'dashboard/api/inbox/conversation';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
defineProps({ const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
conversationInboxType: { conversationInboxType: {
type: String, type: String,
required: true, required: true,
@@ -15,25 +20,13 @@ defineProps({
const store = useStore(); const store = useStore();
const currentUser = useMapGetter('getCurrentUser'); const currentUser = useMapGetter('getCurrentUser');
const assistants = useMapGetter('captainAssistants/getRecords'); const assistants = useMapGetter('captainAssistants/getRecords');
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const inboxAssistant = useMapGetter('getCopilotAssistant'); const inboxAssistant = useMapGetter('getCopilotAssistant');
const currentChat = useMapGetter('getSelectedChat');
const selectedCopilotThreadId = ref(null);
const messages = computed(() =>
store.getters['copilotMessages/getMessagesByThreadId'](
selectedCopilotThreadId.value
)
);
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const selectedAssistantId = ref(null);
const { uiSettings, updateUISettings } = useUISettings(); const { uiSettings, updateUISettings } = useUISettings();
const messages = ref([]);
const isCaptainTyping = ref(false);
const selectedAssistantId = ref(null);
const activeAssistant = computed(() => { const activeAssistant = computed(() => {
const preferredId = uiSettings.value.preferred_captain_assistant_id; const preferredId = uiSettings.value.preferred_captain_assistant_id;
@@ -62,57 +55,68 @@ const setAssistant = async assistant => {
}); });
}; };
const shouldShowCopilotPanel = computed(() => {
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
);
const { is_copilot_panel_open: isCopilotPanelOpen } = uiSettings.value;
return isCaptainEnabled && isCopilotPanelOpen && !uiFlags.value.fetchingList;
});
const handleReset = () => { const handleReset = () => {
selectedCopilotThreadId.value = null; messages.value = [];
}; };
const sendMessage = async message => { const sendMessage = async message => {
if (selectedCopilotThreadId.value) { // Add user message
await store.dispatch('copilotMessages/create', { messages.value.push({
assistant_id: activeAssistant.value.id, id: messages.value.length + 1,
conversation_id: currentChat.value?.id, role: 'user',
threadId: selectedCopilotThreadId.value, content: message,
message, });
isCaptainTyping.value = true;
try {
const { data } = await ConversationAPI.requestCopilot(
props.conversationId,
{
previous_history: messages.value
.map(m => ({
role: m.role,
content: m.content,
}))
.slice(0, -1),
message,
assistant_id: selectedAssistantId.value,
}
);
messages.value.push({
id: new Date().getTime(),
role: 'assistant',
content: data.message,
}); });
} else { } catch (error) {
const response = await store.dispatch('copilotThreads/create', { // eslint-disable-next-line
assistant_id: activeAssistant.value.id, console.log(error);
conversation_id: currentChat.value?.id, } finally {
message, isCaptainTyping.value = false;
});
selectedCopilotThreadId.value = response.id;
} }
}; };
onMounted(() => { onMounted(() => {
store.dispatch('captainAssistants/get'); store.dispatch('captainAssistants/get');
}); });
watchEffect(() => {
if (props.conversationId) {
store.dispatch('getInboxCaptainAssistantById', props.conversationId);
selectedAssistantId.value = activeAssistant.value?.id;
}
});
</script> </script>
<template> <template>
<div <Copilot
v-if="shouldShowCopilotPanel" :messages="messages"
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background" :support-agent="currentUser"
> :is-captain-typing="isCaptainTyping"
<Copilot :conversation-inbox-type="conversationInboxType"
:messages="messages" :assistants="assistants"
:support-agent="currentUser" :active-assistant="activeAssistant"
:conversation-inbox-type="conversationInboxType" @set-assistant="setAssistant"
:assistants="assistants" @send-message="sendMessage"
:active-assistant="activeAssistant" @reset="handleReset"
@set-assistant="setAssistant" />
@send-message="sendMessage"
@reset="handleReset"
/>
</div>
<template v-else />
</template> </template>
@@ -9,7 +9,6 @@ const contacts = accountId => ({
'contacts_edit', 'contacts_edit',
'contacts_edit_segment', 'contacts_edit_segment',
'contacts_edit_label', 'contacts_edit_label',
'contacts_dashboard_active',
], ],
menuItems: [ menuItems: [
{ {
@@ -19,13 +18,6 @@ const contacts = accountId => ({
toState: frontendURL(`accounts/${accountId}/contacts?page=1`), toState: frontendURL(`accounts/${accountId}/contacts?page=1`),
toStateName: 'contacts_dashboard_index', toStateName: 'contacts_dashboard_index',
}, },
{
icon: 'visitor-contacts',
label: 'ACTIVE',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts/active`),
toStateName: 'contacts_dashboard_active',
},
], ],
}); });
@@ -78,7 +78,6 @@ export default {
'markAsRead', 'markAsRead',
'assignPriority', 'assignPriority',
'updateConversationStatus', 'updateConversationStatus',
'deleteConversation',
], ],
data() { data() {
return { return {
@@ -238,10 +237,6 @@ export default {
this.$emit('assignPriority', priority, this.chat.id); this.$emit('assignPriority', priority, this.chat.id);
this.closeContextMenu(); this.closeContextMenu();
}, },
async deleteConversation() {
this.$emit('deleteConversation', this.chat.id);
this.closeContextMenu();
},
}, },
}; };
</script> </script>
@@ -368,7 +363,6 @@ export default {
@mark-as-unread="markAsUnread" @mark-as-unread="markAsUnread"
@mark-as-read="markAsRead" @mark-as-read="markAsRead"
@assign-priority="assignPriority" @assign-priority="assignPriority"
@delete-conversation="deleteConversation"
/> />
</ContextMenu> </ContextMenu>
</div> </div>
@@ -107,10 +107,10 @@ const isLinearFeatureEnabled = computed(() =>
<template> <template>
<div <div
ref="conversationHeader" ref="conversationHeader"
class="flex flex-col gap-3 items-center justify-between flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12" class="flex flex-col items-center justify-center flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
> >
<div <div
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0 xl:flex-1" class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0"
> >
<BackButton <BackButton
v-if="showBackButton" v-if="showBackButton"
@@ -122,12 +122,11 @@ const isLinearFeatureEnabled = computed(() =>
:username="currentContact.name" :username="currentContact.name"
:status="currentContact.availability_status" :status="currentContact.availability_status"
size="32px" size="32px"
class="flex-shrink-0"
/> />
<div <div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2" class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
> >
<div class="flex flex-row items-center max-w-full gap-1 p-0 m-0"> <div class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit">
<span <span
class="text-sm font-medium truncate leading-tight text-n-slate-12" class="text-sm font-medium truncate leading-tight text-n-slate-12"
> >
@@ -137,7 +136,7 @@ const isLinearFeatureEnabled = computed(() =>
v-if="!isHMACVerified" v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')" v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14" size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px] flex-shrink-0" class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
icon="warning" icon="warning"
/> />
</div> </div>
@@ -153,20 +152,20 @@ const isLinearFeatureEnabled = computed(() =>
</div> </div>
</div> </div>
<div <div
class="flex flex-row items-center justify-start xl:justify-end flex-shrink-0 gap-2 w-full xl:w-auto header-actions-wrap" class="flex flex-row items-center justify-start xl:justify-end flex-grow gap-2 w-full xl:w-auto mt-3 header-actions-wrap xl:mt-0"
> >
<SLACardLabel <SLACardLabel
v-if="hasSlaPolicyId" v-if="hasSlaPolicyId"
:chat="chat" :chat="chat"
show-extended-info show-extended-info
:parent-width="width" :parent-width="width"
class="hidden md:flex" class="hidden lg:flex"
/> />
<Linear <Linear
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled" v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
:conversation-id="currentChat.id" :conversation-id="currentChat.id"
:parent-width="width" :parent-width="width"
class="hidden md:flex" class="hidden lg:flex"
/> />
<MoreActions :conversation-id="currentChat.id" /> <MoreActions :conversation-id="currentChat.id" />
</div> </div>
@@ -1,23 +1,43 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import CopilotContainer from '../../copilot/CopilotContainer.vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue'; import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
defineProps({ const props = defineProps({
currentChat: { currentChat: {
required: true, required: true,
type: Object, type: Object,
}, },
}); });
const channelType = computed(() => props.currentChat?.meta?.channel || '');
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotTab = computed(() =>
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
);
const { uiSettings } = useUISettings(); const { uiSettings } = useUISettings();
const activeTab = computed(() => { const activeTab = computed(() => {
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value; const {
is_contact_sidebar_open: isContactSidebarOpen,
is_copilot_panel_open: isCopilotPanelOpen,
} = uiSettings.value;
if (isContactSidebarOpen) { if (isContactSidebarOpen) {
return 0; return 0;
} }
if (isCopilotPanelOpen) {
return 1;
}
return null; return null;
}); });
</script> </script>
@@ -32,6 +52,13 @@ const activeTab = computed(() => {
:conversation-id="currentChat.id" :conversation-id="currentChat.id"
:inbox-id="currentChat.inbox_id" :inbox-id="currentChat.inbox_id"
/> />
<CopilotContainer
v-show="activeTab === 1 && showCopilotTab"
:key="currentChat.id"
:conversation-inbox-type="channelType"
:conversation-id="currentChat.id"
class="flex-1"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -134,7 +134,7 @@ onUnmounted(() => {
<SLAPopoverCard <SLAPopoverCard
v-if="showSlaPopoverCard" v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents" :sla-missed-events="slaEvents"
class="start-0 xl:start-auto xl:end-0 top-7 hidden group-hover:flex" class="rtl:left-0 ltr:right-0 top-7 hidden group-hover:flex"
/> />
</div> </div>
</template> </template>
@@ -8,7 +8,6 @@ import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue'; import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue'; import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
export default { export default {
components: { components: {
@@ -46,14 +45,7 @@ export default {
'assignAgent', 'assignAgent',
'assignTeam', 'assignTeam',
'assignLabel', 'assignLabel',
'deleteConversation',
], ],
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() { data() {
return { return {
STATUS_TYPE: wootConstants.STATUS_TYPE, STATUS_TYPE: wootConstants.STATUS_TYPE,
@@ -129,11 +121,6 @@ export default {
icon: 'people-team-add', icon: 'people-team-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'), label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
}, },
deleteOption: {
key: 'delete',
icon: 'delete',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'),
},
}; };
}, },
computed: { computed: {
@@ -191,9 +178,6 @@ export default {
assignPriority(priority) { assignPriority(priority) {
this.$emit('assignPriority', priority); this.$emit('assignPriority', priority);
}, },
deleteConversation() {
this.$emit('deleteConversation', this.chatId);
},
show(key) { show(key) {
// If the conversation status is same as the action, then don't display the option // If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved. // i.e.: Don't show an option to resolve if the conversation is already resolved.
@@ -293,13 +277,5 @@ export default {
@click.stop="$emit('assignTeam', team)" @click.stop="$emit('assignTeam', team)"
/> />
</MenuItemWithSubmenu> </MenuItemWithSubmenu>
<template v-if="isAdmin">
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItem
:option="deleteOption"
variant="icon"
@click.stop="deleteConversation"
/>
</template>
</div> </div>
</template> </template>
@@ -142,7 +142,7 @@ onMounted(() => {
v-if="linkedIssue" v-if="linkedIssue"
:issue="linkedIssue.issue" :issue="linkedIssue.issue"
:link-id="linkedIssue.id" :link-id="linkedIssue.id"
class="absolute start-0 xl:start-auto xl:end-0 top-9 invisible group-hover:visible" class="absolute rtl:left-0 ltr:right-0 top-9 invisible group-hover:visible"
@unlink-issue="unlinkIssue" @unlink-issue="unlinkIssue"
/> />
<woot-modal <woot-modal
@@ -45,10 +45,9 @@ export function useAccount() {
}; };
}; };
const updateAccount = async (data, options) => { const updateAccount = async data => {
await store.dispatch('accounts/update', { await store.dispatch('accounts/update', {
...data, ...data,
options,
}); });
}; };
@@ -33,14 +33,6 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'code', 'code',
]; ];
export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
];
export const MESSAGE_EDITOR_IMAGE_RESIZES = [ export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{ {
name: 'Small', name: 'Small',
@@ -36,7 +36,6 @@ const translationKeys = {
'teammember:create': `AUDIT_LOGS.TEAM_MEMBER.ADD`, 'teammember:create': `AUDIT_LOGS.TEAM_MEMBER.ADD`,
'teammember:destroy': `AUDIT_LOGS.TEAM_MEMBER.REMOVE`, 'teammember:destroy': `AUDIT_LOGS.TEAM_MEMBER.REMOVE`,
'account:update': `AUDIT_LOGS.ACCOUNT.EDIT`, 'account:update': `AUDIT_LOGS.ACCOUNT.EDIT`,
'conversation:destroy': `AUDIT_LOGS.CONVERSATION.DELETE`,
}; };
function extractAttrChange(attrChange) { function extractAttrChange(attrChange) {
@@ -169,11 +168,6 @@ export function generateTranslationPayload(auditLogItem, agentList) {
const auditableType = auditLogItem.auditable_type.toLowerCase(); const auditableType = auditLogItem.auditable_type.toLowerCase();
const action = auditLogItem.action.toLowerCase(); const action = auditLogItem.action.toLowerCase();
if (auditableType === 'conversation' && action === 'destroy') {
translationPayload.id =
auditLogItem.audited_changes?.display_id || auditLogItem.auditable_id;
}
if (auditableType === 'accountuser') { if (auditableType === 'accountuser') {
translationPayload = handleAccountUser( translationPayload = handleAccountUser(
auditLogItem, auditLogItem,
@@ -69,9 +69,6 @@
}, },
"ACCOUNT": { "ACCOUNT": {
"EDIT": "{agentName} updated the account configuration (#{id})" "EDIT": "{agentName} updated the account configuration (#{id})"
},
"CONVERSATION": {
"DELETE": "{agentName} deleted conversation #{id}"
} }
} }
} }
@@ -286,7 +286,6 @@
"HEADER": { "HEADER": {
"TITLE": "Contacts", "TITLE": "Contacts",
"SEARCH_TITLE": "Search contacts", "SEARCH_TITLE": "Search contacts",
"ACTIVE_TITLE": "Active contacts",
"SEARCH_PLACEHOLDER": "Search...", "SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Message", "MESSAGE_BUTTON": "Message",
"SEND_MESSAGE": "Send message", "SEND_MESSAGE": "Send message",
@@ -561,8 +560,7 @@
"SUBTITLE": "Start adding new contacts by clicking on the button below", "SUBTITLE": "Start adding new contacts by clicking on the button below",
"BUTTON_LABEL": "Add contact", "BUTTON_LABEL": "Add contact",
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍", "SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋", "LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋"
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
} }
}, },
@@ -118,11 +118,6 @@
"FAILED": "Couldn't change priority. Please try again." "FAILED": "Couldn't change priority. Please try again."
} }
}, },
"DELETE_CONVERSATION": {
"TITLE": "Delete conversation #{conversationId}",
"DESCRIPTION": "Are you sure you want to delete this conversation?",
"CONFIRM": "Delete"
},
"CARD_CONTEXT_MENU": { "CARD_CONTEXT_MENU": {
"PENDING": "Mark as pending", "PENDING": "Mark as pending",
"RESOLVED": "Mark as resolved", "RESOLVED": "Mark as resolved",
@@ -139,7 +134,6 @@
"ASSIGN_LABEL": "Assign label", "ASSIGN_LABEL": "Assign label",
"AGENTS_LOADING": "Loading agents...", "AGENTS_LOADING": "Loading agents...",
"ASSIGN_TEAM": "Assign team", "ASSIGN_TEAM": "Assign team",
"DELETE": "Delete conversation",
"API": { "API": {
"AGENT_ASSIGNMENT": { "AGENT_ASSIGNMENT": {
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"", "SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",
@@ -214,8 +208,6 @@
"ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully", "ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully",
"ASSIGN_LABEL_FAILED": "Label assignment failed", "ASSIGN_LABEL_FAILED": "Label assignment failed",
"CHANGE_TEAM": "Conversation team changed", "CHANGE_TEAM": "Conversation team changed",
"SUCCESS_DELETE_CONVERSATION": "Conversation deleted successfully",
"FAIL_DELETE_CONVERSATION": "Couldn't delete conversation! Try again",
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit", "FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit",
"MESSAGE_ERROR": "Unable to send this message, please try again later", "MESSAGE_ERROR": "Unable to send this message, please try again later",
"SENT_BY": "Sent by:", "SENT_BY": "Sent by:",
@@ -46,31 +46,7 @@
}, },
"AUTO_RESOLVE": { "AUTO_RESOLVE": {
"TITLE": "Auto-resolve conversations", "TITLE": "Auto-resolve conversations",
"NOTE": "This configuration would allow you to automatically resolve the conversation after a certain period of inactivity.", "NOTE": "This configuration would allow you to automatically resolve the conversation after a certain period. Set the duration and customize the message to the user below."
"DURATION": {
"LABEL": "Inactivity duration",
"HELP": "Time period of inactivity after which conversation is auto-resolved",
"PLACEHOLDER": "30",
"ERROR": "Auto resolve duration should be between 10 minutes and 999 days",
"API": {
"SUCCESS": "Auto resolve settings updated successfully",
"ERROR": "Failed to update auto resolve settings"
}
},
"MESSAGE": {
"LABEL": "Custom auto-resolution message",
"PLACEHOLDER": "Conversation was marked resolved by system due to 15 days of inactivity",
"HELP": "Message sent to the customer after conversation is auto-resolved"
},
"PREFERENCES": "Preferences",
"LABEL": {
"LABEL": "Add label after auto-resolution",
"PLACEHOLDER": "Select a label"
},
"IGNORE_WAITING": {
"LABEL": "Skip conversations waiting for agents reply"
},
"UPDATE_BUTTON": "Save Changes"
}, },
"NAME": { "NAME": {
"LABEL": "Account name", "LABEL": "Account name",
@@ -94,15 +70,7 @@
}, },
"AUTO_RESOLVE_IGNORE_WAITING": { "AUTO_RESOLVE_IGNORE_WAITING": {
"LABEL": "Exclude unattended conversations", "LABEL": "Exclude unattended conversations",
"HELP": "When enabled, the system will skip resolving conversations that are still waiting for an agent's reply." "HELP": "When enabled, the system will skip resolving conversations that are still waiting for an agents reply."
},
"AUDIO_TRANSCRIPTION": {
"TITLE": "Transcribe Audio Messages",
"NOTE": "Automatically transcribe audio messages in conversations. Generate a text transcript whenever an audio message is sent or received, and display it alongside the message.",
"API": {
"SUCCESS": "Audio transcription setting updated successfully",
"ERROR": "Failed to update audio transcription setting"
}
}, },
"AUTO_RESOLVE_DURATION": { "AUTO_RESOLVE_DURATION": {
"LABEL": "Inactivity duration for resolution", "LABEL": "Inactivity duration for resolution",
@@ -328,9 +328,6 @@
"HEADER_KNOW_MORE": "Know more", "HEADER_KNOW_MORE": "Know more",
"COPILOT": { "COPILOT": {
"TITLE": "Copilot", "TITLE": "Copilot",
"TRY_THESE_PROMPTS": "Try these prompts",
"PANEL_TITLE": "Get started with Copilot",
"KICK_OFF_MESSAGE": "Need a quick summary, want to check past conversations, or draft a better reply? Copilots here to speed things up.",
"SEND_MESSAGE": "Send message...", "SEND_MESSAGE": "Send message...",
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.", "EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
"LOADER": "Captain is thinking", "LOADER": "Captain is thinking",
@@ -351,14 +348,6 @@
"RATE": { "RATE": {
"LABEL": "Rate this conversation", "LABEL": "Rate this conversation",
"CONTENT": "Review the conversation to see how well it meets the customer's needs. Share a rating out of 5 based on tone, clarity, and effectiveness." "CONTENT": "Review the conversation to see how well it meets the customer's needs. Share a rating out of 5 based on tone, clarity, and effectiveness."
},
"HIGH_PRIORITY": {
"LABEL": "High priority conversations",
"CONTENT": "Give me a summary of all high priority open conversations. Include the conversation ID, customer name (if available), last message content, and assigned agent. Group by status if relevant."
},
"LIST_CONTACTS": {
"LABEL": "List contacts",
"CONTENT": "Show me the list of top 10 contacts. Include name, email or phone number (if available), last seen time, tags (if any)."
} }
} }
}, },
@@ -392,7 +381,6 @@
}, },
"ASSISTANTS": { "ASSISTANTS": {
"HEADER": "Assistants", "HEADER": "Assistants",
"NO_ASSISTANTS_AVAILABLE": "There are no assistants available in your account.",
"ADD_NEW": "Create a new assistant", "ADD_NEW": "Create a new assistant",
"DELETE": { "DELETE": {
"TITLE": "Are you sure to delete the assistant?", "TITLE": "Are you sure to delete the assistant?",
@@ -286,7 +286,6 @@
"REPORTS": "Reports", "REPORTS": "Reports",
"SETTINGS": "Settings", "SETTINGS": "Settings",
"CONTACTS": "Contacts", "CONTACTS": "Contacts",
"ACTIVE": "Active",
"CAPTAIN": "Captain", "CAPTAIN": "Captain",
"CAPTAIN_ASSISTANTS": "Assistants", "CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents", "CAPTAIN_DOCUMENTS": "Documents",
@@ -1,6 +1,5 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
const props = defineProps({ const props = defineProps({
config: { config: {
@@ -9,8 +8,6 @@ const props = defineProps({
}, },
}); });
const { formatMessage } = useMessageFormatter();
const isDefaultScreen = computed(() => { const isDefaultScreen = computed(() => {
return ( return (
props.config.isDefaultScreen && props.config.isDefaultScreen &&
@@ -56,13 +53,12 @@ const isDefaultScreen = computed(() => {
</div> </div>
</div> </div>
<div v-if="isDefaultScreen" class="overflow-auto max-h-60"> <div v-if="isDefaultScreen" class="overflow-auto max-h-60">
<h2 class="mb-2 text-2xl break-words text-n-slate-12"> <h2 class="mb-2 text-2xl break-words text-slate-900 dark:text-white">
{{ config.welcomeHeading }} {{ config.welcomeHeading }}
</h2> </h2>
<p <p class="text-sm break-words text-slate-600 dark:text-slate-100">
v-dompurify-html="formatMessage(config.welcomeTagline)" {{ config.welcomeTagline }}
class="text-sm break-words text-n-slate-11 [&_a]:!text-n-slate-11 [&_a]:underline" </p>
/>
</div> </div>
</div> </div>
</div> </div>
@@ -25,8 +25,6 @@ const Sidebar = defineAsyncComponent(
() => import('../../components/layout/Sidebar.vue') () => import('../../components/layout/Sidebar.vue')
); );
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
export default { export default {
components: { components: {
@@ -39,8 +37,6 @@ export default {
AddLabelModal, AddLabelModal,
NotificationPanel, NotificationPanel,
UpgradePage, UpgradePage,
CopilotLauncher,
CopilotContainer,
}, },
setup() { setup() {
const upgradePageRef = ref(null); const upgradePageRef = ref(null);
@@ -223,9 +219,6 @@ export default {
<template v-if="!showUpgradePage"> <template v-if="!showUpgradePage">
<router-view /> <router-view />
<CommandBar /> <CommandBar />
<CopilotLauncher />
<CopilotContainer />
<NotificationPanel <NotificationPanel
v-if="isNotificationPanel" v-if="isNotificationPanel"
@close="closeNotificationPanel" @close="closeNotificationPanel"
@@ -16,7 +16,7 @@ onMounted(() => {
<template> <template>
<div <div
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background px-6" class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background"
> >
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<keep-alive v-if="keepAlive"> <keep-alive v-if="keepAlive">
@@ -67,11 +67,9 @@ const hasContacts = computed(() => contacts.value.length > 0);
const isContactIndexView = computed( const isContactIndexView = computed(
() => route.name === 'contacts_dashboard_index' && pageNumber.value === 1 () => route.name === 'contacts_dashboard_index' && pageNumber.value === 1
); );
const isActiveView = computed(() => route.name === 'contacts_dashboard_active');
const hasAppliedFilters = computed(() => { const hasAppliedFilters = computed(() => {
return appliedFilters.value.length > 0; return appliedFilters.value.length > 0;
}); });
const showEmptyStateLayout = computed(() => { const showEmptyStateLayout = computed(() => {
return ( return (
!searchQuery.value && !searchQuery.value &&
@@ -91,20 +89,11 @@ const showEmptyText = computed(() => {
const headerTitle = computed(() => { const headerTitle = computed(() => {
if (searchQuery.value) return t('CONTACTS_LAYOUT.HEADER.SEARCH_TITLE'); if (searchQuery.value) return t('CONTACTS_LAYOUT.HEADER.SEARCH_TITLE');
if (isActiveView.value) return t('CONTACTS_LAYOUT.HEADER.ACTIVE_TITLE');
if (activeSegmentId.value) return activeSegment.value?.name; if (activeSegmentId.value) return activeSegment.value?.name;
if (activeLabel.value) return `#${activeLabel.value}`; if (activeLabel.value) return `#${activeLabel.value}`;
return t('CONTACTS_LAYOUT.HEADER.TITLE'); return t('CONTACTS_LAYOUT.HEADER.TITLE');
}); });
const emptyStateMessage = computed(() => {
if (isActiveView.value)
return t('CONTACTS_LAYOUT.EMPTY_STATE.ACTIVE_EMPTY_STATE_TITLE');
if (!searchQuery.value || hasAppliedFilters.value)
return t('CONTACTS_LAYOUT.EMPTY_STATE.LIST_EMPTY_STATE_TITLE');
return t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE');
});
const updatePageParam = (page, search = '') => { const updatePageParam = (page, search = '') => {
const query = { const query = {
...route.query, ...route.query,
@@ -143,15 +132,6 @@ const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
updatePageParam(page); updatePageParam(page);
}; };
const fetchActiveContacts = async (page = 1) => {
await store.dispatch('contacts/clearContactFilters');
await store.dispatch('contacts/active', {
page,
sortAttr: buildSortAttr(),
});
updatePageParam(page);
};
const searchContacts = debounce(async (value, page = 1) => { const searchContacts = debounce(async (value, page = 1) => {
await store.dispatch('contacts/clearContactFilters'); await store.dispatch('contacts/clearContactFilters');
searchValue.value = value; searchValue.value = value;
@@ -178,11 +158,6 @@ const fetchContactsBasedOnContext = async page => {
} }
// Reset the search value when we change the view // Reset the search value when we change the view
searchValue.value = ''; searchValue.value = '';
// If we're on the active route, fetch active contacts
if (isActiveView.value) {
await fetchActiveContacts(page);
return;
}
// If there are applied filters or active segment with query // If there are applied filters or active segment with query
if ( if (
(hasAppliedFilters.value || activeSegment.value?.query) && (hasAppliedFilters.value || activeSegment.value?.query) &&
@@ -209,11 +184,6 @@ const handleSort = async ({ sort, order }) => {
return; return;
} }
if (isActiveView.value) {
await fetchActiveContacts();
return;
}
await (activeSegmentId.value || hasAppliedFilters.value await (activeSegmentId.value || hasAppliedFilters.value
? fetchSavedOrAppliedFilteredContact( ? fetchSavedOrAppliedFilteredContact(
activeSegmentId.value activeSegmentId.value
@@ -240,7 +210,7 @@ watch(
); );
watch( watch(
[activeLabel, activeSegment, isActiveView], [activeLabel, activeSegment],
() => { () => {
fetchContactsBasedOnContext(pageNumber.value); fetchContactsBasedOnContext(pageNumber.value);
}, },
@@ -252,13 +222,6 @@ watch(searchQuery, value => {
searchValue.value = value || ''; searchValue.value = value || '';
// Reset the view if there is search query when we click on the sidebar group // Reset the view if there is search query when we click on the sidebar group
if (value === undefined) { if (value === undefined) {
if (
isActiveView.value ||
activeLabel.value ||
activeSegment.value ||
hasAppliedFilters.value
)
return;
fetchContacts(); fetchContacts();
} }
}); });
@@ -269,10 +232,6 @@ onMounted(async () => {
await searchContacts(searchQuery.value, pageNumber.value); await searchContacts(searchQuery.value, pageNumber.value);
return; return;
} }
if (isActiveView.value) {
await fetchActiveContacts(pageNumber.value);
return;
}
await fetchContacts(pageNumber.value); await fetchContacts(pageNumber.value);
} else if (activeSegment.value && activeSegmentId.value) { } else if (activeSegment.value && activeSegmentId.value) {
await fetchSavedOrAppliedFilteredContact( await fetchSavedOrAppliedFilteredContact(
@@ -327,7 +286,11 @@ onMounted(async () => {
class="flex items-center justify-center py-10" class="flex items-center justify-center py-10"
> >
<span class="text-base text-n-slate-11"> <span class="text-base text-n-slate-11">
{{ emptyStateMessage }} {{
searchQuery || !hasAppliedFilters
? t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE')
: t('CONTACTS_LAYOUT.EMPTY_STATE.LIST_EMPTY_STATE_TITLE')
}}
</span> </span>
</div> </div>
@@ -32,12 +32,6 @@ export const routes = [
component: ContactsIndex, component: ContactsIndex,
meta: commonMeta, meta: commonMeta,
}, },
{
path: 'active',
name: 'contacts_dashboard_active',
component: ContactsIndex,
meta: commonMeta,
},
], ],
}, },
{ {
@@ -97,8 +97,11 @@ export default {
return false; return false;
} }
const { is_contact_sidebar_open: isContactSidebarOpen } = this.uiSettings; const {
return isContactSidebarOpen; is_contact_sidebar_open: isContactSidebarOpen,
is_copilot_panel_open: isCopilotPanelOpen,
} = this.uiSettings;
return isContactSidebarOpen || isCopilotPanelOpen;
}, },
showPopOverSearch() { showPopOverSearch() {
return !this.isFeatureEnabledonAccount( return !this.isFeatureEnabledonAccount(
@@ -205,7 +208,7 @@ export default {
</script> </script>
<template> <template>
<section class="flex w-full h-full min-w-0"> <section class="flex w-full h-full">
<ChatList <ChatList
:show-conversation-list="showConversationList" :show-conversation-list="showConversationList"
:conversation-inbox="inboxId" :conversation-inbox="inboxId"
@@ -183,6 +183,7 @@ export default {
</div> </div>
<div v-else class="flex flex-col w-full h-full"> <div v-else class="flex flex-col w-full h-full">
<InboxItemHeader <InboxItemHeader
class="flex-1"
:total-length="totalNotificationCount" :total-length="totalNotificationCount"
:current-index="activeNotificationIndex" :current-index="activeNotificationIndex"
:active-notification="activeNotification" :active-notification="activeNotification"
@@ -109,7 +109,7 @@ export default {
<template> <template>
<div <div
class="flex items-center justify-between w-full gap-2 border-b ltr:pl-4 rtl:pl-2 h-12 ltr:pr-2 rtl:pr-4 rtl:border-r border-n-weak" class="flex items-center justify-between w-full gap-2 py-2 border-b ltr:pl-4 rtl:pl-2 h-14 ltr:pr-2 rtl:pr-4 rtl:border-r border-n-weak"
> >
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<BackButton <BackButton
@@ -16,7 +16,6 @@ import AccountId from './components/AccountId.vue';
import BuildInfo from './components/BuildInfo.vue'; import BuildInfo from './components/BuildInfo.vue';
import AccountDelete from './components/AccountDelete.vue'; import AccountDelete from './components/AccountDelete.vue';
import AutoResolve from './components/AutoResolve.vue'; import AutoResolve from './components/AutoResolve.vue';
import AudioTranscription from './components/AudioTranscription.vue';
import SectionLayout from './components/SectionLayout.vue'; import SectionLayout from './components/SectionLayout.vue';
export default { export default {
@@ -27,7 +26,6 @@ export default {
BuildInfo, BuildInfo,
AccountDelete, AccountDelete,
AutoResolve, AutoResolve,
AudioTranscription,
SectionLayout, SectionLayout,
WithLabel, WithLabel,
NextInput, NextInput,
@@ -237,7 +235,6 @@ export default {
<woot-loading-state v-if="uiFlags.isFetchingItem" /> <woot-loading-state v-if="uiFlags.isFetchingItem" />
</div> </div>
<AutoResolve v-if="showAutoResolutionConfig" /> <AutoResolve v-if="showAutoResolutionConfig" />
<AudioTranscription v-if="isOnChatwootCloud" />
<AccountId /> <AccountId />
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud"> <div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
<AccountDelete /> <AccountDelete />
@@ -1,51 +0,0 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import SectionLayout from './SectionLayout.vue';
import Switch from 'next/switch/Switch.vue';
const { t } = useI18n();
const isEnabled = ref(false);
const { currentAccount, updateAccount } = useAccount();
watch(
currentAccount,
() => {
const { audio_transcriptions } = currentAccount.value?.settings || {};
isEnabled.value = !!audio_transcriptions;
},
{ deep: true, immediate: true }
);
const updateAccountSettings = async settings => {
try {
await updateAccount(settings);
useAlert(t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.API.SUCCESS'));
} catch (error) {
useAlert(t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.API.ERROR'));
}
};
const toggleAudioTranscription = async () => {
return updateAccountSettings({
audio_transcriptions: isEnabled.value,
});
};
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.NOTE')"
with-border
>
<template #headerActions>
<div class="flex justify-end">
<Switch v-model="isEnabled" @change="toggleAudioTranscription" />
</div>
</template>
</SectionLayout>
</template>
@@ -1,78 +1,35 @@
<script setup> <script setup>
import { h, ref, watch, computed } from 'vue'; import { ref, watch } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount'; import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import SectionLayout from './SectionLayout.vue'; import SectionLayout from './SectionLayout.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue'; import WithLabel from 'v3/components/Form/WithLabel.vue';
import DurationInput from 'next/input/DurationInput.vue';
import TextArea from 'next/textarea/TextArea.vue'; import TextArea from 'next/textarea/TextArea.vue';
import Switch from 'next/switch/Switch.vue'; import Switch from 'next/switch/Switch.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import DurationInput from 'next/input/DurationInput.vue';
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
const { t } = useI18n(); const { t } = useI18n();
const duration = ref(0); const duration = ref(0);
const unit = ref(DURATION_UNITS.MINUTES);
const message = ref(''); const message = ref('');
const labelToApply = ref({});
const ignoreWaiting = ref(false); const ignoreWaiting = ref(false);
const isEnabled = ref(false); const isEnabled = ref(false);
const isSubmitting = ref(false);
const { currentAccount, updateAccount } = useAccount(); const { currentAccount, updateAccount } = useAccount();
const labels = useMapGetter('labels/getLabels');
const labelOptions = computed(() =>
labels.value?.length
? labels.value.map(label => ({
id: label.title,
name: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
}))
: []
);
const selectedLabelName = computed(() => {
return labelToApply.value?.name ?? null;
});
watch( watch(
[currentAccount, labelOptions], currentAccount,
() => { () => {
const { const {
auto_resolve_after, auto_resolve_after,
auto_resolve_message, auto_resolve_message,
auto_resolve_ignore_waiting, auto_resolve_ignore_waiting,
auto_resolve_label,
} = currentAccount.value?.settings || {}; } = currentAccount.value?.settings || {};
duration.value = auto_resolve_after; duration.value = auto_resolve_after;
message.value = auto_resolve_message; message.value = auto_resolve_message;
ignoreWaiting.value = auto_resolve_ignore_waiting; ignoreWaiting.value = auto_resolve_ignore_waiting;
// find the correct label option from the list
// the single select component expects the full label object
// in our case, the label id and name are both the same
labelToApply.value = labelOptions.value.find(
option => option.name === auto_resolve_label
);
// Set unit based on duration and its divisibility
if (duration.value) {
if (duration.value % (24 * 60) === 0) {
unit.value = DURATION_UNITS.DAYS;
} else if (duration.value % 60 === 0) {
unit.value = DURATION_UNITS.HOURS;
} else {
unit.value = DURATION_UNITS.MINUTES;
}
}
if (duration.value) { if (duration.value) {
isEnabled.value = true; isEnabled.value = true;
@@ -83,19 +40,16 @@ watch(
const updateAccountSettings = async settings => { const updateAccountSettings = async settings => {
try { try {
isSubmitting.value = true; await updateAccount(settings);
await updateAccount(settings, { silent: true }); useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.API.SUCCESS'));
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.SUCCESS'));
} catch (error) { } catch (error) {
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.ERROR')); useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.API.ERROR'));
} finally {
isSubmitting.value = false;
} }
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (duration.value < 10) { if (duration.value < 10) {
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.ERROR')); useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR'));
return Promise.resolve(); return Promise.resolve();
} }
@@ -103,7 +57,6 @@ const handleSubmit = async () => {
auto_resolve_after: duration.value, auto_resolve_after: duration.value,
auto_resolve_message: message.value, auto_resolve_message: message.value,
auto_resolve_ignore_waiting: ignoreWaiting.value, auto_resolve_ignore_waiting: ignoreWaiting.value,
auto_resolve_label: selectedLabelName.value,
}); });
}; };
@@ -115,7 +68,6 @@ const handleDisable = async () => {
auto_resolve_after: null, auto_resolve_after: null,
auto_resolve_message: '', auto_resolve_message: '',
auto_resolve_ignore_waiting: false, auto_resolve_ignore_waiting: false,
auto_resolve_label: null,
}); });
}; };
@@ -128,7 +80,6 @@ const toggleAutoResolve = async () => {
<SectionLayout <SectionLayout
:title="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE')" :title="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE')" :description="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE')"
:hide-content="!isEnabled"
with-border with-border
> >
<template #headerActions> <template #headerActions>
@@ -139,65 +90,50 @@ const toggleAutoResolve = async () => {
<form class="grid gap-5" @submit.prevent="handleSubmit"> <form class="grid gap-5" @submit.prevent="handleSubmit">
<WithLabel <WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.LABEL')" :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.HELP')" :help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.HELP')"
> >
<div class="gap-2 w-full grid grid-cols-[3fr_1fr]"> <div class="gap-2 w-full grid grid-cols-[3fr_1fr]">
<!-- allow 10 mins to 999 days --> <!-- allow 10 mins to 999 days -->
<DurationInput <DurationInput
v-model="duration" v-model="duration"
v-model:unit="unit"
min="0" min="0"
max="1438560" max="1439856"
class="w-full" class="w-full"
/> />
</div> </div>
</WithLabel> </WithLabel>
<WithLabel <WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')" :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')" :help-message="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_HELP')
"
> >
<TextArea <TextArea
v-model="message" v-model="message"
class="w-full" class="w-full"
:placeholder=" :placeholder="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER') t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_PLACEHOLDER')
" "
/> />
</WithLabel> </WithLabel>
<WithLabel :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.PREFERENCES')"> <WithLabel
<div :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_IGNORE_WAITING.LABEL')"
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 divide-y divide-n-weak" >
> <template #rightOfLabel>
<div class="p-3 h-12 flex items-center justify-between"> <Switch v-model="ignoreWaiting" />
<span> </template>
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.IGNORE_WAITING.LABEL') }} <p class="text-sm ml-px text-n-slate-10 max-w-lg">
</span> {{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_IGNORE_WAITING.HELP') }}
<Switch v-model="ignoreWaiting" /> </p>
</div>
<div class="p-3 h-12 flex items-center justify-between">
<span>
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.LABEL') }}
</span>
<SingleSelect
v-model="labelToApply"
:options="labelOptions"
:placeholder="
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.PLACEHOLDER')
"
placeholder-icon="i-lucide-chevron-down"
placeholder-trailing-icon
variant="faded"
/>
</div>
</div>
</WithLabel> </WithLabel>
<div class="flex gap-2"> <div class="flex gap-2">
<NextButton <NextButton
blue blue
type="submit" type="submit"
:is-loading="isSubmitting" :label="
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.UPDATE_BUTTON')" t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.UPDATE_BUTTON')
"
/> />
</div> </div>
</form> </form>
@@ -1,19 +1,24 @@
<script setup> <script setup>
defineProps({ defineProps({
title: { type: String, required: true }, title: {
description: { type: String, required: true }, type: String,
withBorder: { type: Boolean, default: false }, required: true,
hideContent: { type: Boolean, default: false }, },
description: {
type: String,
required: true,
},
withBorder: {
type: Boolean,
default: false,
},
}); });
</script> </script>
<template> <template>
<section <section
class="grid grid-cols-1 pt-8 gap-5 [interpolate-size:allow-keywords]" class="grid grid-cols-1 py-8 gap-8"
:class="{ :class="{ 'border-t border-n-weak': withBorder }"
'border-t border-n-weak': withBorder,
'pb-8': !hideContent,
}"
> >
<header class="grid grid-cols-4"> <header class="grid grid-cols-4">
<div class="col-span-3"> <div class="col-span-3">
@@ -28,10 +33,7 @@ defineProps({
<slot name="headerActions" /> <slot name="headerActions" />
</div> </div>
</header> </header>
<div <div class="text-n-slate-12">
class="transition-[height] duration-300 ease-in-out text-n-slate-12"
:class="{ 'overflow-hidden h-0': hideContent, 'h-auto': !hideContent }"
>
<slot /> <slot />
</div> </div>
</section> </section>
@@ -23,8 +23,6 @@ import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue'; import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default { export default {
components: { components: {
@@ -45,7 +43,6 @@ export default {
NextButton, NextButton,
InstagramReauthorize, InstagramReauthorize,
DuplicateInboxBanner, DuplicateInboxBanner,
Editor,
}, },
mixins: [inboxMixin], mixins: [inboxMixin],
setup() { setup() {
@@ -73,7 +70,6 @@ export default {
selectedTabIndex: 0, selectedTabIndex: 0,
selectedPortalSlug: '', selectedPortalSlug: '',
showBusinessNameInput: false, showBusinessNameInput: false,
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
}; };
}, },
computed: { computed: {
@@ -484,10 +480,10 @@ export default {
" "
/> />
<Editor <woot-input
v-if="isAWebWidgetInbox" v-if="isAWebWidgetInbox"
v-model="channelWelcomeTagline" v-model="channelWelcomeTagline"
class="mb-4" class="pb-4"
:label=" :label="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.LABEL') $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.LABEL')
" "
@@ -496,8 +492,6 @@ export default {
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.PLACEHOLDER' 'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.PLACEHOLDER'
) )
" "
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
/> />
<label v-if="isAWebWidgetInbox" class="pb-4"> <label v-if="isAWebWidgetInbox" class="pb-4">
@@ -7,16 +7,13 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators'; import { required } from '@vuelidate/validators';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage'; import { LocalStorage } from 'shared/helpers/localStorage';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default { export default {
components: { components: {
Widget, Widget,
InputRadioGroup, InputRadioGroup,
NextButton, NextButton,
Editor,
}, },
props: { props: {
inbox: { inbox: {
@@ -74,7 +71,6 @@ export default {
checked: false, checked: false,
}, },
], ],
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
}; };
}, },
computed: { computed: {
@@ -314,7 +310,7 @@ export default {
) )
" "
/> />
<Editor <woot-input
v-model="welcomeTagline" v-model="welcomeTagline"
:label=" :label="
$t( $t(
@@ -326,9 +322,6 @@ export default {
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_TAGLINE.PLACE_HOLDER' 'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_TAGLINE.PLACE_HOLDER'
) )
" "
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
class="mb-4"
/> />
<label> <label>
{{ {{
@@ -1,17 +1,21 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed, onMounted } from 'vue';
import ForwardToOption from './emailChannels/ForwardToOption.vue'; import ForwardToOption from './emailChannels/ForwardToOption.vue';
import Microsoft from './emailChannels/Microsoft.vue'; import Microsoft from './emailChannels/Microsoft.vue';
import Google from './emailChannels/Google.vue'; import Google from './emailChannels/Google.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue'; import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import PageHeader from '../../SettingsSubPageHeader.vue'; import PageHeader from '../../SettingsSubPageHeader.vue';
import { useAlert } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store'; import { useStoreGetters } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const provider = ref(''); const provider = ref('');
const getters = useStoreGetters(); const getters = useStoreGetters();
const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const globalConfig = getters['globalConfig/get']; const globalConfig = getters['globalConfig/get'];
@@ -45,6 +49,15 @@ const emailProviderList = computed(() => {
}); });
}); });
const checkForOAuthError = () => {
const { error } = route.query;
if (error) {
useAlert(decodeURIComponent(error));
}
};
onMounted(checkForOAuthError);
function onClick(emailProvider) { function onClick(emailProvider) {
if (emailProvider.isEnabled) { if (emailProvider.isEnabled) {
provider.value = emailProvider.key; provider.value = emailProvider.key;
@@ -5,15 +5,12 @@ import router from '../../../../index';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import PageHeader from '../../SettingsSubPageHeader.vue'; import PageHeader from '../../SettingsSubPageHeader.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue'; import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default { export default {
components: { components: {
PageHeader, PageHeader,
GreetingsEditor, GreetingsEditor,
NextButton, NextButton,
Editor,
}, },
data() { data() {
return { return {
@@ -24,7 +21,6 @@ export default {
channelWelcomeTagline: '', channelWelcomeTagline: '',
greetingEnabled: false, greetingEnabled: false,
greetingMessage: '', greetingMessage: '',
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
}; };
}, },
computed: { computed: {
@@ -138,21 +134,22 @@ export default {
/> />
</label> </label>
</div> </div>
<Editor <div class="w-full">
v-model="channelWelcomeTagline" <label>
:label=" {{
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.LABEL') $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.LABEL')
" }}
:placeholder=" <input
$t( v-model="channelWelcomeTagline"
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.PLACEHOLDER' type="text"
) :placeholder="
" $t(
:max-length="255" 'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.PLACEHOLDER'
:enabled-menu-options="welcomeTaglineEditorMenuOptions" )
class="mb-4" "
/> />
</label>
</div>
<label class="w-full"> <label class="w-full">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_TOGGLE.LABEL') }} {{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_TOGGLE.LABEL') }}
<select v-model="greetingEnabled"> <select v-model="greetingEnabled">
@@ -12,7 +12,6 @@ defineOptions({
provider="google" provider="google"
:title="$t('INBOX_MGMT.ADD.GOOGLE.TITLE')" :title="$t('INBOX_MGMT.ADD.GOOGLE.TITLE')"
:description="$t('INBOX_MGMT.ADD.GOOGLE.DESCRIPTION')" :description="$t('INBOX_MGMT.ADD.GOOGLE.DESCRIPTION')"
:input-placeholder="$t('INBOX_MGMT.ADD.GOOGLE.EMAIL_PLACEHOLDER')"
:submit-button-text="$t('INBOX_MGMT.ADD.GOOGLE.SIGN_IN')" :submit-button-text="$t('INBOX_MGMT.ADD.GOOGLE.SIGN_IN')"
:error-message="$t('INBOX_MGMT.ADD.GOOGLE.ERROR_MESSAGE')" :error-message="$t('INBOX_MGMT.ADD.GOOGLE.ERROR_MESSAGE')"
/> />
@@ -12,7 +12,6 @@ defineOptions({
provider="microsoft" provider="microsoft"
:title="$t('INBOX_MGMT.ADD.MICROSOFT.TITLE')" :title="$t('INBOX_MGMT.ADD.MICROSOFT.TITLE')"
:description="$t('INBOX_MGMT.ADD.MICROSOFT.DESCRIPTION')" :description="$t('INBOX_MGMT.ADD.MICROSOFT.DESCRIPTION')"
:input-placeholder="$t('INBOX_MGMT.ADD.MICROSOFT.EMAIL_PLACEHOLDER')"
:submit-button-text="$t('INBOX_MGMT.ADD.MICROSOFT.SIGN_IN')" :submit-button-text="$t('INBOX_MGMT.ADD.MICROSOFT.SIGN_IN')"
:error-message="$t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE')" :error-message="$t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE')"
/> />
@@ -30,14 +30,9 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
inputPlaceholder: {
type: String,
required: true,
},
}); });
const isRequestingAuthorization = ref(false); const isRequestingAuthorization = ref(false);
const email = ref('');
const client = computed(() => { const client = computed(() => {
if (props.provider === 'microsoft') { if (props.provider === 'microsoft') {
@@ -50,9 +45,7 @@ const client = computed(() => {
async function requestAuthorization() { async function requestAuthorization() {
try { try {
isRequestingAuthorization.value = true; isRequestingAuthorization.value = true;
const response = await client.value.generateAuthorization({ const response = await client.value.generateAuthorization();
email: email.value,
});
const { const {
data: { url }, data: { url },
} = response; } = response;
@@ -75,11 +68,6 @@ async function requestAuthorization() {
:header-content="description" :header-content="description"
/> />
<form class="mt-6" @submit.prevent="requestAuthorization"> <form class="mt-6" @submit.prevent="requestAuthorization">
<woot-input
v-model="email"
type="email"
:placeholder="inputPlaceholder"
/>
<NextButton <NextButton
:is-loading="isRequestingAuthorization" :is-loading="isRequestingAuthorization"
type="submit" type="submit"
@@ -1,35 +1,19 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref } from 'vue';
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue'; import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue';
import googleClient from 'dashboard/api/channel/googleClient'; import googleClient from 'dashboard/api/channel/googleClient';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
const props = defineProps({
inbox: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n(); const { t } = useI18n();
const isRequestingAuthorization = ref(false); const isRequestingAuthorization = ref(false);
const inboxEmail = computed(() => {
if (props.inbox.imap_login && props.inbox.imap_enabled) {
return props.inbox.imap_login;
}
return props.inbox.email;
});
async function requestAuthorization() { async function requestAuthorization() {
try { try {
isRequestingAuthorization.value = true; isRequestingAuthorization.value = true;
const response = await googleClient.generateAuthorization({ const response = await googleClient.generateAuthorization();
email: inboxEmail.value,
});
const { const {
data: { url }, data: { url },
@@ -6,13 +6,6 @@ import microsoftClient from 'dashboard/api/channel/microsoftClient';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
const props = defineProps({
inbox: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n(); const { t } = useI18n();
const isRequestingAuthorization = ref(false); const isRequestingAuthorization = ref(false);
@@ -20,9 +13,7 @@ const isRequestingAuthorization = ref(false);
async function requestAuthorization() { async function requestAuthorization() {
try { try {
isRequestingAuthorization.value = true; isRequestingAuthorization.value = true;
const response = await microsoftClient.generateAuthorization({ const response = await microsoftClient.generateAuthorization();
email: props.inbox.email,
});
const { const {
data: { url }, data: { url },
@@ -1,5 +1,7 @@
<template> <template>
<div class="reports--wrapper overflow-auto bg-n-background w-full px-6"> <div
class="reports--wrapper overflow-auto bg-n-background w-full px-8 xl:px-0"
>
<div class="max-w-[60rem] mx-auto pb-12"> <div class="max-w-[60rem] mx-auto pb-12">
<router-view /> <router-view />
</div> </div>
@@ -6,9 +6,9 @@ export default createStore({
API: CopilotMessagesAPI, API: CopilotMessagesAPI,
getters: { getters: {
getMessagesByThreadId: state => copilotThreadId => { getMessagesByThreadId: state => copilotThreadId => {
return state.records return state.records.filter(
.filter(record => record.copilot_thread?.id === Number(copilotThreadId)) record => record.copilot_thread?.id === Number(copilotThreadId)
.sort((a, b) => a.id - b.id); );
}, },
}, },
actions: mutationTypes => ({ actions: mutationTypes => ({
@@ -63,11 +63,8 @@ export const actions = {
}); });
} }
}, },
update: async ({ commit }, { options, ...updateObj }) => { update: async ({ commit }, updateObj) => {
if (options?.silent !== true) { commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
}
try { try {
const response = await AccountAPI.update('', updateObj); const response = await AccountAPI.update('', updateObj);
commit(types.default.EDIT_ACCOUNT, response.data); commit(types.default.EDIT_ACCOUNT, response.data);
@@ -75,21 +75,6 @@ export const actions = {
} }
}, },
active: async ({ commit }, { page = 1, sortAttr } = {}) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
try {
const {
data: { payload, meta },
} = await ContactAPI.active(page, sortAttr);
commit(types.CLEAR_CONTACTS);
commit(types.SET_CONTACTS, payload);
commit(types.SET_CONTACT_META, meta);
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
}
},
show: async ({ commit }, { id }) => { show: async ({ commit }, { id }) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetchingItem: true }); commit(types.SET_CONTACT_UI_FLAG, { isFetchingItem: true });
try { try {
@@ -327,16 +327,6 @@ const actions = {
} }
}, },
deleteConversation: async ({ commit, dispatch }, conversationId) => {
try {
await ConversationApi.delete(conversationId);
commit(types.DELETE_CONVERSATION, conversationId);
dispatch('conversationStats/get', {}, { root: true });
} catch (error) {
throw new Error(error);
}
},
addConversation({ commit, state, dispatch, rootState }, conversation) { addConversation({ commit, state, dispatch, rootState }, conversation) {
const { currentInbox, appliedFilters } = state; const { currentInbox, appliedFilters } = state;
const { const {
@@ -47,7 +47,6 @@
* 3. Nested properties in custom_attributes (conversation_type, etc.) * 3. Nested properties in custom_attributes (conversation_type, etc.)
*/ */
import jsonLogic from 'json-logic-js'; import jsonLogic from 'json-logic-js';
import { coerceToDate } from '@chatwoot/utils';
/** /**
* Gets a value from a conversation based on the attribute key * Gets a value from a conversation based on the attribute key
@@ -158,20 +157,6 @@ const contains = (filterValue, conversationValue) => {
return false; return false;
}; };
/**
* Compares two date values using a comparison function
* @param {*} conversationValue - The conversation value to compare
* @param {*} filterValue - The filter value to compare against
* @param {Function} compareFn - The comparison function to apply
* @returns {Boolean} - Returns true if the comparison succeeds, false otherwise
*/
const compareDates = (conversationValue, filterValue, compareFn) => {
const conversationDate = coerceToDate(conversationValue);
const filterDate = coerceToDate(filterValue);
if (conversationDate === null || filterDate === null) return false;
return compareFn(conversationDate, filterDate);
};
/** /**
* Checks if a value matches a filter condition * Checks if a value matches a filter condition
* @param {*} conversationValue - The value to check * @param {*} conversationValue - The value to check
@@ -210,10 +195,10 @@ const matchesCondition = (conversationValue, filter) => {
return false; // We already handled null/undefined above return false; // We already handled null/undefined above
case 'is_greater_than': case 'is_greater_than':
return compareDates(conversationValue, filterValue, (a, b) => a > b); return new Date(conversationValue) > new Date(filterValue);
case 'is_less_than': case 'is_less_than':
return compareDates(conversationValue, filterValue, (a, b) => a < b); return new Date(conversationValue) < new Date(filterValue);
case 'days_before': { case 'days_before': {
const today = new Date(); const today = new Date();
@@ -362,7 +347,6 @@ export const matchesFilters = (conversation, filters) => {
conversation, conversation,
filters[0].attribute_key filters[0].attribute_key
); );
return matchesCondition(value, filters[0]); return matchesCondition(value, filters[0]);
} }
@@ -463,241 +463,6 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(true); expect(matchesFilters(conversation, filters)).toBe(true);
}); });
// Test conversation with 10-digit timestamp (seconds) vs standard date filter
it('should match conversation with 10-digit timestamp against date string filter', () => {
const conversation = { created_at: 1647777600 }; // March 20, 2022 in seconds (10 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19', // Standard YYYY-MM-DD format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test conversation with 13-digit timestamp (milliseconds) vs standard date filter
it('should match conversation with 13-digit timestamp against date string filter', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022 in milliseconds (13 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19', // Standard YYYY-MM-DD format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test conversation with string timestamp vs standard date filter
it('should match conversation with string 10-digit timestamp against date string filter', () => {
const conversation = { created_at: '1647777600' }; // March 20, 2022 as string (10 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19', // Standard YYYY-MM-DD format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test conversation with string 13-digit timestamp vs standard date filter
it('should match conversation with string 13-digit timestamp against date string filter', () => {
const conversation = { created_at: '1647777600000' }; // March 20, 2022 as string (13 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19', // Standard YYYY-MM-DD format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test conversation with mixed format vs standard date filter with time
it('should match conversation with numeric timestamp against ISO date string filter', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022 12:00:00 GMT (numeric)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19T10:30:00Z', // Standard ISO format from filter
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test parseDate with date string without time (should default to 00:00:00)
it('should match conversation with is_greater_than operator using date string without time', () => {
const conversation = { created_at: 1647820800000 }; // March 21, 2022 00:00:00 GMT
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-20', // March 20, 2022 (should become 00:00:00)
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test parseDate with ISO date string
it('should match conversation with is_greater_than operator using ISO date string', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19T00:00:00.000Z', // March 19, 2022 ISO format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test parseDate with null/undefined values
it('should handle null filter values in date comparison', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: null,
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should handle undefined filter values in date comparison', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: undefined,
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
// Test parseDate with invalid date strings
it('should handle invalid date strings in date comparison', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: 'invalid-date-string',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should handle non-date string values in date comparison', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: 'not-a-date',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
// Test is_less_than with various date formats
it('should match conversation with is_less_than operator using numeric timestamp', () => {
const conversation = { created_at: 1647691200000 }; // March 19, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_less_than',
values: 1647777600, // March 20, 2022 as 10-digit timestamp
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should not match conversation with is_less_than operator when date is later', () => {
const conversation = { created_at: 1647864000000 }; // March 21, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_less_than',
values: '2022-03-20T12:00:00Z', // March 20, 2022 with time
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
// Edge case: Test with conversation having string timestamp
it('should handle conversation with string timestamp value', () => {
const conversation = { created_at: '1647777600000' }; // March 20, 2022 as string
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Edge case: Test with conversation having 10-digit timestamp
it('should handle conversation with 10-digit timestamp value', () => {
const conversation = { created_at: 1647777600 }; // March 20, 2022 as seconds (10 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test date string with different time formats
it('should handle date string with space-separated time', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19 10:30:00', // Date with space-separated time
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test parseDate with object input (should return null and fail comparison)
it('should handle non-string, non-number filter values', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: { date: '2022-03-19' }, // Object instead of string/number
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
describe('days_before operator', () => { describe('days_before operator', () => {
beforeEach(() => { beforeEach(() => {
// Set the date to March 25, 2022 // Set the date to March 25, 2022
@@ -204,12 +204,6 @@ export const mutations = {
_state.allConversations.push(conversation); _state.allConversations.push(conversation);
}, },
[types.DELETE_CONVERSATION](_state, conversationId) {
_state.allConversations = _state.allConversations.filter(
c => c.id !== conversationId
);
},
[types.UPDATE_CONVERSATION](_state, conversation) { [types.UPDATE_CONVERSATION](_state, conversation) {
const { allConversations } = _state; const { allConversations } = _state;
const index = allConversations.findIndex(c => c.id === conversation.id); const index = allConversations.findIndex(c => c.id === conversation.id);
@@ -70,30 +70,6 @@ describe('#actions', () => {
}); });
}); });
describe('#active', () => {
it('sends correct mutations if API is success', async () => {
axios.get.mockResolvedValue({
data: { payload: contactList, meta: { count: 100, current_page: 1 } },
});
await actions.active({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isFetching: true }],
[types.CLEAR_CONTACTS],
[types.SET_CONTACTS, contactList],
[types.SET_CONTACT_META, { count: 100, current_page: 1 }],
[types.SET_CONTACT_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct mutations if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.active({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isFetching: true }],
[types.SET_CONTACT_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#update', () => { describe('#update', () => {
it('sends correct mutations if API is success', async () => { it('sends correct mutations if API is success', async () => {
axios.patch.mockResolvedValue({ data: { payload: contactList[0] } }); axios.patch.mockResolvedValue({ data: { payload: contactList[0] } });
@@ -513,28 +513,6 @@ describe('#deleteMessage', () => {
expect(commit.mock.calls).toEqual([]); expect(commit.mock.calls).toEqual([]);
}); });
describe('#deleteConversation', () => {
it('send correct actions if API is success', async () => {
axios.delete.mockResolvedValue({
data: { id: 1 },
});
await actions.deleteConversation({ commit, dispatch }, 1);
expect(commit.mock.calls).toEqual([[types.DELETE_CONVERSATION, 1]]);
expect(dispatch.mock.calls).toEqual([
['conversationStats/get', {}, { root: true }],
]);
});
it('send no actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.deleteConversation({ commit, dispatch }, 1)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([]);
expect(dispatch.mock.calls).toEqual([]);
});
});
describe('#updateCustomAttributes', () => { describe('#updateCustomAttributes', () => {
it('update conversation custom attributes', async () => { it('update conversation custom attributes', async () => {
axios.post.mockResolvedValue({ axios.post.mockResolvedValue({
@@ -884,17 +884,6 @@ describe('#mutations', () => {
}); });
}); });
describe('#DELETE_CONVERSATION', () => {
it('should delete a conversation', () => {
const state = {
allConversations: [{ id: 1, messages: [] }],
};
mutations[types.DELETE_CONVERSATION](state, 1);
expect(state.allConversations).toEqual([]);
});
});
describe('#SET_LIST_LOADING_STATUS', () => { describe('#SET_LIST_LOADING_STATUS', () => {
it('should set listLoadingStatus to true', () => { it('should set listLoadingStatus to true', () => {
const state = { const state = {
@@ -56,7 +56,6 @@ export default {
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS', SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS', ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS',
DELETE_CONVERSATION_ATTACHMENTS: 'DELETE_CONVERSATION_ATTACHMENTS', DELETE_CONVERSATION_ATTACHMENTS: 'DELETE_CONVERSATION_ATTACHMENTS',
DELETE_CONVERSATION: 'DELETE_CONVERSATION',
SET_CONVERSATION_CAN_REPLY: 'SET_CONVERSATION_CAN_REPLY', SET_CONVERSATION_CAN_REPLY: 'SET_CONVERSATION_CAN_REPLY',
@@ -1,7 +1,6 @@
<script setup> <script setup>
import HeaderActions from './HeaderActions.vue'; import HeaderActions from './HeaderActions.vue';
import { computed } from 'vue'; import { computed } from 'vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
const props = defineProps({ const props = defineProps({
avatarUrl: { avatarUrl: {
@@ -22,8 +21,6 @@ const props = defineProps({
}, },
}); });
const { formatMessage } = useMessageFormatter();
const containerClasses = computed(() => [ const containerClasses = computed(() => [
props.avatarUrl ? 'justify-between' : 'justify-end', props.avatarUrl ? 'justify-between' : 'justify-end',
]); ]);
@@ -50,8 +47,8 @@ const containerClasses = computed(() => [
class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12" class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12"
/> />
<p <p
v-dompurify-html="formatMessage(introBody)" v-dompurify-html="introBody"
class="text-lg leading-normal text-n-slate-11 [&_a]:underline" class="text-lg leading-normal text-n-slate-11"
/> />
</header> </header>
</template> </template>
-1
View File
@@ -8,7 +8,6 @@ class Conversations::ResolutionJob < ApplicationJob
# send message from bot that conversation has been resolved # send message from bot that conversation has been resolved
# do this is account.auto_resolve_message is set # do this is account.auto_resolve_message is set
::MessageTemplates::Template::AutoResolve.new(conversation: conversation).perform if account.auto_resolve_message.present? ::MessageTemplates::Template::AutoResolve.new(conversation: conversation).perform if account.auto_resolve_message.present?
conversation.add_labels(account.auto_resolve_label) if account.auto_resolve_label.present?
conversation.toggle_status conversation.toggle_status
end end
end end
@@ -4,8 +4,7 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer
@agent = agent @agent = agent
@conversation = conversation @conversation = conversation
inbox_name = @conversation.inbox&.sanitized_name subject = "#{@agent.available_name}, A new conversation [ID - #{@conversation.display_id}] has been created in #{@conversation.inbox&.name}."
subject = "#{@agent.available_name}, A new conversation [ID - #{@conversation.display_id}] has been created in #{inbox_name}."
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id) @action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
send_mail_with_liquid(to: @agent.email, subject: subject) and return send_mail_with_liquid(to: @agent.email, subject: subject) and return
end end
+1 -1
View File
@@ -104,7 +104,7 @@ class ConversationReplyMailer < ApplicationMailer
end end
def business_name def business_name
@inbox.business_name || @inbox.sanitized_name @inbox.business_name || @inbox.name
end end
def from_email def from_email
+1 -4
View File
@@ -35,9 +35,7 @@ class Account < ApplicationRecord
{ {
'auto_resolve_after': { 'type': %w[integer null], 'minimum': 10, 'maximum': 1_439_856 }, 'auto_resolve_after': { 'type': %w[integer null], 'minimum': 10, 'maximum': 1_439_856 },
'auto_resolve_message': { 'type': %w[string null] }, 'auto_resolve_message': { 'type': %w[string null] },
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] }, 'auto_resolve_ignore_waiting': { 'type': %w[boolean null] }
'audio_transcriptions': { 'type': %w[boolean null] },
'auto_resolve_label': { 'type': %w[string null] }
}, },
'required': [], 'required': [],
'additionalProperties': true 'additionalProperties': true
@@ -54,7 +52,6 @@ class Account < ApplicationRecord
attribute_resolver: ->(record) { record.settings } attribute_resolver: ->(record) { record.settings }
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
has_many :account_users, dependent: :destroy_async has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async has_many :agent_bot_inboxes, dependent: :destroy_async
+7 -13
View File
@@ -76,24 +76,18 @@ class Article < ApplicationRecord
scope :order_by_views, -> { reorder(views: :desc) } scope :order_by_views, -> { reorder(views: :desc) }
# TODO: if text search slows down https://www.postgresql.org/docs/current/textsearch-features.html#TEXTSEARCH-UPDATE-TRIGGERS # TODO: if text search slows down https://www.postgresql.org/docs/current/textsearch-features.html#TEXTSEARCH-UPDATE-TRIGGERS
# - the A, B and C are for weightage. See: https://github.com/Casecommons/pg_search#weighting
# - the normalization is for ensuring the long articles that mention the search term too many times are not ranked higher.
# it divides rank by log(document_length) to prevent longer articles from ranking higher just due to sizeSee: https://github.com/Casecommons/pg_search#normalization
# - the ranking is to ensure that articles with higher weightage are ranked higher
pg_search_scope( pg_search_scope(
:text_search, :text_search,
against: { against: %i[
title: 'A', title
description: 'B', description
content: 'C' content
}, ],
using: { using: {
tsearch: { tsearch: {
prefix: true, prefix: true
normalization: 2
} }
}, }
ranked_by: ':tsearch'
) )
def self.search(params) def self.search(params)
+4 -27
View File
@@ -44,8 +44,11 @@ class Attachment < ApplicationRecord
def push_event_data def push_event_data
return unless file_type return unless file_type
return base_data.merge(location_metadata) if file_type.to_sym == :location
return base_data.merge(fallback_data) if file_type.to_sym == :fallback
return base_data.merge(contact_metadata) if file_type.to_sym == :contact
base_data.merge(metadata_for_file_type) base_data.merge(file_metadata)
end end
# NOTE: the URl returned does a 301 redirect to the actual file # NOTE: the URl returned does a 301 redirect to the actual file
@@ -73,30 +76,6 @@ class Attachment < ApplicationRecord
private private
def metadata_for_file_type
case file_type.to_sym
when :location
location_metadata
when :fallback
fallback_data
when :contact
contact_metadata
when :audio
audio_metadata
else
file_metadata
end
end
def audio_metadata
audio_file_data = base_data.merge(file_metadata)
audio_file_data.merge(
{
transcribed_text: meta&.[]('transcribed_text') || ''
}
)
end
def file_metadata def file_metadata
metadata = { metadata = {
extension: extension, extension: extension,
@@ -170,5 +149,3 @@ class Attachment < ApplicationRecord
file_content_type.start_with?('image/', 'video/', 'audio/') file_content_type.start_with?('image/', 'video/', 'audio/')
end end
end end
Attachment.include_mod_with('Concerns::Attachment')
-1
View File
@@ -305,6 +305,5 @@ class Conversation < ApplicationRecord
end end
end end
Conversation.include_mod_with('Audit::Conversation')
Conversation.include_mod_with('Concerns::Conversation') Conversation.include_mod_with('Concerns::Conversation')
Conversation.prepend_mod_with('Conversation') Conversation.prepend_mod_with('Conversation')
+2 -26
View File
@@ -47,6 +47,8 @@ class Inbox < ApplicationRecord
# Not allowing characters: # Not allowing characters:
validates :name, presence: true validates :name, presence: true
validates :name, if: :check_channel_type?, format: { with: %r{^^\b[^/\\<>@]*\b$}, multiline: true,
message: I18n.t('errors.inboxes.validations.name') }
validates :account_id, presence: true validates :account_id, presence: true
validates :timezone, inclusion: { in: TZInfo::Timezone.all_identifiers } validates :timezone, inclusion: { in: TZInfo::Timezone.all_identifiers }
validates :out_of_office_message, length: { maximum: Limits::OUT_OF_OFFICE_MESSAGE_MAX_LENGTH } validates :out_of_office_message, length: { maximum: Limits::OUT_OF_OFFICE_MESSAGE_MAX_LENGTH }
@@ -97,16 +99,6 @@ class Inbox < ApplicationRecord
update_account_cache update_account_cache
end end
# Sanitizes inbox name for balanced email provider compatibility
# ALLOWS: /'._- and Unicode letters/numbers/emojis
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
def sanitized_name
return default_name_for_blank_name if name.blank?
sanitized = apply_sanitization_rules(name)
sanitized.blank? && email? ? display_name_from_email : sanitized
end
def sms? def sms?
channel_type == 'Channel::Sms' channel_type == 'Channel::Sms'
end end
@@ -186,22 +178,6 @@ class Inbox < ApplicationRecord
private private
def default_name_for_blank_name
email? ? display_name_from_email : ''
end
def apply_sanitization_rules(name)
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~]/, '') # Remove forbidden chars
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
.gsub(/\s+/, ' ') # Normalize spaces
.strip
end
def display_name_from_email
channel.email.split('@').first.parameterize.titleize
end
def dispatch_create_event def dispatch_create_event
return if ENV['ENABLE_INBOX_EVENTS'].blank? return if ENV['ENABLE_INBOX_EVENTS'].blank?
+2 -6
View File
@@ -224,11 +224,6 @@ class Message < ApplicationRecord
save! save!
end end
def send_update_event
Rails.configuration.dispatcher.dispatch(MESSAGE_UPDATED, Time.zone.now, message: self, performed_by: Current.executed_by,
previous_changes: previous_changes)
end
private private
def prevent_message_flooding def prevent_message_flooding
@@ -318,7 +313,8 @@ class Message < ApplicationRecord
# we want to skip the update event if the message is not updated # we want to skip the update event if the message is not updated
return if previous_changes.blank? return if previous_changes.blank?
send_update_event Rails.configuration.dispatcher.dispatch(MESSAGE_UPDATED, Time.zone.now, message: self, performed_by: Current.executed_by,
previous_changes: previous_changes)
end end
def send_reply def send_reply
+1 -1
View File
@@ -12,7 +12,7 @@ class ContactPolicy < ApplicationPolicy
end end
def export? def export?
@account_user.administrator? || @account_user.agent? @account_user.administrator?
end end
def search? def search?

Some files were not shown because too many files have changed in this diff Show More