Compare commits

...
Author SHA1 Message Date
Sojan Jose 60aac8d731 fix(billing): track cloud payment attribution 2026-04-28 23:11:04 +05:30
Sojan Jose bfdd5d2642 fix(billing): narrow attribution metadata changes 2026-04-28 20:07:30 +05:30
Sojan Jose df0daebc99 feat(billing): attach attribution metadata to cloud subscriptions 2026-04-28 19:51:47 +05:30
6aeda0ddf6 feat: add Playwright setup and login flow test (#13578)
## Description

Adds Playwright E2E testing infrastructure with project configuration
and a login flow test. This is
Phase 1 of the Playwright E2E suite, kept minimal with only the core
setup and login component.

  Ref: Discussion #13500, PR #13067

## Type of change

Please delete options that are not relevant.

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


## How Has This Been Tested?

- Verified all imports resolve correctly
(`login-flow-ui-validation.spec.ts` only imports `Login` from
  `@components/ui`)
  - Ran login flow test locally against a running Chatwoot instance


## Checklist:

  - [x] My code follows the style guidelines of this project
  - [x] I have performed a self-review of my code
  - [x] I have made corresponding changes to the documentation
  - [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-04-28 18:21:05 +05:30
fed32d5964 fix(perf): force better index for pending_reponses longest first sort (#14291)
## Description

Fix a Postgres planner trap on the "Pending Response: Longest first"
sort that causes the conversation list to hang on busy accounts.

The current `sort_on_waiting_since` generates query with `ORDER BY
waiting_since ASC NULLS LAST, created_at ASC`. That order-by is exactly
the shape of the single-column `index_conversations_on_waiting_since`
btree, so the planner picks a forward index walk thinking `LIMIT 25`
will stop early. In practice the per-account matches are spread along
the global waiting_since timeline, so the scan reads tens of millions of
rows from other accounts and discards them via the filter before
producing any results which in turn causes the requests to time out and
the conversation list spinner never resolves.

DESC direction and every other sort (`priority`, `created_at`,
`last_activity_at`) are unaffected. They fall through to
`conv_acid_inbid_stat_asgnid_idx` (account-scoped composite), which is
the right index for this access pattern.

This change leads the ORDER BY with the expression `(waiting_since IS
NULL)`, which no column-only btree can satisfy. The planner falls back
to the same account-scoped index used by every other sort, and sorts in
memory. Same logical NULLS LAST output for both directions; no behavior
change for users.

Fixes CW-6965

## Type of change

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

## How Has This Been Tested?

- [x] Existing specs pass
- [x] Added new specs to cover NULL case
- [x] Verify results and order for old and new query in prod
- [x] Tested in prod since staging data was not sufficient

`EXPLAIN (ANALYZE, BUFFERS)` for the same query (status=0, ASC, LIMIT
25) on a representative production account, before vs after:

| Metric | Before | After |
| --- | --- | --- |
| Execution time | 34,679 ms | 0.71 ms |
| Rows discarded by filter | 36,906,962 | 0 |
| Shared buffer hits | 12,699,924 | 11 |
| Blocks read from disk | 851,226 | 112 |
| I/O read time | 17,785 ms | 0.3 ms |
| Pages dirtied / written | 98 / 31,043 | 0 / 0 |

Verified on two production accounts: identical row IDs in identical
order between the old and new ORDER BY for both ASC and DESC. A
NULL-bucket regression spec was added covering ASC/DESC tail ordering
when some conversations have a null `waiting_since`.

Roughly `49,000×` faster on this query (34,679 ms → 0.71 ms), and
trivially less I/O and buffer pressure on the cluster while it runs.

## 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: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
2026-04-28 18:50:29 +07:00
735bc73c96 fix: Preserve single newlines in outgoing email messages (#14138)
# Pull Request Template

## Description

This PR fixes an issue where outgoing Email messages (via API) do not
preserve single line breaks in rendered HTML.

#### Cause

Messages are stored with `\n`, but rendering differs:

* **Other channel** (`markdown-it`, `breaks: true`) → `\n` → `<br>`
* **Email** (CommonMark) without `HARDBREAKS` → `\n` collapsed into
spaces

Result: multi-line messages appear as a single paragraph in Email.

#### Solution

* Added `hardbreaks:` option to `render_message` (default: false)
* Enabled `hardbreaks: true` in `EmailHelper#render_email_html`

This ensures `\n` renders as `<br />` in Email, matching web widget
behavior.


Fixes
https://linear.app/chatwoot/issue/CW-6941/outgoing-email-messages-strip-single-newlines-from-plain-text-content

## Type of change

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

## How Has This Been Tested?

#### Screenshots

**Before**
<img width="604" height="104" alt="image"
src="https://github.com/user-attachments/assets/f9086ffb-a5c7-4688-99aa-97ea5edcccde"
/>


**After**
<img width="604" height="210" alt="image"
src="https://github.com/user-attachments/assets/a8f21c76-bcb8-4058-937a-dd185fb6745c"
/>



## 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-04-28 12:47:03 +04:00
5e79dd699e fix: prevent country defaulting to Zimbabwe in search result contacts (#14098)
## Context

Same root-cause as #7822 / #14078, but in a second file that PR #14078
doesn't touch:

\`app/javascript/dashboard/modules/search/components/SearchResultContactItem.vue\`.

\`countries.json\` entries only have \`id\` (e.g. \`"AF"\`) — there is
no \`code\` field. So:

\`\`\`js
const countriesMap = countries.reduce((acc, country) => {
acc[country.code] = country; // country.code is undefined →
acc[undefined] = country
  acc[country.id] = country;
  return acc;
}, {});
\`\`\`

…overwrites \`acc[undefined]\` on every iteration, leaving whichever
country comes last in the list (Zimbabwe, \`id: "ZW"\`). Later, the
\`countryDetails\` lookup falls back to that value whenever the
contact's \`country\` / \`countryCode\` is missing or unknown, and
Zimbabwe is displayed incorrectly.

## Fix

One-line delete — drop the dead \`acc[country.code] = country\` write.
Lookups by ISO code continue to work via \`country.id\`.

## Scope

Only the search-results card. The Contacts card is already being fixed
in #14078 with the same one-line delete. It's worth patching this
surface too so the same symptom doesn't reappear when the same contact
is accessed via \`Ctrl+K\` search.

## Test plan

- [ ] Reproduce: search for a contact with \`country: null\` /
\`countryCode: null\` — before this patch the flag renders as Zimbabwe;
after, it renders as no country (current expected fallback).
- [ ] Search for a contact with a valid ISO \`countryCode\` (e.g.
\`"IN"\`) — country still resolves correctly.
- [ ] Contacts list page (\`ContactsCard.vue\`, fixed in #14078) — no
regression.

## Follow-up (not in this PR)

Both components keep rebuilding the same \`countriesMap\` per mount. A
small \`shared/constants/countries.js\` export (\`export const byId =
countries.reduce(...)\`, computed once at module load) would save the
per-mount cost and centralise the shape so this bug can't return.

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

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-04-28 12:08:46 +05:30
05dd31389e fix(whatsapp): Prevent duplicate conversations from concurrent uploads (#14060)
When a WhatsApp contact starts a new conversation by sending multiple
images at once (an album), each image arrives as a separate webhook.
Because no conversation exists yet, the concurrent workers each pass the
"does a conversation exist?" check and each create their own
conversation — producing one conversation per image instead of one
grouped conversation.

This fix serializes webhook processing per `(inbox, contact)` using a
Redis lock at the job level, so only one webhook at a time can create
the initial conversation for a given contact. Concurrent workers retry
with backoff and append to the same conversation once the lock is
released.

## Closes

- Closes #13261

## How to test

1. On a WhatsApp inbox, ensure there is no active (open) conversation
with a specific test contact — resolve or delete any existing one.
2. From a phone, select 6+ images in the WhatsApp gallery and send them
as a single album to the Chatwoot-connected number.
3. Open the Chatwoot dashboard and confirm exactly **one** new
conversation is created, with all images grouped under it.
4. Repeat the test with a mix of attachment types (XMLs, PDFs, images)
sent in rapid succession — still one conversation.

## What changed

- New Redis key `WHATSAPP_MESSAGE_CREATE_LOCK::<inbox_id>::<sender_id>`
in `lib/redis/redis_keys.rb`.
- `Webhooks::WhatsappEventsJob` now inherits from `MutexApplicationJob`
and wraps event processing in `with_lock(key)`, matching the pattern
already used by `FacebookEventsJob`, `InstagramEventsJob`, and
`TiktokEventsJob`.
- Uses `retry_on LockAcquisitionError, wait: 1.second, attempts: 8` so
concurrent webhooks retry until the lock is free instead of poll-waiting
inside the service.
- Sender ID is derived from the webhook payload (contact's `from`, or
`to` for SMB echo events); status-only webhooks bypass the lock.
- Issue 1 from the report (same `source_id` redelivery) was already
handled previously by `Whatsapp::MessageDedupLock` (atomic `SET NX EX`);
no changes needed there.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 10:30:27 +04:00
f7bbd40816 fix(slack): Sync bot interactive responses (#14076)
When a customer responds to a bot's interactive prompt (input_select,
input_csat, form, input_email) from the widget, the response shows up in
the Chatwoot agent UI but is not reflected in the linked Slack channel —
Slack only ever shows the original question. This happens because the
widget submits the answer as an UPDATE to the original message (writing
`content_attributes.submitted_values` or `submitted_email`), but the
Slack hook only listened to `message.created`, so updates were ignored.

Closes https://linear.app/chatwoot/issue/PLA-147

### Preview

<img width="1290" height="1106" alt="CleanShot 2026-04-21 at 13 19
19@2x"
src="https://github.com/user-attachments/assets/cd2a9d3f-89d3-4e81-9230-5b078e1b7b44"
/>

### How to test

  1. Connect a web widget inbox to a Slack channel.
2. Trigger each bot message type (input_select, form, input_csat,
input_email) in a conversation.
  3. Submit responses from the widget.
4. Verify each response now appears in the Slack thread, appended to the
original bot question.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-28 10:29:03 +04:00
Konstantin VasilevandGitHub 7820739f3a fix: prevent Country from being set to Zimbabwe in the contacts list (#14078)
## Description

This PR resolves an issue that was causing Zimbabwe country being
selected in the Contacts list.
The root cause is objects in `countries.json` file are missing the
`code` property which was causing the countries map to always have the
last country in the list also map to an `undefined` key. In turn, this
was causing an error when contact's `country` was undefined.

Additional thing to keep in mind: I am not sure if there is a case when
contact's `country` property is used or if it can be removed as well,
but in my Chatwoot installation contacts only have a `countryCode`
property, and there is no way to set `country` from the widget sdk.

Fixes #7822

## Type of change

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

## How Has This Been Tested?

Simply running the vue application locally targeting my live chatwoot
API.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-04-28 11:36:40 +05:30
224556fd1b feat: onboarding account details with enriched data [UPM-17][UPM-18] (#13979)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-04-28 10:35:51 +05:30
Tanmay Deep SharmaandGitHub 51eb626b88 feat: allow disabling 2FA with a backup code (#14102)
## Linear Ticket
-
https://linear.app/chatwoot/issue/CW-6883/allow-disabling-2fa-using-a-backup-code

## Description

When a user loses access to their authenticator app, they can now
disable 2FA using one of their saved backup codes (in addition to their
password), so they can re-enroll a new authenticator. The disable dialog
includes a toggle to switch between entering a verification code and a
backup code.


## Type of change

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

## How Has This Been Tested?

- Via UI flows
<img width="495" height="423" alt="Screenshot 2026-04-20 at 2 17 21 PM"
src="https://github.com/user-attachments/assets/cc6b3dc5-39e6-4104-b5b9-cdabdc46947e"
/>
<img width="475" height="409" alt="Screenshot 2026-04-20 at 2 17 36 PM"
src="https://github.com/user-attachments/assets/97c7304d-4adb-42ed-b7b4-50f5b38585a3"
/>


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-04-28 10:09:41 +07:00
b0aa844a32 fix(portals): handle integer blob_id in process_attached_logo without 500 (#14274)
Updating portal settings (name, header text, page title, homepage link)
on a portal that already has a logo attached returns 500. The error is
\`NoMethodError: undefined method 'valid_encoding?' for an instance of
Integer\`. The fix is a two-character change in
\`process_attached_logo\`.

Closes #13300

## Root cause

\`ActiveStorage::Blob.find_signed\` expects a signed ID string (e.g.
\`"eyJfcmFpbH..."\`). Internally it calls \`valid_encoding?\` on the
argument to validate the signature payload — a method that exists on
\`String\` but not \`Integer\`.

When a portal already has a logo, the frontend includes the blob's raw
database integer ID (e.g. \`blob_id: 170\`) in the update request
payload. The controller passes this integer directly to \`find_signed\`,
which immediately raises \`NoMethodError\` before any database query is
made.

\`\`\`ruby
# before, crashes when blob_id is an Integer
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_signed(blob_id)  # NoMethodError here
@portal.logo.attach(blob)
\`\`\`

## What changed

\`\`\`ruby
# after, safe for any input type
blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s)
@portal.logo.attach(blob) if blob
\`\`\`

\`.to_s\` on an Integer produces a plain decimal string (\`"170"\`),
which is not a valid signed ID. \`find_signed\` returns \`nil\` for any
invalid signature rather than raising, so the nil guard prevents a
broken \`attach\` call. The existing logo remains attached and the
settings update succeeds.

## Trade-offs considered

| Option | Decision |
|---|---|
| \`find(blob_id)\` when input is an Integer | Bypasses signature
verification — any authenticated user knowing a blob ID could attach
arbitrary files to a portal. Security risk. Rejected. |
| Raise a 422 for non-string blob_id | Overly strict — the frontend
sending an integer is pre-existing behaviour this PR shouldn't break. |
| Silently no-op for invalid blob_id (chosen) | Correct product
behaviour: if no valid signed upload is provided, leave the logo
unchanged. The settings update still succeeds. |

## Known limitation

The correct long-term fix is also on the frontend: it should only send
\`blob_id\` when attaching a **new** upload (using the signed ID from
the direct-upload flow), not when re-submitting the existing logo's raw
database integer ID. This PR makes the server robust against the current
frontend behaviour without requiring a coordinated frontend change.

## How to reproduce

1. Create a Help Center portal and upload a logo
2. Update any text field via \`PUT /api/v1/accounts/:id/portals/:slug\`
while including \`blob_id: <integer>\` in the payload
3. Observe 500 with \`NoMethodError: undefined method 'valid_encoding?'
for an instance of Integer\`

After this fix, the request returns 200, settings are updated, and the
existing logo is preserved.

Co-authored-by: Ramalau Debeila <rdebeila@datacentrix.co.za>
2026-04-28 01:14:51 +05:30
Sony MathewandGitHub c8e551820b fix: [CW-6940] Fix SSRF issue for webhook trigger used by macros and automations (#14155)
This routes external downloads used by webhook fetch used by macros and
acutomations through SafeFetch. It closes the SSRF exposure from raw
Down.download paths, preserves provider-specific auth and header flows,
and adds regression coverage for blocked internal URLs plus
authenticated downloads.

Fixes # (issue):
[CW-6940](https://linear.app/chatwoot/issue/CW-6940/ssrf-via-webhooksautomationmacros-non-upload-non-avatar)
2026-04-27 20:30:59 +05:30
035d2858f5 fix(agent-bots): destroy permissibles on AgentBot deletion and skip orphans in index (#14273)
\`GET /platform/api/v1/agent_bots\` returns 500 when any \`AgentBot\`
that was previously registered with a Platform App has since been
deleted. The bug was introduced by a missing \`dependent: :destroy\` on
the \`AgentBot\` model — deleting a bot left orphaned rows in
\`platform_app_permissibles\`, which the index action later iterated
over and crashed rendering with a \`NoMethodError\` on \`nil\`.

Closes #13407

## Root cause

The index action loads all \`platform_app_permissibles\` for the
platform app and passes each \`resource.permissible\` (the associated
\`AgentBot\`) to a Jbuilder partial. When the \`AgentBot\` no longer
exists, \`resource.permissible\` returns \`nil\` and the partial crashes
calling \`.id\`, \`.name\`, etc. on it.

Every other \`AgentBot\` association (\`agent_bot_inboxes\`,
\`messages\`, \`assigned_conversations\`) had a \`dependent:\` option —
\`platform_app_permissibles\` was the only one missing it. There was
also an N+1 query: the index fired a separate SQL query per permissible
to load each bot.

## What changed

**1. Model — prevent orphans at deletion time**
\`\`\`ruby
has_many :platform_app_permissibles, as: :permissible, dependent:
:destroy
\`\`\`

**2. Controller — eager-load to eliminate N+1**
\`\`\`ruby
@resources = @platform_app.platform_app_permissibles
               .where(permissible_type: 'AgentBot')
               .includes(:permissible)
\`\`\`

**3. Jbuilder — defensive nil guard for pre-existing orphans**
\`\`\`ruby
bot = resource.permissible
next if bot.nil?
json.partial! '...', resource: bot
\`\`\`

## Trade-offs considered

| Option | Decision |
|---|---|
| Rescue \`NoMethodError\` in jbuilder | Hides the failure rather than
fixing it. Rejected. |
| Only add the nil guard, skip the model fix | Leaves the data integrity
gap open — future deletions continue creating orphans. Rejected. |
| Both layers (chosen) | Model fix prevents new orphans; nil guard is
defence-in-depth for any orphans that survived before deployment. |
| \`dependent: :nullify\` | Doesn't apply — a nullified permissible
would still cause the same nil dereference. Rejected. |

## How to reproduce

1. Create an AgentBot via the Platform API
2. Delete the AgentBot via any path (admin UI, API, or direct model
call)
3. Call \`GET /platform/api/v1/agent_bots\` with a Platform App token
4. Observe 500

After this fix, the endpoint returns 200 with an empty array.

Co-authored-by: Ramalau Debeila <rdebeila@datacentrix.co.za>
2026-04-27 19:17:32 +05:30
16b8693e1b fix: standardize contact company field on company_name (#14099)
Standardizes the contact company import/filter/automation contract on
`company_name`.

Closes #14096
Revives #9907

## Why

Contact company is read across the current CRM/contact UI from
`additional_attributes['company_name']`, but CSV import and a few
backend filter/automation paths still used the older `company` key. That
meant imported company values could be saved in a place the dashboard,
sorting, filters, and automation conditions did not consistently read
from.

Based on the production data check, the legacy `company` automation
configuration is effectively dead: the affected account did not have
contacts populated with `additional_attributes['company']`. So this PR
intentionally avoids adding long-term fallback behavior and uses
`company_name` as the single key going forward.

## What changed

- Contact CSV import now writes only `company_name` into
`additional_attributes['company_name']`.
- The example contact import CSV now uses the `company_name` header.
- Contact company sorting/filter config now uses `company_name`.
- Automation condition config now uses `company_name`.
- Existing standard automation conditions with `attribute_key:
'company'` are migrated to `company_name`.
- Existing saved contact filters with standard `attribute_key:
'company'` are migrated to `company_name`.
- Custom attributes named `company` are preserved and are not rewritten
by the migration.

## How to test

- Import a contact CSV with a `company_name` column and confirm the
Contact Company field is populated.
- Sort contacts by Company and confirm imported contacts are ordered
correctly.
- Create/edit an automation with Company as a condition and confirm it
saves with `company_name`.
- Verify existing saved contact filters and automation rules using the
old standard `company` key are migrated to `company_name`.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-04-27 18:43:26 +05:30
Aakash BakhleandGitHub 279dd1876c fix: make captain datetime aware (#14069)
# Pull Request Template

## Description

Captain currently cannot discern today, tomorrow etc. This PR adds
datetime awareness to the system prompt

Fixes:
https://linear.app/chatwoot/issue/AI-148/captain-should-be-aware-of-datetime

## Type of change

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

## How Has This Been Tested?

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

Locally

<img width="696" height="247" alt="CleanShot 2026-04-27 at 14 47 47"
src="https://github.com/user-attachments/assets/6a73a8d9-f48e-46bb-a306-7b9a28a5fa9c"
/>


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-04-27 15:51:49 +05:30
2266eb493b fix: Add validation to the name attribute in user (#10805)
With this change, the form will start displaying a required field. While
validation is already enforced in APIs and other areas, the super_admin
console—being autogenerated—will throw an error since this requirement
isn’t explicitly defined in the model.

<img width="670" alt="Screenshot 2025-01-30 at 2 12 43 PM"
src="https://github.com/user-attachments/assets/e0ab3ace-3649-4ef2-bc94-8d4d80453dd1"
/>

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

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sony Mathew <ynos1234@gmail.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-04-27 15:47:11 +05:30
Sojan JoseandGitHub 0920a01e66 fix(i18n): align pluralization with locale rules (#14266)
Loads Rails locale-specific pluralization rules so languages with an
`other`-only plural model can safely use Crowdin exports without
maintaining duplicate `one` keys.

## Closes

None

## Why

Crowdin exports Rails YAML pluralized strings using each target
language's plural categories. These categories come from Unicode CLDR
and represent grammatical forms, not a literal "number is 1" bucket.

Some languages need separate forms such as `one` and `other`, but
languages like Japanese, Korean, Indonesian, Thai, Vietnamese, and
Chinese use the same form for `1`, `2`, `5`, and larger counts in these
strings. For those locales, CLDR correctly models the plural category as
`other` only.

Before this change, Chatwoot still relied on Rails' default
English-style plural behavior for these locales. That meant a valid
Crowdin export containing only `other` could fail at runtime when Rails
received `count: 1` and looked for a missing `one` branch.

Keeping duplicate `one` keys would only fight Crowdin on every
translation sync. The runtime should instead follow the locale's plural
rules.

## What changed

- Added `rails-i18n` and enabled only its pluralization module.
- Added explicit `other`-only plural rules for Chatwoot's underscore
Chinese locale aliases, `zh_CN` and `zh_TW`.
- Removed redundant `one` keys from the affected Devise and `time_units`
translations.

## Validation

- Ran a Rails runner check across `id`, `ja`, `ko`, `ms`, `th`, `vi`,
`zh_CN`, and `zh_TW` to verify `errors.messages.not_saved` and
`time_units.days` resolve with only `other` for `count: 1`.
- Ran YAML parse validation for all edited locale files.
- Ran `bundle exec rubocop Gemfile config/application.rb
config/initializers/i18n_pluralization.rb`.
2026-04-27 15:40:00 +05:30
Sivin VargheseandGitHub 06467057be fix: oversized email signature images in Letter render (#14144)
# Pull Request Template

## Description

This PR fixes an issue where signature images (with
`?cw_image_height=...`) render at their original large size in the email
bubble.

### Cause

Renderer output:

```html
<img src="..." height="24px" width="auto" />
```

Email UI and clients (Gmail, Outlook) apply CSS like:
`img { max-width: 100%; height: auto; }`
This overrides `height="24px"`.
Other channels work because they use inline styles (`style="height:
24px;"`).


### Solution

Use inline style instead:

```html
<img src="..." style="height: 24px;" />
```


### Why backend fix

* Fixes root cause and aligns Ruby + JS renderers
* Works in both Chatwoot UI and recipient inboxes
* Covers all email-rendered content
* Minimal change


Fixes
https://linear.app/chatwoot/issue/CW-6948/email-signature-image-renders-oversized-in-chatwoot-ui

## Type of change

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

## How Has This Been Tested?

#### Screenshots

**Before**
<img width="1637" height="377" alt="image"
src="https://github.com/user-attachments/assets/0477f6fb-3b95-4fc3-9ea8-f59b71e27f47"
/>


**After**
<img width="1637" height="289" alt="image"
src="https://github.com/user-attachments/assets/de5ea4c1-8452-4c5f-aeb1-e1e11e0fe7d5"
/>



## 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-04-27 13:31:43 +05:30
Sivin VargheseandGitHub 8faa5a74b1 fix: prevent focus jump to title after new article auto-creates (#14145)
# Pull Request Template

## Description

When creating a help center article, typing a title and navigating into
the content auto-creates the article and switches the route
(`/articles/new` → `/articles/.../edit/:slug`). During this transition,
focus was jumping back to the title, interrupting editing.

This happened because `ArticleEditor` always autofocuses the title. On
route change, the component remounts and re-triggers focus. Now, after
auto-create, focus stays in the body as expected.


Fixes
https://linear.app/chatwoot/issue/CW-6951/issue-with-the-cursor-position-on-the-help-center-article-when

## Type of change

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

## How Has This Been Tested?

**Screencast**


https://github.com/user-attachments/assets/dac3f7c6-08c4-4df2-afb0-7731ee76424b



## 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
- [ ] 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-04-27 13:30:51 +05:30
Aakash BakhleandGitHub a651949c33 fix: improve FAQ generation [AI-145] (#14062)
# Pull Request Template

## Description

- Fetch main content only from Firecrawl, exclude some tags to remove
boilerplate
- Prompt changes for FAQ generation

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

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

tested locally

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-04-27 13:01:44 +05:30
2ada713f29 feat: Add bulk actions for help center articles (translate, status change, delete) (#14137)
Fixes
https://linear.app/chatwoot/issue/CW-6950/support-bulk-actions-for-publish-archive-move-to-draft-delete-in

How to test

1. Go to Help Center → Articles
2. Select articles using checkboxes → bulk bar appears
3. Click Publish/Draft/Archive → articles update, list refreshes
4. Click Delete → confirmation dialog → articles removed
5. Click Translate (requires Captain enabled) → select locale + category
→ translation starts
6. Try translating to a locale that already has translations → warning
with links to existing articles → "Overwrite and translate" proceeds
8. Single article: click three-dot menu → Translate → same dialog opens
for that article



https://github.com/user-attachments/assets/7c76495e-f89e-4456-92bd-a6639a9992f4

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:13:43 -07:00
PranavandGitHub 751c28d94d feat(ee): Add article translation via LLM in help center (#14136)
Adds the ability to translate help center articles to other languages using Captain's LLM infrastructure. Translated articles are created as drafts linked to the source article.

Fixes
https://linear.app/chatwoot/issue/CW-6901/translate-article-to-another-language

**How to test**

1. Navigate to Help Center → Articles for a portal with multiple locales
2. Click the three-dot menu on any article → "Translate"
3. Select a target language and category → click Translate
4. Switch to the target locale — the translated article appears as a
draft
5. Try translating the same article again — a warning shows the existing
translation with a link to open it in a new tab
6. Click "Overwrite and translate" to replace the existing translation


https://github.com/user-attachments/assets/1d2e991b-f0ac-403a-bcc1-2181b5731ea4
2026-04-24 08:51:26 -07:00
Sony MathewandGitHub 4959a1ff1e style: [CW-6876] Updated designs for invite email (#14090)
Improved the design for the invite emails
2026-04-24 19:56:01 +05:30
Sony MathewandGitHub 661608c0b1 fix: [CW-6931] Harden external downloads against SSRF [avatar from url job] (#14153)
This routes external downloads used by avatar sync through SafeFetch. It closes the SSRF exposure from raw Down.download paths, preserves provider-specific auth and header flows, and adds regression coverage
for blocked internal URLs plus authenticated downloads.
Fixes # (issue): [CW-6931](https://linear.app/chatwoot/issue/CW-6931/avatarwidget-url-ssrf-downdownload-unprotected-unauth)
2026-04-24 18:59:45 +05:30
Kenta IshizakiandGitHub c5fb8d73cc fix: Prepend UTF-8 BOM to contact CSV export for non-ASCII character support (#14123)
## Description

Spreadsheet applications such as Microsoft Excel do not auto-detect
UTF-8 encoding when opening CSV files. This causes non-ASCII characters
(Arabic, Japanese, Chinese, Korean, etc.) to appear garbled in the
exported contacts CSV.

This PR prepends the UTF-8 Byte Order Mark (`EF BB BF`) to the CSV
output in `Account::ContactsExportJob`, which signals to spreadsheet
applications that the file is UTF-8 encoded.

Fixes: #13998
2026-04-24 18:06:25 +05:30
Kenta IshizakiandGitHub 9a89e1f522 fix: Strip UTF-8 BOM in DataImportJob#csv_reader before parsing CSV (#14126)
## Description

`DataImportJob#csv_reader` reads CSV data with `force_encoding('UTF-8')`
but does not strip the UTF-8 Byte Order Mark (`EF BB BF`). If a CSV file
containing a BOM is imported, the first header key is prefixed with
`\uFEFF`, which causes key mismatches in `DataImport::ContactManager`
when the first column is one of the recognized keys (`:email`,
`:identifier`, `:phone_number`, `:name`).

This was identified during review of #14123 (see #14124 for the tracking
issue).

Fixes #14124

## Type of change

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

## How Has This Been Tested?

- Added a new fixture (`spec/fixtures/data_import/with_bom.csv`)
containing a UTF-8 BOM followed by valid contact data.
- Added a new spec (`will strip UTF-8 BOM and import contacts
correctly`) that imports the BOM fixture and verifies that `name`,
`email`, and `phone_number` are all correctly parsed.
- All existing examples in `spec/jobs/data_import_job_spec.rb` continue
to pass.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-04-24 17:03:03 +05:30
667cd1ba1f fix: widget et translation (#14119)
Added missing et translations for widget

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-04-24 16:41:30 +05:30
Vishnu NarayananandGitHub ecdeb891ff fix(spec): pin SafeFetch error classes to described_class to survive Zeitwerk reload (#14139) 2026-04-23 18:18:05 +04:00
Aakash BakhleandGitHub 2182165201 feat: sync documents job [AI-142] (#14057)
# Pull Request Template

## Description

Document auto-sync job pipeline without wiring to controllers or FE

Fixes # (issue)

## Type of change

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

## How Has This Been Tested?

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

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-04-22 23:36:32 +05:30
Vishnu NarayananandGitHub ce34f93917 fix: index email subject from conversation for outbound messages (#14122)
## Summary

Outbound email messages never populate
`content_attributes.email.subject`. The subject lives only on the parent
conversation's `additional_attributes.mail_subject`. The search index
doc built by `Messages::SearchDataPresenter` pulls from the
message-level field only, so outbound email subjects are unsearchable
for accounts on the advanced_search (Elasticsearch) path.

This change makes the presenter fall back to
`conversation.additional_attributes.mail_subject` when the message-level
subject is blank. Inbound email messages keep their existing behavior
(message-level subject takes precedence).

Closes https://linear.app/chatwoot/issue/CW-6877

## What changes

- `Messages::SearchDataPresenter#content_attributes_data` now falls back
to the conversation's `mail_subject` when the message-level subject is
blank.
- Added specs covering the fallback, precedence, and the neither-set
case.

## What does not change

- No schema changes, no migrations, no backfill of existing OpenSearch
documents.
- Searchkick's `after_commit :reindex_for_search` on `Message` will pick
up the new field for all newly created or updated messages via the
existing indexing path.
- Postgres search path (free accounts, fallback) is untouched. Broader
subject search for those users is a separate follow-up.
2026-04-22 20:36:35 +05:30
Sivin VargheseandGitHub 475db85318 fix: prevent template picker dropdown cut-off in compose form (#14129) 2026-04-22 17:13:26 +05:30
f12118a3c0 fix: void topup invoice when card payment fails (#14104)
When a credit top-up's card charge fails, the finalized invoice was left
in an open state with no hook back into our fulfillment service. If that
invoice was later paid (manually via the hosted invoice page, or by a
future dunning flow), the account would never receive its credits —
silent revenue loss.

This change voids the invoice the moment the charge fails, so a failed
top-up cannot turn into a paid-but-unfulfilled invoice.

## Closes

<!-- add the relevant issue / Linear link -->

## How to test

1. On a Business-plan account with a Stripe customer, attach a test card
that will decline on charge (e.g. `4000 0000 0000 0002`).
2. From the dashboard, open the credit top-up flow and purchase a credit
pack.
3. Observe the API returns an error and the account's captain credits
are unchanged.
4. In the Stripe dashboard, confirm the corresponding invoice is in
`void` status (not `open`).
5. Repeat with a good card (`4242 4242 4242 4242`) and confirm the happy
path still fulfills credits.

## What changed

- `finalize_and_pay` in `Enterprise::Billing::TopupCheckoutService` now
rescues `Stripe::CardError`, voids the open invoice via
`Stripe::Invoice.void_invoice`, and re-raises so the controller surfaces
the original decline error to the client.
- Rescue is intentionally narrow to `Stripe::CardError` (declines,
insufficient funds, SCA `authentication_required`). Transient errors
like `APIConnectionError` / `RateLimitError` are left to propagate — the
charge may have actually succeeded and voiding could be wrong.
- Invoices are created with `auto_advance: false`, so Stripe's Smart
Retries won't collect on an open invoice; voiding is the correct
terminal state.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:21:26 +07:00
Muhsin KelothandGitHub ca66218cb9 Revert: Validate Twilio webhook signatures (X-Twilio-Signature) (#14125)
Reverts chatwoot/chatwoot#13638
2026-04-21 19:16:08 +04:00
6a9c44476e feat(super-admin): Add push diagnostics tool (#14105)
We're getting many customer reports saying "I'm not getting
notifications." We can't always identify the root cause since there are
multiple points of failure. Added a **Push Diagnostics** tool in Super
Admin to help us investigate mobile/web push issues.

Here's how it works:

- Look up a user by email/ID → see all their registered subscriptions
with device info (iOS/Android, brand, model), token freshness, and
last-updated time
- Send a customizable test push and read the raw FCM/web-push/relay
response to see if the customer is receiving push notifications—if not,
it will show proper errors.
- Delete broken subscriptions so the mobile app re-registers on next
launch
<img width="3816" height="1974" alt="CleanShot 2026-04-20 at 12 56
56@2x"
src="https://github.com/user-attachments/assets/08ecab6f-7ec3-44b3-a114-5e6eb8cf0879"
/>

Fixes https://linear.app/chatwoot/issue/CW-6892/push-diagnostics-tool

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:55:12 +04:00
6928f14a76 fix: Validate Twilio webhook signatures (X-Twilio-Signature) (#13638)
Fixes #13619

## Summary

- Add `TwilioSignatureVerifyConcern` that validates the
`X-Twilio-Signature` header using `Twilio::Security::RequestValidator`
(already bundled via `twilio-ruby` gem)
- Include the concern in `Twilio::CallbackController` and
`Twilio::DeliveryStatusController` — both endpoints were previously
accepting requests from any source with no authentication
- Channels using API key authentication (`api_key_sid` present) skip
validation with a warning log, since Twilio signs with the account auth
token which isn't stored for those channels

## How it works

1. `before_action` looks up the `Channel::TwilioSms` from request params
(`MessagingServiceSid` or `AccountSid` + phone number)
2. Validates the HMAC-SHA1 signature using the channel's auth token
3. Returns `403 Forbidden` if signature is invalid, missing, or channel
not found
4. Handles reverse proxy URL reconstruction via `X-Forwarded-Proto`
header

Follows the same pattern used by `Webhooks::ShopifyController` and
`Webhooks::TiktokController`.

## Test plan

- [x] Valid signature → 204 No Content, job enqueued
- [x] Invalid signature → 403 Forbidden, job not enqueued
- [x] Missing signature header → 403 Forbidden
- [x] Channel not found → 403 Forbidden
- [x] API key channel → skips validation, job enqueued (with warning
log)
- [x] MessagingServiceSid lookup → validates and enqueues
- [x] All existing Twilio service/job specs pass (99 examples, 0
failures)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-04-21 13:44:25 +04:00
Sivin VargheseandGitHub d2625d8544 fix: TypeError cannot read properties of null (reading 'name') (#14112) 2026-04-21 14:48:03 +05:30
Tanmay Deep SharmaandGitHub 7d42edd17f fix: sanitize parentheses from email From header to prevent SMTP 553 errors (#14075)
## Description

When an inbox name or business name contains parentheses — e.g. `Giro
Crédito - Soporte (Email` — the resulting From header becomes
unparseable by SMTP servers. The `(` is interpreted as an RFC 5322
comment start, swallowing the actual email address and causing a `553
Invalid email address` rejection.

Closes [CW-6323](https://linear.app/chatwoot/issue/CW-6323)


## Type of change

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

## How to reproduce?

1. Set an inbox's name or business name to include a parenthesis, e.g.
`Support (Email`
2. Send an outgoing email reply from that inbox
3. Observe `Net::SMTPFatalError: 553 ... Invalid email address`

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-04-21 11:30:20 +05:30
Sivin VargheseandGitHub 437dd9d38c fix: prevent Ctrl+Enter adding extra line break on send (Windows) (#14077)
# Pull Request Template

## Description

This PR includes,
On Windows, pressing **Ctrl+Enter** in the reply editor was inserting an
unintended line break before sending. This led to two issues:

* **Unexpected blank lines**
After adding a line break with Shift+Enter and removing it with
Backspace, the editor looked correct. However, sending with Ctrl+Enter
reintroduced a hidden break, resulting in an extra blank line in the
final message.

* **Selected text being replaced**
When text was selected and Ctrl+Enter was pressed, the selection was
replaced with a line break instead of being sent.

Fixes
https://linear.app/chatwoot/issue/CW-6840/newline-bug-in-the-editor


### **Cause**

Two keyboard handlers responded to **Ctrl+Enter** on Windows:

* ProseMirror (`Mod-Enter`) inserted a hard break
* ReplyBox (`$mod+Enter`) triggered send

The existing guard only checked `metaKey` (Cmd), so it never worked on
Windows. As a result, a line break was inserted just before sending.

### **Solution**

Make the modifier check platform-aware so the editor correctly
intercepts the send shortcut:

* Added `detectOS`, `isMac`, and `OS` constants
* Introduced `hasPressedMod` (uses `metaKey` on macOS, `ctrlKey`
elsewhere)

This ensures Ctrl+Enter sends the message without modifying content,
while keeping existing behavior unchanged.


**NB:** macOS behavior with Cmd+Enter remains unchanged



## Type of change

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

## How Has This Been Tested?

**Case 1: line break**

1. Type `hello`
2. Press Shift+Enter, then Backspace
3. Press Ctrl+Enter
   → Message contains an unexpected blank new line

**Case 2: Selection replaced**

1. Type two lines using Shift+Enter
2. Select text on the second line
3. Press Ctrl+Enter
   → Selected text is replaced and not sent

### Screencast

**Before**


https://github.com/user-attachments/assets/d6d285a9-260b-4711-8bbd-d0c8519e8d20

**After**



https://github.com/user-attachments/assets/c0ace1f7-5d22-44a2-8e08-22190ee21e61



## 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-04-20 18:16:41 +05:30
50720f2395 feat: Gate conversation continuity toggle behind inbound_emails feature flag (#13838)
The "conversation continuity via email" toggle was visible to all
accounts regardless of whether they had `inbound_emails` enabled.
Without inbound email infrastructure, replies to those follow-up emails
land in the agent's personal inbox instead of routing back into
Chatwoot. The feature appears to work but silently breaks the reply
path.

The toggle is now gated on the `inbound_emails` feature flag. On
self-hosted without the feature, the toggle is hidden entirely. On
cloud, it remains visible but disabled with upgrade messaging.

On the backend, `inbound_emails` is added to the manually managed
features list in `InternalAttributesService` so that Stripe webhook plan
syncs don't override it when support enables it for an account.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-04-20 17:47:24 +05:30
Sivin VargheseandGitHub 1de90ec136 fix: clean up conversation list rendering (#14107) 2026-04-20 15:48:06 +05:30
Sivin VargheseandGitHub 3a4738729d feat(v5): New expanded layout for chatlist (#13652) 2026-04-20 14:02:10 +05:30
Sivin VargheseandGitHub 787fcc4a95 chore: update conversation sidebar interactions (#13988) 2026-04-20 13:08:19 +05:30
6cbddbdb67 feat(rollup): report builder abstraction [2/3] (#13798)
## PR2: Report builder refactor — DataSource abstraction

The existing report builders (timeseries + summary) had their SQL
queries inlined — each builder constructed its own scopes, groupings,
and aggregations directly. This made it hard to swap the underlying data
source without duplicating builder logic.

This PR extracts all raw-event querying into a `Reports::RawDataSource`
behind a `Reports::DataSource` factory. Builders now call
`data_source.timeseries`, `.aggregate`, or `.summary` instead of
constructing queries themselves. Behavior is identical —
`DataSource.for(...)` returns `RawDataSource` in all cases today.

The timeseries path had two separate builders (`CountReportBuilder`,
`AverageReportBuilder`) that were selected via a metric-name case
statement in `Conversations::BaseReportBuilder`. These are replaced by a
single `ReportBuilder` that delegates to the data source. The metric
type (count vs average) is now decided inside the data source, not the
builder.

Summary builders similarly moved their inline SQL into
`RawDataSource#summary`, which returns a unified hash keyed by dimension
ID.
 the rollup read path.

## Flow

### Before

```
ReportsController ──▶ case metric ──▶ AverageReportBuilder ──▶ inline SQL ──▶ DB
                                  └──▶ CountReportBuilder   ──▶ inline SQL ──▶ DB

SummaryController ──▶ AgentSummaryBuilder ──▶ inline SQL ──▶ DB
                  └──▶ InboxSummaryBuilder ──▶ inline SQL ──▶ DB
                  └──▶ TeamSummaryBuilder  ──▶ inline SQL ──▶ DB
```

### After

```
ReportsController ──▶ ReportBuilder  ──┐
                                       ├──▶ DataSource.for ──▶ RawDataSource ──▶ DB
SummaryController ──▶ SummaryBuilder ──┘
```


### Expected (after rollup read path)

```
ReportsController ──▶ ReportBuilder  ──┐
                                       ├──▶ DataSource.for ──▶ RawDataSource    ──▶ reporting_events
SummaryController ──▶ SummaryBuilder ──┘                   └──▶ RollupDataSource ──▶ reporting_events_rollups
```

### What changed

- `Reports::DataSource` factory + `Reports::RawDataSource`
- `TimezoneHelper#timezone_name_from_params` — prefers IANA name, falls
back to offset
- Unified `Timeseries::ReportBuilder` replaces `CountReportBuilder` +
`AverageReportBuilder`
- Summary builders delegate to `DataSource` instead of querying directly

### How to test

This is a pure refactor — all existing report pages (Overview, Agent,
Inbox, Label, Team) should produce identical numbers. No feature flag or
new config needed.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Tanmay Deep Sharma <tanmaydeepsharma21@gmail.com>
Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
2026-04-20 11:15:48 +05:30
Sojan Jose 5e82f24be5 Merge branch 'release/4.13.0' into develop 2026-04-16 19:03:04 +05:30
Sojan Jose e123a4e500 Bump version to 4.13.0 2026-04-16 19:02:23 +05:30
Sojan JoseandGitHub 135be52431 feat: Introduce last responding agent option to automation assign agent (#12326)
Introduce a `Last Responding Agent` options to assign_agents action in
automations to cover the following use cases.

- Assign conversations to first responding agent : ( automation message
created at , if assignee is nil, assign last responding agent )
- Ensure conversations are not resolved with out an assignee : (
automation conversation resolved at : if assignee is nil, assign last
responding agent )

and potential other cases.

fixes: #1592
2026-04-16 18:54:35 +05:30
03c10ba147 chore: Update translations (#14080)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-04-16 18:12:33 +05:30
aa2e8f99e4 fix(i18n): correct zh/zh_CN conversation assignment message translations (#14033)
## Summary

The `assignee_name` and `user_name` variables are swapped in the Chinese
(zh/zh_CN) locale files for conversation assignment activity messages,
causing the rendered text to show the wrong person as the assignee.

### Before (incorrect)

| Template | English (correct) | Chinese (incorrect) |
|---|---|---|
| `assignee.assigned` | Assigned to **AgentA** by **Admin** | 由
**AgentA** 分配给 **Admin** |
| `team.assigned` | Assigned to **TeamX** by **Admin** | 由 **TeamX** 分配给
**Admin** |
| `team.assigned_with_assignee` | Assigned to **AgentA** via **TeamX**
by **Admin** | 由 **AgentA** 分配给 **TeamX** 团队的 **Admin** |

The Chinese text reads as if the conversation was assigned **to Admin**
(the API caller), when it was actually assigned **to AgentA**.

### After (correct)

| Template | Chinese (fixed) |
|---|---|
| `assignee.assigned` | 由 **Admin** 分配给 **AgentA** |
| `team.assigned` | 由 **Admin** 分配给 **TeamX** |
| `team.assigned_with_assignee` | 由 **Admin** 通过 **TeamX** 团队分配给
**AgentA** |

Now correctly matches the English template semantics.

## Files Changed

- `config/locales/zh_CN.yml` — 3 lines
- `config/locales/zh.yml` — 3 lines

## How to Verify

1. Set locale to `zh_CN`
2. Have an admin assign a conversation to an agent
3. Check the activity message in the conversation — the assignee name
should appear after "分配给", not before it

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-04-16 16:34:20 +05:30
Sojan JoseandGitHub aee979ee0b fix: add explicit remove assignment actions to macros and automations (#12172)
This updates macros and automations so agents can explicitly remove
assigned agents or teams, while keeping the existing `Assign -> None`
flow working for backward compatibility.

Fixes: #7551
Closes: #7551

## Why
The original macro change exposed unassignment only through `Assign ->
None`, which made macros behave differently from automations and left
the explicit remove actions inconsistent across the product. This keeps
the lower-risk compatibility path and adds the explicit remove actions
requested in review.

## What this change does
- Adds `Remove Assigned Agent` and `Remove Assigned Team` as explicit
actions in macros.
- Adds the same explicit remove actions in automations.
- Keeps `Assign Agent -> None` and `Assign Team -> None` working for
existing behavior and stored payloads.
- Preserves backward compatibility for existing macro and automation
execution payloads.
- Downmerges the latest `develop` and resolves the conflicts while
keeping both the new remove actions and current `develop` behavior.

## Validation
- Verified both remove actions are available and selectable in the macro
editor.
- Verified both remove actions are available and selectable in the
automation builder.
- Applied a disposable macro with `Remove Assigned Agent` and `Remove
Assigned Team` on a real conversation and confirmed both fields were
cleared.
- Applied a disposable macro with `Assign Agent -> None` and `Assign
Team -> None` on a real conversation and confirmed both fields were
still cleared.
2026-04-16 15:57:41 +05:30
Vishnu NarayananandGitHub 72b8a31f2d fix: handle users being stuck on is_creating billing flow (#12750)
Fixes https://linear.app/chatwoot/issue/CW-5880/handle-customers-being-stuck-on-biling-page
2026-04-16 13:22:31 +05:30
Sivin VargheseandGitHub 48533e2a5d fix: strip markdown hard-break backslashes from webhook payloads (#13950) 2026-04-16 13:19:35 +05:30
b5264a2560 feat: Adds the ability to resize the editor (#13916)
# Pull Request Template

## Description

This PR adds support for resizing the reply editor up to nearly half the
screen height. It also deprecates the old modal-based pop-out reply box,
clicking the same button now expands the editor inline. Users can adjust
the height using the slider or the expand button.


## Type of change

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

## How Has This Been Tested?

### Loom video
https://www.loom.com/share/be27e1c06d19475ab404289710b3b0da


## Checklist:

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

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2026-04-16 12:37:56 +05:30
98cf1ce9f6 fix(bulk-select): limit select-all to visible items; add secondary slot (#12891)
Update BulkSelectBar to compute selection state (indeterminate/all) from
visible item IDs and only toggle selection for visible items. Preserve
existing selection for off-screen items when toggling, and guard against
empty visibility. Add detection/rendering for an optional
secondary-actions slot and adjust layout/divider. Also fix
ContactsBulkActionBar selection logic to determine "all selected" by
verifying every visible ID is in the selection. These changes ensure
correct select-all behavior with filtered/visible lists and support
additional UI actions.



https://github.com/user-attachments/assets/d06b78d1-a64a-4c0c-a82a-f870140236c7


# Pull Request Template

## Description

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

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

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


## Checklist:

- [ ] 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: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-04-16 12:22:53 +05:30
Sivin VargheseandGitHub 5eee331da3 feat: add slash command menu to article editor (#14035) 2026-04-16 11:27:59 +05:30
Sivin VargheseandGitHub edd0fc98db feat: Table support in article editor (#13974) 2026-04-16 11:23:10 +05:30
Gabriel JablonskiandGitHub cc008951db fix(sidebar): improve active child route matching logic (#13121) 2026-04-16 10:57:16 +05:30
97dae52841 fix: use committed model registry for RubyLLM (#14067)
RubyLLM bundles a static models.json that doesn't know about models
released after the gem was published. Self-hosted users configuring
newer models hit ModelNotFoundError.

Added a rake task that refreshes the registry from models.dev and saves
to disk. ~~Called during Docker image build so every deploy gets fresh
model data. Falls back silently to the bundled registry if models.dev is
unreachable.~~

Commit the models.json file to code so it is available across
deployments.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-04-16 10:28:38 +05:30
Aakash BakhleandGitHub 5264de24b0 feat: migrations for document auto-sync [AI-141] (#14041)
# Pull Request Template

## Description

Add migrations for document auto-sync

Fixes # (issue)

## Type of change

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

## How Has This Been Tested?
locally

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-04-15 17:56:10 +05:30
b96bf41234 chore: Enable Participating tab for conversations (#11714)
## Summary

This PR enables the **Participating** conversation view in the main
sidebar and keeps the behavior aligned with existing conversation views.

## What changed

- Added **Participating** under Conversations in the new sidebar.
- Added a guard in conversation realtime `addConversation` flow so
generic `conversation.created` events are not injected while the user is
on Participating view.
- Added participating route mapping in conversation-list redirect helper
so list redirects resolve correctly to `/participating/conversations`.

## Scope notes

- Kept changes minimal and consistent with current `develop` behavior.
- No additional update-event filtering was added beyond what existing
views already do.

---------


Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-04-15 17:03:39 +05:30
3f9f054c43 fix: drop WhatsApp incoming messages from blocked contacts (#14061)
## Linear ticket

https://linear.app/chatwoot/issue/CW-6839/blocked-contact-can-still-send-messages-to-whatsapp-inbox

## Description

Drop WhatsApp incoming messages  from blocked contacts

## Type of change

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

## How Has This Been Tested?

- Incoming messages for blocked contacts 

## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-04-15 13:42:48 +07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Sivin Varghese
8e5d4f4d23 chore(deps): bump axios from 1.13.6 to 1.15.0 (#14051)
Bumps [axios](https://github.com/axios/axios) from 1.13.6 to 1.15.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/axios/axios/releases">axios's
releases</a>.</em></p>
<blockquote>
<h2>v1.15.0</h2>
<p>This release delivers two critical security patches, adds runtime
support for Deno and Bun, and includes significant CI hardening,
documentation improvements, and routine dependency updates.</p>
<h2>⚠️ Important Changes</h2>
<ul>
<li><strong>Deprecation:</strong> <code>url.parse()</code> usage has
been replaced to address Node.js deprecation warnings. If you are on a
recent version of Node.js, this resolves console warnings you may have
been seeing. (<strong><a
href="https://redirect.github.com/axios/axios/issues/10625">#10625</a></strong>)</li>
</ul>
<h2>🔒 Security Fixes</h2>
<ul>
<li><strong>Proxy Handling:</strong> Fixed a <code>no_proxy</code>
hostname normalisation bypass that could lead to Server-Side Request
Forgery (SSRF). (<strong><a
href="https://redirect.github.com/axios/axios/issues/10661">#10661</a></strong>)</li>
<li><strong>Header Injection:</strong> Fixed an unrestricted cloud
metadata exfiltration vulnerability via a header injection chain.
(<strong><a
href="https://redirect.github.com/axios/axios/issues/10660">#10660</a></strong>)</li>
</ul>
<h2>🚀 New Features</h2>
<ul>
<li><strong>Runtime Support:</strong> Added compatibility checks and
documentation for Deno and Bun environments. (<strong><a
href="https://redirect.github.com/axios/axios/issues/10652">#10652</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10653">#10653</a></strong>)</li>
</ul>
<h2>🔧 Maintenance &amp; Chores</h2>
<ul>
<li><strong>CI Security:</strong> Hardened workflow permissions to least
privilege, added the <code>zizmor</code> security scanner, pinned action
versions, and gated npm publishing with OIDC and environment protection.
(<strong><a
href="https://redirect.github.com/axios/axios/issues/10618">#10618</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10619">#10619</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10627">#10627</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10637">#10637</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10666">#10666</a></strong>)</li>
<li><strong>Dependencies:</strong> Bumped
<code>serialize-javascript</code>, <code>handlebars</code>,
<code>picomatch</code>, <code>vite</code>, and
<code>denoland/setup-deno</code> to latest versions. Added a 7-day
Dependabot cooldown period. (<strong><a
href="https://redirect.github.com/axios/axios/issues/10574">#10574</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10572">#10572</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10568">#10568</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10663">#10663</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10664">#10664</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10665">#10665</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10669">#10669</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10670">#10670</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10616">#10616</a></strong>)</li>
<li><strong>Documentation:</strong> Unified docs, improved
<code>beforeRedirect</code> credential leakage example, clarified
<code>withCredentials</code>/<code>withXSRFToken</code> behaviour,
HTTP/2 support notes, async/await timeout error handling, header case
preservation, and various typo fixes. (<strong><a
href="https://redirect.github.com/axios/axios/issues/10649">#10649</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10624">#10624</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/7452">#7452</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/7471">#7471</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10654">#10654</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10644">#10644</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10589">#10589</a></strong>)</li>
<li><strong>Housekeeping:</strong> Removed stale files, regenerated
lockfile, and updated sponsor scripts and blocks. (<strong><a
href="https://redirect.github.com/axios/axios/issues/10584">#10584</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10650">#10650</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10582">#10582</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10640">#10640</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10659">#10659</a></strong>,
<strong><a
href="https://redirect.github.com/axios/axios/issues/10668">#10668</a></strong>)</li>
<li><strong>Tests:</strong> Added regression coverage for urlencoded
<code>Content-Type</code> casing. (<strong><a
href="https://redirect.github.com/axios/axios/issues/10573">#10573</a></strong>)</li>
</ul>
<h2>🌟 New Contributors</h2>
<p>We are thrilled to welcome our new contributors. Thank you for
helping improve Axios:</p>
<ul>
<li><strong><a
href="https://github.com/raashish1601"><code>@​raashish1601</code></a></strong>
(<strong><a
href="https://redirect.github.com/axios/axios/issues/10573">#10573</a></strong>)</li>
<li><strong><a
href="https://github.com/Kilros0817"><code>@​Kilros0817</code></a></strong>
(<strong><a
href="https://redirect.github.com/axios/axios/issues/10625">#10625</a></strong>)</li>
<li><strong><a
href="https://github.com/ashstrc"><code>@​ashstrc</code></a></strong>
(<strong><a
href="https://redirect.github.com/axios/axios/issues/10624">#10624</a></strong>)</li>
<li><strong><a
href="https://github.com/Abhi3975"><code>@​Abhi3975</code></a></strong>
(<strong><a
href="https://redirect.github.com/axios/axios/issues/10589">#10589</a></strong>)</li>
<li><strong><a
href="https://github.com/theamodhshetty"><code>@​theamodhshetty</code></a></strong>
(<strong><a
href="https://redirect.github.com/axios/axios/issues/7452">#7452</a></strong>)</li>
</ul>
<h2>v1.14.0</h2>
<p>This release focuses on compatibility fixes, adapter stability
improvements, and test/tooling modernisation.</p>
<h2>⚠️ Important Changes</h2>
<ul>
<li><strong>Breaking Changes:</strong> None identified in this
release.</li>
<li><strong>Action Required:</strong> If you rely on env-based proxy
behaviour or CJS resolution edge-cases, validate your integration after
upgrade (notably <code>proxy-from-env</code> v2 alignment and
<code>main</code> entry compatibility fix).</li>
</ul>
<h2>🚀 New Features</h2>
<ul>
<li><strong>Runtime Features:</strong> No new end-user features were
introduced in this release.</li>
<li><strong>Test Coverage Expansion:</strong> Added broader smoke/module
test coverage for CJS and ESM package usage. (<a
href="https://redirect.github.com/axios/axios/pull/7510">#7510</a>)</li>
</ul>
<h2>🐛 Bug Fixes</h2>
<ul>
<li><strong>Headers:</strong> Trim trailing CRLF in normalised header
values. (<a
href="https://redirect.github.com/axios/axios/pull/7456">#7456</a>)</li>
<li><strong>HTTP/2:</strong> Close detached HTTP/2 sessions on timeout
to avoid lingering sessions. (<a
href="https://redirect.github.com/axios/axios/pull/7457">#7457</a>)</li>
<li><strong>Fetch Adapter:</strong> Cancel <code>ReadableStream</code>
created during request-stream capability probing to prevent async
resource leaks. (<a
href="https://redirect.github.com/axios/axios/pull/7515">#7515</a>)</li>
<li><strong>Proxy Handling:</strong> Fixed env proxy behavior with
<code>proxy-from-env</code> v2 usage. (<a
href="https://redirect.github.com/axios/axios/pull/7499">#7499</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/axios/axios/blob/v1.x/CHANGELOG.md">axios's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<h2><a
href="https://github.com/axios/axios/compare/v1.13.2...v1.13.3">1.13.3</a>
(2026-01-20)</h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>http2:</strong> Use port 443 for HTTPS connections by
default. (<a
href="https://redirect.github.com/axios/axios/issues/7256">#7256</a>)
(<a
href="https://github.com/axios/axios/commit/d7e60653460480ffacecf85383012ca1baa6263e">d7e6065</a>)</li>
<li><strong>interceptor:</strong> handle the error in the same
interceptor (<a
href="https://redirect.github.com/axios/axios/issues/6269">#6269</a>)
(<a
href="https://github.com/axios/axios/commit/5945e40bb171d4ac4fc195df276cf952244f0f89">5945e40</a>)</li>
<li>main field in package.json should correspond to cjs artifacts (<a
href="https://redirect.github.com/axios/axios/issues/5756">#5756</a>)
(<a
href="https://github.com/axios/axios/commit/7373fbff24cd92ce650d99ff6f7fe08c2e2a0a04">7373fbf</a>)</li>
<li><strong>package.json:</strong> add 'bun' package.json 'exports'
condition. Load the Node.js build in Bun instead of the browser build
(<a
href="https://redirect.github.com/axios/axios/issues/5754">#5754</a>)
(<a
href="https://github.com/axios/axios/commit/b89217e3e91de17a3d55e2b8f39ceb0e9d8aeda8">b89217e</a>)</li>
<li>silentJSONParsing=false should throw on invalid JSON (<a
href="https://redirect.github.com/axios/axios/issues/7253">#7253</a>)
(<a
href="https://redirect.github.com/axios/axios/issues/7257">#7257</a>)
(<a
href="https://github.com/axios/axios/commit/7d19335e43d6754a1a9a66e424f7f7da259895bf">7d19335</a>)</li>
<li>turn AxiosError into a native error (<a
href="https://redirect.github.com/axios/axios/issues/5394">#5394</a>)
(<a
href="https://redirect.github.com/axios/axios/issues/5558">#5558</a>)
(<a
href="https://github.com/axios/axios/commit/1c6a86dd2c0623ee1af043a8491dbc96d40e883b">1c6a86d</a>)</li>
<li><strong>types:</strong> add handlers to AxiosInterceptorManager
interface (<a
href="https://redirect.github.com/axios/axios/issues/5551">#5551</a>)
(<a
href="https://github.com/axios/axios/commit/8d1271b49fc226ed7defd07cd577bd69a55bb13a">8d1271b</a>)</li>
<li><strong>types:</strong> restore AxiosError.cause type from unknown
to Error (<a
href="https://redirect.github.com/axios/axios/issues/7327">#7327</a>)
(<a
href="https://github.com/axios/axios/commit/d8233d9e8e9a64bfba9bbe01d475ba417510b82b">d8233d9</a>)</li>
<li>unclear error message is thrown when specifying an empty proxy
authorization (<a
href="https://redirect.github.com/axios/axios/issues/6314">#6314</a>)
(<a
href="https://github.com/axios/axios/commit/6ef867e684adf7fb2343e3b29a79078a3c76dc29">6ef867e</a>)</li>
</ul>
<h3>Features</h3>
<ul>
<li>add <code>undefined</code> as a value in AxiosRequestConfig (<a
href="https://redirect.github.com/axios/axios/issues/5560">#5560</a>)
(<a
href="https://github.com/axios/axios/commit/095033c626895ecdcda2288050b63dcf948db3bd">095033c</a>)</li>
<li>add automatic minor and patch upgrades to dependabot (<a
href="https://redirect.github.com/axios/axios/issues/6053">#6053</a>)
(<a
href="https://github.com/axios/axios/commit/65a7584eda6164980ddb8cf5372f0afa2a04c1ed">65a7584</a>)</li>
<li>add Node.js coverage script using c8 (closes <a
href="https://redirect.github.com/axios/axios/issues/7289">#7289</a>)
(<a
href="https://redirect.github.com/axios/axios/issues/7294">#7294</a>)
(<a
href="https://github.com/axios/axios/commit/ec9d94e9f88da13e9219acadf65061fb38ce080a">ec9d94e</a>)</li>
<li>added copilot instructions (<a
href="https://github.com/axios/axios/commit/3f83143bfe617eec17f9d7dcf8bafafeeae74c26">3f83143</a>)</li>
<li>compatibility with frozen prototypes (<a
href="https://redirect.github.com/axios/axios/issues/6265">#6265</a>)
(<a
href="https://github.com/axios/axios/commit/860e03396a536e9b926dacb6570732489c9d7012">860e033</a>)</li>
<li>enhance pipeFileToResponse with error handling (<a
href="https://redirect.github.com/axios/axios/issues/7169">#7169</a>)
(<a
href="https://github.com/axios/axios/commit/88d78842541610692a04282233933d078a8a2552">88d7884</a>)</li>
<li><strong>types:</strong> Intellisense for string literals in a
widened union (<a
href="https://redirect.github.com/axios/axios/issues/6134">#6134</a>)
(<a
href="https://github.com/axios/axios/commit/f73474d02c5aa957b2daeecee65508557fd3c6e5">f73474d</a>),
closes <a
href="https://redirect.github.com//redirect.github.com/microsoft/TypeScript/issues/33471/issues/issuecomment-1376364329">microsoft/TypeScript#33471</a></li>
</ul>
<h3>Reverts</h3>
<ul>
<li>Revert &quot;fix: silentJSONParsing=false should throw on invalid
JSON (<a
href="https://redirect.github.com/axios/axios/issues/7253">#7253</a>)
(<a
href="https://redirect.github.com/axios/axios/issues/7">#7</a>…&quot;
(<a
href="https://redirect.github.com/axios/axios/issues/7298">#7298</a>)
(<a
href="https://github.com/axios/axios/commit/a4230f5581b3f58b6ff531b6dbac377a4fd7942a">a4230f5</a>),
closes <a
href="https://redirect.github.com/axios/axios/issues/7253">#7253</a> <a
href="https://redirect.github.com/axios/axios/issues/7">#7</a> <a
href="https://redirect.github.com/axios/axios/issues/7298">#7298</a></li>
<li><strong>deps:</strong> bump peter-evans/create-pull-request from 7
to 8 in the github-actions group (<a
href="https://redirect.github.com/axios/axios/issues/7334">#7334</a>)
(<a
href="https://github.com/axios/axios/commit/2d6ad5e48bd29b0b2b5e7e95fb473df98301543a">2d6ad5e</a>)</li>
</ul>
<h3>Contributors to this release</h3>
<ul>
<li><!-- raw HTML omitted --> <a href="https://github.com/ashvin2005"
title="+1752/-4 ([#7218](https://github.com/axios/axios/issues/7218)
[#7218](https://github.com/axios/axios/issues/7218) )">Ashvin
Tiwari</a></li>
<li><!-- raw HTML omitted --> <a href="https://github.com/mochinikunj"
title="+940/-12 ([#7294](https://github.com/axios/axios/issues/7294)
[#7294](https://github.com/axios/axios/issues/7294) )">Nikunj
Mochi</a></li>
<li><!-- raw HTML omitted --> <a href="https://github.com/imanchalsingh"
title="+544/-102 ([#7169](https://github.com/axios/axios/issues/7169)
[#7185](https://github.com/axios/axios/issues/7185) )">Anchal
Singh</a></li>
<li><!-- raw HTML omitted --> <a href="https://github.com/jasonsaayman"
title="+317/-73 ([#7334](https://github.com/axios/axios/issues/7334)
[#7298](https://github.com/axios/axios/issues/7298)
)">jasonsaayman</a></li>
<li><!-- raw HTML omitted --> <a href="https://github.com/brodo"
title="+99/-120 ([#5558](https://github.com/axios/axios/issues/5558)
)">Julian Dax</a></li>
<li><!-- raw HTML omitted --> <a
href="https://github.com/AKASHDHARDUBEY" title="+167/-0
([#7287](https://github.com/axios/axios/issues/7287)
[#7288](https://github.com/axios/axios/issues/7288) )">Akash Dhar
Dubey</a></li>
<li><!-- raw HTML omitted --> <a href="https://github.com/madhumitaaa"
title="+20/-68 ([#7198](https://github.com/axios/axios/issues/7198)
)">Madhumita</a></li>
<li><!-- raw HTML omitted --> <a href="https://github.com/Tackoil"
title="+80/-2 ([#6269](https://github.com/axios/axios/issues/6269)
)">Tackoil</a></li>
<li><!-- raw HTML omitted --> <a href="https://github.com/justindhillon"
title="+41/-41 ([#6324](https://github.com/axios/axios/issues/6324)
[#6315](https://github.com/axios/axios/issues/6315) )">Justin
Dhillon</a></li>
<li><!-- raw HTML omitted --> <a href="https://github.com/Rudrxxx"
title="+71/-2 ([#7257](https://github.com/axios/axios/issues/7257)
)">Rudransh</a></li>
<li><!-- raw HTML omitted --> <a href="https://github.com/WuMingDao"
title="+36/-36 ([#7215](https://github.com/axios/axios/issues/7215)
)">WuMingDao</a></li>
<li><!-- raw HTML omitted --> <a href="https://github.com/codenomnom"
title="+70/-0 ([#7201](https://github.com/axios/axios/issues/7201)
[#7201](https://github.com/axios/axios/issues/7201)
)">codenomnom</a></li>
<li><!-- raw HTML omitted --> <a href="https://github.com/Nandann018-ux"
title="+60/-10 ([#7272](https://github.com/axios/axios/issues/7272)
)">Nandan Acharya</a></li>
<li><!-- raw HTML omitted --> <a href="https://github.com/KernelDeimos"
title="+22/-40 ([#7042](https://github.com/axios/axios/issues/7042)
)">Eric Dubé</a></li>
<li><!-- raw HTML omitted --> <a href="https://github.com/tiborpilz"
title="+40/-4 ([#5551](https://github.com/axios/axios/issues/5551)
)">Tibor Pilz</a></li>
<li><!-- raw HTML omitted --> <a href="https://github.com/joaoGabriel55"
title="+31/-4 ([#6314](https://github.com/axios/axios/issues/6314)
)">Gabriel Quaresma</a></li>
<li><!-- raw HTML omitted --> <a href="https://github.com/turadg"
title="+23/-6 ([#6265](https://github.com/axios/axios/issues/6265)
)">Turadg Aleahmad</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/axios/axios/commit/772a4e54ecc4cc2421e2b746daff0aca10f359d7"><code>772a4e5</code></a>
chore(release): prepare release 1.15.0 (<a
href="https://redirect.github.com/axios/axios/issues/10671">#10671</a>)</li>
<li><a
href="https://github.com/axios/axios/commit/4b071371be2f810b4bc7797a13838e0f806ebb22"><code>4b07137</code></a>
chore(deps-dev): bump vite from 8.0.0 to 8.0.5 in /tests/smoke/esm (<a
href="https://redirect.github.com/axios/axios/issues/10663">#10663</a>)</li>
<li><a
href="https://github.com/axios/axios/commit/51e57b39db251bfe3d34af5c943dfea18e06c8b6"><code>51e57b3</code></a>
chore(deps-dev): bump vite from 8.0.2 to 8.0.5 (<a
href="https://redirect.github.com/axios/axios/issues/10664">#10664</a>)</li>
<li><a
href="https://github.com/axios/axios/commit/fba1a77930f0c459677b729161627234b88c90aa"><code>fba1a77</code></a>
chore(deps-dev): bump vite from 8.0.2 to 8.0.5 in /tests/module/esm (<a
href="https://redirect.github.com/axios/axios/issues/10665">#10665</a>)</li>
<li><a
href="https://github.com/axios/axios/commit/0bf6e28eac86e87da2b60bbf5ea4237910e1a08e"><code>0bf6e28</code></a>
chore(deps): bump denoland/setup-deno in the github-actions group (<a
href="https://redirect.github.com/axios/axios/issues/10669">#10669</a>)</li>
<li><a
href="https://github.com/axios/axios/commit/8107157c572ee4a54cb28c01ab7f7f3d895ba661"><code>8107157</code></a>
chore(deps-dev): bump the development_dependencies group with 4 updates
(<a
href="https://redirect.github.com/axios/axios/issues/10670">#10670</a>)</li>
<li><a
href="https://github.com/axios/axios/commit/e66530e3302d56176befd0778155dafea2487542"><code>e66530e</code></a>
ci: require npm-publish environment for releases (<a
href="https://redirect.github.com/axios/axios/issues/10666">#10666</a>)</li>
<li><a
href="https://github.com/axios/axios/commit/49f23cbfe4d308a075281c5f798d4c68f648cbe2"><code>49f23cb</code></a>
chore(sponsor): update sponsor block (<a
href="https://redirect.github.com/axios/axios/issues/10668">#10668</a>)</li>
<li><a
href="https://github.com/axios/axios/commit/363185461b90b1b78845dc8a99a1f103d9b122a1"><code>3631854</code></a>
fix: unrestricted cloud metadata exfiltration via header injection chain
(<a
href="https://redirect.github.com/axios/axios/issues/10">#10</a>...</li>
<li><a
href="https://github.com/axios/axios/commit/fb3befb6daac6cad26b2e54094d0f2d9e47f24df"><code>fb3befb</code></a>
fix: no_proxy hostname normalization bypass leads to ssrf (<a
href="https://redirect.github.com/axios/axios/issues/10661">#10661</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/axios/axios/compare/v1.13.6...v1.15.0">compare
view</a></li>
</ul>
</details>
<details>
<summary>Install script changes</summary>
<p>This version modifies <code>prepare</code> script that runs during
installation. Review the package contents before updating.</p>
</details>
<br />


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

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

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

---

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

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

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-04-15 00:44:54 +05:30
64f6bfc811 feat: Inline edit support for contact info (#13976)
# Pull Request Template

## Description

This PR adds inline editing support for contact name, phone number,
email, and company fields in the conversation contact sidebar

## Type of change

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

## How Has This Been Tested?

**Screencast**


https://github.com/user-attachments/assets/e9f8e37d-145b-4736-b27a-eb9ea66847bd



## 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
- [ ] 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: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-04-14 16:53:40 +04:00
72c9e1775b fix: Prevent article editor from resetting content while typing (#14014)
# Pull Request Template

## Description


### Description

This PR fixes an issue where the editor would reset content and move the
cursor while typing. The issue was caused by a dual debounce setup
(400ms + 2500ms) that saved content and then overwrote local state with
stale API responses while the user was still typing.

### What changed

* Editor now uses local state (`localTitle`, `localContent`) as the
source of truth while editing
* Vuex store is only used on initial load or navigation
* Replaced dual debounce with a single 500ms debounce (fewer API calls)
* `UPDATE_ARTICLE` now merges updates instead of replacing the article
  * Prevents status changes from wiping unsaved content
* Removed `updateAsync` for a simpler update flow

### How it works

User types
→ local ref updates immediately (editor reads from this)
→ 500ms debounce triggers
→ dispatches `articles/update`
→ API persists the change
→ on success: store merges the response (used by other components)
→ editor remains unaffected (continues using local state)



Fixes
https://linear.app/chatwoot/issue/CW-6727/better-syncing-of-content-the-editor-randomly-updates-the-content

## Type of change

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

## How Has This Been Tested?

1. Open any Help Center article for editing
2. Type continuously for a few seconds — content should not reset or
jump
3. Change article status (publish/archive/draft) while editing — content
should remain intact
4. Test on a slow network (use DevTools throttling) — typing should
remain smooth


## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-04-14 16:48:38 +04:00
b7b6e67df7 fix(captain): localize AI summary to account language (#13790)
AI-generated summaries now respect the account's language setting.
Previously, summaries were always returned in English regardless of the
user's configured language, making section headings like "Customer
Intent" and "Action Items" appear in English even for non-English
accounts.

Previous behavior:
<img width="1336" height="790" alt="image"
src="https://github.com/user-attachments/assets/5df8b78b-1218-438d-9578-a806b5cb94ac"
/>


Current Behavior: 
<img width="1253" height="372" alt="image"
src="https://github.com/user-attachments/assets/ae932c97-06da-4baf-9f77-9719bc9162e8"
/>


## What changed
- Added explicit account locale to the AI system prompt in
`Captain::SummaryService`
- Updated the summary prompt template to instruct the model to translate
section headings

## How to test
1. Configure an account with a non-English language (e.g., Portuguese)
2. Open a conversation with messages
3. Use the Copilot "Summarize" feature
4. Verify that section headings ("Customer Intent", "Conversation
Summary", etc.) appear in the account's language

---------

Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-04-14 17:36:10 +05:30
Sivin VargheseandGitHub 288c1cb757 fix: Respect app direction for incoming email content (#14011) 2026-04-14 13:45:34 +05:30
890 changed files with 40626 additions and 2638 deletions
+1
View File
@@ -84,6 +84,7 @@ gem 'barnes'
gem 'devise', '>= 4.9.4'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth', '>= 1.2.3'
gem 'rails-i18n', '~> 7.0'
# two-factor authentication
gem 'devise-two-factor', '>= 5.0.0'
# authorization
+4
View File
@@ -727,6 +727,9 @@ GEM
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.10)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
railties (7.1.5.2)
actionpack (= 7.1.5.2)
activesupport (= 7.1.5.2)
@@ -1125,6 +1128,7 @@ DEPENDENCIES
rack-mini-profiler (>= 3.2.0)
rack-timeout
rails (~> 7.1)
rails-i18n (~> 7.0)
redis
redis-namespace
responders (>= 3.1.1)
+1 -1
View File
@@ -1 +1 @@
4.12.1
4.13.0
+5 -1
View File
@@ -44,7 +44,11 @@ class AccountBuilder
end
def create_account
@account = Account.create!(name: account_name, locale: I18n.locale)
@account = Account.create!(
name: account_name,
locale: I18n.locale,
custom_attributes: { 'onboarding_step' => 'account_details' }
)
Current.account = @account
end
+2 -1
View File
@@ -29,8 +29,9 @@ class AgentBuilder
user = User.from_email(email)
return user if user
@name = email.split('@').first if @name.blank?
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
User.create!(email: email, name: @name, password: temp_password, password_confirmation: temp_password)
end
# Checks if the user needs confirmation.
+1 -1
View File
@@ -41,7 +41,7 @@ class Email::BaseBuilder
end
def business_name
inbox.business_name || inbox.sanitized_name
inbox.sanitized_business_name
end
def account_support_email
+1
View File
@@ -1,6 +1,7 @@
class V2::ReportBuilder
include DateRangeHelper
include ReportHelper
attr_reader :account, :params
DEFAULT_GROUP_BY = 'day'.freeze
@@ -11,10 +11,6 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def fetch_conversations_count
account.conversations.where(created_at: range).group('assignee_id').count
end
def prepare_report
account.account_users.map do |account_user|
build_agent_stats(account_user)
+27 -32
View File
@@ -9,37 +9,13 @@ class V2::Reports::BaseSummaryBuilder
private
def load_data
@conversations_count = fetch_conversations_count
load_reporting_events_data
end
results = data_source.summary
def load_reporting_events_data
# Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
index_key = group_by_key.to_s.split('.').last
results = reporting_events
.select(
"#{group_by_key} as #{index_key}",
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
"AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
"AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
"AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
)
.group(group_by_key)
.index_by { |record| record.public_send(index_key) }
@resolved_count = results.transform_values(&:resolved_count)
@avg_resolution_time = results.transform_values(&:avg_resolution_time)
@avg_first_response_time = results.transform_values(&:avg_first_response_time)
@avg_reply_time = results.transform_values(&:avg_reply_time)
end
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range)
end
def fetch_conversations_count
# Override this method
@conversations_count = results.transform_values { |data| data[:conversations_count] }
@resolved_count = results.transform_values { |data| data[:resolved_conversations_count] }
@avg_resolution_time = results.transform_values { |data| data[:avg_resolution_time] }
@avg_first_response_time = results.transform_values { |data| data[:avg_first_response_time] }
@avg_reply_time = results.transform_values { |data| data[:avg_reply_time] }
end
def group_by_key
@@ -50,7 +26,26 @@ class V2::Reports::BaseSummaryBuilder
# Override this method
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
def data_source
@data_source ||= Reports::DataSource.for(
account: account,
metric: nil,
dimension_type: summary_dimension_type,
dimension_id: nil,
scope: nil,
range: range,
group_by: 'day',
timezone_offset: params[:timezone_offset],
business_hours: params[:business_hours]
)
end
def summary_dimension_type
{
'account_id' => 'account',
'user_id' => 'agent',
'inbox_id' => 'inbox',
'conversations.team_id' => 'team'
}.fetch(group_by_key.to_s)
end
end
@@ -3,23 +3,10 @@ class V2::Reports::Conversations::BaseReportBuilder
private
AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
COUNT_METRICS = %w[
conversations_count
incoming_messages_count
outgoing_messages_count
resolutions_count
bot_resolutions_count
bot_handoffs_count
].freeze
def builder_class(metric)
case metric
when *AVG_METRICS
V2::Reports::Timeseries::AverageReportBuilder
when *COUNT_METRICS
V2::Reports::Timeseries::CountReportBuilder
end
return unless Reports::ReportMetricRegistry.supported?(metric)
V2::Reports::Timeseries::ReportBuilder
end
def log_invalid_metric
@@ -11,15 +11,6 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def load_data
@conversations_count = fetch_conversations_count
load_reporting_events_data
end
def fetch_conversations_count
account.conversations.where(created_at: range).group(group_by_key).count
end
def prepare_report
account.inboxes.map do |inbox|
build_inbox_stats(inbox)
@@ -40,8 +31,4 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
def group_by_key
:inbox_id
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
end
end
@@ -6,14 +6,6 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def fetch_conversations_count
account.conversations.where(created_at: range).group(:team_id).count
end
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
end
def prepare_report
account.teams.map do |team|
build_team_stats(team)
@@ -1,48 +0,0 @@
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_average_time = reporting_events.average(average_value_key)
grouped_event_count = reporting_events.count
grouped_average_time.each_with_object([]) do |element, arr|
event_date, average_time = element
arr << {
value: average_time,
timestamp: event_date.in_time_zone(timezone).to_i,
count: grouped_event_count[event_date]
}
end
end
def aggregate_value
object_scope.average(average_value_key)
end
private
def event_name
metric_to_event_name = {
avg_first_response_time: :first_response,
avg_resolution_time: :conversation_resolved,
reply_time: :reply_time
}
metric_to_event_name[params[:metric].to_sym]
end
def object_scope
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
end
def reporting_events
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
)
end
def average_value_key
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
end
end
@@ -1,12 +1,13 @@
class V2::Reports::Timeseries::BaseTimeseriesBuilder
include TimezoneHelper
include DateRangeHelper
DEFAULT_GROUP_BY = 'day'.freeze
pattr_initialize :account, :params
def scope
case params[:type].to_sym
case dimension_type.to_sym
when :account
account
when :inbox
@@ -20,6 +21,20 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
end
end
def data_source
@data_source ||= Reports::DataSource.for(
account: account,
metric: params[:metric],
dimension_type: dimension_type,
dimension_id: params[:id],
scope: scope,
range: range,
group_by: group_by,
timezone_offset: params[:timezone_offset],
business_hours: params[:business_hours]
)
end
def inbox
@inbox ||= account.inboxes.find(params[:id])
end
@@ -43,4 +58,10 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
end
private
def dimension_type
(params[:type].presence || 'account').to_s
end
end
@@ -1,78 +0,0 @@
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_count.each_with_object([]) do |element, arr|
event_date, event_count = element
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
# because it converts the date to 12:00 AM server timezone.
# The desired output should be 12:00 AM in the specified timezone.
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
end
end
def aggregate_value
object_scope.count
end
private
def metric
@metric ||= params[:metric]
end
def object_scope
send("scope_for_#{metric}")
end
def scope_for_conversations_count
scope.conversations.where(account_id: account.id, created_at: range)
end
def scope_for_incoming_messages_count
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
end
def scope_for_outgoing_messages_count
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
end
def scope_for_resolutions_count
scope.reporting_events.where(
name: :conversation_resolved,
account_id: account.id,
created_at: range
)
end
def scope_for_bot_resolutions_count
scope.reporting_events.where(
name: :conversation_bot_resolved,
account_id: account.id,
created_at: range
)
end
def scope_for_bot_handoffs_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_handoff,
account_id: account.id,
created_at: range
).distinct
end
def grouped_count
# IMPORTANT: time_zone parameter affects both data grouping AND output timestamps
# It converts timestamps to the target timezone before grouping, which means
# the same event can fall into different day buckets depending on timezone
# Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day)
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
).count
end
end
@@ -0,0 +1,9 @@
class V2::Reports::Timeseries::ReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
data_source.timeseries
end
def aggregate_value
data_source.aggregate
end
end
@@ -0,0 +1,43 @@
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :set_articles, only: [:update_status, :delete_articles]
def translate
head :not_implemented
end
def update_status
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
return render_could_not_create_error(I18n.t('portals.articles.invalid_status')) unless Article.statuses.key?(params[:status])
ActiveRecord::Base.transaction do
@articles.find_each { |article| article.update!(status: params[:status]) }
end
head :ok
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.message)
end
def delete_articles
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
@articles.destroy_all
head :ok
end
private
def portal
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
def check_authorization
authorize(Article, :create?)
end
def set_articles
@articles = @portal.articles.where(id: params[:ids])
end
end
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
@@ -5,7 +5,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
sort_on :phone_number, type: :string
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction]
sort_on :company, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
sort_on :company_name, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction]
sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
@@ -61,9 +61,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def process_attached_logo
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_signed(blob_id)
@portal.logo.attach(blob)
blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s)
@portal.logo.attach(blob) if blob
end
private
@@ -58,6 +58,7 @@ class Api::V1::AccountsController < Api::BaseController
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
@account.custom_attributes.merge!(custom_attributes_params)
@account.settings.merge!(settings_params)
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save!
end
@@ -71,9 +72,10 @@ class Api::V1::AccountsController < Api::BaseController
private
def enqueue_branding_enrichment
return if account_params[:email].blank?
email = account_params[:email].presence || @user&.email
return if email.blank?
Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email])
Account::BrandingEnrichmentJob.perform_later(@account.id, email)
Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30)
rescue StandardError => e
# Enrichment is optional — never let queue/Redis failures abort signup
@@ -109,7 +111,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone)
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
end
def settings_params
@@ -2,8 +2,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
before_action :check_mfa_feature_available
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
before_action :check_mfa_disabled, only: [:create, :verify]
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
before_action :validate_password, only: [:destroy]
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
def show; end
@@ -48,7 +48,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
def validate_otp
authenticated = Mfa::AuthenticationService.new(
user: current_user,
otp_code: mfa_params[:otp_code]
otp_code: mfa_params[:otp_code],
backup_code: mfa_params[:backup_code]
).authenticate
return if authenticated
@@ -63,6 +64,6 @@ class Api::V1::Profile::MfaController < Api::BaseController
end
def mfa_params
params.permit(:otp_code, :password)
params.permit(:otp_code, :backup_code, :password)
end
end
@@ -3,15 +3,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
render_report_with(V2::Reports::AgentSummaryBuilder, type: :agent)
end
def team
render_report_with(V2::Reports::TeamSummaryBuilder)
render_report_with(V2::Reports::TeamSummaryBuilder, type: :team)
end
def inbox
render_report_with(V2::Reports::InboxSummaryBuilder)
render_report_with(V2::Reports::InboxSummaryBuilder, type: :inbox)
end
def label
@@ -38,8 +38,9 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
}
end
def render_report_with(builder_class)
builder = builder_class.new(account: Current.account, params: @builder_params)
def render_report_with(builder_class, type: nil)
builder_params = type.present? ? @builder_params.merge(type: type) : @builder_params
builder = builder_class.new(account: Current.account, params: builder_params)
render json: builder.build
end
@@ -3,7 +3,7 @@ class Platform::Api::V1::AgentBotsController < PlatformController
before_action :validate_platform_app_permissible, except: [:index, :create]
def index
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').includes(:permissible)
end
def show; end
@@ -0,0 +1,68 @@
class SuperAdmin::PushDiagnosticsController < SuperAdmin::ApplicationController
def show
@query = params[:user_query].to_s.strip
@user = resolve_user(@query)
@subscriptions = @user ? @user.notification_subscriptions.order(:id) : []
@results = []
end
def create
@user = User.find_by(id: params[:user_id])
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if @user.nil?
ids = parsed_subscription_ids
if ids.empty?
return redirect_to super_admin_push_diagnostics_path(user_query: @user.id),
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_test')
end
run_test_and_render(ids)
end
def destroy_subscriptions
user = User.find_by(id: params[:user_id])
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if user.nil?
ids = parsed_subscription_ids
if ids.empty?
return redirect_to super_admin_push_diagnostics_path(user_query: user.id),
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_delete')
end
deleted_count = user.notification_subscriptions.where(id: ids).destroy_all.size
log_super_admin_action("deleted #{deleted_count} subscriptions for user #{user.id}: #{ids}")
redirect_to super_admin_push_diagnostics_path(user_query: user.id),
notice: I18n.t('super_admin.push_diagnostics.subscriptions_deleted', count: deleted_count)
end
private
def run_test_and_render(ids)
@query = @user.id.to_s
@subscriptions = @user.notification_subscriptions.order(:id)
@results = Notification::PushTestService.new(
user: @user, subscription_ids: ids,
title: params[:push_title], body: params[:push_body]
).perform
log_super_admin_action("test sent for user #{@user.id} subscriptions #{ids}")
render :show
end
def log_super_admin_action(message)
Rails.logger.info(
"[SuperAdmin] push diagnostics #{message} " \
"(actor_id=#{current_super_admin&.id}, actor_email=#{current_super_admin&.email})"
)
end
def resolve_user(query)
return if query.blank?
query.match?(/\A\d+\z/) ? User.find_by(id: query) : User.from_email(query)
end
def parsed_subscription_ids
Array(params[:subscription_ids]).reject(&:blank?).map(&:to_i)
end
end
+1 -1
View File
@@ -7,7 +7,7 @@ module EmailHelper
def render_email_html(content)
return '' if content.blank?
ChatwootMarkdownRenderer.new(content).render_message.to_s
ChatwootMarkdownRenderer.new(content).render_message(hardbreaks: true).to_s
end
# Raise a standard error if any email address is invalid
+3 -3
View File
@@ -59,7 +59,6 @@ export default {
isRTL: 'accounts/isRTL',
currentUser: 'getCurrentUser',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
}),
hideOnOnboardingView() {
return !isOnOnboardingView(this.$route);
@@ -107,8 +106,9 @@ export default {
this.$store.dispatch('setActiveAccount', {
accountId: this.currentAccountId,
});
const account = this.getAccount(this.currentAccountId);
const { locale, latest_chatwoot_version: latestChatwootVersion } =
this.getAccount(this.currentAccountId);
account;
const { pubsub_token: pubsubToken } = this.currentUser || {};
// If user locale is set, use it; otherwise use account locale
this.setLocale(this.uiSettings?.locale || locale);
@@ -131,7 +131,7 @@ export default {
<template>
<div
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
v-if="!authUIFlags.isFetching"
id="app"
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
:dir="isRTL ? 'rtl' : 'ltr'"
@@ -72,6 +72,27 @@ class ArticlesAPI extends PortalsAPI {
category_slug: categorySlug,
});
}
bulkTranslate({ portalSlug, articleIds, locale, categoryId, force = false }) {
return axios.post(
`${this.url}/${portalSlug}/articles/bulk_actions/translate`,
{ ids: articleIds, locale, category_id: categoryId, force }
);
}
bulkUpdateStatus({ portalSlug, articleIds, status }) {
return axios.patch(
`${this.url}/${portalSlug}/articles/bulk_actions/update_status`,
{ ids: articleIds, status }
);
}
bulkDelete({ portalSlug, articleIds }) {
return axios.delete(
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
{ data: { ids: articleIds } }
);
}
}
export default new ArticlesAPI();
+2 -2
View File
@@ -14,9 +14,9 @@ class MfaAPI extends ApiClient {
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
}
disable(password, otpCode) {
disable(password, { otpCode, backupCode } = {}) {
return axios.delete(this.url, {
data: { password, otp_code: otpCode },
data: { password, otp_code: otpCode, backup_code: backupCode },
});
}
@@ -51,7 +51,6 @@ const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
const countriesMap = computed(() => {
return countries.reduce((acc, country) => {
acc[country.code] = country;
acc[country.id] = country;
return acc;
}, {});
@@ -107,11 +107,10 @@ const closeMobileSidebar = () => {
size="sm"
/>
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<template #trigger>
<Button
:label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
size="sm"
@click="toggle"
/>
</template>
</ComposeConversation>
@@ -114,8 +114,8 @@ const emit = defineEmits([
</div>
<div class="w-px h-4 bg-n-strong" />
<ComposeConversation>
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
<template #trigger>
<Button :label="buttonLabel" size="sm" />
</template>
</ComposeConversation>
</div>
@@ -103,6 +103,7 @@ const showPagination = computed(() => {
<ContactsActiveFiltersPreview
v-if="showActiveFiltersPreview"
:active-segment="activeSegment"
class="mb-1"
@clear-filters="emit('clearFilters')"
@open-filter="openFilter"
/>
@@ -0,0 +1,58 @@
<script setup>
import { ref, computed } from 'vue';
import Avatar from 'next/avatar/Avatar.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
contact: { type: Object, required: true },
selected: { type: Boolean, default: false },
enableSelection: { type: Boolean, default: true },
hideThumbnail: { type: Boolean, default: false },
});
const emit = defineEmits(['selectConversation']);
const hovered = ref(false);
const onThumbnailHover = () => {
hovered.value = !props.hideThumbnail;
};
const onThumbnailLeave = () => {
hovered.value = false;
};
const selectedModel = computed({
get: () => props.selected,
set: value => {
emit('selectConversation', value);
},
});
</script>
<template>
<div
class="relative flex items-center flex-shrink-0"
@mouseenter="onThumbnailHover"
@mouseleave="onThumbnailLeave"
>
<Avatar
v-if="!hideThumbnail"
:name="contact.name"
:src="contact.thumbnail"
:size="24"
:status="contact.availability_status"
hide-offline-status
>
<template v-if="enableSelection" #overlay>
<div
v-if="hovered || selected"
class="flex items-center justify-center rounded-md cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] size-6"
@click.stop
>
<Checkbox v-model="selectedModel" />
</div>
</template>
</Avatar>
</div>
</template>
@@ -0,0 +1,47 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import MessagePreview from './MessagePreview.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
import UnreadBadge from './UnreadBadge.vue';
defineProps({
lastMessage: { type: Object, default: null },
voiceCallStatus: { type: String, default: '' },
voiceCallDirection: { type: String, default: '' },
unreadCount: { type: Number, default: 0 },
showExpandedPreview: { type: Boolean, default: false },
});
</script>
<template>
<div
class="grid grid-cols-[1fr_auto] gap-1.5"
:class="showExpandedPreview ? 'items-end' : 'items-center'"
>
<VoiceCallStatus
v-if="voiceCallStatus"
key="voice-status-row"
:status="voiceCallStatus"
:direction="voiceCallDirection"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
/>
<MessagePreview
v-else-if="lastMessage"
key="message-preview"
:message="lastMessage"
:multi-line="showExpandedPreview"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
/>
<span
v-else
key="no-messages"
class="inline-grid grid-flow-col auto-cols-max items-center gap-1 text-body-main"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
>
<Icon icon="i-lucide-info" class="size-3.5" />
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span>
<UnreadBadge :count="unreadCount" :align-bottom="showExpandedPreview" />
</div>
</template>
@@ -0,0 +1,175 @@
<script setup>
import { ref, computed, nextTick, useSlots, watch, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useThrottleFn } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
labels: {
type: Array,
default: () => [],
},
disableToggle: {
type: Boolean,
default: false,
},
});
defineOptions({ inheritAttrs: false });
const attrs = useAttrs();
const slots = useSlots();
const { t } = useI18n();
const accountLabels = useMapGetter('labels/getLabels');
const activeLabels = computed(() => {
return accountLabels.value.filter(({ title }) =>
props.labels.includes(title)
);
});
const showAllLabels = ref(false);
const showExpandLabelButton = ref(false);
const labelPosition = ref(-1);
const labelContainer = ref(null);
// Show if there are labels OR if before slot exists
const showSection = computed(
() => activeLabels.value.length > 0 || !!slots.before
);
const computeVisibleLabelPosition = () => {
const container = labelContainer.value;
if (!container || activeLabels.value.length === 0) {
showExpandLabelButton.value = false;
return;
}
const labels = container.querySelectorAll('[data-label]');
if (labels.length === 0) {
showExpandLabelButton.value = false;
return;
}
// Early exit if all labels are visible
if (showAllLabels.value) return;
const beforeSlot = container.querySelector('[data-before-slot]');
const beforeSlotWidth = beforeSlot?.offsetWidth ?? 0;
const availableWidth = container.clientWidth - 46 - beforeSlotWidth;
let totalWidth = 0;
const labelsArray = Array.from(labels);
// Find last visible label index using some() - stops early on overflow
const overflowIndex = labelsArray.findIndex(label => {
totalWidth += label.offsetWidth + 6;
return totalWidth > availableWidth;
});
const visibleIndex =
overflowIndex === -1 ? labelsArray.length - 1 : overflowIndex - 1;
labelPosition.value = visibleIndex;
showExpandLabelButton.value = visibleIndex < labelsArray.length - 1;
};
const throttledCalculate = useThrottleFn(computeVisibleLabelPosition, 16);
watch(activeLabels, () => nextTick(throttledCalculate), { immediate: true });
const hiddenLabelsCount = computed(() => {
if (!showExpandLabelButton.value || showAllLabels.value) return 0;
return activeLabels.value.length - labelPosition.value - 1;
});
// Check if all labels are hidden (none visible)
const allLabelsHidden = computed(() => {
return labelPosition.value === -1 && activeLabels.value.length > 0;
});
// Label text for button when disableToggle is true and all labels are hidden
const labelsCountText = computed(() => {
if (props.disableToggle && allLabelsHidden.value) {
return t('CONVERSATION.CARD.LABELS_COUNT', {
count: activeLabels.value.length,
});
}
if (!showAllLabels.value && hiddenLabelsCount.value > 0) {
return hiddenLabelsCount.value;
}
return '';
});
const hiddenLabelsTooltip = computed(() => {
if (!props.disableToggle) return '';
// When all labels are hidden, show all label titles
if (allLabelsHidden.value) {
return activeLabels.value.map(label => label.title).join(', ');
}
if (!showExpandLabelButton.value) return '';
const hiddenLabels = activeLabels.value.slice(labelPosition.value + 1);
return hiddenLabels.map(label => label.title).join(', ');
});
const tooltipText = computed(() => {
if (props.disableToggle && hiddenLabelsTooltip.value) {
return hiddenLabelsTooltip.value;
}
return showAllLabels.value
? t('CONVERSATION.CARD.HIDE_LABELS')
: t('CONVERSATION.CARD.SHOW_LABELS');
});
const onShowLabels = e => {
e.stopPropagation();
if (props.disableToggle) return;
showAllLabels.value = !showAllLabels.value;
nextTick(() => computeVisibleLabelPosition());
};
</script>
<template>
<div
v-if="showSection"
ref="labelContainer"
v-bind="attrs"
v-resize="throttledCalculate"
data-labels-container
class="flex items-center flex-shrink min-w-0 min-h-6 gap-x-1.5 gap-y-1 [&:not(:has([data-label],[data-before-slot]))]:hidden"
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
>
<slot name="before" />
<Label
v-for="(label, index) in activeLabels"
:key="label ? label.id : index"
data-label
:label="label"
compact
:class="{
'invisible absolute': !showAllLabels && index > labelPosition,
}"
/>
<Button
v-if="showExpandLabelButton || (disableToggle && allLabelsHidden)"
v-tooltip.top="{
content: tooltipText,
delay: { show: 500, hide: 0 },
}"
:label="labelsCountText"
xs
slate
:no-animation="disableToggle"
:icon="labelsCountText ? 'i-lucide-plus' : 'i-lucide-chevron-left'"
class="!py-0 !px-1.5 flex-shrink-0 !rounded-md !bg-n-button-color -outline-offset-1 !gap-0.5 [&>span:first-child]:!text-n-slate-10 [&>span:last-child]:!text-n-slate-11"
:class="{ 'cursor-default': disableToggle }"
@click="onShowLabels"
/>
</div>
<template v-else />
</template>
@@ -0,0 +1,42 @@
<script setup>
import { computed } from 'vue';
import { CONVERSATION_STATUS } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
status: {
type: String,
default: '',
},
showEmpty: {
type: Boolean,
default: false,
},
});
const icons = {
[CONVERSATION_STATUS.OPEN]: 'i-woot-status-open',
[CONVERSATION_STATUS.RESOLVED]: 'i-woot-status-resolved',
[CONVERSATION_STATUS.PENDING]: 'i-woot-status-pending',
[CONVERSATION_STATUS.SNOOZED]: 'i-woot-status-snoozed',
};
const iconName = computed(() => {
if (props.status && icons[props.status]) {
return icons[props.status];
}
return props.showEmpty ? 'i-woot-status-empty' : '';
});
</script>
<template>
<Icon
v-tooltip.top="{
content: status,
delay: { show: 500, hide: 0 },
}"
:icon="iconName"
class="size-4 flex-shrink-0"
/>
</template>
@@ -0,0 +1,186 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import CardAvatar from './CardAvatar.vue';
import CardContent from './CardContent.vue';
import CardLabels from './CardLabelsV5.vue';
import CardPriorityIcon from './CardPriorityIcon.vue';
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
import Avatar from 'next/avatar/Avatar.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
import CardStatusIcon from './CardStatusIcon.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
chat: { type: Object, required: true },
currentContact: { type: Object, required: true },
assignee: { type: Object, default: () => ({}) },
inbox: { type: Object, default: () => ({}) },
selected: { type: Boolean, default: false },
isActiveChat: { type: Boolean, default: false },
showAssignee: { type: Boolean, default: false },
showInboxName: { type: Boolean, default: false },
isInboxView: { type: Boolean, default: false },
});
const emit = defineEmits([
'selectConversation',
'deSelectConversation',
'click',
'contextmenu',
]);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const unreadCount = computed(() => props.chat.unread_count);
const slaCardLabel = useTemplateRef('slaCardLabel');
const hasSlaPolicyId = computed(
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
);
const selectedModel = computed({
get: () => props.selected,
set: value => {
if (value) {
emit('selectConversation', value);
} else {
emit('deSelectConversation', value);
}
},
});
</script>
<template>
<div
class="conversation relative cursor-pointer group grid gap-4 items-center px-3 h-12 border-b border-n-slate-3 hover:border-n-surface-1 hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 !border-n-surface-1':
isActiveChat,
'selected bg-n-slate-2 dark:bg-n-slate-3 !border-n-surface-1': selected,
'hover:bg-n-alpha-1': !isActiveChat && !selected,
'grid-cols-[minmax(0,2fr)_minmax(0,1fr)]': showLabelsSection,
'grid-cols-[minmax(0,2fr)_max-content]': !showLabelsSection,
}"
@click="$emit('click', $event)"
@contextmenu="$emit('contextmenu', $event)"
>
<!-- LEFT SECTION -->
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="flex items-center justify-center flex-shrink-0" @click.stop>
<Checkbox v-model="selectedModel" />
</div>
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div class="w-4 flex items-center justify-center flex-shrink-0">
<CardPriorityIcon :priority="chat.priority" show-empty />
</div>
<div class="w-4 flex items-center justify-center flex-shrink-0">
<Avatar
v-if="showAssignee && assignee.name"
v-tooltip.top="{
content: assignee.name,
delay: { show: 500, hide: 0 },
}"
:name="assignee.name"
:src="assignee.thumbnail"
:size="14"
:status="assignee.availability_status"
hide-offline-status
/>
<Icon
v-else
icon="i-woot-empty-assignee"
class="size-4 text-n-slate-7"
/>
</div>
<div class="w-4 flex items-center justify-center flex-shrink-0">
<CardStatusIcon :status="chat.status" show-empty />
</div>
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div v-if="!isInboxView && showInboxName" class="w-20 flex-shrink-0">
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
</div>
<div
v-if="!isInboxView && showInboxName"
class="w-px h-3 bg-n-slate-6 flex-shrink-0"
/>
<div
v-tooltip.top="{
content: chat.id,
delay: { show: 500, hide: 0 },
}"
class="h-6 flex items-center gap-1 max-w-20 w-full min-w-0 flex-shrink-0"
>
<Icon
icon="i-woot-hash"
class="size-3.5 text-n-slate-10 flex-shrink-0"
/>
<span class="text-body-main text-n-slate-11 truncate">
{{ chat.id }}
</span>
</div>
<CardAvatar
:contact="currentContact"
:selected="false"
:enable-selection="false"
:hide-thumbnail="false"
/>
<h4
class="text-heading-3 my-0 capitalize truncate text-n-slate-12 font-medium w-32 flex-shrink-0"
>
{{ currentContact.name }}
</h4>
<CardContent
:last-message="lastMessageInChat"
:voice-call-status="voiceCallData.status"
:voice-call-direction="voiceCallData.direction"
:unread-count="unreadCount"
:show-expanded-preview="false"
/>
</div>
<!-- RIGHT SECTION -->
<div class="flex items-center justify-end gap-1.5 flex-shrink-0">
<div v-if="showLabelsSection" class="min-w-0 w-full">
<CardLabels
:labels="chat.labels"
disable-toggle
class="my-0 [&>div]:justify-end justify-end"
/>
</div>
<div v-if="hasSlaPolicyId" class="flex-shrink-0">
<SLACardLabel ref="slaCardLabel" :chat="chat" />
</div>
<div class="flex-shrink-0 w-[4.375rem] text-end">
<TimeAgo
:conversation-id="chat.id"
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
class="font-440 !text-xs text-n-slate-11"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,156 @@
<script setup>
import { computed } from 'vue';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
message: {
type: Object,
required: true,
},
showMessageType: {
type: Boolean,
default: true,
},
defaultEmptyMessage: {
type: String,
default: '',
},
multiLine: {
type: Boolean,
default: false,
},
});
const { getPlainText } = useMessageFormatter();
const attachmentIcons = {
image: 'i-lucide-image',
audio: 'i-lucide-headphones',
video: 'i-lucide-video',
file: 'i-lucide-file',
location: 'i-lucide-map-pin',
fallback: 'i-lucide-link-2',
};
const messageByAgent = computed(() => {
const { message_type: messageType } = props.message;
return messageType === MESSAGE_TYPE.OUTGOING;
});
const isMessageAnActivity = computed(() => {
const { message_type: messageType } = props.message;
return messageType === MESSAGE_TYPE.ACTIVITY;
});
const isMessagePrivate = computed(() => {
const { private: isPrivate } = props.message;
return isPrivate;
});
const parsedLastMessage = computed(() => {
const { content_attributes: contentAttributes } = props.message;
const { email: { subject } = {} } = contentAttributes || {};
return getPlainText(subject || props.message.content);
});
const lastMessageFileType = computed(() => {
const [{ file_type: fileType } = {}] = props.message.attachments;
return fileType;
});
const attachmentIcon = computed(() => {
return attachmentIcons[lastMessageFileType.value];
});
const attachmentMessageContent = computed(() => {
return `CHAT_LIST.ATTACHMENTS.${lastMessageFileType.value}.CONTENT`;
});
const isMessageSticker = computed(() => {
return props.message && props.message.content_type === 'sticker';
});
</script>
<template>
<div
class="min-w-0 text-sm"
:class="
multiLine
? 'flex items-start gap-1'
: 'grid grid-cols-[auto_1fr] items-center gap-1'
"
>
<template v-if="showMessageType && !multiLine">
<Icon
v-if="isMessagePrivate"
icon="i-lucide-lock-keyhole"
class="size-3.5"
/>
<Icon
v-else-if="messageByAgent"
icon="i-lucide-undo-2"
class="size-3.5"
/>
<Icon
v-else-if="isMessageAnActivity"
icon="i-lucide-info"
class="size-3.5"
/>
</template>
<span
class="min-w-0 text-body-main"
:class="multiLine ? 'line-clamp-2' : 'truncate'"
>
<!-- Case for previous and conversation conversation card -->
<template v-if="showMessageType && multiLine">
<Icon
v-if="isMessagePrivate"
icon="i-lucide-lock-keyhole"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<Icon
v-else-if="messageByAgent"
icon="i-lucide-undo-2"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<Icon
v-else-if="isMessageAnActivity"
icon="i-lucide-info"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
</template>
<span
v-if="message.content && isMessageSticker"
class="inline-grid grid-flow-col auto-cols-max items-center gap-1"
>
<Icon icon="i-lucide-image" class="size-3.5" />
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
</span>
<template v-else-if="message.content">
{{ parsedLastMessage }}
</template>
<span
v-else-if="message.attachments"
class="inline-block align-middle truncate"
>
<Icon
v-if="attachmentIcon && showMessageType"
:icon="attachmentIcon"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<span class="inline-block align-middle">
{{ $t(attachmentMessageContent) }}
</span>
</span>
<template v-else>
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
</template>
</span>
</div>
</template>
@@ -0,0 +1,28 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
count: { type: Number, required: true },
alignBottom: { type: Boolean, default: false },
});
const { t } = useI18n();
const displayCount = computed(() =>
props.count > 9 ? t('CHAT_LIST.UNREAD_COUNT_OVERFLOW') : props.count
);
</script>
<template>
<span
v-if="count > 0"
class="bg-n-teal-9 rounded-full h-4 min-w-4 max-w-5 px-1 w-fit font-medium text-xxs leading-3 text-white inline-grid place-items-center flex-shrink-0"
:class="{
'mb-0.5': alignBottom,
}"
>
{{ displayCount }}
</span>
<span v-else />
</template>
@@ -0,0 +1,69 @@
<script setup>
import { computed } from 'vue';
import {
VOICE_CALL_STATUS,
VOICE_CALL_DIRECTION,
} from 'dashboard/components-next/message/constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
status: { type: String, default: '' },
direction: { type: String, default: '' },
});
const LABEL_KEYS = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
);
const labelKey = computed(() => {
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
if (props.status === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const iconName = computed(() => {
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
const statusColor = computed(
() => COLOR_MAP[props.status] || 'text-n-slate-11'
);
</script>
<template>
<div class="grid grid-cols-[auto_1fr] items-center gap-1 min-w-0 text-sm">
<Icon class="size-3.5" :icon="iconName" :class="statusColor" />
<span class="truncate text-body-main" :class="statusColor">
{{ $t(labelKey) }}
</span>
</div>
</template>
@@ -0,0 +1,19 @@
<script setup>
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
defineProps({
inbox: {
type: Object,
default: () => {},
},
});
</script>
<template>
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
<span class="truncate text-body-main text-n-slate-11">
{{ inbox.name }}
</span>
</div>
</template>
@@ -57,7 +57,7 @@ useKeyboardEvents(keyboardEvents);
<template>
<ButtonGroup
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow"
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow !z-20"
>
<Button
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
@@ -0,0 +1,82 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from '@chatwoot/utils';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
});
const REFRESH_INTERVAL = 60000;
const timer = ref(null);
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
});
defineOptions({
inheritAttrs: false,
});
const appliedSLA = computed(() => props.chat?.applied_sla);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value || {},
chat: props.chat,
});
};
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
watch(() => props.chat, updateSlaStatus);
defineExpose({
hasSlaThreshold,
});
</script>
<template>
<div
v-if="hasSlaThreshold"
v-bind="$attrs"
class="relative flex items-center cursor-pointer min-w-fit group"
>
<Label
:label="slaStatus.threshold"
:color="isSlaMissed ? 'ruby' : 'amber'"
compact
>
<template #icon>
<Icon icon="i-lucide-flame" class="flex-shrink-0 size-3.5" />
</template>
</Label>
</div>
<template v-else />
</template>
@@ -9,11 +9,15 @@ import {
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
id: {
@@ -44,14 +48,46 @@ const props = defineProps({
type: Number,
required: true,
},
isSelected: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
showSelectionControl: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['openArticle', 'articleAction']);
const emit = defineEmits([
'openArticle',
'articleAction',
'toggleSelect',
'hover',
]);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const { isEnterprise } = useConfig();
const isTranslationAvailable = computed(
() =>
isEnterprise &&
isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN_TASKS
)
);
const articleMenuItems = computed(() => {
const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce(
(acc, [key, item]) => {
@@ -64,7 +100,9 @@ const articleMenuItems = computed(() => {
const statusItems = (
ARTICLE_MENU_OPTIONS[props.status] ||
ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED]
).map(key => commonItems[key]);
)
.filter(key => key !== 'translate' || isTranslationAvailable.value)
.map(key => commonItems[key]);
return [...statusItems, commonItems.delete];
});
@@ -123,14 +161,27 @@ const handleClick = id => {
</script>
<template>
<CardLayout>
<CardLayout
:selectable="selectable"
class="relative"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div
v-show="showSelectionControl"
class="absolute top-7 ltr:left-3 rtl:right-3"
>
<Checkbox :model-value="isSelected" @change="emit('toggleSelect', id)" />
</div>
<div class="flex justify-between w-full gap-1">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
</span>
<div class="flex items-center gap-2 min-w-0">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
</span>
</div>
<div class="flex items-center gap-2">
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { ref, computed, watch } from 'vue';
import { debounce } from '@chatwoot/utils';
import { useI18n } from 'vue-i18n';
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
@@ -27,7 +27,6 @@ const props = defineProps({
const emit = defineEmits([
'saveArticle',
'saveArticleAsync',
'goBack',
'setAuthor',
'setCategory',
@@ -39,42 +38,37 @@ const { t } = useI18n();
const isNewArticle = computed(() => !props.article?.id);
const saveAndSync = value => {
emit('saveArticle', value);
};
const localTitle = ref(props.article?.title ?? '');
const localContent = ref(props.article?.content ?? '');
// this will only send the data to the backend
// but will not update the local state preventing unnecessary re-renders
// since the data is already saved and we keep the editor text as the source of truth
const quickSave = debounce(
value => emit('saveArticleAsync', value),
400,
false
// Sync local state when navigating to a different article or on initial fetch
watch(
() => props.article?.id,
newId => {
if (newId) {
localTitle.value = props.article?.title ?? '';
localContent.value = props.article?.content ?? '';
}
}
);
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause
// so we can save the data to the backend and retrieve the updated data
// this will update the local state with response data
// Only use to save for existing articles
const saveAndSyncDebounced = debounce(saveAndSync, 2500, false);
const debouncedSave = debounce(value => emit('saveArticle', value), 500, false);
const handleSave = value => {
if (isNewArticle.value) return;
quickSave(value);
saveAndSyncDebounced(value);
debouncedSave(value);
};
const articleTitle = computed({
get: () => props.article.title,
get: () => localTitle.value,
set: value => {
localTitle.value = value;
handleSave({ title: value });
},
});
const localContent = ref(props.article.content || '');
const articleContent = computed({
get: () => props.article.content,
get: () => localContent.value,
set: content => {
localContent.value = content;
handleSave({ content });
@@ -127,12 +121,12 @@ const handleCreateArticle = event => {
custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]"
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
placeholder="Title"
autofocus
:autofocus="isNewArticle"
@blur="handleCreateArticle"
/>
<ArticleEditorControls
:article="article"
@save-article="saveAndSync"
@save-article="values => emit('saveArticle', values)"
@set-author="setAuthorId"
@set-category="setCategoryId"
/>
@@ -144,7 +138,7 @@ const handleCreateArticle = event => {
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
"
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
:autofocus="false"
:autofocus="!isNewArticle"
/>
</template>
</HelpCenterLayout>
@@ -167,8 +161,12 @@ const handleCreateArticle = event => {
}
.editor-root .has-selection {
.ProseMirror-menubar:not(:has(*)) {
display: none !important;
}
.ProseMirror-menubar {
@apply h-8 rounded-lg !px-2 z-50 bg-n-solid-3 items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
display: flex;
top: var(--selection-top, auto) !important;
left: var(--selection-left, 0) !important;
@@ -176,15 +174,10 @@ const handleCreateArticle = event => {
position: absolute !important;
.ProseMirror-menuitem {
@apply mr-0;
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
.ProseMirror-icon {
@apply p-0 mt-0 !mr-0;
svg {
width: 20px !important;
height: 20px !important;
}
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
}
}
@@ -20,8 +20,14 @@ const props = defineProps({
type: Boolean,
default: false,
},
selectedArticleIds: {
type: Set,
default: () => new Set(),
},
});
const emit = defineEmits(['translateArticle', 'toggleSelect']);
const { ARTICLE_STATUS_TYPES } = wootConstants;
const router = useRouter();
@@ -30,12 +36,26 @@ const store = useStore();
const { t } = useI18n();
const localArticles = ref(props.articles);
const hoveredArticleId = ref(null);
const dragEnabled = computed(() => {
// Enable dragging only for category articles and when there's more than one article
return props.isCategoryArticles && localArticles.value?.length > 1;
return (
props.isCategoryArticles &&
localArticles.value?.length > 1 &&
props.selectedArticleIds.size === 0
);
});
const hasBulkSelection = computed(() => props.selectedArticleIds.size > 0);
const shouldShowSelectionControl = id => {
return hoveredArticleId.value === id || hasBulkSelection.value;
};
const handleCardHover = (isHovered, id) => {
hoveredArticleId.value = isHovered ? id : null;
};
const getCategoryById = useMapGetter('categories/categoryById');
const openArticle = id => {
@@ -152,6 +172,10 @@ const handleArticleAction = async (action, { status, id }) => {
};
const updateArticle = ({ action, value, id }) => {
if (action === 'translate') {
emit('translateArticle', id);
return;
}
const status = action !== 'delete' ? getArticleStatus(value) : null;
handleArticleAction(action, { status, id });
};
@@ -187,9 +211,14 @@ watch(
:category="getCategory(element.category.id)"
:views="element.views || 0"
:updated-at="element.updatedAt"
:is-selected="selectedArticleIds.has(element.id)"
selectable
:show-selection-control="shouldShowSelectionControl(element.id)"
:class="{ 'cursor-grab': dragEnabled }"
@open-article="openArticle"
@article-action="updateArticle"
@toggle-select="emit('toggleSelect', $event)"
@hover="isHovered => handleCardHover(isHovered, element.id)"
/>
</li>
</template>
@@ -1,9 +1,13 @@
<script setup>
import { computed } from 'vue';
import { ref, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAlert } from 'dashboard/composables';
import articlesAPI from 'dashboard/api/helpCenter/articles';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
@@ -11,6 +15,10 @@ import ArticleHeaderControls from 'dashboard/components-next/HelpCenter/Pages/Ar
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import BulkTranslateDialog from './BulkTranslateDialog.vue';
const props = defineProps({
articles: {
@@ -39,7 +47,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['pageChange', 'fetchPortal']);
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
const router = useRouter();
const route = useRoute();
@@ -47,6 +55,42 @@ const { t } = useI18n();
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isFetching = useMapGetter('articles/isFetching');
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const selectedArticleIds = ref(new Set());
const deleteConfirmDialogRef = ref(null);
const { isEnterprise } = useConfig();
const isTranslationAvailable = computed(
() =>
isEnterprise &&
isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN_TASKS
)
);
const allItems = computed(() => props.articles.map(a => ({ id: a.id })));
const visibleArticleIds = computed(() => props.articles.map(a => a.id));
const selectAllLabel = computed(() => {
if (!visibleArticleIds.value.length) return '';
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECT_ALL', {
count: visibleArticleIds.value.length,
});
});
const selectedCountLabel = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECTED_COUNT', {
count: selectedArticleIds.value.size,
})
);
const bulkTranslateDialogRef = ref(null);
const hasNoArticles = computed(
() => !isFetching.value && !props.articles.length
@@ -128,6 +172,80 @@ const navigateToNewArticlePage = () => {
params: { categorySlug, locale },
});
};
const handleToggleSelect = articleId => {
const newSet = new Set(selectedArticleIds.value);
if (newSet.has(articleId)) {
newSet.delete(articleId);
} else {
newSet.add(articleId);
}
selectedArticleIds.value = newSet;
};
const clearSelection = () => {
selectedArticleIds.value = new Set();
};
const handleTranslateArticle = articleId => {
selectedArticleIds.value = new Set([articleId]);
bulkTranslateDialogRef.value?.dialogRef?.open();
};
const openTranslateDialog = () => {
bulkTranslateDialogRef.value?.dialogRef?.open();
};
const onBulkActionSuccess = message => {
useAlert(message);
clearSelection();
emit('refreshArticles');
};
const bulkUpdateStatus = async status => {
try {
await articlesAPI.bulkUpdateStatus({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
status,
});
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
);
} catch (error) {
useAlert(
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')
);
}
};
const confirmBulkDelete = () => {
deleteConfirmDialogRef.value?.open();
};
const bulkDelete = async () => {
try {
await articlesAPI.bulkDelete({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
});
deleteConfirmDialogRef.value?.close();
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_SUCCESS')
);
} catch (error) {
deleteConfirmDialogRef.value?.close();
useAlert(
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_ERROR')
);
}
};
// Clear selection when articles change (page change, filter change)
watch(
() => props.articles,
() => clearSelection()
);
</script>
<template>
@@ -166,11 +284,91 @@ const navigateToNewArticlePage = () => {
>
<Spinner />
</div>
<ArticleList
v-else-if="!hasNoArticles"
:articles="articles"
:is-category-articles="isCategoryArticles"
/>
<template v-else-if="!hasNoArticles">
<div
v-if="selectedArticleIds.size > 0"
class="sticky top-0 z-[5] bg-gradient-to-b from-n-surface-1 from-90% to-transparent pt-1 pb-2"
>
<BulkSelectBar
v-model="selectedArticleIds"
:all-items="allItems"
:select-all-label="selectAllLabel"
:selected-count-label="selectedCountLabel"
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
>
<template #secondary-actions>
<Button
sm
ghost
slate
:label="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CLEAR_SELECTION')
"
class="!px-1.5"
@click="clearSelection"
/>
</template>
<template #actions>
<div class="flex items-center gap-2 ml-auto">
<Button
sm
faded
slate
icon="i-lucide-check"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.PUBLISH')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('published')"
/>
<Button
sm
faded
slate
icon="i-lucide-pencil-line"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DRAFT')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('draft')"
/>
<Button
sm
faded
slate
icon="i-lucide-archive-restore"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.ARCHIVE')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('archived')"
/>
<Button
v-if="isTranslationAvailable"
sm
faded
slate
icon="i-lucide-languages"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.TRANSLATE')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="openTranslateDialog"
/>
<Button
sm
faded
ruby
icon="i-lucide-trash"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE')"
class="!px-1.5 [&>span:nth-child(2)]:hidden"
@click="confirmBulkDelete"
/>
</div>
</template>
</BulkSelectBar>
</div>
<ArticleList
:articles="articles"
:is-category-articles="isCategoryArticles"
:selected-article-ids="selectedArticleIds"
class="relative z-0"
@translate-article="handleTranslateArticle"
@toggle-select="handleToggleSelect"
/>
</template>
<ArticleEmptyState
v-else
class="pt-14"
@@ -183,5 +381,31 @@ const navigateToNewArticlePage = () => {
@click="navigateToNewArticlePage"
/>
</template>
<BulkTranslateDialog
ref="bulkTranslateDialogRef"
:selected-article-ids="[...selectedArticleIds]"
:allowed-locales="allowedLocales"
@translate-started="clearSelection"
/>
<Dialog
ref="deleteConfirmDialogRef"
type="alert"
:title="
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_TITLE',
selectedArticleIds.size
)
"
:description="
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_DESCRIPTION',
selectedArticleIds.size
)
"
:confirm-button-label="
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM')
"
@confirm="bulkDelete"
/>
</HelpCenterLayout>
</template>
@@ -0,0 +1,249 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import categoriesAPI from 'dashboard/api/helpCenter/categories.js';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
selectedArticleIds: {
type: Array,
default: () => [],
},
allowedLocales: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['translateStarted']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const router = useRouter();
const dialogRef = ref(null);
const isSubmitting = ref(false);
const selectedLocale = ref('');
const selectedCategoryId = ref('');
const targetCategories = ref([]);
const isFetchingCategories = ref(false);
const duplicateArticles = ref([]);
const currentLocale = computed(() => route.params.locale);
const localeOptions = computed(() => {
return props.allowedLocales
.filter(locale => locale.code !== currentLocale.value)
.map(locale => ({
value: locale.code,
label: `${locale.name} (${locale.code})`,
}));
});
const categoryOptions = computed(() => {
return targetCategories.value.map(category => ({
value: category.id,
label: category.name,
}));
});
const articleCount = computed(() => props.selectedArticleIds.length);
const dialogTitle = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.TITLE', articleCount.value)
);
const description = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DESCRIPTION', articleCount.value)
);
const hasDuplicates = computed(() => duplicateArticles.value.length > 0);
const confirmLabel = computed(() => {
if (hasDuplicates.value) {
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM_OVERWRITE');
}
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM');
});
const isConfirmDisabled = computed(() => {
return !selectedLocale.value || isSubmitting.value;
});
const articleEditUrl = articleId => {
const { portalSlug, categorySlug, tab } = route.params;
const resolved = router.resolve({
name: 'portals_articles_edit',
params: {
portalSlug,
locale: selectedLocale.value,
categorySlug,
tab,
articleSlug: articleId,
},
});
return resolved.href;
};
const fetchCategoriesForLocale = async locale => {
if (!locale) {
targetCategories.value = [];
return;
}
isFetchingCategories.value = true;
try {
const { data } = await categoriesAPI.get({
portalSlug: route.params.portalSlug,
locale,
});
targetCategories.value = data.payload;
} catch {
targetCategories.value = [];
} finally {
isFetchingCategories.value = false;
}
};
watch(selectedLocale, newLocale => {
selectedCategoryId.value = '';
duplicateArticles.value = [];
fetchCategoriesForLocale(newLocale);
});
const resetForm = () => {
selectedLocale.value = '';
selectedCategoryId.value = '';
targetCategories.value = [];
duplicateArticles.value = [];
};
const submitTranslation = async (force = false) => {
isSubmitting.value = true;
try {
await store.dispatch('articles/bulkTranslate', {
portalSlug: route.params.portalSlug,
articleIds: props.selectedArticleIds,
locale: selectedLocale.value,
categoryId: selectedCategoryId.value,
force,
});
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.SUCCESS_MESSAGE'));
resetForm();
dialogRef.value?.close();
emit('translateStarted');
} catch (error) {
if (error.response?.status === 409) {
duplicateArticles.value = error.response.data.duplicate_articles;
return;
}
useAlert(
error?.message ||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.ERROR_MESSAGE')
);
} finally {
isSubmitting.value = false;
}
};
const onConfirm = () => {
if (isConfirmDisabled.value) return;
submitTranslation(hasDuplicates.value);
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="dialogTitle"
:description="description"
:confirm-button-label="confirmLabel"
:disable-confirm-button="isConfirmDisabled"
:is-loading="isSubmitting"
@close="resetForm"
@confirm="onConfirm"
>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_LABEL') }}
</span>
<ComboBox
v-model="selectedLocale"
:options="localeOptions"
:placeholder="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_PLACEHOLDER')
"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_LABEL') }}
<span class="text-n-slate-10 font-normal">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.OPTIONAL') }}
</span>
</span>
<ComboBox
v-model="selectedCategoryId"
:options="categoryOptions"
:disabled="!selectedLocale || isFetchingCategories"
:placeholder="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_PLACEHOLDER')
"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
<div
v-if="hasDuplicates"
class="flex gap-3 p-3 rounded-xl bg-n-amber-2 border border-n-amber-5"
>
<Icon
icon="i-lucide-triangle-alert"
class="size-4 mt-0.5 text-n-amber-11 shrink-0"
/>
<div class="flex flex-col gap-2 min-w-0">
<p class="text-sm text-n-amber-12 m-0">
{{
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_WARNING',
duplicateArticles.length
)
}}
</p>
<div class="flex flex-col gap-1">
<a
v-for="article in duplicateArticles"
:key="article.id"
:href="articleEditUrl(article.id)"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm text-n-amber-12 underline underline-offset-2 hover:text-n-amber-11 truncate"
>
{{ article.title }}
<Icon icon="i-lucide-external-link" class="size-3 shrink-0" />
</a>
</div>
<p class="text-xs text-n-amber-11 m-0">
{{
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_CONFIRM_HINT'
)
}}
</p>
</div>
</div>
</div>
</Dialog>
</template>
@@ -2,13 +2,10 @@
import { reactive, ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useWindowSize } from '@vueuse/core';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { vOnClickOutside } from '@vueuse/components';
import { useAlert } from 'dashboard/composables';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { debounce } from '@chatwoot/utils';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
@@ -18,22 +15,18 @@ import {
processContactableInboxes,
mergeInboxDetails,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import wootConstants from 'dashboard/constants/globals';
import Popover from 'dashboard/components-next/popover/Popover.vue';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
const props = defineProps({
alignPosition: {
type: String,
default: 'left',
},
contactId: {
type: String,
default: null,
},
isModal: {
type: Boolean,
default: false,
align: {
type: String,
default: 'end',
},
});
@@ -42,23 +35,16 @@ const emit = defineEmits(['close']);
const searchContacts = createContactSearcher();
const store = useStore();
const { t } = useI18n();
const { width: windowWidth } = useWindowSize();
const { fetchSignatureFlagFromUISettings } = useUISettings();
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
const viewInModal = computed(() => props.isModal || isSmallScreen.value);
const popoverRef = ref(null);
const contacts = ref([]);
const selectedContact = ref(null);
const targetInbox = ref(null);
const isCreatingContact = ref(false);
const isFetchingInboxes = ref(false);
const isSearching = ref(false);
const showComposeNewConversation = ref(false);
const formState = reactive({
message: '',
@@ -95,14 +81,6 @@ const directUploadsEnabled = computed(
const activeContact = computed(() => contactById.value(props.contactId));
const composePopoverClass = computed(() => {
if (viewInModal.value) return '';
return props.alignPosition === 'right'
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
: 'absolute rtl:left-0 rtl:right-[unset] ltr:right-0 ltr:left-[unset]';
});
const onContactSearch = debounce(
async query => {
isSearching.value = true;
@@ -172,7 +150,7 @@ const clearSelectedContact = () => {
};
const closeCompose = () => {
showComposeNewConversation.value = false;
popoverRef.value?.hide();
if (!props.contactId) {
// If contactId is passed as prop
// Then don't allow to remove the selected contact
@@ -180,7 +158,6 @@ const closeCompose = () => {
}
targetInbox.value = null;
resetContacts();
emit('close');
};
const discardCompose = () => {
@@ -213,8 +190,15 @@ const createConversation = async ({ payload, isFromWhatsApp }) => {
}
};
const toggle = () => {
showComposeNewConversation.value = !showComposeNewConversation.value;
const onPopoverShow = () => {
// Flag to prevent triggering drag n drop,
// When compose modal is active
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
};
const onPopoverHide = () => {
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
emit('close');
};
watch(
@@ -242,64 +226,23 @@ watch(
{ immediate: true, deep: true }
);
const handleClickOutside = () => {
if (!showComposeNewConversation.value) return;
showComposeNewConversation.value = false;
emit('close');
};
const onModalBackdropClick = () => {
if (!viewInModal.value) return;
handleClickOutside();
};
onMounted(() => resetContacts());
const keyboardEvents = {
Escape: {
action: () => {
if (showComposeNewConversation.value) {
showComposeNewConversation.value = false;
emit('close');
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
}
},
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
v-on-click-outside="[
handleClickOutside,
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
// This will prevent closing the compose conversation modal when the editor Create link popup is open
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
]"
class="relative"
:class="{
'z-50': showComposeNewConversation && !viewInModal,
}"
<Popover
ref="popoverRef"
:align="align"
:show-content-border="false"
@show="onPopoverShow"
@hide="onPopoverHide"
>
<slot
name="trigger"
:is-open="showComposeNewConversation"
:toggle="toggle"
/>
<div
v-if="showComposeNewConversation"
:class="{
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
viewInModal,
}"
@click.self="onModalBackdropClick"
>
<template #default="{ isOpen }">
<slot name="trigger" :is-open="isOpen" />
</template>
<template #content>
<ComposeNewConversationForm
:form-state="formState"
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
@@ -321,6 +264,6 @@ useKeyboardEvents(keyboardEvents);
@create-conversation="createConversation"
@discard="discardCompose"
/>
</div>
</div>
</template>
</Popover>
</template>
@@ -20,6 +20,7 @@ const props = defineProps({
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
isTwilioSmsInbox: { type: Boolean, default: false },
isTwilioWhatsAppInbox: { type: Boolean, default: false },
// eslint-disable-next-line vue/no-unused-properties
messageTemplates: { type: Array, default: () => [] },
channelType: { type: String, default: '' },
isLoading: { type: Boolean, default: false },
@@ -198,7 +199,6 @@ useEventListener(document, 'paste', onPaste);
<WhatsAppOptions
v-if="isWhatsappInbox"
:inbox-id="inboxId"
:message-templates="messageTemplates"
@send-message="emit('sendWhatsappMessage', $event)"
/>
<ContentTemplateSelector
@@ -220,7 +220,7 @@ useEventListener(document, 'paste', onPaste);
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="top-full mt-1.5 ltr:left-0 rtl:right-0"
class="!top-auto !bottom-full mb-1.5 ltr:left-0 rtl:right-0"
:on-click="onClickInsertEmoji"
/>
</div>
@@ -361,7 +361,7 @@ useKeyboardEvents({
<template>
<div
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)]"
class="w-full md: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)]"
>
<div class="flex-1 overflow-y-auto divide-y divide-n-strong">
<ContactSelector
@@ -24,9 +24,7 @@ const handleBack = () => {
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem]">
<div class="w-full">
<ContentTemplateParser
:template="template"
@@ -6,6 +6,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Popover from 'dashboard/components-next/popover/Popover.vue';
import ContentTemplateForm from './ContentTemplateForm.vue';
const props = defineProps({
@@ -22,7 +23,6 @@ const inbox = useMapGetter('inboxes/getInbox');
const searchQuery = ref('');
const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const contentTemplates = computed(() => {
const inboxData = inbox.value(props.inboxId);
@@ -39,29 +39,36 @@ const filteredTemplates = computed(() => {
);
});
const handleTriggerClick = () => {
const handlePopoverShow = () => {
searchQuery.value = '';
showTemplatesMenu.value = !showTemplatesMenu.value;
selectedTemplate.value = null;
};
const handlePopoverHide = () => {
selectedTemplate.value = null;
};
const handleTemplateClick = template => {
selectedTemplate.value = template;
showTemplatesMenu.value = false;
};
const handleBack = () => {
selectedTemplate.value = null;
showTemplatesMenu.value = true;
};
const handleSendMessage = template => {
const handleSendMessage = (template, hide) => {
emit('sendMessage', template);
selectedTemplate.value = null;
hide();
};
</script>
<template>
<div class="relative">
<Popover
align="start"
disable-mobile-view
@show="handlePopoverShow"
@hide="handlePopoverHide"
>
<Button
icon="i-ph-whatsapp-logo"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')"
@@ -69,56 +76,59 @@ const handleSendMessage = template => {
size="sm"
:disabled="selectedTemplate"
class="!text-xs font-medium"
@click="handleTriggerClick"
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="w-full">
<Input
v-model="searchQuery"
type="search"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER')
"
custom-input-class="ltr:pl-10 rtl:pr-10"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
/>
</template>
</Input>
</div>
<template #content="{ hide }">
<div
v-for="template in filteredTemplates"
:key="template.content_sid"
tabindex="0"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
v-if="!selectedTemplate"
class="flex flex-col gap-2 p-4 items-center w-[21.875rem]"
>
<div class="flex justify-between items-center">
<span class="text-sm text-n-slate-12">{{
template.friendly_name
}}</span>
<div class="w-full">
<Input
v-model="searchQuery"
type="search"
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER'
)
"
custom-input-class="ltr:pl-10 rtl:pr-10"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
/>
</template>
</Input>
</div>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
</p>
<div
v-for="template in filteredTemplates"
:key="template.content_sid"
tabindex="0"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<div class="flex justify-between items-center">
<span class="text-sm text-n-slate-12">{{
template.friendly_name
}}</span>
</div>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<ContentTemplateForm
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@back="handleBack"
/>
</div>
<ContentTemplateForm
v-else
:template="selectedTemplate"
@send-message="payload => handleSendMessage(payload, hide)"
@back="handleBack"
/>
</template>
</Popover>
</template>
@@ -5,6 +5,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Popover from 'dashboard/components-next/popover/Popover.vue';
import WhatsappTemplate from './WhatsappTemplate.vue';
const props = defineProps({
@@ -24,8 +25,6 @@ const getFilteredWhatsAppTemplates = useMapGetter(
const searchQuery = ref('');
const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const whatsAppTemplateMessages = computed(() => {
return getFilteredWhatsAppTemplates.value(props.inboxId);
});
@@ -40,29 +39,36 @@ const getTemplateBody = template => {
return template.components.find(component => component.type === 'BODY').text;
};
const handleTriggerClick = () => {
const handlePopoverShow = () => {
searchQuery.value = '';
showTemplatesMenu.value = !showTemplatesMenu.value;
selectedTemplate.value = null;
};
const handlePopoverHide = () => {
selectedTemplate.value = null;
};
const handleTemplateClick = template => {
selectedTemplate.value = template;
showTemplatesMenu.value = false;
};
const handleBack = () => {
selectedTemplate.value = null;
showTemplatesMenu.value = true;
};
const handleSendMessage = template => {
const handleSendMessage = (template, hide) => {
emit('sendMessage', template);
selectedTemplate.value = null;
hide();
};
</script>
<template>
<div class="relative">
<Popover
align="start"
disable-mobile-view
@show="handlePopoverShow"
@hide="handlePopoverHide"
>
<Button
icon="i-ri-whatsapp-line"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.LABEL')"
@@ -70,50 +76,53 @@ const handleSendMessage = template => {
size="sm"
:disabled="selectedTemplate"
class="!text-xs font-medium"
@click="handleTriggerClick"
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="relative w-full">
<Icon
icon="i-lucide-search"
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
)
"
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<template #content="{ hide }">
<div
v-for="template in filteredTemplates"
:key="template.id"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
v-if="!selectedTemplate"
class="flex flex-col gap-2 p-4 items-center w-[21.875rem]"
>
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ getTemplateBody(template) }}
</p>
<div class="relative w-full">
<Icon
icon="i-lucide-search"
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
)
"
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
v-for="template in filteredTemplates"
:key="template.id"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ getTemplateBody(template) }}
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{
t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE')
}}
</p>
</template>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<WhatsappTemplate
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@back="handleBack"
/>
</div>
<WhatsappTemplate
v-else
:template="selectedTemplate"
@send-message="payload => handleSendMessage(payload, hide)"
@back="handleBack"
/>
</template>
</Popover>
</template>
@@ -24,9 +24,7 @@ const handleBack = () => {
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem]">
<div class="w-full">
<WhatsAppTemplateParser
:template="template"
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, useSlots } from 'vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -30,23 +30,40 @@ const modelValue = defineModel({
});
const selectedCount = computed(() => modelValue.value.size);
const totalCount = computed(() => props.allItems.length);
const visibleItemIds = computed(() => props.allItems.map(item => item.id));
const visibleItemCount = computed(() => visibleItemIds.value.length);
const selectedVisibleCount = computed(
() => visibleItemIds.value.filter(id => modelValue.value.has(id)).length
);
const hasSelected = computed(() => selectedCount.value > 0);
const isIndeterminate = computed(
() => hasSelected.value && selectedCount.value < totalCount.value
() =>
selectedVisibleCount.value > 0 &&
selectedVisibleCount.value < visibleItemCount.value
);
const allSelected = computed(
() => totalCount.value > 0 && selectedCount.value === totalCount.value
() =>
visibleItemCount.value > 0 &&
selectedVisibleCount.value === visibleItemCount.value
);
const slots = useSlots();
const hasSecondaryActions = computed(() => Boolean(slots['secondary-actions']));
const bulkCheckboxState = computed({
get: () => allSelected.value,
set: shouldSelectAll => {
const newSelectedIds = shouldSelectAll
? new Set(props.allItems.map(item => item.id))
: new Set();
modelValue.value = newSelectedIds;
if (!visibleItemCount.value) {
return;
}
const updatedSelection = new Set(modelValue.value);
if (shouldSelectAll) {
visibleItemIds.value.forEach(id => updatedSelection.add(id));
} else {
visibleItemIds.value.forEach(id => updatedSelection.delete(id));
}
modelValue.value = updatedSelection;
},
});
</script>
@@ -63,7 +80,7 @@ const bulkCheckboxState = computed({
v-if="hasSelected"
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center gap-1.5 min-w-0">
<Checkbox
v-model="bulkCheckboxState"
@@ -78,21 +95,23 @@ const bulkCheckboxState = computed({
<span class="text-sm text-n-slate-10 truncate tabular-nums">
{{ selectedCountLabel }}
</span>
<div class="h-4 w-px bg-n-strong" />
<slot name="secondary-actions" />
</div>
<div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount">
<Button
:label="deleteLabel"
sm
ruby
ghost
class="!px-1.5"
icon="i-lucide-trash"
@click="emit('bulkDelete')"
/>
</slot>
<slot v-if="hasSecondaryActions" name="secondary-actions" />
<div v-if="hasSecondaryActions" class="h-4 w-px bg-n-strong" />
<div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount">
<Button
:label="deleteLabel"
sm
ruby
ghost
class="!px-1.5"
icon="i-lucide-trash"
@click="emit('bulkDelete')"
/>
</slot>
</div>
</div>
</div>
<div v-else class="flex items-center gap-3">
@@ -135,6 +135,16 @@ export function useContactFilterContext() {
filterOperators: containmentOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.COMPANY_NAME,
value: CONTACT_ATTRIBUTES.COMPANY_NAME,
attributeName: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
label: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
inputType: 'plainText',
dataType: 'text',
filterOperators: containmentOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.CREATED_AT,
value: CONTACT_ATTRIBUTES.CREATED_AT,
@@ -23,6 +23,7 @@ export const CONTACT_ATTRIBUTES = {
IDENTIFIER: 'identifier',
COUNTRY_CODE: 'country_code',
CITY: 'city',
COMPANY_NAME: 'company_name',
CREATED_AT: 'created_at',
LAST_ACTIVITY_AT: 'last_activity_at',
REFERER: 'referer',
@@ -30,13 +30,23 @@ const props = defineProps({
type: Boolean,
default: false,
},
readonly: {
type: Boolean,
default: false,
},
focusOnMount: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['enterPress', 'input', 'blur', 'focus']);
const emit = defineEmits([
'enterPress',
'escapePress',
'input',
'blur',
'focus',
]);
const modelValue = defineModel({
type: [String, Number],
@@ -49,6 +59,10 @@ const onEnterPress = () => {
emit('enterPress');
};
const onEscapePress = () => {
emit('escapePress');
};
const handleInput = event => {
emit('input', event.target.value);
modelValue.value = event.target.value;
@@ -72,6 +86,7 @@ onMounted(() => {
defineExpose({
focus: () => inlineInputRef.value?.focus(),
blur: () => inlineInputRef.value?.blur(),
});
</script>
@@ -96,12 +111,14 @@ defineExpose({
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:class="customInputClass"
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none outline-none outline-0 bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown.enter.prevent="onEnterPress"
@keydown.escape.prevent="onEscapePress"
/>
</div>
</template>
@@ -226,4 +226,10 @@ const handleSeeOriginal = () => {
}
}
}
// Email clients (Gmail, Outlook) hardcode dir="ltr" on wrapper elements.
// In RTL apps this forces email content LTR regardless of actual text.
[dir='rtl'] .letter-render [dir='ltr'] {
direction: inherit;
}
</style>
@@ -0,0 +1,139 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
align: {
type: String,
default: 'end',
validator: v => ['start', 'end'].includes(v),
},
disableMobileView: {
type: Boolean,
default: false,
},
showContentBorder: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['show', 'hide']);
const isActive = ref(false);
const triggerRef = ref(null);
const popoverRef = ref(null);
const mobileContentRef = ref(null);
const breakpoints = useBreakpoints(breakpointsTailwind);
const belowMd = breakpoints.smaller('md');
const isMobile = computed(() => !props.disableMobileView && belowMd.value);
const showPopover = computed(() => isActive.value && !isMobile.value);
const { fixedPosition, updatePosition } = useDropdownPosition(
triggerRef,
popoverRef,
showPopover,
{ align: props.align }
);
const show = async () => {
isActive.value = true;
if (!isMobile.value) {
await nextTick();
updatePosition();
}
emit('show');
};
const hide = () => {
if (!isActive.value) return;
isActive.value = false;
emit('hide');
};
const toggle = async () => {
if (isActive.value) hide();
else await show();
};
// Recalculate position when switching from mobile to desktop while open
watch(isMobile, async mobile => {
if (!isActive.value || mobile) return;
await nextTick();
updatePosition();
});
const handleClickOutside = event => {
if (triggerRef.value?.contains(event.target)) return;
hide();
};
// Selectors for teleported elements that should not trigger close
const clickOutsideIgnore = [
'dialog.ProseMirror-prompt-backdrop',
'[data-popover-content]',
];
useKeyboardEvents({
Escape: {
action: () => isActive.value && hide(),
allowOnFocusedInput: true,
},
});
defineExpose({ show, hide, toggle });
</script>
<template>
<span ref="triggerRef" class="inline-flex" @click="toggle">
<slot :is-open="isActive" />
</span>
<TeleportWithDirection to="body">
<!-- Mobile: centered modal with backdrop -->
<div
v-if="isActive && isMobile"
data-popover-backdrop
class="fixed inset-0 z-[9999] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center bg-n-alpha-black1"
>
<div
ref="mobileContentRef"
v-on-click-outside="[
handleClickOutside,
{ ignore: clickOutsideIgnore },
]"
data-popover-content
class="relative flex flex-col w-full max-w-lg max-h-[calc(100vh-4rem)] mx-4 bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
>
<div
class="flex-1 min-h-0 overflow-y-auto overscroll-contain rounded-xl"
>
<slot name="content" :hide="hide" />
</div>
</div>
</div>
<!-- Desktop: fixed popover -->
<div
v-else-if="showPopover"
ref="popoverRef"
v-on-click-outside="[handleClickOutside, { ignore: clickOutsideIgnore }]"
data-popover-content
:class="fixedPosition.class"
:style="fixedPosition.style"
class="flex flex-col bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
>
<div
class="flex-1 min-h-0 overflow-y-auto overscroll-contain rounded-xl"
:class="{ 'border border-n-strong': showContentBorder }"
>
<slot name="content" :hide="hide" />
</div>
</div>
</TeleportWithDirection>
</template>
@@ -10,8 +10,6 @@ import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useWindowSize, useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
@@ -184,15 +182,6 @@ const closeMobileSidebar = () => {
emit('closeMobileSidebar');
};
const onComposeOpen = toggleFn => {
toggleFn();
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
};
const onComposeClose = () => {
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
};
const newReportRoutes = () => [
{
name: 'Reports Agent',
@@ -250,6 +239,12 @@ const menuItems = computed(() => {
activeOn: ['conversation_through_mentions'],
to: accountScopedRoute('conversation_mentions'),
},
{
name: 'Participating',
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
activeOn: ['conversation_through_participating'],
to: accountScopedRoute('conversation_participating'),
},
{
name: 'Unattended',
activeOn: ['conversation_through_unattended'],
@@ -728,7 +723,13 @@ const menuItems = computed(() => {
<aside
v-on-click-outside="[
closeMobileSidebar,
{ ignore: ['#mobile-sidebar-launcher'] },
{
ignore: [
'#mobile-sidebar-launcher',
'[data-popover-content]',
'[data-popover-backdrop]',
],
},
]"
class="bg-n-background flex flex-col text-sm pb-px fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
:class="[
@@ -796,8 +797,8 @@ const menuItems = computed(() => {
>
<span class="i-lucide-search size-4 text-n-slate-11" />
</RouterLink>
<ComposeConversation align-position="right" @close="onComposeClose">
<template #trigger="{ toggle, isOpen }">
<ComposeConversation align="start">
<template #trigger="{ isOpen }">
<Button
icon="i-lucide-pen-line"
color="slate"
@@ -809,7 +810,6 @@ const menuItems = computed(() => {
: '!h-7 !outline-n-weak !text-n-slate-11',
{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen },
]"
@click="onComposeOpen(toggle)"
/>
</template>
</ComposeConversation>
@@ -158,9 +158,11 @@ const activeChild = computed(() => {
return rankedPage ?? activeOnPages[0];
}
return navigableChildren.value.find(
child => child.to && route.path.startsWith(resolvePath(child.to))
);
return navigableChildren.value.find(child => {
if (!child.to) return false;
const childPath = resolvePath(child.to);
return route.path === childPath || route.path.startsWith(`${childPath}/`);
});
});
const hasActiveChild = computed(() => {
@@ -1,15 +1,5 @@
<script setup>
// [TODO] This componet is too big and bulky to be in the same file, we can consider splitting this into multiple
// composables and components, useVirtualChatList, useChatlistFilters
import {
ref,
unref,
provide,
computed,
watch,
onMounted,
defineEmits,
} from 'vue';
import { ref, unref, provide, computed, watch, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import {
@@ -17,23 +7,19 @@ import {
useFunctionGetter,
} from 'dashboard/composables/store.js';
import { Virtualizer } from 'virtua/vue';
import ChatListHeader from './ChatListHeader.vue';
import ConversationList from './ConversationList.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
import { useFilter } from 'shared/composables/useFilter';
import { useTrack } from 'dashboard/composables';
@@ -56,6 +42,7 @@ import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHe
import { conversationListPageURL } from '../helper/URLHelper';
import {
isOnMentionsView,
isOnParticipatingView,
isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers';
import {
@@ -84,10 +71,6 @@ const route = useRoute();
const store = useStore();
const resolveAttributesModalRef = ref(null);
const conversationListRef = ref(null);
const virtualListRef = ref(null);
provide('contextMenuElementTarget', virtualListRef);
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
@@ -99,7 +82,6 @@ const chatsOnView = ref([]);
const foldersQuery = ref({});
const showAddFoldersModal = ref(false);
const showDeleteFoldersModal = ref(false);
const isContextMenuOpen = ref(false);
const appliedFilter = ref([]);
const advancedFilterTypes = ref(
advancedFilterOptions.map(filter => ({
@@ -113,6 +95,7 @@ const chatLists = useMapGetter('getFilteredConversations');
const mineChatsList = useMapGetter('getMineChats');
const allChatList = useMapGetter('getAllStatusChats');
const unAssignedChatsList = useMapGetter('getUnAssignedChats');
const participatingChatsList = useMapGetter('getParticipatingChats');
const chatListLoading = useMapGetter('getChatListLoadingStatus');
const activeInbox = useMapGetter('getSelectedInbox');
const conversationStats = useMapGetter('conversationStats/getStats');
@@ -128,7 +111,6 @@ const currentAccountId = useMapGetter('getCurrentAccountId');
const getTeamFn = useMapGetter('teams/getTeam');
const getConversationById = useMapGetter('getConversationById');
useChatListKeyboardEvents(conversationListRef);
const {
selectedConversations,
selectedInboxes,
@@ -296,13 +278,15 @@ const pageTitle = computed(() => {
if (props.label) {
return `#${props.label}`;
}
if (props.conversationType === 'mention') {
if (props.conversationType === wootConstants.CONVERSATION_TYPE.MENTION) {
return t('CHAT_LIST.MENTION_HEADING');
}
if (props.conversationType === 'participating') {
if (
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
) {
return t('CONVERSATION_PARTICIPANTS.SIDEBAR_MENU_TITLE');
}
if (props.conversationType === 'unattended') {
if (props.conversationType === wootConstants.CONVERSATION_TYPE.UNATTENDED) {
return t('CHAT_LIST.UNATTENDED_HEADING');
}
if (hasActiveFolders.value) {
@@ -311,12 +295,30 @@ const pageTitle = computed(() => {
return t('CHAT_LIST.TAB_HEADING');
});
function filterByAssigneeTab(conversations) {
if (activeAssigneeTab.value === wootConstants.ASSIGNEE_TYPE.ME) {
return conversations.filter(
c => c.meta?.assignee?.id === currentUser.value?.id
);
}
if (activeAssigneeTab.value === wootConstants.ASSIGNEE_TYPE.UNASSIGNED) {
return conversations.filter(c => !c.meta?.assignee);
}
return [...conversations];
}
const conversationList = computed(() => {
let localConversationList = [];
if (!hasAppliedFiltersOrActiveFolders.value) {
const filters = conversationFilters.value;
if (activeAssigneeTab.value === 'me') {
if (
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
) {
localConversationList = filterByAssigneeTab(
participatingChatsList.value(filters)
);
} else if (activeAssigneeTab.value === 'me') {
localConversationList = [...mineChatsList.value(filters)];
} else if (activeAssigneeTab.value === 'unassigned') {
localConversationList = [...unAssignedChatsList.value(filters)];
@@ -338,7 +340,7 @@ const conversationList = computed(() => {
});
const showEndOfListMessage = computed(() => {
return (
return !!(
conversationList.value.length &&
hasCurrentPageEndReached.value &&
!chatListLoading.value
@@ -583,14 +585,6 @@ function loadMoreConversations() {
}
}
// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll.
// If the list doesnt fill the viewport, loading can stall.
// IntersectionObserver triggers as soon as the sentinel is visible.
const intersectionObserverOptions = computed(() => ({
root: conversationListRef.value,
rootMargin: '100px 0px 100px 0px',
}));
function updateAssigneeTab(selectedTab) {
if (activeAssigneeTab.value !== selectedTab) {
resetBulkActions();
@@ -637,9 +631,11 @@ function redirectToConversationList() {
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
conversationType = wootConstants.CONVERSATION_TYPE.MENTION;
} else if (isOnParticipatingView({ route: { name } })) {
conversationType = wootConstants.CONVERSATION_TYPE.PARTICIPATING;
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
conversationType = wootConstants.CONVERSATION_TYPE.UNATTENDED;
}
router.push(
conversationListPageURL({
@@ -782,10 +778,6 @@ function allSelectedConversationsStatus(status) {
});
}
function onContextMenuToggle(state) {
isContextMenuOpen.value = state;
}
function toggleSelectAll(check) {
selectAllConversations(check, conversationList);
}
@@ -833,7 +825,6 @@ provide('assignTeam', onAssignTeam);
provide('assignLabels', onAssignLabels);
provide('removeLabels', onRemoveLabels);
provide('updateConversationStatus', handleResolveConversation);
provide('toggleContextMenu', onContextMenuToggle);
provide('markAsUnread', markAsUnread);
provide('markAsRead', markAsRead);
provide('assignPriority', assignPriority);
@@ -946,43 +937,18 @@ watch(conversationFilters, (newVal, oldVal) => {
@assign-labels="onAssignLabels"
@assign-team="onAssignTeamsForBulk"
/>
<div
ref="conversationListRef"
class="flex-1 min-h-0 overflow-y-auto conversations-list"
:class="{ '!overflow-hidden': isContextMenuOpen }"
>
<Virtualizer
ref="virtualListRef"
v-slot="{ item, index }"
:data="conversationList"
>
<ConversationItem
:source="item"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
:data-index="index"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
/>
</Virtualizer>
<div v-if="chatListLoading" class="flex justify-center my-4">
<Spinner class="text-n-brand" />
</div>
<p
v-else-if="showEndOfListMessage"
class="p-4 text-center text-n-slate-11"
>
{{ $t('CHAT_LIST.EOF') }}
</p>
<IntersectionObserver
v-else
:options="intersectionObserverOptions"
@observed="loadMoreConversations"
/>
</div>
<ConversationList
:conversation-list="conversationList"
:is-loading="chatListLoading"
:show-end-of-list-message="showEndOfListMessage"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
:is-on-expanded-layout="isOnExpandedLayout"
@load-more="loadMoreConversations"
/>
<Dialog
ref="deleteConversationDialogRef"
type="alert"
@@ -1,74 +1,244 @@
<script>
<script setup>
import { computed, ref, watch, inject } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import ConversationCard from './widgets/conversation/ConversationCard.vue';
export default {
components: {
ConversationCard,
},
inject: [
'selectConversation',
'deSelectConversation',
'assignAgent',
'assignTeam',
'assignLabels',
'removeLabels',
'updateConversationStatus',
'toggleContextMenu',
'markAsUnread',
'markAsRead',
'assignPriority',
'isConversationSelected',
'deleteConversation',
],
props: {
source: {
type: Object,
required: true,
},
teamId: {
type: [String, Number],
default: 0,
},
label: {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
foldersId: {
type: [String, Number],
default: 0,
},
showAssignee: {
type: Boolean,
default: false,
},
},
import ConversationCardExpanded from 'dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import ConversationContextMenu from './widgets/conversation/contextMenu/Index.vue';
const props = defineProps({
source: { type: Object, required: true },
teamId: { type: [String, Number], default: 0 },
label: { type: String, default: '' },
conversationType: { type: String, default: '' },
foldersId: { type: [String, Number], default: 0 },
showAssignee: { type: Boolean, default: false },
showExpanded: { type: Boolean, default: false },
});
const router = useRouter();
const store = useStore();
const selectConversation = inject('selectConversation');
const deSelectConversation = inject('deSelectConversation');
const assignAgent = inject('assignAgent');
const assignTeam = inject('assignTeam');
const assignLabels = inject('assignLabels');
const removeLabels = inject('removeLabels');
const updateConversationStatus = inject('updateConversationStatus');
const toggleContextMenu = inject('toggleContextMenu');
const markAsUnread = inject('markAsUnread');
const markAsRead = inject('markAsRead');
const assignPriority = inject('assignPriority');
const isConversationSelected = inject('isConversationSelected');
const deleteConversation = inject('deleteConversation');
// --- Context menu state (shared by both layouts) ---
const showContextMenu = ref(false);
const contextMenu = ref({ x: null, y: null });
// Reset context menu state when the row is recycled to a different conversation.
watch(
() => props.source.id,
() => {
if (showContextMenu.value) {
toggleContextMenu(false);
}
showContextMenu.value = false;
contextMenu.value = { x: null, y: null };
}
);
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
const chatMetadata = computed(() => props.source.meta || {});
const assignee = computed(() => chatMetadata.value.assignee || {});
const senderId = computed(() => chatMetadata.value.sender?.id);
const currentContact = computed(() =>
senderId.value ? store.getters['contacts/getContact'](senderId.value) : {}
);
const isActiveChat = computed(() => currentChat.value.id === props.source.id);
const inbox = computed(() => {
const inboxId = props.source.inbox_id;
return inboxId ? store.getters['inboxes/getInbox'](inboxId) : {};
});
const showInboxName = computed(
() => !activeInbox.value && inboxesList.value.length > 1
);
const isInboxView = computed(() => !!activeInbox.value);
const showAssigneeForExpandedCard = computed(
() => props.showExpanded || props.showAssignee
);
const conversationPath = computed(() =>
frontendURL(
conversationUrl({
accountId: accountId.value,
activeInbox: activeInbox.value,
id: props.source.id,
label: props.label,
teamId: props.teamId,
conversationType: props.conversationType,
foldersId: props.foldersId,
})
)
);
const onCardClick = e => {
const path = conversationPath.value;
if (!path) return;
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
if (isActiveChat.value) return;
router.push({ path });
};
const onExpandedSelect = checked => {
if (checked) {
selectConversation(props.source.id, inbox.value.id);
} else {
deSelectConversation(props.source.id, inbox.value.id);
}
};
const openContextMenu = e => {
e.preventDefault();
toggleContextMenu(true);
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const closeContextMenu = () => {
toggleContextMenu(false);
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
};
const onUpdateConversation = (status, snoozedUntil) => {
closeContextMenu();
updateConversationStatus(props.source.id, status, snoozedUntil);
};
const onAssignAgent = agent => {
assignAgent(agent, [props.source.id]);
closeContextMenu();
};
const onAssignLabel = label => {
assignLabels([label.title], [props.source.id]);
};
const onRemoveLabel = label => {
removeLabels([label.title], [props.source.id]);
};
const onAssignTeam = team => {
assignTeam(team, props.source.id);
closeContextMenu();
};
const onMarkAsUnread = () => {
markAsUnread(props.source.id);
closeContextMenu();
};
const onMarkAsRead = () => {
markAsRead(props.source.id);
closeContextMenu();
};
const onAssignPriority = priority => {
assignPriority(priority, props.source.id);
closeContextMenu();
};
const onDeleteConversation = () => {
deleteConversation(props.source.id);
closeContextMenu();
};
</script>
<template>
<ConversationCard
:active-label="label"
:team-id="teamId"
:folders-id="foldersId"
<!-- Expanded layout: wide screen + expanded setting -->
<ConversationCardExpanded
v-if="showExpanded"
:chat="source"
:conversation-type="conversationType"
:current-contact="currentContact"
:assignee="assignee"
:inbox="inbox"
:selected="isConversationSelected(source.id)"
:is-active-chat="isActiveChat"
:show-assignee="showAssigneeForExpandedCard"
:show-inbox-name="showInboxName"
:is-inbox-view="isInboxView"
@select-conversation="onExpandedSelect"
@de-select-conversation="onExpandedSelect"
@click="onCardClick"
@contextmenu="openContextMenu"
/>
<!-- Default (condensed) layout -->
<ConversationCard
v-else
:chat="source"
:current-contact="currentContact"
:assignee="assignee"
:inbox="inbox"
:selected="isConversationSelected(source.id)"
:is-active-chat="isActiveChat"
:show-assignee="showAssignee"
enable-context-menu
:show-inbox-name="showInboxName"
@click="onCardClick"
@contextmenu="openContextMenu"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
@assign-agent="assignAgent"
@assign-team="assignTeam"
@assign-label="assignLabels"
@remove-label="removeLabels"
@update-conversation-status="updateConversationStatus"
@context-menu-toggle="toggleContextMenu"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
/>
<!-- Shared context menu for both layouts -->
<ContextMenu
v-if="showContextMenu"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<ConversationContextMenu
:status="source.status"
:inbox-id="inbox.id"
:priority="source.priority"
:chat-id="source.id"
:has-unread-messages="source.unread_count > 0"
:conversation-labels="source.labels"
:conversation-url="conversationPath"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@remove-label="onRemoveLabel"
@assign-team="onAssignTeam"
@mark-as-unread="onMarkAsUnread"
@mark-as-read="onMarkAsRead"
@assign-priority="onAssignPriority"
@delete-conversation="onDeleteConversation"
@close="closeContextMenu"
/>
</ContextMenu>
</template>
@@ -0,0 +1,94 @@
<script setup>
import { ref, computed, provide } from 'vue';
import { Virtualizer } from 'virtua/vue';
import { useBreakpoints } from '@vueuse/core';
import { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
import ConversationItem from './ConversationItem.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import wootConstants from 'dashboard/constants/globals';
const props = defineProps({
conversationList: { type: Array, default: () => [] },
isLoading: { type: Boolean, default: false },
showEndOfListMessage: { type: Boolean, default: false },
label: { type: String, default: '' },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
conversationType: { type: String, default: '' },
showAssignee: { type: Boolean, default: false },
isOnExpandedLayout: { type: Boolean, default: false },
});
const emit = defineEmits(['loadMore']);
const conversationListRef = ref(null);
const virtualListRef = ref(null);
const isContextMenuOpen = ref(false);
provide('contextMenuElementTarget', virtualListRef);
const breakpoints = useBreakpoints({
lg: wootConstants.LARGE_SCREEN_BREAKPOINT,
});
const isLgScreen = breakpoints.greaterOrEqual('lg');
const showExpandedCards = computed(
() => props.isOnExpandedLayout && isLgScreen.value
);
useChatListKeyboardEvents(conversationListRef);
const intersectionObserverOptions = computed(() => ({
root: conversationListRef.value,
rootMargin: '100px 0px 100px 0px',
}));
const onContextMenuToggle = state => {
isContextMenuOpen.value = state;
};
const loadMoreConversations = () => {
emit('loadMore');
};
provide('toggleContextMenu', onContextMenuToggle);
defineExpose({ conversationListRef });
</script>
<template>
<div
ref="conversationListRef"
class="flex-1 min-h-0 overflow-y-auto conversations-list"
:class="{ '!overflow-hidden': isContextMenuOpen }"
>
<Virtualizer
ref="virtualListRef"
v-slot="{ item }"
:data="conversationList"
class="[&>div:has(+_div_.active)>*]:!border-n-surface-1 [&>div:has(+_div_.selected)>*]:!border-n-surface-1"
>
<ConversationItem
:source="item"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssignee"
:show-expanded="showExpandedCards"
/>
</Virtualizer>
<div v-if="isLoading" class="flex justify-center my-4">
<Spinner class="text-n-brand" />
</div>
<p v-else-if="showEndOfListMessage" class="p-4 text-center text-n-slate-11">
{{ $t('CHAT_LIST.EOF') }}
</p>
<IntersectionObserver
v-else
:options="intersectionObserverOptions"
@observed="loadMoreConversations"
/>
</div>
</template>
@@ -27,10 +27,6 @@ const props = defineProps({
type: Boolean,
default: true,
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
@@ -208,10 +204,7 @@ onMounted(() => {
<template>
<div class="space-y-2 mb-4">
<div
class="overflow-y-auto"
:class="{ 'max-h-96': isPopout, 'max-h-56': !isPopout }"
>
<div class="overflow-y-auto max-h-56">
<p
v-dompurify-html="formatMessage(generatedContent, false)"
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4"
@@ -984,7 +984,32 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
}
.ProseMirror-woot-style {
@apply overflow-auto min-h-[5rem] max-h-[7.5rem];
@apply overflow-auto;
}
.ProseMirror-woot-style:not(
:where(.resizable-editor-wrapper .ProseMirror-woot-style)
) {
@apply min-h-[5rem] max-h-[7.5rem];
}
// Resizable editor wrapper styles
.resizable-editor-wrapper {
.ProseMirror-woot-style {
min-height: clamp(
var(--editor-min-allowed, var(--editor-min-height, 5rem)),
var(--editor-height, var(--editor-min-height, 5rem)),
var(--editor-max-allowed, var(--editor-max-height, 7.5rem))
);
max-height: clamp(
var(--editor-min-allowed, var(--editor-min-height, 5rem)),
var(--editor-height, var(--editor-min-height, 5rem)),
var(--editor-max-allowed, var(--editor-max-height, 7.5rem))
);
transition:
min-height var(--editor-height-transition, 180ms ease),
max-height var(--editor-height-transition, 180ms ease);
}
}
.ProseMirror-prompt-backdrop::backdrop {
@@ -8,13 +8,22 @@ import {
EditorState,
Selection,
} from '@chatwoot/prosemirror-schema';
import {
suggestionsPlugin,
triggerCharacters,
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
import { toggleMark } from 'prosemirror-commands';
import { wrapInList } from 'prosemirror-schema-list';
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import SlashCommandMenu from './SlashCommandMenu.vue';
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const SLASH_MENU_OFFSET = 4;
const createState = (
content,
placeholder,
@@ -40,6 +49,7 @@ let editorView = null;
let state;
export default {
components: { SlashCommandMenu },
mixins: [keyboardEventListenerMixins],
props: {
modelValue: { type: String, default: '' },
@@ -62,8 +72,15 @@ export default {
},
data() {
return {
plugins: [imagePastePlugin(this.handleImageUpload)],
plugins: [
imagePastePlugin(this.handleImageUpload),
this.createSlashPlugin(),
],
isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection
showSlashMenu: false,
slashSearchTerm: '',
slashRange: null,
slashMenuPosition: null,
};
},
watch: {
@@ -95,6 +112,126 @@ export default {
}
},
methods: {
createSlashPlugin() {
return suggestionsPlugin({
matcher: triggerCharacters('/', 0),
suggestionClass: '',
onEnter: args => {
this.showSlashMenu = true;
this.slashRange = args.range;
this.slashSearchTerm = args.text || '';
this.updateSlashMenuPosition(args.range.from);
return false;
},
onChange: args => {
this.slashRange = args.range;
this.slashSearchTerm = args.text;
return false;
},
onExit: () => {
this.slashSearchTerm = '';
this.showSlashMenu = false;
this.slashMenuPosition = null;
return false;
},
onKeyDown: ({ event }) => {
return (
event.keyCode === 13 &&
this.showSlashMenu &&
this.$refs.slashMenu?.hasItems
);
},
});
},
updateSlashMenuPosition(pos) {
if (!editorView) return;
const coords = editorView.coordsAtPos(pos);
const editorRect = this.$refs.editor.getBoundingClientRect();
const isRtl = getComputedStyle(this.$refs.editor).direction === 'rtl';
this.slashMenuPosition = {
top: coords.bottom - editorRect.top + SLASH_MENU_OFFSET,
...(isRtl
? { right: editorRect.right - coords.right }
: { left: coords.left - editorRect.left }),
};
},
removeSlashTriggerText() {
if (!editorView || !this.slashRange) return;
const { from, to } = this.slashRange;
editorView.dispatch(editorView.state.tr.delete(from, to));
state = editorView.state;
},
executeSlashCommand(actionKey) {
if (!editorView) return;
this.removeSlashTriggerText();
const { schema } = editorView.state;
const commandMap = {
strong: () =>
toggleMark(schema.marks.strong)(
editorView.state,
editorView.dispatch
),
em: () =>
toggleMark(schema.marks.em)(editorView.state, editorView.dispatch),
strike: () =>
toggleMark(schema.marks.strike)(
editorView.state,
editorView.dispatch
),
code: () =>
toggleMark(schema.marks.code)(editorView.state, editorView.dispatch),
h1: () =>
toggleBlockType(schema.nodes.heading, { level: 1 })(
editorView.state,
editorView.dispatch
),
h2: () =>
toggleBlockType(schema.nodes.heading, { level: 2 })(
editorView.state,
editorView.dispatch
),
h3: () =>
toggleBlockType(schema.nodes.heading, { level: 3 })(
editorView.state,
editorView.dispatch
),
bulletList: () =>
wrapInList(schema.nodes.bullet_list)(
editorView.state,
editorView.dispatch
),
orderedList: () =>
wrapInList(schema.nodes.ordered_list)(
editorView.state,
editorView.dispatch
),
insertTable: () => {
const { table, table_row, table_header, table_cell, paragraph } =
schema.nodes;
const headerCells = [0, 1, 2].map(() =>
table_header.createAndFill(null, paragraph.create())
);
const dataCells = [0, 1, 2].map(() =>
table_cell.createAndFill(null, paragraph.create())
);
const headerRow = table_row.create(null, headerCells);
const dataRow = table_row.create(null, dataCells);
const tableNode = table.create(null, [headerRow, dataRow]);
const tr = editorView.state.tr.replaceSelectionWith(tableNode);
editorView.dispatch(tr.scrollIntoView());
},
};
const command = commandMap[actionKey];
if (command) {
command();
state = editorView.state;
this.emitOnChange();
editorView.focus();
}
},
contentFromEditor() {
if (editorView) {
return ArticleMarkdownSerializer.serialize(editorView.state.doc);
@@ -262,7 +399,8 @@ export default {
// Get the editor's width
const editorWidth = editor.offsetWidth;
const menubarWidth = 480; // Menubar width (adjust as needed (px))
const menubar = editor.querySelector('.ProseMirror-menubar');
const menubarWidth = menubar ? menubar.scrollWidth : 480;
// Get the end position of the selection
const { bottom: endBottom, right: endRight } = editorView.coordsAtPos(to);
@@ -290,7 +428,15 @@ export default {
<template>
<div>
<div class="editor-root editor--article">
<div class="editor-root editor--article relative">
<SlashCommandMenu
v-if="showSlashMenu"
ref="slashMenu"
:search-key="slashSearchTerm"
:enabled-menu-options="enabledMenuOptions"
:position="slashMenuPosition"
@select-action="executeSlashCommand"
/>
<input
ref="imageUploadInput"
type="file"
@@ -54,7 +54,7 @@ export default {
default: undefined,
},
},
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
emits: ['setReplyMode', 'toggleEditorSize', 'executeCopilotAction'],
setup(props, { emit }) {
const setReplyMode = mode => {
emit('setReplyMode', mode);
@@ -189,7 +189,7 @@ export default {
class="text-n-slate-11"
sm
icon="i-lucide-maximize-2"
@click="$emit('togglePopout')"
@click="$emit('toggleEditorSize')"
/>
</div>
</div>
@@ -0,0 +1,180 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
searchKey: {
type: String,
default: '',
},
enabledMenuOptions: {
type: Array,
default: () => [],
},
position: {
type: Object,
default: null,
},
});
const emit = defineEmits(['selectAction']);
const { t } = useI18n();
const EDITOR_ACTIONS = [
{
value: 'h1',
labelKey: 'SLASH_COMMANDS.HEADING_1',
icon: 'i-lucide-heading-1',
menuKey: 'h1',
},
{
value: 'h2',
labelKey: 'SLASH_COMMANDS.HEADING_2',
icon: 'i-lucide-heading-2',
menuKey: 'h2',
},
{
value: 'h3',
labelKey: 'SLASH_COMMANDS.HEADING_3',
icon: 'i-lucide-heading-3',
menuKey: 'h3',
},
{
value: 'strong',
labelKey: 'SLASH_COMMANDS.BOLD',
icon: 'i-lucide-bold',
menuKey: 'strong',
},
{
value: 'em',
labelKey: 'SLASH_COMMANDS.ITALIC',
icon: 'i-lucide-italic',
menuKey: 'em',
},
{
value: 'insertTable',
labelKey: 'SLASH_COMMANDS.TABLE',
icon: 'i-lucide-table',
menuKey: 'insertTable',
},
{
value: 'strike',
labelKey: 'SLASH_COMMANDS.STRIKETHROUGH',
icon: 'i-lucide-strikethrough',
menuKey: 'strike',
},
{
value: 'code',
labelKey: 'SLASH_COMMANDS.CODE',
icon: 'i-lucide-code',
menuKey: 'code',
},
{
value: 'bulletList',
labelKey: 'SLASH_COMMANDS.BULLET_LIST',
icon: 'i-lucide-list',
menuKey: 'bulletList',
},
{
value: 'orderedList',
labelKey: 'SLASH_COMMANDS.ORDERED_LIST',
icon: 'i-lucide-list-ordered',
menuKey: 'orderedList',
},
];
const listContainerRef = ref(null);
const selectedIndex = ref(0);
const items = computed(() => {
const search = props.searchKey.toLowerCase();
return EDITOR_ACTIONS.filter(action => {
if (!props.enabledMenuOptions.includes(action.menuKey)) return false;
if (!search) return true;
return t(action.labelKey).toLowerCase().includes(search);
});
});
const hasItems = computed(() => items.value.length > 0);
const menuStyle = computed(() => {
if (!props.position) return {};
const style = { top: `${props.position.top}px` };
if (props.position.right != null) {
style.right = `${props.position.right}px`;
} else {
style.left = `${props.position.left}px`;
}
return style;
});
const adjustScroll = () => {
nextTick(() => {
const container = listContainerRef.value;
if (!container) return;
const el = container.querySelector(`#slash-item-${selectedIndex.value}`);
if (el) {
el.scrollIntoView({ block: 'nearest', behavior: 'auto' });
}
});
};
const onSelect = () => {
const item = items.value[selectedIndex.value];
if (item) emit('selectAction', item.value);
};
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
// Reset selection when filtered items change
watch(items, () => {
selectedIndex.value = 0;
});
const onHover = index => {
selectedIndex.value = index;
};
const onItemClick = index => {
selectedIndex.value = index;
onSelect();
};
defineExpose({ hasItems });
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div
v-if="hasItems"
ref="listContainerRef"
class="bg-n-alpha-3 backdrop-blur-[100px] outline outline-1 outline-n-container absolute rounded-xl z-50 flex flex-col min-w-[10rem] shadow-lg p-2 overflow-auto max-h-[15rem]"
:style="menuStyle"
>
<button
v-for="(item, index) in items"
:id="`slash-item-${index}`"
:key="item.value"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 border-0 rounded-lg text-n-slate-12 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2"
:class="{
'bg-n-alpha-1 dark:bg-n-alpha-2': index === selectedIndex,
}"
@mouseover="onHover(index)"
@click="onItemClick(index)"
>
<Icon :icon="item.icon" class="flex-shrink-0 size-3.5" />
<span class="min-w-0 text-sm truncate">
{{ t(item.labelKey) }}
</span>
</button>
</div>
</template>
@@ -1,98 +1,41 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Avatar from 'next/avatar/Avatar.vue';
import MessagePreview from './MessagePreview.vue';
import InboxName from '../InboxName.vue';
import ConversationContextMenu from './contextMenu/Index.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue';
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
import UnreadBadge from 'dashboard/components-next/Conversation/ConversationCard/UnreadBadge.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
activeLabel: { type: String, default: '' },
chat: { type: Object, default: () => ({}) },
hideInboxName: { type: Boolean, default: false },
hideThumbnail: { type: Boolean, default: false },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
showAssignee: { type: Boolean, default: false },
conversationType: { type: String, default: '' },
chat: { type: Object, required: true },
currentContact: { type: Object, required: true },
assignee: { type: Object, default: () => ({}) },
inbox: { type: Object, default: () => ({}) },
selected: { type: Boolean, default: false },
isActiveChat: { type: Boolean, default: false },
showAssignee: { type: Boolean, default: false },
showInboxName: { type: Boolean, default: false },
hideThumbnail: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
enableContextMenu: { type: Boolean, default: false },
allowedContextMenuOptions: { type: Array, default: () => [] },
});
const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
'removeLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
'click',
'contextmenu',
'selectConversation',
'deSelectConversation',
]);
const router = useRouter();
const store = useStore();
const hovered = ref(false);
const showContextMenu = ref(false);
const contextMenu = ref({ x: null, y: null });
// Reset UI state when conversation changes at same index (no :key, instance reused on reorder)
// This prevents context menu/hover state from leaking to a different conversation
// Emit contextMenuToggle(false) to sync parent state if menu was open during recycling
const resetState = () => {
if (showContextMenu.value) {
emit('contextMenuToggle', false);
}
hovered.value = false;
showContextMenu.value = false;
contextMenu.value = { x: null, y: null };
};
watch(() => props.chat.id, resetState);
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
const chatMetadata = computed(() => props.chat.meta || {});
const assignee = computed(() => chatMetadata.value.assignee || {});
const senderId = computed(() => chatMetadata.value.sender?.id);
const currentContact = computed(() => {
return senderId.value
? store.getters['contacts/getContact'](senderId.value)
: {};
});
const isActiveChat = computed(() => {
return currentChat.value.id === props.chat.id;
});
const unreadCount = computed(() => props.chat.unread_count);
const hasUnread = computed(() => unreadCount.value > 0);
const isInboxNameVisible = computed(() => !activeInbox.value);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const voiceCallData = computed(() => ({
@@ -100,24 +43,10 @@ const voiceCallData = computed(() => ({
direction: props.chat.additional_attributes?.call_direction,
}));
const inboxId = computed(() => props.chat.inbox_id);
const inbox = computed(() => {
return inboxId.value ? store.getters['inboxes/getInbox'](inboxId.value) : {};
});
const showInboxName = computed(() => {
return (
!props.hideInboxName &&
isInboxNameVisible.value &&
inboxesList.value.length > 1
);
});
const showMetaSection = computed(() => {
return (
showInboxName.value ||
(props.showAssignee && assignee.value.name) ||
props.showInboxName ||
(props.showAssignee && props.assignee.name) ||
props.chat.priority
);
});
@@ -136,41 +65,6 @@ const messagePreviewClass = computed(() => {
];
});
const conversationPath = computed(() => {
return frontendURL(
conversationUrl({
accountId: accountId.value,
activeInbox: activeInbox.value,
id: props.chat.id,
label: props.activeLabel,
teamId: props.teamId,
conversationType: props.conversationType,
foldersId: props.foldersId,
})
);
});
const onCardClick = e => {
const path = conversationPath.value;
if (!path) return;
// Handle Ctrl/Cmd + Click for new tab
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
// Skip if already active
if (isActiveChat.value) return;
router.push({ path });
};
const onThumbnailHover = () => {
hovered.value = !props.hideThumbnail;
};
@@ -181,83 +75,37 @@ const onThumbnailLeave = () => {
const onSelectConversation = checked => {
if (checked) {
emit('selectConversation', props.chat.id, inbox.value.id);
emit('selectConversation', props.chat.id, props.inbox.id);
} else {
emit('deSelectConversation', props.chat.id, inbox.value.id);
emit('deSelectConversation', props.chat.id, props.inbox.id);
}
};
const openContextMenu = e => {
if (!props.enableContextMenu) return;
e.preventDefault();
emit('contextMenuToggle', true);
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const selectedModel = computed({
get: () => props.selected,
set: value => onSelectConversation(value),
});
const closeContextMenu = () => {
emit('contextMenuToggle', false);
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
};
const onUpdateConversation = (status, snoozedUntil) => {
closeContextMenu();
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
};
const onAssignAgent = agent => {
emit('assignAgent', agent, [props.chat.id]);
closeContextMenu();
};
const onAssignLabel = label => {
emit('assignLabel', [label.title], [props.chat.id]);
};
const onRemoveLabel = label => {
emit('removeLabel', [label.title], [props.chat.id]);
};
const onAssignTeam = team => {
emit('assignTeam', team, props.chat.id);
closeContextMenu();
};
const markAsUnread = () => {
emit('markAsUnread', props.chat.id);
closeContextMenu();
};
const markAsRead = () => {
emit('markAsRead', props.chat.id);
closeContextMenu();
};
const assignPriority = priority => {
emit('assignPriority', priority, props.chat.id);
closeContextMenu();
};
const deleteConversation = () => {
emit('deleteConversation', props.chat.id);
closeContextMenu();
};
watch(
() => props.chat.id,
() => {
hovered.value = false;
}
);
</script>
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 border-t-0 border-b-0 border-l-0 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 cursor-pointer conversation border-b border-n-slate-3 hover:border-n-surface-1 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
:class="{
'active animate-card-select bg-n-background border-n-weak': isActiveChat,
'bg-n-slate-2': selected,
'active animate-card-select bg-n-background !border-n-surface-1':
isActiveChat,
'selected bg-n-slate-2 !border-n-surface-1': selected,
'px-0': compact,
'px-3': !compact,
}"
@click="onCardClick"
@contextmenu="openContextMenu($event)"
@click="$emit('click', $event)"
@contextmenu="$emit('contextmenu', $event)"
>
<div
class="relative"
@@ -272,7 +120,6 @@ const deleteConversation = () => {
:status="currentContact.availability_status"
:class="!showInboxName ? 'mt-4' : 'mt-8'"
hide-offline-status
rounded-full
>
<template #overlay="{ size }">
<label
@@ -281,20 +128,12 @@ const deleteConversation = () => {
:style="{ width: `${size}px`, height: `${size}px` }"
@click.stop
>
<input
:value="selected"
:checked="selected"
class="!m-0 cursor-pointer"
type="checkbox"
@change="onSelectConversation($event.target.checked)"
/>
<Checkbox v-model="selectedModel" />
</label>
</template>
</Avatar>
</div>
<div
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
>
<div class="px-0 py-3 flex-1 min-w-0 border-line">
<div
v-if="showMetaSection"
class="flex items-center min-w-0 gap-1"
@@ -369,12 +208,11 @@ const deleteConversation = () => {
:conversation-id="chat.id"
/>
</span>
<span
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
:class="hasUnread ? 'block' : 'hidden'"
>
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
<UnreadBadge
v-if="hasUnread"
:count="unreadCount"
class="ltr:ml-auto rtl:mr-auto mt-1"
/>
</div>
<CardLabels
v-if="showLabelsSection"
@@ -386,32 +224,5 @@ const deleteConversation = () => {
</template>
</CardLabels>
</div>
<ContextMenu
v-if="showContextMenu"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<ConversationContextMenu
:status="chat.status"
:inbox-id="inbox.id"
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="hasUnread"
:conversation-labels="chat.labels"
:conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@remove-label="onRemoveLabel"
@assign-team="onAssignTeam"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
@close="closeContextMenu"
/>
</ContextMenu>
</div>
</template>
@@ -45,6 +45,7 @@ const backButtonUrl = computed(() => {
const conversationTypeMap = {
conversation_through_mentions: 'mention',
conversation_through_participating: 'participating',
conversation_through_unattended: 'unattended',
};
return conversationListPageURL({
@@ -41,7 +41,16 @@ const closeContactPanel = () => {
<template>
<div
v-on-click-outside="() => closeContactPanel()"
v-on-click-outside="[
() => closeContactPanel(),
{
ignore: [
'dialog.ProseMirror-prompt-backdrop',
'[data-popover-content]',
'[data-popover-backdrop]',
],
},
]"
class="bg-n-surface-2 h-full overflow-hidden flex flex-col fixed top-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out ltr:right-0 rtl:left-0 md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
:class="[
{
@@ -16,10 +16,6 @@ defineProps({
type: String,
default: '',
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
@@ -69,7 +65,6 @@ const onSend = () => {
:generated-content="generatedContent"
:min-height="4"
:enabled-menu-options="[]"
:is-popout="isPopout"
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"
@@ -1,7 +1,7 @@
<script>
import { ref, provide } from 'vue';
import { ref, provide, useTemplateRef } from 'vue';
import { useElementSize } from '@vueuse/core';
// composable
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
@@ -11,6 +11,7 @@ import MessageList from 'next/message/MessageList.vue';
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ResizableEditorWrapper from './ResizableEditorWrapper.vue';
// stores and apis
import { mapGetters } from 'vuex';
@@ -43,21 +44,16 @@ export default {
Banner,
ConversationLabelSuggestion,
Spinner,
ResizableEditorWrapper,
},
mixins: [inboxMixin],
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const keyboardEvents = {
Escape: {
action: () => {
isPopOutReplyBox.value = false;
},
},
};
useKeyboardEvents(keyboardEvents);
const resizableEditorWrapperRef = ref(null);
const messagesViewRef = useTemplateRef('messagesViewRef');
const topBannerRef = useTemplateRef('topBannerRef');
const { height: containerHeight } = useElementSize(messagesViewRef);
const { height: topBannerHeight } = useElementSize(topBannerRef);
const {
captainTasksEnabled,
@@ -68,11 +64,15 @@ export default {
provide('contextMenuElementTarget', conversationPanelRef);
return {
isPopOutReplyBox,
captainTasksEnabled,
getLabelSuggestions,
isLabelSuggestionFeatureEnabled,
conversationPanelRef,
resizableEditorWrapperRef,
messagesViewRef,
topBannerRef,
containerHeight,
topBannerHeight,
};
},
data() {
@@ -254,6 +254,7 @@ export default {
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
this.messageSentSinceOpened = false;
this.resetReplyEditorHeight();
},
},
@@ -437,26 +438,37 @@ export default {
const payload = useSnakeCase(message);
await this.$store.dispatch('sendMessageWithData', payload);
},
toggleReplyEditorSize() {
this.resizableEditorWrapperRef?.toggleEditorExpand?.();
},
resetReplyEditorHeight() {
this.resizableEditorWrapperRef?.resetEditorHeight?.();
},
},
};
</script>
<template>
<div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0">
<Banner
v-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
:href-link="replyWindowLink"
:href-link-text="replyWindowLinkText"
/>
<Banner
v-else-if="hasDuplicateInstagramInbox"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
<div
ref="messagesViewRef"
class="flex flex-col justify-between flex-grow h-full min-w-0 m-0"
>
<div ref="topBannerRef">
<Banner
v-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
:href-link="replyWindowLink"
:href-link-text="replyWindowLinkText"
/>
<Banner
v-else-if="hasDuplicateInstagramInbox"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
</div>
<MessageList
ref="conversationPanelRef"
class="conversation-panel flex-shrink flex-grow basis-px flex flex-col overflow-y-auto relative h-full m-0 pb-4"
@@ -498,13 +510,7 @@ export default {
/>
</template>
</MessageList>
<div
class="flex relative flex-col"
:class="{
'modal-mask': isPopOutReplyBox,
'bg-n-surface-1': !isPopOutReplyBox,
}"
>
<div class="flex relative flex-col bg-n-surface-1">
<div
v-if="isAnyoneTyping"
class="absolute flex items-center w-full h-0 -top-7"
@@ -520,42 +526,12 @@ export default {
/>
</div>
</div>
<ReplyBox
:pop-out-reply-box="isPopOutReplyBox"
@update:pop-out-reply-box="isPopOutReplyBox = $event"
/>
<ResizableEditorWrapper
ref="resizableEditorWrapperRef"
:container-height="Math.max(0, containerHeight - topBannerHeight)"
>
<ReplyBox @toggle-editor-size="toggleReplyEditorSize" />
</ResizableEditorWrapper>
</div>
</div>
</template>
<style scoped lang="scss">
.modal-mask {
@apply fixed;
&::v-deep {
.ProseMirror-woot-style {
@apply max-h-[25rem];
}
.reply-box {
@apply border border-n-weak max-w-[75rem] w-[70%];
&.is-private {
@apply dark:border-n-amber-3/30 border-n-amber-12/5;
}
}
.reply-box .reply-box__top {
@apply relative min-h-[27.5rem];
}
.reply-box__top .input {
@apply min-h-[27.5rem];
}
.emoji-dialog {
@apply absolute ltr:left-auto rtl:right-auto bottom-1;
}
}
}
</style>
@@ -81,13 +81,7 @@ export default {
CopilotReplyBottomPanel,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
popOutReplyBox: {
type: Boolean,
default: false,
},
},
emits: ['update:popOutReplyBox'],
emits: ['toggleEditorSize'],
setup() {
const {
uiSettings,
@@ -797,7 +791,6 @@ export default {
this.clearMessage();
this.hideEmojiPicker();
this.$emit('update:popOutReplyBox', false);
}
},
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
@@ -1217,8 +1210,9 @@ export default {
file => !file?.isRecordedAudio
);
},
togglePopout() {
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
toggleEditorSize() {
this.$emit('toggleEditorSize');
this.$nextTick(() => this.messageEditor?.focusEditorInputField());
},
onSubmitCopilotReply() {
const acceptedMessage = this.copilot.accept();
@@ -1244,9 +1238,8 @@ export default {
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
:editor-content="message"
:popout-reply-box="popOutReplyBox"
@set-reply-mode="setReplyMode"
@toggle-popout="togglePopout"
@toggle-editor-size="toggleEditorSize"
@toggle-copilot="copilot.toggleEditor"
@execute-copilot-action="executeCopilotAction"
/>
@@ -1275,7 +1268,7 @@ export default {
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
:class="{
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
'emoji-dialog--expanded': isOnExpandedLayout,
}"
:on-click="addIntoEditor"
/>
@@ -1299,7 +1292,6 @@ export default {
: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"
@@ -0,0 +1,154 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, useTemplateRef } from 'vue';
import { useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
const props = defineProps({
containerHeight: { type: Number, default: 0 },
});
const DEFAULT_HEIGHT = 120;
const MIN_HEIGHT = 80;
const MIN_MESSAGES_HEIGHT = 200;
const EXPAND_RATIO = 0.5;
const RESET_DELAY_MS = 120;
const wrapperRef = useTemplateRef('wrapperRef');
const surroundingHeight = ref(0);
const editorHeight = ref(DEFAULT_HEIGHT);
const isResizing = ref(false);
const startY = ref(0);
const startHeight = ref(0);
let resetTimeoutId = null;
const clamp = (val, min, max) => Math.min(Math.max(val, min), max);
// Measure height of elements surrounding the editor (top panel, email fields, bottom panel)
const measureSurroundingHeight = () => {
if (wrapperRef.value) {
surroundingHeight.value = Math.max(
0,
wrapperRef.value.offsetHeight - editorHeight.value
);
}
};
const isContainerReady = computed(() => props.containerHeight > 0);
const sizeBounds = computed(() => {
const h = props.containerHeight;
const s = surroundingHeight.value;
const max = Math.max(MIN_HEIGHT, h - MIN_MESSAGES_HEIGHT - s);
const expanded = clamp(Math.floor(h * EXPAND_RATIO - s / 2), MIN_HEIGHT, max);
return {
min: MIN_HEIGHT,
max: isContainerReady.value ? max : DEFAULT_HEIGHT,
expanded,
default: clamp(DEFAULT_HEIGHT, MIN_HEIGHT, max),
};
});
const clampToBounds = val =>
clamp(val, sizeBounds.value.min, sizeBounds.value.max);
const clearDragStyles = () => {
Object.assign(document.body.style, { cursor: '', userSelect: '' });
};
const getClientY = e => (e.touches ? e.touches[0].clientY : e.clientY);
const onResizeStart = event => {
editorHeight.value = clampToBounds(editorHeight.value);
measureSurroundingHeight();
isResizing.value = true;
startY.value = getClientY(event);
startHeight.value = clampToBounds(editorHeight.value);
editorHeight.value = startHeight.value;
Object.assign(document.body.style, {
cursor: 'row-resize',
userSelect: 'none',
});
};
const onResizeMove = event => {
if (!isResizing.value) return;
if (event.touches) event.preventDefault();
editorHeight.value = clampToBounds(
startHeight.value + startY.value - getClientY(event)
);
};
const onResizeEnd = () => {
if (!isResizing.value) return;
isResizing.value = false;
clearDragStyles();
};
const resetEditorHeight = () => {
editorHeight.value = sizeBounds.value.default;
};
const toggleEditorExpand = () => {
editorHeight.value = clampToBounds(editorHeight.value);
measureSurroundingHeight();
const { expanded, max, default: defaultHeight } = sizeBounds.value;
const isExpanded = editorHeight.value > defaultHeight;
// If expanded is too close to default, use max so the toggle is always noticeable
const target = expanded - defaultHeight < 100 ? max : expanded;
editorHeight.value = isExpanded ? defaultHeight : target;
};
const handleMessageSent = () => {
clearTimeout(resetTimeoutId);
resetTimeoutId = setTimeout(resetEditorHeight, RESET_DELAY_MS);
};
onMounted(() => {
emitter.on(BUS_EVENTS.MESSAGE_SENT, handleMessageSent);
});
onBeforeUnmount(() => {
emitter.off(BUS_EVENTS.MESSAGE_SENT, handleMessageSent);
clearTimeout(resetTimeoutId);
if (isResizing.value) {
isResizing.value = false;
clearDragStyles();
}
});
useEventListener(document, 'mousemove', onResizeMove);
useEventListener(document, 'mouseup', onResizeEnd);
useEventListener(document, 'touchmove', onResizeMove, { passive: false });
useEventListener(document, 'touchend', onResizeEnd);
useEventListener(document, 'touchcancel', onResizeEnd);
useEventListener(window, 'blur', onResizeEnd);
defineExpose({ toggleEditorExpand, resetEditorHeight });
</script>
<template>
<div
ref="wrapperRef"
class="relative resizable-editor-wrapper"
:style="{
'--editor-height': editorHeight + 'px',
'--editor-min-allowed': sizeBounds.min + 'px',
'--editor-max-allowed': sizeBounds.max + 'px',
'--editor-height-transition': isResizing ? 'none' : '180ms ease',
}"
>
<div
class="group absolute inset-x-0 -top-4 z-10 flex h-4 cursor-row-resize select-none items-center justify-center bg-gradient-to-b from-transparent from-10% dark:to-n-surface-1/80 to-n-surface-1/90 backdrop-blur-[0.01875rem]"
@mousedown="onResizeStart"
@touchstart.prevent="onResizeStart"
@dblclick="resetEditorHeight"
>
<div
class="w-8 h-0.5 mt-1 rounded-full bg-n-slate-6 group-hover:bg-n-slate-8 transition-all duration-200 motion-safe:group-hover:animate-bounce"
:class="{ 'bg-n-slate-8 animate-bounce': isResizing }"
/>
</div>
<slot />
</div>
</template>
@@ -92,7 +92,9 @@ describe('useAutomation', () => {
case 'assign_team':
return teams;
case 'assign_agent':
return agents;
return options.addNoneToListFn
? options.addNoneToListFn(options.agents)
: options.agents;
case 'send_email_to_team':
return teams;
case 'send_message':
@@ -240,7 +242,11 @@ describe('useAutomation', () => {
expect(getActionDropdownValues('add_label')).toEqual(labels);
expect(getActionDropdownValues('assign_team')).toEqual(teams);
expect(getActionDropdownValues('assign_agent')).toEqual(agents);
expect(getActionDropdownValues('assign_agent')).toEqual([
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
{ id: 'last_responding_agent', name: 'AUTOMATION.LAST_RESPONDING_AGENT' },
...agents,
]);
expect(getActionDropdownValues('send_email_to_team')).toEqual(teams);
expect(getActionDropdownValues('send_message')).toEqual([]);
expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies);
@@ -0,0 +1,239 @@
import { ref } from 'vue';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
// Mock @vueuse/core — return reactive refs we can control per test
const mockBounding = () => ({
top: ref(0),
bottom: ref(0),
left: ref(0),
right: ref(0),
width: ref(0),
height: ref(0),
update: vi.fn(),
});
const triggerBounds = mockBounding();
const dropdownBounds = mockBounding();
const winWidth = ref(1024);
const winHeight = ref(768);
vi.mock('@vueuse/core', () => {
let callCount = 0;
return {
// First call = trigger, second = dropdown, third = container (if any)
useElementBounding: () => {
callCount += 1;
return callCount % 3 === 1 ? triggerBounds : dropdownBounds;
},
useWindowSize: () => ({ width: winWidth, height: winHeight }),
};
});
const setTrigger = ({ top, bottom, left, right }) => {
triggerBounds.top.value = top;
triggerBounds.bottom.value = bottom;
triggerBounds.left.value = left ?? 100;
triggerBounds.right.value = right ?? 200;
};
const setDropdown = ({ width, height }) => {
dropdownBounds.width.value = width ?? 200;
dropdownBounds.height.value = height;
};
describe('useDropdownPosition', () => {
beforeEach(() => {
winWidth.value = 1024;
winHeight.value = 768;
document.body.innerHTML = '<div id="app" dir="ltr"></div>';
});
describe('verticalClass (relative mode)', () => {
it('places below when enough space', () => {
// Trigger at y=100, dropdown 200px tall
// Space below = 768 - 140 = 628 → fits (628 > 216)
setTrigger({ top: 100, bottom: 140 });
setDropdown({ height: 200 });
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
expect(position.value.class).toBe('top-full mt-2');
});
it('places above when not enough space below but enough above', () => {
// Trigger near bottom at y=600
// Space below = 128 → doesn't fit. Space above = 600 → fits
setTrigger({ top: 600, bottom: 640 });
setDropdown({ height: 200 });
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
expect(position.value.class).toBe('bottom-full mb-2');
});
it('picks the side with more space when dropdown fits neither', () => {
// Dropdown 500px tall, won't fit above (300) or below (428)
// Below has more room → stays below
setTrigger({ top: 300, bottom: 340 });
setDropdown({ height: 500 });
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
expect(position.value.class).toBe('top-full mt-2');
});
it('picks above when above has more space and neither fits', () => {
// Dropdown 600px tall, won't fit above (500) or below (228)
// Above has more room → flips above
setTrigger({ top: 500, bottom: 540 });
setDropdown({ height: 600 });
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
expect(position.value.class).toBe('bottom-full mb-2');
});
it('returns default when disabled', () => {
setTrigger({ top: 700, bottom: 740 });
setDropdown({ height: 200 });
const { position } = useDropdownPosition(
ref(null),
ref(null),
ref(false)
);
expect(position.value.class).toBe('top-full mt-2');
expect(position.value.style).toEqual({});
});
});
describe('fixedPosition', () => {
it('places below with correct top and maxHeight', () => {
// Trigger at y=140, space below = 628
// top = 140 + 8(gap) = 148
// maxHeight = 628 - 8(gap) - 16(margin) = 604
setTrigger({ top: 100, bottom: 140 });
setDropdown({ height: 200, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.top).toBe('148px');
expect(fixedPosition.value.style.bottom).toBeUndefined();
expect(fixedPosition.value.style.maxHeight).toBe('604px');
});
it('flips above with correct bottom and maxHeight', () => {
// Trigger near bottom at y=650, space below = 78 → doesn't fit
// Flips above: bottom = 768 - 650 + 8 = 126
// maxHeight = 650 - 8 - 16 = 626
setTrigger({ top: 650, bottom: 690 });
setDropdown({ height: 200, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.bottom).toBe('126px');
expect(fixedPosition.value.style.top).toBeUndefined();
expect(fixedPosition.value.style.maxHeight).toBe('626px');
});
it('constrains maxHeight to available space on short viewports', () => {
// Short viewport (400px), trigger in the middle, dropdown 500px tall
// Neither side fits → above (200) > below (160) → places above
// maxHeight capped to 200 - 8 - 16 = 176
winHeight.value = 400;
setTrigger({ top: 200, bottom: 240 });
setDropdown({ height: 500, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.bottom).toBeDefined();
expect(fixedPosition.value.style.maxHeight).toBe('176px');
});
it('returns defaults when disabled', () => {
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(false)
);
expect(fixedPosition.value.class).toBe('fixed z-[9999]');
expect(fixedPosition.value.style).toEqual({});
});
});
describe('horizontal positioning (fixedPosition)', () => {
it('anchors to the right edge by default (align=end, LTR)', () => {
// align=end + LTR → anchorLeft=false → uses style.right
// right = 1024 - 900 = 124
setTrigger({ top: 100, bottom: 140, left: 800, right: 900 });
setDropdown({ height: 100, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.right).toBe('124px');
});
it('anchors to the left edge when align=start (LTR)', () => {
// align=start + LTR → anchorLeft=true → uses style.left
setTrigger({ top: 100, bottom: 140, left: 100, right: 200 });
setDropdown({ height: 100, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true),
{ align: 'start' }
);
expect(fixedPosition.value.style.left).toBe('100px');
});
it('shifts left when dropdown overflows right edge', () => {
// Trigger at x=900, dropdown 300px wide → 900+300=1200 > 1024
// Falls back to right: 16px (margin)
setTrigger({ top: 100, bottom: 140, left: 900, right: 1000 });
setDropdown({ height: 100, width: 300 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true),
{ align: 'start' }
);
expect(fixedPosition.value.style.right).toBe('16px');
});
});
describe('RTL', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="app" dir="rtl"></div>';
});
it('flips anchor direction in RTL (align=end anchors left)', () => {
// align=end + RTL → anchorLeft=true → uses style.left
setTrigger({ top: 100, bottom: 140, left: 100, right: 200 });
setDropdown({ height: 100, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.left).toBe('100px');
});
});
});
@@ -16,7 +16,20 @@ describe('useEditableAutomation', () => {
return [];
}),
getActionDropdownValues: vi.fn(),
getActionDropdownValues: vi.fn(actionName => {
if (actionName === 'assign_agent') {
return [
{ id: 'nil', name: 'None' },
{
id: 'last_responding_agent',
name: 'Last Responding Agent',
},
{ id: 1, name: 'Agent 1' },
];
}
return [];
}),
});
});
@@ -51,4 +64,35 @@ describe('useEditableAutomation', () => {
},
]);
});
it('rehydrates last responding agent as a selected action option', () => {
const automation = {
event_name: 'conversation_created',
conditions: [],
actions: [
{
action_name: 'assign_agent',
action_params: ['last_responding_agent'],
},
],
};
const automationActionTypes = [
{ key: 'assign_agent', inputType: 'search_select' },
];
const { formatAutomation } = useEditableAutomation();
const result = formatAutomation(automation, [], {}, automationActionTypes);
expect(result.actions).toEqual([
{
action_name: 'assign_agent',
action_params: [
{
id: 'last_responding_agent',
name: 'Last Responding Agent',
},
],
},
]);
});
});
@@ -119,24 +119,30 @@ describe('useMacros', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toHaveLength(mockLabels.length);
expect(getMacroDropdownValues('assign_team')).toHaveLength(
mockTeams.length
);
mockTeams.length + 1
); // +1 for "None"
expect(getMacroDropdownValues('assign_agent')).toHaveLength(
mockAgents.length + 1
); // +1 for "Self"
mockAgents.length + 2
); // +2 for "None" and "Self"
});
it('returns teams for assign_team and send_email_to_team types', () => {
it('returns teams with "None" option for assign_team and teams only for send_email_to_team', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('assign_team')).toEqual(mockTeams);
const assignTeamResult = getMacroDropdownValues('assign_team');
expect(assignTeamResult[0]).toEqual({
id: 'nil',
name: 'AUTOMATION.NONE_OPTION',
});
expect(assignTeamResult.slice(1)).toEqual(mockTeams);
expect(getMacroDropdownValues('send_email_to_team')).toEqual(mockTeams);
});
it('returns agents with "Self" option for assign_agent type', () => {
it('returns agents with "None" and "Self" options for assign_agent type', () => {
const { getMacroDropdownValues } = useMacros();
const result = getMacroDropdownValues('assign_agent');
expect(result[0]).toEqual({ id: 'self', name: 'Self' });
expect(result.slice(1)).toEqual(mockAgents);
expect(result[0]).toEqual({ id: 'nil', name: 'AUTOMATION.NONE_OPTION' });
expect(result[1]).toEqual({ id: 'self', name: 'Self' });
expect(result.slice(2)).toEqual(mockAgents);
});
it('returns formatted labels for add_label and remove_label types', () => {
@@ -172,8 +178,11 @@ describe('useMacros', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toEqual([]);
expect(getMacroDropdownValues('assign_team')).toEqual([]);
expect(getMacroDropdownValues('assign_team')).toEqual([
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
]);
expect(getMacroDropdownValues('assign_agent')).toEqual([
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
{ id: 'self', name: 'Self' },
]);
});
@@ -121,8 +121,19 @@ export default function useAutomationValues() {
* @returns {Array} An array of action dropdown values.
*/
const getActionDropdownValues = type => {
let agentsList = agents.value;
if (type === 'assign_agent') {
agentsList = [
{
id: 'last_responding_agent',
name: t('AUTOMATION.LAST_RESPONDING_AGENT'),
},
...agentsList,
];
}
return getActionOptions({
agents: agents.value,
agents: agentsList,
labels: labels.value,
teams: teams.value,
slaPolicies: slaPolicies.value,
@@ -0,0 +1,128 @@
import { computed, unref, watch } from 'vue';
import { useElementBounding, useWindowSize } from '@vueuse/core';
const FALLBACK_SIZE = 200;
const SAFE_MARGIN = 16;
const GAP = 8;
/**
* Auto-position a floating element based on available viewport space.
*
* @param {Ref} triggerRef - Trigger element ref
* @param {Ref} dropdownRef - Dropdown/popover element ref
* @param {Ref} enabled - Whether to calculate position
* @param {Object} options
* @param {Ref} [options.container] - Constraining container ref
* @param {number} [options.margin=16] - Min distance from viewport/container edges
* @param {string} [options.align='end'] - 'start' or 'end' (flips automatically for RTL)
*/
export function useDropdownPosition(
triggerRef,
dropdownRef,
enabled,
{ container = null, margin = SAFE_MARGIN, align = 'end' } = {}
) {
const trigger = useElementBounding(triggerRef);
const dropdown = useElementBounding(dropdownRef);
const bounds = useElementBounding(container);
const { width: winWidth, height: winHeight } = useWindowSize();
const isRTL = computed(
() => document.querySelector('#app[dir]')?.getAttribute('dir') === 'rtl'
);
// Whether to anchor to the left edge of the trigger
const anchorLeft = computed(() => (align === 'start') !== isRTL.value);
const verticalClass = computed(() => {
if (!unref(enabled)) return 'top-full mt-2';
const dh = dropdown.height.value || FALLBACK_SIZE;
const spaceBelow = winHeight.value - trigger.bottom.value;
const spaceAbove = trigger.top.value;
// Only flip above if it fits there; otherwise stay below (more room or equal)
if (spaceBelow >= dh + margin) return 'top-full mt-2';
if (spaceAbove >= dh + margin) return 'bottom-full mb-2';
return spaceBelow >= spaceAbove ? 'top-full mt-2' : 'bottom-full mb-2';
});
// Relative mode: Tailwind class + style for absolute-in-parent dropdowns
const position = computed(() => {
if (!unref(enabled)) return { class: 'top-full mt-2', style: {} };
const dw = dropdown.width.value || FALLBACK_SIZE;
const leftBound = container ? bounds.left.value : 0;
const rightBound = container ? bounds.right.value : winWidth.value;
const style = {};
if (anchorLeft.value) {
const available = rightBound - trigger.left.value;
const overflow = dw - available;
style.left = overflow > 0 ? `-${overflow}px` : '0px';
} else {
const available = trigger.right.value - leftBound;
const overflow = dw - available;
style.right = overflow > 0 ? `-${overflow}px` : '0px';
}
return { class: verticalClass.value, style };
});
// Fixed mode: styles for teleported popovers
const fixedPosition = computed(() => {
if (!unref(enabled)) return { class: 'fixed z-[9999]', style: {} };
const dh = dropdown.height.value || FALLBACK_SIZE;
const dw = dropdown.width.value || FALLBACK_SIZE;
const spaceBelow = winHeight.value - trigger.bottom.value;
const style = {};
// Vertical: prefer below, flip above only if it fits, else pick the larger side
const spaceAbove = trigger.top.value;
const placeAbove =
spaceBelow < dh + margin &&
(spaceAbove >= dh + margin || spaceAbove > spaceBelow);
if (placeAbove) {
style.bottom = `${winHeight.value - trigger.top.value + GAP}px`;
style.maxHeight = `${spaceAbove - GAP - margin}px`;
} else {
style.top = `${trigger.bottom.value + GAP}px`;
style.maxHeight = `${spaceBelow - GAP - margin}px`;
}
// Horizontal
if (anchorLeft.value) {
const left = trigger.left.value;
if (left + dw > winWidth.value - margin) {
style.right = `${margin}px`;
} else {
style.left = `${Math.max(margin, left)}px`;
}
} else {
const right = winWidth.value - trigger.right.value;
if (trigger.right.value - dw < margin) {
style.left = `${margin}px`;
} else {
style.right = `${right}px`;
}
}
return { class: 'fixed z-[9999]', style };
});
const updatePosition = () => {
trigger.update();
dropdown.update();
if (container) bounds.update();
};
// Update position when dropdown opens to ensure RTL state is current
watch(
() => unref(enabled),
isEnabled => {
if (isEnabled) updatePosition();
}
);
return { position, fixedPosition, updatePosition };
}
@@ -12,11 +12,14 @@ import { useKeyboardEvents } from './useKeyboardEvents';
/**
* Wrap the action in a function that calls the action and prevents the default event behavior.
* Only prevents default when items are available to navigate.
* @param {Function} action - The action to be called.
* @param {import('vue').Ref<Array>} items - A ref to the array of selectable items.
* @returns {{action: Function, allowOnFocusedInput: boolean}} An object containing the action and a flag to allow the event on focused input.
*/
const createAction = action => ({
const createAction = (action, items) => ({
action: e => {
if (!items.value?.length) return;
action();
e.preventDefault();
},
@@ -38,15 +41,14 @@ const createKeyboardEvents = (
items
) => {
const events = {
ArrowUp: createAction(moveSelectionUp),
'Control+KeyP': createAction(moveSelectionUp),
ArrowDown: createAction(moveSelectionDown),
'Control+KeyN': createAction(moveSelectionDown),
ArrowUp: createAction(moveSelectionUp, items),
'Control+KeyP': createAction(moveSelectionUp, items),
ArrowDown: createAction(moveSelectionDown, items),
'Control+KeyN': createAction(moveSelectionDown, items),
};
// Adds an event handler for the Enter key if the onSelect function is provided.
if (typeof onSelect === 'function') {
events.Enter = createAction(() => items.value?.length > 0 && onSelect());
events.Enter = createAction(onSelect, items);
}
return events;
@@ -15,6 +15,11 @@ export const useMacros = () => {
const teams = computed(() => getters['teams/getTeams'].value);
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
const withNoneOption = options => [
{ id: 'nil', name: t('AUTOMATION.NONE_OPTION') },
...(options || []),
];
/**
* Get dropdown values based on the specified type
* @param {string} type - The type of dropdown values to retrieve
@@ -23,10 +28,15 @@ export const useMacros = () => {
const getMacroDropdownValues = type => {
switch (type) {
case 'assign_team':
return withNoneOption(teams.value);
case 'send_email_to_team':
return teams.value;
case 'assign_agent':
return [{ id: 'self', name: 'Self' }, ...agents.value];
return [
...withNoneOption(),
{ id: 'self', name: 'Self' },
...agents.value,
];
case 'add_label':
case 'remove_label':
return labels.value.map(i => ({
@@ -172,6 +172,7 @@ export const FORMATTING = {
export const ARTICLE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'strike',
'link',
'undo',
'redo',
@@ -182,6 +183,7 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'h3',
'imageUpload',
'code',
'insertTable',
];
/**
@@ -12,6 +12,11 @@ export default {
SNOOZED: 'snoozed',
ALL: 'all',
},
CONVERSATION_TYPE: {
MENTION: 'mention',
PARTICIPATING: 'participating',
UNATTENDED: 'unattended',
},
SORT_BY_TYPE: {
LAST_ACTIVITY_AT_ASC: 'last_activity_at_asc',
LAST_ACTIVITY_AT_DESC: 'last_activity_at_desc',
@@ -40,6 +45,7 @@ export default {
WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL:
'https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations',
SMALL_SCREEN_BREAKPOINT: 768,
LARGE_SCREEN_BREAKPOINT: 1024,
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
SNOOZE_OPTIONS: {
UNTIL_NEXT_REPLY: 'until_next_reply',
@@ -153,3 +153,8 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
NEXT_CLICKED: 'Year in Review: Next clicked',
SHARE_CLICKED: 'Year in Review: Share clicked',
});
export const ONBOARDING_EVENTS = Object.freeze({
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
});
@@ -51,6 +51,7 @@ export const conversationListPageURL = ({
} else if (conversationType) {
const urlMap = {
mention: 'mentions/conversations',
participating: 'participating/conversations',
unattended: 'unattended/conversations',
};
url = `accounts/${accountId}/${urlMap[conversationType]}`;
@@ -33,6 +33,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate,
'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated,
};
}
@@ -194,6 +195,10 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('copilotMessages/upsert', data);
};
onEnrichmentCompleted = () => {
this.app.$store.dispatch('accounts/get', { silent: true });
};
onCacheInvalidate = data => {
const keys = data.cache_keys;
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
@@ -91,6 +91,13 @@ export const ARTICLE_MENU_ITEMS = {
action: 'archive',
icon: 'i-lucide-archive-restore',
},
translate: {
label:
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.TRANSLATE',
value: 'translate',
action: 'translate',
icon: 'i-lucide-languages',
},
delete: {
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DELETE',
value: 'delete',
@@ -100,9 +107,9 @@ export const ARTICLE_MENU_ITEMS = {
};
export const ARTICLE_MENU_OPTIONS = {
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft'],
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive'],
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive'],
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft', 'translate'],
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive', 'translate'],
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive', 'translate'],
};
export const ARTICLE_TABS = {
@@ -40,6 +40,15 @@ describe('#URL Helpers', () => {
'/app/accounts/1/custom_view/1'
);
});
it('should return url to participating conversations', () => {
expect(
conversationListPageURL({
accountId: 1,
conversationType: 'participating',
})
).toBe('/app/accounts/1/participating/conversations');
});
});
describe('conversationUrl', () => {
it('should return direct conversation URL if activeInbox is nil', () => {
@@ -45,6 +45,10 @@ describe('#resolveTeamIds', () => {
const resolvedTeams = '⚙️ sales team, 🤷‍♂️ fayaz';
expect(resolveTeamIds(teams, [1, 2])).toEqual(resolvedTeams);
});
it('resolves nil as None', () => {
expect(resolveTeamIds(teams, ['nil'])).toEqual('None');
});
});
describe('#resolveLabels', () => {
@@ -59,6 +63,10 @@ describe('#resolveAgents', () => {
const resolvedAgents = 'John Doe';
expect(resolveAgents(agents, [1])).toEqual(resolvedAgents);
});
it('resolves nil and self values', () => {
expect(resolveAgents(agents, ['nil', 'self'])).toEqual('None, Self');
});
});
describe('#getFileName', () => {
@@ -125,6 +125,7 @@ const validateSingleAction = action => {
'mute_conversation',
'snooze_conversation',
'resolve_conversation',
'remove_assigned_agent',
'remove_assigned_team',
'open_conversation',
'pending_conversation',
@@ -63,6 +63,16 @@
"ERROR_MESSAGE": "Could not update bot. Please try again."
}
},
"SECRET": {
"LABEL": "Webhook Secret",
"COPY": "ምስጢሩን ወደ ክሊፕቦርድ ቅዳ",
"COPY_SUCCESS": "ምስጢሩ ወደ ክሊፕቦርድ ተቀድሷል",
"TOGGLE": "የምስጢሩን ማየት አሳይ/ደብቅ",
"CREATED_DESC": "Use the secret below to verify webhook signatures. Please copy it now, you can also find it later in the bot settings.",
"DONE": "ተጠናቀቀ",
"RESET_SUCCESS": "Webhook secret regenerated successfully",
"RESET_ERROR": "Unable to regenerate webhook secret. Please try again"
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"DESCRIPTION": "Copy the access token and save it securely",
@@ -140,6 +140,8 @@
"ACTIONS": {
"ASSIGN_AGENT": "Assign to Agent",
"ASSIGN_TEAM": "Assign a Team",
"REMOVE_ASSIGNED_AGENT": "Remove Assigned Agent",
"REMOVE_ASSIGNED_TEAM": "Remove Assigned Team",
"ADD_LABEL": "Add a Label",
"REMOVE_LABEL": "Remove a Label",
"SEND_EMAIL_TO_TEAM": "Send an Email to Team",
@@ -169,6 +171,7 @@
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
"PRIVATE_NOTE": "የግል ማስታወሻ",
"MESSAGE_CONTAINS": "Message Contains",
"EMAIL": "Email",
"INBOX": "Inbox",
@@ -52,5 +52,17 @@
},
"CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!"
},
"SLASH_COMMANDS": {
"HEADING_1": "Heading 1",
"HEADING_2": "Heading 2",
"HEADING_3": "Heading 3",
"BOLD": "Bold",
"ITALIC": "Italic",
"STRIKETHROUGH": "Strikethrough",
"CODE": "Code",
"BULLET_LIST": "Bullet List",
"ORDERED_LIST": "Ordered List",
"TABLE": "Table"
}
}
@@ -20,6 +20,7 @@
"CALL": "ደውል",
"CALL_INITIATED": "ለእውነተኛው እውቀት መደወል እየተከናወነ ነው…",
"CALL_FAILED": "ጥሪውን መጀመር አልቻልንም። እባክዎ ደግመው ይሞክሩ።.",
"CLICK_TO_EDIT": "Click to edit",
"VOICE_INBOX_PICKER": {
"TITLE": "የድምፅ ኢንቦክስ ይምረጡ"
},
@@ -86,6 +86,14 @@
"PLACEHOLDER": "እባክዎ የWebhook URLዎን ያስገቡ",
"ERROR": "እባክዎ ትክክለኛ አድራሻ ያስገቡ"
},
"CHANNEL_WEBHOOK_SECRET": {
"LABEL": "Webhook Secret",
"COPY": "ምስጢሩን ወደ ክሊፕቦርድ ቅዳ",
"COPY_SUCCESS": "ምስጢሩ ወደ ክሊፕቦርድ ተቀድሷል",
"TOGGLE": "የምስጢሩን ማየት አሳይ/ደብቅ",
"RESET_SUCCESS": "Webhook secret regenerated successfully",
"RESET_ERROR": "Unable to regenerate webhook secret. Please try again"
},
"CHANNEL_DOMAIN": {
"LABEL": "የድር ጣቢያ ዶሜን",
"PLACEHOLDER": "የድር ጣቢያዎን ዶሜን ያስገቡ (ለምሳሌ፡ acme.com)"

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