Compare commits
151
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac4b801930 | ||
|
|
4b2bbd38e7 | ||
|
|
2f36c3be9c | ||
|
|
f5691eadc9 | ||
|
|
151d9d8117 | ||
|
|
bff08d615a | ||
|
|
a5ce1edf20 | ||
|
|
0ef7585ccb | ||
|
|
3d914065e0 | ||
|
|
5d9c979ace | ||
|
|
a985010a40 | ||
|
|
ddc863c6f7 | ||
|
|
d765e7c106 | ||
|
|
0bc890302b | ||
|
|
68973c3b7b | ||
|
|
0875ae719e | ||
|
|
e7d9165945 | ||
|
|
2bb358a787 | ||
|
|
872f09f720 | ||
|
|
9e56a024c5 | ||
|
|
1032097978 | ||
|
|
66b0f51279 | ||
|
|
7a7de71c39 | ||
|
|
62e763ecc2 | ||
|
|
8866071029 | ||
|
|
006d7afa44 | ||
|
|
461292fb2b | ||
|
|
3f65838155 | ||
|
|
12f29d0916 | ||
|
|
3c33d912df | ||
|
|
9d889f691b | ||
|
|
801e1e8ec5 | ||
|
|
7795bc9f1d | ||
|
|
f6a88d7f12 | ||
|
|
6b45b97d9c | ||
|
|
25609d88b2 | ||
|
|
5471e52335 | ||
|
|
f0119b3f2b | ||
|
|
b975333fec | ||
|
|
1610b9a4ba | ||
|
|
2e6b415826 | ||
|
|
cd9daacfcc | ||
|
|
ab8fefd3c9 | ||
|
|
dfdadc470d | ||
|
|
c5236ec67e | ||
|
|
ad48473ced | ||
|
|
02ac8f008f | ||
|
|
29fd83c1ea | ||
|
|
96b11e31a1 | ||
|
|
74f9cd764b | ||
|
|
553da80f80 | ||
|
|
83b59ec969 | ||
|
|
e468e45f4d | ||
|
|
7f59c8b771 | ||
|
|
cb180dfc5f | ||
|
|
20af850432 | ||
|
|
4db856e7da | ||
|
|
6d168f3c96 | ||
|
|
b95b5f62a0 | ||
|
|
639d087e53 | ||
|
|
40d3ec99dc | ||
|
|
3210860841 | ||
|
|
20076678d6 | ||
|
|
5e4fc5264d | ||
|
|
4093a8a48d | ||
|
|
2866b29f3a | ||
|
|
0b3284ac62 | ||
|
|
0b1caa7286 | ||
|
|
8df58d0a93 | ||
|
|
23ccec3ebc | ||
|
|
9e012e923b | ||
|
|
c0f1e9237f | ||
|
|
9aa338da0a | ||
|
|
567631b866 | ||
|
|
f712796df3 | ||
|
|
c1e3e61671 | ||
|
|
17ff977b07 | ||
|
|
cb420d83f6 | ||
|
|
97e7c106e3 | ||
|
|
61a8511f7a | ||
|
|
14a9b48d49 | ||
|
|
e6a35193f3 | ||
|
|
4a79abb367 | ||
|
|
5dda138ee7 | ||
|
|
040c11f0dc | ||
|
|
5086edefb3 | ||
|
|
66d9ddb9c4 | ||
|
|
41a7c88aef | ||
|
|
19b5496c56 | ||
|
|
d693d468d8 | ||
|
|
0b1ad1a854 | ||
|
|
6179adac22 | ||
|
|
3c959f0604 | ||
|
|
772734840d | ||
|
|
77c57acffa | ||
|
|
499ac053ee | ||
|
|
5aa1df81a8 | ||
|
|
81464a9087 | ||
|
|
6ed9500792 | ||
|
|
6835f9c85b | ||
|
|
442631102c | ||
|
|
78c615fe42 | ||
|
|
907011e280 | ||
|
|
a766ebf642 | ||
|
|
0e0e0868d7 | ||
|
|
d9077c64d3 | ||
|
|
1abb51992b | ||
|
|
0282156821 | ||
|
|
257a9129d7 | ||
|
|
1ce316efc7 | ||
|
|
ba94d8c2b0 | ||
|
|
6e11bebeae | ||
|
|
069f25f88a | ||
|
|
63c193358c | ||
|
|
7663e2ac30 | ||
|
|
0567655840 | ||
|
|
c25ed094fa | ||
|
|
112a8f9e9e | ||
|
|
723d416ce3 | ||
|
|
f3df486ce6 | ||
|
|
c7b6a87e77 | ||
|
|
19d91e47f1 | ||
|
|
1c945899c7 | ||
|
|
2c025755e2 | ||
|
|
0618e6e229 | ||
|
|
cbef2d4e66 | ||
|
|
cb93aba55c | ||
|
|
beef43f2bf | ||
|
|
cbd3f759b5 | ||
|
|
a7fdc45977 | ||
|
|
41e3ea0d87 | ||
|
|
fd22017292 | ||
|
|
555894793e | ||
|
|
7c87d3d150 | ||
|
|
c37d1b1d6b | ||
|
|
e5a03d08ea | ||
|
|
bc80985f55 | ||
|
|
83cd568ca2 | ||
|
|
f32a7b3f77 | ||
|
|
ba0fcbfc7f | ||
|
|
546193de2a | ||
|
|
67b522c433 | ||
|
|
f5eca9d1d0 | ||
|
|
5c4db3a6bc | ||
|
|
792dc359d4 | ||
|
|
2b0aaa237f | ||
|
|
48022833ea | ||
|
|
e2b3965a08 | ||
|
|
17e25fa3c4 | ||
|
|
37963cc654 | ||
|
|
c7f5277277 |
@@ -98,8 +98,6 @@ SMTP_OPENSSL_VERIFY_MODE=peer
|
||||
# Mail Incoming
|
||||
# This is the domain set for the reply emails when conversation continuity is enabled
|
||||
MAILER_INBOUND_EMAIL_DOMAIN=
|
||||
# Maximum time in seconds to process a single IMAP email
|
||||
# EMAIL_PROCESSING_TIMEOUT_SECONDS=60
|
||||
# Set this to the appropriate ingress channel with regards to incoming emails
|
||||
# Possible values are :
|
||||
# relay for Exim, Postfix, Qmail
|
||||
@@ -234,10 +232,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
||||
# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules
|
||||
# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10
|
||||
|
||||
## SafeFetch private network access
|
||||
## Keep disabled by default. Self-hosted installations can enable this to allow SafeFetch requests to private network URLs.
|
||||
# SAFE_FETCH_ALLOW_PRIVATE_NETWORK=false
|
||||
|
||||
## Running chatwoot as an API only server
|
||||
## setting this value to true will disable the frontend dashboard endpoints
|
||||
# CW_API_ONLY_SERVER=false
|
||||
|
||||
@@ -89,7 +89,7 @@ gem 'rails-i18n', '~> 7.0'
|
||||
# two-factor authentication
|
||||
gem 'devise-two-factor', '>= 5.0.0'
|
||||
# authorization
|
||||
gem 'jwt', '~> 2.10', '>= 2.10.3'
|
||||
gem 'jwt'
|
||||
gem 'pundit'
|
||||
|
||||
# super admin
|
||||
@@ -209,8 +209,6 @@ gem 'opentelemetry-exporter-otlp'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
##############################################################
|
||||
|
||||
|
||||
+3
-5
@@ -301,7 +301,7 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.14.2)
|
||||
faraday (2.14.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
@@ -339,7 +339,6 @@ GEM
|
||||
ffi-compiler (1.0.1)
|
||||
ffi (>= 1.0.0)
|
||||
rake
|
||||
firecrawl-sdk (1.4.1)
|
||||
flag_shih_tzu (0.3.23)
|
||||
foreman (0.87.2)
|
||||
fugit (1.11.1)
|
||||
@@ -491,7 +490,7 @@ GEM
|
||||
judoscale-sidekiq (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
sidekiq (>= 5.0)
|
||||
jwt (2.10.3)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
@@ -1080,7 +1079,6 @@ DEPENDENCIES
|
||||
faker
|
||||
faraday_middleware-aws-sigv4
|
||||
fcm
|
||||
firecrawl-sdk (~> 1.0)
|
||||
flag_shih_tzu
|
||||
foreman
|
||||
gemoji
|
||||
@@ -1102,7 +1100,7 @@ DEPENDENCIES
|
||||
json_schemer
|
||||
judoscale-rails
|
||||
judoscale-sidekiq
|
||||
jwt (~> 2.10, >= 2.10.3)
|
||||
jwt
|
||||
kaminari
|
||||
koala
|
||||
letter_opener
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.14.1
|
||||
4.14.0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
before_action :check_authorization
|
||||
before_action :set_articles, only: [:update_status, :update_category, :delete_articles]
|
||||
before_action :set_articles, only: [:update_status, :delete_articles]
|
||||
|
||||
def translate
|
||||
head :not_implemented
|
||||
@@ -19,18 +19,6 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def update_category
|
||||
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
|
||||
return render_could_not_create_error(I18n.t('portals.articles.category_not_found')) unless category_valid?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@articles.find_each { |article| article.update!(category_id: params[:category_id]) }
|
||||
end
|
||||
head :ok
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def delete_articles
|
||||
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
|
||||
|
||||
@@ -51,9 +39,5 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
|
||||
def set_articles
|
||||
@articles = @portal.articles.where(id: params[:ids])
|
||||
end
|
||||
|
||||
def category_valid?
|
||||
@portal.categories.exists?(id: params[:category_id])
|
||||
end
|
||||
end
|
||||
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_unread_counts_enabled
|
||||
|
||||
def index
|
||||
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
|
||||
render json: { payload: counts }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_unread_counts_enabled
|
||||
return if Current.account.feature_enabled?('conversation_unread_counts')
|
||||
|
||||
render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden
|
||||
end
|
||||
end
|
||||
@@ -162,8 +162,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
@conversation.update_columns(updates)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
|
||||
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
|
||||
end
|
||||
|
||||
def should_update_last_seen?
|
||||
|
||||
@@ -6,7 +6,8 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
|
||||
{
|
||||
redirect_uri: "#{base_url}/microsoft/callback",
|
||||
scope: scope,
|
||||
state: state
|
||||
state: state,
|
||||
prompt: 'consent'
|
||||
}
|
||||
)
|
||||
if redirect_url
|
||||
|
||||
@@ -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
|
||||
@@ -17,7 +17,6 @@ class AsyncDispatcher < BaseDispatcher
|
||||
InstallationWebhookListener.instance,
|
||||
NotificationListener.instance,
|
||||
ParticipationListener.instance,
|
||||
Conversations::UnreadCounts::Listener.instance,
|
||||
ReportingEventListener.instance,
|
||||
WebhookListener.instance
|
||||
]
|
||||
|
||||
@@ -13,10 +13,6 @@ class ConversationApi extends ApiClient {
|
||||
updateLabels(conversationID, labels) {
|
||||
return axios.post(`${this.url}/${conversationID}/labels`, { labels });
|
||||
}
|
||||
|
||||
getUnreadCounts() {
|
||||
return axios.get(`${this.url}/unread_counts`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConversationApi();
|
||||
|
||||
@@ -87,13 +87,6 @@ class ArticlesAPI extends PortalsAPI {
|
||||
);
|
||||
}
|
||||
|
||||
bulkUpdateCategory({ portalSlug, articleIds, categoryId }) {
|
||||
return axios.patch(
|
||||
`${this.url}/${portalSlug}/articles/bulk_actions/update_category`,
|
||||
{ ids: articleIds, category_id: categoryId }
|
||||
);
|
||||
}
|
||||
|
||||
bulkDelete({ portalSlug, articleIds }) {
|
||||
return axios.delete(
|
||||
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ describe('#ConversationApi', () => {
|
||||
expect(conversationsAPI).toHaveProperty('delete');
|
||||
expect(conversationsAPI).toHaveProperty('getLabels');
|
||||
expect(conversationsAPI).toHaveProperty('updateLabels');
|
||||
expect(conversationsAPI).toHaveProperty('getUnreadCounts');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
@@ -48,12 +47,5 @@ describe('#ConversationApi', () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#getUnreadCounts', () => {
|
||||
conversationsAPI.getUnreadCounts();
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/conversations/unread_counts'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,6 @@ 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 { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
@@ -43,7 +39,6 @@ const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
@@ -58,16 +53,6 @@ const isInitiatingCall = computed(() => {
|
||||
return contactsUiFlags.value?.isInitiatingCall || false;
|
||||
});
|
||||
|
||||
// Mirror the conversation-header button: block a new call whenever any provider
|
||||
// call is already active or ringing, otherwise starting a WhatsApp call here
|
||||
// would leave a still-live Twilio (or other) session with no visible control.
|
||||
const isCallButtonDisabled = computed(
|
||||
() =>
|
||||
callsStore.hasActiveCall ||
|
||||
callsStore.hasIncomingCall ||
|
||||
isInitiatingCall.value
|
||||
);
|
||||
|
||||
const navigateToConversation = conversationId => {
|
||||
const accountId = route.params.accountId;
|
||||
if (conversationId && accountId) {
|
||||
@@ -115,21 +100,15 @@ const startWhatsappCall = async (inboxId, conversationIdHint) => {
|
||||
// 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?.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));
|
||||
// Permission flow returns no id — banner already handled server-side; surface to user.
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
navigateToConversation(conversationId);
|
||||
return;
|
||||
}
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
// 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({
|
||||
@@ -137,8 +116,8 @@ const startWhatsappCall = async (inboxId, conversationIdHint) => {
|
||||
callId: response.id,
|
||||
conversationId,
|
||||
inboxId,
|
||||
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
|
||||
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
|
||||
callDirection: 'outbound',
|
||||
provider: 'whatsapp',
|
||||
});
|
||||
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
@@ -146,7 +125,7 @@ const startWhatsappCall = async (inboxId, conversationIdHint) => {
|
||||
};
|
||||
|
||||
const startCall = async (inboxId, conversationIdHint = null) => {
|
||||
if (isCallButtonDisabled.value) return;
|
||||
if (isInitiatingCall.value) return;
|
||||
|
||||
const inbox = (inboxesList.value || []).find(i => i.id === inboxId);
|
||||
if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) {
|
||||
@@ -166,11 +145,12 @@ const startCall = async (inboxId, conversationIdHint = null) => {
|
||||
});
|
||||
const { call_sid: callSid, conversation_id: conversationId } = response;
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
conversationId,
|
||||
inboxId,
|
||||
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
|
||||
callDirection: 'outbound',
|
||||
});
|
||||
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
@@ -218,7 +198,7 @@ const onPickInbox = async inbox => {
|
||||
v-if="shouldRender"
|
||||
v-tooltip.top-end="tooltipLabel || null"
|
||||
v-bind="attrs"
|
||||
:disabled="isCallButtonDisabled"
|
||||
:disabled="isInitiatingCall"
|
||||
:is-loading="isInitiatingCall"
|
||||
:label="label"
|
||||
:icon="icon"
|
||||
|
||||
@@ -142,26 +142,28 @@ watch(
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.editor-wrapper {
|
||||
:deep(.ProseMirror-menubar-wrapper) {
|
||||
.ProseMirror.ProseMirror-woot-style {
|
||||
p {
|
||||
@apply first:mt-0 !important;
|
||||
}
|
||||
::v-deep {
|
||||
.ProseMirror-menubar-wrapper {
|
||||
.ProseMirror.ProseMirror-woot-style {
|
||||
p {
|
||||
@apply first:mt-0 !important;
|
||||
}
|
||||
|
||||
.empty-node {
|
||||
@apply m-0 !important;
|
||||
.empty-node {
|
||||
@apply m-0 !important;
|
||||
|
||||
&::before {
|
||||
@apply text-n-slate-11 dark:text-n-slate-11;
|
||||
&::before {
|
||||
@apply text-n-slate-11 dark:text-n-slate-11;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
width: fit-content !important;
|
||||
position: relative !important;
|
||||
top: unset !important;
|
||||
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
|
||||
.ProseMirror-menubar {
|
||||
width: fit-content !important;
|
||||
position: relative !important;
|
||||
top: unset !important;
|
||||
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-29
@@ -145,43 +145,45 @@ const handleCreateArticle = event => {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.ProseMirror .empty-node::before) {
|
||||
@apply text-n-slate-10 text-base;
|
||||
}
|
||||
|
||||
:deep(.ProseMirror-menubar-wrapper) {
|
||||
.ProseMirror-woot-style {
|
||||
@apply min-h-[15rem] max-h-full;
|
||||
::v-deep {
|
||||
.ProseMirror .empty-node::before {
|
||||
@apply text-n-slate-10 text-base;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ProseMirror-menubar) {
|
||||
display: none; // Hide by default
|
||||
}
|
||||
|
||||
:deep(.editor-root .has-selection) {
|
||||
.ProseMirror-menubar:not(:has(*)) {
|
||||
display: none !important;
|
||||
.ProseMirror-menubar-wrapper {
|
||||
.ProseMirror-woot-style {
|
||||
@apply min-h-[15rem] max-h-full;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
|
||||
display: flex;
|
||||
top: var(--selection-top, auto) !important;
|
||||
left: var(--selection-left, 0) !important;
|
||||
width: fit-content !important;
|
||||
position: absolute !important;
|
||||
display: none; // Hide by default
|
||||
}
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
|
||||
}
|
||||
.editor-root .has-selection {
|
||||
.ProseMirror-menubar:not(:has(*)) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
@apply bg-n-slate-3;
|
||||
.ProseMirror-menubar {
|
||||
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
|
||||
display: flex;
|
||||
top: var(--selection-top, auto) !important;
|
||||
left: var(--selection-left, 0) !important;
|
||||
width: fit-content !important;
|
||||
position: absolute !important;
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
@apply bg-n-slate-3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-55
@@ -2,7 +2,6 @@
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
||||
@@ -19,7 +18,6 @@ import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/A
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import BulkTranslateDialog from './BulkTranslateDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -64,7 +62,6 @@ const isFeatureEnabledonAccount = useMapGetter(
|
||||
|
||||
const selectedArticleIds = ref(new Set());
|
||||
const deleteConfirmDialogRef = ref(null);
|
||||
const isCategoryMenuOpen = ref(false);
|
||||
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
@@ -224,34 +221,6 @@ const bulkUpdateStatus = async status => {
|
||||
}
|
||||
};
|
||||
|
||||
const categoryMenuItems = computed(() =>
|
||||
props.categories.map(category => ({
|
||||
label: category.name,
|
||||
value: category.id,
|
||||
action: 'move',
|
||||
emoji: category.icon,
|
||||
}))
|
||||
);
|
||||
|
||||
const handleBulkUpdateCategory = async ({ value }) => {
|
||||
isCategoryMenuOpen.value = false;
|
||||
try {
|
||||
await articlesAPI.bulkUpdateCategory({
|
||||
portalSlug: route.params.portalSlug,
|
||||
articleIds: [...selectedArticleIds.value],
|
||||
categoryId: value,
|
||||
});
|
||||
onBulkActionSuccess(
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.CATEGORY_SUCCESS')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.CATEGORY_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmBulkDelete = () => {
|
||||
deleteConfirmDialogRef.value?.open();
|
||||
};
|
||||
@@ -371,30 +340,6 @@ watch(
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="bulkUpdateStatus('archived')"
|
||||
/>
|
||||
<div v-if="categoryMenuItems.length" class="relative group">
|
||||
<OnClickOutside @trigger="isCategoryMenuOpen = false">
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-folder-input"
|
||||
:label="
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.MOVE_TO_CATEGORY'
|
||||
)
|
||||
"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="isCategoryMenuOpen"
|
||||
:menu-items="categoryMenuItems"
|
||||
show-search
|
||||
class="right-0 w-48 mt-2 top-full max-h-60"
|
||||
@action="handleBulkUpdateCategory"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
<Button
|
||||
v-if="isTranslationAvailable"
|
||||
sm
|
||||
|
||||
+1
-1
@@ -202,7 +202,7 @@ defineExpose({ state, isSubmitDisabled });
|
||||
size="sm"
|
||||
type="button"
|
||||
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
|
||||
class="!h-[2.38rem] !w-[2.375rem] absolute top-[2rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
|
||||
class="!h-[2.4rem] !w-[2.375rem] absolute top-[1.94rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
|
||||
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
|
||||
/>
|
||||
<EmojiInput
|
||||
|
||||
@@ -32,11 +32,7 @@ const modelValue = defineModel({ type: Boolean, default: false });
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ header }}
|
||||
</span>
|
||||
<template v-if="hideToggle">
|
||||
<slot name="hiddenToggle">
|
||||
<div class="size-2" />
|
||||
</slot>
|
||||
</template>
|
||||
<div v-if="hideToggle" class="size-2" />
|
||||
<ToggleSwitch v-else v-model="modelValue" />
|
||||
</div>
|
||||
<span v-if="description" class="text-body-main text-n-slate-11">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { defineProps } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
<script setup>
|
||||
import CallCard from './CallCard.vue';
|
||||
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
|
||||
const whatsappCall = {
|
||||
callSid: 'wamid.HBgM',
|
||||
conversationId: 1234,
|
||||
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
|
||||
};
|
||||
|
||||
const twilioCall = {
|
||||
callSid: 'CA1234567890',
|
||||
conversationId: 1234,
|
||||
};
|
||||
|
||||
const richCallInfo = {
|
||||
contactName: 'John Does',
|
||||
phoneNumber: '+1 412 245 0242',
|
||||
inboxName: 'Customer support',
|
||||
location: 'San Francisco, United States',
|
||||
countryFlag: '\u{1F1FA}\u{1F1F8}',
|
||||
hasLocation: true,
|
||||
avatar:
|
||||
'https://app.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBd3FodGc9PSIsImV4cCI6bnVsbCwicHVyIjoiYmxvYl9pZCJ9fQ==--d218a325af0ef45061eefd352f8efb9ac84275e8/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lKYW5CbFp3WTZCa1ZVT2hOeVpYTnBlbVZmZEc5ZlptbHNiRnNIYVFINk1BPT0iLCJleHAiOm51bGwsInB1ciI6InZhcmlhdGlvbiJ9fQ==--533c3ad7218e24c4b0e8f8959dc1953ce1d279b9/1707423736896.jpeg',
|
||||
};
|
||||
|
||||
// Falls back to inbox name when neither city nor country is on the contact.
|
||||
const noLocationCallInfo = {
|
||||
contactName: 'Anonymous Lead',
|
||||
phoneNumber: '+44 20 7916 0428',
|
||||
inboxName: 'Sales',
|
||||
location: 'Sales',
|
||||
countryFlag: '',
|
||||
hasLocation: false,
|
||||
avatar: '',
|
||||
};
|
||||
|
||||
// Truncation regression check — both contact name and location should ellipsize.
|
||||
const longCallInfo = {
|
||||
contactName: 'Aleksandra Konstantinopolskaya',
|
||||
phoneNumber: '+49 30 911890',
|
||||
inboxName: 'Customer support',
|
||||
location:
|
||||
'Friedrichshain-Kreuzberg, Federal Republic of Germany (Deutschland)',
|
||||
countryFlag: '\u{1F1E9}\u{1F1EA}',
|
||||
hasLocation: true,
|
||||
avatar: '',
|
||||
};
|
||||
|
||||
const unknownCallerInfo = {
|
||||
contactName: 'Unknown caller',
|
||||
phoneNumber: '',
|
||||
inboxName: 'Customer support',
|
||||
location: 'Customer support',
|
||||
countryFlag: '',
|
||||
hasLocation: false,
|
||||
avatar: '',
|
||||
};
|
||||
|
||||
const log =
|
||||
(label, ...args) =>
|
||||
() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[CallCard.story] ${label}`, ...args);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Call/CallCard"
|
||||
:layout="{ type: 'grid', width: '400px' }"
|
||||
>
|
||||
<Variant title="Incoming · WhatsApp">
|
||||
<CallCard
|
||||
:call="whatsappCall"
|
||||
:call-info="richCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.INCOMING"
|
||||
@accept="log('accept')"
|
||||
@reject="log('reject')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Incoming · Twilio (no flag)">
|
||||
<CallCard
|
||||
:call="twilioCall"
|
||||
:call-info="noLocationCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.INCOMING"
|
||||
@accept="log('accept')"
|
||||
@reject="log('reject')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Outgoing · WhatsApp ringing">
|
||||
<CallCard
|
||||
:call="whatsappCall"
|
||||
:call-info="richCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.OUTGOING"
|
||||
@reject="log('reject')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Ongoing · WhatsApp (mute visible, unmuted)">
|
||||
<CallCard
|
||||
:call="whatsappCall"
|
||||
:call-info="richCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.ONGOING"
|
||||
duration="01:23"
|
||||
:is-muted="false"
|
||||
show-mute
|
||||
@end="log('end')"
|
||||
@toggle-mute="log('toggleMute')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Ongoing · WhatsApp muted">
|
||||
<CallCard
|
||||
:call="whatsappCall"
|
||||
:call-info="richCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.ONGOING"
|
||||
duration="03:47"
|
||||
is-muted
|
||||
show-mute
|
||||
@end="log('end')"
|
||||
@toggle-mute="log('toggleMute')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Ongoing · Twilio (no mute control)">
|
||||
<CallCard
|
||||
:call="twilioCall"
|
||||
:call-info="richCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.ONGOING"
|
||||
duration="00:42"
|
||||
@end="log('end')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Incoming · Unknown caller">
|
||||
<CallCard
|
||||
:call="whatsappCall"
|
||||
:call-info="unknownCallerInfo"
|
||||
:state="VOICE_CALL_DIRECTION.INCOMING"
|
||||
@accept="log('accept')"
|
||||
@reject="log('reject')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Truncation · long name and location">
|
||||
<CallCard
|
||||
:call="whatsappCall"
|
||||
:call-info="longCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.INCOMING"
|
||||
@accept="log('accept')"
|
||||
@reject="log('reject')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Ongoing · no conversation link in footer">
|
||||
<CallCard
|
||||
:call="{ ...whatsappCall, conversationId: null }"
|
||||
:call-info="richCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.ONGOING"
|
||||
duration="02:10"
|
||||
show-mute
|
||||
@end="log('end')"
|
||||
@toggle-mute="log('toggleMute')"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -25,7 +25,7 @@ const store = useStore();
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
const isSyncableDocument = doc =>
|
||||
!doc.pdf_document && doc.status === 'available';
|
||||
!doc.pdf_document && doc.status === 'available' && !doc.sync_in_progress;
|
||||
|
||||
const syncableSelectedIds = computed(() => {
|
||||
if (!props.selectedIds.size) return [];
|
||||
|
||||
@@ -127,6 +127,7 @@ const menuItems = computed(() => {
|
||||
value: 'sync',
|
||||
action: 'sync',
|
||||
icon: 'i-lucide-refresh-cw',
|
||||
disabled: props.syncInProgress,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { watch } from 'vue';
|
||||
import { defineModel, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, defineModel, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, defineProps, defineEmits } from 'vue';
|
||||
import { Chrome } from '@lk77/vue3-color';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { defineProps, ref, defineEmits, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, h, watch, ref } from 'vue';
|
||||
import { computed, defineModel, h, watch, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { defineModel, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import Button from 'next/button/Button.vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { defineModel, computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { h } from 'vue';
|
||||
import { h, defineProps } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
country: { type: String, required: true },
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
<script setup>
|
||||
import ChannelIcon from './ChannelIcon.vue';
|
||||
import { useChannelBrandIcon } from './provider';
|
||||
|
||||
const inboxes = [
|
||||
{ name: 'API', channel_type: 'Channel::Api' },
|
||||
{ name: 'Email', channel_type: 'Channel::Email' },
|
||||
{ name: 'Gmail', channel_type: 'Channel::Email', provider: 'google' },
|
||||
{ name: 'Outlook', channel_type: 'Channel::Email', provider: 'microsoft' },
|
||||
{ name: 'Messenger', channel_type: 'Channel::FacebookPage' },
|
||||
{ name: 'Instagram', channel_type: 'Channel::Instagram' },
|
||||
{ name: 'Line', channel_type: 'Channel::Line' },
|
||||
{ name: 'Telegram', channel_type: 'Channel::Telegram' },
|
||||
{ name: 'WhatsApp', channel_type: 'Channel::Whatsapp' },
|
||||
{ name: 'TikTok', channel_type: 'Channel::Tiktok' },
|
||||
{ name: 'SMS', channel_type: 'Channel::Sms' },
|
||||
{ name: 'Twilio SMS', channel_type: 'Channel::TwilioSms' },
|
||||
{
|
||||
name: 'Twilio WhatsApp',
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
medium: 'whatsapp',
|
||||
},
|
||||
{
|
||||
name: 'Voice',
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
voice_enabled: true,
|
||||
},
|
||||
{ name: 'Website', channel_type: 'Channel::WebWidget' },
|
||||
];
|
||||
|
||||
const brandInboxes = inboxes.filter(inbox => useChannelBrandIcon(inbox).value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Icons/ChannelIcon">
|
||||
<Variant title="Glyph (default)">
|
||||
<div class="grid grid-cols-4 gap-5">
|
||||
<div
|
||||
v-for="inbox in inboxes"
|
||||
:key="inbox.name"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<ChannelIcon :inbox="inbox" class="size-6 text-n-slate-11" />
|
||||
<span>{{ inbox.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Brand icon">
|
||||
<div class="grid grid-cols-4 gap-5">
|
||||
<div
|
||||
v-for="inbox in brandInboxes"
|
||||
:key="inbox.name"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<ChannelIcon
|
||||
:inbox="inbox"
|
||||
use-brand-icon
|
||||
class="size-6 text-n-slate-11"
|
||||
/>
|
||||
<span>{{ inbox.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, toRef } from 'vue';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
import { useChannelIcon, useChannelBrandIcon } from './provider';
|
||||
import { useChannelIcon } from './provider';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -9,35 +9,22 @@ const props = defineProps({
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
// When true, render the full-color brand icon (when one exists for the
|
||||
// channel type) and fall back to the monochrome glyph otherwise.
|
||||
useBrandIcon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const inboxRef = toRef(props, 'inbox');
|
||||
|
||||
const channelIcon = useChannelIcon(toRef(props, 'inbox'));
|
||||
const hasVoiceBadge = computed(() => isVoiceCallEnabled(props.inbox));
|
||||
const channelIcon = useChannelIcon(inboxRef);
|
||||
const brandIcon = useChannelBrandIcon(inboxRef);
|
||||
|
||||
const icon = computed(() =>
|
||||
props.useBrandIcon && brandIcon.value ? brandIcon.value : channelIcon.value
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="relative inline-flex" v-bind="$attrs">
|
||||
<Icon :icon="icon" class="size-full" />
|
||||
<Icon :icon="channelIcon" 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"
|
||||
class="absolute -top-1 -right-1 inline-flex items-center justify-center size-2.5 rounded-full bg-n-alpha-2 ring-1 ring-n-solid-1"
|
||||
>
|
||||
<Icon icon="i-lucide-audio-lines" class="size-1.5 text-n-slate-12" />
|
||||
<Icon icon="i-lucide-audio-lines" class="size-1.5 text-n-slate-10" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -18,7 +18,6 @@ const fileTypeIcon = computed(() => {
|
||||
json: 'i-woot-file-txt',
|
||||
odt: 'i-woot-file-doc',
|
||||
pdf: 'i-woot-file-pdf',
|
||||
pfx: 'i-woot-file-pfx',
|
||||
ppt: 'i-woot-file-ppt',
|
||||
pptx: 'i-woot-file-ppt',
|
||||
rar: 'i-woot-file-zip',
|
||||
@@ -27,7 +26,6 @@ const fileTypeIcon = computed(() => {
|
||||
txt: 'i-woot-file-txt',
|
||||
xls: 'i-woot-file-xls',
|
||||
xlsx: 'i-woot-file-xls',
|
||||
xml: 'i-woot-file-txt',
|
||||
zip: 'i-woot-file-zip',
|
||||
};
|
||||
|
||||
|
||||
@@ -1,63 +1,39 @@
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const channelTypeIconMap = {
|
||||
'Channel::Api': 'i-woot-api',
|
||||
'Channel::Email': 'i-woot-mail',
|
||||
'Channel::FacebookPage': 'i-woot-messenger',
|
||||
'Channel::Line': 'i-woot-line',
|
||||
'Channel::Sms': 'i-woot-sms',
|
||||
'Channel::Telegram': 'i-woot-telegram',
|
||||
'Channel::TwilioSms': 'i-woot-sms',
|
||||
'Channel::TwitterProfile': 'i-woot-x',
|
||||
'Channel::WebWidget': 'i-woot-website',
|
||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||
'Channel::Instagram': 'i-woot-instagram',
|
||||
'Channel::Tiktok': 'i-woot-tiktok',
|
||||
};
|
||||
|
||||
const providerIconMap = {
|
||||
microsoft: 'i-woot-outlook',
|
||||
google: 'i-woot-gmail',
|
||||
};
|
||||
|
||||
// Full-color brand icons. Most come from the `logos` Iconify set; Instagram,
|
||||
// Outlook and Line use custom `woot` glyphs since the `logos` versions are
|
||||
// monochrome or missing. Channels not listed here have no brand variant and
|
||||
// callers should fall back to the monochrome glyph via useChannelIcon.
|
||||
const channelTypeBrandIconMap = {
|
||||
'Channel::FacebookPage': 'i-logos-messenger',
|
||||
'Channel::Line': 'i-woot-line-color',
|
||||
'Channel::Telegram': 'i-logos-telegram',
|
||||
'Channel::Whatsapp': 'i-logos-whatsapp-icon',
|
||||
'Channel::Instagram': 'i-woot-instagram-color',
|
||||
'Channel::Tiktok': 'i-logos-tiktok-icon',
|
||||
};
|
||||
|
||||
const providerBrandIconMap = {
|
||||
microsoft: 'i-woot-outlook-color',
|
||||
google: 'i-logos-google-gmail',
|
||||
};
|
||||
|
||||
const resolveInbox = inbox => inbox?.value ?? inbox;
|
||||
|
||||
export function useChannelIcon(inbox) {
|
||||
const channelTypeIconMap = {
|
||||
'Channel::Api': 'i-woot-api',
|
||||
'Channel::Email': 'i-woot-mail',
|
||||
'Channel::FacebookPage': 'i-woot-messenger',
|
||||
'Channel::Line': 'i-woot-line',
|
||||
'Channel::Sms': 'i-woot-sms',
|
||||
'Channel::Telegram': 'i-woot-telegram',
|
||||
'Channel::TwilioSms': 'i-woot-sms',
|
||||
'Channel::TwitterProfile': 'i-woot-x',
|
||||
'Channel::WebWidget': 'i-woot-website',
|
||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||
'Channel::Instagram': 'i-woot-instagram',
|
||||
'Channel::Tiktok': 'i-woot-tiktok',
|
||||
};
|
||||
|
||||
const providerIconMap = {
|
||||
microsoft: 'i-woot-outlook',
|
||||
google: 'i-woot-gmail',
|
||||
};
|
||||
|
||||
const channelIcon = computed(() => {
|
||||
const inboxDetails = resolveInbox(inbox);
|
||||
const inboxDetails = inbox.value || inbox;
|
||||
const type = inboxDetails.channel_type;
|
||||
let icon = channelTypeIconMap[type];
|
||||
|
||||
if (type === INBOX_TYPES.EMAIL && inboxDetails.provider) {
|
||||
if (type === 'Channel::Email' && inboxDetails.provider) {
|
||||
if (Object.keys(providerIconMap).includes(inboxDetails.provider)) {
|
||||
icon = providerIconMap[inboxDetails.provider];
|
||||
}
|
||||
}
|
||||
|
||||
// Special case for Twilio whatsapp
|
||||
if (
|
||||
type === INBOX_TYPES.TWILIO &&
|
||||
inboxDetails.medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||
) {
|
||||
if (type === 'Channel::TwilioSms' && inboxDetails.medium === 'whatsapp') {
|
||||
icon = 'i-woot-whatsapp';
|
||||
}
|
||||
|
||||
@@ -66,9 +42,9 @@ export function useChannelIcon(inbox) {
|
||||
const voiceEnabled =
|
||||
inboxDetails.voice_enabled || inboxDetails.voiceEnabled;
|
||||
if (
|
||||
type === INBOX_TYPES.TWILIO &&
|
||||
type === 'Channel::TwilioSms' &&
|
||||
voiceEnabled &&
|
||||
inboxDetails.medium !== TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||
inboxDetails.medium !== 'whatsapp'
|
||||
) {
|
||||
icon = 'i-woot-voice';
|
||||
}
|
||||
@@ -78,26 +54,3 @@ export function useChannelIcon(inbox) {
|
||||
|
||||
return channelIcon;
|
||||
}
|
||||
|
||||
export function useChannelBrandIcon(inbox) {
|
||||
return computed(() => {
|
||||
const inboxDetails = resolveInbox(inbox);
|
||||
const type = inboxDetails.channel_type;
|
||||
let icon = channelTypeBrandIconMap[type];
|
||||
|
||||
if (type === INBOX_TYPES.EMAIL && inboxDetails.provider) {
|
||||
if (Object.keys(providerBrandIconMap).includes(inboxDetails.provider)) {
|
||||
icon = providerBrandIconMap[inboxDetails.provider];
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
type === INBOX_TYPES.TWILIO &&
|
||||
inboxDetails.medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||
) {
|
||||
icon = channelTypeBrandIconMap['Channel::Whatsapp'];
|
||||
}
|
||||
|
||||
return icon ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -554,10 +554,11 @@ provideMessageContext({
|
||||
<Avatar v-bind="avatarInfo" :size="24" />
|
||||
</div>
|
||||
<div
|
||||
class="[grid-area:bubble] flex min-w-0"
|
||||
class="[grid-area:bubble] flex"
|
||||
:class="{
|
||||
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
||||
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
|
||||
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
||||
}"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, reactive } from 'vue';
|
||||
import { defineProps, computed, reactive } from 'vue';
|
||||
import Message from './Message.vue';
|
||||
import { MESSAGE_TYPES } from './constants.js';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
|
||||
defineProps({
|
||||
showingOriginal: Boolean,
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ const replyToPreview = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="text-sm min-w-0"
|
||||
class="text-sm"
|
||||
:class="[
|
||||
messageClass,
|
||||
{
|
||||
|
||||
@@ -6,12 +6,9 @@ import BaseBubble from './Base.vue';
|
||||
|
||||
const { inboxId } = useMessageContext();
|
||||
|
||||
const {
|
||||
isAFacebookInbox,
|
||||
isAnInstagramChannel,
|
||||
isATiktokChannel,
|
||||
isAWhatsAppChannel,
|
||||
} = useInbox(inboxId.value);
|
||||
const { isAFacebookInbox, isAnInstagramChannel, isATiktokChannel } = useInbox(
|
||||
inboxId.value
|
||||
);
|
||||
|
||||
const unsupportedMessageKey = computed(() => {
|
||||
if (isAFacebookInbox.value)
|
||||
@@ -19,8 +16,6 @@ const unsupportedMessageKey = computed(() => {
|
||||
if (isAnInstagramChannel.value)
|
||||
return 'CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM';
|
||||
if (isATiktokChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_TIKTOK';
|
||||
if (isAWhatsAppChannel.value)
|
||||
return 'CONVERSATION.UNSUPPORTED_MESSAGE_WHATSAPP';
|
||||
return 'CONVERSATION.UNSUPPORTED_MESSAGE';
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -2,27 +2,17 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import {
|
||||
VOICE_CALL_STATUS,
|
||||
VOICE_CALL_DIRECTION,
|
||||
VOICE_CALL_OUTBOUND_INIT_STATUS,
|
||||
VOICE_CALL_END_REASON,
|
||||
MESSAGE_TYPES,
|
||||
ATTACHMENT_TYPES,
|
||||
} from '../constants';
|
||||
import { VOICE_CALL_STATUS, MESSAGE_TYPES } from '../constants';
|
||||
import { useCallActions } from 'dashboard/composables/useCallSession';
|
||||
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
import { formatDuration } from 'shared/helpers/timeHelper';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import AudioChip from 'next/message/chips/Audio.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const LABEL_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||
@@ -51,11 +41,6 @@ const {
|
||||
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
|
||||
useCallActions();
|
||||
const whatsappCallSession = useWhatsappCallSession();
|
||||
const callsStore = useCallsStore();
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
const isInitiatingCall = computed(
|
||||
() => contactsUiFlags.value?.isInitiatingCall || false
|
||||
);
|
||||
|
||||
const status = computed(() => call.value?.status);
|
||||
// Server-side call records use `outgoing`/`incoming`, while the Pinia store
|
||||
@@ -63,34 +48,26 @@ const status = computed(() => call.value?.status);
|
||||
// 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;
|
||||
if (dir === 'outgoing' || dir === 'outbound') return true;
|
||||
if (dir === 'incoming' || dir === '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 isWhatsapp = computed(() => call.value?.provider === 'whatsapp');
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
|
||||
const endReason = computed(() => call.value?.endReason);
|
||||
const wasDeclinedByAgent = computed(
|
||||
() =>
|
||||
isMissedInbound.value &&
|
||||
endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED
|
||||
() => isMissedInbound.value && endReason.value === 'agent_rejected'
|
||||
);
|
||||
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
|
||||
const didCurrentUserAnswer = computed(
|
||||
() =>
|
||||
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
|
||||
);
|
||||
const conversationAssignee = computed(() => {
|
||||
const conversation = store.getters.getConversationById?.(
|
||||
conversationId?.value
|
||||
@@ -108,7 +85,7 @@ const displayAgentName = computed(() => {
|
||||
});
|
||||
|
||||
const audioAttachment = computed(() =>
|
||||
(attachments?.value || []).find(a => a.fileType === ATTACHMENT_TYPES.AUDIO)
|
||||
(attachments?.value || []).find(a => a.fileType === 'audio')
|
||||
);
|
||||
|
||||
const durationSeconds = computed(() => {
|
||||
@@ -120,48 +97,43 @@ const durationSeconds = computed(() => {
|
||||
|
||||
const formattedDuration = computed(() => formatDuration(durationSeconds.value));
|
||||
|
||||
// Agent who handled the call (initiator on outbound, answerer on inbound), taken
|
||||
// strictly from the persisted accept fields — never the conversation's current
|
||||
// assignee, which would mis-attribute a historical call after a reassignment.
|
||||
const handlerName = computed(() => {
|
||||
if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName;
|
||||
if (!acceptedByAgentId.value) return null;
|
||||
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
|
||||
return agent?.available_name || agent?.name || null;
|
||||
});
|
||||
|
||||
const handledBy = computed(() =>
|
||||
handlerName.value
|
||||
? t('CONVERSATION.VOICE_CALL.HANDLED_BY', { agentName: handlerName.value })
|
||||
: null
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
}
|
||||
if (isFailed.value) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL'
|
||||
: 'CONVERSATION.VOICE_CALL.MISSED_CALL';
|
||||
}
|
||||
// RINGING or an as-yet-unknown/initial status: orient purely by direction so an
|
||||
// outbound call never falls through to the "Incoming call" label.
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const subtext = computed(() => {
|
||||
// Completed: "Handled by {agent} · 0:42" (drops either part when absent).
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? t('CONVERSATION.VOICE_CALL.CALLING')
|
||||
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
|
||||
return [handledBy.value, formattedDuration.value]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return formattedDuration.value;
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return handledBy.value;
|
||||
if (isOutbound.value) return null;
|
||||
if (didCurrentUserAnswer.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
}
|
||||
if (displayAgentName.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
|
||||
agentName: displayAgentName.value,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (isFailed.value) {
|
||||
// Missed/failed calls have no handler, so keep the reason rather than "Handled by".
|
||||
if (isOutbound.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT');
|
||||
}
|
||||
@@ -172,10 +144,6 @@ const subtext = computed(() => {
|
||||
}
|
||||
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT');
|
||||
}
|
||||
// RINGING or an as-yet-unknown/initial status.
|
||||
if (isOutbound.value) {
|
||||
return handledBy.value || t('CONVERSATION.VOICE_CALL.CALLING');
|
||||
}
|
||||
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
});
|
||||
|
||||
@@ -221,7 +189,7 @@ const recordingAttachment = computed(() => {
|
||||
if (!url) return null;
|
||||
return {
|
||||
dataUrl: url,
|
||||
fileType: ATTACHMENT_TYPES.AUDIO,
|
||||
fileType: 'audio',
|
||||
extension: 'wav',
|
||||
transcribedText: call.value?.transcript || '',
|
||||
};
|
||||
@@ -246,53 +214,35 @@ const handleJoinCall = async () => {
|
||||
};
|
||||
|
||||
const canCallBack = computed(
|
||||
() =>
|
||||
isMissedInbound.value &&
|
||||
!!inboxId.value &&
|
||||
!!conversationId.value &&
|
||||
!hasActiveCall.value &&
|
||||
!callsStore.hasIncomingCall
|
||||
() => isMissedInbound.value && !!inboxId.value && !!conversationId.value
|
||||
);
|
||||
|
||||
const handleCallBack = async () => {
|
||||
if (!canCallBack.value || isInitiatingCall.value) return;
|
||||
if (!canCallBack.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;
|
||||
if (response?.status === 'locked') return;
|
||||
if (response?.call_id) {
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid: response.call_id,
|
||||
callId: response.id,
|
||||
conversationId: conversationId.value,
|
||||
inboxId: inboxId.value,
|
||||
callDirection: 'outbound',
|
||||
provider: 'whatsapp',
|
||||
});
|
||||
}
|
||||
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', {
|
||||
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'));
|
||||
}
|
||||
@@ -333,28 +283,31 @@ const handleCallBack = async () => {
|
||||
/>
|
||||
|
||||
<!-- Call back button (missed inbound) -->
|
||||
<NextButton
|
||||
<button
|
||||
v-if="canCallBack"
|
||||
type="button"
|
||||
:label="$t('CONVERSATION.VOICE_CALL.CALL_BACK')"
|
||||
icon="i-ph-phone-bold"
|
||||
teal
|
||||
class="!rounded-full"
|
||||
:disabled="isInitiatingCall"
|
||||
class="flex justify-center items-center gap-2 px-3 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||
@click="handleCallBack"
|
||||
/>
|
||||
>
|
||||
<i class="text-base text-white i-ph-phone-bold" />
|
||||
<span class="text-sm font-medium text-white tracking-tight">
|
||||
{{ $t('CONVERSATION.VOICE_CALL.CALL_BACK') }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Join call button (ringing inbound) -->
|
||||
<NextButton
|
||||
<button
|
||||
v-if="canJoinCall"
|
||||
type="button"
|
||||
:label="$t('CONVERSATION.VOICE_CALL.JOIN_CALL')"
|
||||
icon="i-ph-phone-bold"
|
||||
teal
|
||||
class="!rounded-full"
|
||||
class="flex justify-center items-center gap-2 px-3 h-10 bg-n-teal-9 hover:bg-n-teal-10 disabled:opacity-50 rounded-full transition-colors"
|
||||
:disabled="isJoining"
|
||||
@click="handleJoinCall"
|
||||
/>
|
||||
>
|
||||
<i class="text-base text-white i-ph-phone-bold" />
|
||||
<span class="text-sm font-medium text-white tracking-tight">
|
||||
{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, useAttrs } from 'vue';
|
||||
import { computed, defineOptions, useAttrs } from 'vue';
|
||||
|
||||
import ImageChip from 'next/message/chips/Image.vue';
|
||||
import VideoChip from 'next/message/chips/Video.vue';
|
||||
|
||||
@@ -31,17 +31,6 @@ const timeStampURL = computed(() => {
|
||||
return timeStampAppendedURL(attachment.dataUrl);
|
||||
});
|
||||
|
||||
const TRANSCRIPT_PREVIEW_LENGTH = 200;
|
||||
const isTranscriptExpanded = ref(false);
|
||||
const isTranscriptLong = computed(
|
||||
() => (attachment.transcribedText?.length || 0) > TRANSCRIPT_PREVIEW_LENGTH
|
||||
);
|
||||
const displayedTranscript = computed(() => {
|
||||
const text = attachment.transcribedText || '';
|
||||
if (!isTranscriptLong.value || isTranscriptExpanded.value) return text;
|
||||
return `${text.slice(0, TRANSCRIPT_PREVIEW_LENGTH).trimEnd()}…`;
|
||||
});
|
||||
|
||||
const audioPlayer = useTemplateRef('audioPlayer');
|
||||
|
||||
const isPlaying = ref(false);
|
||||
@@ -226,18 +215,7 @@ const downloadAudio = async () => {
|
||||
v-if="attachment.transcribedText && showTranscribedText"
|
||||
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
|
||||
>
|
||||
{{ displayedTranscript }}
|
||||
<button
|
||||
v-if="isTranscriptLong"
|
||||
class="block mt-1 p-0 border-0 bg-transparent text-n-slate-11 hover:text-n-slate-12 font-medium"
|
||||
@click="isTranscriptExpanded = !isTranscriptExpanded"
|
||||
>
|
||||
{{
|
||||
isTranscriptExpanded
|
||||
? $t('CONVERSATION.VOICE_CALL.TRANSCRIPT_SHOW_LESS')
|
||||
: $t('CONVERSATION.VOICE_CALL.TRANSCRIPT_SHOW_MORE')
|
||||
}}
|
||||
</button>
|
||||
{{ attachment.transcribedText }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -88,18 +88,5 @@ export const VOICE_CALL_STATUS = {
|
||||
|
||||
export const VOICE_CALL_DIRECTION = {
|
||||
INBOUND: 'inbound',
|
||||
INCOMING: 'incoming',
|
||||
OUTGOING: 'outgoing',
|
||||
ONGOING: 'ongoing',
|
||||
OUTBOUND: 'outbound',
|
||||
};
|
||||
|
||||
export const VOICE_CALL_OUTBOUND_INIT_STATUS = {
|
||||
LOCKED: 'locked',
|
||||
PERMISSION_REQUESTED: 'permission_requested',
|
||||
PERMISSION_PENDING: 'permission_pending',
|
||||
};
|
||||
|
||||
export const VOICE_CALL_END_REASON = {
|
||||
AGENT_REJECTED: 'agent_rejected',
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { computed } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import ChannelIcon from 'next/icon/ChannelIcon.vue';
|
||||
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
@@ -18,10 +17,6 @@ const props = defineProps({
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
badgeCount: {
|
||||
type: [Number, String],
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const reauthorizationRequired = computed(() => {
|
||||
@@ -34,7 +29,6 @@ const reauthorizationRequired = computed(() => {
|
||||
<ChannelIcon :inbox="inbox" class="size-4" />
|
||||
</span>
|
||||
<div class="flex-1 truncate min-w-0">{{ label }}</div>
|
||||
<SidebarUnreadBadge :count="badgeCount" />
|
||||
<div
|
||||
v-if="reauthorizationRequired"
|
||||
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { h, ref, computed, onMounted, watch } from 'vue';
|
||||
import { h, ref, computed, onMounted } from 'vue';
|
||||
import { provideSidebarContext, useSidebarResize } from './provider';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
@@ -61,24 +61,6 @@ const hasAdvancedAssignment = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const hasConversationUnreadCounts = computed(() => {
|
||||
return isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
|
||||
);
|
||||
});
|
||||
|
||||
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
|
||||
if (!currentAccountId) return;
|
||||
|
||||
if (!isEnabled) {
|
||||
store.dispatch('conversationUnreadCounts/clear');
|
||||
return;
|
||||
}
|
||||
|
||||
store.dispatch('conversationUnreadCounts/get');
|
||||
};
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -175,15 +157,6 @@ useEventListener(document, 'touchend', onResizeEnd);
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const labels = useMapGetter('labels/getLabelsOnSidebar');
|
||||
const getInboxUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getInboxUnreadCount'
|
||||
);
|
||||
const getLabelUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getLabelUnreadCount'
|
||||
);
|
||||
const getTeamUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getTeamUnreadCount'
|
||||
);
|
||||
const teams = useMapGetter('teams/getMyTeams');
|
||||
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
|
||||
const conversationCustomViews = useMapGetter(
|
||||
@@ -200,48 +173,8 @@ onMounted(() => {
|
||||
store.dispatch('customViews/get', 'contact');
|
||||
});
|
||||
|
||||
watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const normalizeUnreadCount = count => {
|
||||
const unreadCount = Number(count);
|
||||
return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0;
|
||||
};
|
||||
|
||||
const sortByUnreadCount = (items, labelKey, unreadCountKey) =>
|
||||
items.slice().sort((a, b) => {
|
||||
const unreadCountDiff =
|
||||
normalizeUnreadCount(unreadCountKey(b)) -
|
||||
normalizeUnreadCount(unreadCountKey(a));
|
||||
|
||||
if (unreadCountDiff !== 0) return unreadCountDiff;
|
||||
|
||||
return labelKey(a).localeCompare(labelKey(b));
|
||||
});
|
||||
|
||||
const sortedTeams = computed(() =>
|
||||
sortByUnreadCount(
|
||||
teams.value,
|
||||
team => team.name,
|
||||
team => getTeamUnreadCount.value(team.id)
|
||||
)
|
||||
);
|
||||
|
||||
const sortedInboxes = computed(() =>
|
||||
sortByUnreadCount(
|
||||
inboxes.value,
|
||||
inbox => inbox.name,
|
||||
inbox => getInboxUnreadCount.value(inbox.id)
|
||||
)
|
||||
);
|
||||
|
||||
const sortedLabels = computed(() =>
|
||||
sortByUnreadCount(
|
||||
labels.value,
|
||||
label => label.title,
|
||||
label => getLabelUnreadCount.value(label.id)
|
||||
)
|
||||
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||
);
|
||||
|
||||
const closeMobileSidebar = () => {
|
||||
@@ -334,10 +267,9 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.TEAMS'),
|
||||
icon: 'i-lucide-users',
|
||||
activeOn: ['conversations_through_team'],
|
||||
children: sortedTeams.value.map(team => ({
|
||||
children: teams.value.map(team => ({
|
||||
name: `${team.name}-${team.id}`,
|
||||
label: team.name,
|
||||
badgeCount: getTeamUnreadCount.value(team.id),
|
||||
to: accountScopedRoute('team_conversations', { teamId: team.id }),
|
||||
})),
|
||||
},
|
||||
@@ -349,7 +281,6 @@ const menuItems = computed(() => {
|
||||
children: sortedInboxes.value.map(inbox => ({
|
||||
name: `${inbox.name}-${inbox.id}`,
|
||||
label: inbox.name,
|
||||
badgeCount: getInboxUnreadCount.value(inbox.id),
|
||||
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
|
||||
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
|
||||
component: leafProps =>
|
||||
@@ -357,7 +288,6 @@ const menuItems = computed(() => {
|
||||
label: leafProps.label,
|
||||
active: leafProps.active,
|
||||
inbox,
|
||||
badgeCount: leafProps.badgeCount,
|
||||
}),
|
||||
})),
|
||||
},
|
||||
@@ -366,10 +296,9 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.LABELS'),
|
||||
icon: 'i-lucide-tag',
|
||||
activeOn: ['conversations_through_label'],
|
||||
children: sortedLabels.value.map(label => ({
|
||||
children: labels.value.map(label => ({
|
||||
name: `${label.title}-${label.id}`,
|
||||
label: label.title,
|
||||
badgeCount: getLabelUnreadCount.value(label.id),
|
||||
icon: h('span', {
|
||||
class: `size-[8px] rounded-sm`,
|
||||
style: { backgroundColor: label.color },
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useSidebarContext } from './provider';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
@@ -167,7 +166,6 @@ onMounted(async () => {
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ subChild.label }}</span>
|
||||
<SidebarUnreadBadge :count="subChild.badgeCount" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -190,7 +188,6 @@ onMounted(async () => {
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ child.label }}</span>
|
||||
<SidebarUnreadBadge :count="child.badgeCount" />
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { isVNode, computed } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import { useSidebarContext } from './provider';
|
||||
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
@@ -11,7 +10,6 @@ const props = defineProps({
|
||||
icon: { type: [String, Object], default: null },
|
||||
active: { type: Boolean, default: false },
|
||||
component: { type: Function, default: null },
|
||||
badgeCount: { type: [Number, String], default: 0 },
|
||||
});
|
||||
|
||||
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
|
||||
@@ -41,14 +39,15 @@ const shouldRenderComponent = computed(() => {
|
||||
<component
|
||||
:is="component"
|
||||
v-if="shouldRenderComponent"
|
||||
v-bind="{ label, icon, active, badgeCount }"
|
||||
:label
|
||||
:icon
|
||||
:active
|
||||
/>
|
||||
<template v-else>
|
||||
<span v-if="icon" class="size-4 grid place-content-center rounded-full">
|
||||
<Icon :icon="icon" class="size-4 inline-block" />
|
||||
</span>
|
||||
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
|
||||
<SidebarUnreadBadge :count="badgeCount" />
|
||||
</template>
|
||||
</component>
|
||||
</Policy>
|
||||
|
||||
@@ -78,27 +78,26 @@ function changeAvailabilityStatus(availability) {
|
||||
<template>
|
||||
<DropdownSection class="[&>ul]:overflow-visible">
|
||||
<div class="grid gap-0">
|
||||
<DropdownItem preserve-open class="gap-1">
|
||||
<div class="flex-grow flex items-center gap-1 min-w-0">
|
||||
<DropdownItem preserve-open>
|
||||
<div class="flex-grow flex items-center gap-1">
|
||||
{{ $t('SIDEBAR.SET_YOUR_AVAILABILITY') }}
|
||||
</div>
|
||||
<DropdownContainer class="shrink-0">
|
||||
<DropdownContainer>
|
||||
<template #trigger="{ toggle }">
|
||||
<Button
|
||||
size="sm"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="min-w-[96px]"
|
||||
icon="i-lucide-chevron-down"
|
||||
trailing-icon
|
||||
@click="toggle"
|
||||
>
|
||||
<div class="flex gap-1 items-center min-w-0 text-sm">
|
||||
<div class="flex gap-1 items-center flex-grow text-sm">
|
||||
<div class="p-1 flex-shrink-0">
|
||||
<div class="size-2 rounded-sm" :class="activeStatus.color" />
|
||||
</div>
|
||||
<span class="truncate max-w-[7rem]">
|
||||
{{ activeStatus.label }}
|
||||
</span>
|
||||
<span>{{ activeStatus.label }}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -115,12 +114,12 @@ function changeAvailabilityStatus(availability) {
|
||||
</DropdownContainer>
|
||||
</DropdownItem>
|
||||
<DropdownItem>
|
||||
<div class="flex-grow min-w-0">
|
||||
<div class="flex-grow flex items-center gap-1">
|
||||
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
|
||||
<Icon
|
||||
v-tooltip.top="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT')"
|
||||
icon="i-lucide-info"
|
||||
class="inline-block align-middle ms-1 size-4 text-n-slate-10"
|
||||
class="size-4 text-n-slate-10"
|
||||
/>
|
||||
</div>
|
||||
<ToggleSwitch v-model="autoOfflineToggle" />
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
count: { type: [Number, String], default: 0 },
|
||||
});
|
||||
|
||||
const normalizedCount = computed(() => {
|
||||
const count = Number(props.count);
|
||||
return Number.isFinite(count) && count > 0 ? count : 0;
|
||||
});
|
||||
|
||||
const displayCount = computed(() =>
|
||||
normalizedCount.value > 99 ? '99+' : String(normalizedCount.value)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="normalizedCount > 0"
|
||||
data-test-id="sidebar-unread-badge"
|
||||
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-slate-4 px-1 text-xxs font-medium leading-3 text-n-slate-12 dark:bg-n-slate-5 flex-shrink-0"
|
||||
>
|
||||
{{ displayCount }}
|
||||
</span>
|
||||
<span v-else class="hidden" />
|
||||
</template>
|
||||
@@ -1,38 +0,0 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ChannelLeaf from '../ChannelLeaf.vue';
|
||||
|
||||
const mountChannelLeaf = props =>
|
||||
mount(ChannelLeaf, {
|
||||
props: {
|
||||
label: 'Website',
|
||||
inbox: { reauthorization_required: false },
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: key => key,
|
||||
},
|
||||
stubs: {
|
||||
ChannelIcon: true,
|
||||
Icon: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('ChannelLeaf', () => {
|
||||
it('renders unread badge when count is present', () => {
|
||||
const wrapper = mountChannelLeaf({ badgeCount: 3 });
|
||||
const badge = wrapper.find('[data-test-id="sidebar-unread-badge"]');
|
||||
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toBe('3');
|
||||
});
|
||||
|
||||
it('does not render unread badge when count is zero', () => {
|
||||
const wrapper = mountChannelLeaf({ badgeCount: 0 });
|
||||
|
||||
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').exists()).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { h } from 'vue';
|
||||
import SidebarGroupLeaf from '../SidebarGroupLeaf.vue';
|
||||
|
||||
vi.mock('../provider', () => ({
|
||||
useSidebarContext: () => ({
|
||||
resolvePermissions: () => [],
|
||||
resolveFeatureFlag: () => '',
|
||||
}),
|
||||
}));
|
||||
|
||||
const PolicyStub = {
|
||||
props: ['as', 'permissions', 'featureFlag'],
|
||||
template: '<li><slot /></li>',
|
||||
};
|
||||
|
||||
const RouterLinkStub = {
|
||||
props: ['to'],
|
||||
template: '<a><slot /></a>',
|
||||
};
|
||||
|
||||
const mountLeaf = props =>
|
||||
mount(SidebarGroupLeaf, {
|
||||
props: {
|
||||
label: 'Support',
|
||||
to: '/support',
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Icon: true,
|
||||
Policy: PolicyStub,
|
||||
RouterLink: RouterLinkStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('SidebarGroupLeaf', () => {
|
||||
it('renders unread badge when count is present', () => {
|
||||
const wrapper = mountLeaf({ badgeCount: 7 });
|
||||
const badge = wrapper.find('[data-test-id="sidebar-unread-badge"]');
|
||||
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toBe('7');
|
||||
});
|
||||
|
||||
it('does not render unread badge when count is zero', () => {
|
||||
const wrapper = mountLeaf({ badgeCount: 0 });
|
||||
|
||||
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').exists()).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('caps large unread counts', () => {
|
||||
const wrapper = mountLeaf({ badgeCount: 120 });
|
||||
|
||||
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').text()).toBe(
|
||||
'99+'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes unread count to custom leaf components', () => {
|
||||
const wrapper = mountLeaf({
|
||||
badgeCount: 4,
|
||||
component: leafProps =>
|
||||
h(
|
||||
'span',
|
||||
{ 'data-test-id': 'custom-leaf-count' },
|
||||
leafProps.badgeCount
|
||||
),
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe('4');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
|
||||
import { defineEmits } from 'vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
|
||||
@@ -50,7 +50,7 @@ const { t } = useI18n();
|
||||
</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"
|
||||
class="absolute -top-1 -right-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>
|
||||
|
||||
@@ -339,15 +339,17 @@ export default {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.selector-wrap) {
|
||||
@apply m-0 top-1;
|
||||
::v-deep {
|
||||
.selector-wrap {
|
||||
@apply m-0 top-1;
|
||||
|
||||
.selector-name {
|
||||
.selector-name {
|
||||
@apply ml-0;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
@apply ml-0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.name) {
|
||||
@apply ml-0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, defineEmits } from 'vue';
|
||||
import { useIntersectionObserver } from '@vueuse/core';
|
||||
|
||||
const { options } = defineProps({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
// [TODO] Use Teleport to move the modal to the end of the body
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, computed, defineEmits, onMounted } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { defineEmits, defineModel } from 'vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
|
||||
@@ -63,8 +63,10 @@ export default {
|
||||
.colorpicker--chrome.vc-chrome {
|
||||
@apply shadow-lg -mt-2.5 absolute z-[9999] border border-solid border-n-weak rounded;
|
||||
|
||||
:deep(input) {
|
||||
@apply bg-white dark:bg-white;
|
||||
::v-deep {
|
||||
input {
|
||||
@apply bg-white dark:bg-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+9
-26
@@ -5,10 +5,8 @@ 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 CallCard from 'dashboard/components/widgets/call/CallCard.vue';
|
||||
import countriesList from 'shared/constants/countries.js';
|
||||
|
||||
const RINGTONE_URL = '/audio/dashboard/bell.mp3';
|
||||
@@ -33,7 +31,7 @@ const {
|
||||
// don't expose a mic track on the browser side.
|
||||
const isMuted = ref(false);
|
||||
const isWhatsappActive = computed(
|
||||
() => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
|
||||
() => activeCall.value?.provider === 'whatsapp'
|
||||
);
|
||||
|
||||
const primaryIncomingCall = computed(() =>
|
||||
@@ -45,22 +43,11 @@ const stackedIncomingCalls = computed(() =>
|
||||
);
|
||||
|
||||
const mainCardState = computed(() => {
|
||||
if (hasActiveCall.value) return VOICE_CALL_DIRECTION.ONGOING;
|
||||
if (hasActiveCall.value) return 'ongoing';
|
||||
const direction = primaryIncomingCall.value?.callDirection;
|
||||
return direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||
? VOICE_CALL_DIRECTION.OUTGOING
|
||||
: VOICE_CALL_DIRECTION.INCOMING;
|
||||
return direction === 'outbound' ? 'outgoing' : '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);
|
||||
@@ -182,8 +169,8 @@ watch(
|
||||
() => incomingCalls.value[0],
|
||||
call => {
|
||||
if (
|
||||
call?.callDirection === VOICE_CALL_DIRECTION.OUTBOUND &&
|
||||
call?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP &&
|
||||
call?.callDirection === 'outbound' &&
|
||||
call?.provider !== 'whatsapp' &&
|
||||
!hasActiveCall.value &&
|
||||
WindowVisibilityHelper.isWindowVisible()
|
||||
) {
|
||||
@@ -210,9 +197,7 @@ const stopRingtone = () => {
|
||||
};
|
||||
|
||||
const ringingInbound = computed(() =>
|
||||
incomingCalls.value.some(
|
||||
call => call.callDirection !== VOICE_CALL_DIRECTION.OUTBOUND
|
||||
)
|
||||
incomingCalls.value.some(call => call.callDirection !== 'outbound')
|
||||
);
|
||||
|
||||
watch(
|
||||
@@ -240,11 +225,10 @@ onBeforeUnmount(stopRingtone);
|
||||
v-for="call in stackedIncomingCalls"
|
||||
:key="call.callSid"
|
||||
:call="call"
|
||||
:state="stackedCardState(call)"
|
||||
state="incoming"
|
||||
:call-info="getCallInfo(call)"
|
||||
@accept="handleJoinCall(call)"
|
||||
@reject="rejectIncomingCall(call.callSid)"
|
||||
@dismiss="dismissCall(call.callSid)"
|
||||
@reject="dismissCall(call.callSid)"
|
||||
@go-to-conversation="goToConversation(call)"
|
||||
/>
|
||||
|
||||
@@ -259,7 +243,6 @@ onBeforeUnmount(stopRingtone);
|
||||
: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)"
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import getUuid from 'widget/helpers/uuid';
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { ref, onMounted, onUnmounted, defineEmits, defineExpose } from 'vue';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js';
|
||||
import { format, intervalToDuration } from 'date-fns';
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useElementSize, useWindowSize } from '@vueuse/core';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
|
||||
@@ -18,11 +19,9 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Signature-aware emptiness is computed by the parent (which has access to
|
||||
// the signature + channel context) and passed in as a boolean.
|
||||
hasContent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
editorContent: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
conversationId: {
|
||||
type: Number,
|
||||
@@ -34,8 +33,17 @@ const emit = defineEmits(['executeCopilotAction']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { draftMessage } = useCaptain();
|
||||
|
||||
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
|
||||
|
||||
// When editorContent prop is passed, use it exclusively (even if empty)
|
||||
// This ensures each editor instance shows menu items based on its own content
|
||||
// Falls back to global draftMessage only when editorContent is not provided
|
||||
const effectiveContent = computed(() =>
|
||||
props.editorContent !== undefined ? props.editorContent : draftMessage.value
|
||||
);
|
||||
|
||||
// Selection-based menu items (when text is selected)
|
||||
const menuItems = computed(() => {
|
||||
const items = [];
|
||||
@@ -55,7 +63,7 @@ const menuItems = computed(() => {
|
||||
} else if (
|
||||
props.conversationId &&
|
||||
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
|
||||
props.hasContent
|
||||
effectiveContent.value
|
||||
) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
|
||||
@@ -64,7 +72,7 @@ const menuItems = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
if (props.hasContent) {
|
||||
if (effectiveContent.value) {
|
||||
items.push(
|
||||
{
|
||||
label: t(
|
||||
|
||||
@@ -354,17 +354,16 @@ function isBodyEmpty(content) {
|
||||
// if content is undefined, we assume that the body is empty
|
||||
if (!content) return true;
|
||||
|
||||
// Only strip the signature when it's actually being auto-appended for this
|
||||
// draft. Otherwise an agent whose typed text happens to match their saved
|
||||
// signature would be mistakenly treated as empty.
|
||||
const bodyWithoutSignature =
|
||||
sendWithSignature.value && props.signature
|
||||
? removeSignatureHelper(
|
||||
content,
|
||||
props.signature,
|
||||
effectiveChannelType.value
|
||||
)
|
||||
: content;
|
||||
// if the signature is present, we need to remove it before checking
|
||||
// note that we don't update the editorView, so this is safe
|
||||
// Use effective channel type to match how signature was appended
|
||||
const bodyWithoutSignature = props.signature
|
||||
? removeSignatureHelper(
|
||||
content,
|
||||
props.signature,
|
||||
effectiveChannelType.value
|
||||
)
|
||||
: content;
|
||||
|
||||
// trimming should remove all the whitespaces, so we can check the length
|
||||
return bodyWithoutSignature.trim().length === 0;
|
||||
@@ -475,6 +474,17 @@ function removeSignature() {
|
||||
reloadState(content);
|
||||
}
|
||||
|
||||
function toggleSignatureInEditor(signatureEnabled) {
|
||||
// The toggleSignatureInEditor gets the new value from the
|
||||
// watcher, this means that if the value is true, the signature
|
||||
// is supposed to be added, else we remove it.
|
||||
if (signatureEnabled) {
|
||||
addSignature();
|
||||
} else {
|
||||
removeSignature();
|
||||
}
|
||||
}
|
||||
|
||||
function setToolbarPosition() {
|
||||
const editorRect = editorRoot.value.getBoundingClientRect();
|
||||
const rect = selectedImageNode.value.getBoundingClientRect();
|
||||
@@ -549,20 +559,6 @@ function emitOnChange() {
|
||||
emit('update:modelValue', contentFromEditor());
|
||||
}
|
||||
|
||||
function toggleSignatureInEditor(signatureEnabled) {
|
||||
// The toggleSignatureInEditor gets the new value from the
|
||||
// watcher, this means that if the value is true, the signature
|
||||
// is supposed to be added, else we remove it.
|
||||
if (signatureEnabled) {
|
||||
addSignature();
|
||||
} else {
|
||||
removeSignature();
|
||||
}
|
||||
// reloadState replaces editor state directly and bypasses dispatchTransaction,
|
||||
// so v-model never hears about the signature change — sync it back explicitly.
|
||||
emitOnChange();
|
||||
}
|
||||
|
||||
function updateImgToolbarOnDelete() {
|
||||
// check if the selected node is present or not on keyup
|
||||
// this is needed because the user can select an image and then delete it
|
||||
@@ -589,11 +585,11 @@ function isCmdPlusEnterToSendEnabled() {
|
||||
useKeyboardEvents({
|
||||
'Alt+KeyP': {
|
||||
action: focusEditorInputField,
|
||||
allowOnFocusedInput: false,
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Alt+KeyL': {
|
||||
action: focusEditorInputField,
|
||||
allowOnFocusedInput: false,
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -903,7 +899,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
v-on-click-outside="handleClickOutside"
|
||||
:has-selection="isTextSelected"
|
||||
:is-editor-menu-popover="isEditorMenuPopover"
|
||||
:has-content="!isBodyEmpty(modelValue)"
|
||||
:editor-content="modelValue"
|
||||
:conversation-id="conversationId"
|
||||
:show-selection-menu="showSelectionMenu"
|
||||
:show-general-menu="false"
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as ActiveStorage from 'activestorage';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
import VideoCallButton from '../VideoCallButton.vue';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { mapGetters } from 'vuex';
|
||||
@@ -165,6 +166,11 @@ export default {
|
||||
uploadRef,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
ALLOWED_FILE_TYPES,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
@@ -206,11 +212,13 @@ export default {
|
||||
return this.conversationType === 'instagram_direct_message';
|
||||
},
|
||||
allowedFileTypes() {
|
||||
// Use default file types for private notes
|
||||
if (this.isOnPrivateNote) {
|
||||
return getAllowedFileTypesByChannel();
|
||||
return this.ALLOWED_FILE_TYPES;
|
||||
}
|
||||
|
||||
let channelType = this.channelType || this.inbox?.channel_type;
|
||||
|
||||
if (this.isAnInstagramChannel || this.isInstagramDM) {
|
||||
channelType = INBOX_TYPES.INSTAGRAM;
|
||||
}
|
||||
@@ -419,7 +427,7 @@ export default {
|
||||
@apply flex;
|
||||
}
|
||||
|
||||
:deep(.file-uploads) {
|
||||
::v-deep .file-uploads {
|
||||
label {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
@@ -53,10 +53,6 @@ export default {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
hasContent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['setReplyMode', 'toggleEditorSize', 'executeCopilotAction'],
|
||||
setup(props, { emit }) {
|
||||
@@ -80,7 +76,6 @@ export default {
|
||||
|
||||
const { captainTasksEnabled } = useCaptain();
|
||||
const showCopilotMenu = ref(false);
|
||||
const copilotToggleRef = ref(null);
|
||||
|
||||
const handleCopilotAction = (actionKey, data) => {
|
||||
emit('executeCopilotAction', actionKey, data || props.editorContent);
|
||||
@@ -105,11 +100,11 @@ export default {
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyP': {
|
||||
action: () => handleNoteClick(),
|
||||
allowOnFocusedInput: false,
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Alt+KeyL': {
|
||||
action: () => handleReplyClick(),
|
||||
allowOnFocusedInput: false,
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
@@ -122,7 +117,6 @@ export default {
|
||||
captainTasksEnabled,
|
||||
handleCopilotAction,
|
||||
showCopilotMenu,
|
||||
copilotToggleRef,
|
||||
toggleCopilotMenu,
|
||||
handleClickOutside,
|
||||
};
|
||||
@@ -170,7 +164,6 @@ export default {
|
||||
<div v-if="captainTasksEnabled" class="flex items-center gap-2">
|
||||
<div class="relative">
|
||||
<NextButton
|
||||
ref="copilotToggleRef"
|
||||
ghost
|
||||
:disabled="disabled || isEditorDisabled"
|
||||
:class="{
|
||||
@@ -183,12 +176,9 @@ export default {
|
||||
/>
|
||||
<CopilotMenuBar
|
||||
v-if="showCopilotMenu"
|
||||
v-on-click-outside="[
|
||||
handleClickOutside,
|
||||
{ ignore: [copilotToggleRef] },
|
||||
]"
|
||||
v-on-click-outside="handleClickOutside"
|
||||
:has-selection="false"
|
||||
:has-content="hasContent"
|
||||
:editor-content="editorContent"
|
||||
:conversation-id="conversationId"
|
||||
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
|
||||
@execute-copilot-action="handleCopilotAction"
|
||||
|
||||
+50
-84
@@ -1,11 +1,7 @@
|
||||
<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: {
|
||||
@@ -35,24 +31,13 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits([
|
||||
'accept',
|
||||
'reject',
|
||||
'end',
|
||||
'toggleMute',
|
||||
'goToConversation',
|
||||
'dismiss',
|
||||
]);
|
||||
defineEmits(['accept', 'reject', 'end', 'toggleMute', 'goToConversation']);
|
||||
|
||||
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 isOngoing = computed(() => props.state === 'ongoing');
|
||||
const isIncoming = computed(() => props.state === 'incoming');
|
||||
const isOutgoing = computed(() => props.state === 'outgoing');
|
||||
|
||||
const statusIcon = computed(() => {
|
||||
if (isOngoing.value) return 'i-ph-phone-call-bold';
|
||||
@@ -67,23 +52,21 @@ const statusLabel = computed(() => {
|
||||
});
|
||||
|
||||
const channelIcon = computed(() => {
|
||||
if (props.call?.provider === VOICE_CALL_PROVIDERS.WHATSAPP)
|
||||
return 'i-ri-whatsapp-fill';
|
||||
if (props.call?.provider === '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'"
|
||||
class="flex flex-col gap-3 pt-4 pb-4 bg-n-solid-2/95 rounded-2xl shadow-xl outline outline-1 outline-n-strong backdrop-blur-md"
|
||||
>
|
||||
<!-- Top section: status badge + location/inbox + duration -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-3 pb-2">
|
||||
<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" />
|
||||
<i class="text-sm text-n-teal-9" :class="statusIcon" />
|
||||
<span class="text-xs font-medium text-n-teal-9 tracking-tight">
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
@@ -97,10 +80,10 @@ const channelIcon = computed(() => {
|
||||
>
|
||||
{{ callInfo.countryFlag }}
|
||||
</span>
|
||||
<Icon
|
||||
<i
|
||||
v-else-if="!isOngoing"
|
||||
:icon="channelIcon"
|
||||
class="size-3.5 text-n-slate-10 shrink-0"
|
||||
class="text-sm text-n-slate-10 shrink-0"
|
||||
:class="channelIcon"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-medium text-n-slate-11 tracking-tight truncate"
|
||||
@@ -118,33 +101,20 @@ const channelIcon = computed(() => {
|
||||
</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" />
|
||||
<i class="text-sm text-n-teal-9" :class="statusIcon" />
|
||||
<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="flex items-end gap-3 px-4">
|
||||
<div class="shrink-0">
|
||||
<Avatar
|
||||
:src="callInfo.avatar"
|
||||
:name="callInfo.contactName"
|
||||
:size="40"
|
||||
:size="44"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -164,79 +134,75 @@ const channelIcon = computed(() => {
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<!-- Mute toggle (WhatsApp ongoing only) -->
|
||||
<NextButton
|
||||
<button
|
||||
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'
|
||||
class="flex justify-center items-center w-10 h-10 rounded-full transition-colors"
|
||||
:class="
|
||||
isMuted
|
||||
? 'bg-n-amber-9 hover:bg-n-amber-10 text-white'
|
||||
: 'bg-n-teal-3 hover:bg-n-teal-4 text-n-teal-11'
|
||||
"
|
||||
:variant="isMuted ? 'solid' : 'faded'"
|
||||
:color="isMuted ? 'amber' : 'teal'"
|
||||
class="!rounded-full"
|
||||
@click="$emit('toggleMute')"
|
||||
/>
|
||||
>
|
||||
<i
|
||||
class="text-base"
|
||||
:class="
|
||||
isMuted ? 'i-ph-microphone-slash-bold' : 'i-ph-microphone-bold'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Accept call (incoming only) -->
|
||||
<NextButton
|
||||
<button
|
||||
v-if="isIncoming"
|
||||
v-tooltip.top="$t('CONVERSATION.VOICE_WIDGET.JOIN_CALL')"
|
||||
icon="i-ph-phone-bold"
|
||||
teal
|
||||
class="!rounded-full"
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors shadow-sm"
|
||||
@click="$emit('accept')"
|
||||
/>
|
||||
>
|
||||
<i class="text-base text-white i-ph-phone-bold" />
|
||||
</button>
|
||||
|
||||
<!-- Reject / end call (all states) -->
|
||||
<NextButton
|
||||
<button
|
||||
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]"
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors shadow-sm"
|
||||
@click="isOngoing ? $emit('end') : $emit('reject')"
|
||||
/>
|
||||
>
|
||||
<i class="text-base text-white i-ph-phone-x-bold rotate-[134deg]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer: go to conversation thread -->
|
||||
<NextButton
|
||||
<button
|
||||
v-if="call?.conversationId"
|
||||
slate
|
||||
ghost
|
||||
trailing-icon
|
||||
class="!justify-between !px-2 !mx-2"
|
||||
class="flex items-center justify-between gap-2 px-4 pt-3 border-t border-n-strong hover:bg-n-alpha-1 transition-colors group"
|
||||
@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>
|
||||
<span
|
||||
class="flex items-center gap-1 text-n-slate-11 group-hover:text-n-slate-12"
|
||||
>
|
||||
<i class="text-sm i-ph-chat-circle-text-bold" />
|
||||
<span class="text-sm tracking-tight tabular-nums">
|
||||
#{{ call.conversationId }}
|
||||
</span>
|
||||
<i class="text-xs i-ph-caret-right-bold" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -4,15 +4,22 @@ import { useRoute } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import BackButton from '../BackButton.vue';
|
||||
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
|
||||
import InboxName from '../InboxName.vue';
|
||||
import MoreActions from './MoreActions.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import ConversationCallButton from './ConversationCallButton.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import {
|
||||
getVoiceCallProvider,
|
||||
VOICE_CALL_PROVIDERS,
|
||||
} from 'dashboard/helper/inbox';
|
||||
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
@@ -95,6 +102,97 @@ const hasMultipleInboxes = computed(
|
||||
|
||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
const whatsappCallSession = useWhatsappCallSession();
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
|
||||
const isVoiceCallInbox = computed(() => voiceCallProvider.value !== null);
|
||||
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(
|
||||
currentChat.value.id
|
||||
);
|
||||
|
||||
// Composable returns { status: '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 === 'locked') return;
|
||||
// Permission template path returns no call id — show banner, no widget yet.
|
||||
if (!response?.id) {
|
||||
const status = response?.status;
|
||||
const messageKey =
|
||||
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: currentChat.value.id,
|
||||
inboxId: inbox.value?.id,
|
||||
callDirection: 'outbound',
|
||||
provider: '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: currentContact.value.id,
|
||||
inboxId: inbox.value?.id,
|
||||
conversationId: currentChat.value.id,
|
||||
});
|
||||
|
||||
callsStore.addCall({
|
||||
callSid: response?.call_sid,
|
||||
conversationId: response?.conversation_id ?? currentChat.value.id,
|
||||
inboxId: inbox.value?.id,
|
||||
callDirection: 'outbound',
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CONVERSATION.HEADER.VOICE_CALL_FAILED'));
|
||||
}
|
||||
};
|
||||
|
||||
const startCall = () => {
|
||||
if (isWhatsappVoiceInbox.value) return startWhatsappCall();
|
||||
return startTwilioCall();
|
||||
};
|
||||
|
||||
const copyConversationId = async () => {
|
||||
try {
|
||||
await copyTextToClipboard(String(props.chat.id));
|
||||
@@ -173,7 +271,18 @@ const copyConversationId = async () => {
|
||||
:parent-width="width"
|
||||
class="hidden md:flex"
|
||||
/>
|
||||
<ConversationCallButton :inbox="inbox" :chat="currentChat" />
|
||||
<ButtonV4
|
||||
v-if="isVoiceCallInbox"
|
||||
v-tooltip.bottom="callButtonTooltip"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
icon="i-lucide-phone"
|
||||
:is-loading="isCallButtonLoading"
|
||||
:disabled="isCallButtonDisabled"
|
||||
class="rounded-md hover:bg-n-alpha-2"
|
||||
@click="startCall"
|
||||
/>
|
||||
<MoreActions :conversation-id="currentChat.id" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -179,21 +179,6 @@ export default {
|
||||
}
|
||||
return true;
|
||||
},
|
||||
hasMeaningfulEditorContent() {
|
||||
const body = this.message || '';
|
||||
// Only strip the signature when it's actually being auto-appended.
|
||||
// If the toggle is off, the agent's text might happen to match their
|
||||
// saved signature and we'd incorrectly treat it as empty.
|
||||
const shouldStripSignature =
|
||||
!this.isPrivate && this.sendWithSignature && !!this.messageSignature;
|
||||
if (!shouldStripSignature) return !!body.trim();
|
||||
const stripped = removeSignature(
|
||||
body,
|
||||
this.messageSignature,
|
||||
getEffectiveChannelType(this.channelType, this.inbox?.medium || '')
|
||||
);
|
||||
return !!stripped.trim();
|
||||
},
|
||||
isReplyRestricted() {
|
||||
return (
|
||||
!this.currentChat?.can_reply &&
|
||||
@@ -1246,7 +1231,6 @@ export default {
|
||||
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
||||
:characters-remaining="charactersRemaining"
|
||||
:editor-content="message"
|
||||
:has-content="hasMeaningfulEditorContent"
|
||||
@set-reply-mode="setReplyMode"
|
||||
@toggle-editor-size="toggleEditorSize"
|
||||
@toggle-copilot="copilot.toggleEditor"
|
||||
|
||||
+10
-49
@@ -14,11 +14,6 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'conversation',
|
||||
},
|
||||
action: {
|
||||
type: String,
|
||||
default: 'assign',
|
||||
validator: value => ['assign', 'remove'].includes(value),
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -27,13 +22,9 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
appliedLabels: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['assign', 'remove']);
|
||||
const emit = defineEmits(['assign']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -44,43 +35,17 @@ const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
const selectedLabels = ref([]);
|
||||
|
||||
const isTypeContact = computed(() => props.type === 'contact');
|
||||
const isRemoveAction = computed(() => props.action === 'remove');
|
||||
|
||||
const buttonLabel = computed(() => {
|
||||
if (!isTypeContact.value) return '';
|
||||
|
||||
return isRemoveAction.value
|
||||
? t('CONTACTS_BULK_ACTIONS.REMOVE_LABELS')
|
||||
: t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS');
|
||||
});
|
||||
|
||||
const tooltipLabel = computed(() =>
|
||||
isRemoveAction.value
|
||||
? t('BULK_ACTION.LABELS.REMOVE_LABELS')
|
||||
: t('BULK_ACTION.LABELS.ASSIGN_LABELS')
|
||||
);
|
||||
|
||||
const confirmLabel = computed(() =>
|
||||
isRemoveAction.value
|
||||
? t('BULK_ACTION.LABELS.REMOVE_SELECTED_LABELS')
|
||||
: t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')
|
||||
const buttonLabel = computed(() =>
|
||||
props.type === 'contact' ? t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS') : ''
|
||||
);
|
||||
|
||||
const isLabelSelected = labelTitle => {
|
||||
return selectedLabels.value.includes(labelTitle);
|
||||
};
|
||||
|
||||
const visibleLabels = computed(() => {
|
||||
if (!isRemoveAction.value || props.appliedLabels === null) {
|
||||
return labels.value;
|
||||
}
|
||||
|
||||
const applied = new Set(props.appliedLabels);
|
||||
return labels.value.filter(label => applied.has(label.title));
|
||||
});
|
||||
|
||||
const labelMenuItems = computed(() => {
|
||||
return visibleLabels.value.map(label => ({
|
||||
return labels.value.map(label => ({
|
||||
action: 'select',
|
||||
value: label.title,
|
||||
label: label.title,
|
||||
@@ -99,13 +64,9 @@ const toggleLabelSelection = labelTitle => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
const handleAssign = () => {
|
||||
if (selectedLabels.value.length > 0) {
|
||||
if (isRemoveAction.value) {
|
||||
emit('remove', selectedLabels.value);
|
||||
} else {
|
||||
emit('assign', selectedLabels.value);
|
||||
}
|
||||
emit('assign', selectedLabels.value);
|
||||
toggleDropdown(false);
|
||||
selectedLabels.value = [];
|
||||
}
|
||||
@@ -120,9 +81,9 @@ const handleDismiss = () => {
|
||||
<template>
|
||||
<div ref="containerRef" class="relative">
|
||||
<NextButton
|
||||
v-tooltip="tooltipLabel"
|
||||
v-tooltip="isTypeContact ? '' : $t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
|
||||
:label="buttonLabel"
|
||||
:icon="isRemoveAction ? 'i-woot-tag-remove' : 'i-lucide-tag'"
|
||||
icon="i-lucide-tag"
|
||||
slate
|
||||
:size="isTypeContact ? 'sm' : 'xs'"
|
||||
ghost
|
||||
@@ -187,9 +148,9 @@ const handleDismiss = () => {
|
||||
<NextButton
|
||||
sm
|
||||
class="w-full [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
|
||||
:label="confirmLabel"
|
||||
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
|
||||
:disabled="!selectedLabels.length"
|
||||
@click="handleApply"
|
||||
@click="handleAssign"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
-19
@@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, useAttrs } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { getUnixTime } from 'date-fns';
|
||||
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
@@ -56,25 +55,12 @@ defineOptions({
|
||||
const attrs = useAttrs();
|
||||
|
||||
const {
|
||||
selectedConversations,
|
||||
onAssignAgent,
|
||||
onAssignLabels,
|
||||
onRemoveLabels,
|
||||
onAssignTeamsForBulk: onAssignTeam,
|
||||
onUpdateConversations,
|
||||
} = useBulkActions();
|
||||
|
||||
const getConversationById = useMapGetter('getConversationById');
|
||||
|
||||
const appliedLabelsForSelection = computed(() => {
|
||||
const applied = new Set();
|
||||
selectedConversations.value.forEach(id => {
|
||||
const conversation = getConversationById.value(id);
|
||||
(conversation?.labels || []).forEach(label => applied.add(label));
|
||||
});
|
||||
return Array.from(applied);
|
||||
});
|
||||
|
||||
const showCustomTimeSnoozeModal = ref(false);
|
||||
|
||||
function onCmdSnoozeConversation(snoozeType) {
|
||||
@@ -175,11 +161,6 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<BulkLabelActions @assign="onAssignLabels" />
|
||||
<BulkLabelActions
|
||||
action="remove"
|
||||
:applied-labels="appliedLabelsForSelection"
|
||||
@remove="onRemoveLabels"
|
||||
/>
|
||||
<BulkUpdateActions
|
||||
:show-resolve="!showResolvedAction"
|
||||
:show-reopen="!showOpenAction"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, defineOptions } from 'vue';
|
||||
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
|
||||
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ const variableKey = (item = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
.canned-item__button :deep(.button__content) {
|
||||
.canned-item__button::v-deep .button__content {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -108,7 +108,7 @@ export function useBulkActions() {
|
||||
}
|
||||
}
|
||||
|
||||
// Used by both context menu and bulk action bar.
|
||||
// Only used in context menu
|
||||
async function onRemoveLabels(labelsToRemove, conversationId = null) {
|
||||
try {
|
||||
await store.dispatch('bulkActions/process', {
|
||||
@@ -119,24 +119,14 @@ export function useBulkActions() {
|
||||
},
|
||||
});
|
||||
|
||||
// Context-menu remove should not disturb an existing bulk selection.
|
||||
if (conversationId) {
|
||||
useAlert(
|
||||
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
|
||||
labelName: labelsToRemove[0],
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
store.dispatch('bulkActions/clearSelectedConversationIds');
|
||||
useAlert(t('BULK_ACTION.LABELS.REMOVE_SUCCESFUL'));
|
||||
}
|
||||
} catch (err) {
|
||||
useAlert(
|
||||
conversationId
|
||||
? t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED')
|
||||
: t('BULK_ACTION.LABELS.REMOVE_FAILED')
|
||||
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
|
||||
labelName: labelsToRemove[0],
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +1,6 @@
|
||||
const keyboardEventsMock = vi.hoisted(() => ({
|
||||
mountedCallbacks: [],
|
||||
unmountedCallbacks: [],
|
||||
registeredKeybindings: {},
|
||||
}));
|
||||
|
||||
vi.mock('vue', async importOriginal => ({
|
||||
...(await importOriginal()),
|
||||
onMounted: vi.fn(callback =>
|
||||
keyboardEventsMock.mountedCallbacks.push(callback)
|
||||
),
|
||||
onUnmounted: vi.fn(callback =>
|
||||
keyboardEventsMock.unmountedCallbacks.push(callback)
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/useDetectKeyboardLayout', () => ({
|
||||
useDetectKeyboardLayout: vi.fn(() => Promise.resolve('QWERTY')),
|
||||
}));
|
||||
|
||||
vi.mock('tinykeys', () => ({
|
||||
createKeybindingsHandler: vi.fn(keybindings => {
|
||||
keyboardEventsMock.registeredKeybindings = keybindings;
|
||||
return event => keybindings[event.shortcut]?.(event);
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { LAYOUT_QWERTZ } from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
const mount = async events => {
|
||||
useKeyboardEvents(events);
|
||||
await keyboardEventsMock.mountedCallbacks[0]();
|
||||
return keyboardEventsMock.registeredKeybindings;
|
||||
};
|
||||
|
||||
describe('useKeyboardEvents', () => {
|
||||
beforeEach(() => {
|
||||
keyboardEventsMock.mountedCallbacks = [];
|
||||
keyboardEventsMock.unmountedCallbacks = [];
|
||||
keyboardEventsMock.registeredKeybindings = {};
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(useKeyboardEvents).toBeDefined();
|
||||
});
|
||||
@@ -50,46 +9,18 @@ describe('useKeyboardEvents', () => {
|
||||
expect(useKeyboardEvents).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('registers keybindings on mount and removes the listener on unmount', async () => {
|
||||
const addEventListener = vi.spyOn(document, 'addEventListener');
|
||||
it('should set up listeners on mount and remove them on unmount', async () => {
|
||||
const events = {
|
||||
'ALT+KeyL': () => {},
|
||||
};
|
||||
|
||||
const keybindings = await mount({ 'ALT+KeyL': () => {} });
|
||||
expect(Object.keys(keybindings)).toEqual(['ALT+KeyL']);
|
||||
const mountedMock = vi.fn();
|
||||
const unmountedMock = vi.fn();
|
||||
useKeyboardEvents(events);
|
||||
mountedMock();
|
||||
unmountedMock();
|
||||
|
||||
const { signal } = addEventListener.mock.calls[0][2];
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
keyboardEventsMock.unmountedCallbacks[0]();
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('prefixes Shift on QWERTZ layouts for the affected keys', async () => {
|
||||
useDetectKeyboardLayout.mockResolvedValueOnce(LAYOUT_QWERTZ);
|
||||
|
||||
const keybindings = await mount({ 'Alt+KeyL': () => {} });
|
||||
|
||||
expect(Object.keys(keybindings)).toEqual(['Shift+Alt+KeyL']);
|
||||
});
|
||||
|
||||
it('ignores shortcuts on focused typeable elements by default', async () => {
|
||||
const action = vi.fn();
|
||||
const keybindings = await mount({
|
||||
'Alt+KeyL': { action, allowOnFocusedInput: false },
|
||||
});
|
||||
|
||||
keybindings['Alt+KeyL']({ target: document.createElement('textarea') });
|
||||
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs shortcuts on focused typeable elements when allowed', async () => {
|
||||
const action = vi.fn();
|
||||
const keybindings = await mount({
|
||||
'Alt+KeyL': { action, allowOnFocusedInput: true },
|
||||
});
|
||||
|
||||
keybindings['Alt+KeyL']({ target: document.createElement('textarea') });
|
||||
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
expect(mountedMock).toHaveBeenCalled();
|
||||
expect(unmountedMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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 VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||
@@ -11,15 +11,9 @@ import {
|
||||
cleanupWhatsappSession,
|
||||
} from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { handleVoiceCallCreated } from 'dashboard/helper/voice';
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
CONTENT_TYPES,
|
||||
VOICE_CALL_DIRECTION,
|
||||
VOICE_CALL_STATUS,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
import Timer from 'dashboard/helper/Timer';
|
||||
|
||||
const isWhatsappCall = call => call?.provider === VOICE_CALL_PROVIDERS.WHATSAPP;
|
||||
const isWhatsappCall = call => call?.provider === 'whatsapp';
|
||||
|
||||
// Dismissed call sids must not be re-seeded by the conversation-load watcher.
|
||||
// Lives at module scope so all consumers share the same set.
|
||||
@@ -38,7 +32,6 @@ 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;
|
||||
@@ -98,16 +91,10 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// try/finally so a failed leaveConference (e.g. backend 5xx) still
|
||||
// tears down the local Device and UI state — otherwise the call stays
|
||||
// visually active with the mic open.
|
||||
try {
|
||||
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
|
||||
} finally {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
globalDurationTimer?.stop();
|
||||
callsStore.clearActiveCall();
|
||||
}
|
||||
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
|
||||
TwilioVoiceClient.endClientCall();
|
||||
globalDurationTimer?.stop();
|
||||
callsStore.clearActiveCall();
|
||||
};
|
||||
|
||||
const joinCall = async ({ conversationId, inboxId, callSid }) => {
|
||||
@@ -120,10 +107,7 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => {
|
||||
// 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)
|
||||
) {
|
||||
if (call?.callDirection === 'outbound' && isWhatsappCall(call)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -165,11 +149,6 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
markDismissed(callSid);
|
||||
callsStore.dismissCall(callSid);
|
||||
} else if (!isWhatsappCall(call)) {
|
||||
// Tear down the Twilio Device on any other join error so a retry
|
||||
// starts from a clean state — joinClientCall can leave the device
|
||||
// half-initialized after a network blip.
|
||||
TwilioVoiceClient.endClientCall();
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to join call:', error);
|
||||
@@ -188,7 +167,7 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => {
|
||||
const call = findCall(callSid);
|
||||
try {
|
||||
if (isWhatsappCall(call) && call?.callId) {
|
||||
if (call.callDirection === VOICE_CALL_DIRECTION.OUTBOUND) {
|
||||
if (call.callDirection === '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);
|
||||
@@ -235,7 +214,7 @@ const buildReactiveSurface = callsStore => {
|
||||
activeCall,
|
||||
incomingCalls,
|
||||
hasActiveCall,
|
||||
isJoining: globalIsJoiningReadonly,
|
||||
isJoining: globalIsJoining,
|
||||
formattedCallDuration,
|
||||
};
|
||||
};
|
||||
@@ -263,8 +242,8 @@ export function useCallSession() {
|
||||
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;
|
||||
if (msg.content_type !== 'voice_call') return;
|
||||
if (msg.call?.status !== 'ringing') return;
|
||||
const callSid = msg.call?.provider_call_id;
|
||||
if (callSid && dismissedCallSids.has(callSid)) return;
|
||||
handleVoiceCallCreated(msg, currentUserId, currentUserAvailability);
|
||||
|
||||
@@ -36,18 +36,11 @@ export function useConfig() {
|
||||
*/
|
||||
const enterprisePlanName = config.enterprisePlanName;
|
||||
|
||||
/**
|
||||
* Indicates whether inbox webhook events (ENABLE_INBOX_EVENTS) are enabled.
|
||||
* @type {boolean}
|
||||
*/
|
||||
const inboxEventsEnabled = config.inboxEventsEnabled === 'true';
|
||||
|
||||
return {
|
||||
hostURL,
|
||||
vapidPublicKey,
|
||||
enabledLanguages,
|
||||
isEnterprise,
|
||||
enterprisePlanName,
|
||||
inboxEventsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readonly, ref } from 'vue';
|
||||
import { 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.
|
||||
@@ -13,20 +12,12 @@ 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
|
||||
@@ -136,7 +127,6 @@ const cleanup = () => {
|
||||
recorderChunks = [];
|
||||
audioContext = null;
|
||||
activeCallId = null;
|
||||
pendingOutboundAnswers.clear();
|
||||
recorderArmed = false;
|
||||
};
|
||||
|
||||
@@ -281,23 +271,14 @@ export function useWhatsappCallSession() {
|
||||
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 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);
|
||||
};
|
||||
|
||||
const rejectIncomingCall = async callId => {
|
||||
@@ -313,10 +294,8 @@ export function useWhatsappCallSession() {
|
||||
// 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 };
|
||||
if (isInitiatingOutbound.value) return { status: 'locked' };
|
||||
if (hasActiveWhatsappCall()) return { status: 'locked' };
|
||||
isInitiatingOutbound.value = true;
|
||||
try {
|
||||
const sdpOffer = await prepareOutboundOffer();
|
||||
@@ -326,17 +305,6 @@ export function useWhatsappCallSession() {
|
||||
);
|
||||
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 +
|
||||
@@ -351,8 +319,8 @@ export function useWhatsappCallSession() {
|
||||
// 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
|
||||
data?.status === 'permission_requested' ||
|
||||
data?.status === 'permission_pending'
|
||||
) {
|
||||
return { status: data.status };
|
||||
}
|
||||
@@ -373,18 +341,15 @@ export function useWhatsappCallSession() {
|
||||
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);
|
||||
await WhatsappCallsAPI.terminate(callId).catch(() => {});
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isInitiating: isInitiatingOutboundReadonly,
|
||||
isInitiating: isInitiatingOutbound,
|
||||
prepareInboundAnswer,
|
||||
prepareOutboundOffer,
|
||||
acceptIncomingCall,
|
||||
@@ -398,17 +363,11 @@ export function useWhatsappCallSession() {
|
||||
// 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;
|
||||
// voice_call.outbound_connected is broadcast account-wide; only apply
|
||||
// the SDP answer if it's for this tab's in-flight outbound call.
|
||||
if (activeCallId != null && callId !== activeCallId) return;
|
||||
activeCallId = callId;
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
|
||||
};
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ export const FEATURE_FLAGS = {
|
||||
COMPANIES: 'companies',
|
||||
ADVANCED_SEARCH: 'advanced_search',
|
||||
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
|
||||
CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
|
||||
};
|
||||
|
||||
export const PREMIUM_FEATURES = [
|
||||
|
||||
@@ -11,20 +11,14 @@ import {
|
||||
handleWhatsappRemoteEnd,
|
||||
isLocalWhatsappCall,
|
||||
} from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
|
||||
|
||||
class ActionCableConnector extends BaseActionCableConnector {
|
||||
constructor(app, pubsubToken) {
|
||||
const { websocketURL = '' } = window.chatwootConfig || {};
|
||||
super(app, pubsubToken, websocketURL);
|
||||
this.CancelTyping = [];
|
||||
this.lastUnreadCountsFetchAt = null;
|
||||
this.unreadCountsFetchTimer = null;
|
||||
this.events = {
|
||||
'message.created': this.onMessageCreated,
|
||||
'message.updated': this.onMessageUpdated,
|
||||
@@ -45,8 +39,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'notification.updated': this.onNotificationUpdated,
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'conversation.unread_count_changed':
|
||||
this.onConversationUnreadCountChanged,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'account.enrichment_completed': this.onEnrichmentCompleted,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
@@ -139,56 +131,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.fetchConversationStats();
|
||||
};
|
||||
|
||||
onConversationUnreadCountChanged = () => {
|
||||
this.throttledFetchConversationUnreadCounts();
|
||||
};
|
||||
|
||||
throttledFetchConversationUnreadCounts = () => {
|
||||
const now = Date.now();
|
||||
const elapsedTime = now - this.lastUnreadCountsFetchAt;
|
||||
|
||||
if (
|
||||
this.lastUnreadCountsFetchAt === null ||
|
||||
elapsedTime >= UNREAD_COUNTS_REFETCH_THROTTLE_MS
|
||||
) {
|
||||
this.clearUnreadCountsFetchTimer();
|
||||
this.fetchConversationUnreadCounts();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.unreadCountsFetchTimer) return;
|
||||
|
||||
this.unreadCountsFetchTimer = setTimeout(() => {
|
||||
this.unreadCountsFetchTimer = null;
|
||||
this.fetchConversationUnreadCounts();
|
||||
}, UNREAD_COUNTS_REFETCH_THROTTLE_MS - elapsedTime);
|
||||
};
|
||||
|
||||
clearUnreadCountsFetchTimer = () => {
|
||||
if (!this.unreadCountsFetchTimer) return;
|
||||
|
||||
clearTimeout(this.unreadCountsFetchTimer);
|
||||
this.unreadCountsFetchTimer = null;
|
||||
};
|
||||
|
||||
fetchConversationUnreadCounts = () => {
|
||||
if (!this.isConversationUnreadCountsEnabled()) return;
|
||||
|
||||
this.lastUnreadCountsFetchAt = Date.now();
|
||||
this.app.$store.dispatch('conversationUnreadCounts/get');
|
||||
};
|
||||
|
||||
isConversationUnreadCountsEnabled = () => {
|
||||
const accountId = this.app.$store.getters.getCurrentAccountId;
|
||||
const isFeatureEnabled =
|
||||
this.app.$store.getters['accounts/isFeatureEnabledonAccount'];
|
||||
|
||||
return isFeatureEnabled?.(
|
||||
accountId,
|
||||
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
|
||||
);
|
||||
};
|
||||
|
||||
onTypingOn = ({ conversation, user }) => {
|
||||
const conversationId = conversation.id;
|
||||
|
||||
@@ -276,7 +218,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
};
|
||||
|
||||
onVoiceCallIncoming = data => {
|
||||
if (data?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP) return;
|
||||
if (data?.provider !== '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.
|
||||
@@ -288,8 +230,8 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
callId: data.id,
|
||||
conversationId: data.conversation_id,
|
||||
inboxId: data.inbox_id,
|
||||
callDirection: VOICE_CALL_DIRECTION.INBOUND,
|
||||
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
|
||||
callDirection: 'inbound',
|
||||
provider: 'whatsapp',
|
||||
sdpOffer: data.sdp_offer,
|
||||
iceServers: data.ice_servers,
|
||||
caller: data.caller,
|
||||
@@ -301,11 +243,11 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
// 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.
|
||||
if (data?.provider !== 'whatsapp' || !data.sdp_answer) return;
|
||||
// Account-wide broadcast: skip SDPs for calls another agent is handling,
|
||||
// otherwise applyOutboundAnswer would feed a foreign SDP into this tab's
|
||||
// peer connection.
|
||||
if (!isLocalWhatsappCall(data.id)) return;
|
||||
try {
|
||||
await applyOutboundAnswer(data.id, data.sdp_answer);
|
||||
} catch (_) {
|
||||
@@ -317,7 +259,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
// 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;
|
||||
if (data?.provider !== 'whatsapp') return;
|
||||
const store = useCallsStore();
|
||||
if (!store.calls.some(c => c.callSid === data.call_id)) return;
|
||||
store.setCallActive(data.call_id);
|
||||
@@ -326,7 +268,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallEnded = async data => {
|
||||
if (data?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP) return;
|
||||
if (data?.provider !== '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
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {
|
||||
MessageMarkdownSerializer,
|
||||
MessageMarkdownTransformer,
|
||||
messageSchema,
|
||||
MessageMarkdownTransformer,
|
||||
MessageMarkdownSerializer,
|
||||
Selection,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
/**
|
||||
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||
import ActionCableConnector from '../actionCable';
|
||||
|
||||
vi.mock('shared/helpers/mitt', () => ({
|
||||
@@ -30,17 +30,12 @@ describe('ActionCableConnector - Copilot Tests', () => {
|
||||
dispatch: mockDispatch,
|
||||
getters: {
|
||||
getCurrentAccountId: 1,
|
||||
'accounts/isFeatureEnabledonAccount': vi.fn(() => true),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
actionCable = ActionCableConnector.init(store.$store, 'test-token');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
describe('copilot event handlers', () => {
|
||||
it('should register the copilot.message.created event handler', () => {
|
||||
expect(Object.keys(actionCable.events)).toContain(
|
||||
@@ -69,95 +64,4 @@ describe('ActionCableConnector - Copilot Tests', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conversation unread count event handlers', () => {
|
||||
it('should register the conversation.unread_count_changed event handler', () => {
|
||||
expect(Object.keys(actionCable.events)).toContain(
|
||||
'conversation.unread_count_changed'
|
||||
);
|
||||
expect(actionCable.events['conversation.unread_count_changed']).toBe(
|
||||
actionCable.onConversationUnreadCountChanged
|
||||
);
|
||||
});
|
||||
|
||||
it('should refetch unread counts when unread count changes', () => {
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
|
||||
});
|
||||
|
||||
it('does not refetch unread counts when unread count feature is disabled', () => {
|
||||
store.$store.getters[
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
].mockReturnValue(false);
|
||||
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalledWith(
|
||||
'conversationUnreadCounts/get'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throttle unread count refetches for repeated events', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(4999);
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(2);
|
||||
expect(mockDispatch).toHaveBeenLastCalledWith(
|
||||
'conversationUnreadCounts/get'
|
||||
);
|
||||
});
|
||||
|
||||
it('clears pending unread count refetch before immediate refetch', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:06Z'));
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.advanceTimersByTime(4000);
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import { EditorState, EditorView } from '@chatwoot/prosemirror-schema';
|
||||
import { FORMATTING } from 'dashboard/constants/editor';
|
||||
import { Schema } from 'prosemirror-model';
|
||||
import {
|
||||
appendSignature,
|
||||
calculateMenuPosition,
|
||||
cleanSignature,
|
||||
collapseSelection,
|
||||
extractTextFromMarkdown,
|
||||
findNodeToInsertImage,
|
||||
findSignatureInBody,
|
||||
getContentNode,
|
||||
getFormattingForEditor,
|
||||
getMenuAnchor,
|
||||
getSelectionCoords,
|
||||
insertAtCursor,
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
replaceSignature,
|
||||
setURLWithQueryAndSize,
|
||||
stripInlineBase64Images,
|
||||
stripUnsupportedFormatting,
|
||||
cleanSignature,
|
||||
extractTextFromMarkdown,
|
||||
stripUnsupportedMarkdown,
|
||||
insertAtCursor,
|
||||
findNodeToInsertImage,
|
||||
setURLWithQueryAndSize,
|
||||
getContentNode,
|
||||
getFormattingForEditor,
|
||||
getSelectionCoords,
|
||||
getMenuAnchor,
|
||||
calculateMenuPosition,
|
||||
stripUnsupportedFormatting,
|
||||
stripInlineBase64Images,
|
||||
collapseSelection,
|
||||
} 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
|
||||
const schema = new Schema({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
|
||||
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import types from 'dashboard/store/mutation-types';
|
||||
|
||||
@@ -59,10 +58,6 @@ 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 {
|
||||
|
||||
@@ -27,13 +27,9 @@
|
||||
},
|
||||
"LABELS": {
|
||||
"ASSIGN_LABELS": "Assign labels",
|
||||
"REMOVE_LABELS": "Remove labels",
|
||||
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
|
||||
"REMOVE_SELECTED_LABELS": "Remove selected labels",
|
||||
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
|
||||
"ASSIGN_FAILED": "Failed to assign labels. Please try again.",
|
||||
"REMOVE_SUCCESFUL": "Labels removed successfully.",
|
||||
"REMOVE_FAILED": "Failed to remove labels. Please try again."
|
||||
"ASSIGN_FAILED": "Failed to assign labels. Please try again."
|
||||
},
|
||||
"TEAMS": {
|
||||
"NONE": "None",
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
"CALL": "Call",
|
||||
"CALL_INITIATED": "Calling the contact…",
|
||||
"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",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
@@ -586,11 +584,8 @@
|
||||
},
|
||||
"CONTACTS_BULK_ACTIONS": {
|
||||
"ASSIGN_LABELS": "Assign Labels",
|
||||
"REMOVE_LABELS": "Remove Labels",
|
||||
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
|
||||
"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.",
|
||||
"NO_LABELS_FOUND": "No labels available yet.",
|
||||
"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_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_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
|
||||
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
|
||||
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
|
||||
"NO_RESPONSE": "No response",
|
||||
@@ -86,16 +85,13 @@
|
||||
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
|
||||
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
|
||||
"CALL_ENDED": "Call ended",
|
||||
"HANDLED_BY": "Handled by {agentName}",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"CALLING": "Calling…",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"AGENT_ANSWERED": "{agentName} answered",
|
||||
"JOIN_CALL": "Join call",
|
||||
"CALL_BACK": "Call back",
|
||||
"TRANSCRIPT_SHOW_MORE": "Show more",
|
||||
"TRANSCRIPT_SHOW_LESS": "Show less"
|
||||
"CALL_BACK": "Call back"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
@@ -315,7 +311,6 @@
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
|
||||
"REJECT_CALL": "Reject",
|
||||
"DISMISS_CALL": "Dismiss",
|
||||
"JOIN_CALL": "Join call",
|
||||
"END_CALL": "End call",
|
||||
"MUTE": "Mute mic",
|
||||
|
||||
@@ -607,12 +607,9 @@
|
||||
"DRAFT": "Draft",
|
||||
"ARCHIVE": "Archive",
|
||||
"TRANSLATE": "Translate",
|
||||
"MOVE_TO_CATEGORY": "Category",
|
||||
"DELETE": "Delete",
|
||||
"STATUS_SUCCESS": "Articles updated successfully",
|
||||
"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_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",
|
||||
|
||||
@@ -57,8 +57,7 @@
|
||||
"CONTACT_CREATED": "Contact created",
|
||||
"CONTACT_UPDATED": "Contact updated",
|
||||
"CONVERSATION_TYPING_ON": "Conversation Typing On",
|
||||
"CONVERSATION_TYPING_OFF": "Conversation Typing Off",
|
||||
"INBOX_UPDATED": "Inbox updated"
|
||||
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
|
||||
}
|
||||
},
|
||||
"NAME": {
|
||||
|
||||
@@ -272,11 +272,13 @@ export default {
|
||||
}
|
||||
|
||||
.context-menu--delete-modal {
|
||||
:deep(.modal-container) {
|
||||
@apply max-w-[30rem];
|
||||
::v-deep {
|
||||
.modal-container {
|
||||
@apply max-w-[30rem];
|
||||
|
||||
h2 {
|
||||
@apply font-medium text-base;
|
||||
h2 {
|
||||
@apply font-medium text-base;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,12 +104,12 @@ const authorText = computed(() => {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.message-content :deep(p) {
|
||||
.message-content::v-deep p {
|
||||
@apply inline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-content :deep(br) {
|
||||
.message-content::v-deep br {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ref, computed, defineModel, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, defineModel } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, defineModel } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import SearchDateRangeSelector from './SearchDateRangeSelector.vue';
|
||||
import SearchContactAgentSelector from './SearchContactAgentSelector.vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, watch, useTemplateRef } from 'vue';
|
||||
import { ref, watch, useTemplateRef, defineModel } from 'vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import { ROLES } from 'dashboard/constants/permissions';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, defineModel } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { defineProps, computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import SearchResultSection from './SearchResultSection.vue';
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
|
||||
@@ -17,7 +17,7 @@ const CommandBar = defineAsyncComponent(
|
||||
);
|
||||
|
||||
const FloatingCallWidget = defineAsyncComponent(
|
||||
() => import('dashboard/components-next/call/FloatingCallWidget.vue')
|
||||
() => import('dashboard/components/widgets/FloatingCallWidget.vue')
|
||||
);
|
||||
|
||||
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user