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 ---## ##--- gems for server & infra configuration ---##
gem 'dotenv-rails', '>= 3.0.0' gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman' gem 'foreman'
gem 'puma' gem 'puma', '~> 7.2', '>= 7.2.1'
gem 'vite_rails' gem 'vite_rails'
# metrics on heroku # metrics on heroku
gem 'barnes' gem 'barnes'
+34 -23
View File
@@ -136,6 +136,8 @@ GEM
audited (5.4.1) audited (5.4.1)
activerecord (>= 5.0, < 7.7) activerecord (>= 5.0, < 7.7)
activesupport (>= 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) aws-actionmailbox-ses (0.1.0)
actionmailbox (>= 7.1.0) actionmailbox (>= 7.1.0)
aws-sdk-s3 (~> 1, >= 1.123.0) aws-sdk-s3 (~> 1, >= 1.123.0)
@@ -168,7 +170,7 @@ GEM
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.22) bcrypt (3.1.22)
benchmark (0.4.1) benchmark (0.4.1)
bigdecimal (3.2.2) bigdecimal (3.3.1)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.16.0) bootsnap (1.16.0)
msgpack (~> 1.2) msgpack (~> 1.2)
@@ -184,6 +186,7 @@ GEM
bundler (>= 1.2.0, < 3) bundler (>= 1.2.0, < 3)
thor (~> 1.0) thor (~> 1.0)
byebug (11.1.3) byebug (11.1.3)
cgi (0.5.1)
childprocess (5.1.0) childprocess (5.1.0)
logger (~> 1.5) logger (~> 1.5)
cld3 (3.7.0) cld3 (3.7.0)
@@ -312,7 +315,7 @@ GEM
hashie hashie
faraday-multipart (1.2.0) faraday-multipart (1.2.0)
multipart-post (~> 2.0) multipart-post (~> 2.0)
faraday-net_http (3.4.2) faraday-net_http (3.4.4)
net-http (~> 0.5) net-http (~> 0.5)
faraday-net_http_persistent (2.1.0) faraday-net_http_persistent (2.1.0)
faraday (~> 2.5) faraday (~> 2.5)
@@ -435,7 +438,8 @@ GEM
hana (1.3.7) hana (1.3.7)
hash_diff (1.1.1) hash_diff (1.1.1)
hashdiff (1.1.0) hashdiff (1.1.0)
hashie (5.0.0) hashie (5.1.0)
logger
html2text (0.4.0) html2text (0.4.0)
nokogiri (>= 1.0, < 2.0) nokogiri (>= 1.0, < 2.0)
http (5.1.1) http (5.1.1)
@@ -470,7 +474,7 @@ GEM
rails-dom-testing (>= 1, < 3) rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0) railties (>= 4.2.0)
thor (>= 0.14, < 2.0) thor (>= 0.14, < 2.0)
json (2.19.5) json (2.19.8)
json_refs (0.1.8) json_refs (0.1.8)
hana hana
json_schemer (0.2.24) json_schemer (0.2.24)
@@ -568,7 +572,7 @@ GEM
ruby2_keywords ruby2_keywords
msgpack (1.8.0) msgpack (1.8.0)
multi_json (1.15.0) multi_json (1.15.0)
multi_xml (0.8.0) multi_xml (0.9.1)
bigdecimal (>= 3.1, < 5) bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1) multipart-post (2.4.1)
mutex_m (0.3.0) mutex_m (0.3.0)
@@ -593,7 +597,7 @@ GEM
sidekiq sidekiq
newrelic_rpm (9.6.0) newrelic_rpm (9.6.0)
base64 base64
nio4r (2.7.3) nio4r (2.7.5)
nokogiri (1.19.3) nokogiri (1.19.3)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
@@ -603,19 +607,26 @@ GEM
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu) nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
oauth (1.1.0) oauth (1.1.6)
oauth-tty (~> 1.0, >= 1.0.1) auth-sanitizer (~> 0.2, >= 0.2.1)
snaky_hash (~> 2.0) base64 (~> 0.1)
version_gem (~> 1.1) cgi
oauth-tty (1.0.5) oauth-tty (~> 1.0, >= 1.0.8)
version_gem (~> 1.1, >= 1.1.1) snaky_hash (~> 2.0, >= 2.0.5)
oauth2 (2.0.9) version_gem (~> 1.1, >= 1.1.11)
faraday (>= 0.17.3, < 3.0) oauth-tty (1.0.8)
jwt (>= 1.0, < 3.0) 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) multi_xml (~> 0.5)
rack (>= 1.2, < 4) rack (>= 1.2, < 4)
snaky_hash (~> 2.0) snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1) version_gem (~> 1.1, >= 1.1.11)
oj (3.16.10) oj (3.16.10)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
ostruct (>= 0.2) ostruct (>= 0.2)
@@ -682,7 +693,7 @@ GEM
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (7.0.5) public_suffix (7.0.5)
puma (6.4.3) puma (7.2.1)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.3.0) pundit (2.3.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
@@ -935,9 +946,9 @@ GEM
gli gli
hashie hashie
logger logger
snaky_hash (2.0.1) snaky_hash (2.0.5)
hashie hashie (>= 0.1.0, < 6)
version_gem (~> 1.1, >= 1.1.1) version_gem (>= 1.1.8, < 3)
sorbet-runtime (0.5.11934) sorbet-runtime (0.5.11934)
spring (4.1.1) spring (4.1.1)
spring-watcher-listen (2.1.0) spring-watcher-listen (2.1.0)
@@ -995,7 +1006,7 @@ GEM
valid_email2 (5.2.6) valid_email2 (5.2.6)
activemodel (>= 3.2) activemodel (>= 3.2)
mail (~> 2.5) mail (~> 2.5)
version_gem (1.1.4) version_gem (1.1.11)
vite_rails (3.10.0) vite_rails (3.10.0)
railties (>= 5.1, < 9) railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2) vite_ruby (~> 3.0, >= 3.2.2)
@@ -1132,7 +1143,7 @@ DEPENDENCIES
pgvector pgvector
procore-sift procore-sift
pry-rails pry-rails
puma puma (~> 7.2, >= 7.2.1)
pundit pundit
rack-attack (>= 6.7.0) rack-attack (>= 6.7.0)
rack-cors (= 2.0.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", redirect_uri: "#{base_url}/microsoft/callback",
scope: scope, scope: scope,
state: state, state: state
prompt: 'consent'
} }
) )
if redirect_url if redirect_url
@@ -16,6 +16,10 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
render 'api/v1/accounts/update', format: :json render 'api/v1/accounts/update', format: :json
end end
def help_center_generation
render json: help_center_generation_status
end
private private
def finalizing_account_details? def finalizing_account_details?
@@ -33,4 +37,15 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
def custom_attributes_params def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website) params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
end end
def help_center_generation_status
{
generation_id: nil,
state: nil,
articles_count: 0,
categories_count: 0
}
end
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, :id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, :name, :page_title, :slug, :archived,
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] }, { 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 end
def locale_translation_keys
params.dig(:portal, :config, :locale_translations)&.keys || []
end
def live_chat_widget_params def live_chat_widget_params
permitted_params = params.permit(:inbox_id) permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id) return {} unless permitted_params.key?(:inbox_id)
@@ -1,8 +1,9 @@
module AccessTokenAuthHelper module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = { 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/messages' => ['create'],
'api/v1/accounts/conversations/assignments' => ['create'] 'api/v1/accounts/conversations/assignments' => ['create'],
'api/v1/accounts/conversations/labels' => %w[index create]
}.freeze }.freeze
def ensure_access_token def ensure_access_token
@@ -9,7 +9,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
layout 'portal' layout 'portal'
def show 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 end
def sitemap def sitemap
+1 -1
View File
@@ -12,7 +12,7 @@ class ContactDrop < BaseDrop
end end
def first_name 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 end
def last_name def last_name
+1 -1
View File
@@ -12,7 +12,7 @@ class UserDrop < BaseDrop
end end
def first_name 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 end
def last_name def last_name
+6
View File
@@ -34,6 +34,12 @@ disable_branding:
enabled: <%= (ChatwootHub.pricing_plan != 'community') %> enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-sailbot-fill' icon: 'icon-sailbot-fill'
enterprise: true 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 ------- # # ------- Product Features ------- #
help_center: help_center:
@@ -48,6 +48,12 @@ class TwilioVoiceClient extends EventTarget {
return !!this.activeConnection; return !!this.activeConnection;
} }
setMuted(shouldMute) {
if (!this.activeConnection) return false;
this.activeConnection.mute(shouldMute);
return shouldMute;
}
endClientCall() { endClientCall() {
if (this.activeConnection) { if (this.activeConnection) {
this.activeConnection.disconnect(); this.activeConnection.disconnect();
@@ -9,6 +9,10 @@ class OnboardingAPI extends ApiClient {
update(data) { update(data) {
return axios.patch(this.url, data); return axios.patch(this.url, data);
} }
getHelpCenterGeneration() {
return axios.get(`${this.url}/help_center_generation`);
}
} }
export default new OnboardingAPI(); export default new OnboardingAPI();
@@ -46,9 +46,8 @@ const formattedLastActivityAt = computed(() => {
:src="avatarSource" :src="avatarSource"
class="shrink-0" class="shrink-0"
:name="name" :name="name"
:size="48" :size="42"
hide-offline-status hide-offline-status
rounded-full
/> />
<div class="flex flex-col gap-0.5 flex-1 min-w-0"> <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"> <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" :src="avatarSource"
:size="72" :size="72"
:allow-upload="!isAvatarBusy" :allow-upload="!isAvatarBusy"
rounded-full
hide-offline-status hide-offline-status
@upload="handleAvatarUpload" @upload="handleAvatarUpload"
@delete="handleAvatarDelete" @delete="handleAvatarDelete"
@@ -124,10 +124,9 @@ const handleAvatarHover = isHovered => {
<Avatar <Avatar
:name="name" :name="name"
:src="thumbnail" :src="thumbnail"
:size="48" :size="42"
:status="availabilityStatus" :status="availabilityStatus"
hide-offline-status hide-offline-status
rounded-full
> >
<template v-if="selectable" #overlay="{ size }"> <template v-if="selectable" #overlay="{ size }">
<label <label
@@ -53,6 +53,9 @@ const localeMenuLabels = computed(() => ({
'publish-locale': t( 'publish-locale': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE' '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'), delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
})); }));
@@ -128,7 +131,7 @@ const handleAction = ({ action, value }) => {
<DropdownMenu <DropdownMenu
v-if="showDropdownMenu" v-if="showDropdownMenu"
:menu-items="localeMenuItems" :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" @action="handleAction"
/> />
</div> </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> <script setup>
import { ref } from 'vue';
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.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 { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables'; import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -23,6 +25,8 @@ const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings(); const { uiSettings, updateUISettings } = useUISettings();
const contentDialogRef = ref(null);
const isLocaleDefault = code => { const isLocaleDefault = code => {
return props.portal?.meta?.default_locale === code; return props.portal?.meta?.default_locale === code;
}; };
@@ -148,6 +152,8 @@ const handleAction = ({ action }, localeCode) => {
moveLocaleToDraft({ localeCode: localeCode }); moveLocaleToDraft({ localeCode: localeCode });
} else if (action === 'publish-locale') { } else if (action === 'publish-locale') {
publishLocale({ localeCode: localeCode }); publishLocale({ localeCode: localeCode });
} else if (action === 'customize-content') {
contentDialogRef.value.openForLocale(localeCode);
} else if (action === 'delete') { } else if (action === 'delete') {
deletePortalLocale({ localeCode: localeCode }); deletePortalLocale({ localeCode: localeCode });
} }
@@ -167,5 +173,6 @@ const handleAction = ({ action }, localeCode) => {
:category-count="locale.categoriesCount || 0" :category-count="locale.categoriesCount || 0"
@action="handleAction($event, locale.code)" @action="handleAction($event, locale.code)"
/> />
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
</ul> </ul>
</template> </template>
@@ -182,7 +182,10 @@ const MIN_SEARCH_LENGTH = 2;
export const createContactSearcher = () => { export const createContactSearcher = () => {
let controller = null; let controller = null;
return async (query, { skipMinLength = false } = {}) => { return async (
query,
{ skipMinLength = false, reachableOnly = true } = {}
) => {
const trimmed = typeof query === 'string' ? query.trim() : ''; const trimmed = typeof query === 'string' ? query.trim() : '';
controller?.abort(); controller?.abort();
@@ -199,6 +202,8 @@ export const createContactSearcher = () => {
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal }); } = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
const camelCasedPayload = camelcaseKeys(payload, { deep: true }); const camelCasedPayload = camelcaseKeys(payload, { deep: true });
if (!reachableOnly) return camelCasedPayload || [];
// Filter contacts that have either phone_number or email // Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter( const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email contact => contact.phoneNumber || contact.email
@@ -197,9 +197,9 @@ const channelIcon = computed(() => {
? $t('CONVERSATION.VOICE_WIDGET.END_CALL') ? $t('CONVERSATION.VOICE_WIDGET.END_CALL')
: $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL') : $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')
" "
icon="i-ph-phone-x-bold" icon="i-ph-phone-bold"
ruby ruby
class="!rounded-full rotate-[134deg]" class="!rounded-full rotate-[135deg]"
@click="isOngoing ? $emit('end') : $emit('reject')" @click="isOngoing ? $emit('end') : $emit('reject')"
/> />
</div> </div>
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession'; import { useCallSession } from 'dashboard/composables/useCallSession';
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession'; import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox'; import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants'; 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 CallCard from 'dashboard/components-next/call/CallCard.vue';
import countriesList from 'shared/constants/countries.js'; 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 route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -29,8 +30,8 @@ const {
formattedCallDuration, formattedCallDuration,
} = useCallSession(); } = useCallSession();
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and // Mute routes by provider: WhatsApp toggles the local mic track, Twilio uses
// don't expose a mic track on the browser side. // the Voice SDK connection's native mute. Both surface the same button.
const isMuted = ref(false); const isMuted = ref(false);
const isWhatsappActive = computed( const isWhatsappActive = computed(
() => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP () => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
@@ -63,7 +64,11 @@ const stackedCardState = call =>
const toggleMute = () => { const toggleMute = () => {
isMuted.value = !isMuted.value; isMuted.value = !isMuted.value;
setWhatsappCallMuted(isMuted.value); if (isWhatsappActive.value) {
setWhatsappCallMuted(isMuted.value);
} else {
TwilioVoiceClient.setMuted(isMuted.value);
}
}; };
watch(hasActiveCall, active => { watch(hasActiveCall, active => {
@@ -256,7 +261,7 @@ onBeforeUnmount(stopRingtone);
:call-info="getCallInfo(activeCall || primaryIncomingCall)" :call-info="getCallInfo(activeCall || primaryIncomingCall)"
:duration="hasActiveCall ? formattedCallDuration : ''" :duration="hasActiveCall ? formattedCallDuration : ''"
:is-muted="isMuted" :is-muted="isMuted"
:show-mute="hasActiveCall && isWhatsappActive" :show-mute="hasActiveCall"
@accept="handleJoinCall(primaryIncomingCall)" @accept="handleJoinCall(primaryIncomingCall)"
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)" @reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
@dismiss="dismissCall(primaryIncomingCall?.callSid)" @dismiss="dismissCall(primaryIncomingCall?.callSid)"
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed, h, watch, ref } from 'vue'; import { computed, h, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { debounce } from '@chatwoot/utils';
import Button from 'next/button/Button.vue'; import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
import FilterSelect from './inputs/FilterSelect.vue'; import FilterSelect from './inputs/FilterSelect.vue';
@@ -109,6 +110,34 @@ const inputFieldType = computed(() => {
return 'text'; 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 => { const resetModelOnAttributeKeyChange = newAttributeKey => {
/** /**
* Resets the filter values and operator when the attribute key changes. This ensures that * 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); const newInputType = getInputType(newOperator, filter);
if (newInputType === 'multiSelect') { if (newInputType === 'multiSelect') {
values.value = []; values.value = [];
} else if (['searchSelect', 'booleanSelect'].includes(newInputType)) { } else if (
['searchSelect', 'asyncSearchSelect', 'booleanSelect'].includes(
newInputType
)
) {
values.value = {}; values.value = {};
} else { } else {
values.value = ''; values.value = '';
} }
asyncOptions.value = [];
filterOperator.value = newOperator.value; filterOperator.value = newOperator.value;
}; };
@@ -185,6 +219,16 @@ defineExpose({ validate, resetValidation });
:options="currentFilter.options" :options="currentFilter.options"
dropdown-max-height="max-h-64" 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 <SingleSelect
v-else-if="inputType === 'booleanSelect'" v-else-if="inputType === 'booleanSelect'"
v-model="values" v-model="values"
@@ -7,6 +7,7 @@ export const CONVERSATION_ATTRIBUTES = {
ASSIGNEE_ID: 'assignee_id', ASSIGNEE_ID: 'assignee_id',
INBOX_ID: 'inbox_id', INBOX_ID: 'inbox_id',
TEAM_ID: 'team_id', TEAM_ID: 'team_id',
CONTACT_ID: 'contact_id',
DISPLAY_ID: 'display_id', DISPLAY_ID: 'display_id',
CAMPAIGN_ID: 'campaign_id', CAMPAIGN_ID: 'campaign_id',
LABELS: 'labels', 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 { import {
CONVERSATION_ATTRIBUTES,
getCustomAttributeInputType, getCustomAttributeInputType,
buildAttributesFilterTypes, buildAttributesFilterTypes,
replaceUnderscoreWithSpace, replaceUnderscoreWithSpace,
} from './filterHelper'; } 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('filterHelper', () => {
describe('getCustomAttributeInputType', () => { describe('getCustomAttributeInputType', () => {
it('returns date for date type', () => { 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 { const {
options, options,
asyncSearch,
isSearching,
disableSearch, disableSearch,
disableDeselect, disableDeselect,
placeholderIcon, placeholderIcon,
@@ -23,6 +25,14 @@ const {
type: Array, type: Array,
required: true, required: true,
}, },
asyncSearch: {
type: Boolean,
default: false,
},
isSearching: {
type: Boolean,
default: false,
},
disableSearch: { disableSearch: {
type: Boolean, type: Boolean,
default: false, 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 { t } = useI18n();
const selected = defineModel({ const selected = defineModel({
type: Object, type: Object,
@@ -60,7 +76,9 @@ const selected = defineModel({
}); });
const searchTerm = ref(''); const searchTerm = ref('');
const searchResults = computed(() => { const searchResults = computed(() => {
if (asyncSearch) return options;
if (!options) return []; if (!options) return [];
return picoSearch(options, searchTerm.value, ['name']); return picoSearch(options, searchTerm.value, ['name']);
}); });
@@ -77,7 +95,11 @@ const selectedItem = computed(() => {
if (!optionToSearch) return null; if (!optionToSearch) return null;
// extract the selected item from the options array // extract the selected item from the options array
// this ensures that options like icon is also included // 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 => { const toggleSelected = option => {
@@ -131,13 +153,19 @@ const toggleSelected = option => {
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" /> <Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
<input <input
v-model="searchTerm" v-model="searchTerm"
autofocus v-focus
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full" class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')" :placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
@input="emit('search', $event.target.value)"
/> />
</div> </div>
<DropdownSection :height="dropdownMaxHeight"> <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 <DropdownItem
v-for="option in searchResults" v-for="option in searchResults"
:key="option.id" :key="option.id"
@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n';
import { useOperators } from './operators'; import { useOperators } from './operators';
import { useMapGetter } from 'dashboard/composables/store.js'; import { useMapGetter } from 'dashboard/composables/store.js';
import { useChannelIcon } from 'next/icon/provider'; import { useChannelIcon } from 'next/icon/provider';
import { createContactSearcher } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import { import {
buildAttributesFilterTypes, buildAttributesFilterTypes,
CONVERSATION_ATTRIBUTES, 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} 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} 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 {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 {FilterOption[]} [options] - The options available for the attribute if it is a multiSelect or singleSelect type
* @property {'text'|'number'} dataType * @property {'text'|'number'} dataType
* @property {FilterOperator[]} filterOperators - The operators available for the attribute * @property {FilterOperator[]} filterOperators - The operators available for the attribute
@@ -68,6 +69,30 @@ export function useConversationFilterContext() {
getOperatorTypes, getOperatorTypes,
} = useOperators(); } = 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[]>} * @type {import('vue').ComputedRef<FilterType[]>}
*/ */
@@ -158,6 +183,18 @@ export function useConversationFilterContext() {
filterOperators: presenceOperators.value, filterOperators: presenceOperators.value,
attributeModel: 'standard', 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, attributeKey: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
value: CONVERSATION_ATTRIBUTES.DISPLAY_ID, value: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
@@ -145,7 +145,6 @@ const allowedMenuItems = computed(() => {
:src="currentUser.avatar_url" :src="currentUser.avatar_url"
:status="currentUserAvailability" :status="currentUserAvailability"
class="flex-shrink-0" class="flex-shrink-0"
rounded-full
/> />
<div v-if="!isCollapsed" class="min-w-0"> <div v-if="!isCollapsed" class="min-w-0">
<div class="text-sm font-medium leading-4 truncate text-n-slate-12"> <div class="text-sm font-medium leading-4 truncate text-n-slate-12">
@@ -151,6 +151,9 @@ const activeFolder = computed(() => {
return undefined; return undefined;
}); });
const getContact = useMapGetter('contacts/getContact');
const folderContactId = useMapGetter('customViews/getActiveFolderContactId');
const activeFolderName = computed(() => { const activeFolderName = computed(() => {
return activeFolder.value?.name; return activeFolder.value?.name;
}); });
@@ -456,6 +459,7 @@ function setParamsForEditFolderModal() {
inboxes: inboxesList.value, inboxes: inboxesList.value,
labels: labels.value, labels: labels.value,
campaigns: campaigns.value, campaigns: campaigns.value,
contacts: [getContact.value(folderContactId.value)],
languages: languages, languages: languages,
countries: countries, countries: countries,
priority: [ priority: [
@@ -32,7 +32,6 @@ import {
CONVERSATION_EVENTS, CONVERSATION_EVENTS,
CAPTAIN_EVENTS, CAPTAIN_EVENTS,
} from 'dashboard/helper/AnalyticsHelper/events'; } from 'dashboard/helper/AnalyticsHelper/events';
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
import { import {
messageSchema, messageSchema,
@@ -43,6 +42,7 @@ import {
MessageMarkdownSerializer, MessageMarkdownSerializer,
EditorState, EditorState,
Selection, Selection,
imageResizeView,
} from '@chatwoot/prosemirror-schema'; } from '@chatwoot/prosemirror-schema';
import { import {
suggestionsPlugin, suggestionsPlugin,
@@ -57,7 +57,6 @@ import {
insertAtCursor, insertAtCursor,
removeSignature as removeSignatureHelper, removeSignature as removeSignatureHelper,
scrollCursorIntoView, scrollCursorIntoView,
setURLWithQueryAndSize,
getFormattingForEditor, getFormattingForEditor,
getSelectionCoords, getSelectionCoords,
calculateMenuPosition, calculateMenuPosition,
@@ -72,6 +71,7 @@ import {
import { createTypingIndicator } from '@chatwoot/utils'; import { createTypingIndicator } from '@chatwoot/utils';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper'; import { uploadFile } from 'dashboard/helper/uploadHelper';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
const props = defineProps({ const props = defineProps({
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
@@ -93,7 +93,6 @@ const props = defineProps({
channelType: { type: String, default: '' }, channelType: { type: String, default: '' },
conversationId: { type: Number, default: null }, conversationId: { type: Number, default: null },
medium: { type: String, default: '' }, medium: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
focusOnMount: { type: Boolean, default: true }, focusOnMount: { type: Boolean, default: true },
}); });
@@ -119,6 +118,14 @@ const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const DEFAULT_FORMATTING = 'Context::Default'; const DEFAULT_FORMATTING = 'Context::Default';
const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote'; 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(() => const effectiveChannelType = computed(() =>
getEffectiveChannelType(props.channelType, props.medium) getEffectiveChannelType(props.channelType, props.medium)
@@ -192,12 +199,8 @@ const cannedSearchTerm = ref('');
const variableSearchTerm = ref(''); const variableSearchTerm = ref('');
const emojiSearchTerm = ref(''); const emojiSearchTerm = ref('');
const range = ref(null); 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 isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
const showSelectionMenu = ref(false); const showSelectionMenu = ref(false);
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
// element ref // element ref
const editorRoot = useTemplateRef('editorRoot'); const editorRoot = useTemplateRef('editorRoot');
@@ -475,16 +478,6 @@ function removeSignature() {
reloadState(content); 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 } = {}) { function setMenubarPosition({ selection } = {}) {
const wrapper = editorRoot.value; const wrapper = editorRoot.value;
if (!selection || !wrapper) return; if (!selection || !wrapper) return;
@@ -520,30 +513,6 @@ function checkSelection(editorState) {
if (hasSelection) setMenubarPosition(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() { function emitOnChange() {
emit('input', contentFromEditor()); emit('input', contentFromEditor());
emit('update:modelValue', contentFromEditor()); emit('update:modelValue', contentFromEditor());
@@ -563,21 +532,6 @@ function toggleSignatureInEditor(signatureEnabled) {
emitOnChange(); 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() { function isEnterToSendEnabled() {
return isEditorHotKeyEnabled('enter'); return isEditorHotKeyEnabled('enter');
} }
@@ -586,17 +540,6 @@ function isCmdPlusEnterToSendEnabled() {
return isEditorHotKeyEnabled('cmd_enter'); return isEditorHotKeyEnabled('cmd_enter');
} }
useKeyboardEvents({
'Alt+KeyP': {
action: focusEditorInputField,
allowOnFocusedInput: false,
},
'Alt+KeyL': {
action: focusEditorInputField,
allowOnFocusedInput: false,
},
});
function onImageInsertInEditor(fileUrl) { function onImageInsertInEditor(fileUrl) {
const { tr } = editorView.state; const { tr } = editorView.state;
@@ -617,7 +560,11 @@ async function uploadImageToStorage(file) {
onImageInsertInEditor(fileUrl); onImageInsertInEditor(fileUrl);
} }
useAlert( 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) { } catch (error) {
useAlert( useAlert(
@@ -626,8 +573,8 @@ async function uploadImageToStorage(file) {
} }
} }
function onFileChange() { function uploadImageIfWithinSizeLimit(file) {
const file = imageUpload.value.files[0]; if (!file) return;
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) { if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
uploadImageToStorage(file); uploadImageToStorage(file);
} else { } 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) { function handleLineBreakWhenEnterToSendEnabled(event) {
if ( if (
hasPressedEnterAndNotCmdOrShift(event) && hasPressedEnterAndNotCmdOrShift(event) &&
@@ -736,6 +734,9 @@ function createEditorView() {
editorView = new EditorView(editor.value, { editorView = new EditorView(editor.value, {
state: state, state: state,
editable: () => !props.disabled, editable: () => !props.disabled,
nodeViews: {
image: imageResizeView,
},
dispatchTransaction: tx => { dispatchTransaction: tx => {
state = state.apply(tx); state = state.apply(tx);
editorView.updateState(state); editorView.updateState(state);
@@ -748,12 +749,10 @@ function createEditorView() {
keyup: () => { keyup: () => {
if (!props.disabled) { if (!props.disabled) {
typingIndicator.start(); typingIndicator.start();
updateImgToolbarOnDelete();
} }
}, },
keydown: (view, event) => !props.disabled && onKeydown(event), keydown: (view, event) => !props.disabled && onKeydown(event),
focus: () => !props.disabled && emit('focus'), focus: () => !props.disabled && emit('focus'),
click: () => !props.disabled && isEditorMouseFocusedOnAnImage(),
blur: () => { blur: () => {
if (props.disabled) return; if (props.disabled) return;
typingIndicator.stop(); typingIndicator.stop();
@@ -918,23 +917,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@change="onFileChange" @change="onFileChange"
/> />
<div ref="editor" /> <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" /> <slot name="footer" />
</div> </div>
</template> </template>
@@ -997,10 +979,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply text-n-slate-11; @apply text-n-slate-11;
} }
} }
ol li {
@apply list-item list-decimal;
}
} }
} }
@@ -124,7 +124,6 @@ const copyConversationId = async () => {
:size="32" :size="32"
:status="currentContact.availability_status" :status="currentContact.availability_status"
hide-offline-status hide-offline-status
rounded-full
/> />
<div <div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2" 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}`; return `draft-${this.conversationIdByRoute}-${this.replyType}`;
}, },
audioRecordFormat() { audioRecordFormat() {
if (this.isAWhatsAppChannel) { if (this.isAWhatsAppCloudChannel) {
return AUDIO_FORMATS.OGG; return AUDIO_FORMATS.OGG;
} }
if (this.isATelegramChannel) { if (this.isAWhatsAppChannel || this.isATelegramChannel) {
return AUDIO_FORMATS.MP3; return AUDIO_FORMATS.MP3;
} }
if (this.isAPIInbox) { if (this.isAPIInbox) {
@@ -46,6 +46,14 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_2, filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard', attributeModel: 'standard',
}, },
{
attributeKey: 'contact_id',
attributeI18nKey: 'CONTACT',
inputType: 'search_select',
dataType: 'number',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'standard',
},
{ {
attributeKey: 'display_id', attributeKey: 'display_id',
attributeI18nKey: 'CONVERSATION_IDENTIFIER', attributeI18nKey: 'CONVERSATION_IDENTIFIER',
@@ -133,6 +141,10 @@ export const filterAttributeGroups = [
key: 'team_id', key: 'team_id',
i18nKey: 'TEAM_NAME', i18nKey: 'TEAM_NAME',
}, },
{
key: 'contact_id',
i18nKey: 'CONTACT',
},
{ {
key: 'display_id', key: 'display_id',
i18nKey: 'CONVERSATION_IDENTIFIER', 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', 'link',
'bulletList', 'bulletList',
'orderedList', 'orderedList',
'imageUpload',
'undo', 'undo',
'redo', 'redo',
], ],
@@ -30,6 +31,7 @@ export const FORMATTING = {
'strike', 'strike',
'bulletList', 'bulletList',
'orderedList', 'orderedList',
'imageUpload',
'undo', 'undo',
'redo', '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 => { export const getValuesForStatus = values => {
return values.map(value => ({ id: value, name: value })); return values.map(value => ({ id: value, name: value }));
}; };
@@ -84,6 +91,7 @@ export const getValuesForFilter = (filter, params) => {
campaigns, campaigns,
labels, labels,
priority, priority,
contacts,
} = params; } = params;
switch (attribute_key) { switch (attribute_key) {
case 'status': case 'status':
@@ -94,6 +102,8 @@ export const getValuesForFilter = (filter, params) => {
return getValuesName(values, inboxes, 'id', 'name'); return getValuesName(values, inboxes, 'id', 'name');
case 'team_id': case 'team_id':
return getValuesName(values, teams, 'id', 'name'); return getValuesName(values, teams, 'id', 'name');
case 'contact_id':
return getValuesForContact(values, contacts);
case 'campaign_id': case 'campaign_id':
return getValuesName(values, campaigns, 'id', 'title'); return getValuesName(values, campaigns, 'id', 'title');
case 'labels': 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. * Strips unsupported markdown formatting from content based on the editor schema.
* This ensures canned responses with rich formatting can be inserted into channels * This ensures canned responses with rich formatting can be inserted into channels
@@ -159,6 +159,13 @@ export const LOCALE_MENU_ITEMS = {
value: 'publish', value: 'publish',
icon: 'i-lucide-eye', 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: { delete: {
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE', label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
action: 'delete', action: 'delete',
@@ -172,20 +179,28 @@ const disableLocaleMenuItems = menuItems =>
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => { export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
if (isDefault) { if (isDefault) {
return disableLocaleMenuItems([ return [
LOCALE_MENU_ITEMS.makeDefault, ...disableLocaleMenuItems([
LOCALE_MENU_ITEMS.moveToDraft, LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.delete, LOCALE_MENU_ITEMS.moveToDraft,
]); ]),
LOCALE_MENU_ITEMS.customizeContent,
...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]),
];
} }
if (isDraft) { 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 [ return [
LOCALE_MENU_ITEMS.makeDefault, LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.moveToDraft, LOCALE_MENU_ITEMS.moveToDraft,
LOCALE_MENU_ITEMS.customizeContent,
LOCALE_MENU_ITEMS.delete, LOCALE_MENU_ITEMS.delete,
]; ];
}; };
@@ -273,6 +273,41 @@ describe('customViewsHelper', () => {
}; };
expect(generateValuesForEditCustomViews(filter, params)).toEqual('1'); 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', () => { describe('#generateCustomAttributesInputType', () => {
@@ -16,7 +16,6 @@ import {
insertAtCursor, insertAtCursor,
removeSignature, removeSignature,
replaceSignature, replaceSignature,
setURLWithQueryAndSize,
stripInlineBase64Images, stripInlineBase64Images,
stripUnsupportedFormatting, stripUnsupportedFormatting,
stripUnsupportedMarkdown, 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', () => { describe('getContentNode', () => {
let mockEditorView; let mockEditorView;
@@ -64,4 +64,25 @@ describe('#filterQueryGenerator', () => {
filterQueryGenerator(testData).payload.every(i => Array.isArray(i.values)) filterQueryGenerator(testData).payload.every(i => Array.isArray(i.values))
).toBe(true); ).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', () => { 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( expect(
buildLocaleMenuItems({ items
isDefault: true, .filter(item => item.action !== 'customize-content')
isDraft: false, .every(item => item.disabled)
}) ).toBe(true);
).toEqual(
expect.arrayContaining([
expect.objectContaining({ action: 'change-default', disabled: true }),
expect.objectContaining({ action: 'move-to-draft', disabled: true }),
expect.objectContaining({ action: 'delete', disabled: true }),
])
);
}); });
it('returns publish and delete actions for draft locales', () => { it('returns publish, customize, and delete actions for draft locales', () => {
expect( expect(
buildLocaleMenuItems({ buildLocaleMenuItems({
isDefault: false, isDefault: false,
isDraft: true, isDraft: true,
}).map(({ action }) => action) }).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( expect(
buildLocaleMenuItems({ buildLocaleMenuItems({
isDefault: false, isDefault: false,
isDraft: false, isDraft: false,
}).map(({ action }) => action) }).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" "OR": "OR"
}, },
"INPUT_PLACEHOLDER": "Enter value", "INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "እውቂያዎችን ይፈልጉ",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": { "OPERATOR_LABELS": {
"equal_to": "Equal to", "equal_to": "Equal to",
"not_equal_to": "Not equal to", "not_equal_to": "Not equal to",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Assignee name", "ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name", "INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name", "TEAM_NAME": "Team name",
"CONTACT": "እውቂያ",
"CONVERSATION_IDENTIFIER": "Conversation identifier", "CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name", "CAMPAIGN_NAME": "Campaign name",
"LABELS": "Labels", "LABELS": "Labels",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Could not open the audio", "TIP_AUDIORECORDER_ERROR": "Could not open the audio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Drag and drop here to attach", "DRAG_DROP": "Drag and drop here to attach",
"IMAGE_UPLOAD_SUCCESS": "ምስል በተሳካ ሁኔታ ተሰብስቧል",
"START_AUDIO_RECORDING": "Start audio recording", "START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording", "STOP_AUDIO_RECORDING": "Stop audio recording",
"COPILOT_THINKING": "ኮፒሎት እየሰማራ ነው", "COPILOT_THINKING": "ኮፒሎት እየሰማራ ነው",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "እንደ ነባሪ አድርግ", "MAKE_DEFAULT": "እንደ ነባሪ አድርግ",
"MOVE_TO_DRAFT": "Move to draft", "MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale", "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": "የገፅ ርዕስ"
},
"HEADER_TEXT": {
"LABEL": "የራስጌ ጽሑፍ"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": { "ADD_LOCALE_DIALOG": {
"TITLE": "አዲስ ቋንቋ አክል", "TITLE": "አዲስ ቋንቋ አክል",
"DESCRIPTION": "ይህ ሰነድ በሚጻፍበት ቋንቋ ይምረጡ። ይህ ወደ የትርጉም ዝርዝርዎ ይጨምራል እና በኋላ ተጨማሪ ማክሰኞ ይችላሉ።.", "DESCRIPTION": "ይህ ሰነድ በሚጻፍበት ቋንቋ ይምረጡ። ይህ ወደ የትርጉም ዝርዝርዎ ይጨምራል እና በኋላ ተጨማሪ ማክሰኞ ይችላሉ።.",
@@ -19,8 +19,6 @@
"OR": "أو" "OR": "أو"
}, },
"INPUT_PLACEHOLDER": "أدخل القيمة", "INPUT_PLACEHOLDER": "أدخل القيمة",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": { "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": "تعذر فتح الصوت", "TIP_AUDIORECORDER_ERROR": "تعذر فتح الصوت",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "اسحب و أسقط هنا للإرفاق", "DRAG_DROP": "اسحب و أسقط هنا للإرفاق",
"IMAGE_UPLOAD_SUCCESS": "تم رفع الصورة بنجاح",
"START_AUDIO_RECORDING": "بدء التسجيل الصوتي", "START_AUDIO_RECORDING": "بدء التسجيل الصوتي",
"STOP_AUDIO_RECORDING": "إيقاف التسجيل الصوتي", "STOP_AUDIO_RECORDING": "إيقاف التسجيل الصوتي",
"COPILOT_THINKING": "Copilot يفكر", "COPILOT_THINKING": "Copilot يفكر",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default", "MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft", "MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale", "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": "الاسم"
},
"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": { "ADD_LOCALE_DIALOG": {
"TITLE": "إضافة لغة جديدة", "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.", "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" "OR": "OR"
}, },
"INPUT_PLACEHOLDER": "Enter value", "INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Əlaqələrdə axtarış",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": { "OPERATOR_LABELS": {
"equal_to": "Equal to", "equal_to": "Equal to",
"not_equal_to": "Not equal to", "not_equal_to": "Not equal to",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Assignee name", "ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name", "INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name", "TEAM_NAME": "Team name",
"CONTACT": "Əlaqə",
"CONVERSATION_IDENTIFIER": "Conversation identifier", "CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name", "CAMPAIGN_NAME": "Campaign name",
"LABELS": "Labels", "LABELS": "Labels",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Səsi açmaq mümkün olmadı", "TIP_AUDIORECORDER_ERROR": "Səsi açmaq mümkün olmadı",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Qoşmaq üçün buraya sürükləyin və buraxın", "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", "START_AUDIO_RECORDING": "Səs yazısını başla",
"STOP_AUDIO_RECORDING": "Səs yazısını dayandırın", "STOP_AUDIO_RECORDING": "Səs yazısını dayandırın",
"COPILOT_THINKING": "Copilot düşünür", "COPILOT_THINKING": "Copilot düşünür",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default", "MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft", "MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale", "PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Delete" "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": { "ADD_LOCALE_DIALOG": {
"TITLE": "Add a new locale", "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.", "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", "INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": { "OPERATOR_LABELS": {
"equal_to": "Равно на", "equal_to": "Равно на",
"not_equal_to": "Различно от", "not_equal_to": "Различно от",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Assignee name", "ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name", "INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name", "TEAM_NAME": "Team name",
"CONTACT": "Contact",
"CONVERSATION_IDENTIFIER": "Conversation identifier", "CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name", "CAMPAIGN_NAME": "Campaign name",
"LABELS": "Етикети", "LABELS": "Етикети",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Could not open the audio", "TIP_AUDIORECORDER_ERROR": "Could not open the audio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Drag and drop here to attach", "DRAG_DROP": "Drag and drop here to attach",
"IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully",
"START_AUDIO_RECORDING": "Start audio recording", "START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording", "STOP_AUDIO_RECORDING": "Stop audio recording",
"COPILOT_THINKING": "Copilot мисли", "COPILOT_THINKING": "Copilot мисли",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default", "MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft", "MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale", "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": "Име"
},
"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": { "ADD_LOCALE_DIALOG": {
"TITLE": "Add a new locale", "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.", "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" "OR": "OR"
}, },
"INPUT_PLACEHOLDER": "Enter value", "INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "যোগাযোগ অনুসন্ধান করুন",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": { "OPERATOR_LABELS": {
"equal_to": "Equal to", "equal_to": "Equal to",
"not_equal_to": "Not equal to", "not_equal_to": "Not equal to",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Assignee name", "ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name", "INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name", "TEAM_NAME": "Team name",
"CONTACT": "যোগাযোগ",
"CONVERSATION_IDENTIFIER": "Conversation identifier", "CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name", "CAMPAIGN_NAME": "Campaign name",
"LABELS": "Labels", "LABELS": "Labels",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "অডিও খোলা যায়নি", "TIP_AUDIORECORDER_ERROR": "অডিও খোলা যায়নি",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "সংযুক্ত করতে এখানে ড্র্যাগ ও ড্রপ করুন", "DRAG_DROP": "সংযুক্ত করতে এখানে ড্র্যাগ ও ড্রপ করুন",
"IMAGE_UPLOAD_SUCCESS": "ছবি সফলভাবে আপলোড হয়েছে",
"START_AUDIO_RECORDING": "অডিও রেকর্ডিং শুরু করুন", "START_AUDIO_RECORDING": "অডিও রেকর্ডিং শুরু করুন",
"STOP_AUDIO_RECORDING": "অডিও রেকর্ডিং বন্ধ করুন", "STOP_AUDIO_RECORDING": "অডিও রেকর্ডিং বন্ধ করুন",
"COPILOT_THINKING": "Copilot ভাবছে", "COPILOT_THINKING": "Copilot ভাবছে",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "ডিফল্ট করুন", "MAKE_DEFAULT": "ডিফল্ট করুন",
"MOVE_TO_DRAFT": "Move to draft", "MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale", "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": "নাম"
},
"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": { "ADD_LOCALE_DIALOG": {
"TITLE": "নতুন লোকেল যোগ করুন", "TITLE": "নতুন লোকেল যোগ করুন",
"DESCRIPTION": "এই নিবন্ধটি যে ভাষায় লেখা হবে তা নির্বাচন করুন. এটি আপনার অনুবাদ তালিকায় যোগ হবে, এবং পরে আপনি আরও ভাষা যোগ করতে পারবেন.", "DESCRIPTION": "এই নিবন্ধটি যে ভাষায় লেখা হবে তা নির্বাচন করুন. এটি আপনার অনুবাদ তালিকায় যোগ হবে, এবং পরে আপনি আরও ভাষা যোগ করতে পারবেন.",
@@ -19,8 +19,6 @@
"OR": "O" "OR": "O"
}, },
"INPUT_PLACEHOLDER": "Enter value", "INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": { "OPERATOR_LABELS": {
"equal_to": "Igual a", "equal_to": "Igual a",
"not_equal_to": "No és igual a", "not_equal_to": "No és igual a",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Nom assignat", "ASSIGNEE_NAME": "Nom assignat",
"INBOX_NAME": "Nom de la safata d'entrada", "INBOX_NAME": "Nom de la safata d'entrada",
"TEAM_NAME": "Nom de l'equip", "TEAM_NAME": "Nom de l'equip",
"CONTACT": "Contacte",
"CONVERSATION_IDENTIFIER": "Identificador de conversa", "CONVERSATION_IDENTIFIER": "Identificador de conversa",
"CAMPAIGN_NAME": "Nom de la campanya", "CAMPAIGN_NAME": "Nom de la campanya",
"LABELS": "Etiquetes", "LABELS": "Etiquetes",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "No s'ha pogut obrir l'àudio", "TIP_AUDIORECORDER_ERROR": "No s'ha pogut obrir l'àudio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Arrossega i deixa anar aquí per adjuntar-lo", "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", "START_AUDIO_RECORDING": "Inicia la gravació d'àudio",
"STOP_AUDIO_RECORDING": "Atura la gravació d'àudio", "STOP_AUDIO_RECORDING": "Atura la gravació d'àudio",
"COPILOT_THINKING": "Copilot està pensant", "COPILOT_THINKING": "Copilot està pensant",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default", "MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft", "MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale", "PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Esborrar" "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": { "ADD_LOCALE_DIALOG": {
"TITLE": "Afegeix una nova localització", "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.", "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" "OR": "OR"
}, },
"INPUT_PLACEHOLDER": "Enter value", "INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": { "OPERATOR_LABELS": {
"equal_to": "Equal to", "equal_to": "Equal to",
"not_equal_to": "Not equal to", "not_equal_to": "Not equal to",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Assignee name", "ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name", "INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name", "TEAM_NAME": "Team name",
"CONTACT": "Contact",
"CONVERSATION_IDENTIFIER": "Conversation identifier", "CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name", "CAMPAIGN_NAME": "Campaign name",
"LABELS": "Štítky", "LABELS": "Štítky",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Zvuk se nepodařilo otevřít", "TIP_AUDIORECORDER_ERROR": "Zvuk se nepodařilo otevřít",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Přetažením sem připojíte", "DRAG_DROP": "Přetažením sem připojíte",
"IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully",
"START_AUDIO_RECORDING": "Spustit nahrávání zvuku", "START_AUDIO_RECORDING": "Spustit nahrávání zvuku",
"STOP_AUDIO_RECORDING": "Zastavit nahrávání zvuku", "STOP_AUDIO_RECORDING": "Zastavit nahrávání zvuku",
"COPILOT_THINKING": "Copilot přemýšlí", "COPILOT_THINKING": "Copilot přemýšlí",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default", "MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft", "MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale", "PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Vymazat" "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": { "ADD_LOCALE_DIALOG": {
"TITLE": "Add a new locale", "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.", "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" "OR": "ELLER"
}, },
"INPUT_PLACEHOLDER": "Enter value", "INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": { "OPERATOR_LABELS": {
"equal_to": "Lig med", "equal_to": "Lig med",
"not_equal_to": "Ikke lig med", "not_equal_to": "Ikke lig med",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Assignee name", "ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Indbakkens navn", "INBOX_NAME": "Indbakkens navn",
"TEAM_NAME": "Team navn", "TEAM_NAME": "Team navn",
"CONTACT": "Kontakt",
"CONVERSATION_IDENTIFIER": "Conversation identifier", "CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name", "CAMPAIGN_NAME": "Campaign name",
"LABELS": "Etiketter", "LABELS": "Etiketter",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Kunne ikke åbne lyden", "TIP_AUDIORECORDER_ERROR": "Kunne ikke åbne lyden",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Træk og slip her for at vedhæfte", "DRAG_DROP": "Træk og slip her for at vedhæfte",
"IMAGE_UPLOAD_SUCCESS": "Billede blev uploadet",
"START_AUDIO_RECORDING": "Start lydoptagelse", "START_AUDIO_RECORDING": "Start lydoptagelse",
"STOP_AUDIO_RECORDING": "Stop lydoptagelse", "STOP_AUDIO_RECORDING": "Stop lydoptagelse",
"COPILOT_THINKING": "Copilot tænker", "COPILOT_THINKING": "Copilot tænker",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default", "MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft", "MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale", "PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Slet" "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": { "ADD_LOCALE_DIALOG": {
"TITLE": "Tilføj en ny landestandard", "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.", "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" "OR": "ODER"
}, },
"INPUT_PLACEHOLDER": "Wert eintragen", "INPUT_PLACEHOLDER": "Wert eintragen",
"CONTACT_SEARCH_PLACEHOLDER": "Kontakte suchen",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": { "OPERATOR_LABELS": {
"equal_to": "Gleich", "equal_to": "Gleich",
"not_equal_to": "Nicht gleich", "not_equal_to": "Nicht gleich",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Bearbeiter Name", "ASSIGNEE_NAME": "Bearbeiter Name",
"INBOX_NAME": "Posteingangsname", "INBOX_NAME": "Posteingangsname",
"TEAM_NAME": "Teamname", "TEAM_NAME": "Teamname",
"CONTACT": "Kontakt",
"CONVERSATION_IDENTIFIER": "Konversation-ID", "CONVERSATION_IDENTIFIER": "Konversation-ID",
"CAMPAIGN_NAME": "Kampagnenname", "CAMPAIGN_NAME": "Kampagnenname",
"LABELS": "Labels", "LABELS": "Labels",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Audio konnte nicht geöffnet werden", "TIP_AUDIORECORDER_ERROR": "Audio konnte nicht geöffnet werden",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Zum Anhängen hierher ziehen und ablegen", "DRAG_DROP": "Zum Anhängen hierher ziehen und ablegen",
"IMAGE_UPLOAD_SUCCESS": "Bild erfolgreich hochgeladen",
"START_AUDIO_RECORDING": "Audioaufzeichnung starten", "START_AUDIO_RECORDING": "Audioaufzeichnung starten",
"STOP_AUDIO_RECORDING": "Audioaufzeichnung stoppen", "STOP_AUDIO_RECORDING": "Audioaufzeichnung stoppen",
"COPILOT_THINKING": "Copilot denkt", "COPILOT_THINKING": "Copilot denkt",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default", "MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft", "MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale", "PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Löschen" "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": { "ADD_LOCALE_DIALOG": {
"TITLE": "Neues Gebietsschema hinzufügen", "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.", "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" "OR": "OR"
}, },
"INPUT_PLACEHOLDER": "Enter value", "INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": { "OPERATOR_LABELS": {
"equal_to": "Ίσο με", "equal_to": "Ίσο με",
"not_equal_to": "Όχι ίσο με", "not_equal_to": "Όχι ίσο με",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Assignee name", "ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Όνομα Κιβωτίου", "INBOX_NAME": "Όνομα Κιβωτίου",
"TEAM_NAME": "Όνομα ομάδας", "TEAM_NAME": "Όνομα ομάδας",
"CONTACT": "Επαφές",
"CONVERSATION_IDENTIFIER": "Conversation identifier", "CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name", "CAMPAIGN_NAME": "Campaign name",
"LABELS": "Ετικέτες", "LABELS": "Ετικέτες",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Αδυναμία ανοίγματος ήχου", "TIP_AUDIORECORDER_ERROR": "Αδυναμία ανοίγματος ήχου",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Σύρετε και αφήστε εδώ για επισύναψη", "DRAG_DROP": "Σύρετε και αφήστε εδώ για επισύναψη",
"IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully",
"START_AUDIO_RECORDING": "Έναρξη ηχογράφησης", "START_AUDIO_RECORDING": "Έναρξη ηχογράφησης",
"STOP_AUDIO_RECORDING": "Διακοπή ηχογράφησης", "STOP_AUDIO_RECORDING": "Διακοπή ηχογράφησης",
"COPILOT_THINKING": "Ο Copilot σκέφτεται", "COPILOT_THINKING": "Ο Copilot σκέφτεται",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default", "MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft", "MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale", "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": "Όνομα"
},
"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": { "ADD_LOCALE_DIALOG": {
"TITLE": "Προσθέστε μια νέα γλώσσα", "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.", "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" "OR": "OR"
}, },
"INPUT_PLACEHOLDER": "Enter value", "INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": { "OPERATOR_LABELS": {
"equal_to": "Equal to", "equal_to": "Equal to",
"not_equal_to": "Not equal to", "not_equal_to": "Not equal to",
@@ -49,6 +51,7 @@
"ASSIGNEE_NAME": "Assignee name", "ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name", "INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name", "TEAM_NAME": "Team name",
"CONTACT": "Contact",
"CONVERSATION_IDENTIFIER": "Conversation identifier", "CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name", "CAMPAIGN_NAME": "Campaign name",
"LABELS": "Labels", "LABELS": "Labels",
@@ -233,6 +233,7 @@
"TIP_AUDIORECORDER_ERROR": "Could not open the audio", "TIP_AUDIORECORDER_ERROR": "Could not open the audio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Drag and drop here to attach", "DRAG_DROP": "Drag and drop here to attach",
"IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully",
"START_AUDIO_RECORDING": "Start audio recording", "START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording", "STOP_AUDIO_RECORDING": "Stop audio recording",
"COPILOT_THINKING": "Copilot is thinking", "COPILOT_THINKING": "Copilot is thinking",
@@ -700,9 +700,27 @@
"MAKE_DEFAULT": "Make default", "MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft", "MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale", "PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Delete" "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": { "ADD_LOCALE_DIALOG": {
"TITLE": "Add a new locale", "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.", "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" "OR": "O"
}, },
"INPUT_PLACEHOLDER": "Introducir valor", "INPUT_PLACEHOLDER": "Introducir valor",
"CONTACT_SEARCH_PLACEHOLDER": "Buscar contactos",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": { "OPERATOR_LABELS": {
"equal_to": "Igual a", "equal_to": "Igual a",
"not_equal_to": "No igual a", "not_equal_to": "No igual a",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Nombre Asignado", "ASSIGNEE_NAME": "Nombre Asignado",
"INBOX_NAME": "Nombre de la bandeja de entrada", "INBOX_NAME": "Nombre de la bandeja de entrada",
"TEAM_NAME": "Nombre del equipo", "TEAM_NAME": "Nombre del equipo",
"CONTACT": "Contacto",
"CONVERSATION_IDENTIFIER": "Identificador de conversacion", "CONVERSATION_IDENTIFIER": "Identificador de conversacion",
"CAMPAIGN_NAME": "Nombre de Campaña", "CAMPAIGN_NAME": "Nombre de Campaña",
"LABELS": "Etiquetas", "LABELS": "Etiquetas",
@@ -20,8 +20,8 @@
"CALL": "Llamar", "CALL": "Llamar",
"CALL_INITIATED": "Llamando al contacto…", "CALL_INITIATED": "Llamando al contacto…",
"CALL_FAILED": "No se puede iniciar la llamada. Por favor, inténtelo de nuevo.", "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_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"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_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"CLICK_TO_EDIT": "Click to edit", "CLICK_TO_EDIT": "Click to edit",
"VOICE_INBOX_PICKER": { "VOICE_INBOX_PICKER": {
"TITLE": "Seleccionar un buzón de entrada" "TITLE": "Seleccionar un buzón de entrada"
@@ -585,11 +585,11 @@
}, },
"CONTACTS_BULK_ACTIONS": { "CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Asignar etiquetas", "ASSIGN_LABELS": "Asignar etiquetas",
"REMOVE_LABELS": "Quitar etiquetas", "REMOVE_LABELS": "Remove Labels",
"ASSIGN_LABELS_SUCCESS": "Etiquetas asignadas correctamente.", "ASSIGN_LABELS_SUCCESS": "Etiquetas asignadas correctamente.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels", "ASSIGN_LABELS_FAILED": "Failed to assign labels",
"REMOVE_LABELS_SUCCESS": "Etiquetas removidas con éxito.", "REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
"REMOVE_LABELS_FAILED": "Error al eliminar las etiquetas", "REMOVE_LABELS_FAILED": "Failed to remove labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.", "DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.", "NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected", "SELECTED_COUNT": "{count} selected",
@@ -62,7 +62,7 @@
"UNSUPPORTED_MESSAGE_FACEBOOK": "Este mensaje no es compatible. Puedes ver este mensaje en la aplicación de Facebook Messenger.", "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_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_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", "SUCCESS_DELETE_MESSAGE": "Mensaje eliminado correctamente",
"FAIL_DELETE_MESSSAGE": "¡No se pudo eliminar el mensaje! Inténtalo de nuevo", "FAIL_DELETE_MESSSAGE": "¡No se pudo eliminar el mensaje! Inténtalo de nuevo",
"NO_RESPONSE": "No hay respuesta", "NO_RESPONSE": "No hay respuesta",
@@ -73,7 +73,7 @@
"CARD": { "CARD": {
"SHOW_LABELS": "Mostrar etiquetas", "SHOW_LABELS": "Mostrar etiquetas",
"HIDE_LABELS": "Ocultar etiquetas", "HIDE_LABELS": "Ocultar etiquetas",
"LABELS_COUNT": "{count} etiquetas" "LABELS_COUNT": "{count} labels"
}, },
"VOICE_CALL": { "VOICE_CALL": {
"INCOMING_CALL": "Incoming call", "INCOMING_CALL": "Incoming call",
@@ -81,21 +81,21 @@
"CALL_IN_PROGRESS": "Call in progress", "CALL_IN_PROGRESS": "Call in progress",
"NO_ANSWER": "No answer", "NO_ANSWER": "No answer",
"NO_ANSWER_OUTBOUND_LABEL": "No answer", "NO_ANSWER_OUTBOUND_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": "Missed call",
"MISSED_CALL_INBOUND_SUBTEXT": "Ningún agente escogido", "MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
"MISSED_CALL_DECLINED_BY": "Denegado por {agentName}", "MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
"CALL_ENDED": "Call ended", "CALL_ENDED": "Call ended",
"HANDLED_BY": "Manejado por {agentName}", "HANDLED_BY": "Handled by {agentName}",
"NOT_ANSWERED_YET": "Not answered yet", "NOT_ANSWERED_YET": "Not answered yet",
"CALLING": "Llamando…", "CALLING": "Calling…",
"THEY_ANSWERED": "They answered", "THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered", "YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} respondió", "AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Unirse a la llamada", "JOIN_CALL": "Unirse a la llamada",
"CALL_BACK": "Llamar de vuelta", "CALL_BACK": "Call back",
"TRANSCRIPT_SHOW_MORE": "Mostrar más", "TRANSCRIPT_SHOW_MORE": "Show more",
"TRANSCRIPT_SHOW_LESS": "Mostrar menos" "TRANSCRIPT_SHOW_LESS": "Show less"
}, },
"HEADER": { "HEADER": {
"RESOLVE_ACTION": "Resolver", "RESOLVE_ACTION": "Resolver",
@@ -105,17 +105,17 @@
"OPEN": "Más", "OPEN": "Más",
"CLOSE": "Cerrar", "CLOSE": "Cerrar",
"DETAILS": "detalles", "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": "Posponer hasta",
"SNOOZED_UNTIL_TOMORROW": "Pospuesto hasta mañana", "SNOOZED_UNTIL_TOMORROW": "Pospuesto hasta mañana",
"SNOOZED_UNTIL_NEXT_WEEK": "Pospuesto hasta la próxima semana", "SNOOZED_UNTIL_NEXT_WEEK": "Pospuesto hasta la próxima semana",
"SNOOZED_UNTIL_NEXT_REPLY": "Posponer hasta la siguiente respuesta", "SNOOZED_UNTIL_NEXT_REPLY": "Posponer hasta la siguiente respuesta",
"WHATSAPP_CALL": "Iniciar llamada de WhatsApp", "WHATSAPP_CALL": "Start WhatsApp call",
"WHATSAPP_CALL_FAILED": "No se pudo iniciar la llamada de WhatsApp.", "WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
"VOICE_CALL": "Iniciar llamada", "VOICE_CALL": "Start call",
"VOICE_CALL_FAILED": "No se pudo iniciar la llamada.", "VOICE_CALL_FAILED": "Could not start the call.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Solicitud de permiso de llamada al contacto enviada. Inténtalo de nuevo una vez que acepten.", "WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"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_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"SLA_STATUS": { "SLA_STATUS": {
"FRT": "FRT {status}", "FRT": "FRT {status}",
"NRT": "NRT {status}", "NRT": "NRT {status}",
@@ -231,9 +231,8 @@
"TIP_AUDIORECORDER_ICON": "Grabar audio", "TIP_AUDIORECORDER_ICON": "Grabar audio",
"TIP_AUDIORECORDER_PERMISSION": "Permitir el acceso a audio", "TIP_AUDIORECORDER_PERMISSION": "Permitir el acceso a audio",
"TIP_AUDIORECORDER_ERROR": "No se pudo abrir el 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", "DRAG_DROP": "Arrastra y suelta aquí para adjuntar",
"IMAGE_UPLOAD_SUCCESS": "Imagen subida con éxito",
"START_AUDIO_RECORDING": "Iniciar grabación de audio", "START_AUDIO_RECORDING": "Iniciar grabación de audio",
"STOP_AUDIO_RECORDING": "Detener grabación de audio", "STOP_AUDIO_RECORDING": "Detener grabación de audio",
"COPILOT_THINKING": "Copilot está pensando", "COPILOT_THINKING": "Copilot está pensando",
@@ -320,10 +319,10 @@
"DISMISS_CALL": "Descartar", "DISMISS_CALL": "Descartar",
"JOIN_CALL": "Unirse a la llamada", "JOIN_CALL": "Unirse a la llamada",
"END_CALL": "Terminar llamada", "END_CALL": "Terminar llamada",
"MUTE": "Silenciar micrófono", "MUTE": "Mute mic",
"UNMUTE": "Reactivar micrófono", "UNMUTE": "Unmute mic",
"VIEW_CHAT_HISTORY": "Ver historial de chat", "VIEW_CHAT_HISTORY": "View chat history",
"GO_TO_CONVERSATION": "Ir al hilo de conversación" "GO_TO_CONVERSATION": "Go to conversation thread"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {
@@ -390,18 +389,18 @@
"MACROS": "Macros", "MACROS": "Macros",
"LINEAR_ISSUES": "Problemas lineales vinculados", "LINEAR_ISSUES": "Problemas lineales vinculados",
"SHOPIFY_ORDERS": "Shopify Orders", "SHOPIFY_ORDERS": "Shopify Orders",
"SHARED_FILES": "Adjuntos" "SHARED_FILES": "Attachments"
}, },
"SHARED_FILES": { "SHARED_FILES": {
"EMPTY": "No hay archivos adjuntos aún", "EMPTY": "No attachments yet",
"DOWNLOAD": "Descargar archivo", "DOWNLOAD": "Download file",
"DOWNLOAD_ERROR": "No se pudo descargar el archivo. Por favor, inténtelo de nuevo.", "DOWNLOAD_ERROR": "Could not download the file. Please try again.",
"MEDIA_HEADING": "Media", "MEDIA_HEADING": "Media",
"FILES_HEADING": "Archivos", "FILES_HEADING": "Files",
"VIEW_ALL": "Ver todo", "VIEW_ALL": "Ver todo",
"SHOW_LESS": "Mostrar menos", "SHOW_LESS": "Show less",
"MORE_COUNT": "+{count}", "MORE_COUNT": "+{count}",
"UNTITLED_FILE": "Archivo sin título" "UNTITLED_FILE": "Untitled file"
}, },
"SHOPIFY": { "SHOPIFY": {
"ORDER_ID": "Order #{id}", "ORDER_ID": "Order #{id}",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default", "MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft", "MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale", "PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Eliminar" "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": { "ADD_LOCALE_DIALOG": {
"TITLE": "Añadir un nuevo idioma", "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.", "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", "AUTH_PROCESSING": "Authenticating with Meta",
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...", "WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
"PROCESSING": "Setting up your WhatsApp Business Account", "PROCESSING": "Setting up your WhatsApp Business Account",
"ENABLING_CALLING": "Activando llamadas de WhatsApp en tu número…", "ENABLING_CALLING": "Enabling WhatsApp Calling on your number…",
"LOADING_SDK": "Loading Facebook SDK...", "LOADING_SDK": "Loading Facebook SDK...",
"CANCELLED": "WhatsApp Signup was cancelled", "CANCELLED": "WhatsApp Signup was cancelled",
"SUCCESS_TITLE": "WhatsApp Business Account Connected!", "SUCCESS_TITLE": "WhatsApp Business Account Connected!",
@@ -319,7 +319,7 @@
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured", "SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if you’re a tech provider onboarding your own number, please use the {link} flow", "MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if you’re a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow", "MANUAL_LINK_TEXT": "manual setup flow",
"CALLING_ENABLE_FAILED": "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": { "API": {
"ERROR_MESSAGE": "No pudimos guardar el canal de WhatsApp" "ERROR_MESSAGE": "No pudimos guardar el canal de WhatsApp"
@@ -468,8 +468,8 @@
"DESCRIPTION": "Support your customers on WhatsApp" "DESCRIPTION": "Support your customers on WhatsApp"
}, },
"WHATSAPP_CALL": { "WHATSAPP_CALL": {
"TITLE": "Llamada de WhatsApp", "TITLE": "WhatsApp Call",
"DESCRIPTION": "Responde llamadas de voz en tu número de WhatsApp" "DESCRIPTION": "Take voice calls on your WhatsApp number"
}, },
"EMAIL": { "EMAIL": {
"TITLE": "E-mail", "TITLE": "E-mail",
@@ -644,29 +644,29 @@
"ACCOUNT_HEALTH": "Account Health", "ACCOUNT_HEALTH": "Account Health",
"CSAT": "Encuestas de Satisfacción", "CSAT": "Encuestas de Satisfacción",
"VOICE": "Voice", "VOICE": "Voice",
"CALLS": "Llamadas" "CALLS": "Calls"
}, },
"VOICE_CONFIGURATION": { "VOICE_CONFIGURATION": {
"ENABLE_VOICE": { "ENABLE_VOICE": {
"LABEL": "Habilitar llamadas de voz", "LABEL": "Enable Voice Calling",
"DESCRIPTION": "Activar llamadas de voz en esta bandeja de entrada. Los agentes podrán hacer y recibir llamadas telefónicas." "DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
}, },
"CREDENTIALS": { "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": { "WHATSAPP_CALLING": {
"ENABLE": { "ENABLE": {
"LABEL": "Habilitar llamadas de WhatsApp", "LABEL": "Enable WhatsApp Calling",
"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." "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úmero — aú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": { "PHONE_NUMBER": {
"LABEL": "Número de teléfono empresarial", "LABEL": "Business phone number",
"HELP_TEXT": "Número de WhatsApp al que llamarán los clientes." "HELP_TEXT": "WhatsApp number that customers will call."
}, },
"HOW_IT_WORKS": { "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." "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": { "PERMISSION_REQUEST_BODY": {
@@ -763,38 +763,38 @@
}, },
"FILTERS": { "FILTERS": {
"SOURCE": { "SOURCE": {
"ALL": "Todas las fuentes", "ALL": "All sources",
"WEB": "Páginas web", "WEB": "Web pages",
"PDF": "PDFs" "PDF": "PDFs"
}, },
"STATUS": { "STATUS": {
"ANY": "Cualquier estado", "ANY": "Any status",
"UPDATED": "Actualizado", "UPDATED": "Updated",
"NEEDS_UPDATE": "Necesita actualización", "NEEDS_UPDATE": "Needs update",
"UPDATING": "Actualizando", "UPDATING": "Updating",
"FAILED": "Failed" "FAILED": "Failed"
}, },
"SORT": { "SORT": {
"RECENTLY_UPDATED": "Actualizado recientemente", "RECENTLY_UPDATED": "Recently updated",
"RECENTLY_CREATED": "Creado recientemente" "RECENTLY_CREATED": "Recently created"
}, },
"SEARCH_PLACEHOLDER": "Buscar..." "SEARCH_PLACEHOLDER": "Buscar..."
}, },
"SYNC_STATUS": { "SYNC_STATUS": {
"SYNCED": "última actualización {time}", "SYNCED": "last updated {time}",
"SYNCING": "actualizando...", "SYNCING": "actualizando...",
"STALE_SYNC": "update stalled", "STALE_SYNC": "update stalled",
"FAILED": "Fallo al sincronizar", "FAILED": "Failed to sync",
"NEVER_SYNCED": "no actualizado aún" "NEVER_SYNCED": "not updated yet"
}, },
"SYNC_ERRORS": { "SYNC_ERRORS": {
"NOT_FOUND": "Página no encontrada", "NOT_FOUND": "Página no encontrada",
"ACCESS_DENIED": "Acceso denegado", "ACCESS_DENIED": "Access denied",
"TIMEOUT": "La página tardó demasiado en responder", "TIMEOUT": "Page took too long to respond",
"CONTENT_EMPTY": "La página devolvió contenido vacío", "CONTENT_EMPTY": "Page returned empty content",
"FETCH_FAILED": "No se pudo obtener la página", "FETCH_FAILED": "Could not fetch page",
"SYNC_ERROR": "Error inesperado", "SYNC_ERROR": "Unexpected error",
"DEFAULT": "Error de sincronización" "DEFAULT": "Sync error"
}, },
"RELATED_RESPONSES": { "RELATED_RESPONSES": {
"TITLE": "Related FAQs", "TITLE": "Related FAQs",
@@ -839,14 +839,14 @@
}, },
"OPTIONS": { "OPTIONS": {
"VIEW_RELATED_RESPONSES": "View Related Responses", "VIEW_RELATED_RESPONSES": "View Related Responses",
"SYNC_NOW": "Refrescar ahora", "SYNC_NOW": "Refresh now",
"RETRY_SYNC": "Reintentar refresco", "RETRY_SYNC": "Retry refresh",
"DELETE_DOCUMENT": "Delete Document" "DELETE_DOCUMENT": "Delete Document"
}, },
"EMPTY_STATE": { "EMPTY_STATE": {
"TITLE": "No documents available", "TITLE": "No documents available",
"SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant.", "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.", "FILTERED_SUBTITLE": "Try changing the source, status, or search term.",
"FEATURE_SPOTLIGHT": { "FEATURE_SPOTLIGHT": {
"TITLE": "Captain Document", "TITLE": "Captain Document",
@@ -24,11 +24,11 @@
"SELECT_REFERRAL_SOURCE": "Select source" "SELECT_REFERRAL_SOURCE": "Select source"
}, },
"EMAIL_VERIFIED": "Email verified", "EMAIL_VERIFIED": "Email verified",
"SETTING_UP": "Configurando tu cuenta...", "SETTING_UP": "Setting up your account...",
"CONTINUE": "Continue", "CONTINUE": "Continue",
"SAVING": "Guardando...", "SAVING": "Guardando...",
"VALIDATION_ERROR": "Por favor, rellene todos los campos requeridos", "VALIDATION_ERROR": "Please fill in all required fields",
"SUCCESS": "Detalles guardados correctamente", "SUCCESS": "Details saved successfully",
"ERROR": "No se pudo guardar los detalles. Por favor, inténtelo de nuevo." "ERROR": "Could not save details. Please try again."
} }
} }
@@ -19,8 +19,6 @@
"OR": "OR" "OR": "OR"
}, },
"INPUT_PLACEHOLDER": "Enter value", "INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Otsi kontakte",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": { "OPERATOR_LABELS": {
"equal_to": "Equal to", "equal_to": "Equal to",
"not_equal_to": "Not equal to", "not_equal_to": "Not equal to",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Assignee name", "ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name", "INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name", "TEAM_NAME": "Team name",
"CONTACT": "Kontakt",
"CONVERSATION_IDENTIFIER": "Conversation identifier", "CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name", "CAMPAIGN_NAME": "Campaign name",
"LABELS": "Labels", "LABELS": "Labels",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Heli avamine ebaõnnestus", "TIP_AUDIORECORDER_ERROR": "Heli avamine ebaõnnestus",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Lohista siia manusena lisamiseks", "DRAG_DROP": "Lohista siia manusena lisamiseks",
"IMAGE_UPLOAD_SUCCESS": "Pilt edukalt üles laaditud",
"START_AUDIO_RECORDING": "Alusta heli salvestamist", "START_AUDIO_RECORDING": "Alusta heli salvestamist",
"STOP_AUDIO_RECORDING": "Peata heli salvestamine", "STOP_AUDIO_RECORDING": "Peata heli salvestamine",
"COPILOT_THINKING": "Copilot mõtleb", "COPILOT_THINKING": "Copilot mõtleb",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Määra vaikimisi", "MAKE_DEFAULT": "Määra vaikimisi",
"MOVE_TO_DRAFT": "Move to draft", "MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale", "PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Kustuta" "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": { "ADD_LOCALE_DIALOG": {
"TITLE": "Lisa uus keel", "TITLE": "Lisa uus keel",
"DESCRIPTION": "Vali keel, milles see artikkel kirjutatakse. See lisatakse sinu tõlkelisti ja hiljem saad lisada veel.", "DESCRIPTION": "Vali keel, milles see artikkel kirjutatakse. See lisatakse sinu tõlkelisti ja hiljem saad lisada veel.",
@@ -19,8 +19,6 @@
"OR": "یا" "OR": "یا"
}, },
"INPUT_PLACEHOLDER": "Enter value", "INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "جستجوی مخاطبین",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": { "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": "صدا را نمی‌توان باز کند", "TIP_AUDIORECORDER_ERROR": "صدا را نمی‌توان باز کند",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "برای ضمیمه کردن درگ و درآپ کنید", "DRAG_DROP": "برای ضمیمه کردن درگ و درآپ کنید",
"IMAGE_UPLOAD_SUCCESS": "تصویر با موفقیت آپلود شد",
"START_AUDIO_RECORDING": "در حال شروع ضبط صدا", "START_AUDIO_RECORDING": "در حال شروع ضبط صدا",
"STOP_AUDIO_RECORDING": "در حال توقف ضبط صدا", "STOP_AUDIO_RECORDING": "در حال توقف ضبط صدا",
"COPILOT_THINKING": "Copilot در حال فکر کردن است", "COPILOT_THINKING": "Copilot در حال فکر کردن است",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default", "MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft", "MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale", "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": "نام"
},
"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": { "ADD_LOCALE_DIALOG": {
"TITLE": "یک محل جدید اضافه کنید", "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.", "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" "OR": "OR"
}, },
"INPUT_PLACEHOLDER": "Enter value", "INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": { "OPERATOR_LABELS": {
"equal_to": "Equal to", "equal_to": "Equal to",
"not_equal_to": "Not equal to", "not_equal_to": "Not equal to",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Assignee name", "ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name", "INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name", "TEAM_NAME": "Team name",
"CONTACT": "Contact",
"CONVERSATION_IDENTIFIER": "Conversation identifier", "CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name", "CAMPAIGN_NAME": "Campaign name",
"LABELS": "Tunnisteet", "LABELS": "Tunnisteet",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Could not open the audio", "TIP_AUDIORECORDER_ERROR": "Could not open the audio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Drag and drop here to attach", "DRAG_DROP": "Drag and drop here to attach",
"IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully",
"START_AUDIO_RECORDING": "Start audio recording", "START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording", "STOP_AUDIO_RECORDING": "Stop audio recording",
"COPILOT_THINKING": "Copilot ajattelee", "COPILOT_THINKING": "Copilot ajattelee",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default", "MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft", "MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale", "PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Poista" "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": { "ADD_LOCALE_DIALOG": {
"TITLE": "Add a new locale", "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.", "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" "OR": "OU"
}, },
"INPUT_PLACEHOLDER": "Enter value", "INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": { "OPERATOR_LABELS": {
"equal_to": "Égal à", "equal_to": "Égal à",
"not_equal_to": "Pas égal à", "not_equal_to": "Pas égal à",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Nom du responsable", "ASSIGNEE_NAME": "Nom du responsable",
"INBOX_NAME": "Nom de la boîte de réception", "INBOX_NAME": "Nom de la boîte de réception",
"TEAM_NAME": "Nom de l'équipe", "TEAM_NAME": "Nom de l'équipe",
"CONTACT": "Contact",
"CONVERSATION_IDENTIFIER": "Identifiant de la conversation", "CONVERSATION_IDENTIFIER": "Identifiant de la conversation",
"CAMPAIGN_NAME": "Nom de la campagne", "CAMPAIGN_NAME": "Nom de la campagne",
"LABELS": "Étiquettes", "LABELS": "Étiquettes",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Impossible d'ouvrir l'audio", "TIP_AUDIORECORDER_ERROR": "Impossible d'ouvrir l'audio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Glissez et déposez ici pour lier", "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", "START_AUDIO_RECORDING": "Démarrer l'enregistrement audio",
"STOP_AUDIO_RECORDING": "Arrêter l'enregistrement audio", "STOP_AUDIO_RECORDING": "Arrêter l'enregistrement audio",
"COPILOT_THINKING": "Copilot réfléchit", "COPILOT_THINKING": "Copilot réfléchit",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default", "MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft", "MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale", "PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Supprimer" "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": { "ADD_LOCALE_DIALOG": {
"TITLE": "Ajouter une nouvelle langue", "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.", "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": "הכנס ערך", "INPUT_PLACEHOLDER": "הכנס ערך",
"CONTACT_SEARCH_PLACEHOLDER": "חפש אנשי קשר",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": { "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": "לא יכול לפתוח אודיו", "TIP_AUDIORECORDER_ERROR": "לא יכול לפתוח אודיו",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "גרור ושחרר כאן להוספת קובץ מצורף", "DRAG_DROP": "גרור ושחרר כאן להוספת קובץ מצורף",
"IMAGE_UPLOAD_SUCCESS": "התמונה הועלתה בהצלחה",
"START_AUDIO_RECORDING": "התחל הקלטת אודיו", "START_AUDIO_RECORDING": "התחל הקלטת אודיו",
"STOP_AUDIO_RECORDING": "עצור הקלטת אודיו", "STOP_AUDIO_RECORDING": "עצור הקלטת אודיו",
"COPILOT_THINKING": "Copilot חושב", "COPILOT_THINKING": "Copilot חושב",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "הפוך לברירת מחדל", "MAKE_DEFAULT": "הפוך לברירת מחדל",
"MOVE_TO_DRAFT": "Move to draft", "MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale", "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": "שם"
},
"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": { "ADD_LOCALE_DIALOG": {
"TITLE": "הוסף מקום חדש", "TITLE": "הוסף מקום חדש",
"DESCRIPTION": "בחר את השפה שבה מאמר זה ייכתב. זו תתווסף לרשימת התרגומים שלך, ותוכל להוסיף נוספים מאוחר יותר.", "DESCRIPTION": "בחר את השפה שבה מאמר זה ייכתב. זו תתווסף לרשימת התרגומים שלך, ותוכל להוסיף נוספים מאוחר יותר.",

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