Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8b65f86c9 | ||
|
|
4339e0ee0a | ||
|
|
e76650d6d0 | ||
|
|
3b7b94f8d1 | ||
|
|
6c8741b314 | ||
|
|
68e358d732 | ||
|
|
d20950c5b4 | ||
|
|
9c17a6f302 | ||
|
|
2628d7a7fd | ||
|
|
94daf26ead | ||
|
|
7422b656cd | ||
|
|
39a93db007 | ||
|
|
b495aad1e6 | ||
|
|
b33eecaff7 | ||
|
|
7c16071fc7 | ||
|
|
b981ba766f | ||
|
|
37c8e7e699 | ||
|
|
75c2f91019 | ||
|
|
56e30102eb | ||
|
|
6fbff026eb | ||
|
|
52da165cb7 | ||
|
|
03fb6591e0 | ||
|
|
c4a6a19e9b | ||
|
|
b9757447a8 | ||
|
|
e5d66020fd | ||
|
|
6b1d8203c6 | ||
|
|
4550c4130b | ||
|
|
e09496078b | ||
|
|
4cdfe4168c | ||
|
|
0722750a55 | ||
|
|
bef25781de | ||
|
|
1d7a9093d2 | ||
|
|
3c67c41544 | ||
|
|
d0ecdc14d8 | ||
|
|
b1db6c3e9b | ||
|
|
3d20a7b049 | ||
|
|
3cd8cf43ce | ||
|
|
f33e469e9a | ||
|
|
27f2c2b392 | ||
|
|
40deaef458 | ||
|
|
3fae800936 |
@@ -98,6 +98,8 @@ SMTP_OPENSSL_VERIFY_MODE=peer
|
||||
# Mail Incoming
|
||||
# This is the domain set for the reply emails when conversation continuity is enabled
|
||||
MAILER_INBOUND_EMAIL_DOMAIN=
|
||||
# Maximum time in seconds to process a single IMAP email
|
||||
# EMAIL_PROCESSING_TIMEOUT_SECONDS=60
|
||||
# Set this to the appropriate ingress channel with regards to incoming emails
|
||||
# Possible values are :
|
||||
# relay for Exim, Postfix, Qmail
|
||||
@@ -232,6 +234,10 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
||||
# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules
|
||||
# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10
|
||||
|
||||
## SafeFetch private network access
|
||||
## Keep disabled by default. Self-hosted installations can enable this to allow SafeFetch requests to private network URLs.
|
||||
# SAFE_FETCH_ALLOW_PRIVATE_NETWORK=false
|
||||
|
||||
## Running chatwoot as an API only server
|
||||
## setting this value to true will disable the frontend dashboard endpoints
|
||||
# CW_API_ONLY_SERVER=false
|
||||
|
||||
@@ -89,7 +89,7 @@ gem 'rails-i18n', '~> 7.0'
|
||||
# two-factor authentication
|
||||
gem 'devise-two-factor', '>= 5.0.0'
|
||||
# authorization
|
||||
gem 'jwt'
|
||||
gem 'jwt', '~> 2.10', '>= 2.10.3'
|
||||
gem 'pundit'
|
||||
|
||||
# super admin
|
||||
@@ -209,6 +209,8 @@ gem 'opentelemetry-exporter-otlp'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
##############################################################
|
||||
|
||||
|
||||
+5
-3
@@ -301,7 +301,7 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.14.1)
|
||||
faraday (2.14.2)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
@@ -339,6 +339,7 @@ GEM
|
||||
ffi-compiler (1.0.1)
|
||||
ffi (>= 1.0.0)
|
||||
rake
|
||||
firecrawl-sdk (1.4.1)
|
||||
flag_shih_tzu (0.3.23)
|
||||
foreman (0.87.2)
|
||||
fugit (1.11.1)
|
||||
@@ -490,7 +491,7 @@ GEM
|
||||
judoscale-sidekiq (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
sidekiq (>= 5.0)
|
||||
jwt (2.10.1)
|
||||
jwt (2.10.3)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
@@ -1079,6 +1080,7 @@ DEPENDENCIES
|
||||
faker
|
||||
faraday_middleware-aws-sigv4
|
||||
fcm
|
||||
firecrawl-sdk (~> 1.0)
|
||||
flag_shih_tzu
|
||||
foreman
|
||||
gemoji
|
||||
@@ -1100,7 +1102,7 @@ DEPENDENCIES
|
||||
json_schemer
|
||||
judoscale-rails
|
||||
judoscale-sidekiq
|
||||
jwt
|
||||
jwt (~> 2.10, >= 2.10.3)
|
||||
kaminari
|
||||
koala
|
||||
letter_opener
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.14.0
|
||||
4.14.1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
before_action :check_authorization
|
||||
before_action :set_articles, only: [:update_status, :delete_articles]
|
||||
before_action :set_articles, only: [:update_status, :update_category, :delete_articles]
|
||||
|
||||
def translate
|
||||
head :not_implemented
|
||||
@@ -19,6 +19,18 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def update_category
|
||||
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
|
||||
return render_could_not_create_error(I18n.t('portals.articles.category_not_found')) unless category_valid?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@articles.find_each { |article| article.update!(category_id: params[:category_id]) }
|
||||
end
|
||||
head :ok
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def delete_articles
|
||||
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
|
||||
|
||||
@@ -39,5 +51,9 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
|
||||
def set_articles
|
||||
@articles = @portal.articles.where(id: params[:ids])
|
||||
end
|
||||
|
||||
def category_valid?
|
||||
@portal.categories.exists?(id: params[:category_id])
|
||||
end
|
||||
end
|
||||
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_unread_counts_enabled
|
||||
|
||||
def index
|
||||
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
|
||||
render json: { payload: counts }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_unread_counts_enabled
|
||||
return if Current.account.feature_enabled?('conversation_unread_counts')
|
||||
|
||||
render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden
|
||||
end
|
||||
end
|
||||
@@ -162,6 +162,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
@conversation.update_columns(updates)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
|
||||
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
|
||||
end
|
||||
|
||||
def should_update_last_seen?
|
||||
|
||||
@@ -6,8 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
|
||||
{
|
||||
redirect_uri: "#{base_url}/microsoft/callback",
|
||||
scope: scope,
|
||||
state: state,
|
||||
prompt: 'consent'
|
||||
state: state
|
||||
}
|
||||
)
|
||||
if redirect_url
|
||||
|
||||
@@ -79,8 +79,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
params.require(:portal).permit(
|
||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:name, :page_title, :slug, :archived,
|
||||
{ config: [:default_locale, :layout, *Portal::ANALYTICS_CONFIG_FORMATS.keys.map(&:to_sym),
|
||||
{ allowed_locales: [] }, { draft_locales: [] },
|
||||
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
|
||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
|
||||
)
|
||||
end
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
class Api::V1::Accounts::WorkingHoursController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
before_action :fetch_webhook, only: [:update]
|
||||
|
||||
def update
|
||||
@working_hour.update!(working_hour_params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def working_hour_params
|
||||
params.require(:working_hour).permit(:inbox_id, :open_hour, :open_minutes, :close_hour, :close_minutes, :closed_all_day)
|
||||
end
|
||||
|
||||
def fetch_working_hour
|
||||
@working_hour = Current.account.working_hours.find(params[:id])
|
||||
end
|
||||
end
|
||||
@@ -31,7 +31,11 @@ class Twilio::CallbackController < ApplicationController
|
||||
:Latitude,
|
||||
:Longitude,
|
||||
:MessageType,
|
||||
:ProfileName
|
||||
:ProfileName,
|
||||
:ExternalUserId,
|
||||
:ParentExternalUserId,
|
||||
:ProfileUsername,
|
||||
:Username
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,6 +17,7 @@ class AsyncDispatcher < BaseDispatcher
|
||||
InstallationWebhookListener.instance,
|
||||
NotificationListener.instance,
|
||||
ParticipationListener.instance,
|
||||
Conversations::UnreadCounts::Listener.instance,
|
||||
ReportingEventListener.instance,
|
||||
WebhookListener.instance
|
||||
]
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../../ApiClient';
|
||||
|
||||
class WhatsappCallsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('whatsapp_calls', { accountScoped: true });
|
||||
}
|
||||
|
||||
show(callId) {
|
||||
return axios.get(`${this.url}/${callId}`).then(r => r.data);
|
||||
}
|
||||
|
||||
initiate(conversationId, sdpOffer) {
|
||||
return axios
|
||||
.post(`${this.url}/initiate`, {
|
||||
conversation_id: conversationId,
|
||||
sdp_offer: sdpOffer,
|
||||
})
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
accept(callId, sdpAnswer) {
|
||||
return axios
|
||||
.post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer })
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
reject(callId) {
|
||||
return axios.post(`${this.url}/${callId}/reject`).then(r => r.data);
|
||||
}
|
||||
|
||||
terminate(callId) {
|
||||
return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data);
|
||||
}
|
||||
|
||||
uploadRecording(callId, blob, filename = 'call-recording.webm') {
|
||||
const formData = new FormData();
|
||||
formData.append('recording', blob, filename);
|
||||
return axios
|
||||
.post(`${this.url}/${callId}/upload_recording`, formData)
|
||||
.then(r => r.data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new WhatsappCallsAPI();
|
||||
@@ -35,8 +35,9 @@ class ContactAPI extends ApiClient {
|
||||
return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data);
|
||||
}
|
||||
|
||||
getConversations(contactId) {
|
||||
return axios.get(`${this.url}/${contactId}/conversations`);
|
||||
getConversations(contactId, { inboxId } = {}) {
|
||||
const params = inboxId ? { inbox_id: inboxId } : {};
|
||||
return axios.get(`${this.url}/${contactId}/conversations`, { params });
|
||||
}
|
||||
|
||||
getContactableInboxes(contactId) {
|
||||
@@ -47,9 +48,10 @@ class ContactAPI extends ApiClient {
|
||||
return axios.get(`${this.url}/${contactId}/labels`);
|
||||
}
|
||||
|
||||
initiateCall(contactId, inboxId) {
|
||||
initiateCall(contactId, inboxId, conversationId = null) {
|
||||
return axios.post(`${this.url}/${contactId}/call`, {
|
||||
inbox_id: inboxId,
|
||||
conversation_id: conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ class ConversationApi extends ApiClient {
|
||||
updateLabels(conversationID, labels) {
|
||||
return axios.post(`${this.url}/${conversationID}/labels`, { labels });
|
||||
}
|
||||
|
||||
getUnreadCounts() {
|
||||
return axios.get(`${this.url}/unread_counts`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConversationApi();
|
||||
|
||||
@@ -87,6 +87,13 @@ class ArticlesAPI extends PortalsAPI {
|
||||
);
|
||||
}
|
||||
|
||||
bulkUpdateCategory({ portalSlug, articleIds, categoryId }) {
|
||||
return axios.patch(
|
||||
`${this.url}/${portalSlug}/articles/bulk_actions/update_category`,
|
||||
{ ids: articleIds, category_id: categoryId }
|
||||
);
|
||||
}
|
||||
|
||||
bulkDelete({ portalSlug, articleIds }) {
|
||||
return axios.delete(
|
||||
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
|
||||
|
||||
@@ -52,6 +52,14 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
resetSecret(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/reset_secret`);
|
||||
}
|
||||
|
||||
enableWhatsappCalling(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/enable_whatsapp_calling`);
|
||||
}
|
||||
|
||||
disableWhatsappCalling(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -153,4 +153,33 @@ describe('#PortalAPI', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('API calls', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
post: vi.fn(() => Promise.resolve()),
|
||||
get: vi.fn(() => Promise.resolve()),
|
||||
patch: vi.fn(() => Promise.resolve()),
|
||||
delete: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('#bulkUpdateCategory', () => {
|
||||
articlesAPI.bulkUpdateCategory({
|
||||
portalSlug: 'room-rental',
|
||||
articleIds: [1, 2, 3],
|
||||
categoryId: 7,
|
||||
});
|
||||
expect(axiosMock.patch).toHaveBeenCalledWith(
|
||||
'/api/v1/portals/room-rental/articles/bulk_actions/update_category',
|
||||
{ ids: [1, 2, 3], category_id: 7 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,8 @@ describe('#ContactsAPI', () => {
|
||||
it('#getConversations', () => {
|
||||
contactAPI.getConversations(1);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/1/conversations'
|
||||
'/api/v1/contacts/1/conversations',
|
||||
{ params: {} }
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ describe('#ConversationApi', () => {
|
||||
expect(conversationsAPI).toHaveProperty('delete');
|
||||
expect(conversationsAPI).toHaveProperty('getLabels');
|
||||
expect(conversationsAPI).toHaveProperty('updateLabels');
|
||||
expect(conversationsAPI).toHaveProperty('getUnreadCounts');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
@@ -47,5 +48,12 @@ describe('#ConversationApi', () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#getUnreadCounts', () => {
|
||||
conversationsAPI.getUnreadCounts();
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/conversations/unread_counts'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,20 @@ import { computed, ref, useAttrs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
isVoiceCallEnabled,
|
||||
getVoiceCallProvider,
|
||||
VOICE_CALL_PROVIDERS,
|
||||
} from 'dashboard/helper/inbox';
|
||||
import {
|
||||
VOICE_CALL_DIRECTION,
|
||||
VOICE_CALL_OUTBOUND_INIT_STATUS,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
@@ -14,6 +24,9 @@ import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
const props = defineProps({
|
||||
phone: { type: String, default: '' },
|
||||
contactId: { type: [String, Number], required: true },
|
||||
// When set, the WhatsApp call continues in this conversation (matching the
|
||||
// header button) instead of looking up the contact's most recent one.
|
||||
conversationId: { type: [String, Number], default: null },
|
||||
label: { type: String, default: '' },
|
||||
icon: { type: [String, Object, Function], default: '' },
|
||||
size: { type: String, default: 'sm' },
|
||||
@@ -30,6 +43,7 @@ const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
@@ -38,13 +52,22 @@ const voiceInboxes = computed(() =>
|
||||
);
|
||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||
|
||||
// Unified behavior: hide when no phone
|
||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||
|
||||
const isInitiatingCall = computed(() => {
|
||||
return contactsUiFlags.value?.isInitiatingCall || false;
|
||||
});
|
||||
|
||||
// Mirror the conversation-header button: block a new call whenever any provider
|
||||
// call is already active or ringing, otherwise starting a WhatsApp call here
|
||||
// would leave a still-live Twilio (or other) session with no visible control.
|
||||
const isCallButtonDisabled = computed(
|
||||
() =>
|
||||
callsStore.hasActiveCall ||
|
||||
callsStore.hasIncomingCall ||
|
||||
isInitiatingCall.value
|
||||
);
|
||||
|
||||
const navigateToConversation = conversationId => {
|
||||
const accountId = route.params.accountId;
|
||||
if (conversationId && accountId) {
|
||||
@@ -58,23 +81,96 @@ const navigateToConversation = conversationId => {
|
||||
}
|
||||
};
|
||||
|
||||
const startCall = async inboxId => {
|
||||
if (isInitiatingCall.value) return;
|
||||
const whatsappCallSession = useWhatsappCallSession();
|
||||
|
||||
// Find the most recent open conversation for this contact in the picked inbox.
|
||||
// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
|
||||
// Pass inboxId so the BE applies the filter before the 20-row cap — without it,
|
||||
// contacts whose latest WhatsApp conversation falls outside the 20 most recent
|
||||
// across all inboxes would be treated as having no conversation.
|
||||
const findWhatsappConversationId = async inboxId => {
|
||||
const { data } = await ContactAPI.getConversations(props.contactId, {
|
||||
inboxId,
|
||||
});
|
||||
const conversations = data?.payload || [];
|
||||
const match = [...conversations].sort(
|
||||
(a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0)
|
||||
)[0];
|
||||
return match?.id || null;
|
||||
};
|
||||
|
||||
const startWhatsappCall = async (inboxId, conversationIdHint) => {
|
||||
// WhatsApp /initiate is conversation-scoped, so we must hand it a
|
||||
// conversation. Use the caller's hint when given (in-conversation flow);
|
||||
// otherwise pick the most recent one in the inbox.
|
||||
const conversationId =
|
||||
conversationIdHint || (await findWhatsappConversationId(inboxId));
|
||||
if (!conversationId) {
|
||||
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
|
||||
return;
|
||||
}
|
||||
|
||||
const response =
|
||||
await whatsappCallSession.initiateOutboundCall(conversationId);
|
||||
// The composable returns { status: 'locked' } when an init is already in
|
||||
// flight or a call is already active; treat that as a soft no-op rather than
|
||||
// claiming success.
|
||||
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
|
||||
if (!response?.id) {
|
||||
// Permission template path returns no call id. Mirror the header button and
|
||||
// surface whether the request was just sent or is already pending instead of
|
||||
// claiming the call started. The permission message lands in the
|
||||
// conversation, so still navigate there.
|
||||
const messageKey =
|
||||
response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
|
||||
? 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_PENDING'
|
||||
: 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_REQUESTED';
|
||||
useAlert(t(messageKey));
|
||||
navigateToConversation(conversationId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stay non-active until the connect cable event arrives — flipping to active
|
||||
// here would start the duration timer before the contact picks up.
|
||||
callsStore.addCall({
|
||||
callSid: response.call_id,
|
||||
callId: response.id,
|
||||
conversationId,
|
||||
inboxId,
|
||||
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
|
||||
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
|
||||
});
|
||||
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
navigateToConversation(conversationId);
|
||||
};
|
||||
|
||||
const startCall = async (inboxId, conversationIdHint = null) => {
|
||||
if (isCallButtonDisabled.value) return;
|
||||
|
||||
const inbox = (inboxesList.value || []).find(i => i.id === inboxId);
|
||||
if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) {
|
||||
try {
|
||||
await startWhatsappCall(inboxId, conversationIdHint);
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await store.dispatch('contacts/initiateCall', {
|
||||
contactId: props.contactId,
|
||||
inboxId,
|
||||
conversationId: conversationIdHint,
|
||||
});
|
||||
const { call_sid: callSid, conversation_id: conversationId } = response;
|
||||
|
||||
// Add call to store immediately so widget shows
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
conversationId,
|
||||
inboxId,
|
||||
callDirection: 'outbound',
|
||||
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
|
||||
});
|
||||
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
@@ -86,6 +182,22 @@ const startCall = async inboxId => {
|
||||
};
|
||||
|
||||
const onClick = async () => {
|
||||
// In conversation context, only stay in this conversation if its inbox is
|
||||
// itself voice-capable (works the same for Twilio and WhatsApp). For
|
||||
// non-voice channels (email, web, …) fall back to the picker so the call
|
||||
// goes out via a voice inbox.
|
||||
if (props.conversationId) {
|
||||
const conversation = store.getters.getConversationById(
|
||||
props.conversationId
|
||||
);
|
||||
const conversationInbox = (inboxesList.value || []).find(
|
||||
i => i.id === conversation?.inbox_id
|
||||
);
|
||||
if (conversationInbox && isVoiceCallEnabled(conversationInbox)) {
|
||||
await startCall(conversationInbox.id, props.conversationId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (voiceInboxes.value.length > 1) {
|
||||
dialogRef.value?.open();
|
||||
return;
|
||||
@@ -106,7 +218,7 @@ const onPickInbox = async inbox => {
|
||||
v-if="shouldRender"
|
||||
v-tooltip.top-end="tooltipLabel || null"
|
||||
v-bind="attrs"
|
||||
:disabled="isInitiatingCall"
|
||||
:disabled="isCallButtonDisabled"
|
||||
:is-loading="isInitiatingCall"
|
||||
:label="label"
|
||||
:icon="icon"
|
||||
|
||||
@@ -142,29 +142,27 @@ watch(
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.editor-wrapper {
|
||||
::v-deep {
|
||||
.ProseMirror-menubar-wrapper {
|
||||
.ProseMirror.ProseMirror-woot-style {
|
||||
p {
|
||||
@apply first:mt-0 !important;
|
||||
}
|
||||
|
||||
.empty-node {
|
||||
@apply m-0 !important;
|
||||
|
||||
&::before {
|
||||
@apply text-n-slate-11 dark:text-n-slate-11;
|
||||
}
|
||||
}
|
||||
:deep(.ProseMirror-menubar-wrapper) {
|
||||
.ProseMirror.ProseMirror-woot-style {
|
||||
p {
|
||||
@apply first:mt-0 !important;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
width: fit-content !important;
|
||||
position: relative !important;
|
||||
top: unset !important;
|
||||
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
|
||||
.empty-node {
|
||||
@apply m-0 !important;
|
||||
|
||||
&::before {
|
||||
@apply text-n-slate-11 dark:text-n-slate-11;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
width: fit-content !important;
|
||||
position: relative !important;
|
||||
top: unset !important;
|
||||
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
import PortalAvatar from 'dashboard/components-next/HelpCenter/PortalAvatar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import PortalSwitcher from 'dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue';
|
||||
import CreatePortalDialog from 'dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue';
|
||||
@@ -31,10 +30,6 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
breadcrumbLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:currentPage']);
|
||||
@@ -49,15 +44,10 @@ const portals = useMapGetter('portals/allPortals');
|
||||
|
||||
const currentPortalSlug = computed(() => route.params.portalSlug);
|
||||
|
||||
const activePortal = computed(() =>
|
||||
portals.value?.find(portal => portal.slug === currentPortalSlug.value)
|
||||
);
|
||||
|
||||
const activePortalName = computed(() => activePortal.value?.name);
|
||||
|
||||
const activePortalLogo = computed(
|
||||
() => activePortal.value?.logo?.file_url || ''
|
||||
);
|
||||
const activePortalName = computed(() => {
|
||||
return portals.value?.find(portal => portal.slug === currentPortalSlug.value)
|
||||
?.name;
|
||||
});
|
||||
|
||||
const updateCurrentPage = page => {
|
||||
emit('update:currentPage', page);
|
||||
@@ -75,45 +65,32 @@ const togglePortalSwitcher = () => {
|
||||
v-if="showHeaderTitle"
|
||||
class="flex items-center justify-start h-20 gap-2"
|
||||
>
|
||||
<nav v-if="activePortalName" class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<PortalAvatar
|
||||
:src="activePortalLogo"
|
||||
:name="activePortalName"
|
||||
:size="24"
|
||||
<span
|
||||
v-if="activePortalName"
|
||||
class="text-xl font-medium text-n-slate-12"
|
||||
>
|
||||
{{ activePortalName }}
|
||||
</span>
|
||||
<div v-if="activePortalName" class="relative group">
|
||||
<OnClickOutside @trigger="showPortalSwitcher = false">
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3"
|
||||
@click="togglePortalSwitcher"
|
||||
/>
|
||||
<span class="text-lg font-medium text-n-slate-12">
|
||||
{{ activePortalName }}
|
||||
</span>
|
||||
<div class="relative group">
|
||||
<OnClickOutside @trigger="showPortalSwitcher = false">
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
:variant="showPortalSwitcher ? 'faded' : 'ghost'"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
|
||||
@click="togglePortalSwitcher"
|
||||
/>
|
||||
|
||||
<PortalSwitcher
|
||||
v-if="showPortalSwitcher"
|
||||
class="absolute ltr:left-0 rtl:right-0 top-9"
|
||||
@close="showPortalSwitcher = false"
|
||||
@create-portal="createPortalDialogRef.dialogRef.open()"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<CreatePortalDialog ref="createPortalDialogRef" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="breadcrumbLabel">
|
||||
<div class="w-0.5 h-4 rounded-2xl bg-n-weak shrink-0" />
|
||||
<span class="pl-1.5 text-lg font-medium text-n-slate-11">
|
||||
{{ breadcrumbLabel }}
|
||||
</span>
|
||||
</template>
|
||||
</nav>
|
||||
<PortalSwitcher
|
||||
v-if="showPortalSwitcher"
|
||||
class="absolute ltr:left-0 rtl:right-0 top-9"
|
||||
@close="showPortalSwitcher = false"
|
||||
@create-portal="createPortalDialogRef.dialogRef.open()"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<CreatePortalDialog ref="createPortalDialogRef" />
|
||||
</div>
|
||||
</div>
|
||||
<slot name="header-actions" />
|
||||
</div>
|
||||
|
||||
+30
-32
@@ -145,45 +145,43 @@ const handleCreateArticle = event => {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep {
|
||||
.ProseMirror .empty-node::before {
|
||||
@apply text-n-slate-10 text-base;
|
||||
}
|
||||
:deep(.ProseMirror .empty-node::before) {
|
||||
@apply text-n-slate-10 text-base;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar-wrapper {
|
||||
.ProseMirror-woot-style {
|
||||
@apply min-h-[15rem] max-h-full;
|
||||
}
|
||||
:deep(.ProseMirror-menubar-wrapper) {
|
||||
.ProseMirror-woot-style {
|
||||
@apply min-h-[15rem] max-h-full;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ProseMirror-menubar) {
|
||||
display: none; // Hide by default
|
||||
}
|
||||
|
||||
:deep(.editor-root .has-selection) {
|
||||
.ProseMirror-menubar:not(:has(*)) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
display: none; // Hide by default
|
||||
}
|
||||
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
|
||||
display: flex;
|
||||
top: var(--selection-top, auto) !important;
|
||||
left: var(--selection-left, 0) !important;
|
||||
width: fit-content !important;
|
||||
position: absolute !important;
|
||||
|
||||
.editor-root .has-selection {
|
||||
.ProseMirror-menubar:not(:has(*)) {
|
||||
display: none !important;
|
||||
.ProseMirror-menuitem {
|
||||
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
|
||||
display: flex;
|
||||
top: var(--selection-top, auto) !important;
|
||||
left: var(--selection-left, 0) !important;
|
||||
width: fit-content !important;
|
||||
position: absolute !important;
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
@apply bg-n-slate-3;
|
||||
}
|
||||
.ProseMirror-menu-active {
|
||||
@apply bg-n-slate-3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+55
-1
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
||||
@@ -18,6 +19,7 @@ import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/A
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import BulkTranslateDialog from './BulkTranslateDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -62,6 +64,7 @@ const isFeatureEnabledonAccount = useMapGetter(
|
||||
|
||||
const selectedArticleIds = ref(new Set());
|
||||
const deleteConfirmDialogRef = ref(null);
|
||||
const isCategoryMenuOpen = ref(false);
|
||||
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
@@ -221,6 +224,34 @@ const bulkUpdateStatus = async status => {
|
||||
}
|
||||
};
|
||||
|
||||
const categoryMenuItems = computed(() =>
|
||||
props.categories.map(category => ({
|
||||
label: category.name,
|
||||
value: category.id,
|
||||
action: 'move',
|
||||
emoji: category.icon,
|
||||
}))
|
||||
);
|
||||
|
||||
const handleBulkUpdateCategory = async ({ value }) => {
|
||||
isCategoryMenuOpen.value = false;
|
||||
try {
|
||||
await articlesAPI.bulkUpdateCategory({
|
||||
portalSlug: route.params.portalSlug,
|
||||
articleIds: [...selectedArticleIds.value],
|
||||
categoryId: value,
|
||||
});
|
||||
onBulkActionSuccess(
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.CATEGORY_SUCCESS')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.CATEGORY_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmBulkDelete = () => {
|
||||
deleteConfirmDialogRef.value?.open();
|
||||
};
|
||||
@@ -256,7 +287,6 @@ watch(
|
||||
:total-items="articlesCount"
|
||||
:items-per-page="25"
|
||||
:header="portalName"
|
||||
:breadcrumb-label="$t('HELP_CENTER.BREADCRUMB.ARTICLES')"
|
||||
:show-pagination-footer="shouldShowPaginationFooter"
|
||||
@update:current-page="handlePageChange"
|
||||
>
|
||||
@@ -341,6 +371,30 @@ watch(
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="bulkUpdateStatus('archived')"
|
||||
/>
|
||||
<div v-if="categoryMenuItems.length" class="relative group">
|
||||
<OnClickOutside @trigger="isCategoryMenuOpen = false">
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-folder-input"
|
||||
:label="
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.MOVE_TO_CATEGORY'
|
||||
)
|
||||
"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="isCategoryMenuOpen"
|
||||
:menu-items="categoryMenuItems"
|
||||
show-search
|
||||
class="right-0 w-48 mt-2 top-full max-h-60"
|
||||
@action="handleBulkUpdateCategory"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
<Button
|
||||
v-if="isTranslationAvailable"
|
||||
sm
|
||||
|
||||
+1
-4
@@ -112,10 +112,7 @@ const reorderCategories = async reorderedGroup => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HelpCenterLayout
|
||||
:show-pagination-footer="false"
|
||||
:breadcrumb-label="$t('HELP_CENTER.BREADCRUMB.CATEGORIES')"
|
||||
>
|
||||
<HelpCenterLayout :show-pagination-footer="false">
|
||||
<template #header-actions>
|
||||
<CategoryHeaderControls
|
||||
:categories="categories"
|
||||
|
||||
+1
-1
@@ -202,7 +202,7 @@ defineExpose({ state, isSubmitDisabled });
|
||||
size="sm"
|
||||
type="button"
|
||||
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
|
||||
class="!h-[2.4rem] !w-[2.375rem] absolute top-[1.94rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
|
||||
class="!h-[2.38rem] !w-[2.375rem] absolute top-[2rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
|
||||
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
|
||||
/>
|
||||
<EmojiInput
|
||||
|
||||
+1
-4
@@ -31,10 +31,7 @@ const localeCount = computed(() => props.locales?.length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HelpCenterLayout
|
||||
:show-pagination-footer="false"
|
||||
:breadcrumb-label="$t('HELP_CENTER.BREADCRUMB.LOCALES')"
|
||||
>
|
||||
<HelpCenterLayout :show-pagination-footer="false">
|
||||
<template #header-actions>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
|
||||
+45
-2
@@ -12,7 +12,8 @@ import { isValidSlug } from 'shared/helpers/Validators';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import PortalAvatar from 'dashboard/components-next/HelpCenter/PortalAvatar.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -41,12 +42,31 @@ const state = reactive({
|
||||
slug: '',
|
||||
widgetColor: '',
|
||||
homePageLink: '',
|
||||
liveChatWidgetInboxId: '',
|
||||
logoUrl: '',
|
||||
avatarBlobId: '',
|
||||
});
|
||||
|
||||
const originalState = reactive({ ...state });
|
||||
|
||||
const liveChatWidgets = computed(() => {
|
||||
const inboxes = store.getters['inboxes/getInboxes'];
|
||||
const widgetOptions = inboxes
|
||||
.filter(inbox => inbox.channel_type === 'Channel::WebWidget')
|
||||
.map(inbox => ({
|
||||
value: inbox.id,
|
||||
label: inbox.name,
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
value: '',
|
||||
label: t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.NONE_OPTION'),
|
||||
},
|
||||
...widgetOptions,
|
||||
];
|
||||
});
|
||||
|
||||
const rules = {
|
||||
name: { required, minLength: minLength(2) },
|
||||
slug: {
|
||||
@@ -96,6 +116,7 @@ watch(
|
||||
widgetColor: newVal.color,
|
||||
homePageLink: newVal.homepage_link,
|
||||
slug: newVal.slug,
|
||||
liveChatWidgetInboxId: newVal.inbox?.id || '',
|
||||
});
|
||||
if (newVal.logo) {
|
||||
const {
|
||||
@@ -127,6 +148,7 @@ const handleUpdatePortal = () => {
|
||||
header_text: state.headerText,
|
||||
homepage_link: state.homePageLink,
|
||||
blob_id: state.avatarBlobId,
|
||||
inbox_id: state.liveChatWidgetInboxId,
|
||||
};
|
||||
emit('updatePortal', portal);
|
||||
};
|
||||
@@ -182,11 +204,12 @@ const handleAvatarDelete = () => {
|
||||
<label class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50">
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.LABEL') }}
|
||||
</label>
|
||||
<PortalAvatar
|
||||
<Avatar
|
||||
:src="state.logoUrl"
|
||||
:name="state.name"
|
||||
:size="72"
|
||||
allow-upload
|
||||
icon-name="i-lucide-building-2"
|
||||
@upload="handleAvatarUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
/>
|
||||
@@ -280,6 +303,26 @@ const handleAvatarDelete = () => {
|
||||
@blur="v$.slug.$touch()"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
||||
>
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
|
||||
>
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
v-model="state.liveChatWidgetInboxId"
|
||||
:options="liveChatWidgets"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.PLACEHOLDER')
|
||||
"
|
||||
:message="
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.HELP_TEXT')
|
||||
"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-weak"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
||||
>
|
||||
|
||||
+5
-15
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
@@ -62,7 +62,6 @@ const { isOnChatwootCloud } = useAccount();
|
||||
const addCustomDomainDialogRef = ref(null);
|
||||
const dnsConfigurationDialogRef = ref(null);
|
||||
const updatedDomainAddress = ref('');
|
||||
const pendingDomain = ref('');
|
||||
|
||||
const customDomainAddress = computed(
|
||||
() => props.activePortal?.custom_domain || ''
|
||||
@@ -112,23 +111,14 @@ const updatePortalConfiguration = customDomain => {
|
||||
id: props.activePortal?.id,
|
||||
custom_domain: customDomain,
|
||||
};
|
||||
pendingDomain.value = customDomain;
|
||||
emit('updatePortalConfiguration', portal);
|
||||
addCustomDomainDialogRef.value.dialogRef.close();
|
||||
if (customDomain) {
|
||||
updatedDomainAddress.value = customDomain;
|
||||
dnsConfigurationDialogRef.value.dialogRef.open();
|
||||
}
|
||||
};
|
||||
|
||||
// The save above is fire-and-forget, so the DNS instructions dialog must not
|
||||
// open until the domain is actually persisted — otherwise a failed save still
|
||||
// advances to the next step. We open it once the stored value reflects the
|
||||
// submitted domain.
|
||||
watch(customDomainAddress, savedDomain => {
|
||||
if (!pendingDomain.value || savedDomain !== pendingDomain.value) return;
|
||||
|
||||
pendingDomain.value = '';
|
||||
updatedDomainAddress.value = savedDomain;
|
||||
dnsConfigurationDialogRef.value.dialogRef.open();
|
||||
});
|
||||
|
||||
const closeDNSConfigurationDialog = () => {
|
||||
updatedDomainAddress.value = '';
|
||||
dnsConfigurationDialogRef.value.dialogRef.close();
|
||||
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import PortalBaseSettings from './PortalBaseSettings.vue';
|
||||
import ConfirmDeletePortalDialog from './ConfirmDeletePortalDialog.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activePortal: { type: Object, required: true },
|
||||
isFetching: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updatePortal', 'deletePortal']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const confirmDeletePortalDialogRef = ref(null);
|
||||
|
||||
const activePortalName = computed(() => props.activePortal?.name || '');
|
||||
|
||||
const handleUpdatePortal = portal => {
|
||||
emit('updatePortal', portal);
|
||||
};
|
||||
|
||||
const openConfirmDeletePortalDialog = () => {
|
||||
confirmDeletePortalDialogRef.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const handleDeletePortal = () => {
|
||||
emit('deletePortal', props.activePortal);
|
||||
confirmDeletePortalDialogRef.value.dialogRef.close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<PortalBaseSettings
|
||||
:active-portal="activePortal"
|
||||
:is-fetching="isFetching"
|
||||
@update-portal="handleUpdatePortal"
|
||||
/>
|
||||
<div class="w-full h-px bg-n-weak" />
|
||||
<div class="flex items-end justify-between w-full gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.HEADER'
|
||||
)
|
||||
}}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DESCRIPTION'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
:label="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.BUTTON',
|
||||
{ portalName: activePortalName }
|
||||
)
|
||||
"
|
||||
color="ruby"
|
||||
class="max-w-56 !w-fit"
|
||||
@click="openConfirmDeletePortalDialog"
|
||||
/>
|
||||
</div>
|
||||
<ConfirmDeletePortalDialog
|
||||
ref="confirmDeletePortalDialogRef"
|
||||
:active-portal-name="activePortalName"
|
||||
@delete-portal="handleDeletePortal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
-244
@@ -1,244 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activePortal: { type: Object, required: true },
|
||||
isFetching: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updatePortalConfiguration']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
// Mirrors Portal::ANALYTICS_CONFIG_FORMATS on the backend.
|
||||
const ANALYTICS_PROVIDERS = [
|
||||
{
|
||||
key: 'gtm_container_id',
|
||||
i18nKey: 'GTM',
|
||||
icon: 'i-logos-google-tag-manager',
|
||||
format: /^GTM-[A-Z0-9]+$/,
|
||||
},
|
||||
{
|
||||
key: 'ga4_measurement_id',
|
||||
i18nKey: 'GA4',
|
||||
icon: 'i-logos-google-analytics',
|
||||
format: /^G-[A-Z0-9]+$/,
|
||||
},
|
||||
{
|
||||
key: 'hotjar_site_id',
|
||||
i18nKey: 'HOTJAR',
|
||||
icon: 'i-logos-hotjar-icon',
|
||||
format: /^\d+$/,
|
||||
},
|
||||
{
|
||||
key: 'plausible_domain',
|
||||
i18nKey: 'PLAUSIBLE',
|
||||
icon: 'i-woot-plausible',
|
||||
format: /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/i,
|
||||
},
|
||||
{
|
||||
key: 'amplitude_api_key',
|
||||
i18nKey: 'AMPLITUDE',
|
||||
icon: 'i-logos-amplitude-icon',
|
||||
format: /^[a-z0-9]+$/i,
|
||||
},
|
||||
{
|
||||
key: 'clarity_project_id',
|
||||
i18nKey: 'CLARITY',
|
||||
icon: 'i-woot-microsoft-clarity',
|
||||
format: /^[a-z0-9]+$/i,
|
||||
},
|
||||
{
|
||||
key: 'meta_pixel_id',
|
||||
i18nKey: 'META_PIXEL',
|
||||
icon: 'i-logos-meta-icon',
|
||||
format: /^\d+$/,
|
||||
},
|
||||
];
|
||||
|
||||
const portalConfig = computed(() => props.activePortal?.config || {});
|
||||
|
||||
const liveChatWidgets = computed(() => {
|
||||
const widgetOptions = store.getters['inboxes/getInboxes']
|
||||
.filter(inbox => inbox.channel_type === 'Channel::WebWidget')
|
||||
.map(inbox => ({ value: inbox.id, label: inbox.name }));
|
||||
|
||||
return [
|
||||
{
|
||||
value: '',
|
||||
label: t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.LIVE_CHAT.NONE_OPTION'
|
||||
),
|
||||
},
|
||||
...widgetOptions,
|
||||
];
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
liveChatWidgetInboxId: '',
|
||||
...Object.fromEntries(ANALYTICS_PROVIDERS.map(({ key }) => [key, ''])),
|
||||
});
|
||||
const originalState = reactive({ ...state });
|
||||
|
||||
const resetFromPortal = () => {
|
||||
state.liveChatWidgetInboxId = props.activePortal?.inbox?.id || '';
|
||||
ANALYTICS_PROVIDERS.forEach(({ key }) => {
|
||||
state[key] = portalConfig.value[key] || '';
|
||||
});
|
||||
Object.assign(originalState, state);
|
||||
};
|
||||
|
||||
watch(() => props.activePortal, resetFromPortal, {
|
||||
immediate: true,
|
||||
deep: true,
|
||||
});
|
||||
|
||||
const liveChatTitle = computed(() =>
|
||||
replaceInstallationName(
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.LIVE_CHAT.TITLE')
|
||||
)
|
||||
);
|
||||
|
||||
const trimmedAnalyticsValues = computed(() =>
|
||||
Object.fromEntries(
|
||||
ANALYTICS_PROVIDERS.map(({ key }) => [key, state[key].trim()])
|
||||
)
|
||||
);
|
||||
|
||||
const invalidAnalyticsKeys = computed(() =>
|
||||
ANALYTICS_PROVIDERS.filter(({ key, format }) => {
|
||||
const value = trimmedAnalyticsValues.value[key];
|
||||
return value !== '' && !format.test(value);
|
||||
}).map(({ key }) => key)
|
||||
);
|
||||
|
||||
const isInvalid = key => invalidAnalyticsKeys.value.includes(key);
|
||||
|
||||
const hasChanges = computed(
|
||||
() =>
|
||||
state.liveChatWidgetInboxId !== originalState.liveChatWidgetInboxId ||
|
||||
ANALYTICS_PROVIDERS.some(
|
||||
({ key }) => trimmedAnalyticsValues.value[key] !== originalState[key]
|
||||
)
|
||||
);
|
||||
|
||||
const handleSave = () => {
|
||||
emit('updatePortalConfiguration', {
|
||||
id: props.activePortal.id,
|
||||
slug: props.activePortal.slug,
|
||||
inbox_id: state.liveChatWidgetInboxId,
|
||||
config: { ...trimmedAnalyticsValues.value },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.HEADER') }}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-4 p-4 rounded-xl outline outline-1 outline-n-weak"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex items-center justify-center rounded-lg size-9 shrink-0 bg-n-alpha-2 text-n-slate-12"
|
||||
>
|
||||
<Icon icon="i-lucide-messages-square" class="size-5" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h6 class="text-sm font-medium text-n-slate-12">
|
||||
{{ liveChatTitle }}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.LIVE_CHAT.DESCRIPTION'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ComboBox
|
||||
v-model="state.liveChatWidgetInboxId"
|
||||
:options="liveChatWidgets"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.LIVE_CHAT.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-weak"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="provider in ANALYTICS_PROVIDERS"
|
||||
:key="provider.key"
|
||||
class="flex flex-col gap-4 p-4 rounded-xl outline outline-1 outline-n-weak"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex items-center justify-center rounded-lg size-9 shrink-0 bg-n-alpha-2 text-n-slate-12"
|
||||
>
|
||||
<Icon :icon="provider.icon" class="size-5" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h6 class="text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.TITLE`
|
||||
)
|
||||
}}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{
|
||||
t(
|
||||
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.DESCRIPTION`
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
v-model="state[provider.key]"
|
||||
:placeholder="
|
||||
t(
|
||||
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.PLACEHOLDER`
|
||||
)
|
||||
"
|
||||
:message="
|
||||
isInvalid(provider.key)
|
||||
? t(
|
||||
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.INVALID`
|
||||
)
|
||||
: t(
|
||||
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.HELP`
|
||||
)
|
||||
"
|
||||
:message-type="isInvalid(provider.key) ? 'error' : 'info'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.SAVE')"
|
||||
:disabled="!hasChanges || invalidAnalyticsKeys.length > 0 || isFetching"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+72
-83
@@ -5,12 +5,12 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import PortalGeneralSettings from './PortalGeneralSettings.vue';
|
||||
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
|
||||
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
|
||||
import PortalLayoutContentSettings from './PortalLayoutContentSettings.vue';
|
||||
import PortalIntegrationsSettings from './PortalIntegrationsSettings.vue';
|
||||
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
portals: {
|
||||
@@ -34,30 +34,7 @@ const emit = defineEmits([
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const SETTINGS_TABS = [
|
||||
{
|
||||
id: 'general',
|
||||
labelKey: 'HELP_CENTER.PORTAL_SETTINGS.NAV.GENERAL',
|
||||
icon: 'i-lucide-settings-2',
|
||||
},
|
||||
{
|
||||
id: 'domain',
|
||||
labelKey: 'HELP_CENTER.PORTAL_SETTINGS.NAV.DOMAIN',
|
||||
icon: 'i-lucide-globe',
|
||||
},
|
||||
{
|
||||
id: 'appearance',
|
||||
labelKey: 'HELP_CENTER.PORTAL_SETTINGS.NAV.APPEARANCE',
|
||||
icon: 'i-lucide-palette',
|
||||
},
|
||||
{
|
||||
id: 'integrations',
|
||||
labelKey: 'HELP_CENTER.PORTAL_SETTINGS.NAV.INTEGRATIONS',
|
||||
icon: 'i-lucide-blocks',
|
||||
},
|
||||
];
|
||||
|
||||
const activeTab = ref('general');
|
||||
const confirmDeletePortalDialogRef = ref(null);
|
||||
|
||||
const currentPortalSlug = computed(() => route.params.portalSlug);
|
||||
|
||||
@@ -68,6 +45,8 @@ const activePortal = computed(() => {
|
||||
return props.portals?.find(portal => portal.slug === currentPortalSlug.value);
|
||||
});
|
||||
|
||||
const activePortalName = computed(() => activePortal.value?.name || '');
|
||||
|
||||
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
|
||||
|
||||
const handleUpdatePortal = portal => {
|
||||
@@ -86,16 +65,18 @@ const handleSendCnameInstructions = payload => {
|
||||
emit('sendCnameInstructions', payload);
|
||||
};
|
||||
|
||||
const handleDeletePortal = portal => {
|
||||
emit('deletePortal', portal);
|
||||
const openConfirmDeletePortalDialog = () => {
|
||||
confirmDeletePortalDialogRef.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const handleDeletePortal = () => {
|
||||
emit('deletePortal', activePortal.value);
|
||||
confirmDeletePortalDialogRef.value.dialogRef.close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HelpCenterLayout
|
||||
:show-pagination-footer="false"
|
||||
:breadcrumb-label="t('HELP_CENTER.BREADCRUMB.SETTINGS')"
|
||||
>
|
||||
<HelpCenterLayout :show-pagination-footer="false">
|
||||
<template #content>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
@@ -103,60 +84,68 @@ const handleDeletePortal = portal => {
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else-if="activePortal" class="flex items-start w-full gap-8">
|
||||
<nav class="sticky top-0 flex flex-col w-48 gap-0.5 shrink-0 py-1">
|
||||
<button
|
||||
v-for="tab in SETTINGS_TABS"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
class="flex items-center w-full h-9 gap-2 px-2.5 text-sm transition-colors rounded-lg"
|
||||
:class="
|
||||
activeTab === tab.id
|
||||
? 'bg-n-alpha-2 text-n-slate-12 font-medium'
|
||||
: 'text-n-slate-11 hover:bg-n-alpha-1 hover:text-n-slate-12'
|
||||
<div
|
||||
v-else-if="activePortal"
|
||||
class="flex flex-col w-full gap-4 max-w-[40rem] pb-8"
|
||||
>
|
||||
<PortalBaseSettings
|
||||
:active-portal="activePortal"
|
||||
:is-fetching="isFetching"
|
||||
@update-portal="handleUpdatePortal"
|
||||
/>
|
||||
<div class="w-full h-px bg-n-weak" />
|
||||
<PortalConfigurationSettings
|
||||
:active-portal="activePortal"
|
||||
:is-fetching="isFetching"
|
||||
:is-fetching-status="isFetchingSSLStatus"
|
||||
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||
@refresh-status="fetchSSLStatus"
|
||||
@send-cname-instructions="handleSendCnameInstructions"
|
||||
/>
|
||||
<div class="w-full h-px bg-n-weak" />
|
||||
<PortalLayoutContentSettings
|
||||
:active-portal="activePortal"
|
||||
:is-fetching="isFetching"
|
||||
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||
/>
|
||||
<div class="w-full h-px bg-n-weak" />
|
||||
<div class="flex items-end justify-between w-full gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.HEADER'
|
||||
)
|
||||
}}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DESCRIPTION'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
:label="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.BUTTON',
|
||||
{
|
||||
portalName: activePortalName,
|
||||
}
|
||||
)
|
||||
"
|
||||
:aria-current="activeTab === tab.id ? 'page' : undefined"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
<Icon :icon="tab.icon" class="shrink-0 size-4" />
|
||||
{{ t(tab.labelKey) }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-col flex-1 min-w-0 max-w-[40rem] pb-8">
|
||||
<PortalGeneralSettings
|
||||
v-if="activeTab === 'general'"
|
||||
:active-portal="activePortal"
|
||||
:is-fetching="isFetching"
|
||||
@update-portal="handleUpdatePortal"
|
||||
@delete-portal="handleDeletePortal"
|
||||
/>
|
||||
|
||||
<PortalConfigurationSettings
|
||||
v-else-if="activeTab === 'domain'"
|
||||
:active-portal="activePortal"
|
||||
:is-fetching="isFetching"
|
||||
:is-fetching-status="isFetchingSSLStatus"
|
||||
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||
@refresh-status="fetchSSLStatus"
|
||||
@send-cname-instructions="handleSendCnameInstructions"
|
||||
/>
|
||||
|
||||
<PortalLayoutContentSettings
|
||||
v-else-if="activeTab === 'appearance'"
|
||||
:active-portal="activePortal"
|
||||
:is-fetching="isFetching"
|
||||
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||
/>
|
||||
|
||||
<PortalIntegrationsSettings
|
||||
v-else-if="activeTab === 'integrations'"
|
||||
:active-portal="activePortal"
|
||||
:is-fetching="isFetching"
|
||||
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||
color="ruby"
|
||||
class="max-w-56 !w-fit"
|
||||
@click="openConfirmDeletePortalDialog"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<ConfirmDeletePortalDialog
|
||||
ref="confirmDeletePortalDialogRef"
|
||||
:active-portal-name="activePortalName"
|
||||
@delete-portal="handleDeletePortal"
|
||||
/>
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<script setup>
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
// Renders a portal's logo consistently across the help center — the layout
|
||||
// header, the portal switcher, and portal settings — so the icon fallback
|
||||
// and shape stay in one place instead of being copied per call site.
|
||||
defineProps({
|
||||
src: { type: String, default: '' },
|
||||
name: { type: String, default: '' },
|
||||
size: { type: Number, default: 24 },
|
||||
allowUpload: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['upload', 'delete']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Avatar
|
||||
:src="src"
|
||||
:name="name"
|
||||
:size="size"
|
||||
:allow-upload="allowUpload"
|
||||
icon-name="i-lucide-building-2"
|
||||
@upload="emit('upload', $event)"
|
||||
@delete="emit('delete')"
|
||||
/>
|
||||
</template>
|
||||
+4
-2
@@ -6,7 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { buildPortalURL } from 'dashboard/helper/portalHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import PortalAvatar from 'dashboard/components-next/HelpCenter/PortalAvatar.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const emit = defineEmits(['close', 'createPortal']);
|
||||
|
||||
@@ -151,11 +151,13 @@ const redirectToPortalHomePage = () => {
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ portal.name || '' }}
|
||||
</span>
|
||||
<PortalAvatar
|
||||
<Avatar
|
||||
v-if="portal"
|
||||
:name="portal.name"
|
||||
:src="getPortalThumbnailSrc(portal)"
|
||||
:size="20"
|
||||
icon-name="i-lucide-building-2"
|
||||
rounded-full
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,11 @@ const modelValue = defineModel({ type: Boolean, default: false });
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ header }}
|
||||
</span>
|
||||
<div v-if="hideToggle" class="size-2" />
|
||||
<template v-if="hideToggle">
|
||||
<slot name="hiddenToggle">
|
||||
<div class="size-2" />
|
||||
</slot>
|
||||
</template>
|
||||
<ToggleSwitch v-else v-model="modelValue" />
|
||||
</div>
|
||||
<span v-if="description" class="text-body-main text-n-slate-11">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
import { defineProps } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<script setup>
|
||||
import CallCard from './CallCard.vue';
|
||||
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
|
||||
const whatsappCall = {
|
||||
callSid: 'wamid.HBgM',
|
||||
conversationId: 1234,
|
||||
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
|
||||
};
|
||||
|
||||
const twilioCall = {
|
||||
callSid: 'CA1234567890',
|
||||
conversationId: 1234,
|
||||
};
|
||||
|
||||
const richCallInfo = {
|
||||
contactName: 'John Does',
|
||||
phoneNumber: '+1 412 245 0242',
|
||||
inboxName: 'Customer support',
|
||||
location: 'San Francisco, United States',
|
||||
countryFlag: '\u{1F1FA}\u{1F1F8}',
|
||||
hasLocation: true,
|
||||
avatar:
|
||||
'https://app.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBd3FodGc9PSIsImV4cCI6bnVsbCwicHVyIjoiYmxvYl9pZCJ9fQ==--d218a325af0ef45061eefd352f8efb9ac84275e8/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lKYW5CbFp3WTZCa1ZVT2hOeVpYTnBlbVZmZEc5ZlptbHNiRnNIYVFINk1BPT0iLCJleHAiOm51bGwsInB1ciI6InZhcmlhdGlvbiJ9fQ==--533c3ad7218e24c4b0e8f8959dc1953ce1d279b9/1707423736896.jpeg',
|
||||
};
|
||||
|
||||
// Falls back to inbox name when neither city nor country is on the contact.
|
||||
const noLocationCallInfo = {
|
||||
contactName: 'Anonymous Lead',
|
||||
phoneNumber: '+44 20 7916 0428',
|
||||
inboxName: 'Sales',
|
||||
location: 'Sales',
|
||||
countryFlag: '',
|
||||
hasLocation: false,
|
||||
avatar: '',
|
||||
};
|
||||
|
||||
// Truncation regression check — both contact name and location should ellipsize.
|
||||
const longCallInfo = {
|
||||
contactName: 'Aleksandra Konstantinopolskaya',
|
||||
phoneNumber: '+49 30 911890',
|
||||
inboxName: 'Customer support',
|
||||
location:
|
||||
'Friedrichshain-Kreuzberg, Federal Republic of Germany (Deutschland)',
|
||||
countryFlag: '\u{1F1E9}\u{1F1EA}',
|
||||
hasLocation: true,
|
||||
avatar: '',
|
||||
};
|
||||
|
||||
const unknownCallerInfo = {
|
||||
contactName: 'Unknown caller',
|
||||
phoneNumber: '',
|
||||
inboxName: 'Customer support',
|
||||
location: 'Customer support',
|
||||
countryFlag: '',
|
||||
hasLocation: false,
|
||||
avatar: '',
|
||||
};
|
||||
|
||||
const log =
|
||||
(label, ...args) =>
|
||||
() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[CallCard.story] ${label}`, ...args);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Call/CallCard"
|
||||
:layout="{ type: 'grid', width: '400px' }"
|
||||
>
|
||||
<Variant title="Incoming · WhatsApp">
|
||||
<CallCard
|
||||
:call="whatsappCall"
|
||||
:call-info="richCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.INCOMING"
|
||||
@accept="log('accept')"
|
||||
@reject="log('reject')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Incoming · Twilio (no flag)">
|
||||
<CallCard
|
||||
:call="twilioCall"
|
||||
:call-info="noLocationCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.INCOMING"
|
||||
@accept="log('accept')"
|
||||
@reject="log('reject')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Outgoing · WhatsApp ringing">
|
||||
<CallCard
|
||||
:call="whatsappCall"
|
||||
:call-info="richCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.OUTGOING"
|
||||
@reject="log('reject')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Ongoing · WhatsApp (mute visible, unmuted)">
|
||||
<CallCard
|
||||
:call="whatsappCall"
|
||||
:call-info="richCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.ONGOING"
|
||||
duration="01:23"
|
||||
:is-muted="false"
|
||||
show-mute
|
||||
@end="log('end')"
|
||||
@toggle-mute="log('toggleMute')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Ongoing · WhatsApp muted">
|
||||
<CallCard
|
||||
:call="whatsappCall"
|
||||
:call-info="richCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.ONGOING"
|
||||
duration="03:47"
|
||||
is-muted
|
||||
show-mute
|
||||
@end="log('end')"
|
||||
@toggle-mute="log('toggleMute')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Ongoing · Twilio (no mute control)">
|
||||
<CallCard
|
||||
:call="twilioCall"
|
||||
:call-info="richCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.ONGOING"
|
||||
duration="00:42"
|
||||
@end="log('end')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Incoming · Unknown caller">
|
||||
<CallCard
|
||||
:call="whatsappCall"
|
||||
:call-info="unknownCallerInfo"
|
||||
:state="VOICE_CALL_DIRECTION.INCOMING"
|
||||
@accept="log('accept')"
|
||||
@reject="log('reject')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Truncation · long name and location">
|
||||
<CallCard
|
||||
:call="whatsappCall"
|
||||
:call-info="longCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.INCOMING"
|
||||
@accept="log('accept')"
|
||||
@reject="log('reject')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Ongoing · no conversation link in footer">
|
||||
<CallCard
|
||||
:call="{ ...whatsappCall, conversationId: null }"
|
||||
:call-info="richCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.ONGOING"
|
||||
duration="02:10"
|
||||
show-mute
|
||||
@end="log('end')"
|
||||
@toggle-mute="log('toggleMute')"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,242 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
call: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
callInfo: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
// 'incoming' | 'outgoing' | 'ongoing'
|
||||
state: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
duration: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isMuted: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showMute: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits([
|
||||
'accept',
|
||||
'reject',
|
||||
'end',
|
||||
'toggleMute',
|
||||
'goToConversation',
|
||||
'dismiss',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isOngoing = computed(() => props.state === VOICE_CALL_DIRECTION.ONGOING);
|
||||
const isIncoming = computed(
|
||||
() => props.state === VOICE_CALL_DIRECTION.INCOMING
|
||||
);
|
||||
const isOutgoing = computed(
|
||||
() => props.state === VOICE_CALL_DIRECTION.OUTGOING
|
||||
);
|
||||
|
||||
const statusIcon = computed(() => {
|
||||
if (isOngoing.value) return 'i-ph-phone-call-bold';
|
||||
if (isOutgoing.value) return 'i-ph-phone-outgoing-bold';
|
||||
return 'i-ph-phone-incoming-bold';
|
||||
});
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (isOngoing.value) return t('CONVERSATION.VOICE_WIDGET.CALL_IN_PROGRESS');
|
||||
if (isOutgoing.value) return t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL');
|
||||
return t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL');
|
||||
});
|
||||
|
||||
const channelIcon = computed(() => {
|
||||
if (props.call?.provider === VOICE_CALL_PROVIDERS.WHATSAPP)
|
||||
return 'i-ri-whatsapp-fill';
|
||||
return 'i-ph-phone-bold';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2 pt-4 bg-n-solid-2/95 rounded-2xl shadow-xl outline outline-1 outline-n-strong backdrop-blur-md"
|
||||
:class="call?.conversationId ? 'pb-2' : 'pb-4'"
|
||||
>
|
||||
<!-- Top section: status badge + location/inbox + duration -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<!-- Ongoing: status badge on left -->
|
||||
<div v-if="isOngoing" class="flex items-center gap-1.5 shrink-0">
|
||||
<Icon :icon="statusIcon" class="size-3.5 text-n-teal-9 shrink-0" />
|
||||
<span class="text-xs font-medium text-n-teal-9 tracking-tight">
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Caller location (city, country) or fallback to channel + inbox name -->
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span
|
||||
v-if="callInfo.hasLocation && callInfo.countryFlag"
|
||||
class="text-sm leading-none shrink-0"
|
||||
>
|
||||
{{ callInfo.countryFlag }}
|
||||
</span>
|
||||
<Icon
|
||||
v-else-if="!isOngoing"
|
||||
:icon="channelIcon"
|
||||
class="size-3.5 text-n-slate-10 shrink-0"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-medium text-n-slate-11 tracking-tight truncate"
|
||||
>
|
||||
{{ callInfo.location }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Ongoing: duration on right -->
|
||||
<p
|
||||
v-if="isOngoing"
|
||||
class="font-display text-base font-medium text-n-slate-11 shrink-0 mb-0 tabular-nums tracking-tight"
|
||||
>
|
||||
{{ duration }}
|
||||
</p>
|
||||
<!-- Incoming/Outgoing: status badge on right -->
|
||||
<div v-else class="flex items-center gap-1.5 shrink-0">
|
||||
<Icon :icon="statusIcon" class="size-3.5 text-n-teal-9 shrink-0" />
|
||||
<span class="text-xs font-medium text-n-teal-9 tracking-tight">
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
<!-- Dismiss: removes the notification from the UI without declining.
|
||||
Incoming only — outgoing/ongoing calls are ended via the call
|
||||
controls, not silently dismissed. -->
|
||||
<NextButton
|
||||
v-if="isIncoming"
|
||||
v-tooltip.top="$t('CONVERSATION.VOICE_WIDGET.DISMISS_CALL')"
|
||||
icon="i-ph-x-bold"
|
||||
slate
|
||||
ghost
|
||||
xs
|
||||
class="!rounded-full -my-1"
|
||||
@click="$emit('dismiss')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main row: avatar + name/phone + actions -->
|
||||
<div class="flex items-center gap-3 px-4">
|
||||
<div class="shrink-0">
|
||||
<Avatar
|
||||
:src="callInfo.avatar"
|
||||
:name="callInfo.contactName"
|
||||
:size="40"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="font-display text-sm font-medium text-n-slate-12 truncate mb-0.5 tracking-tight leading-tight"
|
||||
>
|
||||
{{ callInfo.contactName }}
|
||||
</p>
|
||||
<p
|
||||
v-if="callInfo.phoneNumber"
|
||||
class="text-sm text-n-slate-11 truncate mb-0 tracking-tight leading-tight"
|
||||
>
|
||||
{{ callInfo.phoneNumber }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<!-- Mute toggle (WhatsApp ongoing only) -->
|
||||
<NextButton
|
||||
v-if="isOngoing && showMute"
|
||||
v-tooltip.top="
|
||||
isMuted
|
||||
? $t('CONVERSATION.VOICE_WIDGET.UNMUTE')
|
||||
: $t('CONVERSATION.VOICE_WIDGET.MUTE')
|
||||
"
|
||||
:icon="
|
||||
isMuted ? 'i-ph-microphone-slash-bold' : 'i-ph-microphone-bold'
|
||||
"
|
||||
:variant="isMuted ? 'solid' : 'faded'"
|
||||
:color="isMuted ? 'amber' : 'teal'"
|
||||
class="!rounded-full"
|
||||
@click="$emit('toggleMute')"
|
||||
/>
|
||||
|
||||
<!-- Accept call (incoming only) -->
|
||||
<NextButton
|
||||
v-if="isIncoming"
|
||||
v-tooltip.top="$t('CONVERSATION.VOICE_WIDGET.JOIN_CALL')"
|
||||
icon="i-ph-phone-bold"
|
||||
teal
|
||||
class="!rounded-full"
|
||||
@click="$emit('accept')"
|
||||
/>
|
||||
|
||||
<!-- Reject / end call (all states) -->
|
||||
<NextButton
|
||||
v-tooltip.top="
|
||||
isOngoing
|
||||
? $t('CONVERSATION.VOICE_WIDGET.END_CALL')
|
||||
: $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')
|
||||
"
|
||||
icon="i-ph-phone-x-bold"
|
||||
ruby
|
||||
class="!rounded-full rotate-[134deg]"
|
||||
@click="isOngoing ? $emit('end') : $emit('reject')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer: go to conversation thread -->
|
||||
<NextButton
|
||||
v-if="call?.conversationId"
|
||||
slate
|
||||
ghost
|
||||
trailing-icon
|
||||
class="!justify-between !px-2 !mx-2"
|
||||
@click="$emit('goToConversation')"
|
||||
>
|
||||
<template #icon>
|
||||
<span
|
||||
class="flex items-center gap-1 text-n-slate-11 group-hover:text-n-slate-12"
|
||||
>
|
||||
<Icon
|
||||
icon="i-ph-chat-circle-text-bold"
|
||||
class="size-3.5 text-n-slate-11 shrink-0"
|
||||
/>
|
||||
<span class="text-sm tracking-tight tabular-nums">
|
||||
#{{ call.conversationId }}
|
||||
</span>
|
||||
<Icon
|
||||
icon="i-ph-caret-right-bold"
|
||||
class="size-3 text-n-slate-11 shrink-0"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
class="text-sm text-n-slate-11 tracking-tight group-hover:text-n-slate-12"
|
||||
>
|
||||
{{ $t('CONVERSATION.VOICE_WIDGET.GO_TO_CONVERSATION') }}
|
||||
</span>
|
||||
</NextButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,268 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
|
||||
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
|
||||
import CallCard from 'dashboard/components-next/call/CallCard.vue';
|
||||
import countriesList from 'shared/constants/countries.js';
|
||||
|
||||
const RINGTONE_URL = '/audio/dashboard/bell.mp3';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const {
|
||||
activeCall,
|
||||
incomingCalls,
|
||||
hasActiveCall,
|
||||
isJoining,
|
||||
joinCall,
|
||||
endCall: endCallSession,
|
||||
rejectIncomingCall,
|
||||
dismissCall,
|
||||
formattedCallDuration,
|
||||
} = useCallSession();
|
||||
|
||||
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
|
||||
// don't expose a mic track on the browser side.
|
||||
const isMuted = ref(false);
|
||||
const isWhatsappActive = computed(
|
||||
() => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
|
||||
);
|
||||
|
||||
const primaryIncomingCall = computed(() =>
|
||||
hasActiveCall.value ? null : incomingCalls.value[0] || null
|
||||
);
|
||||
|
||||
const stackedIncomingCalls = computed(() =>
|
||||
hasActiveCall.value ? incomingCalls.value : incomingCalls.value.slice(1)
|
||||
);
|
||||
|
||||
const mainCardState = computed(() => {
|
||||
if (hasActiveCall.value) return VOICE_CALL_DIRECTION.ONGOING;
|
||||
const direction = primaryIncomingCall.value?.callDirection;
|
||||
return direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||
? VOICE_CALL_DIRECTION.OUTGOING
|
||||
: VOICE_CALL_DIRECTION.INCOMING;
|
||||
});
|
||||
|
||||
// Stacked cards are always non-active (ringing) calls, so reflect each call's
|
||||
// real direction. An outbound call must render as OUTGOING — otherwise it shows
|
||||
// the incoming-only dismiss (✕) control and the agent could drop it locally
|
||||
// without terminating, leaving the customer ringing with no widget to end it.
|
||||
const stackedCardState = call =>
|
||||
call?.callDirection === VOICE_CALL_DIRECTION.OUTBOUND
|
||||
? VOICE_CALL_DIRECTION.OUTGOING
|
||||
: VOICE_CALL_DIRECTION.INCOMING;
|
||||
|
||||
const toggleMute = () => {
|
||||
isMuted.value = !isMuted.value;
|
||||
setWhatsappCallMuted(isMuted.value);
|
||||
};
|
||||
|
||||
watch(hasActiveCall, active => {
|
||||
if (!active) isMuted.value = false;
|
||||
});
|
||||
|
||||
// Convert ISO 3166-1 alpha-2 country code (e.g. "US") to its regional indicator
|
||||
// flag emoji. Returns empty string if the code is missing or malformed.
|
||||
const countryCodeToFlag = code => {
|
||||
if (!code || code.length !== 2) return '';
|
||||
const base = 0x1f1e6;
|
||||
const offset = 'A'.charCodeAt(0);
|
||||
return String.fromCodePoint(
|
||||
...code
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(c => base + (c.charCodeAt(0) - offset))
|
||||
);
|
||||
};
|
||||
|
||||
const getCallInfo = call => {
|
||||
const conversation = store.getters.getConversationById(call?.conversationId);
|
||||
// Look up inbox from the call's own inboxId — the conversation can drop out
|
||||
// of the Vuex store when the user navigates between inbox views, so going
|
||||
// through `conversation.inbox_id` would lose the inbox name (and fall back
|
||||
// to the literal "Customer support" string).
|
||||
const inbox = store.getters['inboxes/getInbox'](call?.inboxId);
|
||||
const sender = conversation?.meta?.sender;
|
||||
// `caller` is the snapshot captured when the call first landed (from the
|
||||
// message sender or the WhatsApp cable payload). It outlives the
|
||||
// conversation being in the store, so prefer it for display.
|
||||
const caller = call?.caller;
|
||||
const additional =
|
||||
sender?.additional_attributes || caller?.additionalAttributes || {};
|
||||
const city = additional.city || '';
|
||||
const countryCode = additional.country_code || '';
|
||||
const country =
|
||||
additional.country ||
|
||||
countriesList.find(c => c.id === countryCode.toUpperCase())?.name ||
|
||||
'';
|
||||
// Prefer the richest available location string ("City, Country"); fall back to
|
||||
// whichever single field is present; finally fall back to the inbox name so
|
||||
// there's always something to show.
|
||||
const locationParts = [city, country].filter(Boolean);
|
||||
const location =
|
||||
locationParts.join(', ') || inbox?.name || 'Customer support';
|
||||
return {
|
||||
conversation,
|
||||
inbox,
|
||||
contactName:
|
||||
caller?.name ||
|
||||
sender?.name ||
|
||||
caller?.phone ||
|
||||
sender?.phone_number ||
|
||||
'Unknown caller',
|
||||
phoneNumber: caller?.phone || sender?.phone_number || '',
|
||||
inboxName: inbox?.name || 'Customer support',
|
||||
location,
|
||||
countryFlag: countryCodeToFlag(countryCode),
|
||||
hasLocation: locationParts.length > 0,
|
||||
avatar: caller?.avatar || sender?.avatar || sender?.thumbnail,
|
||||
};
|
||||
};
|
||||
|
||||
const goToConversation = call => {
|
||||
const conversationId = call?.conversationId;
|
||||
const accountId = route.params.accountId;
|
||||
if (!conversationId || !accountId) return;
|
||||
router.push({
|
||||
path: frontendURL(conversationUrl({ accountId, id: conversationId })),
|
||||
});
|
||||
};
|
||||
|
||||
const handleEndCall = async () => {
|
||||
const call = activeCall.value;
|
||||
if (!call) return;
|
||||
|
||||
const inboxId = call.inboxId || getCallInfo(call).conversation?.inbox_id;
|
||||
if (!inboxId) return;
|
||||
|
||||
await endCallSession({
|
||||
conversationId: call.conversationId,
|
||||
inboxId,
|
||||
callSid: call.callSid,
|
||||
});
|
||||
};
|
||||
|
||||
const handleJoinCall = async call => {
|
||||
if (!call || isJoining.value) return;
|
||||
const { conversation } = getCallInfo(call);
|
||||
|
||||
if (hasActiveCall.value) {
|
||||
await handleEndCall();
|
||||
}
|
||||
|
||||
// The conversation may not be hydrated yet (post-refresh seeding path);
|
||||
// call.inboxId already carries what joinCall needs.
|
||||
const result = await joinCall({
|
||||
conversationId: call.conversationId,
|
||||
inboxId: call.inboxId || conversation?.inbox_id,
|
||||
callSid: call.callSid,
|
||||
});
|
||||
|
||||
if (result && conversation) {
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: call.conversationId },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-join outbound calls when window is visible. WhatsApp outbound has no
|
||||
// separate join step (the offer was sent at initiate time and the answer is
|
||||
// applied directly by the cable handler), so this only covers Twilio.
|
||||
watch(
|
||||
() => incomingCalls.value[0],
|
||||
call => {
|
||||
if (
|
||||
call?.callDirection === VOICE_CALL_DIRECTION.OUTBOUND &&
|
||||
call?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP &&
|
||||
!hasActiveCall.value &&
|
||||
WindowVisibilityHelper.isWindowVisible()
|
||||
) {
|
||||
handleJoinCall(call);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Loop the ringtone while an inbound call is unanswered. Stop the moment any
|
||||
// call is active (we joined), every inbound call cleared, or the widget tears
|
||||
// down. The watcher only fires on the boolean transitioning, so additional
|
||||
// ringing calls arriving while one is already ringing don't restart the audio
|
||||
// — they silently stack into the UI without producing a fresh ring.
|
||||
// Browser autoplay may reject the first play() if the tab has no prior
|
||||
// user gesture; that's fine — the visual widget still surfaces the call.
|
||||
const ringtone = new Audio(RINGTONE_URL);
|
||||
ringtone.loop = true;
|
||||
ringtone.volume = 1;
|
||||
|
||||
const stopRingtone = () => {
|
||||
ringtone.pause();
|
||||
ringtone.currentTime = 0;
|
||||
};
|
||||
|
||||
const ringingInbound = computed(() =>
|
||||
incomingCalls.value.some(
|
||||
call => call.callDirection !== VOICE_CALL_DIRECTION.OUTBOUND
|
||||
)
|
||||
);
|
||||
|
||||
watch(
|
||||
() => ringingInbound.value && !hasActiveCall.value,
|
||||
shouldRing => {
|
||||
if (shouldRing) {
|
||||
ringtone.play().catch(() => {});
|
||||
} else {
|
||||
stopRingtone();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(stopRingtone);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="incomingCalls.length || hasActiveCall"
|
||||
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-3 w-[400px]"
|
||||
>
|
||||
<!-- Stacked incoming calls (shown above the primary card) -->
|
||||
<CallCard
|
||||
v-for="call in stackedIncomingCalls"
|
||||
:key="call.callSid"
|
||||
:call="call"
|
||||
:state="stackedCardState(call)"
|
||||
:call-info="getCallInfo(call)"
|
||||
@accept="handleJoinCall(call)"
|
||||
@reject="rejectIncomingCall(call.callSid)"
|
||||
@dismiss="dismissCall(call.callSid)"
|
||||
@go-to-conversation="goToConversation(call)"
|
||||
/>
|
||||
|
||||
<!-- Main Call Widget -->
|
||||
<CallCard
|
||||
v-if="hasActiveCall || primaryIncomingCall"
|
||||
:call="activeCall || primaryIncomingCall"
|
||||
:state="mainCardState"
|
||||
:call-info="getCallInfo(activeCall || primaryIncomingCall)"
|
||||
:duration="hasActiveCall ? formattedCallDuration : ''"
|
||||
:is-muted="isMuted"
|
||||
:show-mute="hasActiveCall && isWhatsappActive"
|
||||
@accept="handleJoinCall(primaryIncomingCall)"
|
||||
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
|
||||
@dismiss="dismissCall(primaryIncomingCall?.callSid)"
|
||||
@end="handleEndCall"
|
||||
@toggle-mute="toggleMute"
|
||||
@go-to-conversation="goToConversation(activeCall || primaryIncomingCall)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -25,7 +25,7 @@ const store = useStore();
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
const isSyncableDocument = doc =>
|
||||
!doc.pdf_document && doc.status === 'available' && !doc.sync_in_progress;
|
||||
!doc.pdf_document && doc.status === 'available';
|
||||
|
||||
const syncableSelectedIds = computed(() => {
|
||||
if (!props.selectedIds.size) return [];
|
||||
|
||||
@@ -127,7 +127,6 @@ const menuItems = computed(() => {
|
||||
value: 'sync',
|
||||
action: 'sync',
|
||||
icon: 'i-lucide-refresh-cw',
|
||||
disabled: props.syncInProgress,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { defineModel, watch } from 'vue';
|
||||
import { watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, defineModel, ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, defineProps, defineEmits } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import { Chrome } from '@lk77/vue3-color';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { defineProps, ref, defineEmits, computed, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, defineModel, h, watch, ref } from 'vue';
|
||||
import { computed, h, watch, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { defineModel, computed } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import Button from 'next/button/Button.vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { defineModel, computed, ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { h, defineProps } from 'vue';
|
||||
import { h } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
country: { type: String, required: true },
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import ChannelIcon from './ChannelIcon.vue';
|
||||
import { useChannelBrandIcon } from './provider';
|
||||
|
||||
const inboxes = [
|
||||
{ name: 'API', channel_type: 'Channel::Api' },
|
||||
{ name: 'Email', channel_type: 'Channel::Email' },
|
||||
{ name: 'Gmail', channel_type: 'Channel::Email', provider: 'google' },
|
||||
{ name: 'Outlook', channel_type: 'Channel::Email', provider: 'microsoft' },
|
||||
{ name: 'Messenger', channel_type: 'Channel::FacebookPage' },
|
||||
{ name: 'Instagram', channel_type: 'Channel::Instagram' },
|
||||
{ name: 'Line', channel_type: 'Channel::Line' },
|
||||
{ name: 'Telegram', channel_type: 'Channel::Telegram' },
|
||||
{ name: 'WhatsApp', channel_type: 'Channel::Whatsapp' },
|
||||
{ name: 'TikTok', channel_type: 'Channel::Tiktok' },
|
||||
{ name: 'SMS', channel_type: 'Channel::Sms' },
|
||||
{ name: 'Twilio SMS', channel_type: 'Channel::TwilioSms' },
|
||||
{
|
||||
name: 'Twilio WhatsApp',
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
medium: 'whatsapp',
|
||||
},
|
||||
{
|
||||
name: 'Voice',
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
voice_enabled: true,
|
||||
},
|
||||
{ name: 'Website', channel_type: 'Channel::WebWidget' },
|
||||
];
|
||||
|
||||
const brandInboxes = inboxes.filter(inbox => useChannelBrandIcon(inbox).value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Icons/ChannelIcon">
|
||||
<Variant title="Glyph (default)">
|
||||
<div class="grid grid-cols-4 gap-5">
|
||||
<div
|
||||
v-for="inbox in inboxes"
|
||||
:key="inbox.name"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<ChannelIcon :inbox="inbox" class="size-6 text-n-slate-11" />
|
||||
<span>{{ inbox.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Brand icon">
|
||||
<div class="grid grid-cols-4 gap-5">
|
||||
<div
|
||||
v-for="inbox in brandInboxes"
|
||||
:key="inbox.name"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<ChannelIcon
|
||||
:inbox="inbox"
|
||||
use-brand-icon
|
||||
class="size-6 text-n-slate-11"
|
||||
/>
|
||||
<span>{{ inbox.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { toRef } from 'vue';
|
||||
import { useChannelIcon } from './provider';
|
||||
import { computed, toRef } from 'vue';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
import { useChannelIcon, useChannelBrandIcon } from './provider';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -8,11 +9,35 @@ const props = defineProps({
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
// When true, render the full-color brand icon (when one exists for the
|
||||
// channel type) and fall back to the monochrome glyph otherwise.
|
||||
useBrandIcon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const channelIcon = useChannelIcon(toRef(props, 'inbox'));
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const inboxRef = toRef(props, 'inbox');
|
||||
|
||||
const hasVoiceBadge = computed(() => isVoiceCallEnabled(props.inbox));
|
||||
const channelIcon = useChannelIcon(inboxRef);
|
||||
const brandIcon = useChannelBrandIcon(inboxRef);
|
||||
|
||||
const icon = computed(() =>
|
||||
props.useBrandIcon && brandIcon.value ? brandIcon.value : channelIcon.value
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon :icon="channelIcon" />
|
||||
<span class="relative inline-flex" v-bind="$attrs">
|
||||
<Icon :icon="icon" class="size-full" />
|
||||
<span
|
||||
v-if="hasVoiceBadge"
|
||||
class="absolute top-0 ltr:right-0 rtl:left-0 inline-flex items-center justify-center size-2 rounded-full bg-n-surface-1"
|
||||
>
|
||||
<Icon icon="i-lucide-audio-lines" class="size-1.5 text-n-slate-12" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -18,6 +18,7 @@ const fileTypeIcon = computed(() => {
|
||||
json: 'i-woot-file-txt',
|
||||
odt: 'i-woot-file-doc',
|
||||
pdf: 'i-woot-file-pdf',
|
||||
pfx: 'i-woot-file-pfx',
|
||||
ppt: 'i-woot-file-ppt',
|
||||
pptx: 'i-woot-file-ppt',
|
||||
rar: 'i-woot-file-zip',
|
||||
@@ -26,6 +27,7 @@ const fileTypeIcon = computed(() => {
|
||||
txt: 'i-woot-file-txt',
|
||||
xls: 'i-woot-file-xls',
|
||||
xlsx: 'i-woot-file-xls',
|
||||
xml: 'i-woot-file-txt',
|
||||
zip: 'i-woot-file-zip',
|
||||
};
|
||||
|
||||
|
||||
@@ -1,45 +1,75 @@
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import { computed } from 'vue';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
|
||||
const channelTypeIconMap = {
|
||||
'Channel::Api': 'i-woot-api',
|
||||
'Channel::Email': 'i-woot-mail',
|
||||
'Channel::FacebookPage': 'i-woot-messenger',
|
||||
'Channel::Line': 'i-woot-line',
|
||||
'Channel::Sms': 'i-woot-sms',
|
||||
'Channel::Telegram': 'i-woot-telegram',
|
||||
'Channel::TwilioSms': 'i-woot-sms',
|
||||
'Channel::TwitterProfile': 'i-woot-x',
|
||||
'Channel::WebWidget': 'i-woot-website',
|
||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||
'Channel::Instagram': 'i-woot-instagram',
|
||||
'Channel::Tiktok': 'i-woot-tiktok',
|
||||
};
|
||||
|
||||
const providerIconMap = {
|
||||
microsoft: 'i-woot-outlook',
|
||||
google: 'i-woot-gmail',
|
||||
};
|
||||
|
||||
// Full-color brand icons. Most come from the `logos` Iconify set; Instagram,
|
||||
// Outlook and Line use custom `woot` glyphs since the `logos` versions are
|
||||
// monochrome or missing. Channels not listed here have no brand variant and
|
||||
// callers should fall back to the monochrome glyph via useChannelIcon.
|
||||
const channelTypeBrandIconMap = {
|
||||
'Channel::FacebookPage': 'i-logos-messenger',
|
||||
'Channel::Line': 'i-woot-line-color',
|
||||
'Channel::Telegram': 'i-logos-telegram',
|
||||
'Channel::Whatsapp': 'i-logos-whatsapp-icon',
|
||||
'Channel::Instagram': 'i-woot-instagram-color',
|
||||
'Channel::Tiktok': 'i-logos-tiktok-icon',
|
||||
};
|
||||
|
||||
const providerBrandIconMap = {
|
||||
microsoft: 'i-woot-outlook-color',
|
||||
google: 'i-logos-google-gmail',
|
||||
};
|
||||
|
||||
const resolveInbox = inbox => inbox?.value ?? inbox;
|
||||
|
||||
export function useChannelIcon(inbox) {
|
||||
const channelTypeIconMap = {
|
||||
'Channel::Api': 'i-woot-api',
|
||||
'Channel::Email': 'i-woot-mail',
|
||||
'Channel::FacebookPage': 'i-woot-messenger',
|
||||
'Channel::Line': 'i-woot-line',
|
||||
'Channel::Sms': 'i-woot-sms',
|
||||
'Channel::Telegram': 'i-woot-telegram',
|
||||
'Channel::TwilioSms': 'i-woot-sms',
|
||||
'Channel::TwitterProfile': 'i-woot-x',
|
||||
'Channel::WebWidget': 'i-woot-website',
|
||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||
'Channel::Instagram': 'i-woot-instagram',
|
||||
'Channel::Tiktok': 'i-woot-tiktok',
|
||||
};
|
||||
|
||||
const providerIconMap = {
|
||||
microsoft: 'i-woot-outlook',
|
||||
google: 'i-woot-gmail',
|
||||
};
|
||||
|
||||
const channelIcon = computed(() => {
|
||||
const inboxDetails = inbox.value || inbox;
|
||||
const inboxDetails = resolveInbox(inbox);
|
||||
const type = inboxDetails.channel_type;
|
||||
let icon = channelTypeIconMap[type];
|
||||
|
||||
if (type === 'Channel::Email' && inboxDetails.provider) {
|
||||
if (type === INBOX_TYPES.EMAIL && inboxDetails.provider) {
|
||||
if (Object.keys(providerIconMap).includes(inboxDetails.provider)) {
|
||||
icon = providerIconMap[inboxDetails.provider];
|
||||
}
|
||||
}
|
||||
|
||||
// Special case for Twilio whatsapp
|
||||
if (type === 'Channel::TwilioSms' && inboxDetails.medium === 'whatsapp') {
|
||||
if (
|
||||
type === INBOX_TYPES.TWILIO &&
|
||||
inboxDetails.medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||
) {
|
||||
icon = 'i-woot-whatsapp';
|
||||
}
|
||||
|
||||
// Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.)
|
||||
if (isVoiceCallEnabled(inboxDetails)) {
|
||||
// Native Twilio voice inbox: a TwilioSms with voice enabled (and no WhatsApp medium)
|
||||
// is presented as a Voice channel, so show the phone icon.
|
||||
const voiceEnabled =
|
||||
inboxDetails.voice_enabled || inboxDetails.voiceEnabled;
|
||||
if (
|
||||
type === INBOX_TYPES.TWILIO &&
|
||||
voiceEnabled &&
|
||||
inboxDetails.medium !== TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||
) {
|
||||
icon = 'i-woot-voice';
|
||||
}
|
||||
|
||||
@@ -48,3 +78,26 @@ export function useChannelIcon(inbox) {
|
||||
|
||||
return channelIcon;
|
||||
}
|
||||
|
||||
export function useChannelBrandIcon(inbox) {
|
||||
return computed(() => {
|
||||
const inboxDetails = resolveInbox(inbox);
|
||||
const type = inboxDetails.channel_type;
|
||||
let icon = channelTypeBrandIconMap[type];
|
||||
|
||||
if (type === INBOX_TYPES.EMAIL && inboxDetails.provider) {
|
||||
if (Object.keys(providerBrandIconMap).includes(inboxDetails.provider)) {
|
||||
icon = providerBrandIconMap[inboxDetails.provider];
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
type === INBOX_TYPES.TWILIO &&
|
||||
inboxDetails.medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||
) {
|
||||
icon = channelTypeBrandIconMap['Channel::Whatsapp'];
|
||||
}
|
||||
|
||||
return icon ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -554,11 +554,10 @@ provideMessageContext({
|
||||
<Avatar v-bind="avatarInfo" :size="24" />
|
||||
</div>
|
||||
<div
|
||||
class="[grid-area:bubble] flex"
|
||||
class="[grid-area:bubble] flex min-w-0"
|
||||
:class="{
|
||||
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
||||
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
|
||||
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
||||
}"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { defineProps, computed, reactive } from 'vue';
|
||||
import { computed, reactive } from 'vue';
|
||||
import Message from './Message.vue';
|
||||
import { MESSAGE_TYPES } from './constants.js';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
|
||||
defineProps({
|
||||
showingOriginal: Boolean,
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ const replyToPreview = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="text-sm"
|
||||
class="text-sm min-w-0"
|
||||
:class="[
|
||||
messageClass,
|
||||
{
|
||||
|
||||
@@ -6,9 +6,12 @@ import BaseBubble from './Base.vue';
|
||||
|
||||
const { inboxId } = useMessageContext();
|
||||
|
||||
const { isAFacebookInbox, isAnInstagramChannel, isATiktokChannel } = useInbox(
|
||||
inboxId.value
|
||||
);
|
||||
const {
|
||||
isAFacebookInbox,
|
||||
isAnInstagramChannel,
|
||||
isATiktokChannel,
|
||||
isAWhatsAppChannel,
|
||||
} = useInbox(inboxId.value);
|
||||
|
||||
const unsupportedMessageKey = computed(() => {
|
||||
if (isAFacebookInbox.value)
|
||||
@@ -16,6 +19,8 @@ const unsupportedMessageKey = computed(() => {
|
||||
if (isAnInstagramChannel.value)
|
||||
return 'CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM';
|
||||
if (isATiktokChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_TIKTOK';
|
||||
if (isAWhatsAppChannel.value)
|
||||
return 'CONVERSATION.UNSUPPORTED_MESSAGE_WHATSAPP';
|
||||
return 'CONVERSATION.UNSUPPORTED_MESSAGE';
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -2,14 +2,27 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { VOICE_CALL_STATUS } from '../constants';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
import {
|
||||
VOICE_CALL_STATUS,
|
||||
VOICE_CALL_DIRECTION,
|
||||
VOICE_CALL_OUTBOUND_INIT_STATUS,
|
||||
VOICE_CALL_END_REASON,
|
||||
MESSAGE_TYPES,
|
||||
ATTACHMENT_TYPES,
|
||||
} from '../constants';
|
||||
import { useCallActions } from 'dashboard/composables/useCallSession';
|
||||
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
import { formatDuration } from 'shared/helpers/timeHelper';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import AudioChip from 'next/message/chips/Audio.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const LABEL_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||
@@ -17,39 +30,67 @@ const LABEL_MAP = {
|
||||
};
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||
};
|
||||
|
||||
const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9',
|
||||
[VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call-bold',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold',
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { call, conversationId, currentUserId, inboxId } = useMessageContext();
|
||||
const {
|
||||
call,
|
||||
attachments,
|
||||
contentAttributes,
|
||||
conversationId,
|
||||
currentUserId,
|
||||
inboxId,
|
||||
sender,
|
||||
messageType,
|
||||
} = useMessageContext();
|
||||
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
|
||||
useCallSession();
|
||||
useCallActions();
|
||||
const whatsappCallSession = useWhatsappCallSession();
|
||||
const callsStore = useCallsStore();
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
const isInitiatingCall = computed(
|
||||
() => contactsUiFlags.value?.isInitiatingCall || false
|
||||
);
|
||||
|
||||
const status = computed(() => call.value?.status);
|
||||
const isOutbound = computed(() => call.value?.direction === 'outgoing');
|
||||
// Server-side call records use `outgoing`/`incoming`, while the Pinia store
|
||||
// and a few API hops normalise to `outbound`/`inbound`. Accept either so the
|
||||
// bubble label matches the message orientation no matter the source.
|
||||
const isOutbound = computed(() => {
|
||||
const dir = call.value?.direction;
|
||||
if (
|
||||
dir === VOICE_CALL_DIRECTION.OUTGOING ||
|
||||
dir === VOICE_CALL_DIRECTION.OUTBOUND
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
dir === VOICE_CALL_DIRECTION.INCOMING ||
|
||||
dir === VOICE_CALL_DIRECTION.INBOUND
|
||||
)
|
||||
return false;
|
||||
// Fall back to the message orientation: agent-authored messages sit on the
|
||||
// right (outbound) and contact-authored ones on the left.
|
||||
return messageType.value === MESSAGE_TYPES.OUTGOING;
|
||||
});
|
||||
const isWhatsapp = computed(
|
||||
() => call.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
|
||||
);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
|
||||
const didCurrentUserAnswer = computed(
|
||||
const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
|
||||
const endReason = computed(() => call.value?.endReason);
|
||||
const wasDeclinedByAgent = computed(
|
||||
() =>
|
||||
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
|
||||
isMissedInbound.value &&
|
||||
endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED
|
||||
);
|
||||
// Pickup auto-assigns the conversation, so the assignee is a safe display proxy
|
||||
// for the answerer when the Call payload lacks accepted_by_agent_id (e.g.,
|
||||
// Twilio's call-status webhook flipped the call to in-progress before the
|
||||
// participant-join webhook claimed it).
|
||||
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
|
||||
const conversationAssignee = computed(() => {
|
||||
const conversation = store.getters.getConversationById?.(
|
||||
conversationId?.value
|
||||
@@ -66,67 +107,107 @@ const displayAgentName = computed(() => {
|
||||
return conversationAssignee.value?.name || null;
|
||||
});
|
||||
|
||||
const audioAttachment = computed(() =>
|
||||
(attachments?.value || []).find(a => a.fileType === ATTACHMENT_TYPES.AUDIO)
|
||||
);
|
||||
|
||||
const durationSeconds = computed(() => {
|
||||
const fromCall = call.value?.durationSeconds || call.value?.duration_seconds;
|
||||
if (fromCall != null) return fromCall;
|
||||
const data = contentAttributes?.value?.data;
|
||||
return data?.durationSeconds || data?.duration_seconds;
|
||||
});
|
||||
|
||||
const formattedDuration = computed(() => formatDuration(durationSeconds.value));
|
||||
|
||||
// Agent who handled the call (initiator on outbound, answerer on inbound), taken
|
||||
// strictly from the persisted accept fields — never the conversation's current
|
||||
// assignee, which would mis-attribute a historical call after a reassignment.
|
||||
const handlerName = computed(() => {
|
||||
if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName;
|
||||
if (!acceptedByAgentId.value) return null;
|
||||
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
|
||||
return agent?.available_name || agent?.name || null;
|
||||
});
|
||||
|
||||
const handledBy = computed(() =>
|
||||
handlerName.value
|
||||
? t('CONVERSATION.VOICE_CALL.HANDLED_BY', { agentName: handlerName.value })
|
||||
: null
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
if (isFailed.value) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL'
|
||||
: 'CONVERSATION.VOICE_CALL.MISSED_CALL';
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
||||
// RINGING or an as-yet-unknown/initial status: orient purely by direction so an
|
||||
// outbound call never falls through to the "Incoming call" label.
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const formattedDuration = computed(() =>
|
||||
formatDuration(call.value?.durationSeconds)
|
||||
);
|
||||
|
||||
const subtext = computed(() => {
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
}
|
||||
// Completed: "Handled by {agent} · 0:42" (drops either part when absent).
|
||||
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
|
||||
return formattedDuration.value;
|
||||
return [handledBy.value, formattedDuration.value]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
||||
if (didCurrentUserAnswer.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
return handledBy.value;
|
||||
}
|
||||
if (isFailed.value) {
|
||||
// Missed/failed calls have no handler, so keep the reason rather than "Handled by".
|
||||
if (isOutbound.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT');
|
||||
}
|
||||
if (displayAgentName.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
|
||||
if (wasDeclinedByAgent.value && displayAgentName.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_DECLINED_BY', {
|
||||
agentName: displayAgentName.value,
|
||||
});
|
||||
}
|
||||
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
||||
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT');
|
||||
}
|
||||
return isFailed.value
|
||||
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
|
||||
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
// RINGING or an as-yet-unknown/initial status.
|
||||
if (isOutbound.value) {
|
||||
return handledBy.value || t('CONVERSATION.VOICE_CALL.CALLING');
|
||||
}
|
||||
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
|
||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||
return isOutbound.value
|
||||
? 'i-ph-phone-outgoing-bold'
|
||||
: 'i-ph-phone-incoming-bold';
|
||||
});
|
||||
|
||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
// Subtle icon container — matches the design's tonal swatch over the bubble bg.
|
||||
// Status drives the accent: teal for live, ruby for missed, neutral otherwise.
|
||||
const iconContainerClass = computed(() => {
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return 'bg-n-teal-3 text-n-teal-11';
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return 'bg-n-teal-3 text-n-teal-11';
|
||||
}
|
||||
if (isMissedInbound.value) {
|
||||
return 'bg-n-alpha-2 text-n-ruby-9';
|
||||
}
|
||||
return 'bg-n-alpha-2 text-n-slate-12';
|
||||
});
|
||||
|
||||
const callSid = computed(() => call.value?.providerCallId);
|
||||
|
||||
// Show "Join call" when the call is still ringing, no agent has claimed it,
|
||||
// and the conversation is unassigned or assigned to the current user. Mirrors
|
||||
// the eligibility used by FloatingCallWidget so the bubble can act as a
|
||||
// recovery affordance after a refresh or missed widget.
|
||||
const canJoinCall = computed(() => {
|
||||
if (status.value !== VOICE_CALL_STATUS.RINGING) return false;
|
||||
if (isOutbound.value) return false;
|
||||
if (acceptedByAgentId.value) return false;
|
||||
if (!callSid.value || !inboxId.value || !conversationId.value) return false;
|
||||
// Suppress the button once this call is the local active session — the
|
||||
// message status webhook may lag behind, so we can't rely on `status` alone
|
||||
// to hide it after a successful join from this client.
|
||||
if (hasActiveCall.value && activeCall.value?.callSid === callSid.value)
|
||||
return false;
|
||||
const assignee = conversationAssignee.value;
|
||||
@@ -135,11 +216,12 @@ const canJoinCall = computed(() => {
|
||||
});
|
||||
|
||||
const recordingAttachment = computed(() => {
|
||||
if (audioAttachment.value) return audioAttachment.value;
|
||||
const url = call.value?.recordingUrl;
|
||||
if (!url) return null;
|
||||
return {
|
||||
dataUrl: url,
|
||||
fileType: 'audio',
|
||||
fileType: ATTACHMENT_TYPES.AUDIO,
|
||||
extension: 'wav',
|
||||
transcribedText: call.value?.transcript || '',
|
||||
};
|
||||
@@ -162,48 +244,117 @@ const handleJoinCall = async () => {
|
||||
callSid: callSid.value,
|
||||
});
|
||||
};
|
||||
|
||||
const canCallBack = computed(
|
||||
() =>
|
||||
isMissedInbound.value &&
|
||||
!!inboxId.value &&
|
||||
!!conversationId.value &&
|
||||
!hasActiveCall.value &&
|
||||
!callsStore.hasIncomingCall
|
||||
);
|
||||
|
||||
const handleCallBack = async () => {
|
||||
if (!canCallBack.value || isInitiatingCall.value) return;
|
||||
try {
|
||||
if (isWhatsapp.value) {
|
||||
const response = await whatsappCallSession.initiateOutboundCall(
|
||||
conversationId.value
|
||||
);
|
||||
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
|
||||
// Permission template path returns no call id — show banner, no widget yet.
|
||||
if (!response?.id) {
|
||||
useAlert(
|
||||
response?.status ===
|
||||
VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
|
||||
? t('CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_PENDING')
|
||||
: t('CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_REQUESTED')
|
||||
);
|
||||
return;
|
||||
}
|
||||
callsStore.addCall({
|
||||
callSid: response.call_id,
|
||||
callId: response.id,
|
||||
conversationId: conversationId.value,
|
||||
inboxId: inboxId.value,
|
||||
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
|
||||
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const response = await store.dispatch('contacts/initiateCall', {
|
||||
contactId: sender.value?.id,
|
||||
inboxId: inboxId.value,
|
||||
conversationId: conversationId.value,
|
||||
});
|
||||
callsStore.addCall({
|
||||
callSid: response?.call_sid,
|
||||
conversationId: response?.conversation_id ?? conversationId.value,
|
||||
inboxId: inboxId.value,
|
||||
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="p-0 border-none" hide-meta>
|
||||
<div class="flex overflow-hidden flex-col w-full max-w-sm">
|
||||
<div class="flex gap-3 items-center p-3 w-full">
|
||||
<BaseBubble class="!p-3 !max-w-md min-w-[240px]" hide-meta>
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
<!-- Header row: icon + title + duration/subtext -->
|
||||
<div class="flex gap-2.5 items-start">
|
||||
<div
|
||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
||||
:class="bgColor"
|
||||
class="flex justify-center items-center rounded-xl size-11 shrink-0"
|
||||
:class="iconContainerClass"
|
||||
>
|
||||
<Icon
|
||||
class="size-5"
|
||||
:icon="iconName"
|
||||
:class="{
|
||||
'text-n-slate-1': status === VOICE_CALL_STATUS.COMPLETED,
|
||||
'text-white': status !== VOICE_CALL_STATUS.COMPLETED,
|
||||
}"
|
||||
/>
|
||||
<Icon class="size-4" :icon="iconName" />
|
||||
</div>
|
||||
|
||||
<div class="flex overflow-hidden flex-col flex-grow">
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
<div class="flex flex-col flex-1 min-w-0 self-center">
|
||||
<span
|
||||
class="font-display text-sm font-medium leading-tight truncate tracking-tight"
|
||||
>
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
<span v-if="subtext" class="text-xs text-n-slate-11">
|
||||
<span
|
||||
v-if="subtext"
|
||||
class="text-sm leading-tight truncate tracking-tight opacity-75"
|
||||
>
|
||||
{{ subtext }}
|
||||
</span>
|
||||
<button
|
||||
v-if="canJoinCall"
|
||||
type="button"
|
||||
class="p-0 mt-1 text-xs font-medium text-start text-n-teal-10 hover:text-n-teal-11 disabled:opacity-50"
|
||||
:disabled="isJoining"
|
||||
@click="handleJoinCall"
|
||||
>
|
||||
{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="recordingAttachment" class="px-3 pb-3">
|
||||
<AudioChip :attachment="recordingAttachment" />
|
||||
</div>
|
||||
<!-- Audio player (when there's a recording) -->
|
||||
<AudioChip
|
||||
v-if="recordingAttachment"
|
||||
:attachment="recordingAttachment"
|
||||
show-transcribed-text
|
||||
/>
|
||||
|
||||
<!-- Call back button (missed inbound) -->
|
||||
<NextButton
|
||||
v-if="canCallBack"
|
||||
type="button"
|
||||
:label="$t('CONVERSATION.VOICE_CALL.CALL_BACK')"
|
||||
icon="i-ph-phone-bold"
|
||||
teal
|
||||
class="!rounded-full"
|
||||
:disabled="isInitiatingCall"
|
||||
@click="handleCallBack"
|
||||
/>
|
||||
|
||||
<!-- Join call button (ringing inbound) -->
|
||||
<NextButton
|
||||
v-if="canJoinCall"
|
||||
type="button"
|
||||
:label="$t('CONVERSATION.VOICE_CALL.JOIN_CALL')"
|
||||
icon="i-ph-phone-bold"
|
||||
teal
|
||||
class="!rounded-full"
|
||||
:disabled="isJoining"
|
||||
@click="handleJoinCall"
|
||||
/>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, defineOptions, useAttrs } from 'vue';
|
||||
import { computed, useAttrs } from 'vue';
|
||||
|
||||
import ImageChip from 'next/message/chips/Image.vue';
|
||||
import VideoChip from 'next/message/chips/Video.vue';
|
||||
|
||||
@@ -31,6 +31,17 @@ const timeStampURL = computed(() => {
|
||||
return timeStampAppendedURL(attachment.dataUrl);
|
||||
});
|
||||
|
||||
const TRANSCRIPT_PREVIEW_LENGTH = 200;
|
||||
const isTranscriptExpanded = ref(false);
|
||||
const isTranscriptLong = computed(
|
||||
() => (attachment.transcribedText?.length || 0) > TRANSCRIPT_PREVIEW_LENGTH
|
||||
);
|
||||
const displayedTranscript = computed(() => {
|
||||
const text = attachment.transcribedText || '';
|
||||
if (!isTranscriptLong.value || isTranscriptExpanded.value) return text;
|
||||
return `${text.slice(0, TRANSCRIPT_PREVIEW_LENGTH).trimEnd()}…`;
|
||||
});
|
||||
|
||||
const audioPlayer = useTemplateRef('audioPlayer');
|
||||
|
||||
const isPlaying = ref(false);
|
||||
@@ -41,8 +52,33 @@ const playbackSpeed = ref(1);
|
||||
|
||||
const { uid } = getCurrentInstance();
|
||||
|
||||
// MediaRecorder-produced WebM/Opus blobs lack a Duration header → <audio>.duration
|
||||
// resolves to Infinity until we seek past the end, which forces the engine to
|
||||
// scan the file and compute the real length. Safe no-op for files with a real
|
||||
// duration already (mp3/m4a/etc).
|
||||
const resolveStreamingDuration = () => {
|
||||
const el = audioPlayer.value;
|
||||
if (!el) return;
|
||||
const onTimeUpdate = () => {
|
||||
el.removeEventListener('timeupdate', onTimeUpdate);
|
||||
el.currentTime = 0;
|
||||
duration.value = el.duration;
|
||||
};
|
||||
el.addEventListener('timeupdate', onTimeUpdate);
|
||||
try {
|
||||
el.currentTime = Number.MAX_SAFE_INTEGER;
|
||||
} catch {
|
||||
el.removeEventListener('timeupdate', onTimeUpdate);
|
||||
}
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
duration.value = audioPlayer.value?.duration;
|
||||
const d = audioPlayer.value?.duration;
|
||||
if (!Number.isFinite(d)) {
|
||||
resolveStreamingDuration();
|
||||
return;
|
||||
}
|
||||
duration.value = d;
|
||||
};
|
||||
|
||||
const playbackSpeedLabel = computed(() => {
|
||||
@@ -53,7 +89,8 @@ const playbackSpeedLabel = computed(() => {
|
||||
// When the onLoadMetadata is called, so we need to set the duration
|
||||
// value when the component is mounted
|
||||
onMounted(() => {
|
||||
duration.value = audioPlayer.value?.duration;
|
||||
const d = audioPlayer.value?.duration;
|
||||
if (Number.isFinite(d)) duration.value = d;
|
||||
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||
});
|
||||
|
||||
@@ -189,7 +226,18 @@ const downloadAudio = async () => {
|
||||
v-if="attachment.transcribedText && showTranscribedText"
|
||||
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
|
||||
>
|
||||
{{ attachment.transcribedText }}
|
||||
{{ displayedTranscript }}
|
||||
<button
|
||||
v-if="isTranscriptLong"
|
||||
class="block mt-1 p-0 border-0 bg-transparent text-n-slate-11 hover:text-n-slate-12 font-medium"
|
||||
@click="isTranscriptExpanded = !isTranscriptExpanded"
|
||||
>
|
||||
{{
|
||||
isTranscriptExpanded
|
||||
? $t('CONVERSATION.VOICE_CALL.TRANSCRIPT_SHOW_LESS')
|
||||
: $t('CONVERSATION.VOICE_CALL.TRANSCRIPT_SHOW_MORE')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -88,5 +88,18 @@ export const VOICE_CALL_STATUS = {
|
||||
|
||||
export const VOICE_CALL_DIRECTION = {
|
||||
INBOUND: 'inbound',
|
||||
INCOMING: 'incoming',
|
||||
OUTGOING: 'outgoing',
|
||||
ONGOING: 'ongoing',
|
||||
OUTBOUND: 'outbound',
|
||||
};
|
||||
|
||||
export const VOICE_CALL_OUTBOUND_INIT_STATUS = {
|
||||
LOCKED: 'locked',
|
||||
PERMISSION_REQUESTED: 'permission_requested',
|
||||
PERMISSION_PENDING: 'permission_pending',
|
||||
};
|
||||
|
||||
export const VOICE_CALL_END_REASON = {
|
||||
AGENT_REJECTED: 'agent_rejected',
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import ChannelIcon from 'next/icon/ChannelIcon.vue';
|
||||
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
@@ -17,6 +18,10 @@ const props = defineProps({
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
badgeCount: {
|
||||
type: [Number, String],
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const reauthorizationRequired = computed(() => {
|
||||
@@ -29,6 +34,7 @@ const reauthorizationRequired = computed(() => {
|
||||
<ChannelIcon :inbox="inbox" class="size-4" />
|
||||
</span>
|
||||
<div class="flex-1 truncate min-w-0">{{ label }}</div>
|
||||
<SidebarUnreadBadge :count="badgeCount" />
|
||||
<div
|
||||
v-if="reauthorizationRequired"
|
||||
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { h, ref, computed, onMounted } from 'vue';
|
||||
import { h, ref, computed, onMounted, watch } from 'vue';
|
||||
import { provideSidebarContext, useSidebarResize } from './provider';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
@@ -61,6 +61,24 @@ const hasAdvancedAssignment = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const hasConversationUnreadCounts = computed(() => {
|
||||
return isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
|
||||
);
|
||||
});
|
||||
|
||||
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
|
||||
if (!currentAccountId) return;
|
||||
|
||||
if (!isEnabled) {
|
||||
store.dispatch('conversationUnreadCounts/clear');
|
||||
return;
|
||||
}
|
||||
|
||||
store.dispatch('conversationUnreadCounts/get');
|
||||
};
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -157,6 +175,15 @@ useEventListener(document, 'touchend', onResizeEnd);
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const labels = useMapGetter('labels/getLabelsOnSidebar');
|
||||
const getInboxUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getInboxUnreadCount'
|
||||
);
|
||||
const getLabelUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getLabelUnreadCount'
|
||||
);
|
||||
const getTeamUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getTeamUnreadCount'
|
||||
);
|
||||
const teams = useMapGetter('teams/getMyTeams');
|
||||
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
|
||||
const conversationCustomViews = useMapGetter(
|
||||
@@ -173,8 +200,48 @@ onMounted(() => {
|
||||
store.dispatch('customViews/get', 'contact');
|
||||
});
|
||||
|
||||
watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const normalizeUnreadCount = count => {
|
||||
const unreadCount = Number(count);
|
||||
return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0;
|
||||
};
|
||||
|
||||
const sortByUnreadCount = (items, labelKey, unreadCountKey) =>
|
||||
items.slice().sort((a, b) => {
|
||||
const unreadCountDiff =
|
||||
normalizeUnreadCount(unreadCountKey(b)) -
|
||||
normalizeUnreadCount(unreadCountKey(a));
|
||||
|
||||
if (unreadCountDiff !== 0) return unreadCountDiff;
|
||||
|
||||
return labelKey(a).localeCompare(labelKey(b));
|
||||
});
|
||||
|
||||
const sortedTeams = computed(() =>
|
||||
sortByUnreadCount(
|
||||
teams.value,
|
||||
team => team.name,
|
||||
team => getTeamUnreadCount.value(team.id)
|
||||
)
|
||||
);
|
||||
|
||||
const sortedInboxes = computed(() =>
|
||||
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||
sortByUnreadCount(
|
||||
inboxes.value,
|
||||
inbox => inbox.name,
|
||||
inbox => getInboxUnreadCount.value(inbox.id)
|
||||
)
|
||||
);
|
||||
|
||||
const sortedLabels = computed(() =>
|
||||
sortByUnreadCount(
|
||||
labels.value,
|
||||
label => label.title,
|
||||
label => getLabelUnreadCount.value(label.id)
|
||||
)
|
||||
);
|
||||
|
||||
const closeMobileSidebar = () => {
|
||||
@@ -267,9 +334,10 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.TEAMS'),
|
||||
icon: 'i-lucide-users',
|
||||
activeOn: ['conversations_through_team'],
|
||||
children: teams.value.map(team => ({
|
||||
children: sortedTeams.value.map(team => ({
|
||||
name: `${team.name}-${team.id}`,
|
||||
label: team.name,
|
||||
badgeCount: getTeamUnreadCount.value(team.id),
|
||||
to: accountScopedRoute('team_conversations', { teamId: team.id }),
|
||||
})),
|
||||
},
|
||||
@@ -281,6 +349,7 @@ const menuItems = computed(() => {
|
||||
children: sortedInboxes.value.map(inbox => ({
|
||||
name: `${inbox.name}-${inbox.id}`,
|
||||
label: inbox.name,
|
||||
badgeCount: getInboxUnreadCount.value(inbox.id),
|
||||
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
|
||||
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
|
||||
component: leafProps =>
|
||||
@@ -288,6 +357,7 @@ const menuItems = computed(() => {
|
||||
label: leafProps.label,
|
||||
active: leafProps.active,
|
||||
inbox,
|
||||
badgeCount: leafProps.badgeCount,
|
||||
}),
|
||||
})),
|
||||
},
|
||||
@@ -296,9 +366,10 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.LABELS'),
|
||||
icon: 'i-lucide-tag',
|
||||
activeOn: ['conversations_through_label'],
|
||||
children: labels.value.map(label => ({
|
||||
children: sortedLabels.value.map(label => ({
|
||||
name: `${label.title}-${label.id}`,
|
||||
label: label.title,
|
||||
badgeCount: getLabelUnreadCount.value(label.id),
|
||||
icon: h('span', {
|
||||
class: `size-[8px] rounded-sm`,
|
||||
style: { backgroundColor: label.color },
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSidebarContext } from './provider';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
@@ -166,6 +167,7 @@ onMounted(async () => {
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ subChild.label }}</span>
|
||||
<SidebarUnreadBadge :count="subChild.badgeCount" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -188,6 +190,7 @@ onMounted(async () => {
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ child.label }}</span>
|
||||
<SidebarUnreadBadge :count="child.badgeCount" />
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isVNode, computed } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import { useSidebarContext } from './provider';
|
||||
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
@@ -10,6 +11,7 @@ const props = defineProps({
|
||||
icon: { type: [String, Object], default: null },
|
||||
active: { type: Boolean, default: false },
|
||||
component: { type: Function, default: null },
|
||||
badgeCount: { type: [Number, String], default: 0 },
|
||||
});
|
||||
|
||||
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
|
||||
@@ -39,15 +41,14 @@ const shouldRenderComponent = computed(() => {
|
||||
<component
|
||||
:is="component"
|
||||
v-if="shouldRenderComponent"
|
||||
:label
|
||||
:icon
|
||||
:active
|
||||
v-bind="{ label, icon, active, badgeCount }"
|
||||
/>
|
||||
<template v-else>
|
||||
<span v-if="icon" class="size-4 grid place-content-center rounded-full">
|
||||
<Icon :icon="icon" class="size-4 inline-block" />
|
||||
</span>
|
||||
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
|
||||
<SidebarUnreadBadge :count="badgeCount" />
|
||||
</template>
|
||||
</component>
|
||||
</Policy>
|
||||
|
||||
@@ -78,26 +78,27 @@ function changeAvailabilityStatus(availability) {
|
||||
<template>
|
||||
<DropdownSection class="[&>ul]:overflow-visible">
|
||||
<div class="grid gap-0">
|
||||
<DropdownItem preserve-open>
|
||||
<div class="flex-grow flex items-center gap-1">
|
||||
<DropdownItem preserve-open class="gap-1">
|
||||
<div class="flex-grow flex items-center gap-1 min-w-0">
|
||||
{{ $t('SIDEBAR.SET_YOUR_AVAILABILITY') }}
|
||||
</div>
|
||||
<DropdownContainer>
|
||||
<DropdownContainer class="shrink-0">
|
||||
<template #trigger="{ toggle }">
|
||||
<Button
|
||||
size="sm"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="min-w-[96px]"
|
||||
icon="i-lucide-chevron-down"
|
||||
trailing-icon
|
||||
@click="toggle"
|
||||
>
|
||||
<div class="flex gap-1 items-center flex-grow text-sm">
|
||||
<div class="flex gap-1 items-center min-w-0 text-sm">
|
||||
<div class="p-1 flex-shrink-0">
|
||||
<div class="size-2 rounded-sm" :class="activeStatus.color" />
|
||||
</div>
|
||||
<span>{{ activeStatus.label }}</span>
|
||||
<span class="truncate max-w-[7rem]">
|
||||
{{ activeStatus.label }}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -114,12 +115,12 @@ function changeAvailabilityStatus(availability) {
|
||||
</DropdownContainer>
|
||||
</DropdownItem>
|
||||
<DropdownItem>
|
||||
<div class="flex-grow flex items-center gap-1">
|
||||
<div class="flex-grow min-w-0">
|
||||
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
|
||||
<Icon
|
||||
v-tooltip.top="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT')"
|
||||
icon="i-lucide-info"
|
||||
class="size-4 text-n-slate-10"
|
||||
class="inline-block align-middle ms-1 size-4 text-n-slate-10"
|
||||
/>
|
||||
</div>
|
||||
<ToggleSwitch v-model="autoOfflineToggle" />
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
count: { type: [Number, String], default: 0 },
|
||||
});
|
||||
|
||||
const normalizedCount = computed(() => {
|
||||
const count = Number(props.count);
|
||||
return Number.isFinite(count) && count > 0 ? count : 0;
|
||||
});
|
||||
|
||||
const displayCount = computed(() =>
|
||||
normalizedCount.value > 99 ? '99+' : String(normalizedCount.value)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="normalizedCount > 0"
|
||||
data-test-id="sidebar-unread-badge"
|
||||
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-slate-4 px-1 text-xxs font-medium leading-3 text-n-slate-12 dark:bg-n-slate-5 flex-shrink-0"
|
||||
>
|
||||
{{ displayCount }}
|
||||
</span>
|
||||
<span v-else class="hidden" />
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ChannelLeaf from '../ChannelLeaf.vue';
|
||||
|
||||
const mountChannelLeaf = props =>
|
||||
mount(ChannelLeaf, {
|
||||
props: {
|
||||
label: 'Website',
|
||||
inbox: { reauthorization_required: false },
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: key => key,
|
||||
},
|
||||
stubs: {
|
||||
ChannelIcon: true,
|
||||
Icon: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('ChannelLeaf', () => {
|
||||
it('renders unread badge when count is present', () => {
|
||||
const wrapper = mountChannelLeaf({ badgeCount: 3 });
|
||||
const badge = wrapper.find('[data-test-id="sidebar-unread-badge"]');
|
||||
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toBe('3');
|
||||
});
|
||||
|
||||
it('does not render unread badge when count is zero', () => {
|
||||
const wrapper = mountChannelLeaf({ badgeCount: 0 });
|
||||
|
||||
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').exists()).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { h } from 'vue';
|
||||
import SidebarGroupLeaf from '../SidebarGroupLeaf.vue';
|
||||
|
||||
vi.mock('../provider', () => ({
|
||||
useSidebarContext: () => ({
|
||||
resolvePermissions: () => [],
|
||||
resolveFeatureFlag: () => '',
|
||||
}),
|
||||
}));
|
||||
|
||||
const PolicyStub = {
|
||||
props: ['as', 'permissions', 'featureFlag'],
|
||||
template: '<li><slot /></li>',
|
||||
};
|
||||
|
||||
const RouterLinkStub = {
|
||||
props: ['to'],
|
||||
template: '<a><slot /></a>',
|
||||
};
|
||||
|
||||
const mountLeaf = props =>
|
||||
mount(SidebarGroupLeaf, {
|
||||
props: {
|
||||
label: 'Support',
|
||||
to: '/support',
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Icon: true,
|
||||
Policy: PolicyStub,
|
||||
RouterLink: RouterLinkStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('SidebarGroupLeaf', () => {
|
||||
it('renders unread badge when count is present', () => {
|
||||
const wrapper = mountLeaf({ badgeCount: 7 });
|
||||
const badge = wrapper.find('[data-test-id="sidebar-unread-badge"]');
|
||||
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toBe('7');
|
||||
});
|
||||
|
||||
it('does not render unread badge when count is zero', () => {
|
||||
const wrapper = mountLeaf({ badgeCount: 0 });
|
||||
|
||||
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').exists()).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('caps large unread counts', () => {
|
||||
const wrapper = mountLeaf({ badgeCount: 120 });
|
||||
|
||||
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').text()).toBe(
|
||||
'99+'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes unread count to custom leaf components', () => {
|
||||
const wrapper = mountLeaf({
|
||||
badgeCount: 4,
|
||||
component: leafProps =>
|
||||
h(
|
||||
'span',
|
||||
{ 'data-test-id': 'custom-leaf-count' },
|
||||
leafProps.badgeCount
|
||||
),
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe('4');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
|
||||
import { defineEmits } from 'vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
|
||||
@@ -24,6 +24,10 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasVoiceBadge: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -38,10 +42,18 @@ const { t } = useI18n();
|
||||
'cursor-not-allowed disabled:opacity-80': isComingSoon,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex size-10 items-center justify-center rounded-full bg-n-alpha-2"
|
||||
>
|
||||
<Icon :icon="icon" class="text-n-slate-10 size-6" />
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex size-10 items-center justify-center rounded-full bg-n-alpha-2"
|
||||
>
|
||||
<Icon :icon="icon" class="text-n-slate-10 size-6" />
|
||||
</div>
|
||||
<div
|
||||
v-if="hasVoiceBadge"
|
||||
class="absolute -top-1 ltr:-right-1 rtl:-left-1 flex size-4 items-center justify-center rounded-full bg-n-alpha-2 ring-2 ring-n-solid-1"
|
||||
>
|
||||
<Icon icon="i-lucide-audio-lines" class="text-n-slate-10 size-2.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-1.5">
|
||||
|
||||
@@ -339,17 +339,15 @@ export default {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep {
|
||||
.selector-wrap {
|
||||
@apply m-0 top-1;
|
||||
:deep(.selector-wrap) {
|
||||
@apply m-0 top-1;
|
||||
|
||||
.selector-name {
|
||||
@apply ml-0;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
.selector-name {
|
||||
@apply ml-0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.name) {
|
||||
@apply ml-0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, defineEmits } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import { useIntersectionObserver } from '@vueuse/core';
|
||||
|
||||
const { options } = defineProps({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
// [TODO] Use Teleport to move the modal to the end of the body
|
||||
import { ref, computed, defineEmits, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
import { defineEmits, defineModel } from 'vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
|
||||
@@ -56,6 +56,14 @@ const isActive = computed(() => {
|
||||
return props.enabledFeatures.channel_voice;
|
||||
}
|
||||
|
||||
if (key === 'whatsapp_call') {
|
||||
return (
|
||||
props.enabledFeatures.channel_voice &&
|
||||
!!window.chatwootConfig?.whatsappAppId &&
|
||||
window.chatwootConfig.whatsappAppId !== 'none'
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'website',
|
||||
'twilio',
|
||||
@@ -78,7 +86,14 @@ const isComingSoon = computed(() => {
|
||||
});
|
||||
|
||||
const isBeta = computed(() => {
|
||||
return ['tiktok', 'voice'].includes(props.channel.key);
|
||||
return ['tiktok', 'voice', 'whatsapp_call'].includes(props.channel.key);
|
||||
});
|
||||
|
||||
const hasVoiceBadge = computed(() => {
|
||||
return (
|
||||
['voice', 'whatsapp_call'].includes(props.channel.key) &&
|
||||
!!props.enabledFeatures.channel_voice
|
||||
);
|
||||
});
|
||||
|
||||
const onItemClick = () => {
|
||||
@@ -95,6 +110,7 @@ const onItemClick = () => {
|
||||
:icon="channel.icon"
|
||||
:is-coming-soon="isComingSoon"
|
||||
:is-beta="isBeta"
|
||||
:has-voice-badge="hasVoiceBadge"
|
||||
:disabled="!isActive"
|
||||
@click="onItemClick"
|
||||
/>
|
||||
|
||||
@@ -63,10 +63,8 @@ export default {
|
||||
.colorpicker--chrome.vc-chrome {
|
||||
@apply shadow-lg -mt-2.5 absolute z-[9999] border border-solid border-n-weak rounded;
|
||||
|
||||
::v-deep {
|
||||
input {
|
||||
@apply bg-white dark:bg-white;
|
||||
}
|
||||
:deep(input) {
|
||||
@apply bg-white dark:bg-white;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
<script setup>
|
||||
import { watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const {
|
||||
activeCall,
|
||||
incomingCalls,
|
||||
hasActiveCall,
|
||||
isJoining,
|
||||
joinCall,
|
||||
endCall: endCallSession,
|
||||
rejectIncomingCall,
|
||||
dismissCall,
|
||||
formattedCallDuration,
|
||||
} = useCallSession();
|
||||
|
||||
const getCallInfo = call => {
|
||||
const conversation = store.getters.getConversationById(call?.conversationId);
|
||||
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
|
||||
const sender = conversation?.meta?.sender;
|
||||
return {
|
||||
conversation,
|
||||
inbox,
|
||||
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
|
||||
inboxName: inbox?.name || 'Customer support',
|
||||
avatar: sender?.avatar || sender?.thumbnail,
|
||||
};
|
||||
};
|
||||
|
||||
const handleEndCall = async () => {
|
||||
const call = activeCall.value;
|
||||
if (!call) return;
|
||||
|
||||
const inboxId = call.inboxId || getCallInfo(call).conversation?.inbox_id;
|
||||
if (!inboxId) return;
|
||||
|
||||
await endCallSession({
|
||||
conversationId: call.conversationId,
|
||||
inboxId,
|
||||
callSid: call.callSid,
|
||||
});
|
||||
};
|
||||
|
||||
const handleJoinCall = async call => {
|
||||
const { conversation } = getCallInfo(call);
|
||||
if (!call || !conversation || isJoining.value) return;
|
||||
|
||||
// End current active call before joining new one
|
||||
if (hasActiveCall.value) {
|
||||
await handleEndCall();
|
||||
}
|
||||
|
||||
const result = await joinCall({
|
||||
conversationId: call.conversationId,
|
||||
inboxId: conversation.inbox_id,
|
||||
callSid: call.callSid,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: call.conversationId },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-join outbound calls when window is visible
|
||||
watch(
|
||||
() => incomingCalls.value[0],
|
||||
call => {
|
||||
if (
|
||||
call?.callDirection === 'outbound' &&
|
||||
!hasActiveCall.value &&
|
||||
WindowVisibilityHelper.isWindowVisible()
|
||||
) {
|
||||
handleJoinCall(call);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="incomingCalls.length || hasActiveCall"
|
||||
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-2 w-72"
|
||||
>
|
||||
<!-- Incoming Calls (shown above active call) -->
|
||||
<div
|
||||
v-for="call in hasActiveCall ? incomingCalls : []"
|
||||
:key="call.callSid"
|
||||
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||
>
|
||||
<div class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex">
|
||||
<Avatar
|
||||
:src="getCallInfo(call).avatar"
|
||||
:name="getCallInfo(call).contactName"
|
||||
:size="40"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||
{{ getCallInfo(call).contactName }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-11 truncate">
|
||||
{{ getCallInfo(call).inboxName }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||
@click="dismissCall(call.callSid)"
|
||||
>
|
||||
<i class="text-lg text-white i-ph-phone-x-bold" />
|
||||
</button>
|
||||
<button
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||
@click="handleJoinCall(call)"
|
||||
>
|
||||
<i class="text-lg text-white i-ph-phone-bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Call Widget -->
|
||||
<div
|
||||
v-if="hasActiveCall || incomingCalls.length"
|
||||
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||
>
|
||||
<div
|
||||
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
|
||||
:class="{ 'animate-pulse': !hasActiveCall }"
|
||||
>
|
||||
<Avatar
|
||||
:src="getCallInfo(activeCall || incomingCalls[0]).avatar"
|
||||
:name="getCallInfo(activeCall || incomingCalls[0]).contactName"
|
||||
:size="40"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||
{{ getCallInfo(activeCall || incomingCalls[0]).contactName }}
|
||||
</p>
|
||||
<p v-if="hasActiveCall" class="font-mono text-sm text-n-teal-9">
|
||||
{{ formattedCallDuration }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-n-slate-11">
|
||||
{{
|
||||
incomingCalls[0]?.callDirection === 'outbound'
|
||||
? $t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL')
|
||||
: $t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||
@click="
|
||||
hasActiveCall
|
||||
? handleEndCall()
|
||||
: rejectIncomingCall(incomingCalls[0]?.callSid)
|
||||
"
|
||||
>
|
||||
<i class="text-lg text-white i-ph-phone-x-bold" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!hasActiveCall"
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||
@click="handleJoinCall(incomingCalls[0])"
|
||||
>
|
||||
<i class="text-lg text-white i-ph-phone-bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import getUuid from 'widget/helpers/uuid';
|
||||
import { ref, onMounted, onUnmounted, defineEmits, defineExpose } from 'vue';
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js';
|
||||
import { format, intervalToDuration } from 'date-fns';
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useElementSize, useWindowSize } from '@vueuse/core';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
|
||||
@@ -19,9 +18,11 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
editorContent: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
// Signature-aware emptiness is computed by the parent (which has access to
|
||||
// the signature + channel context) and passed in as a boolean.
|
||||
hasContent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
conversationId: {
|
||||
type: Number,
|
||||
@@ -33,17 +34,8 @@ const emit = defineEmits(['executeCopilotAction']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { draftMessage } = useCaptain();
|
||||
|
||||
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
|
||||
|
||||
// When editorContent prop is passed, use it exclusively (even if empty)
|
||||
// This ensures each editor instance shows menu items based on its own content
|
||||
// Falls back to global draftMessage only when editorContent is not provided
|
||||
const effectiveContent = computed(() =>
|
||||
props.editorContent !== undefined ? props.editorContent : draftMessage.value
|
||||
);
|
||||
|
||||
// Selection-based menu items (when text is selected)
|
||||
const menuItems = computed(() => {
|
||||
const items = [];
|
||||
@@ -63,7 +55,7 @@ const menuItems = computed(() => {
|
||||
} else if (
|
||||
props.conversationId &&
|
||||
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
|
||||
effectiveContent.value
|
||||
props.hasContent
|
||||
) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
|
||||
@@ -72,7 +64,7 @@ const menuItems = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
if (effectiveContent.value) {
|
||||
if (props.hasContent) {
|
||||
items.push(
|
||||
{
|
||||
label: t(
|
||||
|
||||
@@ -354,16 +354,17 @@ function isBodyEmpty(content) {
|
||||
// if content is undefined, we assume that the body is empty
|
||||
if (!content) return true;
|
||||
|
||||
// if the signature is present, we need to remove it before checking
|
||||
// note that we don't update the editorView, so this is safe
|
||||
// Use effective channel type to match how signature was appended
|
||||
const bodyWithoutSignature = props.signature
|
||||
? removeSignatureHelper(
|
||||
content,
|
||||
props.signature,
|
||||
effectiveChannelType.value
|
||||
)
|
||||
: content;
|
||||
// Only strip the signature when it's actually being auto-appended for this
|
||||
// draft. Otherwise an agent whose typed text happens to match their saved
|
||||
// signature would be mistakenly treated as empty.
|
||||
const bodyWithoutSignature =
|
||||
sendWithSignature.value && props.signature
|
||||
? removeSignatureHelper(
|
||||
content,
|
||||
props.signature,
|
||||
effectiveChannelType.value
|
||||
)
|
||||
: content;
|
||||
|
||||
// trimming should remove all the whitespaces, so we can check the length
|
||||
return bodyWithoutSignature.trim().length === 0;
|
||||
@@ -474,17 +475,6 @@ function removeSignature() {
|
||||
reloadState(content);
|
||||
}
|
||||
|
||||
function toggleSignatureInEditor(signatureEnabled) {
|
||||
// The toggleSignatureInEditor gets the new value from the
|
||||
// watcher, this means that if the value is true, the signature
|
||||
// is supposed to be added, else we remove it.
|
||||
if (signatureEnabled) {
|
||||
addSignature();
|
||||
} else {
|
||||
removeSignature();
|
||||
}
|
||||
}
|
||||
|
||||
function setToolbarPosition() {
|
||||
const editorRect = editorRoot.value.getBoundingClientRect();
|
||||
const rect = selectedImageNode.value.getBoundingClientRect();
|
||||
@@ -559,6 +549,20 @@ function emitOnChange() {
|
||||
emit('update:modelValue', contentFromEditor());
|
||||
}
|
||||
|
||||
function toggleSignatureInEditor(signatureEnabled) {
|
||||
// The toggleSignatureInEditor gets the new value from the
|
||||
// watcher, this means that if the value is true, the signature
|
||||
// is supposed to be added, else we remove it.
|
||||
if (signatureEnabled) {
|
||||
addSignature();
|
||||
} else {
|
||||
removeSignature();
|
||||
}
|
||||
// reloadState replaces editor state directly and bypasses dispatchTransaction,
|
||||
// so v-model never hears about the signature change — sync it back explicitly.
|
||||
emitOnChange();
|
||||
}
|
||||
|
||||
function updateImgToolbarOnDelete() {
|
||||
// check if the selected node is present or not on keyup
|
||||
// this is needed because the user can select an image and then delete it
|
||||
@@ -585,11 +589,11 @@ function isCmdPlusEnterToSendEnabled() {
|
||||
useKeyboardEvents({
|
||||
'Alt+KeyP': {
|
||||
action: focusEditorInputField,
|
||||
allowOnFocusedInput: true,
|
||||
allowOnFocusedInput: false,
|
||||
},
|
||||
'Alt+KeyL': {
|
||||
action: focusEditorInputField,
|
||||
allowOnFocusedInput: true,
|
||||
allowOnFocusedInput: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -899,7 +903,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
v-on-click-outside="handleClickOutside"
|
||||
:has-selection="isTextSelected"
|
||||
:is-editor-menu-popover="isEditorMenuPopover"
|
||||
:editor-content="modelValue"
|
||||
:has-content="!isBodyEmpty(modelValue)"
|
||||
:conversation-id="conversationId"
|
||||
:show-selection-menu="showSelectionMenu"
|
||||
:show-general-menu="false"
|
||||
|
||||
@@ -7,7 +7,6 @@ import * as ActiveStorage from 'activestorage';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
import VideoCallButton from '../VideoCallButton.vue';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { mapGetters } from 'vuex';
|
||||
@@ -166,11 +165,6 @@ export default {
|
||||
uploadRef,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
ALLOWED_FILE_TYPES,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
@@ -212,13 +206,11 @@ export default {
|
||||
return this.conversationType === 'instagram_direct_message';
|
||||
},
|
||||
allowedFileTypes() {
|
||||
// Use default file types for private notes
|
||||
if (this.isOnPrivateNote) {
|
||||
return this.ALLOWED_FILE_TYPES;
|
||||
return getAllowedFileTypesByChannel();
|
||||
}
|
||||
|
||||
let channelType = this.channelType || this.inbox?.channel_type;
|
||||
|
||||
if (this.isAnInstagramChannel || this.isInstagramDM) {
|
||||
channelType = INBOX_TYPES.INSTAGRAM;
|
||||
}
|
||||
@@ -427,7 +419,7 @@ export default {
|
||||
@apply flex;
|
||||
}
|
||||
|
||||
::v-deep .file-uploads {
|
||||
:deep(.file-uploads) {
|
||||
label {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ export default {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
hasContent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['setReplyMode', 'toggleEditorSize', 'executeCopilotAction'],
|
||||
setup(props, { emit }) {
|
||||
@@ -76,6 +80,7 @@ export default {
|
||||
|
||||
const { captainTasksEnabled } = useCaptain();
|
||||
const showCopilotMenu = ref(false);
|
||||
const copilotToggleRef = ref(null);
|
||||
|
||||
const handleCopilotAction = (actionKey, data) => {
|
||||
emit('executeCopilotAction', actionKey, data || props.editorContent);
|
||||
@@ -100,11 +105,11 @@ export default {
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyP': {
|
||||
action: () => handleNoteClick(),
|
||||
allowOnFocusedInput: true,
|
||||
allowOnFocusedInput: false,
|
||||
},
|
||||
'Alt+KeyL': {
|
||||
action: () => handleReplyClick(),
|
||||
allowOnFocusedInput: true,
|
||||
allowOnFocusedInput: false,
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
@@ -117,6 +122,7 @@ export default {
|
||||
captainTasksEnabled,
|
||||
handleCopilotAction,
|
||||
showCopilotMenu,
|
||||
copilotToggleRef,
|
||||
toggleCopilotMenu,
|
||||
handleClickOutside,
|
||||
};
|
||||
@@ -164,6 +170,7 @@ export default {
|
||||
<div v-if="captainTasksEnabled" class="flex items-center gap-2">
|
||||
<div class="relative">
|
||||
<NextButton
|
||||
ref="copilotToggleRef"
|
||||
ghost
|
||||
:disabled="disabled || isEditorDisabled"
|
||||
:class="{
|
||||
@@ -176,9 +183,12 @@ export default {
|
||||
/>
|
||||
<CopilotMenuBar
|
||||
v-if="showCopilotMenu"
|
||||
v-on-click-outside="handleClickOutside"
|
||||
v-on-click-outside="[
|
||||
handleClickOutside,
|
||||
{ ignore: [copilotToggleRef] },
|
||||
]"
|
||||
:has-selection="false"
|
||||
:editor-content="editorContent"
|
||||
:has-content="hasContent"
|
||||
:conversation-id="conversationId"
|
||||
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
|
||||
@execute-copilot-action="handleCopilotAction"
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
getVoiceCallProvider,
|
||||
VOICE_CALL_PROVIDERS,
|
||||
} from 'dashboard/helper/inbox';
|
||||
import {
|
||||
VOICE_CALL_DIRECTION,
|
||||
VOICE_CALL_OUTBOUND_INIT_STATUS,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
chat: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const callsStore = useCallsStore();
|
||||
const whatsappCallSession = useWhatsappCallSession();
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
|
||||
const voiceCallProvider = computed(() => getVoiceCallProvider(props.inbox));
|
||||
const isVoiceCallInbox = computed(
|
||||
() =>
|
||||
voiceCallProvider.value !== null &&
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.CHANNEL_VOICE)
|
||||
);
|
||||
const isWhatsappVoiceInbox = computed(
|
||||
() => voiceCallProvider.value === VOICE_CALL_PROVIDERS.WHATSAPP
|
||||
);
|
||||
|
||||
const isCallButtonDisabled = computed(() => {
|
||||
if (callsStore.hasActiveCall || callsStore.hasIncomingCall) return true;
|
||||
if (isWhatsappVoiceInbox.value) {
|
||||
return whatsappCallSession.isInitiating.value;
|
||||
}
|
||||
return contactsUiFlags.value?.isInitiatingCall || false;
|
||||
});
|
||||
|
||||
const isCallButtonLoading = computed(() =>
|
||||
isWhatsappVoiceInbox.value
|
||||
? whatsappCallSession.isInitiating.value
|
||||
: !!contactsUiFlags.value?.isInitiatingCall
|
||||
);
|
||||
|
||||
const callButtonTooltip = computed(() =>
|
||||
isWhatsappVoiceInbox.value
|
||||
? t('CONVERSATION.HEADER.WHATSAPP_CALL')
|
||||
: t('CONVERSATION.HEADER.VOICE_CALL')
|
||||
);
|
||||
|
||||
const startWhatsappCall = async () => {
|
||||
if (whatsappCallSession.isInitiating.value) return;
|
||||
try {
|
||||
const response = await whatsappCallSession.initiateOutboundCall(
|
||||
props.chat.id
|
||||
);
|
||||
|
||||
// Composable returns LOCKED when init is already in flight or a call is
|
||||
// active; soft no-op so a parallel click doesn't trigger a banner.
|
||||
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
|
||||
// Permission template path returns no call id — show banner, no widget yet.
|
||||
if (!response?.id) {
|
||||
const status = response?.status;
|
||||
const messageKey =
|
||||
status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
|
||||
? 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_PENDING'
|
||||
: 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_REQUESTED';
|
||||
useAlert(t(messageKey));
|
||||
return;
|
||||
}
|
||||
|
||||
// Stay non-active until Meta delivers the connect webhook (sdp_answer);
|
||||
// flipping to active here would start the duration timer before pickup.
|
||||
callsStore.addCall({
|
||||
callSid: response.call_id,
|
||||
callId: response.id,
|
||||
conversationId: props.chat.id,
|
||||
inboxId: props.inbox?.id,
|
||||
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
|
||||
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CONVERSATION.HEADER.WHATSAPP_CALL_FAILED'));
|
||||
}
|
||||
};
|
||||
|
||||
const startTwilioCall = async () => {
|
||||
if (contactsUiFlags.value?.isInitiatingCall) return;
|
||||
try {
|
||||
const response = await store.dispatch('contacts/initiateCall', {
|
||||
contactId: props.chat?.meta?.sender?.id,
|
||||
inboxId: props.inbox?.id,
|
||||
conversationId: props.chat.id,
|
||||
});
|
||||
|
||||
callsStore.addCall({
|
||||
callSid: response?.call_sid,
|
||||
conversationId: response?.conversation_id ?? props.chat.id,
|
||||
inboxId: props.inbox?.id,
|
||||
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CONVERSATION.HEADER.VOICE_CALL_FAILED'));
|
||||
}
|
||||
};
|
||||
|
||||
const startCall = () => {
|
||||
if (isWhatsappVoiceInbox.value) return startWhatsappCall();
|
||||
return startTwilioCall();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NextButton
|
||||
v-if="isVoiceCallInbox"
|
||||
v-tooltip.bottom="callButtonTooltip"
|
||||
sm
|
||||
ghost
|
||||
slate
|
||||
icon="i-lucide-phone"
|
||||
:is-loading="isCallButtonLoading"
|
||||
:disabled="isCallButtonDisabled"
|
||||
@click="startCall"
|
||||
/>
|
||||
<template v-else />
|
||||
</template>
|
||||
@@ -8,13 +8,14 @@ import InboxName from '../InboxName.vue';
|
||||
import MoreActions from './MoreActions.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import ConversationCallButton from './ConversationCallButton.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
const props = defineProps({
|
||||
chat: {
|
||||
@@ -172,6 +173,7 @@ const copyConversationId = async () => {
|
||||
:parent-width="width"
|
||||
class="hidden md:flex"
|
||||
/>
|
||||
<ConversationCallButton :inbox="inbox" :chat="currentChat" />
|
||||
<MoreActions :conversation-id="currentChat.id" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -179,6 +179,21 @@ export default {
|
||||
}
|
||||
return true;
|
||||
},
|
||||
hasMeaningfulEditorContent() {
|
||||
const body = this.message || '';
|
||||
// Only strip the signature when it's actually being auto-appended.
|
||||
// If the toggle is off, the agent's text might happen to match their
|
||||
// saved signature and we'd incorrectly treat it as empty.
|
||||
const shouldStripSignature =
|
||||
!this.isPrivate && this.sendWithSignature && !!this.messageSignature;
|
||||
if (!shouldStripSignature) return !!body.trim();
|
||||
const stripped = removeSignature(
|
||||
body,
|
||||
this.messageSignature,
|
||||
getEffectiveChannelType(this.channelType, this.inbox?.medium || '')
|
||||
);
|
||||
return !!stripped.trim();
|
||||
},
|
||||
isReplyRestricted() {
|
||||
return (
|
||||
!this.currentChat?.can_reply &&
|
||||
@@ -1231,6 +1246,7 @@ export default {
|
||||
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
||||
:characters-remaining="charactersRemaining"
|
||||
:editor-content="message"
|
||||
:has-content="hasMeaningfulEditorContent"
|
||||
@set-reply-mode="setReplyMode"
|
||||
@toggle-editor-size="toggleEditorSize"
|
||||
@toggle-copilot="copilot.toggleEditor"
|
||||
|
||||
+49
-10
@@ -14,6 +14,11 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'conversation',
|
||||
},
|
||||
action: {
|
||||
type: String,
|
||||
default: 'assign',
|
||||
validator: value => ['assign', 'remove'].includes(value),
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -22,9 +27,13 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
appliedLabels: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['assign']);
|
||||
const emit = defineEmits(['assign', 'remove']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -35,17 +44,43 @@ const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
const selectedLabels = ref([]);
|
||||
|
||||
const isTypeContact = computed(() => props.type === 'contact');
|
||||
const isRemoveAction = computed(() => props.action === 'remove');
|
||||
|
||||
const buttonLabel = computed(() =>
|
||||
props.type === 'contact' ? t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS') : ''
|
||||
const buttonLabel = computed(() => {
|
||||
if (!isTypeContact.value) return '';
|
||||
|
||||
return isRemoveAction.value
|
||||
? t('CONTACTS_BULK_ACTIONS.REMOVE_LABELS')
|
||||
: t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS');
|
||||
});
|
||||
|
||||
const tooltipLabel = computed(() =>
|
||||
isRemoveAction.value
|
||||
? t('BULK_ACTION.LABELS.REMOVE_LABELS')
|
||||
: t('BULK_ACTION.LABELS.ASSIGN_LABELS')
|
||||
);
|
||||
|
||||
const confirmLabel = computed(() =>
|
||||
isRemoveAction.value
|
||||
? t('BULK_ACTION.LABELS.REMOVE_SELECTED_LABELS')
|
||||
: t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')
|
||||
);
|
||||
|
||||
const isLabelSelected = labelTitle => {
|
||||
return selectedLabels.value.includes(labelTitle);
|
||||
};
|
||||
|
||||
const visibleLabels = computed(() => {
|
||||
if (!isRemoveAction.value || props.appliedLabels === null) {
|
||||
return labels.value;
|
||||
}
|
||||
|
||||
const applied = new Set(props.appliedLabels);
|
||||
return labels.value.filter(label => applied.has(label.title));
|
||||
});
|
||||
|
||||
const labelMenuItems = computed(() => {
|
||||
return labels.value.map(label => ({
|
||||
return visibleLabels.value.map(label => ({
|
||||
action: 'select',
|
||||
value: label.title,
|
||||
label: label.title,
|
||||
@@ -64,9 +99,13 @@ const toggleLabelSelection = labelTitle => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
const handleApply = () => {
|
||||
if (selectedLabels.value.length > 0) {
|
||||
emit('assign', selectedLabels.value);
|
||||
if (isRemoveAction.value) {
|
||||
emit('remove', selectedLabels.value);
|
||||
} else {
|
||||
emit('assign', selectedLabels.value);
|
||||
}
|
||||
toggleDropdown(false);
|
||||
selectedLabels.value = [];
|
||||
}
|
||||
@@ -81,9 +120,9 @@ const handleDismiss = () => {
|
||||
<template>
|
||||
<div ref="containerRef" class="relative">
|
||||
<NextButton
|
||||
v-tooltip="isTypeContact ? '' : $t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
|
||||
v-tooltip="tooltipLabel"
|
||||
:label="buttonLabel"
|
||||
icon="i-lucide-tag"
|
||||
:icon="isRemoveAction ? 'i-woot-tag-remove' : 'i-lucide-tag'"
|
||||
slate
|
||||
:size="isTypeContact ? 'sm' : 'xs'"
|
||||
ghost
|
||||
@@ -148,9 +187,9 @@ const handleDismiss = () => {
|
||||
<NextButton
|
||||
sm
|
||||
class="w-full [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
|
||||
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
|
||||
:label="confirmLabel"
|
||||
:disabled="!selectedLabels.length"
|
||||
@click="handleAssign"
|
||||
@click="handleApply"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+19
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, useAttrs } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { getUnixTime } from 'date-fns';
|
||||
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
@@ -55,12 +56,25 @@ defineOptions({
|
||||
const attrs = useAttrs();
|
||||
|
||||
const {
|
||||
selectedConversations,
|
||||
onAssignAgent,
|
||||
onAssignLabels,
|
||||
onRemoveLabels,
|
||||
onAssignTeamsForBulk: onAssignTeam,
|
||||
onUpdateConversations,
|
||||
} = useBulkActions();
|
||||
|
||||
const getConversationById = useMapGetter('getConversationById');
|
||||
|
||||
const appliedLabelsForSelection = computed(() => {
|
||||
const applied = new Set();
|
||||
selectedConversations.value.forEach(id => {
|
||||
const conversation = getConversationById.value(id);
|
||||
(conversation?.labels || []).forEach(label => applied.add(label));
|
||||
});
|
||||
return Array.from(applied);
|
||||
});
|
||||
|
||||
const showCustomTimeSnoozeModal = ref(false);
|
||||
|
||||
function onCmdSnoozeConversation(snoozeType) {
|
||||
@@ -161,6 +175,11 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<BulkLabelActions @assign="onAssignLabels" />
|
||||
<BulkLabelActions
|
||||
action="remove"
|
||||
:applied-labels="appliedLabelsForSelection"
|
||||
@remove="onRemoveLabels"
|
||||
/>
|
||||
<BulkUpdateActions
|
||||
:show-resolve="!showResolvedAction"
|
||||
:show-reopen="!showOpenAction"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, defineOptions } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
|
||||
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ const variableKey = (item = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
.canned-item__button::v-deep .button__content {
|
||||
.canned-item__button :deep(.button__content) {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -108,7 +108,7 @@ export function useBulkActions() {
|
||||
}
|
||||
}
|
||||
|
||||
// Only used in context menu
|
||||
// Used by both context menu and bulk action bar.
|
||||
async function onRemoveLabels(labelsToRemove, conversationId = null) {
|
||||
try {
|
||||
await store.dispatch('bulkActions/process', {
|
||||
@@ -119,14 +119,24 @@ export function useBulkActions() {
|
||||
},
|
||||
});
|
||||
|
||||
useAlert(
|
||||
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
|
||||
labelName: labelsToRemove[0],
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
// Context-menu remove should not disturb an existing bulk selection.
|
||||
if (conversationId) {
|
||||
useAlert(
|
||||
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
|
||||
labelName: labelsToRemove[0],
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
store.dispatch('bulkActions/clearSelectedConversationIds');
|
||||
useAlert(t('BULK_ACTION.LABELS.REMOVE_SUCCESFUL'));
|
||||
}
|
||||
} catch (err) {
|
||||
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED'));
|
||||
useAlert(
|
||||
conversationId
|
||||
? t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED')
|
||||
: t('BULK_ACTION.LABELS.REMOVE_FAILED')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,47 @@
|
||||
const keyboardEventsMock = vi.hoisted(() => ({
|
||||
mountedCallbacks: [],
|
||||
unmountedCallbacks: [],
|
||||
registeredKeybindings: {},
|
||||
}));
|
||||
|
||||
vi.mock('vue', async importOriginal => ({
|
||||
...(await importOriginal()),
|
||||
onMounted: vi.fn(callback =>
|
||||
keyboardEventsMock.mountedCallbacks.push(callback)
|
||||
),
|
||||
onUnmounted: vi.fn(callback =>
|
||||
keyboardEventsMock.unmountedCallbacks.push(callback)
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/useDetectKeyboardLayout', () => ({
|
||||
useDetectKeyboardLayout: vi.fn(() => Promise.resolve('QWERTY')),
|
||||
}));
|
||||
|
||||
vi.mock('tinykeys', () => ({
|
||||
createKeybindingsHandler: vi.fn(keybindings => {
|
||||
keyboardEventsMock.registeredKeybindings = keybindings;
|
||||
return event => keybindings[event.shortcut]?.(event);
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { LAYOUT_QWERTZ } from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
const mount = async events => {
|
||||
useKeyboardEvents(events);
|
||||
await keyboardEventsMock.mountedCallbacks[0]();
|
||||
return keyboardEventsMock.registeredKeybindings;
|
||||
};
|
||||
|
||||
describe('useKeyboardEvents', () => {
|
||||
beforeEach(() => {
|
||||
keyboardEventsMock.mountedCallbacks = [];
|
||||
keyboardEventsMock.unmountedCallbacks = [];
|
||||
keyboardEventsMock.registeredKeybindings = {};
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(useKeyboardEvents).toBeDefined();
|
||||
});
|
||||
@@ -9,18 +50,46 @@ describe('useKeyboardEvents', () => {
|
||||
expect(useKeyboardEvents).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should set up listeners on mount and remove them on unmount', async () => {
|
||||
const events = {
|
||||
'ALT+KeyL': () => {},
|
||||
};
|
||||
it('registers keybindings on mount and removes the listener on unmount', async () => {
|
||||
const addEventListener = vi.spyOn(document, 'addEventListener');
|
||||
|
||||
const mountedMock = vi.fn();
|
||||
const unmountedMock = vi.fn();
|
||||
useKeyboardEvents(events);
|
||||
mountedMock();
|
||||
unmountedMock();
|
||||
const keybindings = await mount({ 'ALT+KeyL': () => {} });
|
||||
expect(Object.keys(keybindings)).toEqual(['ALT+KeyL']);
|
||||
|
||||
expect(mountedMock).toHaveBeenCalled();
|
||||
expect(unmountedMock).toHaveBeenCalled();
|
||||
const { signal } = addEventListener.mock.calls[0][2];
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
keyboardEventsMock.unmountedCallbacks[0]();
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('prefixes Shift on QWERTZ layouts for the affected keys', async () => {
|
||||
useDetectKeyboardLayout.mockResolvedValueOnce(LAYOUT_QWERTZ);
|
||||
|
||||
const keybindings = await mount({ 'Alt+KeyL': () => {} });
|
||||
|
||||
expect(Object.keys(keybindings)).toEqual(['Shift+Alt+KeyL']);
|
||||
});
|
||||
|
||||
it('ignores shortcuts on focused typeable elements by default', async () => {
|
||||
const action = vi.fn();
|
||||
const keybindings = await mount({
|
||||
'Alt+KeyL': { action, allowOnFocusedInput: false },
|
||||
});
|
||||
|
||||
keybindings['Alt+KeyL']({ target: document.createElement('textarea') });
|
||||
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs shortcuts on focused typeable elements when allowed', async () => {
|
||||
const action = vi.fn();
|
||||
const keybindings = await mount({
|
||||
'Alt+KeyL': { action, allowOnFocusedInput: true },
|
||||
});
|
||||
|
||||
keybindings['Alt+KeyL']({ target: document.createElement('textarea') });
|
||||
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,62 +1,145 @@
|
||||
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||
import { computed, readonly, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import {
|
||||
useWhatsappCallSession,
|
||||
sendWhatsappTerminateBeacon,
|
||||
cleanupWhatsappSession,
|
||||
} from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { handleVoiceCallCreated } from 'dashboard/helper/voice';
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
CONTENT_TYPES,
|
||||
VOICE_CALL_DIRECTION,
|
||||
VOICE_CALL_STATUS,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
import Timer from 'dashboard/helper/Timer';
|
||||
|
||||
export function useCallSession() {
|
||||
const callsStore = useCallsStore();
|
||||
const { t } = useI18n();
|
||||
const isJoining = ref(false);
|
||||
const callDuration = ref(0);
|
||||
const durationTimer = new Timer(elapsed => {
|
||||
callDuration.value = elapsed;
|
||||
const isWhatsappCall = call => call?.provider === VOICE_CALL_PROVIDERS.WHATSAPP;
|
||||
|
||||
// Dismissed call sids must not be re-seeded by the conversation-load watcher.
|
||||
// Lives at module scope so all consumers share the same set.
|
||||
const dismissedCallSids = new Set();
|
||||
const markDismissed = callSid => {
|
||||
if (callSid) dismissedCallSids.add(callSid);
|
||||
};
|
||||
|
||||
// Globals attached once across all useCallSession() consumers — bubbles in a
|
||||
// long thread call this composable many times, and a per-instance Timer +
|
||||
// window listener stack would multiply work.
|
||||
let globalsAttachedCount = 0;
|
||||
let globalDurationTimer = null;
|
||||
const globalCallDuration = ref(0);
|
||||
let storedCallsStoreRef = null;
|
||||
// Shared join lock so two surfaces (bubble + widget) clicking concurrently
|
||||
// see one in-flight join, not two unrelated isJoining refs.
|
||||
const globalIsJoining = ref(false);
|
||||
const globalIsJoiningReadonly = readonly(globalIsJoining);
|
||||
|
||||
const handleBeforeUnloadGlobal = event => {
|
||||
const store = storedCallsStoreRef;
|
||||
if (!store) return;
|
||||
if (!store.hasActiveCall && !store.hasIncomingCall) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
};
|
||||
const handlePageHideGlobal = () => sendWhatsappTerminateBeacon();
|
||||
const handleTwilioDisconnectedGlobal = () =>
|
||||
storedCallsStoreRef?.clearActiveCall();
|
||||
|
||||
const attachGlobalsOnFirstMount = callsStore => {
|
||||
globalsAttachedCount += 1;
|
||||
if (globalsAttachedCount > 1) return;
|
||||
storedCallsStoreRef = callsStore;
|
||||
globalDurationTimer = new Timer(elapsed => {
|
||||
globalCallDuration.value = elapsed;
|
||||
});
|
||||
|
||||
const activeCall = computed(() => callsStore.activeCall);
|
||||
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||
|
||||
watch(
|
||||
hasActiveCall,
|
||||
active => {
|
||||
if (active) {
|
||||
durationTimer.start();
|
||||
} else {
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
TwilioVoiceClient.addEventListener(
|
||||
'call:disconnected',
|
||||
handleTwilioDisconnectedGlobal
|
||||
);
|
||||
window.addEventListener('beforeunload', handleBeforeUnloadGlobal);
|
||||
window.addEventListener('pagehide', handlePageHideGlobal);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
);
|
||||
});
|
||||
const detachGlobalsOnLastUnmount = () => {
|
||||
globalsAttachedCount -= 1;
|
||||
if (globalsAttachedCount > 0) return;
|
||||
globalDurationTimer?.stop();
|
||||
globalDurationTimer = null;
|
||||
globalCallDuration.value = 0;
|
||||
storedCallsStoreRef = null;
|
||||
TwilioVoiceClient.removeEventListener(
|
||||
'call:disconnected',
|
||||
handleTwilioDisconnectedGlobal
|
||||
);
|
||||
window.removeEventListener('beforeunload', handleBeforeUnloadGlobal);
|
||||
window.removeEventListener('pagehide', handlePageHideGlobal);
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
durationTimer.stop();
|
||||
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
);
|
||||
});
|
||||
// Build the action surface used by both the root session composable and the
|
||||
// lighter useCallActions consumer. All state is module-scoped — the actions
|
||||
// don't depend on per-instance refs, so they're cheap to call from anywhere.
|
||||
const buildCallActions = ({ callsStore, whatsappSession, t }) => {
|
||||
const findCall = callSid => callsStore.calls.find(c => c.callSid === callSid);
|
||||
|
||||
const endCall = async ({ conversationId, inboxId, callSid }) => {
|
||||
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
|
||||
TwilioVoiceClient.endClientCall();
|
||||
durationTimer.stop();
|
||||
callsStore.clearActiveCall();
|
||||
const call = findCall(callSid);
|
||||
if (isWhatsappCall(call)) {
|
||||
// Pass call.callId so a wiped module state (e.g. a prior accept attempt
|
||||
// tore down the WebRTC session) doesn't stop us hitting /terminate.
|
||||
await whatsappSession.endActiveCall(call?.callId);
|
||||
globalDurationTimer?.stop();
|
||||
callsStore.clearActiveCall();
|
||||
return;
|
||||
}
|
||||
|
||||
// try/finally so a failed leaveConference (e.g. backend 5xx) still
|
||||
// tears down the local Device and UI state — otherwise the call stays
|
||||
// visually active with the mic open.
|
||||
try {
|
||||
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
|
||||
} finally {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
globalDurationTimer?.stop();
|
||||
callsStore.clearActiveCall();
|
||||
}
|
||||
};
|
||||
|
||||
const joinCall = async ({ conversationId, inboxId, callSid }) => {
|
||||
if (isJoining.value) return null;
|
||||
if (globalIsJoining.value) return null;
|
||||
|
||||
isJoining.value = true;
|
||||
const call = findCall(callSid);
|
||||
// Outbound *WhatsApp* calls have no separate join step — the offer was
|
||||
// sent at initiate time and the answer is applied by the cable handler.
|
||||
// Routing through acceptIncomingCall here would call prepareInboundAnswer →
|
||||
// cleanup() and destroy the live outbound session. Outbound *Twilio*
|
||||
// calls still need joinConference + joinClientCall (FloatingCallWidget
|
||||
// auto-joins them), so don't short-circuit those.
|
||||
if (
|
||||
call?.callDirection === VOICE_CALL_DIRECTION.OUTBOUND &&
|
||||
isWhatsappCall(call)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
globalIsJoining.value = true;
|
||||
try {
|
||||
if (isWhatsappCall(call)) {
|
||||
await whatsappSession.acceptIncomingCall({
|
||||
callId: call.callId,
|
||||
sdpOffer: call.sdpOffer,
|
||||
iceServers: call.iceServers,
|
||||
});
|
||||
callsStore.setCallActive(callSid);
|
||||
globalDurationTimer?.start();
|
||||
return { callId: call.callId };
|
||||
}
|
||||
|
||||
const device = await TwilioVoiceClient.initializeDevice(inboxId);
|
||||
if (!device) return null;
|
||||
|
||||
@@ -73,47 +156,165 @@ export function useCallSession() {
|
||||
});
|
||||
|
||||
callsStore.setCallActive(callSid);
|
||||
durationTimer.start();
|
||||
globalDurationTimer?.start();
|
||||
|
||||
return { conferenceSid: joinResponse?.conference_sid };
|
||||
} catch (error) {
|
||||
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
if (error?.response?.status === 409) {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
markDismissed(callSid);
|
||||
callsStore.dismissCall(callSid);
|
||||
} else if (!isWhatsappCall(call)) {
|
||||
// Tear down the Twilio Device on any other join error so a retry
|
||||
// starts from a clean state — joinClientCall can leave the device
|
||||
// half-initialized after a network blip.
|
||||
TwilioVoiceClient.endClientCall();
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to join call:', error);
|
||||
// Drop any half-built WebRTC state so the next click starts fresh.
|
||||
cleanupWhatsappSession();
|
||||
return null;
|
||||
} finally {
|
||||
isJoining.value = false;
|
||||
globalIsJoining.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const rejectIncomingCall = callSid => {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
callsStore.dismissCall(callSid);
|
||||
// Await provider-side reject before dismissing the local entry; if the API
|
||||
// call fails the call should stay surfaced so the agent can retry instead of
|
||||
// disappearing while the backend still rings.
|
||||
const rejectIncomingCall = async callSid => {
|
||||
const call = findCall(callSid);
|
||||
try {
|
||||
if (isWhatsappCall(call) && call?.callId) {
|
||||
if (call.callDirection === VOICE_CALL_DIRECTION.OUTBOUND) {
|
||||
// Outbound calls that are still ringing must be terminated, not
|
||||
// rejected (reject is the inbound-side verb on Meta's API).
|
||||
await whatsappSession.endActiveCall(call.callId);
|
||||
} else {
|
||||
await whatsappSession.rejectIncomingCall(call.callId);
|
||||
}
|
||||
} else if (call?.inboxId && call?.conversationId) {
|
||||
// Twilio incoming reject: agent hasn't joined the Device yet, so
|
||||
// endClientCall is a no-op. End the conference server-side instead
|
||||
// so Twilio hangs up the inbound leg.
|
||||
await VoiceAPI.leaveConference({
|
||||
inboxId: call.inboxId,
|
||||
conversationId: call.conversationId,
|
||||
callSid,
|
||||
});
|
||||
} else {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
}
|
||||
} finally {
|
||||
markDismissed(callSid);
|
||||
callsStore.dismissCall(callSid);
|
||||
}
|
||||
};
|
||||
|
||||
const dismissCall = callSid => {
|
||||
markDismissed(callSid);
|
||||
callsStore.dismissCall(callSid);
|
||||
};
|
||||
|
||||
return { endCall, joinCall, rejectIncomingCall, dismissCall };
|
||||
};
|
||||
|
||||
const buildReactiveSurface = callsStore => {
|
||||
const activeCall = computed(() => callsStore.activeCall);
|
||||
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||
const formattedCallDuration = computed(() => {
|
||||
const minutes = Math.floor(callDuration.value / 60);
|
||||
const seconds = callDuration.value % 60;
|
||||
const total = globalCallDuration.value;
|
||||
const minutes = Math.floor(total / 60);
|
||||
const seconds = total % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
return {
|
||||
activeCall,
|
||||
incomingCalls,
|
||||
hasActiveCall,
|
||||
isJoining,
|
||||
isJoining: globalIsJoiningReadonly,
|
||||
formattedCallDuration,
|
||||
joinCall,
|
||||
endCall,
|
||||
rejectIncomingCall,
|
||||
dismissCall,
|
||||
};
|
||||
};
|
||||
|
||||
// Root-mount composable. Call once at the dashboard root (FloatingCallWidget
|
||||
// is the natural anchor — always mounted, lifetime spans the whole session).
|
||||
// This is the only path that registers global window/Twilio listeners and
|
||||
// owns the duration Timer.
|
||||
export function useCallSession() {
|
||||
const store = useStore();
|
||||
const callsStore = useCallsStore();
|
||||
const whatsappSession = useWhatsappCallSession();
|
||||
const { t } = useI18n();
|
||||
|
||||
const reactive = buildReactiveSurface(callsStore);
|
||||
|
||||
// Cable broadcasts (voice_call.incoming / message.created) are one-shot, so
|
||||
// on a hard refresh they leave the calls store empty. Seed it from any
|
||||
// ringing voice_call message in the conversation cache. Skip calls the
|
||||
// agent has already dismissed locally so they don't re-pop on the next
|
||||
// conversation update.
|
||||
const seedCallsFromHydratedMessages = () => {
|
||||
const conversations = store.getters.getAllConversations || [];
|
||||
const currentUserId = store.getters.getCurrentUserID;
|
||||
const currentUserAvailability = store.getters.getCurrentUserAvailability;
|
||||
conversations.forEach(conv => {
|
||||
(conv.messages || []).forEach(msg => {
|
||||
if (msg.content_type !== CONTENT_TYPES.VOICE_CALL) return;
|
||||
if (msg.call?.status !== VOICE_CALL_STATUS.RINGING) return;
|
||||
const callSid = msg.call?.provider_call_id;
|
||||
if (callSid && dismissedCallSids.has(callSid)) return;
|
||||
handleVoiceCallCreated(msg, currentUserId, currentUserAvailability);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
reactive.hasActiveCall,
|
||||
active => {
|
||||
if (active) {
|
||||
globalDurationTimer?.start();
|
||||
} else {
|
||||
globalDurationTimer?.stop();
|
||||
globalCallDuration.value = 0;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
attachGlobalsOnFirstMount(callsStore);
|
||||
seedCallsFromHydratedMessages();
|
||||
});
|
||||
|
||||
// Re-seed when conversations stream in after mount; addCall merges by callSid
|
||||
// and dismissed sids are filtered, so this is idempotent.
|
||||
watch(
|
||||
() => store.getters.getAllConversations?.length,
|
||||
() => seedCallsFromHydratedMessages()
|
||||
);
|
||||
|
||||
onUnmounted(() => detachGlobalsOnLastUnmount());
|
||||
|
||||
const actions = buildCallActions({ callsStore, whatsappSession, t });
|
||||
|
||||
return { ...reactive, ...actions };
|
||||
}
|
||||
|
||||
// Lightweight consumer for components that need to read state and trigger
|
||||
// actions but should NOT mount global listeners (e.g., per-message bubbles
|
||||
// rendered in a thread). Reads from the same module-level state that
|
||||
// useCallSession owns, so the duration timer and dismissed set stay coherent.
|
||||
export function useCallActions() {
|
||||
const callsStore = useCallsStore();
|
||||
const whatsappSession = useWhatsappCallSession();
|
||||
const { t } = useI18n();
|
||||
|
||||
const reactive = buildReactiveSurface(callsStore);
|
||||
const actions = buildCallActions({ callsStore, whatsappSession, t });
|
||||
|
||||
return { ...reactive, ...actions };
|
||||
}
|
||||
|
||||
@@ -36,11 +36,18 @@ export function useConfig() {
|
||||
*/
|
||||
const enterprisePlanName = config.enterprisePlanName;
|
||||
|
||||
/**
|
||||
* Indicates whether inbox webhook events (ENABLE_INBOX_EVENTS) are enabled.
|
||||
* @type {boolean}
|
||||
*/
|
||||
const inboxEventsEnabled = config.inboxEventsEnabled === 'true';
|
||||
|
||||
return {
|
||||
hostURL,
|
||||
vapidPublicKey,
|
||||
enabledLanguages,
|
||||
isEnterprise,
|
||||
enterprisePlanName,
|
||||
inboxEventsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
import { readonly, ref } from 'vue';
|
||||
import Cookies from 'js-cookie';
|
||||
import WhatsappCallsAPI from 'dashboard/api/channel/whatsapp/whatsappCallsAPI';
|
||||
import { VOICE_CALL_OUTBOUND_INIT_STATUS } from 'dashboard/components-next/message/constants';
|
||||
|
||||
// Module-level state lets the cable handlers and unload listeners reach the
|
||||
// live PeerConnection without prop-drilling refs through every composable.
|
||||
let pc = null;
|
||||
let localStream = null;
|
||||
let remoteStream = null;
|
||||
let remoteAudioEl = null;
|
||||
let mediaRecorder = null;
|
||||
let recorderChunks = [];
|
||||
let audioContext = null;
|
||||
let activeCallId = null;
|
||||
// voice_call.outbound_connected (the sole source of the outbound SDP answer) is
|
||||
// broadcast account-wide and can arrive before the /initiate response sets
|
||||
// activeCallId in this tab. Until we know our own call id we can't tell our
|
||||
// answer from a concurrent agent's, so the cable handler buffers them here keyed
|
||||
// by call id (a single slot would let a concurrent agent's event clobber ours)
|
||||
// and initiateOutboundCall flushes the matching one by id.
|
||||
const pendingOutboundAnswers = new Map();
|
||||
// Module-scoped so multiple composable callers (header button + contact-panel
|
||||
// button) share the same lock. A per-instance ref let two parallel callers
|
||||
// both pass the guard and tear down each other's WebRTC state in cleanup().
|
||||
// ref() so consumers can reactively gate buttons on it (the composable
|
||||
// re-exports this as `isInitiating`).
|
||||
const isInitiatingOutbound = ref(false);
|
||||
const isInitiatingOutboundReadonly = readonly(isInitiatingOutbound);
|
||||
// Inbound calls record from the moment the agent clicks accept (their click =
|
||||
// pickup). Outbound calls must wait — Meta's `connect` webhook (which lands
|
||||
// during ringing) negotiates remote tracks ~20s before the contact actually
|
||||
// answers, and we don't want pre-pickup audio in the recording. This flag is
|
||||
// flipped to true by armOutboundRecorder() when the ACCEPTED status arrives.
|
||||
let recorderArmed = false;
|
||||
|
||||
const ensureRemoteAudioElement = () => {
|
||||
if (remoteAudioEl) return remoteAudioEl;
|
||||
remoteAudioEl = document.createElement('audio');
|
||||
remoteAudioEl.id = 'whatsapp-call-remote-audio';
|
||||
remoteAudioEl.autoplay = true;
|
||||
remoteAudioEl.playsInline = true;
|
||||
remoteAudioEl.style.display = 'none';
|
||||
document.body.appendChild(remoteAudioEl);
|
||||
return remoteAudioEl;
|
||||
};
|
||||
|
||||
const playRemoteStream = stream => {
|
||||
const el = ensureRemoteAudioElement();
|
||||
el.srcObject = stream;
|
||||
el.play().catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[WhatsApp Call] remote audio play() failed:', err);
|
||||
});
|
||||
};
|
||||
|
||||
// 1s timeslice keeps a recent recording chunk in memory so a remote hangup
|
||||
// that races cleanup still has data to upload.
|
||||
const RECORDING_TIMESLICE_MS = 1000;
|
||||
const ICE_GATHER_TIMEOUT_MS = 10000;
|
||||
|
||||
const RECORDER_MIME_CANDIDATES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/ogg;codecs=opus',
|
||||
];
|
||||
|
||||
// Outbound calls have no backend-supplied ice_servers (the call doesn't exist
|
||||
// at offer time). Without STUN the browser only sends host candidates and
|
||||
// browser→Meta media silently drops through any non-trivial NAT.
|
||||
const DEFAULT_OUTBOUND_ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
|
||||
const waitForIceGatheringComplete = peer =>
|
||||
new Promise(resolve => {
|
||||
if (peer.iceGatheringState === 'complete') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(resolve, ICE_GATHER_TIMEOUT_MS);
|
||||
peer.addEventListener('icegatheringstatechange', () => {
|
||||
if (peer.iceGatheringState === 'complete') {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const setupRecorder = () => {
|
||||
if (!localStream || !remoteStream || mediaRecorder) return;
|
||||
// createMediaStreamSource on a stream with no audio tracks wires up to
|
||||
// nothing — the recorded mix would be silence. Wait until ontrack fires.
|
||||
if (remoteStream.getAudioTracks().length === 0) return;
|
||||
|
||||
audioContext = new AudioContext({ sampleRate: 48000 });
|
||||
// AudioContext starts suspended under most autoplay policies; without
|
||||
// resume() the destination stream produces silence.
|
||||
audioContext.resume().catch(() => {});
|
||||
|
||||
const destination = audioContext.createMediaStreamDestination();
|
||||
audioContext.createMediaStreamSource(localStream).connect(destination);
|
||||
audioContext.createMediaStreamSource(remoteStream).connect(destination);
|
||||
|
||||
const mimeType = RECORDER_MIME_CANDIDATES.find(t =>
|
||||
MediaRecorder.isTypeSupported(t)
|
||||
);
|
||||
if (!mimeType) return;
|
||||
|
||||
recorderChunks = [];
|
||||
mediaRecorder = new MediaRecorder(destination.stream, { mimeType });
|
||||
mediaRecorder.ondataavailable = event => {
|
||||
if (event.data && event.data.size > 0) recorderChunks.push(event.data);
|
||||
};
|
||||
mediaRecorder.start(RECORDING_TIMESLICE_MS);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
try {
|
||||
mediaRecorder.stop();
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
if (audioContext && audioContext.state !== 'closed') {
|
||||
audioContext.close().catch(() => {});
|
||||
}
|
||||
if (localStream) localStream.getTracks().forEach(t => t.stop());
|
||||
if (remoteStream) remoteStream.getTracks().forEach(t => t.stop());
|
||||
if (pc) pc.close();
|
||||
if (remoteAudioEl) remoteAudioEl.srcObject = null;
|
||||
|
||||
pc = null;
|
||||
localStream = null;
|
||||
remoteStream = null;
|
||||
mediaRecorder = null;
|
||||
recorderChunks = [];
|
||||
audioContext = null;
|
||||
activeCallId = null;
|
||||
pendingOutboundAnswers.clear();
|
||||
recorderArmed = false;
|
||||
};
|
||||
|
||||
const buildPeerConnection = iceServers => {
|
||||
const config = iceServers && iceServers.length ? { iceServers } : {};
|
||||
pc = new RTCPeerConnection(config);
|
||||
remoteStream = new MediaStream();
|
||||
pc.ontrack = event => {
|
||||
// Reuse the same MediaStream object — the recorder's audioContext source
|
||||
// taps it once, so reassigning would orphan the recorder.
|
||||
const tracks =
|
||||
event.streams && event.streams[0]
|
||||
? event.streams[0].getTracks()
|
||||
: [event.track];
|
||||
tracks.forEach(track => {
|
||||
if (!remoteStream.getTracks().includes(track))
|
||||
remoteStream.addTrack(track);
|
||||
});
|
||||
playRemoteStream(remoteStream);
|
||||
// Only arm the recorder when the call is actually accepted. For outbound
|
||||
// this is the ACCEPTED status webhook; for inbound this is the agent's
|
||||
// own click (acceptIncomingCall flips recorderArmed before returning).
|
||||
if (recorderArmed) setupRecorder();
|
||||
};
|
||||
return pc;
|
||||
};
|
||||
|
||||
const stopRecorderAndUpload = async callId => {
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
await new Promise(resolve => {
|
||||
mediaRecorder.addEventListener('stop', resolve, { once: true });
|
||||
try {
|
||||
mediaRecorder.stop();
|
||||
} catch (_) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!recorderChunks.length || !callId) return;
|
||||
|
||||
const blob = new Blob(recorderChunks, { type: recorderChunks[0].type });
|
||||
// Best-effort — the controller's idempotency guard handles a retry.
|
||||
try {
|
||||
await WhatsappCallsAPI.uploadRecording(callId, blob);
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
|
||||
// devise-token-auth requires access-token / client / uid headers on every
|
||||
// request — navigator.sendBeacon can't set custom headers, so we rehydrate
|
||||
// the auth payload from the cw_d_session_info cookie that the dashboard sets
|
||||
// at login. Used by the page-close terminate path below.
|
||||
const getDeviseAuthHeaders = () => {
|
||||
try {
|
||||
const raw = Cookies.get('cw_d_session_info');
|
||||
if (!raw) return null;
|
||||
const session = JSON.parse(raw);
|
||||
return {
|
||||
'access-token': session['access-token'] || '',
|
||||
client: session.client || '',
|
||||
uid: session.uid || '',
|
||||
expiry: session.expiry || '',
|
||||
'token-type': session['token-type'] || 'Bearer',
|
||||
};
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const beaconTerminate = callId => {
|
||||
if (!callId) return;
|
||||
const accountId = window.location.pathname.split('/')[3];
|
||||
if (!accountId) return;
|
||||
const headers = getDeviseAuthHeaders();
|
||||
if (!headers) return;
|
||||
const url = `/api/v1/accounts/${accountId}/whatsapp_calls/${callId}/terminate`;
|
||||
// fetch+keepalive (instead of navigator.sendBeacon) so we can attach auth
|
||||
// headers — without them devise-token-auth 401s and the call stays open on
|
||||
// Meta until its carrier-side timeout (~60s).
|
||||
try {
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
keepalive: true,
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: '{}',
|
||||
}).catch(() => {});
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
|
||||
export const hasActiveWhatsappCall = () => !!(activeCallId || pc);
|
||||
|
||||
export const isLocalWhatsappCall = callId =>
|
||||
!!callId && activeCallId != null && callId === activeCallId;
|
||||
|
||||
export function useWhatsappCallSession() {
|
||||
const prepareInboundAnswer = async (sdpOffer, iceServers) => {
|
||||
cleanup();
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
buildPeerConnection(iceServers);
|
||||
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
return pc.localDescription.sdp;
|
||||
};
|
||||
|
||||
const prepareOutboundOffer = async () => {
|
||||
cleanup();
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
buildPeerConnection(DEFAULT_OUTBOUND_ICE_SERVERS);
|
||||
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
return pc.localDescription.sdp;
|
||||
};
|
||||
|
||||
const acceptIncomingCall = async ({ callId, sdpOffer, iceServers }) => {
|
||||
// The store may not have sdpOffer yet (the cable broadcast can race the
|
||||
// click). Fall back to GET /whatsapp_calls/:id which exposes it.
|
||||
let offer = sdpOffer;
|
||||
let ice = iceServers;
|
||||
if (!offer && callId) {
|
||||
try {
|
||||
const fresh = await WhatsappCallsAPI.show(callId);
|
||||
offer = fresh?.sdp_offer || fresh?.sdpOffer;
|
||||
ice = ice || fresh?.ice_servers || fresh?.iceServers;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
'[WhatsApp Call] failed to fetch call data for accept:',
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!offer) {
|
||||
throw new Error('Missing sdp_offer for accept — call may have ended.');
|
||||
}
|
||||
|
||||
// Release the mic + peer connection if anything between here and the accept
|
||||
// round-trip fails — otherwise a rejected accept leaves the mic live and
|
||||
// activeCallId set. Mirrors rejectIncomingCall's self-cleanup; rethrow so
|
||||
// the caller can surface the failure and skip marking the call active.
|
||||
try {
|
||||
const sdpAnswer = await prepareInboundAnswer(offer, ice);
|
||||
activeCallId = callId;
|
||||
// Inbound: agent's click is the pickup. Arm the recorder before the API
|
||||
// round-trip so when ontrack fires (triggered by setRemoteDescription
|
||||
// back in prepareInboundAnswer) the recorder is already authorized.
|
||||
recorderArmed = true;
|
||||
setupRecorder();
|
||||
await WhatsappCallsAPI.accept(callId, sdpAnswer);
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const rejectIncomingCall = async callId => {
|
||||
try {
|
||||
await WhatsappCallsAPI.reject(callId);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
const initiateOutboundCall = async conversationId => {
|
||||
// Module-scoped lock + active-session guard so a second click — from the
|
||||
// same composable instance OR a different one (header vs contact panel)
|
||||
// OR while a call is already live — can't tear down the in-flight setup
|
||||
// via prepareOutboundOffer's cleanup().
|
||||
if (isInitiatingOutbound.value)
|
||||
return { status: VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED };
|
||||
if (hasActiveWhatsappCall())
|
||||
return { status: VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED };
|
||||
isInitiatingOutbound.value = true;
|
||||
try {
|
||||
const sdpOffer = await prepareOutboundOffer();
|
||||
const response = await WhatsappCallsAPI.initiate(
|
||||
conversationId,
|
||||
sdpOffer
|
||||
);
|
||||
if (response?.id) {
|
||||
activeCallId = response.id;
|
||||
// A connect webhook that raced ahead of this response was buffered;
|
||||
// apply our own by id now that we know it, then drop every buffered
|
||||
// answer (concurrent agents' calls aren't ours to apply).
|
||||
const buffered = pendingOutboundAnswers.get(activeCallId);
|
||||
pendingOutboundAnswers.clear();
|
||||
if (buffered) {
|
||||
await pc.setRemoteDescription({
|
||||
type: 'answer',
|
||||
sdp: buffered,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
// No call id back: this is the permission-request branch. The mic +
|
||||
// PeerConnection allocated by prepareOutboundOffer aren't useful until
|
||||
// the contact opts in and the agent retries — release them.
|
||||
cleanup();
|
||||
return response;
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
// BE returns 422 when the contact hasn't opted in (permission template
|
||||
// sent or already pending). Surface it to the caller as a normal
|
||||
// response shape so it can render the banner instead of an error toast.
|
||||
const data = e?.response?.data;
|
||||
if (
|
||||
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_REQUESTED ||
|
||||
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
|
||||
) {
|
||||
return { status: data.status };
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
isInitiatingOutbound.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// callIdOverride is the call.id from the dashboard's calls store. Module
|
||||
// `activeCallId` may be null after a prior accept attempt's cleanup() — but
|
||||
// the call still exists on Meta and must still be terminated. Falling back
|
||||
// to the override means hangup is robust to a wiped local session.
|
||||
const endActiveCall = async (callIdOverride = null) => {
|
||||
const callId = activeCallId || callIdOverride;
|
||||
if (!callId) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Terminate on Meta first so the contact is disconnected immediately. The
|
||||
// recording upload below can be slow on long calls or poor networks, and
|
||||
// the peer connection / mic must not stay live for the contact during it.
|
||||
await WhatsappCallsAPI.terminate(callId).catch(() => {});
|
||||
await stopRecorderAndUpload(callId);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isInitiating: isInitiatingOutboundReadonly,
|
||||
prepareInboundAnswer,
|
||||
prepareOutboundOffer,
|
||||
acceptIncomingCall,
|
||||
rejectIncomingCall,
|
||||
initiateOutboundCall,
|
||||
endActiveCall,
|
||||
};
|
||||
}
|
||||
|
||||
// Cable handlers fire outside any composable instance, so the shared session
|
||||
// surface is exposed as module-level functions for them.
|
||||
|
||||
export const applyOutboundAnswer = async (callId, sdpAnswer) => {
|
||||
// No in-flight peer connection in this tab → not our call.
|
||||
if (!pc) return;
|
||||
// /initiate hasn't returned our call id yet (the connect webhook raced
|
||||
// ahead). Buffer the answer; initiateOutboundCall flushes it by id once it
|
||||
// knows which call is ours, so a concurrent agent's answer is never applied.
|
||||
if (activeCallId == null) {
|
||||
pendingOutboundAnswers.set(callId, sdpAnswer);
|
||||
return;
|
||||
}
|
||||
// activeCallId known → only apply the answer for this tab's call.
|
||||
if (callId !== activeCallId) return;
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
|
||||
};
|
||||
|
||||
// Called by the cable handler when Meta delivers status=ACCEPTED for the
|
||||
// outbound call (real pickup). Flips the recorder gate and starts the
|
||||
// MediaRecorder. Idempotent — safe if ontrack hasn't fired yet (setupRecorder
|
||||
// bails until the remote stream has audio tracks; ontrack will retry).
|
||||
export const armOutboundRecorder = () => {
|
||||
recorderArmed = true;
|
||||
setupRecorder();
|
||||
};
|
||||
|
||||
export const cleanupWhatsappSession = () => cleanup();
|
||||
|
||||
export const handleWhatsappRemoteEnd = async callId => {
|
||||
// Snapshot before cleanup nulls activeCallId.
|
||||
const id = callId || activeCallId;
|
||||
if (!id) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await stopRecorderAndUpload(id);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
export const setWhatsappCallMuted = muted => {
|
||||
if (!localStream) return false;
|
||||
localStream.getAudioTracks().forEach(track => {
|
||||
track.enabled = !muted;
|
||||
});
|
||||
return muted;
|
||||
};
|
||||
|
||||
export const sendWhatsappTerminateBeacon = () => {
|
||||
// Always fire when there's a live callId. The beacon endpoint is idempotent,
|
||||
// so racing it with an in-flight Axios terminate (which unload may abort) is
|
||||
// fine — Meta gets exactly one terminate either way, and we avoid leaving
|
||||
// the call ringing on Meta until its carrier-side timeout.
|
||||
if (!activeCallId) return;
|
||||
beaconTerminate(activeCallId);
|
||||
};
|
||||
@@ -46,6 +46,7 @@ export const FEATURE_FLAGS = {
|
||||
COMPANIES: 'companies',
|
||||
ADVANCED_SEARCH: 'advanced_search',
|
||||
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
|
||||
CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
|
||||
};
|
||||
|
||||
export const PREMIUM_FEATURES = [
|
||||
|
||||
@@ -4,14 +4,27 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import {
|
||||
applyOutboundAnswer,
|
||||
armOutboundRecorder,
|
||||
handleWhatsappRemoteEnd,
|
||||
isLocalWhatsappCall,
|
||||
} from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
|
||||
|
||||
class ActionCableConnector extends BaseActionCableConnector {
|
||||
constructor(app, pubsubToken) {
|
||||
const { websocketURL = '' } = window.chatwootConfig || {};
|
||||
super(app, pubsubToken, websocketURL);
|
||||
this.CancelTyping = [];
|
||||
this.lastUnreadCountsFetchAt = null;
|
||||
this.unreadCountsFetchTimer = null;
|
||||
this.events = {
|
||||
'message.created': this.onMessageCreated,
|
||||
'message.updated': this.onMessageUpdated,
|
||||
@@ -32,9 +45,15 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'notification.updated': this.onNotificationUpdated,
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'conversation.unread_count_changed':
|
||||
this.onConversationUnreadCountChanged,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'account.enrichment_completed': this.onEnrichmentCompleted,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
'voice_call.incoming': this.onVoiceCallIncoming,
|
||||
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
|
||||
'voice_call.outbound_accepted': this.onVoiceCallOutboundAccepted,
|
||||
'voice_call.ended': this.onVoiceCallEnded,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,6 +139,56 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.fetchConversationStats();
|
||||
};
|
||||
|
||||
onConversationUnreadCountChanged = () => {
|
||||
this.throttledFetchConversationUnreadCounts();
|
||||
};
|
||||
|
||||
throttledFetchConversationUnreadCounts = () => {
|
||||
const now = Date.now();
|
||||
const elapsedTime = now - this.lastUnreadCountsFetchAt;
|
||||
|
||||
if (
|
||||
this.lastUnreadCountsFetchAt === null ||
|
||||
elapsedTime >= UNREAD_COUNTS_REFETCH_THROTTLE_MS
|
||||
) {
|
||||
this.clearUnreadCountsFetchTimer();
|
||||
this.fetchConversationUnreadCounts();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.unreadCountsFetchTimer) return;
|
||||
|
||||
this.unreadCountsFetchTimer = setTimeout(() => {
|
||||
this.unreadCountsFetchTimer = null;
|
||||
this.fetchConversationUnreadCounts();
|
||||
}, UNREAD_COUNTS_REFETCH_THROTTLE_MS - elapsedTime);
|
||||
};
|
||||
|
||||
clearUnreadCountsFetchTimer = () => {
|
||||
if (!this.unreadCountsFetchTimer) return;
|
||||
|
||||
clearTimeout(this.unreadCountsFetchTimer);
|
||||
this.unreadCountsFetchTimer = null;
|
||||
};
|
||||
|
||||
fetchConversationUnreadCounts = () => {
|
||||
if (!this.isConversationUnreadCountsEnabled()) return;
|
||||
|
||||
this.lastUnreadCountsFetchAt = Date.now();
|
||||
this.app.$store.dispatch('conversationUnreadCounts/get');
|
||||
};
|
||||
|
||||
isConversationUnreadCountsEnabled = () => {
|
||||
const accountId = this.app.$store.getters.getCurrentAccountId;
|
||||
const isFeatureEnabled =
|
||||
this.app.$store.getters['accounts/isFeatureEnabledonAccount'];
|
||||
|
||||
return isFeatureEnabled?.(
|
||||
accountId,
|
||||
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
|
||||
);
|
||||
};
|
||||
|
||||
onTypingOn = ({ conversation, user }) => {
|
||||
const conversationId = conversation.id;
|
||||
|
||||
@@ -205,6 +274,74 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
|
||||
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
|
||||
};
|
||||
|
||||
onVoiceCallIncoming = data => {
|
||||
if (data?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP) return;
|
||||
// Defense in depth: the server already filters to online agent streams,
|
||||
// but if anything ever broadcasts to a broader stream (e.g. account-wide),
|
||||
// an agent who's set availability=offline/busy shouldn't ring.
|
||||
const availability = this.app.$store.getters.getCurrentUserAvailability;
|
||||
if (availability !== 'online') return;
|
||||
|
||||
useCallsStore().addCall({
|
||||
callSid: data.call_id,
|
||||
callId: data.id,
|
||||
conversationId: data.conversation_id,
|
||||
inboxId: data.inbox_id,
|
||||
callDirection: VOICE_CALL_DIRECTION.INBOUND,
|
||||
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
|
||||
sdpOffer: data.sdp_offer,
|
||||
iceServers: data.ice_servers,
|
||||
caller: data.caller,
|
||||
});
|
||||
};
|
||||
|
||||
// `connect` is the WebRTC tunnel-ready signal (fires ~20s before pickup
|
||||
// for outbound). Apply the SDP answer so the handshake completes during
|
||||
// ringing, but stay non-active until `outbound_accepted` arrives.
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallOutboundConnected = async data => {
|
||||
if (data?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP || !data.sdp_answer)
|
||||
return;
|
||||
// Account-wide broadcast that can arrive before /initiate sets this tab's
|
||||
// call id. applyOutboundAnswer filters foreign calls and buffers the answer
|
||||
// until the id is known, so we must not drop it here on a null activeCallId.
|
||||
try {
|
||||
await applyOutboundAnswer(data.id, data.sdp_answer);
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
|
||||
// Real pickup signal — Meta sends status=ACCEPTED on the call when the
|
||||
// contact answers. Flip active (timer starts) and arm the recorder.
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallOutboundAccepted = data => {
|
||||
if (data?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP) return;
|
||||
const store = useCallsStore();
|
||||
if (!store.calls.some(c => c.callSid === data.call_id)) return;
|
||||
store.setCallActive(data.call_id);
|
||||
armOutboundRecorder();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallEnded = async data => {
|
||||
if (data?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP) return;
|
||||
// The store entry should always be removed for this account-wide broadcast,
|
||||
// but the WebRTC/recorder teardown must only run for the call this tab owns
|
||||
// — otherwise an unrelated agent's call ending would stop this tab's
|
||||
// recorder and upload its chunks against the wrong call id.
|
||||
if (isLocalWhatsappCall(data.id)) {
|
||||
// Await upload before removeCall — the store's sync teardown would otherwise
|
||||
// wipe the recorder chunks before they reach the server.
|
||||
try {
|
||||
await handleWhatsappRemoteEnd(data.id);
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
useCallsStore().removeCall(data.call_id);
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {
|
||||
messageSchema,
|
||||
MessageMarkdownTransformer,
|
||||
MessageMarkdownSerializer,
|
||||
MessageMarkdownTransformer,
|
||||
messageSchema,
|
||||
Selection,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
/**
|
||||
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user