Compare commits

...
Author SHA1 Message Date
Shivam Mishra e06a15a432 fix: base scope 2026-01-21 20:52:20 +05:30
Shivam Mishra e5cbc174d5 feat: add sankey generator 2026-01-21 19:15:46 +05:30
Shivam Mishra fc9af7f315 feat: remove duplicate event 2026-01-21 18:53:10 +05:30
Shivam Mishra cba8e83b91 feat: update seeder to match new reporting event 2026-01-21 18:26:45 +05:30
Shivam Mishra 6325bd667a feat: include additional information 2026-01-21 17:55:38 +05:30
Shivam Mishra 92449e273c feat: update reporting_events to include new fields 2026-01-21 17:51:25 +05:30
Shivam Mishra fb81a4fc89 feat(reporting): add conversation_created event to ReportingEventListener
Add a new reporting event that fires when a conversation is created.
This event serves as a denominator for calculating rates like deflection
rate and escalation rate directly from the reporting_events table.
2026-01-21 17:40:04 +05:30
Vinay KeerthiandGitHub f84e95ed6c fix: use safe DOM manipulation for article heading permalinks (#13239) 2026-01-21 13:44:15 +05:30
6a482926b4 feat: new Captain Editor (#13235)
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
2026-01-21 13:39:07 +05:30
Sojan Jose c77c9c9d8a Merge branch 'release/4.10.1' into develop 2026-01-20 08:44:21 -08:00
Sojan Jose ecd4892a23 Bump version to 4.10.1 2026-01-20 08:43:11 -08:00
Muhsin KelothandGitHub 457430e8d9 fix: Remove phone_number_id param from WhatsApp media retrieval for incoming messages (#13319)
Fixes https://github.com/chatwoot/chatwoot/issues/13317
Fixes an issue where WhatsApp attachment messages (images, audio, video,
documents) were failing to download. Messages were being created but
without attachments.

The `phone_number_id` parameter was being passed to the `GET
/<MEDIA_ID>` endpoint when downloading incoming media. According to
Meta's documentation:

> "Note that `phone_number_id` is optional. If included, the request
will only be processed if the business phone number ID included in the
query matches the ID of the business
  phone number **that the media was uploaded on**."

For incoming messages, media is uploaded by the customer, not by the
business phone number. Passing the business's `phone_number_id` causes
validation to fail with error: `Param phone_number_id is not a valid
whatsapp business phone number id ID`

This PR removes the `phone_number_id` parameter from the media URL
request for incoming messages.
2026-01-20 20:32:23 +04:00
Shivam MishraandGitHub e13e3c873a feat: add report download task (#13250) 2026-01-19 18:31:52 +05:30
Shivam MishraandGitHub 0346e9a2c7 fix: captain inbox modal shows wrong assistant data (#13302) 2026-01-19 18:31:46 +05:30
Muhsin KelothandGitHub 7e4d93f649 fix: Setup webhooks for manual WhatsApp Cloud channel creation (#13278)
Fixes https://github.com/chatwoot/chatwoot/issues/13097

### Problem
The PR #12176 removed the `before_save :setup_webhooks` callback to fix
a race condition where Meta's webhook verification request arrived
before the channel was saved to the database. This change broke manual
WhatsApp Cloud channel setup. While embedded signup explicitly calls
`channel.setup_webhooks` in `EmbeddedSignupService`, manual setup had no
equivalent call - meaning the `subscribed_apps` endpoint was never
invoked and Meta never sent webhook events to Chatwoot.


### Solution
Added an `after_commit` callback that triggers webhook setup for manual
WhatsApp Cloud channels
2026-01-19 14:12:36 +04:00
b2ffad1998 fix: Validate status and priority params in search conversations tool (#13295)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 14:08:32 +05:30
Sojan Jose b2eca91c79 Merge branch 'release/4.10.0' into develop 2026-01-15 22:20:33 -08:00
Sojan Jose aee0740bcc Bump version to 4.10.0 2026-01-15 22:19:33 -08:00
96b5780ea7 fix: Respect survey label rules for WhatsApp CSAT template (#13285)
Ensure CSAT survey label rules are evaluated once in CsatSurveyService
before any channel-specific sending (including WhatsApp/Twilio
templates), remove the duplicated rule check from the template builder,
and cover the blocking-label scenario in service specs while simplifying
the template specs accordingly.

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-01-15 22:16:00 -08:00
d451615811 fix: prevent NoMethodError in mute helpers when contact is nil (#13277)
## Linear Ticket

https://linear.app/chatwoot/issue/CW-4569/nomethoderror-undefined-method-blocked-for-nil-nomethoderror

## Description
Fixes NoMethodError in ConversationMuteHelpers that occurs during
contact deletion race condition.
When a contact is deleted, there's a brief window (~50-150ms) where
contact_id becomes nil but conversations still exist. If ResolutionJob
runs during this window, the muted? method crashes trying to call
blocked? on nil.Fixes # (issue)

## Type of change

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

## How Has This Been Tested?

- Created orphaned conversations (contact_id = nil)
- Called muted?, mute!, unmute! - all return gracefully
- Verified async deletion still works correctly

## Checklist:

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



Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-01-15 22:00:09 -08:00
7d68c25c97 chore: upgrade node to v24.13 (#13291)
- upgrade node to v24.13

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-01-15 20:28:13 -08:00
3e560cb4cc chore: Remove year in review from sidebar UI (#13292)
Remove the Year In review from the UI

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 19:57:01 -08:00
PranavandGitHub a8b302d4cd feat(ee): Review Notes for CSAT Reports (#13289)
CSAT scores are helpful, but on their own they rarely tell the full
story. A drop in rating can come from delayed timelines, unclear
expectations, or simple misunderstandings, even when the issue itself
was handled correctly.

Review Notes for CSAT let admins/report manager roles add internal-only
context next to each CSAT response. This makes it easier to interpret
scores properly and focus on patterns and root causes, not just numbers.


<img width="2170" height="1680" alt="image"
src="https://github.com/user-attachments/assets/56df7fab-d0a7-4a94-95b9-e4c459ad33d5"
/>


### Why this matters

* Capture the real context behind individual CSAT ratings
* Clarify whether a low score points to a genuine service issue or a
process gap
* Spot recurring themes across conversations and teams
* Make CSAT reviews more useful for leadership reviews and
retrospectives

### How Review Notes work

**View CSAT responses**
Open the CSAT report to see overall metrics, rating distribution, and
individual responses.

**Add a Review Note**
For any CSAT entry, managers can add a Review Note directly below the
customer’s feedback.

**Document internal insights**
Use Review Notes to capture things like:

* Why a score was lower or higher than expected
* Patterns you are seeing across similar cases
* Observations around communication, timelines, or customer expectations

Review Notes are visible only to administrators and people with report
access only. We may expand visibility to agents in the future based on
feedback. However, customers never see them.

Each note clearly shows who added it and when, making it easy to review
context and changes over time.
2026-01-15 19:53:57 -08:00
Gabriel JablonskiandGitHub da42a4e4a1 ci: fixed bundler version to avoid bundler 4.0.4 incompatibility (#13290)
### Problem

Docker builds started failing with a misleading dependency resolution
error:
```
Could not find compatible versions
Because every version of devise-secure_password depends on railties >= 5.0.0, < 8.0.0
...
```

### Root Cause

[Bundler
4.0.4](https://github.com/ruby/rubygems/releases/tag/bundler-v4.0.4) was
released on January 14, 2026. The Dockerfile used `gem install bundler`
without version pinning, which installed the latest (4.0.4) instead of
the version specified in `Gemfile.lock` (2.5.16).

### Fix

- Pin Bundler installation to match `Gemfile.lock`: `gem install bundler
-v "$BUNDLER_VERSION"`
- Update `BUNDLER_VERSION` from 2.5.11 to 2.5.16

### ⚠️ Note

Fix found by Claude while checking why the deploy was failing, and have
not yet checked for alternatives which might be more appropriate, so
further investigation might be required.
2026-01-15 19:51:16 -08:00
Muhsin KelothandGitHub 8eb0558b0a fix: Case-insensitive language matching for WhatsApp template messages (#13269)
Fixes https://github.com/chatwoot/chatwoot/issues/13257
When sending WhatsApp template messages via API with `processed_params`,
users receiving error `(#132000) Number of parameters does not match the
expected number of params` from WhatsApp. The find template method
performed case-sensitive string comparison on language codes. If a user
sent `language: "ES"` but the template was stored as `language: "es"`,
the template wouldn't be found, resulting in empty `components: []`
being sent to WhatsApp.
2026-01-14 17:33:02 +04:00
Tanmay Deep SharmaandGitHub ee7187d2ed fix: prevent deserialization error on deletion (#13264) 2026-01-14 18:00:12 +05:30
4e0b091ef8 fix: Prevent unsupported file types on clipboard paste (#13182)
# Pull Request Template

## Description

This PR adds file type validation for clipboard-pasted attachments and
prevents unsupported file types from being attached across channels.

https://developers.chatwoot.com/self-hosted/supported-features#outgoing-attachments-supported-file-types

Fixes
https://linear.app/chatwoot/issue/CW-6233/bug-unsupported-file-types-allowed-via-clipboard-paste

## Type of change

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

## How Has This Been Tested?

**Loom video**

**Before**
https://www.loom.com/share/882c335be4894d86b9e149d9f7560e72

**After**
https://www.loom.com/share/90ad9605fc4446afb94a5b8bbe48f7db


## 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: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-14 13:07:46 +04:00
Sivin VargheseandGitHub daaa18b18a chore: Use widget color for chat input focus state (#13214) 2026-01-14 14:33:56 +05:30
Aakash BakhleandGitHub bddf06907b fix: double counting in langfuse instrumentation (#13202) 2026-01-13 18:52:38 +05:30
1a220b2982 chore: Improve compose new conversation form (#13176)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-13 18:52:10 +05:30
c483034a07 feat: Add support for sending CSAT surveys via templates (Whatsapp Twilio) (#13143)
Fixes
https://linear.app/chatwoot/issue/CW-6189/support-for-sending-csat-surveys-via-approved-whatsapp

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-13 16:32:02 +04:00
Shivam MishraandGitHub 7b51939f07 fix: country_code should be checked against the contact (#13186) 2026-01-13 14:47:27 +05:30
821a5b85c2 feat: Add conversations summary CSV export (#13110)
# Pull Request Template

## Description

This PR adds support for exporting conversation summary reports as CSV.
Previously, the Conversations report incorrectly showed an option to
download agent reports; this has now been fixed to export
conversation-level data instead.

Fixes
https://linear.app/chatwoot/issue/CW-6176/conversation-reports-export-button-exports-agent-reports-instead

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Screenshot
<img width="1859" height="1154" alt="image"
src="https://github.com/user-attachments/assets/419d26f4-fda9-4782-aea6-55ffad0c37ab"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] 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
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-13 12:30:26 +04:00
PranavandGitHub 0917e1a646 feat: Add an API to support querying metrics by ChannelType (#13255)
This API gives you how many conversations exist per channel, broken down
by status in a given time period. The max time period is capped to 6
months for now.

**Input Params:**
- **since:** Unix timestamp (seconds) - start of date range
- **until:** Unix timestamp (seconds) - end of date range


**Response Payload:**

```json
{
  "Channel::Sms": {
    "resolved": 85,
    "snoozed": 10,
    "open": 5,
    "pending": 5,
    "total": 100
  },
  "Channel::Email": {
    "resolved": 72,
    "snoozed": 15,
    "open": 13,
    "pending": 13,
    "total": 100
  },
  "Channel::WebWidget": {
    "resolved": 90,
    "snoozed": 7,
    "open": 3,
    "pending": 3,
    "total": 100
  }
}
```

**Definitons:**
resolved = Number of conversations created within the selected time
period that are currently marked as resolved.
snoozed = Number of conversations created within the selected time
period that are currently marked as snoozed.
pending = Number of conversations created within the selected time
period that are currently marked as pending.
open = Number of conversations created within the selected time period
that are currently open.
total = Total number of conversations created within the selected time
period, across all statuses.
2026-01-12 23:18:47 -08:00
Sojan Jose 9407cc2ad5 Merge branch 'hotfix/4.9.2' into develop 2026-01-12 09:15:23 -08:00
Sojan Jose ff68c3a74f Bump version to 4.9.2 2026-01-12 09:14:25 -08:00
Shivam MishraandGitHub 58cec84b93 feat: sanitize html before assiging it to tempDiv (#13252) 2026-01-12 22:41:37 +05:30
34b42a1ce1 feat: add global config for captain settings (#13141)
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-01-12 19:54:19 +05:30
Vishnu NarayananandGitHub ab83a663f0 chore: upgrade node to 24.x LTS (#13004)
Upgrade node to `24.12` LTS

FIxes https://github.com/chatwoot/chatwoot/issues/12546
Fixes https://linear.app/chatwoot/issue/CW-5707/nodejs-23-reached-eol
2026-01-12 18:10:23 +05:30
Shivam MishraandGitHub b099d3a1eb fix: PDF errors not loading in CI (#13236) 2026-01-12 15:22:15 +05:30
d526cf283d fix: pass serialized data in notification.deleted event to avoid Deserialisation (#13061)
https://one.newrelic.com/alerts/issue?account=3437125&duration=259200000&state=d088e9b7-d0ce-3fcf-fda5-145df8b9cb2a


## Description
Pass serialized data instead of ActiveRecord object in
dispatch_destroy_event to prevent ActiveJob::DeserializationError when
the notification is already deleted.

This error occurs frequently because RemoveDuplicateNotificationJob
deletes notifications, and by the time the async EventDispatcherJob
runs, the record no longer exists.

## Type of change

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

## How Has This Been Tested?



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


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Avoids ActiveJob deserialization failures by sending serialized data
for notification deletion and updating the listener accordingly.
> 
> - `Notification#dispatch_destroy_event` now dispatches
`NOTIFICATION_DELETED` with serialized `notification_data` (`id`,
`user_id`, `account_id`) instead of the AR object
> - `ActionCableListener#notification_deleted` reads
`notification_data`, finds `User`/`Account`, computes
`unread_count`/`count` via `NotificationFinder`, and broadcasts using
the user’s pubsub token
> - Specs updated to pass `notification_data` and assert payload
(including `unread_count`/`count`)
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
e2ffbe765b148fdfd2cd2e031c657c36e423c1f5. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-01-12 13:15:40 +05:30
Sivin VargheseandGitHub a2e348df06 fix: Backslash issue with -- and improve autolink handling (#13208) 2026-01-12 11:22:44 +05:30
PranavandGitHub f5957e7970 fix: Reset sidebar to show expanded list when refreshing the page (#13229)
Previously, the sidebar remembered which section was expanded using
session storage. This caused a confusing experience where the sidebar
would collapse on page refresh.

With this update, the session storage dependency is removed, and the
sidebar would expand based on the current active page, which gives a
cleaner UX.
2026-01-11 00:31:17 -08:00
Chatwoot BotandGitHub 8b230c6920 chore: Update translations (#13109) 2026-01-09 16:11:44 -08:00
c7da5b4cde chore(docs): Add contact merge endpoint to swagger documentation (#13172)
## Summary

This PR adds API documentation for the contact merge endpoint:

`POST /api/v1/accounts/{account_id}/actions/contact_merge`

This endpoint allows merging two contacts into one. The base contact
survives and receives all data from the mergee contact, which is then
deleted.

## Changes

- Added `swagger/paths/application/contacts/merge.yml` with complete
endpoint documentation
- Updated `swagger/paths/index.yml` to include the new endpoint

## Related Issues

Closes chatwoot/docs#243

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2026-01-09 15:30:46 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Pranav
b0863ab1cd chore(deps): bump httparty from 0.21.0 to 0.24.0 (#13199)
Bumps [httparty](https://github.com/jnunemaker/httparty) from 0.21.0 to
0.24.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/jnunemaker/httparty/releases">httparty's
releases</a>.</em></p>
<blockquote>
<h2>v0.24.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Force binary encoding throughout by <a
href="https://github.com/jnunemaker"><code>@​jnunemaker</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/823">jnunemaker/httparty#823</a></li>
<li>set Content-Type for Hash body in requests by <a
href="https://github.com/jnunemaker"><code>@​jnunemaker</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/828">jnunemaker/httparty#828</a></li>
<li>feat: stream multipart file uploads to reduce memory usage by <a
href="https://github.com/jnunemaker"><code>@​jnunemaker</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/829">jnunemaker/httparty#829</a></li>
<li>fix: prevent SSRF via absolute URL bypassing base_uri by <a
href="https://github.com/jnunemaker"><code>@​jnunemaker</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/830">jnunemaker/httparty#830</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/jnunemaker/httparty/compare/v0.23.2...v0.24.0">https://github.com/jnunemaker/httparty/compare/v0.23.2...v0.24.0</a></p>
<h2>0.23.2</h2>
<h2>What's Changed</h2>
<ul>
<li>Add changelog_uri metadata to gemspec by <a
href="https://github.com/baraidrissa"><code>@​baraidrissa</code></a> in
<a
href="https://redirect.github.com/jnunemaker/httparty/pull/817">jnunemaker/httparty#817</a></li>
<li>Fix multipart with files in binary mode and fields including
non-ASCII characters by <a
href="https://github.com/rdimartino"><code>@​rdimartino</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/822">jnunemaker/httparty#822</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/baraidrissa"><code>@​baraidrissa</code></a>
made their first contribution in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/817">jnunemaker/httparty#817</a></li>
<li><a
href="https://github.com/rdimartino"><code>@​rdimartino</code></a> made
their first contribution in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/822">jnunemaker/httparty#822</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/jnunemaker/httparty/compare/v0.23.1...v0.23.2">https://github.com/jnunemaker/httparty/compare/v0.23.1...v0.23.2</a></p>
<h2>v0.23.1</h2>
<ul>
<li>Add foul option to class level <a
href="https://github.com/jnunemaker/httparty/commit/d2683879c902b278a0776620dd7510c99a9db670">https://github.com/jnunemaker/httparty/commit/d2683879c902b278a0776620dd7510c99a9db670</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/jnunemaker/httparty/compare/v0.23.0...v0.23.1">https://github.com/jnunemaker/httparty/compare/v0.23.0...v0.23.1</a></p>
<h2>v0.23.0</h2>
<h2>What's Changed</h2>
<ul>
<li>new: foul mode to rescue all common network errors: <a
href="https://github.com/jnunemaker/httparty/blob/891a4a8093afd4cacecab2719223e3170d07f1c0/examples/party_foul_mode.rb">https://github.com/jnunemaker/httparty/blob/891a4a8093afd4cacecab2719223e3170d07f1c0/examples/party_foul_mode.rb</a></li>
<li>docs: replace master branch to main for better view by <a
href="https://github.com/bestony"><code>@​bestony</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/803">jnunemaker/httparty#803</a></li>
<li>Update README.md by <a
href="https://github.com/tradesmanhelix"><code>@​tradesmanhelix</code></a>
in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/811">jnunemaker/httparty#811</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/ashishra0"><code>@​ashishra0</code></a>
made their first contribution with foul mode</li>
<li><a href="https://github.com/bestony"><code>@​bestony</code></a> made
their first contribution in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/803">jnunemaker/httparty#803</a></li>
<li><a
href="https://github.com/tradesmanhelix"><code>@​tradesmanhelix</code></a>
made their first contribution in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/811">jnunemaker/httparty#811</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/jnunemaker/httparty/compare/v0.22.0...v0.23.0">https://github.com/jnunemaker/httparty/compare/v0.22.0...v0.23.0</a></p>
<h2>v0.22.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Fix typo in example name by <a
href="https://github.com/xymbol"><code>@​xymbol</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/780">jnunemaker/httparty#780</a></li>
<li>Extract request building method by <a
href="https://github.com/aliismayilov"><code>@​aliismayilov</code></a>
in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/786">jnunemaker/httparty#786</a></li>
<li>CI: Tell dependabot to update GH Actions by <a
href="https://github.com/olleolleolle"><code>@​olleolleolle</code></a>
in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/791">jnunemaker/httparty#791</a></li>
<li>Add CSV gem as a dependency for Ruby 3.4 by <a
href="https://github.com/ngan"><code>@​ngan</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/796">jnunemaker/httparty#796</a></li>
<li>Clear body when redirecting to a GET by <a
href="https://github.com/rhett-inbox"><code>@​rhett-inbox</code></a> in
<a
href="https://redirect.github.com/jnunemaker/httparty/pull/783">jnunemaker/httparty#783</a></li>
<li>CI against Ruby 3.3 by <a
href="https://github.com/y-yagi"><code>@​y-yagi</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/798">jnunemaker/httparty#798</a></li>
<li>Bump actions/checkout from 3 to 4 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/792">jnunemaker/httparty#792</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/jnunemaker/httparty/blob/main/Changelog.md">httparty's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<p>All notable <a
href="https://github.com/jnunemaker/httparty/releases">changes since
0.22 are documented in GitHub Releases</a>.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/jnunemaker/httparty/commit/55ec76e8d1df7903eab3f7c2367991400d3cf65e"><code>55ec76e</code></a>
Release 0.24.0</li>
<li><a
href="https://github.com/jnunemaker/httparty/commit/ddfbc8ddfca03d4f4026b01763ee906071ca558b"><code>ddfbc8d</code></a>
Merge pull request <a
href="https://redirect.github.com/jnunemaker/httparty/issues/830">#830</a>
from jnunemaker/fix-ssrf-base-uri-bypass</li>
<li><a
href="https://github.com/jnunemaker/httparty/commit/0529bcd6309c9fd9bfdd50ae211843b10054c240"><code>0529bcd</code></a>
fix: prevent SSRF via absolute URL bypassing base_uri
(GHSA-hm5p-x4rq-38w4)</li>
<li><a
href="https://github.com/jnunemaker/httparty/commit/05f38fd35d8088b9770513c2eaecce671f0940ec"><code>05f38fd</code></a>
Merge pull request <a
href="https://redirect.github.com/jnunemaker/httparty/issues/829">#829</a>
from jnunemaker/memory</li>
<li><a
href="https://github.com/jnunemaker/httparty/commit/8901c238c00d0aca8920271314c4c5d7dd2701fb"><code>8901c23</code></a>
feat: stream multipart file uploads to reduce memory usage</li>
<li><a
href="https://github.com/jnunemaker/httparty/commit/091bd6aa909e38822b72f8ce2383385cf8eeb302"><code>091bd6a</code></a>
Merge pull request <a
href="https://redirect.github.com/jnunemaker/httparty/issues/828">#828</a>
from jnunemaker/issue-826</li>
<li><a
href="https://github.com/jnunemaker/httparty/commit/59c0ac5f3d906fb6be2133c1b89d75329755af8f"><code>59c0ac5</code></a>
feat: set Content-Type for Hash body in requests</li>
<li><a
href="https://github.com/jnunemaker/httparty/commit/5c8b45e6297d181d99a56f5297dade3e358cc6f9"><code>5c8b45e</code></a>
Merge pull request <a
href="https://redirect.github.com/jnunemaker/httparty/issues/823">#823</a>
from jnunemaker/mixed-encodings</li>
<li><a
href="https://github.com/jnunemaker/httparty/commit/6419cb307dd435572963e4ab40cd96b41389efcf"><code>6419cb3</code></a>
Force binary encoding throughout</li>
<li><a
href="https://github.com/jnunemaker/httparty/commit/c74571f7925c8e142d02c2b7d6ebeedf923b1dd1"><code>c74571f</code></a>
Release 0.23.2</li>
<li>Additional commits viewable in <a
href="https://github.com/jnunemaker/httparty/compare/v0.21.0...v0.24.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=httparty&package-manager=bundler&previous-version=0.21.0&new-version=0.24.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 merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@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-01-09 15:21:16 -08:00
005a22fd69 feat: Add sorting by contacts count to companies list (#13012)
## Description

Adds the ability to sort companies by the number of contacts they have
(contacts_count) in ascending or descending order. This is part of the
Chatwoot 5.0 release requirements for the companies feature.

The implementation uses a scope-based approach consistent with other
sorting implementations in the codebase (e.g., contacts sorting by
last_activity_at).

## Type of change

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

## Available Sorting Options

After this change, the Companies API supports the following sorting
options:

| Sort Field | Type | Ascending | Descending |
|------------|------|-----------|------------|
| `name` | string | `?sort=name` | `?sort=-name` |
| `domain` | string | `?sort=domain` | `?sort=-domain` |
| `created_at` | datetime | `?sort=created_at` | `?sort=-created_at` |
| `contacts_count` | integer (scope) | `?sort=contacts_count` |
`?sort=-contacts_count` |

**Note:** Prefix with `-` for descending order. Companies with NULL
contacts_count will appear last (NULLS LAST).

## CURL Examples

**Sort by contacts count (ascending):**
```bash
curl -X GET 'https://app.chatwoot.com/api/v1/accounts/{account_id}/companies?sort=contacts_count' \
  -H 'api_access_token: YOUR_API_TOKEN'
```

**Sort by contacts count (descending):**
```bash
curl -X GET 'https://app.chatwoot.com/api/v1/accounts/{account_id}/companies?sort=-contacts_count' \
  -H 'api_access_token: YOUR_API_TOKEN'
```

**Sort by name (ascending):**
```bash
curl -X GET 'https://app.chatwoot.com/api/v1/accounts/{account_id}/companies?sort=name' \
  -H 'api_access_token: YOUR_API_TOKEN'
```

**Sort by created_at (descending):**
```bash
curl -X GET 'https://app.chatwoot.com/api/v1/accounts/{account_id}/companies?sort=-created_at' \
  -H 'api_access_token: YOUR_API_TOKEN'
```

**With pagination:**
```bash
curl -X GET 'https://app.chatwoot.com/api/v1/accounts/{account_id}/companies?sort=-contacts_count&page=2' \
  -H 'api_access_token: YOUR_API_TOKEN'
```

## How Has This Been Tested?

- Added RSpec tests for both ascending and descending sort
- All 24 existing specs pass
- Manually tested the sorting functionality with test data

**Test configuration:**
- Ruby 3.4.4
- Rails 7.1.5.2
- PostgreSQL (test database)

**To reproduce:**
1. Run `bundle exec rspec
spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb`
2. All tests should pass (24 examples, 0 failures)

## Checklist:

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

## Technical Details

**Backend changes:**
- Controller: Added `sort_on :contacts_count` with scope-based sorting
- Model: Added `order_on_contacts_count` scope using
`Arel::Nodes::SqlLiteral` and `sanitize_sql_for_order` with `NULLS LAST`
for consistent NULL handling
- Specs: Added 2 new tests for ascending/descending sort validation

**Files changed:**
- `enterprise/app/controllers/api/v1/accounts/companies_controller.rb`
- `enterprise/app/models/company.rb`
-
`spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb`

**Note:** This PR only includes the backend implementation. Frontend
changes (sort menu UI + i18n) will follow in a separate commit.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2026-01-09 15:20:08 -08:00
ba3eb787e7 fix: Avoid double notification email after importing contacts (#13150)
Eliminate the second email notification sent to the account admins after processing the contacts import.

Co-authored-by: Pranav <pranav@chatwoot.com>
2026-01-09 15:11:19 -08:00
PranavandGitHub 837026c146 feat: Use amplitude for Cloud Analytics (#13217)
Migrates our analytics integration on Cloud from PostHog to Amplitude.
This change updates the core AnalyticsHelper class to use the Amplitude
SDK while maintaining the same tracking interface. Rest of all existing
analytics calls throughout the codebase continue to work without
modification.

**Changes:**
- Replace PostHog analytics with Amplitude SDK
- Rename ANALYTICS_TOKEN to CLOUD_ANALYTICS_TOKEN for clarity
- Fix bug in page() method signature that was causing malformed payloads
2026-01-09 09:32:09 -08:00
PranavandGitHub ded2f2751a fix: Rendering of translations based on the user's locale (#13211)
Previously, translations were generated and resolved purely based on the
account locale. This caused issues in multi-team, multi-region setups
where agents often work in different languages than the account default.

For example, an account might be set to English, while an agent prefers
Spanish. In this setup:
- Translations were always created using the account locale.
- Agents could not view content in their preferred language.
- This did not scale well for global teams.

There was also an issue with locale resolution during rendering, where
the system would incorrectly default to the account locale even when a
more appropriate locale should have been used.

With this update, During rendering, the system first attempts to use the
agent’s locale. If a translation for that locale does not exist, it
falls back to the account locale.


**How to test:**

- Set agent locale to a specific language (e.g., zh_CN) and account
language to en.
  - Translate a message.
- Verify translated content displays correctly for the agent's selected
locale
  - Do the same for another locale for agent.
- With multiple translations on a message (e.g., zh_CN, es, ml), verify
the UI shows the one matching agent's locale
- Change agent locale and verify the displayed translation updates
accordingly
2026-01-08 18:37:42 -08:00
Sivin VargheseandGitHub ed0e87405c fix: Strip autolinks <...> when links are not supported (#13204)
# Pull Request Template

## Description

This PR fixes the crash that occurred when inserting canned responses
containing **autolinks** (e.g. `<https://example.com>`) into reply
channels that **do not support links**, such as **Twilio SMS**.

### Steps to reproduce

1. Create a canned response with an autolink, for example:
`<https://example.com>`.
2. Open a conversation in a channel that does not support links (e.g.
SMS).
3. Insert the canned response into the reply box.

### Cause

* Currently, only standard markdown links (`[text](url)`) are handled
when stripping unsupported formats from canned responses.
* Autolinks (`<https://example.com>`) are not handled during this
process.
* As a result, **Error: Token type link_open not supported by Markdown
parser**

### Solution

* Extended the markdown link parsing logic to explicitly handle
**autolinks** in addition to standard markdown links.
* When a canned response containing an autolink is inserted into a reply
box for a channel that does not support links (e.g. SMS), the angle
brackets (`< >`) are stripped.
* The autolink is safely pasted as **plain text URL**, preventing parser
errors and editor crashes.



Fixes
https://linear.app/chatwoot/issue/CW-6256/error-token-type-link-open-not-supported-by-markdown-parser
Sentry issues
[[1](https://chatwoot-p3.sentry.io/issues/7103543778/?environment=production&project=4507182691975168&query=is%3Aunresolved%20markdown&referrer=issue-stream)],
[[2](https://chatwoot-p3.sentry.io/issues/7104325962/?environment=production&project=4507182691975168&query=is%3Aunresolved%20markdown&referrer=issue-stream
)]


## Type of change

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


## 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
2026-01-08 18:37:52 +04:00
92422f979d chore: Replace plain editor with advanced editor (#13071)
# Pull Request Template

## Description

This PR reverts the plain text editor back to the **advanced editor**,
which was previously removed in
[https://github.com/chatwoot/chatwoot/pull/13058](https://github.com/chatwoot/chatwoot/pull/13058).
All channels now use the **ProseMirror editor**, with formatting applied
based on each channel’s configuration.

This PR also fixes issues where **new lines were not properly preserved
during Markdown serialization**, for both:

* `Enter or CMD/Ctrl+enter` (new paragraph)
* `Shift+Enter` (`hard_break`)

Additionally, it resolves related **[Sentry
issue](https://chatwoot-p3.sentry.io/issues/?environment=production&project=4507182691975168&query=is%3Aunresolved%20markdown&referrer=issue-list&statsPeriod=7d)**.

With these changes:

* Line breaks and spacing are now preserved correctly when saving canned
responses.
* When editing a canned response, the content retains the exact spacing
and formatting as saved in editor.
* Canned responses are now correctly converted to plain text where
required and displayed consistently in the canned response list.

### https://github.com/chatwoot/prosemirror-schema/pull/38

---

## Type of change

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

## 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
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-08 15:17:54 +05:30
Aakash BakhleandGitHub dcc72ee63c fix: consistent instrumentation with conversation.display_id (#13194) 2026-01-08 12:27:44 +05:30
PranavandGitHub 7ba7bf842e fix: Use SignedId instead of regular ID in portal update (#13197)
The Active Storage blob ids were handled inconsistently across the API.

- When a new logo was uploaded, the upload controller returned a
signed_id
- When loading an existing portal that already had a logo, the portal
model returned a blob_id.

This caused the API portal API to fail. There are about 107 instances of
this in production, so this change will fix that.
2026-01-07 19:36:29 -08:00
PranavandGitHub 69be587729 chore: Update copyright year in README.md to 2026 (#13195)
Basically what the title says
2026-01-07 17:42:41 -08:00
59cbf57e20 feat: Advanced Search Backend (#12917)
## Description

Implements comprehensive search functionality with advanced filtering
capabilities for Chatwoot (Linear: CW-5956).

This PR adds:
1. **Time-based filtering** for contacts and conversations (SQL-based
search)
2. **Advanced message search** with multiple filters
(OpenSearch/Elasticsearch-based)
- **`from` filter**: Filter messages by sender (format: `contact:42` or
`agent:5`)
   - **`inbox_id` filter**: Filter messages by specific inbox
- **Time range filters**: Filter messages using `since` and `until`
parameters (Unix timestamps in seconds)
- **90-day limit enforcement**: Automatically limits searches to the
last 90 days to prevent performance issues

The implementation extends the existing `Enterprise::SearchService`
module for advanced features and adds time filtering to the base
`SearchService` for SQL-based searches.

## API Documentation

### Base URL
All search endpoints follow this pattern:
```
GET /api/v1/accounts/{account_id}/search/{resource}
```

### Authentication
All requests require authentication headers:
```
api_access_token: YOUR_ACCESS_TOKEN
```

---

## 1. Search All Resources

**Endpoint:** `GET /api/v1/accounts/{account_id}/search`

Returns results from all searchable resources (contacts, conversations,
messages, articles).

### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |
| `since` | integer | Unix timestamp (contacts/conversations only) | No
|
| `until` | integer | Unix timestamp (contacts/conversations only) | No
|

### Example Request
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search?q=customer" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

### Example Response
```json
{
  "payload": {
    "contacts": [...],
    "conversations": [...],
    "messages": [...],
    "articles": [...]
  }
}
```

---

## 2. Search Contacts

**Endpoint:** `GET /api/v1/accounts/{account_id}/search/contacts`

Search contacts by name, email, phone number, or identifier with
optional time filtering.

### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |
| `since` | integer | Unix timestamp - filter by last_activity_at | No |
| `until` | integer | Unix timestamp - filter by last_activity_at | No |

### Example Requests

**Basic search:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/contacts?q=john" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Search contacts active in the last 7 days:**
```bash
SINCE=$(date -v-7d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/contacts?q=john&since=${SINCE}" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Search contacts active between 30 and 7 days ago:**
```bash
SINCE=$(date -v-30d +%s)
UNTIL=$(date -v-7d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/contacts?q=john&since=${SINCE}&until=${UNTIL}" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

### Example Response
```json
{
  "payload": {
    "contacts": [
      {
        "id": 42,
        "email": "john@example.com",
        "name": "John Doe",
        "phone_number": "+1234567890",
        "identifier": "user_123",
        "additional_attributes": {},
        "created_at": 1701234567
      }
    ]
  }
}
```

---

## 3. Search Conversations

**Endpoint:** `GET /api/v1/accounts/{account_id}/search/conversations`

Search conversations by display ID, contact name, email, phone number,
or identifier with optional time filtering.

### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |
| `since` | integer | Unix timestamp - filter by last_activity_at | No |
| `until` | integer | Unix timestamp - filter by last_activity_at | No |

### Example Requests

**Basic search:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/conversations?q=billing" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Search conversations active in the last 24 hours:**
```bash
SINCE=$(date -v-1d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/conversations?q=billing&since=${SINCE}" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Search conversations from last month:**
```bash
SINCE=$(date -v-30d +%s)
UNTIL=$(date +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/conversations?q=billing&since=${SINCE}&until=${UNTIL}" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

### Example Response
```json
{
  "payload": {
    "conversations": [
      {
        "id": 123,
        "display_id": 45,
        "inbox_id": 1,
        "status": "open",
        "messages": [...],
        "meta": {...}
      }
    ]
  }
}
```

---

## 4. Search Messages (Advanced)

**Endpoint:** `GET /api/v1/accounts/{account_id}/search/messages`

Advanced message search with multiple filters powered by
OpenSearch/Elasticsearch.

### Prerequisites
- OpenSearch/Elasticsearch must be running (`OPENSEARCH_URL` env var
configured)
- Account must have `advanced_search` feature flag enabled
- Messages must be indexed in OpenSearch

### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |
| `from` | string | Filter by sender: `contact:{id}` or `agent:{id}` |
No |
| `inbox_id` | integer | Filter by specific inbox ID | No |
| `since` | integer | Unix timestamp - searches from this time (max 90
days ago) | No |
| `until` | integer | Unix timestamp - searches until this time | No |

### Important Notes
- **90-Day Limit**: If `since` is not provided, searches default to the
last 90 days
- If `since` exceeds 90 days, returns `422` error: "Search is limited to
the last 90 days"
- All time filters use message `created_at` timestamp

### Example Requests

**Basic message search:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Search messages from a specific contact:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&from=contact:42" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Search messages from a specific agent:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&from=agent:5" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Search messages in a specific inbox:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&inbox_id=3" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Search messages from the last 7 days:**
```bash
SINCE=$(date -v-7d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&since=${SINCE}" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Search messages between specific dates:**
```bash
SINCE=$(date -v-30d +%s)
UNTIL=$(date -v-7d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&since=${SINCE}&until=${UNTIL}" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Combine all filters:**
```bash
SINCE=$(date -v-14d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&from=contact:42&inbox_id=3&since=${SINCE}" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Attempt to search beyond 90 days (returns error):**
```bash
SINCE=$(date -v-120d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&since=${SINCE}" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

### Example Response (Success)
```json
{
  "payload": {
    "messages": [
      {
        "id": 789,
        "content": "I need a refund for my purchase",
        "message_type": "incoming",
        "created_at": 1701234567,
        "conversation_id": 123,
        "inbox_id": 3,
        "sender": {
          "id": 42,
          "type": "contact"
        }
      }
    ]
  }
}
```

### Example Response (90-day limit exceeded)
```json
{
  "error": "Search is limited to the last 90 days"
}
```
**Status Code:** `422 Unprocessable Entity`

---

## 5. Search Articles

**Endpoint:** `GET /api/v1/accounts/{account_id}/search/articles`

Search help center articles by title or content.

### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |

### Example Request
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/articles?q=installation" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

### Example Response
```json
{
  "payload": {
    "articles": [
      {
        "id": 456,
        "title": "Installation Guide",
        "slug": "installation-guide",
        "portal_slug": "help",
        "account_id": 1,
        "category_name": "Getting Started",
        "status": "published",
        "updated_at": 1701234567
      }
    ]
  }
}
```

---

## Technical Implementation

### SQL-Based Search (Contacts, Conversations, Articles)
- Uses PostgreSQL `ILIKE` queries by default
- Optional GIN index support via `search_with_gin` feature flag for
better performance
- Time filtering uses `last_activity_at` for contacts/conversations
- Returns paginated results (15 per page)

### Advanced Search (Messages)
- Powered by OpenSearch/Elasticsearch via Searchkick gem
- Requires `OPENSEARCH_URL` environment variable
- Requires `advanced_search` account feature flag
- Enforces 90-day lookback limit via
`Limits::MESSAGE_SEARCH_TIME_RANGE_LIMIT_DAYS`
- Validates inbox access permissions before filtering
- Returns paginated results (15 per page)

---

## Type of change

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

---

## How Has This Been Tested?

### Unit Tests
- **Contact Search Tests**: 3 new test cases for time filtering
(`since`, `until`, combined)
- **Conversation Search Tests**: 3 new test cases for time filtering
- **Message Search Tests**: 10+ test cases covering:
  - Individual filters (`from`, `inbox_id`, time range)
  - Combined filters
  - Permission validation for inbox access
  - Feature flag checks
  - 90-day limit enforcement
  - Error handling for exceeded time limits

### Test Commands
```bash
# Run all search controller tests
bundle exec rspec spec/controllers/api/v1/accounts/search_controller_spec.rb

# Run search service tests (includes enterprise specs)
bundle exec rspec spec/services/search_service_spec.rb
```

### Manual Testing Setup
A rake task is provided to create 50,000 test messages across multiple
inboxes:

```bash
# 1. Create test data
bundle exec rake search:setup_test_data

# 2. Start OpenSearch
mise elasticsearch-start

# 3. Reindex messages
rails runner "Message.search_index.import Message.all"

# 4. Enable feature flag
rails runner "Account.first.enable_features('advanced_search')"

# 5. Test via API or Rails console
```

---

## 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 (this PR
description)
- [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

---

## Additional Notes

### Requirements
- **OpenSearch/Elasticsearch**: Required for advanced message search
  - Set `OPENSEARCH_URL` environment variable
  - Example: `export OPENSEARCH_URL=http://localhost:9200`
- **Feature Flags**:
  - `advanced_search`: Account-level flag for message advanced search
- `search_with_gin` (optional): Account-level flag for GIN-based SQL
search

### Performance Considerations
- 90-day limit prevents expensive long-range queries on large datasets
- GIN indexes recommended for high-volume search on SQL-based resources
- OpenSearch/Elasticsearch provides faster full-text search for messages

### Breaking Changes
- None. All new parameters are optional and backward compatible.

### Frontend Integration
- Frontend PR tracking advanced search UI will consume these endpoints
- Time range pickers should convert JavaScript `Date` to Unix timestamps
(seconds)
- Date conversion: `Math.floor(date.getTime() / 1000)`

### Error Handling
- Invalid `from` parameter format is silently ignored (filter not
applied)
- Time range exceeding 90 days returns `422` with error message
- Missing `q` parameter returns `422` (existing behavior)
- Unauthorized inbox access is filtered out (no error, just excluded
from results)

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-07 15:30:49 +05:30
Shivam MishraandGitHub 566de02385 feat: allow agent bot and captain responses to reset waiting since (#13181)
When AgentBot responds to customer messages, the `waiting_since`
timestamp is not reset, causing inflated reply time metrics when a human
agent eventually responds. This results in inaccurate reporting that
incorrectly includes periods when customers were satisfied with bot
responses.

### Timeline from Production Data

```
Dec 12, 16:20:14 - Customer sends message (ID: 368451924)
                   ↓ waiting_since = Dec 12, 16:20:14

Dec 12, 16:20:17 - AgentBot replies (ID: 368451960)
                   ↓ waiting_since STILL = Dec 12, 16:20:14 ❌
                   ↓ (Bot response doesn't clear it)

14-day gap        - Customer satisfied, no messages
                   ↓ waiting_since STILL = Dec 12, 16:20:14 ❌

Dec 26, 22:25:45 - Customer sends new message (ID: 383522275)
                   ↓ waiting_since STILL = Dec 12, 16:20:14 ❌
                   ↓ (New message doesn't reset it)

Dec 26-27         - More AgentBot interactions
                   ↓ waiting_since STILL = Dec 12, 16:20:14 ❌

Dec 27, 07:36:53 - Human agent finally replies (ID: 383799517)
                   ↓ Reply time calculated: 1,268,404 seconds
                   ↓ = 14.7 DAYS ❌
```
## Root Cause

The core issues is in `app/models/message.rb`, where **AgentBot messages
does not clear `waiting_since`** - The `human_response?` method only
returns true for `User` senders, so bot replies never trigger the
clearing logic. This means once `waiting_since` is set, it stays set
even when customers send new messages after receiving bot responses.

The solution is to simply reset `waiting_since` **after a bot has
responded**. This ensures reply time metrics reflect actual human agent
response times, not bot-handled periods.

### What triggers the rest

This is an intentional "gotcha", that only `AgentBot` and
`Captain::Assistant` messages trigger the waiting time reset. Automation
and campaign messages maintain current behavior (no reset). This is
because interactive bot assistants provide conversational help that
might satisfy customers. Automation and campaigns are one-way
communications and shouldn't affect waiting time calculations.

## Related Work

Extends PR #11787 which fixed `waiting_since` clearing on conversation
resolution. This PR addresses the bot interaction scenario which was not
covered by that fix.

Scripts to clean data:
https://gist.github.com/scmmishra/bd133208e219d0ab52fbfdf03036c48a
2026-01-07 13:57:43 +05:30
Shivam MishraandGitHub 02ab856520 feat(CW-6187): include headers from incoming emails (#13139) 2026-01-07 12:45:54 +05:30
Sojan Jose 8311657f9c Merge branch 'release/4.9.1'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-12-22 18:56:05 -08:00
Sojan Jose 73140998ff Merge branch 'release/4.9.0'
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-12-19 19:12:55 -08:00
Sojan Jose 4e9f644646 Merge branch 'release/4.8.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-11-18 18:41:51 -08:00
Sojan Jose bfa8a8ed60 Merge branch 'release/4.7.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-10-15 22:11:02 -07:00
Sojan Jose 7ab60d9f9c Merge branch 'release/4.6.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-09-19 13:44:30 +05:30
Sojan Jose faac1486e9 Merge branch 'release/4.5.2'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-08-20 21:45:34 +02:00
Sojan Jose 8340bd615c Merge branch 'release/4.5.1'
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-08-20 16:25:15 +02:00
Sojan Jose 75d3569f22 Merge branch 'release/4.5.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-08-18 18:40:04 +02:00
Sojan Jose 545453537f Merge branch 'release/4.4.0'
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-07-16 00:10:42 -07:00
Sojan d75702c6b2 Merge branch 'release/4.3.0'
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-06-17 17:29:27 -07:00
Sojan b76ec878f1 Merge branch 'release/4.2.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-05-20 00:12:06 -07:00
Sojan a954e1eaca Merge branch 'release/4.1.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-04-16 23:10:46 -07:00
Sojan bc5f1722e1 Merge branch 'release/4.0.4'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-03-21 18:55:06 -07:00
Sojan 8f39e62570 Merge branch 'release/4.0.3'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-02-27 21:05:35 -08:00
Sojan 7520ca7a99 Merge branch 'release/4.0.2'
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-02-21 17:04:31 -08:00
Sojan 35f4e63605 Merge branch 'release/4.0.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2025-01-17 01:13:27 +05:30
Sojan 5773089865 Merge branch 'release/4.0.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2025-01-16 17:11:45 +05:30
Sojan d01c7d3fa7 Merge branch 'release/3.16.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-12-17 23:03:24 +05:30
Sojan 959d2c0d8c Merge branch 'release/3.15.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-11-19 18:02:07 +08:00
Vishnu Narayanan 622f29a0b7 Merge branch 'hotfix/3.14.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-10-22 16:03:47 +05:30
687 changed files with 20279 additions and 4402 deletions
+47 -3
View File
@@ -76,7 +76,7 @@ jobs:
bundle install
- node/install:
node-version: '23.7'
node-version: '24.13'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -117,7 +117,7 @@ jobs:
steps:
- checkout
- node/install:
node-version: '23.7'
node-version: '24.13'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -148,7 +148,7 @@ jobs:
steps:
- checkout
- node/install:
node-version: '23.7'
node-version: '24.13'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -218,6 +218,49 @@ jobs:
source ~/.rvm/scripts/rvm
bundle install
# Install and configure OpenSearch
- run:
name: Install OpenSearch
command: |
# Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients)
wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz
tar -xzf opensearch-2.11.0-linux-x64.tar.gz
sudo mv opensearch-2.11.0 /opt/opensearch
- run:
name: Configure and Start OpenSearch
command: |
# Configure OpenSearch for single-node testing
cat > /opt/opensearch/config/opensearch.yml \<< EOF
cluster.name: chatwoot-test
node.name: node-1
network.host: 0.0.0.0
http.port: 9200
discovery.type: single-node
plugins.security.disabled: true
EOF
# Set ownership and permissions
sudo chown -R $USER:$USER /opt/opensearch
# Start OpenSearch in background
/opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid
- run:
name: Wait for OpenSearch to be ready
command: |
echo "Waiting for OpenSearch to start..."
for i in {1..30}; do
if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then
echo "OpenSearch is ready!"
exit 0
fi
echo "Waiting... ($i/30)"
sleep 2
done
echo "OpenSearch failed to start"
exit 1
# Configure environment and database
- run:
name: Database Setup and Configure Environment Variables
@@ -234,6 +277,7 @@ jobs:
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env"
# Database setup
- run:
+1 -1
View File
@@ -10,7 +10,7 @@ services:
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
NODE_VERSION: '24.13.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
+1 -1
View File
@@ -11,7 +11,7 @@ services:
dockerfile: .devcontainer/Dockerfile
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
NODE_VERSION: '24.13.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 23
node-version: 24
cache: 'pnpm'
- name: Install pnpm dependencies
+3 -3
View File
@@ -28,7 +28,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 23
node-version: 24
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
@@ -43,7 +43,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 23
node-version: 24
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
@@ -94,7 +94,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 23
node-version: 24
cache: 'pnpm'
- name: Install pnpm dependencies
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 23
node-version: 24
cache: 'pnpm'
- name: pnpm
+1 -1
View File
@@ -1 +1 @@
23.7.0
24.13.0
+4 -2
View File
@@ -441,7 +441,8 @@ GEM
http-cookie (1.0.5)
domain_name (~> 0.5)
http-form_data (2.3.0)
httparty (0.21.0)
httparty (0.24.0)
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
@@ -555,7 +556,8 @@ GEM
ruby2_keywords
msgpack (1.8.0)
multi_json (1.15.0)
multi_xml (0.6.0)
multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5)
multipart-post (2.3.0)
mutex_m (0.3.0)
neighbor (0.2.3)
+1 -2
View File
@@ -8,7 +8,6 @@ ___
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p>
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
@@ -137,4 +136,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2025, Chatwoot Inc - Released under the MIT License.
*Chatwoot* &copy; 2017-2026, Chatwoot Inc - Released under the MIT License.
+1 -1
View File
@@ -1 +1 @@
4.9.1
4.10.1
+1 -1
View File
@@ -1 +1 @@
3.4.3
3.5.0
@@ -0,0 +1,38 @@
class V2::Reports::ChannelSummaryBuilder
include DateRangeHelper
pattr_initialize [:account!, :params!]
def build
conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
end
private
def conversations_by_channel_and_status
account.conversations
.joins(:inbox)
.where(created_at: range)
.group('inboxes.channel_type', 'conversations.status')
.count
.each_with_object({}) do |((channel_type, status), count), grouped|
grouped[channel_type] ||= {}
grouped[channel_type][status] = count
end
end
def build_channel_stats(status_counts)
open_count = status_counts['open'] || 0
resolved_count = status_counts['resolved'] || 0
pending_count = status_counts['pending'] || 0
snoozed_count = status_counts['snoozed'] || 0
{
open: open_count,
resolved: resolved_count,
pending: pending_count,
snoozed: snoozed_count,
total: open_count + resolved_count + pending_count + snoozed_count
}
end
end
@@ -0,0 +1,76 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :authorize_account_update, only: [:update]
def show
render json: preferences_payload
end
def update
params_to_update = captain_params
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
@current_account.save!
render json: preferences_payload
end
private
def preferences_payload
{
providers: Llm::Models.providers,
models: Llm::Models.models,
features: features_with_account_preferences
}
end
def authorize_account_update
authorize @current_account, :update?
end
def captain_params
permitted = {}
permitted[:captain_models] = merged_captain_models if params[:captain_models].present?
permitted[:captain_features] = merged_captain_features if params[:captain_features].present?
permitted
end
def merged_captain_models
existing_models = @current_account.captain_models || {}
existing_models.merge(permitted_captain_models)
end
def merged_captain_features
existing_features = @current_account.captain_features || {}
existing_features.merge(permitted_captain_features)
end
def permitted_captain_models
params.require(:captain_models).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end
def permitted_captain_features
params.require(:captain_features).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end
def features_with_account_preferences
preferences = Current.account.captain_preferences
account_features = preferences[:features] || {}
account_models = preferences[:models] || {}
Llm::Models.feature_keys.index_with do |feature_key|
config = Llm::Models.feature_config(feature_key)
config.merge(
enabled: account_features[feature_key] == true,
selected: account_models[feature_key] || config[:default]
)
end
end
end
@@ -50,3 +50,5 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
@current_page = params[:page] || 1
end
end
Api::V1::Accounts::CsatSurveyResponsesController.prepend_mod_with('Api::V1::Accounts::CsatSurveyResponsesController')
@@ -1,38 +1,27 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
DEFAULT_LANGUAGE = 'en'.freeze
before_action :fetch_inbox
before_action :validate_whatsapp_channel
def show
template = @inbox.csat_config&.dig('template')
return render json: { template_exists: false } unless template
service = CsatTemplateManagementService.new(@inbox)
result = service.template_status
template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
status_result = @inbox.channel.provider_service.get_template_status(template_name)
render_template_status_response(status_result, template_name)
rescue StandardError => e
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
render json: { error: e.message }, status: :internal_server_error
if result[:service_error]
render json: { error: result[:service_error] }, status: :internal_server_error
else
render json: result
end
end
def create
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
# Delete existing template even though we are using a new one.
# We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
delete_existing_template_if_needed
result = create_template_via_provider(template_params)
service = CsatTemplateManagementService.new(@inbox)
result = service.create_template(template_params)
render_template_creation_result(result)
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error "Error creating CSAT template: #{e.message}"
render json: { error: 'Template creation failed' }, status: :internal_server_error
end
private
@@ -43,9 +32,9 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
end
def validate_whatsapp_channel
return if @inbox.whatsapp?
return if @inbox.whatsapp? || @inbox.twilio_whatsapp?
render json: { error: 'CSAT template operations only available for WhatsApp channels' },
render json: { error: 'CSAT template operations only available for WhatsApp and Twilio WhatsApp channels' },
status: :bad_request
end
@@ -57,35 +46,36 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Message is required' }, status: :unprocessable_entity
end
def create_template_via_provider(template_params)
template_config = {
message: template_params[:message],
button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
language: template_params[:language] || DEFAULT_LANGUAGE,
template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
}
@inbox.channel.provider_service.create_csat_template(template_config)
end
def render_template_creation_result(result)
if result[:success]
render_successful_template_creation(result)
elsif result[:service_error]
render json: { error: result[:service_error] }, status: :internal_server_error
else
render_failed_template_creation(result)
end
end
def render_successful_template_creation(result)
render json: {
template: {
name: result[:template_name],
template_id: result[:template_id],
status: 'PENDING',
language: result[:language] || DEFAULT_LANGUAGE
}
}, status: :created
if @inbox.twilio_whatsapp?
render json: {
template: {
friendly_name: result[:friendly_name],
content_sid: result[:content_sid],
status: result[:status] || 'pending',
language: result[:language] || 'en'
}
}, status: :created
else
render json: {
template: {
name: result[:template_name],
template_id: result[:template_id],
status: 'PENDING',
language: result[:language] || 'en'
}
}, status: :created
end
end
def render_failed_template_creation(result)
@@ -98,45 +88,6 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
}, status: :unprocessable_entity
end
def delete_existing_template_if_needed
template = @inbox.csat_config&.dig('template')
return true if template.blank?
template_name = template['name']
return true if template_name.blank?
template_status = @inbox.channel.provider_service.get_template_status(template_name)
return true unless template_status[:success]
deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name)
if deletion_result[:success]
Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
true
else
Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
false
end
rescue StandardError => e
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
false
end
def render_template_status_response(status_result, template_name)
if status_result[:success]
render json: {
template_exists: true,
template_name: template_name,
status: status_result[:template][:status],
template_id: status_result[:template][:id]
}
else
render json: {
template_exists: false,
error: 'Template not found'
}
end
end
def parse_whatsapp_error(response_body)
return { user_message: nil, technical_details: nil } if response_body.blank?
@@ -176,7 +176,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
{ csat_config: [:display_type, :message, :button_text, :language,
{ survey_rules: [:operator, { values: [] }],
template: [:name, :template_id, :created_at, :language] }] }]
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }]
end
def permitted_params(channel_attributes = [])
@@ -28,5 +28,7 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
search_type: search_type,
params: params
).perform
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end
end
@@ -38,6 +38,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
end
def conversations_summary
@report_data = generate_conversations_report
generate_csv('conversations_summary_report', 'api/v2/accounts/reports/conversations_summary')
end
def conversation_traffic
@report_data = generate_conversations_heatmap_report
timezone_offset = (params[:timezone_offset] || 0).to_f
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -18,6 +18,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::LabelSummaryBuilder)
end
def channel
return render_could_not_create_error(I18n.t('errors.reports.date_range_too_long')) if date_range_too_long?
render_report_with(V2::Reports::ChannelSummaryBuilder)
end
private
def check_authorization
@@ -40,4 +46,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
def permitted_params
params.permit(:since, :until, :business_hours)
end
def date_range_too_long?
return false if permitted_params[:since].blank? || permitted_params[:until].blank?
since_time = Time.zone.at(permitted_params[:since].to_i)
until_time = Time.zone.at(permitted_params[:until].to_i)
(until_time - since_time) > 6.months
end
end
+1 -1
View File
@@ -16,7 +16,7 @@ class DashboardController < ActionController::Base
CHATWOOT_INBOX_TOKEN
API_CHANNEL_NAME
API_CHANNEL_THUMBNAIL
ANALYTICS_TOKEN
CLOUD_ANALYTICS_TOKEN
DIRECT_UPLOADS_ENABLED
MAXIMUM_FILE_UPLOAD_SIZE
HCAPTCHA_SITE_KEY
@@ -49,7 +49,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN],
'captain' => %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_OPEN_AI_ENDPOINT]
}
@allowed_configs = mapping.fetch(
@@ -46,6 +46,13 @@ module Api::V2::Accounts::ReportsHelper
end
end
def generate_conversations_report
builder = V2::Reports::Conversations::MetricBuilder.new(Current.account, build_params(type: :account))
summary = builder.summary
[generate_conversation_report_metrics(summary)]
end
private
def build_params(base_params)
@@ -71,4 +78,16 @@ module Api::V2::Accounts::ReportsHelper
report[:resolved_conversations_count]
]
end
def generate_conversation_report_metrics(summary)
[
summary[:conversations_count],
summary[:incoming_messages_count],
summary[:outgoing_messages_count],
Reports::TimeFormatPresenter.new(summary[:avg_first_response_time]).format,
Reports::TimeFormatPresenter.new(summary[:avg_resolution_time]).format,
summary[:resolutions_count],
Reports::TimeFormatPresenter.new(summary[:reply_time]).format
]
end
end
+6 -7
View File
@@ -2,13 +2,6 @@
# No need to replicate the same values in two places
# ------- Premium Features ------- #
captain:
name: 'Captain'
description: 'Enable AI-powered conversations with your customers.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-captain'
config_key: 'captain'
enterprise: true
saml:
name: 'SAML SSO'
description: 'Configuration for controlling SAML Single Sign-On availability'
@@ -48,6 +41,12 @@ help_center:
description: 'Allow agents to create help center articles and publish them in a portal.'
enabled: true
icon: 'icon-book-2-line'
captain:
name: 'Captain'
description: 'Enable AI-powered conversations with your customers.'
enabled: true
icon: 'icon-captain'
config_key: 'captain'
# ------- Communication Channels ------- #
live_chat:
@@ -0,0 +1,18 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainPreferences extends ApiClient {
constructor() {
super('captain/preferences', { accountScoped: true });
}
get() {
return axios.get(this.url);
}
updatePreferences(data) {
return axios.put(this.url, data);
}
}
export default new CaptainPreferences();
@@ -0,0 +1,107 @@
/* global axios */
import ApiClient from '../ApiClient';
/**
* A client for the Captain Tasks API.
* @extends ApiClient
*/
class TasksAPI extends ApiClient {
/**
* Creates a new TasksAPI instance.
*/
constructor() {
super('captain/tasks', { accountScoped: true });
}
/**
* Rewrites content with a specific operation.
* @param {Object} options - The rewrite options.
* @param {string} options.content - The content to rewrite.
* @param {string} options.operation - The rewrite operation (fix_spelling_grammar, casual, professional, etc).
* @param {string} [options.conversationId] - The conversation ID for context (required for 'improve').
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the rewritten content.
*/
rewrite({ content, operation, conversationId }, signal) {
return axios.post(
`${this.url}/rewrite`,
{
content,
operation,
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Summarizes a conversation.
* @param {string} conversationId - The conversation ID to summarize.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the summary.
*/
summarize(conversationId, signal) {
return axios.post(
`${this.url}/summarize`,
{
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Gets a reply suggestion for a conversation.
* @param {string} conversationId - The conversation ID.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the reply suggestion.
*/
replySuggestion(conversationId, signal) {
return axios.post(
`${this.url}/reply_suggestion`,
{
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Gets label suggestions for a conversation.
* @param {string} conversationId - The conversation ID.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with label suggestions.
*/
labelSuggestion(conversationId, signal) {
return axios.post(
`${this.url}/label_suggestion`,
{
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Sends a follow-up message to continue refining a previous task result.
* @param {Object} options - The follow-up options.
* @param {Object} options.followUpContext - The follow-up context from a previous task.
* @param {string} options.message - The follow-up message/request from the user.
* @param {string} [options.conversationId] - The conversation ID for Langfuse session tracking.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the follow-up response and updated follow-up context.
*/
followUp({ followUpContext, message, conversationId }, signal) {
return axios.post(
`${this.url}/follow_up`,
{
follow_up_context: followUpContext,
message,
conversation_display_id: conversationId,
},
{ signal }
);
}
}
export default new TasksAPI();
@@ -1,81 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
/**
* Represents the data object for a OpenAI hook.
* @typedef {Object} ConversationMessageData
* @property {string} [tone] - The tone of the message.
* @property {string} [content] - The content of the message.
* @property {string} [conversation_display_id] - The display ID of the conversation (optional).
*/
/**
* A client for the OpenAI API.
* @extends ApiClient
*/
class OpenAIAPI extends ApiClient {
/**
* Creates a new OpenAIAPI instance.
*/
constructor() {
super('integrations', { accountScoped: true });
/**
* The conversation events supported by the API.
* @type {string[]}
*/
this.conversation_events = [
'summarize',
'reply_suggestion',
'label_suggestion',
];
/**
* The message events supported by the API.
* @type {string[]}
*/
this.message_events = ['rephrase'];
}
/**
* Processes an event using the OpenAI API.
* @param {Object} options - The options for the event.
* @param {string} [options.type='rephrase'] - The type of event to process.
* @param {string} [options.content] - The content of the event.
* @param {string} [options.tone] - The tone of the event.
* @param {string} [options.conversationId] - The ID of the conversation to process the event for.
* @param {string} options.hookId - The ID of the hook to use for processing the event.
* @returns {Promise} A promise that resolves with the result of the event processing.
*/
processEvent({ type = 'rephrase', content, tone, conversationId, hookId }) {
/**
* @type {ConversationMessageData}
*/
let data = {
tone,
content,
};
// Always include conversation_display_id when available for session tracking
if (conversationId) {
data.conversation_display_id = conversationId;
}
// For conversation-level events, only send conversation_display_id
if (this.conversation_events.includes(type)) {
data = {
conversation_display_id: conversationId,
};
}
return axios.post(`${this.url}/hooks/${hookId}/process_event`, {
event: {
name: type,
data,
},
});
}
}
export default new OpenAIAPI();
+6
View File
@@ -61,6 +61,12 @@ class ReportsAPI extends ApiClient {
});
}
getConversationsSummaryReports({ from: since, to: until, businessHours }) {
return axios.get(`${this.url}/conversations_summary`, {
params: { since, until, business_hours: businessHours },
});
}
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
return axios.get(`${this.url}/conversation_traffic`, {
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
+14 -4
View File
@@ -14,38 +14,48 @@ class SearchAPI extends ApiClient {
});
}
contacts({ q, page = 1 }) {
contacts({ q, page = 1, since, until }) {
return axios.get(`${this.url}/contacts`, {
params: {
q,
page: page,
since,
until,
},
});
}
conversations({ q, page = 1 }) {
conversations({ q, page = 1, since, until }) {
return axios.get(`${this.url}/conversations`, {
params: {
q,
page: page,
since,
until,
},
});
}
messages({ q, page = 1 }) {
messages({ q, page = 1, since, until, from, inboxId }) {
return axios.get(`${this.url}/messages`, {
params: {
q,
page: page,
since,
until,
from,
inbox_id: inboxId,
},
});
}
articles({ q, page = 1 }) {
articles({ q, page = 1, since, until }) {
return axios.get(`${this.url}/articles`, {
params: {
q,
page: page,
since,
until,
},
});
}
@@ -0,0 +1,134 @@
import searchAPI from '../search';
import ApiClient from '../ApiClient';
describe('#SearchAPI', () => {
it('creates correct instance', () => {
expect(searchAPI).toBeInstanceOf(ApiClient);
expect(searchAPI).toHaveProperty('get');
expect(searchAPI).toHaveProperty('contacts');
expect(searchAPI).toHaveProperty('conversations');
expect(searchAPI).toHaveProperty('messages');
expect(searchAPI).toHaveProperty('articles');
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
get: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
vi.clearAllMocks();
});
it('#get', () => {
searchAPI.get({ q: 'test query' });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search', {
params: { q: 'test query' },
});
});
it('#contacts', () => {
searchAPI.contacts({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
params: { q: 'test', page: 1, since: undefined, until: undefined },
});
});
it('#contacts with date filters', () => {
searchAPI.contacts({
q: 'test',
page: 2,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
});
});
it('#conversations', () => {
searchAPI.conversations({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/search/conversations',
{
params: { q: 'test', page: 1, since: undefined, until: undefined },
}
);
});
it('#conversations with date filters', () => {
searchAPI.conversations({
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/search/conversations',
{
params: { q: 'test', page: 1, since: 1700000000, until: 1732000000 },
}
);
});
it('#messages', () => {
searchAPI.messages({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
params: {
q: 'test',
page: 1,
since: undefined,
until: undefined,
from: undefined,
inbox_id: undefined,
},
});
});
it('#messages with all filters', () => {
searchAPI.messages({
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
from: 'contact:42',
inboxId: 10,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
params: {
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
from: 'contact:42',
inbox_id: 10,
},
});
});
it('#articles', () => {
searchAPI.articles({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
params: { q: 'test', page: 1, since: undefined, until: undefined },
});
});
it('#articles with date filters', () => {
searchAPI.articles({
q: 'test',
page: 2,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
});
});
});
});
@@ -94,6 +94,19 @@
--gray-11: 100 100 100;
--gray-12: 32 32 32;
--violet-1: 253 252 254;
--violet-2: 250 248 255;
--violet-3: 244 240 254;
--violet-4: 235 228 255;
--violet-5: 225 217 255;
--violet-6: 212 202 254;
--violet-7: 194 178 248;
--violet-8: 169 153 236;
--violet-9: 110 86 207;
--violet-10: 100 84 196;
--violet-11: 101 85 183;
--violet-12: 47 38 95;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--border-container: 236 236 236;
@@ -209,6 +222,19 @@
--gray-11: 180 180 180;
--gray-12: 238 238 238;
--violet-1: 20 17 31;
--violet-2: 27 21 37;
--violet-3: 41 31 67;
--violet-4: 50 37 85;
--violet-5: 60 46 105;
--violet-6: 71 56 135;
--violet-7: 86 70 151;
--violet-8: 110 86 171;
--violet-9: 110 86 207;
--violet-10: 125 109 217;
--violet-11: 169 153 236;
--violet-12: 226 221 254;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
@@ -19,7 +19,7 @@ const handleClick = () => {
<template>
<div
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2"
>
<div
class="flex w-full gap-3 py-5"
@@ -35,6 +35,10 @@ const sortMenus = [
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
value: 'created_at',
},
{
label: t('COMPANIES.SORT_BY.OPTIONS.CONTACTS_COUNT'),
value: 'contacts_count',
},
];
const orderingMenus = [
@@ -5,6 +5,7 @@ import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const props = defineProps({
modelValue: { type: String, default: '' },
editorKey: { type: String, default: '' },
label: { type: String, default: '' },
placeholder: { type: String, default: '' },
focusOnMount: { type: Boolean, default: false },
@@ -96,6 +97,7 @@ watch(
]"
>
<WootEditor
:editor-id="editorKey"
:model-value="modelValue"
:placeholder="placeholder"
:focus-on-mount="focusOnMount"
@@ -152,6 +154,13 @@ watch(
}
}
}
.ProseMirror-menubar {
width: fit-content !important;
position: relative !important;
top: unset !important;
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
}
}
}
}
@@ -44,6 +44,12 @@ const emit = defineEmits([
const { t } = useI18n();
const attachmentId = ref(0);
const generateUid = () => {
attachmentId.value += 1;
return `attachment-${attachmentId.value}`;
};
const uploadAttachment = ref(null);
const isEmojiPickerOpen = ref(false);
@@ -176,7 +182,8 @@ const onPaste = e => {
.filter(file => file.size > 0)
.forEach(file => {
const { name, type, size } = file;
onFileUpload({ file, name, type, size });
// Add unique ID for clipboard-pasted files
onFileUpload({ file, name, type, size, id: generateUid() });
});
};
@@ -7,6 +7,7 @@ import {
appendSignature,
removeSignature,
getEffectiveChannelType,
stripUnsupportedMarkdown,
} from 'dashboard/helper/editorHelper';
import {
buildContactableInboxesList,
@@ -47,6 +48,8 @@ const emit = defineEmits([
'createConversation',
]);
const DEFAULT_FORMATTING = 'Context::Default';
const showContactsDropdown = ref(false);
const showInboxesDropdown = ref(false);
const showCcEmailsDropdown = ref(false);
@@ -198,10 +201,22 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
showInboxesDropdown.value = true;
};
const handleInboxAction = ({ value, action, ...rest }) => {
const stripMessageFormatting = channelType => {
if (!state.message || !channelType) return;
state.message = stripUnsupportedMarkdown(state.message, channelType, false);
};
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
v$.value.$reset();
state.message = '';
emit('updateTargetInbox', { ...rest });
// Strip unsupported formatting when changing the target inbox
if (channelType) {
const newChannelType = getEffectiveChannelType(channelType, medium);
stripMessageFormatting(newChannelType);
}
emit('updateTargetInbox', { ...rest, channelType, medium });
showInboxesDropdown.value = false;
state.attachedFiles = [];
};
@@ -221,7 +236,9 @@ const removeSignatureFromMessage = () => {
const removeTargetInbox = value => {
v$.value.$reset();
removeSignatureFromMessage();
state.message = '';
stripMessageFormatting(DEFAULT_FORMATTING);
emit('updateTargetInbox', value);
state.attachedFiles = [];
};
@@ -324,67 +341,68 @@ const shouldShowMessageEditor = computed(() => {
<template>
<div
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0"
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
>
<ContactSelector
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isLoading"
:is-creating-contact="isCreatingContact"
:contact-id="contactId"
:contactable-inboxes-list="contactableInboxesList"
:show-inboxes-dropdown="showInboxesDropdown"
:has-errors="validationStates.isContactInvalid"
@search-contacts="handleContactSearch"
@set-selected-contact="setSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<InboxEmptyState v-if="showNoInboxAlert" />
<InboxSelector
v-else
:target-inbox="targetInbox"
:selected-contact="selectedContact"
:show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid"
@update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction"
/>
<div class="flex-1 overflow-y-auto divide-y divide-n-strong">
<ContactSelector
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isLoading"
:is-creating-contact="isCreatingContact"
:contact-id="contactId"
:contactable-inboxes-list="contactableInboxesList"
:show-inboxes-dropdown="showInboxesDropdown"
:has-errors="validationStates.isContactInvalid"
@search-contacts="handleContactSearch"
@set-selected-contact="setSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<InboxEmptyState v-if="showNoInboxAlert" />
<InboxSelector
v-else
:target-inbox="targetInbox"
:selected-contact="selectedContact"
:show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid"
@update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction"
/>
<EmailOptions
v-if="inboxTypes.isEmail"
v-model:cc-emails="state.ccEmails"
v-model:bcc-emails="state.bccEmails"
v-model:subject="state.subject"
:contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
:has-errors="validationStates.isSubjectInvalid"
@search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails"
@update-dropdown="handleDropdownUpdate"
/>
<EmailOptions
v-if="inboxTypes.isEmail"
v-model:cc-emails="state.ccEmails"
v-model:bcc-emails="state.bccEmails"
v-model:subject="state.subject"
:contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
:has-errors="validationStates.isSubjectInvalid"
@search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails"
@update-dropdown="handleDropdownUpdate"
/>
<MessageEditor
v-if="shouldShowMessageEditor"
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
:has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0"
:channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''"
/>
<MessageEditor
v-if="shouldShowMessageEditor"
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
:has-errors="validationStates.isMessageInvalid"
:channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''"
/>
<AttachmentPreviews
v-if="state.attachedFiles.length > 0"
:attachments="state.attachedFiles"
@update:attachments="state.attachedFiles = $event"
/>
<AttachmentPreviews
v-if="state.attachedFiles.length > 0"
:attachments="state.attachedFiles"
@update:attachments="state.attachedFiles = $event"
/>
</div>
<ActionButtons
:attached-files="state.attachedFiles"
@@ -83,7 +83,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-60 w-fit max-w-sm dark:!outline-n-slate-5"
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"
@action="emit('handleInboxAction', $event)"
/>
</div>
@@ -6,7 +6,6 @@ import Editor from 'dashboard/components-next/Editor/Editor.vue';
const props = defineProps({
hasErrors: { type: Boolean, default: false },
hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
@@ -24,14 +23,14 @@ const modelValue = defineModel({
</script>
<template>
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
<div class="flex-1 h-full">
<Editor
:key="editorKey"
v-model="modelValue"
:editor-key="editorKey"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
menuItems: {
@@ -37,9 +38,13 @@ const props = defineProps({
type: String,
default: '',
},
disableLocalFiltering: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['action']);
const emit = defineEmits(['action', 'search']);
const { t } = useI18n();
@@ -57,6 +62,7 @@ const flattenedMenuItems = computed(() => {
});
const filteredMenuItems = computed(() => {
if (props.disableLocalFiltering) return props.menuItems;
if (!searchQuery.value) return flattenedMenuItems.value;
return flattenedMenuItems.value.filter(item =>
@@ -69,7 +75,7 @@ const filteredMenuSections = computed(() => {
return [];
}
if (!searchQuery.value) {
if (props.disableLocalFiltering || !searchQuery.value) {
return props.menuSections;
}
@@ -89,6 +95,12 @@ const filteredMenuSections = computed(() => {
.filter(section => section.items.length > 0);
});
const handleSearchInput = event => {
if (props.disableLocalFiltering) {
emit('search', event.target.value);
}
};
const handleAction = item => {
const { action, value, ...rest } = item;
emit('action', { action, value, ...rest });
@@ -118,7 +130,7 @@ onMounted(() => {
>
<div
v-if="showSearch"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
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" />
@@ -130,6 +142,7 @@ onMounted(() => {
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>
@@ -141,10 +154,23 @@ onMounted(() => {
>
<p
v-if="section.title"
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
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
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"
@@ -235,5 +261,6 @@ onMounted(() => {
: t('DROPDOWN_MENU.EMPTY_STATE')
}}
</div>
<slot name="footer" />
</div>
</template>
@@ -11,7 +11,6 @@ export const CONVERSATION_ATTRIBUTES = {
CAMPAIGN_ID: 'campaign_id',
LABELS: 'labels',
BROWSER_LANGUAGE: 'browser_language',
COUNTRY_CODE: 'country_code',
REFERER: 'referer',
CREATED_AT: 'created_at',
LAST_ACTIVITY_AT: 'last_activity_at',
@@ -7,7 +7,6 @@ import {
buildAttributesFilterTypes,
CONVERSATION_ATTRIBUTES,
} from './helper/filterHelper';
import countries from 'shared/constants/countries.js';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
/**
@@ -218,17 +217,6 @@ export function useConversationFilterContext() {
filterOperators: equalityOperators.value,
attributeModel: 'additional',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
value: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
inputType: 'searchSelect',
options: countries,
dataType: 'text',
filterOperators: equalityOperators.value,
attributeModel: 'additional',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.REFERER,
value: CONVERSATION_ATTRIBUTES.REFERER,
@@ -42,7 +42,10 @@ const props = defineProps({
const emit = defineEmits(['retry']);
const allMessages = computed(() => {
return useCamelCase(props.messages, { deep: true });
return useCamelCase(props.messages, {
deep: true,
stopPaths: ['content_attributes.translations'],
});
});
const currentChat = useMapGetter('getSelectedChat');
@@ -17,6 +17,10 @@ const { attachment } = defineProps({
type: Object,
required: true,
},
showTranscribedText: {
type: Boolean,
default: true,
},
});
defineOptions({
@@ -182,7 +186,7 @@ const downloadAudio = async () => {
</div>
<div
v-if="attachment.transcribedText"
v-if="attachment.transcribedText && showTranscribedText"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
>
{{ attachment.transcribedText }}
@@ -1,12 +1,11 @@
<script setup>
import { h, computed, onMounted } from 'vue';
import { h, ref, computed, onMounted } from 'vue';
import { provideSidebarContext } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components';
import { emitter } from 'shared/helpers/mitt';
@@ -16,7 +15,6 @@ import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue';
import SidebarChangelogCard from './SidebarChangelogCard.vue';
import YearInReviewBanner from '../year-in-review/YearInReviewBanner.vue';
import ChannelLeaf from './ChannelLeaf.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
@@ -55,14 +53,7 @@ const toggleShortcutModalFn = show => {
useSidebarKeyboardShortcuts(toggleShortcutModalFn);
// We're using localStorage to store the expanded item in the sidebar
// This helps preserve context when navigating between portal and dashboard layouts
// and also when the user refreshes the page
const expandedItem = useStorage(
'next-sidebar-expanded-item',
null,
sessionStorage
);
const expandedItem = ref(null);
const setExpandedItem = name => {
expandedItem.value = expandedItem.value === name ? null : name;
@@ -493,6 +484,12 @@ const menuItems = computed(() => {
icon: 'i-lucide-briefcase',
to: accountScopedRoute('general_settings_index'),
},
// {
// name: 'Settings Captain',
// label: t('SIDEBAR.CAPTAIN_AI'),
// icon: 'i-woot-captain',
// to: accountScopedRoute('captain_settings_index'),
// },
{
name: 'Settings Agents',
label: t('SIDEBAR.AGENTS'),
@@ -663,7 +660,6 @@ const menuItems = computed(() => {
<div
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
/>
<YearInReviewBanner />
<SidebarChangelogCard
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
/>
@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, nextTick } from 'vue';
import { computed, onMounted, watch, nextTick } from 'vue';
import { useSidebarContext } from './provider';
import { useRoute, useRouter } from 'vue-router';
import Policy from 'dashboard/components/policy.vue';
@@ -126,6 +126,16 @@ onMounted(async () => {
setExpandedItem(props.name);
}
});
watch(
hasActiveChild,
hasNewActiveChild => {
if (hasNewActiveChild && !isExpanded.value) {
setExpandedItem(props.name);
}
},
{ once: true }
);
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
@@ -1,13 +1,11 @@
<script setup>
import { ref, computed } from 'vue';
import { computed } from 'vue';
import Auth from 'dashboard/api/auth';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Avatar from 'next/avatar/Avatar.vue';
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import YearInReviewModal from 'dashboard/components-next/year-in-review/YearInReviewModal.vue';
import {
DropdownContainer,
@@ -24,7 +22,6 @@ defineOptions({
});
const { t } = useI18n();
const { uiSettings } = useUISettings();
const currentUser = useMapGetter('getCurrentUser');
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
@@ -34,29 +31,6 @@ const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showYearInReviewModal = ref(false);
const bannerClosedKey = computed(() => {
return `yir_closed_${accountId.value}_2025`;
});
const isBannerClosed = computed(() => {
return uiSettings.value?.[bannerClosedKey.value] === true;
});
const showYearInReviewMenuItem = computed(() => {
return isBannerClosed.value;
});
const openYearInReviewModal = () => {
showYearInReviewModal.value = true;
emit('close');
};
const closeYearInReviewModal = () => {
showYearInReviewModal.value = false;
};
const showChatSupport = computed(() => {
return (
isFeatureEnabledonAccount.value(
@@ -68,13 +42,6 @@ const showChatSupport = computed(() => {
const menuItems = computed(() => {
return [
{
show: showYearInReviewMenuItem.value,
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.YEAR_IN_REVIEW'),
icon: 'i-lucide-gift',
click: openYearInReviewModal,
},
{
show: showChatSupport.value,
showOnCustomBrandedInstance: false,
@@ -190,9 +157,4 @@ const allowedMenuItems = computed(() => {
</template>
</DropdownBody>
</DropdownContainer>
<YearInReviewModal
:show="showYearInReviewModal"
@close="closeYearInReviewModal"
/>
</template>
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref, onMounted, nextTick } from 'vue';
import { computed, ref, onMounted, nextTick, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
const props = defineProps({
@@ -31,20 +31,24 @@ const enableTransition = ref(false);
const activeElement = computed(() => tabRefs.value[activeTab.value]);
const updateIndicator = () => {
if (!activeElement.value) return;
nextTick(() => {
if (!activeElement.value) return;
indicatorStyle.value = {
left: `${activeElement.value.offsetLeft}px`,
width: `${activeElement.value.offsetWidth}px`,
};
indicatorStyle.value = {
left: `${activeElement.value.offsetLeft}px`,
width: `${activeElement.value.offsetWidth}px`,
};
});
};
useResizeObserver(activeElement, () => {
if (enableTransition.value || !activeElement.value) updateIndicator();
useResizeObserver(activeElement, updateIndicator);
// Watch for prop/tabs changes to update indicator position
watch([() => props.initialActiveTab, () => props.tabs], updateIndicator, {
immediate: true,
});
onMounted(() => {
updateIndicator();
nextTick(() => {
enableTransition.value = true;
});
@@ -66,7 +70,7 @@ const showDivider = index => {
<template>
<div
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 dark:bg-n-solid-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
>
<div
class="absolute rounded-lg bg-n-solid-active shadow-sm pointer-events-none h-8 outline-1 outline outline-n-container inset-y-0"
@@ -230,7 +230,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-60 w-[inherit] max-w-md dark:!outline-n-slate-5"
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"
@action="handleDropdownAction"
/>
</div>
@@ -206,10 +206,14 @@ const emitDateRange = () => {
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
}
};
const closeDatePicker = () => {
showDatePicker.value = false;
};
</script>
<template>
<div class="relative font-inter">
<div v-on-clickaway="closeDatePicker" class="relative font-inter">
<DatePickerButton
:selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate"
@@ -1,160 +0,0 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import AICTAModal from './AICTAModal.vue';
import AIAssistanceModal from './AIAssistanceModal.vue';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
import { emitter } from 'shared/helpers/mitt';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
AIAssistanceModal,
AICTAModal,
AIAssistanceCTAButton,
},
emits: ['replaceText'],
setup(props, { emit }) {
const { uiSettings, updateUISettings } = useUISettings();
const { isAIIntegrationEnabled, draftMessage, recordAnalytics } = useAI();
const { isAdmin } = useAdmin();
const initialMessage = ref('');
const initializeMessage = draftMsg => {
initialMessage.value = draftMsg;
};
const keyboardEvents = {
'$mod+KeyZ': {
action: () => {
if (initialMessage.value) {
emit('replaceText', initialMessage.value);
initialMessage.value = '';
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
return {
uiSettings,
updateUISettings,
isAdmin,
initialMessage,
initializeMessage,
recordAnalytics,
isAIIntegrationEnabled,
draftMessage,
};
},
data: () => ({
showAIAssistanceModal: false,
showAICtaModal: false,
aiOption: '',
}),
computed: {
...mapGetters({
isAChatwootInstance: 'globalConfig/isAChatwootInstance',
}),
isAICTAModalDismissed() {
return this.uiSettings.is_open_ai_cta_modal_dismissed;
},
// Display a AI CTA button for admins if the AI integration has not been added yet and the AI assistance modal has not been dismissed.
shouldShowAIAssistCTAButtonForAdmin() {
return (
this.isAdmin &&
!this.isAIIntegrationEnabled &&
!this.isAICTAModalDismissed &&
this.isAChatwootInstance
);
},
// Display a AI CTA button for agents and other admins who have not yet opened the AI assistance modal.
shouldShowAIAssistCTAButton() {
return this.isAIIntegrationEnabled && !this.isAICTAModalDismissed;
},
},
mounted() {
emitter.on(CMD_AI_ASSIST, this.onAIAssist);
this.initializeMessage(this.draftMessage);
},
methods: {
hideAIAssistanceModal() {
this.recordAnalytics('DISMISS_AI_SUGGESTION', {
aiOption: this.aiOption,
});
this.showAIAssistanceModal = false;
},
openAIAssist() {
// Dismiss the CTA modal if it is not dismissed
if (!this.isAICTAModalDismissed) {
this.updateUISettings({
is_open_ai_cta_modal_dismissed: true,
});
}
this.initializeMessage(this.draftMessage);
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'ai_assist' });
},
hideAICtaModal() {
this.showAICtaModal = false;
},
openAICta() {
this.showAICtaModal = true;
},
onAIAssist(option) {
this.aiOption = option;
this.showAIAssistanceModal = true;
},
insertText(message) {
this.$emit('replaceText', message);
},
},
};
</script>
<template>
<div>
<div v-if="isAIIntegrationEnabled" class="relative">
<AIAssistanceCTAButton
v-if="shouldShowAIAssistCTAButton"
@open="openAIAssist"
/>
<NextButton
v-else
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.AI_ASSIST')"
icon="i-ph-magic-wand"
slate
faded
sm
@click="openAIAssist"
/>
<woot-modal
v-model:show="showAIAssistanceModal"
:on-close="hideAIAssistanceModal"
>
<AIAssistanceModal
:ai-option="aiOption"
@apply-text="insertText"
@close="hideAIAssistanceModal"
/>
</woot-modal>
</div>
<div v-else-if="shouldShowAIAssistCTAButtonForAdmin" class="relative">
<AIAssistanceCTAButton @click="openAICta" />
<woot-modal v-model:show="showAICtaModal" :on-close="hideAICtaModal">
<AICTAModal @close="hideAICtaModal" />
</woot-modal>
</div>
</div>
</template>
@@ -1,103 +0,0 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['open']);
const onClick = () => {
emit('open');
};
</script>
<template>
<div class="relative">
<NextButton
class="cta-btn cta-btn-light dark:cta-btn-dark hover:cta-btn-light-hover dark:hover:cta-btn-dark-hover"
:label="$t('INTEGRATION_SETTINGS.OPEN_AI.AI_ASSIST')"
icon="i-ph-magic-wand"
sm
@click="onClick"
/>
<div
class="radar-ping-animation absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-n-brand"
/>
<div
class="absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-n-brand opacity-50"
/>
</div>
</template>
<style scoped>
@tailwind components;
@layer components {
/* Gradient animation */
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.cta-btn {
animation: gradient 5s ease infinite;
@apply text-n-slate-12 border-0 text-xs;
}
.cta-btn-light {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #f2f5f8, #f2f5f8);
}
.cta-btn-dark {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #313538, #313538);
}
.cta-btn-light-hover {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #e3e5e7, #e3e5e7);
}
.cta-btn-dark-hover {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #202425, #202425);
}
/* Radar ping animation */
@keyframes ping {
75%,
100% {
transform: scale(2);
opacity: 0;
}
}
.radar-ping-animation {
animation: ping 1s ease infinite;
}
}
</style>
@@ -1,118 +0,0 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useAI } from 'dashboard/composables/useAI';
import AILoader from './AILoader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
AILoader,
NextButton,
},
props: {
aiOption: {
type: String,
required: true,
},
},
emits: ['close', 'applyText'],
setup() {
const { formatMessage } = useMessageFormatter();
const { draftMessage, processEvent, recordAnalytics } = useAI();
return { draftMessage, processEvent, recordAnalytics, formatMessage };
},
data() {
return {
generatedContent: '',
isGenerating: true,
};
},
computed: {
headerTitle() {
const translationKey = this.aiOption?.toUpperCase();
return translationKey
? this.$t(`INTEGRATION_SETTINGS.OPEN_AI.WITH_AI`, {
option: this.$t(
`INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.${translationKey}`
),
})
: '';
},
},
mounted() {
this.generateAIContent(this.aiOption);
},
methods: {
onClose() {
this.$emit('close');
},
async generateAIContent(type = 'rephrase') {
this.isGenerating = true;
this.generatedContent = await this.processEvent(type);
this.isGenerating = false;
},
applyText() {
this.recordAnalytics(this.aiOption);
this.$emit('applyText', this.generatedContent);
this.onClose();
},
},
};
</script>
<template>
<div class="flex flex-col">
<woot-modal-header :header-title="headerTitle" />
<form
class="flex flex-col w-full modal-content"
@submit.prevent="applyText"
>
<div v-if="draftMessage" class="w-full">
<h4 class="mt-1 text-base text-n-slate-12">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.DRAFT_TITLE') }}
</h4>
<p v-dompurify-html="formatMessage(draftMessage, false)" />
<h4 class="mt-1 text-base text-n-slate-12">
{{
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.GENERATED_TITLE')
}}
</h4>
</div>
<div>
<AILoader v-if="isGenerating" />
<p v-else v-dompurify-html="formatMessage(generatedContent, false)" />
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton
faded
slate
type="reset"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.BUTTONS.CANCEL')
"
@click.prevent="onClose"
/>
<NextButton
type="submit"
:disabled="!generatedContent"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.BUTTONS.APPLY')
"
/>
</div>
</form>
</div>
</template>
<style lang="scss" scoped>
.modal-content {
@apply pt-2 px-8 pb-8;
}
.container {
width: 100%;
}
</style>
@@ -1,130 +0,0 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAI } from 'dashboard/composables/useAI';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
emits: ['close'],
setup() {
const { updateUISettings } = useUISettings();
const { recordAnalytics } = useAI();
const v$ = useVuelidate();
return { updateUISettings, v$, recordAnalytics };
},
data() {
return {
value: '',
};
},
validations: {
value: {
required,
},
},
methods: {
onClose() {
this.$emit('close');
},
onDismiss() {
useAlert(
this.$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.DISMISS_MESSAGE')
);
this.updateUISettings({
is_open_ai_cta_modal_dismissed: true,
});
this.onClose();
},
async finishOpenAI() {
const payload = {
app_id: 'openai',
settings: {
api_key: this.value,
},
};
try {
await this.$store.dispatch('integrations/createHook', payload);
this.alertMessage = this.$t(
'INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.SUCCESS_MESSAGE'
);
this.recordAnalytics(
OPEN_AI_EVENTS.ADDED_AI_INTEGRATION_VIA_CTA_BUTTON
);
this.onClose();
} catch (error) {
const errorMessage = error?.response?.data?.message;
this.alertMessage =
errorMessage || this.$t('INTEGRATION_APPS.ADD.API.ERROR_MESSAGE');
} finally {
useAlert(this.alertMessage);
}
},
openOpenAIDoc() {
window.open('https://www.chatwoot.com/blog/v2-17', '_blank');
},
},
};
</script>
<template>
<div class="flex-1 min-w-0 px-0">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.TITLE')"
:header-content="$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.DESC')"
/>
<form
class="flex flex-col flex-wrap modal-content"
@submit.prevent="finishOpenAI"
>
<div class="w-full mt-2">
<woot-input
v-model="value"
type="text"
:class="{ error: v$.value.$error }"
:placeholder="
$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.KEY_PLACEHOLDER')
"
@blur="v$.value.$touch"
/>
</div>
<div class="flex flex-row justify-between w-full gap-2 px-0 py-2">
<NextButton
ghost
type="button"
class="!px-3"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.NEED_HELP')
"
@click.prevent="openOpenAIDoc"
/>
<div class="flex items-center gap-1">
<NextButton
faded
slate
type="reset"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.DISMISS')
"
@click.prevent="onDismiss"
/>
<NextButton
type="submit"
:disabled="v$.value.$invalid"
:label="$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.FINISH')"
/>
</div>
</div>
</form>
</div>
</template>
@@ -46,11 +46,11 @@ const fileName = file => {
</script>
<template>
<div class="flex overflow-auto max-h-[12.5rem]">
<div class="flex flex-wrap gap-y-1 gap-x-2 overflow-auto max-h-[12.5rem]">
<div
v-for="(attachment, index) in nonRecordedAudioAttachments"
:key="attachment.id"
class="flex items-center p-1 bg-n-slate-3 gap-1 rounded-md w-[15rem] mb-1"
class="flex items-center p-1 bg-n-slate-3 gap-1 rounded-md w-[15rem]"
>
<div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
<img
@@ -38,7 +38,7 @@ const toggleShowMore = () => {
<button
v-if="text.length > limit"
class="text-n-brand !p-0 !border-0 align-top"
@click="toggleShowMore"
@click.stop="toggleShowMore"
>
{{ buttonLabel }}
</button>
@@ -0,0 +1,253 @@
<script setup>
import { ref, computed, watch, onMounted, useTemplateRef } from 'vue';
import {
buildMessageSchema,
buildEditor,
EditorView,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
EditorState,
Selection,
} from '@chatwoot/prosemirror-schema';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
modelValue: { type: String, default: '' },
editorId: { type: String, default: '' },
placeholder: {
type: String,
default: 'Give copilot additional prompts, or ask anything else...',
},
generatedContent: { type: String, default: '' },
autofocus: {
type: Boolean,
default: true,
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'blur',
'input',
'update:modelValue',
'keyup',
'focus',
'keydown',
'send',
]);
const { formatMessage } = useMessageFormatter();
// Minimal schema with no marks or nodes for copilot input
const copilotSchema = buildMessageSchema([], []);
const handleSubmit = () => emit('send');
const createState = (
content,
placeholder,
plugins = [],
enabledMenuOptions = []
) => {
return EditorState.create({
doc: new MessageMarkdownTransformer(copilotSchema).parse(content),
plugins: buildEditor({
schema: copilotSchema,
placeholder,
plugins,
enabledMenuOptions,
}),
});
};
// we don't need them to be reactive
// It cases weird issues where the objects are proxied
// and then the editor doesn't work as expected
let editorView = null;
let state = null;
// reactive data
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
// element refs
const editor = useTemplateRef('editor');
function contentFromEditor() {
if (editorView) {
return MessageMarkdownSerializer.serialize(editorView.state.doc);
}
return '';
}
function focusEditorInputField() {
const { tr } = editorView.state;
const selection = Selection.atEnd(tr.doc);
editorView.dispatch(tr.setSelection(selection));
editorView.focus();
}
function emitOnChange() {
emit('update:modelValue', contentFromEditor());
emit('input', contentFromEditor());
}
function onKeyup() {
emit('keyup');
}
function onKeydown(view, event) {
emit('keydown');
// Handle Enter key to send message (Shift+Enter for new line)
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
return true; // Prevent ProseMirror's default Enter handling
}
return false; // Allow other keys to work normally
}
function onBlur() {
emit('blur');
}
function onFocus() {
emit('focus');
}
function checkSelection(editorState) {
const hasSelection = editorState.selection.from !== editorState.selection.to;
if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection;
}
// computed properties
const plugins = computed(() => {
return [];
});
const enabledMenuOptions = computed(() => {
return [];
});
function reloadState() {
state = createState(
props.modelValue,
props.placeholder,
plugins.value,
enabledMenuOptions.value
);
editorView.updateState(state);
focusEditorInputField();
}
function createEditorView() {
editorView = new EditorView(editor.value, {
state: state,
dispatchTransaction: tx => {
state = state.apply(tx);
editorView.updateState(state);
if (tx.docChanged) {
emitOnChange();
}
checkSelection(state);
},
handleDOMEvents: {
keyup: onKeyup,
focus: onFocus,
blur: onBlur,
keydown: onKeydown,
},
});
}
// watchers
watch(
computed(() => props.modelValue),
(newValue = '') => {
if (newValue !== contentFromEditor()) {
reloadState();
}
}
);
watch(
computed(() => props.editorId),
() => {
reloadState();
}
);
// lifecycle
onMounted(() => {
state = createState(
props.modelValue,
props.placeholder,
plugins.value,
enabledMenuOptions.value
);
createEditorView();
editorView.updateState(state);
if (props.autofocus) {
focusEditorInputField();
}
});
</script>
<template>
<div class="space-y-2 mb-4">
<div
class="overflow-y-auto"
:class="{ 'max-h-96': isPopout, 'max-h-56': !isPopout }"
>
<p
v-dompurify-html="formatMessage(generatedContent, false)"
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4"
/>
</div>
<div class="editor-root relative editor--copilot space-x-2">
<div ref="editor" />
<div class="flex items-center justify-end absolute right-2 bottom-2">
<NextButton
class="bg-n-iris-9 text-white !rounded-full"
icon="i-lucide-arrow-up"
solid
sm
@click="handleSubmit"
/>
</div>
</div>
</div>
</template>
<style lang="scss">
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
.editor--copilot {
@apply bg-n-iris-5 rounded;
.ProseMirror-woot-style {
min-height: 5rem;
max-height: 7.5rem !important;
overflow: auto;
@apply px-2 !important;
.empty-node {
&::before {
@apply text-n-iris-9 dark:text-n-iris-11;
}
}
}
}
</style>
@@ -0,0 +1,259 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useElementSize, useWindowSize } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import { useCaptain } from 'dashboard/composables/useCaptain';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import Icon from 'next/icon/Icon.vue';
defineProps({
hasSelection: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['executeCopilotAction']);
const { t } = useI18n();
const { draftMessage } = useCaptain();
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
// Selection-based menu items (when text is selected)
const menuItems = computed(() => {
const items = [];
// for now, we don't allow improving just aprt of the selection
// we will add this feature later. Once we do, we can revert the change
const hasSelection = false;
// const hasSelection = props.hasSelection
if (hasSelection) {
items.push({
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY_SELECTION'
),
key: 'improve_selection',
icon: 'i-fluent-pen-sparkle-24-regular',
});
} else if (
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
draftMessage.value
) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
key: 'improve',
icon: 'i-fluent-pen-sparkle-24-regular',
});
}
if (draftMessage.value) {
items.push(
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.TITLE'
),
key: 'change_tone',
icon: 'i-fluent-sound-wave-circle-sparkle-24-regular',
subMenuItems: [
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.PROFESSIONAL'
),
key: 'professional',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CASUAL'
),
key: 'casual',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.STRAIGHTFORWARD'
),
key: 'straightforward',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CONFIDENT'
),
key: 'confident',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.FRIENDLY'
),
key: 'friendly',
},
],
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.GRAMMAR'),
key: 'fix_spelling_grammar',
icon: 'i-fluent-flow-sparkle-24-regular',
}
);
}
return items;
});
const generalMenuItems = computed(() => {
const items = [];
if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
key: 'reply_suggestion',
icon: 'i-fluent-chat-sparkle-16-regular',
});
}
if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
key: 'summarize',
icon: 'i-fluent-text-bullet-list-square-sparkle-32-regular',
});
}
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.ASK_COPILOT'),
key: 'ask_copilot',
icon: 'i-fluent-circle-sparkle-24-regular',
});
return items;
});
const menuRef = useTemplateRef('menuRef');
const { height: menuHeight } = useElementSize(menuRef);
const { width: windowWidth } = useWindowSize();
// Smart submenu positioning based on available space
const submenuPosition = computed(() => {
const el = menuRef.value?.$el;
if (!el) return 'ltr:right-full rtl:left-full';
const { left, right } = el.getBoundingClientRect();
const SUBMENU_WIDTH = 200;
const spaceRight = (windowWidth.value ?? window.innerWidth) - right;
const spaceLeft = left;
// Prefer right, fallback to side with more space
const showRight = spaceRight >= SUBMENU_WIDTH || spaceRight >= spaceLeft;
return showRight ? 'left-full' : 'right-full';
});
// Computed style for selection menu positioning (only dynamic top offset)
const selectionMenuStyle = computed(() => {
// Dynamically calculate offset based on actual menu height + 10px gap
const dynamicOffset = menuHeight.value > 0 ? menuHeight.value + 10 : 60;
return {
top: `calc(var(--selection-top) - ${dynamicOffset}px)`,
};
});
const handleMenuItemClick = item => {
// For items with submenus, do nothing on click (hover will show submenu)
if (!item.subMenuItems) {
emit('executeCopilotAction', item.key);
}
};
const handleSubMenuItemClick = (parentItem, subItem) => {
emit('executeCopilotAction', subItem.key);
};
</script>
<template>
<DropdownBody
ref="menuRef"
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
:class="{ 'selection-menu': hasSelection }"
:style="hasSelection ? selectionMenuStyle : {}"
>
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
<div
v-for="item in menuItems"
:key="item.key"
class="w-full relative group/submenu"
>
<Button
:label="item.label"
:icon="item.icon"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start"
@click="handleMenuItemClick(item)"
>
<template v-if="item.subMenuItems" #default>
<div class="flex items-center gap-1 justify-between w-full">
<span class="min-w-0 truncate">{{ item.label }}</span>
<Icon
icon="i-lucide-chevron-right"
class="text-n-slate-10 size-3"
/>
</div>
</template>
</Button>
<!-- Hover Submenu -->
<DropdownBody
v-if="item.subMenuItems"
class="group-hover/submenu:block hidden [&>ul]:gap-2 [&>ul]:px-3 [&>ul]:py-2.5 [&>ul]:dark:!border-n-strong max-h-[15rem] min-w-32 z-10 top-0"
:class="submenuPosition"
>
<Button
v-for="subItem in item.subMenuItems"
:key="subItem.key + subItem.label"
:label="subItem.label"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start mb-1"
@click="handleSubMenuItemClick(item, subItem)"
/>
</DropdownBody>
</div>
</div>
<div v-if="menuItems.length > 0" class="h-px w-full bg-n-strong" />
<div class="flex flex-col items-start gap-3">
<Button
v-for="(item, index) in generalMenuItems"
:key="index"
:label="item.label"
:icon="item.icon"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start"
@click="handleMenuItemClick(item)"
/>
</div>
</DropdownBody>
</template>
<style scoped lang="scss">
.selection-menu {
position: absolute !important;
// Default/LTR: position from left
left: var(--selection-left);
// RTL: position from right instead
[dir='rtl'] & {
left: auto;
right: var(--selection-right);
}
}
</style>
@@ -0,0 +1,51 @@
<script setup>
import { computed } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
import { useKbd } from 'dashboard/composables/utils/useKbd';
defineProps({
isGeneratingContent: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const handleCancel = () => {
emit('cancel');
};
const shortcutKey = useKbd(['$mod', '+', 'enter']);
const acceptLabel = computed(() => {
return `${t('GENERAL.ACCEPT')} (${shortcutKey.value})`;
});
const handleSubmit = () => {
emit('submit');
};
</script>
<template>
<div class="flex justify-between items-center p-3 pt-0">
<NextButton
:label="t('GENERAL.DISCARD')"
slate
link
class="!px-1 hover:!no-underline"
sm
:disabled="isGeneratingContent"
@click="handleCancel"
/>
<NextButton
:label="acceptLabel"
class="bg-n-iris-9 text-white"
solid
sm
:disabled="isGeneratingContent"
@click="handleSubmit"
/>
</div>
</template>
@@ -16,13 +16,16 @@ import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
import TagAgents from '../conversation/TagAgents.vue';
import VariableList from '../conversation/VariableList.vue';
import TagTools from '../conversation/TagTools.vue';
import CopilotMenuBar from './CopilotMenuBar.vue';
import { useEmitter } from 'dashboard/composables/emitter';
import { useI18n } from 'vue-i18n';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { vOnClickOutside } from '@vueuse/components';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
@@ -55,6 +58,7 @@ import {
getSelectionCoords,
calculateMenuPosition,
getEffectiveChannelType,
stripUnsupportedFormatting,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -99,13 +103,16 @@ const emit = defineEmits([
'focus',
'input',
'update:modelValue',
'executeCopilotAction',
]);
const { t } = useI18n();
const { captainTasksEnabled } = useCaptain();
const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const DEFAULT_FORMATTING = 'Context::Default';
const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote';
const effectiveChannelType = computed(() =>
getEffectiveChannelType(props.channelType, props.medium)
@@ -115,24 +122,33 @@ const editorSchema = computed(() => {
if (!props.channelType) return messageSchema;
const formatType = props.isPrivate
? DEFAULT_FORMATTING
? PRIVATE_NOTE_FORMATTING
: effectiveChannelType.value;
const formatting = getFormattingForEditor(formatType);
const formatting = getFormattingForEditor(
formatType,
captainTasksEnabled.value
);
return buildMessageSchema(formatting.marks, formatting.nodes);
});
const editorMenuOptions = computed(() => {
const formatType = props.isPrivate
? DEFAULT_FORMATTING
? PRIVATE_NOTE_FORMATTING
: effectiveChannelType.value || DEFAULT_FORMATTING;
const formatting = getFormattingForEditor(formatType);
const formatting = getFormattingForEditor(
formatType,
captainTasksEnabled.value
);
return formatting.menu;
});
const createState = (content, placeholder, plugins = [], methods = {}) => {
const schema = editorSchema.value;
// Strip unsupported formatting before parsing to prevent "Token type not supported" errors
const sanitizedContent = stripUnsupportedFormatting(content, schema);
return EditorState.create({
doc: new MessageMarkdownTransformer(schema).parse(content),
doc: new MessageMarkdownTransformer(schema).parse(sanitizedContent),
plugins: buildEditor({
schema,
placeholder,
@@ -182,6 +198,21 @@ const editorRoot = useTemplateRef('editorRoot');
const imageUpload = useTemplateRef('imageUpload');
const editor = useTemplateRef('editor');
const handleCopilotAction = actionKey => {
if (actionKey === 'improve_selection' && editorView?.state) {
const { from, to } = editorView.state.selection;
const selectedText = editorView.state.doc.textBetween(from, to).trim();
if (from !== to && selectedText) {
emit('executeCopilotAction', 'improve', selectedText);
}
} else {
emit('executeCopilotAction', actionKey);
}
showSelectionMenu.value = false;
};
const contentFromEditor = () => {
return MessageMarkdownSerializer.serialize(editorView.state.doc);
};
@@ -364,13 +395,23 @@ function openFileBrowser() {
imageUpload.value.click();
}
function handleCopilotClick() {
showSelectionMenu.value = !showSelectionMenu.value;
}
function handleClickOutside(event) {
// Check if the clicked element or its parents have the ignored class
if (event.target.closest('.ProseMirror-copilot')) return;
showSelectionMenu.value = false;
}
function reloadState(content = props.modelValue) {
const unrefContent = unref(content);
state = createState(
unrefContent,
props.placeholder,
plugins.value,
{ onImageUpload: openFileBrowser },
{ onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
editorMenuOptions.value
);
@@ -592,7 +633,12 @@ function insertContentIntoEditor(content, defaultFrom = 0) {
const from = defaultFrom || editorView.state.selection.from || 0;
// Use the editor's current schema to ensure compatibility with buildMessageSchema
const currentSchema = editorView.state.schema;
let node = new MessageMarkdownTransformer(currentSchema).parse(content);
// Strip unsupported formatting before parsing to ensure content can be inserted
// into channels that don't support certain markdown features (e.g., API channels)
const sanitizedContent = stripUnsupportedFormatting(content, currentSchema);
let node = new MessageMarkdownTransformer(currentSchema).parse(
sanitizedContent
);
insertNodeIntoEditor(node, from, undefined);
}
@@ -754,7 +800,7 @@ onMounted(() => {
props.modelValue,
props.placeholder,
plugins.value,
{ onImageUpload: openFileBrowser },
{ onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
editorMenuOptions.value
);
@@ -799,6 +845,14 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
:search-key="toolSearchKey"
@select-tool="content => insertSpecialContent('tool', content)"
/>
<CopilotMenuBar
v-if="showSelectionMenu"
v-on-click-outside="handleClickOutside"
:has-selection="isTextSelected"
:show-selection-menu="showSelectionMenu"
:show-general-menu="false"
@execute-copilot-action="handleCopilotAction"
/>
<input
ref="imageUpload"
type="file"
@@ -852,6 +906,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply size-full;
}
}
.ProseMirror-copilot svg {
@apply fill-n-violet-9 text-n-violet-9 stroke-none;
}
}
}
@@ -991,6 +1049,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
.ProseMirror-icon {
@apply p-0.5 flex-shrink-0;
}
.ProseMirror-copilot svg {
@apply fill-n-violet-9 text-n-violet-9 stroke-none;
}
}
.ProseMirror-menu-active {
@@ -12,6 +12,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
isReplyRestricted: {
type: Boolean,
default: false,
},
});
defineEmits(['toggleMode']);
@@ -24,11 +28,17 @@ const privateModeSize = useElementSize(wootEditorPrivateMode);
/**
* Computed boolean indicating if the editor is in private note mode
* When disabled, always show NOTE mode regardless of actual mode prop
* When isReplyRestricted is true, force switch to private note
* Otherwise, respect the current mode prop
* @type {ComputedRef<boolean>}
*/
const isPrivate = computed(() => {
return props.disabled || props.mode === REPLY_EDITOR_MODES.NOTE;
if (props.isReplyRestricted) {
// Force switch to private note when replies are restricted
return true;
}
// Otherwise respect the current mode
return props.mode === REPLY_EDITOR_MODES.NOTE;
});
/**
@@ -60,9 +70,9 @@ const translateValue = computed(() => {
<template>
<button
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
:disabled="disabled"
:disabled="disabled || isReplyRestricted"
:class="{
'cursor-not-allowed': disabled,
'cursor-not-allowed': disabled || isReplyRestricted,
}"
@click="$emit('toggleMode')"
>
@@ -75,7 +85,7 @@ const translateValue = computed(() => {
<div
class="absolute shadow-sm rounded-full h-6 w-[var(--chip-width)] ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
:class="{
'transition-all duration-300': !disabled,
'transition-all duration-300': !disabled && !isReplyRestricted,
}"
:style="{
'--chip-width': width,
@@ -9,14 +9,13 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
name: 'ReplyBottomPanel',
components: { NextButton, FileUpload, VideoCallButton, AIAssistanceButton },
components: { NextButton, FileUpload, VideoCallButton },
mixins: [inboxMixin],
props: {
isNote: {
@@ -98,6 +97,7 @@ export default {
type: Number,
required: true,
},
// eslint-disable-next-line vue/no-unused-properties
message: {
type: String,
default: '',
@@ -370,13 +370,6 @@ export default {
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
:conversation-id="conversationId"
/>
<AIAssistanceButton
v-if="!isFetchingAppIntegrations"
:conversation-id="conversationId"
:is-private-note="isOnPrivateNote"
:message="message"
@replace-text="replaceText"
/>
<transition name="modal-fade">
<div
v-show="uploadRef && uploadRef.dropActive"
@@ -1,14 +1,22 @@
<script>
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { vOnClickOutside } from '@vueuse/components';
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import NextButton from 'dashboard/components-next/button/Button.vue';
import EditorModeToggle from './EditorModeToggle.vue';
import CopilotMenuBar from './CopilotMenuBar.vue';
export default {
name: 'ReplyTopPanel',
components: {
NextButton,
EditorModeToggle,
CopilotMenuBar,
},
directives: {
OnClickOutside: vOnClickOutside,
},
props: {
mode: {
@@ -19,6 +27,10 @@ export default {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
@@ -28,7 +40,7 @@ export default {
default: () => 0,
},
},
emits: ['setReplyMode', 'togglePopout'],
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
setup(props, { emit }) {
const setReplyMode = mode => {
emit('setReplyMode', mode);
@@ -47,6 +59,23 @@ export default {
: REPLY_EDITOR_MODES.REPLY;
setReplyMode(newMode);
};
const { captainTasksEnabled } = useCaptain();
const showCopilotMenu = ref(false);
const handleCopilotAction = actionKey => {
emit('executeCopilotAction', actionKey);
showCopilotMenu.value = false;
};
const toggleCopilotMenu = () => {
showCopilotMenu.value = !showCopilotMenu.value;
};
const handleClickOutside = () => {
showCopilotMenu.value = false;
};
const keyboardEvents = {
'Alt+KeyP': {
action: () => handleNoteClick(),
@@ -64,6 +93,11 @@ export default {
handleReplyClick,
handleNoteClick,
REPLY_EDITOR_MODES,
captainTasksEnabled,
handleCopilotAction,
showCopilotMenu,
toggleCopilotMenu,
handleClickOutside,
};
},
computed: {
@@ -90,11 +124,13 @@ export default {
</script>
<template>
<div class="flex justify-between h-[3.25rem] gap-2 ltr:pl-3 rtl:pr-3">
<div
class="flex justify-between gap-2 h-[3.25rem] items-center ltr:pl-3 ltr:pr-2 rtl:pr-3 rtl:pl-2"
>
<EditorModeToggle
:mode="mode"
:disabled="isReplyRestricted"
class="mt-3"
:disabled="disabled"
:is-reply-restricted="isReplyRestricted"
@toggle-mode="handleModeToggle"
/>
<div class="flex items-center mx-4 my-0">
@@ -104,11 +140,34 @@ export default {
</span>
</div>
</div>
<NextButton
ghost
class="ltr:rounded-bl-md rtl:rounded-br-md ltr:rounded-br-none rtl:rounded-bl-none ltr:rounded-tl-none rtl:rounded-tr-none text-n-slate-11 ltr:rounded-tr-[11px] rtl:rounded-tl-[11px]"
icon="i-lucide-maximize-2"
@click="$emit('togglePopout')"
/>
<div v-if="captainTasksEnabled" class="flex items-center gap-2">
<div class="relative">
<NextButton
ghost
:disabled="disabled"
:class="{
'text-n-violet-9 hover:enabled:!bg-n-violet-3': !showCopilotMenu,
'text-n-violet-9 bg-n-violet-3': showCopilotMenu,
}"
sm
icon="i-ph-sparkle-fill"
@click="toggleCopilotMenu"
/>
<CopilotMenuBar
v-if="showCopilotMenu"
v-on-click-outside="handleClickOutside"
:has-selection="false"
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
@execute-copilot-action="handleCopilotAction"
/>
</div>
<NextButton
ghost
class="text-n-slate-11"
sm
icon="i-lucide-maximize-2"
@click="$emit('togglePopout')"
/>
</div>
</div>
</template>
@@ -0,0 +1,99 @@
<script setup>
import { ref } from 'vue';
import CopilotEditor from 'dashboard/components/widgets/WootWriter/CopilotEditor.vue';
import CaptainLoader from 'dashboard/components/widgets/conversation/copilot/CaptainLoader.vue';
defineProps({
showCopilotEditor: {
type: Boolean,
default: false,
},
isGeneratingContent: {
type: Boolean,
default: false,
},
generatedContent: {
type: String,
default: '',
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'focus',
'blur',
'clearSelection',
'contentReady',
'send',
]);
const copilotEditorContent = ref('');
const onFocus = () => {
emit('focus');
};
const onBlur = () => {
emit('blur');
};
const clearEditorSelection = () => {
emit('clearSelection');
};
const onSend = () => {
emit('send', copilotEditorContent.value);
copilotEditorContent.value = '';
};
</script>
<template>
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
@after-enter="emit('contentReady')"
>
<CopilotEditor
v-if="showCopilotEditor && !isGeneratingContent"
key="copilot-editor"
v-model="copilotEditorContent"
class="copilot-editor"
:generated-content="generatedContent"
:min-height="4"
:enabled-menu-options="[]"
:is-popout="isPopout"
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"
@send="onSend"
/>
<div
v-else-if="isGeneratingContent"
key="loading-state"
class="bg-n-iris-5 rounded min-h-16 w-full mb-4 p-4 flex items-start"
>
<div class="flex items-center gap-2">
<CaptainLoader class="text-n-iris-10 size-4" />
<span class="text-sm text-n-iris-10">
{{ $t('CONVERSATION.REPLYBOX.COPILOT_THINKING') }}
</span>
</div>
</div>
</Transition>
</template>
<style lang="scss">
.copilot-editor {
.ProseMirror-menubar {
display: none;
}
}
</style>
@@ -11,7 +11,7 @@ const openProfileSettings = () => {
<template>
<div
class="my-0 mx-4 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-n-slate-1 border border-dashed border-n-weak rounded-sm overflow-auto"
class="my-0 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-n-slate-1 border border-dashed border-n-weak rounded-sm overflow-auto"
>
<p class="w-fit !m-0">
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
@@ -1,9 +1,8 @@
<script>
import { ref, provide } from 'vue';
// composable
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
// components
@@ -49,7 +48,6 @@ export default {
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const { isEnterprise } = useConfig();
const keyboardEvents = {
Escape: {
@@ -61,22 +59,14 @@ export default {
useKeyboardEvents(keyboardEvents);
const {
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
} = useAI();
const { captainTasksEnabled, getLabelSuggestions } = useCaptain();
provide('contextMenuElementTarget', conversationPanelRef);
return {
isEnterprise,
isPopOutReplyBox,
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
captainTasksEnabled,
getLabelSuggestions,
conversationPanelRef,
};
},
@@ -104,10 +94,7 @@ export default {
},
shouldShowLabelSuggestions() {
return (
this.isOpen &&
this.isEnterprise &&
this.isAIIntegrationEnabled &&
!this.messageSentSinceOpened
this.isOpen && this.captainTasksEnabled && !this.messageSentSinceOpened
);
},
inboxId() {
@@ -291,24 +278,15 @@ export default {
return;
}
if (!this.isEnterprise) {
return;
}
// Early exit if conversation already has labels - no need to suggest more
const existingLabels = this.currentChat?.labels || [];
if (existingLabels.length > 0) return;
// method available in mixin, need to ensure that integrations are present
await this.fetchIntegrationsIfRequired();
if (!this.isLabelSuggestionFeatureEnabled) {
if (!this.captainTasksEnabled) {
return;
}
this.labelSuggestions = await this.fetchLabelSuggestions({
conversationId: this.currentChat.id,
});
this.labelSuggestions = await this.getLabelSuggestions();
// once the labels are fetched, we need to scroll to bottom
// but we need to wait for the DOM to be updated
@@ -7,14 +7,14 @@ import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue';
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
import CopilotEditorSection from './CopilotEditorSection.vue';
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
import ReplyBoxBanner from './ReplyBoxBanner.vue';
import QuotedEmailPreview from './QuotedEmailPreview.vue';
@@ -23,6 +23,7 @@ import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vu
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
import { AUDIO_FORMATS } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import {
getMessageVariables,
getUndefinedVariablesInMessage,
@@ -46,8 +47,10 @@ import {
appendSignature,
removeSignature,
getEffectiveChannelType,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
@@ -72,8 +75,8 @@ export default {
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
ResizableTextArea,
CannedResponse,
CopilotEditorSection,
CopilotReplyBottomPanel,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@@ -93,6 +96,8 @@ export default {
} = useUISettings();
const replyEditor = useTemplateRef('replyEditor');
const copilot = useCopilotReply();
const shortcutKey = useKbd(['$mod', '+', 'enter']);
return {
uiSettings,
@@ -101,6 +106,8 @@ export default {
setQuotedReplyFlagForInbox,
fetchQuotedReplyFlagFromUISettings,
replyEditor,
copilot,
shortcutKey,
};
},
data() {
@@ -114,8 +121,6 @@ export default {
recordingAudioState: '',
recordingAudioDurationText: '',
replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
hasSlashCommand: false,
bccEmails: '',
ccEmails: '',
toEmails: '',
@@ -148,9 +153,6 @@ export default {
if (!senderId) return {};
return this.$store.getters['contacts/getContact'](senderId);
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
},
shouldShowReplyToMessage() {
return (
this.inReplyTo?.id &&
@@ -276,7 +278,7 @@ export default {
sendMessageText = this.$t('CONVERSATION.REPLYBOX.CREATE');
}
const keyLabel = this.isEditorHotKeyEnabled('cmd_enter')
? '(⌘ + ↵)'
? `(${this.shortcutKey})`
: '(↵)';
return `${sendMessageText} ${keyLabel}`;
},
@@ -409,18 +411,8 @@ export default {
!!this.quotedEmailText
);
},
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
}
return false;
},
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
isDefaultEditorMode() {
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
},
},
watch: {
@@ -431,6 +423,8 @@ export default {
// This prevents overwriting user input (e.g., CC/BCC fields) when performing actions
// like self-assign or other updates that do not actually change the conversation context
this.setCCAndToEmailsFromLastChat();
// Reset Copilot editor state (includes cancelling ongoing generation)
this.copilot.reset();
}
if (this.isOnPrivateNote) {
@@ -464,25 +458,7 @@ export default {
this.resetRecorderAndClearAttachments();
}
},
message(updatedMessage) {
// Check if the message starts with a slash.
const bodyWithoutSignature = removeSignature(
updatedMessage,
this.signatureToApply
);
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Determine if the user is potentially typing a slash command.
// This is true if the message starts with a slash and the rich content editor is not active.
this.hasSlashCommand = startsWithSlash && !this.showRichContentEditor;
this.showMentions = this.hasSlashCommand;
// If a slash command is active, extract the command text after the slash.
// If not, reset the mentionSearchKey.
this.mentionSearchKey = this.hasSlashCommand
? bodyWithoutSignature.substring(1)
: '';
message() {
// Autosave the current message draft.
this.doAutoSaveDraft();
},
@@ -518,6 +494,7 @@ export default {
this.onNewConversationModalActive
);
emitter.on(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
emitter.on(CMD_AI_ASSIST, this.executeCopilotAction);
},
unmounted() {
document.removeEventListener('paste', this.onPaste);
@@ -528,24 +505,19 @@ export default {
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive
);
emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
},
methods: {
handleInsert(article) {
const { url, title } = article;
if (this.isRichEditorEnabled) {
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
} else {
this.addIntoEditor(
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
);
}
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
},
@@ -614,26 +586,14 @@ export default {
if (this.isPrivate) {
return message;
}
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
return this.sendWithSignature
? appendSignature(
message,
this.messageSignature,
effectiveChannelType
)
: removeSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
return this.sendWithSignature
? appendSignature(message, this.signatureToApply)
: removeSignature(message, this.signatureToApply);
? appendSignature(message, this.messageSignature, effectiveChannelType)
: removeSignature(message, this.messageSignature, effectiveChannelType);
},
removeFromDraft() {
if (this.conversationIdByRoute) {
@@ -649,7 +609,6 @@ export default {
Escape: {
action: () => {
this.hideEmojiPicker();
this.hideMentions();
},
allowOnFocusedInput: true,
},
@@ -672,7 +631,9 @@ export default {
},
'$mod+Enter': {
action: () => {
if (this.isAValidEvent('cmd_enter')) {
if (this.copilot.isActive.value && this.isFocused) {
this.onSubmitCopilotReply();
} else if (this.isAValidEvent('cmd_enter')) {
this.onSendReply();
}
},
@@ -694,14 +655,28 @@ export default {
// Don't handle paste if compose new conversation modal is open
if (this.newConversationModalActive) return;
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput?.$el?.blur();
}
// Filter valid files (non-zero size)
Array.from(e.clipboardData.files)
.filter(file => file.size > 0)
.filter(file => {
const isAllowed = isFileTypeAllowedForChannel(file, {
channelType: this.channelType || this.inbox?.channel_type,
medium: this.inbox?.medium,
conversationType: this.conversationType,
isInstagramChannel: this.isAnInstagramChannel,
isOnPrivateNote: this.isOnPrivateNote,
});
if (!isAllowed) {
useAlert(
this.$t('CONVERSATION.FILE_TYPE_NOT_SUPPORTED', {
fileName: file.name,
})
);
}
return isAllowed;
})
.forEach(file => {
const { name, type, size } = file;
this.onFileUpload({ name, type, size, file });
@@ -832,19 +807,15 @@ export default {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
} else {
message = appendSignature(message, this.signatureToApply);
}
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
const updatedMessage = replaceVariablesInMessage({
@@ -868,52 +839,33 @@ export default {
});
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
this.replyType = mode;
if (this.showRichContentEditor) {
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
return;
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
this.$nextTick(() => this.$refs.messageInput.focus());
},
clearEditorSelection() {
this.updateEditorSelectionWith = '';
},
insertIntoTextEditor(text, selectionStart, selectionEnd) {
const { message } = this;
const newMessage =
message.slice(0, selectionStart) +
text +
message.slice(selectionEnd, message.length);
this.message = newMessage;
},
addIntoEditor(content) {
if (this.showRichContentEditor) {
this.updateEditorSelectionWith = content;
this.onFocus();
}
if (!this.showRichContentEditor) {
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
}
this.updateEditorSelectionWith = content;
this.onFocus();
},
executeCopilotAction(action, data) {
this.copilot.execute(action, data);
},
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
this.message = appendSignature(
this.message,
this.messageSignature,
effectiveChannelType
);
} else {
this.message = appendSignature(this.message, this.signatureToApply);
}
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
this.message = appendSignature(
this.message,
this.messageSignature,
effectiveChannelType
);
}
this.attachedFiles = [];
this.isRecordingAudio = false;
@@ -948,9 +900,6 @@ export default {
this.toggleEmojiPicker();
}
},
hideMentions() {
this.showMentions = false;
},
onTypingOn() {
this.toggleTyping('on');
},
@@ -1169,6 +1118,9 @@ export default {
togglePopout() {
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
},
onSubmitCopilotReply() {
this.message = this.copilot.accept();
},
},
};
</script>
@@ -1179,11 +1131,17 @@ export default {
<ReplyTopPanel
:mode="replyType"
:is-reply-restricted="isReplyRestricted"
:disabled="
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
showAudioRecorderEditor
"
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
:popout-reply-box="popOutReplyBox"
@set-reply-mode="setReplyMode"
@toggle-popout="togglePopout"
@toggle-copilot="copilot.toggleEditor"
@execute-copilot-action="executeCopilotAction"
/>
<ArticleSearchPopover
v-if="showArticleSearchPopover && connectedPortalSlug"
@@ -1191,135 +1149,167 @@ export default {
@insert="handleInsert"
@close="onSearchPopoverClose"
/>
<div class="reply-box__top">
<ReplyToMessage
v-if="shouldShowReplyToMessage"
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<CannedResponse
v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideMentions"
class="normal-editor__canned-box"
:search-key="mentionSearchKey"
@replace="replaceText"
/>
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
:class="{
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
}"
:on-click="addIntoEditor"
/>
<ReplyEmailHead
v-if="showReplyHead"
v-model:cc-emails="ccEmails"
v-model:bcc-emails="bccEmails"
v-model:to-emails="toEmails"
/>
<AudioRecorder
v-if="showAudioRecorderEditor"
ref="audioRecorderInput"
:audio-record-format="audioRecordFormat"
@recorder-progress-changed="onRecordProgressChanged"
@finish-record="onFinishRecorder"
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<ResizableTextArea
v-else-if="!showRichContentEditor"
ref="messageInput"
v-model="message"
class="rounded-none input"
:placeholder="messagePlaceHolder"
:min-height="4"
:signature="signatureToApply"
allow-signature
:send-with-signature="sendWithSignature"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
/>
<WootMessageEditor
v-else
v-model="message"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
enable-variables
:variables="messageVariables"
:signature="messageSignature"
allow-signature
:channel-type="channelType"
:medium="inbox.medium"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
@toggle-user-mention="toggleUserMention"
@toggle-canned-menu="toggleCannedMenu"
@toggle-variables-menu="toggleVariablesMenu"
@clear-selection="clearEditorSelection"
/>
<QuotedEmailPreview
v-if="shouldShowQuotedPreview"
:quoted-email-text="quotedEmailText"
:preview-text="quotedEmailPreviewText"
@toggle="toggleQuotedReply"
/>
</div>
<div
v-if="hasAttachments && !showAudioRecorderEditor"
class="attachment-preview-box"
@paste="onPaste"
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
>
<AttachmentPreview
class="flex-col mt-4"
:attachments="attachedFiles"
@remove-attachment="removeAttachment"
<div :key="copilot.editorTransitionKey.value" class="reply-box__top">
<ReplyToMessage
v-if="shouldShowReplyToMessage"
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
:class="{
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
}"
:on-click="addIntoEditor"
/>
<ReplyEmailHead
v-if="showReplyHead && isDefaultEditorMode"
v-model:cc-emails="ccEmails"
v-model:bcc-emails="bccEmails"
v-model:to-emails="toEmails"
/>
<AudioRecorder
v-if="showAudioRecorderEditor"
ref="audioRecorderInput"
:audio-record-format="audioRecordFormat"
@recorder-progress-changed="onRecordProgressChanged"
@finish-record="onFinishRecorder"
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<CopilotEditorSection
v-if="copilot.isActive.value && !showAudioRecorderEditor"
:show-copilot-editor="copilot.showEditor.value"
:is-generating-content="copilot.isGenerating.value"
:generated-content="copilot.generatedContent.value"
:is-popout="popOutReplyBox"
:placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')"
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"
@close="copilot.showEditor.value = false"
@content-ready="copilot.setContentReady"
@send="copilot.sendFollowUp"
/>
<WootMessageEditor
v-else-if="!showAudioRecorderEditor"
v-model="message"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
enable-variables
:variables="messageVariables"
:signature="messageSignature"
allow-signature
:channel-type="channelType"
:medium="inbox.medium"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
@toggle-user-mention="toggleUserMention"
@toggle-canned-menu="toggleCannedMenu"
@toggle-variables-menu="toggleVariablesMenu"
@clear-selection="clearEditorSelection"
@execute-copilot-action="executeCopilotAction"
/>
<QuotedEmailPreview
v-if="shouldShowQuotedPreview && isDefaultEditorMode"
:quoted-email-text="quotedEmailText"
:preview-text="quotedEmailPreviewText"
class="mb-2"
@toggle="toggleQuotedReply"
/>
<div
v-if="hasAttachments && isDefaultEditorMode"
class="bg-transparent py-0 mb-2"
@paste="onPaste"
>
<AttachmentPreview
class="mt-2"
:attachments="attachedFiles"
@remove-attachment="removeAttachment"
/>
</div>
<MessageSignatureMissingAlert
v-if="
isSignatureEnabledForInbox &&
!isSignatureAvailable &&
isDefaultEditorMode
"
class="mb-2"
/>
</div>
</Transition>
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
>
<CopilotReplyBottomPanel
v-if="copilot.isActive.value"
key="copilot-bottom-panel"
:is-generating-content="copilot.isButtonDisabled.value"
@submit="onSubmitCopilotReply"
@cancel="copilot.toggleEditor"
/>
</div>
<MessageSignatureMissingAlert
v-if="isSignatureEnabledForInbox && !isSignatureAvailable"
/>
<ReplyBottomPanel
:conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload"
:enable-whats-app-templates="showWhatsappTemplates"
:enable-content-templates="showContentTemplates"
:inbox="inbox"
:is-on-private-note="isOnPrivateNote"
:is-recording-audio="isRecordingAudio"
:is-send-disabled="isReplyButtonDisabled"
:is-note="isPrivate"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
:recording-audio-duration-text="recordingAudioDurationText"
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
:show-audio-recorder="showAudioRecorder"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
:quoted-reply-enabled="quotedReplyPreference"
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker"
:message="message"
:portal-slug="connectedPortalSlug"
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
/>
<ReplyBottomPanel
v-else
key="reply-bottom-panel"
:conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload"
:enable-whats-app-templates="showWhatsappTemplates"
:enable-content-templates="showContentTemplates"
:inbox="inbox"
:is-on-private-note="isOnPrivateNote"
:is-recording-audio="isRecordingAudio"
:is-send-disabled="isReplyButtonDisabled"
:is-note="isPrivate"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
:recording-audio-duration-text="recordingAudioDurationText"
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
:show-audio-recorder="showAudioRecorder"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
:quoted-reply-enabled="quotedReplyPreference"
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker"
:message="message"
:portal-slug="connectedPortalSlug"
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
/>
</Transition>
<WhatsappTemplates
:inbox-id="inbox.id"
:show="showWhatsAppTemplatesModal"
@@ -1349,13 +1339,7 @@ export default {
@apply mb-0;
}
.attachment-preview-box {
@apply bg-transparent py-0 px-4;
}
.reply-box {
transition: height 2s cubic-bezier(0.37, 0, 0.63, 1);
@apply relative mb-2 mx-2 border border-n-weak rounded-xl bg-n-solid-1;
&.is-private {
@@ -1369,10 +1353,6 @@ export default {
.reply-box__top {
@apply relative py-0 px-4 -mt-px;
textarea {
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
}
}
.emoji-dialog {
@@ -1392,9 +1372,4 @@ export default {
@apply ltr:left-1 rtl:right-1 -bottom-2;
}
}
.normal-editor__canned-box {
width: calc(100% - 2 * 1rem);
left: 1rem;
}
</style>
@@ -78,14 +78,6 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'additional',
},
{
attributeKey: 'country_code',
attributeI18nKey: 'COUNTRY_NAME',
inputType: 'search_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'additional',
},
{
attributeKey: 'referer',
attributeI18nKey: 'REFERER_LINK',
@@ -171,10 +163,6 @@ export const filterAttributeGroups = [
key: 'browser_language',
i18nKey: 'BROWSER_LANGUAGE',
},
{
key: 'country_code',
i18nKey: 'COUNTRY_NAME',
},
{
key: 'referer',
i18nKey: 'REFERER_LINK',
@@ -4,7 +4,7 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
// composables
import { useAI } from 'dashboard/composables/useAI';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useTrack } from 'dashboard/composables';
// store & api
@@ -33,9 +33,9 @@ export default {
},
},
setup() {
const { isAIIntegrationEnabled } = useAI();
const { captainTasksEnabled } = useCaptain();
return { isAIIntegrationEnabled };
return { captainTasksEnabled };
},
data() {
return {
@@ -78,7 +78,7 @@ export default {
},
shouldShowSuggestions() {
if (this.isDismissed) return false;
if (!this.isAIIntegrationEnabled) return false;
if (!this.captainTasksEnabled) return false;
return this.preparedLabels.length && this.chatLabels.length === 0;
},
@@ -0,0 +1,33 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
</script>
<template>
<Icon v-once icon="i-woot-captain" class="jumping-logo" />
</template>
<style scoped>
.jumping-logo {
transform-origin: center bottom;
animation: jump 1s cubic-bezier(0.28, 0.84, 0.42, 1) infinite;
will-change: transform;
}
@keyframes jump {
0% {
transform: translateY(0) scale(1, 1);
}
20% {
transform: translateY(0) scale(1.05, 0.95);
}
50% {
transform: translateY(-5px) scale(0.95, 1.05);
}
80% {
transform: translateY(0) scale(1.02, 0.98);
}
100% {
transform: translateY(0) scale(1, 1);
}
}
</style>
@@ -1,6 +1,7 @@
<script setup>
import { ref, watch, computed, nextTick } from 'vue';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
const props = defineProps({
items: {
@@ -15,6 +16,8 @@ const props = defineProps({
const emit = defineEmits(['mentionSelect']);
const { getPlainText } = useMessageFormatter();
const mentionsListContainerRef = ref(null);
const selectedIndex = ref(0);
@@ -94,7 +97,7 @@ const variableKey = (item = {}) => {
'text-n-slate-12': index === selectedIndex,
}"
>
{{ item.description }}
{{ getPlainText(item.description) }}
</p>
<p
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-n-slate-11 group-hover:text-n-slate-12 text-ellipsis whitespace-nowrap"
@@ -3,7 +3,7 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useAI } from 'dashboard/composables/useAI';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import {
@@ -18,7 +18,7 @@ vi.mock('dashboard/composables/store');
vi.mock('vue-i18n');
vi.mock('vue-router');
vi.mock('dashboard/composables/useConversationLabels');
vi.mock('dashboard/composables/useAI');
vi.mock('dashboard/composables/useCaptain');
vi.mock('dashboard/composables/useAgentsList');
describe('useConversationHotKeys', () => {
@@ -49,7 +49,7 @@ describe('useConversationHotKeys', () => {
addLabelToConversation: vi.fn(),
removeLabelFromConversation: vi.fn(),
});
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: true } });
useCaptain.mockReturnValue({ captainTasksEnabled: { value: true } });
useAgentsList.mockReturnValue({
agentsList: { value: [] },
assignableAgents: { value: mockAssignableAgents },
@@ -67,7 +67,7 @@ describe('useConversationHotKeys', () => {
expect(conversationHotKeys.value.length).toBeGreaterThan(0);
});
it('should include AI assist actions when AI integration is enabled', () => {
it('should include AI assist actions when captain tasks is enabled', () => {
const { conversationHotKeys } = useConversationHotKeys();
const aiAssistAction = conversationHotKeys.value.find(
action => action.id === 'ai_assist'
@@ -75,8 +75,8 @@ describe('useConversationHotKeys', () => {
expect(aiAssistAction).toBeDefined();
});
it('should not include AI assist actions when AI integration is disabled', () => {
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: false } });
it('should not include AI assist actions when captain tasks is disabled', () => {
useCaptain.mockReturnValue({ captainTasksEnabled: { value: false } });
const { conversationHotKeys } = useConversationHotKeys();
const aiAssistAction = conversationHotKeys.value.find(
action => action.id === 'ai_assist'
@@ -4,7 +4,7 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { emitter } from 'shared/helpers/mitt';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useAI } from 'dashboard/composables/useAI';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
@@ -102,8 +102,8 @@ const createNonDraftMessageAIAssistActions = (t, replyMode) => {
const createDraftMessageAIAssistActions = t => {
return [
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPHRASE'),
key: 'rephrase',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CONFIDENT'),
key: 'confident',
icon: ICON_AI_ASSIST,
},
{
@@ -112,28 +112,23 @@ const createDraftMessageAIAssistActions = t => {
icon: ICON_AI_GRAMMAR,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.EXPAND'),
key: 'expand',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.PROFESSIONAL'),
key: 'professional',
icon: ICON_AI_EXPAND,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SHORTEN'),
key: 'shorten',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CASUAL'),
key: 'casual',
icon: ICON_AI_SHORTEN,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FRIENDLY'),
key: 'make_friendly',
key: 'friendly',
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FORMAL'),
key: 'make_formal',
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SIMPLIFY'),
key: 'simplify',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.STRAIGHTFORWARD'),
key: 'straightforward',
icon: ICON_AI_ASSIST,
},
];
@@ -151,7 +146,7 @@ export function useConversationHotKeys() {
removeLabelFromConversation,
} = useConversationLabels();
const { isAIIntegrationEnabled } = useAI();
const { captainTasksEnabled } = useCaptain();
const { agentsList } = useAgentsList();
const currentChat = useMapGetter('getSelectedChat');
@@ -386,7 +381,7 @@ export function useConversationHotKeys() {
...labelActions.value,
...assignPriorityActions.value,
];
if (isAIIntegrationEnabled.value) {
if (captainTasksEnabled.value) {
return [...defaultConversationHotKeys, ...AIAssistActions.value];
}
return defaultConversationHotKeys;
@@ -1,122 +0,0 @@
import { useAI } from '../useAI';
import {
useStore,
useStoreGetters,
useMapGetter,
} from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import OpenAPI from 'dashboard/api/integrations/openapi';
import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index';
vi.mock('dashboard/composables/store');
vi.mock('vue-i18n');
vi.mock('dashboard/api/integrations/openapi');
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
actual.default = {
track: vi.fn(),
};
return actual;
});
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
OPEN_AI_EVENTS: {
TEST_EVENT: 'open_ai_test_event',
},
}));
describe('useAI', () => {
const mockStore = {
dispatch: vi.fn(),
};
const mockGetters = {
'integrations/getUIFlags': { value: { isFetching: false } },
'draftMessages/get': { value: () => 'Draft message' },
};
beforeEach(() => {
vi.clearAllMocks();
useStore.mockReturnValue(mockStore);
useStoreGetters.mockReturnValue(mockGetters);
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [],
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
useI18n.mockReturnValue({ t: vi.fn() });
});
it('initializes computed properties correctly', async () => {
const { uiFlags, appIntegrations, currentChat, replyMode, draftMessage } =
useAI();
expect(uiFlags.value).toEqual({ isFetching: false });
expect(appIntegrations.value).toEqual([]);
expect(currentChat.value).toEqual({ id: '123' });
expect(replyMode.value).toBe('reply');
expect(draftMessage.value).toBe('Draft message');
});
it('fetches integrations if required', async () => {
const { fetchIntegrationsIfRequired } = useAI();
await fetchIntegrationsIfRequired();
expect(mockStore.dispatch).toHaveBeenCalledWith('integrations/get');
});
it('does not fetch integrations if already loaded', async () => {
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [{ id: 'openai' }],
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
const { fetchIntegrationsIfRequired } = useAI();
await fetchIntegrationsIfRequired();
expect(mockStore.dispatch).not.toHaveBeenCalled();
});
it('records analytics correctly', async () => {
// const mockTrack = analyticsHelper.track;
const { recordAnalytics } = useAI();
await recordAnalytics('TEST_EVENT', { data: 'test' });
expect(analyticsHelper.track).toHaveBeenCalledWith('open_ai_test_event', {
type: 'TEST_EVENT',
data: 'test',
});
});
it('fetches label suggestions', async () => {
OpenAPI.processEvent.mockResolvedValue({
data: { message: 'label1, label2' },
});
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [
{ id: 'openai', hooks: [{ id: 'hook1' }] },
],
getSelectedChat: { id: '123' },
};
return { value: mockValues[getter] };
});
const { fetchLabelSuggestions } = useAI();
const result = await fetchLabelSuggestions();
expect(OpenAPI.processEvent).toHaveBeenCalledWith({
type: 'label_suggestion',
hookId: 'hook1',
conversationId: '123',
});
expect(result).toEqual(['label1', 'label2']);
});
});
@@ -0,0 +1,213 @@
import { useCaptain } from '../useCaptain';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useI18n } from 'vue-i18n';
import TasksAPI from 'dashboard/api/captain/tasks';
import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useAccount');
vi.mock('dashboard/composables/useConfig');
vi.mock('vue-i18n');
vi.mock('dashboard/api/captain/tasks');
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
actual.default = {
track: vi.fn(),
};
return actual;
});
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
OPEN_AI_EVENTS: {
TEST_EVENT: 'open_ai_test_event',
},
}));
describe('useCaptain', () => {
const mockStore = {
dispatch: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
useStore.mockReturnValue(mockStore);
useFunctionGetter.mockReturnValue({ value: 'Draft message' });
useMapGetter.mockImplementation(getter => {
const mockValues = {
'accounts/getUIFlags': { isFetchingLimits: false },
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
useI18n.mockReturnValue({ t: vi.fn() });
useAccount.mockReturnValue({
isCloudFeatureEnabled: vi.fn().mockReturnValue(true),
currentAccount: { value: { limits: { captain: {} } } },
});
useConfig.mockReturnValue({
isEnterprise: false,
});
});
it('initializes computed properties correctly', async () => {
const { captainEnabled, captainTasksEnabled, currentChat, draftMessage } =
useCaptain();
expect(captainEnabled.value).toBe(true);
expect(captainTasksEnabled.value).toBe(true);
expect(currentChat.value).toEqual({ id: '123' });
expect(draftMessage.value).toBe('Draft message');
});
it('records analytics correctly', async () => {
const { recordAnalytics } = useCaptain();
await recordAnalytics('TEST_EVENT', { data: 'test' });
expect(analyticsHelper.track).toHaveBeenCalledWith('open_ai_test_event', {
type: 'TEST_EVENT',
data: 'test',
});
});
it('gets label suggestions', async () => {
TasksAPI.labelSuggestion.mockResolvedValue({
data: { message: 'label1, label2' },
});
const { getLabelSuggestions } = useCaptain();
const result = await getLabelSuggestions();
expect(TasksAPI.labelSuggestion).toHaveBeenCalledWith('123');
expect(result).toEqual(['label1', 'label2']);
});
it('rewrites content', async () => {
TasksAPI.rewrite.mockResolvedValue({
data: { message: 'Rewritten content', follow_up_context: { id: 'ctx1' } },
});
const { rewriteContent } = useCaptain();
const result = await rewriteContent('Original content', 'improve', {});
expect(TasksAPI.rewrite).toHaveBeenCalledWith(
{
content: 'Original content',
operation: 'improve',
conversationId: '123',
},
undefined
);
expect(result).toEqual({
message: 'Rewritten content',
followUpContext: { id: 'ctx1' },
});
});
it('summarizes conversation', async () => {
TasksAPI.summarize.mockResolvedValue({
data: { message: 'Summary', follow_up_context: { id: 'ctx2' } },
});
const { summarizeConversation } = useCaptain();
const result = await summarizeConversation({});
expect(TasksAPI.summarize).toHaveBeenCalledWith('123', undefined);
expect(result).toEqual({
message: 'Summary',
followUpContext: { id: 'ctx2' },
});
});
it('gets reply suggestion', async () => {
TasksAPI.replySuggestion.mockResolvedValue({
data: { message: 'Reply suggestion', follow_up_context: { id: 'ctx3' } },
});
const { getReplySuggestion } = useCaptain();
const result = await getReplySuggestion({});
expect(TasksAPI.replySuggestion).toHaveBeenCalledWith('123', undefined);
expect(result).toEqual({
message: 'Reply suggestion',
followUpContext: { id: 'ctx3' },
});
});
it('sends follow-up message', async () => {
TasksAPI.followUp.mockResolvedValue({
data: {
message: 'Follow-up response',
follow_up_context: { id: 'ctx4' },
},
});
const { followUp } = useCaptain();
const result = await followUp({
followUpContext: { id: 'ctx3' },
message: 'Make it shorter',
});
expect(TasksAPI.followUp).toHaveBeenCalledWith(
{
followUpContext: { id: 'ctx3' },
message: 'Make it shorter',
conversationId: '123',
},
undefined
);
expect(result).toEqual({
message: 'Follow-up response',
followUpContext: { id: 'ctx4' },
});
});
it('processes event and routes to correct method', async () => {
TasksAPI.summarize.mockResolvedValue({
data: { message: 'Summary' },
});
TasksAPI.replySuggestion.mockResolvedValue({
data: { message: 'Reply' },
});
TasksAPI.rewrite.mockResolvedValue({
data: { message: 'Rewritten' },
});
const { processEvent } = useCaptain();
// Test summarize
await processEvent('summarize', '', {});
expect(TasksAPI.summarize).toHaveBeenCalled();
// Test reply_suggestion
await processEvent('reply_suggestion', '', {});
expect(TasksAPI.replySuggestion).toHaveBeenCalled();
// Test rewrite (improve)
await processEvent('improve', 'content', {});
expect(TasksAPI.rewrite).toHaveBeenCalled();
});
it('returns empty array when no conversation ID for label suggestions', async () => {
useMapGetter.mockImplementation(getter => {
const mockValues = {
'accounts/getUIFlags': { isFetchingLimits: false },
getSelectedChat: { id: null },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
const { getLabelSuggestions } = useCaptain();
const result = await getLabelSuggestions();
expect(result).toEqual([]);
expect(TasksAPI.labelSuggestion).not.toHaveBeenCalled();
});
});
@@ -1,39 +1,31 @@
import { ref } from 'vue';
import { useTranslations } from '../useTranslations';
import { selectTranslation } from '../useTranslations';
describe('useTranslations', () => {
it('returns false and null when contentAttributes is null', () => {
const contentAttributes = ref(null);
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
describe('selectTranslation', () => {
it('returns null when translations is null', () => {
expect(selectTranslation(null, 'en', 'en')).toBeNull();
});
it('returns false and null when translations are missing', () => {
const contentAttributes = ref({});
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
it('returns null when translations is empty', () => {
expect(selectTranslation({}, 'en', 'en')).toBeNull();
});
it('returns false and null when translations is an empty object', () => {
const contentAttributes = ref({ translations: {} });
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
it('returns first translation when no locale matches', () => {
const translations = { en: 'Hello', es: 'Hola' };
expect(selectTranslation(translations, 'fr', 'de')).toBe('Hello');
});
it('returns true and correct translation content when translations exist', () => {
const contentAttributes = ref({
translations: { en: 'Hello' },
});
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(true);
// Should return the first translation (en: 'Hello')
expect(translationContent.value).toBe('Hello');
it('returns translation matching agent locale', () => {
const translations = { en: 'Hello', es: 'Hola', zh_CN: '你好' };
expect(selectTranslation(translations, 'es', 'en')).toBe('Hola');
});
it('falls back to account locale when agent locale not found', () => {
const translations = { en: 'Hello', zh_CN: '你好' };
expect(selectTranslation(translations, 'fr', 'zh_CN')).toBe('你好');
});
it('returns first translation when both locales are undefined', () => {
const translations = { en: 'Hello', es: 'Hola' };
expect(selectTranslation(translations, undefined, undefined)).toBe('Hello');
});
});
@@ -1,203 +0,0 @@
import { computed, onMounted } from 'vue';
import {
useStore,
useStoreGetters,
useMapGetter,
} from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import OpenAPI from 'dashboard/api/integrations/openapi';
/**
* Cleans and normalizes a list of labels.
* @param {string} labels - A comma-separated string of labels.
* @returns {string[]} An array of cleaned and unique labels.
*/
const cleanLabels = labels => {
return labels
.toLowerCase() // Set it to lowercase
.split(',') // split the string into an array
.filter(label => label.trim()) // remove any empty strings
.map(label => label.trim()) // trim the words
.filter((label, index, self) => self.indexOf(label) === index);
};
/**
* A composable function for AI-related operations in the dashboard.
* @returns {Object} An object containing AI-related methods and computed properties.
*/
export function useAI() {
const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
/**
* Computed property for UI flags.
* @type {import('vue').ComputedRef<Object>}
*/
const uiFlags = computed(() => getters['integrations/getUIFlags'].value);
const appIntegrations = useMapGetter('integrations/getAppIntegrations');
const currentChat = useMapGetter('getSelectedChat');
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
/**
* Computed property for the AI integration.
* @type {import('vue').ComputedRef<Object|undefined>}
*/
const aiIntegration = computed(
() =>
appIntegrations.value.find(
integration => integration.id === 'openai' && !!integration.hooks.length
)?.hooks[0]
);
/**
* Computed property to check if AI integration is enabled.
* @type {import('vue').ComputedRef<boolean>}
*/
const isAIIntegrationEnabled = computed(() => !!aiIntegration.value);
/**
* Computed property to check if label suggestion feature is enabled.
* @type {import('vue').ComputedRef<boolean>}
*/
const isLabelSuggestionFeatureEnabled = computed(() => {
if (aiIntegration.value) {
const { settings = {} } = aiIntegration.value || {};
return settings.label_suggestion;
}
return false;
});
/**
* Computed property to check if app integrations are being fetched.
* @type {import('vue').ComputedRef<boolean>}
*/
const isFetchingAppIntegrations = computed(() => uiFlags.value.isFetching);
/**
* Computed property for the hook ID.
* @type {import('vue').ComputedRef<string|undefined>}
*/
const hookId = computed(() => aiIntegration.value?.id);
/**
* Computed property for the conversation ID.
* @type {import('vue').ComputedRef<string|undefined>}
*/
const conversationId = computed(() => currentChat.value?.id);
/**
* Computed property for the draft key.
* @type {import('vue').ComputedRef<string>}
*/
const draftKey = computed(
() => `draft-${conversationId.value}-${replyMode.value}`
);
/**
* Computed property for the draft message.
* @type {import('vue').ComputedRef<string>}
*/
const draftMessage = computed(() =>
getters['draftMessages/get'].value(draftKey.value)
);
/**
* Fetches integrations if they haven't been loaded yet.
* @returns {Promise<void>}
*/
const fetchIntegrationsIfRequired = async () => {
if (!appIntegrations.value.length) {
await store.dispatch('integrations/get');
}
};
/**
* Records analytics for AI-related events.
* @param {string} type - The type of event.
* @param {Object} payload - Additional data for the event.
* @returns {Promise<void>}
*/
const recordAnalytics = async (type, payload) => {
const event = OPEN_AI_EVENTS[type.toUpperCase()];
if (event) {
useTrack(event, {
type,
...payload,
});
}
};
/**
* Fetches label suggestions for the current conversation.
* @returns {Promise<string[]>} An array of suggested labels.
*/
const fetchLabelSuggestions = async () => {
if (!conversationId.value) return [];
try {
const result = await OpenAPI.processEvent({
type: 'label_suggestion',
hookId: hookId.value,
conversationId: conversationId.value,
});
const {
data: { message: labels },
} = result;
return cleanLabels(labels);
} catch {
return [];
}
};
/**
* Processes an AI event, such as rephrasing content.
* @param {string} [type='rephrase'] - The type of AI event to process.
* @returns {Promise<string>} The generated message or an empty string if an error occurs.
*/
const processEvent = async (type = 'rephrase') => {
try {
const result = await OpenAPI.processEvent({
hookId: hookId.value,
type,
content: draftMessage.value,
conversationId: conversationId.value,
});
const {
data: { message: generatedMessage },
} = result;
return generatedMessage;
} catch (error) {
const errorData = error.response.data.error;
const errorMessage =
errorData?.error?.message ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
return '';
}
};
onMounted(() => {
fetchIntegrationsIfRequired();
});
return {
draftMessage,
uiFlags,
appIntegrations,
currentChat,
replyMode,
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
isFetchingAppIntegrations,
fetchIntegrationsIfRequired,
recordAnalytics,
fetchLabelSuggestions,
processEvent,
};
}
@@ -1,20 +1,56 @@
import { computed } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useAlert, useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import TasksAPI from 'dashboard/api/captain/tasks';
/**
* Cleans and normalizes a list of labels.
* @param {string} labels - A comma-separated string of labels.
* @returns {string[]} An array of cleaned and unique labels.
*/
const cleanLabels = labels => {
return labels
.toLowerCase()
.split(',')
.filter(label => label.trim())
.map(label => label.trim())
.filter((label, index, self) => self.indexOf(label) === index);
};
export function useCaptain() {
const store = useStore();
const { t } = useI18n();
const { isCloudFeatureEnabled, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const uiFlags = useMapGetter('accounts/getUIFlags');
const currentChat = useMapGetter('getSelectedChat');
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
const conversationId = computed(() => currentChat.value?.id);
const draftKey = computed(
() => `draft-${conversationId.value}-${replyMode.value}`
);
const draftMessage = useFunctionGetter('draftMessages/get', draftKey);
// === Feature Flags ===
const captainEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
});
const captainTasksEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
});
// === Limits (Enterprise) ===
const captainLimits = computed(() => {
return currentAccount.value?.limits?.captain;
});
@@ -23,7 +59,6 @@ export function useCaptain() {
if (captainLimits.value?.documents) {
return useCamelCase(captainLimits.value.documents);
}
return null;
});
@@ -31,7 +66,6 @@ export function useCaptain() {
if (captainLimits.value?.responses) {
return useCamelCase(captainLimits.value.responses);
}
return null;
});
@@ -43,12 +77,198 @@ export function useCaptain() {
}
};
// === Error Handling ===
/**
* Handles API errors and displays appropriate error messages.
* Silently returns for aborted requests.
* @param {Error} error - The error object from the API call.
*/
const handleAPIError = error => {
if (error.name === 'AbortError' || error.name === 'CanceledError') {
return;
}
const errorMessage =
error.response?.data?.error ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
};
// === Analytics ===
/**
* Records analytics for AI-related events.
* @param {string} type - The type of event.
* @param {Object} payload - Additional data for the event.
* @returns {Promise<void>}
*/
const recordAnalytics = async (type, payload) => {
const event = OPEN_AI_EVENTS[type.toUpperCase()];
if (event) {
useTrack(event, {
type,
...payload,
});
}
};
// === Task Methods ===
/**
* Rewrites content with a specific operation.
* @param {string} content - The content to rewrite.
* @param {string} operation - The operation (fix_spelling_grammar, casual, professional, expand, shorten, improve, etc).
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The rewritten content and optional follow-up context.
*/
const rewriteContent = async (content, operation, options = {}) => {
try {
const result = await TasksAPI.rewrite(
{
content: content || draftMessage.value,
operation,
conversationId: conversationId.value,
},
options.signal
);
const {
data: { message: generatedMessage, follow_up_context: followUpContext },
} = result;
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '' };
}
};
/**
* Summarizes a conversation.
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The summary and optional follow-up context.
*/
const summarizeConversation = async (options = {}) => {
try {
const result = await TasksAPI.summarize(
conversationId.value,
options.signal
);
const {
data: { message: generatedMessage, follow_up_context: followUpContext },
} = result;
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '' };
}
};
/**
* Gets a reply suggestion for the current conversation.
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The reply suggestion and optional follow-up context.
*/
const getReplySuggestion = async (options = {}) => {
try {
const result = await TasksAPI.replySuggestion(
conversationId.value,
options.signal
);
const {
data: { message: generatedMessage, follow_up_context: followUpContext },
} = result;
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '' };
}
};
/**
* Gets label suggestions for the current conversation.
* @returns {Promise<string[]>} An array of suggested labels.
*/
const getLabelSuggestions = async () => {
if (!conversationId.value) return [];
try {
const result = await TasksAPI.labelSuggestion(conversationId.value);
const {
data: { message: labels },
} = result;
return cleanLabels(labels);
} catch {
return [];
}
};
/**
* Sends a follow-up message to refine a previous AI task result.
* @param {Object} options - The follow-up options.
* @param {Object} options.followUpContext - The follow-up context from a previous task.
* @param {string} options.message - The follow-up message/request from the user.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext: Object}>} The follow-up response and updated context.
*/
const followUp = async ({ followUpContext, message, signal }) => {
try {
const result = await TasksAPI.followUp(
{ followUpContext, message, conversationId: conversationId.value },
signal
);
const {
data: { message: generatedMessage, follow_up_context: updatedContext },
} = result;
return { message: generatedMessage, followUpContext: updatedContext };
} catch (error) {
handleAPIError(error);
return { message: '', followUpContext };
}
};
/**
* Processes an AI event. Routes to the appropriate method based on type.
* @param {string} [type='improve'] - The type of AI event to process.
* @param {string} [content=''] - The content to process.
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The generated message and optional follow-up context.
*/
const processEvent = async (type = 'improve', content = '', options = {}) => {
if (type === 'summarize') {
return summarizeConversation(options);
}
if (type === 'reply_suggestion') {
return getReplySuggestion(options);
}
// All other types are rewrite operations
return rewriteContent(content, type, options);
};
return {
// Feature flags
captainEnabled,
captainTasksEnabled,
// Limits (Enterprise)
captainLimits,
documentLimits,
responseLimits,
fetchLimits,
isFetchingLimits,
// Conversation context
draftMessage,
currentChat,
// Task methods
rewriteContent,
summarizeConversation,
getReplySuggestion,
getLabelSuggestions,
followUp,
processEvent,
// Analytics
recordAnalytics,
};
}
@@ -0,0 +1,162 @@
import { ref, computed } from 'vue';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useUISettings } from 'dashboard/composables/useUISettings';
/**
* Composable for managing Copilot reply generation state and actions.
* Extracts copilot-related logic from ReplyBox for cleaner code organization.
*
* @returns {Object} Copilot reply state and methods
*/
export function useCopilotReply() {
const { processEvent, followUp } = useCaptain();
const { updateUISettings } = useUISettings();
const showEditor = ref(false);
const isGenerating = ref(false);
const isContentReady = ref(false);
const generatedContent = ref('');
const followUpContext = ref(null);
const abortController = ref(null);
const isActive = computed(() => showEditor.value || isGenerating.value);
const isButtonDisabled = computed(
() => isGenerating.value || !isContentReady.value
);
const editorTransitionKey = computed(() =>
isActive.value ? 'copilot' : 'rich'
);
/**
* Resets all copilot editor state and cancels any ongoing generation.
*/
function reset() {
if (abortController.value) {
abortController.value.abort();
abortController.value = null;
}
showEditor.value = false;
isGenerating.value = false;
isContentReady.value = false;
generatedContent.value = '';
followUpContext.value = null;
}
/**
* Toggles the copilot editor visibility.
*/
function toggleEditor() {
showEditor.value = !showEditor.value;
}
/**
* Marks content as ready (called after transition completes).
*/
function setContentReady() {
isContentReady.value = true;
}
/**
* Executes a copilot action (e.g., improve, fix grammar).
* @param {string} action - The action type
* @param {string} data - The content to process
*/
async function execute(action, data) {
if (action === 'ask_copilot') {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: true,
});
return;
}
// Reset and start new generation
reset();
abortController.value = new AbortController();
isGenerating.value = true;
isContentReady.value = false;
try {
const { message: content, followUpContext: newContext } =
await processEvent(action, data, {
signal: abortController.value.signal,
});
if (!abortController.value?.signal.aborted) {
generatedContent.value = content;
followUpContext.value = newContext;
if (content) showEditor.value = true;
isGenerating.value = false;
}
} catch {
if (!abortController.value?.signal.aborted) {
isGenerating.value = false;
}
}
}
/**
* Sends a follow-up message to refine the current generated content.
* @param {string} message - The follow-up message from the user
*/
async function sendFollowUp(message) {
if (!followUpContext.value || !message.trim()) return;
abortController.value = new AbortController();
isGenerating.value = true;
isContentReady.value = false;
try {
const { message: content, followUpContext: updatedContext } =
await followUp({
followUpContext: followUpContext.value,
message,
signal: abortController.value.signal,
});
if (!abortController.value?.signal.aborted) {
if (content) {
generatedContent.value = content;
followUpContext.value = updatedContext;
showEditor.value = true;
}
isGenerating.value = false;
}
} catch {
if (!abortController.value?.signal.aborted) {
isGenerating.value = false;
}
}
}
/**
* Accepts the generated content and returns it.
* Note: Formatting is automatically stripped by the Editor component's
* createState function based on the channel's schema.
* @returns {string} The content ready for the editor
*/
function accept() {
const content = generatedContent.value;
showEditor.value = false;
return content;
}
return {
showEditor,
isGenerating,
isContentReady,
generatedContent,
followUpContext,
isActive,
isButtonDisabled,
editorTransitionKey,
reset,
toggleEditor,
setContentReady,
execute,
sendFollowUp,
accept,
};
}
@@ -1,4 +1,25 @@
import { computed } from 'vue';
import { useUISettings } from './useUISettings';
import { useAccount } from './useAccount';
/**
* Select translation based on locale priority.
* @param {Object} translations - Translations object with locale keys
* @param {string} agentLocale - Agent's preferred locale
* @param {string} accountLocale - Account's default locale
* @returns {string|null} Selected translation or null
*/
export function selectTranslation(translations, agentLocale, accountLocale) {
if (!translations || Object.keys(translations).length === 0) return null;
if (agentLocale && translations[agentLocale]) {
return translations[agentLocale];
}
if (accountLocale && translations[accountLocale]) {
return translations[accountLocale];
}
return translations[Object.keys(translations)[0]];
}
/**
* Composable to extract translation state/content from contentAttributes.
@@ -6,6 +27,9 @@ import { computed } from 'vue';
* @returns {Object} { hasTranslations, translationContent }
*/
export function useTranslations(contentAttributes) {
const { uiSettings } = useUISettings();
const { currentAccount } = useAccount();
const hasTranslations = computed(() => {
if (!contentAttributes.value) return false;
const { translations = {} } = contentAttributes.value;
@@ -14,8 +38,11 @@ export function useTranslations(contentAttributes) {
const translationContent = computed(() => {
if (!hasTranslations.value) return null;
const translations = contentAttributes.value.translations;
return translations[Object.keys(translations)[0]];
return selectTranslation(
contentAttributes.value.translations,
uiSettings.value?.locale,
currentAccount.value?.locale
);
});
return { hasTranslations, translationContent };
@@ -1,14 +1,25 @@
import { computed } from 'vue';
function isMacOS() {
// Check modern userAgentData API first
if (navigator.userAgentData?.platform) {
return navigator.userAgentData.platform === 'macOS';
}
// Fallback to navigator.platform
return (
navigator.platform.startsWith('Mac') || navigator.platform === 'iPhone'
);
}
export function useKbd(keys) {
const keySymbols = {
$mod: navigator.platform.includes('Mac') ? '⌘' : 'Ctrl',
$mod: isMacOS() ? '⌘' : 'Ctrl',
shift: '⇧',
alt: '⌥',
ctrl: 'Ctrl',
cmd: '⌘',
option: '⌥',
enter: '↩',
enter: '↵',
tab: '⇥',
esc: '⎋',
};
@@ -16,7 +27,11 @@ export function useKbd(keys) {
return computed(() => {
return keys
.map(key => keySymbols[key.toLowerCase()] || key)
.join('')
.join(' ')
.toUpperCase();
});
}
export function getModifierKey() {
return isMacOS() ? '⌘' : 'Ctrl';
}
+30 -5
View File
@@ -7,6 +7,7 @@ export const FORMATTING = {
marks: ['strong', 'em', 'code', 'link'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -21,6 +22,7 @@ export const FORMATTING = {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -35,12 +37,13 @@ export const FORMATTING = {
'Channel::Api': {
marks: ['strong', 'em'],
nodes: [],
menu: ['strong', 'em', 'undo', 'redo'],
menu: ['copilot', 'strong', 'em', 'undo', 'redo'],
},
'Channel::FacebookPage': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -70,6 +73,7 @@ export const FORMATTING = {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -83,17 +87,18 @@ export const FORMATTING = {
'Channel::Line': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['codeBlock'],
menu: ['strong', 'em', 'code', 'strike', 'undo', 'redo'],
menu: ['copilot', 'strong', 'em', 'code', 'strike', 'undo', 'redo'],
},
'Channel::Telegram': {
marks: ['strong', 'em', 'link', 'code'],
nodes: [],
menu: ['strong', 'em', 'link', 'code', 'undo', 'redo'],
menu: ['copilot', 'strong', 'em', 'link', 'code', 'undo', 'redo'],
},
'Channel::Instagram': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -115,6 +120,22 @@ export const FORMATTING = {
menu: [],
},
// Special contexts (not actual channels)
'Context::PrivateNote': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
menu: [
'copilot',
'strong',
'em',
'code',
'link',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Context::Default': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
@@ -237,8 +258,12 @@ export const MARKDOWN_PATTERNS = [
patterns: [{ pattern: /`([^`]+)`/g, replacement: '$1' }],
},
{
type: 'link', // PM: link, eg: [text](url)
patterns: [{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }],
type: 'link', // PM: link
patterns: [
{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }, // [text](url) -> text
{ pattern: /<([a-zA-Z][a-zA-Z0-9+.-]*:[^\s>]+)>/g, replacement: '$1' }, // <https://...>, <mailto:...>, <tel:...>, <ftp://...>, etc
{ pattern: /<([^\s@]+@[^\s@>]+)>/g, replacement: '$1' }, // <user@example.com> -> user@example.com
],
},
];
@@ -5,4 +5,5 @@ export const LOCAL_STORAGE_KEYS = {
COLOR_SCHEME: 'color_scheme',
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
MESSAGE_REPLY_TO: 'messageReplyTo',
RECENT_SEARCHES: 'recentSearches',
};
+2
View File
@@ -40,9 +40,11 @@ export const FEATURE_FLAGS = {
CHANNEL_TIKTOK: 'channel_tiktok',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_V2: 'captain_integration_v2',
CAPTAIN_TASKS: 'captain_tasks',
SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search',
};
export const PREMIUM_FEATURES = [
@@ -88,6 +88,7 @@ export const OPEN_AI_EVENTS = Object.freeze({
SUMMARIZE: 'OpenAI: Used summarize',
REPLY_SUGGESTION: 'OpenAI: Used reply suggestion',
REPHRASE: 'OpenAI: Used rephrase',
IMPROVE: 'OpenAI: Used improve',
FIX_SPELLING_AND_GRAMMAR: 'OpenAI: Used fix spelling and grammar',
SHORTEN: 'OpenAI: Used shorten',
EXPAND: 'OpenAI: Used expand',
@@ -1,4 +1,4 @@
import posthog from 'posthog-js';
import * as amplitude from '@amplitude/analytics-browser';
/**
* AnalyticsHelper class to initialize and track user analytics
@@ -26,12 +26,10 @@ export class AnalyticsHelper {
return;
}
posthog.init(this.analyticsToken, {
api_host: 'https://app.posthog.com',
capture_pageview: false,
persistence: 'localStorage+cookie',
amplitude.init(this.analyticsToken, {
defaultTracking: false,
});
this.analytics = posthog;
this.analytics = amplitude;
}
/**
@@ -45,20 +43,26 @@ export class AnalyticsHelper {
}
this.user = user;
this.analytics.identify(this.user.id.toString(), {
email: this.user.email,
name: this.user.name,
avatar: this.user.avatar_url,
});
this.analytics.setUserId(`user-${this.user.id.toString()}`);
const identifyEvent = new amplitude.Identify();
identifyEvent.set('email', this.user.email);
identifyEvent.set('name', this.user.name);
identifyEvent.set('avatar', this.user.avatar_url);
this.analytics.identify(identifyEvent);
const { accounts, account_id: accountId } = this.user;
const [currentAccount] = accounts.filter(
account => account.id === accountId
);
if (currentAccount) {
this.analytics.group('company', currentAccount.id.toString(), {
name: currentAccount.name,
});
const groupId = `account-${currentAccount.id.toString()}`;
this.analytics.setGroup('company', groupId);
const groupIdentify = new amplitude.Identify();
groupIdentify.set('name', currentAccount.name);
this.analytics.groupIdentify('company', groupId, groupIdentify);
}
}
@@ -72,20 +76,21 @@ export class AnalyticsHelper {
if (!this.analytics) {
return;
}
this.analytics.capture(eventName, properties);
this.analytics.track(eventName, properties);
}
/**
* Track the page views
* @function
* @param {Object} params - Page view properties
* @param {string} pageName - Page name
* @param {Object} [properties={}] - Page view properties
*/
page(params) {
page(pageName, properties = {}) {
if (!this.analytics) {
return;
}
this.analytics.capture('$pageview', params);
this.analytics.track('$pageview', { pageName, ...properties });
}
}
@@ -1,12 +1,15 @@
import helperObject, { AnalyticsHelper } from '../';
vi.mock('posthog-js', () => ({
default: {
init: vi.fn(),
identify: vi.fn(),
capture: vi.fn(),
group: vi.fn(),
},
vi.mock('@amplitude/analytics-browser', () => ({
init: vi.fn(),
setUserId: vi.fn(),
identify: vi.fn(),
setGroup: vi.fn(),
groupIdentify: vi.fn(),
track: vi.fn(),
Identify: vi.fn(() => ({
set: vi.fn(),
})),
}));
describe('helperObject', () => {
@@ -22,12 +25,12 @@ describe('AnalyticsHelper', () => {
});
describe('init', () => {
it('should initialize posthog with the correct token', async () => {
it('should initialize amplitude with the correct token', async () => {
await analyticsHelper.init();
expect(analyticsHelper.analytics).not.toBe(null);
});
it('should not initialize posthog if token is not provided', async () => {
it('should not initialize amplitude if token is not provided', async () => {
analyticsHelper = new AnalyticsHelper();
await analyticsHelper.init();
expect(analyticsHelper.analytics).toBe(null);
@@ -36,10 +39,15 @@ describe('AnalyticsHelper', () => {
describe('identify', () => {
beforeEach(() => {
analyticsHelper.analytics = { identify: vi.fn(), group: vi.fn() };
analyticsHelper.analytics = {
setUserId: vi.fn(),
identify: vi.fn(),
setGroup: vi.fn(),
groupIdentify: vi.fn(),
};
});
it('should call identify on posthog with correct arguments', () => {
it('should call setUserId and identify on amplitude with correct arguments', () => {
analyticsHelper.identify({
id: 123,
email: 'test@example.com',
@@ -49,19 +57,18 @@ describe('AnalyticsHelper', () => {
account_id: 1,
});
expect(analyticsHelper.analytics.identify).toHaveBeenCalledWith('123', {
email: 'test@example.com',
name: 'Test User',
avatar: 'avatar_url',
});
expect(analyticsHelper.analytics.group).toHaveBeenCalledWith(
'company',
'1',
{ name: 'Account 1' }
expect(analyticsHelper.analytics.setUserId).toHaveBeenCalledWith(
'user-123'
);
expect(analyticsHelper.analytics.identify).toHaveBeenCalled();
expect(analyticsHelper.analytics.setGroup).toHaveBeenCalledWith(
'company',
'account-1'
);
expect(analyticsHelper.analytics.groupIdentify).toHaveBeenCalled();
});
it('should call identify on posthog without group', () => {
it('should call identify on amplitude without group', () => {
analyticsHelper.identify({
id: 123,
email: 'test@example.com',
@@ -71,10 +78,10 @@ describe('AnalyticsHelper', () => {
account_id: 5,
});
expect(analyticsHelper.analytics.group).not.toHaveBeenCalled();
expect(analyticsHelper.analytics.setGroup).not.toHaveBeenCalled();
});
it('should not call analytics.page if analytics is null', () => {
it('should not call analytics methods if analytics is null', () => {
analyticsHelper.analytics = null;
analyticsHelper.identify({});
expect(analyticsHelper.analytics).toBe(null);
@@ -83,27 +90,27 @@ describe('AnalyticsHelper', () => {
describe('track', () => {
beforeEach(() => {
analyticsHelper.analytics = { capture: vi.fn() };
analyticsHelper.analytics = { track: vi.fn() };
analyticsHelper.user = { id: 123 };
});
it('should call capture on posthog with correct arguments', () => {
it('should call track on amplitude with correct arguments', () => {
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith(
'Test Event',
{ prop1: 'value1', prop2: 'value2' }
);
});
it('should call capture on posthog with default properties', () => {
it('should call track on amplitude with default properties', () => {
analyticsHelper.track('Test Event');
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith(
'Test Event',
{}
);
});
it('should not call capture on posthog if analytics is not initialized', () => {
it('should not call track on amplitude if analytics is not initialized', () => {
analyticsHelper.analytics = null;
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
expect(analyticsHelper.analytics).toBe(null);
@@ -112,24 +119,25 @@ describe('AnalyticsHelper', () => {
describe('page', () => {
beforeEach(() => {
analyticsHelper.analytics = { capture: vi.fn() };
analyticsHelper.analytics = { track: vi.fn() };
});
it('should call the capture method for pageview with the correct arguments', () => {
const params = {
name: 'Test page',
url: '/test',
it('should call the track method for pageview with the correct arguments', () => {
const pageName = 'home';
const properties = {
path: '/test',
name: 'home',
};
analyticsHelper.page(params);
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
analyticsHelper.page(pageName, properties);
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith(
'$pageview',
params
{ pageName: 'home', path: '/test', name: 'home' }
);
});
it('should not call analytics.capture if analytics is null', () => {
it('should not call analytics.track if analytics is null', () => {
analyticsHelper.analytics = null;
analyticsHelper.page();
analyticsHelper.page('home');
expect(analyticsHelper.analytics).toBe(null);
});
});
+36 -49
View File
@@ -34,45 +34,35 @@ export function extractTextFromMarkdown(markdown) {
/**
* Strip unsupported markdown formatting based on channel capabilities.
* Uses MARKDOWN_PATTERNS from editor constants.
*
* @param {string} markdown - markdown text to process
* @param {string} channelType - The channel type to check supported formatting
* @param {boolean} cleanWhitespace - Whether to clean up extra whitespace and blank lines (default: true for signatures)
* @returns {string} - The markdown with unsupported formatting removed
*/
export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
export function stripUnsupportedMarkdown(
markdown,
channelType,
cleanWhitespace = true
) {
if (!markdown) return '';
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
const has = (arr, key) => arr.includes(key);
const supported = [...marks, ...nodes];
// Define stripping rules: [condition, pattern, replacement]
const rules = [
[!has(nodes, 'image'), /!\[.*?\]\(.*?\)/g, ''],
[!has(marks, 'link'), /\[([^\]]+)\]\([^)]+\)/g, '$1'],
[!has(nodes, 'codeBlock'), /```[\s\S]*?```/g, ''],
[!has(marks, 'code'), /`([^`]+)`/g, '$1'],
[!has(marks, 'strong'), /\*\*([^*]+)\*\*/g, '$1'],
[!has(marks, 'strong'), /__([^_]+)__/g, '$1'],
[!has(marks, 'em'), /\*([^*]+)\*/g, '$1'],
// Match _text_ only at word boundaries (whitespace/string start/end)
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
[
!has(marks, 'em'),
/(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
'$1',
],
[!has(marks, 'strike'), /~~([^~]+)~~/g, '$1'],
[!has(nodes, 'blockquote'), /^>\s?/gm, ''],
[!has(nodes, 'bulletList'), /^[-*+]\s+/gm, ''],
[!has(nodes, 'orderedList'), /^\d+\.\s+/gm, ''],
];
// Apply patterns from MARKDOWN_PATTERNS for unsupported types
const result = MARKDOWN_PATTERNS.reduce((text, { type, patterns }) => {
if (supported.includes(type)) return text;
return patterns.reduce(
(t, { pattern, replacement }) => t.replace(pattern, replacement),
text
);
}, markdown);
const result = rules.reduce(
(text, [shouldStrip, pattern, replacement]) =>
shouldStrip ? text.replace(pattern, replacement) : text,
markdown
);
if (!cleanWhitespace) return result;
// Clean whitespace for signatures
return result
.split('\n')
.map(line => line.trim())
@@ -173,7 +163,7 @@ export function getEffectiveChannelType(channelType, medium) {
export function appendSignature(body, signature, channelType) {
// Strip only unsupported formatting based on channel capabilities
const preparedSignature = channelType
? stripUnsupportedSignatureMarkdown(signature, channelType)
? stripUnsupportedMarkdown(signature, channelType)
: signature;
const cleanedSignature = cleanSignature(preparedSignature);
// if signature is already present, return body
@@ -186,27 +176,22 @@ export function appendSignature(body, signature, channelType) {
/**
* Removes the signature from the body, along with the signature delimiter.
* Tries to find both the original signature and the stripped version.
* Tries multiple signature variants: original, channel-stripped, and fully stripped.
*
* @param {string} body - The body to remove the signature from.
* @param {string} signature - The signature to remove.
* @param {string} channelType - Optional. The effective channel type for channel-specific stripping.
* For Twilio channels, pass the result of getEffectiveChannelType().
* @returns {string} - The body with the signature removed.
*/
export function removeSignature(body, signature, channelType) {
// Build list of signatures to try: original, channel-stripped, and fully stripped
const cleanedSignature = cleanSignature(signature);
// Build unique list of signature variants to try
const channelStripped = channelType
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
? cleanSignature(stripUnsupportedMarkdown(signature, channelType))
: null;
const fullyStripped = cleanSignature(extractTextFromMarkdown(signature));
// Try signatures in order: original → channel-specific → fully stripped
const signaturesToTry = [
cleanedSignature,
cleanSignature(signature),
channelStripped,
fullyStripped,
cleanSignature(extractTextFromMarkdown(signature)),
].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
// Find the first matching signature
@@ -225,17 +210,12 @@ export function removeSignature(body, signature, channelType) {
newBody = newBody.substring(0, signatureIndex).trimEnd();
}
// let's find the delimiter and remove it
const delimiterIndex = newBody.lastIndexOf(SIGNATURE_DELIMITER);
if (
delimiterIndex !== -1 &&
delimiterIndex === newBody.length - SIGNATURE_DELIMITER.length // this will ensure the delimiter is at the end
) {
// Remove delimiter if it's at the end
if (newBody.endsWith(SIGNATURE_DELIMITER)) {
// if the delimiter is at the end, remove it
newBody = newBody.substring(0, delimiterIndex);
newBody = newBody.slice(0, -SIGNATURE_DELIMITER.length);
}
// return the value
return newBody;
}
@@ -539,12 +519,19 @@ export const getContentNode = (
/**
* Get the formatting configuration for a specific channel type.
* Returns the appropriate marks, nodes, and menu items for the editor.
* TODO: We're hiding captain, enable it back when we add selection improvements
*
* @param {string} channelType - The channel type (e.g., 'Channel::FacebookPage', 'Channel::WebWidget')
* @returns {Object} The formatting configuration with marks, nodes, and menu properties
*/
export function getFormattingForEditor(channelType) {
return FORMATTING[channelType] || FORMATTING['Context::Default'];
export function getFormattingForEditor(channelType, showCaptain = false) {
const formatting = FORMATTING[channelType] || FORMATTING['Context::Default'];
return {
...formatting,
menu: showCaptain
? formatting.menu
: formatting.menu.filter(item => item !== 'copilot'),
};
}
/**
@@ -1,3 +1,5 @@
import DOMPurify from 'dompurify';
// Quote detection strategies
const QUOTE_INDICATORS = [
'.gmail_quote_container',
@@ -29,7 +31,7 @@ export class EmailQuoteExtractor {
static extractQuotes(htmlContent) {
// Create a temporary DOM element to parse HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
// Remove elements matching class selectors
QUOTE_INDICATORS.forEach(selector => {
@@ -56,7 +58,7 @@ export class EmailQuoteExtractor {
*/
static hasQuotes(htmlContent) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
// Check for class-based quotes
// eslint-disable-next-line no-restricted-syntax
@@ -20,6 +20,7 @@ const FEATURE_HELP_URLS = {
webhook: 'https://chwt.app/hc/webhooks',
billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
captain_billing: 'https://chwt.app/hc/captain_billing',
};
export function getHelpUrlForFeature(featureName) {
@@ -1,4 +1,5 @@
import { format, parseISO, isValid as isValidDate } from 'date-fns';
import DOMPurify from 'dompurify';
/**
* Extracts plain text from HTML content
@@ -13,7 +14,7 @@ export const extractPlainTextFromHtml = html => {
return html.replace(/<[^>]*>/g, ' ');
}
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
tempDiv.innerHTML = DOMPurify.sanitize(html);
return tempDiv.textContent || tempDiv.innerText || '';
};
@@ -5,7 +5,7 @@ import {
replaceSignature,
cleanSignature,
extractTextFromMarkdown,
stripUnsupportedSignatureMarkdown,
stripUnsupportedMarkdown,
insertAtCursor,
findNodeToInsertImage,
setURLWithQueryAndSize,
@@ -145,25 +145,19 @@ describe('appendSignature', () => {
});
});
describe('stripUnsupportedSignatureMarkdown', () => {
describe('stripUnsupportedMarkdown', () => {
const richSignature =
'**Bold** _italic_ [link](http://example.com) ![](http://localhost:3000/image.png)';
it('keeps all formatting for Email channel (supports image, link, strong, em)', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Email'
);
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Email');
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('[link](http://example.com)');
expect(result).toContain('![](http://localhost:3000/image.png)');
});
it('strips images but keeps bold/italic for Api channel', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Api'
);
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Api');
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('link'); // link text kept
@@ -171,20 +165,14 @@ describe('stripUnsupportedSignatureMarkdown', () => {
expect(result).not.toContain('![]('); // image removed
});
it('strips images but keeps bold/italic/link for Telegram channel', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Telegram'
);
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Telegram');
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('[link](http://example.com)');
expect(result).not.toContain('![](');
});
it('strips all formatting for SMS channel', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Sms'
);
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Sms');
expect(result).toContain('Bold');
expect(result).toContain('italic');
expect(result).toContain('link');
@@ -194,8 +182,52 @@ describe('stripUnsupportedSignatureMarkdown', () => {
expect(result).not.toContain('![](');
});
it('returns empty string for empty input', () => {
expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe('');
expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe('');
expect(stripUnsupportedMarkdown('', 'Channel::Api')).toBe('');
expect(stripUnsupportedMarkdown(null, 'Channel::Api')).toBe('');
});
describe('with cleanWhitespace parameter', () => {
const textWithWhitespace =
'**Bold** text\n\nWith multiple\n\nLine breaks\n\n And spaces ';
it('cleans whitespace when cleanWhitespace=true (default)', () => {
const result = stripUnsupportedMarkdown(
textWithWhitespace,
'Channel::Api',
true
);
expect(result).toBe(
'**Bold** text\nWith multiple\nLine breaks\nAnd spaces'
);
expect(result).not.toContain('\n\n');
expect(result).not.toContain(' ');
});
it('preserves whitespace when cleanWhitespace=false', () => {
const result = stripUnsupportedMarkdown(
textWithWhitespace,
'Channel::Api',
false
);
expect(result).toContain('\n\n');
expect(result).toContain(' And spaces ');
expect(result).toBe(
'**Bold** text\n\nWith multiple\n\nLine breaks\n\n And spaces '
);
});
it('strips formatting but preserves whitespace for messages', () => {
const messageWithFormatting = '**Bold**\n\n`code`\n\nNormal text';
const result = stripUnsupportedMarkdown(
messageWithFormatting,
'Channel::Sms',
false
);
expect(result).toBe('Bold\n\ncode\n\nNormal text');
expect(result).toContain('\n\n');
expect(result).not.toContain('**');
expect(result).not.toContain('`');
});
});
});
@@ -763,62 +795,6 @@ describe('getContentNode', () => {
});
describe('getFormattingForEditor', () => {
describe('channel-specific formatting', () => {
it('returns full formatting for Email channel', () => {
const result = getFormattingForEditor('Channel::Email');
expect(result).toEqual(FORMATTING['Channel::Email']);
});
it('returns full formatting for WebWidget channel', () => {
const result = getFormattingForEditor('Channel::WebWidget');
expect(result).toEqual(FORMATTING['Channel::WebWidget']);
});
it('returns limited formatting for WhatsApp channel', () => {
const result = getFormattingForEditor('Channel::Whatsapp');
expect(result).toEqual(FORMATTING['Channel::Whatsapp']);
});
it('returns no formatting for API channel', () => {
const result = getFormattingForEditor('Channel::Api');
expect(result).toEqual(FORMATTING['Channel::Api']);
});
it('returns limited formatting for FacebookPage channel', () => {
const result = getFormattingForEditor('Channel::FacebookPage');
expect(result).toEqual(FORMATTING['Channel::FacebookPage']);
});
it('returns no formatting for TwitterProfile channel', () => {
const result = getFormattingForEditor('Channel::TwitterProfile');
expect(result).toEqual(FORMATTING['Channel::TwitterProfile']);
});
it('returns no formatting for SMS channel', () => {
const result = getFormattingForEditor('Channel::Sms');
expect(result).toEqual(FORMATTING['Channel::Sms']);
});
it('returns limited formatting for Telegram channel', () => {
const result = getFormattingForEditor('Channel::Telegram');
expect(result).toEqual(FORMATTING['Channel::Telegram']);
});
it('returns formatting for Instagram channel', () => {
const result = getFormattingForEditor('Channel::Instagram');
expect(result).toEqual(FORMATTING['Channel::Instagram']);
});
});
describe('context-specific formatting', () => {
it('returns default formatting for Context::Default', () => {
const result = getFormattingForEditor('Context::Default');
@@ -896,6 +872,22 @@ describe('stripUnsupportedFormatting', () => {
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves autolinks when schema supports links', () => {
const content = 'Check out <https://cegrafic.com/catalogo/>';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves various URI scheme autolinks', () => {
const content =
'Email <mailto:user@example.com> or call <tel:+1234567890>';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves email autolinks', () => {
const content = 'Contact us at <support@chatwoot.com>';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves lists when schema supports them', () => {
const content = '- item 1\n- item 2\n1. first\n2. second';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
@@ -967,6 +959,38 @@ describe('stripUnsupportedFormatting', () => {
).toBe('Check this link');
});
it('converts autolinks to plain URLs when schema does not support links', () => {
const content = 'Visit <https://cegrafic.com/catalogo/> for more info';
const expected = 'Visit https://cegrafic.com/catalogo/ for more info';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('handles multiple autolinks in content', () => {
const content = 'Check <https://example.com> and <https://test.com>';
const expected = 'Check https://example.com and https://test.com';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('converts URI scheme autolinks to plain text', () => {
const content =
'Email <mailto:support@example.com> or call <tel:+1234567890>';
const expected =
'Email mailto:support@example.com or call tel:+1234567890';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('converts email autolinks to plain text', () => {
const content = 'Reach us at <admin@chatwoot.com> for help';
const expected = 'Reach us at admin@chatwoot.com for help';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('handles mixed autolink types', () => {
const content = 'Visit <https://example.com> or email <info@example.com>';
const expected = 'Visit https://example.com or email info@example.com';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('strips bullet list markers', () => {
expect(
stripUnsupportedFormatting('- item 1\n- item 2', emptySchema)
@@ -96,4 +96,58 @@ describe('EmailQuoteExtractor', () => {
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true);
});
describe('HTML sanitization', () => {
it('removes onerror handlers from img tags in extractQuotes', () => {
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onerror');
expect(cleanedHtml).toContain('<p>Hello</p>');
});
it('removes onerror handlers from img tags in hasQuotes', () => {
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
// Should not throw and should safely check for quotes
const result = EmailQuoteExtractor.hasQuotes(maliciousHtml);
expect(result).toBe(false);
});
it('removes script tags in extractQuotes', () => {
const maliciousHtml =
'<p>Content</p><script>alert("xss")</script><p>More</p>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('<script');
expect(cleanedHtml).not.toContain('alert');
expect(cleanedHtml).toContain('<p>Content</p>');
expect(cleanedHtml).toContain('<p>More</p>');
});
it('removes onclick handlers in extractQuotes', () => {
const maliciousHtml = '<p onclick="alert(1)">Click me</p>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onclick');
expect(cleanedHtml).toContain('Click me');
});
it('removes javascript: URLs in extractQuotes', () => {
const maliciousHtml = '<a href="javascript:alert(1)">Link</a>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
// eslint-disable-next-line no-script-url
expect(cleanedHtml).not.toContain('javascript:');
expect(cleanedHtml).toContain('Link');
});
it('removes encoded payloads with event handlers in extractQuotes', () => {
const maliciousHtml =
'<img src="x" id="PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==" onerror="eval(atob(this.id))">';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onerror');
expect(cleanedHtml).not.toContain('eval');
});
});
});
@@ -33,6 +33,26 @@ describe('quotedEmailHelper', () => {
expect(result).toContain('Line 1');
expect(result).toContain('Line 2');
});
it('sanitizes onerror handlers from img tags', () => {
const html = '<p>Hello</p><img src="x" onerror="alert(1)">';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Hello');
});
it('sanitizes script tags', () => {
const html = '<p>Safe</p><script>alert(1)</script><p>Content</p>';
const result = extractPlainTextFromHtml(html);
expect(result).toContain('Safe');
expect(result).toContain('Content');
expect(result).not.toContain('alert');
});
it('sanitizes onclick handlers', () => {
const html = '<p onclick="alert(1)">Click me</p>';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Click me');
});
});
describe('getEmailSenderName', () => {
@@ -6,7 +6,8 @@
"OPTIONS": {
"NAME": "Name",
"DOMAIN": "Domain",
"CREATED_AT": "Created at"
"CREATED_AT": "Created at",
"CONTACTS_COUNT": "Contacts count"
}
},
"ORDER": {
@@ -28,6 +28,7 @@
"TYPES": {
"MEDIA": "Media",
"QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Text"
}
},
@@ -275,6 +275,16 @@
"SIDEBAR": {
"CONTACT": "Contact",
"COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
}
},
"EMAIL_TRANSCRIPT": {
@@ -808,6 +808,35 @@
"LABEL": "Message",
"PLACEHOLDER": "Please enter a message to show users with the form"
},
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Language",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"SURVEY_RULE": {
"LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -819,6 +848,7 @@
"SELECT_PLACEHOLDER": "select labels"
},
"NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@@ -1,7 +1,7 @@
{
"SEARCH": {
"TABS": {
"ALL": "All",
"ALL": "All results",
"CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages",
@@ -19,14 +19,50 @@
"LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'",
"PLACEHOLDER_KEYBINDING": "/ to focus",
"PLACEHOLDER_KEYBINDING": "/to focus",
"INPUT_PLACEHOLDER": "Type 3 or more characters to search",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Clear all",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ",
"BOT_LABEL": "Bot",
"READ_MORE": "Read more",
"READ_LESS": "Read less",
"WROTE": "wrote:",
"FROM": "from",
"EMAIL": "email",
"EMAIL_SUBJECT": "subject"
"FROM": "From",
"EMAIL": "Email",
"EMAIL_SUBJECT": "Subject",
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "created {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "Last 7 days",
"LAST_30_DAYS": "Last 30 days",
"LAST_60_DAYS": "Last 60 days",
"LAST_90_DAYS": "Last 90 days",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "and",
"APPLY": "Apply",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Clear filter"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "Sender",
"IN": "Inbox",
"AGENTS": "Agents",
"CONTACTS": "Contacts",
"INBOXES": "Inboxes",
"NO_AGENTS": "No agents found",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
}
}
@@ -6,7 +6,8 @@
"OPTIONS": {
"NAME": "الاسم",
"DOMAIN": "النطاق",
"CREATED_AT": "تم إنشاؤها في"
"CREATED_AT": "تم إنشاؤها في",
"CONTACTS_COUNT": "Contacts count"
}
},
"ORDER": {
@@ -28,6 +28,7 @@
"TYPES": {
"MEDIA": "Media",
"QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "النص"
}
},
@@ -275,6 +275,16 @@
"SIDEBAR": {
"CONTACT": "جهات الاتصال",
"COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
}
},
"EMAIL_TRANSCRIPT": {

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