Compare commits

..
Author SHA1 Message Date
Tanmay Deep Sharma abac8268ec fix(reply-box): block empty sends during async audio attachment upload 2026-05-15 17:16:44 +05:30
1528fcde0c fix: missing widget es translations (#14375)
# Pull Request Template

## Description

There were English strings in the Spanish i18n file for the widget. This
PR translates them.

## 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?


## 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
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-15 12:16:49 +05:30
5f6bd951b9 fix: portals#create returns 500 when custom_domain is omitted (#14400)
## Description

`POST /api/v1/accounts/:account_id/portals` returns a generic 500
(`{"status":500,"error":"Internal Server Error"}`) whenever the request
body omits `custom_domain`. Root cause: `parsed_custom_domain` calls
`URI.parse(@portal.custom_domain)` and `URI.parse(nil)` raises
`URI::InvalidURIError`. Existing callers either had to know to pass
`"custom_domain": ""` as a workaround or hit a 500 with no useful
diagnostic.

This PR guards `parsed_custom_domain` against blank values so the
existing fall-through (`else @portal.custom_domain`) applies —
equivalent to passing an empty string.

It also moves the `process_attached_logo` guard from the helper into the
`create` call site so `create` mirrors `update` (`process_attached_logo
if params[:blob_id].present?`) and avoids an unnecessary signed-blob
lookup on every create that doesn't include a logo.

Fixes #14397

## Type of change

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

## How Has This Been Tested?

Two new request specs in
`spec/controllers/api/v1/accounts/portals_controller_spec.rb` covering
the regression:

- `creates portal when custom_domain is omitted from request body` — the
previously-broken case, now returns 200.
- `creates portal when custom_domain is blank` — verifies the existing
workaround (`"custom_domain": ""`) still works after the change.

Manually verified against `chatwoot/chatwoot:latest` Docker image before
the fix (500) and against this branch (200) using the curl repro from
the issue.

```bash
curl -X POST "https://<host>/api/v1/accounts/<account_id>/portals" \
  -H "Content-Type: application/json" \
  -H "api_access_token: <token>" \
  -d '{"name":"Test Portal","slug":"test-portal","color":"#3b82f6"}'
```

Before: `{"status":500,"error":"Internal Server Error"}`
After: `200 OK` with the portal payload.

## 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 (no doc
change needed — controller behaviour, fully backward-compatible)
- [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

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-15 11:11:18 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Pranav
cfc7699b7e chore(deps): bump net-imap from 0.4.20 to 0.4.24 (#14361)
Bumps [net-imap](https://github.com/ruby/net-imap) from 0.4.20 to
0.4.24.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/ruby/net-imap/releases">net-imap's
releases</a>.</em></p>
<blockquote>
<h2>v0.4.24</h2>
<blockquote>
<p>[!IMPORTANT]
<em>The <code>0.4.x</code> release branch will only receive critical
security fixes, and will be unsupported when ruby 3.3 is EOL.
Please upgrade to a newer version.</em></p>
</blockquote>
<h2>What's Changed</h2>
<h3>🔒 Security</h3>
<p>This release contains fixes for <strong>multiple
vulnerabilities</strong> concerning <em><strong><code>STARTTLS</code>
stripping</strong></em>, argument validation, and denial of service
attacks.</p>
<blockquote>
<p>[!WARNING]
<a
href="https://redirect.github.com/ruby/net-imap/pull/666">ruby/net-imap#666</a>
fixes a <code>STARTTLS</code> stripping vulnerability
(GHSA-vcgp-9326-pqcp).
Without this fix, a man-in-the-middle attacker can cause
<code>Net::IMAP#starttls</code> to return &quot;successfully&quot;,
<strong><em>without starting TLS</em></strong>.</p>
</blockquote>
<blockquote>
<p>[!IMPORTANT]
Argument validation is significantly improved. Several injection
vulnerabilities have been fixed:
<a
href="https://redirect.github.com/ruby/net-imap/pull/663">ruby/net-imap#663</a>
fixes CRLF/command/argument injection via Symbol arguments
(GHSA-75xq-5h9v-w6px).
<a
href="https://redirect.github.com/ruby/net-imap/pull/663">ruby/net-imap#663</a>
fixes CRLF/command/argument injection via the <code>attr</code> argument
to <code>#store</code>/<code>#uid_store</code> (GHSA-hm49-wcqc-g2xg)
<a
href="https://redirect.github.com/ruby/net-imap/pull/663">ruby/net-imap#663</a>
fixes CRLF/command/argument injection via the <code>storage_limit</code>
argument to <code>#setquota</code> (GHSA-hm49-wcqc-g2xg).
<a
href="https://redirect.github.com/ruby/net-imap/pull/663">ruby/net-imap#663</a>
fixes CRLF/command injection via <code>RawData</code>
(GHSA-hm49-wcqc-g2xg):</p>
<ul>
<li><code>#search</code> and <code>#uid_search</code> send
<code>criteria</code> as raw data, when it is a String</li>
<li><code>#fetch</code> and <code>#uid_fetch</code> send
<code>attr</code> as raw data, when it is a String.
When <code>attr</code> is an Array, its String members are sent as raw
data.</li>
</ul>
</blockquote>
<blockquote>
<p>[!CAUTION]
<code>RawData</code> does not defend against <em>other</em> forms of
argument injection! It is an intentionally low-level API.</p>
</blockquote>
<blockquote>
<p>[!NOTE]
Two denial of service vulnerabilities have been addressed.
These are generally only relevant when connecting to an <em>untrusted
hostile server</em> (or without TLS).</p>
<p><a
href="https://redirect.github.com/ruby/net-imap/pull/651">ruby/net-imap#651</a>
fixes quadratic time complexity when reading large responses containing
many string literals (GHSA-q2mw-fvj9-vvcw).
<a
href="https://redirect.github.com/ruby/net-imap/pull/655">ruby/net-imap#655</a>
adds a configurable <code>max_iterations</code> count for
<code>SCRAM-*</code> authentication (GHSA-87pf-fpwv-p7m7).</p>
<p>The default <code>ScramAuthenticator#max_iterations</code> is
<code>2**31 - 1</code> (max 32-bit signed int), which was already
OpenSSL's maximum value. <em>It provides no protection</em> against
hostile servers unless it is explicitly set to a lower value by the
user.</p>
</blockquote>
<h3>Added</h3>
<ul>
<li>🔒 Add <code>ScramAuthenticator#max_iterations</code> (backports <a
href="https://redirect.github.com/ruby/net-imap/issues/654">#654</a>) in
<a
href="https://redirect.github.com/ruby/net-imap/pull/655">ruby/net-imap#655</a>,
reported by <a
href="https://github.com/Masamuneee"><code>@​Masamuneee</code></a></li>
</ul>
<h3>Fixed</h3>
<ul>
<li>🔒 Fix STARTTLS stripping vulnerability (backports <a
href="https://redirect.github.com/ruby/net-imap/issues/664">#664</a>) in
<a
href="https://redirect.github.com/ruby/net-imap/pull/666">ruby/net-imap#666</a>,
reported by <a
href="https://github.com/Masamuneee"><code>@​Masamuneee</code></a></li>
<li>🔒 Fix CRLF injection vulnerabilities (backports <a
href="https://redirect.github.com/ruby/net-imap/issues/657">#657</a>, <a
href="https://redirect.github.com/ruby/net-imap/issues/658">#658</a>, <a
href="https://redirect.github.com/ruby/net-imap/issues/659">#659</a>, <a
href="https://redirect.github.com/ruby/net-imap/issues/660">#660</a>, <a
href="https://redirect.github.com/ruby/net-imap/issues/636">#636</a>, <a
href="https://redirect.github.com/ruby/net-imap/issues/661">#661</a>) in
<a
href="https://redirect.github.com/ruby/net-imap/pull/663">ruby/net-imap#663</a>,
reported by <a
href="https://github.com/manunio"><code>@​manunio</code></a></li>
<li>⚡ Much faster ResponseReader performance (backports <a
href="https://redirect.github.com/ruby/net-imap/issues/642">#642</a>) in
<a
href="https://redirect.github.com/ruby/net-imap/pull/651">ruby/net-imap#651</a>,
reported by <a
href="https://github.com/Masamuneee"><code>@​Masamuneee</code></a></li>
<li>🐛 Wait to continue RawData literals (backports <a
href="https://redirect.github.com/ruby/net-imap/issues/660">#660</a>) by
<a href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/663">ruby/net-imap#663</a></li>
</ul>
<h3>Other Changes</h3>
<ul>
<li>♻️ Improve internal literal sending (partially backports <a
href="https://redirect.github.com/ruby/net-imap/issues/358">#358</a>, <a
href="https://redirect.github.com/ruby/net-imap/issues/616">#616</a>, <a
href="https://redirect.github.com/ruby/net-imap/issues/649">#649</a>) by
<a href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/653">ruby/net-imap#653</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/ruby/net-imap/compare/v0.4.23...v0.4.24">https://github.com/ruby/net-imap/compare/v0.4.23...v0.4.24</a></p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/ruby/net-imap/commit/24a4e770b43230286a05aa2a9746cdbb3eb8485e"><code>24a4e77</code></a>
🔀 Merge pull request <a
href="https://redirect.github.com/ruby/net-imap/issues/666">#666</a>
from ruby/backport/v0.4/STARTTLS-stripping</li>
<li><a
href="https://github.com/ruby/net-imap/commit/63f53ffdefa3be6c779090bdbe6b7257b632b36c"><code>63f53ff</code></a>
🔖 Bump version to 0.4.24</li>
<li><a
href="https://github.com/ruby/net-imap/commit/038ae35d5ecbb2b85f77f4fa35e46604154dc8c4"><code>038ae35</code></a>
🍒 pick 24d5c773d: 🔒🥅 Handle tagged &quot;OK&quot; to incomplete command
[backport <a
href="https://redirect.github.com/ruby/net-imap/issues/664">#664</a>]</li>
<li><a
href="https://github.com/ruby/net-imap/commit/705aa59faa28083f496dce50c0ee1eccca287506"><code>705aa59</code></a>
🍒 pick 62eea6ffe: 🔒🥅 Ensure STARTTLS tagged response was handled
[backport <a
href="https://redirect.github.com/ruby/net-imap/issues/664">#664</a>]</li>
<li><a
href="https://github.com/ruby/net-imap/commit/c9a6f28f8794cb991613d378b3920e5719062e29"><code>c9a6f28</code></a>
🍒 pick 46636cae8: ❌🔒 Add failing test for STARTTLS stripping [backport
<a
href="https://redirect.github.com/ruby/net-imap/issues/664">#664</a>]</li>
<li><a
href="https://github.com/ruby/net-imap/commit/aec06996eb87a7e1bbcef1f9f8926e8add2b8c71"><code>aec0699</code></a>
🔀 Merge pull request <a
href="https://redirect.github.com/ruby/net-imap/issues/663">#663</a>
from ruby/backport/v0.4/raw_data-warnings</li>
<li><a
href="https://github.com/ruby/net-imap/commit/fd245ddd1e220143c9b7c7861700590fbe9d35ef"><code>fd245dd</code></a>
🍒 pick be32e712e: 📚 Improve documentation of RawData arguments
[backports <a
href="https://redirect.github.com/ruby/net-imap/issues/661">#661</a>]</li>
<li><a
href="https://github.com/ruby/net-imap/commit/6dd110bfda6b47da9c294791ef16911bd1f49033"><code>6dd110b</code></a>
🍒 pick 47c72186d: 🐛 Validate RawData and wait to continue literals
[backports...</li>
<li><a
href="https://github.com/ruby/net-imap/commit/4e93149e65f6f1a6225ef770d0cd129acb5d63a9"><code>4e93149</code></a>
🔀 Merge branch 'backport/v0.4/QUOTA-argument-validation' into
backport/v0.4/s...</li>
<li><a
href="https://github.com/ruby/net-imap/commit/d2b23602e8617311900a9b3acf0c8d708b70bad4"><code>d2b2360</code></a>
🍒 pick 0ec4fd351: 🥅 Validate <code>#setquota</code> storage limit
argument [backports <a
href="https://redirect.github.com/ruby/net-imap/issues/659">#659</a>]</li>
<li>Additional commits viewable in <a
href="https://github.com/ruby/net-imap/compare/v0.4.20...v0.4.24">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=net-imap&package-manager=bundler&previous-version=0.4.20&new-version=0.4.24)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2026-05-14 19:51:01 -07:00
dc332dd93e feat: add attachments endpoint for contact media view (#14391)
# Pull Request Template

## Description

This PR adds an endpoint to fetch all attachments shared with or by a
contact across all of their conversations.

Results are scoped based on the access:
* Admins can access all attachments
* Agents can access attachments only from inboxes they belong to
* Custom role agents are further filtered based on their conversation
permissions

Each attachment payload includes `conversation_id`, allowing the UI to
deep-link back to the source conversation.

Added `GET
/api/v1/accounts/:account_id/contacts/:contact_id/attachments` under the
existing contacts scope.

Fixes
https://linear.app/chatwoot/issue/CW-7021/add-media-view-to-the-contact-details-page

## 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
- [ ] 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: Sojan Jose <sojan@pepalo.com>
2026-05-14 21:34:39 +05:30
13f66e3a88 fix: incorrect scope across controllers (#14459)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-14 20:34:18 +05:30
fbcb89e955 fix(swagger): prevent path traversal in docs controller (#14458)
This hardens the development/test Swagger docs endpoint by ensuring
requested files are resolved only within the `swagger/` directory.

This did not affect production security because the Swagger controller
only renders files in development or test environments; production
already returns `404`. The change still closes the scanner finding and
prevents future automated reports from flagging the development-only
path.

## Closes

Addresses: GHSA-xhp7-ggjq-p2rg

## How to reproduce

1. Start Chatwoot locally in development.
2. Visit `/swagger/%2Fetc%2Fpasswd`.
3. Before this change, the endpoint could render files outside the
Swagger directory in development/test.

## What changed

- Resolve Swagger file requests relative to `Rails.root/swagger`.
- Return `404` when the resolved path is outside the Swagger directory
or does not point to a file.
- Strip leading slashes from derived request paths.
- Add a request spec for the encoded absolute-path case.

## How to test

1. Start the app locally.
2. Visit `/swagger` and confirm the ReDoc page loads.
3. Visit `/swagger/swagger.json` and confirm the Swagger JSON loads.
4. Visit `/swagger/%2Fetc%2Fpasswd` and confirm it returns `404` with no
file contents.

Note: `bundle exec rspec spec/controllers/swagger_controller_spec.rb`
was passing locally earlier during this fix. A final rerun before
opening the PR was blocked because local Postgres on `localhost:5432`
was not accepting connections.

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-05-14 18:52:14 +05:30
Shivam MishraandGitHub 8712879681 test: Stabilize SafeFetch spec against constant-identity flake (#14454)
`spec/lib/safe_fetch_spec.rb` has been flaking intermittently under
full-suite runs with errors like:

```
expected SafeFetch::FileTooLargeError, got #<SafeFetch::FileTooLargeError: exceeded 1048576 bytes>
```

The class name on both sides is identical — yet RSpec reports a
mismatch. This PR replaces the constant-identity assertions in this spec
with a name-based matcher so the comparison stops depending on the live
Class object's identity. We have made a similar fix earlier, but that
wasn't addressing the core of the issue in #14139.


**How to reproduce:** The flake only surfaces under load. Running the
spec in isolation almost always passes, here's a [run failing in
CI](https://github.com/chatwoot/chatwoot/actions/runs/25852294516/job/75961520248?pr=14370)

## What's actually going on

Three facts combine:

1. **Test env reloads classes.** `config/environments/test.rb` sets
`cache_classes = false` so Zeitwerk reloads autoloadable code on demand.
2. **`lib/` is on the reloadable autoload tree.**
`config/application.rb` adds `lib` to `eager_load_paths`, which (with
`eager_load = false` in test) makes it lazily loaded by Zeitwerk's
*main* (reloading) autoloader. `lib/safe_fetch/` lives under that
umbrella.
3. **RSpec's `raise_error(Klass)` snapshots the Class object.**
`raise_error` matcher captures `Klass` (a specific `Class` instance)
when the matcher is built. At raise time it compares with `Module#===`,
which is identity-based.

When the executor between examples triggers
`Rails.application.reloader.reload!`, Zeitwerk does
`remove_const(:SafeFetch)` and re-installs an autoload trigger. The next
access produces a **fresh** `Class` object for
`SafeFetch::FileTooLargeError` — same name, different identity. The
matcher's snapshot now points at the dead Class, and the live raise
produces the new one. Identity fails, even though the error is
semantically correct.

This bites SafeFetch specifically because:

- SafeFetch is a *namespace* with 7 nested error classes — one reload
invalidates all of them.
- The spec contains 14+ `raise_error(SafeFetch::Foo)` assertions — many
chances to land in a reload window.
- SafeFetch is exercised by request-driven code (webhook delivery,
avatar fetch). Earlier specs in the suite warm up the reloader
machinery, which then fires during this spec.

Other custom-error specs don't visibly flake because their consumers
`rescue ConstName => e` (dynamic class lookup at raise time, walks the
ancestor chain via `Module#===` *at the moment of raise*, with no
captured snapshot), rather than RSpec's snapshot-then-compare pattern.

## What this PR does

Adds a small local helper to the spec that matches by class name string,
not by Class identity:

```ruby
def safe_fetch_error(name, message_pattern = nil)
  satisfy("raise SafeFetch::#{name}#{" matching #{message_pattern.inspect}" if message_pattern}") do |error|
    error.class.name == "SafeFetch::#{name}" &&
      (message_pattern.nil? || message_pattern.match?(error.message))
  end
end
```

Every `raise_error(described_class::FooError)` becomes
`raise_error(safe_fetch_error('FooError'))`. Class names are strings;
string equality survives any number of reloads. The semantic assertion
("this raised the right kind of error") is preserved.

This is the pattern CLAUDE.md already endorses for this codebase:

> Specs in parallel/reloading environments: prefer comparing
`error.class.name` over constant class equality when asserting raised
errors.

## Why this approach (even though it's suboptimal)

To be honest with reviewers: **this is a workaround, not a root-cause
fix.** Future spec authors who use `raise_error(SafeFetch::Foo)` in
other files will hit the same flake. The "real" fix removes the failure
mode at the source rather than dodging it per-spec.

Here's the menu of options we considered and the tradeoffs:

### Option A — Spec-side name matcher (this PR)

- **What:** the helper above.
- **Pro:** one-file change, zero blast radius beyond the spec.
- **Pro:** matches CLAUDE.md's documented stance.
- **Con:** every new spec touching reloadable error classes needs to
remember this pattern. It's a discipline tax, not a structural fix.
- **Verdict:** chosen for this PR.

### Option B — Pin SafeFetch outside Zeitwerk

```ruby
# config/application.rb
Rails.autoloaders.main.ignore(
  Rails.root.join('lib/safe_fetch.rb'),
  Rails.root.join('lib/safe_fetch'),
)
require Rails.root.join('lib/safe_fetch')
```

- **Pro:** real root-cause fix. `SafeFetch::*` constants become
process-lifetime stable. The spec helper becomes unnecessary, every
assertion in any spec works correctly.
- **Pro:** preserves the public API exactly.
- **Con:** loses hot-reload for SafeFetch in dev (need server restart to
see edits).
- **Con:** modifies `application.rb`, which has cross-team review
weight.
- **Verdict:** rejected for scope reasons in this PR; a sensible
follow-up.

### Option C — Move error classes to a non-reloadable location

Define top-level error classes in
`config/initializers/safe_fetch_errors.rb`. Initializers run once at
boot, constants are never reloaded.

- **Pro:** identity-stable errors, no spec helper needed.
- **Con:** API change — `SafeFetch::FileTooLargeError` becomes
`SafeFetchFileTooLargeError` (or similar). Every consumer's `rescue`
clause has to update.
- **Con:** namespace pollution at top-level.
- **Verdict:** rejected. The constraint of touching initializers is what
motivated the simpler PR.

### Option D — Vendor SafeFetch as a path gem

Move `lib/safe_fetch/` → `vendor/gems/safe_fetch/` with a gemspec.
Bundler `require`s gems once; Zeitwerk has zero involvement.

- **Pro:** structurally the most correct fix. Same identity stability as
Option B, no Zeitwerk plumbing required.
- **Pro:** zero API change for consumers.
- **Con:** larger refactor (6+ files moved, gemspec authored,
Gemfile/Gemfile.lock updated).
- **Con:** SafeFetch becomes harder to iterate on in dev (gem-style
edit-then-restart loop).
- **Verdict:** rejected for scope in this PR; the cleanest long-term
home for a security primitive.

### Option E — Drop custom errors, return a `Result`

Refactor SafeFetch to yield a result hash (`{ ok: true, ... }` / `{ ok:
false, kind: :unsafe_url, ... }`) instead of raising for anticipated
outcomes. Failure kinds become symbols, which are interned for the
process lifetime.

- **Pro:** eliminates the failure mode at its true root — there are no
custom exception classes to reload, anywhere.
- **Pro:** forces explicit, exhaustive handling at every call site — a
feature for a security primitive.
- **Pro:** spec assertions become data assertions (`expect(result).to
match(ok: false, kind: :too_large)`), which are robust against any
reload, any reordering.
- **Con:** paradigm shift away from the exception-driven style used
everywhere else in the codebase.
- **Con:** every consumer rewrites their `rescue` block into
pattern-matched handling.
- **Verdict:** rejected for scope in this PR; the architecturally
cleanest answer if we ever revisit SafeFetch's API.

## Why we're shipping A despite knowing B–E are better

This flake has been chewing CI time intermittently and previously took a
partial fix (#14139). We need it stable *now*. Options B–E are real
refactors with broader review surface (`application.rb`, consumer code,
or the lib's public contract). Option A:

- Costs nothing — one helper, mechanical replacements.
- Doesn't preclude any of B/C/D/E later. The helper goes away cleanly
once a root-cause fix lands.
- Aligns with the project's documented guidance for this scenario.

When SafeFetch next gets a substantive change, that's the right moment
to fold in B or D. Until then, the spec is stable and CI gets its time
back.

## What changed

- `spec/lib/safe_fetch_spec.rb`: added `safe_fetch_error(name,
message_pattern = nil)` helper; converted all 14
`raise_error(described_class::FooError[, /regex/])` assertions to
`raise_error(safe_fetch_error('FooError'[, /regex/]))`.
2026-05-14 16:48:14 +05:30
ffbf40c720 fix: harden Active Storage direct uploads and proxy streaming (#14440)
Hardens Active Storage handling on Rails 7.1 by filtering internal
direct-upload metadata keys and limiting proxy range requests, while
keeping audio playback on redirect URLs so large recordings are not
routed through the proxy limiter.

Closes
- CVE-2026-33173
- CVE-2026-33174
- CVE-2026-33658

Why
Rails 7.1 does not currently have patched releases for these Active
Storage advisories, and Chatwoot exposes Active Storage direct-upload
endpoints and media URLs. This keeps the Rails dependency unchanged
while adding small local mitigations until Rails can be upgraded to
7.2.3.1+.

What changed
- Filters `identified`, `analyzed`, and `composed` from direct-upload
blob metadata.
- Limits Active Storage proxy range requests to one range under 100 MB.
- Uses redirect URLs for inline audio attachments so normal playback of
large recordings avoids the proxy streaming path.
- Adds scoped bundle-audit ignores for the locally mitigated Active
Storage advisories and the remaining Rails advisories that are not
reachable through current Chatwoot usage.

How to test
- Upload an attachment from the dashboard reply composer and confirm it
sends successfully.
- Upload an attachment from the website widget and confirm it appears in
the conversation.
- POST a direct-upload request with `blob.metadata.identified`,
`blob.metadata.analyzed`, and `blob.metadata.composed`; confirm those
keys are not persisted while custom metadata remains.
- Play an audio/call-recording attachment and confirm the audio URL
loads through Active Storage redirect rather than proxy.
- Run `bundle exec bundle audit check -v`.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-05-14 14:50:29 +05:30
dd7f5c27e5 perf: eliminate N+1 queries on inboxes#index (#14451)
The `inboxes#index` API endpoint was firing 3 queries per inbox while
rendering the response — one each for the polymorphic channel, avatar
attachment, and working hours. For accounts with many inboxes this
turned a routine list call into a multi-second request — measured at 35
seconds on an account with 5,000 inboxes.

Two root causes:
1. `policy_scope` was silently discarding the controller's
`.includes(...)` chain because `InboxPolicy::Scope#resolve` returns a
fresh `user.assigned_inboxes` relation, ignoring whatever scope is
passed in. So the eager loading never actually applied.
2. `Inbox#weekly_schedule` called `working_hours.order(...).select(...)`
which fires a new query per inbox even when the association is
preloaded.

The fix chains the eager loads and ordering after `policy_scope` so they
apply to the relation the policy actually returns, adds `:portal` and
`:working_hours` to the preload list, and refactors `weekly_schedule` to
sort the preloaded collection in Ruby and build the hash directly.

Result: queries drop from O(n) to a constant ~15 regardless of inbox
count, and total request time drops 5–6× across every account size
tested. Response payload is byte-identical.

## How to test

1. Open the agent dashboard for an account with a large number of
inboxes (1000+). On a stock dev DB you can seed quickly with
`Seeders::AccountSeeder` or by creating a few hundred `Channel::Api`
inboxes via the rails console.
2. Hit any UI surface that lists inboxes (settings → inboxes, sidebar
inbox list, agent assignment dropdowns). Should feel near-instant where
it previously hung.
3. Confirm every inbox shows the same data as before — channel type,
working hours, avatar, portal/help-center link. No fields should be
missing or different.

## Benchmarks

Real HTTP requests via curl against a dev Rails server (median of 5
timed trials after warmup; "Server total" is the time reported in Rails'
`Completed 200 OK in <X>ms` log line).

| Account | Channel mix | OLD | NEW | Speedup |
|---|---|---|---|---|
| 1000 inboxes | 3 types, no portals/avatars | 5.87 s | 0.96 s |
**6.1×** |
| 5000 inboxes | 3 types, no portals/avatars | 33.07 s | 6.93 s |
**4.8×** |
| 5000 inboxes | 10 types, 25% portals, 10% avatars | 35.04 s | 6.55 s |
**5.4×** |

Detailed breakdown for the realistic 5000-inbox case:

|                 | OLD       | NEW      | Δ        |
|-----------------|-----------|----------|----------|
| Server total    | 35,042 ms | 6,550 ms | −81%     |
| Views           | 32,034 ms | 6,452 ms | −80%     |
| ActiveRecord    | 2,948 ms  | 92 ms    | −97%     |
| Allocations     | 42.3 M    | 11.3 M   | −73%     |
| Queries         | ~15,000   | 15       | −99.9%   |

## What changed

- `app/controllers/api/v1/accounts/inboxes_controller.rb` — chain
`.includes(:channel, :portal, :working_hours, avatar_attachment:
:blob).order_by_name` *after* `policy_scope(...)`. The previous code
passed them into `policy_scope` but the policy resolver dropped the
chain.
- `app/models/concerns/out_of_offisable.rb` — `weekly_schedule` now
sorts the preloaded `working_hours` collection in Ruby and constructs
the result hashes inline, avoiding both the per-inbox query from
`.order(...).select(...)` and the `as_json` reflection overhead.

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-05-14 14:20:52 +05:30
c2523c5d8b fix(editor): Refresh reply editor when reply window reopens in real-time (#14446)
On WhatsApp and any channel that disables the reply editor outside its
messaging window (WhatsApp Cloud, Twilio WhatsApp, API channels with
`agent_reply_time_window` set), when the window was already expired and
a new inbound message arrived in real-time, the "messaging restricted"
banner correctly hid but the editor itself stayed un-typeable until the
agent refreshed the page. This made the dashboard look like it accepted
replies even though typing did nothing.

Fixes
[CW-7087](https://linear.app/chatwoot/issue/CW-7087/reply-editor-stays-disabled-after-real-time-incoming-message-reopens)

#### How to reproduce

1. Open a WhatsApp conversation whose last incoming message is older
than 24h, so `can_reply` is `false` (banner shown, editor greyed out).
2. With the dashboard open on that conversation, have the customer send
a fresh inbound message (or simulate one via the channel's webhook).
3. Before the fix: banner disappears, editor wrapper loses its disabled
styling, but clicking into the editor and typing does nothing — refresh
required.
4. After the fix: banner disappears and the editor accepts input
immediately.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-05-14 12:16:58 +04:00
Shivam MishraandGitHub 05bda5f742 feat: don't let onboarding write domain (#14442)
Stop the onboarding flow from writing the user's company website into
`accounts.domain`. That column is reserved for the inbound email domain
used to construct reply-to addresses (`reply+<uuid>@<domain>`), and
silently overloading it from onboarding was breaking email continuity
for accounts whose domain MX didn't point at Chatwoot's inbound —
customer replies were going to an unreachable address.

The website value now lives in `custom_attributes.website`, which is
what the rest of the app already treats as the "company website" field.
2026-05-13 20:09:48 +05:30
379e28df1f fix: prevent bot metrics double-counting when handoff and resolution coexist [CW-6210] (#14032)
The bot metrics dashboard can show `handoff_rate + resolution_rate >
100%`. A single conversation can accumulate both
`conversation_bot_handoff` and `conversation_bot_resolved` events, and
the rate queries count them independently against a shared denominator.

## How it happens

```
Customer messages bot inbox
        │
        ▼
   ┌──────────┐
   │ pending  │ (bot handling)
   └────┬─────┘
        │ bot can't help
        ▼
   ┌──────────┐
   │   open   │ (handed off → conversation_bot_handoff event created)
   └────┬─────┘
        │ agent clicks "Resolve" WITHOUT sending a message
        ▼
   ┌──────────┐
   │ resolved │ conversation_resolved fires
   └──────────┘
        │
        ▼
   create_bot_resolved_event guard checks:
     ✅ inbox.active_bot?
     ✅ no outgoing messages with sender_type: 'User'  ← agent never messaged!
        │
        ▼
   conversation_bot_resolved event ALSO created ← BUG
        │
        ▼
   Same conversation counted in BOTH rates → sum exceeds 100%
```

## Why fix at the read path, not the write path

An earlier attempt added guards in the listener to make the two events
mutually exclusive per conversation — deleting `bot_resolved` when a
handoff fires, suppressing resolutions when a handoff exists. This was
rejected because conversations can be reopened across multiple cycles
(bot resolves on day 1, customer returns on day 5, bot hands off).
Deleting the day-1 resolution corrupts historical reports, and the async
event dispatcher makes listener-level guards vulnerable to race
conditions.

## What this PR does

Within a reporting window, if a conversation has both events, **handoff
wins** — the conversation is excluded from the resolution count. This is
applied via SQL subquery across all three read paths:

```
                    ┌─────────────────────────┐
                    │   Reporting Events DB    │
                    │                          │
                    │  conv_bot_handoff: [A,B] │
                    │  conv_bot_resolved: [A,C]│
                    └────────┬────────────────┘
                             │
              ┌──────────────┼──────────────┐
              ▼              ▼              ▼
       BotMetricsBuilder  ReportHelper  CountReportBuilder
       (rate cards)       (bot_summary)  (timeseries charts)
              │              │              │
              ▼              ▼              ▼
       resolutions:        resolutions:   resolutions:
       [A,C] minus [A,B]  same logic     same logic
       = [C] only          = [C] only     = [C] only

       Result: Conversation A → handoff only
               Conversation B → handoff only
               Conversation C → resolution only
```

For wide date ranges spanning multiple lifecycles, a conversation
bot-resolved in one cycle and handed off in a later cycle will only show
as a handoff. This is an acceptable tradeoff — the alternative (>100%
rates) is clearly worse, and narrow ranges handle this correctly since
the events fall into different windows. No reporting events are
modified, so historical data stays intact.

## Diagnostic tool

`rake bot_metrics:diagnose` — read-only task that prompts for account ID
and date range, shows a before/after rate comparison without modifying
data.

---------

Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-05-13 18:43:23 +05:30
Aarav UniyalandGitHub c6dceb0e07 fix: contacts dropdown overlap (#14305) 2026-05-13 15:28:58 +05:30
Sivin VargheseandGitHub cd33cea69f chore: Update nl translation in widget (#14441) 2026-05-13 14:18:25 +05:30
71cc5168be feat(linear): Auto link Linear issues from private notes (#14405)
When an agent pastes a Linear issue URL into a private note on a
conversation, Chatwoot now links the issue to the conversation
automatically — no need to click "Link to Linear issue" first. The
standard activity message ("X linked Linear issue ABC-123") is posted
just like a manual link.

Fixes
[CW-7032](https://linear.app/chatwoot/issue/CW-7032/if-someone-post-a-linear-url-in-the-private-notes-automatically-link)

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-12 13:03:40 +04:00
58fdd20625 test(voice): WhatsApp Cloud Calling specs [5] (#14357)
Backend test coverage for the WhatsApp Cloud Calling pipeline introduced
in #14356. Stacked on top of that PR so the controller and service under
test exist when CI runs.

## Closes
- Replaces #14348 (which was based on the abandoned \`feature/pla-150\`)

## What's covered

-
\`spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb\`
(new, ~210 lines)
- \`show / accept / reject / terminate / initiate / upload_recording\`
happy paths
- 422 paths: missing sdp_offer, missing recording, calling_disabled
inbox, missing contact phone, ringing-state guards, AlreadyAccepted,
NotRinging, CallFailed
- 138006 (no permission) → throttled opt-in template send under
conversation lock; idempotency on retry
  - \`upload_recording\` idempotency guard (\`already_uploaded\`)

- \`spec/enterprise/services/whatsapp/call_service_spec.rb\` (new, ~135
lines)
- State machine: ringing → in_progress → completed; ringing → failed
(reject); ringing → no_answer (terminate)
- Lock contention: concurrent terminate during accept doesn't corrupt
the message/conversation broadcast
- Provider failure paths surface as \`Voice::CallErrors::CallFailed\`
(transport and business)

- \`spec/models/channel/whatsapp_spec.rb\` — extends existing file with
\`voice_enabled?\` matrix (provider × source × calling_enabled)

## Verification

- 77/77 examples pass locally on this branch (controller + service +
channel + incoming-call + permission-reply + open-ai message builder)
- RuboCop clean

## Stack

- Backend: #14356 (\`feat/whatsapp-call-meta-bridge\` — base of this PR)
- FE: #14346 (\`feat/whatsapp-call-ui\`)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:40:13 +05:30
6c67eb9ba0 fix(notifications): Respect conversation access when notifying agents (#14412)
Agents with limited custom roles were receiving notifications (creation,
assignment, mentions, new messages, SLA) for conversations they couldn't
actually open. For example, an agent whose custom role only grants
`conversation_unassigned_manage` was getting notified about
conversations assigned to other agents.

Notifications now go through the same `ConversationPolicy#show?` check
that gates the conversation view itself, so an agent only gets notified
for conversations they're permitted to see. Administrators and agents
without custom roles are unaffected.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-05-12 10:57:29 +04:00
Sivin VargheseandGitHub 3df827c931 chore: update Captain documents filter UI (#14429) 2026-05-12 11:27:30 +05:30
de696a55cb feat(voice): add WhatsApp inbound call webhook pipeline [3] (#14315)
Adds the server-side flow that turns Meta WhatsApp Cloud Calling
webhooks into Chatwoot Calls, conversations, voice_call message bubbles,
and ActionCable broadcasts. Stacked on top of #14312 (PR-2 — provider
methods); intentionally does not include the HTTP controller, routes, or
frontend (those land in PR-4 and PR-9).

## Closes
- Part of the WhatsApp Cloud Calling rollout. Linear: TBD

## What changed

**Webhook routing**
- `app/jobs/webhooks/whatsapp_events_job.rb` — append
`prepend_mod_with('Webhooks::WhatsappEventsJob')` so EE can extend it
without forking.
- `enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb` (new)
— overlay that prepends `handle_message_events` to intercept `field:
'calls'` payloads (route to `Whatsapp::IncomingCallService`) and
`interactive.call_permission_reply` messages (route to
`Whatsapp::CallPermissionReplyService`); falls through with `super` for
regular messages.

**Services**
- `enterprise/app/services/whatsapp/incoming_call_service.rb` (new) —
gated on `provider_config['calling_enabled']`; processes `connect`
(creates inbound call via `Voice::InboundCallBuilder` or transitions an
existing outbound call to `in_progress`) and `terminate` events; updates
conversation `additional_attributes` and broadcasts
`voice_call.incoming`/`voice_call.outbound_connected`/`voice_call.ended`.
- `enterprise/app/services/whatsapp/call_permission_reply_service.rb`
(new) — handles WhatsApp interactive `call_permission_reply` replies;
clears the conversation's `call_permission_requested_at` flag and
broadcasts `voice_call.permission_granted` so the agent UI can re-enable
the call button.

**Builder/model adjustments**
- `enterprise/app/services/voice/inbound_call_builder.rb` —
provider-agnostic; accepts `provider:` and `extra_meta:` kwargs, drops
`account:` (now derived from `inbox.account` to keep the param count
under rubocop's ceiling without disabling cops), uses digits-only
`source_id` for WhatsApp ContactInbox (validation requires
`^\d{1,15}\z`), skips Twilio-only `conference_sid` for non-Twilio
providers.
- `enterprise/app/services/voice/call_message_builder.rb` — adds
`create!`/`update_status!` API and `CALL_TO_VOICE_STATUS` map; uses
direct `Message.create!` (bypasses `Messages::MessageBuilder`'s
incoming-on-non-Api-inbox guard, which would otherwise reject the system
bubble); content is `'WhatsApp Call'` for WhatsApp and `'Voice Call'`
for Twilio. Backwards-compatible `perform!` retained for the existing
Twilio call sites.
- `enterprise/app/models/call.rb` — adds `default_ice_servers` (driven
by `VOICE_CALL_STUN_URLS` env), `direction_label` alias for the
`inbound`/`outbound` strings the FE expects, and
`ringing?`/`in_progress?`/`terminal?` predicates used throughout the
pipeline.

**Outgoing-channel guard**
- `app/services/base/send_on_channel_service.rb` — extends
`invalid_message?` to skip messages with `content_type == 'voice_call'`.
Without this, agent-initiated outbound calls (PR-4) would deliver
\"WhatsApp Call\" as a text message to the contact every time.

**Twilio call-site update**
- `enterprise/app/controllers/twilio/voice_controller.rb` — drops the
now-redundant `account: current_account` kwarg from the
`Voice::InboundCallBuilder.perform!` call.

**Tests**
- New: `spec/enterprise/services/whatsapp/incoming_call_service_spec.rb`
(5 examples — calling-disabled, inbound connect, outbound connect,
terminate completed, terminate no-answer, unknown event).
- New:
`spec/enterprise/services/whatsapp/call_permission_reply_service_spec.rb`
(3 examples — accept, reject, calling-disabled).
- Updated: `spec/enterprise/services/voice/inbound_call_builder_spec.rb`
and `spec/enterprise/controllers/twilio/voice_controller_spec.rb` to
drop the `account:` kwarg from call expectations.

## How to test

In `rails console` against an account with a WhatsApp inbox where
`provider_config['calling_enabled']` is true:

```ruby
inbox = Inbox.find(<id>)
params = { calls: [{ id: 'wacid_test', from: '15550001111', event: 'connect',
                     session: { sdp: 'v=0...', sdp_type: 'offer' } }] }
Whatsapp::IncomingCallService.new(inbox: inbox, params: params).perform
# => Conversation + Call (status: 'ringing', provider: 'whatsapp') + voice_call message bubble
# => ActionCable broadcasts `voice_call.incoming` to the assignee or account-wide

# Then terminate it:
Whatsapp::IncomingCallService.new(inbox: inbox,
  params: { calls: [{ id: 'wacid_test', event: 'terminate', duration: 0, terminate_reason: 'no_answer' }] }
).perform
# => Call status flips to 'no_answer', message bubble updates, `voice_call.ended` broadcast fires
```

End-to-end browser flow (Meta → cable → UI) requires the controller from
PR-4 and the frontend from PR-9.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:23:57 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
79a7423f9f chore(deps): bump nokogiri from 1.19.1 to 1.19.3 (#14410)
Bumps [nokogiri](https://github.com/sparklemotion/nokogiri) from 1.19.1
to 1.19.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/sparklemotion/nokogiri/releases">nokogiri's
releases</a>.</em></p>
<blockquote>
<h2>v1.19.3 / 2026-04-27</h2>
<h3>Fixed / Security</h3>
<ul>
<li>Address exponential regex backtracking in CSS selector tokenizer.
See <a
href="https://github.com/sparklemotion/nokogiri/security/advisories/GHSA-c4rq-3m3g-8wgx">GHSA-c4rq-3m3g-8wgx</a>
for more information.</li>
<li>[CRuby] Address memory leak in
<code>XSLT::Stylesheet#transform</code>. See <a
href="https://github.com/sparklemotion/nokogiri/security/advisories/GHSA-v2fc-qm4h-8hqv">GHSA-v2fc-qm4h-8hqv</a>
for more information.</li>
</ul>
<!-- raw HTML omitted -->

<pre><code>46b89e5d7b9e844c2ee360794240c6ea2a4e6fa0c5892a4ed487db621224b639
nokogiri-1.19.3-aarch64-linux-gnu.gem
8392dfdcd21be7a94dbbe9ccc138dea01b97b24cb2dc02a114ca98bfb1d9a0b7
nokogiri-1.19.3-aarch64-linux-musl.gem
3919d5ffc334ad778a4a9eb88fda7dcb8b1fb58c8a52ac640c6dcd2f038e774f
nokogiri-1.19.3-arm-linux-gnu.gem
9ce1cb6346bb9c67b1550eb537aa183ead91e4b6eadb2f36ade02d8dd2a79fb6
nokogiri-1.19.3-arm-linux-musl.gem
71b9bd424b1b7abc18b05052a1a3cfd3627abdca62be280854cc411791357e42
nokogiri-1.19.3-arm64-darwin.gem
40ea6ebf5cf2005dae1dee26dd557d3afb41fb6de6c9764aca8cf06fdb841db1
nokogiri-1.19.3-java.gem
8bb7132cad356c879a1286eaabcb5e68326cb2490317984280fbc62f456d506a
nokogiri-1.19.3-x64-mingw-ucrt.gem
77f3fba57d46c53ab31e62fc6c28f705109d1bf6264356c76f132b2be5728d4d
nokogiri-1.19.3-x86_64-darwin.gem
2f5078620fe12e83669b5b17311b32532a8153d02eee7ad06948b926d6080976
nokogiri-1.19.3-x86_64-linux-gnu.gem
248c906d2166eca5efb56d52fdee5f9a1f51d69a72e2b64fdac647b4ce39ea3f
nokogiri-1.19.3-x86_64-linux-musl.gem
78312cbac32a40c812780d9678221b79d51288eec00054c1a8d15f7ce05960e8
nokogiri-1.19.3.gem
</code></pre>
<h2>v1.19.2 / 2026-03-19</h2>
<h3>Dependencies</h3>
<ul>
<li>[JRuby] Saxon-HE is updated to 12.7, from 9.6.0-4. Saxon-HE is a
transitive dependency of nu.validator:jing, and this update addresses
CVEs in Saxon-HE's own transitive dependencies JDOM and dom4j. We don't
think this warrants a security release, however we're cutting a patch
release to help users whose security scanners are flagging this. <a
href="https://redirect.github.com/sparklemotion/nokogiri/issues/3611">#3611</a>
<a
href="https://github.com/flavorjones"><code>@​flavorjones</code></a></li>
</ul>
<h3>SHA256 Checksums</h3>

<pre><code>c34d5c8208025587554608e98fd88ab125b29c80f9352b821964e9a5d5cfbd19
nokogiri-1.19.2-aarch64-linux-gnu.gem
7f6b4b0202d507326841a4f790294bf75098aef50c7173443812e3ac5cb06515
nokogiri-1.19.2-aarch64-linux-musl.gem
b7fa1139016f3dc850bda1260988f0d749934a939d04ef2da13bec060d7d5081
nokogiri-1.19.2-arm-linux-gnu.gem
61114d44f6742ff72194a1b3020967201e2eb982814778d130f6471c11f9828c
nokogiri-1.19.2-arm-linux-musl.gem
58d8ea2e31a967b843b70487a44c14c8ba1866daa1b9da9be9dbdf1b43dee205
nokogiri-1.19.2-arm64-darwin.gem
e9d67034bc80ca71043040beea8a91be5dc99b662daa38a2bfb361b7a2cc8717
nokogiri-1.19.2-java.gem
8ccf25eea3363a2c7b3f2e173a3400582c633cfead27f805df9a9c56d4852d1a
nokogiri-1.19.2-x64-mingw-ucrt.gem
7d9af11fda72dfaa2961d8c4d5380ca0b51bc389dc5f8d4b859b9644f195e7a4
nokogiri-1.19.2-x86_64-darwin.gem
fa8feca882b73e871a9845f3817a72e9734c8e974bdc4fbad6e4bc6e8076b94f
nokogiri-1.19.2-x86_64-linux-gnu.gem
93128448e61a9383a30baef041bf1f5817e22f297a1d400521e90294445069a8
nokogiri-1.19.2-x86_64-linux-musl.gem
38fdd8b59db3d5ea9e7dfb14702e882b9bf819198d5bf976f17ebce12c481756
nokogiri-1.19.2.gem
</code></pre>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/sparklemotion/nokogiri/compare/v1.19.1...v1.19.2">https://github.com/sparklemotion/nokogiri/compare/v1.19.1...v1.19.2</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/sparklemotion/nokogiri/blob/main/CHANGELOG.md">nokogiri's
changelog</a>.</em></p>
<blockquote>
<h2>v1.19.3 / 2026-04-27</h2>
<h3>Fixed / Security</h3>
<ul>
<li>Address exponential regex backtracking in CSS selector tokenizer.
See <a
href="https://github.com/sparklemotion/nokogiri/security/advisories/GHSA-c4rq-3m3g-8wgx">GHSA-c4rq-3m3g-8wgx</a>
for more information.</li>
<li>[CRuby] Address memory leak in
<code>XSLT::Stylesheet#transform</code>. See <a
href="https://github.com/sparklemotion/nokogiri/security/advisories/GHSA-v2fc-qm4h-8hqv">GHSA-v2fc-qm4h-8hqv</a>
for more information.</li>
</ul>
<h2>v1.19.2 / 2026-03-19</h2>
<h3>Dependencies</h3>
<ul>
<li>[JRuby] Saxon-HE is updated to 12.7, from 9.6.0-4. Saxon-HE is a
transitive dependency of nu.validator:jing, and this update addresses
CVEs in Saxon-HE's own transitive dependencies JDOM and dom4j. We don't
think this warrants a security release, however we're cutting a patch
release to help users whose security scanners are flagging this. <a
href="https://redirect.github.com/sparklemotion/nokogiri/issues/3611">#3611</a>
<a
href="https://github.com/flavorjones"><code>@​flavorjones</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/c139a3da0fe0cae7499a0bafa20f2875877c585b"><code>c139a3d</code></a>
version bump to v1.19.3</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/7501a63b9f4246d12516e35b91fed8be34f854c0"><code>7501a63</code></a>
fix: backtracking in CSS tokenizer rules (v1.19.x backport) (<a
href="https://redirect.github.com/sparklemotion/nokogiri/issues/3627">#3627</a>)</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/03e7968a730a6544ab56a8d6c3e82dd630ad4339"><code>03e7968</code></a>
test: skip CSS tokenizer benchmarks on JRuby</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/b984b7e47f622d1aa97d54c16d5cd596c3eb9538"><code>b984b7e</code></a>
fix: ReDoS in CSS tokenizer ident rule</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/00926231e28d5a20e5b4873efba36099aea0d5c6"><code>0092623</code></a>
fix: ReDoS in CSS tokenizer STRING rule</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/ee17d33aff3adb30c14e71d3d4c8163465acaccf"><code>ee17d33</code></a>
fix: memory leak in XSLT transform (backport to v1.19.x) (<a
href="https://redirect.github.com/sparklemotion/nokogiri/issues/3624">#3624</a>)</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/ce188a395192e3757d8701949afb643dc025084c"><code>ce188a3</code></a>
doc: update CHANGELOG</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/caeaac41f874f0944f9397c78bf6c1bfac2cb472"><code>caeaac4</code></a>
fix: memory leak in XSLT transform</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/25220bf268c9808e28415563ed7f8ea8d5c332bf"><code>25220bf</code></a>
dep(test): test against libxml-ruby v6 (<a
href="https://redirect.github.com/sparklemotion/nokogiri/issues/3618">#3618</a>)</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/0caeb21a5c5e9ff45bbede88fb53655f6753bb0e"><code>0caeb21</code></a>
doc: add security warnings for untrusted XSLT stylesheets</li>
<li>Additional commits viewable in <a
href="https://github.com/sparklemotion/nokogiri/compare/v1.19.1...v1.19.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=nokogiri&package-manager=bundler&previous-version=1.19.1&new-version=1.19.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 18:49:31 -07:00
Sivin VargheseandGitHub 85ddc68834 fix: prevent bulk action checkbox reset in team view (#14432) 2026-05-12 07:13:48 +05:30
Sojan JoseandGitHub 086aa36ffe feat(companies): add notes and history to company details (#14401) 2026-05-11 23:15:25 +05:30
f6be0d80ef feat: UI changes for document auto sync [AI-153] (#14258)
# Pull Request Template

## Description

FE code for document sync

Adds:
- UI to show counts (stats) of available web pages, stale and synced
documents and last synced at
- Bulk action and manual ways to sync web documents
- index to stats related columns

## Type of change

Please delete options that are not relevant.

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

## 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.

https://linear.app/chatwoot/issue/AI-153/fe-document-auto-sync

Documents dashboard:
<img width="2160" height="986" alt="CleanShot 2026-05-11 at 17 57 09@2x"
src="https://github.com/user-attachments/assets/6d934764-964c-4656-b005-1b4f0329e553"
/>

Filters:
<img width="1138" height="564" alt="CleanShot 2026-05-11 at 17 58 13@2x"
src="https://github.com/user-attachments/assets/cee780e6-eb8f-4aed-8cc5-b674244a821b"
/>

Needs update:
<img width="2222" height="966" alt="CleanShot 2026-05-11 at 17 57 53@2x"
src="https://github.com/user-attachments/assets/70c85ddd-7eb1-4328-ba14-7929e67e7b36"
/>

pdfs:
<img width="2180" height="558" alt="CleanShot 2026-05-11 at 17 58 30@2x"
src="https://github.com/user-attachments/assets/975b5c9f-bd1c-4979-9870-8f926d7f6e11"
/>

bulk actions:
<img width="2244" height="992" alt="CleanShot 2026-05-11 at 17 58 57@2x"
src="https://github.com/user-attachments/assets/bdb3c63f-d2de-41dc-a6d5-8821d3303be0"
/>

single url sync:
<img width="2264" height="722" alt="CleanShot 2026-05-11 at 17 59 19@2x"
src="https://github.com/user-attachments/assets/7d7323a5-0fcb-4be9-8635-55e56964999b"
/>



## 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

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-05-11 20:13:29 +05:30
Shivam MishraandGitHub 3489298726 feat: add WidgetCreationService for onboarding web widget setup (#14314)
When a new account finishes onboarding we want to land them on a
dashboard with a working web widget already configured, branded, named,
and assigned to them, instead of an empty inbox list. This PR adds the
services that produce that widget. **No user-visible change yet:** the
services are dormant until the trigger and background job are wired up
in the follow-up PR.

## Context

Milestone 1 added `Account::BrandingEnrichmentJob`, which calls
context.dev during signup and stores brand data on
`account.custom_attributes['brand_info']`, plus the new onboarding form
that captures `domain`, `name`, `industry`, etc. Milestone 2 starts
using that data, and the first thing we want is a web widget
materialized automatically. Splitting the service layer from the
orchestration plumbing (Redis key, `onboarding_step` extension,
controller wiring, ActionCable) keeps this diff focused and lets the
LLM/widget logic merge independently.

## How to test

Run against an existing account that already has `brand_info` populated.

```ruby
account = Account.find(<account_id>)
user    = account.administrators.first
inbox   = WidgetCreationService.new(account, user).perform

inbox.channel.widget_color     # color from brand_info, or '#1f93ff'
inbox.channel.welcome_title    # brand_info[:title], or account.name
inbox.channel.welcome_tagline  # LLM tagline (Enterprise + system key set),
                               # else brand_info[:slogan]/[:description]/nil
inbox.inbox_members.pluck(:user_id)
```

Toggle `InstallationConfig['CAPTAIN_OPEN_AI_API_KEY']` to flip between
LLM and brand-text tagline paths. To verify failure isolation, raise
inside `Captain::Llm::WidgetTaglineService#perform` and confirm widget
creation still succeeds with the fallback tagline.
2026-05-11 16:10:48 +05:30
Shivam MishraandGitHub 2e13f69fdf chore: log errors from context.dev (#14310)
This PR updates the way we log errors and results from context.dev to
have better visibility on the enrichment process for onboarding
2026-05-11 16:09:45 +05:30
Shivam MishraandGitHub bc768bf04f chore: verbosely log errors for leadsquare activity failure (#14407) 2026-05-11 10:58:23 +05:30
Shivam MishraandGitHub cc612e755b fix: SafeFetch dependency loading (#14408)
The SafeFetch spec suite was failing in CI with `NameError:
uninitialized constant SafeFetch::Fetcher` across every example that
exercised `SafeFetch.fetch`. From a product perspective, this made the
external-file fetch path look unreliable even though the failure
happened before any network validation, SSRF protection, content-type
checks, or tempfile handling could run.

The symptom pointed to a load-order issue rather than an actual fetch
behavior regression. `SafeFetch.fetch` referenced `Fetcher` from the
top-level module, but that nested class was not guaranteed to be loaded
in every test execution path before the method was invoked.

This change keeps the existing SafeFetch split between the public API
and the implementation classes, but makes the public entry point
responsible for loading the implementation it needs before use. That is
intentionally smaller than folding all of the fetcher logic into
`lib/safe_fetch.rb`; the separate files still keep the request option
parsing and streaming implementation readable, while the public API no
longer depends on Rails or the test runner having loaded nested
constants in a particular order.

The file also now uses a single `SafeFetch` module declaration. That
removes the awkward reopen pattern and makes the dependency boundary
easier to see: constants and errors are defined first, then the public
`fetch` method loads and delegates to the implementation classes.
2026-05-08 21:18:50 +05:30
Aakash BakhleandGitHub aa10d42237 chore: bump RubyLLM version [AI-152] (#14387)
# Pull Request Template

## Description

Bump RubyLLM version and update model registry

## Type of change

Version bump on package

## 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.

locally and specs


## 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-05-08 18:44:15 +05:30
202403873d feat: Ability to specify the authentication type for imap server (#12306)
# Pull Request Template

## Description

This PR adds IMAP authentication mechanism selection to Chatwoot's email
inbox configuration. Users can now choose between 'plain', 'login', and
'cram-md5' authentication methods when configuring IMAP settings,
providing flexibility for different email providers that require
specific authentication types.

https://github.com/chatwoot/chatwoot/issues/8867

The implementation includes:
- Frontend dropdown with numeric keys (1, 2, 3) matching SMTP auth style
- Backend API validation for allowed authentication mechanisms
- Consistent 'cram-md5' format throughout the codebase
- Updated IMAP service to handle different auth types properly

This feature maintains consistency with existing SMTP authentication
options and follows the established UI/UX patterns in the application.

## Type of change

Please delete options that are not relevant.

- [x] New feature (non-breaking change which adds functionality)
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] 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?

### Manual Testing:
- Tested in Docker environment
- Verified IMAP auth dropdown appears in inbox settings
- Confirmed all three auth mechanisms (plain, login, cram-md5) can be
selected and saved
- Tested API validation by attempting to save invalid auth mechanisms

### Automated Testing:
- Updated existing IMAP service tests to use consistent lowercase values
- Updated API controller tests for authentication parameter handling
- All tests pass locally with the new changes

### Test Configuration:
- Tested with both new and existing inbox configurations

## 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

## Additional Notes

- This feature is backward compatible and doesn't break existing IMAP
configurations
- The 'cram-md5' format is used consistently throughout (UI, API,
storage, services)
- Net::IMAP compatibility is maintained by converting to 'CRAM-MD5'
internally
- Follows the same pattern established by SMTP authentication
configuration

---------

Co-authored-by: João Santos <joao.santos@madigital.eu>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-05-08 16:40:15 +05:30
9c1d1c4070 feat(labels): remove label associations asynchronously on delete (#13531)
## Summary
- Remove label deletion dependency on association cleanup by deleting
immediately and enqueueing a background job.
- Add `Labels::RemoveAssociationsJob` to strip deleted label references
from tagged conversations and contacts.
- Keep this version simple by removing the label count/prompt
requirement requested.

## Implementation notes
- Enqueue job from `Api::V1::Accounts::LabelsController#destroy` with
label title + account id.
- Background work performed in `Labels::DestroyService`.

## References
- Linear issue:
https://linear.app/chatwoot/issue/CW-4765/cw-2857-enhancement-removing-labels-is-inconsistent
- GitHub issue: https://github.com/chatwoot/chatwoot/issues/1249

## Testing
- `bundle exec rspec
spec/controllers/api/v1/accounts/labels_controller_spec.rb
spec/services/labels/destroy_service_spec.rb
spec/jobs/labels/remove_associations_job_spec.rb
spec/services/labels/update_service_spec.rb`
- `bundle exec rubocop
app/controllers/api/v1/accounts/labels_controller.rb
app/jobs/labels/remove_associations_job.rb
spec/controllers/api/v1/accounts/labels_controller_spec.rb
spec/jobs/labels/remove_associations_job_spec.rb
spec/services/labels/destroy_service_spec.rb`

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-05-08 13:40:36 +05:30
5c6ea78ce6 fix(security): Enforce admin authorization on custom attribute definitions API (#14392)
Custom attribute definitions can now only be created, edited, or deleted
by administrators, matching the existing settings UI restriction.
Previously, an agent could call the `custom_attribute_definitions` API
directly and modify account configuration that they couldn't reach
through the dashboard — a Broken Access Control vulnerability reported
externally.

Fixes
https://linear.app/chatwoot/issue/CW-7038/broken-access-control-on-custom-attribute-definitions-api

## How to test

1. Sign in as an agent.
2. Try to create a custom attribute by calling `POST
/api/v1/accounts/<id>/custom_attribute_definitions` directly (the
settings page is hidden for agents — use curl with the agent's
`api_access_token`).
3. Expect `401 Unauthorized` with body `{"error":"You are not authorized
to do this action"}`. Repeat for `PATCH` and `DELETE`.
4. Sign in as an administrator and confirm create/edit/delete still work
from Settings → Custom Attributes.
5. As either role, the listing endpoint (`GET
.../custom_attribute_definitions`) should still succeed — agents need
this to render attributes in conversation and contact panels.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-05-08 11:42:23 +04:00
Tanmay Deep SharmaandGitHub e6b8f48b3b fix: settle captain credits on subscription cancellation (#14089)
## Linear Ticket
-
https://linear.app/chatwoot/issue/CW-6875/captain-credits-3-bugs-in-stripe-subscription-lifecycle-cancel-ratchet

## Description

Fixes Captain credit settlement on subscription cancellation. Previously
`limits['captain_responses']` and `captain_responses_usage` were left in
their pre-cancellation state, which caused incorrect credit totals when
a customer re-subscribed. Cancellation now settles the monthly allotment
(preserving any remaining topup) and resets the usage counter.

## Type of change

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

## How Has This Been Tested?

1. Set up an account subscribed to a paid plan (e.g. Startups) so
`limits['captain_responses']` reflects the plan allotment.
2. Fire `customer.subscription.deleted` for that account's Stripe
customer. Confirm the limits.
3. Fire `customer.subscription.updated` re-subscribing to the paid plan.
Confirm the limits.
4. Repeat cancel → re-subscribe several times;


## 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-05-08 12:18:53 +05:30
Sojan JoseandGitHub 10597863d7 feat(companies): add company creation flow (#14402) 2026-05-08 11:46:33 +05:30
53b2a517d7 fix: resolve SendReplyJob flaky specs (#14394)
`SendReplyJob` was caching reloadable service class objects in
`CHANNEL_SERVICES`. In test, a request spec can trigger Rails constant
reloading after `SendReplyJob` has already been loaded, leaving the job
with stale class objects while later specs stub the reloaded constants.
This resolves the channel service at perform time so the job follows the
current Rails constant table.

How to reproduce
Run the CircleCI shard that contains send_reply_job_spec, or the
minimized order-dependent reproduction:
```sh
bundle exec rspec --format progress spec/builders/v2/reports/label_summary_builder_spec.rb spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb spec/jobs/send_reply_job_spec.rb:32
```

What changed
- Store service class names in `SendReplyJob::CHANNEL_SERVICES` instead
of class objects.
- Resolve the service with constantize inside perform so reloads do not
leave stale cached classes.

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-05-07 22:47:09 +05:30
7a7db22a43 fix: Implement resend confirmation feature for login page (#11970)
# Pull Request Template

## Description

This PR fixes the non-functional resend confirmation feature on the V3
login page where clicking "Resend confirmation" did nothing. The issue
was caused by the V3 store not having the `resendConfirmation` action
that the login page was trying to dispatch.

**Key improvements:**
- Fixed V3 store integration by importing `resendConfirmation` directly
from auth API
- Added comprehensive UX improvements with loading states and 60-second
cooldown timer
- Implemented environment-aware debug logging for development
- Added proper error handling and user feedback
- Enhanced backend test coverage

**Context:** Users with unconfirmed accounts were unable to resend
confirmation emails from the login page, creating a poor user experience
and potential support burden.

Fixes #3157

## Type of change

- [x] 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?

**Backend Testing:**
- All existing resend_confirmation tests passing (7/7)
- Added comprehensive new test suite in
`spec/requests/api/v1/resend_confirmation_spec.rb`
- API endpoint returns 200 OK responses in ~0.39 seconds
- Email delivery confirmed via SMTP with test user `info@airbonar.com`

**Frontend Testing:**
- All frontend tests passing 
- ESLint compliant code with automatic corrections applied
- Manual testing of login page functionality:
  - 60-second cooldown timer with countdown display
  - Error handling with user-friendly messages
  - Development logging works (console output in dev mode only)

**Test Configuration:**
- Ruby/Rails backend with RSpec test suite
- Vue.js frontend with Jest/testing-library
- Development environment with Gmail SMTP configured
- Test user: unconfirmed account `info@airbonar.com`

**Reproduction Steps:**
1. Navigate to login page with unconfirmed account
2. Click "Resend confirmation link"
3. Observe loading state, API call, and success feedback
4. Verify 60-second cooldown prevents spam
5. Check email delivery.

## Checklist:

- [ ] 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: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-05-07 15:13:04 +05:30
Sivin VargheseandGitHub 2435d7503c feat: Update conversation bulk action UI (#14118) 2026-05-07 14:58:38 +05:30
salmonumbrellaandGitHub fe44b07147 feat(companies): add company detail page (#14054) 2026-05-06 20:50:27 +05:30
42bba748cf fix(mailbox): render inline images without Content-Disposition (#11949)
## Description
This pull request addresses issue #11948, where inline images embedded
in emails (such as those sent from Outlook) are not rendered correctly
if the Content-Disposition header is missing.

The solution ensures that images referenced via cid: in the HTML body
are correctly identified and rewritten using url_for.

Fixes #11948

## Type of change
Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?
Added test: detects image inline attachment by cid reference when
Content-Disposition is missing

## 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
- [ ] My changes generate no new warnings
- [X] 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: Pranav <pranav@chatwoot.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-05-06 18:56:31 +05:30
d43a87c9dc feat: Add labels to contact import/export (#13313)
Adds label support to contact import and export so teams can carry
approved contact labels through CSV workflows. Imports accept a `labels`
column with labels that already exist in the account; multiple labels
should be entered as a quoted comma-separated CSV value, for example
`"customer,vip"`.

Imports are additive: they add labels to contacts and do not remove
labels already on a contact. Removing a label from the CSV row or
leaving the `labels` cell blank will not clear existing contact labels.
To remove a label, edit the contact directly.

## Closes

- Closes #8535

## How to test

1. Create a few contact labels in the account, such as `customer`,
`vip`, and `lead`.
2. Go to Contacts -> Import contacts and download the sample CSV.
3. Import contacts with a `labels` column. Use a single label like
`lead`, or quote multiple labels like `"customer,vip"`.
4. Confirm imported contacts are created with the expected labels.
5. Re-import an existing contact with a new label and confirm the new
label is added without removing existing labels.
6. Try a row with an unknown label, such as `"vip,unknown_label"`, and
confirm only that row is rejected in the failed records CSV while the
other valid rows are imported.
7. Export contacts and confirm the CSV includes a `labels` column with
comma-separated approved labels.

## What changed

- Contact exports include approved `labels` in the default CSV columns.
This adds a new default export column for CSV consumers.
- Contact imports parse `labels` as comma-separated values inside the
CSV cell.
- Imported labels are validated against labels that already exist in the
account.
- Rows with unknown labels are rejected with an `Unknown labels: ...`
error; valid rows in the same import continue to process.
- Imported labels are additive and do not remove existing contact
labels.
- Label application during import does not dispatch an additional
per-contact update event.
- The sample CSV includes an import-safe `labels` column. The modal
keeps the existing generic CSV import copy.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-06 18:46:36 +05:30
Aakash BakhleandGitHub d7d1e4113c fix: captain auto sync scheduler resilience (#14379)
# Pull Request Template

## Description

skip documents that fail with ActiveRecord errors possibly due to
stale/corrupt data and not crash scheduler

How did we find out about this error?

before October 28th, 2025, we did not have url normalisation.
so we had document rows as: 
id: 123 `https://example.com` status: `in_progress` --> likely stuck
crawl
id 234: `https://example.com/` status: `available` 

When the schedule sync job ran, it ran an `document.update!(sync_status:
:syncing, last_sync_attempted_at: Time.current)` on the 234 one since it
was `available`

now `update!` runs `before_validation :normalize_external_link`
so `https://example.com/` became `https://example.com`

which invalidated:
`validates :external_link, uniqueness: { scope: :assistant_id }`

so the scheduler crashed.

This PR logs the skipped ones with their errors and continues to pick
other documents to scheduler doesn't crash

## 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.
spec

## 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-05-06 18:07:22 +05:30
Sivin VargheseandGitHub 815593eec9 feat: display conversation ID conversation view (#14381) 2026-05-06 17:33:23 +05:30
8d7e926e06 fix: [Snyk] Security upgrade video.js from 7.18.1 to 7.21.1 (#13973)
![snyk-top-banner](https://res.cloudinary.com/snyk/image/upload/r-d/scm-platform/snyk-pull-requests/pr-banner-default.svg)

### Snyk has created this PR to fix 1 vulnerabilities in the yarn
dependencies of this project.

#### Snyk changed the following file(s):

- `package.json`


#### Note for
[zero-installs](https://yarnpkg.com/features/zero-installs) users

If you are using the Yarn feature
[zero-installs](https://yarnpkg.com/features/zero-installs) that was
introduced in Yarn V2, note that this PR does not update the
`.yarn/cache/` directory meaning this code cannot be pulled and
immediately developed on as one would expect for a zero-install project
- you will need to run `yarn` to update the contents of the
`./yarn/cache` directory.
If you are not using zero-install you can ignore this as your flow
should likely be unchanged.



<details>
<summary>⚠️ <b>Warning</b></summary>

```
Failed to update the yarn.lock, please update manually before merging.
```

</details>



#### Vulnerabilities that will be fixed with an upgrade:

|  | Issue |  
:-------------------------:|:-------------------------
![high
severity](https://res.cloudinary.com/snyk/image/upload/w_20,h_20/v1561977819/icon/h.png
'high severity') | XML Injection
<br/>[SNYK-JS-XMLDOMXMLDOM-15869636](https://snyk.io/vuln/SNYK-JS-XMLDOMXMLDOM-15869636)




---

> [!IMPORTANT]
>
> - Check the changes in this PR to ensure they won't cause issues with
your project.
> - Max score is 1000. Note that the real score may have changed since
the PR was raised.
> - This PR was automatically created by Snyk using the credentials of a
real user.

---

**Note:** _You are seeing this because you or someone else with access
to this repository has authorized Snyk to open fix PRs._

For more information: <img
src="https://api.segment.io/v1/pixel/track?data=eyJ3cml0ZUtleSI6InJyWmxZcEdHY2RyTHZsb0lYd0dUcVg4WkFRTnNCOUEwIiwiYW5vbnltb3VzSWQiOiJhMzFhMWZiNS1hOWYwLTQ1MTMtOTMxNi1iZTg3OThhYmZkOWMiLCJldmVudCI6IlBSIHZpZXdlZCIsInByb3BlcnRpZXMiOnsicHJJZCI6ImEzMWExZmI1LWE5ZjAtNDUxMy05MzE2LWJlODc5OGFiZmQ5YyJ9fQ=="
width="0" height="0"/>
🧐 [View latest project
report](https://app.snyk.io/org/chatwoot/project/3ca3819e-26b5-4e23-ac65-184abd9a6f10?utm_source&#x3D;github&amp;utm_medium&#x3D;referral&amp;page&#x3D;fix-pr)
📜 [Customise PR
templates](https://docs.snyk.io/scan-using-snyk/pull-requests/snyk-fix-pull-or-merge-requests/customize-pr-templates?utm_source=github&utm_content=fix-pr-template)
🛠 [Adjust project
settings](https://app.snyk.io/org/chatwoot/project/3ca3819e-26b5-4e23-ac65-184abd9a6f10?utm_source&#x3D;github&amp;utm_medium&#x3D;referral&amp;page&#x3D;fix-pr/settings)
📚 [Read about Snyk's upgrade
logic](https://docs.snyk.io/scan-with-snyk/snyk-open-source/manage-vulnerabilities/upgrade-package-versions-to-fix-vulnerabilities?utm_source=github&utm_content=fix-pr-template)

---

**Learn how to fix vulnerabilities with free interactive lessons:**

🦉 [XML Injection](https://learn.snyk.io/lesson/xxe/?loc&#x3D;fix-pr)

[//]: #
'snyk:metadata:{"breakingChangeRiskLevel":null,"FF_showPullRequestBreakingChanges":false,"FF_showPullRequestBreakingChangesWebSearch":false,"customTemplate":{"variablesUsed":[],"fieldsUsed":[]},"dependencies":[{"name":"video.js","from":"7.18.1","to":"7.21.1"}],"env":"prod","issuesToFix":["SNYK-JS-XMLDOMXMLDOM-15869636","SNYK-JS-XMLDOMXMLDOM-15869636"],"prId":"a31a1fb5-a9f0-4513-9316-be8798abfd9c","prPublicId":"a31a1fb5-a9f0-4513-9316-be8798abfd9c","packageManager":"yarn","priorityScoreList":[null],"projectPublicId":"3ca3819e-26b5-4e23-ac65-184abd9a6f10","projectUrl":"https://app.snyk.io/org/chatwoot/project/3ca3819e-26b5-4e23-ac65-184abd9a6f10?utm_source=github&utm_medium=referral&page=fix-pr","prType":"fix","templateFieldSources":{"branchName":"default","commitMessage":"default","description":"default","title":"default"},"templateVariants":["updated-fix-title","pr-warning-shown"],"type":"auto","upgrade":["SNYK-JS-XMLDOMXMLDOM-15869636"],"vulns":["SNYK-JS-XMLDOMXMLDOM-15869636"],"patch":[],"isBreakingChange":false,"remediationStrategy":"vuln"}'

---------

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-05-06 16:33:16 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Sony Mathew
dd52f1d32b chore(deps): bump rack-session from 2.1.1 to 2.1.2 (#14017)
Bumps [rack-session](https://github.com/rack/rack-session) from 2.1.1 to
2.1.2.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rack/rack-session/blob/main/releases.md">rack-session's
changelog</a>.</em></p>
<blockquote>
<h2>v2.1.2</h2>
<ul>
<li><a
href="https://github.com/advisories/GHSA-33qg-7wpp-89cq">CVE-2026-39324</a>
Don't fall back to unencrypted coder if encryptors are present.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/rack/rack-session/commit/504367b59caf7ec78127785cc6351f46be14f8ca"><code>504367b</code></a>
Bump patch version.</li>
<li><a
href="https://github.com/rack/rack-session/commit/f43638cb3a4d15c3ecaf59e67a04b47fda08eeac"><code>f43638c</code></a>
Don't fall back to unencrypted coder if encryptors are present.</li>
<li><a
href="https://github.com/rack/rack-session/commit/dadcfe60f193e8d8540bec6b95ca75bed8e5fd7e"><code>dadcfe6</code></a>
Bump actions/checkout from 4 to 5 (<a
href="https://redirect.github.com/rack/rack-session/issues/54">#54</a>)</li>
<li><a
href="https://github.com/rack/rack-session/commit/4eb9ea83b372e319c65a8c2bcfe87e8be942cf9b"><code>4eb9ea8</code></a>
Add top level session spec to validate existing formats.</li>
<li><a
href="https://github.com/rack/rack-session/commit/8f94577c1d11b746692974f1417acff2856060cb"><code>8f94577</code></a>
Add rails to external tests.</li>
<li><a
href="https://github.com/rack/rack-session/commit/38ea47da9937afb4f2140b3c23866e3791a46eaf"><code>38ea47d</code></a>
Allow the v2 encryptor to serialize messages with <code>Marshal</code>
(<a
href="https://redirect.github.com/rack/rack-session/issues/44">#44</a>)</li>
<li><a
href="https://github.com/rack/rack-session/commit/43f2e3a46393b51473bb90f54e61189465ae759d"><code>43f2e3a</code></a>
Fix compatibility with older Rubies.</li>
<li><a
href="https://github.com/rack/rack-session/commit/6a060b806399bff4961eaf6bf89535395c95549c"><code>6a060b8</code></a>
Support UTF-8 data when using the JSON serializer (<a
href="https://redirect.github.com/rack/rack-session/issues/39">#39</a>)</li>
<li><a
href="https://github.com/rack/rack-session/commit/8ce0146a7079332d9c58a43e418acb1ecf904ef6"><code>8ce0146</code></a>
Fix <code>auth_tag</code> retrieval on JRuby (<a
href="https://redirect.github.com/rack/rack-session/issues/32">#32</a>)</li>
<li><a
href="https://github.com/rack/rack-session/commit/77271850efd977897d02903bfde8ed51e4137a68"><code>7727185</code></a>
Add AEAD encryption (<a
href="https://redirect.github.com/rack/rack-session/issues/23">#23</a>)</li>
<li>See full diff in <a
href="https://github.com/rack/rack-session/compare/v2.1.1...v2.1.2">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=rack-session&package-manager=bundler&previous-version=2.1.1&new-version=2.1.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-05-06 15:37:32 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Sony Mathew
deb259c8d2 chore(deps): bump rack from 3.2.5 to 3.2.6 (#13987)
Bumps [rack](https://github.com/rack/rack) from 3.2.5 to 3.2.6.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rack/rack/releases">rack's
releases</a>.</em></p>
<blockquote>
<h2>v3.2.6</h2>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/rack/rack/compare/v3.2.5...v3.2.6">https://github.com/rack/rack/compare/v3.2.5...v3.2.6</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rack/rack/blob/main/CHANGELOG.md">rack's
changelog</a>.</em></p>
<blockquote>
<h2>[3.2.6] - 2026-04-01</h2>
<h3>Security</h3>
<ul>
<li><a
href="https://github.com/advisories/GHSA-7mqq-6cf9-v2qp">CVE-2026-34763</a>
Root directory disclosure via unescaped regex interpolation in
<code>Rack::Directory</code>.</li>
<li><a
href="https://github.com/advisories/GHSA-v569-hp3g-36wr">CVE-2026-34230</a>
Avoid O(n^2) algorithm in <code>Rack::Utils.select_best_encoding</code>
which could lead to denial of service.</li>
<li><a
href="https://github.com/advisories/GHSA-qfgr-crr9-7r49">CVE-2026-32762</a>
Forwarded header semicolon injection enables Host and Scheme
spoofing.</li>
<li><a
href="https://github.com/advisories/GHSA-vgpv-f759-9wx3">CVE-2026-26961</a>
Raise error for multipart requests with multiple boundary
parameters.</li>
<li><a
href="https://github.com/advisories/GHSA-q4qf-9j86-f5mh">CVE-2026-34786</a>
<code>Rack::Static</code> <code>header_rules</code> bypass via
URL-encoded path mismatch.</li>
<li><a
href="https://github.com/advisories/GHSA-q2ww-5357-x388">CVE-2026-34831</a>
<code>Content-Length</code> mismatch in <code>Rack::Files</code> error
responses.</li>
<li><a
href="https://github.com/advisories/GHSA-x8cg-fq8g-mxfx">CVE-2026-34826</a>
Multipart byte range processing allows denial of service via excessive
overlapping ranges.</li>
<li><a
href="https://github.com/advisories/GHSA-g2pf-xv49-m2h5">CVE-2026-34835</a>
<code>Rack::Request</code> accepts invalid Host characters, enabling
host allowlist bypass.</li>
<li><a
href="https://github.com/advisories/GHSA-qv7j-4883-hwh7">CVE-2026-34830</a>
<code>Rack::Sendfile</code> header-based <code>X-Accel-Mapping</code>
regex injection enables unauthorized <code>X-Accel-Redirect</code>.</li>
<li><a
href="https://github.com/advisories/GHSA-h2jq-g4cq-5ppq">CVE-2026-34785</a>
<code>Rack::Static</code> prefix matching can expose unintended files
under the static root.</li>
<li><a
href="https://github.com/advisories/GHSA-8vqr-qjwx-82mw">CVE-2026-34829</a>
Multipart parsing without <code>Content-Length</code> header allows
unbounded chunked file uploads.</li>
<li><a
href="https://github.com/advisories/GHSA-v6x5-cg8r-vv6x">CVE-2026-34827</a>
Multipart header parsing allows denial of service via escape-heavy
quoted parameters.</li>
<li><a
href="https://github.com/advisories/GHSA-rx22-g9mx-qrhv">CVE-2026-26962</a>
Improper unfolding of folded multipart headers preserves CRLF in parsed
parameter values.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/rack/rack/commit/e1f22fdbe99afd2126b6fbf05bb12399359574b7"><code>e1f22fd</code></a>
Bump patch version.</li>
<li><a
href="https://github.com/rack/rack/commit/31989fd7bb6f806fdb3cfa4e9aec1fe8434f47d1"><code>31989fd</code></a>
Fix typo in test.</li>
<li><a
href="https://github.com/rack/rack/commit/d268165e390e17b83573fec916dcdef6304a8b4b"><code>d268165</code></a>
Fix test expectation.</li>
<li><a
href="https://github.com/rack/rack/commit/8f425de0ee75a2f3cdfbfdd57858c1910b7645ff"><code>8f425de</code></a>
Add Ruby v4.0 to the test matrix.</li>
<li><a
href="https://github.com/rack/rack/commit/bf830426ce5b3daccb5a226b733703c86504ceba"><code>bf83042</code></a>
Drop EOL Rubies from external tests.</li>
<li><a
href="https://github.com/rack/rack/commit/d50c4d3dab62fa80b2a276271d0d4fb338cfa7df"><code>d50c4d3</code></a>
Implement OBS unfolding for multipart requests per RFC 5322 2.2.3</li>
<li><a
href="https://github.com/rack/rack/commit/bfb69142dbe2a1e3298ad52d12935938d1b58205"><code>bfb6914</code></a>
Limit the number of quoted escapes during multipart parsing</li>
<li><a
href="https://github.com/rack/rack/commit/b3e5945c648c5a5b6982e5072b26e51990991229"><code>b3e5945</code></a>
Add Content-Length size check in Rack::Multipart::Parser</li>
<li><a
href="https://github.com/rack/rack/commit/7a8f32696609b88e2c4c1f09d473a1d2d837ed4b"><code>7a8f326</code></a>
Fix root prefix bug in Rack::Static</li>
<li><a
href="https://github.com/rack/rack/commit/a57bc140247f904dc1e3302badedcb73645072c7"><code>a57bc14</code></a>
Only do a simple substitution on the x-accel-mapping paths</li>
<li>Additional commits viewable in <a
href="https://github.com/rack/rack/compare/v3.2.5...v3.2.6">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-05-06 15:33:40 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Sivin Varghese
9c8cfc40b6 chore(deps): bump dompurify from 3.3.2 to 3.4.0 (#14074)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.3.2 to
3.4.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/cure53/DOMPurify/releases">dompurify's
releases</a>.</em></p>
<blockquote>
<h2>DOMPurify 3.4.0</h2>
<p><strong>Most relevant changes:</strong></p>
<ul>
<li>Fixed a problem with <code>FORBID_TAGS</code> not winning over
<code>ADD_TAGS</code>, thanks <a
href="https://github.com/kodareef5"><code>@​kodareef5</code></a></li>
<li>Fixed several minor problems and typos regarding MathML attributes,
thanks <a
href="https://github.com/DavidOliver"><code>@​DavidOliver</code></a></li>
<li>Fixed <code>ADD_ATTR</code>/<code>ADD_TAGS</code> function leaking
into subsequent array-based calls, thanks <a
href="https://github.com/1Jesper1"><code>@​1Jesper1</code></a></li>
<li>Fixed a missing <code>SAFE_FOR_TEMPLATES</code> scrub in
<code>RETURN_DOM</code> path, thanks <a
href="https://github.com/bencalif"><code>@​bencalif</code></a></li>
<li>Fixed a prototype pollution via
<code>CUSTOM_ELEMENT_HANDLING</code>, thanks <a
href="https://github.com/trace37labs"><code>@​trace37labs</code></a></li>
<li>Fixed an issue with <code>ADD_TAGS</code> function form bypassing
<code>FORBID_TAGS</code>, thanks <a
href="https://github.com/eddieran"><code>@​eddieran</code></a></li>
<li>Fixed an issue with <code>ADD_ATTR</code> predicates skipping URI
validation, thanks <a
href="https://github.com/christos-eth"><code>@​christos-eth</code></a></li>
<li>Fixed an issue with <code>USE_PROFILES</code> prototype pollution,
thanks <a
href="https://github.com/christos-eth"><code>@​christos-eth</code></a></li>
<li>Fixed an issue leading to possible mXSS via Re-Contextualization,
thanks <a
href="https://github.com/researchatfluidattacks"><code>@​researchatfluidattacks</code></a>
and others</li>
<li>Fixed an issue with closing tags leading to possible mXSS, thanks <a
href="https://github.com/frevadiscor"><code>@​frevadiscor</code></a></li>
<li>Fixed a problem with the type dentition patcher after Node version
bump</li>
<li>Fixed freezing BS runs by reducing the tested browsers array</li>
<li>Bumped several dependencies where possible</li>
<li>Added needed files for OpenSSF scorecard checks</li>
</ul>
<p><strong>Published Advisories are here:</strong>
<a
href="https://github.com/cure53/DOMPurify/security/advisories?state=published">https://github.com/cure53/DOMPurify/security/advisories?state=published</a></p>
<h2>DOMPurify 3.3.3</h2>
<ul>
<li>Fixed an engine requirement for Node 20 which caused hiccups, thanks
<a href="https://github.com/Rotzbua"><code>@​Rotzbua</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/cure53/DOMPurify/commit/5b16e0b892e82b1779d62b9928b43c4c4ff290b9"><code>5b16e0b</code></a>
Getting 3.x branch ready for 3.4.0 release (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1250">#1250</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/8bcbf73ae7eb56e7b4f1300b66cf543342c7ee27"><code>8bcbf73</code></a>
chore: Preparing 3.3.3 release</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/5faddd60af7b4d612f32a0c6b44432b77c8c490c"><code>5faddd6</code></a>
fix: engine requirement (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1210">#1210</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/0f91e3add5c028bc4110c513b0c2571b284c35af"><code>0f91e3a</code></a>
Update README.md</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/d5ff1a8c605df1df998c2e7df2c4c8ac762b0dea"><code>d5ff1a8</code></a>
Merge branch 'main' of github.com:cure53/DOMPurify</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/c3efd489010366e755de9d65fd741888fd8b7462"><code>c3efd48</code></a>
fix: moved back from jsdom 28 to jsdom 20</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/988b888108c8df911ef37e68d0e26c85ad90e885"><code>988b888</code></a>
fix: moved back from jsdom 28 to jsdom 20</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/2726c74e9c6a0645127d1630e5ca49f64bc9fe67"><code>2726c74</code></a>
chore: Preparing 3.3.2 release</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/6202c7e43e9df01ba606396aed60fbae5583f7a1"><code>6202c7e</code></a>
build(deps): bump <code>@​tootallnate/once</code> and jsdom (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1204">#1204</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/302b51de22535cc90235472c52e3401bedd46f80"><code>302b51d</code></a>
fix: Expanded the regex ever so slightly to also cover script</li>
<li>Additional commits viewable in <a
href="https://github.com/cure53/DOMPurify/compare/3.3.2...3.4.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=dompurify&package-manager=npm_and_yarn&previous-version=3.3.2&new-version=3.4.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-05-06 15:14:54 +05:30
Sivin VargheseandGitHub 8f532f45ff feat: add attachments section to conversation sidebar (#14371) 2026-05-06 15:13:51 +05:30
Sojan JoseandGitHub 00ba468486 fix(macros): disable public visibility for agents (#14349) 2026-05-06 15:10:11 +05:30
b8108b71c1 fix(tiktok): Resolve media upload failures and gate attachments by conversation capability (#13643)
This PR fixes TikTok attachment send failures and adds a
capability-based guard so attachments are only enabled for conversations
that support media sending.

- Fixed TikTok media upload request formatting so TikTok accepts image
uploads reliably.
- Added TikTok capability check (IMAGE_SEND) during conversation
creation.
- Stored capability in
conversation.additional_attributes.tiktok_capabilities.
- Updated reply composer UI to disable/hide attachment upload for TikTok
conversations where image_send is false.

Fixes
https://linear.app/chatwoot/issue/CW-6532/enable-attachments-based-on-the-conversation-capability
and
https://linear.app/chatwoot/issue/CW-6996/unable-to-send-image-attachments-to-tiktok-customer-400-parsing-error

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-05-06 11:21:15 +04:00
cc5974da9b feat(inbox): Add beta badge for TikTok and Voice channels (#14378)
TikTok and Voice channels in the inbox creation flow now display a small
"Beta" badge next to their title, signaling that these integrations are
still being polished while keeping them available for users to try.
Fixes
https://linear.app/chatwoot/issue/CW-7026/add-beta-label-for-tiktok-and-voice-inboxes

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:54:00 +04:00
2192af80f4 fix: html-escape captured values in helpcenter article markdown embeds (#14140)
Embed templates interpolate regex captures from user-authored article
URLs into HTML attribute values. CommonMark's angle-bracket link
destination syntax allows characters that the capture regexes don't
filter, so the unescaped substitution could produce malformed attribute
output. Escaping at substitution time keeps the render deterministic
regardless of the URL.

### How was this tested?
Added specs.

Fixes [CW-6934](https://linear.app/chatwoot/issue/CW-6934/)

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-05-05 17:46:21 +05:30
Vishnu NarayananandGitHub 941c8a86b4 fix: use a dedicated PAT for ghsa linear sync gh action (#14364)
The default `GITHUB_TOKEN` cannot read `security-advisories`; that endpoint requires the `repository_advisories` permission, which is not available to the GitHub Actions installation token.

Switched to a fine-grained PAT stored in `GHSA_READ_TOKEN`.

Tested locally: the same PAT returns the full triage list

Changes
----
- Switch to custom token
- Add a discord alert for new advisories
- Switch to python
2026-05-05 17:20:22 +05:30
295 changed files with 49844 additions and 5942 deletions
+15 -4
View File
@@ -2,8 +2,19 @@
ignore:
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
# Chatwoot defaults to Active Storage redirect-style URLs, and its recommended
# storage setup uses local/cloud storage with optional direct uploads to the
# storage provider rather than Rails proxy mode. Revisit if we enable
# rails_storage_proxy or other app-served Active Storage proxy routes.
# Rails 7.1 has no patched release for the Active Storage proxy range
# advisories. Chatwoot limits proxy range requests locally.
- CVE-2026-33658
# Rails 7.1 has no patched release for this Active Storage direct-upload
# advisory. Chatwoot filters internal metadata keys locally.
- CVE-2026-33173
- CVE-2026-33174
# Rails 7.1 has no patched release for these Rails advisories. These are not
# reachable through Chatwoot's current usage patterns and should be removed
# once we upgrade to Rails 7.2.3.1+.
- CVE-2026-33168
- CVE-2026-33169
- CVE-2026-33170
- CVE-2026-33176
- CVE-2026-33195
- CVE-2026-33202
+1 -1
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 18
parallelism: 20
steps:
- checkout
- node/install:
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""Sync triage GitHub security advisories to Linear issues."""
from __future__ import annotations
import os
import sys
from typing import Any
import requests
GITHUB_API = "https://api.github.com"
LINEAR_API = "https://api.linear.app/graphql"
SEVERITY_PRIORITY = {"critical": 1, "high": 2, "medium": 3, "low": 4}
SEVERITY_COLOR = {
"critical": 15548997,
"high": 15105570,
"medium": 15844367,
"low": 3066993,
}
DEFAULT_COLOR = 9807270
def required_env(name: str) -> str:
value = os.environ.get(name)
if not value:
sys.exit(f"Missing required env var: {name}")
return value
def fetch_triage_advisories(repo: str, token: str) -> list[dict[str, Any]]:
url: str | None = f"{GITHUB_API}/repos/{repo}/security-advisories"
params: dict[str, Any] | None = {"state": "triage", "per_page": 100}
headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
}
advisories: list[dict[str, Any]] = []
while url:
r = requests.get(url, headers=headers, params=params, timeout=30)
r.raise_for_status()
advisories.extend(r.json())
next_link = r.links.get("next")
url = next_link["url"] if next_link else None
params = None
return advisories
def linear_call(query: str, variables: dict[str, Any], api_key: str) -> dict[str, Any]:
r = requests.post(
LINEAR_API,
headers={"Authorization": api_key},
json={"query": query, "variables": variables},
timeout=30,
)
r.raise_for_status()
return r.json()
def linear_issue_exists(ghsa_id: str, api_key: str) -> bool:
query = (
"query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) "
"{ nodes { id } } }"
)
resp = linear_call(query, {"q": ghsa_id}, api_key)
return len(resp.get("data", {}).get("issues", {}).get("nodes", [])) > 0
def linear_create_issue(input_data: dict[str, Any], api_key: str) -> dict[str, str] | None:
query = (
"mutation($input: IssueCreateInput!) { issueCreate(input: $input) "
"{ success issue { identifier url } } }"
)
resp = linear_call(query, {"input": input_data}, api_key)
create = resp.get("data", {}).get("issueCreate") or {}
if not create.get("success"):
return None
return create.get("issue")
def reporter_login(advisory: dict[str, Any]) -> str:
for credit in advisory.get("credits") or []:
user = (credit or {}).get("user") or {}
if user.get("login"):
return user["login"]
return "unknown"
def cvss_score(advisory: dict[str, Any]) -> str:
score = (advisory.get("cvss") or {}).get("score")
return str(score) if score is not None else "n/a"
def build_description(adv: dict[str, Any]) -> str:
return (
f"**GHSA:** {adv['ghsa_id']}\n"
f"**CVE:** {adv.get('cve_id') or 'n/a'}\n"
f"**Severity:** {adv.get('severity') or 'unknown'} (CVSS {cvss_score(adv)})\n"
f"**Reporter:** {reporter_login(adv)}\n"
f"**Reported:** {(adv.get('created_at') or '').split('T')[0]}\n"
f"**Advisory:** {adv['html_url']}\n\n"
f"---\n\n"
f"{adv.get('description') or 'No description provided.'}"
)
def post_discord(adv: dict[str, Any], issue: dict[str, str], webhook_url: str) -> None:
severity = adv.get("severity") or "unknown"
title = f"[{adv['ghsa_id']}] {adv['summary']}"[:250]
payload = {
"username": "GHSA Sync",
"embeds": [
{
"title": title,
"url": issue["url"],
"color": SEVERITY_COLOR.get(severity, DEFAULT_COLOR),
"fields": [
{"name": "Linear", "value": issue["identifier"], "inline": True},
{
"name": "Severity",
"value": f"{severity} (CVSS {cvss_score(adv)})",
"inline": True,
},
{
"name": "Advisory",
"value": f"[GitHub]({adv['html_url']})",
"inline": True,
},
],
}
],
}
try:
requests.post(webhook_url, json=payload, timeout=10)
except requests.RequestException:
pass
def main() -> int:
repo = required_env("GITHUB_REPOSITORY")
gh_token = required_env("GHSA_READ_TOKEN")
linear_api_key = required_env("LINEAR_API_KEY")
team_id = required_env("LINEAR_TEAM_ID")
project_id = required_env("LINEAR_PROJECT_ID")
label_id = required_env("LINEAR_LABEL_ID")
discord_webhook = os.environ.get("DISCORD_WEBHOOK_URL") or None
advisories = fetch_triage_advisories(repo, gh_token)
print(f"Fetched {len(advisories)} triage advisories")
created = skipped = failed = 0
for adv in advisories:
ghsa_id = adv.get("ghsa_id")
if not ghsa_id:
failed += 1
continue
try:
if linear_issue_exists(ghsa_id, linear_api_key):
skipped += 1
continue
severity = adv.get("severity") or "unknown"
issue = linear_create_issue(
{
"title": f"[{ghsa_id}] {adv.get('summary', '')}",
"description": build_description(adv),
"teamId": team_id,
"projectId": project_id,
"labelIds": [label_id],
"priority": SEVERITY_PRIORITY.get(severity, 3),
},
linear_api_key,
)
except requests.RequestException:
failed += 1
continue
if not issue:
failed += 1
continue
created += 1
if discord_webhook:
post_discord(adv, issue, discord_webhook)
print(f"Created {created}, skipped {skipped}, failed {failed}")
return 1 if failed > 0 else 0
if __name__ == "__main__":
sys.exit(main())
+13 -81
View File
@@ -5,93 +5,25 @@ on:
- cron: '0 4 * * *' # daily at 09:30 IST
workflow_dispatch: {}
permissions:
contents: read
jobs:
sync:
runs-on: ubuntu-latest
permissions:
security-events: read
steps:
- name: Fetch triage advisories
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api --paginate \
-H "Accept: application/vnd.github+json" \
"/repos/${{ github.repository }}/security-advisories?state=triage&per_page=100" \
| jq -cs 'add | [.[] | {
ghsa_id, cve_id, summary, severity, state, html_url,
description, created_at,
cvss_score: .cvss.score,
reporter: ([.credits[]?.user.login] | first // "unknown")
}]' > advisories.json
echo "Fetched $(jq 'length' advisories.json) triage advisories"
- name: Create Linear issues for new advisories
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install requests==2.32.3
- name: Sync advisories
env:
GHSA_READ_TOKEN: ${{ secrets.GHSA_READ_TOKEN }}
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }}
LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }}
run: |
created_count=0
skipped_count=0
failed_count=0
while read -r advisory; do
ghsa_id=$(printf '%s' "$advisory" | jq -r '.ghsa_id')
summary=$(printf '%s' "$advisory" | jq -r '.summary')
severity=$(printf '%s' "$advisory" | jq -r '.severity // "unknown"')
cve_id=$(printf '%s' "$advisory" | jq -r '.cve_id // "n/a"')
cvss=$(printf '%s' "$advisory" | jq -r '.cvss_score // "n/a"')
reporter=$(printf '%s' "$advisory" | jq -r '.reporter')
html_url=$(printf '%s' "$advisory" | jq -r '.html_url')
created_date=$(printf '%s' "$advisory" | jq -r '.created_at' | cut -dT -f1)
description=$(printf '%s' "$advisory" | jq -r '.description // "No description provided."')
existing=$(curl -s -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d "$(jq -n --arg q "$ghsa_id" '{query: "query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) { nodes { id } } }", variables: {q: $q}}')" \
| jq '.data.issues.nodes | length')
if [ "${existing:-0}" -gt 0 ] 2>/dev/null; then
skipped_count=$((skipped_count+1))
continue
fi
priority=3
case "$severity" in
critical) priority=1 ;;
high) priority=2 ;;
medium) priority=3 ;;
low) priority=4 ;;
esac
title="[$ghsa_id] $summary"
body=$(printf '**GHSA:** %s\n**CVE:** %s\n**Severity:** %s (CVSS %s)\n**Reporter:** %s\n**Reported:** %s\n**Advisory:** %s\n\n---\n\n%s' \
"$ghsa_id" "$cve_id" "$severity" "$cvss" "$reporter" "$created_date" "$html_url" "$description")
success=$(curl -s -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d "$(jq -n \
--arg title "$title" \
--arg body "$body" \
--arg teamId "$LINEAR_TEAM_ID" \
--arg projectId "$LINEAR_PROJECT_ID" \
--arg labelId "$LINEAR_LABEL_ID" \
--argjson priority "$priority" \
'{
query: "mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success } }",
variables: {input: {title: $title, description: $body, teamId: $teamId, projectId: $projectId, labelIds: [$labelId], priority: $priority}}
}')" | jq -r '.data.issueCreate.success // false')
if [ "$success" = "true" ]; then
created_count=$((created_count+1))
else
failed_count=$((failed_count+1))
fi
done < <(jq -c '.[]' advisories.json)
echo "Created $created_count, skipped $skipped_count, failed $failed_count"
if [ "$failed_count" -gt 0 ]; then
exit 1
fi
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
run: python3 .github/scripts/ghsa_linear_sync.py
+2 -2
View File
@@ -195,10 +195,10 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.9.1'
gem 'ai-agents', '>= 0.10.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm', '>= 1.14.1'
gem 'ruby_llm-schema'
gem 'cld3', '~> 3.7'
+21 -21
View File
@@ -126,8 +126,8 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.9.1)
ruby_llm (~> 1.9.1)
ai-agents (0.10.0)
ruby_llm (~> 1.14)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
@@ -212,7 +212,7 @@ GEM
logger
msgpack
datadog-ruby_core_source (3.4.1)
date (3.4.1)
date (3.5.1)
debug (1.8.0)
irb (>= 1.5.0)
reline (>= 0.3.1)
@@ -307,8 +307,8 @@ GEM
faraday-mashify (1.0.0)
faraday (~> 2.0)
hashie
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.2)
net-http (~> 0.5)
faraday-net_http_persistent (2.1.0)
@@ -466,7 +466,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.2)
json (2.19.5)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -574,7 +574,7 @@ GEM
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.20)
net-imap (0.4.24)
date
net-protocol
net-pop (0.1.2)
@@ -590,14 +590,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.19.1)
nokogiri (1.19.3)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.1-arm64-darwin)
nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-darwin)
nokogiri (1.19.3-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-gnu)
nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -684,7 +684,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.5)
rack (3.2.6)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -699,7 +699,7 @@ GEM
rack (>= 3.0.0, < 4)
rack-proxy (0.7.7)
rack
rack-session (2.1.1)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.1.0)
@@ -835,17 +835,17 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.9.2)
ruby_llm (1.15.0)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
faraday-multipart (>= 1)
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1.0)
ruby_llm-schema (~> 0.2.1)
marcel (~> 1)
ruby_llm-schema (~> 0)
zeitwerk (~> 2)
ruby_llm-schema (0.2.5)
ruby_llm-schema (0.3.0)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
@@ -961,7 +961,7 @@ GEM
time_diff (0.3.0)
activesupport
i18n
timeout (0.4.3)
timeout (0.6.1)
trailblazer-option (0.1.2)
twilio-ruby (7.6.0)
faraday (>= 0.9, < 3.0)
@@ -1019,7 +1019,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.7.4)
zeitwerk (2.7.5)
PLATFORMS
arm64-darwin-20
@@ -1039,7 +1039,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.9.1)
ai-agents (>= 0.10.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -1144,7 +1144,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
ruby_llm (>= 1.8.2)
ruby_llm (>= 1.14.1)
ruby_llm-schema
scout_apm
scss_lint
@@ -28,6 +28,10 @@ class Messages::Messenger::MessageBuilder
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
)
# The Attachment row is saved before the blob is attached, so the
# after_create_commit broadcast bails on `file.attached?`. Re-fire here
# for audio so the bubble updates without waiting on transcription.
attachment.message&.reload&.send_update_event if attachment.file_type.to_sym == :audio
end
def attachment_params(attachment)
+15
View File
@@ -27,6 +27,8 @@ class NotificationBuilder
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
# skip notifications for blocked conversations except for user mentions
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
# respect conversation access (inbox/team membership and custom-role permissions)
return unless user_can_access_conversation?
user.notifications.create!(
notification_type: notification_type,
@@ -36,4 +38,17 @@ class NotificationBuilder
secondary_actor: secondary_actor || current_user
)
end
def user_can_access_conversation?
conversation = primary_actor.is_a?(Conversation) ? primary_actor : primary_actor.try(:conversation)
return true if conversation.blank?
account_user = AccountUser.find_by(account_id: account.id, user_id: user.id)
return false if account_user.blank?
ConversationPolicy.new(
{ user: user, account: account, account_user: account_user },
conversation
).show?
end
end
+15 -4
View File
@@ -31,13 +31,24 @@ class V2::Reports::BotMetricsBuilder
end
def bot_resolutions_count
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
created_at: range).distinct.count
# Exclude conversations that also had a handoff in the same range — handoff wins
account.reporting_events.joins(:conversation).select(:conversation_id)
.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
.distinct.count
end
def bot_handoffs_count
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
created_at: range).distinct.count
account.reporting_events.joins(:conversation).select(:conversation_id)
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
.distinct.count
end
def bot_handoff_conversation_ids_subquery
account.reporting_events
.where(name: :conversation_bot_handoff, created_at: range)
.where.not(conversation_id: nil)
.select(:conversation_id)
end
def bot_resolution_rate
@@ -0,0 +1,18 @@
class Api::V1::Accounts::Contacts::AttachmentsController < Api::V1::Accounts::Contacts::BaseController
RESULTS_PER_PAGE = 100
def index
conversations = Conversations::PermissionFilterService.new(
Current.account.conversations.where(contact_id: @contact.id),
Current.user,
Current.account
).perform
@attachments = Attachment.where(message_id: Message.where(conversation_id: conversations).select(:id))
.includes({ file_attachment: :blob }, message: [:conversation, :inbox, { sender: { avatar_attachment: :blob } }])
.order(created_at: :desc)
.page(params[:page])
.per(RESULTS_PER_PAGE)
@attachments_count = @attachments.total_count
end
end
@@ -28,7 +28,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def attachments
@attachments_count = @conversation.attachments.count
@attachments = @conversation.attachments
.includes(:message)
.includes({ file_attachment: :blob }, message: [:inbox, { sender: { avatar_attachment: :blob } }])
.order(created_at: :desc)
.page(attachment_params[:page])
.per(ATTACHMENT_RESULTS_PER_PAGE)
@@ -1,6 +1,7 @@
class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController
before_action :fetch_custom_attributes_definitions, except: [:create]
before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy]
before_action :check_authorization
DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
def index; end
@@ -9,7 +9,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
def index
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
@inboxes = policy_scope(Current.account.inboxes)
.includes(:channel, :portal, :working_hours, { avatar_attachment: :blob })
.order_by_name
end
def show; end
@@ -85,7 +87,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def fetch_agent_bot
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:agent_bot]) if params[:agent_bot]
end
def create_channel
@@ -18,7 +18,16 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
end
def destroy
label_title = @label.title
account_id = Current.account.id
label_deleted_at = Time.current
@label.destroy!
Labels::RemoveAssociationsJob.perform_later(
label_title: label_title,
account_id: account_id,
label_deleted_at: label_deleted_at
)
head :ok
end
@@ -41,9 +41,9 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
def destroy_all
if params[:type] == 'read'
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read)
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :read)
else
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all)
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :all)
end
head :ok
end
@@ -69,7 +69,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
end
def fetch_notification
@notification = current_user.notifications.find(params[:id])
@notification = current_user.notifications.where(account_id: Current.account.id).find(params[:id])
end
def set_current_page
@@ -18,7 +18,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params))
@portal.custom_domain = parsed_custom_domain
@portal.save!
process_attached_logo
process_attached_logo if params[:blob_id].present?
end
def update
@@ -87,7 +87,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
return {} unless permitted_params.key?(:inbox_id)
return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank?
inbox = Inbox.find(permitted_params[:inbox_id])
inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
return {} unless inbox.web_widget?
{ channel_web_widget_id: inbox.channel.id }
@@ -98,6 +98,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def parsed_custom_domain
return @portal.custom_domain if @portal.custom_domain.blank?
domain = URI.parse(@portal.custom_domain)
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
end
@@ -111,7 +111,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
end
def settings_params
@@ -8,7 +8,8 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
end
def destroy
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription = current_user.notification_subscriptions
.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription.destroy! if notification_subscription.present?
head :ok
end
@@ -83,6 +83,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end
def set_message
@message = @web_widget.inbox.messages.find(permitted_params[:id])
# `conversation.messages.find` would be simpler, but `conversation` is `conversations.last`,
# which means a visitor with more than one open thread could not edit a message in any
# but their most recent one. Scoping across all of the visitor's conversations keeps the
# happy path correct for that future multi-conversation widget flow.
@message = Message.where(conversation_id: conversations.select(:id)).find(permitted_params[:id])
end
end
@@ -25,6 +25,14 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
private
def render_create_error_not_confirmed
render_error(
:unauthorized,
I18n.t('devise_token_auth.sessions.not_confirmed', email: @resource.email),
error_code: 'user_not_confirmed'
)
end
def find_user_for_authentication
return nil unless params[:email].present? && params[:password].present?
@@ -24,6 +24,10 @@ class Public::Api::V1::InboxesController < PublicController
def set_conversation
return if params[:conversation_id].blank?
@conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
@conversation = if @contact_inbox.hmac_verified?
@contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
else
@contact_inbox.conversations.find_by!(display_id: params[:conversation_id])
end
end
end
+8 -3
View File
@@ -1,7 +1,12 @@
class SwaggerController < ApplicationController
def respond
if Rails.env.development? || Rails.env.test?
render inline: Rails.root.join('swagger', derived_path).read
swagger_root = Rails.root.join('swagger')
file_path = swagger_root.join(derived_path).cleanpath
return head :not_found unless file_path.to_s.start_with?("#{swagger_root}/") && file_path.file?
render inline: file_path.read
else
head :not_found
end
@@ -11,8 +16,8 @@ class SwaggerController < ApplicationController
def derived_path
params[:path] ||= 'index.html'
path = Rack::Utils.clean_path_info(params[:path])
path << ".#{Rack::Utils.clean_path_info(params[:format])}" unless path.ends_with?(params[:format].to_s)
path = Rack::Utils.clean_path_info(params[:path]).delete_prefix('/')
path << ".#{Rack::Utils.clean_path_info(params[:format]).delete_prefix('/')}" unless path.ends_with?(params[:format].to_s)
path
end
end
+18 -10
View File
@@ -17,15 +17,12 @@ module Api::V1::InboxesHelper
def validate_imap(channel_data)
return unless channel_data.key?('imap_enabled') && channel_data[:imap_enabled]
Mail.defaults do
retriever_method :imap, { address: channel_data[:imap_address],
port: channel_data[:imap_port],
user_name: channel_data[:imap_login],
password: channel_data[:imap_password],
enable_ssl: channel_data[:imap_enable_ssl] }
end
# Validate the user-selected auth mechanism before opening the connection.
authentication = Imap::Authentication.validate_user_configurable!(channel_data[:imap_authentication])
check_imap_connection(channel_data)
# Use the same auth adapter as the fetch service so LOGIN uses the IMAP LOGIN command,
# not SASL AUTH=LOGIN.
check_imap_connection(channel_data, authentication)
end
def validate_smtp(channel_data)
@@ -37,8 +34,8 @@ module Api::V1::InboxesHelper
check_smtp_connection(channel_data, smtp)
end
def check_imap_connection(channel_data)
Mail.connection {} # rubocop:disable:block
def check_imap_connection(channel_data, authentication)
imap = open_imap_connection(channel_data, authentication)
rescue SocketError => e
raise StandardError, I18n.t('errors.inboxes.imap.socket_error')
rescue Net::IMAP::NoResponseError => e
@@ -53,9 +50,20 @@ module Api::V1::InboxesHelper
rescue StandardError => e
raise StandardError, e.message
ensure
imap.disconnect if imap.present? && !imap.disconnected?
Rails.logger.error "[Api::V1::InboxesHelper] check_imap_connection failed with #{e.message}" if e.present?
end
def open_imap_connection(channel_data, authentication)
imap = build_imap_connection(channel_data)
Imap::Authentication.authenticate!(imap, authentication, channel_data[:imap_login], channel_data[:imap_password])
imap
end
def build_imap_connection(channel_data)
Net::IMAP.new(channel_data[:imap_address], port: channel_data[:imap_port], ssl: channel_data[:imap_enable_ssl])
end
def check_smtp_connection(channel_data, smtp)
smtp.open_timeout = 10
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
+7 -4
View File
@@ -53,13 +53,12 @@ module ReportHelper
end
def resolutions
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved,
created_at: range)
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, created_at: range)
end
def bot_resolutions
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved,
created_at: range)
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
end
def bot_handoffs
@@ -67,6 +66,10 @@ module ReportHelper
created_at: range).distinct
end
def bot_handoff_conversation_ids_subquery
bot_handoffs
end
def avg_first_response_time
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id))
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
@@ -6,15 +6,22 @@ class CaptainDocument extends ApiClient {
super('captain/documents', { accountScoped: true });
}
get({ page = 1, searchKey, assistantId } = {}) {
get({ page = 1, searchKey, assistantId, filter, source, sort } = {}) {
return axios.get(this.url, {
params: {
page,
searchKey,
search_key: searchKey,
assistant_id: assistantId,
filter,
source,
sort,
},
});
}
sync(id) {
return axios.post(`${this.url}/${id}/sync`);
}
}
export default new CaptainDocument();
@@ -1,45 +0,0 @@
/* global axios */
import ApiClient from '../../ApiClient';
class WhatsappCallsAPI extends ApiClient {
constructor() {
super('whatsapp_calls', { accountScoped: true });
}
show(callId) {
return axios.get(`${this.url}/${callId}`).then(r => r.data);
}
initiate(conversationId, sdpOffer) {
return axios
.post(`${this.url}/initiate`, {
conversation_id: conversationId,
sdp_offer: sdpOffer,
})
.then(r => r.data);
}
accept(callId, sdpAnswer) {
return axios
.post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer })
.then(r => r.data);
}
reject(callId) {
return axios.post(`${this.url}/${callId}/reject`).then(r => r.data);
}
terminate(callId) {
return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data);
}
uploadRecording(callId, blob, filename = 'call-recording.webm') {
const formData = new FormData();
formData.append('recording', blob, filename);
return axios
.post(`${this.url}/${callId}/upload_recording`, formData)
.then(r => r.data);
}
}
export default new WhatsappCallsAPI();
+43 -17
View File
@@ -1,21 +1,12 @@
/* global axios */
import ApiClient from './ApiClient';
export const buildCompanyParams = (page, sort) => {
let params = `page=${page}`;
if (sort) {
params = `${params}&sort=${sort}`;
}
return params;
};
export const buildSearchParams = (query, page, sort) => {
let params = `q=${encodeURIComponent(query)}&page=${page}`;
if (sort) {
params = `${params}&sort=${sort}`;
}
return params;
};
const buildParams = params =>
new URLSearchParams(
Object.entries(params).filter(
([key, value]) => value !== undefined && (value !== '' || key === 'q')
)
).toString();
class CompanyAPI extends ApiClient {
constructor() {
@@ -24,14 +15,49 @@ class CompanyAPI extends ApiClient {
get(params = {}) {
const { page = 1, sort = 'name' } = params;
const requestURL = `${this.url}?${buildCompanyParams(page, sort)}`;
const requestURL = `${this.url}?${buildParams({ page, sort })}`;
return axios.get(requestURL);
}
search(query = '', page = 1, sort = 'name') {
const requestURL = `${this.url}/search?${buildSearchParams(query, page, sort)}`;
const requestURL = `${this.url}/search?${buildParams({ q: query, page, sort })}`;
return axios.get(requestURL);
}
listContacts(id, page = 1) {
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`);
}
listNotes(id) {
return axios.get(`${this.url}/${id}/notes`);
}
listConversations(id) {
return axios.get(`${this.url}/${id}/conversations`);
}
searchContacts(id, query = '', page = 1) {
const requestURL = `${this.url}/${id}/contacts/search?${buildParams({ q: query, page })}`;
return axios.get(requestURL);
}
createContact(id, payload) {
return axios.post(`${this.url}/${id}/contacts`, payload);
}
removeContact(id, contactId) {
return axios.delete(`${this.url}/${id}/contacts/${contactId}`);
}
destroyCustomAttributes(id, customAttributes) {
return axios.post(`${this.url}/${id}/destroy_custom_attributes`, {
custom_attributes: customAttributes,
});
}
destroyAvatar(id) {
return axios.delete(`${this.url}/${id}/avatar`);
}
}
export default new CompanyAPI();
+1 -2
View File
@@ -47,10 +47,9 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/labels`);
}
initiateCall(contactId, inboxId, conversationId = null) {
initiateCall(contactId, inboxId) {
return axios.post(`${this.url}/${contactId}/call`, {
inbox_id: inboxId,
conversation_id: conversationId,
});
}
@@ -1,7 +1,4 @@
import companyAPI, {
buildCompanyParams,
buildSearchParams,
} from '../companies';
import companyAPI from '../companies';
import ApiClient from '../ApiClient';
describe('#CompanyAPI', () => {
@@ -9,7 +6,6 @@ describe('#CompanyAPI', () => {
expect(companyAPI).toBeInstanceOf(ApiClient);
expect(companyAPI).toHaveProperty('get');
expect(companyAPI).toHaveProperty('show');
expect(companyAPI).toHaveProperty('create');
expect(companyAPI).toHaveProperty('update');
expect(companyAPI).toHaveProperty('delete');
expect(companyAPI).toHaveProperty('search');
@@ -32,111 +28,69 @@ describe('#CompanyAPI', () => {
window.axios = originalAxios;
});
it('#get with default params', () => {
it('#get includes pagination and sorting params', () => {
companyAPI.get({});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=1&sort=name'
);
});
it('#get with page and sort params', () => {
companyAPI.get({ page: 2, sort: 'domain' });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=2&sort=domain'
);
});
it('#get with descending sort', () => {
companyAPI.get({ page: 1, sort: '-created_at' });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=1&sort=-created_at'
);
});
it('#search with query', () => {
companyAPI.search('acme', 1, 'name');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=acme&page=1&sort=name'
);
});
it('#search with special characters in query', () => {
it('#search encodes query params', () => {
companyAPI.search('acme & co', 2, 'domain');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=acme%20%26%20co&page=2&sort=domain'
'/api/v1/companies/search?q=acme+%26+co&page=2&sort=domain'
);
});
it('#search with descending sort', () => {
companyAPI.search('test', 1, '-created_at');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=test&page=1&sort=-created_at'
);
});
it('#search with empty query', () => {
it('#search keeps empty query param for backend validation', () => {
companyAPI.search('', 1, 'name');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=&page=1&sort=name'
);
});
});
});
describe('#buildCompanyParams', () => {
it('returns correct string with page only', () => {
expect(buildCompanyParams(1)).toBe('page=1');
});
it('returns correct string with page and sort', () => {
expect(buildCompanyParams(1, 'name')).toBe('page=1&sort=name');
});
it('returns correct string with different page', () => {
expect(buildCompanyParams(3, 'domain')).toBe('page=3&sort=domain');
});
it('returns correct string with descending sort', () => {
expect(buildCompanyParams(1, '-created_at')).toBe(
'page=1&sort=-created_at'
);
});
it('returns correct string without sort parameter', () => {
expect(buildCompanyParams(2, '')).toBe('page=2');
});
});
describe('#buildSearchParams', () => {
it('returns correct string with all parameters', () => {
expect(buildSearchParams('acme', 1, 'name')).toBe(
'q=acme&page=1&sort=name'
);
});
it('returns correct string with special characters', () => {
expect(buildSearchParams('acme & co', 2, 'domain')).toBe(
'q=acme%20%26%20co&page=2&sort=domain'
);
});
it('returns correct string with empty query', () => {
expect(buildSearchParams('', 1, 'name')).toBe('q=&page=1&sort=name');
});
it('returns correct string without sort parameter', () => {
expect(buildSearchParams('test', 1, '')).toBe('q=test&page=1');
});
it('returns correct string with descending sort', () => {
expect(buildSearchParams('company', 3, '-created_at')).toBe(
'q=company&page=3&sort=-created_at'
);
});
it('encodes special characters correctly', () => {
expect(buildSearchParams('test@example.com', 1, 'name')).toBe(
'q=test%40example.com&page=1&sort=name'
);
it('#destroyAvatar deletes the company avatar endpoint', () => {
companyAPI.destroyAvatar(1);
expect(axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/companies/1/avatar'
);
});
it('#listContacts fetches company contacts', () => {
companyAPI.listContacts(1, 2);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/1/contacts?page=2'
);
});
it('#searchContacts encodes contact search params', () => {
companyAPI.searchContacts(1, 'jane & co', 3);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/1/contacts/search?q=jane+%26+co&page=3'
);
});
it('#createContact links a contact to the company', () => {
companyAPI.createContact(1, { contact_id: 2 });
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/companies/1/contacts',
{ contact_id: 2 }
);
});
it('#removeContact unlinks a contact from the company', () => {
companyAPI.removeContact(1, 2);
expect(axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/companies/1/contacts/2'
);
});
it('#destroyCustomAttributes removes company custom attributes', () => {
companyAPI.destroyCustomAttributes(1, ['plan']);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/companies/1/destroy_custom_attributes',
{ custom_attributes: ['plan'] }
);
});
});
});
@@ -1,7 +1,7 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { formatDistanceToNow } from 'date-fns';
import { dynamicTime } from 'shared/helpers/timeHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
@@ -12,9 +12,8 @@ const props = defineProps({
name: { type: String, default: '' },
domain: { type: String, default: '' },
contactsCount: { type: Number, default: 0 },
description: { type: String, default: '' },
avatarUrl: { type: String, default: '' },
updatedAt: { type: [String, Number], default: null },
lastActivityAt: { type: [String, Number], default: null },
});
const emit = defineEmits(['showCompany']);
@@ -27,15 +26,21 @@ const displayName = computed(() => props.name || t('COMPANIES.UNNAMED'));
const avatarSource = computed(() => props.avatarUrl || null);
const formattedUpdatedAt = computed(() => {
if (!props.updatedAt) return '';
return formatDistanceToNow(new Date(props.updatedAt), { addSuffix: true });
const hasContacts = computed(() => Number(props.contactsCount || 0) > 0);
const contactsCountLabel = computed(() =>
t('COMPANIES.CONTACTS_COUNT', { n: Number(props.contactsCount || 0) })
);
const formattedLastActivityAt = computed(() => {
if (!props.lastActivityAt) return '';
return dynamicTime(props.lastActivityAt);
});
</script>
<template>
<CardLayout layout="row" @click="onClickViewDetails">
<div class="flex items-center justify-start flex-1 gap-4">
<div class="flex items-center justify-start flex-1 gap-4 cursor-pointer">
<Avatar
:username="displayName"
:src="avatarSource"
@@ -51,42 +56,29 @@ const formattedUpdatedAt = computed(() => {
{{ displayName }}
</span>
<span
v-if="domain && description"
v-if="hasContacts"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
>
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
<span class="truncate">{{ domain }}</span>
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
{{ contactsCountLabel }}
</span>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center min-w-0">
<span
v-if="domain && !description"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
v-if="domain"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate cursor-text"
@click.stop
>
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
<span class="truncate">{{ domain }}</span>
</span>
<span v-if="description" class="text-sm text-n-slate-11 truncate">
{{ description }}
</span>
<div
v-if="(description || domain) && contactsCount"
class="w-px h-3 bg-n-slate-6"
/>
<span
v-if="contactsCount"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
>
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
{{ t('COMPANIES.CONTACTS_COUNT', { n: contactsCount }) }}
</span>
</div>
<span
v-if="updatedAt"
v-if="lastActivityAt"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 flex-shrink-0"
>
{{ formattedUpdatedAt }}
{{ formattedLastActivityAt }}
</span>
</div>
</div>
@@ -0,0 +1,125 @@
<script setup>
import { ref, useSlots } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
breadcrumbItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['back']);
const slots = useSlots();
const isSidebarOpen = ref(false);
const toggleSidebar = () => {
isSidebarOpen.value = !isSidebarOpen.value;
};
const closeMobileSidebar = () => {
if (!isSidebarOpen.value) {
return;
}
isSidebarOpen.value = false;
};
</script>
<template>
<section
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
>
<div
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
>
<header class="sticky top-0 z-10 px-6 3xl:px-0">
<div class="w-full mx-auto max-w-[40.625rem]">
<div
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
>
<Breadcrumb :items="breadcrumbItems" @click="emit('back')" />
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
<slot />
</div>
</main>
</div>
<div
v-if="slots.sidebar"
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
</div>
<div
v-if="slots.sidebar"
class="lg:hidden fixed top-0 ltr:right-0 rtl:left-0 h-full z-50 flex justify-end transition-all duration-200 ease-in-out"
:class="isSidebarOpen ? 'w-full' : 'w-16'"
>
<div
v-on-click-outside="[
closeMobileSidebar,
{ ignore: ['#details-sidebar-content'] },
]"
class="flex items-start p-1 w-fit h-fit relative order-1 xs:top-24 top-28 transition-all bg-n-solid-2 border border-n-weak duration-500 ease-in-out"
:class="[
isSidebarOpen
? 'justify-end ltr:rounded-l-full rtl:rounded-r-full ltr:rounded-r-none rtl:rounded-l-none'
: 'justify-center rounded-full ltr:mr-6 rtl:ml-6',
]"
>
<Button
ghost
slate
sm
class="!rounded-full rtl:rotate-180"
:class="{ 'bg-n-alpha-2': isSidebarOpen }"
:icon="
isSidebarOpen
? 'i-lucide-panel-right-close'
: 'i-lucide-panel-right-open'
"
data-details-sidebar-toggle
@click="toggleSidebar"
/>
</div>
<Transition
enter-active-class="transition-transform duration-200 ease-in-out"
leave-active-class="transition-transform duration-200 ease-in-out"
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
>
<div
v-if="isSidebarOpen"
id="details-sidebar-content"
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
</div>
</Transition>
</div>
</section>
</template>
@@ -2,6 +2,7 @@
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CompanySortMenu from './components/CompanySortMenu.vue';
import CompanyMoreActions from './components/CompanyMoreActions.vue';
defineProps({
showSearch: { type: Boolean, default: true },
@@ -11,7 +12,7 @@ defineProps({
activeOrdering: { type: String, default: '' },
});
const emit = defineEmits(['search', 'update:sort']);
const emit = defineEmits(['search', 'update:sort', 'create']);
</script>
<template>
@@ -19,22 +20,15 @@ const emit = defineEmits(['search', 'update:sort']);
<div
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
>
<span class="text-heading-1 truncate text-n-slate-12">
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-row flex-shrink-0 gap-2">
<div class="flex items-center">
<CompanySortMenu
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
</div>
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4">
<div v-if="showSearch" class="flex items-center gap-2 w-full">
<Input
:model-value="searchValue"
type="search"
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
:placeholder="$t('COMPANIES.SEARCH_PLACEHOLDER')"
:custom-input-class="[
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
]"
@@ -49,6 +43,14 @@ const emit = defineEmits(['search', 'update:sort']);
</template>
</Input>
</div>
<div class="flex items-center flex-shrink-0 gap-2">
<CompanySortMenu
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
<CompanyMoreActions @create="emit('create')" />
</div>
</div>
</div>
</header>
@@ -0,0 +1,45 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const emit = defineEmits(['create']);
const { t } = useI18n();
const showActionsDropdown = ref(false);
const menuItems = [
{
label: t('COMPANIES.ACTIONS.CREATE'),
action: 'create',
value: 'create',
icon: 'i-lucide-plus',
},
];
const handleAction = ({ action }) => {
if (action === 'create') emit('create');
showActionsDropdown.value = false;
};
</script>
<template>
<div v-on-clickaway="() => (showActionsDropdown = false)" class="relative">
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
variant="ghost"
size="sm"
:class="showActionsDropdown ? 'bg-n-alpha-2' : ''"
@click="showActionsDropdown = !showActionsDropdown"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="ltr:right-0 rtl:left-0 mt-1 w-52 top-full"
@action="handleAction($event)"
/>
</div>
</template>
@@ -35,6 +35,10 @@ const sortMenus = [
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
value: 'created_at',
},
{
label: t('COMPANIES.SORT_BY.OPTIONS.LAST_ACTIVITY_AT'),
value: 'last_activity_at',
},
{
label: t('COMPANIES.SORT_BY.OPTIONS.CONTACTS_COUNT'),
value: 'contacts_count',
@@ -101,6 +105,7 @@ const handleOrderChange = value => {
:model-value="activeSort"
:options="sortMenus"
:label="activeSortLabel"
sub-menu-position="left"
@update:model-value="handleSortChange"
/>
</div>
@@ -112,6 +117,7 @@ const handleOrderChange = value => {
:model-value="activeOrdering"
:options="orderingMenus"
:label="activeOrderingLabel"
sub-menu-position="left"
@update:model-value="handleOrderChange"
/>
</div>
@@ -12,7 +12,12 @@ defineProps({
showPaginationFooter: { type: Boolean, default: true },
});
const emit = defineEmits(['update:currentPage', 'update:sort', 'search']);
const emit = defineEmits([
'update:currentPage',
'update:sort',
'search',
'create',
]);
const updateCurrentPage = page => {
emit('update:currentPage', page);
@@ -31,6 +36,7 @@ const updateCurrentPage = page => {
:active-ordering="activeOrdering"
@search="emit('search', $event)"
@update:sort="emit('update:sort', $event)"
@create="emit('create')"
/>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full mx-auto max-w-5xl py-4">
@@ -0,0 +1,110 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
defineProps({
isLoading: { type: Boolean, default: false },
});
const emit = defineEmits(['create']);
const { t } = useI18n();
const dialogRef = ref(null);
const form = reactive({ name: '', domain: '', description: '' });
const isFormInvalid = computed(() => !form.name.trim());
const resetForm = () => {
form.name = '';
form.domain = '';
form.description = '';
};
const handleConfirm = () => {
if (isFormInvalid.value) return;
emit('create', {
name: form.name.trim(),
domain: form.domain.trim() || null,
description: form.description.trim() || null,
});
};
const closeDialog = () => {
dialogRef.value?.close();
};
const onSuccess = () => {
resetForm();
closeDialog();
};
defineExpose({ dialogRef, onSuccess });
</script>
<template>
<Dialog
ref="dialogRef"
width="3xl"
overflow-y-auto
@confirm="handleConfirm"
@close="resetForm"
>
<div class="flex flex-col gap-6">
<div class="flex flex-col items-start gap-2">
<span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('COMPANIES.CREATE.TITLE') }}
</span>
<div class="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
<Input
v-model="form.name"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.NAME')"
:disabled="isLoading"
custom-input-class="h-8 !pt-1 !pb-1 [&:not(.error,.focus)]:!outline-transparent"
autofocus
/>
<Input
v-model="form.domain"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.DOMAIN')"
:disabled="isLoading"
custom-input-class="h-8 !pt-1 !pb-1 [&:not(.error,.focus)]:!outline-transparent"
/>
</div>
</div>
<TextArea
v-model="form.description"
:placeholder="t('COMPANIES.DETAIL.PROFILE.DESCRIPTION_PLACEHOLDER')"
:disabled="isLoading"
:max-length="280"
class="w-full"
show-character-count
auto-height
/>
</div>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<Button
:label="t('DIALOG.BUTTONS.CANCEL')"
variant="link"
type="reset"
class="h-10 hover:!no-underline hover:text-n-brand"
@click="closeDialog"
/>
<Button
:label="t('COMPANIES.CREATE.ACTIONS.SAVE')"
color="blue"
type="submit"
:disabled="isFormInvalid || isLoading"
:is-loading="isLoading"
/>
</div>
</template>
</Dialog>
</template>
@@ -0,0 +1,354 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { debounce } from '@chatwoot/utils';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
const props = defineProps({
company: {
type: Object,
default: () => ({}),
},
contacts: {
type: Array,
default: () => [],
},
meta: {
type: Object,
default: () => ({}),
},
isLoading: {
type: Boolean,
default: false,
},
isBusy: {
type: Boolean,
default: false,
},
searchResults: {
type: Array,
default: () => [],
},
isSearching: {
type: Boolean,
default: false,
},
selectedContact: {
type: Object,
default: null,
},
});
const emit = defineEmits([
'cancelContactSelection',
'confirmContactSelection',
'removeContact',
'search',
'selectContact',
'update:currentPage',
]);
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const selectedContactId = ref(null);
const searchQuery = ref('');
const hasContacts = computed(() => props.contacts.length > 0);
const currentPage = computed(() => Number(props.meta?.page || 1));
const totalContacts = computed(() => Number(props.meta?.totalCount || 0));
const linkedContactIds = computed(
() => new Set(props.contacts.map(contact => Number(contact.id)))
);
const showPaginationFooter = computed(
() => hasContacts.value && totalContacts.value > props.contacts.length
);
const openContact = contactId => {
router.push({
name: 'contacts_edit',
params: {
accountId: route.params.accountId,
contactId,
},
});
};
const contactMeta = contact =>
[contact.email, contact.phoneNumber].filter(Boolean).join(' • ');
const contactName = contact =>
contact.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
const contactOptions = computed(() =>
props.searchResults
.filter(
contact =>
!contact.linkedToCurrentCompany &&
!linkedContactIds.value.has(Number(contact.id))
)
.map(contact => ({
value: contact.id,
label: [contactName(contact), contact.email, contact.phoneNumber]
.filter(Boolean)
.join(' · '),
}))
);
const emptyState = computed(() => {
if (props.isSearching) {
return t('COMPANIES.DETAIL.CONTACTS.LOADING');
}
return searchQuery.value.trim()
? t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.EMPTY')
: t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.INITIAL');
});
const selectedContactName = computed(() =>
props.selectedContact ? contactName(props.selectedContact) : ''
);
const selectedContactMeta = computed(() =>
props.selectedContact ? contactMeta(props.selectedContact) : ''
);
const selectedContactCompanyName = computed(
() => props.selectedContact?.company?.name || ''
);
const summaryRows = computed(() => [
{
key: 'company',
label: t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.COMPANY_LABEL'),
avatarName: props.company.name || t('COMPANIES.UNNAMED'),
avatarSrc: props.company.avatarUrl,
primary: props.company.name || t('COMPANIES.UNNAMED'),
secondary: props.company.domain,
},
{
key: 'contact',
label: t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CONTACT_LABEL'),
badge: selectedContactCompanyName.value
? t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CURRENT_COMPANY', {
companyName: selectedContactCompanyName.value,
})
: '',
avatarName: selectedContactName.value,
avatarSrc: props.selectedContact?.thumbnail,
primary: selectedContactName.value,
secondary: selectedContactMeta.value,
},
]);
const debouncedSearch = debounce(query => {
emit('search', query);
}, 300);
const handleSearch = query => {
searchQuery.value = query;
debouncedSearch(query.trim());
};
const handleContactSelect = contactId => {
const selectedContact = props.searchResults.find(
contact => contact.id === Number(contactId)
);
selectedContactId.value = null;
if (selectedContact) {
emit('selectContact', selectedContact);
}
};
</script>
<template>
<div class="flex flex-col gap-6 px-6 pb-8">
<div v-if="!selectedContact" class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label class="text-base text-n-slate-12">
{{ t('COMPANIES.DETAIL.CONTACTS.ACTIONS.ADD') }}
</label>
<span class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.DESCRIPTION') }}
</span>
</div>
<ComboBox
use-api-results
:model-value="selectedContactId"
:options="contactOptions"
:disabled="isBusy"
:empty-state="emptyState"
:search-placeholder="
t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.SEARCH_PLACEHOLDER')
"
:placeholder="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.ADD')"
class="[&>div>button]:bg-n-alpha-black2"
@search="handleSearch"
@update:model-value="handleContactSelect"
/>
</div>
<div v-else class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label class="text-base text-n-slate-12">
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CONFIRM_TITLE') }}
</label>
<span class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CONFIRM_DESCRIPTION') }}
</span>
</div>
<div class="flex flex-col gap-4">
<div
v-for="row in summaryRows"
:key="row.key"
class="flex flex-col gap-2"
>
<div class="flex items-center justify-between h-5 gap-2">
<label class="text-sm text-n-slate-12">
{{ row.label }}
</label>
<span
v-if="row.badge"
class="px-2 py-0.5 text-xs rounded-md text-n-amber-11 bg-n-alpha-2"
>
{{ row.badge }}
</span>
</div>
<div
class="border border-n-strong h-[60px] gap-2 flex items-center rounded-xl p-3"
>
<Avatar
:name="row.avatarName"
:src="row.avatarSrc"
:size="32"
rounded-full
hide-offline-status
/>
<div class="flex flex-col w-full min-w-0 gap-1">
<span
class="text-sm leading-4 font-medium truncate text-n-slate-12"
>
{{ row.primary }}
</span>
<span
v-if="row.secondary"
class="text-sm leading-4 truncate text-n-slate-11"
>
{{ row.secondary }}
</span>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between gap-3 mt-2">
<Button
variant="faded"
color="slate"
:label="t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
:disabled="isBusy"
@click="emit('cancelContactSelection')"
/>
<Button
:label="t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.ADD')"
class="w-full"
:is-loading="isBusy"
:disabled="isBusy"
@click="emit('confirmContactSelection')"
/>
</div>
</div>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<h4 class="text-sm font-medium text-n-slate-12">
{{ t('COMPANIES.DETAIL.SIDEBAR.TABS.CONTACTS') }}
</h4>
<span v-if="hasContacts" class="text-xs tabular-nums text-n-slate-11">
{{ t('COMPANIES.CONTACTS_COUNT', { n: totalContacts }) }}
</span>
</div>
<div
v-if="isLoading && !hasContacts"
class="py-8 text-sm text-center rounded-xl border border-dashed border-n-weak text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.CONTACTS.LOADING') }}
</div>
<div
v-else-if="!hasContacts"
class="py-8 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.CONTACTS.EMPTY') }}
</div>
<div v-else class="flex flex-col divide-y divide-n-weak">
<div
v-for="contact in contacts"
:key="contact.id"
class="flex items-center gap-2 py-3 group/contact"
>
<button
type="button"
class="flex items-center flex-1 min-w-0 !p-0 gap-3 text-start rounded-lg transition-colors text-n-slate-12 hover:text-n-blue-11 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-n-brand focus-visible:ring-offset-2 focus-visible:ring-offset-n-background"
@click="openContact(contact.id)"
>
<Avatar
:name="contactName(contact)"
:src="contact.thumbnail"
:size="32"
rounded-full
hide-offline-status
/>
<div class="min-w-0 space-y-0.5">
<span
class="text-sm font-medium leading-5 truncate text-n-slate-12"
>
{{ contactName(contact) }}
</span>
<p
v-if="contactMeta(contact)"
class="text-sm leading-5 truncate text-n-slate-11"
>
{{ contactMeta(contact) }}
</p>
</div>
</button>
<Button
icon="i-lucide-unlink"
color="slate"
variant="ghost"
size="xs"
class="shrink-0 opacity-70 transition-opacity sm:opacity-0 sm:group-hover/contact:opacity-100 sm:focus-visible:opacity-100"
:disabled="isBusy"
:title="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.REMOVE')"
:aria-label="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.REMOVE')"
@click.stop="emit('removeContact', contact.id)"
/>
</div>
</div>
<PaginationFooter
v-if="showPaginationFooter"
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalContacts"
:items-per-page="15"
class="!px-0 before:hidden bg-transparent"
@update:current-page="emit('update:currentPage', $event)"
/>
</div>
</div>
</template>
@@ -0,0 +1,94 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useCompaniesStore } from 'dashboard/stores/companies';
import ListAttribute from 'dashboard/components-next/CustomAttributes/ListAttribute.vue';
import CheckboxAttribute from 'dashboard/components-next/CustomAttributes/CheckboxAttribute.vue';
import DateAttribute from 'dashboard/components-next/CustomAttributes/DateAttribute.vue';
import OtherAttribute from 'dashboard/components-next/CustomAttributes/OtherAttribute.vue';
const props = defineProps({
companyId: {
type: Number,
required: true,
},
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const companiesStore = useCompaniesStore();
const { t } = useI18n();
const handleDelete = async () => {
try {
await companiesStore.deleteCustomAttributes({
id: props.companyId,
customAttributes: [props.attribute.attributeKey],
});
useAlert(t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.DELETE_SUCCESS'));
} catch (error) {
useAlert(
error?.response?.message ||
t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.DELETE_ERROR')
);
}
};
const handleUpdate = async value => {
try {
await companiesStore.update({
id: props.companyId,
customAttributes: {
[props.attribute.attributeKey]: value,
},
});
useAlert(t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.UPDATE_SUCCESS'));
} catch (error) {
useAlert(
error?.response?.message ||
t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.UPDATE_ERROR')
);
}
};
const componentMap = {
list: ListAttribute,
checkbox: CheckboxAttribute,
date: DateAttribute,
default: OtherAttribute,
};
const CurrentAttributeComponent = computed(
() =>
componentMap[props.attribute.attributeDisplayType] || componentMap.default
);
</script>
<template>
<div
class="grid grid-cols-[140px,1fr] group/attribute items-center w-full gap-2"
:class="isEditingView ? 'min-h-10' : 'min-h-11'"
>
<div class="flex items-center justify-between truncate">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
<component
:is="CurrentAttributeComponent"
:attribute="attribute"
:is-editing-view="isEditingView"
@update="handleUpdate"
@delete="handleDelete"
/>
</div>
</template>
@@ -0,0 +1,142 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import CompanyCustomAttributeItem from 'dashboard/components-next/Companies/CompanyDetail/CompanyCustomAttributeItem.vue';
const props = defineProps({
company: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const store = useStore();
const searchQuery = ref('');
const companyAttributes = useMapGetter('attributes/getCompanyAttributes');
const customAttributes = computed(() => props.company?.customAttributes || {});
const hasCompanyAttributes = computed(
() => companyAttributes.value?.length > 0
);
const processCompanyAttributes = (
attributes,
attributeValues,
filterCondition
) => {
if (!attributes.length) {
return [];
}
return attributes.reduce((result, attribute) => {
const { attributeKey } = attribute;
if (filterCondition(attributeKey, attributeValues)) {
result.push({
...attribute,
value: attributeValues[attributeKey] ?? '',
});
}
return result;
}, []);
};
const usedAttributes = computed(() =>
processCompanyAttributes(
companyAttributes.value,
customAttributes.value,
(key, values) => key in values
)
);
const unusedAttributes = computed(() =>
processCompanyAttributes(
companyAttributes.value,
customAttributes.value,
(key, values) => !(key in values)
)
);
const filteredUnusedAttributes = computed(() =>
unusedAttributes.value.filter(attribute =>
attribute.attributeDisplayName
.toLowerCase()
.includes(searchQuery.value.toLowerCase())
)
);
const unusedAttributesCount = computed(() => unusedAttributes.value.length);
const hasNoUnusedAttributes = computed(() => unusedAttributesCount.value === 0);
const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
onMounted(() => {
store.dispatch('attributes/get');
});
</script>
<template>
<div v-if="hasCompanyAttributes" class="flex flex-col gap-6 px-6 py-6">
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
<CompanyCustomAttributeItem
v-for="attribute in usedAttributes"
:key="`${company.id}-${attribute.id}`"
is-editing-view
:company-id="company.id"
:attribute="attribute"
/>
</div>
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-3">
<div class="flex-1 h-px bg-n-slate-5" />
<span class="text-sm font-medium text-n-slate-10">
{{
t('COMPANIES.DETAIL.ATTRIBUTES.UNUSED_ATTRIBUTES', {
count: unusedAttributesCount,
})
}}
</span>
<div class="flex-1 h-px bg-n-slate-5" />
</div>
<div class="flex flex-col gap-3">
<div v-if="!hasNoUnusedAttributes" class="relative">
<span
class="absolute i-lucide-search size-3.5 top-2 ltr:left-3 rtl:right-3"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="t('COMPANIES.DETAIL.ATTRIBUTES.SEARCH_PLACEHOLDER')"
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
v-if="filteredUnusedAttributes.length === 0 && !hasNoUnusedAttributes"
class="flex items-center justify-start h-11"
>
<p class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.ATTRIBUTES.NO_ATTRIBUTES') }}
</p>
</div>
<div v-if="!hasNoUnusedAttributes" class="flex flex-col gap-2">
<CompanyCustomAttributeItem
v-for="attribute in filteredUnusedAttributes"
:key="`${company.id}-${attribute.id}`"
:company-id="company.id"
:attribute="attribute"
/>
</div>
</div>
</div>
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
{{ t('COMPANIES.DETAIL.ATTRIBUTES.EMPTY_STATE') }}
</p>
</template>
@@ -0,0 +1,67 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
defineProps({
conversations: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const contactsById = useMapGetter('contacts/getContactById');
const stateInbox = useMapGetter('inboxes/getInboxById');
const accountLabels = useMapGetter('labels/getLabels');
const accountLabelsValue = computed(() => accountLabels.value);
const conversationContact = conversation => {
const sender = conversation.meta?.sender || {};
const contact = contactsById.value(sender.id);
return contact.id ? contact : sender;
};
const conversationInbox = conversation =>
stateInbox.value(conversation.inboxId) || {
name: '',
channelType: conversation.meta?.channel,
};
</script>
<template>
<div
v-if="isLoading"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div
v-else-if="conversations.length > 0"
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
>
<ConversationCard
v-for="conversation in conversations"
:key="conversation.id"
:conversation="conversation"
:contact="conversationContact(conversation)"
:state-inbox="conversationInbox(conversation)"
:account-labels="accountLabelsValue"
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
/>
</div>
<p
v-else
class="py-8 px-4 mx-6 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.HISTORY.EMPTY') }}
</p>
</template>
@@ -0,0 +1,114 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
notes: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const currentUser = useMapGetter('getCurrentUser');
const hasNotes = computed(() => props.notes.length > 0);
const contactName = contact =>
contact?.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
const getWrittenBy = note => {
const isCurrentUser = note?.user?.id === currentUser.value.id;
return isCurrentUser
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
: note?.user?.name || 'Bot';
};
const openContact = contactId => {
router.push({
name: 'contacts_edit',
params: {
accountId: route.params.accountId,
contactId,
},
});
};
</script>
<template>
<div v-if="hasNotes" class="flex flex-col px-6">
<div class="flex flex-col divide-y divide-n-strong">
<div
v-for="note in notes"
:key="note.id"
class="flex flex-col gap-2 py-4 group/note"
>
<div class="flex items-center gap-1.5 min-w-0">
<Avatar
:name="contactName(note.contact)"
:src="note.contact?.thumbnail"
:size="16"
rounded-full
hide-offline-status
/>
<div
class="flex items-center justify-between min-w-0 gap-1 w-full text-sm text-n-slate-11"
>
<button
type="button"
class="min-w-0 font-medium truncate text-start text-n-slate-12 hover:text-n-blue-11 p-0"
@click="openContact(note.contact.id)"
>
{{ contactName(note.contact) }}
</button>
<div class="min-w-0 truncate">
<span
class="inline-flex items-center gap-1 text-sm text-n-slate-10"
>
<span class="font-medium text-n-slate-11">
{{ getWrittenBy(note) }}
</span>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
<span class="font-medium text-n-slate-11">
{{ dynamicTime(note.createdAt) }}
</span>
</span>
</div>
</div>
</div>
<p
v-dompurify-html="formatMessage(note.content || '')"
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
/>
</div>
</div>
</div>
<div
v-else-if="isLoading"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<p
v-else
class="py-8 mx-6 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.NOTES.EMPTY') }}
</p>
</template>
@@ -0,0 +1,203 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { dynamicTime } from 'shared/helpers/timeHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import { useCompaniesStore } from 'dashboard/stores/companies';
const props = defineProps({
company: { type: Object, default: () => ({}) },
isLoading: { type: Boolean, default: false },
});
const { t } = useI18n();
const companiesStore = useCompaniesStore();
const form = reactive({ name: '', domain: '', description: '' });
const avatarPreviewUrl = ref('');
const isUploadingAvatar = ref(false);
const uiFlags = computed(() => companiesStore.getUIFlags);
const isUpdating = computed(() => uiFlags.value.updatingItem);
const isAvatarBusy = computed(
() =>
isUploadingAvatar.value || uiFlags.value.deletingAvatar || isUpdating.value
);
const displayName = computed(
() => props.company?.name || t('COMPANIES.UNNAMED')
);
const avatarSource = computed(
() => avatarPreviewUrl.value || props.company?.avatarUrl || ''
);
const isFormInvalid = computed(() => !form.name.trim());
const hasChanges = computed(
() =>
form.name.trim() !== (props.company?.name || '').trim() ||
form.domain.trim() !== (props.company?.domain || '').trim() ||
form.description.trim() !== (props.company?.description || '').trim()
);
const summary = computed(() => {
const { createdAt, lastActivityAt } = props.company || {};
return [
createdAt &&
t('COMPANIES.DETAIL.PROFILE.CREATED_AT', {
date: dynamicTime(createdAt),
}),
lastActivityAt &&
t('COMPANIES.DETAIL.PROFILE.LAST_ACTIVE', {
date: dynamicTime(lastActivityAt),
}),
]
.filter(Boolean)
.join(' • ');
});
const syncForm = company => {
form.name = company?.name || '';
form.domain = company?.domain || '';
form.description = company?.description || '';
};
const isCurrentCompany = companyId => Number(props.company?.id) === companyId;
watch(
() => [
props.company?.id,
props.company?.name,
props.company?.domain,
props.company?.description,
props.company?.avatarUrl,
],
() => {
avatarPreviewUrl.value = '';
syncForm(props.company);
},
{ immediate: true }
);
const handleAvatarUpload = async ({ file, url }) => {
avatarPreviewUrl.value = url;
isUploadingAvatar.value = true;
try {
await companiesStore.update({ id: props.company.id, avatar: file });
useAlert(t('COMPANIES.DETAIL.AVATAR.UPLOAD_SUCCESS'));
} catch {
avatarPreviewUrl.value = '';
useAlert(t('COMPANIES.DETAIL.AVATAR.UPLOAD_ERROR'));
} finally {
isUploadingAvatar.value = false;
}
};
const handleAvatarDelete = async () => {
try {
await companiesStore.deleteCompanyAvatar(props.company.id);
avatarPreviewUrl.value = '';
useAlert(t('COMPANIES.DETAIL.AVATAR.DELETE_SUCCESS'));
} catch {
useAlert(t('COMPANIES.DETAIL.AVATAR.DELETE_ERROR'));
}
};
const handleUpdateCompany = async () => {
const companyId = Number(props.company.id);
try {
const updated = await companiesStore.update({
id: companyId,
name: form.name.trim(),
domain: form.domain.trim() || null,
description: form.description.trim() || null,
});
if (!isCurrentCompany(companyId)) return;
syncForm(updated);
useAlert(t('COMPANIES.DETAIL.PROFILE.MESSAGES.UPDATE_SUCCESS'));
} catch {
if (!isCurrentCompany(companyId)) return;
syncForm(props.company);
useAlert(t('COMPANIES.DETAIL.PROFILE.MESSAGES.UPDATE_ERROR'));
}
};
</script>
<template>
<div v-if="isLoading && !company?.id" class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.LOADING') }}
</div>
<div v-else-if="company?.id" class="flex flex-col items-start gap-8 pb-6">
<div class="flex flex-col items-start gap-3">
<Avatar
:name="displayName"
:src="avatarSource"
:size="72"
:allow-upload="!isAvatarBusy"
rounded-full
hide-offline-status
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
<div class="flex flex-col gap-1">
<h3 class="text-base font-medium text-n-slate-12">
{{ displayName }}
</h3>
<span class="text-sm leading-6 text-n-slate-11">{{ summary }}</span>
<p
v-if="isUploadingAvatar || uiFlags.deletingAvatar"
class="text-sm text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.AVATAR.UPDATING') }}
</p>
</div>
</div>
<div class="flex flex-col items-start w-full gap-6">
<span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('COMPANIES.DETAIL.PROFILE.TITLE') }}
</span>
<div class="grid w-full gap-4 sm:grid-cols-2">
<Input
v-model="form.name"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.NAME')"
:disabled="isUpdating"
custom-input-class="h-8 !pt-1 !pb-1"
/>
<Input
v-model="form.domain"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.DOMAIN')"
:disabled="isUpdating"
custom-input-class="h-8 !pt-1 !pb-1"
/>
</div>
<TextArea
v-model="form.description"
:placeholder="t('COMPANIES.DETAIL.PROFILE.DESCRIPTION_PLACEHOLDER')"
:disabled="isUpdating"
:max-length="280"
class="w-full"
show-character-count
auto-height
/>
<Button
:label="t('COMPANIES.DETAIL.PROFILE.ACTIONS.SAVE')"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid || !hasChanges"
@click="handleUpdateCompany"
/>
</div>
</div>
</template>
@@ -0,0 +1,45 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
company: {
type: Object,
default: () => ({}),
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['confirm']);
const { t } = useI18n();
const dialogRef = ref(null);
const description = computed(() =>
props.company?.name
? t('COMPANIES.DETAIL.DELETE.DESCRIPTION_WITH_NAME', {
companyName: props.company.name,
})
: t('COMPANIES.DETAIL.DELETE.DESCRIPTION')
);
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('COMPANIES.DETAIL.DELETE.TITLE')"
:description="description"
:confirm-button-label="t('COMPANIES.DETAIL.DELETE.CONFIRM')"
:is-loading="isLoading"
@confirm="emit('confirm')"
/>
</template>
@@ -32,7 +32,7 @@ const emit = defineEmits([
</script>
<template>
<header class="sticky top-0 z-10 px-6">
<header class="sticky top-0 z-20 px-6">
<div
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
>
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4 z-50"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
@@ -3,16 +3,10 @@ import { computed, ref, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import {
isVoiceCallEnabled,
getVoiceCallProvider,
VOICE_CALL_PROVIDERS,
} from 'dashboard/helper/inbox';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import ContactAPI from 'dashboard/api/contacts';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -20,9 +14,6 @@ import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
phone: { type: String, default: '' },
contactId: { type: [String, Number], required: true },
// When set, the WhatsApp call continues in this conversation (matching the
// header button) instead of looking up the contact's most recent one.
conversationId: { type: [String, Number], default: null },
label: { type: String, default: '' },
icon: { type: [String, Object, Function], default: '' },
size: { type: String, default: 'sm' },
@@ -47,6 +38,7 @@ const voiceInboxes = computed(() =>
);
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
// Unified behavior: hide when no phone
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
const isInitiatingCall = computed(() => {
@@ -66,76 +58,17 @@ const navigateToConversation = conversationId => {
}
};
const whatsappCallSession = useWhatsappCallSession();
// Find the most recent open conversation for this contact in the picked inbox.
// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
const findWhatsappConversationId = async inboxId => {
const { data } = await ContactAPI.getConversations(props.contactId);
const conversations = data?.payload || [];
const match = conversations
.filter(c => c.inbox_id === inboxId)
.sort((a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0))[0];
return match?.id || null;
};
const startWhatsappCall = async (inboxId, conversationIdHint) => {
// WhatsApp /initiate is conversation-scoped, so we must hand it a
// conversation. Use the caller's hint when given (in-conversation flow);
// otherwise pick the most recent one in the inbox.
const conversationId =
conversationIdHint || (await findWhatsappConversationId(inboxId));
if (!conversationId) {
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
return;
}
const response =
await whatsappCallSession.initiateOutboundCall(conversationId);
if (!response?.id) {
// Permission flow returns no id — banner already handled server-side; surface to user.
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(conversationId);
return;
}
const callsStore = useCallsStore();
// Stay non-active until the connect cable event arrives — flipping to active
// here would start the duration timer before the contact picks up.
callsStore.addCall({
callSid: response.call_id,
callId: response.id,
conversationId,
inboxId,
callDirection: 'outbound',
provider: 'whatsapp',
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(conversationId);
};
const startCall = async (inboxId, conversationIdHint = null) => {
const startCall = async inboxId => {
if (isInitiatingCall.value) return;
const inbox = (inboxesList.value || []).find(i => i.id === inboxId);
if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) {
try {
await startWhatsappCall(inboxId, conversationIdHint);
} catch (error) {
useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
}
return;
}
try {
const response = await store.dispatch('contacts/initiateCall', {
contactId: props.contactId,
inboxId,
conversationId: conversationIdHint,
});
const { call_sid: callSid, conversation_id: conversationId } = response;
// Add call to store immediately so widget shows
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
@@ -153,22 +86,6 @@ const startCall = async (inboxId, conversationIdHint = null) => {
};
const onClick = async () => {
// In conversation context, only stay in this conversation if its inbox is
// itself voice-capable (works the same for Twilio and WhatsApp). For
// non-voice channels (email, web, …) fall back to the picker so the call
// goes out via a voice inbox.
if (props.conversationId) {
const conversation = store.getters.getConversationById(
props.conversationId
);
const conversationInbox = (inboxesList.value || []).find(
i => i.id === conversation?.inbox_id
);
if (conversationInbox && isVoiceCallEnabled(conversationInbox)) {
await startCall(conversationInbox.id, props.conversationId);
return;
}
}
if (voiceInboxes.value.length > 1) {
dialogRef.value?.open();
return;
@@ -134,7 +134,7 @@ const handleInputUpdate = async () => {
:message-type="hasError ? 'error' : 'info'"
autofocus
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@keyup.enter="handleInputUpdate"
@enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
@@ -191,7 +191,7 @@ const handleInputUpdate = async () => {
:message="attributeErrorMessage"
:message-type="hasError ? 'error' : 'info'"
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@keyup.enter="handleInputUpdate"
@enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
@@ -201,7 +201,7 @@ onMounted(() => {
v-if="openAgentsList && hasAgentList"
:menu-items="agentList"
show-search
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-60"
class="z-[100] w-48 mt-2 ltr:left-0 rtl:right-0 top-full max-h-60"
@action="handleArticleAction"
/>
</OnClickOutside>
@@ -233,7 +233,7 @@ onMounted(() => {
v-if="openCategoryList && hasCategoryMenuItems"
:menu-items="categoryList"
show-search
class="w-48 mt-2 z-[100] overflow-y-auto left-0 top-full max-h-60"
class="w-48 mt-2 z-[100] left-0 top-full max-h-60"
@action="handleArticleAction"
/>
</OnClickOutside>
@@ -159,7 +159,7 @@ const handleTabChange = value => {
v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems"
show-search
class="left-0 w-40 max-w-[300px] mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
class="left-0 w-40 max-w-[300px] mt-2 xl:right-0 top-full max-h-60"
@action="handleLocaleAction"
/>
</OnClickOutside>
@@ -180,7 +180,7 @@ const handleTabChange = value => {
v-if="isCategoryMenuOpen"
:menu-items="categoryMenuItems"
show-search
class="left-0 w-48 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
class="left-0 w-48 mt-2 xl:right-0 top-full max-h-60"
@action="handleCategoryAction"
/>
</OnClickOutside>
@@ -296,7 +296,7 @@ watch(
:selected-count-label="selectedCountLabel"
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
>
<template #secondary-actions>
<template #secondaryActions>
<Button
sm
ghost
@@ -142,7 +142,7 @@ const handleBreadcrumbClick = () => {
v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems"
show-search
class="left-0 w-40 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
class="left-0 w-40 mt-2 xl:right-0 top-full max-h-60"
@action="handleLocaleAction"
/>
</OnClickOutside>
@@ -90,7 +90,7 @@ const targetInboxLabel = computed(() => {
<DropdownMenu
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
:menu-items="contactableInboxesList"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-fit max-w-sm dark:!outline-n-slate-5"
class="ltr:left-0 rtl:right-0 z-[100] top-8 max-h-56 w-fit max-w-sm dark:!outline-n-slate-5"
@action="emit('handleInboxAction', $event)"
/>
</div>
@@ -1,5 +1,5 @@
<script setup>
import { computed, useSlots } from 'vue';
import { computed } from 'vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -47,9 +47,6 @@ const allSelected = computed(
selectedVisibleCount.value === visibleItemCount.value
);
const slots = useSlots();
const hasSecondaryActions = computed(() => Boolean(slots['secondary-actions']));
const bulkCheckboxState = computed({
get: () => allSelected.value,
set: shouldSelectAll => {
@@ -95,10 +92,12 @@ const bulkCheckboxState = computed({
<span class="text-sm text-n-slate-10 truncate tabular-nums">
{{ selectedCountLabel }}
</span>
<div v-if="$slots.primaryActions" class="h-4 w-px bg-n-strong" />
<slot v-if="$slots.primaryActions" name="primaryActions" />
</div>
<div class="flex items-center gap-3">
<slot v-if="hasSecondaryActions" name="secondary-actions" />
<div v-if="hasSecondaryActions" class="h-4 w-px bg-n-strong" />
<slot v-if="$slots.secondaryActions" name="secondaryActions" />
<div v-if="$slots.secondaryActions" class="h-4 w-px bg-n-strong" />
<div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount">
<Button
@@ -0,0 +1,113 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
selectedIds: { type: Set, default: () => new Set() },
documents: { type: Array, default: () => [] },
});
const emit = defineEmits([
'update:selectedIds',
'bulkSyncQueued',
'bulkDeleteSucceeded',
]);
const { t } = useI18n();
const store = useStore();
const bulkDeleteDialog = ref(null);
const isSyncableDocument = doc =>
!doc.pdf_document && doc.status === 'available' && !doc.sync_in_progress;
const syncableSelectedIds = computed(() => {
if (!props.selectedIds.size) return [];
return props.documents
.filter(doc => props.selectedIds.has(doc.id) && isSyncableDocument(doc))
.map(doc => doc.id);
});
const hasSyncableSelection = computed(
() => syncableSelectedIds.value.length > 0
);
const selectAllLabel = computed(() => {
const count = props.documents.length;
const isAllSelected = props.selectedIds.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.DOCUMENTS.UNSELECT_ALL', { count })
: t('CAPTAIN.DOCUMENTS.SELECT_ALL', { count });
});
const selectedCountLabel = computed(() =>
t('CAPTAIN.DOCUMENTS.SELECTED', { count: props.selectedIds.size })
);
const handleBulkSync = async () => {
const ids = syncableSelectedIds.value;
if (!ids.length) return;
try {
const response = await store.dispatch('captainBulkActions/handleBulkSync', {
ids,
});
const queuedCount = response?.count ?? response?.ids?.length ?? 0;
let message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.ZERO_MESSAGE');
if (queuedCount === 1) {
message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.SUCCESS_MESSAGE_ONE');
} else if (queuedCount > 1) {
message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.SUCCESS_MESSAGE', {
count: queuedCount,
});
}
useAlert(message);
emit('update:selectedIds', new Set());
if (queuedCount > 0) emit('bulkSyncQueued');
} catch (error) {
useAlert(t('CAPTAIN.DOCUMENTS.BULK_SYNC.ERROR_MESSAGE'));
}
};
</script>
<template>
<div>
<BulkSelectBar
:model-value="selectedIds"
:all-items="documents"
:select-all-label="selectAllLabel"
:selected-count-label="selectedCountLabel"
:delete-label="$t('CAPTAIN.DOCUMENTS.BULK_DELETE_BUTTON')"
class="w-fit"
:class="{ 'mb-2': selectedIds.size > 0 }"
@update:model-value="emit('update:selectedIds', $event)"
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
>
<template v-if="hasSyncableSelection" #secondaryActions>
<Button
:label="$t('CAPTAIN.DOCUMENTS.BULK_SYNC_BUTTON')"
sm
slate
ghost
icon="i-lucide-refresh-cw"
class="!px-1.5"
@click="handleBulkSync"
/>
</template>
</BulkSelectBar>
<BulkDeleteDialog
ref="bulkDeleteDialog"
:bulk-ids="selectedIds"
type="AssistantDocument"
@delete-success="emit('bulkDeleteSucceeded')"
/>
</div>
</template>
@@ -5,14 +5,17 @@ import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { usePolicy } from 'dashboard/composables/usePolicy';
import {
isPdfDocument,
isSafeHttpLink,
formatDocumentLink,
getDocumentDisplayPath,
} from 'shared/helpers/documentHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import DocumentSyncStatus from 'dashboard/components-next/captain/assistant/DocumentSyncStatus.vue';
const props = defineProps({
id: {
@@ -31,10 +34,38 @@ const props = defineProps({
type: String,
required: true,
},
pdfDocument: {
type: Boolean,
default: false,
},
createdAt: {
type: Number,
required: true,
},
status: {
type: String,
default: null,
},
syncStatus: {
type: String,
default: null,
},
lastSyncedAt: {
type: Number,
default: null,
},
lastSyncErrorCode: {
type: String,
default: null,
},
syncInProgress: {
type: Boolean,
default: false,
},
syncStaleAfterHours: {
type: Number,
default: null,
},
isSelected: {
type: Boolean,
default: false,
@@ -64,6 +95,20 @@ const modelValue = computed({
set: () => emit('select', props.id),
});
const isPdf = computed(() => props.pdfDocument);
const hasSafeLink = computed(() => isSafeHttpLink(props.externalLink));
const canManage = computed(() => checkPermissions(['administrator']));
const isAvailable = computed(() => props.status === 'available');
const canSync = computed(
() => canManage.value && !isPdf.value && isAvailable.value
);
const isSyncing = computed(() => props.syncStatus === 'syncing');
const isFailed = computed(() => props.syncStatus === 'failed');
const isRetryableSync = computed(
() => isFailed.value || (isSyncing.value && !props.syncInProgress)
);
const showSyncStatus = computed(() => !isPdf.value);
const menuItems = computed(() => {
const allOptions = [
{
@@ -74,7 +119,19 @@ const menuItems = computed(() => {
},
];
if (checkPermissions(['administrator'])) {
if (canSync.value) {
allOptions.push({
label: isRetryableSync.value
? t('CAPTAIN.DOCUMENTS.OPTIONS.RETRY_SYNC')
: t('CAPTAIN.DOCUMENTS.OPTIONS.SYNC_NOW'),
value: 'sync',
action: 'sync',
icon: 'i-lucide-refresh-cw',
disabled: props.syncInProgress,
});
}
if (canManage.value) {
allOptions.push({
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
value: 'delete',
@@ -86,17 +143,25 @@ const menuItems = computed(() => {
return allOptions;
});
const createdAt = computed(() => dynamicTime(props.createdAt));
const createdAtLabel = computed(() => dynamicTime(props.createdAt));
const displayLink = computed(() => formatDocumentLink(props.externalLink));
const displayLink = computed(() =>
isPdf.value
? formatDocumentLink(props.externalLink)
: getDocumentDisplayPath(props.externalLink)
);
const linkIcon = computed(() =>
isPdfDocument(props.externalLink) ? 'i-ph-file-pdf' : 'i-ph-link-simple'
isPdf.value ? 'i-ph-file-pdf' : 'i-ph-link-simple'
);
const handleAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
};
const handleRetry = () => {
emit('action', { action: 'sync', id: props.id });
};
</script>
<template>
@@ -141,17 +206,41 @@ const handleAction = ({ action, value }) => {
<span
class="flex gap-1 items-center text-sm truncate shrink-0 text-n-slate-11"
>
<i class="i-woot-captain" />
<Icon icon="i-woot-captain" />
{{ assistant?.name || '' }}
</span>
<a
v-if="!isPdf && hasSafeLink"
:href="externalLink"
:title="externalLink"
target="_blank"
rel="noopener noreferrer"
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11 hover:text-n-slate-12 hover:underline"
@click.stop
>
<Icon :icon="linkIcon" class="shrink-0" />
<span class="truncate">{{ displayLink }}</span>
<Icon icon="i-lucide-external-link size-3 shrink-0 opacity-70" />
</a>
<span
v-else
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11"
>
<i :class="linkIcon" class="shrink-0" />
<Icon :icon="linkIcon" class="shrink-0" />
<span class="truncate">{{ displayLink }}</span>
</span>
<div class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
{{ createdAt }}
<DocumentSyncStatus
v-if="showSyncStatus"
:status="syncStatus"
:last-synced-at="lastSyncedAt"
:error-code="lastSyncErrorCode"
:sync-in-progress="syncInProgress"
:stale-after-hours="syncStaleAfterHours"
:show-retry="canSync && isRetryableSync"
@retry="handleRetry"
/>
<div v-else class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
{{ createdAtLabel }}
</div>
</div>
</CardLayout>
@@ -0,0 +1,61 @@
<script setup>
import { computed, ref } from 'vue';
import DocumentFiltersBar from 'dashboard/components-next/captain/assistant/DocumentFiltersBar.vue';
const emit = defineEmits(['change']);
const source = ref('all');
const status = ref(null);
const sort = ref('recently_updated');
const hasActiveFilters = computed(
() => source.value !== 'all' || Boolean(status.value)
);
const buildParams = (page = 1) => {
const params = { page };
if (source.value !== 'all') params.source = source.value;
if (status.value) params.filter = status.value;
if (sort.value) params.sort = sort.value;
return params;
};
const emitChange = () => emit('change');
const handleSourceSelect = sourceKey => {
source.value = sourceKey;
if (sourceKey !== 'web') status.value = null;
emitChange();
};
const handleStatusSelect = statusKey => {
status.value = statusKey;
if (statusKey) source.value = 'web';
emitChange();
};
const handleSortSelect = sortKey => {
sort.value = sortKey;
emitChange();
};
const reset = () => {
source.value = 'all';
status.value = null;
sort.value = 'recently_updated';
};
defineExpose({ buildParams, reset, hasActiveFilters });
</script>
<template>
<DocumentFiltersBar
:active-source-filter="source"
:active-status-filter="status"
:active-sort="sort"
@select-source="handleSourceSelect"
@select-status="handleStatusSelect"
@select-sort="handleSortSelect"
/>
</template>
@@ -0,0 +1,141 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
activeSourceFilter: { type: String, default: 'all' },
activeStatusFilter: { type: String, default: null },
activeSort: { type: String, default: 'recently_updated' },
});
const emit = defineEmits(['selectSource', 'selectStatus', 'selectSort']);
const { t } = useI18n();
const openMenu = ref(null);
const MENU_CONFIG = [
{
key: 'source',
activeKey: 'activeSourceFilter',
dropdownClass: 'min-w-48',
options: [
{ labelKey: 'SOURCE.ALL', value: 'all', icon: 'i-lucide-files' },
{ labelKey: 'SOURCE.WEB', value: 'web', icon: 'i-lucide-link' },
{ labelKey: 'SOURCE.PDF', value: 'pdf', icon: 'i-lucide-file-text' },
],
},
{
key: 'status',
activeKey: 'activeStatusFilter',
dropdownClass: 'min-w-52',
options: [
{ labelKey: 'STATUS.ANY', value: null, icon: 'i-lucide-circle-dashed' },
{
labelKey: 'STATUS.UPDATED',
value: 'synced',
icon: 'i-lucide-check-circle',
},
{
labelKey: 'STATUS.NEEDS_UPDATE',
value: 'stale',
icon: 'i-lucide-clock',
},
{
labelKey: 'STATUS.UPDATING',
value: 'syncing',
icon: 'i-lucide-refresh-cw',
},
{
labelKey: 'STATUS.FAILED',
value: 'failed',
icon: 'i-lucide-circle-x',
},
],
},
{
key: 'sort',
activeKey: 'activeSort',
dropdownClass: 'min-w-56',
options: [
{
labelKey: 'SORT.RECENTLY_UPDATED',
value: 'recently_updated',
icon: 'i-lucide-arrow-down-up',
},
{
labelKey: 'SORT.RECENTLY_CREATED',
value: 'recently_created',
icon: 'i-lucide-clock',
},
],
},
];
const filterMenus = computed(() =>
MENU_CONFIG.filter(
menu => !(menu.key === 'status' && props.activeSourceFilter === 'pdf')
).map(menu => {
const active = props[menu.activeKey];
const items = menu.options.map(opt => ({
label: t(`CAPTAIN.DOCUMENTS.FILTERS.${opt.labelKey}`),
value: opt.value,
icon: opt.icon,
action: menu.key,
isSelected: opt.value === active,
}));
return {
...menu,
items,
selected: items.find(item => item.isSelected) || items[0],
};
})
);
const closeMenu = () => {
openMenu.value = null;
};
const toggleMenu = menu => {
openMenu.value = openMenu.value === menu ? null : menu;
};
const handleMenuAction = ({ action, value }) => {
closeMenu();
if (action === 'source') emit('selectSource', value);
else if (action === 'status') emit('selectStatus', value);
else if (action === 'sort') emit('selectSort', value);
};
</script>
<template>
<div
v-on-click-outside="closeMenu"
class="inline-flex flex-wrap items-center gap-2 pt-2 w-fit"
>
<div v-for="menu in filterMenus" :key="menu.key" class="relative">
<Button
:icon="menu.selected.icon"
slate
size="sm"
:class="{ 'bg-n-slate-9/10': openMenu === menu.key }"
@click="toggleMenu(menu.key)"
>
<span class="min-w-0 truncate">{{ menu.selected.label }}</span>
<Icon icon="i-lucide-chevron-down" class="shrink-0 size-4" />
</Button>
<DropdownMenu
v-if="openMenu === menu.key"
:menu-items="menu.items"
:class="menu.dropdownClass"
class="top-full mt-2 ltr:left-0 rtl:right-0"
@action="handleMenuAction"
/>
</div>
</div>
</template>
@@ -0,0 +1,153 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
status: {
type: String,
default: null,
},
lastSyncedAt: {
type: Number,
default: null,
},
errorCode: {
type: String,
default: null,
},
syncInProgress: {
type: Boolean,
default: false,
},
staleAfterHours: {
type: Number,
default: null,
},
showRetry: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['retry']);
const { t } = useI18n();
const SECONDS_PER_HOUR = 3600;
const SYNCING = 'syncing';
const FAILED = 'failed';
const ERROR_CODE_LABELS = {
not_found: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.NOT_FOUND',
access_denied: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.ACCESS_DENIED',
timeout: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.TIMEOUT',
content_empty: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.CONTENT_EMPTY',
fetch_failed: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.FETCH_FAILED',
sync_error: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.SYNC_ERROR',
};
const DEFAULT_ERROR_LABEL = 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.DEFAULT';
const hasSyncingStatus = computed(() => props.status === SYNCING);
const isSyncing = computed(
() => hasSyncingStatus.value && props.syncInProgress
);
const isStaleSync = computed(
() => hasSyncingStatus.value && !props.syncInProgress
);
const isFailed = computed(() => props.status === FAILED);
const canRetry = computed(() => isFailed.value || isStaleSync.value);
const hasBeenSynced = computed(() => Boolean(props.lastSyncedAt));
const ageInHours = computed(() => {
if (!props.lastSyncedAt) return null;
const nowSeconds = Date.now() / 1000;
return (nowSeconds - props.lastSyncedAt) / SECONDS_PER_HOUR;
});
const staleAfterHours = computed(() => Number(props.staleAfterHours));
const hasStaleThreshold = computed(
() => Number.isFinite(staleAfterHours.value) && staleAfterHours.value > 0
);
const isStale = computed(
() =>
hasStaleThreshold.value &&
ageInHours.value !== null &&
ageInHours.value >= staleAfterHours.value
);
const errorLabel = computed(() =>
t(ERROR_CODE_LABELS[props.errorCode] || DEFAULT_ERROR_LABEL)
);
const label = computed(() => {
if (isSyncing.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCING');
if (isStaleSync.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.STALE_SYNC');
if (isFailed.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.FAILED');
if (hasBeenSynced.value)
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCED', {
time: dynamicTime(props.lastSyncedAt),
});
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.NEVER_SYNCED');
});
const fullLabel = computed(() => {
if (isSyncing.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCING');
if (isStaleSync.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.STALE_SYNC');
if (isFailed.value)
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.FAILED', {
error: errorLabel.value,
});
if (hasBeenSynced.value)
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCED', {
time: dynamicTime(props.lastSyncedAt),
});
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.NEVER_SYNCED');
});
const tone = computed(() => {
if (isSyncing.value) return 'amber';
if (isStaleSync.value) return 'amber';
if (isFailed.value) return 'ruby';
if (isStale.value) return 'amber';
return 'slate';
});
const textClass = computed(() => {
if (tone.value === 'amber') return 'text-n-amber-11';
if (tone.value === 'ruby') return 'text-n-ruby-11';
return 'text-n-slate-11';
});
const statusIcon = computed(() => {
if (isFailed.value || isStale.value || isStaleSync.value) {
return 'i-lucide-circle-alert';
}
return 'i-lucide-refresh-cw';
});
</script>
<template>
<span
class="flex gap-1.5 items-center text-sm truncate shrink-0 tabular-nums"
:class="textClass"
:title="fullLabel"
>
<Spinner v-if="isSyncing" class="text-n-amber-11 size-3" />
<Icon v-else :icon="statusIcon" class="shrink-0 size-3.5" />
<span class="truncate">{{ label }}</span>
<Button
v-if="showRetry && canRetry"
:label="t('CAPTAIN.DOCUMENTS.OPTIONS.RETRY_SYNC')"
xs
link
ruby
icon="i-lucide-refresh-cw"
class="hover:!no-underline !gap-1 ms-1"
@click.stop="emit('retry')"
/>
</span>
</template>
@@ -15,7 +15,7 @@ defineProps({
},
});
const emit = defineEmits(['close']);
const emit = defineEmits(['close', 'createSuccess']);
const { t } = useI18n();
const store = useStore();
@@ -26,6 +26,7 @@ const i18nKey = 'CAPTAIN.DOCUMENTS.CREATE';
const handleSubmit = async newDocument => {
try {
await store.dispatch('captainDocuments/create', newDocument);
emit('createSuccess');
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
@@ -10,9 +10,6 @@ const props = defineProps({
menuItems: {
type: Array,
default: () => [],
validator: value => {
return value.every(item => item.action && item.value && item.label);
},
},
menuSections: {
type: Array,
@@ -22,6 +19,10 @@ const props = defineProps({
type: Number,
default: 20,
},
roundedThumbnail: {
type: Boolean,
default: true,
},
showSearch: {
type: Boolean,
default: false,
@@ -42,9 +43,17 @@ const props = defineProps({
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
emptyStateMessage: {
type: String,
default: 'DROPDOWN_MENU.EMPTY_STATE',
},
});
const emit = defineEmits(['action', 'search']);
const emit = defineEmits(['action', 'search', 'empty']);
const { t } = useI18n();
@@ -96,9 +105,13 @@ const filteredMenuSections = computed(() => {
});
const handleSearchInput = event => {
if (props.disableLocalFiltering) {
emit('search', event.target.value);
}
emit('search', event.target.value);
const isEmpty = hasSections.value
? filteredMenuSections.value.length === 0
: filteredMenuItems.value.length === 0;
if (isEmpty) emit('empty');
};
const handleAction = item => {
@@ -123,57 +136,104 @@ onMounted(() => {
<template>
<div
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 gap-2 flex flex-col min-w-[136px] shadow-lg pb-2 px-2"
:class="{
'pt-2': !showSearch,
}"
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 flex flex-col min-w-[136px] shadow-lg pt-2 overflow-hidden"
>
<div
v-if="showSearch"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2 z-20"
>
<div class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
ref="searchInput"
v-model="searchQuery"
type="search"
:placeholder="
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
@input="handleSearchInput"
/>
</div>
<div v-if="showSearch" class="relative shrink-0 px-2 mb-2">
<span
class="absolute i-lucide-search size-3.5 top-2 ltr:left-5 rtl:right-5"
/>
<input
ref="searchInput"
v-model="searchQuery"
type="search"
:placeholder="
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="reset-base w-full h-8 py-2 ltr:pl-10 ltr:pr-2 rtl:pl-2 rtl:pr-10 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
@input="handleSearchInput"
/>
</div>
<template v-if="hasSections">
<div
v-for="(section, sectionIndex) in filteredMenuSections"
:key="section.title || sectionIndex"
class="flex flex-col gap-1"
>
<p
v-if="section.title"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky z-10 bg-n-alpha-3 backdrop-blur-sm"
:class="showSearch ? 'top-10' : 'top-0'"
>
{{ section.title }}
</p>
<div class="flex flex-col gap-2 overflow-y-auto min-h-0 px-2 pb-2">
<template v-if="hasSections">
<div
v-if="section.isLoading"
class="flex items-center justify-center py-2"
v-for="(section, sectionIndex) in filteredMenuSections"
:key="section.title || sectionIndex"
class="flex flex-col gap-1"
>
<p
v-if="section.title"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky top-0 z-10 bg-n-alpha-3 backdrop-blur-sm"
>
{{ section.title }}
</p>
<div
v-if="section.isLoading"
class="flex items-center justify-center py-2"
>
<Spinner :size="24" />
</div>
<div
v-else-if="!section.items.length && section.emptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{ section.emptyState }}
</div>
<button
v-for="(item, itemIndex) in section.items"
:key="item.value || itemIndex"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
:rounded-full="roundedThumbnail"
/>
</slot>
<slot name="icon" :item="item">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
</slot>
<span v-if="item.emoji" class="flex-shrink-0">{{
item.emoji
}}</span>
<slot name="label" :item="item">
<span
v-if="item.label"
class="min-w-0 text-sm font-420 truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</slot>
<slot name="trailing-icon" :item="item" />
</button>
<div
v-if="sectionIndex < filteredMenuSections.length - 1"
class="h-px bg-n-alpha-2 mx-2 my-1"
/>
</div>
</template>
<template v-else>
<div v-if="isLoading" class="flex items-center justify-center py-2">
<Spinner :size="24" />
</div>
<div
v-else-if="!section.items.length && section.emptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{ section.emptyState }}
</div>
<button
v-for="(item, itemIndex) in section.items"
:key="item.value || itemIndex"
v-for="(item, index) in filteredMenuItems"
:key="index"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
@@ -190,77 +250,44 @@ onMounted(() => {
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
:rounded-full="roundedThumbnail"
/>
</slot>
<slot name="icon" :item="item">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
<slot name="label" :item="item">
<span
v-if="item.label"
class="min-w-0 text-sm font-420 truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</slot>
<slot name="trailing-icon" :item="item" />
</button>
<div
v-if="sectionIndex < filteredMenuSections.length - 1"
class="h-px bg-n-alpha-2 mx-2 my-1"
/>
</div>
</template>
<template v-else>
<button
v-for="(item, index) in filteredMenuItems"
:key="index"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
</template>
<div
v-if="shouldShowEmptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
</template>
<div
v-if="shouldShowEmptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{
isSearching
? t('DROPDOWN_MENU.SEARCHING')
: t('DROPDOWN_MENU.EMPTY_STATE')
}}
{{
isSearching
? t('DROPDOWN_MENU.SEARCHING')
: searchQuery
? t('DROPDOWN_MENU.EMPTY_STATE')
: t(emptyStateMessage)
}}
</div>
</div>
<div v-if="$slots.footer" class="shrink-0">
<slot name="footer" />
</div>
<slot name="footer" />
</div>
</template>
@@ -35,7 +35,7 @@ const showDropdown = ref(false);
v-on-clickaway="() => (showDropdown = false)"
:menu-items="labelMenuItems"
show-search
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
class="z-[100] w-48 mt-2 ltr:left-0 rtl:right-0 top-full max-h-52"
@action="emit('updateLabel', $event)"
>
<template #thumbnail="{ item }">
@@ -32,14 +32,7 @@ const BG_COLOR_MAP = {
const { t } = useI18n();
const store = useStore();
const {
call,
attachments,
contentAttributes,
conversationId,
currentUserId,
inboxId,
} = useMessageContext();
const { call, conversationId, currentUserId, inboxId } = useMessageContext();
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
useCallSession();
@@ -73,23 +66,6 @@ const displayAgentName = computed(() => {
return conversationAssignee.value?.name || null;
});
const audioAttachment = computed(() =>
(attachments?.value || []).find(a => a.fileType === 'audio')
);
// Duration may arrive on call.duration_seconds (push_event_data) or
// content_attributes.data.duration_seconds — and either may be camelCased
// upstream, so check every variant.
const durationSeconds = computed(() => {
const fromCall = call.value?.durationSeconds || call.value?.duration_seconds;
if (fromCall != null) return fromCall;
const data = contentAttributes?.value?.data;
return data?.durationSeconds || data?.duration_seconds;
});
const formattedDuration = computed(() => formatDuration(durationSeconds.value));
const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.RINGING) {
@@ -97,30 +73,24 @@ const labelKey = computed(() => {
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
if (isFailed.value) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL'
: 'CONVERSATION.VOICE_CALL.MISSED_CALL';
}
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
return isFailed.value
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const formattedDuration = computed(() =>
formatDuration(call.value?.durationSeconds)
);
const subtext = computed(() => {
if (status.value === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? t('CONVERSATION.VOICE_CALL.CALLING')
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return formattedDuration.value;
}
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
// Outbound calls can flip to in-progress before the contact actually
// picks up (e.g., Twilio marks in-progress when the agent joins the
// conference); claiming "they answered" here would be misleading. The
// 'Call in progress' label alone is accurate; the floating widget shows
// the live duration.
if (isOutbound.value) return null;
if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
if (didCurrentUserAnswer.value) {
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
}
@@ -129,14 +99,11 @@ const subtext = computed(() => {
agentName: displayAgentName.value,
});
}
return null;
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
}
if (isFailed.value) {
return isOutbound.value
? t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT')
: t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT');
}
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
return isFailed.value
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
});
const iconName = computed(() => {
@@ -168,7 +135,6 @@ const canJoinCall = computed(() => {
});
const recordingAttachment = computed(() => {
if (audioAttachment.value) return audioAttachment.value;
const url = call.value?.recordingUrl;
if (!url) return null;
return {
@@ -235,8 +201,8 @@ const handleJoinCall = async () => {
</div>
</div>
<div v-if="recordingAttachment" class="px-3 pb-3 w-full">
<AudioChip :attachment="recordingAttachment" class="text-n-slate-12" />
<div v-if="recordingAttachment" class="px-3 pb-3">
<AudioChip :attachment="recordingAttachment" />
</div>
</div>
</BaseBubble>
@@ -41,33 +41,8 @@ const playbackSpeed = ref(1);
const { uid } = getCurrentInstance();
// MediaRecorder-produced WebM/Opus blobs lack a Duration header → <audio>.duration
// resolves to Infinity until we seek past the end, which forces the engine to
// scan the file and compute the real length. Safe no-op for files with a real
// duration already (mp3/m4a/etc).
const resolveStreamingDuration = () => {
const el = audioPlayer.value;
if (!el) return;
const onTimeUpdate = () => {
el.removeEventListener('timeupdate', onTimeUpdate);
el.currentTime = 0;
duration.value = el.duration;
};
el.addEventListener('timeupdate', onTimeUpdate);
try {
el.currentTime = Number.MAX_SAFE_INTEGER;
} catch {
el.removeEventListener('timeupdate', onTimeUpdate);
}
};
const onLoadedMetadata = () => {
const d = audioPlayer.value?.duration;
if (!Number.isFinite(d)) {
resolveStreamingDuration();
return;
}
duration.value = d;
duration.value = audioPlayer.value?.duration;
};
const playbackSpeedLabel = computed(() => {
@@ -78,8 +53,7 @@ const playbackSpeedLabel = computed(() => {
// When the onLoadMetadata is called, so we need to set the duration
// value when the component is mounted
onMounted(() => {
const d = audioPlayer.value?.duration;
if (Number.isFinite(d)) duration.value = d;
duration.value = audioPlayer.value?.duration;
audioPlayer.value.playbackRate = playbackSpeed.value;
});
@@ -209,7 +209,7 @@ watch(
v-if="showDropdown"
:menu-items="filteredCountries"
show-search
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
class="z-[100] w-48 mt-2 ltr:left-0 rtl:right-0 top-full max-h-52"
@action="onSelectCountry"
/>
</div>
@@ -457,7 +457,7 @@ const menuItems = computed(() => {
{},
{ page: 1, search: undefined }
),
activeOn: ['companies_dashboard_index'],
activeOn: ['companies_dashboard_index', 'companies_dashboard_show'],
},
],
},
@@ -247,7 +247,7 @@ const handleBlur = e => emit('blur', e);
v-if="showDropdownMenu"
:menu-items="filteredMenuItems"
:is-searching="isLoading"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-[inherit] max-w-md dark:!outline-n-slate-5"
class="ltr:left-0 rtl:right-0 z-[100] top-8 max-h-56 w-[inherit] max-w-md dark:!outline-n-slate-5"
@action="handleDropdownAction"
/>
</div>
@@ -1,5 +1,7 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Icon from 'next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
defineProps({
title: {
@@ -18,7 +20,13 @@ defineProps({
type: Boolean,
default: false,
},
isBeta: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
</script>
<template>
@@ -37,9 +45,18 @@ defineProps({
</div>
<div class="flex flex-col items-start gap-1.5">
<h3 class="text-n-slate-12 text-sm text-start font-medium capitalize">
{{ title }}
</h3>
<div class="flex items-center gap-2">
<h3 class="text-n-slate-12 text-sm text-start font-medium capitalize">
{{ title }}
</h3>
<Label
v-if="isBeta && !isComingSoon"
v-tooltip.top="t('GENERAL.BETA_DESCRIPTION')"
:label="t('GENERAL.BETA')"
color="blue"
compact
/>
</div>
<p class="text-n-slate-11 text-start text-sm">
{{ description }}
</p>
@@ -50,7 +67,7 @@ defineProps({
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-surface-1/90 via-n-surface-1/70 to-n-surface-1/95 cursor-not-allowed"
>
<span class="text-n-slate-12 font-medium text-sm">
{{ $t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
{{ t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
</span>
</div>
</button>
@@ -122,8 +122,6 @@ const {
onAssignAgent,
onAssignLabels,
onRemoveLabels,
onAssignTeamsForBulk,
onUpdateConversations,
} = useBulkActions();
const {
@@ -866,7 +864,7 @@ watch(conversationFilters, (newVal, oldVal) => {
<template>
<div
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1"
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1 relative"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
@@ -924,18 +922,14 @@ watch(conversationFilters, (newVal, oldVal) => {
{{ $t('CHAT_LIST.LIST.404') }}
</p>
<ConversationBulkActions
v-if="selectedConversations.length"
:conversations="selectedConversations"
:all-conversations-selected="allConversationsSelected"
:selected-inboxes="uniqueInboxes"
:show-open-action="allSelectedConversationsStatus('open')"
:show-resolved-action="allSelectedConversationsStatus('resolved')"
:show-snoozed-action="allSelectedConversationsStatus('snoozed')"
:class="isOnExpandedLayout && 'sm:!w-[24rem] !w-full'"
@select-all-conversations="toggleSelectAll"
@assign-agent="onAssignAgent"
@update-conversations="onUpdateConversations"
@assign-labels="onAssignLabels"
@assign-team="onAssignTeamsForBulk"
/>
<ConversationList
:conversation-list="conversationList"
@@ -77,6 +77,10 @@ const isComingSoon = computed(() => {
return ['voice'].includes(key) && !isActive.value;
});
const isBeta = computed(() => {
return ['tiktok', 'voice'].includes(props.channel.key);
});
const onItemClick = () => {
if (isActive.value) {
emit('channelItemClick', props.channel.key);
@@ -90,6 +94,7 @@ const onItemClick = () => {
:description="channel.description"
:icon="channel.icon"
:is-coming-soon="isComingSoon"
:is-beta="isBeta"
:disabled="!isActive"
@click="onItemClick"
/>
@@ -1,16 +1,11 @@
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession';
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const RINGTONE_URL = '/audio/dashboard/bell.mp3';
const route = useRoute();
const router = useRouter();
const store = useStore();
@@ -26,53 +21,19 @@ const {
formattedCallDuration,
} = useCallSession();
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
// don't expose a mic track on the browser side.
const isMuted = ref(false);
const isWhatsappActive = computed(
() => activeCall.value?.provider === 'whatsapp'
);
const toggleMute = () => {
isMuted.value = !isMuted.value;
setWhatsappCallMuted(isMuted.value);
};
watch(hasActiveCall, active => {
if (!active) isMuted.value = false;
});
const getCallInfo = call => {
const conversation = store.getters.getConversationById(call?.conversationId);
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
const sender = conversation?.meta?.sender;
// Inbound WhatsApp calls stash caller info on the call record (from the cable
// payload) so the widget has something to show before the conversation lands.
const caller = call?.caller;
return {
conversation,
inbox,
contactName:
sender?.name ||
sender?.phone_number ||
caller?.name ||
caller?.phone ||
'Unknown caller',
phoneNumber: sender?.phone_number || caller?.phone || '',
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
inboxName: inbox?.name || 'Customer support',
avatar: sender?.avatar || sender?.thumbnail || caller?.avatar,
avatar: sender?.avatar || sender?.thumbnail,
};
};
const goToConversation = call => {
const conversationId = call?.conversationId;
const accountId = route.params.accountId;
if (!conversationId || !accountId) return;
router.push({
path: frontendURL(conversationUrl({ accountId, id: conversationId })),
});
};
const handleEndCall = async () => {
const call = activeCall.value;
if (!call) return;
@@ -88,22 +49,21 @@ const handleEndCall = async () => {
};
const handleJoinCall = async call => {
if (!call || isJoining.value) return;
const { conversation } = getCallInfo(call);
if (!call || !conversation || isJoining.value) return;
// End current active call before joining new one
if (hasActiveCall.value) {
await handleEndCall();
}
// The conversation may not be hydrated yet (post-refresh seeding path);
// call.inboxId already carries what joinCall needs.
const result = await joinCall({
conversationId: call.conversationId,
inboxId: call.inboxId || conversation?.inbox_id,
inboxId: conversation.inbox_id,
callSid: call.callSid,
});
if (result && conversation) {
if (result) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: call.conversationId },
@@ -111,15 +71,12 @@ const handleJoinCall = async call => {
}
};
// Auto-join outbound calls when window is visible. WhatsApp outbound has no
// separate join step (the offer was sent at initiate time and the answer is
// applied directly by the cable handler), so this only covers Twilio.
// Auto-join outbound calls when window is visible
watch(
() => incomingCalls.value[0],
call => {
if (
call?.callDirection === 'outbound' &&
call?.provider !== 'whatsapp' &&
!hasActiveCall.value &&
WindowVisibilityHelper.isWindowVisible()
) {
@@ -128,103 +85,47 @@ watch(
},
{ immediate: true }
);
// Loop the ringtone while an inbound call is unanswered. Stop the moment any
// call is active (we joined), every inbound call cleared, or the widget tears
// down. Browser autoplay may reject the first play() if the tab has no prior
// user gesture; that's fine — the visual widget still surfaces the call.
const ringtone = new Audio(RINGTONE_URL);
ringtone.loop = true;
ringtone.volume = 1;
const stopRingtone = () => {
ringtone.pause();
ringtone.currentTime = 0;
};
const ringingInbound = computed(() =>
incomingCalls.value.some(call => call.callDirection !== 'outbound')
);
watch(
() => ringingInbound.value && !hasActiveCall.value,
shouldRing => {
if (shouldRing) {
ringtone.play().catch(() => {});
} else {
stopRingtone();
}
},
{ immediate: true }
);
onBeforeUnmount(stopRingtone);
</script>
<template>
<div
v-if="incomingCalls.length || hasActiveCall"
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-2 w-80"
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-2 w-72"
>
<!-- Incoming Calls (shown above active call) -->
<div
v-for="call in hasActiveCall ? incomingCalls : []"
:key="call.callSid"
class="flex flex-col gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div class="flex items-center gap-3">
<div
class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex"
>
<Avatar
:src="getCallInfo(call).avatar"
:name="getCallInfo(call).contactName"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(call).contactName }}
</p>
<p
v-if="getCallInfo(call).phoneNumber"
class="text-xs text-n-slate-11 truncate mb-0"
>
{{ getCallInfo(call).phoneNumber }}
</p>
</div>
<div class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex">
<Avatar
:src="getCallInfo(call).avatar"
:name="getCallInfo(call).contactName"
:size="40"
rounded-full
/>
</div>
<button
v-if="call.conversationId"
class="flex items-center gap-2 px-3 py-2 bg-n-alpha-2 hover:bg-n-alpha-3 rounded-lg transition-colors"
@click="goToConversation(call)"
>
<i class="text-base text-n-slate-11 i-ph-chat-circle-text-bold" />
<span class="text-xs text-n-slate-12">
{{ $t('CONVERSATION.VOICE_WIDGET.VIEW_CHAT_HISTORY') }}
</span>
<span class="ml-auto text-xs text-n-slate-11">
#{{ call.conversationId }}
</span>
<i class="text-xs text-n-slate-11 i-ph-caret-right-bold" />
</button>
<div class="flex items-center justify-between gap-2">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(call).contactName }}
</p>
<p class="text-xs text-n-slate-11 truncate">
{{ getCallInfo(call).inboxName }}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
v-tooltip.top="$t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')"
class="flex justify-center items-center w-10 h-10 bg-n-slate-3 hover:bg-n-slate-4 rounded-full transition-colors"
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="dismissCall(call.callSid)"
>
<i class="text-lg text-n-slate-12 i-ph-phone-x-bold" />
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
class="flex justify-center items-center gap-2 px-4 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
@click="handleJoinCall(call)"
>
<i class="text-base text-white i-ph-phone-bold" />
<span class="text-sm font-medium text-white">
{{ $t('CONVERSATION.VOICE_WIDGET.JOIN_CALL') }}
</span>
<i class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
@@ -232,38 +133,27 @@ onBeforeUnmount(stopRingtone);
<!-- Main Call Widget -->
<div
v-if="hasActiveCall || incomingCalls.length"
class="flex flex-col gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div class="flex items-center gap-3">
<div
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
:class="{ 'animate-pulse': !hasActiveCall }"
>
<Avatar
:src="getCallInfo(activeCall || incomingCalls[0]).avatar"
:name="getCallInfo(activeCall || incomingCalls[0]).contactName"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(activeCall || incomingCalls[0]).contactName }}
</p>
<p
v-if="getCallInfo(activeCall || incomingCalls[0]).phoneNumber"
class="text-xs text-n-slate-11 truncate mb-0"
>
{{ getCallInfo(activeCall || incomingCalls[0]).phoneNumber }}
</p>
</div>
<p
v-if="hasActiveCall"
class="font-mono text-sm text-n-slate-12 shrink-0"
>
<div
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
:class="{ 'animate-pulse': !hasActiveCall }"
>
<Avatar
:src="getCallInfo(activeCall || incomingCalls[0]).avatar"
:name="getCallInfo(activeCall || incomingCalls[0]).contactName"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(activeCall || incomingCalls[0]).contactName }}
</p>
<p v-if="hasActiveCall" class="font-mono text-sm text-n-teal-9">
{{ formattedCallDuration }}
</p>
<p v-else class="text-xs text-n-slate-11 shrink-0">
<p v-else class="text-xs text-n-slate-11">
{{
incomingCalls[0]?.callDirection === 'outbound'
? $t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL')
@@ -271,76 +161,24 @@ onBeforeUnmount(stopRingtone);
}}
</p>
</div>
<button
v-if="(activeCall || incomingCalls[0])?.conversationId"
class="flex items-center gap-2 px-3 py-2 bg-n-alpha-2 hover:bg-n-alpha-3 rounded-lg transition-colors"
@click="goToConversation(activeCall || incomingCalls[0])"
>
<i class="text-base text-n-slate-11 i-ph-chat-circle-text-bold" />
<span class="text-xs text-n-slate-12">
{{ $t('CONVERSATION.VOICE_WIDGET.VIEW_CHAT_HISTORY') }}
</span>
<span class="ml-auto text-xs text-n-slate-11">
#{{ (activeCall || incomingCalls[0])?.conversationId }}
</span>
<i class="text-xs text-n-slate-11 i-ph-caret-right-bold" />
</button>
<div class="flex items-center justify-between gap-2">
<div class="flex gap-2">
<button
v-if="hasActiveCall && isWhatsappActive"
v-tooltip.top="
isMuted
? $t('CONVERSATION.VOICE_WIDGET.UNMUTE')
: $t('CONVERSATION.VOICE_WIDGET.MUTE')
"
class="flex justify-center items-center w-10 h-10 rounded-full transition-colors"
:class="
isMuted
? 'bg-n-amber-9 hover:bg-n-amber-10'
: 'bg-n-slate-3 hover:bg-n-slate-4'
"
@click="toggleMute"
>
<i
class="text-lg"
:class="
isMuted
? 'text-white i-ph-microphone-slash-bold'
: 'text-n-slate-12 i-ph-microphone-bold'
"
/>
</button>
</div>
<div class="flex gap-2">
<button
v-if="
!hasActiveCall && incomingCalls[0]?.callDirection !== 'outbound'
"
class="flex justify-center items-center gap-2 px-4 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
@click="handleJoinCall(incomingCalls[0])"
>
<i class="text-base text-white i-ph-phone-bold" />
<span class="text-sm font-medium text-white">
{{ $t('CONVERSATION.VOICE_WIDGET.JOIN_CALL') }}
</span>
</button>
<button
class="flex justify-center items-center gap-2 px-4 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="
hasActiveCall
? handleEndCall()
: rejectIncomingCall(incomingCalls[0]?.callSid)
"
>
<i class="text-base text-white i-ph-phone-x-bold" />
<span class="text-sm font-medium text-white">
{{ $t('CONVERSATION.VOICE_WIDGET.END_CALL') }}
</span>
</button>
</div>
<div class="flex shrink-0 gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="
hasActiveCall
? handleEndCall()
: rejectIncomingCall(incomingCalls[0]?.callSid)
"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
v-if="!hasActiveCall"
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
@click="handleJoinCall(incomingCalls[0])"
>
<i class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
</div>
@@ -803,6 +803,11 @@ watch(
}
);
watch(
computed(() => props.disabled),
() => editorView?.setProps({})
);
watch(
computed(() => props.updateSelectionWith),
(newValue, oldValue) => {
@@ -4,7 +4,6 @@ import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { useElementSize } from '@vueuse/core';
import BackButton from '../BackButton.vue';
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
import Avatar from 'next/avatar/Avatar.vue';
@@ -13,14 +12,9 @@ import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox';
import {
getVoiceCallProvider,
VOICE_CALL_PROVIDERS,
} from 'dashboard/helper/inbox';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import { useCallsStore } from 'dashboard/stores/calls';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
const props = defineProps({
chat: {
@@ -100,50 +94,12 @@ const hasMultipleInboxes = computed(
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const callsStore = useCallsStore();
const whatsappCallSession = useWhatsappCallSession();
const isWhatsappVoiceInbox = computed(
() => getVoiceCallProvider(inbox.value) === VOICE_CALL_PROVIDERS.WHATSAPP
);
const isWhatsappCallButtonDisabled = computed(
() =>
whatsappCallSession.isInitiating.value ||
callsStore.hasActiveCall ||
callsStore.hasIncomingCall
);
const startWhatsappCall = async () => {
if (whatsappCallSession.isInitiating.value) return;
const copyConversationId = async () => {
try {
const response = await whatsappCallSession.initiateOutboundCall(
currentChat.value.id
);
// Permission template path returns no call id — show banner, no widget yet.
if (!response?.id) {
const status = response?.status;
const messageKey =
status === 'permission_pending'
? 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_PENDING'
: 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_REQUESTED';
useAlert(t(messageKey));
return;
}
// Stay non-active until Meta delivers the connect webhook (sdp_answer);
// flipping to active here would start the duration timer before pickup.
callsStore.addCall({
callSid: response.call_id,
callId: response.id,
conversationId: currentChat.value.id,
inboxId: inbox.value?.id,
callDirection: 'outbound',
provider: 'whatsapp',
});
await copyTextToClipboard(String(props.chat.id));
useAlert(t('CONVERSATION.HEADER.COPY_ID_SUCCESS'));
} catch (error) {
useAlert(error?.message || t('CONVERSATION.HEADER.WHATSAPP_CALL_FAILED'));
// error
}
};
</script>
@@ -188,9 +144,18 @@ const startWhatsappCall = async () => {
</div>
<div
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
class="flex items-center gap-1 overflow-hidden text-xs conversation--header--actions text-n-slate-11 text-ellipsis whitespace-nowrap"
>
<button
type="button"
class="truncate text-label-small text-n-slate-11 hover:text-n-slate-12 !p-0 cucursor-pointer"
@click="copyConversationId"
>
{{ `#${chat.id}` }}
</button>
<span v-if="hasMultipleInboxes">•</span>
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" class="!mx-0" />
<span v-if="isSnoozed">•</span>
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
@@ -207,18 +172,6 @@ const startWhatsappCall = async () => {
:parent-width="width"
class="hidden md:flex"
/>
<ButtonV4
v-if="isWhatsappVoiceInbox"
v-tooltip.bottom="$t('CONVERSATION.HEADER.WHATSAPP_CALL')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-phone"
:is-loading="whatsappCallSession.isInitiating.value"
:disabled="isWhatsappCallButtonDisabled"
class="rounded-md hover:bg-n-alpha-2"
@click="startWhatsappCall"
/>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
@@ -217,7 +217,11 @@ export default {
isReplyButtonDisabled() {
if (this.isEditorDisabled) return true;
if (this.isATwitterInbox) return true;
if (this.hasAttachments || this.hasRecordedAudio) return false;
// Note: only `hasAttachments` (attachedFiles populated) enables send.
// `hasRecordedAudio` becomes true the moment recording stops, but the
// file upload that follows is async — sending in that window produced
// empty messages that Meta rejected with "text.body is required".
if (this.hasAttachments) return false;
return (
this.isMessageEmpty ||
@@ -273,6 +277,10 @@ export default {
return MESSAGE_MAX_LENGTH.GENERAL;
},
showFileUpload() {
const { image_send: imageSend } =
this.currentChat?.additional_attributes?.tiktok_capabilities ?? {};
const tiktokAttachmentSupported = imageSend ?? true;
return (
this.isAWebWidgetInbox ||
this.isAFacebookInbox ||
@@ -283,7 +291,7 @@ export default {
this.isATelegramChannel ||
this.isALineChannel ||
this.isAnInstagramChannel ||
this.isATiktokChannel
(this.isATiktokChannel && tiktokAttachmentSupported)
);
},
replyButtonLabel() {
@@ -706,6 +714,7 @@ export default {
// Don't handle paste if editor is disabled
if (this.isEditorDisabled) return;
if (!this.showFileUpload && !this.isOnPrivateNote) return;
// Filter valid files (non-zero size)
Array.from(e.clipboardData.files)
@@ -759,6 +768,13 @@ export default {
if (this.isReplyButtonDisabled) {
return;
}
// Defensive: never send a payload that has neither content nor a file.
// The send button's disabled state already covers this, but guard here
// too so any other entry point (hotkey, programmatic) cannot create the
// empty Message rows that Meta rejects with "text.body is required".
if (this.isMessageEmpty && !this.hasAttachments) {
return;
}
if (!this.showMentions) {
const copilotAcceptedMessage = this.getCopilotAcceptedMessage();
const isOnWhatsApp =
@@ -1025,6 +1041,8 @@ export default {
});
},
attachFile({ blob, file }) {
if (!this.showFileUpload && !this.isOnPrivateNote) return;
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
@@ -22,6 +22,10 @@ const props = defineProps({
type: Array,
required: true,
},
autoPlay: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['close']);
@@ -309,6 +313,7 @@ onMounted(() => {
:src="activeAttachment.data_url"
controls
playsInline
:autoplay="autoPlay"
class="max-h-full max-w-full object-contain"
@click.stop
/>
@@ -317,6 +322,7 @@ onMounted(() => {
v-if="isAudio"
:key="activeAttachment.message_id"
controls
:autoplay="autoPlay"
class="w-full max-w-md"
@click.stop
>
@@ -1,254 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import Avatar from 'next/avatar/Avatar.vue';
import Spinner from 'shared/components/Spinner.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Avatar,
Spinner,
NextButton,
},
props: {
selectedInboxes: {
type: Array,
default: () => [],
},
conversationCount: {
type: Number,
default: 0,
},
},
emits: ['select', 'close'],
data() {
return {
query: '',
selectedAgent: null,
goBackToAgentList: false,
};
},
computed: {
...mapGetters({
uiFlags: 'bulkActions/getUIFlags',
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
}),
filteredAgents() {
if (this.query) {
return this.assignableAgents.filter(agent =>
agent.name.toLowerCase().includes(this.query.toLowerCase())
);
}
return [
{
confirmed: true,
name: 'None',
id: null,
role: 'agent',
account_id: 0,
email: 'None',
},
...this.assignableAgents,
];
},
assignableAgents() {
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.selectedInboxes.join(',')
);
},
conversationLabel() {
return this.conversationCount > 1 ? 'conversations' : 'conversation';
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', this.selectedInboxes);
},
methods: {
submit() {
this.$emit('select', this.selectedAgent);
},
goBack() {
this.goBackToAgentList = true;
this.selectedAgent = null;
},
assignAgent(agent) {
this.selectedAgent = agent;
},
onClose() {
this.$emit('close');
},
onCloseAgentList() {
if (this.selectedAgent === null && !this.goBackToAgentList) {
this.onClose();
}
this.goBackToAgentList = false;
},
},
};
</script>
<template>
<div v-on-clickaway="onCloseAgentList" class="bulk-action__agents">
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between header">
<span>{{ $t('BULK_ACTION.AGENT_SELECT_LABEL') }}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="container">
<div
v-if="assignableAgentsUiFlags.isFetching"
class="agent__list-loading"
>
<Spinner />
<p>{{ $t('BULK_ACTION.AGENT_LIST_LOADING') }}</p>
</div>
<div v-else class="agent__list-container">
<ul v-if="!selectedAgent">
<li class="search-container">
<div
class="flex items-center justify-between h-8 gap-2 agent-list-search"
>
<fluent-icon icon="search" class="search-icon" size="16" />
<input
v-model="query"
type="search"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm agent--search_input"
/>
</div>
</li>
<li v-for="agent in filteredAgents" :key="agent.id">
<div class="agent-list-item" @click="assignAgent(agent)">
<Avatar
:name="agent.name"
:src="agent.thumbnail"
:status="agent.availability_status"
:size="22"
hide-offline-status
rounded-full
/>
<span class="my-0 text-n-slate-12">
{{ agent.name }}
</span>
</div>
</li>
</ul>
<div v-else class="agent-confirmation-container">
<p v-if="selectedAgent.id">
{{
$t('BULK_ACTION.ASSIGN_CONFIRMATION_LABEL', {
conversationCount,
conversationLabel,
})
}}
<strong>
{{ selectedAgent.name }}
</strong>
<span>?</span>
</p>
<p v-else>
{{
$t('BULK_ACTION.UNASSIGN_CONFIRMATION_LABEL', {
conversationCount,
conversationLabel,
})
}}
</p>
<div class="agent-confirmation-actions">
<NextButton
faded
sm
slate
type="reset"
:label="$t('BULK_ACTION.GO_BACK_LABEL')"
@click="goBack"
/>
<NextButton
sm
type="submit"
:label="$t('BULK_ACTION.YES')"
:is-loading="uiFlags.isUpdating"
@click="submit"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.bulk-action__agents {
@apply max-w-[75%] absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply overflow-y-auto max-h-[15rem];
.agent__list-container {
@apply h-full;
}
.agent-list-search {
@apply py-0 px-2.5 bg-n-alpha-black2 border border-solid border-n-strong rounded-md;
.search-icon {
@apply text-n-slate-10;
}
.agent--search_input {
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent h-[unset] w-full;
}
}
}
.triangle {
@apply block z-10 absolute -top-3 text-left ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
}
ul {
@apply m-0 list-none;
li {
&:last-child {
.agent-list-item {
@apply last:rounded-b-lg;
}
}
}
}
.agent-list-item {
@apply flex items-center p-2.5 gap-2 cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
span {
@apply text-sm;
}
}
.agent-confirmation-container {
@apply flex flex-col h-full p-2.5;
p {
@apply flex-grow;
}
.agent-confirmation-actions {
@apply w-full grid grid-cols-2 gap-2.5;
}
}
.search-container {
@apply py-0 px-2.5 sticky top-0 z-20 bg-n-alpha-3 backdrop-blur-[100px];
}
.agent__list-loading {
@apply m-2.5 rounded-md dark:bg-n-solid-3 bg-n-slate-2 flex items-center justify-center flex-col p-5 h-[calc(95%-6.25rem)];
}
</style>
@@ -0,0 +1,202 @@
<script setup>
import { useTemplateRef, computed, ref } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useStore } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
selectedInboxes: {
type: Array,
default: () => [],
},
conversationCount: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const store = useStore();
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const selectedAgent = ref(null);
const assignableAgentsUiFlags = useMapGetter(
'inboxAssignableAgents/getUIFlags'
);
const bulkActionsUiFlags = useMapGetter('bulkActions/getUIFlags');
const isLoading = computed(() => assignableAgentsUiFlags.value.isFetching);
const isUpdating = computed(() => bulkActionsUiFlags.value.isUpdating);
const assignableAgentsList = useMapGetter(
'inboxAssignableAgents/getAssignableAgents'
);
const assignableAgents = computed(() =>
assignableAgentsList.value(props.selectedInboxes.join(','))
);
const agentMenuItems = computed(() => {
const items = [
{
action: 'select',
value: 'none',
label: t('BULK_ACTION.NONE'),
thumbnail: {
name: t('BULK_ACTION.NONE'),
src: '',
},
isSelected: selectedAgent.value?.id === null,
},
];
assignableAgents.value.forEach(agent => {
items.push({
action: 'select',
value: agent.id,
label: agent.name,
thumbnail: {
name: agent.name,
src: agent.thumbnail,
},
isSelected: selectedAgent.value?.id === agent.id,
});
});
return items;
});
const handleSelectAgent = item => {
if (item.value === 'none') {
selectedAgent.value = { id: null, name: t('BULK_ACTION.NONE') };
} else {
const agent = assignableAgents.value.find(a => a.id === item.value);
selectedAgent.value = agent || { id: null, name: t('BULK_ACTION.NONE') };
}
};
const handleAssign = () => {
if (isUpdating.value) return;
emit('select', selectedAgent.value);
selectedAgent.value = null;
toggleDropdown(false);
};
const handleCancel = () => {
selectedAgent.value = null;
};
const handleDismiss = () => {
selectedAgent.value = null;
toggleDropdown(false);
};
const handleToggleDropdown = () => {
const willOpen = !showDropdown.value;
toggleDropdown();
// Fetch agents only when opening the dropdown
if (willOpen && props.selectedInboxes.length > 0) {
store.dispatch('inboxAssignableAgents/fetch', props.selectedInboxes);
}
};
</script>
<template>
<div ref="containerRef" class="relative">
<Button
v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
icon="i-lucide-user-round-check"
slate
xs
ghost
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="handleToggleDropdown"
/>
<Transition
enter-active-class="transition-all duration-150 ease-out origin-bottom"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-bottom"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[handleDismiss, { ignore: [containerRef] }]"
:menu-items="agentMenuItems"
:is-loading="isLoading"
show-search
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="ltr:-right-10 rtl:-left-10 ltr:2xl:right-0 rtl:2xl:left-0 bottom-8 w-60 max-h-80"
@action="handleSelectAgent"
>
<template v-if="selectedAgent" #footer>
<div
class="pt-2 pb-2 px-2 border-t border-n-weak sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px]"
>
<div class="flex flex-col gap-2">
<I18nT
v-if="selectedAgent.id"
keypath="BULK_ACTION.ASSIGN_AGENT_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #n>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
<template #agentName>
<strong class="text-n-slate-12">
{{ selectedAgent.name }}
</strong>
</template>
</I18nT>
<I18nT
v-else
keypath="BULK_ACTION.UNASSIGN_AGENT_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #n>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
</I18nT>
<div class="flex gap-2">
<Button
sm
faded
slate
class="flex-1"
:label="t('BULK_ACTION.CANCEL')"
@click="handleCancel"
/>
<Button
sm
class="flex-1"
:label="t('BULK_ACTION.YES')"
:disabled="isUpdating"
:is-loading="isUpdating"
@click="handleAssign"
/>
</div>
</div>
</div>
</template>
</DropdownMenu>
</Transition>
</div>
</template>
@@ -0,0 +1,160 @@
<script setup>
import { ref, useTemplateRef, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
type: {
type: String,
default: 'conversation',
},
isLoading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['assign']);
const { t } = useI18n();
const labels = useMapGetter('labels/getLabels');
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const selectedLabels = ref([]);
const isTypeContact = computed(() => props.type === 'contact');
const buttonLabel = computed(() =>
props.type === 'contact' ? t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS') : ''
);
const isLabelSelected = labelTitle => {
return selectedLabels.value.includes(labelTitle);
};
const labelMenuItems = computed(() => {
return labels.value.map(label => ({
action: 'select',
value: label.title,
label: label.title,
color: label.color,
id: label.id,
isSelected: isLabelSelected(label.title),
}));
});
const toggleLabelSelection = labelTitle => {
const index = selectedLabels.value.indexOf(labelTitle);
if (index > -1) {
selectedLabels.value.splice(index, 1);
} else {
selectedLabels.value.push(labelTitle);
}
};
const handleAssign = () => {
if (selectedLabels.value.length > 0) {
emit('assign', selectedLabels.value);
toggleDropdown(false);
selectedLabels.value = [];
}
};
const handleDismiss = () => {
selectedLabels.value = [];
toggleDropdown(false);
};
</script>
<template>
<div ref="containerRef" class="relative">
<NextButton
v-tooltip="isTypeContact ? '' : $t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
:label="buttonLabel"
icon="i-lucide-tag"
slate
:size="isTypeContact ? 'sm' : 'xs'"
ghost
:class="{
'bg-n-alpha-2': showDropdown,
'[&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline w-fit !text-n-blue-11 [&>span]:!text-n-blue-11 !px-2':
isTypeContact,
}"
:disabled="disabled || isLoading"
:is-loading="isLoading"
@click="toggleDropdown()"
/>
<Transition
:enter-active-class="
!isTypeContact
? 'transition-all duration-150 ease-out origin-bottom'
: 'transition-all duration-150 ease-out origin-top'
"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
:leave-active-class="
!isTypeContact
? 'transition-all duration-100 ease-in origin-bottom'
: 'transition-all duration-100 ease-in origin-top'
"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[handleDismiss, { ignore: [containerRef] }]"
:menu-items="labelMenuItems"
show-search
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="w-60 max-h-80"
:class="{
'ltr:-right-[6.5rem] rtl:-left-[6.5rem] ltr:2xl:right-0 rtl:2xl:left-0 bottom-8':
!isTypeContact,
'ltr:right-0 rtl:left-0 mb-1 top-10': isTypeContact,
}"
@action="item => toggleLabelSelection(item.value)"
>
<template #thumbnail="{ item }">
<span
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
:style="{ backgroundColor: item.color }"
/>
</template>
<template #trailing-icon="{ item }">
<Icon
v-if="isLabelSelected(item.value)"
icon="i-lucide-check"
class="size-4 text-n-blue-11 flex-shrink-0"
/>
</template>
<template #footer>
<div
class="sticky bottom-0 rounded-b-md px-2 py-2 z-20 bg-n-alpha-3 backdrop-blur-[4px]"
>
<NextButton
sm
class="w-full [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length"
@click="handleAssign"
/>
</div>
</template>
</DropdownMenu>
</Transition>
</div>
</template>
@@ -0,0 +1,168 @@
<script setup>
import { useTemplateRef, computed, ref } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
conversationCount: {
type: Number,
required: true,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const selectedTeam = ref(null);
const teams = useMapGetter('teams/getTeams');
const bulkActionsUiFlags = useMapGetter('bulkActions/getUIFlags');
const isUpdating = computed(() => bulkActionsUiFlags.value.isUpdating);
const teamMenuItems = computed(() => {
const items = [
{
action: 'select',
value: 'none',
label: t('BULK_ACTION.TEAMS.NONE'),
isSelected: selectedTeam.value?.id === 0,
},
];
teams.value.forEach(team => {
items.push({
action: 'select',
value: team.id,
label: team.name,
isSelected: selectedTeam.value?.id === team.id,
});
});
return items;
});
const handleSelectTeam = item => {
if (item.value === 'none') {
selectedTeam.value = { id: 0, name: t('BULK_ACTION.TEAMS.NONE') };
} else {
const foundTeam = teams.value.find(team => team.id === item.value);
selectedTeam.value = foundTeam || {
id: 0,
name: t('BULK_ACTION.TEAMS.NONE'),
};
}
};
const handleAssign = () => {
if (isUpdating.value) return;
emit('select', selectedTeam.value);
selectedTeam.value = null;
toggleDropdown(false);
};
const handleCancel = () => {
selectedTeam.value = null;
};
const handleDismiss = () => {
selectedTeam.value = null;
toggleDropdown(false);
};
</script>
<template>
<div ref="containerRef" class="relative">
<Button
v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
icon="i-lucide-users-round"
slate
xs
ghost
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="toggleDropdown()"
/>
<Transition
enter-active-class="transition-all duration-150 ease-out origin-bottom"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-bottom"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[handleDismiss, { ignore: [containerRef] }]"
:menu-items="teamMenuItems"
show-search
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="ltr:-right-2 rtl:-left-2 bottom-8 w-60 max-h-80"
@action="handleSelectTeam"
>
<template v-if="selectedTeam" #footer>
<div
class="pt-2 pb-2 px-2 border-t border-n-weak sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px]"
>
<div class="flex flex-col gap-2">
<I18nT
v-if="selectedTeam.id"
keypath="BULK_ACTION.TEAMS.ASSIGN_TEAM_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #n>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
<template #teamName>
<strong class="text-n-slate-12">
{{ selectedTeam.name }}
</strong>
</template>
</I18nT>
<I18nT
v-else
keypath="BULK_ACTION.TEAMS.UNASSIGN_TEAM_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #n>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
</I18nT>
<div class="flex gap-2">
<Button
sm
faded
slate
class="flex-1"
:label="t('BULK_ACTION.CANCEL')"
@click="handleCancel"
/>
<Button
sm
class="flex-1"
:label="t('BULK_ACTION.YES')"
:disabled="isUpdating"
:is-loading="isUpdating"
@click="handleAssign"
/>
</div>
</div>
</div>
</template>
</DropdownMenu>
</Transition>
</div>
</template>
@@ -0,0 +1,109 @@
<script setup>
import { useTemplateRef, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
showResolve: {
type: Boolean,
default: true,
},
showReopen: {
type: Boolean,
default: true,
},
showSnooze: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['update']);
const { t } = useI18n();
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const updateMenuItems = computed(() => {
const items = [];
if (props.showResolve) {
items.push({
action: 'update',
value: 'resolved',
label: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
icon: 'i-lucide-check',
});
}
if (props.showReopen) {
items.push({
action: 'update',
value: 'open',
label: t('CONVERSATION.HEADER.REOPEN_ACTION'),
icon: 'i-lucide-redo',
});
}
if (props.showSnooze) {
items.push({
action: 'update',
value: 'snoozed',
label: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
icon: 'i-lucide-alarm-clock',
});
}
return items;
});
const handleUpdate = item => {
if (item.value === 'snoozed') {
// If the user clicks on the snooze option from the bulk action change status dropdown.
// Open the snooze option for bulk action in the cmd bar.
const ninja = document.querySelector('ninja-keys');
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
} else {
emit('update', item.value);
}
toggleDropdown(false);
};
</script>
<template>
<div ref="containerRef" class="relative">
<Button
v-tooltip="$t('BULK_ACTION.UPDATE.CHANGE_STATUS')"
icon="i-lucide-circle-fading-arrow-up"
slate
xs
ghost
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="toggleDropdown()"
/>
<Transition
enter-active-class="transition-all duration-150 ease-out origin-bottom"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-bottom"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[
() => toggleDropdown(false),
{ ignore: [containerRef] },
]"
:menu-items="updateMenuItems"
class="ltr:-right-[4.5rem] rtl:-left-[4.5rem] ltr:2xl:right-0 rtl:2xl:left-0 bottom-8 w-36"
@action="handleUpdate"
/>
</Transition>
</div>
</template>
@@ -1,7 +1,9 @@
<script>
<script setup>
import { ref, computed, onMounted, onUnmounted, useAttrs } from 'vue';
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { emitter } from 'shared/helpers/mitt';
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions.js';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
@@ -10,315 +12,181 @@ import {
} from 'dashboard/helper/commandbar/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
import AgentSelector from './AgentSelector.vue';
import UpdateActions from './UpdateActions.vue';
import LabelActions from './LabelActions.vue';
import TeamActions from './TeamActions.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import BulkAgentActions from './BulkAgentActions.vue';
import BulkUpdateActions from './BulkUpdateActions.vue';
import BulkLabelActions from './BulkLabelActions.vue';
import BulkTeamActions from './BulkTeamActions.vue';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
export default {
components: {
AgentSelector,
UpdateActions,
LabelActions,
TeamActions,
CustomSnoozeModal,
NextButton,
const props = defineProps({
conversations: {
type: Array,
default: () => [],
},
props: {
conversations: {
type: Array,
default: () => [],
},
allConversationsSelected: {
type: Boolean,
default: false,
},
selectedInboxes: {
type: Array,
default: () => [],
},
showOpenAction: {
type: Boolean,
default: false,
},
showResolvedAction: {
type: Boolean,
default: false,
},
showSnoozedAction: {
type: Boolean,
default: false,
},
allConversationsSelected: {
type: Boolean,
default: false,
},
emits: [
'selectAllConversations',
'assignAgent',
'updateConversations',
'assignLabels',
'assignTeam',
'resolveConversations',
],
data() {
return {
showAgentsList: false,
showUpdateActions: false,
showLabelActions: false,
showTeamsList: false,
popoverPositions: {},
showCustomTimeSnoozeModal: false,
};
selectedInboxes: {
type: Array,
default: () => [],
},
mounted() {
emitter.on(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
emitter.on(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
emitter.on(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
showOpenAction: {
type: Boolean,
default: false,
},
unmounted() {
emitter.off(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
emitter.off(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
emitter.off(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
showResolvedAction: {
type: Boolean,
default: false,
},
methods: {
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomTimeSnoozeModal = true;
} else if (typeof snoozeType === 'number') {
this.updateConversations('snoozed', snoozeType);
} else {
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
},
onCmdReopenConversation() {
this.updateConversations('open', null);
},
onCmdResolveConversation() {
this.updateConversations('resolved', null);
},
customSnoozeTime(customSnoozedTime) {
this.showCustomTimeSnoozeModal = false;
if (customSnoozedTime) {
this.updateConversations('snoozed', getUnixTime(customSnoozedTime));
}
},
hideCustomSnoozeModal() {
this.showCustomTimeSnoozeModal = false;
},
selectAll(e) {
this.$emit('selectAllConversations', e.target.checked);
},
submit(agent) {
this.$emit('assignAgent', agent);
},
updateConversations(status, snoozedUntil) {
this.$emit('updateConversations', status, snoozedUntil);
},
assignLabels(labels) {
this.$emit('assignLabels', labels);
},
assignTeam(team) {
this.$emit('assignTeam', team);
},
resolveConversations() {
this.$emit('resolveConversations');
},
toggleUpdateActions() {
this.showUpdateActions = !this.showUpdateActions;
},
toggleLabelActions() {
this.showLabelActions = !this.showLabelActions;
},
toggleAgentList() {
this.showAgentsList = !this.showAgentsList;
},
toggleTeamsList() {
this.showTeamsList = !this.showTeamsList;
},
showSnoozedAction: {
type: Boolean,
default: false,
},
};
});
const emit = defineEmits(['selectAllConversations']);
defineOptions({
inheritAttrs: false,
});
const attrs = useAttrs();
const {
onAssignAgent,
onAssignLabels,
onAssignTeamsForBulk: onAssignTeam,
onUpdateConversations,
} = useBulkActions();
const showCustomTimeSnoozeModal = ref(false);
function onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
showCustomTimeSnoozeModal.value = true;
} else if (typeof snoozeType === 'number') {
onUpdateConversations('snoozed', snoozeType);
} else {
onUpdateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
}
function onCmdReopenConversation() {
onUpdateConversations('open', null);
}
function onCmdResolveConversation() {
onUpdateConversations('resolved', null);
}
function customSnoozeTime(customSnoozedTime) {
showCustomTimeSnoozeModal.value = false;
if (customSnoozedTime) {
onUpdateConversations('snoozed', getUnixTime(customSnoozedTime));
}
}
function hideCustomSnoozeModal() {
showCustomTimeSnoozeModal.value = false;
}
// Computed property with getter/setter to enable v-model usage
const allSelected = computed({
get: () => props.allConversationsSelected,
set: value => {
emit('selectAllConversations', value);
},
});
onMounted(() => {
emitter.on(CMD_BULK_ACTION_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
emitter.on(CMD_BULK_ACTION_REOPEN_CONVERSATION, onCmdReopenConversation);
emitter.on(CMD_BULK_ACTION_RESOLVE_CONVERSATION, onCmdResolveConversation);
});
onUnmounted(() => {
emitter.off(CMD_BULK_ACTION_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
emitter.off(CMD_BULK_ACTION_REOPEN_CONVERSATION, onCmdReopenConversation);
emitter.off(CMD_BULK_ACTION_RESOLVE_CONVERSATION, onCmdResolveConversation);
});
</script>
<template>
<div class="bulk-action__container">
<div class="flex items-center justify-between">
<label class="flex items-center justify-between bulk-action__panel">
<input
type="checkbox"
class="checkbox"
:checked="allConversationsSelected"
:indeterminate.prop="!allConversationsSelected"
@change="selectAll($event)"
/>
<span>
{{
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
conversationCount: conversations.length,
})
}}
</span>
</label>
<div class="flex items-center gap-1 bulk-action__actions">
<NextButton
v-tooltip="$t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
icon="i-lucide-tags"
slate
xs
faded
@click="toggleLabelActions"
/>
<NextButton
v-tooltip="$t('BULK_ACTION.UPDATE.CHANGE_STATUS')"
icon="i-lucide-repeat"
slate
xs
faded
@click="toggleUpdateActions"
/>
<NextButton
v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
icon="i-lucide-user-round-plus"
slate
xs
faded
@click="toggleAgentList"
/>
<NextButton
v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
icon="i-lucide-users-round"
slate
xs
faded
@click="toggleTeamsList"
/>
</div>
<transition name="popover-animation">
<LabelActions
v-if="showLabelActions"
class="label-actions-box"
@assign="assignLabels"
@close="showLabelActions = false"
/>
</transition>
<transition name="popover-animation">
<UpdateActions
v-if="showUpdateActions"
class="update-actions-box"
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
:show-resolve="!showResolvedAction"
:show-reopen="!showOpenAction"
:show-snooze="!showSnoozedAction"
@update="updateConversations"
@close="showUpdateActions = false"
/>
</transition>
<transition name="popover-animation">
<AgentSelector
v-if="showAgentsList"
class="agent-actions-box"
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
@select="submit"
@close="showAgentsList = false"
/>
</transition>
<transition name="popover-animation">
<TeamActions
v-if="showTeamsList"
class="team-actions-box"
@assign-team="assignTeam"
@close="showTeamsList = false"
/>
</transition>
</div>
<div v-if="allConversationsSelected" class="bulk-action__alert">
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
</div>
<woot-modal
v-model:show="showCustomTimeSnoozeModal"
:on-close="hideCustomSnoozeModal"
<Transition
enter-active-class="transition-all duration-200 ease-out origin-bottom"
enter-from-class="opacity-0 scale-95 translate-y-2"
enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in origin-bottom"
leave-from-class="opacity-100 scale-100 translate-y-0"
leave-to-class="opacity-0 scale-95 translate-y-2"
>
<div
v-if="conversations.length > 0"
v-bind="attrs"
class="px-2 absolute bottom-20 sm:bottom-4 left-1/2 -translate-x-1/2 z-30 w-full origin-bottom"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@choose-time="customSnoozeTime"
/>
</woot-modal>
</div>
<div
v-if="allConversationsSelected"
class="bg-n-amber-2 outline -outline-offset-1 outline-1 outline-n-amber-5 rounded-lg text-sm mb-2 py-1.5 px-2 text-n-amber-text"
>
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
</div>
<div
class="flex items-center justify-between p-2 bg-n-button-color outline outline-1 -outline-offset-1 rounded-[10px] outline-n-weak shadow-[0_0_12px_0_rgba(27,40,59,0.08)]"
>
<div class="ltr:ml-0.5 rtl:mr-0.5 flex items-center gap-1">
<label class="cursor-pointer flex items-center gap-1.5">
<Checkbox
v-model="allSelected"
:indeterminate="!allConversationsSelected"
/>
<span class="cursor-pointer">
{{
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
conversationCount: conversations.length,
})
}}
</span>
</label>
<div class="w-px h-3 bg-n-weak rounded-lg ltr:ml-1 rtl:mr-1" />
<NextButton
:label="$t('BULK_ACTION.CLEAR_SELECTION')"
ghost
class="!text-n-blue-11 !px-1 !h-6"
sm
@click="allSelected = false"
/>
</div>
<div class="flex items-center gap-2">
<BulkLabelActions @assign="onAssignLabels" />
<BulkUpdateActions
:show-resolve="!showResolvedAction"
:show-reopen="!showOpenAction"
:show-snooze="!showSnoozedAction"
@update="onUpdateConversations"
/>
<BulkAgentActions
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
@select="onAssignAgent"
/>
<BulkTeamActions
:conversation-count="conversations.length"
@select="onAssignTeam"
/>
</div>
</div>
</div>
</Transition>
<woot-modal
v-model:show="showCustomTimeSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@choose-time="customSnoozeTime"
/>
</woot-modal>
</template>
<style scoped lang="scss">
.bulk-action__container {
@apply p-3 relative border-b border-solid border-n-strong dark:border-n-weak;
}
.bulk-action__panel {
@apply cursor-pointer;
span {
@apply text-xs my-0 mx-1;
}
input[type='checkbox'] {
@apply cursor-pointer m-0;
}
}
.bulk-action__alert {
@apply bg-n-amber-3 text-n-amber-12 rounded text-xs mt-2 py-1 px-2 border border-solid border-n-amber-5;
}
.popover-animation-enter-active,
.popover-animation-leave-active {
transition: transform ease-out 0.1s;
}
.popover-animation-enter {
transform: scale(0.95);
@apply opacity-0;
}
.popover-animation-enter-to {
transform: scale(1);
@apply opacity-100;
}
.popover-animation-leave {
transform: scale(1);
@apply opacity-100;
}
.popover-animation-leave-to {
transform: scale(0.95);
@apply opacity-0;
}
.label-actions-box {
--triangle-position: 5.3125rem;
}
.update-actions-box {
--triangle-position: 3.5rem;
}
.agent-actions-box {
--triangle-position: 1.75rem;
}
.team-actions-box {
--triangle-position: 0.125rem;
}
</style>
@@ -1,141 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const emit = defineEmits(['close', 'assign']);
const { t } = useI18n();
const labels = useMapGetter('labels/getLabels');
const query = ref('');
const selectedLabels = ref([]);
const filteredLabels = computed(() => {
if (!query.value) return labels.value;
return labels.value.filter(label =>
label.title.toLowerCase().includes(query.value.toLowerCase())
);
});
const hasLabels = computed(() => labels.value.length > 0);
const hasFilteredLabels = computed(() => filteredLabels.value.length > 0);
const isLabelSelected = label => {
return selectedLabels.value.includes(label);
};
const onClose = () => {
emit('close');
};
const handleAssign = () => {
if (selectedLabels.value.length > 0) {
emit('assign', selectedLabels.value);
}
};
</script>
<template>
<div
v-on-click-outside="onClose"
class="absolute ltr:right-2 rtl:left-2 top-12 origin-top-right z-20 w-60 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md"
role="dialog"
aria-labelledby="label-dialog-title"
>
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between p-2.5">
<span class="text-sm font-medium">{{
t('BULK_ACTION.LABELS.ASSIGN_LABELS')
}}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="flex flex-col max-h-60 min-h-0">
<header class="py-2 px-2.5">
<Input
v-model="query"
type="search"
:placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
icon-left="i-lucide-search"
size="sm"
class="w-full"
:aria-label="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
/>
</header>
<ul
v-if="hasLabels"
class="flex-1 overflow-y-auto m-0 list-none"
role="listbox"
:aria-label="t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
>
<li v-if="!hasFilteredLabels" class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('BULK_ACTION.LABELS.NO_LABELS_FOUND')
}}</span>
</li>
<li
v-for="label in filteredLabels"
:key="label.id"
class="my-1 mx-0 py-0 px-2.5"
role="option"
:aria-selected="isLabelSelected(label.title)"
>
<label
class="items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3 has-[:checked]:bg-n-slate-2"
>
<input
v-model="selectedLabels"
type="checkbox"
:value="label.title"
class="my-0 ltr:mr-2.5 rtl:ml-2.5"
:aria-label="label.title"
/>
<span
class="overflow-hidden flex-grow w-full text-sm whitespace-nowrap text-ellipsis"
>
{{ label.title }}
</span>
<span
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
:style="{ backgroundColor: label.color }"
/>
</label>
</li>
</ul>
<div v-else class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('CONTACTS_BULK_ACTIONS.NO_LABELS_FOUND')
}}</span>
</div>
<footer class="p-2">
<NextButton
sm
type="submit"
class="w-full"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length"
@click="handleAssign"
/>
</footer>
</div>
</div>
</template>
<style scoped lang="scss">
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
</style>
@@ -1,145 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
emits: ['assignTeam', 'close'],
data() {
return {
query: '',
selectedteams: [],
};
},
computed: {
...mapGetters({ teams: 'teams/getTeams' }),
filteredTeams() {
return [
{ name: 'None', id: 0 },
...this.teams.filter(team =>
team.name.toLowerCase().includes(this.query.toLowerCase())
),
];
},
},
methods: {
assignTeam(key) {
this.$emit('assignTeam', key);
},
onClose() {
this.$emit('close');
},
},
};
</script>
<template>
<div v-on-clickaway="onClose" class="bulk-action__teams">
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between header">
<span>{{ $t('BULK_ACTION.TEAMS.TEAM_SELECT_LABEL') }}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="container">
<div class="team__list-container">
<ul>
<li class="search-container">
<div
class="flex items-center justify-between h-8 gap-2 agent-list-search"
>
<fluent-icon icon="search" class="search-icon" size="16" />
<input
v-model="query"
type="search"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm agent--search_input"
/>
</div>
</li>
<template v-if="filteredTeams.length">
<li v-for="team in filteredTeams" :key="team.id">
<div class="team__list-item" @click="assignTeam(team)">
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
{{ team.name }}
</span>
</div>
</li>
</template>
<li v-else>
<div class="team__list-item">
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
{{ $t('BULK_ACTION.TEAMS.NO_TEAMS_AVAILABLE') }}
</span>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.bulk-action__teams {
@apply max-w-[75%] absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply overflow-y-auto max-h-[15rem];
.team__list-container {
@apply h-full;
}
.agent-list-search {
@apply py-0 px-2.5 bg-n-alpha-black2 border border-solid border-n-strong rounded-md;
.search-icon {
@apply text-n-slate-10;
}
.agent--search_input {
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent w-full h-[unset];
}
}
}
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
}
ul {
@apply m-0 list-none;
li {
&:last-child {
.agent-list-item {
@apply last:rounded-b-lg;
}
}
}
}
.team__list-item {
@apply flex items-center p-2.5 cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
span {
@apply text-sm;
}
}
.search-container {
@apply py-0 px-2.5 sticky top-0 z-20 bg-n-alpha-3 backdrop-blur-[100px];
}
</style>
@@ -1,109 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
showResolve: {
type: Boolean,
default: true,
},
showReopen: {
type: Boolean,
default: true,
},
showSnooze: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['update', 'close']);
const { t } = useI18n();
const actions = ref([
{ icon: 'i-lucide-check', key: 'resolved' },
{ icon: 'i-lucide-redo', key: 'open' },
{ icon: 'i-lucide-alarm-clock', key: 'snoozed' },
]);
const updateConversations = key => {
if (key === 'snoozed') {
// If the user clicks on the snooze option from the bulk action change status dropdown.
// Open the snooze option for bulk action in the cmd bar.
const ninja = document.querySelector('ninja-keys');
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
} else {
emit('update', key);
}
};
const onClose = () => {
emit('close');
};
const showAction = key => {
const actionsMap = {
resolved: props.showResolve,
open: props.showReopen,
snoozed: props.showSnooze,
};
return actionsMap[key] || false;
};
const actionLabel = key => {
const labelsMap = {
resolved: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
open: t('CONVERSATION.HEADER.REOPEN_ACTION'),
snoozed: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
};
return labelsMap[key] || '';
};
</script>
<template>
<div
v-on-clickaway="onClose"
class="absolute z-20 w-auto origin-top-right border border-solid rounded-lg shadow-md ltr:right-2 rtl:left-2 top-12 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak"
>
<div
class="right-[var(--triangle-position)] block z-10 absolute text-left -top-3"
>
<svg height="12" viewBox="0 0 24 12" width="24">
<path
d="M20 12l-8-8-12 12"
fill-rule="evenodd"
stroke-width="1px"
class="fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak"
/>
</svg>
</div>
<div class="p-2.5 flex gap-1 items-center justify-between">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}
</span>
<Button ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="px-2.5 pt-0 pb-2.5">
<WootDropdownMenu class="m-0 list-none">
<template v-for="action in actions">
<WootDropdownItem v-if="showAction(action.key)" :key="action.key">
<Button
ghost
sm
slate
class="!w-full !justify-start"
:icon="action.icon"
:label="actionLabel(action.key)"
@click="updateConversations(action.key)"
/>
</WootDropdownItem>
</template>
</WootDropdownMenu>
</div>
</div>
</template>
@@ -23,9 +23,15 @@ export function useBulkActions() {
function deSelectConversation(conversationId, inboxId) {
store.dispatch('bulkActions/removeSelectedConversationIds', conversationId);
selectedInboxes.value = selectedInboxes.value.filter(
item => item !== inboxId
);
// Only remove one instance of the inboxId, not all
// This handles the case where multiple conversations from the same inbox are selected
const index = selectedInboxes.value.indexOf(inboxId);
if (index > -1) {
selectedInboxes.value = [
...selectedInboxes.value.slice(0, index),
...selectedInboxes.value.slice(index + 1),
];
}
}
function resetBulkActions() {
@@ -141,6 +147,8 @@ export function useBulkActions() {
}
async function onUpdateConversations(status, snoozedUntil) {
if (selectedConversations.value.length === 0) return;
let conversationIds = selectedConversations.value;
let skippedCount = 0;
@@ -1,24 +1,13 @@
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { useCallsStore } from 'dashboard/stores/calls';
import { useAlert } from 'dashboard/composables';
import {
useWhatsappCallSession,
sendWhatsappTerminateBeacon,
cleanupWhatsappSession,
} from 'dashboard/composables/useWhatsappCallSession';
import { handleVoiceCallCreated } from 'dashboard/helper/voice';
import Timer from 'dashboard/helper/Timer';
const isWhatsappCall = call => call?.provider === 'whatsapp';
export function useCallSession() {
const store = useStore();
const callsStore = useCallsStore();
const whatsappSession = useWhatsappCallSession();
const { t } = useI18n();
const isJoining = ref(false);
const callDuration = ref(0);
@@ -29,7 +18,6 @@ export function useCallSession() {
const activeCall = computed(() => callsStore.activeCall);
const incomingCalls = computed(() => callsStore.incomingCalls);
const hasActiveCall = computed(() => callsStore.hasActiveCall);
const hasIncomingCall = computed(() => callsStore.hasIncomingCall);
watch(
hasActiveCall,
@@ -44,78 +32,20 @@ export function useCallSession() {
{ immediate: true }
);
// Warn before a refresh/close drops a live or ringing call. Cable events
// aren't replayed on reconnect, so a confirmed refresh during ringing would
// leave the agent unable to accept; for active calls the WebRTC session
// dies outright (no rejoin path).
const handleBeforeUnload = event => {
if (!hasActiveCall.value && !hasIncomingCall.value) return;
event.preventDefault();
event.returnValue = '';
};
// Cable broadcasts (voice_call.incoming / message.created) are one-shot, so
// on a hard refresh they leave the calls store empty. Seed it from any
// ringing voice_call message in the conversation cache.
const seedCallsFromHydratedMessages = () => {
const conversations = store.getters.getAllConversations || [];
const currentUserId = store.getters.getCurrentUserID;
conversations.forEach(conv => {
(conv.messages || []).forEach(msg => {
if (msg.content_type !== 'voice_call') return;
if (msg.call?.status !== 'ringing') return;
handleVoiceCallCreated(msg, currentUserId);
});
});
};
// Terminate only the active call — ringing calls stay alive on Meta so the
// agent can pick them up after reload (seeded back via the watcher above).
const handlePageHide = () => {
sendWhatsappTerminateBeacon();
};
const handleTwilioDisconnected = () => callsStore.clearActiveCall();
onMounted(() => {
TwilioVoiceClient.addEventListener(
'call:disconnected',
handleTwilioDisconnected
TwilioVoiceClient.addEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('pagehide', handlePageHide);
seedCallsFromHydratedMessages();
});
// Re-seed when conversations stream in after mount; addCall merges by callSid.
watch(
() => store.getters.getAllConversations?.length,
() => seedCallsFromHydratedMessages()
);
onUnmounted(() => {
durationTimer.stop();
TwilioVoiceClient.removeEventListener(
'call:disconnected',
handleTwilioDisconnected
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('pagehide', handlePageHide);
});
const findCall = callSid => callsStore.calls.find(c => c.callSid === callSid);
const endCall = async ({ conversationId, inboxId, callSid }) => {
const call = findCall(callSid);
if (isWhatsappCall(call)) {
// Pass call.callId so a wiped module state (e.g. a prior accept attempt
// tore down the WebRTC session) doesn't stop us hitting /terminate.
await whatsappSession.endActiveCall(call.callId);
durationTimer.stop();
callsStore.clearActiveCall();
return;
}
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
TwilioVoiceClient.endClientCall();
durationTimer.stop();
@@ -125,26 +55,8 @@ export function useCallSession() {
const joinCall = async ({ conversationId, inboxId, callSid }) => {
if (isJoining.value) return null;
const call = findCall(callSid);
// Outbound calls were initiated by this agent — there is no inbound offer
// to accept and the WebRTC session is already mid-handshake. Routing
// through acceptIncomingCall would call prepareInboundAnswer → cleanup()
// and destroy the live outbound session, then 409 from the backend.
if (call?.callDirection === 'outbound') return null;
isJoining.value = true;
try {
if (isWhatsappCall(call)) {
await whatsappSession.acceptIncomingCall({
callId: call.callId,
sdpOffer: call.sdpOffer,
iceServers: call.iceServers,
});
callsStore.setCallActive(callSid);
durationTimer.start();
return { callId: call.callId };
}
const device = await TwilioVoiceClient.initializeDevice(inboxId);
if (!device) return null;
@@ -172,8 +84,6 @@ export function useCallSession() {
}
// eslint-disable-next-line no-console
console.error('Failed to join call:', error);
// Drop any half-built WebRTC state so the next click starts fresh.
cleanupWhatsappSession();
return null;
} finally {
isJoining.value = false;
@@ -181,19 +91,7 @@ export function useCallSession() {
};
const rejectIncomingCall = callSid => {
const call = findCall(callSid);
if (isWhatsappCall(call) && call?.callId) {
// Outbound calls that are still ringing must be terminated, not rejected
// (reject is the inbound-side verb on Meta's API). Pass call.callId so
// a wiped module state still hits /terminate.
if (call.callDirection === 'outbound') {
whatsappSession.endActiveCall(call.callId);
} else {
whatsappSession.rejectIncomingCall(call.callId);
}
} else {
TwilioVoiceClient.endClientCall();
}
TwilioVoiceClient.endClientCall();
callsStore.dismissCall(callSid);
};
@@ -7,6 +7,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'conversation_info' },
{ name: 'contact_attributes' },
{ name: 'contact_notes' },
{ name: 'shared_files' },
{ name: 'previous_conversation' },
{ name: 'conversation_participants' },
{ name: 'linear_issues' },
@@ -1,391 +0,0 @@
import { ref } from 'vue';
import Cookies from 'js-cookie';
import WhatsappCallsAPI from 'dashboard/api/channel/whatsapp/whatsappCallsAPI';
// Module-level state lets the cable handlers and unload listeners reach the
// live PeerConnection without prop-drilling refs through every composable.
let pc = null;
let localStream = null;
let remoteStream = null;
let remoteAudioEl = null;
let mediaRecorder = null;
let recorderChunks = [];
let audioContext = null;
let activeCallId = null;
let intentionallyClosing = false;
// Inbound calls record from the moment the agent clicks accept (their click =
// pickup). Outbound calls must wait — Meta's `connect` webhook (which lands
// during ringing) negotiates remote tracks ~20s before the contact actually
// answers, and we don't want pre-pickup audio in the recording. This flag is
// flipped to true by armOutboundRecorder() when the ACCEPTED status arrives.
let recorderArmed = false;
const ensureRemoteAudioElement = () => {
if (remoteAudioEl) return remoteAudioEl;
remoteAudioEl = document.createElement('audio');
remoteAudioEl.id = 'whatsapp-call-remote-audio';
remoteAudioEl.autoplay = true;
remoteAudioEl.playsInline = true;
remoteAudioEl.style.display = 'none';
document.body.appendChild(remoteAudioEl);
return remoteAudioEl;
};
const playRemoteStream = stream => {
const el = ensureRemoteAudioElement();
el.srcObject = stream;
el.play().catch(err => {
// eslint-disable-next-line no-console
console.warn('[WhatsApp Call] remote audio play() failed:', err);
});
};
// 1s timeslice keeps a recent recording chunk in memory so a remote hangup
// that races cleanup still has data to upload.
const RECORDING_TIMESLICE_MS = 1000;
const ICE_GATHER_TIMEOUT_MS = 10000;
const RECORDER_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
];
// Outbound calls have no backend-supplied ice_servers (the call doesn't exist
// at offer time). Without STUN the browser only sends host candidates and
// browser→Meta media silently drops through any non-trivial NAT.
const DEFAULT_OUTBOUND_ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }];
const waitForIceGatheringComplete = peer =>
new Promise(resolve => {
if (peer.iceGatheringState === 'complete') {
resolve();
return;
}
const timer = setTimeout(resolve, ICE_GATHER_TIMEOUT_MS);
peer.addEventListener('icegatheringstatechange', () => {
if (peer.iceGatheringState === 'complete') {
clearTimeout(timer);
resolve();
}
});
});
const setupRecorder = () => {
if (!localStream || !remoteStream || mediaRecorder) return;
// createMediaStreamSource on a stream with no audio tracks wires up to
// nothing — the recorded mix would be silence. Wait until ontrack fires.
if (remoteStream.getAudioTracks().length === 0) return;
audioContext = new AudioContext({ sampleRate: 48000 });
// AudioContext starts suspended under most autoplay policies; without
// resume() the destination stream produces silence.
audioContext.resume().catch(() => {});
const destination = audioContext.createMediaStreamDestination();
audioContext.createMediaStreamSource(localStream).connect(destination);
audioContext.createMediaStreamSource(remoteStream).connect(destination);
const mimeType = RECORDER_MIME_CANDIDATES.find(t =>
MediaRecorder.isTypeSupported(t)
);
if (!mimeType) return;
recorderChunks = [];
mediaRecorder = new MediaRecorder(destination.stream, { mimeType });
mediaRecorder.ondataavailable = event => {
if (event.data && event.data.size > 0) recorderChunks.push(event.data);
};
mediaRecorder.start(RECORDING_TIMESLICE_MS);
};
const cleanup = () => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch (_) {
/* noop */
}
}
if (audioContext && audioContext.state !== 'closed') {
audioContext.close().catch(() => {});
}
if (localStream) localStream.getTracks().forEach(t => t.stop());
if (remoteStream) remoteStream.getTracks().forEach(t => t.stop());
if (pc) pc.close();
if (remoteAudioEl) remoteAudioEl.srcObject = null;
pc = null;
localStream = null;
remoteStream = null;
mediaRecorder = null;
recorderChunks = [];
audioContext = null;
activeCallId = null;
intentionallyClosing = false;
recorderArmed = false;
};
const buildPeerConnection = iceServers => {
const config = iceServers && iceServers.length ? { iceServers } : {};
pc = new RTCPeerConnection(config);
remoteStream = new MediaStream();
pc.ontrack = event => {
// Reuse the same MediaStream object — the recorder's audioContext source
// taps it once, so reassigning would orphan the recorder.
const tracks =
event.streams && event.streams[0]
? event.streams[0].getTracks()
: [event.track];
tracks.forEach(track => {
if (!remoteStream.getTracks().includes(track))
remoteStream.addTrack(track);
});
playRemoteStream(remoteStream);
// Only arm the recorder when the call is actually accepted. For outbound
// this is the ACCEPTED status webhook; for inbound this is the agent's
// own click (acceptIncomingCall flips recorderArmed before returning).
if (recorderArmed) setupRecorder();
};
return pc;
};
const stopRecorderAndUpload = async callId => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
await new Promise(resolve => {
mediaRecorder.addEventListener('stop', resolve, { once: true });
try {
mediaRecorder.stop();
} catch (_) {
resolve();
}
});
}
if (!recorderChunks.length || !callId) return;
const blob = new Blob(recorderChunks, { type: recorderChunks[0].type });
// Best-effort — the controller's idempotency guard handles a retry.
try {
await WhatsappCallsAPI.uploadRecording(callId, blob);
} catch (_) {
/* noop */
}
};
// devise-token-auth requires access-token / client / uid headers on every
// request — navigator.sendBeacon can't set custom headers, so we rehydrate
// the auth payload from the cw_d_session_info cookie that the dashboard sets
// at login. Used by the page-close terminate path below.
const getDeviseAuthHeaders = () => {
try {
const raw = Cookies.get('cw_d_session_info');
if (!raw) return null;
const session = JSON.parse(raw);
return {
'access-token': session['access-token'] || '',
client: session.client || '',
uid: session.uid || '',
expiry: session.expiry || '',
'token-type': session['token-type'] || 'Bearer',
};
} catch (_) {
return null;
}
};
const beaconTerminate = callId => {
if (!callId) return;
const accountId = window.location.pathname.split('/')[3];
if (!accountId) return;
const headers = getDeviseAuthHeaders();
if (!headers) return;
const url = `/api/v1/accounts/${accountId}/whatsapp_calls/${callId}/terminate`;
// fetch+keepalive (instead of navigator.sendBeacon) so we can attach auth
// headers — without them devise-token-auth 401s and the call stays open on
// Meta until its carrier-side timeout (~60s).
try {
fetch(url, {
method: 'POST',
keepalive: true,
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...headers },
body: '{}',
}).catch(() => {});
} catch (_) {
/* noop */
}
};
export function useWhatsappCallSession() {
const isInitiating = ref(false);
const prepareInboundAnswer = async (sdpOffer, iceServers) => {
cleanup();
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
buildPeerConnection(iceServers);
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
};
const prepareOutboundOffer = async () => {
cleanup();
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
buildPeerConnection(DEFAULT_OUTBOUND_ICE_SERVERS);
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
};
const acceptIncomingCall = async ({ callId, sdpOffer, iceServers }) => {
// The store may not have sdpOffer yet (the cable broadcast can race the
// click). Fall back to GET /whatsapp_calls/:id which exposes it.
let offer = sdpOffer;
let ice = iceServers;
if (!offer && callId) {
try {
const fresh = await WhatsappCallsAPI.show(callId);
offer = fresh?.sdp_offer || fresh?.sdpOffer;
ice = ice || fresh?.ice_servers || fresh?.iceServers;
} catch (e) {
// eslint-disable-next-line no-console
console.error(
'[WhatsApp Call] failed to fetch call data for accept:',
e
);
}
}
if (!offer) {
throw new Error('Missing sdp_offer for accept — call may have ended.');
}
const sdpAnswer = await prepareInboundAnswer(offer, ice);
activeCallId = callId;
// Inbound: agent's click is the pickup. Arm the recorder before the API
// round-trip so when ontrack fires (triggered by setRemoteDescription
// back in prepareInboundAnswer) the recorder is already authorized.
recorderArmed = true;
setupRecorder();
await WhatsappCallsAPI.accept(callId, sdpAnswer);
};
const rejectIncomingCall = async callId => {
intentionallyClosing = true;
try {
await WhatsappCallsAPI.reject(callId);
} finally {
cleanup();
}
};
const initiateOutboundCall = async conversationId => {
if (isInitiating.value) return null;
isInitiating.value = true;
try {
const sdpOffer = await prepareOutboundOffer();
const response = await WhatsappCallsAPI.initiate(
conversationId,
sdpOffer
);
// The permission-request branch returns no call id; let the caller render the banner.
activeCallId = response?.id || null;
return response;
} catch (e) {
cleanup();
// BE returns 422 when the contact hasn't opted in (permission template
// sent or already pending). Surface it to the caller as a normal
// response shape so it can render the banner instead of an error toast.
const data = e?.response?.data;
if (
data?.status === 'permission_requested' ||
data?.status === 'permission_pending'
) {
return { status: data.status };
}
throw e;
} finally {
isInitiating.value = false;
}
};
// callIdOverride is the call.id from the dashboard's calls store. Module
// `activeCallId` may be null after a prior accept attempt's cleanup() — but
// the call still exists on Meta and must still be terminated. Falling back
// to the override means hangup is robust to a wiped local session.
const endActiveCall = async (callIdOverride = null) => {
const callId = activeCallId || callIdOverride;
if (!callId) {
cleanup();
return;
}
intentionallyClosing = true;
try {
await stopRecorderAndUpload(callId);
await WhatsappCallsAPI.terminate(callId).catch(() => {});
} finally {
cleanup();
}
};
return {
isInitiating,
prepareInboundAnswer,
prepareOutboundOffer,
acceptIncomingCall,
rejectIncomingCall,
initiateOutboundCall,
endActiveCall,
};
}
// Cable handlers fire outside any composable instance, so the shared session
// surface is exposed as module-level functions for them.
export const applyOutboundAnswer = async (callId, sdpAnswer) => {
if (!pc) return;
activeCallId = callId;
await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
};
// Called by the cable handler when Meta delivers status=ACCEPTED for the
// outbound call (real pickup). Flips the recorder gate and starts the
// MediaRecorder. Idempotent — safe if ontrack hasn't fired yet (setupRecorder
// bails until the remote stream has audio tracks; ontrack will retry).
export const armOutboundRecorder = () => {
recorderArmed = true;
setupRecorder();
};
export const cleanupWhatsappSession = () => cleanup();
export const handleWhatsappRemoteEnd = async callId => {
// Snapshot before cleanup nulls activeCallId.
const id = callId || activeCallId;
if (!id) {
cleanup();
return;
}
try {
await stopRecorderAndUpload(id);
} finally {
cleanup();
}
};
export const setWhatsappCallMuted = muted => {
if (!localStream) return false;
localStream.getAudioTracks().forEach(track => {
track.enabled = !muted;
});
return muted;
};
export const sendWhatsappTerminateBeacon = () => {
if (!activeCallId || intentionallyClosing) return;
beaconTerminate(activeCallId);
};
+1
View File
@@ -41,6 +41,7 @@ export const FEATURE_FLAGS = {
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
CAPTAIN_V2: 'captain_integration_v2',
CAPTAIN_TASKS: 'captain_tasks',
CAPTAIN_DOCUMENT_AUTO_SYNC: 'captain_document_auto_sync',
SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
COMPANIES: 'companies',
@@ -4,12 +4,6 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import { useCallsStore } from 'dashboard/stores/calls';
import {
applyOutboundAnswer,
armOutboundRecorder,
handleWhatsappRemoteEnd,
} from 'dashboard/composables/useWhatsappCallSession';
const { isImpersonating } = useImpersonation();
@@ -41,10 +35,6 @@ class ActionCableConnector extends BaseActionCableConnector {
'account.cache_invalidated': this.onCacheInvalidate,
'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated,
'voice_call.incoming': this.onVoiceCallIncoming,
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
'voice_call.outbound_accepted': this.onVoiceCallOutboundAccepted,
'voice_call.ended': this.onVoiceCallEnded,
};
}
@@ -215,59 +205,6 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallIncoming = data => {
if (data?.provider !== 'whatsapp') return;
useCallsStore().addCall({
callSid: data.call_id,
callId: data.id,
conversationId: data.conversation_id,
inboxId: data.inbox_id,
callDirection: 'inbound',
provider: 'whatsapp',
sdpOffer: data.sdp_offer,
iceServers: data.ice_servers,
caller: data.caller,
});
};
// `connect` is the WebRTC tunnel-ready signal (fires ~20s before pickup
// for outbound). Apply the SDP answer so the handshake completes during
// ringing, but stay non-active until `outbound_accepted` arrives.
// eslint-disable-next-line class-methods-use-this
onVoiceCallOutboundConnected = async data => {
if (data?.provider !== 'whatsapp' || !data.sdp_answer) return;
try {
await applyOutboundAnswer(data.id, data.sdp_answer);
} catch (_) {
/* noop */
}
};
// Real pickup signal — Meta sends status=ACCEPTED on the call when the
// contact answers. Flip active (timer starts) and arm the recorder.
// eslint-disable-next-line class-methods-use-this
onVoiceCallOutboundAccepted = data => {
if (data?.provider !== 'whatsapp') return;
const store = useCallsStore();
if (!store.calls.some(c => c.callSid === data.call_id)) return;
store.setCallActive(data.call_id);
armOutboundRecorder();
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallEnded = async data => {
if (data?.provider !== 'whatsapp') return;
// Await upload before removeCall — the store's sync teardown would otherwise
// wipe the recorder chunks before they reach the server.
try {
await handleWhatsappRemoteEnd(data.id);
} catch (_) {
/* noop */
}
useCallsStore().removeCall(data.call_id);
};
}
export default {
+3 -6
View File
@@ -16,7 +16,6 @@ export const INBOX_TYPES = {
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
WHATSAPP: 'whatsapp',
};
export const getVoiceCallProvider = inbox => {
@@ -26,11 +25,9 @@ export const getVoiceCallProvider = inbox => {
const channelType = inbox.channel_type || inbox.channelType;
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
if (!voiceEnabled) return null;
if (channelType === INBOX_TYPES.TWILIO) return VOICE_CALL_PROVIDERS.TWILIO;
if (channelType === INBOX_TYPES.WHATSAPP)
return VOICE_CALL_PROVIDERS.WHATSAPP;
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
return VOICE_CALL_PROVIDERS.TWILIO;
}
return null;
};
+2 -13
View File
@@ -49,8 +49,6 @@ function extractCallData(message) {
const call = message?.call || {};
return {
callSid: call.provider_call_id,
callId: call.id,
provider: call.provider,
status: call.status,
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
conversationId: message?.conversation_id,
@@ -62,15 +60,8 @@ function extractCallData(message) {
export function handleVoiceCallCreated(message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const {
callSid,
callId,
provider,
callDirection,
conversationId,
assigneeId,
senderId,
} = extractCallData(message);
const { callSid, callDirection, conversationId, assigneeId, senderId } =
extractCallData(message);
if (
!shouldShowCall({
@@ -86,8 +77,6 @@ export function handleVoiceCallCreated(message, currentUserId) {
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
callId,
provider,
conversationId,
callDirection,
senderId,
@@ -3,14 +3,15 @@
"HEADER": "Custom Attributes",
"HEADER_BTN_TXT": "Add Custom Attribute",
"LOADING": "Fetching custom attributes",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts, companies, or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"COUNT": "{n} attribute | {n} attributes",
"SEARCH_PLACEHOLDER": "Search attributes...",
"NO_RESULTS": "No attributes found matching your search",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversation",
"CONTACT": "Contact"
"CONTACT": "Contact",
"COMPANY": "Company"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Text",
@@ -108,7 +109,8 @@
"TABS": {
"HEADER": "Custom Attributes",
"CONVERSATION": "Conversation",
"CONTACT": "Contact"
"CONTACT": "Contact",
"COMPANY": "Company"
},
"LIST": {
"TABLE_HEADER": {
@@ -1,12 +1,12 @@
{
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "{conversationCount} conversations selected",
"AGENT_SELECT_LABEL": "Select agent",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {conversationCount} {conversationLabel} to",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"CONVERSATIONS_SELECTED": "{conversationCount} selected",
"NONE": "None",
"CLEAR_SELECTION": "Clear",
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
"UNASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"YES": "Yes",
"CANCEL": "Cancel",
"SEARCH_INPUT_PLACEHOLDER": "Search",
"ASSIGN_AGENT_TOOLTIP": "Assign agent",
"ASSIGN_TEAM_TOOLTIP": "Assign team",
@@ -15,7 +15,6 @@
"RESOLVE_SUCCESFUL": "Conversations resolved successfully.",
"RESOLVE_FAILED": "Failed to resolve conversations. Please try again.",
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
"AGENT_LIST_LOADING": "Loading agents",
"UPDATE": {
"CHANGE_STATUS": "Change status",
"SNOOZE_UNTIL": "Snooze",
@@ -28,16 +27,14 @@
},
"LABELS": {
"ASSIGN_LABELS": "Assign labels",
"NO_LABELS_FOUND": "No labels found",
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
"ASSIGN_FAILED": "Failed to assign labels. Please try again."
},
"TEAMS": {
"TEAM_SELECT_LABEL": "Select team",
"NONE": "None",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
"ASSIGN_FAILED": "Failed to assign team. Please try again."
}
@@ -7,6 +7,7 @@
"NAME": "Name",
"DOMAIN": "Domain",
"CREATED_AT": "Created at",
"LAST_ACTIVITY_AT": "Last activity",
"CONTACTS_COUNT": "Contacts count"
}
},
@@ -21,6 +22,121 @@
"LOADING": "Loading companies...",
"UNNAMED": "Unnamed Company",
"CONTACTS_COUNT": "{n} contact | {n} contacts",
"ACTIONS": {
"CREATE": "Add company"
},
"CREATE": {
"TITLE": "Add company details",
"ACTIONS": {
"SAVE": "Add company"
},
"MESSAGES": {
"SUCCESS": "Company created.",
"ERROR": "Could not create the company."
}
},
"DETAIL": {
"LOADING": "Loading company details...",
"EMPTY_STATE": {
"TITLE": "Company not found",
"SUBTITLE": "This company may have been removed or is no longer available in this account."
},
"SIDEBAR": {
"TABS": {
"ATTRIBUTES": "Attributes",
"CONTACTS": "Contacts",
"HISTORY": "History",
"NOTES": "Notes"
}
},
"HISTORY": {
"EMPTY": "No conversations found for this company's contacts yet."
},
"NOTES": {
"EMPTY": "No notes found for this company's contacts yet."
},
"ATTRIBUTES": {
"SEARCH_PLACEHOLDER": "Search attributes...",
"EMPTY_STATE": "There are no company custom attributes configured yet.",
"NO_ATTRIBUTES": "No matching attributes found.",
"UNUSED_ATTRIBUTES": "{count} unused attribute | {count} unused attributes",
"MESSAGES": {
"UPDATE_SUCCESS": "Company attribute updated.",
"UPDATE_ERROR": "Could not update company attribute.",
"DELETE_SUCCESS": "Company attribute removed.",
"DELETE_ERROR": "Could not remove company attribute."
}
},
"CONTACTS": {
"LOADING": "Loading contacts...",
"EMPTY": "No contacts are linked to this company yet.",
"UNNAMED_CONTACT": "Unnamed contact",
"ACTIONS": {
"ADD": "Add contact",
"REMOVE": "Remove contact"
},
"DIALOGS": {
"ADD": {
"DESCRIPTION": "Search for an existing contact and link it to this company.",
"SEARCH_PLACEHOLDER": "Search contacts...",
"INITIAL": "Start typing to search contacts.",
"EMPTY": "No contacts found.",
"CONFIRM_TITLE": "Link contact",
"CONFIRM_DESCRIPTION": "Confirm the company and contact before linking them.",
"COMPANY_LABEL": "Company",
"CONTACT_LABEL": "Contact",
"CURRENT_COMPANY": "Currently linked to {companyName}",
"ADD": "Link contact",
"CANCEL": "Cancel"
}
},
"MESSAGES": {
"ADD_SUCCESS": "Contact linked to company.",
"ADD_ERROR": "Could not link contact to company.",
"REASSIGN_SUCCESS": "Contact reassigned to company.",
"REASSIGN_ERROR": "Could not reassign contact to company.",
"REMOVE_SUCCESS": "Contact removed from company.",
"REMOVE_ERROR": "Could not remove contact from company."
}
},
"AVATAR": {
"UPDATING": "Updating company avatar...",
"UPLOAD_SUCCESS": "Company avatar updated.",
"UPLOAD_ERROR": "Could not update the company avatar.",
"DELETE_SUCCESS": "Company avatar removed.",
"DELETE_ERROR": "Could not remove the company avatar."
},
"PROFILE": {
"TITLE": "Edit company details",
"CREATED_AT": "Created {date}",
"LAST_ACTIVE": "Last active {date}",
"DESCRIPTION_PLACEHOLDER": "Add a short description for this company",
"ACTIONS": {
"SAVE": "Update company"
},
"MESSAGES": {
"UPDATE_SUCCESS": "Company updated.",
"UPDATE_ERROR": "Could not update the company."
},
"FIELDS": {
"NAME": "Name",
"DOMAIN": "Domain"
}
},
"DELETE": {
"SECTION_TITLE": "Danger zone",
"SECTION_DESCRIPTION": "Delete this company and unlink its contacts. The contacts will remain in the account.",
"BUTTON": "Delete company",
"TITLE": "Delete company?",
"DESCRIPTION": "This will remove the company and unlink all associated contacts. Contacts themselves will be preserved.",
"DESCRIPTION_WITH_NAME": "This will remove {companyName} and unlink all associated contacts. Contacts themselves will be preserved.",
"CONFIRM": "Delete company",
"MESSAGES": {
"SUCCESS": "Company deleted.",
"ERROR": "Could not delete the company."
}
}
},
"EMPTY_STATE": {
"TITLE": "No companies found"
}
@@ -79,13 +79,9 @@
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NO_ANSWER": "No answer",
"NO_ANSWER_OUTBOUND_LABEL": "No answer",
"NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up",
"MISSED_CALL": "Missed call",
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"CALLING": "Calling…",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered",
@@ -99,14 +95,11 @@
"OPEN": "More",
"CLOSE": "Close",
"DETAILS": "details",
"COPY_ID_SUCCESS": "Conversation ID copied to clipboard",
"SNOOZED_UNTIL": "Snoozed until",
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
"WHATSAPP_CALL": "Start WhatsApp call",
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"SLA_STATUS": {
"FRT": "FRT {status}",
"NRT": "NRT {status}",
@@ -307,10 +300,7 @@
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call",
"MUTE": "Mute mic",
"UNMUTE": "Unmute mic",
"VIEW_CHAT_HISTORY": "View chat history"
"END_CALL": "End call"
}
},
"EMAIL_TRANSCRIPT": {
@@ -376,7 +366,19 @@
"PREVIOUS_CONVERSATION": "Previous Conversations",
"MACROS": "Macros",
"LINEAR_ISSUES": "Linked Linear Issues",
"SHOPIFY_ORDERS": "Shopify Orders"
"SHOPIFY_ORDERS": "Shopify Orders",
"SHARED_FILES": "Attachments"
},
"SHARED_FILES": {
"EMPTY": "No attachments yet",
"DOWNLOAD": "Download file",
"DOWNLOAD_ERROR": "Could not download the file. Please try again.",
"MEDIA_HEADING": "Media",
"FILES_HEADING": "Files",
"VIEW_ALL": "View all",
"SHOW_LESS": "Show less",
"MORE_COUNT": "+{count}",
"UNTITLED_FILE": "Untitled file"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
@@ -637,8 +637,7 @@
"BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT",
"VOICE": "Voice",
"CALLS": "Calls"
"VOICE": "Voice"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
@@ -649,25 +648,6 @@
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
}
},
"WHATSAPP_CALLING": {
"ENABLE": {
"LABEL": "Enable WhatsApp Calling",
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
},
"PHONE_NUMBER": {
"LABEL": "Business phone number",
"HELP_TEXT": "WhatsApp number that customers will call."
},
"HOW_IT_WORKS": {
"LABEL": "How it works",
"DESCRIPTION": "Calls are placed peer-to-peer between the agent's browser and Meta — no extra credentials are required. Make sure the agent's browser has microphone permission for this site."
},
"PERMISSION_REQUEST_BODY": {
"LABEL": "Call permission request message",
"HELP_TEXT": "Shown to the contact when they haven't yet consented to receive calls. Leave blank to use the default.",
"PLACEHOLDER": "We would like to call you regarding your conversation."
}
},
"CHANNEL_PREFERENCES": "Channel Preferences",
"WIDGET_FEATURES": "Widget features",
"ACCOUNT_HEALTH": {
@@ -820,10 +800,6 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
"WHATSAPP_CALLING_ENABLED": {
"LABEL": "Enable voice calling",
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
},
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
},
"HELP_CENTER": {
@@ -1035,7 +1011,8 @@
"LABEL": "Password",
"PLACE_HOLDER": "Password"
},
"ENABLE_SSL": "Enable SSL"
"ENABLE_SSL": "Enable SSL",
"AUTH_MECHANISM": "Authentication"
},
"MICROSOFT": {
"TITLE": "Microsoft",
@@ -742,6 +742,7 @@
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_DELETE_BUTTON": "Delete",
"BULK_SYNC_BUTTON": "Refresh",
"BULK_DELETE": {
"TITLE": "Delete documents?",
"DESCRIPTION": "Are you sure you want to delete the selected documents? This action cannot be undone.",
@@ -749,6 +750,51 @@
"SUCCESS_MESSAGE": "Documents deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the documents, please try again."
},
"BULK_SYNC": {
"SUCCESS_MESSAGE_ONE": "Refresh queued for 1 document",
"SUCCESS_MESSAGE": "Refresh queued for {count} documents",
"ZERO_MESSAGE": "No documents marked for refresh.",
"ERROR_MESSAGE": "There was an error queuing the refresh, please try again."
},
"SYNC": {
"QUEUED_MESSAGE": "Refresh queued. We'll update the document shortly.",
"ERROR_MESSAGE": "Could not queue refresh, please try again."
},
"FILTERS": {
"SOURCE": {
"ALL": "All sources",
"WEB": "Web pages",
"PDF": "PDFs"
},
"STATUS": {
"ANY": "Any status",
"UPDATED": "Updated",
"NEEDS_UPDATE": "Needs update",
"UPDATING": "Updating",
"FAILED": "Failed"
},
"SORT": {
"RECENTLY_UPDATED": "Recently updated",
"RECENTLY_CREATED": "Recently created"
},
"SEARCH_PLACEHOLDER": "Search..."
},
"SYNC_STATUS": {
"SYNCED": "last updated {time}",
"SYNCING": "updating...",
"STALE_SYNC": "update stalled",
"FAILED": "Failed to sync",
"NEVER_SYNCED": "not updated yet"
},
"SYNC_ERRORS": {
"NOT_FOUND": "Page not found",
"ACCESS_DENIED": "Access denied",
"TIMEOUT": "Page took too long to respond",
"CONTENT_EMPTY": "Page returned empty content",
"FETCH_FAILED": "Could not fetch page",
"SYNC_ERROR": "Unexpected error",
"DEFAULT": "Sync error"
},
"RELATED_RESPONSES": {
"TITLE": "Related FAQs",
"DESCRIPTION": "These FAQs are generated directly from the document."
@@ -793,11 +839,15 @@
"OPTIONS": {
"VIEW_RELATED_RESPONSES": "View Related Responses",
"SYNC_NOW": "Refresh now",
"RETRY_SYNC": "Retry refresh",
"DELETE_DOCUMENT": "Delete Document"
},
"EMPTY_STATE": {
"TITLE": "No documents available",
"SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant.",
"FILTERED_TITLE": "No matching documents",
"FILTERED_SUBTITLE": "Try changing the source, status, or search term.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain Document",
"NOTE": "A document in Captain serves as a knowledge resource for the assistant. By connecting your help center or guides, Captain can analyze the content and provide accurate responses for customer inquiries."
@@ -49,6 +49,9 @@
"ERROR_MESSAGE": "There was an error deleting the macro. Please try again later"
}
},
"VIEW": {
"TOOLTIP": "View macro"
},
"EDIT": {
"TOOLTIP": "Edit macro",
"API": {
@@ -66,7 +69,9 @@
"LABEL": "Macro Visibility",
"GLOBAL": {
"LABEL": "Public",
"DESCRIPTION": "This macro is available publicly for all agents in this account."
"DESCRIPTION": "This macro is available publicly for all agents in this account.",
"CREATE_DISABLED_DESCRIPTION": "Only administrators can create public macros.",
"EDIT_DISABLED_DESCRIPTION": "Only administrators can edit public macros."
},
"PERSONAL": {
"LABEL": "Private",
@@ -232,7 +232,7 @@ watch(() => modelValue.value, resolveContactName, { immediate: true });
show-search
disable-local-filtering
:is-searching="isSearching"
class="mt-1 ltr:left-0 rtl:right-0 top-full w-64 max-h-80 overflow-y-auto"
class="mt-1 ltr:left-0 rtl:right-0 top-full w-64 max-h-80"
@search="performSearch"
@action="handleAction"
/>

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