Compare commits

..
Author SHA1 Message Date
Tanmay Deep Sharma ac4b801930 fix(voice): reuse open conversation for inbound calls instead of always creating new 2026-05-20 17:02:22 +05:30
Tanmay Deep Sharma 4b2bbd38e7 fix(voice): re-check call state under lock before forcing agent_rejected 2026-05-20 16:41:48 +05:30
Tanmay Deep Sharma 2f36c3be9c fix(voice): require embedded-signup cloud inbox to toggle WhatsApp calling 2026-05-20 16:39:49 +05:30
Tanmay Deep Sharma f5691eadc9 fix(voice): end Twilio conference before marking inbound reject terminal 2026-05-20 16:30:31 +05:30
Tanmay Deep Sharma 151d9d8117 fix(voice): skip provider config re-check when disabling WhatsApp calling 2026-05-20 16:30:29 +05:30
Tanmay Deep Sharma bff08d615a feat(voice): WhatsApp WABA calling backend — status sync, webhook, incoming pipeline
Backend for WhatsApp WABA calling: query/enable/disable calling status via Meta, webhook re-subscribe, inbox policy, and incoming call service wiring. Builds on the WhatsApp calling UI branch.
2026-05-20 16:22:38 +05:30
Tanmay Deep Sharma a5ce1edf20 feat(voice): WhatsApp calling UI — inbox toggle, call widget, badges
Frontend for WhatsApp WABA calling: calling status toggle in inbox settings, WhatsApp Call channel tile, voice badges, call widget redesign, and conversation header call button.
2026-05-20 15:57:05 +05:30
Tanmay Deep Sharma 0ef7585ccb Merge remote-tracking branch 'origin/develop' into feat/whatsapp-call-ui 2026-05-20 15:56:35 +05:30
Tanmay Deep Sharma 3d914065e0 feat(inbox): preserve channel logo with voice badge in inbox/sidebar
Inboxes with voice enabled were showing a generic phone icon in the
inbox list and sidebar, hiding the underlying channel (e.g. WhatsApp).
Keep the native channel logo and overlay an audio-waveform badge when
voice is enabled. The native Twilio Voice inbox (TwilioSms with no
WhatsApp medium) still resolves to the phone icon.
2026-05-13 16:28:40 +05:30
Tanmay Deep Sharma 5d9c979ace feat(voice): resolve country name from country_code in call widget
When a contact has only country_code (e.g. "US") stored in
additional_attributes and not the full country name, the floating call
widget now falls back to the shared countries list to render
"City, Country" in the location line.
2026-05-13 15:02:36 +05:30
Tanmay Deep Sharma a985010a40 feat(inbox): show voice badge on channel cards when voice is enabled
Adds an audio-waveform badge overlay on the WhatsApp and Voice (Twilio)
channel cards in the new-inbox selector when the channel_voice feature
flag is enabled on the account.
2026-05-13 15:02:28 +05:30
Tanmay Deep SharmaandClaude Opus 4.7 ddc863c6f7 feat(voice): Redesign call widget and in-thread call bubbles
Brings the floating call widget and in-thread voice-call bubbles in
line with the new Figma design across Twilio and WhatsApp, light and
dark.

- New CallCard component for the floating widget with incoming /
  outgoing / ongoing states, rounded-square avatar, status badges,
  duration timer, and "Go to conversation thread" footer.
- Floating widget header now shows the contact's city, country, and
  country flag (derived from country_code) when available, falling
  back to the channel icon + inbox name.
- In-thread VoiceCall bubble redesigned: 44x44 tonal icon container,
  title + duration/subtext, audio player + transcript when a
  recording exists, and a "Call back" pill for missed inbound calls.
- isOutbound on the call bubble now accepts both outgoing/outbound
  and incoming/inbound spellings and falls back to message
  orientation, so the label can no longer disagree with which side
  of the thread the bubble sits on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:44:20 +05:30
Tanmay Deep Sharma d765e7c106 Merge remote-tracking branch 'origin/develop' into feat/whatsapp-call-ui
# Conflicts:
#	spec/enterprise/services/voice/inbound_call_builder_spec.rb
2026-05-12 17:38:27 +05:30
Tanmay Deep Sharma 0bc890302b Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-08 12:20:12 +05:30
Tanmay Deep Sharma 68973c3b7b Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-08 12:20:02 +05:30
Tanmay Deep Sharma 0875ae719e fix(voice): only reuse open conversations for Twilio outbound calls
Message#reopen_conversation bails on outgoing messages, so reusing a
resolved/snoozed/pending conversation hint left the call bubble inside
a thread that stays in its prior status. Restrict the existing-conversation
hint to open conversations and fall through to creating a fresh one
otherwise.
2026-05-08 12:19:52 +05:30
Tanmay Deep Sharma e7d9165945 fix(voice): only reuse open conversations for Twilio outbound calls
Message#reopen_conversation bails on outgoing messages, so reusing a
resolved/snoozed/pending conversation hint left the call bubble inside
a thread that stays in its prior status. Restrict the existing-conversation
hint to open conversations and fall through to creating a fresh one
otherwise.
2026-05-08 12:19:26 +05:30
Tanmay Deep Sharma 2bb358a787 Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-07 20:11:37 +07:00
Tanmay Deep Sharma 872f09f720 Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-07 20:11:31 +07:00
Tanmay Deep Sharma 9e56a024c5 fix(voice): honor Meta failure reasons on terminate
Call::TERMINAL_STATUSES include 'failed' but handle_terminate only ever
chose between 'completed' and 'no_answer'. A network drop or any other
failure reason landing after status=ACCEPTED was being recorded as
'completed' purely because the call was already in_progress, surfacing
failed conversations as successful in the dashboard. Map known failure
reasons to 'failed' before falling back to the answered/no_answer
heuristic.
2026-05-07 20:11:17 +07:00
Tanmay Deep Sharma 1032097978 Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-07 20:01:03 +07:00
Tanmay Deep Sharma 66b0f51279 Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-07 20:00:57 +07:00
Tanmay Deep Sharma 7a7de71c39 Revert "test(telegram): fix business-chat send-attachments spec"
This reverts commit 461292fb2b.
2026-05-07 20:00:54 +07:00
Tanmay Deep Sharma 62e763ecc2 Revert "test(telegram): fix business-chat send-attachments spec"
This reverts commit 461292fb2b.
2026-05-07 20:00:41 +07:00
Tanmay Deep Sharma 8866071029 Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-07 19:58:58 +07:00
Tanmay Deep Sharma 006d7afa44 Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-07 19:58:52 +07:00
Tanmay Deep Sharma 461292fb2b test(telegram): fix business-chat send-attachments spec
The let(:channel) and the service's message.inbox.channel were different
object instances, so the chat_id / business_connection_id stubs on the
test instance never applied to the service. Other examples masked it
because they only counted requests; the business-chat example matches
on body and tripped. Set the values via conversation.additional_attributes
(where the channel methods actually read from) instead of stubbing.
2026-05-07 19:58:41 +07:00
Tanmay Deep Sharma 3f65838155 Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-07 19:57:42 +07:00
Tanmay Deep Sharma 12f29d0916 Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-07 19:57:36 +07:00
Tanmay Deep Sharma 3c33d912df fix(voice): persist late outbound SDP answers when ACCEPTED races connect
If status=ACCEPTED is processed before Meta's connect webhook, the call
is already in_progress when accept_outbound_call runs, and the previous
guard dropped the SDP answer — leaving the browser without the data it
needs to complete the WebRTC handshake. Gate on a stored sdp_answer
instead of in_progress so out-of-order delivery still persists and
broadcasts the answer (and connect retries stay idempotent).
2026-05-07 19:57:27 +07:00
Tanmay Deep SharmaandGitHub 9d889f691b Merge branch 'develop' into feat/whatsapp-call-incoming-pipeline 2026-05-07 19:38:02 +07:00
Tanmay Deep Sharma 801e1e8ec5 Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-07 17:12:58 +07:00
Tanmay Deep Sharma 7795bc9f1d Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-07 17:12:51 +07:00
Tanmay Deep SharmaandClaude Opus 4.7 f6a88d7f12 test(voice): align WhatsApp call specs with feature gate and v22 calls path
- call_permission_reply specs: enable channel_voice and set
  provider_config['source'] = 'embedded_signup' so voice_enabled? returns
  true; without it the service short-circuits before any handling.
- whatsapp_cloud_service specs: stub /v22.0/<phone_id>/calls (and
  /messages) to match calls_phone_id_path, which uses
  WHATSAPP_API_VERSION (fallback v22.0). The OSS v13.0 path is locked
  for legacy /messages compatibility only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:12:39 +07:00
Tanmay Deep SharmaandClaude Opus 4.7 6b45b97d9c chore(voice): drop non-UI changes from feat/whatsapp-call-ui
Reset BE/migration/spec files to base so this PR diffs as a pure UI change.
The deleted migration and the BE-side comment/edits belong on the BE PR
chain (feat/whatsapp-call-incoming-pipeline) rather than the UI PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:06:46 +07:00
Tanmay Deep Sharma 25609d88b2 Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-07 17:00:43 +07:00
Tanmay Deep Sharma 5471e52335 Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-07 17:00:21 +07:00
Tanmay Deep SharmaandClaude Opus 4.7 f0119b3f2b fix(voice): preserve duration when agents hang up active WhatsApp calls
finalize_call wasn't writing duration_seconds or end_reason, and the Meta
terminate webhook that would have filled them in is suppressed by the
terminal-state idempotency guard in IncomingCallService#handle_terminate.
The result was completed-call bubbles with no duration. Compute the
duration from started_at at agent-hangup time and stamp end_reason
locally instead of waiting for a webhook that won't apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:00:11 +07:00
Tanmay Deep Sharma b975333fec Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-07 16:28:01 +07:00
Tanmay Deep Sharma 1610b9a4ba Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-07 16:27:55 +07:00
Tanmay Deep SharmaandClaude Opus 4.7 2e6b415826 test(voice): align WhatsApp call specs with current voice_enabled gate and connect semantics
- Enable channel_voice and set provider_config['source'] = 'embedded_signup' in
  the before block so Channel::Whatsapp#voice_enabled? actually returns true;
  otherwise the service short-circuits before any handling runs.
- Outbound `connect` only stores the SDP answer and broadcasts
  voice_call.outbound_connected; the in_progress / started_at transition has
  moved to the separate status=ACCEPTED webhook. Update the existing-call spec
  to expect status: 'ringing' and started_at: nil instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:27:46 +07:00
Tanmay Deep Sharma cd9daacfcc Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-07 16:21:13 +07:00
Tanmay Deep Sharma ab8fefd3c9 Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge
# Conflicts:
#	enterprise/app/services/whatsapp/incoming_call_service.rb
2026-05-07 16:20:57 +07:00
Tanmay Deep SharmaandClaude Opus 4.7 dfdadc470d fix(voice): lock webhook transitions and skip unknown terminates
- Wrap mark_outbound_accepted, accept_outbound_call, and handle_terminate in
  call.with_lock so they serialize against Whatsapp::CallService (which holds
  call.with_lock across the Meta API call). Without this, an ACCEPTED webhook
  arriving mid-terminate could overwrite the freshly-finalized terminal status
  back to in_progress and broadcast an erroneous outbound_accepted.

- Drop build_missed_inbound_call. An outbound terminate landing in the tiny
  window between provider_service.initiate_call and Call.create! had no local
  row, so the fallback materialised an inbound row with the same
  provider_call_id and tripped the unique (provider, provider_call_id) index
  on the controller's create. The "out-of-order webhook" case it protected is
  rare in practice; trade that for eliminating the false-positive collision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:20:07 +07:00
c5236ec67e feat(voice): bridge WhatsApp Cloud Calling to Chatwoot voice pipeline [4] (#14356)
# Pull Request Template

## Description

Bridges WhatsApp Cloud Calling onto Chatwoot's voice pipeline so agents
can answer and place calls on WhatsApp Cloud inboxes with the same UX as
Twilio voice. Browser ↔ Meta WebRTC is direct (no media-server hop);
Chatwoot owns signalling, recording upload, and `Call`/`Message`
lifecycle state.

Companion PRs:
- FE: #14346 (`feat/whatsapp-call-ui`) — UI consumer for these
endpoints.
- Specs: #14357 (`feat/whatsapp-call-meta-bridge-specs`) — backend test
coverage.

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## What changed

- **API** — new `Api::V1::Accounts::WhatsappCallsController` with `show
/ accept / reject / terminate / initiate / upload_recording`.
Conversation-level Pundit auth. `initiate` is gated on the channel being
embedded-signup voice-enabled. Permission-blocked initiations (Meta
error 138006) return **422** with `{ status: 'permission_requested' |
'permission_pending' }` and trigger a throttled opt-in template send
under a conversation lock.
- **State machine** — `Whatsapp::CallService` handles accept / reject /
terminate with call-level locking. Meta failures are wrapped as
`Voice::CallErrors::CallFailed` so the controller renders 422 instead of
leaking 500s.
- **Webhooks** — `Whatsapp::IncomingCallService` processes Meta
connect/terminate events. Pins `setup:active` on outbound answers,
materialises a missed-call record when terminate arrives before connect
(out-of-order delivery), and broadcasts `voice_call.*` events to the
assignee's pubsub stream when assigned, otherwise account-wide.
- **Provider** — `Whatsapp::Providers::WhatsappCloudService` adds
`pre_accept_call / accept_call / reject_call / terminate_call /
initiate_call / send_call_permission_request`. Uses configurable
`WHATSAPP_API_VERSION` (default v22) since Calls API needs v17+ and the
OSS `phone_id_path` is locked at v13 for legacy `/messages`
compatibility.
- **Audio recordings** — `Attachment after_create_commit` enqueues
transcription and rebroadcasts the message so the FE bubble updates
immediately. Active Storage initializer allows audio MIME types to serve
inline.

## How to test

1. Set up a WhatsApp Cloud Embedded Signup inbox; flip
`provider_config.calling_enabled = true` (Calls tab in inbox settings —
ships with FE PR #14346).
2. Place a real call from a phone number that has previously messaged
the business → expect `voice_call.incoming` cable + `Call` row +
`voice_call` Message bubble.
3. Click Accept (FE PR) → `/whatsapp_calls/:id/accept` 200, audio flows
browser ↔ Meta, recording uploads on hangup, Whisper transcript appears
within ~10s.
4. From Chatwoot, click outbound → `/whatsapp_calls/initiate` 200 if the
contact is opted-in, otherwise **422** with `status:
'permission_requested'` and a template send (FE shows a banner instead
of an error toast).
5. Refresh during a ringing call → call survives on Meta, FE seeds it
back from the conversation cache, agent can still accept.
6. Refresh during an active call → `pagehide` beacon terminates the call
on Meta with `status: 'completed'` (no orphan).

## Checklist

- [x] Code follows project style (RuboCop / ESLint clean)
- [x] Self-review done
- [x] Hard-to-understand areas commented
- [ ] Documentation update (architecture doc lives in companion branch)
- [x] No new warnings
- [x] Tests live in companion PR #14357
- [x] Existing tests pass locally
- [x] Dependent changes merged downstream (FE #14346 consumes these
endpoints)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:09:36 +07:00
Tanmay Deep Sharma ad48473ced Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui
# Conflicts:
#	app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue
2026-05-07 15:41:24 +07:00
Tanmay Deep Sharma 02ac8f008f Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-07 15:39:42 +07:00
Tanmay Deep SharmaandClaude Opus 4.7 29fd83c1ea fix(voice): don't mint inbound calls for outbound connect webhooks
If Meta delivered a connect for an outbound call before the controller
committed the Call row (race between Meta's API response and our INSERT),
the handler treated the unknown call_id as inbound and either tripped the
unique (provider, provider_call_id) index or broadcast voice_call.incoming
for an outbound. Use session.sdp_type to gate inbound creation on 'offer'
only; 'answer' with no row is logged and skipped — the next status webhook
finds the now-committed row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:39:29 +07:00
Tanmay Deep SharmaandClaude Opus 4.7 96b11e31a1 fix(voice): don't mint inbound calls for outbound connect webhooks
If Meta delivered a connect for an outbound call before the controller
committed the Call row (race between Meta's API response and our INSERT),
the handler treated the unknown call_id as inbound and either tripped the
unique (provider, provider_call_id) index or broadcast voice_call.incoming
for an outbound. Use session.sdp_type to gate inbound creation on 'offer'
only; 'answer' with no row is logged and skipped — the next status webhook
finds the now-committed row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:31:58 +07:00
Tanmay Deep SharmaandGitHub 74f9cd764b Merge branch 'develop' into feat/whatsapp-call-incoming-pipeline 2026-05-07 13:13:27 +07:00
Tanmay Deep SharmaandClaude Opus 4.7 553da80f80 fix(voice): retry ContactInbox lookup on inbound call race
A concurrent WhatsApp message webhook for the same wa_id could win the
(inbox_id, source_id) insert; the call path then re-raised RecordNotUnique
and the outer rescue mislabeled it as a duplicate provider_call_id,
silently dropping the connect. Handle the race inside ensure_contact_inbox!
and drop the now-redundant outer rescue so unrelated unique violations
surface instead of being swallowed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:19:13 +07:00
Tanmay Deep Sharma 83b59ec969 fix(voice): address PR #14346 review feedback in one pass
Multi-agent / module-state correctness
- Hoist WhatsApp outbound init lock to module scope so header + contact-panel
  buttons share one guard; add an active-session guard so a second click
  returns { status: 'locked' } instead of cleanup()-ing the live call.
- isLocalWhatsappCall() filter on voice_call.outbound_connected and
  voice_call.ended cable handlers — account-wide broadcasts no longer
  feed foreign SDP into this tab's PeerConnection or stop its recorder.
- Permission-flow path (200 with no call id) now releases the
  prepareOutboundOffer() mic + RTCPeerConnection instead of leaving the
  mic indicator stuck on.
- Drop the intentionallyClosing guard around sendWhatsappTerminateBeacon
  so a hangup-then-close race still terminates Meta's side (beacon
  endpoint is idempotent).
- Distinguish locked init from permission_requested in callers to avoid
  a false "call initiated" alert.

Provider routing
- joinCall outbound short-circuit now scoped to WhatsApp-like calls so
  FloatingCallWidget's auto-join for outbound Twilio still works.
- isWhatsappLikeCall(callId-keyed) so calls seeded by message.updated /
  refresh path (which lack provider metadata) route to the WhatsApp flow.
- syncConversationCallVisibility per-call filter via shouldShowCall, so
  outbound calls aren't ripped from under the caller on assignee change.
- removeCallsForConversation tears down each active call via
  teardownByProvider — WhatsApp gets cleanupWhatsappSession (closes pc,
  stops recorder/mic) instead of a Twilio-only endClientCall.
- await reject before dismissing in rejectIncomingCall so a failing reject
  keeps the call surfaced for retry.

Per-bubble overhead
- Split useCallSession into the root-mount hook + a lightweight
  useCallActions for components like VoiceCall.vue that just need state +
  actions without registering global window/Twilio listeners. Globals
  attach once via a refcount, dismissed-call sids live at module scope so
  the seed watcher can't re-add a locally dismissed ringing call.

Lookup correctness
- /contacts/:id/conversations accepts an optional inbox_id filter; the
  WhatsApp call button passes inboxId so a contact's older WhatsApp
  thread doesn't fall outside the BE's 20-row cap.

Twilio lifecycle / security
- Defer accepted_by_agent claim to the participant-join webhook so a
  failed agent device init doesn't leave the call ringing-but-claimed
  with no recovery path. mark_agent_joined still raises 409 if another
  agent has already claimed.
- Verify X-Twilio-Signature on recording_status — the controller fetches
  the recording with channel auth credentials, so an unsigned POST
  could coerce credential-bearing requests to an attacker-controlled host.

Legacy data
- Migration to delete orphaned inboxes whose channel_type still says
  'Channel::Voice' after the model was removed. The polymorphic
  belongs_to :channel lookup on those rows otherwise crashes the inbox
  serializer with `uninitialized constant Channel::Voice`.
2026-05-06 17:14:08 +07:00
Tanmay Deep Sharma e468e45f4d Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-06 16:43:52 +07:00
Tanmay Deep Sharma 7f59c8b771 Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-06 16:43:44 +07:00
Tanmay Deep Sharma cb180dfc5f fix(voice): keep terminate handling idempotent for ended calls
A retried terminate webhook for an answered short call (duration=0)
would re-enter handle_terminate, find call.in_progress? false, fall
through to no_answer, and corrupt a completed call's status. Bail when
the call is already terminal so retries don't recompute the status.
2026-05-06 16:43:31 +07:00
Tanmay Deep Sharma 20af850432 fix(voice): normalize WhatsApp wa_id before ContactInbox lookup
Inbound WhatsApp calls were matching contacts by stripping `+` from the
raw from_number, bypassing the BR/AR wa_id normalization that
Whatsapp::IncomingMessageServiceHelpers#processed_waid runs on the
messaging path. A wa_id whose webhook variant differs from the stored
normalized source_id (notably Brazil's added "9") would miss the
existing ContactInbox and create a fresh contact/conversation,
fragmenting history. Route the voice path through the same
Whatsapp::PhoneNumberNormalizationService so the lookup is consistent.
2026-05-06 16:20:52 +07:00
Tanmay Deep Sharma 4db856e7da Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-06 16:00:14 +07:00
Tanmay Deep Sharma 6d168f3c96 fix(voice): use action: 'connect' for outbound WhatsApp call initiate
Meta's Calls API documents `action: 'connect'` (alongside `pre_accept`,
`accept`, `reject`, `terminate`) as the required field on POST /calls
for business-initiated calls. The previous body used `type: 'audio'`,
which is the /messages media shape — Meta currently accepts it because
unknown fields are ignored, but that's an implicit contract that could
break the moment validation tightens.
2026-05-06 16:00:04 +07:00
Tanmay Deep Sharma b95b5f62a0 Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-06 15:25:25 +07:00
Tanmay Deep Sharma 639d087e53 fix(messenger): re-broadcast audio message after blob attaches
Messenger ingest saves the Attachment row before attaching the blob, so
the after_create_commit broadcast bails on file.attached? and the FE
never gets the audio bubble until a refresh (or until Whisper finishes,
or never if transcription is disabled or returns blank). Re-fire the
message update after attach completes.
2026-05-06 15:25:16 +07:00
Tanmay Deep Sharma 40d3ec99dc fix(voice): require conversation to belong to dialed contact
Reject conversation_id hints that point to a different contact in the
same voice inbox, so reuse only happens when the agent is genuinely
inside the open thread for the dialed contact.
2026-05-06 15:25:16 +07:00
Tanmay Deep Sharma 3210860841 fix(messenger): re-broadcast audio message after blob attaches
Messenger ingest saves the Attachment row before attaching the blob, so
the after_create_commit broadcast bails on file.attached? and the FE
never gets the audio bubble until a refresh (or until Whisper finishes,
or never if transcription is disabled or returns blank). Re-fire the
message update after attach completes.
2026-05-06 15:23:50 +07:00
Tanmay Deep Sharma 20076678d6 fix(voice): require conversation to belong to dialed contact
Reject conversation_id hints that point to a different contact in the
same voice inbox, so reuse only happens when the agent is genuinely
inside the open thread for the dialed contact.
2026-05-06 15:23:42 +07:00
Tanmay Deep Sharma 5e4fc5264d Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-06 13:46:58 +07:00
Tanmay Deep SharmaandClaude Opus 4.7 4093a8a48d feat(voice): unify call button flow and redesign call widget
- Contact-panel call button now continues in the open conversation when
  the conversation's inbox is voice-capable, mirroring the header button.
  For non-voice channels it falls back to the inbox picker.
- Twilio and WhatsApp paths share the same upstream decision; both pass
  a conversationId hint to the provider so the agent stays in the same
  thread.
- Floating call widget redesigned: avatar + name + phone with duration
  on top, a clickable "View chat history" pill linking to the
  conversation, and labeled End / Join action buttons. Mute is preserved
  for active WhatsApp calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:45:10 +07:00
Tanmay Deep SharmaandClaude Opus 4.7 2866b29f3a feat(voice): reuse open conversation for Twilio outbound calls
Accept an optional conversation_id in POST /contacts/:id/call. When the
hint matches the picked voice inbox, OutboundCallBuilder reuses that
conversation instead of creating a new one — mirroring the WhatsApp
calling flow so the agent stays in the same thread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:44:28 +07:00
Tanmay Deep Sharma 0b3284ac62 Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-05 18:14:40 +07:00
Tanmay Deep Sharma 0b1caa7286 Merge remote-tracking branch 'origin/feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-05 18:14:30 +07:00
Tanmay Deep Sharma 8df58d0a93 Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-05 18:11:11 +07:00
Tanmay Deep Sharma 23ccec3ebc test(whatsapp): update facebook_api_client spec for calls subscription
Mirrors the subscribed_fields change in 567631b — the webmock body matcher
needs `calls` so the override-failure stub actually fires.
2026-05-05 18:11:01 +07:00
Tanmay Deep Sharma 9e012e923b Merge branch 'develop' into feat/whatsapp-call-meta-bridge
Conflicts resolved:
- config/locales/en.yml — kept the new
  conversations.messages.voice_call.{twilio,whatsapp} keys this branch added.
- enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb
  — kept this branch's calls_phone_id_path helper that pins to
  WHATSAPP_API_VERSION (v22) for the Calls API endpoints. Develop's version
  from PR #14312 used the OSS phone_id_path (v13) which doesn't support the
  Calls API.
2026-05-05 18:09:33 +07:00
Tanmay Deep Sharma c0f1e9237f feat(voice): hide WhatsApp Calling settings tab when channel_voice flag is off
Matches the existing voice-configuration tab gating so admins on accounts
without the premium channel_voice entitlement don't see the WhatsApp Calling
configuration tab.
2026-05-05 18:01:33 +07:00
Tanmay Deep Sharma 9aa338da0a Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-05 18:01:25 +07:00
Tanmay Deep Sharma 567631b866 feat(voice): subscribe to calls webhook + gate WhatsApp calling on channel_voice flag
- Add 'calls' to the WhatsApp WABA override_callback subscribed_fields so a
  fresh embedded-signup connection (or a re-register) automatically receives
  call webhooks from Meta. Previously required a manual App Dashboard step.
- Gate Channel::Whatsapp#voice_enabled? on the account-level 'channel_voice'
  feature flag, matching the entitlement Twilio voice channel creation
  already requires. Cascades through the controller, webhook services, and
  inbox jbuilder voice_enabled serialization (FE button hides automatically).
2026-05-05 18:01:14 +07:00
Tanmay Deep Sharma f712796df3 Merge branch 'develop' into feat/whatsapp-call-incoming-pipeline
Resolves conflict in config/locales/en.yml — kept the new
conversations.messages.voice_call.{twilio,whatsapp} keys added in
this branch.
2026-05-05 17:45:52 +07:00
Tanmay Deep Sharma c1e3e61671 fix(voice): handle 422 permission-blocked response in WhatsApp call composable
The BE now returns 422 (instead of 200) when the contact hasn't opted in
yet — see companion fix in WhatsappCallsController#render_permission_request.
Detect that shape in the catch block and surface it as a normal response so
the existing banner branch in ConversationHeader.vue keeps working without
falling into the error toast path.
2026-05-05 17:30:31 +07:00
Tanmay Deep Sharma 17ff977b07 Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-05 17:30:15 +07:00
Tanmay Deep Sharma cb420d83f6 fix(voice): address PR #14356 review feedback
- Return 422 (not 200) from `WhatsappCallsController#initiate` permission
  branch so clients can't mistake the opt-in template path for a
  successful dial. Body shape unchanged (`{ status: ... }`).
- Drop the `file.attached?` guard from `enqueue_audio_transcription`.
  The social-media ingest path (Messages::Messenger::MessageBuilder)
  saves the Attachment before attaching the blob, and the guard was
  silently dropping transcription for those audio messages.
  AudioTranscriptionJob already has retry_on FileNotFoundError to ride
  out the create-then-attach race.
2026-05-05 17:30:03 +07:00
Tanmay Deep Sharma 97e7c106e3 Merge feat/whatsapp-call-meta-bridge: out-of-scope cleanups 2026-05-05 16:28:19 +07:00
Tanmay Deep Sharma 61a8511f7a chore: revert audio_transcription_service temperature change (out of scope)
Whisper temperature tuning belongs with the transcription stack work,
not with WhatsApp call wiring. Restoring develop's value to keep the
PR focused.
2026-05-05 16:28:07 +07:00
Tanmay Deep Sharma 14a9b48d49 chore: drop Channel::Voice stub (out of scope for this PR)
The legacy-row stub for Channel::Voice belongs with the upstream
DropChannelVoice migration cleanup, not with WhatsApp call wiring.
Removing it keeps the PR's surface focused on the call pipeline.
2026-05-05 16:27:33 +07:00
Tanmay Deep Sharma e6a35193f3 fix(voice): align outbound WhatsApp call lifecycle with real pickup
A pile of related fixes around the dashboard's WhatsApp call flow:

- ConversationHeader / Contacts/VoiceCallButton: drop the immediate
  setCallActive at initiate time. The call sits in incomingCalls
  (callDirection: outbound) until the backend signals real pickup, so
  the duration timer never starts pre-pickup. Phone button is disabled
  whenever there is an active or incoming call.

- FloatingCallWidget:
  * Loop a ringtone (bell.mp3) for inbound ringing only.
  * Hide the green Join button for outbound — the agent has nothing to
    "join", and clicking it routed through acceptIncomingCall →
    prepareInboundAnswer → cleanup() and tore down the live outbound
    session before the API 409 ("already accepted by another agent").
  * Auto-join watcher skips whatsapp outbound (Twilio's joinConference
    flow only).

- useCallSession:
  * joinCall short-circuits for outbound calls — defense-in-depth so
    no future surface can re-trigger the destroyed-session bug.
  * endCall + outbound rejectIncomingCall pass call.callId to
    endActiveCall, so terminate fires even if module state was wiped.

- useWhatsappCallSession:
  * New recorderArmed flag, reset by cleanup. ontrack only calls
    setupRecorder when armed.
  * Inbound's acceptIncomingCall arms the recorder before the API
    round-trip (agent click = pickup).
  * armOutboundRecorder exported for the cable handler when ACCEPTED
    arrives.
  * endActiveCall accepts a callIdOverride to fall back when the
    module's activeCallId was nulled by an earlier cleanup.

- actionCable:
  * Split the cable contract: outbound_connected only applies the SDP
    answer (tunnel-up signal); outbound_accepted (new) is the real
    pickup signal — flips active and arms the recorder.
2026-05-05 14:15:40 +07:00
Tanmay Deep Sharma 4a79abb367 fix(voice): direction-aware copy for voice call bubble
Outbound IN_PROGRESS no longer claims "They answered" — the backend can
flip in_progress before the contact actually picks up (Twilio agent-join
flow), so the label "Call in progress" stands on its own. Outbound
no_answer now reads "Contact didn't pick up" instead of the inbound
"Missed call / No answer" framing, and outbound ringing shows
"Calling…" instead of "Not answered yet".
2026-05-05 14:14:52 +07:00
Tanmay Deep Sharma 5dda138ee7 feat(voice): inbox-level customizable WhatsApp call permission body
Surface inbox.provider_config['call_permission_request_body'] in the
WhatsappCallingPage settings as an editable textarea — saving submits the
new key alongside calling_enabled. Blank trims to null so the i18n default
keeps applying. Backend reads the override before the i18n fallback.
2026-05-05 14:14:39 +07:00
Tanmay Deep Sharma 040c11f0dc Merge feat/whatsapp-call-meta-bridge into feat/whatsapp-call-ui 2026-05-05 14:13:42 +07:00
Tanmay Deep Sharma 5086edefb3 fix(voice): use status=ACCEPTED as the real pickup signal for outbound calls
Meta delivers two payload shapes under field=calls: value.calls[] (event:
connect/terminate) and value.statuses[] (status: RINGING/ACCEPTED). The
dispatcher only forwarded the calls[] array, so status webhooks were
silently dropped.

Empirically `connect` for BUSINESS_INITIATED outbound fires when the
WebRTC tunnel is up, not on pickup — sometimes ~20s before the contact
actually answers. The terminate webhook's start_time aligns with the
ACCEPTED status timestamp, confirming ACCEPTED is the true pickup.

- whatsapp_events_job: route value.statuses[] (type=call) to
  IncomingCallService under the same per-call_id mutex.
- incoming_call_service: handle_status routes ACCEPTED to
  mark_outbound_accepted (flip in_progress, set started_at, broadcast
  voice_call.outbound_accepted). Strip the in_progress flip from the
  connect handler — connect now only stores the SDP answer + broadcasts
  voice_call.outbound_connected so the browser can complete the DTLS
  handshake during ringing.
2026-05-05 14:13:26 +07:00
Tanmay Deep Sharma 66d9ddb9c4 feat(voice): add activity messages + customizable body for call permission
Emit a conversation activity message when the call permission template is
sent (in the controller's render_permission_request) and another when the
contact accepts the prompt (in CallPermissionReplyService). Read an inbox
provider_config['call_permission_request_body'] override before falling
back to the i18n default — surfaced in the inbox UI in a separate change.
2026-05-05 14:13:10 +07:00
Tanmay Deep Sharma 41a7c88aef Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui
# Conflicts:
#	app/services/base/send_on_channel_service.rb
#	enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb
#	enterprise/app/models/enterprise/concerns/attachment.rb
2026-05-04 20:51:43 +07:00
Tanmay Deep Sharma 19b5496c56 Merge remote-tracking branch 'origin/develop' into feat/whatsapp-call-ui
# Conflicts:
#	app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
#	app/javascript/dashboard/composables/useCallSession.js
#	app/javascript/dashboard/helper/voice.js
#	config/locales/en.yml
#	enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb
2026-05-04 20:48:25 +07:00
Tanmay Deep Sharma d693d468d8 feat(voice): bridge WhatsApp Cloud Calling to Chatwoot voice pipeline
Wires Meta's WhatsApp Cloud Calling APIs into the voice subsystem so
agents can answer / place calls on WhatsApp Cloud inboxes from the
existing Voice flow. Browser ↔ Meta WebRTC is direct (no media-server
hop); Chatwoot owns the signalling, recording upload, and lifecycle
state on the Call/Message records.

Backend
- API surface: new Api::V1::Accounts::WhatsappCallsController with
  show / accept / reject / terminate / initiate / upload_recording.
  Enforces conversation-level Pundit visibility, blocks initiate when
  the channel isn't embedded-signup voice-enabled, and surfaces the
  Meta 138006 (no-call-permission) flow as a throttled opt-in template
  send under a conversation lock so concurrent retries can't double-send.
- Whatsapp::CallService — accept/reject/terminate state machine with
  call-level locking; wraps Meta API failures (transport or business)
  as Voice::CallErrors::CallFailed so the controller renders 422.
- Whatsapp::IncomingCallService — handles Meta connect/terminate
  webhooks; pins setup:active on outbound answers, materialises a
  missed-call record when terminate arrives before connect, and
  broadcasts voice_call.* events to assignee or account streams.
- Whatsapp::Providers::WhatsappCloudService — adds pre_accept/accept/
  reject/terminate/initiate/permission-request endpoints. Uses
  configurable WHATSAPP_API_VERSION (default v22) since the Calls API
  needs v17+ and OSS phone_id_path is locked at v13.
- Audio recordings: Attachment after_create_commit enqueues
  transcription and rebroadcasts the message so the FE bubble updates
  immediately. Active Storage initializer allows audio MIME types to
  serve inline. Audio transcription drops Whisper temperature to 0 to
  suppress hallucinations on silence.
- Channel::Voice stub backs legacy inbox rows whose channel_type
  survived the DropChannelVoice migration so jbuilder .try chains
  short-circuit instead of raising.

Routes / config
- /api/v1/accounts/:id/whatsapp_calls/* (enterprise-only)
- en.yml error strings under errors.whatsapp.calls.*

The FE consumer of this API ships separately on feat/whatsapp-call-ui.
2026-05-04 15:38:28 +07:00
Tanmay Deep Sharma 0b1ad1a854 chore(voice): trim dead exports, fix duplicate-OR bug, drop redundant comments
Branch review pass:
- useWhatsappCallSession: drop unused `error` ref and unused exports
  (hasActiveWhatsappCall, isWhatsappCallMuted), fold sendWhatsappCallBeacon
  into a private beaconTerminate helper since only sendWhatsappTerminateBeacon
  consumed it externally. Trim WHAT-comments; keep WHY-comments
  (browser-quirk explanations, race-condition notes, auth-cookie rationale).
- VoiceCall.vue bubble: fix `data || data` short-circuit that did nothing —
  upstream key transform was the same on both branches; collapse to one read.
- calls/useCallSession/actionCable/FloatingCallWidget/VoiceCallButton:
  drop comments that just describe what the next line already says.

Net diff: -63 lines across 7 files. No behavior change.
2026-05-04 15:16:04 +07:00
Tanmay Deep Sharma 6179adac22 fix(voice): keep ringing WhatsApp calls alive across page refresh
Inverted the previous decision: only the active (accepted) call gets
terminated on pagehide, since its WebRTC session dies with the page and
genuinely can't be rejoined. Ringing calls have no WebRTC state yet —
they're just a row on the backend and a state on Meta — so killing them
on refresh would needlessly hang up on the customer when the agent
intended to recover their UI, not reject the call.

After this, the flow on refresh during a ringing inbound is:
  - beforeunload prompt fires (warning the agent)
  - pagehide does nothing for ringing calls; active calls still terminate
  - new page loads, conversation messages fetch
  - seedCallsFromHydratedMessages picks up status='ringing' voice_call
    messages and populates the calls store
  - FloatingCallWidget reappears with Accept; agent clicks; acceptIncomingCall
    fetches the SDP via /whatsapp_calls/:id and the call connects
2026-05-03 16:49:00 +07:00
Tanmay Deep Sharma 3c959f0604 fix(voice): authenticate page-close terminate beacon
The previous beacon used navigator.sendBeacon, which can't set custom
request headers — and Chatwoot's API requires devise-token-auth headers
(access-token / client / uid) on every call. So the beacon hit the
terminate endpoint, got 401, and Meta kept the call alive until the
carrier-side timeout (~60s) — exactly the bug reported: refresh during
an in-progress call leaves it ringing on Meta's side.

Switch to fetch with keepalive:true so the request survives page unload
AND lets us rehydrate the auth headers from the cw_d_session_info cookie
the dashboard sets at login. credentials:'same-origin' keeps any session
cookies along for the ride. Empty body is well under the 64KB keepalive
quota so it actually flushes before the tab dies.
2026-05-03 16:11:43 +07:00
Tanmay Deep Sharma 772734840d fix(voice): terminate ringing WhatsApp calls on page close
Browser-direct WebRTC has no rejoin path, so any call that outlives the
agent's tab becomes permanently orphaned on Meta's side. The existing
pagehide beacon only covered the active (accepted) call — extend it to
every ringing inbound too, so a hard refresh during ringing releases the
call instead of leaving Meta to time it out.

- useWhatsappCallSession: factor sendWhatsappCallBeacon(callId) out of
  the active-call wrapper so any caller can post terminate for any callId
  without going through the activeCallId / intentionallyClosing guard.
- useCallSession.handlePageHide: after the active-call beacon, iterate
  the calls store for any ringing WhatsApp call and beacon /terminate
  for each. terminate is the right endpoint because the backend records
  it as 'no_answer' when the call was still ringing — accurate UX shape.
2026-05-03 16:07:55 +07:00
Tanmay Deep Sharma 77c57acffa feat(voice): move WhatsApp calling toggle to its own Calls tab
Mirror the Twilio voice configuration flow: split the WhatsApp Cloud
calling-enabled toggle out of the generic Configuration tab into a
dedicated Calls tab so the surface for telephony settings is consistent
across providers.

- Added WhatsappCallingPage.vue with the toggle, business phone display,
  and a how-it-works blurb (no extra credentials needed since the
  embedded-signup token already grants the call scopes).
- Settings.vue: register the page and surface a Calls tab on
  WhatsApp Cloud + embedded-signup inboxes.
- ConfigurationPage.vue: drop the inline calling-enabled section, the
  watcher that auto-saved on every toggle flip, and the now-orphan
  updateWhatsAppCallingEnabled method.
- en/inboxMgmt.json: TABS.CALLS label + WHATSAPP_CALLING.* strings.
2026-05-03 15:52:43 +07:00
Tanmay Deep Sharma 499ac053ee chore(voice): drop debug toasts; appease rubocop on inbox jbuilder
- Remove the [debug] useAlert breadcrumbs that surfaced the accept-call
  silent-fail (the !conversation guard). The actual fix from
  6ed9500792 stays.
- Rubocop Style/IfUnlessModifier on the WhatsApp voice_enabled jbuilder
  block — convert to modifier form.
2026-05-03 15:40:30 +07:00
Tanmay Deep Sharma 5aa1df81a8 debug(voice): surface accept-call diagnostics via toast alerts
Console logs aren't visible to the agent on staging — switch to
useAlert toasts so the debug breadcrumbs appear directly in the UI.
Will revert once root cause is identified.
2026-05-03 14:12:22 +07:00
Tanmay Deep Sharma 81464a9087 debug(voice): add temporary console logs around accept-call path
Trace every checkpoint between the green Accept button click and the
backend POST so we can see exactly where the silent failure happens
(0 /accept requests landing on backend, no console errors, no network
requests reported by the user). Logs prefixed [CW Voice] for grep.
Will revert once the root cause is identified.
2026-05-03 13:49:33 +07:00
Tanmay Deep Sharma 6ed9500792 fix(voice): accept calls without conversation hydration; warn on refresh during ringing
- FloatingCallWidget: drop !conversation early-return in handleJoinCall —
  on a fresh account or after a hard refresh the inbound call's conversation
  may not be in the Vuex store yet, which silently no-op'd Accept while
  Reject worked (Reject doesn't read the conversation).
- useCallSession: seed the calls store from already-loaded voice_call
  messages with status='ringing' on mount and whenever the conversation
  list changes. Cable events (voice_call.incoming, message.created) are
  one-shot and not replayed on reconnect, so without seeding a refresh
  during a ringing call leaves the FloatingCallWidget empty.
- useCallSession: extend the beforeunload warning to fire while a call is
  ringing too — losing a ringing inbound to refresh is the same UX hit as
  losing an active one.
2026-05-02 17:35:53 +07:00
Tanmay Deep Sharma 6835f9c85b feat(voice): WhatsApp Cloud Calling — UI 2026-05-02 16:06:48 +07:00
Tanmay Deep Sharma 442631102c fix(voice): stub Channel::Voice for legacy inbox rows after DropChannelVoice migration 2026-05-02 16:06:36 +07:00
Tanmay Deep Sharma 78c615fe42 chore(voice): align whatsapp_spec to incoming-pipeline (specs go to specs PR) 2026-05-02 14:49:21 +07:00
Tanmay Deep Sharma 907011e280 chore(voice): split spec changes out to feat/whatsapp-call-specs (sibling PR) 2026-05-02 14:48:41 +07:00
Tanmay Deep Sharma a766ebf642 chore(voice): align FE files to incoming-pipeline base (UI changes consolidated to PR #14346) 2026-05-02 14:40:06 +07:00
Tanmay Deep Sharma 0e0e0868d7 chore(voice): split FE changes out to feat/whatsapp-call-ui (PR #14346) 2026-05-02 14:31:13 +07:00
Tanmay Deep Sharma d9077c64d3 fix(voice): robust accept-with-fetch + remote-end recording race + single-duration bubble + audio playback signals 2026-05-01 18:29:54 +07:00
Tanmay Deep Sharma 1abb51992b fix(voice): fall back to content_attributes.data for duration when call payload lacks it 2026-05-01 17:50:02 +07:00
Tanmay Deep Sharma 0282156821 fix(audio): allow audio attachments to serve inline so <audio> can play call recordings 2026-05-01 17:47:04 +07:00
Tanmay Deep Sharma 257a9129d7 fix(voice): create missed-call record when terminate webhook arrives before connect 2026-05-01 17:33:05 +07:00
Tanmay Deep Sharma 1ce316efc7 ui(voice): use ButtonV4 for header WhatsApp call button to match action toolbar styling 2026-05-01 17:27:57 +07:00
Tanmay Deep Sharma ba94d8c2b0 fix(transcription): drop Whisper temperature to 0 to suppress hallucinations 2026-05-01 16:31:48 +07:00
Tanmay Deep Sharma 6e11bebeae fix(audio): resolve real duration for MediaRecorder WebM blobs via end-seek trick 2026-05-01 11:16:55 +07:00
Tanmay Deep Sharma 069f25f88a fix(voice): await remote-end upload before store removeCall to prevent sync cleanup race 2026-05-01 11:15:09 +07:00
Tanmay Deep Sharma 63c193358c fix(voice): render audio + transcript + duration on call bubble; upload recording on remote-end too 2026-05-01 11:06:53 +07:00
Tanmay Deep Sharma 7663e2ac30 fix(voice): merge call store entries so cable + message.created races don't drop provider/sdp/caller 2026-05-01 10:54:47 +07:00
Tanmay Deep Sharma 0567655840 fix(voice): outbound STUN, mute toggle, skip voice_call bubble in send pipeline 2026-04-30 20:43:35 +07:00
Tanmay Deep Sharma c25ed094fa fix(voice): pipe Meta's remote audio to a hidden audio element so the agent hears the contact 2026-04-30 20:17:19 +07:00
Tanmay Deep Sharma 112a8f9e9e feat(voice): branch contact-panel call button on inbox provider for WhatsApp 2026-04-30 20:14:21 +07:00
Tanmay Deep Sharma 723d416ce3 fix(voice): bump WhatsApp Cloud Calling endpoints to v22 (OSS phone_id_path is locked at v13) 2026-04-30 19:54:31 +07:00
Tanmay Deep Sharma f3df486ce6 feat(voice): add WhatsApp calling enable toggle to inbox configuration 2026-04-30 19:39:19 +07:00
Tanmay Deep Sharma c7b6a87e77 feat(voice): wire WhatsApp call UI on top of existing Twilio session flow 2026-04-30 19:30:43 +07:00
Tanmay Deep Sharma 19d91e47f1 fix(voice): rescue transport errors during permission_request send 2026-04-30 19:11:14 +07:00
Tanmay Deep Sharma 1c945899c7 fix(whatsapp): restrict voice_enabled? to embedded-signup whatsapp_cloud channels 2026-04-30 19:10:06 +07:00
Tanmay Deep Sharma 2c025755e2 fix(voice): wrap Meta transport errors during accept as CallFailed 2026-04-30 18:52:51 +07:00
Tanmay Deep Sharma 0618e6e229 fix(voice): close lock-window gaps in accept, throttle, and call_attributes_changed? 2026-04-30 18:46:09 +07:00
Tanmay Deep Sharma cbef2d4e66 refactor(voice): memoize provider_service accessor in whatsapp calls controller 2026-04-30 18:26:43 +07:00
Tanmay Deep Sharma cb93aba55c refactor(voice): split call validations into single-purpose before_actions 2026-04-30 18:14:29 +07:00
Tanmay Deep Sharma beef43f2bf refactor(voice): hoist validations to before_action and centralize call-error rendering 2026-04-30 18:04:25 +07:00
Tanmay Deep Sharma cbd3f759b5 fix(voice): order accept guards correctly and validate contact phone upstream 2026-04-30 17:46:24 +07:00
Tanmay Deep Sharma a7fdc45977 fix(voice): bail on Meta failures and restrict initiate to WhatsApp inboxes 2026-04-30 17:26:01 +07:00
Tanmay Deep Sharma 41e3ea0d87 fix(voice): record permission_request wamid for context-based reply matching 2026-04-30 17:11:55 +07:00
Tanmay Deep Sharma fd22017292 Merge feat/whatsapp-call-incoming-pipeline into feature/pla-150 2026-04-30 17:10:45 +07:00
Tanmay Deep Sharma 555894793e fix(voice): match permission reply to originating conversation via context.id 2026-04-30 17:10:34 +07:00
Tanmay Deep Sharma 7c87d3d150 fix(voice): lock call state transitions, branch terminate on connection state, atomic recording upload, rescue CallFailed in initiate 2026-04-30 16:54:47 +07:00
Tanmay Deep Sharma c37d1b1d6b Merge feat/whatsapp-call-incoming-pipeline into feature/pla-150 2026-04-30 16:51:42 +07:00
Tanmay Deep Sharma e5a03d08ea fix(voice): resolve contact via source_id and i18n call message bubble 2026-04-30 16:51:29 +07:00
Tanmay Deep Sharma bc80985f55 refactor(voice): align WhatsappCallsController with Chatwoot jbuilder + concern conventions 2026-04-30 16:38:08 +07:00
Tanmay Deep Sharma 83cd568ca2 feat(voice): add WhatsApp Cloud Calling agent service, controller and routes 2026-04-30 16:24:39 +07:00
Tanmay Deep Sharma f32a7b3f77 refactor(voice): drop redundant CallMessageBuilder.update_status! class delegate 2026-04-30 16:13:53 +07:00
Tanmay Deep Sharma ba0fcbfc7f refactor(voice): tighten WhatsApp call pipeline and broadcast hyphenated terminate status 2026-04-30 16:04:39 +07:00
Tanmay Deep Sharma 546193de2a refactor(voice): tighten predicate and trim STUN comment 2026-04-30 15:35:58 +07:00
Tanmay Deep Sharma 67b522c433 refactor(voice): expose voice_enabled? on Channel::Whatsapp for duck-typed checks 2026-04-30 15:34:08 +07:00
Tanmay Deep Sharma f5eca9d1d0 refactor(voice): inline single-use helpers in WhatsApp call services 2026-04-30 15:28:41 +07:00
Tanmay Deep Sharma 5c4db3a6bc refactor(voice): extract finalize_status! helper to DRY incoming call lifecycle 2026-04-30 15:19:21 +07:00
Tanmay Deep Sharma 792dc359d4 fix(voice): tighten WhatsApp call lifecycle handling and per-call locking 2026-04-30 15:07:51 +07:00
Tanmay Deep Sharma 2b0aaa237f feat(voice): add WhatsApp inbound call webhook pipeline 2026-04-30 14:59:46 +07:00
Tanmay Deep Sharma 48022833ea refactor(voice): address review feedback on WhatsApp Cloud Calling provider methods 2026-04-30 14:43:44 +07:00
Tanmay Deep Sharma e2b3965a08 refactor(voice): raise typed Voice::CallErrors::CallFailed instead of StandardError 2026-04-30 14:43:44 +07:00
Tanmay Deep Sharma 17e25fa3c4 test(voice): cover WhatsApp Cloud Calling provider methods 2026-04-30 14:43:44 +07:00
Tanmay Deep Sharma 37963cc654 fix(voice): coerce non-Hash parsed_response in initiate_call error path 2026-04-30 14:43:44 +07:00
Tanmay Deep Sharma c7f5277277 feat(voice): add WhatsApp Cloud Calling provider methods 2026-04-30 14:43:44 +07:00
270 changed files with 1097 additions and 5903 deletions
-6
View File
@@ -98,8 +98,6 @@ SMTP_OPENSSL_VERIFY_MODE=peer
# Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled
MAILER_INBOUND_EMAIL_DOMAIN=
# Maximum time in seconds to process a single IMAP email
# EMAIL_PROCESSING_TIMEOUT_SECONDS=60
# Set this to the appropriate ingress channel with regards to incoming emails
# Possible values are :
# relay for Exim, Postfix, Qmail
@@ -234,10 +232,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules
# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10
## SafeFetch private network access
## Keep disabled by default. Self-hosted installations can enable this to allow SafeFetch requests to private network URLs.
# SAFE_FETCH_ALLOW_PRIVATE_NETWORK=false
## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints
# CW_API_ONLY_SERVER=false
+1 -3
View File
@@ -89,7 +89,7 @@ gem 'rails-i18n', '~> 7.0'
# two-factor authentication
gem 'devise-two-factor', '>= 5.0.0'
# authorization
gem 'jwt', '~> 2.10', '>= 2.10.3'
gem 'jwt'
gem 'pundit'
# super admin
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
4.14.1
4.14.0
@@ -1,7 +1,7 @@
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :set_articles, only: [:update_status, :update_category, :delete_articles]
before_action :set_articles, only: [:update_status, :delete_articles]
def translate
head :not_implemented
@@ -19,18 +19,6 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
render_could_not_create_error(e.message)
end
def update_category
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
return render_could_not_create_error(I18n.t('portals.articles.category_not_found')) unless category_valid?
ActiveRecord::Base.transaction do
@articles.find_each { |article| article.update!(category_id: params[:category_id]) }
end
head :ok
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.message)
end
def delete_articles
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
@@ -51,9 +39,5 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
def set_articles
@articles = @portal.articles.where(id: params[:ids])
end
def category_valid?
@portal.categories.exists?(id: params[:category_id])
end
end
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
@@ -1,16 +0,0 @@
class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController
before_action :ensure_unread_counts_enabled
def index
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
render json: { payload: counts }
end
private
def ensure_unread_counts_enabled
return if Current.account.feature_enabled?('conversation_unread_counts')
render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden
end
end
@@ -162,8 +162,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_columns(updates)
# rubocop:enable Rails/SkipsModelValidations
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
end
def should_update_last_seen?
@@ -6,7 +6,8 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state
state: state,
prompt: 'consent'
}
)
if redirect_url
@@ -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
-1
View File
@@ -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;
}
}
}
}
@@ -145,43 +145,45 @@ const handleCreateArticle = event => {
</template>
<style lang="scss" scoped>
:deep(.ProseMirror .empty-node::before) {
@apply text-n-slate-10 text-base;
}
:deep(.ProseMirror-menubar-wrapper) {
.ProseMirror-woot-style {
@apply min-h-[15rem] max-h-full;
::v-deep {
.ProseMirror .empty-node::before {
@apply text-n-slate-10 text-base;
}
}
:deep(.ProseMirror-menubar) {
display: none; // Hide by default
}
:deep(.editor-root .has-selection) {
.ProseMirror-menubar:not(:has(*)) {
display: none !important;
.ProseMirror-menubar-wrapper {
.ProseMirror-woot-style {
@apply min-h-[15rem] max-h-full;
}
}
.ProseMirror-menubar {
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
display: flex;
top: var(--selection-top, auto) !important;
left: var(--selection-left, 0) !important;
width: fit-content !important;
position: absolute !important;
display: none; // Hide by default
}
.ProseMirror-menuitem {
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
.ProseMirror-icon {
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
}
.editor-root .has-selection {
.ProseMirror-menubar:not(:has(*)) {
display: none !important;
}
.ProseMirror-menu-active {
@apply bg-n-slate-3;
.ProseMirror-menubar {
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
display: flex;
top: var(--selection-top, auto) !important;
left: var(--selection-left, 0) !important;
width: fit-content !important;
position: absolute !important;
.ProseMirror-menuitem {
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
.ProseMirror-icon {
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
}
}
.ProseMirror-menu-active {
@apply bg-n-slate-3;
}
}
}
}
@@ -2,7 +2,6 @@
import { ref, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
@@ -19,7 +18,6 @@ import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/A
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import BulkTranslateDialog from './BulkTranslateDialog.vue';
const props = defineProps({
@@ -64,7 +62,6 @@ const isFeatureEnabledonAccount = useMapGetter(
const selectedArticleIds = ref(new Set());
const deleteConfirmDialogRef = ref(null);
const isCategoryMenuOpen = ref(false);
const { isEnterprise } = useConfig();
@@ -224,34 +221,6 @@ const bulkUpdateStatus = async status => {
}
};
const categoryMenuItems = computed(() =>
props.categories.map(category => ({
label: category.name,
value: category.id,
action: 'move',
emoji: category.icon,
}))
);
const handleBulkUpdateCategory = async ({ value }) => {
isCategoryMenuOpen.value = false;
try {
await articlesAPI.bulkUpdateCategory({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
categoryId: value,
});
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.CATEGORY_SUCCESS')
);
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.CATEGORY_ERROR')
);
}
};
const confirmBulkDelete = () => {
deleteConfirmDialogRef.value?.open();
};
@@ -371,30 +340,6 @@ watch(
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('archived')"
/>
<div v-if="categoryMenuItems.length" class="relative group">
<OnClickOutside @trigger="isCategoryMenuOpen = false">
<Button
sm
faded
slate
icon="i-lucide-folder-input"
:label="
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.MOVE_TO_CATEGORY'
)
"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
/>
<DropdownMenu
v-if="isCategoryMenuOpen"
:menu-items="categoryMenuItems"
show-search
class="right-0 w-48 mt-2 top-full max-h-60"
@action="handleBulkUpdateCategory"
/>
</OnClickOutside>
</div>
<Button
v-if="isTranslationAvailable"
sm
@@ -202,7 +202,7 @@ defineExpose({ state, isSubmitDisabled });
size="sm"
type="button"
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
class="!h-[2.38rem] !w-[2.375rem] absolute top-[2rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
class="!h-[2.4rem] !w-[2.375rem] absolute top-[1.94rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
@@ -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,5 +1,5 @@
<script setup>
import { watch } from 'vue';
import { defineModel, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { computed, defineModel, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -1,5 +1,5 @@
<script setup>
import { ref } from 'vue';
import { ref, defineProps, defineEmits } from 'vue';
import { Chrome } from '@lk77/vue3-color';
import { OnClickOutside } from '@vueuse/components';
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { defineProps, ref, defineEmits, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
@@ -1,5 +1,5 @@
<script setup>
import { computed, h, watch, ref } from 'vue';
import { computed, defineModel, h, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { defineModel, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'next/icon/Icon.vue';
import Button from 'next/button/Button.vue';
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { defineModel, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { picoSearch } from '@scmmishra/pico-search';
import Icon from 'next/icon/Icon.vue';
@@ -1,5 +1,5 @@
<script setup>
import { h } from 'vue';
import { h, defineProps } from 'vue';
const props = defineProps({
country: { type: String, required: true },
@@ -1,65 +0,0 @@
<script setup>
import ChannelIcon from './ChannelIcon.vue';
import { useChannelBrandIcon } from './provider';
const inboxes = [
{ name: 'API', channel_type: 'Channel::Api' },
{ name: 'Email', channel_type: 'Channel::Email' },
{ name: 'Gmail', channel_type: 'Channel::Email', provider: 'google' },
{ name: 'Outlook', channel_type: 'Channel::Email', provider: 'microsoft' },
{ name: 'Messenger', channel_type: 'Channel::FacebookPage' },
{ name: 'Instagram', channel_type: 'Channel::Instagram' },
{ name: 'Line', channel_type: 'Channel::Line' },
{ name: 'Telegram', channel_type: 'Channel::Telegram' },
{ name: 'WhatsApp', channel_type: 'Channel::Whatsapp' },
{ name: 'TikTok', channel_type: 'Channel::Tiktok' },
{ name: 'SMS', channel_type: 'Channel::Sms' },
{ name: 'Twilio SMS', channel_type: 'Channel::TwilioSms' },
{
name: 'Twilio WhatsApp',
channel_type: 'Channel::TwilioSms',
medium: 'whatsapp',
},
{
name: 'Voice',
channel_type: 'Channel::TwilioSms',
voice_enabled: true,
},
{ name: 'Website', channel_type: 'Channel::WebWidget' },
];
const brandInboxes = inboxes.filter(inbox => useChannelBrandIcon(inbox).value);
</script>
<template>
<Story title="Components/Icons/ChannelIcon">
<Variant title="Glyph (default)">
<div class="grid grid-cols-4 gap-5">
<div
v-for="inbox in inboxes"
:key="inbox.name"
class="flex items-center gap-2"
>
<ChannelIcon :inbox="inbox" class="size-6 text-n-slate-11" />
<span>{{ inbox.name }}</span>
</div>
</div>
</Variant>
<Variant title="Brand icon">
<div class="grid grid-cols-4 gap-5">
<div
v-for="inbox in brandInboxes"
:key="inbox.name"
class="flex items-center gap-2"
>
<ChannelIcon
:inbox="inbox"
use-brand-icon
class="size-6 text-n-slate-11"
/>
<span>{{ inbox.name }}</span>
</div>
</div>
</Variant>
</Story>
</template>
@@ -1,7 +1,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>
@@ -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"
@@ -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"
@@ -14,11 +14,6 @@ const props = defineProps({
type: String,
default: 'conversation',
},
action: {
type: String,
default: 'assign',
validator: value => ['assign', 'remove'].includes(value),
},
isLoading: {
type: Boolean,
default: false,
@@ -27,13 +22,9 @@ const props = defineProps({
type: Boolean,
default: false,
},
appliedLabels: {
type: Array,
default: null,
},
});
const emit = defineEmits(['assign', 'remove']);
const emit = defineEmits(['assign']);
const { t } = useI18n();
@@ -44,43 +35,17 @@ const [showDropdown, toggleDropdown] = useToggle(false);
const selectedLabels = ref([]);
const isTypeContact = computed(() => props.type === 'contact');
const isRemoveAction = computed(() => props.action === 'remove');
const buttonLabel = computed(() => {
if (!isTypeContact.value) return '';
return isRemoveAction.value
? t('CONTACTS_BULK_ACTIONS.REMOVE_LABELS')
: t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS');
});
const tooltipLabel = computed(() =>
isRemoveAction.value
? t('BULK_ACTION.LABELS.REMOVE_LABELS')
: t('BULK_ACTION.LABELS.ASSIGN_LABELS')
);
const confirmLabel = computed(() =>
isRemoveAction.value
? t('BULK_ACTION.LABELS.REMOVE_SELECTED_LABELS')
: t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')
const buttonLabel = computed(() =>
props.type === 'contact' ? t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS') : ''
);
const isLabelSelected = labelTitle => {
return selectedLabels.value.includes(labelTitle);
};
const visibleLabels = computed(() => {
if (!isRemoveAction.value || props.appliedLabels === null) {
return labels.value;
}
const applied = new Set(props.appliedLabels);
return labels.value.filter(label => applied.has(label.title));
});
const labelMenuItems = computed(() => {
return visibleLabels.value.map(label => ({
return labels.value.map(label => ({
action: 'select',
value: label.title,
label: label.title,
@@ -99,13 +64,9 @@ const toggleLabelSelection = labelTitle => {
}
};
const handleApply = () => {
const handleAssign = () => {
if (selectedLabels.value.length > 0) {
if (isRemoveAction.value) {
emit('remove', selectedLabels.value);
} else {
emit('assign', selectedLabels.value);
}
emit('assign', selectedLabels.value);
toggleDropdown(false);
selectedLabels.value = [];
}
@@ -120,9 +81,9 @@ const handleDismiss = () => {
<template>
<div ref="containerRef" class="relative">
<NextButton
v-tooltip="tooltipLabel"
v-tooltip="isTypeContact ? '' : $t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
:label="buttonLabel"
:icon="isRemoveAction ? 'i-woot-tag-remove' : 'i-lucide-tag'"
icon="i-lucide-tag"
slate
:size="isTypeContact ? 'sm' : 'xs'"
ghost
@@ -187,9 +148,9 @@ const handleDismiss = () => {
<NextButton
sm
class="w-full [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
:label="confirmLabel"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length"
@click="handleApply"
@click="handleAssign"
/>
</div>
</template>
@@ -1,6 +1,5 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, useAttrs } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { emitter } from 'shared/helpers/mitt';
@@ -56,25 +55,12 @@ defineOptions({
const attrs = useAttrs();
const {
selectedConversations,
onAssignAgent,
onAssignLabels,
onRemoveLabels,
onAssignTeamsForBulk: onAssignTeam,
onUpdateConversations,
} = useBulkActions();
const getConversationById = useMapGetter('getConversationById');
const appliedLabelsForSelection = computed(() => {
const applied = new Set();
selectedConversations.value.forEach(id => {
const conversation = getConversationById.value(id);
(conversation?.labels || []).forEach(label => applied.add(label));
});
return Array.from(applied);
});
const showCustomTimeSnoozeModal = ref(false);
function onCmdSnoozeConversation(snoozeType) {
@@ -175,11 +161,6 @@ onUnmounted(() => {
</div>
<div class="flex items-center gap-2">
<BulkLabelActions @assign="onAssignLabels" />
<BulkLabelActions
action="remove"
:applied-labels="appliedLabelsForSelection"
@remove="onRemoveLabels"
/>
<BulkUpdateActions
:show-resolve="!showResolvedAction"
:show-reopen="!showOpenAction"
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, defineOptions } from 'vue';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
@@ -121,7 +121,7 @@ const variableKey = (item = {}) => {
}
}
.canned-item__button :deep(.button__content) {
.canned-item__button::v-deep .button__content {
@apply overflow-hidden text-ellipsis whitespace-nowrap;
}
</style>
@@ -108,7 +108,7 @@ export function useBulkActions() {
}
}
// Used by both context menu and bulk action bar.
// Only used in context menu
async function onRemoveLabels(labelsToRemove, conversationId = null) {
try {
await store.dispatch('bulkActions/process', {
@@ -119,24 +119,14 @@ export function useBulkActions() {
},
});
// Context-menu remove should not disturb an existing bulk selection.
if (conversationId) {
useAlert(
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
labelName: labelsToRemove[0],
conversationId,
})
);
} else {
store.dispatch('bulkActions/clearSelectedConversationIds');
useAlert(t('BULK_ACTION.LABELS.REMOVE_SUCCESFUL'));
}
} catch (err) {
useAlert(
conversationId
? t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED')
: t('BULK_ACTION.LABELS.REMOVE_FAILED')
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
labelName: labelsToRemove[0],
conversationId,
})
);
} catch (err) {
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED'));
}
}
@@ -1,47 +1,6 @@
const keyboardEventsMock = vi.hoisted(() => ({
mountedCallbacks: [],
unmountedCallbacks: [],
registeredKeybindings: {},
}));
vi.mock('vue', async importOriginal => ({
...(await importOriginal()),
onMounted: vi.fn(callback =>
keyboardEventsMock.mountedCallbacks.push(callback)
),
onUnmounted: vi.fn(callback =>
keyboardEventsMock.unmountedCallbacks.push(callback)
),
}));
vi.mock('dashboard/composables/useDetectKeyboardLayout', () => ({
useDetectKeyboardLayout: vi.fn(() => Promise.resolve('QWERTY')),
}));
vi.mock('tinykeys', () => ({
createKeybindingsHandler: vi.fn(keybindings => {
keyboardEventsMock.registeredKeybindings = keybindings;
return event => keybindings[event.shortcut]?.(event);
}),
}));
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { LAYOUT_QWERTZ } from 'shared/helpers/KeyboardHelpers';
const mount = async events => {
useKeyboardEvents(events);
await keyboardEventsMock.mountedCallbacks[0]();
return keyboardEventsMock.registeredKeybindings;
};
describe('useKeyboardEvents', () => {
beforeEach(() => {
keyboardEventsMock.mountedCallbacks = [];
keyboardEventsMock.unmountedCallbacks = [];
keyboardEventsMock.registeredKeybindings = {};
});
it('should be defined', () => {
expect(useKeyboardEvents).toBeDefined();
});
@@ -50,46 +9,18 @@ describe('useKeyboardEvents', () => {
expect(useKeyboardEvents).toBeInstanceOf(Function);
});
it('registers keybindings on mount and removes the listener on unmount', async () => {
const addEventListener = vi.spyOn(document, 'addEventListener');
it('should set up listeners on mount and remove them on unmount', async () => {
const events = {
'ALT+KeyL': () => {},
};
const keybindings = await mount({ 'ALT+KeyL': () => {} });
expect(Object.keys(keybindings)).toEqual(['ALT+KeyL']);
const mountedMock = vi.fn();
const unmountedMock = vi.fn();
useKeyboardEvents(events);
mountedMock();
unmountedMock();
const { signal } = addEventListener.mock.calls[0][2];
expect(signal.aborted).toBe(false);
keyboardEventsMock.unmountedCallbacks[0]();
expect(signal.aborted).toBe(true);
});
it('prefixes Shift on QWERTZ layouts for the affected keys', async () => {
useDetectKeyboardLayout.mockResolvedValueOnce(LAYOUT_QWERTZ);
const keybindings = await mount({ 'Alt+KeyL': () => {} });
expect(Object.keys(keybindings)).toEqual(['Shift+Alt+KeyL']);
});
it('ignores shortcuts on focused typeable elements by default', async () => {
const action = vi.fn();
const keybindings = await mount({
'Alt+KeyL': { action, allowOnFocusedInput: false },
});
keybindings['Alt+KeyL']({ target: document.createElement('textarea') });
expect(action).not.toHaveBeenCalled();
});
it('runs shortcuts on focused typeable elements when allowed', async () => {
const action = vi.fn();
const keybindings = await mount({
'Alt+KeyL': { action, allowOnFocusedInput: true },
});
keybindings['Alt+KeyL']({ target: document.createElement('textarea') });
expect(action).toHaveBeenCalledTimes(1);
expect(mountedMock).toHaveBeenCalled();
expect(unmountedMock).toHaveBeenCalled();
});
});
@@ -1,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 });
};
-1
View File
@@ -46,7 +46,6 @@ export const FEATURE_FLAGS = {
COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search',
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
};
export const PREMIUM_FEATURES = [
+10 -68
View File
@@ -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({
-5
View File
@@ -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,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