Compare commits

..
Author SHA1 Message Date
Sojan Jose 3325020129 fix: improve widget select option contrast 2026-05-22 11:44:59 +05:30
336 changed files with 1343 additions and 10196 deletions
-3
View File
@@ -2,9 +2,6 @@
ignore:
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
# Devise 5 is currently blocked by devise-secure_password/devise_token_auth/devise-two-factor.
# Chatwoot does not enable Timeoutable, so the timeout redirect path is not reachable.
- GHSA-jp94-3292-c3xv
# Rails 7.1 has no patched release for the Active Storage proxy range
# advisories. Chatwoot limits proxy range requests locally.
- CVE-2026-33658
-6
View File
@@ -98,8 +98,6 @@ 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
@@ -234,10 +232,6 @@ 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
+3 -5
View File
@@ -89,7 +89,7 @@ gem 'rails-i18n', '~> 7.0'
# two-factor authentication
gem 'devise-two-factor', '>= 5.0.0'
# authorization
gem 'jwt', '~> 2.10', '>= 2.10.3'
gem 'jwt'
gem 'pundit'
# super admin
@@ -133,9 +133,9 @@ gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.19.0', require: false
##-- background job processing --##
gem 'sidekiq', '~> 7.3', '>= 7.3.1'
gem 'sidekiq', '>= 7.3.1'
# We want cron jobs
gem 'sidekiq-cron', '>= 2.4.0'
gem 'sidekiq-cron', '>= 1.12.0'
# for sidekiq healthcheck
gem 'sidekiq_alive'
@@ -209,8 +209,6 @@ gem 'opentelemetry-exporter-otlp'
gem 'shopify_api'
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
### Gems required only in specific deployment environments ###
##############################################################
+8 -15
View File
@@ -196,9 +196,6 @@ GEM
bigdecimal
rexml
crass (1.0.6)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
csv (3.3.0)
csv-safe (3.3.1)
csv (~> 3.0)
@@ -301,7 +298,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.2)
faraday (2.14.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -339,7 +336,6 @@ 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)
@@ -491,7 +487,7 @@ GEM
judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0)
jwt (2.10.3)
jwt (2.10.1)
base64
kaminari (1.2.2)
activesupport (>= 4.1.0)
@@ -906,11 +902,10 @@ GEM
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
sidekiq-cron (2.4.0)
cronex (>= 0.13.0)
fugit (~> 1.8, >= 1.11.1)
sidekiq-cron (1.12.0)
fugit (~> 1.8)
globalid (>= 1.0.1)
sidekiq (>= 6.5.0)
sidekiq (>= 6)
sidekiq_alive (2.5.0)
gserver (~> 0.0.1)
sidekiq (>= 5, < 9)
@@ -984,7 +979,6 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode (0.4.4.5)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
@@ -1080,7 +1074,6 @@ DEPENDENCIES
faker
faraday_middleware-aws-sigv4
fcm
firecrawl-sdk (~> 1.0)
flag_shih_tzu
foreman
gemoji
@@ -1102,7 +1095,7 @@ DEPENDENCIES
json_schemer
judoscale-rails
judoscale-sidekiq
jwt (~> 2.10, >= 2.10.3)
jwt
kaminari
koala
letter_opener
@@ -1162,8 +1155,8 @@ DEPENDENCIES
sentry-sidekiq (>= 5.19.0)
shopify_api
shoulda-matchers
sidekiq (~> 7.3, >= 7.3.1)
sidekiq-cron (>= 2.4.0)
sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0)
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
+1 -1
View File
@@ -1 +1 @@
4.14.1
4.14.0
@@ -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, :update_category, :delete_articles]
before_action :set_articles, only: [:update_status, :delete_articles]
def translate
head :not_implemented
@@ -19,18 +19,6 @@ 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?
@@ -51,9 +39,5 @@ 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')
@@ -1,16 +0,0 @@
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,8 +162,6 @@ 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,7 +6,8 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state
state: state,
prompt: 'consent'
}
)
if redirect_url
@@ -78,9 +78,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived,
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
)
end
@@ -0,0 +1,18 @@
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,11 +31,7 @@ class Twilio::CallbackController < ApplicationController
:Latitude,
:Longitude,
:MessageType,
:ProfileName,
:ExternalUserId,
:ParentExternalUserId,
:ProfileUsername,
:Username
:ProfileName
)
end
end
-1
View File
@@ -17,7 +17,6 @@ class AsyncDispatcher < BaseDispatcher
InstallationWebhookListener.instance,
NotificationListener.instance,
ParticipationListener.instance,
Conversations::UnreadCounts::Listener.instance,
ReportingEventListener.instance,
WebhookListener.instance
]
@@ -1,45 +0,0 @@
/* 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();
+3 -5
View File
@@ -35,9 +35,8 @@ class ContactAPI extends ApiClient {
return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data);
}
getConversations(contactId, { inboxId } = {}) {
const params = inboxId ? { inbox_id: inboxId } : {};
return axios.get(`${this.url}/${contactId}/conversations`, { params });
getConversations(contactId) {
return axios.get(`${this.url}/${contactId}/conversations`);
}
getContactableInboxes(contactId) {
@@ -48,10 +47,9 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/labels`);
}
initiateCall(contactId, inboxId, conversationId = null) {
initiateCall(contactId, inboxId) {
return axios.post(`${this.url}/${contactId}/call`, {
inbox_id: inboxId,
conversation_id: conversationId,
});
}
@@ -13,10 +13,6 @@ 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,13 +87,6 @@ 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`,
-8
View File
@@ -52,14 +52,6 @@ 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,33 +153,4 @@ 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,8 +41,7 @@ describe('#ContactsAPI', () => {
it('#getConversations', () => {
contactAPI.getConversations(1);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/1/conversations',
{ params: {} }
'/api/v1/contacts/1/conversations'
);
});
@@ -11,7 +11,6 @@ describe('#ConversationApi', () => {
expect(conversationsAPI).toHaveProperty('delete');
expect(conversationsAPI).toHaveProperty('getLabels');
expect(conversationsAPI).toHaveProperty('updateLabels');
expect(conversationsAPI).toHaveProperty('getUnreadCounts');
});
describe('API calls', () => {
@@ -48,12 +47,5 @@ describe('#ConversationApi', () => {
}
);
});
it('#getUnreadCounts', () => {
conversationsAPI.getUnreadCounts();
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/conversations/unread_counts'
);
});
});
});
@@ -3,20 +3,10 @@ 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,
getVoiceCallProvider,
VOICE_CALL_PROVIDERS,
} from 'dashboard/helper/inbox';
import {
VOICE_CALL_DIRECTION,
VOICE_CALL_OUTBOUND_INIT_STATUS,
} from 'dashboard/components-next/message/constants';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
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';
@@ -24,9 +14,6 @@ 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' },
@@ -43,7 +30,6 @@ const { t } = useI18n();
const dialogRef = ref(null);
const callsStore = useCallsStore();
const inboxesList = useMapGetter('inboxes/getInboxes');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
@@ -52,22 +38,13 @@ 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) {
@@ -81,96 +58,23 @@ const navigateToConversation = conversationId => {
}
};
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;
}
const startCall = async inboxId => {
if (isInitiatingCall.value) 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: VOICE_CALL_DIRECTION.OUTBOUND,
callDirection: 'outbound',
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
@@ -182,22 +86,6 @@ const startCall = async (inboxId, conversationIdHint = null) => {
};
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;
@@ -218,7 +106,7 @@ const onPickInbox = async inbox => {
v-if="shouldRender"
v-tooltip.top-end="tooltipLabel || null"
v-bind="attrs"
:disabled="isCallButtonDisabled"
:disabled="isInitiatingCall"
:is-loading="isInitiatingCall"
:label="label"
:icon="icon"
@@ -142,26 +142,28 @@ watch(
<style lang="scss" scoped>
.editor-wrapper {
:deep(.ProseMirror-menubar-wrapper) {
.ProseMirror.ProseMirror-woot-style {
p {
@apply first:mt-0 !important;
}
::v-deep {
.ProseMirror-menubar-wrapper {
.ProseMirror.ProseMirror-woot-style {
p {
@apply first:mt-0 !important;
}
.empty-node {
@apply m-0 !important;
.empty-node {
@apply m-0 !important;
&::before {
@apply text-n-slate-11 dark:text-n-slate-11;
&::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;
.ProseMirror-menubar {
width: fit-content !important;
position: relative !important;
top: unset !important;
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
}
}
}
}
@@ -145,43 +145,45 @@ const handleCreateArticle = event => {
</template>
<style lang="scss" scoped>
:deep(.ProseMirror .empty-node::before) {
@apply text-n-slate-10 text-base;
}
:deep(.ProseMirror-menubar-wrapper) {
.ProseMirror-woot-style {
@apply min-h-[15rem] max-h-full;
::v-deep {
.ProseMirror .empty-node::before {
@apply text-n-slate-10 text-base;
}
}
:deep(.ProseMirror-menubar) {
display: none; // Hide by default
}
:deep(.editor-root .has-selection) {
.ProseMirror-menubar:not(:has(*)) {
display: none !important;
.ProseMirror-menubar-wrapper {
.ProseMirror-woot-style {
@apply min-h-[15rem] max-h-full;
}
}
.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;
display: none; // Hide by default
}
.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;
}
.editor-root .has-selection {
.ProseMirror-menubar:not(:has(*)) {
display: none !important;
}
.ProseMirror-menu-active {
@apply bg-n-slate-3;
.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;
}
}
}
}
@@ -2,7 +2,6 @@
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';
@@ -19,7 +18,6 @@ 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({
@@ -64,7 +62,6 @@ const isFeatureEnabledonAccount = useMapGetter(
const selectedArticleIds = ref(new Set());
const deleteConfirmDialogRef = ref(null);
const isCategoryMenuOpen = ref(false);
const { isEnterprise } = useConfig();
@@ -224,34 +221,6 @@ 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();
};
@@ -371,30 +340,6 @@ 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
@@ -202,7 +202,7 @@ defineExpose({ state, isSubmitDisabled });
size="sm"
type="button"
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
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"
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"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
@@ -1,290 +0,0 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import classicLayoutPreview from './classic-layout-preview.svg?raw';
import documentationLayoutPreview from './documentation-layout-preview.svg?raw';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
const props = defineProps({
activePortal: { type: Object, required: true },
isFetching: { type: Boolean, default: false },
});
const emit = defineEmits(['updatePortalConfiguration']);
const { t } = useI18n();
const PORTAL_LAYOUTS = {
CLASSIC: 'classic',
DOCUMENTATION: 'documentation',
};
// `prefix` is the link the help center auto-fills; the DB only stores the handle.
const SOCIAL_PLATFORMS = [
{
key: 'facebook',
label: 'Facebook',
icon: 'i-ri-facebook-circle-fill',
prefix: 'facebook.com/',
},
{ key: 'x', label: 'X', icon: 'i-ri-twitter-x-fill', prefix: 'x.com/' },
{
key: 'instagram',
label: 'Instagram',
icon: 'i-ri-instagram-fill',
prefix: 'instagram.com/',
},
{
key: 'linkedin',
label: 'LinkedIn',
icon: 'i-ri-linkedin-box-fill',
prefix: 'linkedin.com/',
},
{
key: 'youtube',
label: 'YouTube',
icon: 'i-ri-youtube-fill',
prefix: 'youtube.com/',
},
{
key: 'tiktok',
label: 'TikTok',
icon: 'i-ri-tiktok-fill',
prefix: 'tiktok.com/',
},
{
key: 'github',
label: 'GitHub',
icon: 'i-ri-github-fill',
prefix: 'github.com/',
},
{
key: 'whatsapp',
label: 'WhatsApp',
icon: 'i-ri-whatsapp-fill',
prefix: 'wa.me/',
},
];
const portalConfig = computed(() => props.activePortal?.config || {});
const state = reactive({
layout: PORTAL_LAYOUTS.CLASSIC,
socialProfiles: {},
});
const visiblePlatforms = ref([]);
const showAddMenu = ref(false);
let originalSnapshot = '';
const platformByKey = key => SOCIAL_PLATFORMS.find(p => p.key === key);
const trimmedHandle = key => (state.socialProfiles[key] || '').trim();
const buildSocialProfiles = () =>
visiblePlatforms.value.reduce((acc, key) => {
const handle = trimmedHandle(key);
if (handle) acc[key] = handle;
return acc;
}, {});
const snapshot = () =>
JSON.stringify({ layout: state.layout, social: buildSocialProfiles() });
const resetFromPortal = () => {
const savedProfiles = portalConfig.value.social_profiles || {};
state.layout = portalConfig.value.layout || PORTAL_LAYOUTS.CLASSIC;
state.socialProfiles = SOCIAL_PLATFORMS.reduce((acc, { key }) => {
acc[key] = savedProfiles[key] || '';
return acc;
}, {});
visiblePlatforms.value = SOCIAL_PLATFORMS.map(p => p.key).filter(key =>
(savedProfiles[key] || '').trim()
);
originalSnapshot = snapshot();
};
watch(() => props.activePortal, resetFromPortal, {
immediate: true,
deep: true,
});
const hasChanges = computed(() => snapshot() !== originalSnapshot);
const visiblePlatformDetails = computed(() =>
visiblePlatforms.value.map(platformByKey)
);
const addablePlatforms = computed(() =>
SOCIAL_PLATFORMS.filter(p => !visiblePlatforms.value.includes(p.key)).map(
p => ({ label: p.label, value: p.key, action: p.key, icon: p.icon })
)
);
const addPlatform = ({ value }) => {
if (!visiblePlatforms.value.includes(value)) {
visiblePlatforms.value.push(value);
}
showAddMenu.value = false;
};
const removePlatform = key => {
visiblePlatforms.value = visiblePlatforms.value.filter(k => k !== key);
state.socialProfiles[key] = '';
};
const handleSave = () => {
emit('updatePortalConfiguration', {
id: props.activePortal.id,
slug: props.activePortal.slug,
config: {
layout: state.layout,
social_profiles: buildSocialProfiles(),
},
});
};
</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.LAYOUT_CONTENT.HEADER') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.DESCRIPTION') }}
</span>
</div>
<section class="flex flex-col gap-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-n-slate-11">
<RadioCard
:id="PORTAL_LAYOUTS.CLASSIC"
:is-active="state.layout === PORTAL_LAYOUTS.CLASSIC"
:label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.CLASSIC.TITLE')
"
:description="
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.CLASSIC.DESCRIPTION'
)
"
@select="value => (state.layout = value)"
>
<div
class="w-full mt-2 rounded-md overflow-hidden border border-solid border-n-weak bg-n-slate-2 dark:bg-n-slate-1"
>
<span v-dompurify-html="classicLayoutPreview" />
</div>
</RadioCard>
<RadioCard
:id="PORTAL_LAYOUTS.DOCUMENTATION"
beta
:is-active="state.layout === PORTAL_LAYOUTS.DOCUMENTATION"
:label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.SIDEBAR.TITLE')
"
:description="
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.SIDEBAR.DESCRIPTION'
)
"
@select="value => (state.layout = value)"
>
<div
class="w-full mt-2 rounded-md overflow-hidden border border-solid border-n-weak bg-n-slate-2 dark:bg-n-slate-1"
>
<span v-dompurify-html="documentationLayoutPreview" />
</div>
</RadioCard>
</div>
</section>
<section
v-if="state.layout === PORTAL_LAYOUTS.DOCUMENTATION"
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1">
<h6 class="text-sm font-medium text-n-slate-12">
{{
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.HEADER')
}}
</h6>
<span class="text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.DESCRIPTION'
)
}}
</span>
</div>
<div
v-for="platform in visiblePlatformDetails"
:key="platform.key"
class="flex items-center h-10 gap-1.5 px-3 rounded-lg outline outline-1 outline-n-weak focus-within:outline-n-brand"
>
<Icon :icon="platform.icon" class="size-4 shrink-0 text-n-slate-11" />
<span class="text-sm shrink-0 text-n-slate-10">{{
platform.prefix
}}</span>
<input
v-model="state.socialProfiles[platform.key]"
type="text"
class="flex-1 min-w-0 text-sm bg-transparent outline-none reset-base text-n-slate-12 placeholder:text-n-slate-10"
:placeholder="
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.PLACEHOLDER'
)
"
/>
<Button
icon="i-lucide-x"
color="slate"
variant="ghost"
size="xs"
:aria-label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.REMOVE')
"
@click="removePlatform(platform.key)"
/>
</div>
<div
v-if="addablePlatforms.length"
v-on-clickaway="() => (showAddMenu = false)"
class="relative"
>
<Button
:label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.ADD')
"
icon="i-lucide-plus"
color="slate"
variant="faded"
size="sm"
@click="showAddMenu = !showAddMenu"
/>
<DropdownMenu
v-if="showAddMenu"
:menu-items="addablePlatforms"
class="mt-1 w-52 top-full ltr:left-0 rtl:right-0"
@action="addPlatform"
/>
</div>
</section>
<div class="flex justify-end">
<Button
:label="t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SAVE')"
:disabled="!hasChanges || isFetching"
@click="handleSave"
/>
</div>
</div>
</template>
@@ -7,7 +7,6 @@ import { useMapGetter } from 'dashboard/composables/store.js';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
import PortalLayoutContentSettings from './PortalLayoutContentSettings.vue';
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -103,12 +102,6 @@ const handleDeletePortal = () => {
@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">
@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120" class="w-full h-auto block" aria-hidden="true">
<rect x="0" y="0" width="200" height="14" fill="currentColor" opacity="0.1"/>
<rect x="14" y="5" width="20" height="4" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="14" y="22" width="64" height="5" rx="1" fill="currentColor" opacity="0.32"/>
<rect x="14" y="32" width="128" height="9" rx="2" fill="currentColor" opacity="0.18"/>
<rect x="14" y="70" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="104" y="70" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="14" y="93" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="104" y="93" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 818 B

@@ -1,18 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120" class="w-full h-auto block" aria-hidden="true">
<rect x="0" y="0" width="200" height="14" fill="currentColor" opacity="0.1"/>
<rect x="6" y="5" width="20" height="4" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="0" y="14" width="50" height="106" fill="currentColor" opacity="0.07"/>
<rect x="6" y="22" width="38" height="4" rx="1" fill="currentColor" opacity="0.25"/>
<rect x="6" y="32" width="30" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="6" y="40" width="35" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="6" y="48" width="28" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="6" y="56" width="32" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="60" y="25" width="80" height="6" rx="1" fill="currentColor" opacity="0.35"/>
<rect x="60" y="38" width="120" height="8" rx="2" fill="currentColor" opacity="0.18"/>
<rect x="60" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="101" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="142" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="60" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="101" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="142" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

@@ -32,11 +32,7 @@ const modelValue = defineModel({ type: Boolean, default: false });
<span class="text-heading-3 text-n-slate-12">
{{ header }}
</span>
<template v-if="hideToggle">
<slot name="hiddenToggle">
<div class="size-2" />
</slot>
</template>
<div v-if="hideToggle" class="size-2" />
<ToggleSwitch v-else v-model="modelValue" />
</div>
<span v-if="description" class="text-body-main text-n-slate-11">
@@ -1,4 +1,5 @@
<script setup>
import { defineProps } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
@@ -1,179 +0,0 @@
<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>
@@ -1,242 +0,0 @@
<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>
@@ -1,268 +0,0 @@
<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.pdf_document && doc.status === 'available' && !doc.sync_in_progress;
const syncableSelectedIds = computed(() => {
if (!props.selectedIds.size) return [];
@@ -127,6 +127,7 @@ const menuItems = computed(() => {
value: 'sync',
action: 'sync',
icon: 'i-lucide-refresh-cw',
disabled: props.syncInProgress,
});
}
@@ -1,5 +1,5 @@
<script setup>
import { watch } from 'vue';
import { defineModel, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { computed, defineModel, 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 } from 'vue';
import { ref, defineProps, defineEmits } from 'vue';
import { Chrome } from '@lk77/vue3-color';
import { OnClickOutside } from '@vueuse/components';
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { defineProps, ref, defineEmits, 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, h, watch, ref } from 'vue';
import { computed, defineModel, 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 { computed } from 'vue';
import { defineModel, 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 { computed, ref } from 'vue';
import { defineModel, 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 } from 'vue';
import { h, defineProps } from 'vue';
const props = defineProps({
country: { type: String, required: true },
@@ -1,65 +0,0 @@
<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,7 +1,6 @@
<script setup>
import { computed, toRef } from 'vue';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { useChannelIcon, useChannelBrandIcon } from './provider';
import { toRef } from 'vue';
import { useChannelIcon } from './provider';
import Icon from 'next/icon/Icon.vue';
const props = defineProps({
@@ -9,35 +8,11 @@ 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,
},
});
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
);
const channelIcon = useChannelIcon(toRef(props, 'inbox'));
</script>
<template>
<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>
<Icon :icon="channelIcon" />
</template>
@@ -18,7 +18,6 @@ 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',
@@ -27,7 +26,6 @@ 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,75 +1,45 @@
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import { computed } from 'vue';
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;
import { isVoiceCallEnabled } from 'dashboard/helper/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 = resolveInbox(inbox);
const inboxDetails = inbox.value || inbox;
const type = inboxDetails.channel_type;
let icon = channelTypeIconMap[type];
if (type === INBOX_TYPES.EMAIL && inboxDetails.provider) {
if (type === 'Channel::Email' && inboxDetails.provider) {
if (Object.keys(providerIconMap).includes(inboxDetails.provider)) {
icon = providerIconMap[inboxDetails.provider];
}
}
// Special case for Twilio whatsapp
if (
type === INBOX_TYPES.TWILIO &&
inboxDetails.medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
) {
if (type === 'Channel::TwilioSms' && inboxDetails.medium === 'whatsapp') {
icon = 'i-woot-whatsapp';
}
// 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
) {
// Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.)
if (isVoiceCallEnabled(inboxDetails)) {
icon = 'i-woot-voice';
}
@@ -78,26 +48,3 @@ 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,10 +554,11 @@ provideMessageContext({
<Avatar v-bind="avatarInfo" :size="24" />
</div>
<div
class="[grid-area:bubble] flex min-w-0"
class="[grid-area:bubble] flex"
: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 { computed, reactive } from 'vue';
import { defineProps, computed, reactive } from 'vue';
import Message from './Message.vue';
import { MESSAGE_TYPES } from './constants.js';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
@@ -1,4 +1,6 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
defineProps({
showingOriginal: Boolean,
});
@@ -95,7 +95,7 @@ const replyToPreview = computed(() => {
<template>
<div
class="text-sm min-w-0"
class="text-sm"
:class="[
messageClass,
{
@@ -6,12 +6,9 @@ import BaseBubble from './Base.vue';
const { inboxId } = useMessageContext();
const {
isAFacebookInbox,
isAnInstagramChannel,
isATiktokChannel,
isAWhatsAppChannel,
} = useInbox(inboxId.value);
const { isAFacebookInbox, isAnInstagramChannel, isATiktokChannel } = useInbox(
inboxId.value
);
const unsupportedMessageKey = computed(() => {
if (isAFacebookInbox.value)
@@ -19,8 +16,6 @@ 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,27 +2,14 @@
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,
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 { VOICE_CALL_STATUS } from '../constants';
import { useCallSession } from 'dashboard/composables/useCallSession';
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',
@@ -30,67 +17,39 @@ const LABEL_MAP = {
};
const ICON_MAP = {
[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',
[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',
};
const { t } = useI18n();
const store = useStore();
const {
call,
attachments,
contentAttributes,
conversationId,
currentUserId,
inboxId,
sender,
messageType,
} = useMessageContext();
const { call, conversationId, currentUserId, inboxId } = useMessageContext();
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
useCallActions();
const whatsappCallSession = useWhatsappCallSession();
const callsStore = useCallsStore();
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const isInitiatingCall = computed(
() => contactsUiFlags.value?.isInitiatingCall || false
);
useCallSession();
const status = computed(() => call.value?.status);
// 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 isOutbound = computed(() => call.value?.direction === 'outgoing');
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
const endReason = computed(() => call.value?.endReason);
const wasDeclinedByAgent = computed(
() =>
isMissedInbound.value &&
endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED
);
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
const didCurrentUserAnswer = computed(
() =>
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
);
// 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 conversationAssignee = computed(() => {
const conversation = store.getters.getConversationById?.(
conversationId?.value
@@ -107,107 +66,67 @@ 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 (isFailed.value) {
if (status.value === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL'
: 'CONVERSATION.VOICE_CALL.MISSED_CALL';
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_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'
return isFailed.value
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const formattedDuration = computed(() =>
formatDuration(call.value?.durationSeconds)
);
const subtext = computed(() => {
// Completed: "Handled by {agent} · 0:42" (drops either part when absent).
if (status.value === VOICE_CALL_STATUS.RINGING) {
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return [handledBy.value, formattedDuration.value]
.filter(Boolean)
.join(' · ');
return formattedDuration.value;
}
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
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 (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
if (didCurrentUserAnswer.value) {
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
}
if (wasDeclinedByAgent.value && displayAgentName.value) {
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_DECLINED_BY', {
if (displayAgentName.value) {
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
agentName: displayAgentName.value,
});
}
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT');
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
}
// 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');
return isFailed.value
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
: 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-bold'
: 'i-ph-phone-incoming-bold';
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
// 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 bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
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;
@@ -216,12 +135,11 @@ 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: ATTACHMENT_TYPES.AUDIO,
fileType: 'audio',
extension: 'wav',
transcribedText: call.value?.transcript || '',
};
@@ -244,117 +162,48 @@ 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-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">
<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">
<div
class="flex justify-center items-center rounded-xl size-11 shrink-0"
:class="iconContainerClass"
class="flex justify-center items-center rounded-full size-10 shrink-0"
:class="bgColor"
>
<Icon class="size-4" :icon="iconName" />
<Icon
class="size-5"
:icon="iconName"
:class="{
'text-n-slate-1': status === VOICE_CALL_STATUS.COMPLETED,
'text-white': status !== VOICE_CALL_STATUS.COMPLETED,
}"
/>
</div>
<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"
>
<div class="flex overflow-hidden flex-col flex-grow">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ $t(labelKey) }}
</span>
<span
v-if="subtext"
class="text-sm leading-tight truncate tracking-tight opacity-75"
>
<span v-if="subtext" class="text-xs text-n-slate-11">
{{ 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>
<!-- 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 v-if="recordingAttachment" class="px-3 pb-3">
<AudioChip :attachment="recordingAttachment" />
</div>
</div>
</BaseBubble>
</template>
@@ -1,5 +1,5 @@
<script setup>
import { computed, useAttrs } from 'vue';
import { computed, defineOptions, useAttrs } from 'vue';
import ImageChip from 'next/message/chips/Image.vue';
import VideoChip from 'next/message/chips/Video.vue';
@@ -31,17 +31,6 @@ 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);
@@ -52,33 +41,8 @@ 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 = () => {
const d = audioPlayer.value?.duration;
if (!Number.isFinite(d)) {
resolveStreamingDuration();
return;
}
duration.value = d;
duration.value = audioPlayer.value?.duration;
};
const playbackSpeedLabel = computed(() => {
@@ -89,8 +53,7 @@ const playbackSpeedLabel = computed(() => {
// When the onLoadMetadata is called, so we need to set the duration
// value when the component is mounted
onMounted(() => {
const d = audioPlayer.value?.duration;
if (Number.isFinite(d)) duration.value = d;
duration.value = audioPlayer.value?.duration;
audioPlayer.value.playbackRate = playbackSpeed.value;
});
@@ -226,18 +189,7 @@ 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"
>
{{ 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>
{{ attachment.transcribedText }}
</div>
</div>
</template>
@@ -88,18 +88,5 @@ 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',
};
@@ -1,5 +1,4 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
@@ -31,16 +30,10 @@ const props = defineProps({
type: String,
default: '',
},
beta: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const handleChange = () => {
if (!props.isActive && !props.disabled) {
emit('select', props.id);
@@ -49,14 +42,14 @@ const handleChange = () => {
</script>
<template>
<label
:for="id"
class="rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6 focus-within:has-[:focus-visible]:ring-2 focus-within:has-[:focus-visible]:ring-n-strong"
<div
class="cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
:class="[
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
!disabled && !isActive ? 'hover:outline-n-strong' : '',
]"
@click="handleChange"
>
<div class="flex flex-col gap-2 items-start">
<div class="flex items-center justify-between w-full gap-3">
@@ -65,7 +58,6 @@ const handleChange = () => {
{{ label }}
</h3>
<Label v-if="disabled" :label="disabledLabel" color="amber" compact />
<Label v-if="beta" :label="t('GENERAL.BETA')" color="blue" compact />
</div>
<input
:id="`${id}`"
@@ -74,7 +66,7 @@ const handleChange = () => {
:name="id"
:disabled="disabled"
type="radio"
class="shadow cursor-pointer grid place-items-center border-2 border-n-strong appearance-none rounded-full w-5 h-5 checked:bg-n-brand before:content-[''] before:bg-n-brand before:border-4 before:rounded-full before:border-n-strong checked:before:w-[18px] checked:before:h-[18px] checked:border checked:border-n-brand"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0 flex-shrink-0"
@change="handleChange"
/>
</div>
@@ -83,5 +75,5 @@ const handleChange = () => {
</p>
<slot />
</div>
</label>
</div>
</template>
@@ -2,7 +2,6 @@
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: {
@@ -18,10 +17,6 @@ const props = defineProps({
type: Object,
required: true,
},
badgeCount: {
type: [Number, String],
default: 0,
},
});
const reauthorizationRequired = computed(() => {
@@ -34,7 +29,6 @@ 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, watch } from 'vue';
import { h, ref, computed, onMounted } from 'vue';
import { provideSidebarContext, useSidebarResize } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
@@ -61,24 +61,6 @@ 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');
@@ -175,15 +157,6 @@ 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(
@@ -200,48 +173,8 @@ 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(() =>
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)
)
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
);
const closeMobileSidebar = () => {
@@ -334,10 +267,9 @@ const menuItems = computed(() => {
label: t('SIDEBAR.TEAMS'),
icon: 'i-lucide-users',
activeOn: ['conversations_through_team'],
children: sortedTeams.value.map(team => ({
children: teams.value.map(team => ({
name: `${team.name}-${team.id}`,
label: team.name,
badgeCount: getTeamUnreadCount.value(team.id),
to: accountScopedRoute('team_conversations', { teamId: team.id }),
})),
},
@@ -349,7 +281,6 @@ 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 =>
@@ -357,7 +288,6 @@ const menuItems = computed(() => {
label: leafProps.label,
active: leafProps.active,
inbox,
badgeCount: leafProps.badgeCount,
}),
})),
},
@@ -366,10 +296,9 @@ const menuItems = computed(() => {
label: t('SIDEBAR.LABELS'),
icon: 'i-lucide-tag',
activeOn: ['conversations_through_label'],
children: sortedLabels.value.map(label => ({
children: labels.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,7 +5,6 @@ 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 },
@@ -167,7 +166,6 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ subChild.label }}</span>
<SidebarUnreadBadge :count="subChild.badgeCount" />
</button>
</li>
</ul>
@@ -190,7 +188,6 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ child.label }}</span>
<SidebarUnreadBadge :count="child.badgeCount" />
</button>
</li>
</template>
@@ -3,7 +3,6 @@ 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 },
@@ -11,7 +10,6 @@ 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();
@@ -41,14 +39,15 @@ const shouldRenderComponent = computed(() => {
<component
:is="component"
v-if="shouldRenderComponent"
v-bind="{ label, icon, active, badgeCount }"
:label
:icon
:active
/>
<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,27 +78,26 @@ function changeAvailabilityStatus(availability) {
<template>
<DropdownSection class="[&>ul]:overflow-visible">
<div class="grid gap-0">
<DropdownItem preserve-open class="gap-1">
<div class="flex-grow flex items-center gap-1 min-w-0">
<DropdownItem preserve-open>
<div class="flex-grow flex items-center gap-1">
{{ $t('SIDEBAR.SET_YOUR_AVAILABILITY') }}
</div>
<DropdownContainer class="shrink-0">
<DropdownContainer>
<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 min-w-0 text-sm">
<div class="flex gap-1 items-center flex-grow text-sm">
<div class="p-1 flex-shrink-0">
<div class="size-2 rounded-sm" :class="activeStatus.color" />
</div>
<span class="truncate max-w-[7rem]">
{{ activeStatus.label }}
</span>
<span>{{ activeStatus.label }}</span>
</div>
</Button>
</template>
@@ -115,12 +114,12 @@ function changeAvailabilityStatus(availability) {
</DropdownContainer>
</DropdownItem>
<DropdownItem>
<div class="flex-grow min-w-0">
<div class="flex-grow flex items-center gap-1">
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
<Icon
v-tooltip.top="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT')"
icon="i-lucide-info"
class="inline-block align-middle ms-1 size-4 text-n-slate-10"
class="size-4 text-n-slate-10"
/>
</div>
<ToggleSwitch v-model="autoOfflineToggle" />
@@ -1,27 +0,0 @@
<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>
@@ -1,38 +0,0 @@
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
);
});
});
@@ -1,76 +0,0 @@
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,5 +1,6 @@
<script setup>
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
import { defineEmits } from 'vue';
defineProps({
title: {
@@ -24,10 +24,6 @@ defineProps({
type: Boolean,
default: false,
},
hasVoiceBadge: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
@@ -42,18 +38,10 @@ const { t } = useI18n();
'cursor-not-allowed disabled:opacity-80': isComingSoon,
}"
>
<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
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 class="flex flex-col items-start gap-1.5">
@@ -339,15 +339,17 @@ export default {
</template>
<style lang="scss" scoped>
:deep(.selector-wrap) {
@apply m-0 top-1;
::v-deep {
.selector-wrap {
@apply m-0 top-1;
.selector-name {
.selector-name {
@apply ml-0;
}
}
.name {
@apply ml-0;
}
}
:deep(.name) {
@apply ml-0;
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import { ref } from 'vue';
import { ref, defineEmits } 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, onMounted } from 'vue';
import { ref, computed, defineEmits, onMounted } from 'vue';
import { useEventListener } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -1,4 +1,5 @@
<script setup>
import { defineEmits, defineModel } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
@@ -56,14 +56,6 @@ 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',
@@ -86,14 +78,7 @@ const isComingSoon = computed(() => {
});
const isBeta = computed(() => {
return ['tiktok', 'voice', 'whatsapp_call'].includes(props.channel.key);
});
const hasVoiceBadge = computed(() => {
return (
['voice', 'whatsapp_call'].includes(props.channel.key) &&
!!props.enabledFeatures.channel_voice
);
return ['tiktok', 'voice'].includes(props.channel.key);
});
const onItemClick = () => {
@@ -110,7 +95,6 @@ const onItemClick = () => {
:icon="channel.icon"
:is-coming-soon="isComingSoon"
:is-beta="isBeta"
:has-voice-badge="hasVoiceBadge"
:disabled="!isActive"
@click="onItemClick"
/>
@@ -63,8 +63,10 @@ export default {
.colorpicker--chrome.vc-chrome {
@apply shadow-lg -mt-2.5 absolute z-[9999] border border-solid border-n-weak rounded;
:deep(input) {
@apply bg-white dark:bg-white;
::v-deep {
input {
@apply bg-white dark:bg-white;
}
}
}
</style>
@@ -0,0 +1,185 @@
<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 } from 'vue';
import { ref, onMounted, onUnmounted, defineEmits, defineExpose } from 'vue';
import WaveSurfer from 'wavesurfer.js';
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js';
import { format, intervalToDuration } from 'date-fns';
@@ -4,6 +4,7 @@ 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';
@@ -18,11 +19,9 @@ const props = defineProps({
type: Boolean,
default: false,
},
// 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,
editorContent: {
type: String,
default: undefined,
},
conversationId: {
type: Number,
@@ -34,8 +33,17 @@ 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 = [];
@@ -55,7 +63,7 @@ const menuItems = computed(() => {
} else if (
props.conversationId &&
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
props.hasContent
effectiveContent.value
) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
@@ -64,7 +72,7 @@ const menuItems = computed(() => {
});
}
if (props.hasContent) {
if (effectiveContent.value) {
items.push(
{
label: t(
@@ -354,17 +354,16 @@ function isBodyEmpty(content) {
// if content is undefined, we assume that the body is empty
if (!content) return true;
// 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;
// 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;
// trimming should remove all the whitespaces, so we can check the length
return bodyWithoutSignature.trim().length === 0;
@@ -475,6 +474,17 @@ 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();
@@ -549,20 +559,6 @@ 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
@@ -589,11 +585,11 @@ function isCmdPlusEnterToSendEnabled() {
useKeyboardEvents({
'Alt+KeyP': {
action: focusEditorInputField,
allowOnFocusedInput: false,
allowOnFocusedInput: true,
},
'Alt+KeyL': {
action: focusEditorInputField,
allowOnFocusedInput: false,
allowOnFocusedInput: true,
},
});
@@ -903,7 +899,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
v-on-click-outside="handleClickOutside"
:has-selection="isTextSelected"
:is-editor-menu-popover="isEditorMenuPopover"
:has-content="!isBodyEmpty(modelValue)"
:editor-content="modelValue"
:conversation-id="conversationId"
:show-selection-menu="showSelectionMenu"
:show-general-menu="false"
@@ -7,7 +7,6 @@ import {
ArticleMarkdownTransformer,
EditorState,
Selection,
imageResizeView,
} from '@chatwoot/prosemirror-schema';
import {
suggestionsPlugin,
@@ -236,7 +235,6 @@ export default {
const tr = editorView.state.tr.replaceSelectionWith(tableNode);
editorView.dispatch(tr.scrollIntoView());
},
imageUpload: () => this.openFileBrowser(),
};
const command = commandMap[actionKey];
@@ -334,9 +332,6 @@ export default {
createEditorView() {
editorView = new EditorView(this.$refs.editor, {
state: state,
nodeViews: {
image: imageResizeView,
},
dispatchTransaction: tx => {
state = state.apply(tx);
editorView.updateState(state);
@@ -7,6 +7,7 @@ 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';
@@ -165,6 +166,11 @@ export default {
uploadRef,
};
},
data() {
return {
ALLOWED_FILE_TYPES,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
@@ -206,11 +212,13 @@ export default {
return this.conversationType === 'instagram_direct_message';
},
allowedFileTypes() {
// Use default file types for private notes
if (this.isOnPrivateNote) {
return getAllowedFileTypesByChannel();
return this.ALLOWED_FILE_TYPES;
}
let channelType = this.channelType || this.inbox?.channel_type;
if (this.isAnInstagramChannel || this.isInstagramDM) {
channelType = INBOX_TYPES.INSTAGRAM;
}
@@ -419,7 +427,7 @@ export default {
@apply flex;
}
:deep(.file-uploads) {
::v-deep .file-uploads {
label {
@apply cursor-pointer;
}
@@ -53,10 +53,6 @@ export default {
type: String,
default: undefined,
},
hasContent: {
type: Boolean,
default: false,
},
},
emits: ['setReplyMode', 'toggleEditorSize', 'executeCopilotAction'],
setup(props, { emit }) {
@@ -80,7 +76,6 @@ export default {
const { captainTasksEnabled } = useCaptain();
const showCopilotMenu = ref(false);
const copilotToggleRef = ref(null);
const handleCopilotAction = (actionKey, data) => {
emit('executeCopilotAction', actionKey, data || props.editorContent);
@@ -105,11 +100,11 @@ export default {
const keyboardEvents = {
'Alt+KeyP': {
action: () => handleNoteClick(),
allowOnFocusedInput: false,
allowOnFocusedInput: true,
},
'Alt+KeyL': {
action: () => handleReplyClick(),
allowOnFocusedInput: false,
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
@@ -122,7 +117,6 @@ export default {
captainTasksEnabled,
handleCopilotAction,
showCopilotMenu,
copilotToggleRef,
toggleCopilotMenu,
handleClickOutside,
};
@@ -170,7 +164,6 @@ export default {
<div v-if="captainTasksEnabled" class="flex items-center gap-2">
<div class="relative">
<NextButton
ref="copilotToggleRef"
ghost
:disabled="disabled || isEditorDisabled"
:class="{
@@ -183,12 +176,9 @@ export default {
/>
<CopilotMenuBar
v-if="showCopilotMenu"
v-on-click-outside="[
handleClickOutside,
{ ignore: [copilotToggleRef] },
]"
v-on-click-outside="handleClickOutside"
:has-selection="false"
:has-content="hasContent"
:editor-content="editorContent"
:conversation-id="conversationId"
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
@execute-copilot-action="handleCopilotAction"
@@ -60,12 +60,6 @@ const EDITOR_ACTIONS = [
icon: 'i-lucide-table',
menuKey: 'insertTable',
},
{
value: 'imageUpload',
labelKey: 'SLASH_COMMANDS.IMAGE',
icon: 'i-lucide-image',
menuKey: 'imageUpload',
},
{
value: 'strike',
labelKey: 'SLASH_COMMANDS.STRIKETHROUGH',
@@ -1,144 +0,0 @@
<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,14 +8,13 @@ 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: {
@@ -173,7 +172,6 @@ const copyConversationId = async () => {
:parent-width="width"
class="hidden md:flex"
/>
<ConversationCallButton :inbox="inbox" :chat="currentChat" />
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
@@ -179,21 +179,6 @@ 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 &&
@@ -1246,7 +1231,6 @@ 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"
@@ -14,11 +14,6 @@ const props = defineProps({
type: String,
default: 'conversation',
},
action: {
type: String,
default: 'assign',
validator: value => ['assign', 'remove'].includes(value),
},
isLoading: {
type: Boolean,
default: false,
@@ -27,13 +22,9 @@ const props = defineProps({
type: Boolean,
default: false,
},
appliedLabels: {
type: Array,
default: null,
},
});
const emit = defineEmits(['assign', 'remove']);
const emit = defineEmits(['assign']);
const { t } = useI18n();
@@ -44,43 +35,17 @@ const [showDropdown, toggleDropdown] = useToggle(false);
const selectedLabels = ref([]);
const isTypeContact = computed(() => props.type === 'contact');
const isRemoveAction = computed(() => props.action === 'remove');
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 buttonLabel = computed(() =>
props.type === 'contact' ? t('CONTACTS_BULK_ACTIONS.ASSIGN_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 visibleLabels.value.map(label => ({
return labels.value.map(label => ({
action: 'select',
value: label.title,
label: label.title,
@@ -99,13 +64,9 @@ const toggleLabelSelection = labelTitle => {
}
};
const handleApply = () => {
const handleAssign = () => {
if (selectedLabels.value.length > 0) {
if (isRemoveAction.value) {
emit('remove', selectedLabels.value);
} else {
emit('assign', selectedLabels.value);
}
emit('assign', selectedLabels.value);
toggleDropdown(false);
selectedLabels.value = [];
}
@@ -120,9 +81,9 @@ const handleDismiss = () => {
<template>
<div ref="containerRef" class="relative">
<NextButton
v-tooltip="tooltipLabel"
v-tooltip="isTypeContact ? '' : $t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
:label="buttonLabel"
:icon="isRemoveAction ? 'i-woot-tag-remove' : 'i-lucide-tag'"
icon="i-lucide-tag"
slate
:size="isTypeContact ? 'sm' : 'xs'"
ghost
@@ -187,9 +148,9 @@ const handleDismiss = () => {
<NextButton
sm
class="w-full [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
:label="confirmLabel"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length"
@click="handleApply"
@click="handleAssign"
/>
</div>
</template>
@@ -1,6 +1,5 @@
<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';
@@ -56,25 +55,12 @@ 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) {
@@ -175,11 +161,6 @@ 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,5 +1,5 @@
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, defineOptions } 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 :deep(.button__content) {
.canned-item__button::v-deep .button__content {
@apply overflow-hidden text-ellipsis whitespace-nowrap;
}
</style>
@@ -108,7 +108,7 @@ export function useBulkActions() {
}
}
// Used by both context menu and bulk action bar.
// Only used in context menu
async function onRemoveLabels(labelsToRemove, conversationId = null) {
try {
await store.dispatch('bulkActions/process', {
@@ -119,24 +119,14 @@ export function useBulkActions() {
},
});
// 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(
conversationId
? t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED')
: t('BULK_ACTION.LABELS.REMOVE_FAILED')
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
labelName: labelsToRemove[0],
conversationId,
})
);
} catch (err) {
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED'));
}
}
@@ -1,47 +1,6 @@
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();
});
@@ -50,46 +9,18 @@ describe('useKeyboardEvents', () => {
expect(useKeyboardEvents).toBeInstanceOf(Function);
});
it('registers keybindings on mount and removes the listener on unmount', async () => {
const addEventListener = vi.spyOn(document, 'addEventListener');
it('should set up listeners on mount and remove them on unmount', async () => {
const events = {
'ALT+KeyL': () => {},
};
const keybindings = await mount({ 'ALT+KeyL': () => {} });
expect(Object.keys(keybindings)).toEqual(['ALT+KeyL']);
const mountedMock = vi.fn();
const unmountedMock = vi.fn();
useKeyboardEvents(events);
mountedMock();
unmountedMock();
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);
expect(mountedMock).toHaveBeenCalled();
expect(unmountedMock).toHaveBeenCalled();
});
});
@@ -1,145 +1,62 @@
import { computed, readonly, ref, watch, onUnmounted, onMounted } from 'vue';
import { useStore } from 'vuex';
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
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';
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;
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;
});
TwilioVoiceClient.addEventListener(
'call:disconnected',
handleTwilioDisconnectedGlobal
);
window.addEventListener('beforeunload', handleBeforeUnloadGlobal);
window.addEventListener('pagehide', handlePageHideGlobal);
};
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);
};
const activeCall = computed(() => callsStore.activeCall);
const incomingCalls = computed(() => callsStore.incomingCalls);
const hasActiveCall = computed(() => callsStore.hasActiveCall);
// 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);
watch(
hasActiveCall,
active => {
if (active) {
durationTimer.start();
} else {
durationTimer.stop();
callDuration.value = 0;
}
},
{ immediate: true }
);
onMounted(() => {
TwilioVoiceClient.addEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
});
onUnmounted(() => {
durationTimer.stop();
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
});
const endCall = async ({ conversationId, inboxId, callSid }) => {
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();
}
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
TwilioVoiceClient.endClientCall();
durationTimer.stop();
callsStore.clearActiveCall();
};
const joinCall = async ({ conversationId, inboxId, callSid }) => {
if (globalIsJoining.value) return null;
if (isJoining.value) return null;
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;
isJoining.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;
@@ -156,165 +73,47 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => {
});
callsStore.setCallActive(callSid);
globalDurationTimer?.start();
durationTimer.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 {
globalIsJoining.value = false;
isJoining.value = false;
}
};
// 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);
const rejectIncomingCall = callSid => {
TwilioVoiceClient.endClientCall();
callsStore.dismissCall(callSid);
};
return { endCall, joinCall, rejectIncomingCall, dismissCall };
};
const dismissCall = callSid => {
callsStore.dismissCall(callSid);
};
const buildReactiveSurface = callsStore => {
const activeCall = computed(() => callsStore.activeCall);
const incomingCalls = computed(() => callsStore.incomingCalls);
const hasActiveCall = computed(() => callsStore.hasActiveCall);
const formattedCallDuration = computed(() => {
const total = globalCallDuration.value;
const minutes = Math.floor(total / 60);
const seconds = total % 60;
const minutes = Math.floor(callDuration.value / 60);
const seconds = callDuration.value % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
return {
activeCall,
incomingCalls,
hasActiveCall,
isJoining: globalIsJoiningReadonly,
isJoining,
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,18 +36,11 @@ 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,
};
}
@@ -1,455 +0,0 @@
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);
};
-1
View File
@@ -46,7 +46,6 @@ 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,27 +4,14 @@ 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,
@@ -45,15 +32,9 @@ 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,
};
}
@@ -139,56 +120,6 @@ 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;
@@ -274,74 +205,6 @@ 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 {
MessageMarkdownSerializer,
MessageMarkdownTransformer,
messageSchema,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
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.
+3 -6
View File
@@ -16,7 +16,6 @@ export const INBOX_TYPES = {
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
WHATSAPP: 'whatsapp',
};
export const getVoiceCallProvider = inbox => {
@@ -26,11 +25,9 @@ export const getVoiceCallProvider = inbox => {
const channelType = inbox.channel_type || inbox.channelType;
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
if (!voiceEnabled) return null;
if (channelType === INBOX_TYPES.TWILIO) return VOICE_CALL_PROVIDERS.TWILIO;
if (channelType === INBOX_TYPES.WHATSAPP)
return VOICE_CALL_PROVIDERS.WHATSAPP;
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
return VOICE_CALL_PROVIDERS.TWILIO;
}
return null;
};
@@ -1,4 +1,4 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import { describe, it, beforeEach, expect, vi } from 'vitest';
import ActionCableConnector from '../actionCable';
vi.mock('shared/helpers/mitt', () => ({
@@ -30,17 +30,12 @@ describe('ActionCableConnector - Copilot Tests', () => {
dispatch: mockDispatch,
getters: {
getCurrentAccountId: 1,
'accounts/isFeatureEnabledonAccount': vi.fn(() => true),
},
},
};
actionCable = ActionCableConnector.init(store.$store, 'test-token');
});
afterEach(() => {
vi.useRealTimers();
});
describe('copilot event handlers', () => {
it('should register the copilot.message.created event handler', () => {
expect(Object.keys(actionCable.events)).toContain(
@@ -69,95 +64,4 @@ describe('ActionCableConnector - Copilot Tests', () => {
);
});
});
describe('conversation unread count event handlers', () => {
it('should register the conversation.unread_count_changed event handler', () => {
expect(Object.keys(actionCable.events)).toContain(
'conversation.unread_count_changed'
);
expect(actionCable.events['conversation.unread_count_changed']).toBe(
actionCable.onConversationUnreadCountChanged
);
});
it('should refetch unread counts when unread count changes', () => {
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
});
it('does not refetch unread counts when unread count feature is disabled', () => {
store.$store.getters[
'accounts/isFeatureEnabledonAccount'
].mockReturnValue(false);
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
});
it('should throttle unread count refetches for repeated events', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(4999);
expect(mockDispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(mockDispatch).toHaveBeenCalledTimes(2);
expect(mockDispatch).toHaveBeenLastCalledWith(
'conversationUnreadCounts/get'
);
});
it('clears pending unread count refetch before immediate refetch', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
vi.advanceTimersByTime(1000);
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
vi.setSystemTime(new Date('2026-01-01T00:00:06Z'));
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(4000);
expect(mockDispatch).toHaveBeenCalledTimes(2);
});
});
});

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