Compare commits

..
Author SHA1 Message Date
Sony Mathew 5b0300963e test(storage): isolate migrator spec service state
Restore ActiveStorage::Blob service globals after migrator specs so later attachment specs do not inherit leaked test doubles.
2026-06-16 18:39:48 +05:30
Sony Mathew b2a8e03f62 test(storage): cover blob migration contract
Replace mock-only migrator specs with disk-backed ActiveStorage blob coverage for non-image uploads, service-name updates, missing files, upload failures, and idempotent reruns.

Add a rake task regression for re-enabled repeated invocation.
2026-06-16 18:25:42 +05:30
Sony Mathew 8c0cac0532 fix(storage): make blob migration resilient
Limit storage migration to blobs on the source service, skip missing source files per blob, and keep target upload failures loud.

Clean up the service-name update flag naming and document rake task usage.
2026-06-16 18:16:40 +05:30
Sony Mathew 1fd280ec0e fix(storage): preserve application logger
Remove the ActiveStorage migrator class-load logger override so eager loading does not replace the configured Rails logger or log level.
2026-06-16 18:04:29 +05:30
Sony Mathew 18b6681e9b fix(storage): migrate all blob content types
Remove the image-only filter so storage migrations copy every Active Storage blob, including audio, documents, imports, exports, and enterprise attachments.
2026-06-16 18:00:39 +05:30
Sony Mathew 8558b6f4c0 fix(storage): update migrated blob service names
Default storage migrations to update active_storage_blobs.service_name after successful uploads while allowing copy-only runs with UPDATE_BLOB_SERVICE_NAME=false.

Remove unused migrator helper methods and cover the migrator/rake flag behavior in specs.
2026-06-16 17:53:44 +05:30
Sony MathewandGitHub 90d7735ade Merge branch 'develop' into feature/#7907-script-to-change-local-storage-to-aws 2026-06-16 15:31:54 +05:30
41a3ab6dfa feat(companies): add contact company selector (#14496)
Adds a company selector to the contact details form so agents can
associate a contact with an existing company directly from the contact
page.

Closes

- None

Why

Contacts already expose company information through the CRM fields, but
the form only accepted free-text company names. As we split company CRM
work into smaller PRs, this keeps the contact page aligned with the
structured company model while preserving the existing company-name
behavior used by automations.

What changed

- Shows a company dropdown in the contact details form when the
Companies feature is enabled.
- Keeps legacy free-text company names editable when a contact has no
structured `company_id`.
- Allows Enterprise contact create/update APIs to accept account-scoped
`company_id`.
- Syncs `additional_attributes.company_name` when a contact is
associated with a company, including the existing email-domain
auto-association path.
- Serializes `company_id` in the contact model payload so the form can
show the current association.

How to test

1. Enable Companies for an account and open a contact details page.
2. In Edit contact details, use the Company field to select an existing
company.
3. Save the contact and refresh the page.
4. Confirm the selected company remains visible and the contact is
associated with that company.
5. Confirm contacts with only a legacy free-text company name still show
the text input instead of an empty selector.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-06-16 15:29:27 +05:30
de137e8297 feat: Capture CTWA referral metadata for WhatsApp conversations (#14681)
Adds support for capturing Click-to-WhatsApp ad referral metadata from
incoming WhatsApp messages.

This stores Meta Cloud API `referral` payloads on the incoming message
`content_attributes` and normalizes Twilio `Referral*` callback fields
into the same shape. The UI display is intentionally deferred until
Instagram, Messenger, and TikTok referral payloads are captured as well,
so we can design one cross-channel referral surface instead of a
WhatsApp-only sidebar block.

Why message-level storage

Meta sends CTWA referral details as part of the inbound message payload,
not as a stable conversation-level webhook. The referral represents the
exact ad click that produced that specific customer message, and later
messages in the same conversation may not carry the same context.
Storing the normalized referral on the message preserves the original
webhook semantics, avoids adding conversation-level attribution that
could become stale or ambiguous, and keeps support for both Cloud API
and Twilio payloads aligned behind `content_attributes.referral`.
Fixes
https://linear.app/chatwoot/issue/CW-7090/surface-meta-ctwa-referral-on-incoming-whatsapp-messages-cloud-api
Closes https://github.com/chatwoot/chatwoot/issues/13995,
https://github.com/chatwoot/chatwoot/issues/12560,
https://github.com/chatwoot/chatwoot/issues/13006


Related community PRs
- https://github.com/chatwoot/chatwoot/pull/13130
- https://github.com/chatwoot/chatwoot/pull/14180
- https://github.com/chatwoot/chatwoot/pull/14121

Related follow-ups
-
https://linear.app/chatwoot/issue/CW-7301/capture-instagram-ad-referral-metadata-on-incoming-messages
-
https://linear.app/chatwoot/issue/CW-7302/capture-messenger-ad-referral-metadata-on-facebook-page-conversations
-
https://linear.app/chatwoot/issue/CW-7303/capture-tiktok-ad-referral-metadata-from-im-referral-msg-events

How to test
1. Send or replay a WhatsApp Cloud API inbound message that contains a
`messages[0].referral` payload from a Click-to-WhatsApp ad.
2. Confirm the generated incoming message stores the payload under
`content_attributes.referral`.
3. Repeat with a Twilio WhatsApp callback containing `Referral*` fields
and confirm the stored message has the same normalized
`content_attributes.referral` structure.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-06-16 09:58:29 +04:00
Sivin VargheseandGitHub a3d05ef55d chore: Update emoji picker in widget (#14741) 2026-06-16 10:39:47 +05:30
dda25c0b51 fix(portal): emit valid BCP 47 html lang attribute (#14680)
Locales are stored with underscores (e.g. pt_BR), but the HTML lang
attribute requires hyphens (pt-BR). Convert via a helper in the public
portal layouts.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-06-15 14:34:52 -07:00
Sivin VargheseandGitHub 1713cabdf2 feat: Add emoji & icon picker for Help Center categories (#14702) 2026-06-16 02:10:05 +05:30
d8a16278b9 fix: added ordering capability for sidebar sections folders, teams, channels and labels (CW-7193) (#14609)
## Description

* Added the ability to sort for 4 sub-sections under conversations
folders, teams, channels and labels.
* the sort options are basically created at, alphabetical and unread
counts along with both directions.
* for folders we don't have an unread count, so we sort it by only
created and alphabetical.
* all the sort preferences are stored on the frontend - easiest
implementation for now.

Fixes # CW-7193

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

Tested this locally by visually verifying the changes. Also ran the
newly added tests for component level changes.

Here is the screenshot of the changes:

Added the sort option to sub sections in the conversation sidebar:
<img width="282" height="848" alt="Screenshot 2026-06-02 at 1 58 12 AM"
src="https://github.com/user-attachments/assets/4a7c6061-86e3-438a-92ae-ee643a0128b6"
/>

The sort options looks like this:
<img width="783" height="698" alt="Screenshot 2026-06-02 at 1 58 49 AM"
src="https://github.com/user-attachments/assets/a15bb0a7-b810-4423-a88c-fbd84d0476c0"
/>


## 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: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-06-15 21:34:19 +05:30
Sony MathewandGitHub d916557fda Merge branch 'develop' into feature/#7907-script-to-change-local-storage-to-aws 2026-06-15 18:16:51 +05:30
Sony MathewandGitHub 4816e923b6 chore: added a new sort by unread option for conversations (CW-7152) (#14512)
## Description

Added a new option for the sort by option in conversation filters called
unread. This is to filter out unread conversations.
Fixes # CW-7152

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

Tested manually in local.

<img width="1397" height="603" alt="Screenshot 2026-05-20 at 10 40
20 PM"
src="https://github.com/user-attachments/assets/6c60263e-907b-419f-a7ed-06010bbe8736"
/>


## 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
2026-06-15 17:58:04 +05:30
396631ad7d feat: enforce concurrent session limit with login picker (CW-7169) (#14621)
## Description

Cap active sessions at `MAX_USER_SESSIONS` which defaults to existing
value of `25` per user. This ensure existing user login behavior is not
affected for self-hosted installations. Browser users at the cap see a
session picker (409 response) to choose which session to end.
Non-browser clients and partially-tracked users get silent
oldest-session eviction.

Depends on #14556.

## Type of change

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

## How Has This Been Tested?

Specs cover: under limit, at limit (browser picker, non-browser
eviction), partial tracking fallback, revoke single/all sessions during
login, session row creation on successful login.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-15 17:28:13 +05:30
Vishnu NarayananandGitHub ba0ba46c9c fix: skip session tracking and use short-lived token for impersonation (CW-7169) (#14622)
## Description

SuperAdmin impersonation SSO logins no longer create UserSession rows
visible to the customer. Impersonation tokens use a 2-day lifespan
instead of ~2 months, so they naturally evict first and don't linger in
the user's token list.

Server-side detection via Redis value (`'impersonation'` vs `'normal'`)
without changing the `valid_sso_auth_token?` signature. Backward
compatible with in-flight tokens.

Depends on #14556.

## Type of change

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

## How Has This Been Tested?

Specs cover: impersonation login skips UserSession creation,
impersonation token has short lifespan, normal SSO login still creates
UserSession row.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-15 17:14:34 +05:30
Vishnu NarayananandGitHub ee6382109a feat: prevent deleted email conversations from syncing again (#14612)
# Pull Request Template

## Description

Prevent deleted email conversations from being synced into Chatwoot
again while they are still within the IMAP sync window.

When an admin explicitly deletes an email conversation, the incoming
email message IDs are stored temporarily in Redis. IMAP sync checks
these recently deleted message IDs in addition to existing message
records. Each Redis key expires automatically after two days.

This applies only to explicit conversation deletion. Individual message
deletion, inbox deletion, and account deletion keep their existing
behavior.

Fixes
[CW-7214](https://linear.app/chatwoot/issue/CW-7214/deleted-mails-in-gmail-inbox-gets-synced-again)
2026-06-15 17:06:40 +05:30
Sivin VargheseandGitHub 35bef21f83 feat: add media view for contacts (#14393) 2026-06-15 15:42:11 +05:30
e5c140158e fix(new-conversation): stop writing literal "undefined" as mail_subject (#14383)
## Description

This PR fixes a data-corruption bug in the new conversation flow that
surfaces in conversation search results as `Subject: undefined`.

### The Problem
When creating a conversation through the "New conversation" modal
without typing a subject (every non-email channel never shows the
subject input, and email channels can be left blank), the conversation
gets persisted with `additional_attributes.mail_subject = "undefined"`
(the literal string). The bogus value shows up in conversation search
results as `Subject: undefined`, which started rendering after #10843.

### Root Cause
`createConversationPayload` in
`app/javascript/dashboard/store/modules/contactConversations.js` appends
the `mail_subject` field unconditionally:

```js
payload.append('additional_attributes[mail_subject]', mailSubject);
```

`composeConversationHelper.js` only sets `payload.mailSubject` when
`subject` is truthy, so when no subject is provided, `mailSubject` is
destructured as `undefined` in `createConversationPayload`.
`FormData.append` coerces `undefined` to the string `"undefined"`, and
the backend persists it as-is into the JSONB column.

### The Fix
Skip the append when `mailSubject` is falsy. The backend already treats
a missing key the same as an empty subject (the email mailer falls back
to a default subject when `mail_subject` is `nil`), so omitting the
field is safe across channels.

### Key Changes
- Guarded the `additional_attributes[mail_subject]` append in
`createConversationPayload`.
- Added a spec covering the case where `mailSubject` is omitted.

> Note: existing rows persisted before this fix will continue to show
`Subject: undefined` in search until cleaned up. A simple SQL cleanup
for non-email inboxes:
>
> ```sql
> UPDATE conversations
> SET additional_attributes = additional_attributes - 'mail_subject'
> FROM inboxes
> WHERE conversations.inbox_id = inboxes.id
>   AND inboxes.channel_type <> 'Channel::Email'
> AND conversations.additional_attributes->>'mail_subject' =
'undefined';
> ```
>
> I'm leaving any data-cleanup migration out of this PR since it's a
maintenance concern that may be handled differently per installation.

## Type of change

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

## How Has This Been Tested?

1. Open the "New conversation" modal on a non-email inbox (e.g.,
WhatsApp, SMS, API).
2. Create a conversation without filling any subject (field is not
present on those, so regular flow).
3. Open the global search and search for the conversation.
4. **Before:** the result card shows `Subject: undefined`.
   **After:** the subject row is hidden.
5. Repeat on an email inbox leaving the subject blank — same result.
6. On an email inbox, type a subject and verify it still persists and
renders correctly.
7. Run the new spec: `pnpm test contactConversations`.

## 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: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-14 08:57:38 +05:30
274e92e0e4 chore: collapse conversation sidebar sections (folders, teams, inboxes and labels) - CW-7059 (#14509)
## Description

Added ability to collapse conversation sidebar sections (folders, teams,
inboxes and labels)

Fixes #CW-7059

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

Tested locally. Added specs. Attaching the loom for them same.


https://github.com/user-attachments/assets/40d613e7-6c82-4078-abf4-79739a00f718


## 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: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-06-14 06:54:18 +05:30
27404b4a27 fix: redact sensitive integration secrets from API responses (#14147)
## Summary

The `GET /api/v1/accounts/:id/integrations/apps` endpoint returns raw
secret values (OpenAI API keys, Google service account private keys,
Linear refresh tokens, etc.) in hook settings within the JSON response.
Although gated behind an administrator check, these secrets are visible
in the browser network tab.

This PR filters hook settings through the existing `visible_properties`
whitelist defined in `config/integration/apps.yml`, and adds explicit
whitelists to integrations that were missing them.

Closes #14042

## Bug reproduction

**Setup:** Created an OpenAI integration hook with a fake API key
(`sk-test-secret-12345`).

**Step 1 — Browser Network tab shows raw secrets:**

<img width="1676" height="869" alt="Screenshot 2026-04-24 at 14 32 28"
src="https://github.com/user-attachments/assets/6fc69463-365e-458b-b216-98a7390bf528"
/>


Navigate to Settings > Integrations as an admin. Open DevTools Network
tab and observe the response from `GET
/api/v1/accounts/:id/integrations/apps`. The hook settings contain the
full API key in plaintext:

```json
"hooks": [
  {
    "id": 1,
    "app_id": "openai",
    "settings": {
      "api_key": "sk-test-secret-12345",
      "label_suggestion": false
    }
  }
]
```

**Step 2 — API call confirms the leak:**

```bash
curl -s "http://localhost:3000/api/v1/accounts/2/integrations/apps" \
  -H "access-token: <token>" \
  -H "client: <client>" \
  -H "uid: user@test.com" \
  | jq '.payload[] | select(.id == "openai") | {id, name, hooks: [.hooks[] | {id, app_id, settings}]}'
```

Response:

```json
{
  "id": "openai",
  "name": "OpenAI",
  "hooks": [
    {
      "id": 1,
      "app_id": "openai",
      "settings": {
        "api_key": "sk-test-secret-12345",
        "label_suggestion": false
      }
    }
  ]
}
```

Any admin user can extract the raw API key from the response. The same
applies to other integrations — Dialogflow exposes full Google service
account credentials, Linear exposes refresh tokens, etc.

## After fix

After applying this change, the GET
/api/v1/accounts/:id/integrations/apps endpoint no longer returns
sensitive secret values in hook settings. Instead, the response is
filtered using each integration’s visible_properties whitelist, ensuring
only safe, user-facing fields are exposed. For example, OpenAI
integrations return non-sensitive fields like label_suggestion while
excluding raw API keys. This prevents secrets from being exposed in the
browser network tab or API responses, even for authenticated admin
users.

```bash
curl -X GET "http://localhost:3000/api/v1/accounts/2/integrations/apps" \
  -H "Accept: application/json" \
  -H "Authorization: Bearer eyJhY2Nlc3MtdG9rZW4iOiJqZG5jOWg4TnljaWFJa3JlZkxGQzRnIiwidG9rZW4tdHlwZSI6IkJlYXJlciIsImNsaWVudCI6Ik81bjVnTDFEOVVhbGpwbWxjaHZNanciLCJleHBpcnkiOiIxNzgyMzMyNTA4IiwidWlkIjoidXNlckB0ZXN0LmNvbSJ9" \
  -H "access-token: jdnc9h8NyciaIkrefLFC4g" \
  -H "client: O5n5gL1D9UaljpmlchvMjw" \
  -H "uid: user@test.com" \
  | jq '.payload[] | select(.id == "openai") | {id, name, hooks: [.hooks[] | {id, app_id, settings}]}'
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 10174  100 10174    0     0  33232      0 --:--:-- --:--:-- --:--:-- 33357
{
  "id": "openai",
  "name": "OpenAI",
  "hooks": [
    {
      "id": 1,
      "app_id": "openai",
      "settings": {
        "label_suggestion": false
      }
    }
  ]
}
```

## What changed

- Added `visible_properties` accessor to `Integrations::App` model to
expose the whitelist from config
- Updated `_hook.json.jbuilder` to filter `resource.settings` through
the associated app's `visible_properties` instead of returning the full
hash
- Added `visible_properties` to integrations that were missing it:
  - Linear: `[]` (settings contain refresh_token)
  - Notion: `[]` (OAuth-based, no user-facing settings)
- Slack: `['channel_name']` (UI needs this to display connected channel)
  - Shopify: `[]` (no settings)

App config metadata (`_app.json.jbuilder`) is left unchanged —
hook_type, settings_form_schema, etc. are not secrets and the frontend
depends on them.

## How to test

1. Create an OpenAI integration hook with an API key
2. As an admin, call `GET /api/v1/accounts/:id/integrations/apps`
3. Verify hook settings include `api_key` (whitelisted) but not raw
credential objects
4. For Dialogflow hooks, verify `credentials` (private key JSON) is
excluded while `project_id` is included
5. For Slack hooks, verify `channel_name` is still returned
6. For Linear hooks, verify `refresh_token` is not returned

---------

Co-authored-by: Botshelo Nokoane (Konstruktors) <botshelo@entersekt.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-06-14 06:53:13 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Sony MathewSony Mathew
006b529918 chore(deps): bump net-imap from 0.4.24 to 0.6.4.1 (#14688)
Bumps [net-imap](https://github.com/ruby/net-imap) from 0.4.24 to
0.6.4.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/ruby/net-imap/releases">net-imap's
releases</a>.</em></p>
<blockquote>
<h2>v0.5.15</h2>
<h2>What's Changed</h2>
<h3>🔒 Security</h3>
<p>This release fixes several more security vulnerabilities which are
related to the fixes in <code>v0.5.14</code>. Please see the linked
security advisories for more information.</p>
<ul>
<li><em>(moderate)</em> Command Injection via non-synchronizing literal
in &quot;raw&quot; argument (CVE-2026-47240, GHSA-8p34-64r3-mwg8)
This vulnerability depends how the server interprets non-synchronizing
literals.
The connection is <em>not</em> vulnerable if the server supports
non-synchronizing literals.
<ul>
<li>🥅 Validate non-synchronizing literals support by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/703">ruby/net-imap#703</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/701">ruby/net-imap#701</a>)</li>
</ul>
</li>
<li><em>(moderate)</em> Command Injection via unvalidated ID and ENABLE
arguments (CVE-2026-47242, GHSA-46q3-7gv7-qmgg)
<ul>
<li>🥅 Validate <code>ID</code> values contain only valid bytes by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/703">ruby/net-imap#703</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/698">ruby/net-imap#698</a>)</li>
<li>🥅 Validate <code>#enable</code> arguments are all atoms by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/703">ruby/net-imap#703</a>
(backport <a
href="https://redirect.github.com/ruby/net-imap/pull/699">ruby/net-imap#699</a>)
<strong>NOTE:</strong> <code>#enable</code> should
<strong><em>never</em></strong> be called with untrusted input.</li>
</ul>
</li>
<li><em>(low)</em> Denial of Service via incomplete &quot;raw&quot;
argument validation (CVE-2026-47241, GHSA-c4fp-cxrr-mj66)
This results in the affected command hanging until the connection is
closed. If another thread attempts to send a concurrent pipelined
command, the first thread will return with a syntax error and the second
thread will hang until the connection closes.
<ul>
<li><em>Reported by <a
href="https://github.com/fg0x0"><code>@​fg0x0</code></a></em></li>
<li>🐛 Prevent trailing <code>{0}</code> in RawData validation by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/703">ruby/net-imap#703</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/700">ruby/net-imap#700</a>)</li>
</ul>
</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>🥅 Validate that Atom and Flag are not empty by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/685">ruby/net-imap#685</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/684">ruby/net-imap#684</a>)</li>
<li>🧵 Fix deadlock in <code>#disconnect</code> by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/697">ruby/net-imap#697</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/686">ruby/net-imap#686</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>⚠️ Boost visibility of raw data argument documentation warnings by
<a href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/696">ruby/net-imap#696</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/677">ruby/net-imap#677</a>)</li>
</ul>
<h3>Other Changes</h3>
<ul>
<li>🏷️ Allow 64-bit Integer arguments in <a
href="https://redirect.github.com/ruby/net-imap/pull/696">ruby/net-imap#696</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/675">ruby/net-imap#675</a>)</li>
<li>🥅 Ensure send_number_data input is an Integer in <a
href="https://redirect.github.com/ruby/net-imap/pull/696">ruby/net-imap#696</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/676">ruby/net-imap#676</a>)</li>
<li>♻️ Improve <code>RawData.new</code>, Add <code>RawData.split</code>
by <a href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/696">ruby/net-imap#696</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/679">ruby/net-imap#679</a>)</li>
<li>🥅 Validate response literal byte size format by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/696">ruby/net-imap#696</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/681">ruby/net-imap#681</a>)</li>
</ul>
<h3>Miscellaneous</h3>
<ul>
<li>✅ Improvements to tests' FakeServer in <a
href="https://redirect.github.com/ruby/net-imap/pull/696">ruby/net-imap#696</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/678">ruby/net-imap#678</a>)</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/ruby/net-imap/compare/v0.5.14...v0.5.15">https://github.com/ruby/net-imap/compare/v0.5.14...v0.5.15</a></p>
<h2>v0.5.14</h2>
<h2>What's Changed</h2>
<h3>🔒 Security</h3>
<p>This release contains fixes for <strong>multiple
vulnerabilities</strong> concerning <em><strong><code>STARTTLS</code>
stripping</strong></em>, argument validation, and denial of service
attacks.</p>
<blockquote>
<p>[!WARNING]
<a
href="https://redirect.github.com/ruby/net-imap/pull/665">ruby/net-imap#665</a>
fixes a <code>STARTTLS</code> stripping vulnerability
(GHSA-vcgp-9326-pqcp).
Without this fix, a man-in-the-middle attacker can cause
<code>Net::IMAP#starttls</code> to return &quot;successfully&quot;,
<strong><em>without starting TLS</em></strong>.</p>
</blockquote>
<blockquote>
<p>[!IMPORTANT]
Argument validation is significantly improved. Several command injection
vulnerabilities have been fixed:
<a
href="https://redirect.github.com/ruby/net-imap/pull/662">ruby/net-imap#662</a>
fixes CRLF/command/argument injection via Symbol arguments
(GHSA-75xq-5h9v-w6px).
<a
href="https://redirect.github.com/ruby/net-imap/pull/662">ruby/net-imap#662</a>
fixes CRLF/command/argument injection via the <code>attr</code> argument
to <code>#store</code>/<code>#uid_store</code> (GHSA-hm49-wcqc-g2xg)</p>
</blockquote>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/ruby/net-imap/commit/ce20fc8e9974ed87d0848dc411eef490623cdeb9"><code>ce20fc8</code></a>
🔖 Bump version to 0.5.15</li>
<li><a
href="https://github.com/ruby/net-imap/commit/0b7b83c19312dd3f44cb72be2e7681289324d9db"><code>0b7b83c</code></a>
🔀 Merge pull request <a
href="https://redirect.github.com/ruby/net-imap/issues/703">#703</a>
from ruby/backport/v0.5/security-patches</li>
<li><a
href="https://github.com/ruby/net-imap/commit/f22fd6cee01140b53cdb7fc8d42a352a734f4509"><code>f22fd6c</code></a>
🍒 pick 0ea9eba3 (<a
href="https://redirect.github.com/ruby/net-imap/issues/701">#701</a>): ✅
Fix flaky tests for MacOS, TruffleRuby</li>
<li><a
href="https://github.com/ruby/net-imap/commit/12460740451d064cef84f59dd8c91baba0388ae1"><code>1246074</code></a>
🍒 pick ae9f83b5 (<a
href="https://redirect.github.com/ruby/net-imap/issues/701">#701</a>):
♻️ Extract str.bytesize lvar in send_literal</li>
<li><a
href="https://github.com/ruby/net-imap/commit/a2f61af6b7a7e6f0e4f27691531de6e4861318cd"><code>a2f61af</code></a>
🍒 pick 62a0da6d (<a
href="https://redirect.github.com/ruby/net-imap/issues/701">#701</a>): 🥅
Validate non-synchronizing literals support</li>
<li><a
href="https://github.com/ruby/net-imap/commit/e33348ccdc639d0d4414f72e4ba7024bdb24bf29"><code>e33348c</code></a>
🍒 pick d6ddd294 (<a
href="https://redirect.github.com/ruby/net-imap/issues/700">#700</a>): 🐛
Prevent trailing <code>{0}</code> in RawData validation</li>
<li><a
href="https://github.com/ruby/net-imap/commit/4f81b69044297de02ada859fffba6ad9923a43a5"><code>4f81b69</code></a>
🍒 pick 1f97168b (<a
href="https://redirect.github.com/ruby/net-imap/issues/699">#699</a>): 🥅
Validate <code>#enable</code> arguments are all atoms</li>
<li><a
href="https://github.com/ruby/net-imap/commit/69da4a490dd148526fb287a5c82056bfd20c6f95"><code>69da4a4</code></a>
🍒 pick 8d9397ab (<a
href="https://redirect.github.com/ruby/net-imap/issues/698">#698</a>): 🥅
Validate QuotedString contains only valid bytes</li>
<li><a
href="https://github.com/ruby/net-imap/commit/7aab580ad35f2cad1404a58bf0ff015567f2593c"><code>7aab580</code></a>
🍒 pick e3c50fad (<a
href="https://redirect.github.com/ruby/net-imap/issues/698">#698</a>):
♻️ Refactor RawText, add improve test coverage</li>
<li><a
href="https://github.com/ruby/net-imap/commit/fac1733d45ec57da3675c4d266c771c73c7265bb"><code>fac1733</code></a>
🍒 pick aab64f92 (<a
href="https://redirect.github.com/ruby/net-imap/issues/686">#686</a>): 🧵
Fix deadlock in <code>#disconnect</code></li>
<li>Additional commits viewable in <a
href="https://github.com/ruby/net-imap/compare/v0.4.24...v0.5.15">compare
view</a></li>
</ul>
</details>
<br />


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

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

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

---

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

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

</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-06-13 22:49:03 +05:30
Sony MathewandGitHub b1c2db5435 fix: Stabilize help center builder error spec (#14725)
## Description

Stabilizes the enterprise help center article builder source URL
validation spec by asserting the custom exception via its class name and
message text. This keeps the spec focused on the intended behavior while
avoiding brittle custom exception constant identity checks in
CI/reloading environments.

A bunch of builds on different PRs have been failing because of this
error, sample traces are below:
*
https://app.circleci.com/pipelines/github/chatwoot/chatwoot/114064/workflows/bdb6eca9-3b65-4c38-b8cf-f2f8564476f8/jobs/158777
*
https://app.circleci.com/pipelines/github/chatwoot/chatwoot/114064/workflows/bdb6eca9-3b65-4c38-b8cf-f2f8564476f8/jobs/158777

Fixes # N/A

## Type of change

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

## How Has This Been Tested?

- `/Users/sonymathew/.rbenv/shims/bundle exec rspec
spec/enterprise/services/onboarding/help_center_article_builder_spec.rb`
- `/Users/sonymathew/.rbenv/shims/bundle exec rubocop
spec/enterprise/services/onboarding/help_center_article_builder_spec.rb`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] New and existing unit tests pass locally with my changes
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-13 19:57:42 +05:30
Vishnu NarayananandGitHub 92a1fb8ab7 feat: manage active user sessions from profile (CW-7169) (#14556)
## Description

First PR of user_sessions feature - enforcement, impersonation and mfa will be handled separately.

Adds an Active Sessions section under Profile where users can see every
device currently logged in and revoke any session they don't recognize.
Helps users lock down stale or unrecognized logins on their own without
needing support.

**Behavior at the limit, by client:**
- **Browser:** returns 409 with a picker overlay; user picks a session
to revoke or chooses "End all sessions" to clear them.

- **Mobile / API client:** silently evicts the oldest session and
proceeds with login (no picker UI to render).
- **Pre-tracking users** (token rows without `user_sessions`, i.e.
anyone already logged in before this ships): silent-evict any untracked
token first, so freshly tracked sessions are never killed in favor of
legacy ones.

Sessions are stored in a new `user_sessions` table keyed on `(user_id,
client_id)` with browser, platform, IP, last activity and (when
configured) geo. Kept in sync with `user.tokens` via an after_save
callback so revoking a token from any path cleans up the row.

Fixes https://linear.app/chatwoot/issue/CW-7169

## Type of change

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

## How Has This Been Tested?

- Added specs.
- Manual local testing: browser picker fires at limit; pre-tracking user
silent-evicts; mixed tracked/untracked correctly drops the untracked one
first; profile page revoke succeeds; current session cannot be revoked
from profile.

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-12 16:11:07 +05:30
e055cead35 fix: only subscribe calls webhook field when voice calling enabled (#14718)
## Description

Solves issue https://github.com/chatwoot/chatwoot/issues/14690

## Type of change

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

## How has this been tested?

- UI flows

## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-12 12:25:25 +05:30
c041fde3a2 fix: Preserve original filenames for WhatsApp cloud attachments (#14168)
## Summary

Preserves the original filename provided in the WhatsApp Cloud
attachment payload when downloading media files.

## Problem

Files containing accented or special characters could be saved with
malformed names or incorrect extensions due to relying on remote
download metadata.

## Changes Made

- Uses attachment_payload[:filename] when present
- Creates a tempfile using the original filename and extension
- Preserves content type metadata
- Falls back to existing behavior when no filename is provided

## Notes

This should improve attachment downloads for filenames containing
accented characters.
Related to #10973

---------

Co-authored-by: Rorrick Smith <rorrick@bulksms.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-12 10:21:42 +04:00
c6a38e2fc6 fix: Keep Instagram scopes out of new Messenger OAuth flows (#14695)
Removes Instagram permissions from the Facebook Messenger inbox setup
flow to avoid blocking Meta App Review for Messenger-only apps.

Meta rejects or stalls Messenger-only app reviews when the OAuth flow
requests unrelated Instagram permissions such as `instagram_basic` and
`instagram_manage_messages`. This blocks approval for Messenger
permissions like `human_agent`, even when the user only wants to
configure a Facebook Messenger inbox.

New Facebook Messenger inbox setup now requests only the Page and
Messenger permissions required for Messenger. Existing Facebook
Messenger inboxes that already have Instagram details continue to
request Instagram permissions during reauthorization, preserving support
for the legacy combined Facebook/Instagram inbox flow.

Going forward, new Instagram setups should use the dedicated Instagram
channel inbox instead of being bundled into the Messenger setup flow.

Closes #13860

## How to test

1. Go to Settings → Inboxes → Add Inbox → Facebook Messenger.
2. Click Login with Facebook.
3. Verify the OAuth scope does not include `instagram_basic` or
`instagram_manage_messages`.
4. Reauthorize an existing Facebook Messenger inbox with `instagram_id`
present.
5. Verify the reauth scope still includes `instagram_basic` and
`instagram_manage_messages`.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-06-12 09:39:26 +04:00
Sugar Ray LedesmaandGitHub 2663a8495d refactor(security): rename path_without_extensions in Rack::Attack (#14216)
Fix misspelling 'extentions' and clarify comments. Behavior unchanged:
same path-stripping logic for throttle matching (e.g. /auth and
/auth.json). Also correct 'You may' in remote_ip comment.
2026-06-11 18:36:34 +05:30
Sivin VargheseandGitHub d0d275d962 fix: cannot resize pasted images (#14709) 2026-06-11 16:47:11 +05:30
95d6aecb51 chore: Add security flags to session cookie configuration (#14248)
## Summary
- Adds `httponly` and `secure` flags to session cookie configuration
- Adds RSpec tests for session configuration

## Changes
- `config/initializers/session_store.rb`: add `httponly: true`, `secure:
FORCE_SSL`
- `spec/config/session_store_spec.rb`: tests for all session store
options

Ref #2683 (hardens the session cookie but does not remove it, sessions
are still needed for super_admin dashboard)

---------

Co-authored-by: Adam-Relay <adam@userelay.ai>
Co-authored-by: Adam Berger <adam@adam-berger.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-06-11 16:13:57 +05:30
a901c87ab4 perf: merge 3 conversation COUNT queries in /meta into single query (#13536)
# Pull Request Template

## Description


- Merges 3 separate COUNT queries in
`ConversationFinder#set_count_for_all_conversations` into a single query
using PostgreSQL `COUNT(*) FILTER (WHERE ...)`
- Every call to `/meta` and conversation index fires 3 COUNT queries
against the conversations table — one for "mine", one for "unassigned",
one for "all". These share the same base query and scan the same rows,
but execute as 3 independent round-trips.

from pg_stats

| Query | Calls | Total DB Time | Avg Latency |
|-------|-------|--------------|-------------|
| COUNT unassigned | 4.1M | 141,081 sec | 34ms |
| COUNT all | 4.1M | 132,302 sec | 32ms |
| COUNT mine | 3.7M | 37,637 sec | 10ms |
| **Total** | **~12M** | **311,020 sec** | |

`/meta` alone runs at 2.3K RPM (NewRelic), triggered on every WebSocket
event (conversation created/updated/status changed/assignee changed).



Fixes
https://linear.app/chatwoot/issue/INF-43/make-single-query-to-count-the-assigned-unassigned-and-all-instead-of

## Type of change

- [x] Performance fix

## How Has This Been Tested?

- [x] Verify conversation list shows correct
mine/unassigned/assigned/all counts
- [x] Verify /meta endpoint returns correct counts
- [x] Verify counts update correctly when assigning/unassigning
conversations
- [x] Verify counts with team filter applied
- [x] Monitor DB query count reduction in NewRelic after deploy

## 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: Sony Mathew <ynos1234@gmail.com>
2026-06-11 16:10:48 +05:30
Sony MathewandGitHub 6d26e7930a fix(contacts): truncate inbound contact names (IMAP sender display names) (#14631)
## Description

Truncates long contact names in `ContactInboxWithContactBuilder` before
persisting them, so inbound IMAP sender display names over the
`contacts.name` 255-character limit no longer raise
`ActiveRecord::RecordInvalid` from `Inboxes::FetchImapEmailsJob`. This
keeps email ingestion moving while preserving the existing contact name
length constraint.

Linear ticket:
[https://linear.app/chatwoot/issue/CW-7263/sentry-active-record-invalid-contact-create-from-imap-fetch-job](https://linear.app/chatwoot/issue/CW-7263/sentry-active-record-invalid-contact-create-from-imap-fetch-job)

## Type of change

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

## How Has This Been Tested?

- [x] `bundle exec rspec
spec/builders/contact_inbox_with_contact_builder_spec.rb`
- [x] `bundle exec rspec spec/mailboxes/imap/imap_mailbox_spec.rb`
- [x] `bundle exec rubocop
app/builders/contact_inbox_with_contact_builder.rb
spec/builders/contact_inbox_with_contact_builder_spec.rb`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
2026-06-11 15:15:14 +05:30
Tanmay Deep SharmaandGitHub 8d5d02ea97 feat: add per-inbox toggle to disable incoming calls (#14645)
## Description

Adds a per-inbox "Allow incoming calls" toggle for voice-enabled
WhatsApp and Twilio inboxes. When turned off, the setting is persisted
on the channel; actually rejecting inbound calls is handled in a
follow-up PR.

## Type of change

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

## Screenshot
<img width="804" height="384" alt="Screenshot 2026-06-04 at 11 44 07 AM"
src="https://github.com/user-attachments/assets/df8bb026-0387-4031-bcba-6d9a56872eb7"
/>


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-11 14:22:44 +05:30
ce93ddec78 fix: re-fetch widget conversation status on reconnect (#14683)
When an inbox has **"Allow messages after conversation is resolved"**
disabled, the live-chat widget should hide the reply box once a
conversation is resolved — but users could still send a reply, silently
reopening it. This syncs the widget's conversation status on reconnect
so the reply box hides correctly.

## Closes
- CW-7272

## How to reproduce
1. Disable **Allow messages after conversation is resolved** on an
inbox.
2. Open the widget, then let its websocket drop (background tab / lose
network).
3. While disconnected, get the conversation resolved (e.g. auto-resolve
on inactivity).
4. Reconnect → reply box is still visible and a sent message reopens the
resolved conversation.

## Why
Reply-box hiding is gated on the widget's local status, updated via the
`conversation.status_changed` socket event. If the resolve happens while
disconnected, the event is missed — and on reconnect the widget only
re-synced messages, never the status, so it stayed stale at `open`.

## What changed
- `onReconnect` now also dispatches
`conversationAttributes/getAttributes` to refresh status after a missed
event.
- Added a spec covering the reconnect behavior.

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-06-11 14:09:13 +05:30
Sivin VargheseandGitHub 940428c10e fix: preserve markdown links with unsafe href characters in help center content (#14686) 2026-06-11 13:47:08 +05:30
4d3196b02f feat: Show unread count for all conversations (#14627)
## Description

Adds the unread count for All Conversations to the left sidebar. The
unread counts API now returns an `all_count` aggregate based on the same
permission-scoped inbox counts already used for sidebar badges, and the
sidebar renders that backend-provided value on the All Conversations
item.

Fixes
[CW-7240](https://linear.app/chatwoot/issue/CW-7240/unread-count-for-all-conversations)

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec
spec/services/conversations/unread_counts/counter_spec.rb
spec/enterprise/services/conversations/unread_counts/counter_spec.rb
spec/controllers/api/v1/accounts/conversations_controller_spec.rb`
- `pnpm test
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js`
- `pnpm exec eslint
app/javascript/dashboard/components-next/sidebar/Sidebar.vue
app/javascript/dashboard/store/modules/conversationUnreadCounts.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js`
- `bundle exec rubocop
app/services/conversations/unread_counts/counter.rb
spec/services/conversations/unread_counts/counter_spec.rb
spec/enterprise/services/conversations/unread_counts/counter_spec.rb
spec/controllers/api/v1/accounts/conversations_controller_spec.rb`

## Checklist:

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

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-11 13:05:52 +05:30
Tanmay Deep SharmaandGitHub c6e5657ee5 fix(call): emit assignee activity message when agent answers a call (#14700)
## Linear Ticket
- https://linear.app/chatwoot/issue/CW-7249/debug-assignment-log

## Description

When an agent answers an inbound WhatsApp call on an unassigned
conversation, the conversation is claimed for that agent — but no
"assigned" activity message or assignee-changed event was emitted, so
the assignment was invisible in the timeline (and notifications/live
assignee panel didn't update).

The claim ran before `update_conversation_call_status`, so that later
`update!` on the same conversation clobbered
`saved_change_to_assignee_id?` before `after_commit` fired. Moving the
claim to be the conversation's final write in the transaction restores
the activity message, the `ASSIGNEE_CHANGED` event, and the live
assignee update.

## Type of change

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

## How Has This Been Tested?

1. Open an unassigned WhatsApp-call conversation.
2. As an agent, answer an inbound call.
3. Before: the conversation is assigned to you, but no "self-assigned"
activity message appears. After: the activity message is created and the
assignee updates live.

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-11 11:16:41 +05:30
Francisco Brito c8a9e36085 fixing error in the test 2024-03-20 13:24:36 -03:00
Francisco Brito 43d950001c fixing the path in the rake task test 2024-03-20 12:42:29 -03:00
Francisco BritoandGitHub f701a4df9d Merge branch 'chatwoot:develop' into feature/#7907-script-to-change-local-storage-to-aws 2024-03-20 12:32:03 -03:00
Francisco Brito f5b2e7b5de fixing path for RuboCop 2024-03-15 20:41:41 -03:00
Francisco Brito cf297801fd feature/#7907-script-to-change-local-storage-to-aws 2024-03-15 20:26:51 -03:00
Francisco Brito 5c350348d6 feature/#7907-script-to-change-local-storage-to-aws 2024-03-15 15:48:27 -03:00
244 changed files with 8745 additions and 7877 deletions
+1 -1
View File
@@ -582,7 +582,7 @@ GEM
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.24)
net-imap (0.6.4.1)
date
net-protocol
net-pop (0.1.2)
@@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder
def create_contact
account.contacts.create!(
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
name: contact_name,
phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email],
identifier: contact_attributes[:identifier],
@@ -59,6 +59,11 @@ class ContactInboxWithContactBuilder
)
end
def contact_name
name = contact_attributes[:name] || ::Haikunator.haikunate(1000)
name.truncate(ApplicationRecord::MAX_STRING_COLUMN_LENGTH, omission: '')
end
def find_contact
contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email])
-14
View File
@@ -7,7 +7,6 @@ class RoomChannel < ApplicationCable::Channel
ensure_stream
update_subscription
broadcast_presence
transmit_cache_keys
end
def update_presence
@@ -25,19 +24,6 @@ class RoomChannel < ApplicationCable::Channel
ActionCable.server.broadcast(pubsub_token, { event: 'presence.update', data: data })
end
# Push the authoritative cache-key map to this subscriber on every
# (re)subscribe. Boot and reconnect cache freshness ride the same
# account.cache_invalidated event the dashboard already handles for live
# invalidations — the client never pulls /cache_keys itself.
def transmit_cache_keys
return if @current_account.blank? || !@current_user.is_a?(User)
transmit({
event: Events::Types::ACCOUNT_CACHE_INVALIDATED,
data: { account_id: @current_account.id, cache_keys: @current_account.cache_keys }
})
end
def ensure_stream
stream_from pubsub_token
stream_from "account_#{@current_account.id}" if @current_account.present? && @current_user.is_a?(User)
@@ -53,7 +53,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
def category_params
params.require(:category).permit(
:name, :description, :position, :slug, :locale, :icon, :parent_category_id, :associated_category_id
:name, :description, :position, :slug, :locale, :icon, :icon_color, :parent_category_id, :associated_category_id
)
end
@@ -214,3 +214,5 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
render json: error, status: error_status
end
end
Api::V1::Accounts::ContactsController.prepend_mod_with('Api::V1::Accounts::ContactsController')
@@ -140,7 +140,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def destroy
authorize @conversation, :destroy?
::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
::Conversations::DeleteService.new(conversation: @conversation, user: Current.user, ip: request.ip).perform
head :ok
end
@@ -50,6 +50,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def cache_keys
expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes
render json: { cache_keys: cache_keys_for_account }, status: :ok
end
@@ -92,7 +93,11 @@ class Api::V1::AccountsController < Api::BaseController
end
def cache_keys_for_account
@account.cache_keys
{
label: fetch_value_for_key(params[:id], Label.name.underscore),
inbox: fetch_value_for_key(params[:id], Inbox.name.underscore),
team: fetch_value_for_key(params[:id], Team.name.underscore)
}
end
def fetch_account
@@ -0,0 +1,36 @@
class Api::V1::Profile::SessionsController < Api::BaseController
before_action :set_session, only: [:destroy]
def index
@sessions = current_user.user_sessions.where(client_id: active_token_client_ids).order(last_activity_at: :desc)
@current_client_id = request.headers['client']
end
def destroy
if @session.current?(request.headers['client'])
render json: { error: I18n.t('profile_settings.sessions.cannot_revoke_current') }, status: :unprocessable_entity
return
end
revoke_token!(@session.client_id)
@session.destroy!
head :ok
end
private
def set_session
@session = current_user.user_sessions.find(params[:id])
end
def revoke_token!(client_id)
tokens = current_user.tokens
tokens.delete(client_id)
current_user.update!(tokens: tokens)
end
def active_token_client_ids
now = Time.current.to_i
(current_user.tokens || {}).select { |_, v| v['expiry'].to_i > now }.keys
end
end
@@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base
include RequestExceptionHandler
include Pundit::Authorization
include SwitchLocale
include TrackSessionActivity
skip_before_action :verify_authenticity_token
@@ -0,0 +1,22 @@
module TrackSessionActivity
extend ActiveSupport::Concern
included do
after_action :update_session_activity
end
private
def update_session_activity
return unless current_user
return if request.headers['client'].blank?
UserSessionTrackingService.new(
user: current_user,
request: request,
client_id: request.headers['client']
).update_activity!
rescue StandardError => e
Rails.logger.warn "Session activity update failed: #{e.message}"
end
end
@@ -1,4 +1,6 @@
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
MAX_SESSIONS = ENV.fetch('MAX_USER_SESSIONS', 25).to_i
# Prevent session parameter from being passed
# Unpermitted parameter: session
wrap_parameters format: []
@@ -14,12 +16,14 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
user = find_user_for_authentication
return handle_mfa_required(user) if user&.mfa_enabled?
return if user && enforce_session_limit_for_password_login(user)
# Only proceed with standard authentication if no MFA is required
super
end
def render_create_success
track_user_session unless @impersonation
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
end
@@ -53,6 +57,8 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def handle_sso_authentication
return if !@impersonation && enforce_session_limit_for_password_login(@resource)
authenticate_resource_with_sso_token
yield @resource if block_given?
render_create_success
@@ -65,7 +71,10 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def authenticate_resource_with_sso_token
@token = @resource.create_token
# DTA evicts the earliest-expiring token after save when at max_number_of_devices.
# The short-lived impersonation token would always be that one, so pre-evict to make room.
make_room_for_impersonation_token if @impersonation
@token = @resource.create_token(lifespan: @impersonation ? 2.days.to_i : nil)
@resource.save!
sign_in(:user, @resource, store: false, bypass: false)
@@ -73,11 +82,21 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
@resource.invalidate_sso_auth_token(params[:sso_auth_token])
end
def make_room_for_impersonation_token
return if @resource.tokens.size < DeviseTokenAuth.max_number_of_devices
oldest_client_id = @resource.tokens.min_by { |_, v| v['expiry'].to_i }&.first
@resource.tokens.delete(oldest_client_id) if oldest_client_id
end
def process_sso_auth_token
return if params[:email].blank?
user = User.from_email(params[:email])
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
return unless user&.valid_sso_auth_token?(params[:sso_auth_token])
@resource = user
@impersonation = user.sso_auth_token_impersonation?(params[:sso_auth_token])
end
def handle_mfa_required(user)
@@ -103,6 +122,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def sign_in_mfa_user(user)
evict_oldest_session(user) if sessions_limit_reached?(user)
@resource = user
@token = @resource.create_token
@resource.save!
@@ -114,6 +134,103 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
def render_mfa_error(message_key, status = :bad_request)
render json: { error: I18n.t(message_key) }, status: status
end
def sessions_limit_reached?(user)
active_token_count(user) >= MAX_SESSIONS
end
def active_token_count(user)
now = Time.current.to_i
(user.tokens || {}).count { |_, v| v['expiry'].to_i > now }
end
# Returns true when a response has been rendered (e.g., 409 picker). Non-browser clients
# auto-evict instead of getting stuck on a UI they can't render.
def enforce_session_limit_for_password_login(user)
if revoking_sessions?
revoke_sessions_for_login(user)
return false
end
return false unless sessions_limit_reached?(user)
# Picker only when every token has a tracked session; partial tracking would
# show a misleading count, so fall through to silent eviction instead.
if browser_request? && user.user_sessions.count >= user.tokens.size
handle_sessions_limit_for_login(user)
true
else
evict_oldest_session(user)
false
end
end
def browser_request?
request.user_agent.to_s.include?('Mozilla')
end
def revoking_sessions?
params[:revoke_session_id].present? || params[:revoke_all_sessions].present?
end
def revoke_sessions_for_login(user)
if params[:revoke_all_sessions].present?
user.tokens = {}
user.save!
user.user_sessions.destroy_all
elsif params[:revoke_session_id].present?
session = user.user_sessions.find_by(id: params[:revoke_session_id])
return unless session
user.tokens.delete(session.client_id)
user.save!
session.destroy!
end
end
def evict_oldest_session(user)
# Drop pre-rollout untracked tokens first so freshly tracked logins aren't evicted.
return evict_oldest_token(user) if user.user_sessions.count < user.tokens.size
oldest_session = user.user_sessions.order(Arel.sql('COALESCE(last_activity_at, created_at) ASC')).first
return evict_oldest_token(user) unless oldest_session
user.tokens.delete(oldest_session.client_id)
user.save!
oldest_session.destroy!
end
# Fallback if a token exists without a UserSession row (e.g., legacy data before tracking shipped).
def evict_oldest_token(user)
return if user.tokens.blank?
oldest_client_id = user.tokens.min_by { |_, v| v['expiry'].to_i }&.first
return unless oldest_client_id
user.tokens.delete(oldest_client_id)
user.save!
end
PICKER_SESSION_FIELDS = %i[id browser_name browser_version device_name platform_name platform_version
ip_address city country last_activity_at created_at].freeze
def handle_sessions_limit_for_login(user)
sessions = user.user_sessions.order(last_activity_at: :desc).map { |s| s.slice(*PICKER_SESSION_FIELDS) }
render json: { sessions_limit_reached: true, sessions: sessions }, status: :conflict
end
def track_user_session
client_id = @token&.try(:client) || response.headers['client']
return unless client_id.present? && @resource.present?
UserSessionTrackingService.new(
user: @resource,
request: request,
client_id: client_id
).create_or_update!
rescue StandardError => e
Rails.logger.warn "Session tracking failed: #{e.message}"
end
end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
+11 -1
View File
@@ -35,7 +35,17 @@ class Twilio::CallbackController < ApplicationController
:ExternalUserId,
:ParentExternalUserId,
:ProfileUsername,
:Username
:Username,
:ReferralBody,
:ReferralHeadline,
:ReferralSourceId,
:ReferralSourceType,
:ReferralSourceUrl,
:ReferralMediaId,
:ReferralMediaContentType,
:ReferralMediaUrl,
:ReferralNumMedia,
:ReferralCtwaClid
)
end
end
+13 -1
View File
@@ -12,6 +12,7 @@ class ConversationFinder
'waiting_since_asc' => %w[sort_on_waiting_since asc],
'waiting_since_desc' => %w[sort_on_waiting_since desc],
'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc],
'unread' => %w[sort_on_unread desc],
# To be removed in v3.5.0
'latest' => %w[sort_on_last_activity_at desc],
@@ -40,7 +41,7 @@ class ConversationFinder
def perform
set_up
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
mine_count, unassigned_count, all_count = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
filter_by_assignee_type
@@ -184,6 +185,17 @@ class ConversationFinder
end
def set_count_for_all_conversations
return legacy_count_for_all_conversations if @conversations.limit_value || @conversations.offset_value || @conversations.eager_loading?
counts = @conversations.unscope(:order).pick(
Arel.sql("COUNT(*) FILTER (WHERE assignee_id = #{current_user.id})"),
Arel.sql('COUNT(*) FILTER (WHERE assignee_id IS NULL)'),
Arel.sql('COUNT(*)')
)
counts || [0, 0, 0]
end
def legacy_count_for_all_conversations
[
@conversations.assigned_to(current_user).count,
@conversations.unassigned.count,
+17 -5
View File
@@ -17,15 +17,11 @@ module PortalHelper
uri.to_s
end
def generate_portal_bg_color(portal_color, theme)
def generate_portal_bg(portal_color, theme)
base_color = theme == 'dark' ? 'black' : 'white'
"color-mix(in srgb, #{portal_color} 20%, #{base_color})"
end
def generate_portal_bg(portal_color, theme)
generate_portal_bg_color(portal_color, theme)
end
def generate_gradient_to_bottom(theme)
base_color = theme == 'dark' ? '#151718' : 'white'
"linear-gradient(to bottom, transparent, #{base_color})"
@@ -41,6 +37,10 @@ module PortalHelper
language_map[locale] || locale
end
def html_lang_attribute(locale)
locale.to_s.tr('_', '-')
end
def theme_query_string(theme)
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
end
@@ -97,6 +97,18 @@ module PortalHelper
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
end
# Renders a stored category icon: a bare ri icon name (e.g. `vip-crown-2-fill/line`) saved color, or a plain emoji character.
def render_emoji_or_icon(value, color = nil)
return '' if value.blank?
# Emojis are non-ascii; bare icon names match this safe charset.
return ERB::Util.html_escape(value) unless value.match?(/\A[a-z][a-z0-9-]*\z/)
icon_class = value.start_with?('i-') ? value : "i-ri-#{value}"
style = "color: #{color};" if color.to_s.match?(/\A#\h{3,8}\z/)
tag.span(class: icon_class, style: style, 'aria-hidden': true)
end
def thumbnail_bg_color(username)
colors = ['#6D95BA', '#A4C3C3', '#E19191']
return colors.sample if username.blank?
+2 -10
View File
@@ -19,7 +19,6 @@ import {
verifyServiceWorkerExistence,
} from './helper/pushHelper';
import ReconnectService from 'dashboard/helper/ReconnectService';
import paintStoresFromCache from 'dashboard/helper/CacheHelper/paintStoresFromCache';
import { useUISettings } from 'dashboard/composables/useUISettings';
export default {
@@ -109,21 +108,14 @@ export default {
this.$store.dispatch('setActiveAccount', {
accountId: this.currentAccountId,
});
const { pubsub_token: pubsubToken } = this.currentUser || {};
vueActionCable.init(this.store, pubsubToken);
// Paint cached config from IndexedDB instantly while the cable
// connects. Freshness needs no orchestration here: RoomChannel pushes
// the cache-key map on every (re)subscribe and on every server-side
// change, all through the same account.cache_invalidated event.
await paintStoresFromCache(this.$store, this.currentAccountId);
const account = this.getAccount(this.currentAccountId);
const { locale, latest_chatwoot_version: latestChatwootVersion } =
account;
const { pubsub_token: pubsubToken } = this.currentUser || {};
// If user locale is set, use it; otherwise use account locale
this.setLocale(this.uiSettings?.locale || locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(this.store, pubsubToken);
this.reconnectService = new ReconnectService(this.store, this.router);
window.reconnectService = this.reconnectService;
@@ -5,15 +5,14 @@ import ApiClient from './ApiClient';
class CacheEnabledApiClient extends ApiClient {
constructor(resource, options = {}) {
super(resource, options);
// `cacheModel` is the Rails Model.name.underscore value — simultaneously
// the server cache-key name and the IDB object-store name.
this.cacheModelName = options.cacheModel;
// inbox/label endpoints wrap collections in { payload }; the rest return
// the bare array.
this.payloadEnvelope = options.payloadEnvelope || false;
this.dataManager = new DataManager(this.accountIdFromRoute);
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
throw new Error('cacheModelName is not defined');
}
get(cache = false) {
if (cache) {
return this.getFromCache();
@@ -26,14 +25,14 @@ class CacheEnabledApiClient extends ApiClient {
return axios.get(this.url);
}
// eslint-disable-next-line class-methods-use-this
extractDataFromResponse(response) {
return this.payloadEnvelope ? response.data.payload : response.data;
return response.data.payload;
}
// eslint-disable-next-line class-methods-use-this
marshallData(dataToParse) {
return this.payloadEnvelope
? { data: { payload: dataToParse } }
: { data: dataToParse };
return { data: { payload: dataToParse } };
}
async getFromCache() {
@@ -44,23 +43,24 @@ class CacheEnabledApiClient extends ApiClient {
return this.getFromNetwork();
}
// Trust the IDB cache. Freshness is maintained by the
// account.cache_invalidated event alone: RoomChannel pushes the cache-key
// map on every (re)subscribe — boot and reconnect included — and the
// server broadcasts it on every change. Skipping a per-call /cache_keys
// preflight eliminates N GET requests per cold settings-page load.
const localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
const { data } = await axios.get(
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
);
const cacheKeyFromApi = data.cache_keys[this.cacheModelName];
const isCacheValid = await this.validateCacheKey(cacheKeyFromApi);
if (localData.length > 0) {
return this.marshallData(localData);
let localData = [];
if (isCacheValid) {
localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
}
// Empty IDB (first load or wiped): fetch data without a cache key. The
// next pushed key map won't match the missing key and will refetch once,
// stamping the authoritative key — the client never pulls keys itself.
return this.refetchAndCommit(null);
if (localData.length === 0) {
return this.refetchAndCommit(cacheKeyFromApi);
}
return this.marshallData(localData);
}
async refetchAndCommit(newKey = null) {
@@ -69,9 +69,7 @@ class CacheEnabledApiClient extends ApiClient {
try {
await this.dataManager.initDb();
// Await replace so data is persisted before the cache key is — otherwise
// a concurrent reader could see a fresh key paired with stale data.
await this.dataManager.replace({
this.dataManager.replace({
modelName: this.cacheModelName,
data: this.extractDataFromResponse(response),
});
@@ -91,15 +89,8 @@ class CacheEnabledApiClient extends ApiClient {
await this.dataManager.initDb();
}
const cacheKey = await this.dataManager.getCacheKey(this.cacheModelName);
if (cacheKey === undefined) {
const localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
return localData.length === 0;
}
return cacheKeyFromApi === cacheKey;
const cachekey = await this.dataManager.getCacheKey(this.cacheModelName);
return cacheKeyFromApi === cachekey;
}
}
+7
View File
@@ -9,6 +9,13 @@ class AccountAPI extends ApiClient {
createAccount(data) {
return axios.post(`${this.apiVersion}/accounts`, data);
}
async getCacheKeys() {
const response = await axios.get(
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
);
return response.data.cache_keys;
}
}
export default new AccountAPI();
+6
View File
@@ -106,4 +106,10 @@ export default {
const urlData = endPoints('resetAccessToken');
return axios.post(urlData.url);
},
getSessions() {
return axios.get('/api/v1/profile/sessions');
},
revokeSession(id) {
return axios.delete(`/api/v1/profile/sessions/${id}`);
},
};
+6
View File
@@ -40,6 +40,12 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/conversations`, { params });
}
getAttachments(contactId, page = 1) {
return axios.get(`${this.url}/${contactId}/attachments`, {
params: { page },
});
}
getContactableInboxes(contactId) {
return axios.get(`${this.url}/${contactId}/contactable_inboxes`);
}
+12 -5
View File
@@ -3,11 +3,12 @@ import CacheEnabledApiClient from './CacheEnabledApiClient';
class Inboxes extends CacheEnabledApiClient {
constructor() {
super('inboxes', {
accountScoped: true,
cacheModel: 'inbox',
payloadEnvelope: true,
});
super('inboxes', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'inbox';
}
getCampaigns(inboxId) {
@@ -59,6 +60,12 @@ class Inboxes extends CacheEnabledApiClient {
disableWhatsappCalling(inboxId) {
return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
}
setInboundCalls(inboxId, enabled) {
return axios.post(`${this.url}/${inboxId}/set_inbound_calls`, {
inbound_calls_enabled: enabled,
});
}
}
export default new Inboxes();
+6 -5
View File
@@ -2,11 +2,12 @@ import CacheEnabledApiClient from './CacheEnabledApiClient';
class LabelsAPI extends CacheEnabledApiClient {
constructor() {
super('labels', {
accountScoped: true,
cacheModel: 'label',
payloadEnvelope: true,
});
super('labels', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'label';
}
}
+17 -1
View File
@@ -1,9 +1,25 @@
/* global axios */
// import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
export class TeamsAPI extends CacheEnabledApiClient {
constructor() {
super('teams', { accountScoped: true, cacheModel: 'team' });
super('teams', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'team';
}
// eslint-disable-next-line class-methods-use-this
extractDataFromResponse(response) {
return response.data;
}
// eslint-disable-next-line class-methods-use-this
marshallData(dataToParse) {
return { data: dataToParse };
}
getAgents({ teamId }) {
@@ -26,6 +26,13 @@ const resetForm = () => {
form.description = '';
};
const open = (company = {}) => {
form.name = company.name || '';
form.domain = company.domain || '';
form.description = company.description || '';
dialogRef.value?.open();
};
const handleConfirm = () => {
if (isFormInvalid.value) return;
@@ -45,7 +52,7 @@ const onSuccess = () => {
closeDialog();
};
defineExpose({ dialogRef, onSuccess });
defineExpose({ dialogRef, onSuccess, open });
</script>
<template>
@@ -0,0 +1,153 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useDebounceFn } from '@vueuse/core';
import CompanyAPI from 'dashboard/api/companies';
import { useAlert } from 'dashboard/composables';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import CompanyCreateDialog from 'dashboard/components-next/Companies/CompanyCreateDialog.vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
// Name of the linked company, so the label shows before the list is loaded.
selectedName: {
type: String,
default: '',
},
isDetailsView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const CREATE_PREFIX = 'create:';
const options = ref([]);
const searchQuery = ref('');
const createDialogRef = ref(null);
const isCreatingCompany = ref(false);
const toOption = company => ({ label: company.name, value: company.id });
const createOption = computed(() => {
const name = searchQuery.value.trim();
if (!name) return null;
const exists = options.value.some(
option => option.label.toLowerCase() === name.toLowerCase()
);
if (exists) return null;
return {
label: t('COMPANIES.SELECTOR.CREATE_OPTION', { name }),
value: `${CREATE_PREFIX}${name}`,
};
});
const comboboxOptions = computed(() => {
const list = [...options.value];
// Keep the linked company visible even when it is not in the loaded results.
if (
props.modelValue &&
props.selectedName &&
!list.some(option => option.value === Number(props.modelValue))
) {
list.unshift({
label: props.selectedName,
value: Number(props.modelValue),
});
}
if (createOption.value) list.push(createOption.value);
return list;
});
const fetchCompanies = async query => {
try {
const {
data: { payload },
} = query
? await CompanyAPI.search(query)
: await CompanyAPI.get({ page: 1 });
options.value = (payload || []).map(toOption);
} catch {
options.value = [];
}
};
// Fetch lazily, only when the dropdown opens, instead of on mount.
const handleOpen = () => {
searchQuery.value = '';
fetchCompanies('');
};
const handleSearch = useDebounceFn(query => {
searchQuery.value = query?.trim() || '';
fetchCompanies(searchQuery.value);
}, 300);
// Open the create dialog (prefilled with the typed name) so the user can add
// domain/description before saving, instead of creating with just a name.
const createCompany = async company => {
isCreatingCompany.value = true;
try {
const {
data: { payload },
} = await CompanyAPI.create({ company });
createDialogRef.value?.onSuccess();
emit('select', { id: payload.id, name: payload.name });
useAlert(t('COMPANIES.CREATE.MESSAGES.SUCCESS'));
} catch {
useAlert(t('COMPANIES.CREATE.MESSAGES.ERROR'));
} finally {
isCreatingCompany.value = false;
}
};
const handleSelect = value => {
if (typeof value === 'string' && value.startsWith(CREATE_PREFIX)) {
createDialogRef.value?.open({ name: value.slice(CREATE_PREFIX.length) });
// Drop the transient "Add …" option so the button label doesn't stick to
// it if the dialog is dismissed without creating.
searchQuery.value = '';
return;
}
const id = value ? Number(value) : '';
const selected = comboboxOptions.value.find(option => option.value === id);
emit('select', { id, name: selected?.label || '' });
};
</script>
<template>
<ComboBox
:model-value="modelValue"
:options="comboboxOptions"
:display-label="selectedName"
:placeholder="t('COMPANIES.SELECTOR.PLACEHOLDER')"
:search-placeholder="t('COMPANIES.SEARCH_PLACEHOLDER')"
use-api-results
class="[&>div>button]:h-8 [&>div>div_ul]:max-h-56"
:class="{
'[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:!outline-transparent':
!isDetailsView,
'[&>div>button]:!bg-n-alpha-black2': isDetailsView,
}"
@open="handleOpen"
@search="handleSearch"
@update:model-value="handleSelect"
/>
<CompanyCreateDialog
ref="createDialogRef"
:is-loading="isCreatingCompany"
@create="createCompany"
/>
</template>
@@ -15,6 +15,7 @@ const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
email: { type: String, default: '' },
companyId: { type: [Number, String], default: '' },
additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' },
@@ -41,6 +42,7 @@ const getInitialContactData = () => ({
id: props.id,
name: props.name,
email: props.email,
companyId: props.companyId,
phoneNumber: props.phoneNumber,
additionalAttributes: props.additionalAttributes,
});
@@ -128,9 +128,14 @@ const closeMobileSidebar = () => {
<!-- Desktop sidebar -->
<div
v-if="slots.sidebar"
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
>
<slot name="sidebar" />
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
</div>
<!-- Mobile sidebar container -->
@@ -179,9 +184,14 @@ const closeMobileSidebar = () => {
<div
v-if="isContactSidebarOpen"
id="contact-sidebar-content"
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
>
<slot name="sidebar" />
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
</div>
</Transition>
</div>
@@ -5,8 +5,11 @@ import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { splitName } from '@chatwoot/utils';
import countries from 'shared/constants/countries.js';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import CompanySelector from 'dashboard/components-next/Companies/CompanySelector.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue';
@@ -28,6 +31,7 @@ const props = defineProps({
const emit = defineEmits(['update']);
const { t } = useI18n();
const { currentAccount, isCloudFeatureEnabled } = useAccount();
const FORM_CONFIG = {
FIRST_NAME: { field: 'firstName' },
@@ -54,6 +58,7 @@ const defaultState = {
id: 0,
name: '',
email: '',
companyId: '',
firstName: '',
lastName: '',
phoneNumber: '',
@@ -85,6 +90,23 @@ const validationRules = {
const v$ = useVuelidate(validationRules, state);
const isFormInvalid = computed(() => v$.value.$invalid);
const hasCompaniesFeature = computed(
() =>
currentAccount.value?.id && isCloudFeatureEnabled(FEATURE_FLAGS.COMPANIES)
);
const showCompanySelector = computed(
() =>
hasCompaniesFeature.value &&
(Boolean(state.companyId) || !state.additionalAttributes.companyName)
);
const emitContactUpdate = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
const { firstName, lastName, ...stateWithoutNames } = state;
emit('update', stateWithoutNames);
};
const prepareStateBasedOnProps = () => {
if (props.isNewContact) {
@@ -96,6 +118,7 @@ const prepareStateBasedOnProps = () => {
name = '',
email: emailAddress,
phoneNumber,
companyId = '',
additionalAttributes = {},
} = props.contactData || {};
const { firstName, lastName } = splitName(name || '');
@@ -115,6 +138,7 @@ const prepareStateBasedOnProps = () => {
Object.assign(state, {
id,
name,
companyId: companyId || '',
firstName,
lastName,
email: emailAddress,
@@ -200,11 +224,7 @@ const getFormBinding = key => {
}
}
const isFormValid = await v$.value.$validate();
if (isFormValid) {
const { firstName, lastName, ...stateWithoutNames } = state;
emit('update', stateWithoutNames);
}
await emitContactUpdate();
},
});
};
@@ -221,6 +241,12 @@ const handleCountrySelection = value => {
emit('update', state);
};
const handleCompanySelection = async ({ id, name }) => {
state.companyId = id || '';
state.additionalAttributes.companyName = name || '';
await emitContactUpdate();
};
const resetValidation = () => {
v$.value.$reset();
};
@@ -273,6 +299,13 @@ defineExpose({
:placeholder="item.placeholder"
:show-border="isDetailsView"
/>
<CompanySelector
v-else-if="item.key === 'COMPANY_NAME' && showCompanySelector"
:model-value="state.companyId"
:selected-name="state.additionalAttributes.companyName"
:is-details-view="isDetailsView"
@select="handleCompanySelection"
/>
<Input
v-else
v-model="getFormBinding(item.key).value"
@@ -108,7 +108,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
</script>
<template>
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6 py-6">
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6">
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
<ContactCustomAttributeItem
v-for="attribute in usedAttributes"
@@ -36,7 +36,7 @@ const contactConversations = computed(() =>
</div>
<div
v-else-if="contactConversations.length > 0"
class="px-6 py-4 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
>
<ConversationCard
v-for="conversation in contactConversations"
@@ -0,0 +1,108 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import {
MEDIA_TYPES,
NON_FILE_TYPES,
} from 'dashboard/components-next/message/constants';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
import Media from 'dashboard/components-next/SharedAttachments/Media.vue';
import Files from 'dashboard/components-next/SharedAttachments/Files.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const MEDIA_PEEK_LIMIT = 12;
const FILES_PEEK_LIMIT = 6;
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const attachmentsByContact = useMapGetter('contacts/getContactAttachments');
const uiFlags = useMapGetter('contacts/getUIFlags');
const attachments = computed(() =>
attachmentsByContact.value(route.params.contactId)
);
const isFetching = computed(() => uiFlags.value.isFetchingAttachments);
const hasContent = computed(() =>
attachments.value.some(
a => a.data_url && !NON_FILE_TYPES.includes(a.file_type)
)
);
const mediaAttachments = computed(() =>
attachments.value
.filter(a => MEDIA_TYPES.includes(a.file_type) && a.data_url)
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
);
const showGallery = ref(false);
const selectedAttachment = ref(null);
const onMediaSelect = attachment => {
selectedAttachment.value = attachment;
showGallery.value = true;
};
const onFileSelect = attachment => {
if (attachment.data_url) {
window.open(attachment.data_url, '_blank', 'noopener,noreferrer');
}
};
const onJumpToMessage = attachment => {
if (!attachment.conversation_id || !attachment.message_id) return;
router.push({
name: 'inbox_conversation',
params: {
accountId: route.params.accountId,
conversation_id: attachment.conversation_id,
},
query: { messageId: attachment.message_id },
});
};
onMounted(() => {
store.dispatch('contacts/fetchAttachments', route.params.contactId);
});
</script>
<template>
<div class="px-6">
<div v-if="isFetching" class="flex justify-center p-3">
<Spinner class="size-5" />
</div>
<p v-else-if="!hasContent" class="p-3 text-sm text-center text-n-slate-11">
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
</p>
<div v-else class="flex flex-col gap-5">
<Media
:attachments="attachments"
:peek-limit="MEDIA_PEEK_LIMIT"
show-jump-to-message
@select="onMediaSelect"
@jump-to-message="onJumpToMessage"
/>
<Files
:attachments="attachments"
:peek-limit="FILES_PEEK_LIMIT"
show-jump-to-message
@select="onFileSelect"
@jump-to-message="onJumpToMessage"
/>
</div>
<GalleryView
v-if="showGallery && selectedAttachment"
v-model:show="showGallery"
:attachment="selectedAttachment"
:all-attachments="mediaAttachments"
auto-play
@close="showGallery = false"
/>
</div>
</template>
@@ -103,7 +103,7 @@ const onMergeContacts = async () => {
</script>
<template>
<div class="flex flex-col gap-8 px-6 py-6">
<div class="flex flex-col gap-8 px-6">
<div class="flex flex-col gap-2">
<h4 class="text-base text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
@@ -55,7 +55,7 @@ useKeyboardEvents(keyboardEvents);
</script>
<template>
<div class="flex flex-col gap-6 py-6">
<div class="flex flex-col gap-6">
<Editor
v-model="state.message"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
@@ -92,6 +92,7 @@ const handleAvatarHover = (id, isHovered) => {
:id="contact.id"
:name="contact.name"
:email="contact.email"
:company-id="contact.companyId"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
@@ -13,6 +13,7 @@ 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 EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.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';
@@ -131,7 +132,7 @@ const statusText = computed(() => {
const categoryName = computed(() => {
if (props.category?.slug) {
return `${props.category.icon} ${props.category.name}`;
return props.category.name;
}
return t(
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.CATEGORY.UNCATEGORISED'
@@ -222,7 +223,15 @@ const handleClick = id => {
{{ authorName || '-' }}
</span>
</div>
<span class="block text-sm whitespace-nowrap text-n-slate-11">
<span
class="flex items-center gap-1 text-sm whitespace-nowrap text-n-slate-11"
>
<EmojiIcon
v-if="category?.icon"
:value="category.icon"
:color="category.icon_color"
class="flex-shrink-0 size-4"
/>
{{ categoryName }}
</span>
<div
@@ -6,6 +6,7 @@ import { useToggle } from '@vueuse/core';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
const props = defineProps({
id: {
@@ -20,6 +21,10 @@ const props = defineProps({
type: String,
required: true,
},
iconColor: {
type: String,
default: '',
},
description: {
type: String,
required: true,
@@ -55,10 +60,6 @@ const categoryMenuItems = [
},
];
const categoryTitleWithIcon = computed(() => {
return `${props.icon} ${props.title}`;
});
const description = computed(() => {
return props.description ? props.description : 'No description added';
});
@@ -83,10 +84,16 @@ const handleAction = ({ action, value }) => {
<div class="flex justify-between w-full gap-2">
<div class="flex items-center justify-start w-full min-w-0 gap-2">
<span
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
class="flex items-center gap-1.5 text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
@click="handleClick(slug)"
>
{{ categoryTitleWithIcon }}
<EmojiIcon
v-if="icon"
:value="icon"
:color="iconColor"
class="shrink-0 size-4"
/>
<span class="truncate">{{ title }}</span>
</span>
<span
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg bg-n-slate-1 whitespace-nowrap shrink-0 text-n-slate-11 border-n-slate-4"
@@ -8,6 +8,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import ArticleEditorProperties from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorProperties.vue';
const props = defineProps({
@@ -107,10 +108,11 @@ const selectedCategory = computed(() => {
const categoryList = computed(() => {
return (
categories.value
.map(({ name, id, icon }) => ({
.map(({ name, id, icon, icon_color: iconColor }) => ({
label: name,
value: id,
emoji: icon,
iconColor,
isSelected: isNewArticle.value
? id === (selectedCategoryId.value || selectedCategory.value?.id)
: id === props.article?.category?.id,
@@ -202,10 +204,6 @@ onMounted(() => {
<div class="relative">
<OnClickOutside @trigger="openCategoryList = false">
<Button
:label="
selectedCategory?.name ||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')
"
:icon="!selectedCategory?.icon ? 'i-lucide-shapes' : ''"
variant="ghost"
color="slate"
@@ -213,12 +211,20 @@ onMounted(() => {
@click="openCategoryList = !openCategoryList"
>
<span
v-if="selectedCategory"
class="text-sm text-n-slate-12 hover:text-n-slate-11"
class="flex items-center gap-1.5 min-w-0 text-sm text-n-slate-12 hover:text-n-slate-11"
>
{{
`${selectedCategory.icon || ''} ${selectedCategory.name || t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')}`
}}
<EmojiIcon
v-if="selectedCategory?.icon"
:value="selectedCategory.icon"
:color="selectedCategory.icon_color"
class="flex-shrink-0 size-4"
/>
<span class="truncate">
{{
selectedCategory?.name ||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')
}}
</span>
</span>
</Button>
<DropdownMenu
@@ -13,6 +13,7 @@ import {
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
const props = defineProps({
categories: {
@@ -63,18 +64,15 @@ const activeTabIndex = computed(() => {
return tabs.value.findIndex(tab => tab.value === tabParam);
});
const activeCategoryName = computed(() => {
const activeCategory = props.categories.find(
category => category.slug === route.params.categorySlug
);
const activeCategory = computed(() =>
props.categories.find(category => category.slug === route.params.categorySlug)
);
if (activeCategory) {
const { icon, name } = activeCategory;
return `${icon} ${name}`;
}
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL');
});
const activeCategoryName = computed(
() =>
activeCategory.value?.name ||
t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL')
);
const activeLocaleName = computed(() => {
return props.allowedLocales.find(
@@ -94,6 +92,7 @@ const categoryMenuItems = computed(() => {
value: category.slug,
action: 'filter',
emoji: category.icon,
iconColor: category.icon_color,
}));
const hasCategorySlug = !!route.params.categorySlug;
@@ -167,14 +166,23 @@ const handleTabChange = value => {
<div v-if="hasCategoryMenuItems" class="relative group">
<OnClickOutside @trigger="isCategoryMenuOpen = false">
<Button
:label="activeCategoryName"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
/>
>
<span class="flex items-center gap-1.5 min-w-0">
<EmojiIcon
v-if="activeCategory?.icon"
:value="activeCategory.icon"
:color="activeCategory.icon_color"
class="flex-shrink-0 size-4"
/>
<span class="truncate">{{ activeCategoryName }}</span>
</span>
</Button>
<DropdownMenu
v-if="isCategoryMenuOpen"
@@ -230,6 +230,7 @@ const categoryMenuItems = computed(() =>
value: category.id,
action: 'move',
emoji: category.icon,
iconColor: category.icon_color,
}))
);
@@ -38,8 +38,8 @@ const { t } = useI18n();
const route = useRoute();
const handleCategory = async formData => {
const { id, name, slug, icon, description, locale } = formData;
const categoryData = { name, icon, slug, description };
const { id, name, slug, icon, iconColor, description, locale } = formData;
const categoryData = { name, icon, icon_color: iconColor, slug, description };
if (props.mode === 'create') {
categoryData.locale = locale;
@@ -18,6 +18,7 @@ import { convertToCategorySlug } from 'dashboard/helper/commons.js';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
const props = defineProps({
mode: {
@@ -49,8 +50,9 @@ const props = defineProps({
const emit = defineEmits(['submit', 'cancel']);
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
const EmojiIconPicker = defineAsyncComponent(
() =>
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
);
const { t } = useI18n();
@@ -72,6 +74,7 @@ const state = reactive({
id: '',
name: '',
icon: '',
iconColor: '',
slug: '',
description: '',
locale: '',
@@ -109,8 +112,19 @@ const slugHelpText = computed(() => {
});
});
const onClickInsertEmoji = emoji => {
state.icon = emoji;
const onSelectIcon = ({ type, value, color }) => {
state.icon = value;
state.iconColor = type === 'icon' ? color : '';
isEmojiPickerOpen.value = false;
};
const onColorChange = color => {
state.iconColor = color;
};
const onRemoveIcon = () => {
state.icon = '';
state.iconColor = '';
isEmojiPickerOpen.value = false;
};
@@ -139,7 +153,14 @@ watch(
newCategory => {
if (props.mode === 'edit' && newCategory) {
const { id, name, icon, slug, description } = newCategory;
Object.assign(state, { id, name, icon, slug, description });
Object.assign(state, {
id,
name,
icon,
iconColor: newCategory.icon_color || '',
slug,
description,
});
}
},
{ immediate: true }
@@ -197,19 +218,29 @@ defineExpose({ state, isSubmitDisabled });
<template #prefix>
<OnClickOutside @trigger="isEmojiPickerOpen = false">
<Button
:label="state.icon"
color="slate"
size="sm"
type="button"
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
class="!h-[2.38rem] !w-[2.375rem] absolute top-[2rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
variant="ghost"
color="slate"
class="!h-[2.38rem] !w-[2.375rem] !p-0 absolute top-[2rem] start-px ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
>
<EmojiIcon
v-if="state.icon"
:value="state.icon"
:color="state.iconColor"
class="size-5 text-xl !leading-5"
/>
<span v-else class="i-lucide-smile-plus size-4" />
</Button>
<EmojiIconPicker
v-if="isEmojiPickerOpen"
class="left-0 top-16"
:value="state.icon"
:color="state.iconColor"
show-remove-button
:on-click="onClickInsertEmoji"
@select="onSelectIcon"
@color-change="onColorChange"
@remove="onRemoveIcon"
/>
</OnClickOutside>
</template>
@@ -106,6 +106,7 @@ const breadcrumbItems = computed(() => {
}
),
emoji: selectedCategoryEmoji.value,
iconColor: selectedCategory.value?.icon_color,
});
}
return items;
@@ -68,6 +68,7 @@ watch(
:id="element.id"
:title="element.name"
:icon="element.icon"
:icon-color="element.icon_color"
:description="element.description"
:articles-count="element.meta?.articles_count || 0"
:slug="element.slug"
@@ -52,8 +52,8 @@ const activeLocaleCode = computed(() => activeLocale.value?.code ?? '');
const onUpdateCategory = async () => {
if (!categoryFormRef.value) return;
const { state } = categoryFormRef.value;
const { id, name, slug, icon, description } = state;
const categoryData = { name, icon, slug, description };
const { id, name, slug, icon, iconColor, description } = state;
const categoryData = { name, icon, icon_color: iconColor, slug, description };
categoryData.id = id;
try {
@@ -53,8 +53,9 @@ const generateUid = () => {
const uploadAttachment = ref(null);
const isEmojiPickerOpen = ref(false);
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
const EmojiIconPicker = defineAsyncComponent(
() =>
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
);
const {
@@ -215,10 +216,11 @@ useEventListener(document, 'paste', onPaste);
class="!w-10"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
<EmojiIconPicker
v-if="isEmojiPickerOpen"
mode="emoji"
class="!top-auto !bottom-full mb-1.5 ltr:left-0 rtl:right-0"
:on-click="onClickInsertEmoji"
@select="onClickInsertEmoji($event.value)"
/>
</div>
<FileUpload
@@ -0,0 +1,175 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { formatBytes } from 'shared/helpers/FileHelper';
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
import { downloadFile } from '@chatwoot/utils';
import {
MEDIA_TYPES,
NON_FILE_TYPES,
} from 'dashboard/components-next/message/constants';
import FileIcon from 'next/icon/FileIcon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attachments: { type: Array, default: () => [] },
peekLimit: { type: Number, default: 0 },
showJumpToMessage: { type: Boolean, default: false },
});
const emit = defineEmits(['select', 'jumpToMessage']);
const { t } = useI18n();
const fileAttachments = computed(() =>
[...props.attachments]
.filter(
a =>
a.data_url &&
!MEDIA_TYPES.includes(a.file_type) &&
!NON_FILE_TYPES.includes(a.file_type)
)
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
);
const showAll = ref(false);
const downloadingId = ref(null);
const isPeekable = computed(() => props.peekLimit > 0);
const visibleFiles = computed(() => {
if (!isPeekable.value || showAll.value) return fileAttachments.value;
return fileAttachments.value.slice(0, props.peekLimit);
});
const fileNameFromUrl = url => {
if (!url) return '';
const name = url.split('/').pop();
return name ? decodeURIComponent(name) : '';
};
const displayName = attachment =>
fileNameFromUrl(attachment.data_url) ||
t('CONVERSATION_SIDEBAR.SHARED_FILES.UNTITLED_FILE');
const displaySize = attachment => {
if (attachment.file_size) return formatBytes(attachment.file_size);
if (attachment.extension) return attachment.extension.toUpperCase();
return '—';
};
const displayTime = attachment => {
if (!attachment.created_at) return '';
return shortTimestamp(dynamicTime(attachment.created_at), true);
};
const onActivate = attachment => emit('select', attachment);
const onDownloadFile = async attachment => {
const { id, file_type: type, data_url: url, extension } = attachment;
try {
downloadingId.value = id;
await downloadFile({ url, type, extension });
} catch (error) {
useAlert(t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD_ERROR'));
} finally {
downloadingId.value = null;
}
};
</script>
<template>
<section v-if="fileAttachments.length" class="flex flex-col gap-2.5">
<header class="flex items-center justify-between px-0.5">
<h4
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
>
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.FILES_HEADING') }}
<span
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
>
{{ fileAttachments.length }}
</span>
</h4>
<NextButton
v-if="isPeekable && fileAttachments.length > peekLimit"
ghost
slate
xs
trailing-icon
:icon="showAll ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'"
:label="
showAll
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
"
@click="showAll = !showAll"
/>
</header>
<ul class="flex flex-col gap-0.5">
<li
v-for="attachment in visibleFiles"
:key="attachment.id"
role="button"
tabindex="0"
class="flex items-center gap-3 px-2 py-2 transition-colors rounded-lg cursor-pointer hover:bg-n-slate-3 group focus:outline-none focus-visible:ring-2 focus-visible:ring-n-blue-9"
@click="onActivate(attachment)"
@keydown.enter="onActivate(attachment)"
@keydown.space.prevent="onActivate(attachment)"
>
<div
class="flex items-center justify-center rounded-lg size-9 shrink-0 bg-gradient-to-br from-n-slate-3 to-n-slate-4 ring-1 ring-inset ring-n-slate-4/40"
>
<FileIcon
:file-type="attachment.extension?.toLowerCase() || ''"
class="size-4 text-n-slate-11"
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate text-n-slate-12 mb-1">
{{ displayName(attachment) }}
</p>
<p class="text-xs text-n-slate-11">
{{ displaySize(attachment) }}
<template v-if="displayTime(attachment)">
· {{ displayTime(attachment) }}
</template>
</p>
</div>
<div class="flex items-center gap-1">
<NextButton
v-if="showJumpToMessage && attachment.message_id"
v-tooltip.top="{
content: t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE'),
delay: { show: 500, hide: 0 },
}"
ghost
slate
sm
icon="i-lucide-external-link"
class="opacity-0 group-hover:opacity-100"
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE')"
@click.stop="emit('jumpToMessage', attachment)"
@keydown.enter.stop
@keydown.space.stop
/>
<NextButton
ghost
slate
sm
icon="i-lucide-download"
class="opacity-0 group-hover:opacity-100"
:is-loading="downloadingId === attachment.id"
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
@click.stop="onDownloadFile(attachment)"
@keydown.enter.stop
@keydown.space.stop
/>
</div>
</li>
</ul>
</section>
<template v-else />
</template>
@@ -0,0 +1,305 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import {
dynamicTime,
formatDuration,
shortTimestamp,
} from 'shared/helpers/timeHelper';
import { downloadFile } from '@chatwoot/utils';
import {
ATTACHMENT_TYPES,
MEDIA_TYPES,
} from 'dashboard/components-next/message/constants';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attachments: { type: Array, default: () => [] },
peekLimit: { type: Number, default: 0 },
showJumpToMessage: { type: Boolean, default: false },
});
const emit = defineEmits(['select', 'jumpToMessage']);
const { t } = useI18n();
const mediaAttachments = computed(() =>
[...props.attachments]
.filter(a => a.data_url && MEDIA_TYPES.includes(a.file_type))
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
);
const showAll = ref(false);
const failedThumbs = ref(new Set());
const failedPreviews = ref(new Set());
const durations = ref({});
const downloadingId = ref(null);
const isPeekable = computed(() => props.peekLimit > 0);
const visibleMedia = computed(() => {
if (!isPeekable.value || showAll.value) return mediaAttachments.value;
return mediaAttachments.value.slice(0, props.peekLimit);
});
const overflow = computed(() => {
if (!isPeekable.value) return 0;
const total = mediaAttachments.value.length;
return total > props.peekLimit ? total - (props.peekLimit - 1) : 0;
});
const fileNameFromUrl = url => {
if (!url) return '';
const name = url.split('/').pop();
return name ? decodeURIComponent(name) : '';
};
const isVideoType = type =>
[ATTACHMENT_TYPES.VIDEO, ATTACHMENT_TYPES.IG_REEL].includes(type);
const isAudioType = type => type === ATTACHMENT_TYPES.AUDIO;
const isPlayableType = type => isVideoType(type) || isAudioType(type);
const imagePreviewSrc = ({
id,
file_type: type,
thumb_url: thumbUrl,
data_url: dataUrl,
}) => {
const canUseThumb = thumbUrl && !failedThumbs.value.has(id);
if (type === ATTACHMENT_TYPES.IMAGE) return canUseThumb ? thumbUrl : dataUrl;
if (isVideoType(type)) return canUseThumb ? thumbUrl : null;
return null;
};
const onPreviewError = ({
id,
file_type: type,
thumb_url: thumbUrl,
data_url: dataUrl,
}) => {
const canRetryWithFull = thumbUrl && !failedThumbs.value.has(id) && dataUrl;
if (
canRetryWithFull &&
(type === ATTACHMENT_TYPES.IMAGE || isVideoType(type))
) {
failedThumbs.value.add(id);
return;
}
failedPreviews.value.add(id);
};
const hasPreview = attachment =>
!!imagePreviewSrc(attachment) && !failedPreviews.value.has(attachment.id);
const hasVideoPreview = attachment =>
isVideoType(attachment.file_type) &&
attachment.data_url &&
!failedPreviews.value.has(attachment.id);
const fallbackIcon = type => {
if (isAudioType(type)) return 'i-lucide-music';
if (isVideoType(type)) return 'i-lucide-video';
return 'i-lucide-image';
};
const onLoadedMetadata = (attachment, event) => {
const seconds = event.target?.duration;
if (Number.isFinite(seconds) && seconds > 0) {
durations.value[attachment.id] = seconds;
}
};
const displayDuration = attachment => {
const seconds = durations.value[attachment.id];
return seconds ? formatDuration(Math.round(seconds)) : '';
};
const displayTime = attachment => {
if (!attachment.created_at) return '';
return shortTimestamp(dynamicTime(attachment.created_at), true);
};
const isOverflowTile = index =>
isPeekable.value &&
!showAll.value &&
overflow.value > 0 &&
index === props.peekLimit - 1;
const onTileActivate = (attachment, index) => {
if (isOverflowTile(index)) {
showAll.value = true;
return;
}
emit('select', attachment);
};
const onDownloadFile = async attachment => {
const { id, file_type: type, data_url: url, extension } = attachment;
try {
downloadingId.value = id;
await downloadFile({ url, type, extension });
} catch (error) {
useAlert(t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD_ERROR'));
} finally {
downloadingId.value = null;
}
};
</script>
<template>
<section v-if="mediaAttachments.length" class="flex flex-col gap-2.5">
<header class="flex items-center justify-between px-0.5">
<h4
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
>
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.MEDIA_HEADING') }}
<span
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
>
{{ mediaAttachments.length }}
</span>
</h4>
<NextButton
v-if="overflow > 0"
ghost
slate
xs
trailing-icon
:icon="showAll ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'"
:label="
showAll
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
"
@click="showAll = !showAll"
/>
</header>
<div class="grid grid-cols-3 gap-2">
<div
v-for="(attachment, index) in visibleMedia"
:key="attachment.id"
role="button"
tabindex="0"
class="relative w-full overflow-hidden transition-all duration-200 rounded-lg cursor-pointer aspect-square bg-n-slate-3 shadow-sm hover:shadow-md hover:-translate-y-px group focus:outline-none focus-visible:ring-2 focus-visible:ring-n-blue-9"
@click="onTileActivate(attachment, index)"
@keydown.enter="onTileActivate(attachment, index)"
@keydown.space.prevent="onTileActivate(attachment, index)"
>
<template v-if="!isOverflowTile(index)">
<img
v-if="hasPreview(attachment)"
:src="imagePreviewSrc(attachment)"
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110"
loading="lazy"
:alt="fileNameFromUrl(attachment.data_url)"
@error="onPreviewError(attachment)"
/>
<video
v-else-if="hasVideoPreview(attachment)"
:src="`${attachment.data_url}#t=0.1`"
preload="metadata"
muted
playsinline
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110 pointer-events-none"
@loadedmetadata="onLoadedMetadata(attachment, $event)"
@error="onPreviewError(attachment)"
/>
<div
v-else
class="flex items-center justify-center w-full h-full bg-gradient-to-br from-n-slate-3 to-n-slate-4"
>
<Icon
:icon="fallbackIcon(attachment.file_type)"
class="size-6 text-n-slate-11"
/>
</div>
<audio
v-if="isAudioType(attachment.file_type) && attachment.data_url"
:src="attachment.data_url"
preload="metadata"
class="hidden"
@loadedmetadata="onLoadedMetadata(attachment, $event)"
/>
<div
class="absolute inset-0 transition-opacity duration-200 opacity-0 pointer-events-none group-hover:opacity-100 bg-gradient-to-t from-black/40 via-transparent to-transparent"
/>
<div
v-if="hasVideoPreview(attachment)"
class="absolute inset-0 flex items-center justify-center pointer-events-none bg-gradient-to-t from-black/30 via-transparent to-transparent"
>
<div
class="flex items-center justify-center rounded-full size-7 bg-white/95 shadow-md"
>
<Icon icon="i-lucide-play" class="ms-0.5 size-3.5 text-n-black" />
</div>
</div>
<span
v-if="
isPlayableType(attachment.file_type) &&
displayDuration(attachment)
"
class="absolute text-xxs font-medium tabular-nums transition-opacity bottom-1.5 ltr:right-1.5 rtl:left-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-0"
>
{{ displayDuration(attachment) }}
</span>
<span
v-if="displayTime(attachment)"
class="absolute text-xxs font-medium transition-opacity opacity-0 bottom-1.5 ltr:left-1.5 rtl:right-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-100"
>
{{ displayTime(attachment) }}
</span>
<button
type="button"
class="absolute flex items-center justify-center !p-px transition-all rounded-full opacity-0 bottom-1.5 ltr:right-1.5 rtl:left-1.5 size-6 bg-white/95 shadow-md group-hover:opacity-100 hover:bg-white disabled:opacity-50"
:disabled="downloadingId === attachment.id"
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
@click.stop="onDownloadFile(attachment)"
@keydown.enter.stop
@keydown.space.stop
>
<Icon icon="i-lucide-download" class="size-3 text-n-black" />
</button>
<button
v-if="showJumpToMessage && attachment.message_id"
v-tooltip.top="{
content: t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE'),
delay: { show: 500, hide: 0 },
}"
type="button"
class="absolute flex items-center justify-center !p-px transition-all rounded-full opacity-0 top-1.5 ltr:right-1.5 rtl:left-1.5 size-6 bg-white/95 shadow-md group-hover:opacity-100 hover:bg-white"
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE')"
@click.stop="emit('jumpToMessage', attachment)"
@keydown.enter.stop
@keydown.space.stop
>
<Icon icon="i-lucide-external-link" class="size-3 text-n-black" />
</button>
</template>
<div
v-if="isOverflowTile(index)"
class="absolute inset-0 flex items-center justify-center bg-n-slate-5"
>
<span class="text-base font-semibold text-n-slate-12">
{{
t('CONVERSATION_SIDEBAR.SHARED_FILES.MORE_COUNT', {
count: overflow,
})
}}
</span>
</div>
</div>
</div>
</section>
<template v-else />
</template>
@@ -2,6 +2,7 @@
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
defineProps({
items: {
@@ -47,8 +48,17 @@ const onClick = (item, index) => {
</button>
<!-- The last breadcrumb item is plain text -->
<span v-else class="text-sm truncate text-n-slate-12 min-w-0 block">
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
<span
v-else
class="inline-flex items-center gap-1 text-sm truncate min-w-0"
>
<EmojiIcon
v-if="item.emoji"
:value="item.emoji"
:color="item.iconColor"
class="flex-shrink-0 size-4"
/>
<span class="truncate text-n-slate-12">{{ item.label }}</span>
</span>
</li>
</ol>
@@ -14,6 +14,9 @@ const props = defineProps({
value.every(option => 'value' in option && 'label' in option),
},
placeholder: { type: String, default: '' },
// Fallback label shown when the selected value is not in `options` yet
// (e.g. API-backed lists that load lazily on open).
displayLabel: { type: String, default: '' },
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
searchPlaceholder: { type: String, default: '' },
@@ -23,7 +26,7 @@ const props = defineProps({
useApiResults: { type: Boolean, default: false }, // useApiResults prop to determine if search is handled by API
});
const emit = defineEmits(['update:modelValue', 'search']);
const emit = defineEmits(['update:modelValue', 'search', 'open']);
const { t } = useI18n();
@@ -52,7 +55,7 @@ const selectedLabel = computed(() => {
const selected = props.options.find(
option => option.value === selectedValue.value
);
return selected?.label ?? selectPlaceholder.value;
return selected?.label ?? (props.displayLabel || selectPlaceholder.value);
});
const selectOption = option => {
@@ -72,6 +75,7 @@ const toggleDropdown = () => {
open.value = !open.value;
if (open.value) {
search.value = '';
emit('open');
nextTick(() => dropdownRef.value?.focus());
}
};
@@ -87,7 +87,7 @@ defineExpose({
}"
role="option"
:aria-selected="isSelected(option)"
@click="emit('select', option)"
@click.stop="emit('select', option)"
>
<span
:class="{
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
@@ -162,7 +163,7 @@ onMounted(() => {
>
<p
v-if="section.title"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky top-0 z-10 bg-n-alpha-3 backdrop-blur-sm"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky top-0 z-10 bg-n-alpha-3 backdrop-blur-sm truncate min-w-0"
>
{{ section.title }}
</p>
@@ -207,9 +208,12 @@ onMounted(() => {
class="flex-shrink-0 size-3.5"
/>
</slot>
<span v-if="item.emoji" class="flex-shrink-0">{{
item.emoji
}}</span>
<EmojiIcon
v-if="item.emoji"
:value="item.emoji"
:color="item.iconColor"
class="flex-shrink-0 size-4"
/>
<slot name="label" :item="item">
<span
v-if="item.label"
@@ -260,7 +264,12 @@ onMounted(() => {
class="flex-shrink-0 size-3.5"
/>
</slot>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<EmojiIcon
v-if="item.emoji"
:value="item.emoji"
:color="item.iconColor"
class="flex-shrink-0 size-4"
/>
<slot name="label" :item="item">
<span
v-if="item.label"
@@ -0,0 +1,27 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { ICON_COLORS } from './constants';
const { t } = useI18n();
const selectedColor = defineModel({ type: String, default: '' });
</script>
<template>
<div class="flex items-center justify-between gap-1 px-0.5 mx-2 py-1 mb-px">
<button
v-for="color in ICON_COLORS"
:key="color.value"
v-tooltip.top="t(`EMOJI_ICON_PICKER.COLORS.${color.name}`)"
type="button"
class="flex items-center justify-center flex-shrink-0 transition-all rounded-full size-5 ring-offset-2 ring-offset-n-surface-2 hover:ring-2"
:class="{ 'ring-2': selectedColor === color.value }"
:style="{ backgroundColor: color.value, '--tw-ring-color': color.value }"
@click="selectedColor = color.value"
>
<span
v-if="selectedColor === color.value"
class="text-white i-lucide-check size-3"
/>
</button>
</div>
</template>
@@ -0,0 +1,35 @@
<script setup>
import { computed } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { isIconValue, iconClassFor } from './constants';
// Renders a saved picker value: a color-tinted icon or an emoji.
const props = defineProps({
value: {
type: String,
default: '',
},
color: {
type: String,
default: '',
},
});
const isIcon = computed(() => isIconValue(props.value));
const iconClass = computed(() => iconClassFor(props.value));
const iconStyle = computed(() =>
props.color ? { color: props.color } : undefined
);
</script>
<template>
<span class="inline-flex items-center justify-center leading-none">
<Icon
v-if="isIcon"
:icon="iconClass"
class="size-full"
:style="iconStyle"
/>
<span v-else v-dompurify-html="value" />
</span>
</template>
@@ -0,0 +1,310 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { Virtualizer } from 'virtua/vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ColorPalette from './ColorPalette.vue';
import { CURATED_ICONS } from './icons';
import {
DEFAULT_ICON_COLOR,
ICON_STYLE,
PICKER_MODE,
PICKER_TAB,
isIconValue,
} from './constants';
import {
buildEmojiSections,
getEmojiTint,
getRecentEmojis,
addRecentEmoji,
} from 'shared/components/emoji/pickerHelper';
const props = defineProps({
// 'both' shows Icons + Emojis tabs; 'emoji' shows only the emoji panel.
mode: {
type: String,
default: PICKER_MODE.BOTH,
validator: value => Object.values(PICKER_MODE).includes(value),
},
// Saved value: an emoji char or an icon name (e.g. "rocket-line").
value: {
type: String,
default: '',
},
// Current stored icon color (hex), used to preselect a swatch.
color: {
type: String,
default: '',
},
showRemoveButton: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select', 'remove', 'colorChange']);
const { t } = useI18n();
const showTabs = computed(() => props.mode === PICKER_MODE.BOTH);
const showHeader = computed(
() => showTabs.value || (props.showRemoveButton && props.value)
);
const activeTab = ref(
props.value && !isIconValue(props.value)
? PICKER_TAB.EMOJIS
: PICKER_TAB.ICONS
);
const tabs = computed(() => [
{
id: PICKER_TAB.ICONS,
label: t('EMOJI_ICON_PICKER.TABS.ICONS'),
icon: 'i-lucide-shapes',
},
{
id: PICKER_TAB.EMOJIS,
label: t('EMOJI_ICON_PICKER.TABS.EMOJIS'),
icon: 'i-lucide-smile',
},
]);
// Icons
const iconSearch = ref('');
const selectedColor = ref(props.color || DEFAULT_ICON_COLOR);
// Preselect the outline/filled toggle from the saved icon's style suffix.
const iconStyle = ref(
props.value.endsWith(`-${ICON_STYLE.FILL}`)
? ICON_STYLE.FILL
: ICON_STYLE.LINE
);
const filteredIcons = computed(() => {
const term = iconSearch.value.trim().toLowerCase();
if (!term) return CURATED_ICONS;
return CURATED_ICONS.filter(
icon => icon.name.includes(term) || icon.keywords.includes(term)
);
});
const toggleIconStyle = () => {
iconStyle.value =
iconStyle.value === ICON_STYLE.LINE ? ICON_STYLE.FILL : ICON_STYLE.LINE;
};
// Each icon button tints its hover with the selected color (low opacity).
const iconHoverStyle = computed(() => ({
'--ep-tint': `${selectedColor.value}24`,
}));
const selectIcon = icon => {
// Store the name with its style suffix, e.g. "rocket-fill".
const value = `${icon.name}-${iconStyle.value}`;
emit('select', {
type: 'icon',
value,
icon: value,
color: selectedColor.value,
});
};
watch(selectedColor, color => {
if (isIconValue(props.value)) emit('colorChange', color);
});
// Emojis
const emojiSearch = ref('');
const recentEmojis = ref([]);
onMounted(() => {
recentEmojis.value = getRecentEmojis();
});
const emojiSections = computed(() =>
buildEmojiSections(
emojiSearch.value,
recentEmojis.value,
t('EMOJI_ICON_PICKER.FREQUENTLY_USED')
)
);
// Tints an emoji button with a light shade of the emoji's own color on hover.
const applyEmojiTint = (event, emoji) => {
event.currentTarget.style.setProperty('--ep-tint', getEmojiTint(emoji));
};
const selectEmoji = emoji => {
recentEmojis.value = addRecentEmoji(emoji);
emit('select', { type: 'emoji', value: emoji.emoji, emoji: emoji.emoji });
};
</script>
<template>
<div
role="dialog"
class="absolute z-20 flex flex-col overflow-hidden shadow-xl w-[22rem] bg-n-surface-2 backdrop-blur-[100px] rounded-2xl outline outline-1 outline-n-weak dark:outline-n-strong/50"
>
<div v-if="showHeader" class="flex items-center justify-between gap-2 p-2">
<div v-if="showTabs" class="flex gap-0.5 p-0.5 rounded-lg bg-n-alpha-1">
<button
v-for="tab in tabs"
:key="tab.id"
v-tooltip.top="tab.label"
type="button"
class="flex items-center justify-center !p-0 transition-all rounded-md size-7 active:scale-[0.92]"
:class="
activeTab === tab.id
? 'bg-n-surface-1 shadow-sm text-n-slate-12'
: 'text-n-slate-10 hover:text-n-slate-12'
"
@click="activeTab = tab.id"
>
<span :class="tab.icon" class="size-4" />
</button>
</div>
<Button
v-if="showRemoveButton && value"
v-tooltip.top="t('EMOJI_ICON_PICKER.REMOVE')"
variant="ghost"
color="slate"
size="sm"
icon="i-lucide-eraser"
class="flex-shrink-0"
@click="emit('remove')"
/>
</div>
<!-- Icons panel -->
<div
v-if="showTabs && activeTab === PICKER_TAB.ICONS"
class="flex flex-col gap-1.5"
:style="iconHoverStyle"
>
<ColorPalette v-model="selectedColor" />
<div class="flex items-center gap-1 px-2">
<Input
v-model="iconSearch"
size="md"
class="flex-1"
:placeholder="t('EMOJI_ICON_PICKER.SEARCH_ICON')"
custom-input-class="!ps-9 !bg-transparent"
autofocus
>
<template #prefix>
<span
class="absolute z-10 -translate-y-1/2 i-lucide-search size-4 text-n-slate-10 top-1/2 start-3"
/>
</template>
</Input>
<button
v-tooltip.top="
iconStyle === ICON_STYLE.FILL
? t('EMOJI_ICON_PICKER.STYLE.FILLED')
: t('EMOJI_ICON_PICKER.STYLE.OUTLINE')
"
type="button"
class="flex items-center justify-center flex-shrink-0 transition-all rounded-lg !p-2 size-9 text-n-slate-11 hover:bg-[var(--ep-tint)] active:scale-[0.92]"
@click="toggleIconStyle"
>
<span
:class="
iconStyle === ICON_STYLE.FILL
? 'i-ri-contrast-2-fill'
: 'i-ri-contrast-2-line'
"
class="size-[1.2rem]"
:style="{ color: selectedColor }"
/>
</button>
</div>
<div
v-if="filteredIcons.length"
class="grid grid-cols-10 h-52 gap-0.5 overflow-y-auto no-scrollbar content-start px-2 pb-2"
>
<button
v-for="icon in filteredIcons"
:key="icon.name"
type="button"
:title="icon.name"
class="flex items-center justify-center !p-0 size-8 transition-colors rounded-lg aspect-square hover:bg-[var(--ep-tint)] active:enabled:scale-[0.97]"
@click="selectIcon(icon)"
>
<span
:class="icon[iconStyle]"
class="size-[1.125rem]"
:style="{ color: selectedColor }"
/>
</button>
</div>
<div
v-else
class="flex flex-col items-center justify-center gap-2 h-52 text-n-slate-10"
>
<span class="i-lucide-search-x size-7" />
<span class="text-sm font-medium">
{{ t('EMOJI_ICON_PICKER.NO_ICON') }}
</span>
</div>
</div>
<!-- Emojis panel -->
<div
v-if="!showTabs || activeTab === PICKER_TAB.EMOJIS"
class="flex flex-col gap-1.5"
:class="{ 'pt-2': !showHeader }"
>
<div class="px-2">
<Input
v-model="emojiSearch"
size="md"
:placeholder="t('EMOJI_ICON_PICKER.SEARCH_EMOJI')"
custom-input-class="!ps-9 !bg-transparent"
autofocus
>
<template #prefix>
<span
class="absolute z-10 -translate-y-1/2 i-lucide-search size-4 text-n-slate-10 top-1/2 start-3"
/>
</template>
</Input>
</div>
<div
v-if="emojiSections.length"
class="h-60 overflow-y-auto px-2 no-scrollbar pb-2"
>
<Virtualizer v-slot="{ item: section }" :data="emojiSections">
<h5
class="px-1 pt-2 pb-1 m-0 text-xs font-medium tracking-wide uppercase text-n-slate-10"
>
{{ section.name }}
</h5>
<div class="grid grid-cols-10 gap-0.5">
<button
v-for="emoji in section.emojis"
:key="`${section.name}-${emoji.slug}`"
type="button"
:title="emoji.name"
class="flex items-center justify-center !p-0 size-8 w-full max-w-[2rem] text-xl transition-colors rounded-lg aspect-square hover:bg-[var(--ep-tint)] active:enabled:scale-[0.97]"
@mouseenter="applyEmojiTint($event, emoji.emoji)"
@click="selectEmoji(emoji)"
>
{{ emoji.emoji }}
</button>
</div>
</Virtualizer>
</div>
<div
v-else
class="flex flex-col items-center justify-center gap-2 h-60 text-n-slate-10"
>
<span class="i-lucide-smile-plus size-7" />
<span class="text-sm font-medium">
{{ t('EMOJI_ICON_PICKER.NO_EMOJI') }}
</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,40 @@
// Prefix that turns a stored icon name (e.g. "rocket-line") into a class.
// Swapping icon libraries means changing this and the curated set only.
export const ICON_PREFIX = 'i-ri-';
export const ICON_STYLE = {
LINE: 'line',
FILL: 'fill',
};
// Icon values are ascii names (e.g. "rocket-line"); emoji are non-ascii.
export const isIconValue = value =>
typeof value === 'string' && /^[a-z][a-z0-9-]*$/.test(value);
export const iconClassFor = value =>
value.startsWith(ICON_PREFIX) ? value : `${ICON_PREFIX}${value}`;
export const ICON_COLORS = [
{ name: 'SLATE', value: '#64748B' },
{ name: 'RED', value: '#EF4444' },
{ name: 'ORANGE', value: '#F97316' },
{ name: 'AMBER', value: '#F59E0B' },
{ name: 'GREEN', value: '#22C55E' },
{ name: 'TEAL', value: '#14B8A6' },
{ name: 'BLUE', value: '#3B82F6' },
{ name: 'INDIGO', value: '#6366F1' },
{ name: 'VIOLET', value: '#8B5CF6' },
{ name: 'PINK', value: '#EC4899' },
];
export const DEFAULT_ICON_COLOR = '#3B82F6';
export const PICKER_MODE = {
BOTH: 'both',
EMOJI: 'emoji',
};
export const PICKER_TAB = {
ICONS: 'icons',
EMOJIS: 'emojis',
};
@@ -0,0 +1,983 @@
// Curated Remix (ri) icons, each with a `line` and `fill` class. The class
// literals are kept here so Tailwind generates them. { name, keywords, line, fill }
export const CURATED_ICONS = [
{
name: 'home',
keywords: 'home house main',
line: 'i-ri-home-line',
fill: 'i-ri-home-fill',
},
{
name: 'star',
keywords: 'favorite star',
line: 'i-ri-star-line',
fill: 'i-ri-star-fill',
},
{
name: 'heart',
keywords: 'like love',
line: 'i-ri-heart-line',
fill: 'i-ri-heart-fill',
},
{
name: 'bookmark',
keywords: 'save',
line: 'i-ri-bookmark-line',
fill: 'i-ri-bookmark-fill',
},
{
name: 'flag-2',
keywords: 'flag report',
line: 'i-ri-flag-2-line',
fill: 'i-ri-flag-2-fill',
},
{
name: 'price-tag-3',
keywords: 'tag label',
line: 'i-ri-price-tag-3-line',
fill: 'i-ri-price-tag-3-fill',
},
{
name: 'map-pin',
keywords: 'location place',
line: 'i-ri-map-pin-line',
fill: 'i-ri-map-pin-fill',
},
{
name: 'sparkling-2',
keywords: 'magic ai shine sparkle',
line: 'i-ri-sparkling-2-line',
fill: 'i-ri-sparkling-2-fill',
},
{
name: 'fire',
keywords: 'flame hot trending',
line: 'i-ri-fire-line',
fill: 'i-ri-fire-fill',
},
{
name: 'lightbulb',
keywords: 'idea tip',
line: 'i-ri-lightbulb-line',
fill: 'i-ri-lightbulb-fill',
},
{
name: 'rocket',
keywords: 'launch startup ship',
line: 'i-ri-rocket-line',
fill: 'i-ri-rocket-fill',
},
{
name: 'focus-3',
keywords: 'target goal aim',
line: 'i-ri-focus-3-line',
fill: 'i-ri-focus-3-fill',
},
{
name: 'award',
keywords: 'prize badge medal',
line: 'i-ri-award-line',
fill: 'i-ri-award-fill',
},
{
name: 'trophy',
keywords: 'win achievement',
line: 'i-ri-trophy-line',
fill: 'i-ri-trophy-fill',
},
{
name: 'vip-crown-2',
keywords: 'crown premium vip',
line: 'i-ri-vip-crown-2-line',
fill: 'i-ri-vip-crown-2-fill',
},
{
name: 'vip-diamond',
keywords: 'gem diamond premium',
line: 'i-ri-vip-diamond-line',
fill: 'i-ri-vip-diamond-fill',
},
{
name: 'gift',
keywords: 'present reward',
line: 'i-ri-gift-line',
fill: 'i-ri-gift-fill',
},
{
name: 'notification-3',
keywords: 'bell alert',
line: 'i-ri-notification-3-line',
fill: 'i-ri-notification-3-fill',
},
{
name: 'global',
keywords: 'globe world web',
line: 'i-ri-global-line',
fill: 'i-ri-global-fill',
},
{
name: 'compass-3',
keywords: 'explore discover',
line: 'i-ri-compass-3-line',
fill: 'i-ri-compass-3-fill',
},
{
name: 'puzzle-2',
keywords: 'extension integration',
line: 'i-ri-puzzle-2-line',
fill: 'i-ri-puzzle-2-fill',
},
{
name: 'ticket-2',
keywords: 'support coupon',
line: 'i-ri-ticket-2-line',
fill: 'i-ri-ticket-2-fill',
},
{
name: 'megaphone',
keywords: 'announce broadcast marketing',
line: 'i-ri-megaphone-line',
fill: 'i-ri-megaphone-fill',
},
{
name: 'briefcase-4',
keywords: 'work job business',
line: 'i-ri-briefcase-4-line',
fill: 'i-ri-briefcase-4-fill',
},
{
name: 'building-2',
keywords: 'company office org',
line: 'i-ri-building-2-line',
fill: 'i-ri-building-2-fill',
},
{
name: 'store-2',
keywords: 'shop commerce',
line: 'i-ri-store-2-line',
fill: 'i-ri-store-2-fill',
},
{
name: 'community',
keywords: 'group team',
line: 'i-ri-community-line',
fill: 'i-ri-community-fill',
},
{
name: 'folder',
keywords: 'directory',
line: 'i-ri-folder-line',
fill: 'i-ri-folder-fill',
},
{
name: 'folder-open',
keywords: 'directory open',
line: 'i-ri-folder-open-line',
fill: 'i-ri-folder-open-fill',
},
{
name: 'file',
keywords: 'document',
line: 'i-ri-file-line',
fill: 'i-ri-file-fill',
},
{
name: 'file-text',
keywords: 'document doc',
line: 'i-ri-file-text-line',
fill: 'i-ri-file-text-fill',
},
{
name: 'file-copy',
keywords: 'documents files',
line: 'i-ri-file-copy-line',
fill: 'i-ri-file-copy-fill',
},
{
name: 'clipboard',
keywords: 'copy tasks',
line: 'i-ri-clipboard-line',
fill: 'i-ri-clipboard-fill',
},
{
name: 'book-2',
keywords: 'read guide book',
line: 'i-ri-book-2-line',
fill: 'i-ri-book-2-fill',
},
{
name: 'book-open',
keywords: 'read guide docs',
line: 'i-ri-book-open-line',
fill: 'i-ri-book-open-fill',
},
{
name: 'graduation-cap',
keywords: 'learn course education',
line: 'i-ri-graduation-cap-line',
fill: 'i-ri-graduation-cap-fill',
},
{
name: 'newspaper',
keywords: 'news article blog',
line: 'i-ri-newspaper-line',
fill: 'i-ri-newspaper-fill',
},
{
name: 'sticky-note',
keywords: 'note memo',
line: 'i-ri-sticky-note-line',
fill: 'i-ri-sticky-note-fill',
},
{
name: 'pencil',
keywords: 'write edit',
line: 'i-ri-pencil-line',
fill: 'i-ri-pencil-fill',
},
{
name: 'quill-pen',
keywords: 'write author',
line: 'i-ri-quill-pen-line',
fill: 'i-ri-quill-pen-fill',
},
{
name: 'mark-pen',
keywords: 'highlight',
line: 'i-ri-mark-pen-line',
fill: 'i-ri-mark-pen-fill',
},
{
name: 'calendar-2',
keywords: 'date schedule calendar',
line: 'i-ri-calendar-2-line',
fill: 'i-ri-calendar-2-fill',
},
{
name: 'calendar-event',
keywords: 'schedule event',
line: 'i-ri-calendar-event-line',
fill: 'i-ri-calendar-event-fill',
},
{
name: 'time',
keywords: 'clock time',
line: 'i-ri-time-line',
fill: 'i-ri-time-fill',
},
{
name: 'timer',
keywords: 'countdown',
line: 'i-ri-timer-line',
fill: 'i-ri-timer-fill',
},
{
name: 'history',
keywords: 'recent past',
line: 'i-ri-history-line',
fill: 'i-ri-history-fill',
},
{
name: 'hourglass',
keywords: 'wait time',
line: 'i-ri-hourglass-line',
fill: 'i-ri-hourglass-fill',
},
{
name: 'shopping-cart-2',
keywords: 'buy ecommerce cart',
line: 'i-ri-shopping-cart-2-line',
fill: 'i-ri-shopping-cart-2-fill',
},
{
name: 'shopping-bag-3',
keywords: 'buy purchase bag',
line: 'i-ri-shopping-bag-3-line',
fill: 'i-ri-shopping-bag-3-fill',
},
{
name: 'box-3',
keywords: 'package box shipping',
line: 'i-ri-box-3-line',
fill: 'i-ri-box-3-fill',
},
{
name: 'archive',
keywords: 'store box archive',
line: 'i-ri-archive-line',
fill: 'i-ri-archive-fill',
},
{
name: 'bank-card',
keywords: 'pay billing card credit',
line: 'i-ri-bank-card-line',
fill: 'i-ri-bank-card-fill',
},
{
name: 'wallet-3',
keywords: 'money pay wallet',
line: 'i-ri-wallet-3-line',
fill: 'i-ri-wallet-3-fill',
},
{
name: 'money-dollar-circle',
keywords: 'money price usd',
line: 'i-ri-money-dollar-circle-line',
fill: 'i-ri-money-dollar-circle-fill',
},
{
name: 'copper-coin',
keywords: 'coins money',
line: 'i-ri-copper-coin-line',
fill: 'i-ri-copper-coin-fill',
},
{
name: 'currency',
keywords: 'money exchange',
line: 'i-ri-currency-line',
fill: 'i-ri-currency-fill',
},
{
name: 'receipt',
keywords: 'invoice bill',
line: 'i-ri-receipt-line',
fill: 'i-ri-receipt-fill',
},
{
name: 'percent',
keywords: 'discount sale',
line: 'i-ri-percent-line',
fill: 'i-ri-percent-fill',
},
{
name: 'calculator',
keywords: 'math',
line: 'i-ri-calculator-line',
fill: 'i-ri-calculator-fill',
},
{
name: 'mail',
keywords: 'email message',
line: 'i-ri-mail-line',
fill: 'i-ri-mail-fill',
},
{
name: 'send-plane',
keywords: 'send submit',
line: 'i-ri-send-plane-line',
fill: 'i-ri-send-plane-fill',
},
{
name: 'chat-3',
keywords: 'chat talk message',
line: 'i-ri-chat-3-line',
fill: 'i-ri-chat-3-fill',
},
{
name: 'message-2',
keywords: 'chat comment',
line: 'i-ri-message-2-line',
fill: 'i-ri-message-2-fill',
},
{
name: 'question-answer',
keywords: 'chat conversation faq',
line: 'i-ri-question-answer-line',
fill: 'i-ri-question-answer-fill',
},
{
name: 'phone',
keywords: 'call',
line: 'i-ri-phone-line',
fill: 'i-ri-phone-fill',
},
{
name: 'vidicon',
keywords: 'video call meeting',
line: 'i-ri-vidicon-line',
fill: 'i-ri-vidicon-fill',
},
{
name: 'mic',
keywords: 'record voice audio',
line: 'i-ri-mic-line',
fill: 'i-ri-mic-fill',
},
{
name: 'group',
keywords: 'team people users',
line: 'i-ri-group-line',
fill: 'i-ri-group-fill',
},
{
name: 'user',
keywords: 'person account',
line: 'i-ri-user-line',
fill: 'i-ri-user-fill',
},
{
name: 'user-add',
keywords: 'add member',
line: 'i-ri-user-add-line',
fill: 'i-ri-user-add-fill',
},
{
name: 'contacts',
keywords: 'person card contact',
line: 'i-ri-contacts-line',
fill: 'i-ri-contacts-fill',
},
{
name: 'emotion-happy',
keywords: 'happy smile emoji',
line: 'i-ri-emotion-happy-line',
fill: 'i-ri-emotion-happy-fill',
},
{
name: 'thumb-up',
keywords: 'like approve',
line: 'i-ri-thumb-up-line',
fill: 'i-ri-thumb-up-fill',
},
{
name: 'hand-heart',
keywords: 'care support',
line: 'i-ri-hand-heart-line',
fill: 'i-ri-hand-heart-fill',
},
{
name: 'at',
keywords: 'mention email at',
line: 'i-ri-at-line',
fill: 'i-ri-at-fill',
},
{
name: 'image-2',
keywords: 'picture photo image',
line: 'i-ri-image-2-line',
fill: 'i-ri-image-2-fill',
},
{
name: 'camera-3',
keywords: 'photo camera',
line: 'i-ri-camera-3-line',
fill: 'i-ri-camera-3-fill',
},
{
name: 'film',
keywords: 'movie video',
line: 'i-ri-film-line',
fill: 'i-ri-film-fill',
},
{
name: 'music-2',
keywords: 'audio song music',
line: 'i-ri-music-2-line',
fill: 'i-ri-music-2-fill',
},
{
name: 'headphone',
keywords: 'audio support',
line: 'i-ri-headphone-line',
fill: 'i-ri-headphone-fill',
},
{
name: 'palette',
keywords: 'color design art',
line: 'i-ri-palette-line',
fill: 'i-ri-palette-fill',
},
{
name: 'brush-3',
keywords: 'paint design brush',
line: 'i-ri-brush-3-line',
fill: 'i-ri-brush-3-fill',
},
{
name: 'scissors-2',
keywords: 'cut',
line: 'i-ri-scissors-2-line',
fill: 'i-ri-scissors-2-fill',
},
{
name: 'code-s-slash',
keywords: 'develop code programming',
line: 'i-ri-code-s-slash-line',
fill: 'i-ri-code-s-slash-fill',
},
{
name: 'terminal-box',
keywords: 'console cli terminal',
line: 'i-ri-terminal-box-line',
fill: 'i-ri-terminal-box-fill',
},
{
name: 'git-branch',
keywords: 'version code git',
line: 'i-ri-git-branch-line',
fill: 'i-ri-git-branch-fill',
},
{
name: 'github',
keywords: 'git code',
line: 'i-ri-github-line',
fill: 'i-ri-github-fill',
},
{
name: 'cpu',
keywords: 'chip processor',
line: 'i-ri-cpu-line',
fill: 'i-ri-cpu-fill',
},
{
name: 'database-2',
keywords: 'storage data',
line: 'i-ri-database-2-line',
fill: 'i-ri-database-2-fill',
},
{
name: 'server',
keywords: 'host backend',
line: 'i-ri-server-line',
fill: 'i-ri-server-fill',
},
{
name: 'cloud',
keywords: 'storage cloud',
line: 'i-ri-cloud-line',
fill: 'i-ri-cloud-fill',
},
{
name: 'wifi',
keywords: 'network internet',
line: 'i-ri-wifi-line',
fill: 'i-ri-wifi-fill',
},
{
name: 'computer',
keywords: 'screen desktop monitor',
line: 'i-ri-computer-line',
fill: 'i-ri-computer-fill',
},
{
name: 'smartphone',
keywords: 'mobile phone',
line: 'i-ri-smartphone-line',
fill: 'i-ri-smartphone-fill',
},
{
name: 'macbook',
keywords: 'laptop computer',
line: 'i-ri-macbook-line',
fill: 'i-ri-macbook-fill',
},
{
name: 'bug',
keywords: 'issue error bug',
line: 'i-ri-bug-line',
fill: 'i-ri-bug-fill',
},
{
name: 'command',
keywords: 'keyboard shortcut',
line: 'i-ri-command-line',
fill: 'i-ri-command-fill',
},
{
name: 'settings-3',
keywords: 'config gear options settings',
line: 'i-ri-settings-3-line',
fill: 'i-ri-settings-3-fill',
},
{
name: 'equalizer',
keywords: 'controls adjust sliders filter',
line: 'i-ri-equalizer-line',
fill: 'i-ri-equalizer-fill',
},
{
name: 'tools',
keywords: 'fix tools config',
line: 'i-ri-tools-line',
fill: 'i-ri-tools-fill',
},
{
name: 'hammer',
keywords: 'build fix hammer',
line: 'i-ri-hammer-line',
fill: 'i-ri-hammer-fill',
},
{
name: 'key-2',
keywords: 'password access key',
line: 'i-ri-key-2-line',
fill: 'i-ri-key-2-fill',
},
{
name: 'lock',
keywords: 'secure private lock',
line: 'i-ri-lock-line',
fill: 'i-ri-lock-fill',
},
{
name: 'lock-unlock',
keywords: 'open access',
line: 'i-ri-lock-unlock-line',
fill: 'i-ri-lock-unlock-fill',
},
{
name: 'shield',
keywords: 'security protect',
line: 'i-ri-shield-line',
fill: 'i-ri-shield-fill',
},
{
name: 'shield-check',
keywords: 'secure verified',
line: 'i-ri-shield-check-line',
fill: 'i-ri-shield-check-fill',
},
{
name: 'eye',
keywords: 'view visible preview',
line: 'i-ri-eye-line',
fill: 'i-ri-eye-fill',
},
{
name: 'search',
keywords: 'find lookup search',
line: 'i-ri-search-line',
fill: 'i-ri-search-fill',
},
{
name: 'filter-3',
keywords: 'sort refine filter',
line: 'i-ri-filter-3-line',
fill: 'i-ri-filter-3-fill',
},
{
name: 'links',
keywords: 'url chain link',
line: 'i-ri-links-line',
fill: 'i-ri-links-fill',
},
{
name: 'share-forward',
keywords: 'share send distribute',
line: 'i-ri-share-forward-line',
fill: 'i-ri-share-forward-fill',
},
{
name: 'plug',
keywords: 'connect integration',
line: 'i-ri-plug-line',
fill: 'i-ri-plug-fill',
},
{
name: 'shut-down',
keywords: 'power on off',
line: 'i-ri-shut-down-line',
fill: 'i-ri-shut-down-fill',
},
{
name: 'qr-scan-2',
keywords: 'qr scan',
line: 'i-ri-qr-scan-2-line',
fill: 'i-ri-qr-scan-2-fill',
},
{
name: 'fingerprint',
keywords: 'identity auth fingerprint',
line: 'i-ri-fingerprint-line',
fill: 'i-ri-fingerprint-fill',
},
{
name: 'bar-chart',
keywords: 'analytics stats graph bar',
line: 'i-ri-bar-chart-line',
fill: 'i-ri-bar-chart-fill',
},
{
name: 'pie-chart',
keywords: 'analytics stats pie',
line: 'i-ri-pie-chart-line',
fill: 'i-ri-pie-chart-fill',
},
{
name: 'line-chart',
keywords: 'analytics trend graph line',
line: 'i-ri-line-chart-line',
fill: 'i-ri-line-chart-fill',
},
{
name: 'stock',
keywords: 'growth increase trending',
line: 'i-ri-stock-line',
fill: 'i-ri-stock-fill',
},
{
name: 'pulse',
keywords: 'activity health monitor',
line: 'i-ri-pulse-line',
fill: 'i-ri-pulse-fill',
},
{
name: 'dashboard-3',
keywords: 'speed performance dashboard gauge',
line: 'i-ri-dashboard-3-line',
fill: 'i-ri-dashboard-3-fill',
},
{
name: 'check',
keywords: 'done complete ok check',
line: 'i-ri-check-line',
fill: 'i-ri-check-fill',
},
{
name: 'checkbox-circle',
keywords: 'done success check',
line: 'i-ri-checkbox-circle-line',
fill: 'i-ri-checkbox-circle-fill',
},
{
name: 'close-circle',
keywords: 'error fail close',
line: 'i-ri-close-circle-line',
fill: 'i-ri-close-circle-fill',
},
{
name: 'information',
keywords: 'info information',
line: 'i-ri-information-line',
fill: 'i-ri-information-fill',
},
{
name: 'error-warning',
keywords: 'warning alert',
line: 'i-ri-error-warning-line',
fill: 'i-ri-error-warning-fill',
},
{
name: 'alert',
keywords: 'warning caution',
line: 'i-ri-alert-line',
fill: 'i-ri-alert-fill',
},
{
name: 'question',
keywords: 'support faq help',
line: 'i-ri-question-line',
fill: 'i-ri-question-fill',
},
{
name: 'layout-grid',
keywords: 'dashboard apps grid',
line: 'i-ri-layout-grid-line',
fill: 'i-ri-layout-grid-fill',
},
{
name: 'table',
keywords: 'grid data table',
line: 'i-ri-table-line',
fill: 'i-ri-table-fill',
},
{
name: 'function',
keywords: 'kanban board',
line: 'i-ri-function-line',
fill: 'i-ri-function-fill',
},
{
name: 'map-2',
keywords: 'location navigate map',
line: 'i-ri-map-2-line',
fill: 'i-ri-map-2-fill',
},
{
name: 'navigation',
keywords: 'direction gps',
line: 'i-ri-navigation-line',
fill: 'i-ri-navigation-fill',
},
{
name: 'car',
keywords: 'drive vehicle car',
line: 'i-ri-car-line',
fill: 'i-ri-car-fill',
},
{
name: 'truck',
keywords: 'delivery shipping',
line: 'i-ri-truck-line',
fill: 'i-ri-truck-fill',
},
{
name: 'plane',
keywords: 'travel flight plane',
line: 'i-ri-plane-line',
fill: 'i-ri-plane-fill',
},
{
name: 'bike',
keywords: 'cycle bike',
line: 'i-ri-bike-line',
fill: 'i-ri-bike-fill',
},
{
name: 'anchor',
keywords: 'ship marine anchor',
line: 'i-ri-anchor-line',
fill: 'i-ri-anchor-fill',
},
{
name: 'leaf',
keywords: 'eco nature green leaf',
line: 'i-ri-leaf-line',
fill: 'i-ri-leaf-fill',
},
{
name: 'plant',
keywords: 'nature plant grow',
line: 'i-ri-plant-line',
fill: 'i-ri-plant-fill',
},
{
name: 'sun',
keywords: 'day light weather sun',
line: 'i-ri-sun-line',
fill: 'i-ri-sun-fill',
},
{
name: 'moon',
keywords: 'night dark moon',
line: 'i-ri-moon-line',
fill: 'i-ri-moon-fill',
},
{
name: 'rainy',
keywords: 'weather rain',
line: 'i-ri-rainy-line',
fill: 'i-ri-rainy-fill',
},
{
name: 'umbrella',
keywords: 'rain protect umbrella',
line: 'i-ri-umbrella-line',
fill: 'i-ri-umbrella-fill',
},
{
name: 'landscape',
keywords: 'outdoor hike mountain',
line: 'i-ri-landscape-line',
fill: 'i-ri-landscape-fill',
},
{
name: 'drop',
keywords: 'water drop',
line: 'i-ri-drop-line',
fill: 'i-ri-drop-fill',
},
{
name: 'cup',
keywords: 'coffee drink cup',
line: 'i-ri-cup-line',
fill: 'i-ri-cup-fill',
},
{
name: 'restaurant',
keywords: 'food eat restaurant',
line: 'i-ri-restaurant-line',
fill: 'i-ri-restaurant-fill',
},
{
name: 'cake-3',
keywords: 'birthday celebrate cake',
line: 'i-ri-cake-3-line',
fill: 'i-ri-cake-3-fill',
},
{
name: 'goblet',
keywords: 'drink wine bar',
line: 'i-ri-goblet-line',
fill: 'i-ri-goblet-fill',
},
{
name: 'heart-pulse',
keywords: 'health medical',
line: 'i-ri-heart-pulse-line',
fill: 'i-ri-heart-pulse-fill',
},
{
name: 'stethoscope',
keywords: 'health doctor medical',
line: 'i-ri-stethoscope-line',
fill: 'i-ri-stethoscope-fill',
},
{
name: 'capsule',
keywords: 'medicine pill health',
line: 'i-ri-capsule-line',
fill: 'i-ri-capsule-fill',
},
{
name: 'add',
keywords: 'add new create plus',
line: 'i-ri-add-line',
fill: 'i-ri-add-fill',
},
{
name: 'delete-bin',
keywords: 'delete remove trash',
line: 'i-ri-delete-bin-line',
fill: 'i-ri-delete-bin-fill',
},
{
name: 'download',
keywords: 'save export download',
line: 'i-ri-download-line',
fill: 'i-ri-download-fill',
},
{
name: 'upload',
keywords: 'import upload',
line: 'i-ri-upload-line',
fill: 'i-ri-upload-fill',
},
{
name: 'refresh',
keywords: 'reload sync update refresh',
line: 'i-ri-refresh-line',
fill: 'i-ri-refresh-fill',
},
{
name: 'external-link',
keywords: 'open new external',
line: 'i-ri-external-link-line',
fill: 'i-ri-external-link-fill',
},
{
name: 'logout-box',
keywords: 'exit signout logout',
line: 'i-ri-logout-box-line',
fill: 'i-ri-logout-box-fill',
},
{
name: 'fullscreen',
keywords: 'expand maximize fullscreen',
line: 'i-ri-fullscreen-line',
fill: 'i-ri-fullscreen-fill',
},
{
name: 'drag-move',
keywords: 'drag reorder move',
line: 'i-ri-drag-move-line',
fill: 'i-ri-drag-move-fill',
},
{
name: 'paint-brush',
keywords: 'brush paint',
line: 'i-ri-paint-brush-line',
fill: 'i-ri-paint-brush-fill',
},
];
@@ -78,6 +78,12 @@ export const MEDIA_TYPES = [
ATTACHMENT_TYPES.IG_REEL,
];
export const NON_FILE_TYPES = [
ATTACHMENT_TYPES.LOCATION,
ATTACHMENT_TYPES.FALLBACK,
ATTACHMENT_TYPES.CONTACT,
];
export const VOICE_CALL_STATUS = {
IN_PROGRESS: 'in-progress',
RINGING: 'ringing',
@@ -21,6 +21,12 @@ import ChannelIcon from 'next/icon/ChannelIcon.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import {
SIDEBAR_SORT_SECTIONS,
getSidebarSortOptions,
resolveSidebarSort,
sortSidebarItems,
} from 'dashboard/helper/sidebarSort';
const props = defineProps({
isMobileSidebarOpen: {
@@ -50,6 +56,7 @@ const { width: windowWidth } = useWindowSize();
const isMobile = computed(() => windowWidth.value < 768);
const accountId = useMapGetter('getCurrentAccountId');
const currentUserId = useMapGetter('getCurrentUserID');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
@@ -79,6 +86,11 @@ const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
store.dispatch('conversationUnreadCounts/get');
};
const fetchSidebarSortPreferences = ([currentAccountId, userId]) => {
if (!currentAccountId || !userId) return;
store.dispatch('sidebarSortPreferences/initialize');
};
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
@@ -175,6 +187,9 @@ useEventListener(document, 'touchend', onResizeEnd);
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
const allUnreadCount = useMapGetter(
'conversationUnreadCounts/getAllUnreadCount'
);
const getInboxUnreadCount = useMapGetter(
'conversationUnreadCounts/getInboxUnreadCount'
);
@@ -189,6 +204,9 @@ const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
'customViews/getConversationCustomViews'
);
const getSidebarSectionSort = useMapGetter(
'sidebarSortPreferences/getSectionSort'
);
onMounted(() => {
store.dispatch('labels/get');
@@ -204,44 +222,62 @@ watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
immediate: true,
});
const normalizeUnreadCount = count => {
const unreadCount = Number(count);
return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0;
};
watch([accountId, currentUserId], fetchSidebarSortPreferences, {
immediate: true,
});
const sortByUnreadCount = (items, labelKey, unreadCountKey) =>
items.slice().sort((a, b) => {
const unreadCountDiff =
normalizeUnreadCount(unreadCountKey(b)) -
normalizeUnreadCount(unreadCountKey(a));
if (unreadCountDiff !== 0) return unreadCountDiff;
return labelKey(a).localeCompare(labelKey(b));
const getSortOptionsForSection = section =>
getSidebarSortOptions(section, {
hasUnreadCounts: hasConversationUnreadCounts.value,
});
const getSortForSection = section =>
resolveSidebarSort(section, getSidebarSectionSort.value(section), {
hasUnreadCounts: hasConversationUnreadCounts.value,
});
const updateSortPreference = (section, sortBy) => {
store.dispatch('sidebarSortPreferences/setSectionSort', {
section,
sortBy,
});
};
const buildSortConfig = section => ({
sortOptions: getSortOptionsForSection(section),
activeSort: getSortForSection(section),
onSortChange: sortBy => updateSortPreference(section, sortBy),
});
const sortedFolders = computed(() =>
sortSidebarItems(conversationCustomViews.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.FOLDERS),
labelKey: view => view.name,
})
);
const sortedTeams = computed(() =>
sortByUnreadCount(
teams.value,
team => team.name,
team => getTeamUnreadCount.value(team.id)
)
sortSidebarItems(teams.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.TEAMS),
labelKey: team => team.name,
unreadCountKey: team => getTeamUnreadCount.value(team.id),
})
);
const sortedInboxes = computed(() =>
sortByUnreadCount(
inboxes.value,
inbox => inbox.name,
inbox => getInboxUnreadCount.value(inbox.id)
)
sortSidebarItems(inboxes.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.CHANNELS),
labelKey: inbox => inbox.name,
unreadCountKey: inbox => getInboxUnreadCount.value(inbox.id),
})
);
const sortedLabels = computed(() =>
sortByUnreadCount(
labels.value,
label => label.title,
label => getLabelUnreadCount.value(label.id)
)
sortSidebarItems(labels.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.LABELS),
labelKey: label => label.title,
unreadCountKey: label => getLabelUnreadCount.value(label.id),
})
);
const closeMobileSidebar = () => {
@@ -297,18 +333,22 @@ const menuItems = computed(() => {
{
name: 'All',
label: t('SIDEBAR.ALL_CONVERSATIONS'),
icon: 'i-lucide-inbox',
badgeCount: allUnreadCount.value,
activeOn: ['inbox_conversation'],
to: accountScopedRoute('home'),
},
{
name: 'Mentions',
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
icon: 'i-lucide-at-sign',
activeOn: ['conversation_through_mentions'],
to: accountScopedRoute('conversation_mentions'),
},
{
name: 'Participating',
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
icon: 'i-lucide-user-round-check',
activeOn: ['conversation_through_participating'],
to: accountScopedRoute('conversation_participating'),
},
@@ -316,6 +356,7 @@ const menuItems = computed(() => {
name: 'Unattended',
activeOn: ['conversation_through_unattended'],
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
icon: 'i-lucide-clock-alert',
to: accountScopedRoute('conversation_unattended'),
},
{
@@ -323,7 +364,10 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CUSTOM_VIEWS_FOLDER'),
icon: 'i-lucide-folder',
activeOn: ['conversations_through_folders'],
children: conversationCustomViews.value.map(view => ({
...buildSortConfig(SIDEBAR_SORT_SECTIONS.FOLDERS),
collapsible: true,
showTreeLine: true,
children: sortedFolders.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
to: accountScopedRoute('folder_conversations', { id: view.id }),
@@ -334,6 +378,9 @@ const menuItems = computed(() => {
label: t('SIDEBAR.TEAMS'),
icon: 'i-lucide-users',
activeOn: ['conversations_through_team'],
...buildSortConfig(SIDEBAR_SORT_SECTIONS.TEAMS),
collapsible: true,
showTreeLine: true,
children: sortedTeams.value.map(team => ({
name: `${team.name}-${team.id}`,
label: team.name,
@@ -346,6 +393,9 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CHANNELS'),
icon: 'i-lucide-mailbox',
activeOn: ['conversation_through_inbox'],
...buildSortConfig(SIDEBAR_SORT_SECTIONS.CHANNELS),
collapsible: true,
showTreeLine: true,
children: sortedInboxes.value.map(inbox => ({
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
@@ -366,6 +416,9 @@ const menuItems = computed(() => {
label: t('SIDEBAR.LABELS'),
icon: 'i-lucide-tag',
activeOn: ['conversations_through_label'],
...buildSortConfig(SIDEBAR_SORT_SECTIONS.LABELS),
collapsible: true,
showTreeLine: true,
children: sortedLabels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
@@ -477,6 +530,8 @@ const menuItems = computed(() => {
name: 'Segments',
icon: 'i-lucide-group',
label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'),
collapsible: true,
showTreeLine: true,
children: contactCustomViews.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
@@ -495,6 +550,8 @@ const menuItems = computed(() => {
name: 'Tagged With',
icon: 'i-lucide-tag',
label: t('SIDEBAR.TAGGED_WITH'),
collapsible: true,
showTreeLine: true,
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
@@ -1,11 +1,13 @@
<script setup>
import { computed, ref, onMounted, nextTick } from 'vue';
import { onClickOutside } from '@vueuse/core';
import { useRouter } from 'vue-router';
import { useSidebarContext } from './provider';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'next/icon/Icon.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
import SidebarSortMenu from './SidebarSortMenu.vue';
const props = defineProps({
label: { type: String, required: true },
@@ -14,7 +16,7 @@ const props = defineProps({
triggerRect: { type: Object, default: () => ({ top: 0, left: 0 }) },
});
const emit = defineEmits(['close', 'mouseenter', 'mouseleave']);
const emit = defineEmits(['close', 'mouseenter', 'mouseleave', 'sortToggle']);
const router = useRouter();
const { isAllowed, sidebarWidth } = useSidebarContext();
@@ -29,6 +31,14 @@ const toggleSubGroup = name => {
expandedSubGroup.value = expandedSubGroup.value === name ? null : name;
};
const handleSortToggle = isSortOpen => {
emit('sortToggle', isSortOpen);
};
onClickOutside(popoverRef, () => emit('close'), {
ignore: ['[data-popover-content]'],
});
const navigateAndClose = to => {
router.push(to);
emit('close');
@@ -123,24 +133,44 @@ onMounted(async () => {
>
<template v-for="child in accessibleChildren" :key="child.name">
<!-- SubGroup with children -->
<li v-if="child.children" class="py-0.5">
<button
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-n-slate-11 hover:bg-n-alpha-2 transition-colors duration-150 ease-out text-left rtl:text-right"
@click="toggleSubGroup(child.name)"
<li v-if="child.children" class="group/sidebar-section py-0.5">
<div
class="flex items-center rounded-lg text-n-slate-11 hover:bg-n-alpha-2 transition-colors duration-150 ease-out"
>
<Icon
v-if="child.icon"
:icon="child.icon"
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate text-sm">{{ child.label }}</span>
<span
class="size-3 transition-transform i-lucide-chevron-down"
:class="{
'rotate-180': expandedSubGroup === child.name,
}"
/>
</button>
<button
class="flex flex-1 min-w-0 items-center gap-2 ps-2 py-1.5 text-left rtl:text-right"
@click="toggleSubGroup(child.name)"
>
<Icon
v-if="child.icon"
:icon="child.icon"
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate text-sm">{{ child.label }}</span>
</button>
<div class="flex flex-shrink-0 items-center gap-1 pe-2">
<SidebarSortMenu
v-if="child.sortOptions?.length"
:active-sort="child.activeSort"
:options="child.sortOptions"
:open-on-hover="false"
@sort="child.onSortChange"
@toggle="handleSortToggle"
/>
<button
type="button"
class="flex size-6 flex-shrink-0 items-center justify-center rounded-md text-n-slate-11 hover:bg-n-alpha-2 focus-visible:bg-n-alpha-2 focus-visible:outline-none"
@click.stop="toggleSubGroup(child.name)"
>
<span
class="size-4 flex-shrink-0 transition-transform i-lucide-chevron-down"
:class="{
'rotate-180': expandedSubGroup === child.name,
}"
/>
</button>
</div>
</div>
<Transition v-bind="transition">
<ul
v-if="expandedSubGroup === child.name"
@@ -55,6 +55,9 @@ const hasChildren = computed(
const isPopoverOpen = computed(() => activePopover.value === props.name);
const triggerRef = ref(null);
const triggerRect = ref({ top: 0, left: 0, bottom: 0, right: 0 });
// The sort dropdown teleports outside the popover; keep the popover open while
// it is showing so moving the cursor onto it does not close everything.
const isSortMenuOpen = ref(false);
const openPopover = () => {
if (triggerRef.value) {
@@ -82,7 +85,7 @@ const handleMouseEnter = () => {
};
const handleMouseLeave = () => {
if (!hasChildren.value) return;
if (!hasChildren.value || isSortMenuOpen.value) return;
scheduleClose(200);
};
@@ -91,28 +94,53 @@ const handlePopoverMouseEnter = () => {
};
const handlePopoverMouseLeave = () => {
if (isSortMenuOpen.value) return;
scheduleClose(100);
};
const handleSortToggle = isOpen => {
isSortMenuOpen.value = isOpen;
cancelClose();
};
// Close popover when mouse leaves the window
const handleWindowBlur = () => {
closeActivePopover();
};
const accessibleItems = computed(() => {
const hasAccessibleSubChildren = child => {
return child.children?.some(
subChild => subChild.to && isAllowed(subChild.to)
);
};
const visibleChildren = computed(() => {
if (!hasChildren.value) return [];
return props.children.filter(child => {
// If a item has no link, it means it's just a subgroup header
// So we don't need to check for permissions here, because there's nothing to
// access here anyway
if (child.children) return hasAccessibleSubChildren(child);
return child.to && isAllowed(child.to);
});
});
const hasAccessibleChildren = computed(() => {
return accessibleItems.value.length > 0;
const accessibleItems = computed(() => {
if (!hasChildren.value) return [];
return visibleChildren.value
.flatMap(child => child.children || child)
.filter(child => child.to && isAllowed(child.to));
});
const hasAccessibleChildren = computed(() => {
return visibleChildren.value.length > 0;
});
const isLastVisibleChild = child => {
const lastChild = visibleChildren.value[visibleChildren.value.length - 1];
return lastChild === child;
};
const isActive = computed(() => {
if (props.to) {
if (route.path === resolvePath(props.to)) return true;
@@ -254,6 +282,7 @@ watch(
@close="closePopover"
@mouseenter="handlePopoverMouseEnter"
@mouseleave="handlePopoverMouseLeave"
@sort-toggle="handleSortToggle"
/>
</div>
</template>
@@ -274,16 +303,23 @@ watch(
<ul
v-if="hasChildren"
v-show="isExpanded || hasActiveChild"
class="grid m-0 list-none sidebar-group-children min-w-0"
class="grid m-0 list-none min-w-0"
>
<template v-for="child in children" :key="child.name">
<template v-for="child in visibleChildren" :key="child.name">
<SidebarSubGroup
v-if="child.children"
:name="`${name}:${child.name}`"
:label="child.label"
:icon="child.icon"
:children="child.children"
:collapsible="child.collapsible"
:show-tree-line="child.showTreeLine"
:end-tree-line="child.showTreeLine && isLastVisibleChild(child)"
:is-expanded="isExpanded"
:active-child="activeChild"
:sort-options="child.sortOptions"
:active-sort="child.activeSort"
@update-sort="child.onSortChange"
/>
<SidebarGroupLeaf
v-else-if="isAllowed(child.to)"
@@ -299,59 +335,3 @@ watch(
</template>
</Policy>
</template>
<style>
.sidebar-group-children .child-item::before {
content: '';
position: absolute;
width: 0.125rem;
/* 0.5px */
height: 100%;
}
.sidebar-group-children .child-item:first-child::before {
border-radius: 4px 4px 0 0;
}
/* This selects the last child in a group */
/* https://codepen.io/scmmishra/pen/yLmKNLW */
.sidebar-group-children > .child-item:last-child::before,
.sidebar-group-children
> *:last-child
> *:last-child
> .child-item:last-child::before {
height: 20%;
}
.sidebar-group-children > .child-item:last-child::after,
.sidebar-group-children
> *:last-child
> *:last-child
> .child-item:last-child::after {
content: '';
position: absolute;
width: 10px;
height: 12px;
bottom: calc(50% - 2px);
border-bottom-width: 0.125rem;
border-left-width: 0.125rem;
border-right-width: 0px;
border-top-width: 0px;
border-radius: 0 0 0 4px;
left: 0;
}
#app[dir='rtl'] .sidebar-group-children > .child-item:last-child::after,
#app[dir='rtl']
.sidebar-group-children
> *:last-child
> *:last-child
> .child-item:last-child::after {
right: 0;
border-bottom-width: 0.125rem;
border-right-width: 0.125rem;
border-left-width: 0px;
border-top-width: 0px;
border-radius: 0 0 4px 0px;
}
</style>
@@ -45,7 +45,9 @@ const count = computed(() =>
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
/>
</div>
<div class="flex items-center gap-1.5 flex-grow min-w-0 flex-1">
<div
class="flex items-center gap-1.5 flex-grow justify-between min-w-0 flex-1"
>
<span
class="truncate"
:class="{
@@ -57,11 +59,7 @@ const count = computed(() =>
</span>
<span
v-if="dynamicCount && !expandable"
class="rounded-md capitalize text-xs leading-5 font-medium text-center outline outline-1 px-1 flex-shrink-0"
:class="{
'text-n-slate-12 outline-n-slate-6': isActive,
'text-n-slate-11 outline-n-strong': !isActive,
}"
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-slate-4 px-1 text-xxs font-medium leading-3 text-n-slate-12 dark:bg-n-slate-5 flex-shrink-0"
>
{{ count }}
</span>
@@ -12,6 +12,8 @@ const props = defineProps({
active: { type: Boolean, default: false },
component: { type: Function, default: null },
badgeCount: { type: [Number, String], default: 0 },
hideTreeLine: { type: Boolean, default: false },
thinTreeLine: { type: Boolean, default: false },
});
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
@@ -19,21 +21,30 @@ const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
const shouldRenderComponent = computed(() => {
return typeof props.component === 'function' || isVNode(props.component);
});
// Tree-line connector per leaf: vertical line (::before) + rounded elbow on the
// last child (::after). Logical props (start / border-s / rounded-es)
const TREE_CONNECTOR =
"child-item before:content-[''] before:absolute before:start-0 before:w-0.5 before:h-full before:bg-n-slate-4 first:before:rounded-t last:before:h-1/5 last:after:content-[''] last:after:absolute last:after:start-0 last:after:bottom-[calc(50%_-_2px)] last:after:h-3 last:after:w-2.5 last:after:border-b-2 last:after:border-s-2 last:after:rounded-es last:after:border-n-slate-4";
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<Policy
:permissions="resolvePermissions(to)"
:feature-flag="resolveFeatureFlag(to)"
as="li"
class="py-0.5 ltr:pl-2 rtl:pr-2 rtl:mr-3 ltr:ml-3 relative text-n-slate-11 child-item before:bg-n-slate-4 after:bg-transparent after:border-n-slate-4 before:left-0 rtl:before:right-0 min-w-0"
class="py-0.5 ps-2 ms-3 relative text-n-slate-11 min-w-0"
:class="{
[TREE_CONNECTOR]: !hideTreeLine,
'before:!w-px last:after:!border-b last:after:!border-s':
!hideTreeLine && thinTreeLine,
}"
>
<component
:is="to ? 'router-link' : 'div'"
:to="to"
:title="label"
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group min-w-0"
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg ltr:hover:bg-gradient-to-r rtl:hover:bg-gradient-to-l from-transparent via-n-slate-3/70 to-n-slate-3/70 group min-w-0"
:class="{
'text-n-slate-12 bg-n-alpha-2 active': active,
}"
@@ -1,25 +1,104 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
import SidebarSortMenu from './SidebarSortMenu.vue';
defineProps({
collapsible: {
type: Boolean,
default: false,
},
isExpanded: {
type: Boolean,
default: true,
},
label: {
type: String,
default: '',
},
icon: {
type: [Object, String],
default: '',
},
sortOptions: {
type: Array,
default: () => [],
},
activeSort: {
type: String,
default: '',
},
showTreeLine: {
type: Boolean,
default: false,
},
endTreeLine: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['toggle', 'update-sort']);
const TREE_VERTICAL_LINE =
"before:content-[''] before:absolute before:-top-1 before:w-0.5 before:bg-n-slate-4 before:start-[-0.5rem]";
const TREE_ELBOW =
"after:content-[''] after:absolute after:w-2.5 after:h-3 after:bottom-1/2 after:start-[-0.5rem] after:border-b-2 after:border-s-2 after:rounded-es after:border-n-slate-4";
</script>
<template>
<div
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 text-n-slate-10 select-none pointer-events-none"
>
<Icon v-if="icon" :icon="icon" class="size-4" />
<span class="text-sm font-medium leading-5 flex-grow">
{{ label }}
</span>
<div class="relative min-w-0" :class="{ 'ms-5': collapsible }">
<component
:is="collapsible ? 'button' : 'div'"
:type="collapsible ? 'button' : undefined"
:aria-expanded="collapsible ? isExpanded : undefined"
:title="label"
class="relative flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-n-slate-10 select-none"
:class="[
showTreeLine && TREE_VERTICAL_LINE,
showTreeLine &&
(endTreeLine ? `before:h-3 ${TREE_ELBOW}` : 'before:-bottom-1'),
{
'pointer-events-none': !collapsible,
'cursor-pointer hover:bg-n-alpha-2': collapsible,
'pe-14': collapsible && sortOptions.length,
'pe-8': collapsible && !sortOptions.length,
'pe-10': !collapsible && sortOptions.length,
},
]"
@click.stop="collapsible ? emit('toggle') : undefined"
>
<div class="inline-flex min-w-0 items-center gap-2">
<Icon v-if="icon" :icon="icon" class="size-4 flex-shrink-0" />
<span
class="flex-grow truncate text-start text-sm font-medium leading-5"
>
{{ label }}
</span>
</div>
</component>
<div
v-if="collapsible || sortOptions.length"
class="absolute end-2 top-1/2 flex -translate-y-1/2 items-center gap-1"
>
<SidebarSortMenu
v-if="sortOptions.length"
:active-sort="activeSort"
:options="sortOptions"
@sort="sortBy => emit('update-sort', sortBy)"
/>
<button
v-if="collapsible"
type="button"
class="flex size-6 flex-shrink-0 items-center justify-center rounded-md text-n-slate-10 hover:bg-n-alpha-2 focus-visible:bg-n-alpha-2 focus-visible:outline-none"
:aria-expanded="isExpanded"
:aria-label="label"
@click.stop="emit('toggle')"
>
<span
class="size-3 flex-shrink-0"
:class="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
/>
</button>
</div>
</div>
</template>
@@ -0,0 +1,209 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import { SIDEBAR_SORT_KEYS } from 'dashboard/helper/sidebarSort';
const props = defineProps({
activeSort: {
type: String,
default: '',
},
options: {
type: Array,
default: () => [],
},
openOnHover: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['sort', 'toggle']);
const SORT_OPTION_GROUPS = [
{
key: 'created',
options: [SIDEBAR_SORT_KEYS.CREATED_DESC, SIDEBAR_SORT_KEYS.CREATED_ASC],
},
{
key: 'alphabetical',
options: [
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
],
},
{
key: 'unread_count',
options: [
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
},
];
const { t } = useI18n();
const isOpen = ref(false);
const triggerRef = ref(null);
const popoverRef = ref(null);
let closeTimer;
const { fixedPosition, updatePosition } = useDropdownPosition(
triggerRef,
popoverRef,
isOpen,
{ align: 'start' }
);
const getSortOptionLabel = option => {
if (option === SIDEBAR_SORT_KEYS.CREATED_DESC) {
return t('SIDEBAR.SORT_OPTIONS.CREATED_DESC');
}
if (option === SIDEBAR_SORT_KEYS.CREATED_ASC) {
return t('SIDEBAR.SORT_OPTIONS.CREATED_ASC');
}
if (option === SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC) {
return t('SIDEBAR.SORT_OPTIONS.ALPHABETICAL_ASC');
}
if (option === SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC) {
return t('SIDEBAR.SORT_OPTIONS.ALPHABETICAL_DESC');
}
if (option === SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC) {
return t('SIDEBAR.SORT_OPTIONS.UNREAD_COUNT_DESC');
}
if (option === SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC) {
return t('SIDEBAR.SORT_OPTIONS.UNREAD_COUNT_ASC');
}
return '';
};
const getSortGroupLabel = groupKey => {
if (groupKey === 'created') {
return t('SIDEBAR.SORT_GROUPS.CREATED');
}
if (groupKey === 'alphabetical') {
return t('SIDEBAR.SORT_GROUPS.ALPHABETICAL');
}
if (groupKey === 'unread_count') {
return t('SIDEBAR.SORT_GROUPS.UNREAD_COUNT');
}
return '';
};
const sortMenuSections = computed(() =>
SORT_OPTION_GROUPS.map(group => ({
title: getSortGroupLabel(group.key),
items: group.options
.filter(option => props.options.includes(option))
.map(option => ({
label: getSortOptionLabel(option),
value: option,
action: 'sort',
isActive: option === props.activeSort,
})),
})).filter(section => section.items.length)
);
const clearCloseTimer = () => {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
};
const openMenu = async () => {
clearCloseTimer();
isOpen.value = true;
emit('toggle', true);
await nextTick();
updatePosition();
};
const closeMenu = () => {
clearCloseTimer();
isOpen.value = false;
emit('toggle', false);
};
const scheduleClose = () => {
clearCloseTimer();
closeTimer = setTimeout(closeMenu, 150);
};
const handleTriggerEnter = () => {
if (props.openOnHover) openMenu();
};
const handleTriggerLeave = () => {
if (props.openOnHover) scheduleClose();
};
const handleClickOutside = event => {
if (triggerRef.value?.contains(event.target)) return;
closeMenu();
};
const handleSortChange = ({ value }) => {
emit('sort', value);
closeMenu();
};
onBeforeUnmount(clearCloseTimer);
</script>
<template>
<div
ref="triggerRef"
class="relative invisible flex-shrink-0 opacity-0 pointer-events-none transition-opacity duration-150 group-hover/sidebar-section:visible group-hover/sidebar-section:opacity-100 group-hover/sidebar-section:pointer-events-auto"
:class="{ '!visible !opacity-100 !pointer-events-auto': isOpen }"
@mouseenter="handleTriggerEnter"
@mouseleave="handleTriggerLeave"
>
<Button
:title="t('SIDEBAR.SORT_TOOLTIP')"
icon="i-lucide-arrow-up-down"
ghost
slate
xs
class="!size-6 !text-n-slate-11 hover:!text-n-slate-12"
:class="{ '!bg-n-alpha-2': isOpen }"
@click.stop="openMenu"
/>
<TeleportWithDirection>
<DropdownMenu
v-if="isOpen"
ref="popoverRef"
v-on-click-outside="handleClickOutside"
data-popover-content
:menu-sections="sortMenuSections"
:class="fixedPosition.class"
:style="fixedPosition.style"
class="w-60 !fixed"
@action="handleSortChange"
@mouseenter="clearCloseTimer"
@mouseleave="handleTriggerLeave"
>
<template #trailing-icon="{ item }">
<span
v-if="item.isActive"
class="i-lucide-check ms-auto size-4 flex-shrink-0 text-n-slate-11"
/>
</template>
</DropdownMenu>
</TeleportWithDirection>
</div>
</template>
@@ -1,21 +1,56 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useEventListener } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import Icon from 'next/icon/Icon.vue';
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
import SidebarGroupSeparator from './SidebarGroupSeparator.vue';
import { useSidebarContext } from './provider';
import { useEventListener } from '@vueuse/core';
const props = defineProps({
name: { type: String, required: true },
isExpanded: { type: Boolean, default: false },
label: { type: String, required: true },
icon: { type: [Object, String], required: true },
children: { type: Array, default: undefined },
activeChild: { type: Object, default: undefined },
sortOptions: { type: Array, default: () => [] },
activeSort: { type: String, default: '' },
collapsible: { type: Boolean, default: false },
showTreeLine: { type: Boolean, default: false },
endTreeLine: { type: Boolean, default: false },
});
const emit = defineEmits(['update-sort']);
const { isAllowed } = useSidebarContext();
const scrollableContainer = ref(null);
const accountId = useMapGetter('getCurrentAccountId');
const minimizedSectionsKey = LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS;
const getMinimizedSections = () => {
const minimizedSections = LocalStorage.get(minimizedSectionsKey);
return minimizedSections &&
typeof minimizedSections === 'object' &&
!Array.isArray(minimizedSections)
? minimizedSections
: {};
};
const minimizedSections = ref(getMinimizedSections());
const storageKey = computed(() =>
accountId.value ? `${accountId.value}:${props.name}` : props.name
);
const isSubGroupExpanded = computed(
() => !props.collapsible || !minimizedSections.value[storageKey.value]
);
const hasActiveChild = computed(() =>
props.children.some(child => child.name === props.activeChild?.name)
);
const accessibleItems = computed(() =>
props.children.filter(child => {
@@ -28,84 +63,120 @@ const hasAccessibleItems = computed(() => {
});
const isScrollable = computed(() => {
return accessibleItems.value.length > 7;
return (
props.isExpanded &&
isSubGroupExpanded.value &&
accessibleItems.value.length > 7
);
});
const scrollEnd = ref(false);
const CHILDREN_TRUNK =
"before:content-[''] before:absolute before:top-0 before:bottom-0 before:w-0.5 before:bg-n-slate-4 before:start-[-0.5rem]";
const hideLeafTreeLine = computed(
() => props.showTreeLine && !props.isExpanded
);
const toggleSubGroup = () => {
if (!props.collapsible) return;
if (isSubGroupExpanded.value) {
LocalStorage.updateJsonStore(minimizedSectionsKey, storageKey.value, true);
} else {
LocalStorage.deleteFromJsonStore(minimizedSectionsKey, storageKey.value);
}
minimizedSections.value = getMinimizedSections();
};
const expandSubGroupOnActiveChild = () => {
if (!props.collapsible || !hasActiveChild.value || isSubGroupExpanded.value) {
return;
}
LocalStorage.deleteFromJsonStore(minimizedSectionsKey, storageKey.value);
minimizedSections.value = getMinimizedSections();
};
const shouldShowItem = child => {
return (
isSubGroupExpanded.value &&
(props.isExpanded || props.activeChild?.name === child.name)
);
};
// set scrollEnd to true when the scroll reaches the end
useEventListener(scrollableContainer, 'scroll', () => {
const { scrollHeight, scrollTop, clientHeight } = scrollableContainer.value;
scrollEnd.value = scrollHeight - scrollTop === clientHeight;
});
useEventListener(window, 'storage', event => {
if (event.key === minimizedSectionsKey) {
minimizedSections.value = getMinimizedSections();
}
});
watch([hasActiveChild, storageKey], expandSubGroupOnActiveChild, {
immediate: true,
});
</script>
<template>
<SidebarGroupSeparator
v-if="hasAccessibleItems"
v-show="isExpanded"
:label
:icon
class="my-1"
/>
<ul
v-if="children.length"
class="m-0 list-none reset-base relative group min-w-0"
>
<!-- Each element has h-8, which is 32px, we will show 7 items with one hidden at the end,
which is 14rem. Then we add 16px so that we have some text visible from the next item -->
<div
ref="scrollableContainer"
class="min-w-0"
:class="{
'max-h-[calc(14rem+16px)] overflow-y-scroll no-scrollbar': isScrollable,
}"
>
<SidebarGroupLeaf
v-for="child in children"
v-show="isExpanded || activeChild?.name === child.name"
v-bind="child"
:key="child.name"
:active="activeChild?.name === child.name"
<li class="group/sidebar-section relative flex flex-col list-none min-w-0">
<template v-if="hasAccessibleItems">
<SidebarGroupSeparator
v-show="isExpanded"
:label
:icon
:collapsible
:is-expanded="isSubGroupExpanded"
:show-tree-line="showTreeLine"
:end-tree-line="endTreeLine"
:sort-options="sortOptions"
:active-sort="activeSort"
class="my-1"
@toggle="toggleSubGroup"
@update-sort="sortBy => emit('update-sort', sortBy)"
/>
</div>
<div
v-if="isScrollable && isExpanded"
v-show="!scrollEnd"
class="absolute bg-gradient-to-t from-n-background w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
>
<svg
width="16"
height="24"
viewBox="0 0 16 24"
fill="none"
class="text-n-slate-9 opacity-50 group-hover:opacity-100"
xmlns="http://www.w3.org/2000/svg"
<ul
v-if="children.length"
class="m-0 list-none reset-base relative group min-w-0"
:class="[
{ 'ms-5': collapsible },
showTreeLine && !endTreeLine && CHILDREN_TRUNK,
]"
>
<path
d="M4 4L8 8L12 4"
stroke="currentColor"
opacity="0.5"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4 10L8 14L12 10"
stroke="currentColor"
opacity="0.75"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4 16L8 20L12 16"
stroke="currentColor"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</ul>
<div
ref="scrollableContainer"
class="min-w-0"
:class="{
'max-h-60 overflow-y-scroll no-scrollbar': isScrollable,
}"
>
<SidebarGroupLeaf
v-for="child in children"
v-show="shouldShowItem(child)"
v-bind="child"
:key="child.name"
:active="activeChild?.name === child.name"
:hide-tree-line="hideLeafTreeLine"
thin-tree-line
/>
</div>
<div
v-if="isScrollable && isExpanded"
v-show="!scrollEnd"
class="absolute bg-gradient-to-t from-n-background w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
>
<Icon
icon="i-woot-chevrons-down"
class="w-4 h-6 text-n-slate-9 opacity-50 group-hover:opacity-100"
/>
</div>
</ul>
</template>
</li>
</template>
@@ -0,0 +1,172 @@
import { mount } from '@vue/test-utils';
import { ref } from 'vue';
import SidebarSubGroup from '../SidebarSubGroup.vue';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { provideSidebarContext } from '../provider';
vi.mock('dashboard/composables/store', () => ({
useMapGetter: () => ref(1),
}));
vi.mock('dashboard/composables/usePolicy', () => ({
usePolicy: () => ({
shouldShow: () => true,
}),
}));
vi.mock('vue-router', () => ({
useRouter: () => ({
resolve: () => ({ path: '/' }),
getRoutes: () => [],
}),
}));
const children = [
{
name: 'Sales-1',
label: 'Sales',
to: { name: 'team_conversations' },
},
];
const mountSubGroup = props => {
return mount(
{
components: { SidebarSubGroup },
setup() {
provideSidebarContext({});
},
template: '<SidebarSubGroup v-bind="$attrs" />',
},
{
attrs: {
name: 'Conversation:Teams',
label: 'Teams',
icon: 'i-lucide-users',
children,
isExpanded: true,
collapsible: true,
...props,
},
global: {
stubs: {
SidebarGroupLeaf: {
props: {
label: { type: String, required: true },
hideTreeLine: { type: Boolean, default: false },
thinTreeLine: { type: Boolean, default: false },
},
template:
'<li class="sidebar-leaf" :data-hide-tree-line="String(hideTreeLine)" :data-thin-tree-line="String(thinTreeLine)">{{ label }}</li>',
},
},
},
}
);
};
describe('SidebarSubGroup', () => {
let localStorageStore;
beforeEach(() => {
localStorageStore = {};
Object.defineProperty(window, 'localStorage', {
value: {
getItem: key => localStorageStore[key] || null,
setItem: (key, value) => {
localStorageStore[key] = String(value);
},
removeItem: key => {
delete localStorageStore[key];
},
clear: () => {
localStorageStore = {};
},
},
configurable: true,
});
window.localStorage.clear();
});
it('keeps collapsible sections expanded by default', () => {
const wrapper = mountSubGroup();
expect(wrapper.find('button').attributes('aria-expanded')).toBe('true');
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(true);
});
it('renders the tree line on the separator, positioned relative to it', () => {
const wrapper = mountSubGroup({ showTreeLine: true });
const button = wrapper.find('button');
expect(button.classes()).toContain('relative');
expect(button.classes()).toContain('before:bg-n-slate-4');
expect(button.classes()).toContain('before:-bottom-1');
});
it('renders the end curve on the last separator', () => {
const wrapper = mountSubGroup({ showTreeLine: true, endTreeLine: true });
const button = wrapper.find('button');
expect(button.classes()).toContain('before:h-3');
expect(button.classes()).toContain('after:border-b-2');
});
it('draws nested item tree lines via the leaf connectors', () => {
const wrapper = mountSubGroup({ showTreeLine: true });
expect(
wrapper.find('.sidebar-leaf').attributes('data-hide-tree-line')
).toBe('false');
});
it('marks nested item connectors as thin', () => {
const wrapper = mountSubGroup({ showTreeLine: true });
expect(
wrapper.find('.sidebar-leaf').attributes('data-thin-tree-line')
).toBe('true');
});
it('minimizes the section and stores it by account and section name', async () => {
const wrapper = mountSubGroup();
await wrapper.find('button').trigger('click');
const storedSections = JSON.parse(
window.localStorage.getItem(LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS)
);
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false');
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(false);
expect(storedSections).toEqual({ '1:Conversation:Teams': true });
});
it('uses the stored minimized state when mounted again', () => {
window.localStorage.setItem(
LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS,
JSON.stringify({ '1:Conversation:Teams': true })
);
const wrapper = mountSubGroup();
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false');
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(false);
});
it('expands a stored minimized section when one of its children is active', () => {
window.localStorage.setItem(
LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS,
JSON.stringify({ '1:Conversation:Teams': true })
);
const wrapper = mountSubGroup({ activeChild: children[0] });
const storedSections = JSON.parse(
window.localStorage.getItem(LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS)
);
expect(wrapper.find('button').attributes('aria-expanded')).toBe('true');
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(true);
expect(storedSections).toEqual({});
});
});
@@ -81,6 +81,7 @@ const showDivider = index => {
<template v-for="(tab, index) in tabs" :key="index">
<button
:ref="el => (tabRefs[index] = el)"
type="button"
class="relative z-10 px-4 truncate py-1.5 text-sm border-0 outline-1 outline-transparent rounded-lg transition-all duration-200 ease-out hover:text-n-brand active:scale-[1.02]"
:class="[
activeTab === index
@@ -308,6 +308,15 @@ function filterByAssigneeTab(conversations) {
return [...conversations];
}
function sortByUnreadStatus(conversations) {
return [...conversations].sort((a, b) => {
const unreadCountDiff = (b.unread_count || 0) - (a.unread_count || 0);
if (unreadCountDiff !== 0) return unreadCountDiff;
return (b.last_activity_at || 0) - (a.last_activity_at || 0);
});
}
const conversationList = computed(() => {
let localConversationList = [];
@@ -337,6 +346,13 @@ const conversationList = computed(() => {
});
}
if (
!hasAppliedFiltersOrActiveFolders.value &&
activeSortBy.value === wootConstants.SORT_BY_TYPE.UNREAD
) {
localConversationList = sortByUnreadStatus(localConversationList);
}
return localConversationList;
});
@@ -0,0 +1,141 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { format, parseISO } from 'date-fns';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
sessions: {
type: Array,
required: true,
},
});
const emit = defineEmits(['revoke', 'revokeAll', 'cancel']);
const { t } = useI18n();
const revokingId = ref(null);
const revokingAll = ref(false);
const sortedSessions = computed(() =>
[...props.sessions].sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at)
)
);
const formatDate = dateStr => {
if (!dateStr) return '';
return format(parseISO(dateStr), 'MMMM d, yyyy');
};
const formatTime = dateStr => {
if (!dateStr) return '';
return format(parseISO(dateStr), 'hh:mma');
};
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
const sessionLabel = session => {
const parts = [];
if (!isUnknown(session.browser_name)) parts.push(session.browser_name);
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
return parts.join(' on ') || t('SESSION_LIMIT.UNKNOWN_DEVICE');
};
watch(
() => props.sessions,
() => {
revokingId.value = null;
revokingAll.value = false;
}
);
const handleRevoke = session => {
revokingId.value = session.id;
emit('revoke', session.id);
};
const handleRevokeAll = () => {
revokingAll.value = true;
emit('revokeAll');
};
</script>
<template>
<div class="w-full max-w-lg mx-auto">
<div
class="bg-white shadow dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
>
<!-- Header -->
<div class="flex items-start justify-between gap-4 mb-6">
<div>
<h2 class="text-2xl font-semibold text-n-slate-12">
{{ $t('SESSION_LIMIT.TITLE') }}
</h2>
<p class="text-sm text-n-slate-11 mt-2">
{{ $t('SESSION_LIMIT.DESCRIPTION') }}
</p>
</div>
<NextButton
type="button"
faded
sm
class="flex-shrink-0 whitespace-nowrap"
:label="$t('SESSION_LIMIT.END_ALL')"
:is-loading="revokingAll"
:disabled="revokingId !== null"
@click="handleRevokeAll"
/>
</div>
<!-- Session List -->
<div class="flex flex-col gap-3 max-h-80 overflow-y-auto">
<div
v-for="session in sortedSessions"
:key="session.id"
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background px-4 py-3"
>
<div class="flex items-center gap-3 min-w-0">
<Icon
icon="i-lucide-monitor"
class="size-4 text-n-slate-10 flex-shrink-0"
/>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-sm font-medium text-n-slate-12">
{{ sessionLabel(session) }}
</span>
<span class="text-xs text-n-slate-10">
{{
`${$t('SESSION_LIMIT.STARTED')} ${formatDate(session.created_at)}, ${formatTime(session.created_at)}`
}}
</span>
</div>
</div>
<button
type="button"
class="text-sm font-medium text-n-slate-11 hover:text-n-slate-12 flex-shrink-0 disabled:opacity-50"
:disabled="revokingId !== null || revokingAll"
@click="handleRevoke(session)"
>
{{ $t('SESSION_LIMIT.END') }}
</button>
</div>
</div>
<!-- Cancel -->
<div class="text-center pt-4">
<NextButton
sm
slate
link
type="button"
class="w-full hover:!no-underline"
:label="$t('SESSION_LIMIT.CANCEL')"
@click="() => emit('cancel')"
/>
</div>
</div>
</div>
</template>
@@ -78,6 +78,10 @@ const chatSortOptions = computed(() => [
label: t('CHAT_LIST.SORT_ORDER_ITEMS.created_at_asc.TEXT'),
value: 'created_at_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.unread.TEXT'),
value: 'unread',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_desc.TEXT'),
value: 'priority_desc',
@@ -56,8 +56,9 @@ import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { emitter } from 'shared/helpers/mitt';
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
const EmojiIconPicker = defineAsyncComponent(
() =>
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
);
export default {
@@ -66,7 +67,7 @@ export default {
AttachmentPreview,
AudioRecorder,
ReplyBoxBanner,
EmojiInput,
EmojiIconPicker,
MessageSignatureMissingAlert,
ReplyBottomPanel,
ReplyEmailHead,
@@ -1284,13 +1285,15 @@ export default {
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<EmojiInput
<EmojiIconPicker
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
mode="emoji"
class="emoji-dialog"
:class="{
'emoji-dialog--expanded': isOnExpandedLayout,
}"
:on-click="addIntoEditor"
@select="addIntoEditor($event.value)"
/>
<ReplyEmailHead
v-if="showReplyHead && isDefaultEditorMode"
@@ -70,7 +70,8 @@ describe('useFacebookPageConnect', () => {
ACCOUNT_ID
);
expect(window.FB.login).toHaveBeenCalledWith(expect.any(Function), {
scope: expect.stringContaining('pages_show_list'),
scope:
'pages_manage_metadata,business_management,pages_messaging,pages_show_list,pages_read_engagement',
});
});
@@ -1,13 +1,9 @@
import { ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import ChannelApi from 'dashboard/api/channels';
import { buildFacebookLoginScopes } from 'dashboard/helper/facebookScopes';
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
// Page-management + messaging scopes required to list pages and create a
// Channel::FacebookPage inbox (mirrors the standalone settings flow).
const FB_PAGE_SCOPES =
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages';
// Headless half of the Facebook Page connect flow: load the Meta SDK, run
// FB.login for page scopes, and fetch the user's pages. The caller owns the
// page-picker UI and the channel creation, because choosing a page is an
@@ -51,7 +47,7 @@ export function useFacebookPageConnect() {
: null
);
},
{ scope: FB_PAGE_SCOPES }
{ scope: buildFacebookLoginScopes() }
);
});
@@ -27,6 +27,7 @@ export default {
WAITING_SINCE_ASC: 'waiting_since_asc',
WAITING_SINCE_DESC: 'waiting_since_desc',
PRIORITY_DESC_CREATED_AT_ASC: 'priority_desc_created_at_asc',
UNREAD: 'unread',
},
ARTICLE_STATUS_TYPES: {
DRAFT: 0,
@@ -7,4 +7,5 @@ export const LOCAL_STORAGE_KEYS = {
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
MESSAGE_REPLY_TO: 'messageReplyTo',
RECENT_SEARCHES: 'recentSearches',
SIDEBAR_MINIMIZED_SECTIONS: 'sidebarMinimizedSections',
};
@@ -154,6 +154,11 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
SHARE_CLICKED: 'Year in Review: Share clicked',
});
export const SESSION_EVENTS = Object.freeze({
LIMIT_HIT: 'Session limit reached at login',
REVOKED_FROM_PROFILE: 'Revoked an active session',
});
export const ONBOARDING_EVENTS = Object.freeze({
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
@@ -1,10 +1,9 @@
import { openDB } from 'idb';
import { DATA_VERSION } from './version';
import { cacheableModels, cacheableModelNames } from './cacheableModels';
export class DataManager {
constructor(accountId) {
this.modelsToSync = cacheableModelNames;
this.modelsToSync = ['inbox', 'label', 'team'];
this.accountId = accountId;
this.db = null;
}
@@ -12,26 +11,12 @@ export class DataManager {
async initDb() {
if (this.db) return this.db;
const dbName = `cw-store-${this.accountId}`;
this.db = await openDB(dbName, DATA_VERSION, {
upgrade(db, oldVersion, _newVersion, tx) {
// Flush data carried over from a previous schema version so a
// DATA_VERSION bump acts as a global cache reset. oldVersion === 0 on
// first install, so fresh devices skip this. Clearing before creating
// means we only ever clear stores that pre-existed this upgrade.
if (oldVersion > 0) {
for (let index = 0; index < db.objectStoreNames.length; index += 1) {
tx.objectStore(db.objectStoreNames.item(index)).clear();
}
}
if (!db.objectStoreNames.contains('cache-keys')) {
db.createObjectStore('cache-keys');
}
cacheableModels.forEach(model => {
if (!db.objectStoreNames.contains(model.name)) {
db.createObjectStore(model.name, { keyPath: 'id' });
}
});
this.db = await openDB(`cw-store-${this.accountId}`, DATA_VERSION, {
upgrade(db) {
db.createObjectStore('cache-keys');
db.createObjectStore('inbox', { keyPath: 'id' });
db.createObjectStore('label', { keyPath: 'id' });
db.createObjectStore('team', { keyPath: 'id' });
},
});
@@ -56,7 +41,7 @@ export class DataManager {
async replace({ modelName, data }) {
this.validateModel(modelName);
await this.db.clear(modelName);
this.db.clear(modelName);
return this.push({ modelName, data });
}
@@ -80,11 +65,9 @@ export class DataManager {
}
async setCacheKeys(cacheKeys) {
await Promise.all(
Object.entries(cacheKeys).map(([modelName, value]) =>
this.db.put('cache-keys', value, modelName)
)
);
Object.keys(cacheKeys).forEach(async modelName => {
this.db.put('cache-keys', cacheKeys[modelName], modelName);
});
}
async getCacheKey(modelName) {
@@ -1,17 +0,0 @@
// Single source of truth for IDB-cached workspace config.
//
// Each entry must keep `name` equal to the Rails `Model.name.underscore` value
// so the server's `cache_keys` payload (and the IDB object store name) lines up
// with what the client looks up.
//
// `setMutation` is the full commit path used to seed Vuex from IDB (boot
// paint) and to swap in refetched rows (event-driven revalidation). Every
// SET_* mutation must REPLACE its records (not merge) so rows deleted
// server-side never survive as phantoms.
export const cacheableModels = [
{ name: 'inbox', setMutation: 'inboxes/SET_INBOXES' },
{ name: 'label', setMutation: 'labels/SET_LABELS' },
{ name: 'team', setMutation: 'teams/SET_TEAMS' },
];
export const cacheableModelNames = cacheableModels.map(model => model.name);
@@ -1,36 +0,0 @@
import InboxesAPI from 'dashboard/api/inboxes';
import LabelsAPI from 'dashboard/api/labels';
import TeamsAPI from 'dashboard/api/teams';
import { cacheableModels } from './cacheableModels';
// model name → cache-enabled API client. Lives here rather than in
// cacheableModels to keep that module import-cycle-free: the API clients
// import DataManager, which imports cacheableModels.
const apiByModel = {
inbox: InboxesAPI,
label: LabelsAPI,
team: TeamsAPI,
};
const revalidateModel = async (store, model, newKey) => {
try {
const api = apiByModel[model.name];
if (await api.validateCacheKey(newKey)) return;
const response = await api.refetchAndCommit(newKey);
store.commit(model.setMutation, api.extractDataFromResponse(response));
} catch {
// Ignore error — a failed refetch leaves the painted data in place; the
// next pushed key map retries.
}
};
// The single freshness engine: given a pushed { model_name => key } map
// (RoomChannel transmits one on every (re)subscribe, the server broadcasts
// one on every change), diff each key against IDB and refetch mismatches.
export const dispatchCacheRevalidations = (store, keys = {}) =>
Promise.all(
cacheableModels
.filter(model => keys[model.name] !== undefined)
.map(model => revalidateModel(store, model, keys[model.name]))
);
@@ -1,31 +0,0 @@
import { DataManager } from './DataManager';
import { cacheableModels } from './cacheableModels';
// Seed Vuex from IndexedDB before the dashboard renders so warm boots paint
// cached config instantly. This is purely local — zero network calls.
//
// Freshness is handled entirely by the account.cache_invalidated event:
// RoomChannel transmits the current cache-key map on every (re)subscribe, and
// the server broadcasts it on every change. dispatchCacheRevalidations diffs
// those keys against IDB and refetches mismatches — the client never pulls
// cache keys itself.
export default async function paintStoresFromCache(store, accountId) {
let dm;
try {
dm = new DataManager(accountId);
await dm.initDb();
} catch {
// IDB unsupported (e.g. Firefox private mode) — silent no-op. Components
// will fetch from the network normally via the cache-enabled API client.
return;
}
// Stale-while-revalidate paint: commit cached data into Vuex immediately.
await Promise.all(
cacheableModels.map(async model => {
const localData = await dm.get({ modelName: model.name });
if (localData.length === 0) return;
store.commit(model.setMutation, localData);
})
);
}
@@ -98,6 +98,17 @@ class ReconnectService {
await this.store.dispatch('notifications/index', { ...filter, page: 1 });
};
revalidateCaches = async () => {
const { label, inbox, team } = await this.store.dispatch(
'accounts/getCacheKeys'
);
await Promise.all([
this.store.dispatch('labels/revalidate', { newKey: label }),
this.store.dispatch('inboxes/revalidate', { newKey: inbox }),
this.store.dispatch('teams/revalidate', { newKey: team }),
]);
};
handleRouteSpecificFetch = async () => {
const currentRoute = this.router.currentRoute.value.name;
if (isAConversationRoute(currentRoute, true)) {
@@ -127,11 +138,9 @@ class ReconnectService {
this.setConversationLastMessageId();
};
// Cached workspace config needs no explicit revalidation here: ActionCable
// auto-resubscribes after a drop, and RoomChannel pushes the cache-key map
// on every subscribe via the account.cache_invalidated event.
onReconnect = async () => {
await this.handleRouteSpecificFetch();
await this.revalidateCaches();
emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED);
};
}
@@ -14,7 +14,6 @@ import {
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { dispatchCacheRevalidations } from './CacheHelper/dispatchCacheRevalidations';
const { isImpersonating } = useImpersonation();
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
@@ -270,7 +269,10 @@ class ActionCableConnector extends BaseActionCableConnector {
};
onCacheInvalidate = data => {
dispatchCacheRevalidations(this.app.$store, data.cache_keys);
const keys = data.cache_keys;
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
onVoiceCallIncoming = data => {
@@ -0,0 +1,22 @@
export const FACEBOOK_PAGE_SCOPES = [
'pages_manage_metadata',
'business_management',
'pages_messaging',
'pages_show_list',
'pages_read_engagement',
];
export const INSTAGRAM_SCOPES = [
'instagram_basic',
'instagram_manage_messages',
];
export const buildFacebookLoginScopes = ({
includeInstagramScopes = false,
} = {}) => {
const scopes = [...FACEBOOK_PAGE_SCOPES];
if (includeInstagramScopes) {
scopes.push(...INSTAGRAM_SCOPES);
}
return scopes.join(',');
};
@@ -0,0 +1,181 @@
export const SIDEBAR_SORT_KEYS = Object.freeze({
CREATED_DESC: 'created_at_desc',
CREATED_ASC: 'created_at_asc',
ALPHABETICAL_ASC: 'alphabetical_asc',
ALPHABETICAL_DESC: 'alphabetical_desc',
UNREAD_COUNT_DESC: 'unread_count_desc',
UNREAD_COUNT_ASC: 'unread_count_asc',
});
export const SIDEBAR_SORT_SECTIONS = Object.freeze({
FOLDERS: 'folders',
TEAMS: 'teams',
CHANNELS: 'channels',
LABELS: 'labels',
});
export const SIDEBAR_SORT_OPTIONS_BY_SECTION = Object.freeze({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
],
[SIDEBAR_SORT_SECTIONS.TEAMS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
[SIDEBAR_SORT_SECTIONS.CHANNELS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
[SIDEBAR_SORT_SECTIONS.LABELS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
});
const UNREAD_COUNT_SORT_OPTIONS = [
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
];
export const DEFAULT_SIDEBAR_SORT_PREFERENCES = Object.freeze({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.CREATED_DESC,
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
[SIDEBAR_SORT_SECTIONS.CHANNELS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
[SIDEBAR_SORT_SECTIONS.LABELS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
});
export const isValidSidebarSort = (section, sortBy) => {
return SIDEBAR_SORT_OPTIONS_BY_SECTION[section]?.includes(sortBy);
};
const isUnreadCountSort = sortBy => UNREAD_COUNT_SORT_OPTIONS.includes(sortBy);
export const getSidebarSortOptions = (
section,
{ hasUnreadCounts = true } = {}
) => {
const options = SIDEBAR_SORT_OPTIONS_BY_SECTION[section] || [];
if (hasUnreadCounts) return options;
return options.filter(option => !isUnreadCountSort(option));
};
export const resolveSidebarSort = (
section,
sortBy,
{ hasUnreadCounts = true } = {}
) => {
const options = getSidebarSortOptions(section, { hasUnreadCounts });
if (options.includes(sortBy)) return sortBy;
if (!hasUnreadCounts && isUnreadCountSort(sortBy)) {
return SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC;
}
return options[0] || SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC;
};
export const normalizeSidebarSortPreferences = (preferences = {}) => {
const savedPreferences = preferences || {};
return Object.keys(DEFAULT_SIDEBAR_SORT_PREFERENCES).reduce(
(result, section) => {
const sortBy = savedPreferences[section];
result[section] = isValidSidebarSort(section, sortBy)
? sortBy
: DEFAULT_SIDEBAR_SORT_PREFERENCES[section];
return result;
},
{}
);
};
const normalizeUnreadCount = count => {
const unreadCount = Number(count);
return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0;
};
const getCreatedValue = item => {
const createdAt = item.created_at || item.createdAt;
if (typeof createdAt === 'number') return createdAt;
if (createdAt) {
const timestamp = Date.parse(createdAt);
if (Number.isFinite(timestamp)) return timestamp;
}
const id = Number(item.id);
return Number.isFinite(id) ? id : 0;
};
const getLabelValue = (item, labelKey) => {
return String(labelKey(item) || '');
};
const compareAlphabetically = (a, b, labelKey) => {
return getLabelValue(a, labelKey).localeCompare(
getLabelValue(b, labelKey),
undefined,
{
sensitivity: 'base',
}
);
};
export const sortSidebarItems = (
items,
{ sortBy, labelKey, unreadCountKey = () => 0 }
) => {
return (items || []).slice().sort((a, b) => {
if (sortBy === SIDEBAR_SORT_KEYS.CREATED_DESC) {
const createdDiff = getCreatedValue(b) - getCreatedValue(a);
if (createdDiff !== 0) return createdDiff;
return compareAlphabetically(a, b, labelKey);
}
if (sortBy === SIDEBAR_SORT_KEYS.CREATED_ASC) {
const createdDiff = getCreatedValue(a) - getCreatedValue(b);
if (createdDiff !== 0) return createdDiff;
return compareAlphabetically(a, b, labelKey);
}
if (sortBy === SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC) {
return compareAlphabetically(b, a, labelKey);
}
if (sortBy === SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC) {
const unreadCountDiff =
normalizeUnreadCount(unreadCountKey(b)) -
normalizeUnreadCount(unreadCountKey(a));
if (unreadCountDiff !== 0) return unreadCountDiff;
}
if (sortBy === SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC) {
const unreadCountDiff =
normalizeUnreadCount(unreadCountKey(a)) -
normalizeUnreadCount(unreadCountKey(b));
if (unreadCountDiff !== 0) return unreadCountDiff;
}
return compareAlphabetically(a, b, labelKey);
});
};
@@ -1,86 +0,0 @@
import { dispatchCacheRevalidations } from '../../CacheHelper/dispatchCacheRevalidations';
import InboxesAPI from 'dashboard/api/inboxes';
import LabelsAPI from 'dashboard/api/labels';
import TeamsAPI from 'dashboard/api/teams';
vi.mock('dashboard/api/inboxes', () => ({
default: {
validateCacheKey: vi.fn(),
refetchAndCommit: vi.fn(),
extractDataFromResponse: vi.fn(),
},
}));
vi.mock('dashboard/api/labels', () => ({
default: {
validateCacheKey: vi.fn(),
refetchAndCommit: vi.fn(),
extractDataFromResponse: vi.fn(),
},
}));
vi.mock('dashboard/api/teams', () => ({
default: {
validateCacheKey: vi.fn(),
refetchAndCommit: vi.fn(),
extractDataFromResponse: vi.fn(),
},
}));
describe('dispatchCacheRevalidations', () => {
let store;
beforeEach(() => {
vi.clearAllMocks();
store = { commit: vi.fn() };
});
it('refetches stale models and commits via their setMutation', async () => {
InboxesAPI.validateCacheKey.mockResolvedValue(false);
InboxesAPI.refetchAndCommit.mockResolvedValue({ data: { payload: [] } });
InboxesAPI.extractDataFromResponse.mockReturnValue([{ id: 1 }]);
LabelsAPI.validateCacheKey.mockResolvedValue(true);
await dispatchCacheRevalidations(store, {
inbox: 'inbox-key',
label: 'label-key',
});
expect(InboxesAPI.refetchAndCommit).toHaveBeenCalledWith('inbox-key');
expect(store.commit).toHaveBeenCalledWith('inboxes/SET_INBOXES', [
{ id: 1 },
]);
expect(LabelsAPI.refetchAndCommit).not.toHaveBeenCalled();
expect(store.commit).toHaveBeenCalledTimes(1);
});
it('skips models absent from the key payload', async () => {
InboxesAPI.validateCacheKey.mockResolvedValue(true);
await dispatchCacheRevalidations(store, { inbox: 'inbox-key' });
expect(TeamsAPI.validateCacheKey).not.toHaveBeenCalled();
expect(store.commit).not.toHaveBeenCalled();
});
it('treats missing keys as an empty payload', async () => {
await dispatchCacheRevalidations(store);
expect(InboxesAPI.validateCacheKey).not.toHaveBeenCalled();
expect(store.commit).not.toHaveBeenCalled();
});
it('swallows per-model errors so one failure does not block the rest', async () => {
InboxesAPI.validateCacheKey.mockResolvedValue(false);
InboxesAPI.refetchAndCommit.mockRejectedValue(new Error('network down'));
TeamsAPI.validateCacheKey.mockResolvedValue(false);
TeamsAPI.refetchAndCommit.mockResolvedValue({ data: [] });
TeamsAPI.extractDataFromResponse.mockReturnValue([{ id: 7 }]);
await dispatchCacheRevalidations(store, {
inbox: 'inbox-key',
team: 'team-key',
});
expect(store.commit).toHaveBeenCalledWith('teams/SET_TEAMS', [{ id: 7 }]);
expect(store.commit).toHaveBeenCalledTimes(1);
});
});
@@ -1,79 +0,0 @@
import paintStoresFromCache from '../../CacheHelper/paintStoresFromCache';
import { DataManager } from '../../CacheHelper/DataManager';
describe('paintStoresFromCache', () => {
const accountId = 'paint-test-account';
const originalAxios = window.axios;
let axiosMock;
let dm;
let storeMock;
beforeEach(async () => {
axiosMock = {
get: vi.fn(),
};
window.axios = axiosMock;
storeMock = {
commit: vi.fn(),
dispatch: vi.fn(),
};
dm = new DataManager(accountId);
await dm.initDb();
});
afterEach(async () => {
const tx = dm.db.transaction(
[...dm.modelsToSync, 'cache-keys'],
'readwrite'
);
[...dm.modelsToSync, 'cache-keys'].forEach(name => {
tx.objectStore(name).clear();
});
await tx.done;
window.axios = originalAxios;
});
it('does nothing when IDB is empty (first ever load)', async () => {
await paintStoresFromCache(storeMock, accountId);
expect(storeMock.commit).not.toHaveBeenCalled();
expect(storeMock.dispatch).not.toHaveBeenCalled();
});
it('seeds Vuex from IDB without any network interaction', async () => {
await dm.push({
modelName: 'inbox',
data: [{ id: 1, name: 'Support' }],
});
await dm.push({
modelName: 'label',
data: [{ id: 9, title: 'Bug' }],
});
await paintStoresFromCache(storeMock, accountId);
expect(storeMock.commit).toHaveBeenCalledWith('inboxes/SET_INBOXES', [
{ id: 1, name: 'Support' },
]);
expect(storeMock.commit).toHaveBeenCalledWith('labels/SET_LABELS', [
{ id: 9, title: 'Bug' },
]);
expect(axiosMock.get).not.toHaveBeenCalled();
expect(storeMock.dispatch).not.toHaveBeenCalled();
});
it('seeds teams via SET_TEAMS', async () => {
await dm.push({
modelName: 'team',
data: [{ id: 1, name: 'Sales' }],
});
await paintStoresFromCache(storeMock, accountId);
expect(storeMock.commit).toHaveBeenCalledWith('teams/SET_TEAMS', [
{ id: 1, name: 'Sales' },
]);
});
});
@@ -253,6 +253,27 @@ describe('ReconnectService', () => {
});
});
describe('revalidateCaches', () => {
it('should dispatch revalidate actions for labels, inboxes, and teams', async () => {
storeMock.dispatch.mockResolvedValueOnce({
label: 'labelKey',
inbox: 'inboxKey',
team: 'teamKey',
});
await reconnectService.revalidateCaches();
expect(storeMock.dispatch).toHaveBeenCalledWith('accounts/getCacheKeys');
expect(storeMock.dispatch).toHaveBeenCalledWith('labels/revalidate', {
newKey: 'labelKey',
});
expect(storeMock.dispatch).toHaveBeenCalledWith('inboxes/revalidate', {
newKey: 'inboxKey',
});
expect(storeMock.dispatch).toHaveBeenCalledWith('teams/revalidate', {
newKey: 'teamKey',
});
});
});
describe('handleRouteSpecificFetch', () => {
it('should fetch conversations and messages if current route is a conversation route', async () => {
isAConversationRoute.mockReturnValue(true);
@@ -314,10 +335,12 @@ describe('ReconnectService', () => {
});
describe('onReconnect', () => {
it('should handle route-specific fetch and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => {
it('should handle route-specific fetch, revalidate caches, and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => {
reconnectService.handleRouteSpecificFetch = vi.fn();
reconnectService.revalidateCaches = vi.fn();
await reconnectService.onReconnect();
expect(reconnectService.handleRouteSpecificFetch).toHaveBeenCalled();
expect(reconnectService.revalidateCaches).toHaveBeenCalled();
expect(emitter.emit).toHaveBeenCalledWith(
BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED
);
@@ -0,0 +1,205 @@
import {
DEFAULT_SIDEBAR_SORT_PREFERENCES,
SIDEBAR_SORT_KEYS,
SIDEBAR_SORT_SECTIONS,
getSidebarSortOptions,
normalizeSidebarSortPreferences,
resolveSidebarSort,
sortSidebarItems,
} from '../sidebarSort';
const items = [
{
id: 1,
name: 'Billing',
created_at: '2024-01-01T00:00:00.000Z',
},
{
id: 3,
name: 'Accounts',
created_at: '2024-03-01T00:00:00.000Z',
},
{
id: 2,
name: 'Support',
created_at: '2024-02-01T00:00:00.000Z',
},
];
describe('#sortSidebarItems', () => {
it('sorts by created date descending', () => {
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.CREATED_DESC,
labelKey: item => item.name,
});
expect(sortedItems.map(item => item.name)).toEqual([
'Accounts',
'Support',
'Billing',
]);
});
it('sorts by created date ascending', () => {
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.CREATED_ASC,
labelKey: item => item.name,
});
expect(sortedItems.map(item => item.name)).toEqual([
'Billing',
'Support',
'Accounts',
]);
});
it('falls back to id when created date is not present', () => {
const sortedItems = sortSidebarItems(
items.map(({ created_at: _createdAt, ...item }) => item),
{
sortBy: SIDEBAR_SORT_KEYS.CREATED_DESC,
labelKey: item => item.name,
}
);
expect(sortedItems.map(item => item.id)).toEqual([3, 2, 1]);
});
it('sorts alphabetically from A to Z', () => {
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
labelKey: item => item.name,
});
expect(sortedItems.map(item => item.name)).toEqual([
'Accounts',
'Billing',
'Support',
]);
});
it('sorts alphabetically from Z to A', () => {
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
labelKey: item => item.name,
});
expect(sortedItems.map(item => item.name)).toEqual([
'Support',
'Billing',
'Accounts',
]);
});
it('sorts by unread count descending and falls back to alphabetical order', () => {
const unreadCounts = {
1: 3,
2: 3,
3: 7,
};
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
labelKey: item => item.name,
unreadCountKey: item => unreadCounts[item.id],
});
expect(sortedItems.map(item => item.name)).toEqual([
'Accounts',
'Billing',
'Support',
]);
});
it('sorts by unread count ascending and falls back to alphabetical order', () => {
const unreadCounts = {
1: 3,
2: 3,
3: 7,
};
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
labelKey: item => item.name,
unreadCountKey: item => unreadCounts[item.id],
});
expect(sortedItems.map(item => item.name)).toEqual([
'Billing',
'Support',
'Accounts',
]);
});
});
describe('#normalizeSidebarSortPreferences', () => {
it('keeps valid preferences', () => {
const preferences = normalizeSidebarSortPreferences({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.CREATED_ASC,
});
expect(preferences).toEqual({
...DEFAULT_SIDEBAR_SORT_PREFERENCES,
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.CREATED_ASC,
});
});
it('falls back to defaults for unsupported preferences', () => {
const preferences = normalizeSidebarSortPreferences({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
});
expect(preferences).toEqual(DEFAULT_SIDEBAR_SORT_PREFERENCES);
});
it('falls back to defaults when stored preferences are null', () => {
expect(normalizeSidebarSortPreferences(null)).toEqual(
DEFAULT_SIDEBAR_SORT_PREFERENCES
);
});
});
describe('#getSidebarSortOptions', () => {
it('keeps unread count options when unread counts are enabled', () => {
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.TEAMS, {
hasUnreadCounts: true,
});
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
});
it('removes unread count options when unread counts are disabled', () => {
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.TEAMS, {
hasUnreadCounts: false,
});
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
expect(options).toContain(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
});
describe('#resolveSidebarSort', () => {
it('keeps unread count sort when unread counts are enabled', () => {
const sortBy = resolveSidebarSort(
SIDEBAR_SORT_SECTIONS.TEAMS,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
{ hasUnreadCounts: true }
);
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
});
it('falls back to alphabetical sort when unread counts are disabled', () => {
const sortBy = resolveSidebarSort(
SIDEBAR_SORT_SECTIONS.TEAMS,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
{ hasUnreadCounts: false }
);
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
});
@@ -79,6 +79,9 @@
},
"priority_desc_created_at_asc": {
"TEXT": "Priority: Highest first, Created: Oldest first"
},
"unread": {
"TEXT": "Unread Count: Highest first"
}
},
"ATTACHMENTS": {
@@ -25,6 +25,10 @@
"ACTIONS": {
"CREATE": "Add company"
},
"SELECTOR": {
"PLACEHOLDER": "Select company",
"CREATE_OPTION": "Add \"{name}\""
},
"CREATE": {
"TITLE": "Add company details",
"ACTIONS": {
@@ -511,6 +511,7 @@
"ATTRIBUTES": "Attributes",
"HISTORY": "History",
"NOTES": "Notes",
"MEDIA": "Media",
"MERGE": "Merge"
},
"HISTORY": {
@@ -401,7 +401,8 @@
"VIEW_ALL": "View all",
"SHOW_LESS": "Show less",
"MORE_COUNT": "+{count}",
"UNTITLED_FILE": "Untitled file"
"UNTITLED_FILE": "Untitled file",
"JUMP_TO_MESSAGE": "Jump to message"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
@@ -3,5 +3,33 @@
"PLACEHOLDER": "Search emojis",
"NOT_FOUND": "No emoji match your search",
"REMOVE": "Remove"
},
"EMOJI_ICON_PICKER": {
"TABS": {
"ICONS": "Icons",
"EMOJIS": "Emojis"
},
"SEARCH_EMOJI": "Search emoji…",
"SEARCH_ICON": "Search icons…",
"FREQUENTLY_USED": "Frequently used",
"NO_EMOJI": "No emoji match your search",
"NO_ICON": "No icons match your search",
"REMOVE": "Remove",
"STYLE": {
"OUTLINE": "Outline icons",
"FILLED": "Filled icons"
},
"COLORS": {
"SLATE": "Slate",
"RED": "Red",
"ORANGE": "Orange",
"AMBER": "Amber",
"GREEN": "Green",
"TEAL": "Teal",
"BLUE": "Blue",
"INDIGO": "Indigo",
"VIOLET": "Violet",
"PINK": "Pink"
}
}
}
@@ -653,6 +653,10 @@
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
},
"INBOUND": {
"LABEL": "Allow incoming calls",
"DESCRIPTION": "Let customers call this number. When turned off, incoming calls are declined automatically — agents aren't notified and no conversation is created. Agents can still place outgoing calls."
}
},
"WHATSAPP_CALLING": {
@@ -40,6 +40,7 @@ import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
import onboarding from './onboarding.json';
import sessionLimit from './sessionLimit.json';
import yearInReview from './yearInReview.json';
export default {
@@ -85,5 +86,6 @@ export default {
...contentTemplates,
...mfa,
...onboarding,
...sessionLimit,
...yearInReview,
};
@@ -0,0 +1,12 @@
{
"SESSION_LIMIT": {
"TITLE": "Active session limit reached",
"DESCRIPTION": "You have reached your limit of active sessions. Please end a session before logging in.",
"END": "End",
"END_ALL": "End all sessions",
"LOG_IN": "Log in",
"CANCEL": "Back to login",
"UNKNOWN_DEVICE": "Unknown device",
"STARTED": "Started"
}
}
@@ -86,6 +86,17 @@
"NOTE": "Manage additional security features for your account.",
"MFA_BUTTON": "Manage Two-Factor Authentication"
},
"SESSIONS_SECTION": {
"TITLE": "Active Sessions",
"NOTE": "These are the devices currently logged in to your account.",
"CURRENT": "Current session",
"REVOKE": "Revoke",
"REVOKE_SUCCESS": "Session revoked successfully",
"REVOKE_ERROR": "Unable to revoke session. Please try again.",
"FETCH_ERROR": "Unable to fetch sessions. Please try again.",
"LAST_ACTIVE": "Last active",
"UNKNOWN_DEVICE": "Unknown device"
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration",
@@ -379,6 +390,21 @@
"INFO_TEXT": "Let the system automatically mark you offline when you aren't using the app or dashboard.",
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
},
"SORT_TOOLTIP": "Sort",
"SORT_BY": "Sort by",
"SORT_GROUPS": {
"CREATED": "Sort by date of creation",
"ALPHABETICAL": "Sort in alphabetical order",
"UNREAD_COUNT": "Sort by unread count"
},
"SORT_OPTIONS": {
"CREATED_DESC": "Newest first",
"CREATED_ASC": "Oldest first",
"ALPHABETICAL_ASC": "Ascending (A - Z)",
"ALPHABETICAL_DESC": "Descending (Z - A)",
"UNREAD_COUNT_DESC": "Highest first",
"UNREAD_COUNT_ASC": "Lowest first"
},
"DOCS": "Read docs",
"SECURITY": "Security",
"CAPTAIN_AI": "Captain",
@@ -126,7 +126,7 @@ const showCompany = companyId => {
};
const openCreateCompanyDialog = () => {
createCompanyDialogRef.value?.dialogRef.open();
createCompanyDialogRef.value?.open();
};
const createCompany = async company => {
@@ -11,6 +11,7 @@ import ContactDetails from 'dashboard/components-next/Contacts/Pages/ContactDeta
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import ContactNotes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue';
import ContactHistory from 'dashboard/components-next/Contacts/ContactsSidebar/ContactHistory.vue';
import ContactMedia from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMedia.vue';
import ContactMerge from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue';
import ContactCustomAttributes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue';
@@ -40,6 +41,7 @@ const CONTACT_TABS_OPTIONS = [
{ key: 'ATTRIBUTES', value: 'attributes' },
{ key: 'HISTORY', value: 'history' },
{ key: 'NOTES', value: 'notes' },
{ key: 'MEDIA', value: 'media' },
{ key: 'MERGE', value: 'merge' },
];
@@ -149,8 +151,8 @@ onMounted(() => {
:selected-contact="selectedContact"
@go-to-contacts-list="goToContactsList"
/>
<template #sidebar>
<div class="px-6">
<template #sidebarHeader>
<div class="px-6 pt-6 pb-3">
<TabBar
:tabs="tabs"
:initial-active-tab="activeTabIndex"
@@ -158,6 +160,8 @@ onMounted(() => {
@tab-changed="handleTabChange"
/>
</div>
</template>
<template #sidebar>
<div
v-if="isFetchingItem"
class="flex items-center justify-center py-10 text-n-slate-11"
@@ -171,6 +175,7 @@ onMounted(() => {
/>
<ContactNotes v-if="activeTab === 'notes'" />
<ContactHistory v-if="activeTab === 'history'" />
<ContactMedia v-if="activeTab === 'media'" />
<ContactMerge
v-if="activeTab === 'merge'"
ref="contactMergeRef"
@@ -2,413 +2,70 @@
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { formatBytes } from 'shared/helpers/FileHelper';
import {
dynamicTime,
formatDuration,
shortTimestamp,
} from 'shared/helpers/timeHelper';
import { downloadFile } from '@chatwoot/utils';
import {
ATTACHMENT_TYPES,
MEDIA_TYPES,
NON_FILE_TYPES,
} from 'dashboard/components-next/message/constants';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
import Icon from 'next/icon/Icon.vue';
import FileIcon from 'next/icon/FileIcon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Media from 'dashboard/components-next/SharedAttachments/Media.vue';
import Files from 'dashboard/components-next/SharedAttachments/Files.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const MEDIA_PEEK_LIMIT = 6;
const FILES_PEEK_LIMIT = 3;
const { t } = useI18n();
const allAttachments = useMapGetter('getSelectedChatAttachments');
const attachmentsLoaded = useMapGetter('getSelectedChatAttachmentsLoaded');
const sortedAttachments = computed(() =>
[...allAttachments.value].sort(
(a, b) => (b.created_at || 0) - (a.created_at || 0)
)
);
const { t } = useI18n();
const mediaAttachments = computed(() =>
sortedAttachments.value.filter(a => MEDIA_TYPES.includes(a.file_type))
allAttachments.value
.filter(a => MEDIA_TYPES.includes(a.file_type) && a.data_url)
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
);
const fileAttachments = computed(() =>
sortedAttachments.value.filter(
a => !MEDIA_TYPES.includes(a.file_type) && a.data_url
const hasContent = computed(() =>
allAttachments.value.some(
a => a.data_url && !NON_FILE_TYPES.includes(a.file_type)
)
);
const showAllMedia = ref(false);
const showAllFiles = ref(false);
const visibleMedia = computed(() =>
showAllMedia.value
? mediaAttachments.value
: mediaAttachments.value.slice(0, MEDIA_PEEK_LIMIT)
);
const visibleFiles = computed(() =>
showAllFiles.value
? fileAttachments.value
: fileAttachments.value.slice(0, FILES_PEEK_LIMIT)
);
const mediaOverflow = computed(() => {
const total = mediaAttachments.value.length;
return total > MEDIA_PEEK_LIMIT ? total - (MEDIA_PEEK_LIMIT - 1) : 0;
});
const showGallery = ref(false);
const selectedAttachment = ref(null);
const downloadingId = ref(null);
const fileNameFromUrl = url => {
if (!url) return '';
const name = url.split('/').pop();
return name ? decodeURIComponent(name) : '';
};
const onDownloadFile = async attachment => {
const { id, file_type: type, data_url: url, extension } = attachment;
try {
downloadingId.value = id;
await downloadFile({ url, type, extension });
} catch (error) {
useAlert(t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD_ERROR'));
} finally {
downloadingId.value = null;
}
};
const isVideoType = type =>
[ATTACHMENT_TYPES.VIDEO, ATTACHMENT_TYPES.IG_REEL].includes(type);
const isAudioType = type => type === ATTACHMENT_TYPES.AUDIO;
const isPlayableType = type => isVideoType(type) || isAudioType(type);
const durations = ref({});
const onLoadedMetadata = (attachment, event) => {
const seconds = event.target?.duration;
if (Number.isFinite(seconds) && seconds > 0) {
durations.value[attachment.id] = seconds;
}
};
const displayDuration = attachment => {
const seconds = durations.value[attachment.id];
return seconds ? formatDuration(Math.round(seconds)) : '';
};
const isOverflowTile = index =>
!showAllMedia.value &&
mediaOverflow.value > 0 &&
index === MEDIA_PEEK_LIMIT - 1;
const onTileActivate = (attachment, index) => {
if (isOverflowTile(index)) {
showAllMedia.value = true;
return;
}
const onMediaSelect = attachment => {
selectedAttachment.value = attachment;
showGallery.value = true;
};
const failedThumbs = ref(new Set());
const failedPreviews = ref(new Set());
const imagePreviewSrc = ({
id,
file_type: type,
thumb_url: thumbUrl,
data_url: dataUrl,
}) => {
const canUseThumb = thumbUrl && !failedThumbs.value.has(id);
if (type === ATTACHMENT_TYPES.IMAGE) return canUseThumb ? thumbUrl : dataUrl;
if (isVideoType(type)) return canUseThumb ? thumbUrl : null;
return null;
};
const onPreviewError = ({
id,
file_type: type,
thumb_url: thumbUrl,
data_url: dataUrl,
}) => {
const canRetryWithFull = thumbUrl && !failedThumbs.value.has(id) && dataUrl;
if (
canRetryWithFull &&
(type === ATTACHMENT_TYPES.IMAGE || isVideoType(type))
) {
failedThumbs.value.add(id);
return;
const onFileSelect = attachment => {
if (attachment.data_url) {
window.open(attachment.data_url, '_blank', 'noopener,noreferrer');
}
failedPreviews.value.add(id);
};
const hasPreview = attachment =>
!!imagePreviewSrc(attachment) && !failedPreviews.value.has(attachment.id);
const hasVideoPreview = attachment =>
isVideoType(attachment.file_type) &&
attachment.data_url &&
!failedPreviews.value.has(attachment.id);
const fallbackIcon = type => {
if (type === ATTACHMENT_TYPES.AUDIO) return 'i-lucide-music';
if (isVideoType(type)) return 'i-lucide-video';
return 'i-lucide-image';
};
const displayName = attachment =>
fileNameFromUrl(attachment.data_url) ||
t('CONVERSATION_SIDEBAR.SHARED_FILES.UNTITLED_FILE');
const displaySize = attachment => {
if (attachment.file_size) return formatBytes(attachment.file_size);
if (attachment.extension) return attachment.extension.toUpperCase();
return '—';
};
const displayTime = attachment => {
if (!attachment.created_at) return '';
return shortTimestamp(dynamicTime(attachment.created_at), true);
};
</script>
<template>
<div class="flex flex-col gap-5 p-2">
<div class="p-2">
<div v-if="!attachmentsLoaded" class="flex justify-center p-3">
<Spinner class="size-5" />
</div>
<p
v-else-if="!mediaAttachments.length && !fileAttachments.length"
class="p-3 text-sm text-center text-n-slate-11"
>
<p v-else-if="!hasContent" class="p-3 text-sm text-center text-n-slate-11">
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
</p>
<section v-if="mediaAttachments.length" class="flex flex-col gap-2.5">
<header class="flex items-center justify-between px-0.5">
<h4
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
>
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.MEDIA_HEADING') }}
<span
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
>
{{ mediaAttachments.length }}
</span>
</h4>
<NextButton
v-if="mediaOverflow > 0"
ghost
slate
xs
trailing-icon
:icon="
showAllMedia ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'
"
:label="
showAllMedia
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
"
@click="showAllMedia = !showAllMedia"
/>
</header>
<div class="grid grid-cols-3 gap-2">
<div
v-for="(attachment, index) in visibleMedia"
:key="attachment.id"
role="button"
tabindex="0"
class="relative w-full overflow-hidden transition-all duration-200 rounded-lg cursor-pointer aspect-square bg-n-slate-3 shadow-sm hover:shadow-md hover:-translate-y-px group focus:outline-none focus-visible:ring-2 focus-visible:ring-n-blue-9"
@click="onTileActivate(attachment, index)"
@keydown.enter="onTileActivate(attachment, index)"
@keydown.space.prevent="onTileActivate(attachment, index)"
>
<template v-if="!isOverflowTile(index)">
<img
v-if="hasPreview(attachment)"
:src="imagePreviewSrc(attachment)"
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110"
loading="lazy"
:alt="fileNameFromUrl(attachment.data_url)"
@error="onPreviewError(attachment)"
/>
<video
v-else-if="hasVideoPreview(attachment)"
:src="`${attachment.data_url}#t=0.1`"
preload="metadata"
muted
playsinline
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110 pointer-events-none"
@loadedmetadata="onLoadedMetadata(attachment, $event)"
@error="onPreviewError(attachment)"
/>
<div
v-else
class="flex items-center justify-center w-full h-full bg-gradient-to-br from-n-slate-3 to-n-slate-4"
>
<Icon
:icon="fallbackIcon(attachment.file_type)"
class="size-6 text-n-slate-11"
/>
</div>
<audio
v-if="isAudioType(attachment.file_type) && attachment.data_url"
:src="attachment.data_url"
preload="metadata"
class="hidden"
@loadedmetadata="onLoadedMetadata(attachment, $event)"
/>
<div
class="absolute inset-0 transition-opacity duration-200 opacity-0 pointer-events-none group-hover:opacity-100 bg-gradient-to-t from-black/40 via-transparent to-transparent"
/>
<div
v-if="hasVideoPreview(attachment)"
class="absolute inset-0 flex items-center justify-center pointer-events-none bg-gradient-to-t from-black/30 via-transparent to-transparent"
>
<div
class="flex items-center justify-center rounded-full size-7 bg-white/95 shadow-md"
>
<Icon
icon="i-lucide-play"
class="ms-0.5 size-3.5 text-n-black"
/>
</div>
</div>
<span
v-if="
isPlayableType(attachment.file_type) &&
displayDuration(attachment)
"
class="absolute text-xxs font-medium tabular-nums transition-opacity bottom-1.5 ltr:right-1.5 rtl:left-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-0"
>
{{ displayDuration(attachment) }}
</span>
<span
v-if="displayTime(attachment)"
class="absolute text-xxs font-medium transition-opacity opacity-0 bottom-1.5 ltr:left-1.5 rtl:right-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-100"
>
{{ displayTime(attachment) }}
</span>
<button
type="button"
class="absolute flex items-center justify-center !p-px transition-all rounded-full opacity-0 bottom-1.5 ltr:right-1.5 rtl:left-1.5 size-6 bg-white/95 shadow-md group-hover:opacity-100 hover:bg-white disabled:opacity-50"
:disabled="downloadingId === attachment.id"
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
@click.stop="onDownloadFile(attachment)"
@keydown.enter.stop
@keydown.space.stop
>
<Icon icon="i-lucide-download" class="size-3 text-n-black" />
</button>
</template>
<div
v-if="isOverflowTile(index)"
class="absolute inset-0 flex items-center justify-center bg-n-slate-5"
>
<span class="text-base font-semibold text-n-slate-12">
{{
t('CONVERSATION_SIDEBAR.SHARED_FILES.MORE_COUNT', {
count: mediaOverflow,
})
}}
</span>
</div>
</div>
</div>
</section>
<section v-if="fileAttachments.length" class="flex flex-col gap-2.5">
<header class="flex items-center justify-between px-0.5">
<h4
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
>
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.FILES_HEADING') }}
<span
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
>
{{ fileAttachments.length }}
</span>
</h4>
<NextButton
v-if="fileAttachments.length > FILES_PEEK_LIMIT"
ghost
slate
xs
trailing-icon
:icon="
showAllFiles ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'
"
:label="
showAllFiles
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
"
@click="showAllFiles = !showAllFiles"
/>
</header>
<ul class="flex flex-col gap-0.5">
<li
v-for="attachment in visibleFiles"
:key="attachment.id"
class="flex items-center gap-3 px-2 py-2 transition-colors rounded-lg hover:bg-n-slate-3 group"
>
<div
class="flex items-center justify-center rounded-lg size-9 shrink-0 bg-gradient-to-br from-n-slate-3 to-n-slate-4 ring-1 ring-inset ring-n-slate-4/40"
>
<FileIcon
:file-type="attachment.extension?.toLowerCase() || ''"
class="size-4 text-n-slate-11"
/>
</div>
<a
:href="attachment.data_url"
target="_blank"
rel="noopener noreferrer"
class="flex-1 min-w-0"
:title="displayName(attachment)"
>
<p class="text-sm font-medium truncate text-n-slate-12 mb-1">
{{ displayName(attachment) }}
</p>
<p class="text-xs text-n-slate-11">
{{ displaySize(attachment) }}
<template v-if="displayTime(attachment)">
· {{ displayTime(attachment) }}
</template>
</p>
</a>
<NextButton
ghost
slate
sm
icon="i-lucide-download"
class="opacity-0 group-hover:opacity-100"
:is-loading="downloadingId === attachment.id"
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
@click="onDownloadFile(attachment)"
/>
</li>
</ul>
</section>
<div v-else class="flex flex-col gap-5">
<Media
:attachments="allAttachments"
:peek-limit="MEDIA_PEEK_LIMIT"
@select="onMediaSelect"
/>
<Files
:attachments="allAttachments"
:peek-limit="FILES_PEEK_LIMIT"
@select="onFileSelect"
/>
</div>
<GalleryView
v-if="showGallery && selectedAttachment"
v-model:show="showGallery"
@@ -4,6 +4,7 @@ import InboxReconnectionRequired from '../components/InboxReconnectionRequired.v
import { useAlert } from 'dashboard/composables';
import { loadScript } from 'dashboard/helper/DOMHelpers';
import { buildFacebookLoginScopes } from 'dashboard/helper/facebookScopes';
import * as Sentry from '@sentry/vue';
export default {
@@ -20,6 +21,11 @@ export default {
inboxId() {
return this.inbox.id;
},
facebookLoginScopes() {
return buildFacebookLoginScopes({
includeInstagramScopes: !!this.inbox.instagram_id,
});
},
},
mounted() {
window.fbAsyncInit = this.runFBInit;
@@ -77,8 +83,7 @@ export default {
}
},
{
scope:
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages',
scope: this.facebookLoginScopes,
auth_type: 'reauthorize',
}
);

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