Compare commits

..
Author SHA1 Message Date
Muhsin KelothandGitHub a60d182744 Merge branch 'develop' into fix/CW-6944 2026-05-20 09:09:44 +04:00
Muhsin KelothandGitHub 38446a1ec0 Merge branch 'develop' into fix/CW-6944 2026-05-08 12:25:11 +04:00
Sivin VargheseandGitHub 8534fb42fc Merge branch 'develop' into fix/CW-6944 2026-05-06 15:32:11 +05:30
Sivin VargheseandGitHub a9424b6c7c Merge branch 'develop' into fix/CW-6944 2026-05-05 18:56:41 +05:30
Sivin VargheseandGitHub e44b2fd571 Merge branch 'develop' into fix/CW-6944 2026-05-05 15:55:39 +05:30
Sivin VargheseandGitHub 28c5bb6a75 Merge branch 'develop' into fix/CW-6944 2026-05-05 14:19:18 +05:30
iamsivin ce4ea484bd chore: Clean up 2026-05-05 12:55:43 +05:30
iamsivin 4caaa77c61 chore: Clean up 2026-05-05 12:44:18 +05:30
iamsivin 5e21a475a7 chore: Clean up 2026-05-05 12:34:11 +05:30
iamsivin 2a3da3e6e1 chore: Review fix 2026-05-05 12:24:12 +05:30
iamsivin bfd9fb4bd9 chore: clean up 2026-05-05 12:11:11 +05:30
iamsivin 761077c1d6 chore: Minor fix 2026-05-05 12:04:44 +05:30
Sivin VargheseandGitHub a195764c03 Merge branch 'develop' into fix/CW-6944 2026-05-05 10:50:28 +05:30
Muhsin KelothandGitHub 3f93e4f23a Merge branch 'develop' into fix/CW-6944 2026-05-04 12:38:36 +04:00
iamsivin e8c85362a1 chore: Minor fix 2026-05-01 22:05:10 +05:30
iamsivin 6c40375703 chore: Minor fix 2026-05-01 21:50:02 +05:30
iamsivin 85e0dfbfd9 chore: Review fix 2026-04-30 23:17:20 +05:30
Sivin VargheseandGitHub 890509858d Merge branch 'develop' into fix/CW-6944 2026-04-30 22:16:01 +05:30
Sivin VargheseandGitHub 193c3b38aa Merge branch 'develop' into fix/CW-6944 2026-04-30 18:59:29 +05:30
iamsivin ccffef6759 chore: Review fix 2026-04-30 10:45:43 +05:30
iamsivin 8d3eb35ab1 chore: Clean up 2026-04-29 23:56:23 +05:30
iamsivin c104886a71 chore: Clean up 2026-04-29 23:55:33 +05:30
Sivin VargheseandGitHub cbe1b68417 Merge branch 'develop' into fix/CW-6944 2026-04-29 23:34:27 +05:30
iamsivin 91fe8e0e47 chore: Minor fix 2026-04-29 23:30:40 +05:30
Sivin VargheseandGitHub 8dc7247e39 Merge branch 'develop' into fix/CW-6944 2026-04-29 23:21:23 +05:30
iamsivin 5245cc4245 chore: Review fix 2026-04-29 23:01:41 +05:30
Sivin VargheseandGitHub c8abbadafc Merge branch 'develop' into fix/CW-6944 2026-04-29 23:01:36 +05:30
Sivin VargheseandGitHub 19d3680358 Merge branch 'develop' into fix/CW-6944 2026-04-29 22:10:32 +05:30
iamsivin 6e8635e4c9 chore: Clean up 2026-04-29 21:43:21 +05:30
Sivin VargheseandGitHub 435b38c911 Merge branch 'develop' into fix/CW-6944 2026-04-29 21:38:56 +05:30
iamsivin 90f3365d45 chore: Clean up review fix 2026-04-29 21:38:28 +05:30
iamsivin f8414bfa4f chore: Minor fix 2026-04-29 18:28:34 +05:30
iamsivin 122295b5f4 chore: Review fix 2026-04-29 18:28:34 +05:30
Aakash BakhleandGitHub 6630eb5c14 Merge branch 'develop' into fix/CW-6944 2026-04-29 17:36:30 +05:30
iamsivin b8f938b7c3 chore: Minor fix 2026-04-29 16:00:00 +05:30
iamsivin 31287976b9 fix: show/hide quoted text on plain-text emails 2026-04-29 15:44:49 +05:30
318 changed files with 1787 additions and 9869 deletions
-6
View File
@@ -98,8 +98,6 @@ SMTP_OPENSSL_VERIFY_MODE=peer
# Mail Incoming # Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled # This is the domain set for the reply emails when conversation continuity is enabled
MAILER_INBOUND_EMAIL_DOMAIN= 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 # Set this to the appropriate ingress channel with regards to incoming emails
# Possible values are : # Possible values are :
# relay for Exim, Postfix, Qmail # 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 # 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 # 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 ## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints ## setting this value to true will disable the frontend dashboard endpoints
# CW_API_ONLY_SERVER=false # CW_API_ONLY_SERVER=false
+1 -3
View File
@@ -89,7 +89,7 @@ gem 'rails-i18n', '~> 7.0'
# two-factor authentication # two-factor authentication
gem 'devise-two-factor', '>= 5.0.0' gem 'devise-two-factor', '>= 5.0.0'
# authorization # authorization
gem 'jwt', '~> 2.10', '>= 2.10.3' gem 'jwt'
gem 'pundit' gem 'pundit'
# super admin # super admin
@@ -209,8 +209,6 @@ gem 'opentelemetry-exporter-otlp'
gem 'shopify_api' gem 'shopify_api'
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
### Gems required only in specific deployment environments ### ### Gems required only in specific deployment environments ###
############################################################## ##############################################################
+3 -5
View File
@@ -301,7 +301,7 @@ GEM
railties (>= 5.0.0) railties (>= 5.0.0)
faker (3.2.0) faker (3.2.0)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.14.2) faraday (2.14.1)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
json json
logger logger
@@ -339,7 +339,6 @@ GEM
ffi-compiler (1.0.1) ffi-compiler (1.0.1)
ffi (>= 1.0.0) ffi (>= 1.0.0)
rake rake
firecrawl-sdk (1.4.1)
flag_shih_tzu (0.3.23) flag_shih_tzu (0.3.23)
foreman (0.87.2) foreman (0.87.2)
fugit (1.11.1) fugit (1.11.1)
@@ -491,7 +490,7 @@ GEM
judoscale-sidekiq (1.8.2) judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2) judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0) sidekiq (>= 5.0)
jwt (2.10.3) jwt (2.10.1)
base64 base64
kaminari (1.2.2) kaminari (1.2.2)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
@@ -1080,7 +1079,6 @@ DEPENDENCIES
faker faker
faraday_middleware-aws-sigv4 faraday_middleware-aws-sigv4
fcm fcm
firecrawl-sdk (~> 1.0)
flag_shih_tzu flag_shih_tzu
foreman foreman
gemoji gemoji
@@ -1102,7 +1100,7 @@ DEPENDENCIES
json_schemer json_schemer
judoscale-rails judoscale-rails
judoscale-sidekiq judoscale-sidekiq
jwt (~> 2.10, >= 2.10.3) jwt
kaminari kaminari
koala koala
letter_opener letter_opener
+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 class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
before_action :portal before_action :portal
before_action :check_authorization 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 def translate
head :not_implemented head :not_implemented
@@ -19,18 +19,6 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
end 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 def delete_articles
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.no_articles_found')) if @articles.none?
@@ -51,9 +39,5 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
def set_articles def set_articles
@articles = @portal.articles.where(id: params[:ids]) @articles = @portal.articles.where(id: params[:ids])
end end
def category_valid?
@portal.categories.exists?(id: params[:category_id])
end
end end
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController') 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 # rubocop:disable Rails/SkipsModelValidations
@conversation.update_columns(updates) @conversation.update_columns(updates)
# rubocop:enable Rails/SkipsModelValidations # rubocop:enable Rails/SkipsModelValidations
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
end end
def should_update_last_seen? def should_update_last_seen?
@@ -6,7 +6,8 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{ {
redirect_uri: "#{base_url}/microsoft/callback", redirect_uri: "#{base_url}/microsoft/callback",
scope: scope, scope: scope,
state: state state: state,
prompt: 'consent'
} }
) )
if redirect_url if redirect_url
@@ -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, :Latitude,
:Longitude, :Longitude,
:MessageType, :MessageType,
:ProfileName, :ProfileName
:ExternalUserId,
:ParentExternalUserId,
:ProfileUsername,
:Username
) )
end end
end end
-1
View File
@@ -17,7 +17,6 @@ class AsyncDispatcher < BaseDispatcher
InstallationWebhookListener.instance, InstallationWebhookListener.instance,
NotificationListener.instance, NotificationListener.instance,
ParticipationListener.instance, ParticipationListener.instance,
Conversations::UnreadCounts::Listener.instance,
ReportingEventListener.instance, ReportingEventListener.instance,
WebhookListener.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); return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data);
} }
getConversations(contactId, { inboxId } = {}) { getConversations(contactId) {
const params = inboxId ? { inbox_id: inboxId } : {}; return axios.get(`${this.url}/${contactId}/conversations`);
return axios.get(`${this.url}/${contactId}/conversations`, { params });
} }
getContactableInboxes(contactId) { getContactableInboxes(contactId) {
@@ -48,10 +47,9 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/labels`); return axios.get(`${this.url}/${contactId}/labels`);
} }
initiateCall(contactId, inboxId, conversationId = null) { initiateCall(contactId, inboxId) {
return axios.post(`${this.url}/${contactId}/call`, { return axios.post(`${this.url}/${contactId}/call`, {
inbox_id: inboxId, inbox_id: inboxId,
conversation_id: conversationId,
}); });
} }
@@ -13,10 +13,6 @@ class ConversationApi extends ApiClient {
updateLabels(conversationID, labels) { updateLabels(conversationID, labels) {
return axios.post(`${this.url}/${conversationID}/labels`, { labels }); return axios.post(`${this.url}/${conversationID}/labels`, { labels });
} }
getUnreadCounts() {
return axios.get(`${this.url}/unread_counts`);
}
} }
export default new ConversationApi(); 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 }) { bulkDelete({ portalSlug, articleIds }) {
return axios.delete( return axios.delete(
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`, `${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
-8
View File
@@ -52,14 +52,6 @@ class Inboxes extends CacheEnabledApiClient {
resetSecret(inboxId) { resetSecret(inboxId) {
return axios.post(`${this.url}/${inboxId}/reset_secret`); 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(); 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', () => { it('#getConversations', () => {
contactAPI.getConversations(1); contactAPI.getConversations(1);
expect(axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/1/conversations', '/api/v1/contacts/1/conversations'
{ params: {} }
); );
}); });
@@ -11,7 +11,6 @@ describe('#ConversationApi', () => {
expect(conversationsAPI).toHaveProperty('delete'); expect(conversationsAPI).toHaveProperty('delete');
expect(conversationsAPI).toHaveProperty('getLabels'); expect(conversationsAPI).toHaveProperty('getLabels');
expect(conversationsAPI).toHaveProperty('updateLabels'); expect(conversationsAPI).toHaveProperty('updateLabels');
expect(conversationsAPI).toHaveProperty('getUnreadCounts');
}); });
describe('API calls', () => { 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 { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store'; import { useMapGetter, useStore } from 'dashboard/composables/store';
import { import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
isVoiceCallEnabled,
getVoiceCallProvider,
VOICE_CALL_PROVIDERS,
} from 'dashboard/helper/inbox';
import {
VOICE_CALL_DIRECTION,
VOICE_CALL_OUTBOUND_INIT_STATUS,
} from 'dashboard/components-next/message/constants';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls'; 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 Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.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({ const props = defineProps({
phone: { type: String, default: '' }, phone: { type: String, default: '' },
contactId: { type: [String, Number], required: true }, 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: '' }, label: { type: String, default: '' },
icon: { type: [String, Object, Function], default: '' }, icon: { type: [String, Object, Function], default: '' },
size: { type: String, default: 'sm' }, size: { type: String, default: 'sm' },
@@ -43,7 +30,6 @@ const { t } = useI18n();
const dialogRef = ref(null); const dialogRef = ref(null);
const callsStore = useCallsStore();
const inboxesList = useMapGetter('inboxes/getInboxes'); const inboxesList = useMapGetter('inboxes/getInboxes');
const contactsUiFlags = useMapGetter('contacts/getUIFlags'); const contactsUiFlags = useMapGetter('contacts/getUIFlags');
@@ -52,22 +38,13 @@ const voiceInboxes = computed(() =>
); );
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0); const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
// Unified behavior: hide when no phone
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone); const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
const isInitiatingCall = computed(() => { const isInitiatingCall = computed(() => {
return contactsUiFlags.value?.isInitiatingCall || false; 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 navigateToConversation = conversationId => {
const accountId = route.params.accountId; const accountId = route.params.accountId;
if (conversationId && accountId) { if (conversationId && accountId) {
@@ -81,96 +58,23 @@ const navigateToConversation = conversationId => {
} }
}; };
const whatsappCallSession = useWhatsappCallSession(); const startCall = async inboxId => {
if (isInitiatingCall.value) return;
// Find the most recent open conversation for this contact in the picked inbox.
// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
// Pass inboxId so the BE applies the filter before the 20-row cap — without it,
// contacts whose latest WhatsApp conversation falls outside the 20 most recent
// across all inboxes would be treated as having no conversation.
const findWhatsappConversationId = async inboxId => {
const { data } = await ContactAPI.getConversations(props.contactId, {
inboxId,
});
const conversations = data?.payload || [];
const match = [...conversations].sort(
(a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0)
)[0];
return match?.id || null;
};
const startWhatsappCall = async (inboxId, conversationIdHint) => {
// WhatsApp /initiate is conversation-scoped, so we must hand it a
// conversation. Use the caller's hint when given (in-conversation flow);
// otherwise pick the most recent one in the inbox.
const conversationId =
conversationIdHint || (await findWhatsappConversationId(inboxId));
if (!conversationId) {
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
return;
}
const response =
await whatsappCallSession.initiateOutboundCall(conversationId);
// The composable returns { status: 'locked' } when an init is already in
// flight or a call is already active; treat that as a soft no-op rather than
// claiming success.
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
if (!response?.id) {
// Permission template path returns no call id. Mirror the header button and
// surface whether the request was just sent or is already pending instead of
// claiming the call started. The permission message lands in the
// conversation, so still navigate there.
const messageKey =
response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
? 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_PENDING'
: 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_REQUESTED';
useAlert(t(messageKey));
navigateToConversation(conversationId);
return;
}
// Stay non-active until the connect cable event arrives — flipping to active
// here would start the duration timer before the contact picks up.
callsStore.addCall({
callSid: response.call_id,
callId: response.id,
conversationId,
inboxId,
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(conversationId);
};
const startCall = async (inboxId, conversationIdHint = null) => {
if (isCallButtonDisabled.value) return;
const inbox = (inboxesList.value || []).find(i => i.id === inboxId);
if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) {
try {
await startWhatsappCall(inboxId, conversationIdHint);
} catch (error) {
useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
}
return;
}
try { try {
const response = await store.dispatch('contacts/initiateCall', { const response = await store.dispatch('contacts/initiateCall', {
contactId: props.contactId, contactId: props.contactId,
inboxId, inboxId,
conversationId: conversationIdHint,
}); });
const { call_sid: callSid, conversation_id: conversationId } = response; const { call_sid: callSid, conversation_id: conversationId } = response;
// Add call to store immediately so widget shows
const callsStore = useCallsStore();
callsStore.addCall({ callsStore.addCall({
callSid, callSid,
conversationId, conversationId,
inboxId, inboxId,
callDirection: VOICE_CALL_DIRECTION.OUTBOUND, callDirection: 'outbound',
}); });
useAlert(t('CONTACT_PANEL.CALL_INITIATED')); useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
@@ -182,22 +86,6 @@ const startCall = async (inboxId, conversationIdHint = null) => {
}; };
const onClick = async () => { 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) { if (voiceInboxes.value.length > 1) {
dialogRef.value?.open(); dialogRef.value?.open();
return; return;
@@ -218,7 +106,7 @@ const onPickInbox = async inbox => {
v-if="shouldRender" v-if="shouldRender"
v-tooltip.top-end="tooltipLabel || null" v-tooltip.top-end="tooltipLabel || null"
v-bind="attrs" v-bind="attrs"
:disabled="isCallButtonDisabled" :disabled="isInitiatingCall"
:is-loading="isInitiatingCall" :is-loading="isInitiatingCall"
:label="label" :label="label"
:icon="icon" :icon="icon"
@@ -142,26 +142,28 @@ watch(
<style lang="scss" scoped> <style lang="scss" scoped>
.editor-wrapper { .editor-wrapper {
:deep(.ProseMirror-menubar-wrapper) { ::v-deep {
.ProseMirror.ProseMirror-woot-style { .ProseMirror-menubar-wrapper {
p { .ProseMirror.ProseMirror-woot-style {
@apply first:mt-0 !important; p {
} @apply first:mt-0 !important;
}
.empty-node { .empty-node {
@apply m-0 !important; @apply m-0 !important;
&::before { &::before {
@apply text-n-slate-11 dark:text-n-slate-11; @apply text-n-slate-11 dark:text-n-slate-11;
}
} }
} }
}
.ProseMirror-menubar { .ProseMirror-menubar {
width: fit-content !important; width: fit-content !important;
position: relative !important; position: relative !important;
top: unset !important; top: unset !important;
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important; @apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
}
} }
} }
} }
@@ -145,43 +145,45 @@ const handleCreateArticle = event => {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
:deep(.ProseMirror .empty-node::before) { ::v-deep {
@apply text-n-slate-10 text-base; .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;
} }
}
:deep(.ProseMirror-menubar) { .ProseMirror-menubar-wrapper {
display: none; // Hide by default .ProseMirror-woot-style {
} @apply min-h-[15rem] max-h-full;
}
:deep(.editor-root .has-selection) {
.ProseMirror-menubar:not(:has(*)) {
display: none !important;
} }
.ProseMirror-menubar { .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: none; // Hide by default
display: flex; }
top: var(--selection-top, auto) !important;
left: var(--selection-left, 0) !important;
width: fit-content !important;
position: absolute !important;
.ProseMirror-menuitem { .editor-root .has-selection {
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center; .ProseMirror-menubar:not(:has(*)) {
display: none !important;
.ProseMirror-icon {
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
}
} }
.ProseMirror-menu-active { .ProseMirror-menubar {
@apply bg-n-slate-3; @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 { ref, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store.js'; import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig'; import { useConfig } from 'dashboard/composables/useConfig';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper'; 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 BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.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'; import BulkTranslateDialog from './BulkTranslateDialog.vue';
const props = defineProps({ const props = defineProps({
@@ -64,7 +62,6 @@ const isFeatureEnabledonAccount = useMapGetter(
const selectedArticleIds = ref(new Set()); const selectedArticleIds = ref(new Set());
const deleteConfirmDialogRef = ref(null); const deleteConfirmDialogRef = ref(null);
const isCategoryMenuOpen = ref(false);
const { isEnterprise } = useConfig(); 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 = () => { const confirmBulkDelete = () => {
deleteConfirmDialogRef.value?.open(); deleteConfirmDialogRef.value?.open();
}; };
@@ -371,30 +340,6 @@ watch(
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit" class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('archived')" @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 <Button
v-if="isTranslationAvailable" v-if="isTranslationAvailable"
sm sm
@@ -202,7 +202,7 @@ defineExpose({ state, isSubmitDisabled });
size="sm" size="sm"
type="button" type="button"
:icon="!state.icon ? 'i-lucide-smile-plus' : ''" :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" @click="isEmojiPickerOpen = !isEmojiPickerOpen"
/> />
<EmojiInput <EmojiInput
@@ -32,11 +32,7 @@ const modelValue = defineModel({ type: Boolean, default: false });
<span class="text-heading-3 text-n-slate-12"> <span class="text-heading-3 text-n-slate-12">
{{ header }} {{ header }}
</span> </span>
<template v-if="hideToggle"> <div v-if="hideToggle" class="size-2" />
<slot name="hiddenToggle">
<div class="size-2" />
</slot>
</template>
<ToggleSwitch v-else v-model="modelValue" /> <ToggleSwitch v-else v-model="modelValue" />
</div> </div>
<span v-if="description" class="text-body-main text-n-slate-11"> <span v-if="description" class="text-body-main text-n-slate-11">
@@ -1,4 +1,5 @@
<script setup> <script setup>
import { defineProps } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue'; 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 bulkDeleteDialog = ref(null);
const isSyncableDocument = doc => const isSyncableDocument = doc =>
!doc.pdf_document && doc.status === 'available'; !doc.pdf_document && doc.status === 'available' && !doc.sync_in_progress;
const syncableSelectedIds = computed(() => { const syncableSelectedIds = computed(() => {
if (!props.selectedIds.size) return []; if (!props.selectedIds.size) return [];
@@ -127,6 +127,7 @@ const menuItems = computed(() => {
value: 'sync', value: 'sync',
action: 'sync', action: 'sync',
icon: 'i-lucide-refresh-cw', icon: 'i-lucide-refresh-cw',
disabled: props.syncInProgress,
}); });
} }
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { watch } from 'vue'; import { defineModel, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue'; import { computed, defineModel, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref, defineProps, defineEmits } from 'vue';
import { Chrome } from '@lk77/vue3-color'; import { Chrome } from '@lk77/vue3-color';
import { OnClickOutside } from '@vueuse/components'; import { OnClickOutside } from '@vueuse/components';
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { defineProps, ref, defineEmits, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue';
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, h, watch, ref } from 'vue'; import { computed, defineModel, h, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Button from 'next/button/Button.vue'; import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed } from 'vue'; import { defineModel, computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
import Button from 'next/button/Button.vue'; import Button from 'next/button/Button.vue';
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { defineModel, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { picoSearch } from '@scmmishra/pico-search'; import { picoSearch } from '@scmmishra/pico-search';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { h } from 'vue'; import { h, defineProps } from 'vue';
const props = defineProps({ const props = defineProps({
country: { type: String, required: true }, 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> <script setup>
import { computed, toRef } from 'vue'; import { toRef } from 'vue';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox'; import { useChannelIcon } from './provider';
import { useChannelIcon, useChannelBrandIcon } from './provider';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
const props = defineProps({ const props = defineProps({
@@ -9,35 +8,11 @@ const props = defineProps({
type: Object, type: Object,
required: true, 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 channelIcon = useChannelIcon(toRef(props, 'inbox'));
const inboxRef = toRef(props, 'inbox');
const hasVoiceBadge = computed(() => isVoiceCallEnabled(props.inbox));
const channelIcon = useChannelIcon(inboxRef);
const brandIcon = useChannelBrandIcon(inboxRef);
const icon = computed(() =>
props.useBrandIcon && brandIcon.value ? brandIcon.value : channelIcon.value
);
</script> </script>
<template> <template>
<span class="relative inline-flex" v-bind="$attrs"> <Icon :icon="channelIcon" />
<Icon :icon="icon" class="size-full" />
<span
v-if="hasVoiceBadge"
class="absolute top-0 ltr:right-0 rtl:left-0 inline-flex items-center justify-center size-2 rounded-full bg-n-surface-1"
>
<Icon icon="i-lucide-audio-lines" class="size-1.5 text-n-slate-12" />
</span>
</span>
</template> </template>
@@ -18,7 +18,6 @@ const fileTypeIcon = computed(() => {
json: 'i-woot-file-txt', json: 'i-woot-file-txt',
odt: 'i-woot-file-doc', odt: 'i-woot-file-doc',
pdf: 'i-woot-file-pdf', pdf: 'i-woot-file-pdf',
pfx: 'i-woot-file-pfx',
ppt: 'i-woot-file-ppt', ppt: 'i-woot-file-ppt',
pptx: 'i-woot-file-ppt', pptx: 'i-woot-file-ppt',
rar: 'i-woot-file-zip', rar: 'i-woot-file-zip',
@@ -27,7 +26,6 @@ const fileTypeIcon = computed(() => {
txt: 'i-woot-file-txt', txt: 'i-woot-file-txt',
xls: 'i-woot-file-xls', xls: 'i-woot-file-xls',
xlsx: 'i-woot-file-xls', xlsx: 'i-woot-file-xls',
xml: 'i-woot-file-txt',
zip: 'i-woot-file-zip', zip: 'i-woot-file-zip',
}; };
@@ -1,75 +1,45 @@
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import { computed } from 'vue'; import { computed } from 'vue';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
const channelTypeIconMap = {
'Channel::Api': 'i-woot-api',
'Channel::Email': 'i-woot-mail',
'Channel::FacebookPage': 'i-woot-messenger',
'Channel::Line': 'i-woot-line',
'Channel::Sms': 'i-woot-sms',
'Channel::Telegram': 'i-woot-telegram',
'Channel::TwilioSms': 'i-woot-sms',
'Channel::TwitterProfile': 'i-woot-x',
'Channel::WebWidget': 'i-woot-website',
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
};
const providerIconMap = {
microsoft: 'i-woot-outlook',
google: 'i-woot-gmail',
};
// Full-color brand icons. Most come from the `logos` Iconify set; Instagram,
// Outlook and Line use custom `woot` glyphs since the `logos` versions are
// monochrome or missing. Channels not listed here have no brand variant and
// callers should fall back to the monochrome glyph via useChannelIcon.
const channelTypeBrandIconMap = {
'Channel::FacebookPage': 'i-logos-messenger',
'Channel::Line': 'i-woot-line-color',
'Channel::Telegram': 'i-logos-telegram',
'Channel::Whatsapp': 'i-logos-whatsapp-icon',
'Channel::Instagram': 'i-woot-instagram-color',
'Channel::Tiktok': 'i-logos-tiktok-icon',
};
const providerBrandIconMap = {
microsoft: 'i-woot-outlook-color',
google: 'i-logos-google-gmail',
};
const resolveInbox = inbox => inbox?.value ?? inbox;
export function useChannelIcon(inbox) { 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 channelIcon = computed(() => {
const inboxDetails = resolveInbox(inbox); const inboxDetails = inbox.value || inbox;
const type = inboxDetails.channel_type; const type = inboxDetails.channel_type;
let icon = channelTypeIconMap[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)) { if (Object.keys(providerIconMap).includes(inboxDetails.provider)) {
icon = providerIconMap[inboxDetails.provider]; icon = providerIconMap[inboxDetails.provider];
} }
} }
// Special case for Twilio whatsapp // Special case for Twilio whatsapp
if ( if (type === 'Channel::TwilioSms' && inboxDetails.medium === 'whatsapp') {
type === INBOX_TYPES.TWILIO &&
inboxDetails.medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
) {
icon = 'i-woot-whatsapp'; icon = 'i-woot-whatsapp';
} }
// Native Twilio voice inbox: a TwilioSms with voice enabled (and no WhatsApp medium) // Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.)
// is presented as a Voice channel, so show the phone icon. if (isVoiceCallEnabled(inboxDetails)) {
const voiceEnabled =
inboxDetails.voice_enabled || inboxDetails.voiceEnabled;
if (
type === INBOX_TYPES.TWILIO &&
voiceEnabled &&
inboxDetails.medium !== TWILIO_CHANNEL_MEDIUM.WHATSAPP
) {
icon = 'i-woot-voice'; icon = 'i-woot-voice';
} }
@@ -78,26 +48,3 @@ export function useChannelIcon(inbox) {
return channelIcon; 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" /> <Avatar v-bind="avatarInfo" :size="24" />
</div> </div>
<div <div
class="[grid-area:bubble] flex min-w-0" class="[grid-area:bubble] flex"
:class="{ :class="{
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT, 'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT, 'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}" }"
@contextmenu="openContextMenu($event)" @contextmenu="openContextMenu($event)"
> >
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, reactive } from 'vue'; import { defineProps, computed, reactive } from 'vue';
import Message from './Message.vue'; import Message from './Message.vue';
import { MESSAGE_TYPES } from './constants.js'; import { MESSAGE_TYPES } from './constants.js';
import { useCamelCase } from 'dashboard/composables/useTransformKeys'; import { useCamelCase } from 'dashboard/composables/useTransformKeys';
@@ -1,4 +1,6 @@
<script setup> <script setup>
import { defineProps, defineEmits } from 'vue';
defineProps({ defineProps({
showingOriginal: Boolean, showingOriginal: Boolean,
}); });
@@ -95,7 +95,7 @@ const replyToPreview = computed(() => {
<template> <template>
<div <div
class="text-sm min-w-0" class="text-sm"
:class="[ :class="[
messageClass, messageClass,
{ {
@@ -6,12 +6,9 @@ import BaseBubble from './Base.vue';
const { inboxId } = useMessageContext(); const { inboxId } = useMessageContext();
const { const { isAFacebookInbox, isAnInstagramChannel, isATiktokChannel } = useInbox(
isAFacebookInbox, inboxId.value
isAnInstagramChannel, );
isATiktokChannel,
isAWhatsAppChannel,
} = useInbox(inboxId.value);
const unsupportedMessageKey = computed(() => { const unsupportedMessageKey = computed(() => {
if (isAFacebookInbox.value) if (isAFacebookInbox.value)
@@ -19,8 +16,6 @@ const unsupportedMessageKey = computed(() => {
if (isAnInstagramChannel.value) if (isAnInstagramChannel.value)
return 'CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM'; return 'CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM';
if (isATiktokChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_TIKTOK'; if (isATiktokChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_TIKTOK';
if (isAWhatsAppChannel.value)
return 'CONVERSATION.UNSUPPORTED_MESSAGE_WHATSAPP';
return 'CONVERSATION.UNSUPPORTED_MESSAGE'; return 'CONVERSATION.UNSUPPORTED_MESSAGE';
}); });
</script> </script>
@@ -2,27 +2,14 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import { useMessageContext } from '../provider.js'; import { useMessageContext } from '../provider.js';
import { import { VOICE_CALL_STATUS } from '../constants';
VOICE_CALL_STATUS, import { useCallSession } from 'dashboard/composables/useCallSession';
VOICE_CALL_DIRECTION,
VOICE_CALL_OUTBOUND_INIT_STATUS,
VOICE_CALL_END_REASON,
MESSAGE_TYPES,
ATTACHMENT_TYPES,
} from '../constants';
import { useCallActions } from 'dashboard/composables/useCallSession';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import { useCallsStore } from 'dashboard/stores/calls';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { formatDuration } from 'shared/helpers/timeHelper'; import { formatDuration } from 'shared/helpers/timeHelper';
import { useAlert } from 'dashboard/composables';
import Icon from 'dashboard/components-next/icon/Icon.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue'; import BaseBubble from 'next/message/bubbles/Base.vue';
import AudioChip from 'next/message/chips/Audio.vue'; import AudioChip from 'next/message/chips/Audio.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const LABEL_MAP = { const LABEL_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS', [VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
@@ -30,67 +17,39 @@ const LABEL_MAP = {
}; };
const ICON_MAP = { const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call-bold', [VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold', [VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold', [VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold', };
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 { t } = useI18n();
const store = useStore(); const store = useStore();
const { const { call, conversationId, currentUserId, inboxId } = useMessageContext();
call,
attachments,
contentAttributes,
conversationId,
currentUserId,
inboxId,
sender,
messageType,
} = useMessageContext();
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } = const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
useCallActions(); useCallSession();
const whatsappCallSession = useWhatsappCallSession();
const callsStore = useCallsStore();
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const isInitiatingCall = computed(
() => contactsUiFlags.value?.isInitiatingCall || false
);
const status = computed(() => call.value?.status); const status = computed(() => call.value?.status);
// Server-side call records use `outgoing`/`incoming`, while the Pinia store const isOutbound = computed(() => call.value?.direction === 'outgoing');
// and a few API hops normalise to `outbound`/`inbound`. Accept either so the
// bubble label matches the message orientation no matter the source.
const isOutbound = computed(() => {
const dir = call.value?.direction;
if (
dir === VOICE_CALL_DIRECTION.OUTGOING ||
dir === VOICE_CALL_DIRECTION.OUTBOUND
)
return true;
if (
dir === VOICE_CALL_DIRECTION.INCOMING ||
dir === VOICE_CALL_DIRECTION.INBOUND
)
return false;
// Fall back to the message orientation: agent-authored messages sit on the
// right (outbound) and contact-authored ones on the left.
return messageType.value === MESSAGE_TYPES.OUTGOING;
});
const isWhatsapp = computed(
() => call.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
);
const isFailed = computed(() => const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value) [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 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 conversationAssignee = computed(() => {
const conversation = store.getters.getConversationById?.( const conversation = store.getters.getConversationById?.(
conversationId?.value conversationId?.value
@@ -107,107 +66,67 @@ const displayAgentName = computed(() => {
return conversationAssignee.value?.name || null; 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(() => { const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value]; if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
if (isFailed.value) { if (status.value === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value return isOutbound.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL' ? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.MISSED_CALL'; : 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
} }
// RINGING or an as-yet-unknown/initial status: orient purely by direction so an return isFailed.value
// outbound call never falls through to the "Incoming call" label. ? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; : 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}); });
const formattedDuration = computed(() =>
formatDuration(call.value?.durationSeconds)
);
const subtext = computed(() => { 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) { if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return [handledBy.value, formattedDuration.value] return formattedDuration.value;
.filter(Boolean)
.join(' · ');
} }
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) { if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return handledBy.value; if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
} if (didCurrentUserAnswer.value) {
if (isFailed.value) { return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
// 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 (wasDeclinedByAgent.value && displayAgentName.value) { if (displayAgentName.value) {
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_DECLINED_BY', { return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
agentName: displayAgentName.value, 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. return isFailed.value
if (isOutbound.value) { ? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
return handledBy.value || t('CONVERSATION.VOICE_CALL.CALLING'); : t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}); });
const iconName = computed(() => { const iconName = computed(() => {
if (ICON_MAP[status.value]) return ICON_MAP[status.value]; if (ICON_MAP[status.value]) return ICON_MAP[status.value];
return isOutbound.value return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
? 'i-ph-phone-outgoing-bold'
: 'i-ph-phone-incoming-bold';
}); });
// Subtle icon container matches the design's tonal swatch over the bubble bg. const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
// Status drives the accent: teal for live, ruby for missed, neutral otherwise.
const iconContainerClass = computed(() => {
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return 'bg-n-teal-3 text-n-teal-11';
}
if (status.value === VOICE_CALL_STATUS.RINGING) {
return 'bg-n-teal-3 text-n-teal-11';
}
if (isMissedInbound.value) {
return 'bg-n-alpha-2 text-n-ruby-9';
}
return 'bg-n-alpha-2 text-n-slate-12';
});
const callSid = computed(() => call.value?.providerCallId); 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(() => { const canJoinCall = computed(() => {
if (status.value !== VOICE_CALL_STATUS.RINGING) return false; if (status.value !== VOICE_CALL_STATUS.RINGING) return false;
if (isOutbound.value) return false; if (isOutbound.value) return false;
if (acceptedByAgentId.value) return false; if (acceptedByAgentId.value) return false;
if (!callSid.value || !inboxId.value || !conversationId.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) if (hasActiveCall.value && activeCall.value?.callSid === callSid.value)
return false; return false;
const assignee = conversationAssignee.value; const assignee = conversationAssignee.value;
@@ -216,12 +135,11 @@ const canJoinCall = computed(() => {
}); });
const recordingAttachment = computed(() => { const recordingAttachment = computed(() => {
if (audioAttachment.value) return audioAttachment.value;
const url = call.value?.recordingUrl; const url = call.value?.recordingUrl;
if (!url) return null; if (!url) return null;
return { return {
dataUrl: url, dataUrl: url,
fileType: ATTACHMENT_TYPES.AUDIO, fileType: 'audio',
extension: 'wav', extension: 'wav',
transcribedText: call.value?.transcript || '', transcribedText: call.value?.transcript || '',
}; };
@@ -244,117 +162,48 @@ const handleJoinCall = async () => {
callSid: callSid.value, 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> </script>
<template> <template>
<BaseBubble class="!p-3 !max-w-md min-w-[240px]" hide-meta> <BaseBubble class="p-0 border-none" hide-meta>
<div class="flex flex-col gap-3 w-full"> <div class="flex overflow-hidden flex-col w-full max-w-sm">
<!-- Header row: icon + title + duration/subtext --> <div class="flex gap-3 items-center p-3 w-full">
<div class="flex gap-2.5 items-start">
<div <div
class="flex justify-center items-center rounded-xl size-11 shrink-0" class="flex justify-center items-center rounded-full size-10 shrink-0"
:class="iconContainerClass" :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>
<div class="flex flex-col flex-1 min-w-0 self-center">
<span <div class="flex overflow-hidden flex-col flex-grow">
class="font-display text-sm font-medium leading-tight truncate tracking-tight" <span class="text-sm font-medium truncate text-n-slate-12">
>
{{ $t(labelKey) }} {{ $t(labelKey) }}
</span> </span>
<span <span v-if="subtext" class="text-xs text-n-slate-11">
v-if="subtext"
class="text-sm leading-tight truncate tracking-tight opacity-75"
>
{{ subtext }} {{ subtext }}
</span> </span>
<button
v-if="canJoinCall"
type="button"
class="p-0 mt-1 text-xs font-medium text-start text-n-teal-10 hover:text-n-teal-11 disabled:opacity-50"
:disabled="isJoining"
@click="handleJoinCall"
>
{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}
</button>
</div> </div>
</div> </div>
<!-- Audio player (when there's a recording) --> <div v-if="recordingAttachment" class="px-3 pb-3">
<AudioChip <AudioChip :attachment="recordingAttachment" />
v-if="recordingAttachment" </div>
: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> </div>
</BaseBubble> </BaseBubble>
</template> </template>
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, useAttrs } from 'vue'; import { computed, defineOptions, useAttrs } from 'vue';
import ImageChip from 'next/message/chips/Image.vue'; import ImageChip from 'next/message/chips/Image.vue';
import VideoChip from 'next/message/chips/Video.vue'; import VideoChip from 'next/message/chips/Video.vue';
@@ -31,17 +31,6 @@ const timeStampURL = computed(() => {
return timeStampAppendedURL(attachment.dataUrl); 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 audioPlayer = useTemplateRef('audioPlayer');
const isPlaying = ref(false); const isPlaying = ref(false);
@@ -52,33 +41,8 @@ const playbackSpeed = ref(1);
const { uid } = getCurrentInstance(); 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 onLoadedMetadata = () => {
const d = audioPlayer.value?.duration; duration.value = audioPlayer.value?.duration;
if (!Number.isFinite(d)) {
resolveStreamingDuration();
return;
}
duration.value = d;
}; };
const playbackSpeedLabel = computed(() => { const playbackSpeedLabel = computed(() => {
@@ -89,8 +53,7 @@ const playbackSpeedLabel = computed(() => {
// When the onLoadMetadata is called, so we need to set the duration // When the onLoadMetadata is called, so we need to set the duration
// value when the component is mounted // value when the component is mounted
onMounted(() => { onMounted(() => {
const d = audioPlayer.value?.duration; duration.value = audioPlayer.value?.duration;
if (Number.isFinite(d)) duration.value = d;
audioPlayer.value.playbackRate = playbackSpeed.value; audioPlayer.value.playbackRate = playbackSpeed.value;
}); });
@@ -226,18 +189,7 @@ const downloadAudio = async () => {
v-if="attachment.transcribedText && showTranscribedText" v-if="attachment.transcribedText && showTranscribedText"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words" class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
> >
{{ displayedTranscript }} {{ attachment.transcribedText }}
<button
v-if="isTranscriptLong"
class="block mt-1 p-0 border-0 bg-transparent text-n-slate-11 hover:text-n-slate-12 font-medium"
@click="isTranscriptExpanded = !isTranscriptExpanded"
>
{{
isTranscriptExpanded
? $t('CONVERSATION.VOICE_CALL.TRANSCRIPT_SHOW_LESS')
: $t('CONVERSATION.VOICE_CALL.TRANSCRIPT_SHOW_MORE')
}}
</button>
</div> </div>
</div> </div>
</template> </template>
@@ -88,18 +88,5 @@ export const VOICE_CALL_STATUS = {
export const VOICE_CALL_DIRECTION = { export const VOICE_CALL_DIRECTION = {
INBOUND: 'inbound', INBOUND: 'inbound',
INCOMING: 'incoming',
OUTGOING: 'outgoing',
ONGOING: 'ongoing',
OUTBOUND: 'outbound', OUTBOUND: 'outbound',
}; };
export const VOICE_CALL_OUTBOUND_INIT_STATUS = {
LOCKED: 'locked',
PERMISSION_REQUESTED: 'permission_requested',
PERMISSION_PENDING: 'permission_pending',
};
export const VOICE_CALL_END_REASON = {
AGENT_REJECTED: 'agent_rejected',
};
@@ -2,7 +2,6 @@
import { computed } from 'vue'; import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
import ChannelIcon from 'next/icon/ChannelIcon.vue'; import ChannelIcon from 'next/icon/ChannelIcon.vue';
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({ const props = defineProps({
label: { label: {
@@ -18,10 +17,6 @@ const props = defineProps({
type: Object, type: Object,
required: true, required: true,
}, },
badgeCount: {
type: [Number, String],
default: 0,
},
}); });
const reauthorizationRequired = computed(() => { const reauthorizationRequired = computed(() => {
@@ -34,7 +29,6 @@ const reauthorizationRequired = computed(() => {
<ChannelIcon :inbox="inbox" class="size-4" /> <ChannelIcon :inbox="inbox" class="size-4" />
</span> </span>
<div class="flex-1 truncate min-w-0">{{ label }}</div> <div class="flex-1 truncate min-w-0">{{ label }}</div>
<SidebarUnreadBadge :count="badgeCount" />
<div <div
v-if="reauthorizationRequired" v-if="reauthorizationRequired"
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')" v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { h, ref, computed, onMounted, watch } from 'vue'; import { h, ref, computed, onMounted } from 'vue';
import { provideSidebarContext, useSidebarResize } from './provider'; import { provideSidebarContext, useSidebarResize } from './provider';
import { useAccount } from 'dashboard/composables/useAccount'; import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd'; 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 => { const toggleShortcutModalFn = show => {
if (show) { if (show) {
emit('openKeyShortcutModal'); emit('openKeyShortcutModal');
@@ -175,15 +157,6 @@ useEventListener(document, 'touchend', onResizeEnd);
const inboxes = useMapGetter('inboxes/getInboxes'); const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar'); 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 teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews'); const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter( const conversationCustomViews = useMapGetter(
@@ -200,48 +173,8 @@ onMounted(() => {
store.dispatch('customViews/get', 'contact'); 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(() => const sortedInboxes = computed(() =>
sortByUnreadCount( inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
inboxes.value,
inbox => inbox.name,
inbox => getInboxUnreadCount.value(inbox.id)
)
);
const sortedLabels = computed(() =>
sortByUnreadCount(
labels.value,
label => label.title,
label => getLabelUnreadCount.value(label.id)
)
); );
const closeMobileSidebar = () => { const closeMobileSidebar = () => {
@@ -334,10 +267,9 @@ const menuItems = computed(() => {
label: t('SIDEBAR.TEAMS'), label: t('SIDEBAR.TEAMS'),
icon: 'i-lucide-users', icon: 'i-lucide-users',
activeOn: ['conversations_through_team'], activeOn: ['conversations_through_team'],
children: sortedTeams.value.map(team => ({ children: teams.value.map(team => ({
name: `${team.name}-${team.id}`, name: `${team.name}-${team.id}`,
label: team.name, label: team.name,
badgeCount: getTeamUnreadCount.value(team.id),
to: accountScopedRoute('team_conversations', { teamId: team.id }), to: accountScopedRoute('team_conversations', { teamId: team.id }),
})), })),
}, },
@@ -349,7 +281,6 @@ const menuItems = computed(() => {
children: sortedInboxes.value.map(inbox => ({ children: sortedInboxes.value.map(inbox => ({
name: `${inbox.name}-${inbox.id}`, name: `${inbox.name}-${inbox.id}`,
label: inbox.name, label: inbox.name,
badgeCount: getInboxUnreadCount.value(inbox.id),
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }), icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }), to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps => component: leafProps =>
@@ -357,7 +288,6 @@ const menuItems = computed(() => {
label: leafProps.label, label: leafProps.label,
active: leafProps.active, active: leafProps.active,
inbox, inbox,
badgeCount: leafProps.badgeCount,
}), }),
})), })),
}, },
@@ -366,10 +296,9 @@ const menuItems = computed(() => {
label: t('SIDEBAR.LABELS'), label: t('SIDEBAR.LABELS'),
icon: 'i-lucide-tag', icon: 'i-lucide-tag',
activeOn: ['conversations_through_label'], activeOn: ['conversations_through_label'],
children: sortedLabels.value.map(label => ({ children: labels.value.map(label => ({
name: `${label.title}-${label.id}`, name: `${label.title}-${label.id}`,
label: label.title, label: label.title,
badgeCount: getLabelUnreadCount.value(label.id),
icon: h('span', { icon: h('span', {
class: `size-[8px] rounded-sm`, class: `size-[8px] rounded-sm`,
style: { backgroundColor: label.color }, style: { backgroundColor: label.color },
@@ -5,7 +5,6 @@ import { useSidebarContext } from './provider';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue'; import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({ const props = defineProps({
label: { type: String, required: true }, label: { type: String, required: true },
@@ -167,7 +166,6 @@ onMounted(async () => {
class="size-4 flex-shrink-0" class="size-4 flex-shrink-0"
/> />
<span class="flex-1 truncate">{{ subChild.label }}</span> <span class="flex-1 truncate">{{ subChild.label }}</span>
<SidebarUnreadBadge :count="subChild.badgeCount" />
</button> </button>
</li> </li>
</ul> </ul>
@@ -190,7 +188,6 @@ onMounted(async () => {
class="size-4 flex-shrink-0" class="size-4 flex-shrink-0"
/> />
<span class="flex-1 truncate">{{ child.label }}</span> <span class="flex-1 truncate">{{ child.label }}</span>
<SidebarUnreadBadge :count="child.badgeCount" />
</button> </button>
</li> </li>
</template> </template>
@@ -3,7 +3,6 @@ import { isVNode, computed } from 'vue';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
import Policy from 'dashboard/components/policy.vue'; import Policy from 'dashboard/components/policy.vue';
import { useSidebarContext } from './provider'; import { useSidebarContext } from './provider';
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({ const props = defineProps({
label: { type: String, required: true }, label: { type: String, required: true },
@@ -11,7 +10,6 @@ const props = defineProps({
icon: { type: [String, Object], default: null }, icon: { type: [String, Object], default: null },
active: { type: Boolean, default: false }, active: { type: Boolean, default: false },
component: { type: Function, default: null }, component: { type: Function, default: null },
badgeCount: { type: [Number, String], default: 0 },
}); });
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext(); const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
@@ -41,14 +39,15 @@ const shouldRenderComponent = computed(() => {
<component <component
:is="component" :is="component"
v-if="shouldRenderComponent" v-if="shouldRenderComponent"
v-bind="{ label, icon, active, badgeCount }" :label
:icon
:active
/> />
<template v-else> <template v-else>
<span v-if="icon" class="size-4 grid place-content-center rounded-full"> <span v-if="icon" class="size-4 grid place-content-center rounded-full">
<Icon :icon="icon" class="size-4 inline-block" /> <Icon :icon="icon" class="size-4 inline-block" />
</span> </span>
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div> <div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
<SidebarUnreadBadge :count="badgeCount" />
</template> </template>
</component> </component>
</Policy> </Policy>
@@ -78,27 +78,26 @@ function changeAvailabilityStatus(availability) {
<template> <template>
<DropdownSection class="[&>ul]:overflow-visible"> <DropdownSection class="[&>ul]:overflow-visible">
<div class="grid gap-0"> <div class="grid gap-0">
<DropdownItem preserve-open class="gap-1"> <DropdownItem preserve-open>
<div class="flex-grow flex items-center gap-1 min-w-0"> <div class="flex-grow flex items-center gap-1">
{{ $t('SIDEBAR.SET_YOUR_AVAILABILITY') }} {{ $t('SIDEBAR.SET_YOUR_AVAILABILITY') }}
</div> </div>
<DropdownContainer class="shrink-0"> <DropdownContainer>
<template #trigger="{ toggle }"> <template #trigger="{ toggle }">
<Button <Button
size="sm" size="sm"
color="slate" color="slate"
variant="faded" variant="faded"
class="min-w-[96px]"
icon="i-lucide-chevron-down" icon="i-lucide-chevron-down"
trailing-icon trailing-icon
@click="toggle" @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="p-1 flex-shrink-0">
<div class="size-2 rounded-sm" :class="activeStatus.color" /> <div class="size-2 rounded-sm" :class="activeStatus.color" />
</div> </div>
<span class="truncate max-w-[7rem]"> <span>{{ activeStatus.label }}</span>
{{ activeStatus.label }}
</span>
</div> </div>
</Button> </Button>
</template> </template>
@@ -115,12 +114,12 @@ function changeAvailabilityStatus(availability) {
</DropdownContainer> </DropdownContainer>
</DropdownItem> </DropdownItem>
<DropdownItem> <DropdownItem>
<div class="flex-grow min-w-0"> <div class="flex-grow flex items-center gap-1">
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }} {{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
<Icon <Icon
v-tooltip.top="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT')" v-tooltip.top="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT')"
icon="i-lucide-info" 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> </div>
<ToggleSwitch v-model="autoOfflineToggle" /> <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> <script setup>
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue'; import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
import { defineEmits } from 'vue';
defineProps({ defineProps({
title: { title: {
@@ -24,10 +24,6 @@ defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
hasVoiceBadge: {
type: Boolean,
default: false,
},
}); });
const { t } = useI18n(); const { t } = useI18n();
@@ -42,18 +38,10 @@ const { t } = useI18n();
'cursor-not-allowed disabled:opacity-80': isComingSoon, 'cursor-not-allowed disabled:opacity-80': isComingSoon,
}" }"
> >
<div class="relative"> <div
<div class="flex size-10 items-center justify-center rounded-full bg-n-alpha-2"
class="flex size-10 items-center justify-center rounded-full bg-n-alpha-2" >
> <Icon :icon="icon" class="text-n-slate-10 size-6" />
<Icon :icon="icon" class="text-n-slate-10 size-6" />
</div>
<div
v-if="hasVoiceBadge"
class="absolute -top-1 ltr:-right-1 rtl:-left-1 flex size-4 items-center justify-center rounded-full bg-n-alpha-2 ring-2 ring-n-solid-1"
>
<Icon icon="i-lucide-audio-lines" class="text-n-slate-10 size-2.5" />
</div>
</div> </div>
<div class="flex flex-col items-start gap-1.5"> <div class="flex flex-col items-start gap-1.5">
@@ -339,15 +339,17 @@ export default {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
:deep(.selector-wrap) { ::v-deep {
@apply m-0 top-1; .selector-wrap {
@apply m-0 top-1;
.selector-name { .selector-name {
@apply ml-0;
}
}
.name {
@apply ml-0; @apply ml-0;
} }
} }
:deep(.name) {
@apply ml-0;
}
</style> </style>
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref, defineEmits } from 'vue';
import { useIntersectionObserver } from '@vueuse/core'; import { useIntersectionObserver } from '@vueuse/core';
const { options } = defineProps({ const { options } = defineProps({
@@ -1,6 +1,6 @@
<script setup> <script setup>
// [TODO] Use Teleport to move the modal to the end of the body // [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 { useEventListener } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
@@ -1,4 +1,5 @@
<script setup> <script setup>
import { defineEmits, defineModel } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({ defineProps({
@@ -56,14 +56,6 @@ const isActive = computed(() => {
return props.enabledFeatures.channel_voice; return props.enabledFeatures.channel_voice;
} }
if (key === 'whatsapp_call') {
return (
props.enabledFeatures.channel_voice &&
!!window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
}
return [ return [
'website', 'website',
'twilio', 'twilio',
@@ -86,14 +78,7 @@ const isComingSoon = computed(() => {
}); });
const isBeta = computed(() => { const isBeta = computed(() => {
return ['tiktok', 'voice', 'whatsapp_call'].includes(props.channel.key); return ['tiktok', 'voice'].includes(props.channel.key);
});
const hasVoiceBadge = computed(() => {
return (
['voice', 'whatsapp_call'].includes(props.channel.key) &&
!!props.enabledFeatures.channel_voice
);
}); });
const onItemClick = () => { const onItemClick = () => {
@@ -110,7 +95,6 @@ const onItemClick = () => {
:icon="channel.icon" :icon="channel.icon"
:is-coming-soon="isComingSoon" :is-coming-soon="isComingSoon"
:is-beta="isBeta" :is-beta="isBeta"
:has-voice-badge="hasVoiceBadge"
:disabled="!isActive" :disabled="!isActive"
@click="onItemClick" @click="onItemClick"
/> />
@@ -63,8 +63,10 @@ export default {
.colorpicker--chrome.vc-chrome { .colorpicker--chrome.vc-chrome {
@apply shadow-lg -mt-2.5 absolute z-[9999] border border-solid border-n-weak rounded; @apply shadow-lg -mt-2.5 absolute z-[9999] border border-solid border-n-weak rounded;
:deep(input) { ::v-deep {
@apply bg-white dark:bg-white; input {
@apply bg-white dark:bg-white;
}
} }
} }
</style> </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> <script setup>
import getUuid from 'widget/helpers/uuid'; 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 WaveSurfer from 'wavesurfer.js';
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js'; import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js';
import { format, intervalToDuration } from 'date-fns'; import { format, intervalToDuration } from 'date-fns';
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
import { useElementSize, useWindowSize } from '@vueuse/core'; import { useElementSize, useWindowSize } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants'; 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 Button from 'dashboard/components-next/button/Button.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue'; import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
@@ -18,11 +19,9 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
// Signature-aware emptiness is computed by the parent (which has access to editorContent: {
// the signature + channel context) and passed in as a boolean. type: String,
hasContent: { default: undefined,
type: Boolean,
default: false,
}, },
conversationId: { conversationId: {
type: Number, type: Number,
@@ -34,8 +33,17 @@ const emit = defineEmits(['executeCopilotAction']);
const { t } = useI18n(); const { t } = useI18n();
const { draftMessage } = useCaptain();
const replyMode = useMapGetter('draftMessages/getReplyEditorMode'); 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) // Selection-based menu items (when text is selected)
const menuItems = computed(() => { const menuItems = computed(() => {
const items = []; const items = [];
@@ -55,7 +63,7 @@ const menuItems = computed(() => {
} else if ( } else if (
props.conversationId && props.conversationId &&
replyMode.value === REPLY_EDITOR_MODES.REPLY && replyMode.value === REPLY_EDITOR_MODES.REPLY &&
props.hasContent effectiveContent.value
) { ) {
items.push({ items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'), 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( items.push(
{ {
label: t( label: t(
@@ -354,17 +354,16 @@ function isBodyEmpty(content) {
// if content is undefined, we assume that the body is empty // if content is undefined, we assume that the body is empty
if (!content) return true; if (!content) return true;
// Only strip the signature when it's actually being auto-appended for this // if the signature is present, we need to remove it before checking
// draft. Otherwise an agent whose typed text happens to match their saved // note that we don't update the editorView, so this is safe
// signature would be mistakenly treated as empty. // Use effective channel type to match how signature was appended
const bodyWithoutSignature = const bodyWithoutSignature = props.signature
sendWithSignature.value && props.signature ? removeSignatureHelper(
? removeSignatureHelper( content,
content, props.signature,
props.signature, effectiveChannelType.value
effectiveChannelType.value )
) : content;
: content;
// trimming should remove all the whitespaces, so we can check the length // trimming should remove all the whitespaces, so we can check the length
return bodyWithoutSignature.trim().length === 0; return bodyWithoutSignature.trim().length === 0;
@@ -475,6 +474,17 @@ function removeSignature() {
reloadState(content); 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() { function setToolbarPosition() {
const editorRect = editorRoot.value.getBoundingClientRect(); const editorRect = editorRoot.value.getBoundingClientRect();
const rect = selectedImageNode.value.getBoundingClientRect(); const rect = selectedImageNode.value.getBoundingClientRect();
@@ -549,20 +559,6 @@ function emitOnChange() {
emit('update:modelValue', contentFromEditor()); 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() { function updateImgToolbarOnDelete() {
// check if the selected node is present or not on keyup // 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 // this is needed because the user can select an image and then delete it
@@ -589,11 +585,11 @@ function isCmdPlusEnterToSendEnabled() {
useKeyboardEvents({ useKeyboardEvents({
'Alt+KeyP': { 'Alt+KeyP': {
action: focusEditorInputField, action: focusEditorInputField,
allowOnFocusedInput: false, allowOnFocusedInput: true,
}, },
'Alt+KeyL': { 'Alt+KeyL': {
action: focusEditorInputField, action: focusEditorInputField,
allowOnFocusedInput: false, allowOnFocusedInput: true,
}, },
}); });
@@ -903,7 +899,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
v-on-click-outside="handleClickOutside" v-on-click-outside="handleClickOutside"
:has-selection="isTextSelected" :has-selection="isTextSelected"
:is-editor-menu-popover="isEditorMenuPopover" :is-editor-menu-popover="isEditorMenuPopover"
:has-content="!isBodyEmpty(modelValue)" :editor-content="modelValue"
:conversation-id="conversationId" :conversation-id="conversationId"
:show-selection-menu="showSelectionMenu" :show-selection-menu="showSelectionMenu"
:show-general-menu="false" :show-general-menu="false"
@@ -7,6 +7,7 @@ import * as ActiveStorage from 'activestorage';
import inboxMixin from 'shared/mixins/inboxMixin'; import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { getAllowedFileTypesByChannel } from '@chatwoot/utils'; import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue'; import VideoCallButton from '../VideoCallButton.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
@@ -165,6 +166,11 @@ export default {
uploadRef, uploadRef,
}; };
}, },
data() {
return {
ALLOWED_FILE_TYPES,
};
},
computed: { computed: {
...mapGetters({ ...mapGetters({
accountId: 'getCurrentAccountId', accountId: 'getCurrentAccountId',
@@ -206,11 +212,13 @@ export default {
return this.conversationType === 'instagram_direct_message'; return this.conversationType === 'instagram_direct_message';
}, },
allowedFileTypes() { allowedFileTypes() {
// Use default file types for private notes
if (this.isOnPrivateNote) { if (this.isOnPrivateNote) {
return getAllowedFileTypesByChannel(); return this.ALLOWED_FILE_TYPES;
} }
let channelType = this.channelType || this.inbox?.channel_type; let channelType = this.channelType || this.inbox?.channel_type;
if (this.isAnInstagramChannel || this.isInstagramDM) { if (this.isAnInstagramChannel || this.isInstagramDM) {
channelType = INBOX_TYPES.INSTAGRAM; channelType = INBOX_TYPES.INSTAGRAM;
} }
@@ -419,7 +427,7 @@ export default {
@apply flex; @apply flex;
} }
:deep(.file-uploads) { ::v-deep .file-uploads {
label { label {
@apply cursor-pointer; @apply cursor-pointer;
} }
@@ -53,10 +53,6 @@ export default {
type: String, type: String,
default: undefined, default: undefined,
}, },
hasContent: {
type: Boolean,
default: false,
},
}, },
emits: ['setReplyMode', 'toggleEditorSize', 'executeCopilotAction'], emits: ['setReplyMode', 'toggleEditorSize', 'executeCopilotAction'],
setup(props, { emit }) { setup(props, { emit }) {
@@ -80,7 +76,6 @@ export default {
const { captainTasksEnabled } = useCaptain(); const { captainTasksEnabled } = useCaptain();
const showCopilotMenu = ref(false); const showCopilotMenu = ref(false);
const copilotToggleRef = ref(null);
const handleCopilotAction = (actionKey, data) => { const handleCopilotAction = (actionKey, data) => {
emit('executeCopilotAction', actionKey, data || props.editorContent); emit('executeCopilotAction', actionKey, data || props.editorContent);
@@ -105,11 +100,11 @@ export default {
const keyboardEvents = { const keyboardEvents = {
'Alt+KeyP': { 'Alt+KeyP': {
action: () => handleNoteClick(), action: () => handleNoteClick(),
allowOnFocusedInput: false, allowOnFocusedInput: true,
}, },
'Alt+KeyL': { 'Alt+KeyL': {
action: () => handleReplyClick(), action: () => handleReplyClick(),
allowOnFocusedInput: false, allowOnFocusedInput: true,
}, },
}; };
useKeyboardEvents(keyboardEvents); useKeyboardEvents(keyboardEvents);
@@ -122,7 +117,6 @@ export default {
captainTasksEnabled, captainTasksEnabled,
handleCopilotAction, handleCopilotAction,
showCopilotMenu, showCopilotMenu,
copilotToggleRef,
toggleCopilotMenu, toggleCopilotMenu,
handleClickOutside, handleClickOutside,
}; };
@@ -170,7 +164,6 @@ export default {
<div v-if="captainTasksEnabled" class="flex items-center gap-2"> <div v-if="captainTasksEnabled" class="flex items-center gap-2">
<div class="relative"> <div class="relative">
<NextButton <NextButton
ref="copilotToggleRef"
ghost ghost
:disabled="disabled || isEditorDisabled" :disabled="disabled || isEditorDisabled"
:class="{ :class="{
@@ -183,12 +176,9 @@ export default {
/> />
<CopilotMenuBar <CopilotMenuBar
v-if="showCopilotMenu" v-if="showCopilotMenu"
v-on-click-outside="[ v-on-click-outside="handleClickOutside"
handleClickOutside,
{ ignore: [copilotToggleRef] },
]"
:has-selection="false" :has-selection="false"
:has-content="hasContent" :editor-content="editorContent"
:conversation-id="conversationId" :conversation-id="conversationId"
class="ltr:right-0 rtl:left-0 bottom-full mb-2" class="ltr:right-0 rtl:left-0 bottom-full mb-2"
@execute-copilot-action="handleCopilotAction" @execute-copilot-action="handleCopilotAction"
@@ -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 MoreActions from './MoreActions.vue';
import Avatar from 'next/avatar/Avatar.vue'; import Avatar from 'next/avatar/Avatar.vue';
import SLACardLabel from './components/SLACardLabel.vue'; import SLACardLabel from './components/SLACardLabel.vue';
import ConversationCallButton from './ConversationCallButton.vue';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper'; import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers'; import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox'; import { useInbox } from 'dashboard/composables/useInbox';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { copyTextToClipboard } from 'shared/helpers/clipboard'; import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
const props = defineProps({ const props = defineProps({
chat: { chat: {
@@ -173,7 +172,6 @@ const copyConversationId = async () => {
:parent-width="width" :parent-width="width"
class="hidden md:flex" class="hidden md:flex"
/> />
<ConversationCallButton :inbox="inbox" :chat="currentChat" />
<MoreActions :conversation-id="currentChat.id" /> <MoreActions :conversation-id="currentChat.id" />
</div> </div>
</div> </div>
@@ -179,21 +179,6 @@ export default {
} }
return true; 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() { isReplyRestricted() {
return ( return (
!this.currentChat?.can_reply && !this.currentChat?.can_reply &&
@@ -1246,7 +1231,6 @@ export default {
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold" :is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining" :characters-remaining="charactersRemaining"
:editor-content="message" :editor-content="message"
:has-content="hasMeaningfulEditorContent"
@set-reply-mode="setReplyMode" @set-reply-mode="setReplyMode"
@toggle-editor-size="toggleEditorSize" @toggle-editor-size="toggleEditorSize"
@toggle-copilot="copilot.toggleEditor" @toggle-copilot="copilot.toggleEditor"
@@ -14,11 +14,6 @@ const props = defineProps({
type: String, type: String,
default: 'conversation', default: 'conversation',
}, },
action: {
type: String,
default: 'assign',
validator: value => ['assign', 'remove'].includes(value),
},
isLoading: { isLoading: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -27,13 +22,9 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
appliedLabels: {
type: Array,
default: null,
},
}); });
const emit = defineEmits(['assign', 'remove']); const emit = defineEmits(['assign']);
const { t } = useI18n(); const { t } = useI18n();
@@ -44,43 +35,17 @@ const [showDropdown, toggleDropdown] = useToggle(false);
const selectedLabels = ref([]); const selectedLabels = ref([]);
const isTypeContact = computed(() => props.type === 'contact'); const isTypeContact = computed(() => props.type === 'contact');
const isRemoveAction = computed(() => props.action === 'remove');
const buttonLabel = computed(() => { const buttonLabel = computed(() =>
if (!isTypeContact.value) return ''; props.type === 'contact' ? t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS') : ''
return isRemoveAction.value
? t('CONTACTS_BULK_ACTIONS.REMOVE_LABELS')
: t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS');
});
const tooltipLabel = computed(() =>
isRemoveAction.value
? t('BULK_ACTION.LABELS.REMOVE_LABELS')
: t('BULK_ACTION.LABELS.ASSIGN_LABELS')
);
const confirmLabel = computed(() =>
isRemoveAction.value
? t('BULK_ACTION.LABELS.REMOVE_SELECTED_LABELS')
: t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')
); );
const isLabelSelected = labelTitle => { const isLabelSelected = labelTitle => {
return selectedLabels.value.includes(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(() => { const labelMenuItems = computed(() => {
return visibleLabels.value.map(label => ({ return labels.value.map(label => ({
action: 'select', action: 'select',
value: label.title, value: label.title,
label: label.title, label: label.title,
@@ -99,13 +64,9 @@ const toggleLabelSelection = labelTitle => {
} }
}; };
const handleApply = () => { const handleAssign = () => {
if (selectedLabels.value.length > 0) { if (selectedLabels.value.length > 0) {
if (isRemoveAction.value) { emit('assign', selectedLabels.value);
emit('remove', selectedLabels.value);
} else {
emit('assign', selectedLabels.value);
}
toggleDropdown(false); toggleDropdown(false);
selectedLabels.value = []; selectedLabels.value = [];
} }
@@ -120,9 +81,9 @@ const handleDismiss = () => {
<template> <template>
<div ref="containerRef" class="relative"> <div ref="containerRef" class="relative">
<NextButton <NextButton
v-tooltip="tooltipLabel" v-tooltip="isTypeContact ? '' : $t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
:label="buttonLabel" :label="buttonLabel"
:icon="isRemoveAction ? 'i-woot-tag-remove' : 'i-lucide-tag'" icon="i-lucide-tag"
slate slate
:size="isTypeContact ? 'sm' : 'xs'" :size="isTypeContact ? 'sm' : 'xs'"
ghost ghost
@@ -187,9 +148,9 @@ const handleDismiss = () => {
<NextButton <NextButton
sm sm
class="w-full [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex" 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" :disabled="!selectedLabels.length"
@click="handleApply" @click="handleAssign"
/> />
</div> </div>
</template> </template>
@@ -1,6 +1,5 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted, useAttrs } from 'vue'; import { ref, computed, onMounted, onUnmounted, useAttrs } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import { getUnixTime } from 'date-fns'; import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers'; import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
@@ -56,25 +55,12 @@ defineOptions({
const attrs = useAttrs(); const attrs = useAttrs();
const { const {
selectedConversations,
onAssignAgent, onAssignAgent,
onAssignLabels, onAssignLabels,
onRemoveLabels,
onAssignTeamsForBulk: onAssignTeam, onAssignTeamsForBulk: onAssignTeam,
onUpdateConversations, onUpdateConversations,
} = useBulkActions(); } = 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); const showCustomTimeSnoozeModal = ref(false);
function onCmdSnoozeConversation(snoozeType) { function onCmdSnoozeConversation(snoozeType) {
@@ -175,11 +161,6 @@ onUnmounted(() => {
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<BulkLabelActions @assign="onAssignLabels" /> <BulkLabelActions @assign="onAssignLabels" />
<BulkLabelActions
action="remove"
:applied-labels="appliedLabelsForSelection"
@remove="onRemoveLabels"
/>
<BulkUpdateActions <BulkUpdateActions
:show-resolve="!showResolvedAction" :show-resolve="!showResolvedAction"
:show-reopen="!showOpenAction" :show-reopen="!showOpenAction"
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed, defineOptions } from 'vue';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue'; import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.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; @apply overflow-hidden text-ellipsis whitespace-nowrap;
} }
</style> </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) { async function onRemoveLabels(labelsToRemove, conversationId = null) {
try { try {
await store.dispatch('bulkActions/process', { 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( useAlert(
conversationId t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
? t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED') labelName: labelsToRemove[0],
: t('BULK_ACTION.LABELS.REMOVE_FAILED') 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 { 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', () => { describe('useKeyboardEvents', () => {
beforeEach(() => {
keyboardEventsMock.mountedCallbacks = [];
keyboardEventsMock.unmountedCallbacks = [];
keyboardEventsMock.registeredKeybindings = {};
});
it('should be defined', () => { it('should be defined', () => {
expect(useKeyboardEvents).toBeDefined(); expect(useKeyboardEvents).toBeDefined();
}); });
@@ -50,46 +9,18 @@ describe('useKeyboardEvents', () => {
expect(useKeyboardEvents).toBeInstanceOf(Function); expect(useKeyboardEvents).toBeInstanceOf(Function);
}); });
it('registers keybindings on mount and removes the listener on unmount', async () => { it('should set up listeners on mount and remove them on unmount', async () => {
const addEventListener = vi.spyOn(document, 'addEventListener'); const events = {
'ALT+KeyL': () => {},
};
const keybindings = await mount({ 'ALT+KeyL': () => {} }); const mountedMock = vi.fn();
expect(Object.keys(keybindings)).toEqual(['ALT+KeyL']); const unmountedMock = vi.fn();
useKeyboardEvents(events);
mountedMock();
unmountedMock();
const { signal } = addEventListener.mock.calls[0][2]; expect(mountedMock).toHaveBeenCalled();
expect(signal.aborted).toBe(false); expect(unmountedMock).toHaveBeenCalled();
keyboardEventsMock.unmountedCallbacks[0]();
expect(signal.aborted).toBe(true);
});
it('prefixes Shift on QWERTZ layouts for the affected keys', async () => {
useDetectKeyboardLayout.mockResolvedValueOnce(LAYOUT_QWERTZ);
const keybindings = await mount({ 'Alt+KeyL': () => {} });
expect(Object.keys(keybindings)).toEqual(['Shift+Alt+KeyL']);
});
it('ignores shortcuts on focused typeable elements by default', async () => {
const action = vi.fn();
const keybindings = await mount({
'Alt+KeyL': { action, allowOnFocusedInput: false },
});
keybindings['Alt+KeyL']({ target: document.createElement('textarea') });
expect(action).not.toHaveBeenCalled();
});
it('runs shortcuts on focused typeable elements when allowed', async () => {
const action = vi.fn();
const keybindings = await mount({
'Alt+KeyL': { action, allowOnFocusedInput: true },
});
keybindings['Alt+KeyL']({ target: document.createElement('textarea') });
expect(action).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -1,145 +1,62 @@
import { computed, readonly, ref, watch, onUnmounted, onMounted } from 'vue'; import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient'; import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient'; import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { useCallsStore } from 'dashboard/stores/calls'; import { useCallsStore } from 'dashboard/stores/calls';
import { useAlert } from 'dashboard/composables'; 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'; import Timer from 'dashboard/helper/Timer';
const isWhatsappCall = call => call?.provider === VOICE_CALL_PROVIDERS.WHATSAPP; export function useCallSession() {
const callsStore = useCallsStore();
// Dismissed call sids must not be re-seeded by the conversation-load watcher. const { t } = useI18n();
// Lives at module scope so all consumers share the same set. const isJoining = ref(false);
const dismissedCallSids = new Set(); const callDuration = ref(0);
const markDismissed = callSid => { const durationTimer = new Timer(elapsed => {
if (callSid) dismissedCallSids.add(callSid); callDuration.value = elapsed;
};
// 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;
}); });
TwilioVoiceClient.addEventListener(
'call:disconnected',
handleTwilioDisconnectedGlobal
);
window.addEventListener('beforeunload', handleBeforeUnloadGlobal);
window.addEventListener('pagehide', handlePageHideGlobal);
};
const detachGlobalsOnLastUnmount = () => { const activeCall = computed(() => callsStore.activeCall);
globalsAttachedCount -= 1; const incomingCalls = computed(() => callsStore.incomingCalls);
if (globalsAttachedCount > 0) return; const hasActiveCall = computed(() => callsStore.hasActiveCall);
globalDurationTimer?.stop();
globalDurationTimer = null;
globalCallDuration.value = 0;
storedCallsStoreRef = null;
TwilioVoiceClient.removeEventListener(
'call:disconnected',
handleTwilioDisconnectedGlobal
);
window.removeEventListener('beforeunload', handleBeforeUnloadGlobal);
window.removeEventListener('pagehide', handlePageHideGlobal);
};
// Build the action surface used by both the root session composable and the watch(
// lighter useCallActions consumer. All state is module-scoped — the actions hasActiveCall,
// don't depend on per-instance refs, so they're cheap to call from anywhere. active => {
const buildCallActions = ({ callsStore, whatsappSession, t }) => { if (active) {
const findCall = callSid => callsStore.calls.find(c => c.callSid === callSid); 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 endCall = async ({ conversationId, inboxId, callSid }) => {
const call = findCall(callSid); await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
if (isWhatsappCall(call)) { TwilioVoiceClient.endClientCall();
// Pass call.callId so a wiped module state (e.g. a prior accept attempt durationTimer.stop();
// tore down the WebRTC session) doesn't stop us hitting /terminate. callsStore.clearActiveCall();
await whatsappSession.endActiveCall(call?.callId);
globalDurationTimer?.stop();
callsStore.clearActiveCall();
return;
}
// try/finally so a failed leaveConference (e.g. backend 5xx) still
// tears down the local Device and UI state — otherwise the call stays
// visually active with the mic open.
try {
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
} finally {
TwilioVoiceClient.endClientCall();
globalDurationTimer?.stop();
callsStore.clearActiveCall();
}
}; };
const joinCall = async ({ conversationId, inboxId, callSid }) => { const joinCall = async ({ conversationId, inboxId, callSid }) => {
if (globalIsJoining.value) return null; if (isJoining.value) return null;
const call = findCall(callSid); isJoining.value = true;
// Outbound *WhatsApp* calls have no separate join step — the offer was
// sent at initiate time and the answer is applied by the cable handler.
// Routing through acceptIncomingCall here would call prepareInboundAnswer →
// cleanup() and destroy the live outbound session. Outbound *Twilio*
// calls still need joinConference + joinClientCall (FloatingCallWidget
// auto-joins them), so don't short-circuit those.
if (
call?.callDirection === VOICE_CALL_DIRECTION.OUTBOUND &&
isWhatsappCall(call)
) {
return null;
}
globalIsJoining.value = true;
try { 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); const device = await TwilioVoiceClient.initializeDevice(inboxId);
if (!device) return null; if (!device) return null;
@@ -156,165 +73,47 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => {
}); });
callsStore.setCallActive(callSid); callsStore.setCallActive(callSid);
globalDurationTimer?.start(); durationTimer.start();
return { conferenceSid: joinResponse?.conference_sid }; return { conferenceSid: joinResponse?.conference_sid };
} catch (error) { } catch (error) {
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED')); useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
if (error?.response?.status === 409) { if (error?.response?.status === 409) {
TwilioVoiceClient.endClientCall(); TwilioVoiceClient.endClientCall();
markDismissed(callSid);
callsStore.dismissCall(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 // eslint-disable-next-line no-console
console.error('Failed to join call:', error); console.error('Failed to join call:', error);
// Drop any half-built WebRTC state so the next click starts fresh.
cleanupWhatsappSession();
return null; return null;
} finally { } finally {
globalIsJoining.value = false; isJoining.value = false;
} }
}; };
// Await provider-side reject before dismissing the local entry; if the API const rejectIncomingCall = callSid => {
// call fails the call should stay surfaced so the agent can retry instead of TwilioVoiceClient.endClientCall();
// disappearing while the backend still rings.
const rejectIncomingCall = async callSid => {
const call = findCall(callSid);
try {
if (isWhatsappCall(call) && call?.callId) {
if (call.callDirection === VOICE_CALL_DIRECTION.OUTBOUND) {
// Outbound calls that are still ringing must be terminated, not
// rejected (reject is the inbound-side verb on Meta's API).
await whatsappSession.endActiveCall(call.callId);
} else {
await whatsappSession.rejectIncomingCall(call.callId);
}
} else if (call?.inboxId && call?.conversationId) {
// Twilio incoming reject: agent hasn't joined the Device yet, so
// endClientCall is a no-op. End the conference server-side instead
// so Twilio hangs up the inbound leg.
await VoiceAPI.leaveConference({
inboxId: call.inboxId,
conversationId: call.conversationId,
callSid,
});
} else {
TwilioVoiceClient.endClientCall();
}
} finally {
markDismissed(callSid);
callsStore.dismissCall(callSid);
}
};
const dismissCall = callSid => {
markDismissed(callSid);
callsStore.dismissCall(callSid); 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 formattedCallDuration = computed(() => {
const total = globalCallDuration.value; const minutes = Math.floor(callDuration.value / 60);
const minutes = Math.floor(total / 60); const seconds = callDuration.value % 60;
const seconds = total % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}); });
return { return {
activeCall, activeCall,
incomingCalls, incomingCalls,
hasActiveCall, hasActiveCall,
isJoining: globalIsJoiningReadonly, isJoining,
formattedCallDuration, 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; const enterprisePlanName = config.enterprisePlanName;
/**
* Indicates whether inbox webhook events (ENABLE_INBOX_EVENTS) are enabled.
* @type {boolean}
*/
const inboxEventsEnabled = config.inboxEventsEnabled === 'true';
return { return {
hostURL, hostURL,
vapidPublicKey, vapidPublicKey,
enabledLanguages, enabledLanguages,
isEnterprise, isEnterprise,
enterprisePlanName, 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', COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search', ADVANCED_SEARCH: 'advanced_search',
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes', CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
}; };
export const PREMIUM_FEATURES = [ export const PREMIUM_FEATURES = [
@@ -4,27 +4,14 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation'; 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 { isImpersonating } = useImpersonation();
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
class ActionCableConnector extends BaseActionCableConnector { class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) { constructor(app, pubsubToken) {
const { websocketURL = '' } = window.chatwootConfig || {}; const { websocketURL = '' } = window.chatwootConfig || {};
super(app, pubsubToken, websocketURL); super(app, pubsubToken, websocketURL);
this.CancelTyping = []; this.CancelTyping = [];
this.lastUnreadCountsFetchAt = null;
this.unreadCountsFetchTimer = null;
this.events = { this.events = {
'message.created': this.onMessageCreated, 'message.created': this.onMessageCreated,
'message.updated': this.onMessageUpdated, 'message.updated': this.onMessageUpdated,
@@ -45,15 +32,9 @@ class ActionCableConnector extends BaseActionCableConnector {
'notification.updated': this.onNotificationUpdated, 'notification.updated': this.onNotificationUpdated,
'conversation.read': this.onConversationRead, 'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated, 'conversation.updated': this.onConversationUpdated,
'conversation.unread_count_changed':
this.onConversationUnreadCountChanged,
'account.cache_invalidated': this.onCacheInvalidate, 'account.cache_invalidated': this.onCacheInvalidate,
'account.enrichment_completed': this.onEnrichmentCompleted, 'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated, '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(); 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 }) => { onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id; 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('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team }); 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 { export default {
@@ -1,14 +1,14 @@
import { import {
MessageMarkdownSerializer,
MessageMarkdownTransformer,
messageSchema, messageSchema,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
Selection, Selection,
} from '@chatwoot/prosemirror-schema'; } from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils'; import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue'; import * as Sentry from '@sentry/vue';
import camelcaseKeys from 'camelcase-keys';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor'; import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox'; 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. * Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
@@ -1,6 +1,8 @@
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
// Quote detection strategies // Wrapper classes mail clients use around the quoted reply.
// Removing them depth-agnostically covers Gmail, Outlook, Yahoo, Thunderbird,
// ProtonMail, Apple Mail signatures, etc.
const QUOTE_INDICATORS = [ const QUOTE_INDICATORS = [
'.gmail_quote_container', '.gmail_quote_container',
'.gmail_quote', '.gmail_quote',
@@ -10,149 +12,216 @@ const QUOTE_INDICATORS = [
'.quote', '.quote',
'[class*="quote"]', '[class*="quote"]',
'[class*="Quote"]', '[class*="Quote"]',
'.moz-cite-prefix',
'.yahoo_quoted',
'#divRplyFwdMsg',
]; ];
const BLOCKQUOTE_FALLBACK_SELECTOR = 'blockquote'; // Full-line forwarded markers — anchored so prose can't false-trigger.
const HARD_HEADERS = [
// Regex patterns for quote identification /^\s*-+\s*Original Message\s*-+\s*$/im,
const QUOTE_PATTERNS = [ /^\s*-+\s*Forwarded message\s*-+\s*$/im,
/On .* wrote:/i, /^\s*Begin forwarded message:\s*$/im,
/-----Original Message-----/i,
/Sent: /i,
/From: /i,
]; ];
const ATTRIBUTION = /^On .* wrote:/im;
// One Outlook header field. Block needs >= 2 such lines to count, so prose
// like "From: now on, please …" can't false-trigger.
const HEADER_LINE = /^(?:From|Sent|To|Cc|Bcc|Date|Subject):\s/im;
export class EmailQuoteExtractor { const BLOCK_SELECTOR = 'div, p, blockquote, section';
/** const TEXT = 3; // Node.TEXT_NODE
* Remove quotes from email HTML and return cleaned HTML const ELEM = 1; // Node.ELEMENT_NODE
* @param {string} htmlContent - Full HTML content of the email
* @returns {string} HTML content with quotes removed
*/
static extractQuotes(htmlContent) {
// Create a temporary DOM element to parse HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
// Remove elements matching class selectors // `<br>` and whitespace-only text — sit inside a tail, never start one.
QUOTE_INDICATORS.forEach(selector => { const isNeutral = n =>
tempDiv.querySelectorAll(selector).forEach(el => { (n.nodeType === TEXT && !n.textContent.trim()) ||
el.remove(); (n.nodeType === ELEM && n.tagName === 'BR');
});
});
this.removeTrailingBlockquote(tempDiv); // Element text with `<br>` rendered as `\n`, so line-anchored regexes match
// shapes like `<p>From: Sam<br>Sent: Wed</p>`.
const blockText = el => {
const tmp = document.createElement('div');
tmp.innerHTML = el.innerHTML.replaceAll(/<br\s*\/?>/gi, '\n');
return tmp.textContent;
};
// Remove text-based quotes const nodeText = n => {
const textNodeQuotes = this.findTextNodeQuotes(tempDiv); if (n.nodeType === TEXT) return n.textContent;
textNodeQuotes.forEach(el => { if (n.nodeType === ELEM) return blockText(n);
el.remove(); return '';
}); };
return tempDiv.innerHTML; // Walk back over leading neutrals so the cut sits at the boundary.
const walkBack = (kids, idx) => {
let i = idx;
while (i > 0 && isNeutral(kids[i - 1])) i -= 1;
return i;
};
const countHeaderLines = t =>
t.split('\n').filter(l => HEADER_LINE.test(l)).length;
const isSoftHeader = t => ATTRIBUTION.test(t) || countHeaderLines(t) >= 2;
const isHardHeader = t => HARD_HEADERS.some(re => re.test(t));
// Find blocks matching `predicate`. Default keeps innermost so outer wrappers
// matching via bubbled-up text don't take the user's reply with them. `outer`
// keeps outermost — for hard markers, where forwarded body lives in the
// wrapper's later siblings.
const findBlocks = (root, predicate, { outer = false } = {}) => {
const all = [...root.querySelectorAll(BLOCK_SELECTOR)].filter(el =>
predicate(blockText(el))
);
return outer
? all.filter(el => !all.some(o => o !== el && o.contains(el)))
: all.filter(el => !all.some(o => o !== el && el.contains(o)));
};
// Strip from the first child whose text matches `marker` (skipping leading
// neutrals), then remove every sibling after `block` — that's where the
// original-message body lives on forwarded layouts. Drop block if empty.
const cutBlockAtMarker = (block, marker) => {
const kids = [...block.childNodes];
const idx = kids.findIndex(c => marker(nodeText(c)));
const from = idx === -1 ? 0 : walkBack(kids, idx);
kids.slice(from).forEach(c => c.remove());
while (block.nextSibling) block.nextSibling.remove();
if (!block.childNodes.length) block.remove();
};
// Nearest enclosing `<blockquote>` (including `block` itself), or null.
const findEnclosingBlockquote = (block, root) => {
let cur = block;
while (cur && cur !== root) {
if (cur.tagName === 'BLOCKQUOTE') return cur;
cur = cur.parentElement;
} }
return null;
};
/** // Walk up while `block` is the first substantive child of its parent.
* Check if HTML content contains any quotes // Promotes the cut to the wrapper, so a divider `<div>` plus body siblings
* @param {string} htmlContent - Full HTML content of the email // AFTER it strip together.
* @returns {boolean} True if quotes are detected, false otherwise const expandToWrapper = (block, root) => {
*/ let cur = block;
static hasQuotes(htmlContent) { while (cur.parentElement && cur.parentElement !== root) {
const tempDiv = document.createElement('div'); const kids = [...cur.parentElement.childNodes];
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent); const before = kids.slice(0, kids.indexOf(cur));
if (before.some(c => !isNeutral(c) && c.textContent.trim())) break;
cur = cur.parentElement;
}
return cur;
};
// Check for class-based quotes // Every visible line of the text node begins with `>`.
// eslint-disable-next-line no-restricted-syntax const isRfcQuoted = n =>
for (const selector of QUOTE_INDICATORS) { n.nodeType === TEXT &&
if (tempDiv.querySelector(selector)) { !!n.textContent.trim() &&
return true; n.textContent
.split('\n')
.filter(l => l.trim())
.every(l => l.trim().startsWith('>'));
// Top-level (text + <br>, no block wrapper) tail-start index. RFC `>` only
// fires when every following node is `>`-quoted or neutral. A header-line
// text node needs the joined tail to carry >= 2 header lines.
const findTopLevelTailStart = root => {
const kids = [...root.childNodes];
const tailText = i =>
kids
.slice(i)
.map(n => {
if (n.nodeType === TEXT) return n.textContent;
if (n.nodeType !== ELEM) return '';
return n.tagName === 'BR' ? '\n' : blockText(n);
})
.join('');
const idx = kids.findIndex((n, i) => {
if (isRfcQuoted(n))
return kids.slice(i).every(c => isRfcQuoted(c) || isNeutral(c));
if (isNeutral(n)) return false;
const t = nodeText(n);
if (!t.trim()) return false;
// Trigger must be the FIRST non-empty line — a buried header inside
// a multiline node would otherwise drop the reply above it.
const probe = (t.split('\n').find(l => l.trim()) ?? '').trim();
if (HARD_HEADERS.some(re => re.test(probe)) || ATTRIBUTION.test(probe)) {
// No sibling content above → could be bottom-posted. Fire only when
// both the lines BELOW the trigger inside this node AND every
// following sibling are `>`-quoted or empty. Otherwise leave alone.
if (kids.slice(0, i).every(isNeutral)) {
const probeIdx = t.split('\n').findIndex(l => l.trim());
const tailHereOk = t
.split('\n')
.slice(probeIdx + 1)
.every(l => !l.trim() || l.trim().startsWith('>'));
return (
tailHereOk &&
kids.slice(i + 1).every(c => isRfcQuoted(c) || isNeutral(c))
);
} }
}
if (this.findTrailingBlockquote(tempDiv)) {
return true; return true;
} }
return HEADER_LINE.test(probe) && countHeaderLines(tailText(i)) >= 2;
});
return idx === -1 ? -1 : walkBack(kids, idx);
};
// Check for text-based quotes // Five additive strategies, each independent. Run in order.
const textNodeQuotes = this.findTextNodeQuotes(tempDiv); const apply = root => {
return textNodeQuotes.length > 0; // 1. Strip every known quote-wrapper class.
} root.querySelectorAll(QUOTE_INDICATORS.join(',')).forEach(el => el.remove());
// 2. Hard markers — cut at the outer wrapper so its later siblings (the
/** // forwarded body) go with it.
* Find text nodes that match quote patterns findBlocks(root, isHardHeader, { outer: true }).forEach(b =>
* @param {Element} rootElement - Root element to search cutBlockAtMarker(b, isHardHeader)
* @returns {Element[]} Array of parent block elements containing quote-like text );
*/ // 3. Trailing <blockquote> as the last top-level child.
static findTextNodeQuotes(rootElement) { if (root.lastElementChild?.matches?.('blockquote'))
const quoteBlocks = []; root.lastElementChild.remove();
const treeWalker = document.createTreeWalker( // 4. Soft headers. Match inside a <blockquote> → remove that blockquote
rootElement, // (Apple Mail wraps attribution + body together). Match inside a nested
NodeFilter.SHOW_TEXT, // outer wrapper (WordSection1 shape) → hard-cut at the wrapper. At root,
null, // strip trailing siblings only when reply content sits ABOVE the trigger
false // — header-first at root could be a bottom-posted reply, so leave it.
); findBlocks(root, isSoftHeader).forEach(block => {
const bq = findEnclosingBlockquote(block, root);
for ( if (bq) return bq.remove();
let currentNode = treeWalker.nextNode(); const marker = t => HEADER_LINE.test(t) || ATTRIBUTION.test(t);
currentNode !== null; const cutPoint = expandToWrapper(block, root);
currentNode = treeWalker.nextNode() if (cutPoint !== block && cutPoint.parentElement !== root) {
) { return cutBlockAtMarker(cutPoint, marker);
const isQuoteLike = QUOTE_PATTERNS.some(pattern =>
pattern.test(currentNode.textContent)
);
if (isQuoteLike) {
const parentBlock = this.findParentBlock(currentNode);
if (parentBlock && !quoteBlocks.includes(parentBlock)) {
quoteBlocks.push(parentBlock);
}
}
} }
const kids = [...root.childNodes];
const hasPrecedingContent = kids
.slice(0, kids.indexOf(block))
.some(n => !isNeutral(n) && n.textContent.trim());
if (hasPrecedingContent) return cutBlockAtMarker(block, marker);
return block.remove();
});
// 5. Top-level RFC `>` / header tail.
const start = findTopLevelTailStart(root);
if (start !== -1) [...root.childNodes].slice(start).forEach(n => n.remove());
};
return quoteBlocks; const parse = html => {
const root = document.createElement('div');
root.innerHTML = DOMPurify.sanitize(html);
return root;
};
export class EmailQuoteExtractor {
/** Strip the quoted-reply tail and return cleaned HTML. */
static extractQuotes(html) {
const root = parse(html);
apply(root);
return root.innerHTML;
} }
/** /** True when any strategy would strip something. */
* Find the closest block-level parent element by recursively traversing up the DOM tree. static hasQuotes(html) {
* This method searches for common block-level elements like DIV, P, BLOCKQUOTE, and SECTION const root = parse(html);
* that contain the text node. It's used to identify and remove entire block-level elements const before = root.innerHTML;
* that contain quote-like text, rather than just removing the text node itself. This ensures apply(root);
* proper structural removal of quoted content while maintaining HTML integrity. return root.innerHTML !== before;
* @param {Node} node - Starting node to find parent
* @returns {Element|null} Block-level parent element
*/
static findParentBlock(node) {
const blockElements = ['DIV', 'P', 'BLOCKQUOTE', 'SECTION'];
let current = node.parentElement;
while (current) {
if (blockElements.includes(current.tagName)) {
return current;
}
current = current.parentElement;
}
return null;
}
/**
* Remove fallback blockquote if it is the last top-level element.
* @param {Element} rootElement - Root element containing the HTML
*/
static removeTrailingBlockquote(rootElement) {
const trailingBlockquote = this.findTrailingBlockquote(rootElement);
trailingBlockquote?.remove();
}
/**
* Locate a fallback blockquote that is the last top-level element.
* @param {Element} rootElement - Root element containing the HTML
* @returns {Element|null} The trailing blockquote element if present
*/
static findTrailingBlockquote(rootElement) {
const lastElement = rootElement.lastElementChild;
if (lastElement?.matches?.(BLOCKQUOTE_FALLBACK_SELECTOR)) {
return lastElement;
}
return null;
} }
} }
+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) // Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = { export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio', TWILIO: 'twilio',
WHATSAPP: 'whatsapp',
}; };
export const getVoiceCallProvider = inbox => { export const getVoiceCallProvider = inbox => {
@@ -26,11 +25,9 @@ export const getVoiceCallProvider = inbox => {
const channelType = inbox.channel_type || inbox.channelType; const channelType = inbox.channel_type || inbox.channelType;
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled; const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
if (!voiceEnabled) return null; if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
return VOICE_CALL_PROVIDERS.TWILIO;
if (channelType === INBOX_TYPES.TWILIO) return VOICE_CALL_PROVIDERS.TWILIO; }
if (channelType === INBOX_TYPES.WHATSAPP)
return VOICE_CALL_PROVIDERS.WHATSAPP;
return null; 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'; import ActionCableConnector from '../actionCable';
vi.mock('shared/helpers/mitt', () => ({ vi.mock('shared/helpers/mitt', () => ({
@@ -30,17 +30,12 @@ describe('ActionCableConnector - Copilot Tests', () => {
dispatch: mockDispatch, dispatch: mockDispatch,
getters: { getters: {
getCurrentAccountId: 1, getCurrentAccountId: 1,
'accounts/isFeatureEnabledonAccount': vi.fn(() => true),
}, },
}, },
}; };
actionCable = ActionCableConnector.init(store.$store, 'test-token'); actionCable = ActionCableConnector.init(store.$store, 'test-token');
}); });
afterEach(() => {
vi.useRealTimers();
});
describe('copilot event handlers', () => { describe('copilot event handlers', () => {
it('should register the copilot.message.created event handler', () => { it('should register the copilot.message.created event handler', () => {
expect(Object.keys(actionCable.events)).toContain( 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);
});
});
}); });
@@ -1,26 +1,27 @@
import { EditorState, EditorView } from '@chatwoot/prosemirror-schema';
import { FORMATTING } from 'dashboard/constants/editor';
import { Schema } from 'prosemirror-model';
import { import {
appendSignature,
calculateMenuPosition,
cleanSignature,
collapseSelection,
extractTextFromMarkdown,
findNodeToInsertImage,
findSignatureInBody, findSignatureInBody,
getContentNode, appendSignature,
getFormattingForEditor,
getMenuAnchor,
getSelectionCoords,
insertAtCursor,
removeSignature, removeSignature,
replaceSignature, replaceSignature,
setURLWithQueryAndSize, cleanSignature,
stripInlineBase64Images, extractTextFromMarkdown,
stripUnsupportedFormatting,
stripUnsupportedMarkdown, stripUnsupportedMarkdown,
insertAtCursor,
findNodeToInsertImage,
setURLWithQueryAndSize,
getContentNode,
getFormattingForEditor,
getSelectionCoords,
getMenuAnchor,
calculateMenuPosition,
stripUnsupportedFormatting,
stripInlineBase64Images,
collapseSelection,
} from '../editorHelper'; } from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema';
import { EditorView } from '@chatwoot/prosemirror-schema';
import { Schema } from 'prosemirror-model';
// Define a basic ProseMirror schema // Define a basic ProseMirror schema
const schema = new Schema({ const schema = new Schema({
@@ -1,4 +1,3 @@
import { describe, it, expect } from 'vitest';
import { EmailQuoteExtractor } from '../emailQuoteExtractor.js'; import { EmailQuoteExtractor } from '../emailQuoteExtractor.js';
const SAMPLE_EMAIL_HTML = ` const SAMPLE_EMAIL_HTML = `
@@ -43,35 +42,31 @@ const EMAIL_WITH_FOLLOW_UP_CONTENT = `
<p>Regards,</p> <p>Regards,</p>
`; `;
// Regression coverage for the quote-toggle rewrite shipped on this branch.
// Parse cleaned HTML into a container so each test can assert against
// .textContent or query nested nodes.
const cleaned = html => {
const c = document.createElement('div');
c.innerHTML = EmailQuoteExtractor.extractQuotes(html);
return c;
};
describe('EmailQuoteExtractor', () => { describe('EmailQuoteExtractor', () => {
it('removes blockquote-based quotes from the email body', () => { it('removes blockquote-based quotes from the email body', () => {
const cleanedHtml = EmailQuoteExtractor.extractQuotes(SAMPLE_EMAIL_HTML); const c = cleaned(SAMPLE_EMAIL_HTML);
expect(c.querySelectorAll('blockquote').length).toBe(0);
const container = document.createElement('div'); expect(c.textContent?.trim()).toBe('method');
container.innerHTML = cleanedHtml; expect(c.textContent).not.toContain('On Mon, Sep 29, 2025 at 5:18 PM');
expect(container.querySelectorAll('blockquote').length).toBe(0);
expect(container.textContent?.trim()).toBe('method');
expect(container.textContent).not.toContain(
'On Mon, Sep 29, 2025 at 5:18 PM'
);
}); });
it('keeps blockquote fallback when it is not the last top-level element', () => { it('keeps blockquote fallback when it is not the last top-level element', () => {
const cleanedHtml = EmailQuoteExtractor.extractQuotes( const c = cleaned(EMAIL_WITH_FOLLOW_UP_CONTENT);
EMAIL_WITH_FOLLOW_UP_CONTENT expect(c.querySelector('blockquote')).not.toBeNull();
); expect(c.lastElementChild?.tagName).toBe('P');
const container = document.createElement('div');
container.innerHTML = cleanedHtml;
expect(container.querySelector('blockquote')).not.toBeNull();
expect(container.lastElementChild?.tagName).toBe('P');
}); });
it('detects quote indicators in nested blockquotes', () => { it('detects quote indicators in nested blockquotes', () => {
const result = EmailQuoteExtractor.hasQuotes(SAMPLE_EMAIL_HTML); expect(EmailQuoteExtractor.hasQuotes(SAMPLE_EMAIL_HTML)).toBe(true);
expect(result).toBe(true);
}); });
it('does not flag blockquotes that are followed by other elements', () => { it('does not flag blockquotes that are followed by other elements', () => {
@@ -81,16 +76,14 @@ describe('EmailQuoteExtractor', () => {
}); });
it('returns false when no quote indicators are present', () => { it('returns false when no quote indicators are present', () => {
const html = '<p>Plain content</p>'; expect(EmailQuoteExtractor.hasQuotes('<p>Plain content</p>')).toBe(false);
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
}); });
it('removes trailing blockquotes while preserving trailing signatures', () => { it('removes trailing blockquotes while preserving trailing signatures', () => {
const cleanedHtml = EmailQuoteExtractor.extractQuotes(EMAIL_WITH_SIGNATURE); const c = cleaned(EMAIL_WITH_SIGNATURE);
expect(c.querySelector('blockquote')).toBeNull();
expect(cleanedHtml).toContain('<p>Thanks,</p>'); expect(c.textContent).toContain('Thanks,');
expect(cleanedHtml).toContain('<p>Jane Doe</p>'); expect(c.textContent).toContain('Jane Doe');
expect(cleanedHtml).not.toContain('<blockquote');
}); });
it('detects quotes for trailing blockquotes even when signatures follow text', () => { it('detects quotes for trailing blockquotes even when signatures follow text', () => {
@@ -150,4 +143,352 @@ describe('EmailQuoteExtractor', () => {
expect(cleanedHtml).not.toContain('eval'); expect(cleanedHtml).not.toContain('eval');
}); });
}); });
describe('client wrappers', () => {
it('Gmail — strips .gmail_quote_container with attribution + blockquote', () => {
const html = `<div dir="ltr"><p>Dear Sam,</p><p>Thank you for the quotation.</p><p>Best,<br>Alex</p></div><br><div class="gmail_quote gmail_quote_container"><div class="gmail_attr">On Wed, 4 Dec 2024 at 17:15, Sam wrote:<br></div><blockquote class="gmail_quote"><p>Dear Alex,</p><p>Thank you for your inquiry.</p></blockquote></div>`;
const c = cleaned(html);
expect(c.textContent).toContain('Dear Sam');
expect(c.textContent).not.toContain('Thank you for your inquiry');
expect(c.querySelector('.gmail_quote')).toBeNull();
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
});
it('Outlook — strips #divRplyFwdMsg header AND the trailing bare blockquote', () => {
const html = `<p>Hi team,</p><p>Regards,<br>Pat</p><div id="divRplyFwdMsg"><b>From:</b> Sam<br><b>Sent:</b> Wed Dec 4, 2024<br><b>To:</b> Pat<br><b>Subject:</b> Quotation</div><blockquote><p>Hi Pat,</p><p>Quotation attached.</p></blockquote>`;
const c = cleaned(html);
expect(c.textContent).toContain('Hi team');
expect(c.textContent).not.toContain('From: Sam');
expect(c.textContent).not.toContain('Quotation attached');
expect(c.querySelector('blockquote')).toBeNull();
expect(c.querySelector('#divRplyFwdMsg')).toBeNull();
});
it('Yahoo — strips .yahoo_quoted wrapper', () => {
const html = `<div>My reply text here.</div><div class="yahoo_quoted"><div>On Wed, Dec 4, 2024, Sam wrote:</div><div>Original Yahoo-quoted message body.</div></div>`;
const c = cleaned(html);
expect(c.textContent).toContain('My reply text here');
expect(c.textContent).not.toContain('Original Yahoo-quoted message');
expect(c.querySelector('.yahoo_quoted')).toBeNull();
});
it('Thunderbird — strips .moz-cite-prefix attribution AND <blockquote type="cite">', () => {
const html = `<p>My reply.</p><p class="moz-cite-prefix">On 4/12/24 17:15, Sam wrote:</p><blockquote type="cite"><p>Original Thunderbird-quoted message body.</p></blockquote>`;
const c = cleaned(html);
expect(c.textContent).toContain('My reply');
expect(c.textContent).not.toContain('On 4/12/24 17:15');
expect(c.textContent).not.toContain(
'Original Thunderbird-quoted message'
);
expect(c.querySelector('blockquote')).toBeNull();
expect(c.querySelector('.moz-cite-prefix')).toBeNull();
});
});
describe('hard markers', () => {
it('Gmail "---------- Forwarded message ----------" block is stripped', () => {
const html = `<div>FYI — see the original below.<div class="gmail_quote"><div>---------- Forwarded message ---------<br>From: Sam<br>Date: Wed, 4 Dec 2024<br>Subject: Quotation<br>To: Pat</div><div>Original forwarded body content.</div></div></div>`;
const c = cleaned(html);
expect(c.textContent).toContain('FYI — see the original below');
expect(c.textContent).not.toContain('Forwarded message');
expect(c.textContent).not.toContain('Original forwarded body content');
});
it('Outlook plain-style "-----Original Message-----" header is stripped', () => {
const html = `<p>Quick reply.</p><p>-----Original Message-----<br>From: Sam<br>Sent: Wed Dec 4, 2024<br>To: Pat<br>Subject: Quotation</p><p>Original Outlook plain-style reply.</p>`;
const c = cleaned(html);
expect(c.textContent).toContain('Quick reply');
expect(c.textContent).not.toContain('Original Message');
expect(c.textContent).not.toContain('Original Outlook plain-style reply');
});
it('preserves reply text that sits before a hard marker in the SAME block', () => {
const html =
'<div>My reply<br><br>-----Original Message-----<br>From: Sam<br>Old body</div>';
const c = cleaned(html);
expect(c.textContent).toContain('My reply');
expect(c.textContent).not.toContain('Original Message');
expect(c.textContent).not.toContain('Old body');
});
it('does not strip when "Original Message" appears inside a sentence', () => {
const html =
'<p>The bug ticket says the markdown for `-----Original Message-----` should render.</p><p>Here is my fix.</p>';
expect(cleaned(html).textContent).toContain('Here is my fix');
});
it('does not strip when "Original Message" sits inside <code> mid-paragraph', () => {
const html =
'<p>Hey Sam,</p><p>The markdown for <code>-----Original Message-----</code> should render.</p><p>Tested locally.</p><p>Pat</p>';
const c = cleaned(html);
expect(c.textContent).toContain('Hey Sam');
expect(c.textContent).toContain('Tested locally');
expect(c.textContent).toContain('Pat');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
});
it('cuts wrapper-level siblings when the marker is in a nested child', () => {
// Outlook nested shape: marker sits in an inner block, but the actual
// forwarded body lives in later siblings of the outer wrapper. Strategy
// 2 selects the outer match so the wrapper's later siblings strip too.
const html =
'<div>' +
'<p>My reply.</p>' +
'<div class="OutlookHeader"><p>-----Original Message-----</p></div>' +
'<p>From: Sam</p>' +
'<p>Old body</p>' +
'</div>';
const c = cleaned(html);
expect(c.textContent).toContain('My reply');
expect(c.textContent).not.toContain('Original Message');
expect(c.textContent).not.toContain('From: Sam');
expect(c.textContent).not.toContain('Old body');
});
it('treats hard markers as absolute boundaries — sibling tail is always quoted body', () => {
// Hard markers (`-----Original Message-----` etc.) are explicit,
// anchored boundaries: by convention everything after is the original.
// Strategy 4 (soft headers) preserves following siblings because
// `From:` can appear in prose; strategy 2 deliberately does not. Locks
// down the asymmetry against future "preserve bottom-post" rewrites.
const html =
'<p>-----Original Message-----</p>' +
'<p>From: Sam</p>' +
'<p>Old quoted body</p>' +
'<p>Reply pretending to be bottom-posted</p>';
const c = cleaned(html);
expect(c.textContent).not.toContain('Original Message');
expect(c.textContent).not.toContain('From: Sam');
expect(c.textContent).not.toContain('Old quoted body');
expect(c.textContent).not.toContain(
'Reply pretending to be bottom-posted'
);
});
});
describe('multi-line attribution headers (From / Sent / To)', () => {
it('detects header inside a single outer wrapper <div>', () => {
const html =
'<div><p>My reply.</p><p>-----Original Message-----<br>From: Sam<br>Sent: ...</p><p>Old body</p></div>';
const c = cleaned(html);
expect(c.textContent).toContain('My reply');
expect(c.textContent).not.toContain('Original Message');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
});
it('detects "From:/Sent:" header even when followed by un-prefixed old lines', () => {
const html =
'<p>Reply text.</p><p>From: Sam<br>Sent: Wed Dec 4, 2024</p><p>Old line 1</p>';
const c = cleaned(html);
expect(c.textContent).toContain('Reply text');
expect(c.textContent).not.toContain('From: Sam');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
});
it('detects minimal "From: name + Sent: weekday" header (no @, no year)', () => {
const html =
'<p>Reply text.</p><p>From: Sam<br>Sent: Wednesday<br>To: Pat<br>Subject: Re: foo</p><p>Old body</p>';
const c = cleaned(html);
expect(c.textContent).toContain('Reply text');
expect(c.textContent).not.toContain('From: Sam');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
});
it('detects header buried in a deep wrapper (Outlook WordSection1)', () => {
const html = `
<div class="WordSection1">
<p>Pat please look into this.</p>
<p>Thanks,<br>Sam</p>
<div style="border-top:solid #E1E1E1 1.0pt">
<p><b>From:</b> Maya<br><b>Sent:</b> Wed, 4 Dec<br><b>To:</b> Sam<br><b>Subject:</b> Customer escalation</p>
</div>
<p>Sam, Acme Corp is threatening to churn.</p>
</div>
`;
const c = cleaned(html);
expect(c.textContent).toContain('Pat — please look into this');
expect(c.textContent).not.toContain('From: Maya');
expect(c.textContent).not.toContain('Subject: Customer escalation');
});
it('strips a flat header at root but keeps the body visible (bottom-post safety)', () => {
const html =
'<p>Confirming I received this — will review tomorrow.</p>' +
'<p>Thanks,<br>Pat</p>' +
'<p>From: Sam<br>Sent: Wed Dec 4, 2024<br>To: Pat<br>Subject: Quotation</p>' +
'<p>Hi Pat,<br>Quotation attached.<br>Sam</p>';
const c = cleaned(html);
expect(c.textContent).toContain('Confirming I received this');
expect(c.textContent).toContain('Thanks,');
expect(c.textContent).not.toContain('From: Sam');
});
it('preserves a bottom-posted reply that follows a flat header block at root', () => {
const html =
'<p>From: Sam<br>Sent: Wed Dec 4, 2024<br>To: Pat<br>Subject: foo</p>' +
'<p>Hi Pat, original message body.</p>' +
'<p>--- My reply below ---</p>' +
'<p>Got it, thanks!</p>';
const c = cleaned(html);
expect(c.textContent).toContain('Got it, thanks');
expect(c.textContent).toContain('My reply below');
expect(c.textContent).not.toContain('From: Sam');
});
it('strips Apple-Mail blockquote (attribution + body inside one <blockquote type="cite">)', () => {
const html =
'<div>Sounds good, see you Friday.</div>' +
'<div><blockquote type="cite">' +
'<div>On Apr 6, 2026, at 11:26 AM, Sam wrote:</div>' +
'<div><div>Hi Pat,</div><div>Locking the Friday slot.</div></div>' +
'</blockquote></div>';
const c = cleaned(html);
expect(c.textContent).toContain('Sounds good');
expect(c.textContent).not.toContain('On Apr 6, 2026');
expect(c.textContent).not.toContain('Locking the Friday slot');
});
it('preserves user reply that follows a soft-header <blockquote>', () => {
const html =
'<blockquote>On Mon, Sep 22, Sam wrote:<br>Original quoted line.</blockquote><p>My actual reply.</p>';
expect(cleaned(html).textContent).toContain('My actual reply');
});
it('preserves user reply that follows a wrapper containing a soft-header block', () => {
const html =
'<div><blockquote>On Mon, Sam wrote:</blockquote></div><p>My reply outside the wrapper.</p>';
expect(cleaned(html).textContent).toContain(
'My reply outside the wrapper'
);
});
it('does not strip prose paragraphs that start with "From: " or "Sent: "', () => {
const fromHtml =
'<p>From: now on, please follow this checklist.</p><p>This is regular content.</p>';
expect(cleaned(fromHtml).textContent).toContain('From: now on');
expect(cleaned(fromHtml).textContent).toContain('regular content');
const sentHtml =
'<p>Sent: yesterday by the courier.</p><p>Tracking number to follow.</p>';
expect(cleaned(sentHtml).textContent).toContain('Sent: yesterday');
expect(cleaned(sentHtml).textContent).toContain('Tracking number');
});
});
describe('single-line "On … wrote:" attribution', () => {
it('detects header even when followed by un-prefixed old lines', () => {
const html =
'<p>On Wed, Sam wrote:</p><p>Old line 1</p><p>Old line 2</p>';
const c = cleaned(html);
expect(c.textContent).not.toContain('On Wed, Sam wrote');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
});
});
describe('top-level RFC `>` and header tail (text + <br>)', () => {
it('iPhone Mail — strips RFC `>` quoted lines from a plain-text only body', () => {
const html = [
'Test payments email<br><br>',
'Thanks,<br>Shruthi<br><br>',
'Sent from my iPhone<br><br>',
'&gt; On Apr 6, 2026, at 11:26 PM, Shruthi wrote:<br>',
'&gt; <br>&gt; Hi email<br>&gt; To Eli<br>',
'&gt; Thanks,<br>&gt; Shruthi',
].join('');
const c = cleaned(html);
expect(c.textContent).toContain('Test payments email');
expect(c.textContent).toContain('Sent from my iPhone'); // signature stays
expect(c.textContent).not.toContain('On Apr 6, 2026');
expect(c.textContent).not.toContain('Hi email');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
});
it('strips both plain-text `>` lines and HTML quote blocks in the same body', () => {
const html = `<p>My HTML reply</p>
<p>Sivin</p>
<br>
&gt; On Mon, Apr 6, 2026, Shruthi wrote:<br>
&gt; Inline plain-text quote line<br>
<div class="gmail_quote">
<p>The original HTML quoted email</p>
</div>`;
const c = cleaned(html);
expect(c.textContent).toContain('My HTML reply');
expect(c.textContent).toContain('Sivin');
expect(c.textContent).not.toContain('Inline plain-text quote line');
expect(c.textContent).not.toContain('The original HTML quoted email');
expect(c.querySelectorAll('.gmail_quote').length).toBe(0);
});
it('detects top-level "On … wrote:" header (no wrapper)', () => {
const html =
'Reply text<br><br>On Tue, Pat wrote:<br>Original line 1<br>Original line 2';
const c = cleaned(html);
expect(c.textContent).toContain('Reply text');
expect(c.textContent).not.toContain('On Tue, Pat wrote');
expect(c.textContent).not.toContain('Original line 1');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
});
it('detects top-level "From:/Sent:" header (no wrapper)', () => {
const html =
'Reply text<br>From: Sam<br>Sent: Wed Dec 4, 2024<br>Original body';
const c = cleaned(html);
expect(c.textContent).toContain('Reply text');
expect(c.textContent).not.toContain('From: Sam');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
});
it('detects top-level "-----Original Message-----" (no wrapper)', () => {
const html = 'Reply<br>-----Original Message-----<br>From: Sam<br>Body';
const c = cleaned(html);
expect(c.textContent).toContain('Reply');
expect(c.textContent).not.toContain('Original Message');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
});
it('preserves user reply that is bottom-posted below `>`-quoted lines', () => {
const html =
'&gt; On Tue, Pat wrote:<br>&gt; Attached is the doc.<br>&gt; Pat<br><br>Got it, looks good.';
expect(cleaned(html).textContent).toContain('Got it, looks good');
});
it('preserves user answers inline-posted between `>`-quoted lines', () => {
const html =
'&gt; Q1: pricing?<br>A1: USD 100<br>&gt; Q2: timeline?<br>A2: 2 weeks<br><br>Thanks!';
const c = cleaned(html);
expect(c.textContent).toContain('A1: USD 100');
expect(c.textContent).toContain('A2: 2 weeks');
expect(c.textContent).toContain('Thanks!');
});
});
describe('inline / no-quote bodies', () => {
it('inline reply — when content follows the quoted block, body is left intact', () => {
const html =
'<p>See my responses inline below.</p><blockquote><p>Q1: pricing?</p><p>A1: usd 100.</p></blockquote><p>Let me know if any of that needs clarification.</p><p>Pat</p>';
const c = cleaned(html);
expect(c.textContent).toContain('See my responses inline below');
expect(c.textContent).toContain('Q1: pricing?');
expect(c.textContent).toContain(
'Let me know if any of that needs clarification'
);
expect(c.querySelector('blockquote')).not.toBeNull();
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
});
it('plain body with no quotes — body unchanged, no toggle', () => {
const html =
'<p>Just checking in — any update on this?</p><p>Thanks,<br>Pat</p>';
const c = cleaned(html);
expect(c.textContent).toContain('Just checking in');
expect(c.textContent).toContain('Thanks,');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
});
it('empty body — no error, no toggle', () => {
expect(() => EmailQuoteExtractor.extractQuotes('')).not.toThrow();
expect(EmailQuoteExtractor.hasQuotes('')).toBe(false);
});
});
}); });
+5 -83
View File
@@ -1,5 +1,4 @@
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants'; import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { useCallsStore } from 'dashboard/stores/calls'; import { useCallsStore } from 'dashboard/stores/calls';
import types from 'dashboard/store/mutation-types'; import types from 'dashboard/store/mutation-types';
@@ -46,66 +45,23 @@ const shouldShowCall = ({
return !isAssignedToAnotherAgent(assigneeId, currentUserId); return !isAssignedToAnotherAgent(assigneeId, currentUserId);
}; };
// Offline/busy agents shouldn't get a ringing popup for inbound calls, but
// outbound calls always belong to the initiator regardless of their status,
// and existing (already-surfaced) calls keep going so a status change
// mid-call doesn't yank away an active widget.
const shouldRingInbound = (callDirection, currentUserAvailability) => {
if (callDirection === 'outbound') return true;
return currentUserAvailability === 'online';
};
function extractCallerSnapshot(message) {
// Snapshot caller info from the message at add-time so the widget can keep
// rendering it after the user navigates away from a conversation list that
// had the conversation hydrated (and Vuex evicts it from the store).
// Only incoming messages carry the contact as the sender; on outbound calls
// the sender is the initiating agent, so skip the snapshot and let the widget
// fall back to the conversation's contact (conversation.meta.sender).
if (message?.message_type !== MESSAGE_TYPE.INCOMING) return null;
const sender = message?.sender;
if (!sender) return null;
return {
name: sender.name,
phone: sender.phone_number,
avatar: sender.avatar || sender.thumbnail,
additionalAttributes: sender.additional_attributes || {},
};
}
function extractCallData(message) { function extractCallData(message) {
const call = message?.call || {}; const call = message?.call || {};
return { return {
callSid: call.provider_call_id, callSid: call.provider_call_id,
callId: call.id,
provider: call.provider,
status: call.status, status: call.status,
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound', callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
conversationId: message?.conversation_id, conversationId: message?.conversation_id,
inboxId: message?.inbox_id ?? message?.conversation?.inbox_id,
assigneeId: extractAssigneeId(message?.conversation), assigneeId: extractAssigneeId(message?.conversation),
senderId: message?.sender?.id, senderId: message?.sender?.id,
caller: extractCallerSnapshot(message),
}; };
} }
export function handleVoiceCallCreated( export function handleVoiceCallCreated(message, currentUserId) {
message,
currentUserId,
currentUserAvailability
) {
if (!isVoiceCallMessage(message)) return; if (!isVoiceCallMessage(message)) return;
const { const { callSid, callDirection, conversationId, assigneeId, senderId } =
callSid, extractCallData(message);
callId,
provider,
callDirection,
conversationId,
inboxId,
assigneeId,
senderId,
} = extractCallData(message);
if ( if (
!shouldShowCall({ !shouldShowCall({
@@ -118,37 +74,23 @@ export function handleVoiceCallCreated(
return; return;
} }
if (!shouldRingInbound(callDirection, currentUserAvailability)) return;
const callsStore = useCallsStore(); const callsStore = useCallsStore();
callsStore.addCall({ callsStore.addCall({
callSid, callSid,
callId,
provider,
conversationId, conversationId,
inboxId,
callDirection, callDirection,
senderId, senderId,
caller: extractCallerSnapshot(message),
}); });
} }
export function handleVoiceCallUpdated( export function handleVoiceCallUpdated(commit, message, currentUserId) {
commit,
message,
currentUserId,
currentUserAvailability
) {
if (!isVoiceCallMessage(message)) return; if (!isVoiceCallMessage(message)) return;
const { const {
callSid, callSid,
callId,
provider,
status, status,
callDirection, callDirection,
conversationId, conversationId,
inboxId,
assigneeId, assigneeId,
senderId, senderId,
} = extractCallData(message); } = extractCallData(message);
@@ -176,17 +118,11 @@ export function handleVoiceCallUpdated(
} }
if (status === 'ringing') { if (status === 'ringing') {
if (!shouldRingInbound(callDirection, currentUserAvailability)) return;
callsStore.addCall({ callsStore.addCall({
callSid, callSid,
callId,
provider,
conversationId, conversationId,
inboxId,
callDirection, callDirection,
senderId, senderId,
caller: extractCallerSnapshot(message),
}); });
} }
} }
@@ -195,20 +131,6 @@ export function syncConversationCallVisibility(conversation, currentUserId) {
const assigneeId = extractAssigneeId(conversation); const assigneeId = extractAssigneeId(conversation);
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return; if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
// Outbound calls belong to the initiator regardless of who the conversation
// is currently assigned to (auto-assignment may flip mid-call). Mirror
// shouldShowCall's outbound exception so an in-progress outbound call isn't
// ripped out from under the caller when the conversation reassigns.
const callsStore = useCallsStore(); const callsStore = useCallsStore();
const callsToRemove = callsStore.calls.filter( callsStore.removeCallsForConversation(conversation.id);
call =>
call.conversationId === conversation.id &&
!shouldShowCall({
callDirection: call.callDirection,
senderId: call.senderId,
assigneeId,
currentUserId,
})
);
callsToRemove.forEach(call => callsStore.removeCall(call.callSid));
} }
@@ -27,13 +27,9 @@
}, },
"LABELS": { "LABELS": {
"ASSIGN_LABELS": "Assign labels", "ASSIGN_LABELS": "Assign labels",
"REMOVE_LABELS": "Remove labels",
"ASSIGN_SELECTED_LABELS": "Assign selected labels", "ASSIGN_SELECTED_LABELS": "Assign selected labels",
"REMOVE_SELECTED_LABELS": "Remove selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully.", "ASSIGN_SUCCESFUL": "Labels assigned successfully.",
"ASSIGN_FAILED": "Failed to assign labels. Please try again.", "ASSIGN_FAILED": "Failed to assign labels. Please try again."
"REMOVE_SUCCESFUL": "Labels removed successfully.",
"REMOVE_FAILED": "Failed to remove labels. Please try again."
}, },
"TEAMS": { "TEAMS": {
"NONE": "None", "NONE": "None",
@@ -20,8 +20,6 @@
"CALL": "Call", "CALL": "Call",
"CALL_INITIATED": "Calling the contact…", "CALL_INITIATED": "Calling the contact…",
"CALL_FAILED": "Unable to start the call. Please try again.", "CALL_FAILED": "Unable to start the call. Please try again.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"CLICK_TO_EDIT": "Click to edit", "CLICK_TO_EDIT": "Click to edit",
"VOICE_INBOX_PICKER": { "VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox" "TITLE": "Choose a voice inbox"
@@ -586,11 +584,8 @@
}, },
"CONTACTS_BULK_ACTIONS": { "CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Assign Labels", "ASSIGN_LABELS": "Assign Labels",
"REMOVE_LABELS": "Remove Labels",
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.", "ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels", "ASSIGN_LABELS_FAILED": "Failed to assign labels",
"REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
"REMOVE_LABELS_FAILED": "Failed to remove labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.", "DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.", "NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected", "SELECTED_COUNT": "{count} selected",
@@ -62,7 +62,6 @@
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.", "UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.", "UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
"UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.", "UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.",
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully", "SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again", "FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "No response", "NO_RESPONSE": "No response",
@@ -80,22 +79,13 @@
"OUTGOING_CALL": "Outgoing call", "OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress", "CALL_IN_PROGRESS": "Call in progress",
"NO_ANSWER": "No answer", "NO_ANSWER": "No answer",
"NO_ANSWER_OUTBOUND_LABEL": "No answer",
"NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up",
"MISSED_CALL": "Missed call", "MISSED_CALL": "Missed call",
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
"CALL_ENDED": "Call ended", "CALL_ENDED": "Call ended",
"HANDLED_BY": "Handled by {agentName}",
"NOT_ANSWERED_YET": "Not answered yet", "NOT_ANSWERED_YET": "Not answered yet",
"CALLING": "Calling…",
"THEY_ANSWERED": "They answered", "THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered", "YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered", "AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Join call", "JOIN_CALL": "Join call"
"CALL_BACK": "Call back",
"TRANSCRIPT_SHOW_MORE": "Show more",
"TRANSCRIPT_SHOW_LESS": "Show less"
}, },
"HEADER": { "HEADER": {
"RESOLVE_ACTION": "Resolve", "RESOLVE_ACTION": "Resolve",
@@ -110,12 +100,6 @@
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow", "SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week", "SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply", "SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
"WHATSAPP_CALL": "Start WhatsApp call",
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
"VOICE_CALL": "Start call",
"VOICE_CALL_FAILED": "Could not start the call.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"SLA_STATUS": { "SLA_STATUS": {
"FRT": "FRT {status}", "FRT": "FRT {status}",
"NRT": "NRT {status}", "NRT": "NRT {status}",
@@ -315,13 +299,8 @@
"NOT_ANSWERED_YET": "Not answered yet", "NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab", "HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject", "REJECT_CALL": "Reject",
"DISMISS_CALL": "Dismiss",
"JOIN_CALL": "Join call", "JOIN_CALL": "Join call",
"END_CALL": "End call", "END_CALL": "End call"
"MUTE": "Mute mic",
"UNMUTE": "Unmute mic",
"VIEW_CHAT_HISTORY": "View chat history",
"GO_TO_CONVERSATION": "Go to conversation thread"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {
@@ -607,12 +607,9 @@
"DRAFT": "Draft", "DRAFT": "Draft",
"ARCHIVE": "Archive", "ARCHIVE": "Archive",
"TRANSLATE": "Translate", "TRANSLATE": "Translate",
"MOVE_TO_CATEGORY": "Category",
"DELETE": "Delete", "DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully", "STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles", "STATUS_ERROR": "Failed to update articles",
"CATEGORY_SUCCESS": "Articles moved successfully",
"CATEGORY_ERROR": "Failed to move articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles", "DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.", "DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "Delete", "DELETE_CONFIRM": "Delete",
@@ -308,7 +308,6 @@
"AUTH_PROCESSING": "Authenticating with Meta", "AUTH_PROCESSING": "Authenticating with Meta",
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...", "WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
"PROCESSING": "Setting up your WhatsApp Business Account", "PROCESSING": "Setting up your WhatsApp Business Account",
"ENABLING_CALLING": "Enabling WhatsApp Calling on your number…",
"LOADING_SDK": "Loading Facebook SDK...", "LOADING_SDK": "Loading Facebook SDK...",
"CANCELLED": "WhatsApp Signup was cancelled", "CANCELLED": "WhatsApp Signup was cancelled",
"SUCCESS_TITLE": "WhatsApp Business Account Connected!", "SUCCESS_TITLE": "WhatsApp Business Account Connected!",
@@ -318,8 +317,7 @@
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.", "AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured", "SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow", "MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow", "MANUAL_LINK_TEXT": "manual setup flow"
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
}, },
"API": { "API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel" "ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
@@ -467,10 +465,6 @@
"TITLE": "WhatsApp", "TITLE": "WhatsApp",
"DESCRIPTION": "Support your customers on WhatsApp" "DESCRIPTION": "Support your customers on WhatsApp"
}, },
"WHATSAPP_CALL": {
"TITLE": "WhatsApp Call",
"DESCRIPTION": "Take voice calls on your WhatsApp number"
},
"EMAIL": { "EMAIL": {
"TITLE": "Email", "TITLE": "Email",
"DESCRIPTION": "Connect with Gmail, Outlook, or other providers" "DESCRIPTION": "Connect with Gmail, Outlook, or other providers"
@@ -643,8 +637,7 @@
"BOT_CONFIGURATION": "Bot Configuration", "BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health", "ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT", "CSAT": "CSAT",
"VOICE": "Voice", "VOICE": "Voice"
"CALLS": "Calls"
}, },
"VOICE_CONFIGURATION": { "VOICE_CONFIGURATION": {
"ENABLE_VOICE": { "ENABLE_VOICE": {
@@ -655,26 +648,6 @@
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections." "DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
} }
}, },
"WHATSAPP_CALLING": {
"ENABLE": {
"LABEL": "Enable WhatsApp Calling",
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
},
"ENABLE_FAILED": "Voice calling couldn't be turned on for this number — it isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then try again.",
"PHONE_NUMBER": {
"LABEL": "Business phone number",
"HELP_TEXT": "WhatsApp number that customers will call."
},
"HOW_IT_WORKS": {
"LABEL": "How it works",
"DESCRIPTION": "Calls are placed peer-to-peer between the agent's browser and Meta — no extra credentials are required. Make sure the agent's browser has microphone permission for this site."
},
"PERMISSION_REQUEST_BODY": {
"LABEL": "Call permission request message",
"HELP_TEXT": "Shown to the contact when they haven't yet consented to receive calls. Leave blank to use the default.",
"PLACEHOLDER": "We would like to call you regarding your conversation."
}
},
"CHANNEL_PREFERENCES": "Channel Preferences", "CHANNEL_PREFERENCES": "Channel Preferences",
"WIDGET_FEATURES": "Widget features", "WIDGET_FEATURES": "Widget features",
"ACCOUNT_HEALTH": { "ACCOUNT_HEALTH": {
@@ -827,10 +800,6 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.", "WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates", "WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.", "WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
"WHATSAPP_CALLING_ENABLED": {
"LABEL": "Enable voice calling",
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
},
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings" "UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
}, },
"HELP_CENTER": { "HELP_CENTER": {

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