Compare commits

..
Author SHA1 Message Date
Sivin VargheseandGitHub b7d23d9435 Merge branch 'develop' into feat/open-article-endpoint 2026-06-10 23:32:50 +05:30
Sony Mathew e9a92a6366 Merge branch 'release/4.14.2' into develop 2026-06-10 22:44:31 +05:30
Sony Mathew bec795f764 Bump version to 4.14.2 2026-06-10 22:43:22 +05:30
Shivam MishraandGitHub 9f64d50011 Merge branch 'develop' into feat/open-article-endpoint 2026-06-10 16:40:34 +05:30
Shivam MishraandGitHub e6dfb91fcc refactor: use SafeFetch for website branding page fetch (#14693) 2026-06-10 16:26:24 +05:30
Shivam MishraandGitHub d9c07fe2e9 feat: expose onboarding help center generation status (#14671)
This PR adds a reload-safe onboarding Help Center generation status
path. Previously, generation progress was pushed through ActionCable,
which meant the onboarding UI could lose context after a page reload or
missed websocket event. The new endpoint exposes the current generation
id, raw Redis generation state, and Help Center article/category counts
so clients can recover state by fetching from the backend.

**What changed**

- Added an onboarding Help Center generation status endpoint.
- Persisted `help_center_generation_id` when generation is enqueued.
- Removed Help Center generation ActionCable broadcasts completely.
- Kept generation progress in Redis as the backend source of truth.
- Kept Help Center generation out of the onboarding hot path; the
existing onboarding controller does not start generation yet.

**How to test**

1. Start Help Center generation for an account.
2. Fetch the onboarding generation status endpoint and verify it returns
the generation id, Redis state, and article/category counts.
3. Reload the onboarding UI or client state and fetch again to confirm
progress can be recovered without relying on websocket events.
4. Verify skipped/completed generation states are reflected from Redis.

## Related PRs

- https://github.com/chatwoot/chatwoot/pull/14569
- https://github.com/chatwoot/chatwoot/pull/14568
- https://github.com/chatwoot/chatwoot/pull/14567
- https://github.com/chatwoot/chatwoot/pull/14619
- https://github.com/chatwoot/chatwoot/pull/14565 (Primary onboarding
PR)
2026-06-10 16:24:37 +05:30
f93f2067b6 fix(whatsapp): Drop obsolete WABA scope check broken by Meta embedded signup (#14697)
## Description

Fixes WhatsApp embedded signup failing with "No WABA scope found in
token." Meta recently changed which scopes embedded-signup tokens carry
(whatsapp_business_manage_events instead of
whatsapp_business_management), which tripped a brittle scope-string
check. That check was redundant anyway — PhoneInfoService already
verifies the token's access to the specific WABA by calling its
/phone_numbers endpoint right before it. This removes the obsolete
TokenValidationService and relies on the functional check.

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-10 13:22:30 +04:00
Shivam Mishra f926f5e500 fix: navigating once an article is open 2026-06-10 14:33:35 +05:30
Shivam MishraandGitHub c9f6fb202c Merge branch 'develop' into feat/open-article-endpoint 2026-06-10 14:12:41 +05:30
Shivam MishraandGitHub 3dfb5061e1 refactor: route captain custom tool requests through SafeFetch (#14620)
Captain custom tools previously used their own hand-rolled `Net::HTTP`
request code. This moves them onto `SafeFetch`, the same shared
HTTP-fetching helper already used by webhooks, uploads, avatar imports,
and the Captain page crawler. Behavior for normal tools is unchanged —
they just now share one consistent path for host resolution, timeouts,
response size limits, and redirect handling.

**What changed**
- `HttpTool#execute_http_request` now delegates to `SafeFetch.fetch`
instead of building `Net::HTTP` requests by hand. Auth headers, basic
auth, metadata headers, JSON content-type, and the 1 MB response cap all
map onto `SafeFetch` options.
- Removed ~80 lines of bespoke request/validation plumbing from
`HttpTool`.
- The custom tools `test` endpoint now reads the response body string
directly (the executor returns the body rather than a response object).

**How to test**
1. Enable `custom_tools` (or `captain_integration_v2`) for an account.
2. Create a Captain custom tool pointing at a public HTTPS endpoint
(e.g. a test API).
3. Use the **Test** button in the tool form — you should get a success
result.
4. Run the tool from a Captain conversation and confirm the response is
returned/templated as before.

**Gotchas**
- **Local dev:** `SafeFetch` blocks requests to private/loopback
addresses by default. If you're testing a custom tool against a service
on `localhost` or a private IP during development, set
`SAFE_FETCH_ALLOW_PRIVATE_NETWORK=true` or the request will be rejected.
(This matches how the rest of `SafeFetch` already behaves locally.)
- **Test endpoint status field:** on success the `test` response now
reports `status: 200` rather than the exact 2xx code (201/204/etc.),
because `SafeFetch` signals success-vs-failure rather than exposing the
raw response. The UI only checks the 2xx range, so this is invisible
there — but worth knowing if anything consumes the API directly.
- **Non-2xx responses** still surface as an error (same as before), now
via `SafeFetch::HttpError`.
2026-06-10 14:01:30 +05:30
72a59e4795 fix: update oauth dependencies (#14691)
Updates the locked OAuth dependencies to patched versions so bundle
audit no longer reports the current OAuth advisories.

What changed
- Updated `oauth` from `1.1.0` to `1.1.6` for `GHSA-prq8-7wvh-44qh`.
- Updated `oauth2` from `2.0.9` to `2.0.22` for `GHSA-pp92-crg2-gfv9`.
- Accepted Bundler's required transitive lockfile updates for the
patched OAuth gems.

How to test
1. Run `bundle exec bundle audit update && bundle exec bundle audit
check -v` and confirm no vulnerabilities are reported.
2. Smoke test OAuth-based authentication and email integration flows.

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-06-10 12:33:46 +05:30
Sivin VargheseandGitHub cabe9bc733 fix: populate general settings form on hard reload (#14685) 2026-06-10 10:56:17 +05:30
33f7550525 fix(whatsapp): restrict OGG voice recording to WhatsApp Cloud inboxes (#14692)
Recording and sending an audio message from a **Twilio WhatsApp** inbox
failed silently — Twilio rejected the media with delivery error `63019`
("Media failed to download") and the voice note never reached the
customer. This restores audio sending for Twilio WhatsApp (and
360dialog) inboxes.

Closes Regression from #14606

## How to reproduce

1. Open a conversation in a **Twilio WhatsApp** inbox.
2. Record a voice message in the reply box and send it.
3. Before this fix: the message fails to deliver and a
`Webhooks::TwilioDeliveryStatusJob` is enqueued with `ErrorCode: 63019`,
`ErrorMessage: "Media failed to download"`.
4. After this fix: the audio is recorded as MP3 and delivers normally.

## What changed

PR #14606 added WhatsApp **Cloud** voice notes, which require OGG/Opus.
It changed `audioRecordFormat` in `ReplyBox.vue` to return OGG for
`isAWhatsAppChannel` — but that getter is also `true` for Twilio
WhatsApp inboxes. The OGG handling (content-type normalization + the
`voice: true` flag) lives only in `WhatsappCloudService`, so Twilio
could not download/process the remuxed OGG file.

This change scopes OGG to `isAWhatsAppCloudChannel`. Twilio WhatsApp,
360dialog, and Telegram fall back to MP3 exactly as they did before the
PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:52:34 +04:00
Shivam MishraandGitHub 7830fec604 Merge branch 'develop' into feat/open-article-endpoint 2026-06-09 22:33:34 +05:30
Sivin VargheseandGitHub 59d869d1ed feat: Ability to resize table column width (#14611) 2026-06-09 18:04:48 +05:30
8a3b129292 fix: Disable re-oauth flow for manual whatsapp (#13599)
Restrict the WhatsApp reauthorization flag to channels whose
provider_config source is 'embedded_signup'. Previously the view exposed
resource.channel.reauthorization_required? for all WhatsApp channels;
now it only returns true when (provider_config || {}).to_h['source'] ==
'embedded_signup' && resource.channel.reauthorization_required?. This
prevents showing reauthorization prompts for manual/API-key flows
(non-OAuth) and safely handles nil provider_config.

# Pull Request Template

## Description

Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.
Fixes  #13553 

## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-06-09 11:12:03 +04:00
af1dfc21f6 fix: include account data in webhook payloads (#12445)
Conversation and inbox webhook payloads now include the account object
(`{ id, name }`) so integrations can reliably identify the account
without depending on nested message data.

## Closes

Closes #11753
Closes #12442

## Why

Message, contact, and contact inbox webhook payloads already expose
`account`. Conversation and inbox webhook payloads were outliers, which
forced webhook consumers to infer account context from nested messages
or other fields that may not always be present.

## What changed

- Adds `account: { id, name }` to conversation webhook payloads.
- Adds webhook-specific inbox payload data with `account: { id, name }`
for inbox created/updated events.
- Keeps non-webhook conversation and inbox push payload behavior
unchanged.

## How to test

- Configure an account webhook for `conversation_created`, trigger a new
conversation, and confirm the webhook payload includes `account.id` and
`account.name`.
- Configure an account webhook for `inbox_created`, create a new inbox,
and confirm the webhook payload includes `account.id` and
`account.name`.
- Configure a webhook agent bot and update a conversation status, then
confirm the bot webhook payload includes the same account object.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-06-09 12:12:46 +05:30
d1c482cb64 fix(email): strip null bytes from inbound mailbox messages (#14546)
Inbound emails containing null bytes could fail while Chatwoot persisted
the incoming message, causing IMAP sync to drop and repeatedly retry the
same malformed email. This strips null bytes at the inbound mailbox
message persistence boundary so the rest of the email can be saved
normally.

Fixes
https://linear.app/chatwoot/issue/CW-7165/argumenterror-string-contains-null-byte-argumenterror
Sentry: https://chatwoot-p3.sentry.io/issues/7405946944/

## Test
- [x] added specs
- [x] manually tested the logic on prod for the inbox(email) which was
throwing the error

## What changed

- Sanitizes null bytes only while building attributes for inbound
mailbox message persistence.
- Uses the sanitized source ID for both duplicate lookup and message
creation.
- Adds regression coverage for the mailbox message creation path.

---------

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-08 17:12:25 +05:30
d8656edc61 fix(facebook): Stop force tagging Messenger replies with MESSAGE_TAG/ACCOUNT_UPDATE (#14329)
Outbound Facebook Messenger replies were always sent with
`messaging_type: MESSAGE_TAG` and a `tag` of either `HUMAN_AGENT` or
`ACCOUNT_UPDATE` (fallback). `ACCOUNT_UPDATE` is reserved by Meta policy
for non-recurring account notifications (password resets, suspicious
activity, account-status changes), so general agent replies were getting
rejected by Facebook with "Invalid parameter" and risked page-level
penalties. After this fix, Facebook treats default replies as standard
`RESPONSE` messages within the 24-hour window, and only attaches
`HUMAN_AGENT` tagging when the operator opts in via
`ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT`.

## Closes

<!-- Add the relevant issue link, e.g. Closes #1234 -->

## How to reproduce

1. Connect a Facebook page inbox.
2. Leave `ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT` disabled (default).
3. From the dashboard, reply to an active Messenger conversation with a
regular/promotional message.
4. Before this PR: the request to Graph API includes
`messaging_type=MESSAGE_TAG&tag=ACCOUNT_UPDATE`. Facebook returns
"Invalid parameter" for content that does not match the `ACCOUNT_UPDATE`
use case, and the message is marked failed in Chatwoot.
5. After this PR: the request omits `messaging_type` and `tag`, so
Facebook treats it as a standard `RESPONSE` and accepts it within the
24-hour customer service window.

## What changed

- `app/services/facebook/send_on_facebook_service.rb` now mirrors the
Instagram service pattern: text and attachment params are built without
`messaging_type`/`tag` by default, and a shared `merge_human_agent_tag`
helper attaches `MESSAGE_TAG`/`HUMAN_AGENT` only when
`ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT` is enabled.
- Removed the unused `sent_first_outgoing_message_after_24_hours?`
helper (dead code with no callers).
- Updated `spec/services/facebook/send_on_facebook_service_spec.rb`:
dropped `ACCOUNT_UPDATE` expectations, added a spec asserting no
`messaging_type`/`tag` is sent by default, and tightened the HUMAN_AGENT
spec to verify both `messaging_type` and `tag`.

## Operator note

Operators who were relying on the prior `ACCOUNT_UPDATE` fallback to
bypass the 24-hour window were already in violation of Meta policy and
likely seeing send failures. They should enable
`ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT` (and request the Human Agent
permission from Meta) for the 7-day extended window

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-06-08 15:28:36 +04:00
e919a2cef5 feat: add contact filter for conversations (#14629)
# Pull Request Template

## Description

Adds a Contact condition to the conversation advanced filter so agents
can search for an existing contact and filter conversations by
`conversations.contact_id`.

Fixes
[CW-7239](https://linear.app/chatwoot/issue/CW-7239/contact-filter-for-conversations)

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

- `eval "$(rbenv init -)" && bundle exec rspec
spec/services/conversations/filter_service_spec.rb`
- `pnpm exec vitest --no-watch --no-cache --no-coverage --logHeapUsage
app/javascript/dashboard/components-next/filter/helper/filterHelper.spec.js
app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js
app/javascript/dashboard/helper/specs/customViewsHelper.spec.js
app/javascript/dashboard/helper/specs/filterQueryGenerator.spec.js`
- `pnpm eslint`
- `eval "$(rbenv init -)" && bundle exec rubocop
spec/services/conversations/filter_service_spec.rb`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-06-08 16:00:17 +05:30
Sojan JoseandGitHub 7718f2a62c fix: update puma for security advisories (#14670)
Updates Puma to the patched 7.2 line so bundle-audit no longer reports
the high-severity Puma advisories affecting the current lockfile.

Fixes: CVE-2026-47736, CVE-2026-47737
Closes: N/A

## Why

The refreshed ruby-advisory-db flagged Puma 6.4.3 for two high-severity
PROXY Protocol v1 parser advisories. The advisory-recommended fixed
versions are the Puma 7.2 patch line or Puma 8, so this keeps the update
to the smaller fixed line.

## What changed

- Constrains Puma to `~> 7.2`, `>= 7.2.1`
- Updates the lockfile from Puma 6.4.3 to 7.2.1
- Updates nio4r from 2.7.3 to 2.7.5 as the transitive Puma dependency

## Validation

- `bundle exec bundle-audit check` -> `No vulnerabilities found`
- `bundle exec rails runner 'puts "Rails #{Rails.version}; Puma
#{Puma::Const::PUMA_VERSION}; Rack #{Rack.release}"'` -> `Rails 7.1.5.2;
Puma 7.2.1; Rack 3.2.6`
- `bundle exec rails server -p 3051` booted Puma 7.2.1 successfully
- Smoke checked `/`, `/api`, unknown-route 404, and `/cable` WebSocket
upgrade
2026-06-08 15:40:43 +05:30
Aakash BakhleandGitHub 45f4b423ae fix: captain usage for BYOK OpenAI tasks (#14587)
# Pull Request Template

## Description

Fixes:
https://linear.app/chatwoot/issue/CW-7167/label-suggestions-bad-ux

## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-08 13:37:21 +05:30
Shivam MishraandGitHub 78a6b2457d refactor(onboarding): extract whatsapp and facebook channel connect into reusable composables (#14619)
Extracts the Meta JS SDK logic for the WhatsApp embedded-signup and
Facebook Page connect flows out of their settings components into two
reusable composables. No behavior change — the settings inbox-creation
flows work exactly as before; this just makes the SDK orchestration
reusable (an upcoming onboarding PR consumes them) and adds unit
coverage.

## What changed
- `useWhatsappEmbeddedSignup` — owns the embedded-signup popup (SDK
load, FB.login, and the auth-code / postMessage race).
`WhatsappEmbeddedSignup.vue` now consumes it.
- `useFacebookPageConnect` — owns FB.login (page scopes) + page fetch,
with SDK preload split from login so the popup opens within the click's
activation window. `Facebook.vue` now consumes it.
- Adds unit specs for both composables.

## Related PRs

- https://github.com/chatwoot/chatwoot/pull/14569
- https://github.com/chatwoot/chatwoot/pull/14568
- https://github.com/chatwoot/chatwoot/pull/14567
- https://github.com/chatwoot/chatwoot/pull/14649
- https://github.com/chatwoot/chatwoot/pull/14565 (Primary onboarding
PR)
2026-06-08 13:13:20 +05:30
Shivam MishraandGitHub 73ba0b26e5 fix(instagram): process webhook events after mutex retry exhaustion (#14647)
Instagram webhooks use a Redis mutex to protect the first-message path
for a contact/account pair. When multiple webhook events for the same
Instagram contact arrive at nearly the same time, they can all enter the
“find or create” flow together.

There are two pieces involved:

`ContactInbox` maps the external Instagram scoped user id to a Chatwoot
contact inside an inbox. That side already has a unique `(inbox_id,
source_id)` index and retry handling, so duplicate contact-inbox
creation is mostly protected at the database layer.

`Conversation` creation is more fragile. The message builder looks for
an existing active conversation for the contact/inbox and creates one
when none is found. If two first-message jobs run concurrently, both can
see “no active conversation yet” and both can create a conversation. The
mutex exists to bump those jobs apart long enough for the first one to
create the conversation, so the next one appends to it instead of
creating a duplicate.

In production, the old behavior could turn that race dampener into a
drop path. Once `Webhooks::InstagramEventsJob` exhausted its lock
retries, ActiveJob stopped retrying with
`MutexApplicationJob::LockAcquisitionError`. Since ActiveJob retries are
not FIFO, later jobs could still win the lock while older jobs kept
getting deferred until exhaustion.

## Mutex Application Job Changes

This adds `retry_on_lock_conflict` as a small wrapper around the
existing `retry_on LockAcquisitionError` pattern.

The new API keeps the default behavior intact, but lets jobs explicitly
define what should happen after lock retry exhaustion:

```ruby
retry_on_lock_conflict wait: 1.second,
                       attempts: 3,
                       on_exhaustion: :process_without_lock
```

The fallback receives the original job arguments, so job classes do not
need to reach into ActiveJob internals like `arguments.first`.

## Instagram Fallback

Instagram now processes the webhook payload even after the mutex retry
window is exhausted.

This matches what the mutex was meant to do in the first place: bump
concurrent events apart long enough to avoid duplicate conversation
creation, not permanently block or drop webhook events. If a job cannot
acquire the lock after a few retries, it continues through the normal
Instagram processing path without the mutex.

## Retry And Lock Tuning

The Instagram lock retry window now uses deterministic backoff:

```ruby
retry_on_lock_conflict wait: ->(executions) { executions.seconds },
                       attempts: 3,
                       on_exhaustion: :process_without_lock
```

The lock TTL is also set explicitly:

```ruby
with_lock(key, 3.seconds)
```

This matters because Rails treats `attempts` as total executions, not
retries after the first execution. With a fixed `wait: 1.second` and
`attempts: 3`, the exhaustion handler can run roughly two seconds after
the first lock conflict. That is earlier than a 3-second lock TTL, so
the fallback could process without the lock while the original mutex is
still valid. That would reopen the duplicate-conversation race the lock
is meant to dampen.

Using a proc wait makes the timing predictable and avoids Rails jitter
for this retry path. The first conflict waits about 1 second, the second
waits about 2 seconds, and the final attempt happens around the 3-second
mark. That lines the retry window up with the Redis lock TTL before
falling back to `process_without_lock`.

The protected race window is intentionally small. `ContactInbox`
creation already has database uniqueness protection, while conversation
creation is the softer `find active conversation || create` path. We
only need to give the first job enough time to create the conversation
state that later jobs should reuse.

The mutex still does not preserve message order, and ActiveJob retries
are not FIFO. A longer retry window would mostly add latency for hot
Instagram contacts without making ordering more correct. After the lock
TTL has elapsed, processing without the lock is the better tradeoff than
dead-lettering customer messages.
2026-06-05 14:06:45 +05:30
f9385a31fc fix(api): allow agent bots to read conversations and manage labels (#14655)
Agent bots assigned to a conversation can now fetch that conversation's
details and manage its labels using their bot token. Previously these
two operations returned `Access to this endpoint is not authorized for
bots`, even though the same token worked for sending messages, updating
the conversation, and setting custom attributes.

Fixes
https://linear.app/chatwoot/issue/PLA-174/agent-bot-tokens-cannot-fetch-conversation-details-or-manage-labels

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:01:25 +04:00
9d808a18df fix(drops): Resolve first_name for single-word contact and agent names (#13488)
WhatsApp template variables like `{{contact.first_name}}` previously
resolved to an empty string whenever a contact or agent had a
single-word name (e.g. "Petterson"). Because the variable came back
blank, WhatsApp template sends would either fail validation or get stuck
indefinitely in the "Sending" state. With this fix, `first_name` falls
back to the full single-word name, so templates render correctly for
everyone regardless of how many words their name has.

## Closes

- Fixes #13222

## How to reproduce

1. Create a contact with a single-word name (e.g. `Petterson`).
2. Configure a WhatsApp template action that uses
`{{contact.first_name}}`.
3. Send the template message.
4. **Before:** the variable resolves to an empty string and the message
fails / stays in "Sending".
**After:** the variable resolves to `Petterson` and the message sends.

## What changed

- Removed the "name must have 2+ words" guard from `first_name` in
`ContactDrop` and `UserDrop`, so a single-word name is returned (still
capitalized).
- Capitalization behavior introduced in #6758 is preserved; only the
single-word edge case changes.
- `last_name` still returns `nil` for single-word names, which is the
expected behavior.
- Added specs covering single-word names for both `ContactDrop` and
`UserDrop`.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-05 10:03:24 +04:00
Sivin VargheseandGitHub ec43975f3f chore: use square avatars (#14656) 2026-06-05 11:25:32 +05:30
64c5aeebee feat(help-center): support per-locale portal title, name & header (#14642)
Portals can now override their name, page title and header text per
locale, with a fallback chain of locale override → default locale → base
value. Overrides are stored under portal.config.locale_translations and
validated with a JSON schema.

Editing is exposed as a "Localize content" action in each non-default
locale's menu on the Locale page, and all public-facing portal views
(classic + documentation layouts) render the localized values. The live
chat widget is also loaded in the active portal locale.


<img width="494" height="395" alt="Screenshot 2026-06-03 at 2 48 59 PM"
src="https://github.com/user-attachments/assets/e55a06e8-f20f-4d8a-9352-92fde28a9a19"
/>



https://github.com/user-attachments/assets/f5affbd2-7ad4-415a-b376-57c759fc2aaa

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:34:12 -07:00
Shivam MishraandGitHub ba04e0b678 Merge branch 'develop' into feat/open-article-endpoint 2026-06-04 13:02:20 +05:30
cd9192f7d1 chore(captain): update Firecrawl to use the v2 API (#14624)
## Description

Migrates Firecrawl from the v1 to the v2 API.
`Captain::Tools::FirecrawlService` now targets `api.firecrawl.dev/v2`,
with the request body updated to match the v2 schema.

> Disclosure: I work at Firecrawl.

Fixes # (n/a)

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Updated
`spec/enterprise/services/captain/tools/firecrawl_service_spec.rb` to
assert the v2 endpoint and request body.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes

---------

Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-06-03 23:47:49 +05:30
Aakash BakhleandGitHub eaffad12e7 feat(langfuse): propagate observation metadata for evals (#14634)
# Pull Request Template

## Description

We need to pass on trace level attributes down to the spans inside them
like tool calls, observations, etc.
This way, we can filter observations based on trace level attributes.


## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

Attributes added to observation metadata for easy filtering
<img width="1327" height="708" alt="image"
src="https://github.com/user-attachments/assets/8f1d1bf8-cde4-481d-a2c2-7920ad2fc52e"
/>

added a `generation_stage` to differentiate llm_calls that call tools vs
those that generate a `final_response`
<img width="1806" height="968" alt="CleanShot 2026-06-03 at 15 11 09@2x"
src="https://github.com/user-attachments/assets/db1fa8e0-7f2d-404b-a719-27a16d400442"
/>


propagated attributes to tool calls for future use
<img width="903" height="517" alt="image"
src="https://github.com/user-attachments/assets/edc61ce8-93db-465c-a66e-043138e2dc15"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-03 16:45:19 +05:30
Sivin VargheseandGitHub 18ef019cd4 fix: improve article editor typography & nested lists (#14572) 2026-06-03 16:11:32 +05:30
Tanmay Deep SharmaandGitHub ea910227ac feat: add Voice Calls feature card to settings showcase (#14635)
## Linear Ticket 

https://linear.app/chatwoot/issue/CW-7250/add-voice-calls-to-self-hosted-super-admin-feature-list

## Description

Adds a **Voice Calls** card to the Super Admin → Settings → Features
showcase so self-hostedadmins can see at a glance whether voice calling
is available on the installation.

## Type of change

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

## Screenshots?

<img width="1512" height="843" alt="Screenshot 2026-06-03 at 3 15 22 PM"
src="https://github.com/user-attachments/assets/988db14b-fef1-4b72-a55e-c7a8884521dd"
/>


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-03 16:01:14 +05:30
Tanmay Deep SharmaandGitHub 94ddd98050 chore: update the voice call ringtone (#14636)
## Linear Ticket
https://linear.app/chatwoot/issue/CW-7260/update-the-voice-call-ringtone

## Description

Updates the incoming voice call ringtone. The dashboard call widget now
plays the new tone while an inbound call is unanswered, replacing the
previous bell sound.

## Type of change

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

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-03 16:00:04 +05:30
Tanmay Deep SharmaandGitHub 0e87519ecd feat: add mute button for Twilio calls (#14637)
## Linear Ticket

https://linear.app/chatwoot/issue/CW-7264/add-mute-button-in-twilio-calls

## Description

Adds mute support for Twilio voice calls. Previously the mute button was
shown only for WhatsApp calls (which toggle the local mic track in the
browser), so Twilio calls had no way to mute. The call widget now routes
mute by provider: WhatsApp continues to toggle the local mic track,
while Twilio uses the Voice SDK connection's native `mute()`. The mute
button is now shown for any active call, and unmute works symmetrically.

## Type of change

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

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-03 15:59:23 +05:30
Tanmay Deep SharmaandGitHub de89391031 fix: use hang-up handset icon for reject/end call button (#14640)
## Linear Ticket
https://linear.app/chatwoot/issue/CW-7261/call-window-theme-alignment

## Description

Updates the call window's reject/end button to use the hang-up handset
icon (the same handset as the accept button, rotated) instead of the
phone-with-X, matching the design.

## Type of change

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

## Screenshots

<img width="346" height="135" alt="Screenshot 2026-06-03 at 3 41 10 PM"
src="https://github.com/user-attachments/assets/4d507141-332e-436a-a6ec-516964a31013"
/>


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
2026-06-03 15:59:12 +05:30
Sojan JoseandGitHub 8e42307bdc fix: improve email inbox IMAP and SMTP compatibility (#14589)
Fetch IMAP message content using `BODY.PEEK[]` instead of `RFC822` to
avoid provider-specific parser failures while preserving unread state.
This also applies the existing SMTP timeout configuration to custom SMTP
email-channel replies, so provider SMTP responses have enough time to
complete.

Fixes: https://github.com/chatwoot/chatwoot/issues/12762

## Why

Some IMAP providers can return responses for `FETCH RFC822` that Ruby
`net-imap` fails to parse with:

`Net::IMAP::ResponseParseError: unexpected RPAR (expected ATOM or NIL)`

We reproduced this with iCloud IMAP. Authentication, `INBOX` selection,
and header fetches worked, but fetching full message content with
`RFC822` failed before Chatwoot received a `Mail::Message`.

The same mailbox successfully returned full message content when fetched
with `BODY.PEEK[]`.

> During end-to-end iCloud validation, inbound fetch worked after the
IMAP change, but outbound replies through the custom SMTP settings could
still fail with a socket read timeout. The OAuth SMTP path already used
explicit SMTP timeout values; the custom SMTP path was relying on mailer
defaults instead.

## What this change does

- Replaces the full message fetch from `RFC822` to `BODY.PEEK[]`
- Reads the returned message content from `BODY[]`, which is how
`net-imap` exposes the response attribute
- Keeps the existing `BODY.PEEK[HEADER]` header-fetch behavior unchanged
- Applies `SMTP_OPEN_TIMEOUT` and `SMTP_READ_TIMEOUT` to custom SMTP
email-channel replies
- Defaults custom SMTP reply delivery to `open_timeout: 15` and
`read_timeout: 30`
- Updates IMAP service specs for standard and Microsoft IMAP fetch flows
- Updates mailer specs for custom SMTP timeout settings

`BODY.PEEK[]` is preferable here because it fetches the full message
content without marking messages as read.

## Validation

- Configured a local email inbox against iCloud IMAP and SMTP
- Confirmed `FETCH RFC822` reproduces `Net::IMAP::ResponseParseError:
unexpected RPAR (expected ATOM or NIL)`
- Confirmed `BODY[]` and `BODY.PEEK[]` fetch the same mailbox
successfully
- Confirmed Chatwoot imports iCloud messages after the IMAP change
- Sent two outbound replies from the Chatwoot UI through iCloud SMTP
after applying the timeout settings
- Confirmed both UI-created outbound messages were marked `sent`, had
iCloud SMTP `source_id` values, and had no `external_error`
- Ran `bundle exec rspec spec/services/imap/fetch_email_service_spec.rb
spec/services/imap/microsoft_fetch_email_service_spec.rb`
- Ran `bundle exec rspec spec/mailers/conversation_reply_mailer_spec.rb`
2026-06-03 15:56:54 +05:30
1beaa284c6 feat: inline images in website and email channels (#14516)
# Pull Request Template

## Description

This PR adds support for inline image uploads in the reply editor for
Email and Website (chat widget) channels.

Agents can now insert images inline between text and resize them
directly in the editor by dragging the bottom corner, similar to the
help center editor experience.

Image sizes are preserved through markdown using the `cw_image_width`
URL param and render correctly in both outgoing emails and chat widget
messages.

Agents can also paste copied images directly into Email or Website
replies using **Shift+Cmd+V** (Shift+Ctrl+V on Windows/Linux). The image
gets inserted inline at the cursor position and supports resizing just
like uploaded images. Regular **Cmd+V / Ctrl+V** behavior remains
unchanged and continues to add images as attachments, so both inline and
attachment flows are supported.


### Prosemirror repo PR:
https://github.com/chatwoot/prosemirror-schema/pull/48

Fixes
https://linear.app/chatwoot/issue/CW-7133/inline-images-in-live-chat-and-email

https://linear.app/chatwoot/issue/CW-7225/ghsa-8j9w-jppp-xcfc-html-attribute-injection-via-unvalidated-cw-image

## Type of change

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

## How Has This Been Tested?

### Screencast



https://github.com/user-attachments/assets/a928f852-ab15-413a-9d35-6ea69b718ecf

<img width="414" height="654" alt="image"
src="https://github.com/user-attachments/assets/205e0729-8f2d-4cc5-9c55-7696f032eca4"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-03 15:05:17 +05:30
Sivin VargheseandGitHub d028cc1984 fix: prevent list marker overflow in messages (#14618) 2026-06-03 14:18:48 +05:30
Sivin VargheseandGitHub 0d59fb4459 fix: validate portal color format (#14632) 2026-06-03 14:18:19 +05:30
7acbe8b3ff fix(whatsapp): truncate location fallback_title to 255 chars to avoid silent message drop (#14517)
## Summary

`Whatsapp::IncomingMessageBaseService#attach_location` builds a
`fallback_title` by concatenating `location['name']` and
`location['address']` with no length cap, then stores it directly into
`Attachment#fallback_title`. `ApplicationRecord` enforces a generic
255-character limit on string columns, so any WhatsApp location whose
`"#{name}, #{address}"` exceeds 255 chars (a common case for Google
Places that include a long full address) raises
`ActiveRecord::RecordInvalid` deep inside the Sidekiq job. The message
and attachment INSERTs are part of the same transaction, so the whole
thing rolls back. Sidekiq retries once; the retry dedup-skips the wamid
silently and exits without an error. **Result: the message is
irrecoverably lost — no row in `messages`, no entry in the UI, no
outgoing webhook, no clue for the operator.**

Confirmed in `v4.13.0`, `v4.14.0`, and `develop` (commit `f33e469`,
2026-05-20). No upstream issue found before opening this PR.

## How to reproduce

1. From WhatsApp, share a Google Place whose `name + ", " + address` is
> 255 chars. The Spanish business address `Gremi de Fusters, 33,
Edificio VIP Asima, Piso 2, Local 2, Norte, 07009 Polígon industrial de
Son Castelló, Illes Balears, España` (132 chars) used as both `name` and
`address` is enough.
2. Sidekiq logs:
   ```
   ERROR ActiveRecord::RecordInvalid: Validation failed:
   Attachments fallback title is too long (maximum is 255 characters)
   ```
3. The `messages` table has no row. The conversation UI shows nothing
for that timestamp.
4. The first retry "Performed" successfully but creates nothing — the
dedup-by-source-id silently swallows the failure.

## Fix

Cap the existing concatenated title at 255 chars via `.first(255)`.
Minimal change, no behavioural difference for any message shorter than
the limit, prevents the silent data loss for any longer ones.

```diff
-    location_name = location['name'] ? "#{location['name']}, #{location['address']}" : ''
+    location_name = (location['name'] ? "#{location['name']}, #{location['address']}" : '').first(255)
```

## Alternatives considered

- **Increase the validation limit on `Attachment#fallback_title`**: more
invasive; would touch other inbound channels and possibly require a DB
column change.
- **Use `name` alone (no concat)**: cleaner semantically (in many real
payloads `name == address`), but changes user-visible behaviour. Left as
a follow-up if desired.
- **Truncate with ellipsis**: cosmetic only; deferred.

This PR is intentionally minimal so it can be merged on its own.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-06-03 12:50:21 +05:30
Vinícius FitznerandGitHub b791d75b30 fix(microsoft): prevent OAuth admin consent loop (#13962)
Fixes #9775

## Description

This fixes a repeated admin consent loop in the Microsoft OAuth flow
when connecting a Microsoft email inbox.

Chatwoot was always sending `prompt=consent` in the Microsoft
authorization URL. In the current code path, this parameter is only used
when building the authorization URL and is not required by the callback,
token exchange, token persistence, or refresh flow.

By removing the forced consent prompt, the OAuth flow can proceed
normally without repeatedly sending users back through the admin consent
screen.

## What changed

- removed `prompt: 'consent'` from the Microsoft authorization URL
- added a regression assertion to ensure `prompt` is not included in the
generated URL

## Why this is safe

- `redirect_uri`, `scope`, and `state` remain unchanged
- callback and token exchange flow remain unchanged
- refresh token flow remains unchanged
- no other part of the current Microsoft inbox flow depends on forcing a
consent screen

## Testing

- updated controller spec to assert that the generated authorization URL
does not include `prompt`
2026-06-03 12:05:25 +05:30
Shivam Mishra 8625a00918 feat: open help center article from sdk
Add window.$chatwoot.openArticle(slug) which opens the widget and navigates the in-widget article viewer to the given help center article.
2026-06-02 16:01:09 +05:30
Shivam Mishra 16e638aeb7 refactor: extract article viewer link builder
Move the article viewer query-param logic out of ArticleContainer into a shared buildArticleViewerLink helper so it can be reused.
2026-06-02 16:01:00 +05:30
399 changed files with 3103 additions and 2602 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ gem 'faraday_middleware-aws-sigv4'
##--- gems for server & infra configuration ---##
gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman'
gem 'puma'
gem 'puma', '~> 7.2', '>= 7.2.1'
gem 'vite_rails'
# metrics on heroku
gem 'barnes'
+34 -23
View File
@@ -136,6 +136,8 @@ GEM
audited (5.4.1)
activerecord (>= 5.0, < 7.7)
activesupport (>= 5.0, < 7.7)
auth-sanitizer (0.2.1)
version_gem (~> 1.1, >= 1.1.10)
aws-actionmailbox-ses (0.1.0)
actionmailbox (>= 7.1.0)
aws-sdk-s3 (~> 1, >= 1.123.0)
@@ -168,7 +170,7 @@ GEM
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.4.1)
bigdecimal (3.2.2)
bigdecimal (3.3.1)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -184,6 +186,7 @@ GEM
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
cgi (0.5.1)
childprocess (5.1.0)
logger (~> 1.5)
cld3 (3.7.0)
@@ -312,7 +315,7 @@ GEM
hashie
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.2)
faraday-net_http (3.4.4)
net-http (~> 0.5)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
@@ -435,7 +438,8 @@ GEM
hana (1.3.7)
hash_diff (1.1.1)
hashdiff (1.1.0)
hashie (5.0.0)
hashie (5.1.0)
logger
html2text (0.4.0)
nokogiri (>= 1.0, < 2.0)
http (5.1.1)
@@ -470,7 +474,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.5)
json (2.19.8)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -568,7 +572,7 @@ GEM
ruby2_keywords
msgpack (1.8.0)
multi_json (1.15.0)
multi_xml (0.8.0)
multi_xml (0.9.1)
bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
mutex_m (0.3.0)
@@ -593,7 +597,7 @@ GEM
sidekiq
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nio4r (2.7.5)
nokogiri (1.19.3)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
@@ -603,19 +607,26 @@ GEM
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
oauth-tty (1.0.5)
version_gem (~> 1.1, >= 1.1.1)
oauth2 (2.0.9)
faraday (>= 0.17.3, < 3.0)
jwt (>= 1.0, < 3.0)
oauth (1.1.6)
auth-sanitizer (~> 0.2, >= 0.2.1)
base64 (~> 0.1)
cgi
oauth-tty (~> 1.0, >= 1.0.8)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
oauth-tty (1.0.8)
auth-sanitizer (~> 0.1, >= 0.1.3)
cgi
version_gem (~> 1.1, >= 1.1.9)
oauth2 (2.0.22)
auth-sanitizer (~> 0.2, >= 0.2.1)
faraday (>= 0.17.3, < 4.0)
jwt (>= 1.0, < 4.0)
logger (~> 1.2)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
@@ -682,7 +693,7 @@ GEM
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (7.0.5)
puma (6.4.3)
puma (7.2.1)
nio4r (~> 2.0)
pundit (2.3.0)
activesupport (>= 3.0.0)
@@ -935,9 +946,9 @@ GEM
gli
hashie
logger
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
snaky_hash (2.0.5)
hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3)
sorbet-runtime (0.5.11934)
spring (4.1.1)
spring-watcher-listen (2.1.0)
@@ -995,7 +1006,7 @@ GEM
valid_email2 (5.2.6)
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.4)
version_gem (1.1.11)
vite_rails (3.10.0)
railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2)
@@ -1132,7 +1143,7 @@ DEPENDENCIES
pgvector
procore-sift
pry-rails
puma
puma (~> 7.2, >= 7.2.1)
pundit
rack-attack (>= 6.7.0)
rack-cors (= 2.0.0)
+1 -1
View File
@@ -1 +1 @@
4.14.1
4.14.2
@@ -6,8 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state,
prompt: 'consent'
state: state
}
)
if redirect_url
@@ -16,6 +16,10 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
render 'api/v1/accounts/update', format: :json
end
def help_center_generation
render json: help_center_generation_status
end
private
def finalizing_account_details?
@@ -33,4 +37,15 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
end
def help_center_generation_status
{
generation_id: nil,
state: nil,
articles_count: 0,
categories_count: 0
}
end
end
Api::V1::Accounts::OnboardingsController.prepend_mod_with('Api::V1::Accounts::OnboardingsController')
@@ -80,10 +80,15 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived,
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
)
end
def locale_translation_keys
params.dig(:portal, :config, :locale_translations)&.keys || []
end
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id)
@@ -1,8 +1,9 @@
module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations' => %w[show toggle_status toggle_typing_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations/messages' => ['create'],
'api/v1/accounts/conversations/assignments' => ['create']
'api/v1/accounts/conversations/assignments' => ['create'],
'api/v1/accounts/conversations/labels' => %w[index create]
}.freeze
def ensure_access_token
@@ -9,7 +9,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
layout 'portal'
def show
@og_image_url = helpers.set_og_image_url('', @portal.header_text)
@og_image_url = helpers.set_og_image_url('', @portal.localized_value('header_text', @locale))
end
def sitemap
+1 -1
View File
@@ -12,7 +12,7 @@ class ContactDrop < BaseDrop
end
def first_name
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
@obj.try(:name).try(:split).try(:first).try(:capitalize)
end
def last_name
+1 -1
View File
@@ -12,7 +12,7 @@ class UserDrop < BaseDrop
end
def first_name
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
@obj.try(:name).try(:split).try(:first).try(:capitalize)
end
def last_name
+6
View File
@@ -34,6 +34,12 @@ disable_branding:
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-sailbot-fill'
enterprise: true
voice_calls:
name: 'Voice Calls'
description: 'Enable voice calling capabilities for your agents and customers.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-voice-line'
enterprise: true
# ------- Product Features ------- #
help_center:
@@ -48,6 +48,12 @@ class TwilioVoiceClient extends EventTarget {
return !!this.activeConnection;
}
setMuted(shouldMute) {
if (!this.activeConnection) return false;
this.activeConnection.mute(shouldMute);
return shouldMute;
}
endClientCall() {
if (this.activeConnection) {
this.activeConnection.disconnect();
@@ -9,6 +9,10 @@ class OnboardingAPI extends ApiClient {
update(data) {
return axios.patch(this.url, data);
}
getHelpCenterGeneration() {
return axios.get(`${this.url}/help_center_generation`);
}
}
export default new OnboardingAPI();
@@ -46,9 +46,8 @@ const formattedLastActivityAt = computed(() => {
:src="avatarSource"
class="shrink-0"
:name="name"
:size="48"
:size="42"
hide-offline-status
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
@@ -141,7 +141,6 @@ const handleUpdateCompany = async () => {
:src="avatarSource"
:size="72"
:allow-upload="!isAvatarBusy"
rounded-full
hide-offline-status
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
@@ -124,10 +124,9 @@ const handleAvatarHover = isHovered => {
<Avatar
:name="name"
:src="thumbnail"
:size="48"
:size="42"
:status="availabilityStatus"
hide-offline-status
rounded-full
>
<template v-if="selectable" #overlay="{ size }">
<label
@@ -53,6 +53,9 @@ const localeMenuLabels = computed(() => ({
'publish-locale': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
),
'customize-content': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
),
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
}));
@@ -128,7 +131,7 @@ const handleAction = ({ action, value }) => {
<DropdownMenu
v-if="showDropdownMenu"
:menu-items="localeMenuItems"
class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
class="ltr:right-0 rtl:left-0 mt-1 top-full z-60 min-w-[150px]"
@action="handleAction"
/>
</div>
@@ -0,0 +1,98 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
portal: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const activeLocale = ref('');
const name = ref('');
const pageTitle = ref('');
const headerText = ref('');
const localeTranslations = computed(
() => props.portal?.config?.locale_translations || {}
);
const openForLocale = localeCode => {
const existing = localeTranslations.value[localeCode] || {};
activeLocale.value = localeCode;
name.value = existing.name || '';
pageTitle.value = existing.page_title || '';
headerText.value = existing.header_text || '';
dialogRef.value?.open();
};
const onConfirm = async () => {
const translations = { ...localeTranslations.value };
const fields = {};
if (name.value.trim()) fields.name = name.value.trim();
if (pageTitle.value.trim()) fields.page_title = pageTitle.value.trim();
if (headerText.value.trim()) fields.header_text = headerText.value.trim();
if (Object.keys(fields).length) {
translations[activeLocale.value] = fields;
} else {
delete translations[activeLocale.value];
}
try {
await store.dispatch('portals/update', {
portalSlug: props.portal?.slug,
config: { locale_translations: translations },
});
dialogRef.value?.close();
useAlert(t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.ERROR_MESSAGE')
);
}
};
defineExpose({ openForLocale });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.TITLE')"
:description="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.DESCRIPTION')"
@confirm="onConfirm"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.NAME.LABEL') }}
</label>
<Input v-model="name" :placeholder="portal.name" />
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.PAGE_TITLE.LABEL') }}
</label>
<Input v-model="pageTitle" :placeholder="portal.page_title" />
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.HEADER_TEXT.LABEL') }}
</label>
<Input v-model="headerText" :placeholder="portal.header_text" />
</div>
</div>
</Dialog>
</template>
@@ -1,5 +1,7 @@
<script setup>
import { ref } from 'vue';
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -23,6 +25,8 @@ const { t } = useI18n();
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const contentDialogRef = ref(null);
const isLocaleDefault = code => {
return props.portal?.meta?.default_locale === code;
};
@@ -148,6 +152,8 @@ const handleAction = ({ action }, localeCode) => {
moveLocaleToDraft({ localeCode: localeCode });
} else if (action === 'publish-locale') {
publishLocale({ localeCode: localeCode });
} else if (action === 'customize-content') {
contentDialogRef.value.openForLocale(localeCode);
} else if (action === 'delete') {
deletePortalLocale({ localeCode: localeCode });
}
@@ -167,5 +173,6 @@ const handleAction = ({ action }, localeCode) => {
:category-count="locale.categoriesCount || 0"
@action="handleAction($event, locale.code)"
/>
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
</ul>
</template>
@@ -182,7 +182,10 @@ const MIN_SEARCH_LENGTH = 2;
export const createContactSearcher = () => {
let controller = null;
return async (query, { skipMinLength = false } = {}) => {
return async (
query,
{ skipMinLength = false, reachableOnly = true } = {}
) => {
const trimmed = typeof query === 'string' ? query.trim() : '';
controller?.abort();
@@ -199,6 +202,8 @@ export const createContactSearcher = () => {
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
if (!reachableOnly) return camelCasedPayload || [];
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email
@@ -197,9 +197,9 @@ const channelIcon = computed(() => {
? $t('CONVERSATION.VOICE_WIDGET.END_CALL')
: $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')
"
icon="i-ph-phone-x-bold"
icon="i-ph-phone-bold"
ruby
class="!rounded-full rotate-[134deg]"
class="!rounded-full rotate-[135deg]"
@click="isOngoing ? $emit('end') : $emit('reject')"
/>
</div>
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession';
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
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';
@@ -11,7 +12,7 @@ import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilit
import CallCard from 'dashboard/components-next/call/CallCard.vue';
import countriesList from 'shared/constants/countries.js';
const RINGTONE_URL = '/audio/dashboard/bell.mp3';
const RINGTONE_URL = '/audio/dashboard/ringtone.mp3';
const route = useRoute();
const router = useRouter();
@@ -29,8 +30,8 @@ const {
formattedCallDuration,
} = useCallSession();
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
// don't expose a mic track on the browser side.
// Mute routes by provider: WhatsApp toggles the local mic track, Twilio uses
// the Voice SDK connection's native mute. Both surface the same button.
const isMuted = ref(false);
const isWhatsappActive = computed(
() => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
@@ -63,7 +64,11 @@ const stackedCardState = call =>
const toggleMute = () => {
isMuted.value = !isMuted.value;
setWhatsappCallMuted(isMuted.value);
if (isWhatsappActive.value) {
setWhatsappCallMuted(isMuted.value);
} else {
TwilioVoiceClient.setMuted(isMuted.value);
}
};
watch(hasActiveCall, active => {
@@ -256,7 +261,7 @@ onBeforeUnmount(stopRingtone);
:call-info="getCallInfo(activeCall || primaryIncomingCall)"
:duration="hasActiveCall ? formattedCallDuration : ''"
:is-muted="isMuted"
:show-mute="hasActiveCall && isWhatsappActive"
:show-mute="hasActiveCall"
@accept="handleJoinCall(primaryIncomingCall)"
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
@dismiss="dismissCall(primaryIncomingCall?.callSid)"
@@ -1,6 +1,7 @@
<script setup>
import { computed, h, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { debounce } from '@chatwoot/utils';
import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import FilterSelect from './inputs/FilterSelect.vue';
@@ -109,6 +110,34 @@ const inputFieldType = computed(() => {
return 'text';
});
const asyncOptions = ref([]);
const isSearching = ref(false);
const lastSearchQuery = ref('');
const performAsyncSearch = async query => {
let results;
try {
results = await currentFilter.value.searchOptions(query);
} catch {
results = [];
}
// skip stale responses — a newer search in this row owns the UI
if (query !== lastSearchQuery.value) return;
// null means another row's search aborted ours, reset instead of staying stuck on the searching state
if (results !== null) asyncOptions.value = results;
isSearching.value = false;
};
const debouncedAsyncSearch = debounce(performAsyncSearch, 300);
const onAsyncSearch = query => {
const hasQuery = !!query.trim();
lastSearchQuery.value = query;
if (!hasQuery) asyncOptions.value = [];
isSearching.value = hasQuery;
debouncedAsyncSearch(query);
};
const resetModelOnAttributeKeyChange = newAttributeKey => {
/**
* Resets the filter values and operator when the attribute key changes. This ensures that
@@ -121,11 +150,16 @@ const resetModelOnAttributeKeyChange = newAttributeKey => {
const newInputType = getInputType(newOperator, filter);
if (newInputType === 'multiSelect') {
values.value = [];
} else if (['searchSelect', 'booleanSelect'].includes(newInputType)) {
} else if (
['searchSelect', 'asyncSearchSelect', 'booleanSelect'].includes(
newInputType
)
) {
values.value = {};
} else {
values.value = '';
}
asyncOptions.value = [];
filterOperator.value = newOperator.value;
};
@@ -185,6 +219,16 @@ defineExpose({ validate, resetValidation });
:options="currentFilter.options"
dropdown-max-height="max-h-64"
/>
<SingleSelect
v-else-if="inputType === 'asyncSearchSelect'"
v-model="values"
async-search
:options="asyncOptions"
:is-searching="isSearching"
:search-placeholder="currentFilter.searchPlaceholder"
dropdown-max-height="max-h-64"
@search="onAsyncSearch"
/>
<SingleSelect
v-else-if="inputType === 'booleanSelect'"
v-model="values"
@@ -7,6 +7,7 @@ export const CONVERSATION_ATTRIBUTES = {
ASSIGNEE_ID: 'assignee_id',
INBOX_ID: 'inbox_id',
TEAM_ID: 'team_id',
CONTACT_ID: 'contact_id',
DISPLAY_ID: 'display_id',
CAMPAIGN_ID: 'campaign_id',
LABELS: 'labels',
@@ -1,9 +1,37 @@
import { ref } from 'vue';
import ContactAPI from 'dashboard/api/contacts';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConversationFilterContext } from '../provider';
import {
CONVERSATION_ATTRIBUTES,
getCustomAttributeInputType,
buildAttributesFilterTypes,
replaceUnderscoreWithSpace,
} from './filterHelper';
vi.mock('dashboard/api/contacts', () => ({
default: {
search: vi.fn(),
},
}));
vi.mock('dashboard/composables/store.js', () => ({
useMapGetter: vi.fn(),
}));
vi.mock('next/icon/provider', () => ({
useChannelIcon: () => ref('i-test-channel'),
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params = {}) => {
if (key === 'FILTER.CONTACT_FALLBACK') return `Contact #${params.id}`;
return key;
},
}),
}));
describe('filterHelper', () => {
describe('getCustomAttributeInputType', () => {
it('returns date for date type', () => {
@@ -135,3 +163,64 @@ describe('filterHelper', () => {
});
});
});
const storeValues = {
'attributes/getConversationAttributes': ref([]),
'labels/getLabels': ref([]),
'agents/getAgents': ref([]),
'inboxes/getInboxes': ref([]),
'teams/getTeams': ref([]),
'campaigns/getAllCampaigns': ref([]),
};
describe('useConversationFilterContext', () => {
beforeEach(() => {
vi.clearAllMocks();
useMapGetter.mockImplementation(key => storeValues[key] || ref([]));
});
it('exposes contact as an async searchable conversation filter', () => {
const { filterTypes } = useConversationFilterContext();
const contactFilter = filterTypes.value.find(
filter => filter.attributeKey === CONVERSATION_ATTRIBUTES.CONTACT_ID
);
expect(contactFilter).toMatchObject({
attributeKey: 'contact_id',
label: 'FILTER.ATTRIBUTES.CONTACT',
inputType: 'asyncSearchSelect',
dataType: 'number',
attributeModel: 'standard',
});
expect(
contactFilter.filterOperators.map(operator => operator.value)
).toEqual(['equal_to', 'not_equal_to']);
});
it('uses the existing contact search API for contact filter options', async () => {
ContactAPI.search.mockResolvedValue({
data: {
payload: [
{ id: 1, name: 'Jane Doe' },
{ id: 2, email: 'alex@example.com' },
{ id: 3 },
],
},
});
const { filterTypes } = useConversationFilterContext();
const contactFilter = filterTypes.value.find(
filter => filter.attributeKey === CONVERSATION_ATTRIBUTES.CONTACT_ID
);
const options = await contactFilter.searchOptions('jane');
expect(ContactAPI.search).toHaveBeenCalledWith('jane', 1, 'name', '', {
signal: expect.any(AbortSignal),
});
expect(options).toEqual([
{ id: 1, name: 'Jane Doe' },
{ id: 2, name: 'alex@example.com' },
{ id: 3, name: 'Contact #3' },
]);
});
});
@@ -11,6 +11,8 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const {
options,
asyncSearch,
isSearching,
disableSearch,
disableDeselect,
placeholderIcon,
@@ -23,6 +25,14 @@ const {
type: Array,
required: true,
},
asyncSearch: {
type: Boolean,
default: false,
},
isSearching: {
type: Boolean,
default: false,
},
disableSearch: {
type: Boolean,
default: false,
@@ -53,6 +63,12 @@ const {
},
});
const emit = defineEmits(['search']);
// the input is re-inserted on every dropdown open (v-if),
// where the native autofocus attribute is ignored so focus it via a directive instead
const vFocus = { mounted: el => el.focus() };
const { t } = useI18n();
const selected = defineModel({
type: Object,
@@ -60,7 +76,9 @@ const selected = defineModel({
});
const searchTerm = ref('');
const searchResults = computed(() => {
if (asyncSearch) return options;
if (!options) return [];
return picoSearch(options, searchTerm.value, ['name']);
});
@@ -77,7 +95,11 @@ const selectedItem = computed(() => {
if (!optionToSearch) return null;
// extract the selected item from the options array
// this ensures that options like icon is also included
return options.find(option => option.id === optionToSearch.id);
return (
options.find(option => option.id === optionToSearch.id) ||
// async options may not include the selected option, fall back to it
(asyncSearch && optionToSearch.id !== undefined ? optionToSearch : null)
);
});
const toggleSelected = option => {
@@ -131,13 +153,19 @@ const toggleSelected = option => {
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
<input
v-model="searchTerm"
autofocus
v-focus
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
@input="emit('search', $event.target.value)"
/>
</div>
<DropdownSection :height="dropdownMaxHeight">
<template v-if="searchResults.length">
<template v-if="isSearching">
<DropdownItem disabled>
{{ t('DROPDOWN_MENU.SEARCHING') }}
</DropdownItem>
</template>
<template v-else-if="searchResults.length">
<DropdownItem
v-for="option in searchResults"
:key="option.id"
@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n';
import { useOperators } from './operators';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useChannelIcon } from 'next/icon/provider';
import { createContactSearcher } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import {
buildAttributesFilterTypes,
CONVERSATION_ATTRIBUTES,
@@ -30,7 +31,7 @@ import languages from 'dashboard/components/widgets/conversation/advancedFilterI
* @property {string} value - This is a proxy for the attribute key used in FilterSelect
* @property {string} attributeName - The attribute name used to display on the UI
* @property {string} label - This is a proxy for the attribute name used in FilterSelect
* @property {'multiSelect'|'searchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
* @property {'multiSelect'|'searchSelect'|'asyncSearchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
* @property {FilterOption[]} [options] - The options available for the attribute if it is a multiSelect or singleSelect type
* @property {'text'|'number'} dataType
* @property {FilterOperator[]} filterOperators - The operators available for the attribute
@@ -68,6 +69,30 @@ export function useConversationFilterContext() {
getOperatorTypes,
} = useOperators();
const searchContacts = createContactSearcher();
const contactOptionName = contact =>
contact.name ||
contact.email ||
contact.phoneNumber ||
contact.identifier ||
t('FILTER.CONTACT_FALLBACK', { id: contact.id });
const searchContactOptions = async query => {
const contacts = await searchContacts(query, {
skipMinLength: true,
reachableOnly: false,
});
// null means the request was aborted (a newer search is in-flight)
if (contacts === null) return null;
return contacts.map(contact => ({
id: contact.id,
name: contactOptionName(contact),
}));
};
/**
* @type {import('vue').ComputedRef<FilterType[]>}
*/
@@ -158,6 +183,18 @@ export function useConversationFilterContext() {
filterOperators: presenceOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.CONTACT_ID,
value: CONVERSATION_ATTRIBUTES.CONTACT_ID,
attributeName: t('FILTER.ATTRIBUTES.CONTACT'),
label: t('FILTER.ATTRIBUTES.CONTACT'),
inputType: 'asyncSearchSelect',
searchOptions: searchContactOptions,
searchPlaceholder: t('FILTER.CONTACT_SEARCH_PLACEHOLDER'),
dataType: 'number',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
value: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
@@ -145,7 +145,6 @@ const allowedMenuItems = computed(() => {
:src="currentUser.avatar_url"
:status="currentUserAvailability"
class="flex-shrink-0"
rounded-full
/>
<div v-if="!isCollapsed" class="min-w-0">
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
@@ -151,6 +151,9 @@ const activeFolder = computed(() => {
return undefined;
});
const getContact = useMapGetter('contacts/getContact');
const folderContactId = useMapGetter('customViews/getActiveFolderContactId');
const activeFolderName = computed(() => {
return activeFolder.value?.name;
});
@@ -456,6 +459,7 @@ function setParamsForEditFolderModal() {
inboxes: inboxesList.value,
labels: labels.value,
campaigns: campaigns.value,
contacts: [getContact.value(folderContactId.value)],
languages: languages,
countries: countries,
priority: [
@@ -32,7 +32,6 @@ import {
CONVERSATION_EVENTS,
CAPTAIN_EVENTS,
} from 'dashboard/helper/AnalyticsHelper/events';
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
import {
messageSchema,
@@ -43,6 +42,7 @@ import {
MessageMarkdownSerializer,
EditorState,
Selection,
imageResizeView,
} from '@chatwoot/prosemirror-schema';
import {
suggestionsPlugin,
@@ -57,7 +57,6 @@ import {
insertAtCursor,
removeSignature as removeSignatureHelper,
scrollCursorIntoView,
setURLWithQueryAndSize,
getFormattingForEditor,
getSelectionCoords,
calculateMenuPosition,
@@ -72,6 +71,7 @@ import {
import { createTypingIndicator } from '@chatwoot/utils';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
const props = defineProps({
modelValue: { type: String, default: '' },
@@ -93,7 +93,6 @@ const props = defineProps({
channelType: { type: String, default: '' },
conversationId: { type: Number, default: null },
medium: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
focusOnMount: { type: Boolean, default: true },
});
@@ -119,6 +118,14 @@ const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const DEFAULT_FORMATTING = 'Context::Default';
const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote';
const MESSAGE_SIGNATURE_FORMATTING = 'Context::MessageSignature';
const INLINE_IMAGE_PASTE_TYPES = [
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/webp',
];
const effectiveChannelType = computed(() =>
getEffectiveChannelType(props.channelType, props.medium)
@@ -192,12 +199,8 @@ const cannedSearchTerm = ref('');
const variableSearchTerm = ref('');
const emojiSearchTerm = ref('');
const range = ref(null);
const isImageNodeSelected = ref(false);
const toolbarPosition = ref({ top: 0, left: 0 });
const selectedImageNode = ref(null);
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
const showSelectionMenu = ref(false);
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
// element ref
const editorRoot = useTemplateRef('editorRoot');
@@ -475,16 +478,6 @@ function removeSignature() {
reloadState(content);
}
function setToolbarPosition() {
const editorRect = editorRoot.value.getBoundingClientRect();
const rect = selectedImageNode.value.getBoundingClientRect();
toolbarPosition.value = {
top: `${rect.top - editorRect.top - 30}px`,
left: `${rect.left - editorRect.left - 4}px`,
};
}
function setMenubarPosition({ selection } = {}) {
const wrapper = editorRoot.value;
if (!selection || !wrapper) return;
@@ -520,30 +513,6 @@ function checkSelection(editorState) {
if (hasSelection) setMenubarPosition(editorState);
}
function setURLWithQueryAndImageSize(size) {
if (!props.showImageResizeToolbar) {
return;
}
setURLWithQueryAndSize(selectedImageNode.value, size, editorView);
isImageNodeSelected.value = false;
}
function isEditorMouseFocusedOnAnImage() {
if (!props.showImageResizeToolbar) {
return;
}
selectedImageNode.value = document.querySelector(
'img.ProseMirror-selectednode'
);
if (selectedImageNode.value) {
isImageNodeSelected.value = !!selectedImageNode.value;
// Get the position of the selected node
setToolbarPosition();
} else {
isImageNodeSelected.value = false;
}
}
function emitOnChange() {
emit('input', contentFromEditor());
emit('update:modelValue', contentFromEditor());
@@ -563,21 +532,6 @@ function toggleSignatureInEditor(signatureEnabled) {
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
// in that case, the selected node will be null and we need to hide the toolbar
// otherwise, the toolbar will be visible even when the image is deleted and cause some errors
if (selectedImageNode.value) {
const hasImgSelectedNode = document.querySelector(
'img.ProseMirror-selectednode'
);
if (!hasImgSelectedNode) {
isImageNodeSelected.value = false;
}
}
}
function isEnterToSendEnabled() {
return isEditorHotKeyEnabled('enter');
}
@@ -586,17 +540,6 @@ function isCmdPlusEnterToSendEnabled() {
return isEditorHotKeyEnabled('cmd_enter');
}
useKeyboardEvents({
'Alt+KeyP': {
action: focusEditorInputField,
allowOnFocusedInput: false,
},
'Alt+KeyL': {
action: focusEditorInputField,
allowOnFocusedInput: false,
},
});
function onImageInsertInEditor(fileUrl) {
const { tr } = editorView.state;
@@ -617,7 +560,11 @@ async function uploadImageToStorage(file) {
onImageInsertInEditor(fileUrl);
}
useAlert(
t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS')
props.channelType === MESSAGE_SIGNATURE_FORMATTING
? t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS'
)
: t('CONVERSATION.REPLYBOX.IMAGE_UPLOAD_SUCCESS')
);
} catch (error) {
useAlert(
@@ -626,8 +573,8 @@ async function uploadImageToStorage(file) {
}
}
function onFileChange() {
const file = imageUpload.value.files[0];
function uploadImageIfWithinSizeLimit(file) {
if (!file) return;
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
uploadImageToStorage(file);
} else {
@@ -640,10 +587,61 @@ function onFileChange() {
)
);
}
imageUpload.value = '';
}
function onFileChange() {
const input = imageUpload.value;
uploadImageIfWithinSizeLimit(input.files[0]);
input.value = '';
}
const allowsInlineImagePaste = computed(
() =>
!props.isPrivate &&
(props.channelType === INBOX_TYPES.EMAIL ||
props.channelType === INBOX_TYPES.WEB)
);
// Shift+Cmd/Ctrl+V on email/website: upload a clipboard image inline. This
// gesture's native paste event carries no image, so clipboard.read() is the
// only way to get the bytes. No preventDefault: text still pastes natively.
async function pasteInlineImageFromClipboard() {
if (!editorView?.hasFocus()) return;
if (!allowsInlineImagePaste.value || !navigator.clipboard?.read) return;
try {
const items = await navigator.clipboard.read();
const imageItem = items.find(item =>
item.types.some(type => INLINE_IMAGE_PASTE_TYPES.includes(type))
);
if (!imageItem) return;
const imageType = imageItem.types.find(type =>
INLINE_IMAGE_PASTE_TYPES.includes(type)
);
const blob = await imageItem.getType(imageType);
uploadImageIfWithinSizeLimit(
new File([blob], 'pasted-image', { type: imageType })
);
} catch (error) {
// clipboard-read denied/unfocused (NotAllowedError): image can't be read.
// Text paste is unaffected — ProseMirror handles it from the native event.
}
}
useKeyboardEvents({
'Alt+KeyP': {
action: focusEditorInputField,
allowOnFocusedInput: false,
},
'Alt+KeyL': {
action: focusEditorInputField,
allowOnFocusedInput: false,
},
'$mod+Shift+KeyV': {
action: pasteInlineImageFromClipboard,
allowOnFocusedInput: true,
},
});
function handleLineBreakWhenEnterToSendEnabled(event) {
if (
hasPressedEnterAndNotCmdOrShift(event) &&
@@ -736,6 +734,9 @@ function createEditorView() {
editorView = new EditorView(editor.value, {
state: state,
editable: () => !props.disabled,
nodeViews: {
image: imageResizeView,
},
dispatchTransaction: tx => {
state = state.apply(tx);
editorView.updateState(state);
@@ -748,12 +749,10 @@ function createEditorView() {
keyup: () => {
if (!props.disabled) {
typingIndicator.start();
updateImgToolbarOnDelete();
}
},
keydown: (view, event) => !props.disabled && onKeydown(event),
focus: () => !props.disabled && emit('focus'),
click: () => !props.disabled && isEditorMouseFocusedOnAnImage(),
blur: () => {
if (props.disabled) return;
typingIndicator.stop();
@@ -918,23 +917,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@change="onFileChange"
/>
<div ref="editor" />
<div
v-show="isImageNodeSelected && showImageResizeToolbar"
class="absolute shadow-md rounded-[6px] flex gap-1 py-1 px-1 bg-n-solid-3 outline outline-1 outline-n-weak text-n-slate-12"
:style="{
top: toolbarPosition.top,
left: toolbarPosition.left,
}"
>
<button
v-for="size in sizes"
:key="size.name"
class="text-xs font-medium rounded-[4px] outline outline-1 outline-n-strong px-1.5 py-0.5 hover:bg-n-slate-5"
@click="setURLWithQueryAndImageSize(size)"
>
{{ size.name }}
</button>
</div>
<slot name="footer" />
</div>
</template>
@@ -997,10 +979,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply text-n-slate-11;
}
}
ol li {
@apply list-item list-decimal;
}
}
}
@@ -124,7 +124,6 @@ const copyConversationId = async () => {
:size="32"
:status="currentContact.availability_status"
hide-offline-status
rounded-full
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
@@ -375,10 +375,10 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
if (this.isAWhatsAppChannel) {
if (this.isAWhatsAppCloudChannel) {
return AUDIO_FORMATS.OGG;
}
if (this.isATelegramChannel) {
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
return AUDIO_FORMATS.MP3;
}
if (this.isAPIInbox) {
@@ -46,6 +46,14 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'contact_id',
attributeI18nKey: 'CONTACT',
inputType: 'search_select',
dataType: 'number',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'standard',
},
{
attributeKey: 'display_id',
attributeI18nKey: 'CONVERSATION_IDENTIFIER',
@@ -133,6 +141,10 @@ export const filterAttributeGroups = [
key: 'team_id',
i18nKey: 'TEAM_NAME',
},
{
key: 'contact_id',
i18nKey: 'CONTACT',
},
{
key: 'display_id',
i18nKey: 'CONVERSATION_IDENTIFIER',
@@ -0,0 +1,148 @@
import { useFacebookPageConnect } from '../useFacebookPageConnect';
import { useMapGetter } from 'dashboard/composables/store';
import ChannelApi from 'dashboard/api/channels';
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
vi.mock('dashboard/composables/store', () => ({ useMapGetter: vi.fn() }));
vi.mock('dashboard/api/channels', () => ({
default: { fetchFacebookPages: vi.fn() },
}));
vi.mock(
'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils',
() => ({ setupFacebookSdk: vi.fn() })
);
const flushPromises = () =>
new Promise(resolve => {
setTimeout(resolve, 0);
});
const createDeferred = () => {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const ACCOUNT_ID = 42;
const PAGES = [
{ id: 'p1', name: 'Page One', access_token: 'page-token-1' },
{ id: 'p2', name: 'Page Two', access_token: 'page-token-2', exists: true },
];
const pagesResponse = {
data: { data: { page_details: PAGES, user_access_token: 'long-token' } },
};
// FB.login invokes its callback with the given response.
const stubLogin = response => {
window.FB = { login: vi.fn(callback => callback(response)) };
};
const connected = {
status: 'connected',
authResponse: { accessToken: 'user-token' },
};
describe('useFacebookPageConnect', () => {
beforeEach(() => {
vi.clearAllMocks();
window.chatwootConfig = { fbAppId: 'fb-app', fbApiVersion: 'v22.0' };
useMapGetter.mockReturnValue({ value: ACCOUNT_ID });
setupFacebookSdk.mockResolvedValue();
ChannelApi.fetchFacebookPages.mockResolvedValue(pagesResponse);
stubLogin(connected);
});
it('resolves the user token and pages on a connected login', async () => {
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).resolves.toEqual({
userAccessToken: 'long-token',
pages: PAGES,
});
expect(setupFacebookSdk).toHaveBeenCalledWith('fb-app', 'v22.0');
expect(ChannelApi.fetchFacebookPages).toHaveBeenCalledWith(
'user-token',
ACCOUNT_ID
);
expect(window.FB.login).toHaveBeenCalledWith(expect.any(Function), {
scope: expect.stringContaining('pages_show_list'),
});
});
it('resolves null when the user is not authorized', async () => {
stubLogin({ status: 'not_authorized' });
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).resolves.toBeNull();
expect(ChannelApi.fetchFacebookPages).not.toHaveBeenCalled();
});
it('resolves null on an unknown login status', async () => {
stubLogin({ status: 'unknown' });
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).resolves.toBeNull();
});
it('rejects when fetching pages fails', async () => {
ChannelApi.fetchFacebookPages.mockRejectedValue(new Error('fetch failed'));
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).rejects.toThrow('fetch failed');
});
it('rejects when the SDK fails to load', async () => {
const error = new Error('script load failed');
error.name = 'ScriptLoaderError';
setupFacebookSdk.mockRejectedValue(error);
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).rejects.toThrow('script load failed');
});
it('ignores a second call while a run is in flight', async () => {
const pending = createDeferred();
ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise);
const { loginAndFetchPages } = useFacebookPageConnect();
const first = loginAndFetchPages();
const second = loginAndFetchPages();
await expect(second).resolves.toBeNull();
pending.resolve(pagesResponse);
await first;
expect(window.FB.login).toHaveBeenCalledTimes(1);
});
it('toggles isAuthenticating across a run', async () => {
const pending = createDeferred();
ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise);
const { isAuthenticating, loginAndFetchPages } = useFacebookPageConnect();
expect(isAuthenticating.value).toBe(false);
const result = loginAndFetchPages();
await flushPromises();
expect(isAuthenticating.value).toBe(true);
pending.resolve(pagesResponse);
await result;
expect(isAuthenticating.value).toBe(false);
});
it('preloads the SDK once and reuses it for login', async () => {
const { preloadSdk, loginAndFetchPages } = useFacebookPageConnect();
preloadSdk();
preloadSdk();
await loginAndFetchPages();
expect(setupFacebookSdk).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,208 @@
import { useWhatsappEmbeddedSignup } from '../useWhatsappEmbeddedSignup';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
vi.mock(
'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils',
() => ({
setupFacebookSdk: vi.fn(),
initWhatsAppEmbeddedSignup: vi.fn(),
createMessageHandler: vi.fn(),
isValidBusinessData: vi.fn(data =>
Boolean(data && data.business_id && data.waba_id)
),
})
);
const flushPromises = () =>
new Promise(resolve => {
setTimeout(resolve, 0);
});
const createDeferred = () => {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const VALID_BUSINESS = {
business_id: 'biz-1',
waba_id: 'waba-1',
phone_number_id: 'phone-1',
};
describe('useWhatsappEmbeddedSignup', () => {
// The mocked createMessageHandler captures the callback the composable
// registers, so tests can simulate Meta's WA_EMBEDDED_SIGNUP postMessages
// directly without the window-event + origin plumbing (that is covered by
// the utils' own tests).
let signupCallback;
let registeredListener;
const emit = data => signupCallback(data);
beforeEach(() => {
vi.clearAllMocks();
window.chatwootConfig = {
whatsappAppId: 'app-id',
whatsappConfigurationId: 'config-id',
whatsappApiVersion: 'v22.0',
};
setupFacebookSdk.mockResolvedValue();
createMessageHandler.mockImplementation(callback => {
signupCallback = callback;
registeredListener = () => {};
return registeredListener;
});
});
it('resolves credentials when the auth code arrives before the business data', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
await flushPromises(); // SDK setup + FB.login resolve the code first
emit({ event: 'FINISH', data: VALID_BUSINESS });
await expect(result).resolves.toEqual({
code: 'auth-code',
business_id: 'biz-1',
waba_id: 'waba-1',
phone_number_id: 'phone-1',
});
expect(setupFacebookSdk).toHaveBeenCalledWith('app-id', 'v22.0');
expect(initWhatsAppEmbeddedSignup).toHaveBeenCalledWith('config-id');
});
it('resolves credentials when the business data arrives before the auth code', async () => {
const code = createDeferred();
initWhatsAppEmbeddedSignup.mockReturnValue(code.promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
// Business data lands first, while FB.login is still pending.
emit({
event: 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING',
data: VALID_BUSINESS,
});
code.resolve('late-code');
await expect(result).resolves.toEqual({
code: 'late-code',
business_id: 'biz-1',
waba_id: 'waba-1',
phone_number_id: 'phone-1',
});
});
it('defaults phone_number_id to an empty string when absent', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
await flushPromises();
emit({
event: 'FINISH',
data: { business_id: 'biz-1', waba_id: 'waba-1' },
});
await expect(result).resolves.toMatchObject({ phone_number_id: '' });
});
it('resolves null when FB.login is cancelled', async () => {
initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('Login cancelled'));
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
await expect(runEmbeddedSignup()).resolves.toBeNull();
});
it('resolves null on a CANCEL event', async () => {
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
emit({ event: 'CANCEL' });
await expect(result).resolves.toBeNull();
});
it('rejects with the Meta error message on an error event', async () => {
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
emit({ event: 'error', error_message: 'WABA not eligible' });
await expect(result).rejects.toThrow('WABA not eligible');
});
it('rejects when the business data is invalid', async () => {
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
emit({ event: 'FINISH', data: { business_id: 'biz-1' } }); // no waba_id
await expect(result).rejects.toThrow('Invalid business data');
});
it('rejects when the SDK or login fails for a non-cancel reason', async () => {
initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('popup blocked'));
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
await expect(runEmbeddedSignup()).rejects.toThrow('popup blocked');
});
it('ignores a second call while a run is in flight', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const first = runEmbeddedSignup();
const second = runEmbeddedSignup();
await expect(second).resolves.toBeNull();
// Let the first run finish so it doesn't leak into the next test.
await flushPromises();
emit({ event: 'FINISH', data: VALID_BUSINESS });
await first;
expect(setupFacebookSdk).toHaveBeenCalledTimes(1);
});
it('toggles isAuthenticating and removes the listener once settled', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const removeSpy = vi.spyOn(window, 'removeEventListener');
const { isAuthenticating, runEmbeddedSignup } = useWhatsappEmbeddedSignup();
expect(isAuthenticating.value).toBe(false);
const result = runEmbeddedSignup();
expect(isAuthenticating.value).toBe(true);
await flushPromises();
emit({ event: 'FINISH', data: VALID_BUSINESS });
await result;
expect(isAuthenticating.value).toBe(false);
expect(removeSpy).toHaveBeenCalledWith('message', registeredListener);
removeSpy.mockRestore();
});
});
@@ -0,0 +1,81 @@
import { ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import ChannelApi from 'dashboard/api/channels';
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
// Page-management + messaging scopes required to list pages and create a
// Channel::FacebookPage inbox (mirrors the standalone settings flow).
const FB_PAGE_SCOPES =
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages';
// Headless half of the Facebook Page connect flow: load the Meta SDK, run
// FB.login for page scopes, and fetch the user's pages. The caller owns the
// page-picker UI and the channel creation, because choosing a page is an
// interactive step (a user can manage several pages).
//
// Split into preloadSdk() + loginAndFetchPages() for popup safety: FB.login
// opens a popup and needs the click's transient activation. Preloading the SDK
// when the picker opens means the click-time `await` resolves within that
// activation window; a cold load resolves on the script's `load` task seconds
// later, after activation has expired, and the popup gets blocked.
export function useFacebookPageConnect() {
const accountId = useMapGetter('getCurrentAccountId');
const isAuthenticating = ref(false);
let sdkSetupPromise = null;
// Idempotent — call this when the picker UI opens. A failed load clears the
// cache so a later attempt can retry instead of being stuck on a rejection.
const preloadSdk = () => {
if (!sdkSetupPromise) {
sdkSetupPromise = setupFacebookSdk(
window.chatwootConfig?.fbAppId,
window.chatwootConfig?.fbApiVersion
).catch(error => {
sdkSetupPromise = null;
throw error;
});
}
return sdkSetupPromise;
};
// FB.login never rejects; resolve the user access token on success and null
// for any other status (closed popup, not_authorized, unknown).
const login = () =>
new Promise(resolve => {
window.FB.login(
response => {
resolve(
response.status === 'connected'
? response.authResponse?.accessToken || null
: null
);
},
{ scope: FB_PAGE_SCOPES }
);
});
// Resolves { userAccessToken, pages } on success, null when the user cancels,
// and rejects on SDK-load or page-fetch failure (the caller maps it to UI).
const loginAndFetchPages = async () => {
if (isAuthenticating.value) return null;
isAuthenticating.value = true;
try {
await preloadSdk();
const token = await login();
if (!token) return null;
const response = await ChannelApi.fetchFacebookPages(
token,
accountId.value
);
const { page_details: pages, user_access_token: userAccessToken } =
response.data.data;
return { userAccessToken, pages };
} finally {
isAuthenticating.value = false;
}
};
return { isAuthenticating, preloadSdk, loginAndFetchPages };
}
@@ -0,0 +1,96 @@
import { ref } from 'vue';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
isValidBusinessData,
} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
// Drives Meta's WhatsApp embedded-signup popup (Facebook JS SDK). FB.login()
// resolves an auth `code` while the WABA identifiers (waba_id, phone_number_id)
// arrive separately over a postMessage event — order isn't guaranteed, so we
// hold both and resolve once both are present.
//
// `runEmbeddedSignup` returns the signup credentials; the caller exchanges them
// for an inbox via `inboxes/createWhatsAppEmbeddedSignup` and owns its own UX
// (alerts, navigation, etc). Resolves `null` when the user cancels the popup;
// rejects on SDK load or signup errors. The window listener is scoped to a
// single run, so this is safe to call from anywhere without lifecycle wiring.
export function useWhatsappEmbeddedSignup() {
const isAuthenticating = ref(false);
const runEmbeddedSignup = () => {
if (isAuthenticating.value) return Promise.resolve(null);
isAuthenticating.value = true;
return new Promise((resolve, reject) => {
let authCode = null;
let businessData = null;
let settled = false;
let messageHandler;
const settle = (fn, value) => {
if (settled) return;
settled = true;
window.removeEventListener('message', messageHandler);
isAuthenticating.value = false;
fn(value);
};
// Both the auth code and the business data arrive asynchronously and in
// no fixed order; only resolve once we're holding both.
const resolveIfReady = () => {
if (!authCode || !businessData) return;
settle(resolve, {
code: authCode,
business_id: businessData.business_id,
waba_id: businessData.waba_id,
phone_number_id: businessData.phone_number_id || '',
});
};
messageHandler = createMessageHandler(data => {
if (
data.event === 'FINISH' ||
data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING'
) {
if (!isValidBusinessData(data.data)) {
settle(reject, new Error('Invalid business data'));
return;
}
businessData = data.data;
resolveIfReady();
} else if (data.event === 'CANCEL') {
settle(resolve, null);
} else if (data.event === 'error') {
settle(reject, new Error(data.error_message || 'Signup error'));
}
});
window.addEventListener('message', messageHandler);
(async () => {
try {
await setupFacebookSdk(
window.chatwootConfig?.whatsappAppId,
window.chatwootConfig?.whatsappApiVersion
);
authCode = await initWhatsAppEmbeddedSignup(
window.chatwootConfig?.whatsappConfigurationId
);
resolveIfReady();
} catch (error) {
// FB.login() rejects with 'Login cancelled' when the user dismisses
// the popup — treat it as a cancel rather than an error.
if (error.message === 'Login cancelled') {
settle(resolve, null);
} else {
settle(reject, error);
}
}
})();
});
};
return { isAuthenticating, runEmbeddedSignup };
}
+2 -20
View File
@@ -14,6 +14,7 @@ export const FORMATTING = {
'link',
'bulletList',
'orderedList',
'imageUpload',
'undo',
'redo',
],
@@ -30,6 +31,7 @@ export const FORMATTING = {
'strike',
'bulletList',
'orderedList',
'imageUpload',
'undo',
'redo',
],
@@ -263,23 +265,3 @@ export const MARKDOWN_PATTERNS = [
],
},
];
// Editor image resize options for Message Editor
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{
name: 'Small',
height: '24px',
},
{
name: 'Medium',
height: '48px',
},
{
name: 'Large',
height: '72px',
},
{
name: 'Original Size',
height: 'auto',
},
];
@@ -37,6 +37,13 @@ export const getValuesName = (values, list, idKey, nameKey) => {
};
};
const getValuesForContact = (values, contacts) => ({
id: values[0],
name:
contacts?.find(contact => contact.id === values[0])?.name ||
`Contact #${values[0]}`,
});
export const getValuesForStatus = values => {
return values.map(value => ({ id: value, name: value }));
};
@@ -84,6 +91,7 @@ export const getValuesForFilter = (filter, params) => {
campaigns,
labels,
priority,
contacts,
} = params;
switch (attribute_key) {
case 'status':
@@ -94,6 +102,8 @@ export const getValuesForFilter = (filter, params) => {
return getValuesName(values, inboxes, 'id', 'name');
case 'team_id':
return getValuesName(values, teams, 'id', 'name');
case 'contact_id':
return getValuesForContact(values, contacts);
case 'campaign_id':
return getValuesName(values, campaigns, 'id', 'title');
case 'labels':
@@ -379,31 +379,6 @@ export const findNodeToInsertImage = (editorState, fileUrl) => {
};
};
/**
* Set URL with query and size.
*
* @param {Object} selectedImageNode - The current selected node.
* @param {Object} size - The size to set.
* @param {Object} editorView - The editor view.
*/
export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
if (selectedImageNode) {
// Create and apply the transaction
const tr = editorView.state.tr.setNodeMarkup(
editorView.state.selection.from,
null,
{
src: selectedImageNode.src,
height: size.height,
}
);
if (tr.docChanged) {
editorView.dispatch(tr);
}
}
}
/**
* Strips unsupported markdown formatting from content based on the editor schema.
* This ensures canned responses with rich formatting can be inserted into channels
@@ -159,6 +159,13 @@ export const LOCALE_MENU_ITEMS = {
value: 'publish',
icon: 'i-lucide-eye',
},
customizeContent: {
label:
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT',
action: 'customize-content',
value: 'customize-content',
icon: 'i-lucide-pencil',
},
delete: {
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
action: 'delete',
@@ -172,20 +179,28 @@ const disableLocaleMenuItems = menuItems =>
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
if (isDefault) {
return disableLocaleMenuItems([
LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.moveToDraft,
LOCALE_MENU_ITEMS.delete,
]);
return [
...disableLocaleMenuItems([
LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.moveToDraft,
]),
LOCALE_MENU_ITEMS.customizeContent,
...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]),
];
}
if (isDraft) {
return [LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.delete];
return [
LOCALE_MENU_ITEMS.publishLocale,
LOCALE_MENU_ITEMS.customizeContent,
LOCALE_MENU_ITEMS.delete,
];
}
return [
LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.moveToDraft,
LOCALE_MENU_ITEMS.customizeContent,
LOCALE_MENU_ITEMS.delete,
];
};
@@ -273,6 +273,41 @@ describe('customViewsHelper', () => {
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual('1');
});
it('returns contact name for contact filters when contact is available', () => {
const filter = {
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [123],
};
const params = {
contacts: [{ id: 123, name: 'John Doe' }],
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 123,
name: 'John Doe',
});
});
it('returns fallback contact display value when contact is not available', () => {
const filter = {
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [123],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 123,
name: 'Contact #123',
});
});
});
describe('#generateCustomAttributesInputType', () => {
@@ -16,7 +16,6 @@ import {
insertAtCursor,
removeSignature,
replaceSignature,
setURLWithQueryAndSize,
stripInlineBase64Images,
stripUnsupportedFormatting,
stripUnsupportedMarkdown,
@@ -653,71 +652,6 @@ describe('findNodeToInsertImage', () => {
});
});
describe('setURLWithQueryAndSize', () => {
let selectedNode;
let editorView;
beforeEach(() => {
selectedNode = {
setAttribute: vi.fn(),
};
const tr = {
setNodeMarkup: vi.fn().mockReturnValue({
docChanged: true,
}),
};
const state = {
selection: { from: 0 },
tr,
};
editorView = {
state,
dispatch: vi.fn(),
};
});
it('updates the URL with the given size and updates the editor view', () => {
const size = { height: '20px' };
setURLWithQueryAndSize(selectedNode, size, editorView);
// Check if the editor view is updated
expect(editorView.dispatch).toHaveBeenCalledTimes(1);
});
it('updates the URL with the given size and updates the editor view with original size', () => {
const size = { height: 'auto' };
setURLWithQueryAndSize(selectedNode, size, editorView);
// Check if the editor view is updated
expect(editorView.dispatch).toHaveBeenCalledTimes(1);
});
it('does not update the editor view if the document has not changed', () => {
editorView.state.tr.setNodeMarkup = vi.fn().mockReturnValue({
docChanged: false,
});
const size = { height: '20px' };
setURLWithQueryAndSize(selectedNode, size, editorView);
// Check if the editor view dispatch was not called
expect(editorView.dispatch).not.toHaveBeenCalled();
});
it('does not perform any operations if selectedNode is not provided', () => {
setURLWithQueryAndSize(null, { height: '20px' }, editorView);
// Ensure the dispatch method wasn't called
expect(editorView.dispatch).not.toHaveBeenCalled();
});
});
describe('getContentNode', () => {
let mockEditorView;
@@ -64,4 +64,25 @@ describe('#filterQueryGenerator', () => {
filterQueryGenerator(testData).payload.every(i => Array.isArray(i.values))
).toBe(true);
});
it('serializes a selected contact object to contact id', () => {
const result = filterQueryGenerator([
{
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: { id: 123, name: 'Jane Doe' },
query_operator: 'and',
},
]);
expect(result).toMatchObject({
payload: [
{
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [123],
},
],
});
});
});
@@ -74,37 +74,40 @@ describe('PortalHelper', () => {
});
describe('buildLocaleMenuItems', () => {
it('returns disabled actions for the default locale', () => {
it('disables other actions but keeps customize enabled for the default locale', () => {
const items = buildLocaleMenuItems({ isDefault: true, isDraft: false });
const customize = items.find(item => item.action === 'customize-content');
expect(customize).toBeTruthy();
expect(customize.disabled).toBeFalsy();
expect(
buildLocaleMenuItems({
isDefault: true,
isDraft: false,
})
).toEqual(
expect.arrayContaining([
expect.objectContaining({ action: 'change-default', disabled: true }),
expect.objectContaining({ action: 'move-to-draft', disabled: true }),
expect.objectContaining({ action: 'delete', disabled: true }),
])
);
items
.filter(item => item.action !== 'customize-content')
.every(item => item.disabled)
).toBe(true);
});
it('returns publish and delete actions for draft locales', () => {
it('returns publish, customize, and delete actions for draft locales', () => {
expect(
buildLocaleMenuItems({
isDefault: false,
isDraft: true,
}).map(({ action }) => action)
).toEqual(['publish-locale', 'delete']);
).toEqual(['publish-locale', 'customize-content', 'delete']);
});
it('returns default, draft, and delete actions for live locales', () => {
it('returns default, draft, customize, and delete actions for live locales', () => {
expect(
buildLocaleMenuItems({
isDefault: false,
isDraft: false,
}).map(({ action }) => action)
).toEqual(['change-default', 'move-to-draft', 'delete']);
).toEqual([
'change-default',
'move-to-draft',
'customize-content',
'delete',
]);
});
});
});
@@ -19,8 +19,6 @@
"OR": "OR"
},
"INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "እውቂያዎችን ይፈልጉ",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "Equal to",
"not_equal_to": "Not equal to",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name",
"CONTACT": "እውቂያ",
"CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name",
"LABELS": "Labels",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Drag and drop here to attach",
"IMAGE_UPLOAD_SUCCESS": "ምስል በተሳካ ሁኔታ ተሰብስቧል",
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",
"COPILOT_THINKING": "ኮፒሎት እየሰማራ ነው",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "እንደ ነባሪ አድርግ",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "ሰርዝ"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "Name"
},
"PAGE_TITLE": {
"LABEL": "የገፅ ርዕስ"
},
"HEADER_TEXT": {
"LABEL": "የራስጌ ጽሑፍ"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "አዲስ ቋንቋ አክል",
"DESCRIPTION": "ይህ ሰነድ በሚጻፍበት ቋንቋ ይምረጡ። ይህ ወደ የትርጉም ዝርዝርዎ ይጨምራል እና በኋላ ተጨማሪ ማክሰኞ ይችላሉ።.",
@@ -19,8 +19,6 @@
"OR": "أو"
},
"INPUT_PLACEHOLDER": "أدخل القيمة",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "يساوي",
"not_equal_to": "لا يساوي",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "اسم المكلَّف",
"INBOX_NAME": "اسم صندوق الوارد",
"TEAM_NAME": "اسم الفريق",
"CONTACT": "جهات الاتصال",
"CONVERSATION_IDENTIFIER": "معرف المحادثة",
"CAMPAIGN_NAME": "اسم الحملة",
"LABELS": "الوسوم",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "تعذر فتح الصوت",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "اسحب و أسقط هنا للإرفاق",
"IMAGE_UPLOAD_SUCCESS": "تم رفع الصورة بنجاح",
"START_AUDIO_RECORDING": "بدء التسجيل الصوتي",
"STOP_AUDIO_RECORDING": "إيقاف التسجيل الصوتي",
"COPILOT_THINKING": "Copilot يفكر",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "حذف"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "الاسم"
},
"PAGE_TITLE": {
"LABEL": "Page title"
},
"HEADER_TEXT": {
"LABEL": "Header text"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "إضافة لغة جديدة",
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
@@ -19,8 +19,6 @@
"OR": "OR"
},
"INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Əlaqələrdə axtarış",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "Equal to",
"not_equal_to": "Not equal to",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name",
"CONTACT": "Əlaqə",
"CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name",
"LABELS": "Labels",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Səsi açmaq mümkün olmadı",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Qoşmaq üçün buraya sürükləyin və buraxın",
"IMAGE_UPLOAD_SUCCESS": "Şəkil uğurla yükləndi",
"START_AUDIO_RECORDING": "Səs yazısını başla",
"STOP_AUDIO_RECORDING": "Səs yazısını dayandırın",
"COPILOT_THINKING": "Copilot düşünür",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Delete"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "Name"
},
"PAGE_TITLE": {
"LABEL": "Page title"
},
"HEADER_TEXT": {
"LABEL": "Header text"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "Add a new locale",
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
@@ -19,8 +19,6 @@
"OR": "ИЛИ"
},
"INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "Равно на",
"not_equal_to": "Различно от",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name",
"CONTACT": "Contact",
"CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name",
"LABELS": "Етикети",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Drag and drop here to attach",
"IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully",
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",
"COPILOT_THINKING": "Copilot мисли",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Изтрий"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "Име"
},
"PAGE_TITLE": {
"LABEL": "Page title"
},
"HEADER_TEXT": {
"LABEL": "Header text"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "Add a new locale",
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
@@ -19,8 +19,6 @@
"OR": "OR"
},
"INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "যোগাযোগ অনুসন্ধান করুন",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "Equal to",
"not_equal_to": "Not equal to",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name",
"CONTACT": "যোগাযোগ",
"CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name",
"LABELS": "Labels",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "অডিও খোলা যায়নি",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "সংযুক্ত করতে এখানে ড্র্যাগ ও ড্রপ করুন",
"IMAGE_UPLOAD_SUCCESS": "ছবি সফলভাবে আপলোড হয়েছে",
"START_AUDIO_RECORDING": "অডিও রেকর্ডিং শুরু করুন",
"STOP_AUDIO_RECORDING": "অডিও রেকর্ডিং বন্ধ করুন",
"COPILOT_THINKING": "Copilot ভাবছে",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "ডিফল্ট করুন",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "মুছে ফেলুন"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "নাম"
},
"PAGE_TITLE": {
"LABEL": "পৃষ্ঠার শিরোনাম"
},
"HEADER_TEXT": {
"LABEL": "হেডার টেক্সট"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "নতুন লোকেল যোগ করুন",
"DESCRIPTION": "এই নিবন্ধটি যে ভাষায় লেখা হবে তা নির্বাচন করুন. এটি আপনার অনুবাদ তালিকায় যোগ হবে, এবং পরে আপনি আরও ভাষা যোগ করতে পারবেন.",
@@ -19,8 +19,6 @@
"OR": "O"
},
"INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "Igual a",
"not_equal_to": "No és igual a",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Nom assignat",
"INBOX_NAME": "Nom de la safata d'entrada",
"TEAM_NAME": "Nom de l'equip",
"CONTACT": "Contacte",
"CONVERSATION_IDENTIFIER": "Identificador de conversa",
"CAMPAIGN_NAME": "Nom de la campanya",
"LABELS": "Etiquetes",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "No s'ha pogut obrir l'àudio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Arrossega i deixa anar aquí per adjuntar-lo",
"IMAGE_UPLOAD_SUCCESS": "La imatge s'ha carregat correctament",
"START_AUDIO_RECORDING": "Inicia la gravació d'àudio",
"STOP_AUDIO_RECORDING": "Atura la gravació d'àudio",
"COPILOT_THINKING": "Copilot està pensant",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Esborrar"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "Nom"
},
"PAGE_TITLE": {
"LABEL": "Page title"
},
"HEADER_TEXT": {
"LABEL": "Header text"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "Afegeix una nova localització",
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
@@ -19,8 +19,6 @@
"OR": "OR"
},
"INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "Equal to",
"not_equal_to": "Not equal to",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name",
"CONTACT": "Contact",
"CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name",
"LABELS": "Štítky",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Zvuk se nepodařilo otevřít",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Přetažením sem připojíte",
"IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully",
"START_AUDIO_RECORDING": "Spustit nahrávání zvuku",
"STOP_AUDIO_RECORDING": "Zastavit nahrávání zvuku",
"COPILOT_THINKING": "Copilot přemýšlí",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Vymazat"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "Název"
},
"PAGE_TITLE": {
"LABEL": "Page title"
},
"HEADER_TEXT": {
"LABEL": "Header text"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "Add a new locale",
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
@@ -19,8 +19,6 @@
"OR": "ELLER"
},
"INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "Lig med",
"not_equal_to": "Ikke lig med",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Indbakkens navn",
"TEAM_NAME": "Team navn",
"CONTACT": "Kontakt",
"CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name",
"LABELS": "Etiketter",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Kunne ikke åbne lyden",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Træk og slip her for at vedhæfte",
"IMAGE_UPLOAD_SUCCESS": "Billede blev uploadet",
"START_AUDIO_RECORDING": "Start lydoptagelse",
"STOP_AUDIO_RECORDING": "Stop lydoptagelse",
"COPILOT_THINKING": "Copilot tænker",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Slet"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "Navn"
},
"PAGE_TITLE": {
"LABEL": "Page title"
},
"HEADER_TEXT": {
"LABEL": "Header text"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "Tilføj en ny landestandard",
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
@@ -19,8 +19,6 @@
"OR": "ODER"
},
"INPUT_PLACEHOLDER": "Wert eintragen",
"CONTACT_SEARCH_PLACEHOLDER": "Kontakte suchen",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "Gleich",
"not_equal_to": "Nicht gleich",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Bearbeiter Name",
"INBOX_NAME": "Posteingangsname",
"TEAM_NAME": "Teamname",
"CONTACT": "Kontakt",
"CONVERSATION_IDENTIFIER": "Konversation-ID",
"CAMPAIGN_NAME": "Kampagnenname",
"LABELS": "Labels",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Audio konnte nicht geöffnet werden",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Zum Anhängen hierher ziehen und ablegen",
"IMAGE_UPLOAD_SUCCESS": "Bild erfolgreich hochgeladen",
"START_AUDIO_RECORDING": "Audioaufzeichnung starten",
"STOP_AUDIO_RECORDING": "Audioaufzeichnung stoppen",
"COPILOT_THINKING": "Copilot denkt",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Löschen"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "Name"
},
"PAGE_TITLE": {
"LABEL": "Page title"
},
"HEADER_TEXT": {
"LABEL": "Header text"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "Neues Gebietsschema hinzufügen",
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
@@ -19,8 +19,6 @@
"OR": "OR"
},
"INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "Ίσο με",
"not_equal_to": "Όχι ίσο με",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Όνομα Κιβωτίου",
"TEAM_NAME": "Όνομα ομάδας",
"CONTACT": "Επαφές",
"CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name",
"LABELS": "Ετικέτες",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Αδυναμία ανοίγματος ήχου",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Σύρετε και αφήστε εδώ για επισύναψη",
"IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully",
"START_AUDIO_RECORDING": "Έναρξη ηχογράφησης",
"STOP_AUDIO_RECORDING": "Διακοπή ηχογράφησης",
"COPILOT_THINKING": "Ο Copilot σκέφτεται",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Διαγραφή"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "Όνομα"
},
"PAGE_TITLE": {
"LABEL": "Page title"
},
"HEADER_TEXT": {
"LABEL": "Header text"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "Προσθέστε μια νέα γλώσσα",
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
@@ -19,6 +19,8 @@
"OR": "OR"
},
"INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "Equal to",
"not_equal_to": "Not equal to",
@@ -49,6 +51,7 @@
"ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name",
"CONTACT": "Contact",
"CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name",
"LABELS": "Labels",
@@ -233,6 +233,7 @@
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Drag and drop here to attach",
"IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully",
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",
"COPILOT_THINKING": "Copilot is thinking",
@@ -700,9 +700,27 @@
"MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Delete"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "Name"
},
"PAGE_TITLE": {
"LABEL": "Page title"
},
"HEADER_TEXT": {
"LABEL": "Header text"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "Add a new locale",
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
@@ -19,8 +19,6 @@
"OR": "O"
},
"INPUT_PLACEHOLDER": "Introducir valor",
"CONTACT_SEARCH_PLACEHOLDER": "Buscar contactos",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "Igual a",
"not_equal_to": "No igual a",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Nombre Asignado",
"INBOX_NAME": "Nombre de la bandeja de entrada",
"TEAM_NAME": "Nombre del equipo",
"CONTACT": "Contacto",
"CONVERSATION_IDENTIFIER": "Identificador de conversacion",
"CAMPAIGN_NAME": "Nombre de Campaña",
"LABELS": "Etiquetas",
@@ -20,8 +20,8 @@
"CALL": "Llamar",
"CALL_INITIATED": "Llamando al contacto…",
"CALL_FAILED": "No se puede iniciar la llamada. Por favor, inténtelo de nuevo.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Solicitud de permiso de llamada al contacto enviada. Inténtalo de nuevo una vez que acepten.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Solicitud de permiso de llamada ya enviada recientemente. Inténtelo de nuevo una vez que el contacto acepte.",
"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": "Seleccionar un buzón de entrada"
@@ -585,11 +585,11 @@
},
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Asignar etiquetas",
"REMOVE_LABELS": "Quitar etiquetas",
"REMOVE_LABELS": "Remove Labels",
"ASSIGN_LABELS_SUCCESS": "Etiquetas asignadas correctamente.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
"REMOVE_LABELS_SUCCESS": "Etiquetas removidas con éxito.",
"REMOVE_LABELS_FAILED": "Error al eliminar las etiquetas",
"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,7 @@
"UNSUPPORTED_MESSAGE_FACEBOOK": "Este mensaje no es compatible. Puedes ver este mensaje en la aplicación de Facebook Messenger.",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "Este mensaje no es compatible. Puedes ver este mensaje en la aplicación de Instagram.",
"UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.",
"UNSUPPORTED_MESSAGE_WHATSAPP": "Este mensaje no es compatible. Puedes ver este mensaje en la aplicación de WhatsApp.",
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
"SUCCESS_DELETE_MESSAGE": "Mensaje eliminado correctamente",
"FAIL_DELETE_MESSSAGE": "¡No se pudo eliminar el mensaje! Inténtalo de nuevo",
"NO_RESPONSE": "No hay respuesta",
@@ -73,7 +73,7 @@
"CARD": {
"SHOW_LABELS": "Mostrar etiquetas",
"HIDE_LABELS": "Ocultar etiquetas",
"LABELS_COUNT": "{count} etiquetas"
"LABELS_COUNT": "{count} labels"
},
"VOICE_CALL": {
"INCOMING_CALL": "Incoming call",
@@ -81,21 +81,21 @@
"CALL_IN_PROGRESS": "Call in progress",
"NO_ANSWER": "No answer",
"NO_ANSWER_OUTBOUND_LABEL": "No answer",
"NO_ANSWER_OUTBOUND_SUBTEXT": "El contacto no ha atendido",
"NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up",
"MISSED_CALL": "Missed call",
"MISSED_CALL_INBOUND_SUBTEXT": "Ningún agente escogido",
"MISSED_CALL_DECLINED_BY": "Denegado por {agentName}",
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
"CALL_ENDED": "Call ended",
"HANDLED_BY": "Manejado por {agentName}",
"HANDLED_BY": "Handled by {agentName}",
"NOT_ANSWERED_YET": "Not answered yet",
"CALLING": "Llamando…",
"CALLING": "Calling…",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} respondió",
"AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Unirse a la llamada",
"CALL_BACK": "Llamar de vuelta",
"TRANSCRIPT_SHOW_MORE": "Mostrar más",
"TRANSCRIPT_SHOW_LESS": "Mostrar menos"
"CALL_BACK": "Call back",
"TRANSCRIPT_SHOW_MORE": "Show more",
"TRANSCRIPT_SHOW_LESS": "Show less"
},
"HEADER": {
"RESOLVE_ACTION": "Resolver",
@@ -105,17 +105,17 @@
"OPEN": "Más",
"CLOSE": "Cerrar",
"DETAILS": "detalles",
"COPY_ID_SUCCESS": "ID de conversación copiado al portapapeles",
"COPY_ID_SUCCESS": "Conversation ID copied to clipboard",
"SNOOZED_UNTIL": "Posponer hasta",
"SNOOZED_UNTIL_TOMORROW": "Pospuesto hasta mañana",
"SNOOZED_UNTIL_NEXT_WEEK": "Pospuesto hasta la próxima semana",
"SNOOZED_UNTIL_NEXT_REPLY": "Posponer hasta la siguiente respuesta",
"WHATSAPP_CALL": "Iniciar llamada de WhatsApp",
"WHATSAPP_CALL_FAILED": "No se pudo iniciar la llamada de WhatsApp.",
"VOICE_CALL": "Iniciar llamada",
"VOICE_CALL_FAILED": "No se pudo iniciar la llamada.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Solicitud de permiso de llamada al contacto enviada. Inténtalo de nuevo una vez que acepten.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Solicitud de permiso de llamada ya enviada recientemente. Inténtelo de nuevo una vez que el contacto acepte.",
"WHATSAPP_CALL": "Start WhatsApp call",
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
"VOICE_CALL": "Start call",
"VOICE_CALL_FAILED": "Could not start the call.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"SLA_STATUS": {
"FRT": "FRT {status}",
"NRT": "NRT {status}",
@@ -231,9 +231,8 @@
"TIP_AUDIORECORDER_ICON": "Grabar audio",
"TIP_AUDIORECORDER_PERMISSION": "Permitir el acceso a audio",
"TIP_AUDIORECORDER_ERROR": "No se pudo abrir el audio",
"AUDIO_CONVERSION_FAILED": "Error al convertir audio. Vuelve a intentarlo.",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Arrastra y suelta aquí para adjuntar",
"IMAGE_UPLOAD_SUCCESS": "Imagen subida con éxito",
"START_AUDIO_RECORDING": "Iniciar grabación de audio",
"STOP_AUDIO_RECORDING": "Detener grabación de audio",
"COPILOT_THINKING": "Copilot está pensando",
@@ -320,10 +319,10 @@
"DISMISS_CALL": "Descartar",
"JOIN_CALL": "Unirse a la llamada",
"END_CALL": "Terminar llamada",
"MUTE": "Silenciar micrófono",
"UNMUTE": "Reactivar micrófono",
"VIEW_CHAT_HISTORY": "Ver historial de chat",
"GO_TO_CONVERSATION": "Ir al hilo de conversación"
"MUTE": "Mute mic",
"UNMUTE": "Unmute mic",
"VIEW_CHAT_HISTORY": "View chat history",
"GO_TO_CONVERSATION": "Go to conversation thread"
}
},
"EMAIL_TRANSCRIPT": {
@@ -390,18 +389,18 @@
"MACROS": "Macros",
"LINEAR_ISSUES": "Problemas lineales vinculados",
"SHOPIFY_ORDERS": "Shopify Orders",
"SHARED_FILES": "Adjuntos"
"SHARED_FILES": "Attachments"
},
"SHARED_FILES": {
"EMPTY": "No hay archivos adjuntos aún",
"DOWNLOAD": "Descargar archivo",
"DOWNLOAD_ERROR": "No se pudo descargar el archivo. Por favor, inténtelo de nuevo.",
"EMPTY": "No attachments yet",
"DOWNLOAD": "Download file",
"DOWNLOAD_ERROR": "Could not download the file. Please try again.",
"MEDIA_HEADING": "Media",
"FILES_HEADING": "Archivos",
"FILES_HEADING": "Files",
"VIEW_ALL": "Ver todo",
"SHOW_LESS": "Mostrar menos",
"SHOW_LESS": "Show less",
"MORE_COUNT": "+{count}",
"UNTITLED_FILE": "Archivo sin título"
"UNTITLED_FILE": "Untitled file"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Eliminar"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "Nombre"
},
"PAGE_TITLE": {
"LABEL": "Page title"
},
"HEADER_TEXT": {
"LABEL": "Header text"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "Añadir un nuevo idioma",
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
@@ -308,7 +308,7 @@
"AUTH_PROCESSING": "Authenticating with Meta",
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
"PROCESSING": "Setting up your WhatsApp Business Account",
"ENABLING_CALLING": "Activando llamadas de WhatsApp en tu número…",
"ENABLING_CALLING": "Enabling WhatsApp Calling on your number…",
"LOADING_SDK": "Loading Facebook SDK...",
"CANCELLED": "WhatsApp Signup was cancelled",
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
@@ -319,7 +319,7 @@
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow",
"CALLING_ENABLE_FAILED": "Tu bandeja de entrada de WhatsApp está lista, pero las llamadas de voz no se pudieron activar — este número aún no está inscrito en la API de llamadas empresariales de WhatsApp. Llega a Meta o a tu proveedor de soluciones empresariales de WhatsApp a bordo, luego enciende la llamada desde la configuración de las llamadas de la bandeja de entrada."
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
},
"API": {
"ERROR_MESSAGE": "No pudimos guardar el canal de WhatsApp"
@@ -468,8 +468,8 @@
"DESCRIPTION": "Support your customers on WhatsApp"
},
"WHATSAPP_CALL": {
"TITLE": "Llamada de WhatsApp",
"DESCRIPTION": "Responde llamadas de voz en tu número de WhatsApp"
"TITLE": "WhatsApp Call",
"DESCRIPTION": "Take voice calls on your WhatsApp number"
},
"EMAIL": {
"TITLE": "E-mail",
@@ -644,29 +644,29 @@
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "Encuestas de Satisfacción",
"VOICE": "Voice",
"CALLS": "Llamadas"
"CALLS": "Calls"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
"LABEL": "Habilitar llamadas de voz",
"DESCRIPTION": "Activar llamadas de voz en esta bandeja de entrada. Los agentes podrán hacer y recibir llamadas telefónicas."
"LABEL": "Enable Voice Calling",
"DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
},
"CREDENTIALS": {
"DESCRIPTION": "Llamada de voz requiere credenciales de Clave de Twilio API. Estas se utilizan para generar tokens para las conexiones de voz del agente."
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
}
},
"WHATSAPP_CALLING": {
"ENABLE": {
"LABEL": "Habilitar llamadas de WhatsApp",
"DESCRIPTION": "Permitir a los agentes recibir y hacer llamadas a WhatsApp Cloud en esta bandeja de entrada. Los clientes pueden llamar a este número de negocio directamente desde WhatsApp."
"LABEL": "Enable WhatsApp Calling",
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
},
"ENABLE_FAILED": "No se pudo activar la llamada de voz para este númeroaún no está inscrito en la API de llamadas empresariales de WhatsApp. Llega a Meta o a tu proveedor de soluciones de negocios de WhatsApp a bordo de él, luego vuelve a intentarlo.",
"ENABLE_FAILED": "Voice calling couldn't be turned on for this number — it isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then try again.",
"PHONE_NUMBER": {
"LABEL": "Número de teléfono empresarial",
"HELP_TEXT": "Número de WhatsApp al que llamarán los clientes."
"LABEL": "Business phone number",
"HELP_TEXT": "WhatsApp number that customers will call."
},
"HOW_IT_WORKS": {
"LABEL": "Cómo funciona",
"LABEL": "How it works",
"DESCRIPTION": "Calls are placed peer-to-peer between the agent's browser and Meta — no extra credentials are required. Make sure the agent's browser has microphone permission for this site."
},
"PERMISSION_REQUEST_BODY": {
@@ -763,38 +763,38 @@
},
"FILTERS": {
"SOURCE": {
"ALL": "Todas las fuentes",
"WEB": "Páginas web",
"ALL": "All sources",
"WEB": "Web pages",
"PDF": "PDFs"
},
"STATUS": {
"ANY": "Cualquier estado",
"UPDATED": "Actualizado",
"NEEDS_UPDATE": "Necesita actualización",
"UPDATING": "Actualizando",
"ANY": "Any status",
"UPDATED": "Updated",
"NEEDS_UPDATE": "Needs update",
"UPDATING": "Updating",
"FAILED": "Failed"
},
"SORT": {
"RECENTLY_UPDATED": "Actualizado recientemente",
"RECENTLY_CREATED": "Creado recientemente"
"RECENTLY_UPDATED": "Recently updated",
"RECENTLY_CREATED": "Recently created"
},
"SEARCH_PLACEHOLDER": "Buscar..."
},
"SYNC_STATUS": {
"SYNCED": "última actualización {time}",
"SYNCED": "last updated {time}",
"SYNCING": "actualizando...",
"STALE_SYNC": "update stalled",
"FAILED": "Fallo al sincronizar",
"NEVER_SYNCED": "no actualizado aún"
"FAILED": "Failed to sync",
"NEVER_SYNCED": "not updated yet"
},
"SYNC_ERRORS": {
"NOT_FOUND": "Página no encontrada",
"ACCESS_DENIED": "Acceso denegado",
"TIMEOUT": "La página tardó demasiado en responder",
"CONTENT_EMPTY": "La página devolvió contenido vacío",
"FETCH_FAILED": "No se pudo obtener la página",
"SYNC_ERROR": "Error inesperado",
"DEFAULT": "Error de sincronización"
"ACCESS_DENIED": "Access denied",
"TIMEOUT": "Page took too long to respond",
"CONTENT_EMPTY": "Page returned empty content",
"FETCH_FAILED": "Could not fetch page",
"SYNC_ERROR": "Unexpected error",
"DEFAULT": "Sync error"
},
"RELATED_RESPONSES": {
"TITLE": "Related FAQs",
@@ -839,14 +839,14 @@
},
"OPTIONS": {
"VIEW_RELATED_RESPONSES": "View Related Responses",
"SYNC_NOW": "Refrescar ahora",
"RETRY_SYNC": "Reintentar refresco",
"SYNC_NOW": "Refresh now",
"RETRY_SYNC": "Retry refresh",
"DELETE_DOCUMENT": "Delete Document"
},
"EMPTY_STATE": {
"TITLE": "No documents available",
"SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant.",
"FILTERED_TITLE": "No hay documentos coincidentes",
"FILTERED_TITLE": "No matching documents",
"FILTERED_SUBTITLE": "Try changing the source, status, or search term.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain Document",
@@ -24,11 +24,11 @@
"SELECT_REFERRAL_SOURCE": "Select source"
},
"EMAIL_VERIFIED": "Email verified",
"SETTING_UP": "Configurando tu cuenta...",
"SETTING_UP": "Setting up your account...",
"CONTINUE": "Continue",
"SAVING": "Guardando...",
"VALIDATION_ERROR": "Por favor, rellene todos los campos requeridos",
"SUCCESS": "Detalles guardados correctamente",
"ERROR": "No se pudo guardar los detalles. Por favor, inténtelo de nuevo."
"VALIDATION_ERROR": "Please fill in all required fields",
"SUCCESS": "Details saved successfully",
"ERROR": "Could not save details. Please try again."
}
}
@@ -19,8 +19,6 @@
"OR": "OR"
},
"INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Otsi kontakte",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "Equal to",
"not_equal_to": "Not equal to",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name",
"CONTACT": "Kontakt",
"CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name",
"LABELS": "Labels",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Heli avamine ebaõnnestus",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Lohista siia manusena lisamiseks",
"IMAGE_UPLOAD_SUCCESS": "Pilt edukalt üles laaditud",
"START_AUDIO_RECORDING": "Alusta heli salvestamist",
"STOP_AUDIO_RECORDING": "Peata heli salvestamine",
"COPILOT_THINKING": "Copilot mõtleb",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Määra vaikimisi",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Kustuta"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "Name"
},
"PAGE_TITLE": {
"LABEL": "Lehe pealkiri"
},
"HEADER_TEXT": {
"LABEL": "Päise tekst"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "Lisa uus keel",
"DESCRIPTION": "Vali keel, milles see artikkel kirjutatakse. See lisatakse sinu tõlkelisti ja hiljem saad lisada veel.",
@@ -19,8 +19,6 @@
"OR": "یا"
},
"INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "جستجوی مخاطبین",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "مساوی با",
"not_equal_to": "مساوی نیست با",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "نام مسئول",
"INBOX_NAME": "نام صندوق ورودی",
"TEAM_NAME": "نام تیم",
"CONTACT": "مخاطب",
"CONVERSATION_IDENTIFIER": "شناسه گفتگو",
"CAMPAIGN_NAME": "نام کمپین",
"LABELS": "برچسب‌ها",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "صدا را نمی‌توان باز کند",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "برای ضمیمه کردن درگ و درآپ کنید",
"IMAGE_UPLOAD_SUCCESS": "تصویر با موفقیت آپلود شد",
"START_AUDIO_RECORDING": "در حال شروع ضبط صدا",
"STOP_AUDIO_RECORDING": "در حال توقف ضبط صدا",
"COPILOT_THINKING": "Copilot در حال فکر کردن است",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "حذف"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "نام"
},
"PAGE_TITLE": {
"LABEL": "Page title"
},
"HEADER_TEXT": {
"LABEL": "Header text"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "یک محل جدید اضافه کنید",
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
@@ -19,8 +19,6 @@
"OR": "OR"
},
"INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "Equal to",
"not_equal_to": "Not equal to",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name",
"CONTACT": "Contact",
"CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name",
"LABELS": "Tunnisteet",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Drag and drop here to attach",
"IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully",
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",
"COPILOT_THINKING": "Copilot ajattelee",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Poista"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "Nimi"
},
"PAGE_TITLE": {
"LABEL": "Page title"
},
"HEADER_TEXT": {
"LABEL": "Header text"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "Add a new locale",
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
@@ -19,8 +19,6 @@
"OR": "OU"
},
"INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "Égal à",
"not_equal_to": "Pas égal à",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Nom du responsable",
"INBOX_NAME": "Nom de la boîte de réception",
"TEAM_NAME": "Nom de l'équipe",
"CONTACT": "Contact",
"CONVERSATION_IDENTIFIER": "Identifiant de la conversation",
"CAMPAIGN_NAME": "Nom de la campagne",
"LABELS": "Étiquettes",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Impossible d'ouvrir l'audio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Glissez et déposez ici pour lier",
"IMAGE_UPLOAD_SUCCESS": "Image téléchargée avec succès",
"START_AUDIO_RECORDING": "Démarrer l'enregistrement audio",
"STOP_AUDIO_RECORDING": "Arrêter l'enregistrement audio",
"COPILOT_THINKING": "Copilot réfléchit",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Supprimer"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "Nom"
},
"PAGE_TITLE": {
"LABEL": "Page title"
},
"HEADER_TEXT": {
"LABEL": "Header text"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "Ajouter une nouvelle langue",
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
@@ -19,8 +19,6 @@
"OR": "או"
},
"INPUT_PLACEHOLDER": "הכנס ערך",
"CONTACT_SEARCH_PLACEHOLDER": "חפש אנשי קשר",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "שווה ל",
"not_equal_to": "לא שווה ל",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "שם מוקצה",
"INBOX_NAME": "שם תיבת דואר נכנס",
"TEAM_NAME": "שם צוות",
"CONTACT": "איש קשר",
"CONVERSATION_IDENTIFIER": "מזהה שיחה",
"CAMPAIGN_NAME": "שם קמפיין",
"LABELS": "תוויות",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "לא יכול לפתוח אודיו",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "גרור ושחרר כאן להוספת קובץ מצורף",
"IMAGE_UPLOAD_SUCCESS": "התמונה הועלתה בהצלחה",
"START_AUDIO_RECORDING": "התחל הקלטת אודיו",
"STOP_AUDIO_RECORDING": "עצור הקלטת אודיו",
"COPILOT_THINKING": "Copilot חושב",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "הפוך לברירת מחדל",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "מחק"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "שם"
},
"PAGE_TITLE": {
"LABEL": "כותרת עמוד"
},
"HEADER_TEXT": {
"LABEL": "טקסט כותרת עליונה"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "הוסף מקום חדש",
"DESCRIPTION": "בחר את השפה שבה מאמר זה ייכתב. זו תתווסף לרשימת התרגומים שלך, ותוכל להוסיף נוספים מאוחר יותר.",

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