Compare commits

...
Author SHA1 Message Date
Shivam Mishra 1b31b8b93c fix: spacing 2025-06-17 13:22:59 +05:30
Shivam Mishra 4502ed6213 feat: allow disabling private note 2025-06-17 13:17:06 +05:30
Shivam Mishra f0e376c0d9 feat: better versioning 2025-06-12 14:43:11 +05:30
Shivam Mishra 0be8af9c3d feat: disable context menu 2025-06-12 14:37:48 +05:30
Shivam Mishra f6c4224400 docs: development instructions 2025-06-12 14:36:12 +05:30
Shivam Mishra 8f47496373 feat: update package scripts 2025-06-12 14:26:04 +05:30
Shivam Mishra 0990e33860 fix: enter to send 2025-06-12 14:24:08 +05:30
Shivam Mishra c93c6aed5f fix: realtime events not working 2025-06-12 14:13:39 +05:30
Shivam Mishra 0381aa188a fix: clearMessage error 2025-06-12 13:07:32 +05:30
Shivam Mishra 90e212a81a feat: add copy mode 2025-06-12 13:06:24 +05:30
Shivam Mishra 76e1643ccf feat: expose disable-editor 2025-06-12 09:50:50 +05:30
Shivam Mishra 0b56add844 fix: version 2025-06-11 18:28:04 +05:30
Shivam Mishra e9b99a5811 fix: uploads 2025-06-11 18:09:08 +05:30
Shivam Mishra a910758fd8 fix: colors 2025-06-11 18:08:53 +05:30
Shivam Mishra aa68525b6b fix: name 2025-06-11 17:34:38 +05:30
Shivam Mishra ba1f363a28 fix: gap and text color 2025-06-11 17:34:08 +05:30
Shivam Mishra b8e64722a7 feat: correct name 2025-06-10 20:11:34 +05:30
Shivam Mishra 153d0371ff feat: update readme 2025-06-10 18:54:04 +05:30
Shivam Mishra c66f8600ec feat: disable upload 2025-06-10 18:46:06 +05:30
Shivam Mishra 38b5c61d51 feat: make tailwind work 2025-06-10 18:45:34 +05:30
Shivam Mishra 81f5c9aa12 chore: remove comment 2025-06-10 17:51:20 +05:30
Shivam Mishra 532c5c3324 chore: remove unused file 2025-06-10 17:49:34 +05:30
Shivam Mishra 2dca1eee89 refactor: simplify component loading 2025-06-10 17:49:23 +05:30
Shivam Mishra 851f622fe1 fix: exports 2025-06-10 17:35:45 +05:30
Shivam Mishra 1503ddeda4 feat: remove unused files 2025-06-10 17:32:18 +05:30
Shivam Mishra c645ba3922 feat: add store from CE options 2025-06-10 17:31:52 +05:30
Shivam Mishra 5d64079a5e feat: simplify provider and vue wrapper 2025-06-10 17:31:42 +05:30
Shivam Mishra 82e296b152 feat: add dompurify and fix i18n ijection 2025-06-10 16:31:55 +05:30
Shivam Mishra 016a8541c1 feat: use i18 and other ce options 2025-06-10 16:29:44 +05:30
Shivam Mishra d289daf6ca refactor: component cleanup 2025-06-10 16:29:33 +05:30
Shivam Mishra c8cc157683 refactor: provider 2025-06-10 16:07:35 +05:30
Shivam Mishra 3fd506c27f feat: update publish script 2025-06-10 14:50:59 +05:30
Shivam Mishra d8a1fdbce5 feat: setup vue component 2025-06-10 14:48:37 +05:30
Shivam Mishra 6840108f30 feat: add publishing pipeline 2025-06-10 13:39:57 +05:30
Shivam Mishra d7288835fa feat: vue component for PoC 2025-06-10 13:00:33 +05:30
Shivam Mishra 31e1d59d58 feat: kickstart basic react component 2025-06-10 12:40:52 +05:30
Shivam Mishra 4f55a66fba Merge branch 'develop' of github.com:chatwoot/chatwoot into feat/ui-lib 2025-06-10 10:17:19 +05:30
Sivin VargheseandGitHub 3e73c1b4bc feat: Add RTL support in public help center (#11692)
# Pull Request Template

## Description

This PR adds RTL support in public help center.

Fixes
https://linear.app/chatwoot/issue/CW-4459/support-for-rtl-in-public-help-center

## Type of change

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

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/d48a26ec80e04545addca825882b4d79?sid=aa7a6b37-33bc-4f63-b1cc-54b27a7733cf

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-06-09 15:23:04 -04:00
25f947223d feat: sanitize inbox name (#11597)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-06-09 14:46:12 +05:30
PranavandGitHub 9b43a0f72b fix: Retry job if file not found (#11683)
Removed StandardError rescue blocks and added retry_on for
ResponseBuilderJob and AudioTranscriptionJob
2025-06-05 22:53:11 -05:00
Sojan JoseandGitHub 10363e77ad chore: Add region option to Dialogflow integration (#11510)
## Summary
- support region option when configuring Dialogflow integration
- connect to region endpoint when set
- use session identification based on the region

Fixes: https://github.com/chatwoot/chatwoot/issues/4129
2025-06-05 20:01:17 -05:00
Shivam MishraandGitHub 27bce50210 fix: Incorrect date parsing in matchesFilter (#11679)
The `matchesFilter` is a utility that checks the incoming payload
against a filter and returns `true` or `false`.
For the `greater_than` and `less_than` filter specifically, the date
parsing would fail when the timestamp was a 10 digit number.

This PR solves this by adding a `coerceToDate` method that tries to
parse the given value to a Date object as correctly as possible before
comparing.

Ref: https://github.com/chatwoot/utils/pull/53
2025-06-05 19:38:56 -05:00
8bc00f707b feat(ee): Add transcription support for audio messages (#11670)
<img width="419" alt="Screenshot 2025-06-03 at 4 25 37 PM"
src="https://github.com/user-attachments/assets/4b6ddd11-9b91-4981-a571-83746cc4d40b"
/>


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

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-06-05 18:29:37 -05:00
273c277d47 feat: Add conversation delete feature (#11677)
<img width="1240" alt="Screenshot 2025-06-05 at 12 39 04 AM"
src="https://github.com/user-attachments/assets/0071cd23-38c3-4638-946e-f1fbd11ec845"
/>


## Changes

Give the admins an option to delete conversation via the context menu

- enable conversation deletion in routes and controller
- expose delete API on conversations
- add delete option in conversation context menu and integrate with card
and list
- implement store action and mutation for delete
- update i18n with new strings

fixes: https://github.com/chatwoot/chatwoot/issues/947

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-06-05 15:53:17 -05:00
4c0d096e4d feat: Add rich text support for widget welcome tagline (#11666)
# Pull Request Template

## Description

This PR adds rich text support for the widget welcome tagline, enabling
users to format the message using basic text styling (bold, italics,
links, etc.)
https://github.com/chatwoot/chatwoot/pull/11629#issuecomment-2932448975

## Type of change

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

## How Has This Been Tested?

### Screenshots
<img width="1100" alt="image"
src="https://github.com/user-attachments/assets/eef2bfd3-7bc9-4aea-b21d-078f6b29f334"
/>
<img width="448" alt="image"
src="https://github.com/user-attachments/assets/713c6b07-d0dc-4c8f-bfba-cd119a858375"
/>



## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-06-05 14:13:46 -05:00
Sojan JoseandGitHub 2f40f95f77 fix: broken link in admin account user list (#11661)
- Add a dummy link for account users page that redirect back to user
page
- move common account user form in user and account super admin pages to
a partial

fixes:  #10757
2025-06-04 21:37:05 -05:00
Sivin VargheseandGitHub ee293c3598 fix: Show default dashboard prompt for contact and articles (#11675)
# Pull Request Template

## Description

This PR fixes an issue where the copilot displayed irrelevant prompts on
the Contacts and Article pages.

Previously, conversation-specific prompts like "Summarize", "Suggest
answer", and "Rate the conversation" were shown even outside
conversations. With this update, users navigating to the Contacts or
Articles routes will now see appropriate dashboard prompts such as "High
priority conversation" and "List contacts".

## Type of change

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

## How Has This Been Tested?

### Screenshots

**Contacts Page**
<img width="1401" alt="image"
src="https://github.com/user-attachments/assets/242f42a1-d9a5-4369-bd00-5c22e977970d"
/>

**Articles page**
<img width="1401" alt="image"
src="https://github.com/user-attachments/assets/12c5575d-9c50-4530-a0c9-2f02fbf4c732"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-06-04 13:54:51 -05:00
raza-akandGitHub 513d954027 feat: Show active Contacts (#8243) 2025-06-04 21:52:13 +05:30
Shivam Mishra 312c8a1a74 fix: upload toggle 2025-06-04 16:43:57 +05:30
Shivam Mishra 79f7afaeec feat: allow disabling editor 2025-06-04 16:43:08 +05:30
Shivam Mishra 4e7ca49297 fix: menubar not empty 2025-06-04 16:40:47 +05:30
Shivam Mishra db51d2d011 fix: options 2025-06-04 16:34:58 +05:30
Shivam Mishra 6f86944758 feat: allow option to disable upload 2025-06-04 16:34:47 +05:30
Shivam Mishra d74798fcd5 feat: disable editor menu for ios 2025-06-04 16:34:26 +05:30
Shivam Mishra 394419ce95 feat: allow new confg 2025-06-04 16:09:42 +05:30
Shivam MishraandGitHub 4fe9b88642 Merge branch 'develop' into feat/ui-lib 2025-06-04 15:51:21 +05:30
e9a132a923 feat: auto resolve label option and fixes (#11541)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-06-04 14:53:24 +05:30
Sojan JoseandGitHub b70d2c0ebe chore: Improvements for codespace (#11667)
- move to vite build instead of dev for codespaces
- disable miniprofiler
2025-06-04 00:21:23 -05:00
Tanmay Deep SharmaandGitHub 623734a631 chore: mintlify docs migrate (#11641)
Remove the developer docs from this repo and move it to
https://github.com/chatwoot/docs


PR - https://github.com/chatwoot/docs/pull/542
2025-06-03 22:42:00 -05:00
PranavandGitHub b81f1bc971 fix: Reset conversation sidebar when copilot is open (#11657)
Both the Copilot and the conversation sidebar were open at the same
time, which shouldn’t be possible.
2025-06-03 13:39:18 -05:00
Sivin VargheseandGitHub ff0ad53f49 fix: Style issue with conversation header (#11655) 2025-06-03 15:21:30 +05:30
Shivam Mishra 0f8037b6fc fix: ios virtual keyboard issue on editor focus 2025-05-21 19:59:26 +05:30
Shivam Mishra 38938fe776 feat: hide name in mobile 2025-05-20 16:39:37 +05:30
Shivam Mishra 02ca4136ad fix: message attachments 2025-05-20 16:28:08 +05:30
Shivam Mishra da4049d18a feat: register UI kit and enable gallery 2025-05-20 16:07:33 +05:30
Shivam Mishra 088608abcf feat: disable elastic scrolling 2025-05-19 19:45:02 +05:30
Shivam Mishra afb3c0109b fix: list item styling 2025-05-19 19:12:08 +05:30
Shivam Mishra c54973eeb2 feat: use enter for next line in isolated shell mode 2025-05-19 19:11:07 +05:30
Shivam Mishra cb2785096e feat: ensure inbox was always there 2025-05-19 16:27:21 +05:30
Shivam Mishra 27f35a7dd8 style: if block 2025-05-19 16:11:47 +05:30
Shivam Mishra 59790e38da fix: editor loads with private note color 2025-05-19 16:10:51 +05:30
Shivam Mishra 461f1b3bf0 chore: attempt to fix CW-4353 2025-05-19 15:39:57 +05:30
Shivam Mishra 4056a144e4 feat: add spinner 2025-05-19 15:37:51 +05:30
Shivam Mishra 31681610f2 feat: disable lightbox in UI shell 2025-05-16 22:01:16 +05:30
Shivam Mishra a8b33023f8 feat: auto init if callback is not present 2025-05-16 16:55:31 +05:30
Shivam Mishra 8c5204d0c9 feat: use computed for conversation ID 2025-05-16 16:55:17 +05:30
Shivam Mishra aa5242fdb0 feat: run on callback 2025-05-16 16:35:18 +05:30
Shivam Mishra b591893486 feat: better init 2025-05-16 16:23:56 +05:30
Shivam Mishra a118bef98e feat: use init fn 2025-05-16 16:18:37 +05:30
Shivam Mishra daa54f6866 feat: disable unused options 2025-05-16 16:12:05 +05:30
Shivam Mishra c38e7af0e7 feat: allow cors for api 2025-05-16 09:48:48 +05:30
Shivam Mishra d6f862a85c feat: new lite reply box 2025-05-15 17:06:32 +05:30
Shivam Mishra 13f742fd68 feat: allow customizing reply box 2025-05-15 17:06:20 +05:30
Shivam Mishra b74e78f698 feat: add spinner 2025-05-15 16:10:27 +05:30
Shivam Mishra 5d8646a3f3 feat: smaller bundle 2025-05-14 19:48:10 +05:30
Shivam Mishra 453eba9a81 feat: conditionally show typing indicator 2025-05-14 16:21:14 +05:30
Shivam Mishra 6beb355f29 feat: add typing indicator 2025-05-14 16:01:18 +05:30
Shivam Mishra 980eca46c6 feat: start typing indicator 2025-05-14 15:53:30 +05:30
Shivam Mishra 0868a9af70 refactor: use vue 2025-05-14 14:05:58 +05:30
Shivam Mishra da6fbe8579 feat: make message sending work 2025-05-13 19:59:14 +05:30
Shivam Mishra 2c814fff99 feat: kickstart message editor\ 2025-05-13 19:52:18 +05:30
Shivam Mishra c1c44512d4 feat: remove test component 2025-05-13 19:30:01 +05:30
Shivam Mishra 1138195846 feat: include ui in tailwind content 2025-05-13 19:24:29 +05:30
Shivam Mishra 84d31b91f4 feat: add space for editor 2025-05-13 19:22:36 +05:30
Shivam Mishra 2df2459674 chore: run lint 2025-05-13 19:03:37 +05:30
Shivam Mishra 81e18aaafa feat: fetch inboxes 2025-05-13 19:00:39 +05:30
Shivam Mishra f07a5215cf feat: allow bypassing indexedb cache 2025-05-13 19:00:30 +05:30
Shivam Mishra 1899a31416 feat: infinite scrolling 2025-05-13 18:50:48 +05:30
Shivam Mishra 60f5145365 fix: test 2025-05-12 19:04:53 +05:30
Shivam Mishra e58736e746 feat: kickstart scroll 2025-05-12 18:55:36 +05:30
Shivam Mishra a9fd459cf4 feat: wait for auth 2025-05-12 17:53:27 +05:30
Shivam Mishra 7235e44a9d feat: ignore audio events for ui mode 2025-05-12 17:53:09 +05:30
Shivam Mishra ddd1fb5396 fix: auth payload 2025-05-12 17:52:59 +05:30
Shivam Mishra a97cf6e4ad feat: allow api based auth 2025-05-12 17:35:06 +05:30
Shivam Mishra 4ecc92ad6d feat: setup wss 2025-05-12 17:13:41 +05:30
Shivam Mishra ff70fbe2e4 Merge branch 'develop' of github.com:chatwoot/chatwoot into feat/ui-lib 2025-05-09 09:39:24 +05:30
Shivam Mishra 4f27f1605a feat: setup conversation list 2025-05-08 20:55:51 +05:30
Shivam Mishra e00b54ce07 feat: include dompurify 2025-05-08 19:47:35 +05:30
Shivam Mishra df20a59a6c fix: route may not be present 2025-05-08 19:47:28 +05:30
Shivam Mishra bb6f7b9fcd feat: messages 2025-05-08 19:40:10 +05:30
Shivam Mishra e28c6312e4 feat: make i18n work 2025-05-08 10:11:01 +05:30
Shivam Mishra fc119d14d9 feat: styles are working 2025-05-08 09:06:49 +05:30
Shivam Mishra 9243b6367c feat: configure i18n (try atleat) 2025-05-08 08:59:59 +05:30
Shivam Mishra 5af8881a5a feat: tester 2025-05-07 22:36:47 +05:30
Shivam Mishra 70029177da debug: patch process.env 2025-05-07 22:36:38 +05:30
Shivam Mishra 1abb04f592 feat: update ui.js 2025-05-07 22:24:27 +05:30
Shivam Mishra 355b587c2c feat: try 2025-05-07 22:13:17 +05:30
Shivam Mishra b22a903446 feat: import all styles 2025-05-07 22:03:01 +05:30
Shivam Mishra 2d5993dfe3 fix: import name 2025-05-07 19:32:22 +05:30
Shivam Mishra db50f42054 feat: kickstart ui 2025-05-07 19:17:37 +05:30
Shivam Mishra f8afc1509f feat: kickstart ui 2025-05-07 19:12:44 +05:30
147 changed files with 3908 additions and 687 deletions
+3
View File
@@ -94,3 +94,6 @@ yarn-debug.log*
.vscode
.claude/settings.local.json
.cursor
# react component
dist
+7 -1
View File
@@ -44,6 +44,12 @@ force_run:
rm -f tmp/pids/*.pid
overmind start -f Procfile.dev
force_run_tunnel:
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.tunnel
debug:
overmind connect backend
@@ -53,4 +59,4 @@ debug_worker:
docker:
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker
+4
View File
@@ -0,0 +1,4 @@
backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
vite: bin/vite build --watch
@@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update]
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
def index
@contacts_count = resolved_contacts.count
@@ -56,7 +56,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
.get_available_contact_ids(Current.account.id))
@contacts_count = contacts.count
@contacts = contacts.page(@current_page)
@contacts = fetch_contacts(contacts)
end
def show; end
@@ -124,6 +124,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.save!
end
def destroy
authorize @conversation, :destroy?
::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
head :ok
end
private
def permitted_update_params
@@ -92,7 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting)
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
end
def check_signup_enabled
@@ -71,6 +71,7 @@ class OauthCallbackController < ApplicationController
def create_channel_with_inbox
ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account)
account.inboxes.create!(
account: account,
channel: channel_email,
@@ -2,6 +2,13 @@ class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
# Since account/user page - account user role attribute links to the show page
# Handle with a redirect to the user show page
def show
redirect_to super_admin_user_path(requested_resource.user)
end
def create
resource = resource_class.new(resource_params)
authorize_resource(resource)
@@ -15,6 +15,12 @@ class ApiClient {
// eslint-disable-next-line class-methods-use-this
get accountIdFromRoute() {
// eslint-disable-next-line no-underscore-dangle
if (window.__WOOT_ACCOUNT_ID__) {
// eslint-disable-next-line no-underscore-dangle
return window.__WOOT_ACCOUNT_ID__;
}
const isInsideAccountScopedURLs =
window.location.pathname.includes('/app/accounts');
+12 -1
View File
@@ -8,7 +8,14 @@ import {
} from '../store/utils/api';
export default {
validityCheck() {
async validityCheck() {
if (this.hasAuthToken()) {
const urlData = endPoints('profileUpdate');
const response = await axios.get(urlData.url);
// to match the response signature of the validityCheck endpoint
return Promise.resolve({ data: { payload: response } });
}
const urlData = endPoints('validityCheck');
return axios.get(urlData.url);
},
@@ -31,6 +38,10 @@ export default {
hasAuthCookie() {
return !!Cookies.get('cw_d_session_info');
},
hasAuthToken() {
// eslint-disable-next-line no-underscore-dangle
return !!window.__WOOT_ACCESS_TOKEN__;
},
getAuthData() {
if (this.hasAuthCookie()) {
const savedAuthInfo = Cookies.get('cw_d_session_info');
+5
View File
@@ -61,6 +61,11 @@ class ContactAPI extends ApiClient {
return axios.get(requestURL);
}
active(page = 1, sortAttr = 'name') {
let requestURL = `${this.url}/active?${buildContactParams(page, sortAttr)}`;
return axios.get(requestURL);
}
// eslint-disable-next-line default-param-last
filter(page = 1, sortAttr = 'name', queryPayload) {
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;
@@ -137,6 +137,10 @@ class ConversationApi extends ApiClient {
getInboxAssistant(conversationId) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
}
delete(conversationId) {
return axios.delete(`${this.url}/${conversationId}`);
}
}
export default new ConversationApi();
+8 -1
View File
@@ -1,7 +1,14 @@
/* global axios */
import CacheEnabledApiClient from './CacheEnabledApiClient';
// import ApiClient from './ApiClient';
import ApiClient from './ApiClient';
class Inboxes extends CacheEnabledApiClient {
// eslint-disable-next-line no-underscore-dangle
const BaseClass = window.__WOOT_ISOLATED_SHELL__
? ApiClient
: CacheEnabledApiClient;
class Inboxes extends BaseClass {
constructor() {
super('inboxes', { accountScoped: true });
}
@@ -17,6 +17,7 @@ const props = defineProps({
additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' },
availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false },
});
@@ -92,7 +93,13 @@ const onClickViewDetails = () => emit('showContact', props.id);
<template>
<CardLayout :key="id" layout="row">
<div class="flex items-center justify-start flex-1 gap-4">
<Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
<Avatar
:name="name"
:src="thumbnail"
:size="48"
:status="availabilityStatus"
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
@@ -7,42 +7,16 @@ import ContactMoreActions from './components/ContactMoreActions.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
defineProps({
showSearch: {
type: Boolean,
default: true,
},
searchValue: {
type: String,
default: '',
},
headerTitle: {
type: String,
required: true,
},
buttonLabel: {
type: String,
default: '',
},
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
isSegmentsView: {
type: Boolean,
default: false,
},
hasActiveFilters: {
type: Boolean,
default: false,
},
isLabelView: {
type: Boolean,
default: false,
},
showSearch: { type: Boolean, default: true },
searchValue: { type: String, default: '' },
headerTitle: { type: String, required: true },
buttonLabel: { type: String, default: '' },
activeSort: { type: String, default: 'last_activity_at' },
activeOrdering: { type: String, default: '' },
isSegmentsView: { type: Boolean, default: false },
hasActiveFilters: { type: Boolean, default: false },
isLabelView: { type: Boolean, default: false },
isActiveView: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -85,7 +59,7 @@ const emit = defineEmits([
</Input>
</div>
<div class="flex items-center gap-2">
<div v-if="!isLabelView" class="relative">
<div v-if="!isLabelView && !isActiveView" class="relative">
<Button
id="toggleContactsFilterButton"
:icon="
@@ -105,7 +79,12 @@ const emit = defineEmits([
<slot name="filter" />
</div>
<Button
v-if="hasActiveFilters && !isSegmentsView && !isLabelView"
v-if="
hasActiveFilters &&
!isSegmentsView &&
!isLabelView &&
!isActiveView
"
icon="i-lucide-save"
color="slate"
size="sm"
@@ -113,7 +92,7 @@ const emit = defineEmits([
@click="emit('createSegment')"
/>
<Button
v-if="isSegmentsView && !isLabelView"
v-if="isSegmentsView && !isLabelView && !isActiveView"
icon="i-lucide-trash"
color="slate"
size="sm"
@@ -36,6 +36,7 @@ const props = defineProps({
activeSegment: { type: Object, default: null },
hasAppliedFilters: { type: Boolean, default: false },
isLabelView: { type: Boolean, default: false },
isActiveView: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -277,6 +278,7 @@ defineExpose({
:header-title="headerTitle"
:is-segments-view="hasActiveSegments"
:is-label-view="isLabelView"
:is-active-view="isActiveView"
:has-active-filters="hasAppliedFilters"
:button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
@search="emit('search', $event)"
@@ -6,7 +6,7 @@ import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/Contact
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
defineProps({
const props = defineProps({
searchValue: { type: String, default: '' },
headerTitle: { type: String, default: '' },
showPaginationFooter: { type: Boolean, default: true },
@@ -37,10 +37,23 @@ const isNotSegmentView = computed(() => {
return route.name !== 'contacts_dashboard_segments_index';
});
const isActiveView = computed(() => {
return route.name === 'contacts_dashboard_active';
});
const isLabelView = computed(
() => route.name === 'contacts_dashboard_labels_index'
);
const showActiveFiltersPreview = computed(() => {
return (
(props.hasAppliedFilters || !isNotSegmentView.value) &&
!props.isFetchingList &&
!isLabelView.value &&
!isActiveView.value
);
});
const updateCurrentPage = page => {
emit('update:currentPage', page);
};
@@ -57,7 +70,7 @@ const openFilter = () => {
<div class="flex flex-col w-full h-full transition-all duration-300">
<ContactListHeaderWrapper
ref="contactListHeaderWrapper"
:show-search="isNotSegmentView"
:show-search="isNotSegmentView && !isActiveView"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@@ -66,6 +79,7 @@ const openFilter = () => {
:segments-id="segmentsId"
:has-applied-filters="hasAppliedFilters"
:is-label-view="isLabelView"
:is-active-view="isActiveView"
@update:sort="emit('update:sort', $event)"
@search="emit('search', $event)"
@apply-filter="emit('applyFilter', $event)"
@@ -74,11 +88,7 @@ const openFilter = () => {
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]">
<ContactsActiveFiltersPreview
v-if="
(hasAppliedFilters || !isNotSegmentView) &&
!isFetchingList &&
!isLabelView
"
v-if="showActiveFiltersPreview"
:active-segment="activeSegment"
@clear-filters="emit('clearFilters')"
@open-filter="openFilter"
@@ -71,6 +71,7 @@ const toggleExpanded = id => {
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating"
@toggle="toggleExpanded(contact.id)"
@@ -56,7 +56,7 @@ useKeyboardEvents(keyboardEvents);
<template>
<div
class="flex flex-col justify-center items-center absolute top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
>
<Button
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
@@ -4,38 +4,14 @@ import { computed, ref, watch, useSlots } from 'vue';
import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const props = defineProps({
modelValue: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
focusOnMount: {
type: Boolean,
default: false,
},
maxLength: {
type: Number,
default: 200,
},
showCharacterCount: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
message: {
type: String,
default: '',
},
modelValue: { type: String, default: '' },
label: { type: String, default: '' },
placeholder: { type: String, default: '' },
focusOnMount: { type: Boolean, default: false },
maxLength: { type: Number, default: 200 },
showCharacterCount: { type: Boolean, default: true },
disabled: { type: Boolean, default: false },
message: { type: String, default: '' },
messageType: {
type: String,
default: 'info',
@@ -43,6 +19,7 @@ const props = defineProps({
},
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:modelValue']);
@@ -120,6 +97,7 @@ watch(
:disabled="disabled"
:enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -46,8 +46,6 @@ const getCurrentRoute = () => {
const path = route.path;
if (path.includes('/conversations')) return 'conversations';
if (path.includes('/dashboard')) return 'dashboard';
if (path.includes('/contacts')) return 'contacts';
if (path.includes('/articles')) return 'articles';
return 'dashboard';
};
@@ -42,6 +42,7 @@ const showCopilotLauncher = computed(() => {
const toggleSidebar = () => {
updateUISettings({
is_copilot_panel_open: !uiSettings.value.is_copilot_panel_open,
is_contact_sidebar_open: false,
});
};
</script>
@@ -74,6 +74,7 @@ const updateSelected = newValue => {
<slot name="trigger" :toggle="toggle">
<Button
ref="triggerRef"
type="button"
sm
slate
:variant
@@ -9,7 +9,14 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { options } = defineProps({
const {
options,
disableSearch,
placeholderIcon,
placeholder,
placeholderTrailingIcon,
searchPlaceholder,
} = defineProps({
options: {
type: Array,
required: true,
@@ -18,6 +25,22 @@ const { options } = defineProps({
type: Boolean,
default: false,
},
placeholderIcon: {
type: String,
default: 'i-lucide-plus',
},
placeholder: {
type: String,
default: '',
},
placeholderTrailingIcon: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
});
const { t } = useI18n();
@@ -69,15 +92,26 @@ const toggleSelected = option => {
sm
slate
faded
type="button"
:icon="selectedItem.icon"
:label="selectedItem.name"
@click="toggle"
/>
<Button v-else sm slate faded @click="toggle">
<Button
v-else
sm
slate
faded
type="button"
:trailing-icon="placeholderTrailingIcon"
@click="toggle"
>
<template #icon>
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
<Icon :icon="placeholderIcon" class="text-n-slate-11" />
</template>
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
<span class="text-n-slate-11">{{
placeholder || t('COMBOBOX.PLACEHOLDER')
}}</span>
</Button>
</template>
<DropdownBody class="top-0 min-w-56 z-50" strong>
@@ -87,7 +121,7 @@ const toggleSelected = option => {
v-model="searchTerm"
autofocus
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
:placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
/>
</div>
<DropdownSection class="max-h-80 overflow-scroll">
@@ -1,7 +1,8 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, watch } from 'vue';
import Input from './Input.vue';
import { useI18n } from 'vue-i18n';
import { DURATION_UNITS } from './constants';
const props = defineProps({
min: { type: Number, default: 0 },
@@ -11,36 +12,50 @@ const props = defineProps({
const { t } = useI18n();
const duration = defineModel('modelValue', { type: Number, default: null });
const unit = defineModel('unit', {
type: String,
default: DURATION_UNITS.MINUTES,
validate(value) {
return Object.values(DURATION_UNITS).includes(value);
},
});
const UNIT_TYPES = {
MINUTES: 'minutes',
HOURS: 'hours',
DAYS: 'days',
const convertToMinutes = newValue => {
if (unit.value === DURATION_UNITS.MINUTES) {
return Math.floor(newValue);
}
if (unit.value === DURATION_UNITS.HOURS) {
return Math.floor(newValue) * 60;
}
return Math.floor(newValue) * 24 * 60;
};
const unit = ref(UNIT_TYPES.MINUTES);
const transformedValue = computed({
get() {
if (unit.value === UNIT_TYPES.MINUTES) return duration.value;
if (unit.value === UNIT_TYPES.HOURS) return Math.floor(duration.value / 60);
if (unit.value === UNIT_TYPES.DAYS)
if (unit.value === DURATION_UNITS.MINUTES) return duration.value;
if (unit.value === DURATION_UNITS.HOURS)
return Math.floor(duration.value / 60);
if (unit.value === DURATION_UNITS.DAYS)
return Math.floor(duration.value / 24 / 60);
return 0;
},
set(newValue) {
let minuteValue;
if (unit.value === UNIT_TYPES.MINUTES) {
minuteValue = Math.floor(newValue);
} else if (unit.value === UNIT_TYPES.HOURS) {
minuteValue = Math.floor(newValue * 60);
} else if (unit.value === UNIT_TYPES.DAYS) {
minuteValue = Math.floor(newValue * 24 * 60);
}
let minuteValue = convertToMinutes(newValue);
duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
},
});
// when unit is changed set the nearest value to that unit
// so if the minute is set to 900, and the user changes the unit to "days"
// the transformed value will show 0, but the real value will still be 900
// this might create some confusion, especially when saving
// this watcher fixes it by rounding the duration basically, to the nearest unit value
watch(unit, () => {
let adjustedValue = convertToMinutes(transformedValue.value);
duration.value = Math.min(Math.max(adjustedValue, props.min), props.max);
});
</script>
<template>
@@ -57,10 +72,12 @@ const transformedValue = computed({
:disabled="disabled"
class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40"
>
<option :value="UNIT_TYPES.MINUTES">
<option :value="DURATION_UNITS.MINUTES">
{{ t('DURATION_INPUT.MINUTES') }}
</option>
<option :value="UNIT_TYPES.HOURS">{{ t('DURATION_INPUT.HOURS') }}</option>
<option :value="UNIT_TYPES.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
<option :value="DURATION_UNITS.HOURS">
{{ t('DURATION_INPUT.HOURS') }}
</option>
<option :value="DURATION_UNITS.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
</select>
</template>
@@ -0,0 +1,5 @@
export const DURATION_UNITS = {
MINUTES: 'minutes',
HOURS: 'hours',
DAYS: 'days',
};
@@ -315,6 +315,9 @@ const componentToRender = computed(() => {
});
const shouldShowContextMenu = computed(() => {
// eslint-disable-next-line no-underscore-dangle
if (window.__WOOT_ISOLATED_SHELL__) return false;
return !props.contentAttributes?.isUnsupported;
});
@@ -441,7 +444,7 @@ const avatarTooltip = computed(() => {
});
const setupHighlightTimer = () => {
if (Number(route.query.messageId) !== Number(props.id)) {
if (Number(route?.query?.messageId) !== Number(props.id)) {
return;
}
@@ -0,0 +1,45 @@
<template>
<svg
class="w-8 h-4"
viewBox="0 0 120 30"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
>
<circle cx="15" cy="15" r="12">
<animate
attributeName="cy"
from="15"
to="5"
begin="0s"
dur="0.9s"
values="15;5;15;15;15;15"
calcMode="linear"
repeatCount="indefinite"
/>
</circle>
<circle cx="55" cy="15" r="12">
<animate
attributeName="cy"
from="15"
to="5"
begin="0.1s"
dur="0.9s"
values="15;5;15;15;15;15"
calcMode="linear"
repeatCount="indefinite"
/>
</circle>
<circle cx="95" cy="15" r="12">
<animate
attributeName="cy"
from="15"
to="5"
begin="0.2s"
dur="0.9s"
values="15;5;15;15;15;15"
calcMode="linear"
repeatCount="indefinite"
/>
</circle>
</svg>
</template>
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
import BaseBubble from './Base.vue';
import Button from 'next/button/Button.vue';
import Icon from 'next/icon/Icon.vue';
import { useGallery } from '../useGallery';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useMessageContext } from '../provider.js';
import { downloadFile } from '@chatwoot/utils';
@@ -21,8 +22,8 @@ const attachment = computed(() => {
});
const hasError = ref(false);
const showGallery = ref(false);
const isDownloading = ref(false);
const { showGallery, isGalleryAllowed, toggleGallery } = useGallery();
const handleError = () => {
hasError.value = true;
@@ -46,7 +47,7 @@ const downloadAttachment = async () => {
<BaseBubble
class="overflow-hidden p-3"
data-bubble-name="image"
@click="showGallery = true"
@click="toggleGallery(true)"
>
<div v-if="hasError" class="flex items-center gap-1 text-center rounded-lg">
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
@@ -82,7 +83,7 @@ const downloadAttachment = async () => {
</div>
</BaseBubble>
<GalleryView
v-if="showGallery"
v-if="showGallery && isGalleryAllowed"
v-model:show="showGallery"
:attachment="useSnakeCase(attachment)"
:all-attachments="filteredCurrentChatAttachments"
@@ -2,6 +2,7 @@
import { ref, computed } from 'vue';
import BaseBubble from './Base.vue';
import Icon from 'next/icon/Icon.vue';
import { useGallery } from '../useGallery';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useMessageContext } from '../provider.js';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
@@ -9,7 +10,7 @@ import { ATTACHMENT_TYPES } from '../constants';
const emit = defineEmits(['error']);
const hasError = ref(false);
const showGallery = ref(false);
const { showGallery, isGalleryAllowed, toggleGallery } = useGallery();
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
const handleError = () => {
@@ -30,7 +31,7 @@ const isReel = computed(() => {
<BaseBubble
class="overflow-hidden p-3"
data-bubble-name="video"
@click="showGallery = true"
@click="toggleGallery(true)"
>
<div class="relative group rounded-lg overflow-hidden">
<div
@@ -52,7 +53,7 @@ const isReel = computed(() => {
</div>
</BaseBubble>
<GalleryView
v-if="showGallery"
v-if="showGallery && isGalleryAllowed"
v-model:show="showGallery"
:attachment="useSnakeCase(attachment)"
:all-attachments="filteredCurrentChatAttachments"
@@ -109,49 +109,58 @@ const downloadAudio = async () => {
</audio>
<div
v-bind="$attrs"
class="rounded-xl w-full gap-1 p-1.5 bg-n-alpha-white flex items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]"
class="rounded-xl w-full gap-2 p-1.5 bg-n-alpha-white flex flex-col items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]"
>
<button class="p-0 border-0 size-8" @click="playOrPause">
<Icon
v-if="isPlaying"
class="size-8"
icon="i-teenyicons-pause-small-solid"
/>
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
</button>
<div class="tabular-nums text-xs">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
<div class="flex gap-1 w-full flex-1 items-center justify-start">
<button class="p-0 border-0 size-8" @click="playOrPause">
<Icon
v-if="isPlaying"
class="size-8"
icon="i-teenyicons-pause-small-solid"
/>
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
</button>
<div class="tabular-nums text-xs">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
<div class="flex-1 items-center flex px-2">
<input
type="range"
min="0"
:max="duration"
:value="currentTime"
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
</div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
>
<span class="text-xs text-n-slate-11 font-medium">
{{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
</div>
<div class="flex-1 items-center flex px-2">
<input
type="range"
min="0"
:max="duration"
:value="currentTime"
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
<div
v-if="attachment.transcribedText"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
>
{{ attachment.transcribedText }}
</div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
>
<span class="text-xs text-n-slate-11 font-medium">
{{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
</div>
</template>
@@ -1,6 +1,7 @@
<script setup>
import { ref } from 'vue';
import Icon from 'next/icon/Icon.vue';
import { useGallery } from '../useGallery';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useMessageContext } from '../provider.js';
@@ -13,7 +14,7 @@ defineProps({
},
});
const hasError = ref(false);
const showGallery = ref(false);
const { showGallery, toggleGallery, isGalleryAllowed } = useGallery();
const { filteredCurrentChatAttachments } = useMessageContext();
@@ -25,7 +26,7 @@ const handleError = () => {
<template>
<div
class="size-[72px] overflow-hidden contain-content rounded-xl cursor-pointer"
@click="showGallery = true"
@click="toggleGallery(true)"
>
<div
v-if="hasError"
@@ -42,7 +43,7 @@ const handleError = () => {
/>
</div>
<GalleryView
v-if="showGallery"
v-if="showGallery && isGalleryAllowed"
v-model:show="showGallery"
:attachment="useSnakeCase(attachment)"
:all-attachments="filteredCurrentChatAttachments"
@@ -1,6 +1,6 @@
<script setup>
import { ref } from 'vue';
import Icon from 'next/icon/Icon.vue';
import { useGallery } from '../useGallery';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useMessageContext } from '../provider.js';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
@@ -12,7 +12,7 @@ defineProps({
},
});
const showGallery = ref(false);
const { showGallery, isGalleryAllowed, toggleGallery } = useGallery();
const { filteredCurrentChatAttachments } = useMessageContext();
</script>
@@ -20,7 +20,7 @@ const { filteredCurrentChatAttachments } = useMessageContext();
<template>
<div
class="size-[72px] overflow-hidden contain-content rounded-xl cursor-pointer relative group"
@click="showGallery = true"
@click="toggleGallery(true)"
>
<video
:src="attachment.dataUrl"
@@ -42,7 +42,7 @@ const { filteredCurrentChatAttachments } = useMessageContext();
</div>
</div>
<GalleryView
v-if="showGallery"
v-if="showGallery && isGalleryAllowed"
v-model:show="showGallery"
:attachment="useSnakeCase(attachment)"
:all-attachments="filteredCurrentChatAttachments"
@@ -0,0 +1,23 @@
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
export function useGallery() {
const [showGallery] = useToggle(false);
const isGalleryAllowed = computed(() => {
return true;
// return !window.__WOOT_ISOLATED_SHELL__;
});
const toggleGallery = value => {
if (!isGalleryAllowed.value) return;
showGallery.value = value;
};
return {
showGallery,
isGalleryAllowed,
toggleGallery,
};
}
@@ -235,6 +235,12 @@ const menuItems = computed(() => {
),
activeOn: ['contacts_dashboard_index', 'contacts_edit'],
},
{
name: 'Active',
label: t('SIDEBAR.ACTIVE'),
to: accountScopedRoute('contacts_dashboard_active'),
activeOn: ['contacts_dashboard_active'],
},
{
name: 'Segments',
icon: 'i-lucide-group',
@@ -22,6 +22,7 @@ import {
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import ChatListHeader from './ChatListHeader.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
@@ -82,6 +83,7 @@ const emit = defineEmits(['conversationLoad']);
const { uiSettings } = useUISettings();
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const store = useStore();
const conversationListRef = ref(null);
@@ -646,6 +648,30 @@ function openLastItemAfterDeleteInFolder() {
}
}
function redirectToConversationList() {
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = route;
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
router.push(
conversationListPageURL({
accountId,
conversationType: conversationType,
customViewId: props.foldersId,
inboxId,
label,
teamId,
})
);
}
async function assignPriority(priority, conversationId = null) {
store.dispatch('setCurrentChatPriority', {
priority,
@@ -670,26 +696,7 @@ async function markAsUnread(conversationId) {
await store.dispatch('markMessagesUnread', {
id: conversationId,
});
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = useRoute();
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
router.push(
conversationListPageURL({
accountId,
conversationType: conversationType,
customViewId: props.foldersId,
inboxId,
label,
teamId,
})
);
redirectToConversationList();
} catch (error) {
// Ignore error
}
@@ -703,6 +710,7 @@ async function markAsRead(conversationId) {
// Ignore error
}
}
async function onAssignTeam(team, conversationId = null) {
try {
await store.dispatch('assignTeam', {
@@ -764,6 +772,26 @@ onMounted(() => {
}
});
const deleteConversationDialogRef = ref(null);
const selectedConversationId = ref(null);
async function deleteConversation() {
try {
await store.dispatch('deleteConversation', selectedConversationId.value);
redirectToConversationList();
selectedConversationId.value = null;
deleteConversationDialogRef.value.close();
useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION'));
} catch (error) {
useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION'));
}
}
const handleDelete = conversationId => {
selectedConversationId.value = conversationId;
deleteConversationDialogRef.value.open();
};
provide('selectConversation', selectConversation);
provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent);
@@ -775,6 +803,7 @@ provide('markAsUnread', markAsUnread);
provide('markAsRead', markAsRead);
provide('assignPriority', assignPriority);
provide('isConversationSelected', isConversationSelected);
provide('deleteConversation', handleDelete);
watch(activeTeam, () => resetAndFetchData());
@@ -938,6 +967,19 @@ watch(conversationFilters, (newVal, oldVal) => {
</template>
</DynamicScroller>
</div>
<Dialog
ref="deleteConversationDialogRef"
type="alert"
:title="
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
conversationId: selectedConversationId,
})
"
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
@confirm="deleteConversation"
@close="selectedConversationId = null"
/>
<TeleportWithDirection
v-if="showAdvancedFilters"
to="#conversationFilterTeleportTarget"
@@ -16,6 +16,7 @@ export default {
'markAsRead',
'assignPriority',
'isConversationSelected',
'deleteConversation',
],
props: {
source: {
@@ -67,5 +68,6 @@ export default {
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
/>
</template>
@@ -134,7 +134,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<template>
<div class="relative flex items-center justify-end resolve-actions">
<div
class="rounded-lg shadow outline-1 outline"
class="rounded-lg shadow outline-1 outline flex-shrink-0"
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
>
<Button
@@ -178,7 +178,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<div
v-if="showActionsDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open left-auto top-full mt-0.5 ltr:right-0 rtl:left-0 max-w-[12.5rem] min-w-[9.75rem]"
class="dropdown-pane dropdown-pane--open left-auto top-full mt-0.5 start-0 xl:start-auto xl:end-0 max-w-[12.5rem] min-w-[9.75rem]"
>
<WootDropdownMenu class="mb-0">
<WootDropdownItem v-if="!isPending">
@@ -9,6 +9,7 @@ const contacts = accountId => ({
'contacts_edit',
'contacts_edit_segment',
'contacts_edit_label',
'contacts_dashboard_active',
],
menuItems: [
{
@@ -18,6 +19,13 @@ const contacts = accountId => ({
toState: frontendURL(`accounts/${accountId}/contacts?page=1`),
toStateName: 'contacts_dashboard_index',
},
{
icon: 'visitor-contacts',
label: 'ACTIVE',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts/active`),
toStateName: 'contacts_dashboard_active',
},
],
});
@@ -170,6 +170,10 @@ const shouldShowCannedResponses = computed(() => {
});
const editorMenuOptions = computed(() => {
// eslint-disable-next-line no-underscore-dangle
if (window.__WOOT_ISOLATED_SHELL__) {
return [];
}
return props.enabledMenuOptions.length
? props.enabledMenuOptions
: MESSAGE_EDITOR_MENU_OPTIONS;
@@ -422,10 +426,14 @@ function updateImgToolbarOnDelete() {
}
function isEnterToSendEnabled() {
// eslint-disable-next-line no-underscore-dangle
if (window.__WOOT_ISOLATED_SHELL__) return false;
return isEditorHotKeyEnabled('enter');
}
function isCmdPlusEnterToSendEnabled() {
// eslint-disable-next-line no-underscore-dangle
if (window.__WOOT_ISOLATED_SHELL__) return true;
return isEditorHotKeyEnabled('cmd_enter');
}
@@ -653,7 +661,7 @@ watch(sendWithSignature, newValue => {
}
});
onMounted(() => {
onMounted(async () => {
// [VITE] state assignment was done in created before
state = createState(
props.modelValue,
@@ -734,6 +742,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
.ProseMirror-menubar-wrapper {
@apply flex flex-col;
.ProseMirror-menubar:empty {
display: none;
}
.ProseMirror-menubar {
min-height: var(--space-two) !important;
@apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
@@ -55,10 +55,16 @@ const translateValue = computed(() => {
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0"
@click="$emit('toggleMode')"
>
<div ref="wootEditorReplyMode" class="flex items-center gap-1 px-2 z-20">
<div
ref="wootEditorReplyMode"
class="flex items-center gap-1 px-2 z-20 text-n-slate-11"
>
{{ $t('CONVERSATION.REPLYBOX.REPLY') }}
</div>
<div ref="wootEditorPrivateMode" class="flex items-center gap-1 px-2 z-20">
<div
ref="wootEditorPrivateMode"
class="flex items-center gap-1 px-2 z-20 text-n-slate-11"
>
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
</div>
<div
@@ -118,6 +118,12 @@ export default {
type: String,
default: '',
},
allowSignature: { type: Boolean, default: false },
allowEmoji: { type: Boolean, default: false },
allowAiAssist: { type: Boolean, default: false },
allowVideoCall: { type: Boolean, default: false },
allowFileUpload: { type: Boolean, default: false },
allowAudioRecorder: { type: Boolean, default: false },
},
emits: [
'replaceText',
@@ -264,6 +270,7 @@ export default {
<div class="flex justify-between p-3" :class="wrapClass">
<div class="left-wrap">
<NextButton
v-if="allowEmoji"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
icon="i-ph-smiley-sticker"
slate
@@ -272,6 +279,7 @@ export default {
@click="toggleEmojiPicker"
/>
<FileUpload
v-if="allowFileUpload"
ref="uploadRef"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
input-id="conversationAttachment"
@@ -295,36 +303,40 @@ export default {
sm
/>
</FileUpload>
<template v-if="allowAudioRecorder">
<NextButton
v-if="showAudioRecorderButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
:icon="
!isRecordingAudio ? 'i-ph-microphone' : 'i-ph-microphone-slash'
"
slate
faded
sm
@click="toggleAudioRecorder"
/>
<NextButton
v-if="showEditorToggle"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
icon="i-ph-quotes"
slate
faded
sm
@click="$emit('toggleEditor')"
/>
<NextButton
v-if="showAudioPlayStopButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
:icon="audioRecorderPlayStopIcon"
slate
faded
sm
:label="recordingAudioDurationText"
@click="toggleAudioRecorderPlayPause"
/>
</template>
<NextButton
v-if="showAudioRecorderButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
:icon="!isRecordingAudio ? 'i-ph-microphone' : 'i-ph-microphone-slash'"
slate
faded
sm
@click="toggleAudioRecorder"
/>
<NextButton
v-if="showEditorToggle"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
icon="i-ph-quotes"
slate
faded
sm
@click="$emit('toggleEditor')"
/>
<NextButton
v-if="showAudioPlayStopButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
:icon="audioRecorderPlayStopIcon"
slate
faded
sm
:label="recordingAudioDurationText"
@click="toggleAudioRecorderPlayPause"
/>
<NextButton
v-if="showMessageSignatureButton"
v-if="allowSignature && showMessageSignatureButton"
v-tooltip.top-end="signatureToggleTooltip"
icon="i-ph-signature"
slate
@@ -342,11 +354,15 @@ export default {
@click="$emit('selectWhatsappTemplate')"
/>
<VideoCallButton
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
v-if="
allowVideoCall &&
(isAWebWidgetInbox || isAPIInbox) &&
!isOnPrivateNote
"
:conversation-id="conversationId"
/>
<AIAssistanceButton
v-if="!isFetchingAppIntegrations"
v-if="allowAiAssist && !isFetchingAppIntegrations"
:conversation-id="conversationId"
:is-private-note="isOnPrivateNote"
:message="message"
@@ -15,6 +15,10 @@ export default {
type: String,
default: REPLY_EDITOR_MODES.REPLY,
},
disablePopout: {
type: Boolean,
default: false,
},
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
@@ -85,7 +89,7 @@ export default {
</script>
<template>
<div class="flex justify-between h-[3.25rem] gap-2 ltr:pl-3 rtl:pr-3">
<div class="flex justify-between h-[3.25rem] gap-2 ms-3">
<EditorModeToggle
:mode="mode"
class="mt-3"
@@ -99,6 +103,7 @@ export default {
</div>
</div>
<NextButton
v-if="!disablePopout"
ghost
class="ltr:rounded-bl-md rtl:rounded-br-md ltr:rounded-br-none rtl:rounded-bl-none ltr:rounded-tl-none rtl:rounded-tr-none text-n-slate-11 ltr:rounded-tr-[11px] rtl:rounded-tl-[11px]"
icon="i-lucide-maximize-2"
@@ -78,6 +78,7 @@ export default {
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
],
data() {
return {
@@ -237,6 +238,10 @@ export default {
this.$emit('assignPriority', priority, this.chat.id);
this.closeContextMenu();
},
async deleteConversation() {
this.$emit('deleteConversation', this.chat.id);
this.closeContextMenu();
},
},
};
</script>
@@ -363,6 +368,7 @@ export default {
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
/>
</ContextMenu>
</div>
@@ -107,10 +107,10 @@ const isLinearFeatureEnabled = computed(() =>
<template>
<div
ref="conversationHeader"
class="flex flex-col items-center justify-center flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
class="flex flex-col gap-3 items-center justify-between flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
>
<div
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0"
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0 xl:flex-1"
>
<BackButton
v-if="showBackButton"
@@ -122,11 +122,12 @@ const isLinearFeatureEnabled = computed(() =>
:username="currentContact.name"
:status="currentContact.availability_status"
size="32px"
class="flex-shrink-0"
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
>
<div class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit">
<div class="flex flex-row items-center max-w-full gap-1 p-0 m-0">
<span
class="text-sm font-medium truncate leading-tight text-n-slate-12"
>
@@ -136,7 +137,7 @@ const isLinearFeatureEnabled = computed(() =>
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
class="text-n-amber-10 my-0 mx-0 min-w-[14px] flex-shrink-0"
icon="warning"
/>
</div>
@@ -152,20 +153,20 @@ const isLinearFeatureEnabled = computed(() =>
</div>
</div>
<div
class="flex flex-row items-center justify-start xl:justify-end flex-grow gap-2 w-full xl:w-auto mt-3 header-actions-wrap xl:mt-0"
class="flex flex-row items-center justify-start xl:justify-end flex-shrink-0 gap-2 w-full xl:w-auto header-actions-wrap"
>
<SLACardLabel
v-if="hasSlaPolicyId"
:chat="chat"
show-extended-info
:parent-width="width"
class="hidden lg:flex"
class="hidden md:flex"
/>
<Linear
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
:conversation-id="currentChat.id"
:parent-width="width"
class="hidden lg:flex"
class="hidden md:flex"
/>
<MoreActions :conversation-id="currentChat.id" />
</div>
@@ -1240,6 +1240,12 @@ export default {
:message="message"
:portal-slug="connectedPortalSlug"
:new-conversation-modal-active="newConversationModalActive"
allow-signature
allow-emoji
allow-ai-assist
allow-video-call
allow-audio-recorder
allow-file-upload
@select-whatsapp-template="openWhatsappTemplateModal"
@toggle-editor="toggleRichContentEditor"
@replace-text="replaceText"
@@ -209,7 +209,7 @@ onMounted(() => {
</div>
<div
class="flex-1 mx-2 px-2 truncate text-sm font-medium text-center text-n-slate-12"
class="flex-1 mx-2 px-2 truncate text-sm font-medium text-center text-n-slate-12 hidden sm:block"
>
<span v-dompurify-html="fileNameFromDataUrl" class="truncate" />
</div>
@@ -134,7 +134,7 @@ onUnmounted(() => {
<SLAPopoverCard
v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents"
class="rtl:left-0 ltr:right-0 top-7 hidden group-hover:flex"
class="start-0 xl:start-auto xl:end-0 top-7 hidden group-hover:flex"
/>
</div>
</template>
@@ -8,6 +8,7 @@ import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
export default {
components: {
@@ -45,7 +46,14 @@ export default {
'assignAgent',
'assignTeam',
'assignLabel',
'deleteConversation',
],
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() {
return {
STATUS_TYPE: wootConstants.STATUS_TYPE,
@@ -121,6 +129,11 @@ export default {
icon: 'people-team-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
},
deleteOption: {
key: 'delete',
icon: 'delete',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'),
},
};
},
computed: {
@@ -178,6 +191,9 @@ export default {
assignPriority(priority) {
this.$emit('assignPriority', priority);
},
deleteConversation() {
this.$emit('deleteConversation', this.chatId);
},
show(key) {
// If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved.
@@ -277,5 +293,13 @@ export default {
@click.stop="$emit('assignTeam', team)"
/>
</MenuItemWithSubmenu>
<template v-if="isAdmin">
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItem
:option="deleteOption"
variant="icon"
@click.stop="deleteConversation"
/>
</template>
</div>
</template>
@@ -142,7 +142,7 @@ onMounted(() => {
v-if="linkedIssue"
:issue="linkedIssue.issue"
:link-id="linkedIssue.id"
class="absolute rtl:left-0 ltr:right-0 top-9 invisible group-hover:visible"
class="absolute start-0 xl:start-auto xl:end-0 top-9 invisible group-hover:visible"
@unlink-issue="unlinkIssue"
/>
<woot-modal
@@ -2,6 +2,12 @@ import { computed, unref } from 'vue';
import { getCurrentInstance } from 'vue';
export const useStore = () => {
// eslint-disable-next-line no-underscore-dangle
if (window.__CHATWOOT_STORE__) {
// eslint-disable-next-line no-underscore-dangle
return window.__CHATWOOT_STORE__;
}
const vm = getCurrentInstance();
if (!vm) throw new Error('must be called in setup');
return vm.proxy.$store;
@@ -45,9 +45,10 @@ export function useAccount() {
};
};
const updateAccount = async data => {
const updateAccount = async (data, options) => {
await store.dispatch('accounts/update', {
...data,
options,
});
};
@@ -33,6 +33,14 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'code',
];
export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
];
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{
name: 'Small',
+11 -3
View File
@@ -10,7 +10,10 @@ const { isImpersonating } = useImpersonation();
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
const { websocketURL = '' } = window.chatwootConfig || {};
super(app, pubsubToken, websocketURL);
// eslint-disable-next-line no-underscore-dangle
const wsURL = websocketURL || window.__WEBSOCKET_URL__ || '';
super(app, pubsubToken, wsURL);
this.CancelTyping = [];
this.events = {
'message.created': this.onMessageCreated,
@@ -48,7 +51,9 @@ class ActionCableConnector extends BaseActionCableConnector {
};
isAValidEvent = data => {
return this.app.$store.getters.getCurrentAccountId === data.account_id;
// eslint-disable-next-line no-underscore-dangle
const currentAccountId = this.app.$store.getters.getCurrentAccountId;
return currentAccountId === data.account_id;
};
onMessageUpdated = data => {
@@ -98,7 +103,10 @@ class ActionCableConnector extends BaseActionCableConnector {
conversation: { last_activity_at: lastActivityAt },
conversation_id: conversationId,
} = data;
DashboardAudioNotificationHelper.onNewMessage(data);
// eslint-disable-next-line no-underscore-dangle
if (!window.__WOOT_ISOLATED_SHELL__) {
DashboardAudioNotificationHelper.onNewMessage(data);
}
this.app.$store.dispatch('addMessage', data);
this.app.$store.dispatch('updateConversationLastActivity', {
lastActivityAt,
@@ -36,6 +36,7 @@ const translationKeys = {
'teammember:create': `AUDIT_LOGS.TEAM_MEMBER.ADD`,
'teammember:destroy': `AUDIT_LOGS.TEAM_MEMBER.REMOVE`,
'account:update': `AUDIT_LOGS.ACCOUNT.EDIT`,
'conversation:destroy': `AUDIT_LOGS.CONVERSATION.DELETE`,
};
function extractAttrChange(attrChange) {
@@ -168,6 +169,11 @@ export function generateTranslationPayload(auditLogItem, agentList) {
const auditableType = auditLogItem.auditable_type.toLowerCase();
const action = auditLogItem.action.toLowerCase();
if (auditableType === 'conversation' && action === 'destroy') {
translationPayload.id =
auditLogItem.audited_changes?.display_id || auditLogItem.auditable_id;
}
if (auditableType === 'accountuser') {
translationPayload = handleAccountUser(
auditLogItem,
@@ -69,6 +69,9 @@
},
"ACCOUNT": {
"EDIT": "{agentName} updated the account configuration (#{id})"
},
"CONVERSATION": {
"DELETE": "{agentName} deleted conversation #{id}"
}
}
}
@@ -286,6 +286,7 @@
"HEADER": {
"TITLE": "Contacts",
"SEARCH_TITLE": "Search contacts",
"ACTIVE_TITLE": "Active contacts",
"SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Message",
"SEND_MESSAGE": "Send message",
@@ -560,7 +561,8 @@
"SUBTITLE": "Start adding new contacts by clicking on the button below",
"BUTTON_LABEL": "Add contact",
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋"
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋",
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
}
},
@@ -118,6 +118,11 @@
"FAILED": "Couldn't change priority. Please try again."
}
},
"DELETE_CONVERSATION": {
"TITLE": "Delete conversation #{conversationId}",
"DESCRIPTION": "Are you sure you want to delete this conversation?",
"CONFIRM": "Delete"
},
"CARD_CONTEXT_MENU": {
"PENDING": "Mark as pending",
"RESOLVED": "Mark as resolved",
@@ -134,6 +139,7 @@
"ASSIGN_LABEL": "Assign label",
"AGENTS_LOADING": "Loading agents...",
"ASSIGN_TEAM": "Assign team",
"DELETE": "Delete conversation",
"API": {
"AGENT_ASSIGNMENT": {
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",
@@ -208,6 +214,8 @@
"ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully",
"ASSIGN_LABEL_FAILED": "Label assignment failed",
"CHANGE_TEAM": "Conversation team changed",
"SUCCESS_DELETE_CONVERSATION": "Conversation deleted successfully",
"FAIL_DELETE_CONVERSATION": "Couldn't delete conversation! Try again",
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit",
"MESSAGE_ERROR": "Unable to send this message, please try again later",
"SENT_BY": "Sent by:",
@@ -46,7 +46,31 @@
},
"AUTO_RESOLVE": {
"TITLE": "Auto-resolve conversations",
"NOTE": "This configuration would allow you to automatically resolve the conversation after a certain period. Set the duration and customize the message to the user below."
"NOTE": "This configuration would allow you to automatically resolve the conversation after a certain period of inactivity.",
"DURATION": {
"LABEL": "Inactivity duration",
"HELP": "Time period of inactivity after which conversation is auto-resolved",
"PLACEHOLDER": "30",
"ERROR": "Auto resolve duration should be between 10 minutes and 999 days",
"API": {
"SUCCESS": "Auto resolve settings updated successfully",
"ERROR": "Failed to update auto resolve settings"
}
},
"MESSAGE": {
"LABEL": "Custom auto-resolution message",
"PLACEHOLDER": "Conversation was marked resolved by system due to 15 days of inactivity",
"HELP": "Message sent to the customer after conversation is auto-resolved"
},
"PREFERENCES": "Preferences",
"LABEL": {
"LABEL": "Add label after auto-resolution",
"PLACEHOLDER": "Select a label"
},
"IGNORE_WAITING": {
"LABEL": "Skip conversations waiting for agents reply"
},
"UPDATE_BUTTON": "Save Changes"
},
"NAME": {
"LABEL": "Account name",
@@ -70,7 +94,15 @@
},
"AUTO_RESOLVE_IGNORE_WAITING": {
"LABEL": "Exclude unattended conversations",
"HELP": "When enabled, the system will skip resolving conversations that are still waiting for an agents reply."
"HELP": "When enabled, the system will skip resolving conversations that are still waiting for an agent's reply."
},
"AUDIO_TRANSCRIPTION": {
"TITLE": "Transcribe Audio Messages",
"NOTE": "Automatically transcribe audio messages in conversations. Generate a text transcript whenever an audio message is sent or received, and display it alongside the message.",
"API": {
"SUCCESS": "Audio transcription setting updated successfully",
"ERROR": "Failed to update audio transcription setting"
}
},
"AUTO_RESOLVE_DURATION": {
"LABEL": "Inactivity duration for resolution",
@@ -286,6 +286,7 @@
"REPORTS": "Reports",
"SETTINGS": "Settings",
"CONTACTS": "Contacts",
"ACTIVE": "Active",
"CAPTAIN": "Captain",
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents",
@@ -1,5 +1,6 @@
<script setup>
import { computed } from 'vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
const props = defineProps({
config: {
@@ -8,6 +9,8 @@ const props = defineProps({
},
});
const { formatMessage } = useMessageFormatter();
const isDefaultScreen = computed(() => {
return (
props.config.isDefaultScreen &&
@@ -53,12 +56,13 @@ const isDefaultScreen = computed(() => {
</div>
</div>
<div v-if="isDefaultScreen" class="overflow-auto max-h-60">
<h2 class="mb-2 text-2xl break-words text-slate-900 dark:text-white">
<h2 class="mb-2 text-2xl break-words text-n-slate-12">
{{ config.welcomeHeading }}
</h2>
<p class="text-sm break-words text-slate-600 dark:text-slate-100">
{{ config.welcomeTagline }}
</p>
<p
v-dompurify-html="formatMessage(config.welcomeTagline)"
class="text-sm break-words text-n-slate-11 [&_a]:!text-n-slate-11 [&_a]:underline"
/>
</div>
</div>
</div>
@@ -67,9 +67,11 @@ const hasContacts = computed(() => contacts.value.length > 0);
const isContactIndexView = computed(
() => route.name === 'contacts_dashboard_index' && pageNumber.value === 1
);
const isActiveView = computed(() => route.name === 'contacts_dashboard_active');
const hasAppliedFilters = computed(() => {
return appliedFilters.value.length > 0;
});
const showEmptyStateLayout = computed(() => {
return (
!searchQuery.value &&
@@ -89,11 +91,20 @@ const showEmptyText = computed(() => {
const headerTitle = computed(() => {
if (searchQuery.value) return t('CONTACTS_LAYOUT.HEADER.SEARCH_TITLE');
if (isActiveView.value) return t('CONTACTS_LAYOUT.HEADER.ACTIVE_TITLE');
if (activeSegmentId.value) return activeSegment.value?.name;
if (activeLabel.value) return `#${activeLabel.value}`;
return t('CONTACTS_LAYOUT.HEADER.TITLE');
});
const emptyStateMessage = computed(() => {
if (isActiveView.value)
return t('CONTACTS_LAYOUT.EMPTY_STATE.ACTIVE_EMPTY_STATE_TITLE');
if (!searchQuery.value || hasAppliedFilters.value)
return t('CONTACTS_LAYOUT.EMPTY_STATE.LIST_EMPTY_STATE_TITLE');
return t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE');
});
const updatePageParam = (page, search = '') => {
const query = {
...route.query,
@@ -132,6 +143,15 @@ const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
updatePageParam(page);
};
const fetchActiveContacts = async (page = 1) => {
await store.dispatch('contacts/clearContactFilters');
await store.dispatch('contacts/active', {
page,
sortAttr: buildSortAttr(),
});
updatePageParam(page);
};
const searchContacts = debounce(async (value, page = 1) => {
await store.dispatch('contacts/clearContactFilters');
searchValue.value = value;
@@ -158,6 +178,11 @@ const fetchContactsBasedOnContext = async page => {
}
// Reset the search value when we change the view
searchValue.value = '';
// If we're on the active route, fetch active contacts
if (isActiveView.value) {
await fetchActiveContacts(page);
return;
}
// If there are applied filters or active segment with query
if (
(hasAppliedFilters.value || activeSegment.value?.query) &&
@@ -184,6 +209,11 @@ const handleSort = async ({ sort, order }) => {
return;
}
if (isActiveView.value) {
await fetchActiveContacts();
return;
}
await (activeSegmentId.value || hasAppliedFilters.value
? fetchSavedOrAppliedFilteredContact(
activeSegmentId.value
@@ -210,7 +240,7 @@ watch(
);
watch(
[activeLabel, activeSegment],
[activeLabel, activeSegment, isActiveView],
() => {
fetchContactsBasedOnContext(pageNumber.value);
},
@@ -222,6 +252,13 @@ watch(searchQuery, value => {
searchValue.value = value || '';
// Reset the view if there is search query when we click on the sidebar group
if (value === undefined) {
if (
isActiveView.value ||
activeLabel.value ||
activeSegment.value ||
hasAppliedFilters.value
)
return;
fetchContacts();
}
});
@@ -232,6 +269,10 @@ onMounted(async () => {
await searchContacts(searchQuery.value, pageNumber.value);
return;
}
if (isActiveView.value) {
await fetchActiveContacts(pageNumber.value);
return;
}
await fetchContacts(pageNumber.value);
} else if (activeSegment.value && activeSegmentId.value) {
await fetchSavedOrAppliedFilteredContact(
@@ -286,11 +327,7 @@ onMounted(async () => {
class="flex items-center justify-center py-10"
>
<span class="text-base text-n-slate-11">
{{
searchQuery || !hasAppliedFilters
? t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE')
: t('CONTACTS_LAYOUT.EMPTY_STATE.LIST_EMPTY_STATE_TITLE')
}}
{{ emptyStateMessage }}
</span>
</div>
@@ -32,6 +32,12 @@ export const routes = [
component: ContactsIndex,
meta: commonMeta,
},
{
path: 'active',
name: 'contacts_dashboard_active',
component: ContactsIndex,
meta: commonMeta,
},
],
},
{
@@ -205,7 +205,7 @@ export default {
</script>
<template>
<section class="flex w-full h-full">
<section class="flex w-full h-full min-w-0">
<ChatList
:show-conversation-list="showConversationList"
:conversation-inbox="inboxId"
@@ -16,6 +16,7 @@ import AccountId from './components/AccountId.vue';
import BuildInfo from './components/BuildInfo.vue';
import AccountDelete from './components/AccountDelete.vue';
import AutoResolve from './components/AutoResolve.vue';
import AudioTranscription from './components/AudioTranscription.vue';
import SectionLayout from './components/SectionLayout.vue';
export default {
@@ -26,6 +27,7 @@ export default {
BuildInfo,
AccountDelete,
AutoResolve,
AudioTranscription,
SectionLayout,
WithLabel,
NextInput,
@@ -235,6 +237,7 @@ export default {
<woot-loading-state v-if="uiFlags.isFetchingItem" />
</div>
<AutoResolve v-if="showAutoResolutionConfig" />
<AudioTranscription v-if="isOnChatwootCloud" />
<AccountId />
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
<AccountDelete />
@@ -0,0 +1,51 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import SectionLayout from './SectionLayout.vue';
import Switch from 'next/switch/Switch.vue';
const { t } = useI18n();
const isEnabled = ref(false);
const { currentAccount, updateAccount } = useAccount();
watch(
currentAccount,
() => {
const { audio_transcriptions } = currentAccount.value?.settings || {};
isEnabled.value = !!audio_transcriptions;
},
{ deep: true, immediate: true }
);
const updateAccountSettings = async settings => {
try {
await updateAccount(settings);
useAlert(t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.API.SUCCESS'));
} catch (error) {
useAlert(t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.API.ERROR'));
}
};
const toggleAudioTranscription = async () => {
return updateAccountSettings({
audio_transcriptions: isEnabled.value,
});
};
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.NOTE')"
with-border
>
<template #headerActions>
<div class="flex justify-end">
<Switch v-model="isEnabled" @change="toggleAudioTranscription" />
</div>
</template>
</SectionLayout>
</template>
@@ -1,35 +1,78 @@
<script setup>
import { ref, watch } from 'vue';
import { h, ref, watch, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import SectionLayout from './SectionLayout.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import DurationInput from 'next/input/DurationInput.vue';
import TextArea from 'next/textarea/TextArea.vue';
import Switch from 'next/switch/Switch.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import DurationInput from 'next/input/DurationInput.vue';
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
const { t } = useI18n();
const duration = ref(0);
const unit = ref(DURATION_UNITS.MINUTES);
const message = ref('');
const labelToApply = ref({});
const ignoreWaiting = ref(false);
const isEnabled = ref(false);
const isSubmitting = ref(false);
const { currentAccount, updateAccount } = useAccount();
const labels = useMapGetter('labels/getLabels');
const labelOptions = computed(() =>
labels.value?.length
? labels.value.map(label => ({
id: label.title,
name: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
}))
: []
);
const selectedLabelName = computed(() => {
return labelToApply.value?.name ?? null;
});
watch(
currentAccount,
[currentAccount, labelOptions],
() => {
const {
auto_resolve_after,
auto_resolve_message,
auto_resolve_ignore_waiting,
auto_resolve_label,
} = currentAccount.value?.settings || {};
duration.value = auto_resolve_after;
message.value = auto_resolve_message;
ignoreWaiting.value = auto_resolve_ignore_waiting;
// find the correct label option from the list
// the single select component expects the full label object
// in our case, the label id and name are both the same
labelToApply.value = labelOptions.value.find(
option => option.name === auto_resolve_label
);
// Set unit based on duration and its divisibility
if (duration.value) {
if (duration.value % (24 * 60) === 0) {
unit.value = DURATION_UNITS.DAYS;
} else if (duration.value % 60 === 0) {
unit.value = DURATION_UNITS.HOURS;
} else {
unit.value = DURATION_UNITS.MINUTES;
}
}
if (duration.value) {
isEnabled.value = true;
@@ -40,16 +83,19 @@ watch(
const updateAccountSettings = async settings => {
try {
await updateAccount(settings);
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.API.SUCCESS'));
isSubmitting.value = true;
await updateAccount(settings, { silent: true });
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.SUCCESS'));
} catch (error) {
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.API.ERROR'));
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.ERROR'));
} finally {
isSubmitting.value = false;
}
};
const handleSubmit = async () => {
if (duration.value < 10) {
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR'));
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.ERROR'));
return Promise.resolve();
}
@@ -57,6 +103,7 @@ const handleSubmit = async () => {
auto_resolve_after: duration.value,
auto_resolve_message: message.value,
auto_resolve_ignore_waiting: ignoreWaiting.value,
auto_resolve_label: selectedLabelName.value,
});
};
@@ -68,6 +115,7 @@ const handleDisable = async () => {
auto_resolve_after: null,
auto_resolve_message: '',
auto_resolve_ignore_waiting: false,
auto_resolve_label: null,
});
};
@@ -80,6 +128,7 @@ const toggleAutoResolve = async () => {
<SectionLayout
:title="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE')"
:hide-content="!isEnabled"
with-border
>
<template #headerActions>
@@ -90,50 +139,65 @@ const toggleAutoResolve = async () => {
<form class="grid gap-5" @submit.prevent="handleSubmit">
<WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.HELP')"
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.HELP')"
>
<div class="gap-2 w-full grid grid-cols-[3fr_1fr]">
<!-- allow 10 mins to 999 days -->
<DurationInput
v-model="duration"
v-model:unit="unit"
min="0"
max="1439856"
max="1438560"
class="w-full"
/>
</div>
</WithLabel>
<WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_LABEL')"
:help-message="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_HELP')
"
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
>
<TextArea
v-model="message"
class="w-full"
:placeholder="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_PLACEHOLDER')
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
"
/>
</WithLabel>
<WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_IGNORE_WAITING.LABEL')"
>
<template #rightOfLabel>
<Switch v-model="ignoreWaiting" />
</template>
<p class="text-sm ml-px text-n-slate-10 max-w-lg">
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_IGNORE_WAITING.HELP') }}
</p>
<WithLabel :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.PREFERENCES')">
<div
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 divide-y divide-n-weak"
>
<div class="p-3 h-12 flex items-center justify-between">
<span>
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.IGNORE_WAITING.LABEL') }}
</span>
<Switch v-model="ignoreWaiting" />
</div>
<div class="p-3 h-12 flex items-center justify-between">
<span>
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.LABEL') }}
</span>
<SingleSelect
v-model="labelToApply"
:options="labelOptions"
:placeholder="
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.PLACEHOLDER')
"
placeholder-icon="i-lucide-chevron-down"
placeholder-trailing-icon
variant="faded"
/>
</div>
</div>
</WithLabel>
<div class="flex gap-2">
<NextButton
blue
type="submit"
:label="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.UPDATE_BUTTON')
"
:is-loading="isSubmitting"
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.UPDATE_BUTTON')"
/>
</div>
</form>
@@ -1,24 +1,19 @@
<script setup>
defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
withBorder: {
type: Boolean,
default: false,
},
title: { type: String, required: true },
description: { type: String, required: true },
withBorder: { type: Boolean, default: false },
hideContent: { type: Boolean, default: false },
});
</script>
<template>
<section
class="grid grid-cols-1 py-8 gap-8"
:class="{ 'border-t border-n-weak': withBorder }"
class="grid grid-cols-1 pt-8 gap-5 [interpolate-size:allow-keywords]"
:class="{
'border-t border-n-weak': withBorder,
'pb-8': !hideContent,
}"
>
<header class="grid grid-cols-4">
<div class="col-span-3">
@@ -33,7 +28,10 @@ defineProps({
<slot name="headerActions" />
</div>
</header>
<div class="text-n-slate-12">
<div
class="transition-[height] duration-300 ease-in-out text-n-slate-12"
:class="{ 'overflow-hidden h-0': hideContent, 'h-auto': !hideContent }"
>
<slot />
</div>
</section>
@@ -23,6 +23,8 @@ import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default {
components: {
@@ -43,6 +45,7 @@ export default {
NextButton,
InstagramReauthorize,
DuplicateInboxBanner,
Editor,
},
mixins: [inboxMixin],
setup() {
@@ -70,6 +73,7 @@ export default {
selectedTabIndex: 0,
selectedPortalSlug: '',
showBusinessNameInput: false,
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
};
},
computed: {
@@ -480,10 +484,10 @@ export default {
"
/>
<woot-input
<Editor
v-if="isAWebWidgetInbox"
v-model="channelWelcomeTagline"
class="pb-4"
class="mb-4"
:label="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.LABEL')
"
@@ -492,6 +496,8 @@ export default {
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.PLACEHOLDER'
)
"
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
/>
<label v-if="isAWebWidgetInbox" class="pb-4">
@@ -7,13 +7,16 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default {
components: {
Widget,
InputRadioGroup,
NextButton,
Editor,
},
props: {
inbox: {
@@ -71,6 +74,7 @@ export default {
checked: false,
},
],
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
};
},
computed: {
@@ -310,7 +314,7 @@ export default {
)
"
/>
<woot-input
<Editor
v-model="welcomeTagline"
:label="
$t(
@@ -322,6 +326,9 @@ export default {
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_TAGLINE.PLACE_HOLDER'
)
"
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
class="mb-4"
/>
<label>
{{
@@ -5,12 +5,15 @@ import router from '../../../../index';
import NextButton from 'dashboard/components-next/button/Button.vue';
import PageHeader from '../../SettingsSubPageHeader.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default {
components: {
PageHeader,
GreetingsEditor,
NextButton,
Editor,
},
data() {
return {
@@ -21,6 +24,7 @@ export default {
channelWelcomeTagline: '',
greetingEnabled: false,
greetingMessage: '',
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
};
},
computed: {
@@ -134,22 +138,21 @@ export default {
/>
</label>
</div>
<div class="w-full">
<label>
{{
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.LABEL')
}}
<input
v-model="channelWelcomeTagline"
type="text"
:placeholder="
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.PLACEHOLDER'
)
"
/>
</label>
</div>
<Editor
v-model="channelWelcomeTagline"
:label="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.LABEL')
"
:placeholder="
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.PLACEHOLDER'
)
"
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
class="mb-4"
/>
<label class="w-full">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_TOGGLE.LABEL') }}
<select v-model="greetingEnabled">
@@ -63,8 +63,11 @@ export const actions = {
});
}
},
update: async ({ commit }, updateObj) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
update: async ({ commit }, { options, ...updateObj }) => {
if (options?.silent !== true) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
}
try {
const response = await AccountAPI.update('', updateObj);
commit(types.default.EDIT_ACCOUNT, response.data);
@@ -53,6 +53,9 @@ export const getters = {
},
getCurrentAccountId(_, __, rootState) {
// eslint-disable-next-line no-underscore-dangle
if (window.__WOOT_ACCOUNT_ID__) return window.__WOOT_ACCOUNT_ID__;
if (rootState.route.params && rootState.route.params.accountId) {
return Number(rootState.route.params.accountId);
}
@@ -114,7 +117,7 @@ export const actions = {
}
},
async setUser({ commit, dispatch }) {
if (authAPI.hasAuthCookie()) {
if (authAPI.hasAuthCookie() || authAPI.hasAuthToken()) {
await dispatch('validityCheck');
} else {
commit(types.CLEAR_USER);
@@ -75,6 +75,21 @@ export const actions = {
}
},
active: async ({ commit }, { page = 1, sortAttr } = {}) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
try {
const {
data: { payload, meta },
} = await ContactAPI.active(page, sortAttr);
commit(types.CLEAR_CONTACTS);
commit(types.SET_CONTACTS, payload);
commit(types.SET_CONTACT_META, meta);
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
}
},
show: async ({ commit }, { id }) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetchingItem: true });
try {
@@ -327,6 +327,16 @@ const actions = {
}
},
deleteConversation: async ({ commit, dispatch }, conversationId) => {
try {
await ConversationApi.delete(conversationId);
commit(types.DELETE_CONVERSATION, conversationId);
dispatch('conversationStats/get', {}, { root: true });
} catch (error) {
throw new Error(error);
}
},
addConversation({ commit, state, dispatch, rootState }, conversation) {
const { currentInbox, appliedFilters } = state;
const {
@@ -47,6 +47,7 @@
* 3. Nested properties in custom_attributes (conversation_type, etc.)
*/
import jsonLogic from 'json-logic-js';
import { coerceToDate } from '@chatwoot/utils';
/**
* Gets a value from a conversation based on the attribute key
@@ -157,6 +158,20 @@ const contains = (filterValue, conversationValue) => {
return false;
};
/**
* Compares two date values using a comparison function
* @param {*} conversationValue - The conversation value to compare
* @param {*} filterValue - The filter value to compare against
* @param {Function} compareFn - The comparison function to apply
* @returns {Boolean} - Returns true if the comparison succeeds, false otherwise
*/
const compareDates = (conversationValue, filterValue, compareFn) => {
const conversationDate = coerceToDate(conversationValue);
const filterDate = coerceToDate(filterValue);
if (conversationDate === null || filterDate === null) return false;
return compareFn(conversationDate, filterDate);
};
/**
* Checks if a value matches a filter condition
* @param {*} conversationValue - The value to check
@@ -195,10 +210,10 @@ const matchesCondition = (conversationValue, filter) => {
return false; // We already handled null/undefined above
case 'is_greater_than':
return new Date(conversationValue) > new Date(filterValue);
return compareDates(conversationValue, filterValue, (a, b) => a > b);
case 'is_less_than':
return new Date(conversationValue) < new Date(filterValue);
return compareDates(conversationValue, filterValue, (a, b) => a < b);
case 'days_before': {
const today = new Date();
@@ -347,6 +362,7 @@ export const matchesFilters = (conversation, filters) => {
conversation,
filters[0].attribute_key
);
return matchesCondition(value, filters[0]);
}
@@ -463,6 +463,241 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test conversation with 10-digit timestamp (seconds) vs standard date filter
it('should match conversation with 10-digit timestamp against date string filter', () => {
const conversation = { created_at: 1647777600 }; // March 20, 2022 in seconds (10 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19', // Standard YYYY-MM-DD format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test conversation with 13-digit timestamp (milliseconds) vs standard date filter
it('should match conversation with 13-digit timestamp against date string filter', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022 in milliseconds (13 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19', // Standard YYYY-MM-DD format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test conversation with string timestamp vs standard date filter
it('should match conversation with string 10-digit timestamp against date string filter', () => {
const conversation = { created_at: '1647777600' }; // March 20, 2022 as string (10 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19', // Standard YYYY-MM-DD format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test conversation with string 13-digit timestamp vs standard date filter
it('should match conversation with string 13-digit timestamp against date string filter', () => {
const conversation = { created_at: '1647777600000' }; // March 20, 2022 as string (13 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19', // Standard YYYY-MM-DD format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test conversation with mixed format vs standard date filter with time
it('should match conversation with numeric timestamp against ISO date string filter', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022 12:00:00 GMT (numeric)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19T10:30:00Z', // Standard ISO format from filter
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test parseDate with date string without time (should default to 00:00:00)
it('should match conversation with is_greater_than operator using date string without time', () => {
const conversation = { created_at: 1647820800000 }; // March 21, 2022 00:00:00 GMT
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-20', // March 20, 2022 (should become 00:00:00)
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test parseDate with ISO date string
it('should match conversation with is_greater_than operator using ISO date string', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19T00:00:00.000Z', // March 19, 2022 ISO format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test parseDate with null/undefined values
it('should handle null filter values in date comparison', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: null,
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should handle undefined filter values in date comparison', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: undefined,
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
// Test parseDate with invalid date strings
it('should handle invalid date strings in date comparison', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: 'invalid-date-string',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should handle non-date string values in date comparison', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: 'not-a-date',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
// Test is_less_than with various date formats
it('should match conversation with is_less_than operator using numeric timestamp', () => {
const conversation = { created_at: 1647691200000 }; // March 19, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_less_than',
values: 1647777600, // March 20, 2022 as 10-digit timestamp
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should not match conversation with is_less_than operator when date is later', () => {
const conversation = { created_at: 1647864000000 }; // March 21, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_less_than',
values: '2022-03-20T12:00:00Z', // March 20, 2022 with time
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
// Edge case: Test with conversation having string timestamp
it('should handle conversation with string timestamp value', () => {
const conversation = { created_at: '1647777600000' }; // March 20, 2022 as string
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Edge case: Test with conversation having 10-digit timestamp
it('should handle conversation with 10-digit timestamp value', () => {
const conversation = { created_at: 1647777600 }; // March 20, 2022 as seconds (10 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test date string with different time formats
it('should handle date string with space-separated time', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19 10:30:00', // Date with space-separated time
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test parseDate with object input (should return null and fail comparison)
it('should handle non-string, non-number filter values', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: { date: '2022-03-19' }, // Object instead of string/number
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
describe('days_before operator', () => {
beforeEach(() => {
// Set the date to March 25, 2022
@@ -204,6 +204,12 @@ export const mutations = {
_state.allConversations.push(conversation);
},
[types.DELETE_CONVERSATION](_state, conversationId) {
_state.allConversations = _state.allConversations.filter(
c => c.id !== conversationId
);
},
[types.UPDATE_CONVERSATION](_state, conversation) {
const { allConversations } = _state;
const index = allConversations.findIndex(c => c.id === conversation.id);
@@ -70,6 +70,30 @@ describe('#actions', () => {
});
});
describe('#active', () => {
it('sends correct mutations if API is success', async () => {
axios.get.mockResolvedValue({
data: { payload: contactList, meta: { count: 100, current_page: 1 } },
});
await actions.active({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isFetching: true }],
[types.CLEAR_CONTACTS],
[types.SET_CONTACTS, contactList],
[types.SET_CONTACT_META, { count: 100, current_page: 1 }],
[types.SET_CONTACT_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct mutations if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.active({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isFetching: true }],
[types.SET_CONTACT_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#update', () => {
it('sends correct mutations if API is success', async () => {
axios.patch.mockResolvedValue({ data: { payload: contactList[0] } });
@@ -513,6 +513,28 @@ describe('#deleteMessage', () => {
expect(commit.mock.calls).toEqual([]);
});
describe('#deleteConversation', () => {
it('send correct actions if API is success', async () => {
axios.delete.mockResolvedValue({
data: { id: 1 },
});
await actions.deleteConversation({ commit, dispatch }, 1);
expect(commit.mock.calls).toEqual([[types.DELETE_CONVERSATION, 1]]);
expect(dispatch.mock.calls).toEqual([
['conversationStats/get', {}, { root: true }],
]);
});
it('send no actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.deleteConversation({ commit, dispatch }, 1)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([]);
expect(dispatch.mock.calls).toEqual([]);
});
});
describe('#updateCustomAttributes', () => {
it('update conversation custom attributes', async () => {
axios.post.mockResolvedValue({
@@ -884,6 +884,17 @@ describe('#mutations', () => {
});
});
describe('#DELETE_CONVERSATION', () => {
it('should delete a conversation', () => {
const state = {
allConversations: [{ id: 1, messages: [] }],
};
mutations[types.DELETE_CONVERSATION](state, 1);
expect(state.allConversations).toEqual([]);
});
});
describe('#SET_LIST_LOADING_STATUS', () => {
it('should set listLoadingStatus to true', () => {
const state = {
@@ -56,6 +56,7 @@ export default {
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS',
DELETE_CONVERSATION_ATTACHMENTS: 'DELETE_CONVERSATION_ATTACHMENTS',
DELETE_CONVERSATION: 'DELETE_CONVERSATION',
SET_CONVERSATION_CAN_REPLY: 'SET_CONVERSATION_CAN_REPLY',
+56
View File
@@ -0,0 +1,56 @@
import { createApp } from 'vue';
import '../dashboard/assets/scss/app.scss';
import 'vue-multiselect/dist/vue-multiselect.css';
import 'floating-vue/dist/style.css';
// import tailwindStyles from '../dashboard/assets/scss/_woot.scss?inline';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import WootUiKit from 'dashboard/components';
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer.js';
import store from '../dashboard/store';
import constants from '../dashboard/constants/globals';
import axios from 'axios';
import createAxios from '../ui/axios';
import commonHelpers from '../dashboard/helper/commons';
import vueActionCable from '../dashboard/helper/actionCable';
import MessageList from '../ui/MessageList.vue';
import en from '../dashboard/i18n/locale/en';
import { createI18n } from 'vue-i18n';
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en,
},
});
export const init = async () => {
commonHelpers();
// eslint-disable-next-line no-underscore-dangle
window.__CHATWOOT_STORE__ = store;
window.WootConstants = constants;
window.axios = createAxios(axios);
return store.dispatch('setUser').then(() => {
const app = createApp(MessageList);
app.use(store);
app.use(i18n);
app.use(WootUiKit);
app.use(VueDOMPurifyHTML, domPurifyConfig);
// eslint-disable-next-line no-underscore-dangle
vueActionCable.init(store, window.__PUBSUB_TOKEN__);
app.mount('#app');
});
};
if (typeof window.chatwootCallback === 'function') {
window.chatwootCallback(init);
} else {
init();
}
+16 -1
View File
@@ -1,3 +1,4 @@
// scss-lint:disable SpaceAfterPropertyColon
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@@ -7,7 +8,21 @@
html,
body {
font-family: 'InterDisplay', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
font-family:
'InterDisplay',
-apple-system,
system-ui,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Tahoma,
Arial,
sans-serif,
'Noto Sans',
'Apple Color Emoji',
'Segoe UI Emoji',
'Noto Color Emoji';
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
height: 100%;
@@ -37,7 +37,7 @@ export default {
}
if (el.tag === 'h2') {
if (this.h1Count > 0) {
return 'ml-2';
return 'ltr:ml-2 rtl:mr-2';
}
return '';
}
@@ -46,7 +46,7 @@ export default {
if (!this.h1Count && !this.h2Count) {
return '';
}
return 'ml-5';
return 'ltr:ml-5 rtl:mr-5';
}
return '';
@@ -94,17 +94,19 @@ export default {
</script>
<template>
<div class="hidden lg:block flex-1 py-6 scroll-mt-24 pl-4 sticky top-24">
<div
class="hidden lg:block flex-1 py-6 scroll-mt-24 ltr:pl-4 rtl:pr-4 sticky top-24"
>
<div v-if="rows.length > 0" class="py-2 overflow-auto">
<nav class="max-w-2xl">
<ol
role="list"
class="flex flex-col gap-2 text-base border-l-2 border-solid border-slate-100 dark:border-slate-800"
class="flex flex-col gap-2 text-base ltr:border-l-2 rtl:border-r-2 border-solid border-slate-100 dark:border-slate-800"
>
<li
v-for="element in rows"
:key="element.slug"
class="leading-6 border-l-2 relative -left-0.5 border-solid"
class="leading-6 ltr:border-l-2 rtl:border-r-2 relative ltr:-left-0.5 rtl:-right-0.5 border-solid"
:class="elementBorderStyles(element)"
>
<p class="py-1 px-3" :class="getClassName(element)">
+10
View File
@@ -8,6 +8,7 @@ import slugifyWithCounter from '@sindresorhus/slugify';
import PublicArticleSearch from './components/PublicArticleSearch.vue';
import TableOfContents from './components/TableOfContents.vue';
import { initializeTheme } from './portalThemeHelper.js';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
export const getHeadingsfromTheArticle = () => {
const rows = [];
@@ -114,10 +115,19 @@ export const InitializationHelpers = {
});
},
setDirectionAttribute: () => {
const portalElement = document.getElementById('portal');
if (!portalElement) return;
const locale = document.querySelector('.locale-switcher')?.value;
portalElement.dir = locale && getLanguageDirection(locale) ? 'rtl' : 'ltr';
},
initializeThemesInPortal: initializeTheme,
initialize: () => {
openExternalLinksInNewTab();
InitializationHelpers.setDirectionAttribute();
if (window.portalConfig.isPlainLayoutEnabled === 'true') {
InitializationHelpers.appendPlainParamToURLs();
} else {
@@ -0,0 +1,95 @@
# Chatwoot React Components
This module exports Chatwoot UI components as React components that can be embedded in other applications. It uses a hybrid approach combining Vue Web Components with React wrappers.
## Architecture
The system works by:
1. **Vue Components** → Compiled as custom web components using Vue's `customElement: true`
2. **React Wrappers** → Provide React-friendly interfaces to the Vue web components
3. **Vite Build** → Packages everything into distributable ES/CJS modules
## Build System
### Build Modes (vite.config.ts)
- **`BUILD_MODE=react-components`** - Builds React components library
- Entry point: `./app/javascript/react-components/src/index.jsx`
- Output: ES modules (`react-components.es.js`) and CommonJS (`react-components.cjs.js`)
- External dependencies: `react` and `react-dom` (peer dependencies)
### Available Scripts (package.json)
```bash
# Build the React components once
pnpm build:react
# Build and watch for changes during development
pnpm dev:react
# Package for NPM distribution (builds + creates dist/react-components/)
pnpm package:react
```
## Usage
In any React app, version 16+, you could use the following snippet
```jsx
import * as ChatwootComponents from '@chatwoot/agent-react-components';
import '@chatwoot/agent-react-components/style.css'
const { ChatwootProvider, ChatwootConversation } = ChatwootComponents;
function App() {
return (
<ChatwootProvider
baseURL="http://localhost:3000"
accountId="1"
conversationId={13918}
userToken="XLwgvCQiewUJMGuYpZqa4J3M"
pubsubToken="HkAhoq8aWm2ecdY2tn9FV4BU"
websocketURL="ws://localhost:3000"
>
<ChatwootConversation/>
</ChatwootProvider>
)
}
export default App
```
The `<ChatwootProvider>` component ensures all the data necessary for mounting the web component is present before rendering the ChatwootConversation.
The package also exposes a `useChatwoot` hook for accessing the config instance being used by the conversation, it can be used as a read-only source of truth for the config being used.
## Development Workflow
To test this package, you can create a separate React application where you can use the snippet from above, run the following command in your Rails App:
```bash
pnpm dev:react
```
This will start a development server that serves the React components and their dependencies.
Following this, in a separate terminal, run the following command
```bash
watchexec --restart -N --watch public/packs/react-components.es.js "pnpm package:react --copyMode"
```
This will ensure that the built files are copied correctly to the dist/react-components folder.
The `package:react` script has a copy mode, which just copies the build from the `public/` folder to the dist folder for linking purposes. It also creates a package.json file with proper entry points, ensure you run the script without copyMode once before.
Once done, in your React app, you can use `pnpm link <rails-app-dir>/dist/react-components` to link the package. Running the development server will automatically link the package and any changes to it.
## Publishing
The `scripts/package-react-components.js` handles:
- Building the components with Vite
- Copying built files to `dist/react-components/`
- Generating `package.json` with proper entry points
- Creating development versions with git hash suffixes
Once the script is packaged, you can run `npm publish` to publish the package. It will be published as `@chatwoot/agent-react-components` with a version that is the latest commit hash
@@ -0,0 +1,26 @@
import { useChatwoot } from './ChatwootProvider';
import { ChatwootMessageListWrapper } from './ChatwootMessageListWrapper';
export const ChatwootConversation = ({
conversationId,
className = '',
style = {},
...otherProps
}) => {
// Ensure we're inside a ChatwootProvider
useChatwoot(); // This will throw if not in Provider context
return (
<div
className={`chatwoot-conversation ${className} text-n-slate-11`}
style={{
height: '100%',
width: '100%',
position: 'relative',
...style,
}}
>
<ChatwootMessageListWrapper className="h-full w-full" {...otherProps} />
</div>
);
};
@@ -0,0 +1,59 @@
import { useRef, useEffect } from 'react';
export const ChatwootMessageListWrapper = ({
conversationId,
className = '',
style = {},
onError = null,
onLoad = null,
...otherProps
}) => {
const elementRef = useRef();
// Update Web Component props when React props change
useEffect(() => {
if (!elementRef.current) return;
const element = elementRef.current;
// Set conversation ID on the Web Component
element.conversationId = conversationId;
}, [conversationId]);
// Handle Web Component events
useEffect(() => {
if (!elementRef.current) return;
const element = elementRef.current;
const handleLoad = () => {
onLoad?.();
};
const handleError = (event) => {
const errorMessage = event.detail?.message || 'Unknown error occurred';
onError?.(new Error(errorMessage));
};
// Listen for custom events from the Web Component
element.addEventListener('chatwoot:loaded', handleLoad);
element.addEventListener('chatwoot:error', handleError);
return () => {
element.removeEventListener('chatwoot:loaded', handleLoad);
element.removeEventListener('chatwoot:error', handleError);
};
}, [onLoad, onError]);
// Render the Web Component
// Global setup is handled by ChatwootProvider
return (
<chatwoot-message-list
ref={elementRef}
className={className}
style={style}
{...otherProps}
/>
);
};
@@ -0,0 +1,113 @@
import { createContext, useContext, useEffect, useRef, useState } from 'react';
import { registerVueWebComponents } from '../vue-components/registerWebComponents';
import store from '../../../dashboard/store';
import constants from '../../../dashboard/constants/globals';
import axios from 'axios';
import createAxios from '../../../ui/axios';
import commonHelpers from '../../../dashboard/helper/commons';
import vueActionCable from '../../../dashboard/helper/actionCable';
const ChatwootContext = createContext();
export const ChatwootProvider = ({
baseURL,
accountId,
userToken,
websocketURL,
pubsubToken,
conversationId,
disableUpload,
disableEditor,
disablePrivateNote,
children,
}) => {
const isInitialized = useRef(false);
const [initializationComplete, setInitializationComplete] = useState(false);
// Validate required props
if (!baseURL) {
throw new Error('ChatwootProvider: baseURL is required');
}
if (!userToken) {
throw new Error('ChatwootProvider: userToken is required');
}
// Configuration object passed to all child components
const config = {
baseURL: baseURL.replace(/\/$/, ''), // Remove trailing slash
userToken,
accountId: Number(accountId),
conversationId: Number(conversationId),
disableEditor: disableEditor || false,
disablePrivateNote: disablePrivateNote || false,
disableUpload: disableUpload || false,
websocketURL: websocketURL,
pubsubToken: pubsubToken,
};
function initializeChatwootGlobals() {
// Register Web Components
registerVueWebComponents();
// Set up global variables that Vue components expect
/* eslint-disable no-underscore-dangle */
window.__WOOT_API_HOST__ = config.baseURL;
window.__WOOT_ACCOUNT_ID__ = config.accountId;
window.__WOOT_ACCESS_TOKEN__ = config.userToken;
window.__WEBSOCKET_URL__ = config.websocketURL;
window.__PUBSUB_TOKEN__ = config.pubsubToken;
window.__WOOT_CONVERSATION_ID__ = config.conversationId;
window.__EDITOR_DISABLE_UPLOAD__ = config.disableUpload;
window.__EDITOR_DISABLE_PRIVATE_NOTE__ = config.disablePrivateNote;
window.__DISABLE_EDITOR__ = config.disableEditor;
window.__WOOT_ISOLATED_SHELL__ = true;
/* eslint-enable no-underscore-dangle */
// Initialize common helpers
commonHelpers();
// Set up global objects
// eslint-disable-next-line no-underscore-dangle
window.WootConstants = constants;
window.axios = createAxios(axios);
// Initialize user in store and ActionCable
store.dispatch('setUser').then(() => {
vueActionCable.init(store, config.pubsubToken);
setInitializationComplete(true);
});
}
useEffect(() => {
if (isInitialized.current) return;
initializeChatwootGlobals();
isInitialized.current = true;
}, [
config.baseURL,
config.userToken,
config.websocketURL,
config.pubsubToken,
]);
if (!initializationComplete) {
return null;
}
return (
<ChatwootContext.Provider value={config}>
{children}
</ChatwootContext.Provider>
);
};
export const useChatwoot = () => {
const context = useContext(ChatwootContext);
if (!context) {
throw new Error('useChatwoot must be used within ChatwootProvider');
}
return context;
};
// For backwards compatibility and explicit configuration access
export const useChawootConfig = useChatwoot;
@@ -0,0 +1,8 @@
// Export all React components for the Chatwoot React Components library
// Main API components
export { ChatwootProvider, useChatwoot } from './components/ChatwootProvider';
export { ChatwootConversation } from './components/ChatwootConversation';
// Lower-level components (advanced usage)
export { ChatwootMessageListWrapper } from './components/ChatwootMessageListWrapper';
@@ -0,0 +1,49 @@
<script setup>
import MessageList from '../../../ui/MessageList.vue';
</script>
<template>
<MessageList />
</template>
<style>
@import '../../../dashboard/assets/scss/app.scss';
@import 'vue-multiselect/dist/vue-multiselect.css';
@import 'floating-vue/dist/style.css';
.file-uploads {
overflow: hidden;
position: relative;
text-align: center;
display: inline-block;
}
.file-uploads.file-uploads-html4 input,
.file-uploads.file-uploads-html5 label {
/* background fix ie click */
background: #fff;
opacity: 0;
font-size: 20em;
z-index: 1;
top: 0;
left: 0;
right: 0;
bottom: 0;
position: absolute;
width: 100%;
height: 100%;
}
.file-uploads.file-uploads-html5 input,
.file-uploads.file-uploads-html4 label {
/* background fix ie click */
position: absolute;
background: rgba(255, 255, 255, 0);
overflow: hidden;
position: fixed;
width: 1px;
height: 1px;
z-index: -1;
opacity: 0;
}
</style>
@@ -0,0 +1,52 @@
import { defineCustomElement } from 'vue';
import ChatwootMessageListWebComponent from './ChatwootMessageListWebComponent.vue';
import '../../../dashboard/assets/scss/app.scss';
import en from '../../../dashboard/i18n/locale/en';
import store from '../../../dashboard/store';
import vueActionCable from '../../../dashboard/helper/actionCable';
import { createI18n, I18nInjectionKey } from 'vue-i18n';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en,
},
});
const ceOptions = {
configureApp(app) {
app.use(store);
app.use(VueDOMPurifyHTML, domPurifyConfig);
// I18n has to be injected inside that can be picked
// up by the compononent, the API stays the same, just use `useI18n`
// https://vue-i18n.intlify.dev/guide/advanced/wc
// Adding this link for my goldfish brain
app.use(i18n);
app.provide(I18nInjectionKey, i18n);
// eslint-disable-next-line no-underscore-dangle
vueActionCable.init(store, window.__PUBSUB_TOKEN__);
},
};
// Convert Vue components to Web Components
const ChatwootMessageListElement = defineCustomElement(
ChatwootMessageListWebComponent,
ceOptions
);
// Register Web Components
export const registerVueWebComponents = () => {
if (!customElements.get('chatwoot-message-list')) {
customElements.define('chatwoot-message-list', ChatwootMessageListElement);
// eslint-disable-next-line no-console
console.log('✅ Registered chatwoot-message-list Web Component');
}
};
// Export for manual registration if needed
export { ChatwootMessageListElement };
+538
View File
@@ -0,0 +1,538 @@
<script>
import { useTemplateRef } from 'vue';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin from 'shared/mixins/inboxMixin';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
export default {
components: {
AttachmentPreview,
ReplyTopPanel,
ReplyBottomPanel,
WootMessageEditor,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
setup() {
const replyEditor = useTemplateRef('replyEditor');
return { replyEditor };
},
data() {
return {
message: '',
isFocused: false,
attachedFiles: [],
isUploading: false,
replyType: REPLY_EDITOR_MODES.REPLY,
hasSlashCommand: false,
doAutoSaveDraft: () => {},
updateEditorSelectionWith: '',
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
}),
currentContact() {
return this.$store.getters['contacts/getContact'](
this.currentChat.meta.sender.id
);
},
showRichContentEditor() {
return true;
},
isPrivate() {
// if the current chat is not loaded, assume we can reply
// this avoids rendering the editor with is-private yellow bg
// optimisitaclly defaulting to reply editor
if (!this.currentChat || !Object.keys(this.currentChat).length) {
return false;
}
if (this.currentChat.can_reply) {
return this.isOnPrivateNote;
}
return true;
},
inboxId() {
return this.currentChat.inbox_id;
},
inbox() {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
messagePlaceHolder() {
return this.isPrivate
? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT')
: this.$t('CONVERSATION.FOOTER.MSG_INPUT');
},
isMessageLengthReachingThreshold() {
return this.message.length > this.maxLength - 50;
},
charactersRemaining() {
return this.maxLength - this.message.length;
},
isReplyButtonDisabled() {
if (this.hasAttachments || this.hasRecordedAudio) return false;
return (
this.isMessageEmpty ||
this.message.length === 0 ||
this.message.length > this.maxLength
);
},
sender() {
return {
name: this.currentUser.name,
thumbnail: this.currentUser.avatar_url,
};
},
conversationType() {
const { additional_attributes: additionalAttributes } = this.currentChat;
const type = additionalAttributes ? additionalAttributes.type : '';
return type || '';
},
maxLength() {
return MESSAGE_MAX_LENGTH.GENERAL;
},
allowPrivateNote() {
// eslint-disable-next-line no-underscore-dangle
return window.__EDITOR_DISABLE_PRIVATE_NOTE__ !== true;
},
isEditorDisabled() {
// eslint-disable-next-line no-underscore-dangle
return window.__DISABLE_EDITOR__;
},
allowFileUpload() {
// eslint-disable-next-line no-underscore-dangle
return window.__EDITOR_DISABLE_UPLOAD__ !== true;
},
replyButtonLabel() {
if (this.isPrivate) {
return this.$t('CONVERSATION.REPLYBOX.CREATE');
}
return this.$t('CONVERSATION.REPLYBOX.SEND');
},
replyBoxClass() {
return {
'is-private': this.isPrivate && this.allowPrivateNote,
'is-focused': this.isFocused || this.hasAttachments,
'pointer-events-none grayscale opacity-70': this.isEditorDisabled,
};
},
hasAttachments() {
return this.attachedFiles.length;
},
isOnPrivateNote() {
if (!this.allowPrivateNote) return false;
return this.replyType === REPLY_EDITOR_MODES.NOTE;
},
isOnExpandedLayout() {
return false;
},
isMessageEmpty() {
if (!this.message) {
return true;
}
return !this.message.trim().replace(/\n/g, '').length;
},
showReplyHead() {
return !this.isOnPrivateNote;
},
enableMultipleFileUpload() {
return true;
},
editorMessageKey() {
const { editor_message_key: isEnabled } = this.uiSettings;
return isEnabled;
},
conversationId() {
return this.currentChat.id;
},
editorStateId() {
return `draft-${this.conversationId}-${this.replyType}`;
},
},
mounted() {
document.addEventListener('paste', this.onPaste);
document.addEventListener('keydown', this.handleKeyEvents);
// // A hacky fix to solve the drag and drop
// // Is showing on top of new conversation modal drag and drop
// // TODO need to find a better solution
// emitter.on(
// BUS_EVENTS.NEW_CONVERSATION_MODAL,
// this.onNewConversationModalActive
// );
// emitter.on(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
},
unmounted() {
document.removeEventListener('paste', this.onPaste);
document.removeEventListener('keydown', this.handleKeyEvents);
},
methods: {
getElementToBind() {
return this.replyEditor;
},
onPaste(e) {
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput.$el.blur();
}
if (!data.length || !data[0]) {
return;
}
data.forEach(file => {
const { name, type, size } = file;
this.onFileUpload({ name, type, size, file: file });
});
},
confirmOnSendReply() {
if (this.isReplyButtonDisabled) {
return;
}
if (!this.showMentions) {
const messagePayload = this.getMessagePayload(this.message);
this.sendMessage(messagePayload);
this.clearMessage();
}
},
async onSendReply() {
this.confirmOnSendReply();
},
async sendMessage(messagePayload) {
try {
await this.$store.dispatch(
'createPendingMessageAndSend',
messagePayload
);
// emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
// emitter.emit(BUS_EVENTS.MESSAGE_SENT);
} catch (error) {
const errorMessage =
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
useAlert(errorMessage);
}
},
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
if (!this.allowPrivateNote) return;
const { can_reply: canReply } = this.currentChat;
this.$store.dispatch('draftMessages/setReplyEditorMode', {
mode,
});
if (canReply) this.replyType = mode;
this.$nextTick(() => {
// This block addresses a scrolling issue on iOS Safari when the virtual
// keyboard opens and can obscure the focused ProseMirror editor.
// This behavior is primarily an iOS Safari quirk related to how it handles
// the viewport with the virtual keyboard. It's not something we
// directly control or can easily fix at the component levels.
//
// `this.$nextTick()`: Ensures this code runs after Vue has finished its
// DOM update cycle. This is crucial if the editor's visibility or focus
// was just programmatically changed, guaranteeing we operate on the
// finalized DOM.
//
// `setTimeout(() => { ... }, 300)`: A delay is necessary because iOS
// Safari needs time for the virtual keyboard to fully animate into view
// and for the page layout to adjust. Attempting to scroll immediately
// (even after $nextTick) often fails as the keyboard isn't yet fully
// present or the viewport dimensions haven't updated. The 300ms is a
// common heuristic.
//
// The `scrollIntoView()` method then attempts to bring the editor
// into the visible part of the viewport. This manual scroll is a
// pragmatic workaround for this specific iOS Safari behavior.
setTimeout(() => {
document.querySelector('.ProseMirror')?.scrollIntoView();
}, 300);
});
if (this.showRichContentEditor) {
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
return;
}
this.$nextTick(() => this.$refs.messageInput.focus());
},
clearEditorSelection() {
this.updateEditorSelectionWith = '';
},
insertIntoTextEditor(text, selectionStart, selectionEnd) {
const { message } = this;
const newMessage =
message.slice(0, selectionStart) +
text +
message.slice(selectionEnd, message.length);
this.message = newMessage;
},
addIntoEditor(content) {
if (this.showRichContentEditor) {
this.updateEditorSelectionWith = content;
this.onFocus();
}
if (!this.showRichContentEditor) {
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
}
},
clearMessage() {
this.message = '';
this.attachedFiles = [];
this.isRecordingAudio = false;
},
onTypingOn() {
this.toggleTyping('on');
},
onTypingOff() {
this.toggleTyping('off');
},
onBlur() {
this.isFocused = false;
},
onFocus() {
this.isFocused = true;
},
toggleTyping(status) {
const conversationId = this.currentChat.id;
const isPrivate = this.isPrivate;
if (!conversationId) {
return;
}
this.$store.dispatch('conversationTypingStatus/toggleTyping', {
status,
conversationId,
isPrivate,
});
},
attachFile({ blob, file }) {
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
this.attachedFiles.push({
currentChatId: this.currentChat.id,
resource: blob || file,
isPrivate: this.isPrivate,
thumb: reader.result,
blobSignedId: blob ? blob.signed_id : undefined,
isRecordedAudio: file?.isRecordedAudio || false,
});
};
},
removeAttachment(attachments) {
this.attachedFiles = attachments;
},
setReplyToInPayload(payload) {
if (this.inReplyTo?.id) {
return {
...payload,
contentAttributes: {
...payload.contentAttributes,
in_reply_to: this.inReplyTo.id,
},
};
}
return payload;
},
getMessagePayload(message) {
let messagePayload = {
conversationId: this.currentChat.id,
message,
private: this.isPrivate,
sender: this.sender,
};
messagePayload = this.setReplyToInPayload(messagePayload);
if (this.attachedFiles && this.attachedFiles.length) {
messagePayload.files = [];
this.attachedFiles.forEach(attachment => {
if (this.globalConfig.directUploadsEnabled) {
messagePayload.files.push(attachment.blobSignedId);
} else {
messagePayload.files.push(attachment.resource.file);
}
});
}
return messagePayload;
},
getKeyboardEvents() {
return {
'$mod+Enter': {
action: this.onSendReply,
allowOnFocusedInput: true,
},
};
},
},
};
</script>
<template>
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
<ReplyTopPanel
v-if="allowPrivateNote"
:mode="replyType"
disable-popout
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
@set-reply-mode="setReplyMode"
/>
<div class="reply-box__top">
<WootMessageEditor
v-model="message"
:editor-id="editorStateId"
:disabled="isEditorDisabled"
class="input"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
enable-variables
:variables="messageVariables"
:signature="signatureToApply"
allow-signature
:channel-type="channelType"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
@toggle-user-mention="toggleUserMention"
@toggle-canned-menu="toggleCannedMenu"
@toggle-variables-menu="toggleVariablesMenu"
@clear-selection="clearEditorSelection"
/>
</div>
<div
v-if="hasAttachments && !showAudioRecorderEditor"
class="attachment-preview-box"
@paste="onPaste"
>
<AttachmentPreview
class="flex-col mt-4"
:attachments="attachedFiles"
@remove-attachment="removeAttachment"
/>
</div>
<ReplyBottomPanel
:conversation-id="conversationId"
:inbox="inbox"
:is-on-private-note="isOnPrivateNote"
:is-send-disabled="isReplyButtonDisabled"
:mode="replyType"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
:send-button-text="replyButtonLabel"
:show-audio-recorder="showAudioRecorder"
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
:show-emoji-picker="false"
:show-file-upload="allowFileUpload"
:message="message"
:enable-multiple-file-upload="allowFileUpload"
:allow-file-upload="allowFileUpload"
/>
</div>
</template>
<style lang="scss" scoped>
.send-button {
@apply mb-0;
}
.banner--self-assign {
@apply py-2;
}
.attachment-preview-box {
@apply bg-transparent py-0 px-4;
}
.reply-box {
transition: height 2s cubic-bezier(0.37, 0, 0.63, 1);
@apply relative mb-2 mx-2 border border-n-weak rounded-xl bg-n-solid-1;
&.is-private {
@apply bg-n-solid-amber dark:border-n-amber-3/10 border-n-amber-12/5;
}
}
/* in safari the list marker is truncated */
/* We could add, the following, but in Safari, it behaves weirdly with the curosr */
/* ::v-deep .ProseMirror li {
list-style-position: inside !important;
} */
@supports (-webkit-hyphens: none) {
::v-deep .ProseMirror ul {
margin-left: 2ch !important;
}
::v-deep .ProseMirror ol {
margin-left: 2ch !important;
}
}
.send-button {
@apply mb-0;
}
.reply-box__top {
@apply relative py-0 px-4 -mt-px;
textarea {
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
}
}
.emoji-dialog {
@apply top-[unset] -bottom-10 -left-80 right-[unset];
&::before {
transform: rotate(270deg);
filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.08));
@apply -right-4 bottom-2 rtl:right-0 rtl:-left-4;
}
}
.emoji-dialog--rtl {
@apply left-[unset] -right-80;
&::before {
transform: rotate(90deg);
filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.08));
}
}
.emoji-dialog--expanded {
@apply left-[unset] bottom-0 absolute z-[100];
&::before {
transform: rotate(0deg);
@apply left-1 -bottom-2;
}
}
.normal-editor__canned-box {
width: calc(100% - 2 * var(--space-normal));
left: var(--space-normal);
}
</style>
+149
View File
@@ -0,0 +1,149 @@
<script setup>
import { ref, onMounted, computed, watch, useTemplateRef } from 'vue';
import Message from 'next/message/Message.vue';
import TypingIndicator from 'next/message/TypingIndicator.vue';
import Snipper from 'next/spinner/Spinner.vue';
import LiteReplyBox from './LiteReplyBox.vue';
import {
useStore,
useMapGetter,
useFunctionGetter,
//
} from '../dashboard/composables/store';
import { getTypingUsersText } from '../dashboard/helper/commons';
import { useCamelCase } from '../dashboard/composables/useTransformKeys';
import { useInfiniteScroll, useThrottleFn } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
const conversationId = computed(() => {
// eslint-disable-next-line no-underscore-dangle
return window.__WOOT_CONVERSATION_ID__;
});
const { t } = useI18n();
const messageListRef = useTemplateRef('messageListRef');
const store = useStore();
const isAllLoaded = useMapGetter('getAllMessagesLoaded');
const isFetching = ref(false);
const typingUserList = useFunctionGetter(
'conversationTypingStatus/getUserList',
conversationId.value
);
const isAnyoneTyping = computed(() => {
return typingUserList.value.length > 0;
});
const typingUserNames = computed(() => {
if (!isAnyoneTyping.value) return '';
const [i18nKey, params] = getTypingUsersText(typingUserList.value);
// eslint-disable-next-line @intlify/vue-i18n/no-dynamic-keys
return t(i18nKey, params);
});
const conversation = computed(() => {
return store.getters.getConversationById(conversationId.value);
});
const allMessages = computed(() => {
if (!conversation.value) return [];
return useCamelCase(conversation.value.messages, { deep: true }).reverse();
});
const disablePrivateNote = computed(() => {
// eslint-disable-next-line no-underscore-dangle
return window.__EDITOR_DISABLE_PRIVATE_NOTE__ === true;
});
const fetchMore = async () => {
if (isFetching.value) return;
if (!conversation?.value?.id) return;
if (!allMessages.value?.length) return;
try {
isFetching.value = true;
await store.dispatch('fetchPreviousMessages', {
conversationId: conversation.value.id,
before: allMessages.value[allMessages.value.length - 1].id,
});
} finally {
isFetching.value = false;
}
};
onMounted(async () => {
await store.dispatch('inboxes/get');
await Promise.all([
store.dispatch('getConversation', conversationId.value),
store.dispatch('fetchAllAttachments', conversationId.value),
]);
});
watch(conversation, () => {
store.dispatch('setActiveChat', {
data: {
id: conversation.value.id,
},
});
});
useInfiniteScroll(messageListRef, useThrottleFn(fetchMore, 1000), {
canLoadMore: () => {
return !isAllLoaded.value;
},
distance: 10,
direction: 'top',
});
</script>
<template>
<div class="relative">
<ul
ref="messageListRef"
class="px-4 pt-4 flex flex-col-reverse bg-n-background overflow-scroll h-screen"
:class="{
'pb-60': !disablePrivateNote,
'pb-48': disablePrivateNote,
}"
>
<div
v-if="isAnyoneTyping"
id="conversationFooter"
class="my-2 py-2 flex items-center w-full"
>
<div
class="flex py-2 px-4 shadow-md rounded-full bg-white dark:bg-slate-700 text-n-slate-11 text-xs font-semibold mx-auto items-center gap-1"
>
{{ typingUserNames }}
<TypingIndicator class="text-n-slate-9" />
</div>
</div>
<template v-for="message in allMessages" :key="message.id">
<Message
v-bind="message"
:is-email-inbox="isAnEmailChannel"
:group-with-next="false"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
/>
</template>
<div v-show="isFetching" class="w-full py-4">
<Snipper class="mx-auto" />
</div>
</ul>
<div class="p-2 w-full bg-white absolute bottom-0">
<LiteReplyBox />
</div>
</div>
</template>
<style>
/* this will disable elastic scrolling on the y axis */
* {
overscroll-behavior-y: none;
}
</style>
+19
View File
@@ -0,0 +1,19 @@
const parseErrorCode = error => Promise.reject(error);
export default axios => {
// eslint-disable-next-line no-underscore-dangle
const apiHost = window.__WOOT_API_HOST__;
// eslint-disable-next-line no-underscore-dangle
const accessToken = window.__WOOT_ACCESS_TOKEN__;
const wootApi = axios.create({ baseURL: `${apiHost}/` });
Object.assign(wootApi.defaults.headers.common, {
api_access_token: accessToken,
});
wootApi.interceptors.response.use(
response => response,
error => parseErrorCode(error)
);
return wootApi;
};
+46
View File
@@ -0,0 +1,46 @@
# Chatwoot UI Components Usage Guide
This document explains how to use the Chatwoot UI components library, which can be used both as Web Components (Custom Elements) and as Vue components.
## Installation
```bash
npm install @chatwoot/ui
# or
yarn add @chatwoot/ui
# or
pnpm add @chatwoot/ui
```
## Usage
### As Web Components (Custom Elements)
Web Components can be used in any HTML page or application, regardless of the framework.
```html
<!-- Include the built JS file -->
<script src="https://unpkg.com/@chatwoot/ui/dist/ui.js"></script>
<!-- Use the custom element anywhere in your HTML -->
<chat-button label="Click me"></chat-button>
```
When the script loads, it automatically registers all custom elements with the browser, making them immediately available for use.
#### Accessing the Store in Web Components
The components have access to a global store instance. When using Web Components, the store is available through `window.__CHATWOOT_STORE__`.
You can interact with the store from your JavaScript:
```javascript
// Access the store
const store = window.__CHATWOOT_STORE__;
// Check store state
console.log(store.state.someData);
// Dispatch actions
store.dispatch('someAction', { data: 'example' });
```
@@ -1,6 +1,7 @@
<script setup>
import HeaderActions from './HeaderActions.vue';
import { computed } from 'vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
const props = defineProps({
avatarUrl: {
@@ -21,6 +22,8 @@ const props = defineProps({
},
});
const { formatMessage } = useMessageFormatter();
const containerClasses = computed(() => [
props.avatarUrl ? 'justify-between' : 'justify-end',
]);
@@ -47,8 +50,8 @@ const containerClasses = computed(() => [
class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12"
/>
<p
v-dompurify-html="introBody"
class="text-lg leading-normal text-n-slate-11"
v-dompurify-html="formatMessage(introBody)"
class="text-lg leading-normal text-n-slate-11 [&_a]:underline"
/>
</header>
</template>
+1
View File
@@ -8,6 +8,7 @@ class Conversations::ResolutionJob < ApplicationJob
# send message from bot that conversation has been resolved
# do this is account.auto_resolve_message is set
::MessageTemplates::Template::AutoResolve.new(conversation: conversation).perform if account.auto_resolve_message.present?
conversation.add_labels(account.auto_resolve_label) if account.auto_resolve_label.present?
conversation.toggle_status
end
end
@@ -4,7 +4,8 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer
@agent = agent
@conversation = conversation
subject = "#{@agent.available_name}, A new conversation [ID - #{@conversation.display_id}] has been created in #{@conversation.inbox&.name}."
inbox_name = @conversation.inbox&.sanitized_name
subject = "#{@agent.available_name}, A new conversation [ID - #{@conversation.display_id}] has been created in #{inbox_name}."
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
send_mail_with_liquid(to: @agent.email, subject: subject) and return
end

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