Compare commits

...
Author SHA1 Message Date
Shivam Mishra 5b72f3a26b feat: always print build SHA 2026-06-08 17:47:41 +05:30
Shivam Mishra 70e9fbc53c refactor: catch failures 2026-06-04 13:06:54 +05:30
Shivam Mishra 186d9e0457 feat: fetch teams and agents 2026-06-04 13:06:22 +05:30
Shivam Mishra 295bc94fa3 feat: use single inbox id 2026-05-14 20:30:02 +05:30
Shivam Mishra 83e96956fc feat: remove usage of .last 2026-04-23 14:44:52 +05:30
Shivam Mishra 190416d765 test: handle setActiveChat 2026-04-23 12:55:46 +05:30
Shivam Mishra 3bf370dbba chore: remove isolated shell check 2026-04-23 11:47:41 +05:30
Shivam Mishra 5b4d3c1f83 fix: setactivechat last message 2026-04-22 19:05:26 +05:30
iamsivin 475ca365c0 feat: add context menu disable feature 2026-04-21 18:23:05 +05:30
Shivam Mishra 6ff0c67501 fix: useInbox 2026-04-08 21:26:47 +05:30
Shivam Mishra 3bd4570580 feat: move inboxes get to provider 2026-04-08 20:53:08 +05:30
Shivam Mishra fb75f8783f fix: remove multiselect 2026-03-20 17:38:47 +05:30
Shivam Mishra 135357a455 fix: merge conflict issues 2026-03-20 17:35:47 +05:30
Shivam Mishra 5670f38eb4 Merge branch 'develop' of github.com:chatwoot/chatwoot into feat/ui-lib 2026-03-20 17:34:27 +05:30
Sivin VargheseandGitHub 251e9980fd chore: Auto-focus editor when replying to a message (#13857)
# Pull Request Template

## Description

This PR adds support to auto-focus the editor when clicking reply to
this message, the editor now automatically receives focus so users can
start typing immediately.

Fixes
https://linear.app/chatwoot/issue/CW-6661/typing-box-not-focused-after-clicking-reply-to-message

## Type of change


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

## How Has This Been Tested?

### Screencast


https://github.com/user-attachments/assets/c5e77055-3f68-4ad8-934e-cfc465166e8a




## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-03-20 16:59:27 +05:30
Tanmay Deep SharmaandGitHub 2b50909d9b fix: use last_activity_at for orphan conversation cleanup timeframe (#13859)
## Description

The RemoveOrphanConversationsService filters orphan conversations by a
time window before deleting them. Previously it used created_at, which
could miss old conversations that still had recent activity.
Switching to last_activity_at ensures the cleanup window reflects actual
conversation activity rather than creation time.

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

- By running Rake task 
- Run the job from console 

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-03-20 16:28:05 +05:30
Aakash BakhleandGitHub 290dd3abf5 feat: allow captain to access contact attributes (#13850)
# Pull Request Template

## Description

Captain v1 does not have access to contact attributes. Added a toggle to
let user choose if they want contact information available to Captain.

## Type of change

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

## How Has This Been Tested?

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

Specs and locally
<img width="1924" height="740" alt="CleanShot 2026-03-19 at 18 48 19@2x"
src="https://github.com/user-attachments/assets/353cfeaa-cd58-40eb-89e7-d660a1dc1185"
/>

![Uploading CleanShot 2026-03-19 at 18.53.26@2x.png…]()


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-03-20 16:15:06 +05:30
Shivam Mishra 477dad1e8e fix: persistance 2026-03-20 13:32:07 +05:30
Shivam Mishra 6b1389a489 fix: new line 2026-03-19 13:39:18 +05:30
Shivam Mishra b19747a23a fix: correct format for signature 2026-03-19 13:35:18 +05:30
Shivam Mishra 64d0aae672 fix: disable IDB cache 2026-03-19 13:31:16 +05:30
Shivam Mishra 6d3926413b fix: height 2026-03-19 13:10:01 +05:30
Shivam Mishra 3489e21753 fix: signature reaonly toggle 2026-03-19 13:08:24 +05:30
Shivam Mishra 6d541a8d39 feat: force read-only signature 2026-03-19 10:46:22 +05:30
Shivam Mishra 07b9dbc720 feat: force custom signature 2026-03-19 10:31:36 +05:30
Shivam Mishra 7331b0a98d chore: add todo 2026-03-11 14:36:11 +05:30
Shivam Mishra 38d991f19a fix: signature toggle 2026-03-11 14:33:36 +05:30
Shivam Mishra 2f46cf162d feat: allow disabling signature 2026-03-05 16:01:03 +05:30
Shivam Mishra 6a6c3b7c27 fix: mark message read 2026-03-05 15:50:37 +05:30
Shivam Mishra 15c710ec01 feat: allow custom signature 2026-02-26 20:31:15 +05:30
Shivam Mishra 787c99bfd2 fix: useAccount for isolated shell 2026-02-09 16:57:26 +05:30
Shivam MishraandGitHub 76b1769731 Merge branch 'develop' into feat/ui-lib 2026-02-09 16:51:26 +05:30
Shivam Mishra ce5b7e310e fix: h-full 2026-02-04 17:36:12 +05:30
Shivam Mishra 4a2b64cc7c fix: icons 2026-02-04 14:32:14 +05:30
Shivam Mishra 28cc377a8d fix: additional colors 2026-01-16 13:28:56 +05:30
Shivam Mishra ae76548f8e fix: email padding 2026-01-16 13:28:01 +05:30
Shivam Mishra 87e8c50ee1 feat: setup overrides for error and email 2026-01-16 13:24:50 +05:30
Shivam Mishra 8cacf95aae fix: signature not applied 2026-01-15 13:23:03 +05:30
Shivam Mishra e7ae724488 feat: define props 2025-12-22 21:13:25 +05:30
Shivam Mishra 05da860aca feat: force align left 2025-12-22 21:02:29 +05:30
Shivam Mishra 290c2ff4c5 feat: add bubble border 2025-12-22 21:02:07 +05:30
Shivam Mishra 9d95f73ec2 feat: add alignment changes 2025-12-22 20:06:45 +05:30
Shivam Mishra 3396087047 feat: always show avatar when force aligned 2025-12-22 19:47:01 +05:30
Shivam Mishra f6bef53252 feat: add force align to message component 2025-12-22 19:45:35 +05:30
Shivam Mishra c22f8a07c5 chore: revert padding changes 2025-12-22 19:42:36 +05:30
Shivam Mishra e8157a2c0e fix: spacing ratio 2025-12-22 19:36:06 +05:30
Shivam Mishra 7a8b1700ea fix: bubble styles for email 2025-12-22 19:35:57 +05:30
Shivam Mishra c811d698df feat: rebuild pnpm lock 2025-12-22 18:18:38 +05:30
Shivam MishraandGitHub ae9b2daf84 Merge branch 'develop' into feat/ui-lib 2025-12-22 18:17:12 +05:30
Shivam Mishra 73bd05a941 feat: push modal to shadow root in isolated shell mode 2025-12-15 15:39:23 +05:30
Shivam Mishra 9ea746df9a feat: try modal target 2025-12-15 15:22:32 +05:30
Shivam Mishra 44d363f437 feat: always allow gallery 2025-12-15 14:39:45 +05:30
Shivam Mishra 391b139652 feat: use woot modal component directly 2025-12-15 14:37:06 +05:30
Shivam Mishra 55d1e249dc fix: email channel check 2025-12-12 12:45:22 +05:30
Shivam Mishra 852f23f0f5 fix: export 2025-12-11 19:13:17 +05:30
Shivam Mishra 443991078a fix: import 2025-12-11 19:12:10 +05:30
Sivin VargheseandGitHub 5c3ba3ccf2 Merge branch 'develop' into feat/ui-lib 2025-12-11 12:31:46 +05:30
Shivam Mishra f161d8bf23 feat: remove define flag 2025-12-09 20:48:43 +05:30
Shivam Mishra aebb93f88c fix: env 2025-12-09 20:47:52 +05:30
Shivam MishraandGitHub 2a824151cb Merge branch 'develop' into feat/ui-lib 2025-12-09 18:09:56 +05:30
Shivam Mishra 219ce15302 chore: remove currentuserid prop 2025-12-09 17:26:20 +05:30
Shivam Mishra 1c363262b6 feat: allow group with next 2025-12-09 17:26:06 +05:30
Shivam Mishra 1dcafe6e47 chore: remove usage file 2025-12-09 17:11:48 +05:30
Shivam Mishra 491569fa4f chore: revert redundant change 2025-12-09 17:09:04 +05:30
Shivam Mishra c421f75bca chore: remove template to simplify diff 2025-12-09 17:07:46 +05:30
Shivam Mishra 52fc0c683d fix: audio recorder toggle 2025-12-09 17:06:52 +05:30
Shivam Mishra 9145b513c8 fix: allow signature toggle 2025-12-09 17:05:52 +05:30
Shivam Mishra 1f7136860b chore: comments on highlight timer check 2025-12-09 17:03:12 +05:30
Shivam Mishra 3758a07a29 feat: disable gallery for isolated shell 2025-12-09 17:01:02 +05:30
Shivam Mishra de9fea0983 chore: remove redundant changes 2025-12-09 16:57:03 +05:30
Shivam Mishra 11917b7bd7 chore: update pnpm lock 2025-12-09 16:55:37 +05:30
Shivam MishraandGitHub d80247b5ee Merge branch 'develop' into feat/ui-lib 2025-12-09 16:54:04 +05:30
Shivam Mishra c8ade357d7 feat: better spacing 2025-12-01 16:21:31 +05:30
Shivam Mishra c548f71c1a feat: override color and message meta 2025-12-01 16:16:12 +05:30
Shivam Mishra faf6b29626 chore: update docs 2025-12-01 15:54:12 +05:30
Shivam Mishra 6535d19945 feat: allow overrides 2025-12-01 15:53:15 +05:30
Shivam Mishra 1844758848 feat: add doc.md 2025-12-01 15:25:06 +05:30
Shivam Mishra 567092b9a8 feat: try runtime themeing 2025-12-01 15:24:54 +05:30
Shivam Mishra 59e5a5feea chore: update pnpm 2025-12-01 14:12:30 +05:30
Shivam MishraandGitHub 52ce0baef7 Merge branch 'develop' into feat/ui-lib 2025-12-01 14:11:07 +05:30
Shivam Mishra 22a0dac4d6 fix: version suffix 2025-10-22 15:11:55 +05:30
Shivam Mishra 2f215a9cff fix: upload styles not getting included 2025-10-22 15:08:55 +05:30
Shivam Mishra 8f8c7fdfe7 fix: css vars targets only root and not shadow dom host 2025-10-22 15:08:44 +05:30
Shivam Mishra f046d6be32 feat: try separate root and host 2025-10-22 14:56:59 +05:30
Shivam MishraandGitHub 3e5928c8e9 Merge branch 'develop' into feat/ui-lib 2025-10-22 14:30:00 +05:30
Shivam Mishra 1fe5a3c8fb feat: move styles to shadow dom 2025-10-16 13:19:16 +05:30
Shivam Mishra 2b933f0166 feat: use react 18 2025-10-16 11:49:42 +05:30
Shivam Mishra d617266258 feat: update components 2025-10-15 16:18:10 +05:30
Shivam Mishra 65a254705a feat: install packages 2025-10-15 16:16:57 +05:30
Shivam Mishra 1ede5bdeec Merge branch 'feat/ui-lib' of github.com:chatwoot/chatwoot into feat/ui-lib 2025-10-15 16:13:15 +05:30
Shivam MishraandGitHub 3a348d594d Merge branch 'develop' into feat/ui-lib 2025-10-15 15:34:45 +05:30
Shivam MishraandGitHub 6b57264fec Merge branch 'develop' into feat/ui-lib 2025-08-14 20:50:39 +05:30
Muhsin KelothandGitHub 5aaa0400af Merge branch 'develop' into feat/ui-lib 2025-08-14 13:57:46 +05:30
Muhsin KelothandGitHub c9bd109dd8 Merge branch 'develop' into feat/ui-lib 2025-06-26 11:07:34 +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
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
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
85 changed files with 3938 additions and 294 deletions
+4
View File
@@ -94,6 +94,10 @@ yarn-debug.log*
.vscode
.claude/settings.local.json
.cursor
# react component
dist
.codex/
.claude/
CLAUDE.local.md
@@ -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');
@@ -14,7 +14,8 @@ class CacheEnabledApiClient extends ApiClient {
}
get(cache = false) {
if (cache) {
// eslint-disable-next-line no-underscore-dangle
if (cache && !window.__WOOT_ISOLATED_SHELL__) {
return this.getFromCache();
}
+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');
+7 -1
View File
@@ -1,7 +1,13 @@
/* global axios */
import CacheEnabledApiClient from './CacheEnabledApiClient';
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 });
}
@@ -1,7 +1,9 @@
// scss-lint:disable PropertySortOrder
// NEXT COLORS START
@layer base {
// NEXT COLORS START
:root {
:root,
:host {
// slate
--slate-1: 252 252 253;
--slate-2: 249 249 251;
@@ -138,6 +140,48 @@
--label-background: 247 247 247;
--label-border: 0, 0, 0, 0.04;
// Bubble-specific colors (override these for custom theming)
--bubble-agent-bg: var(--solid-blue);
--bubble-agent-text: var(--slate-12);
--bubble-user-bg: var(--slate-4);
--bubble-user-text: var(--slate-12);
--bubble-private-bg: var(--solid-amber);
--bubble-private-text: var(--amber-12);
--bubble-bot-bg: var(--solid-iris);
--bubble-bot-text: var(--slate-12);
// Bubble border customization
--bubble-border-width: 0;
--bubble-agent-border: var(--blue-6);
--bubble-user-border: var(--slate-6);
--bubble-private-border: var(--amber-6);
--bubble-bot-border: var(--iris-6);
// Bubble spacing (ratio of 1 = default, 1.5 = 50% larger, 0.5 = 50% smaller)
--bubble-spacing-ratio: 1;
--bubble-radius: calc(0.75rem * var(--bubble-spacing-ratio));
--bubble-radius-sm: calc(0.125rem * var(--bubble-spacing-ratio));
--bubble-radius-md: calc(0.375rem * var(--bubble-spacing-ratio));
--bubble-radius-lg: calc(0.5rem * var(--bubble-spacing-ratio));
--bubble-padding-x: calc(1rem * var(--bubble-spacing-ratio));
--bubble-padding-y: calc(0.75rem * var(--bubble-spacing-ratio));
// Bubble meta text colors (per variant)
--bubble-agent-meta: var(--slate-11);
--bubble-user-meta: var(--slate-11);
--bubble-private-meta: var(--amber-12);
--bubble-bot-meta: var(--slate-11);
// Message status colors (per variant)
--bubble-agent-status: var(--slate-10);
--bubble-agent-status-read: 126 182 255;
--bubble-user-status: var(--slate-10);
--bubble-user-status-read: 126 182 255;
--bubble-private-status: var(--amber-11);
--bubble-private-status-read: 126 182 255;
--bubble-bot-status: var(--slate-10);
--bubble-bot-status-read: 126 182 255;
--alpha-1: 215, 215, 215, 0.22;
--alpha-2: 196, 197, 198, 0.22;
--alpha-3: 255, 255, 255, 0.96;
@@ -147,7 +191,8 @@
--white-alpha: 255, 255, 255, 0.8;
}
.dark {
.dark,
:host(.dark) {
// slate
--slate-1: 17 17 19;
--slate-2: 24 25 27;
@@ -283,6 +328,48 @@
--label-background: 36 38 45;
--label-border: 255, 255, 255, 0.03;
// Bubble-specific colors (override these for custom theming)
--bubble-agent-bg: var(--solid-blue);
--bubble-agent-text: var(--slate-12);
--bubble-user-bg: var(--slate-4);
--bubble-user-text: var(--slate-12);
--bubble-private-bg: var(--solid-amber);
--bubble-private-text: var(--amber-12);
--bubble-bot-bg: var(--solid-iris);
--bubble-bot-text: var(--slate-12);
// Bubble border customization
--bubble-border-width: 0;
--bubble-agent-border: var(--blue-6);
--bubble-user-border: var(--slate-6);
--bubble-private-border: var(--amber-6);
--bubble-bot-border: var(--iris-6);
// Bubble spacing (inherited from light mode, can be overridden)
--bubble-spacing-ratio: 1;
--bubble-radius: calc(0.75rem * var(--bubble-spacing-ratio));
--bubble-radius-sm: calc(0.125rem * var(--bubble-spacing-ratio));
--bubble-radius-md: calc(0.375rem * var(--bubble-spacing-ratio));
--bubble-radius-lg: calc(0.5rem * var(--bubble-spacing-ratio));
--bubble-padding-x: calc(1rem * var(--bubble-spacing-ratio));
--bubble-padding-y: calc(0.75rem * var(--bubble-spacing-ratio));
// Bubble meta text colors (per variant)
--bubble-agent-meta: var(--slate-11);
--bubble-user-meta: var(--slate-11);
--bubble-private-meta: var(--amber-12);
--bubble-bot-meta: var(--slate-11);
// Message status colors (per variant)
--bubble-agent-status: var(--slate-10);
--bubble-agent-status-read: 126 182 255;
--bubble-user-status: var(--slate-10);
--bubble-user-status-read: 126 182 255;
--bubble-private-status: var(--amber-11);
--bubble-private-status-read: 126 182 255;
--bubble-bot-status: var(--slate-10);
--bubble-bot-status-read: 126 182 255;
--alpha-1: 35, 36, 42, 0.8;
--alpha-2: 147, 153, 176, 0.12;
--alpha-3: 33, 34, 38, 0.95;
@@ -293,4 +380,3 @@
--white-alpha: 255, 255, 255, 0.1;
}
}
// NEXT COLORS END
@@ -27,6 +27,7 @@ const initialState = {
conversationFaqs: false,
memories: false,
citations: false,
contactAttributes: false,
},
};
@@ -59,6 +60,7 @@ const updateStateFromAssistant = assistant => {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
citations: config.feature_citation || false,
contactAttributes: config.feature_contact_attributes || false,
};
};
@@ -79,6 +81,7 @@ const handleBasicInfoUpdate = async () => {
feature_faq: state.features.conversationFaqs,
feature_memory: state.features.memories,
feature_citation: state.features.citations,
feature_contact_attributes: state.features.contactAttributes,
},
};
@@ -138,6 +141,10 @@ watch(
<input v-model="state.features.citations" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
</label>
<label class="flex items-center gap-2">
<input v-model="state.features.contactAttributes" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONTACT_ATTRIBUTES') }}
</label>
</div>
</div>
@@ -134,6 +134,11 @@ const props = defineProps({
senderId: { type: Number, default: null },
senderType: { type: String, default: null },
sourceId: { type: String, default: '' }, // eslint-disable-line vue/no-unused-properties
forceAlignTo: {
type: String,
default: null,
validator: value => value === null || ['left', 'right'].includes(value),
},
});
const emit = defineEmits(['retry']);
@@ -226,12 +231,16 @@ const isBotOrAgentMessage = computed(() => {
* @returns {import('vue').ComputedRef<'left'|'right'|'center'>} The computed orientation
*/
const orientation = computed(() => {
if (props.messageType === MESSAGE_TYPES.ACTIVITY) return ORIENTATION.CENTER;
if (props.forceAlignTo) {
return props.forceAlignTo === 'left' ? ORIENTATION.LEFT : ORIENTATION.RIGHT;
}
if (isBotOrAgentMessage.value) {
return ORIENTATION.RIGHT;
}
if (props.messageType === MESSAGE_TYPES.ACTIVITY) return ORIENTATION.CENTER;
return ORIENTATION.LEFT;
});
@@ -246,6 +255,10 @@ const flexOrientationClass = computed(() => {
});
const gridClass = computed(() => {
if (props.forceAlignTo) {
return 'grid gap-y-2';
}
const map = {
[ORIENTATION.LEFT]: 'grid grid-cols-1fr',
[ORIENTATION.RIGHT]: 'grid grid-cols-[1fr_24px]',
@@ -255,6 +268,14 @@ const gridClass = computed(() => {
});
const gridTemplate = computed(() => {
if (props.forceAlignTo) {
return `
"avatar"
"bubble"
"meta"
`;
}
const map = {
[ORIENTATION.LEFT]: `
"bubble"
@@ -277,11 +298,19 @@ const shouldGroupWithNext = computed(() => {
const shouldShowAvatar = computed(() => {
if (props.messageType === MESSAGE_TYPES.ACTIVITY) return false;
if (props.forceAlignTo) return true;
if (orientation.value === ORIENTATION.LEFT) return false;
return true;
});
const shouldShowSenderName = computed(() => {
if (props.messageType === MESSAGE_TYPES.ACTIVITY) return false;
if (!props.forceAlignTo) return false;
return true;
});
const componentToRender = computed(() => {
if (props.isEmailInbox && !props.private) {
const emailInboxTypes = [MESSAGE_TYPES.INCOMING, MESSAGE_TYPES.OUTGOING];
@@ -344,6 +373,8 @@ const componentToRender = computed(() => {
});
const shouldShowContextMenu = computed(() => {
// eslint-disable-next-line no-underscore-dangle
if (window.__WOOT_DISABLE_CONTEXT_MENU__) return false;
return !props.contentAttributes?.isUnsupported;
});
@@ -410,6 +441,12 @@ const shouldRenderMessage = computed(() => {
});
function openContextMenu(e) {
// eslint-disable-next-line no-underscore-dangle
if (window.__WOOT_DISABLE_CONTEXT_MENU__) {
e.preventDefault();
return;
}
const shouldSkipContextMenu =
e.target?.classList.contains('skip-context-menu') ||
['a', 'img'].includes(e.target?.tagName.toLowerCase());
@@ -490,7 +527,8 @@ const avatarTooltip = computed(() => {
});
const setupHighlightTimer = () => {
if (Number(route.query.messageId) !== Number(props.id)) {
// This is a defensive check, since in the ISOLATED shell mode, there might be no route.query
if (Number(route?.query?.messageId) !== Number(props.id)) {
return;
}
@@ -548,15 +586,23 @@ provideMessageContext({
<div
v-if="!shouldGroupWithNext && shouldShowAvatar"
v-tooltip.left-end="avatarTooltip"
class="[grid-area:avatar] flex items-end"
class="[grid-area:avatar] flex items-end gap-2"
>
<Avatar v-bind="avatarInfo" :size="24" />
<span
v-if="shouldShowSenderName"
class="text-sm font-semibold text-n-slate-12 pb-0.5"
>
{{ avatarInfo.name }}
</span>
</div>
<div
class="[grid-area:bubble] flex"
:class="{
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
'ltr:ml-8 rtl:mr-8 justify-end':
!props.forceAlignTo && orientation === ORIENTATION.RIGHT,
'ltr:mr-8 rtl:ml-8':
!props.forceAlignTo && orientation === ORIENTATION.LEFT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}"
@contextmenu="openContextMenu($event)"
@@ -1,7 +1,7 @@
<script setup>
import { defineProps, computed, reactive } from 'vue';
import Message from './Message.vue';
import { MESSAGE_TYPES } from './constants.js';
import shouldGroupWithNext from './groupWithNext.js';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useMapGetter } from 'dashboard/composables/store.js';
import MessageApi from 'dashboard/api/inbox/message.js';
@@ -90,39 +90,6 @@ const fetchReplyMessage = async (messageId, conversationId) => {
}
};
/**
* Determines if a message should be grouped with the next message
* @param {Number} index - Index of the current message
* @param {Array} searchList - Array of messages to check
* @returns {Boolean} - Whether the message should be grouped with next
*/
const shouldGroupWithNext = (index, searchList) => {
if (index === searchList.length - 1) return false;
const current = searchList[index];
const next = searchList[index + 1];
if (next.status === 'failed') return false;
const nextSenderId = next.senderId ?? next.sender?.id;
const currentSenderId = current.senderId ?? current.sender?.id;
const hasSameSender = nextSenderId === currentSenderId;
const nextMessageType = next.messageType;
const currentMessageType = current.messageType;
const areBothTemplates =
nextMessageType === MESSAGE_TYPES.TEMPLATE &&
currentMessageType === MESSAGE_TYPES.TEMPLATE;
if (!hasSameSender || areBothTemplates) return false;
if (currentMessageType !== nextMessageType) return false;
// Check if messages are in the same minute by rounding down to nearest minute
return Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60);
};
/**
* Gets the message that was replied to
* @param {Object} parentMessage - The message containing the reply reference
@@ -9,6 +9,21 @@ import { useMessageContext } from './provider.js';
import { MESSAGE_STATUS, MESSAGE_TYPES } from './constants';
const {
status,
isPrivate,
createdAt,
sourceId,
messageType,
contentAttributes,
inboxId,
} = useMessageContext();
// Resolve channel type from the message's own inbox so that the status
// indicator does not depend on `getSelectedChat` being populated. In the
// Agent SDK shell `setActiveChat` runs from a watcher in MessageList and can
// race with the first render of MessageMeta, leaving `currentChat` empty and
// every channel check returning false (the spinner stays forever).
const {
isAFacebookInbox,
isALineChannel,
@@ -21,16 +36,7 @@ const {
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
} = useInbox();
const {
status,
isPrivate,
createdAt,
sourceId,
messageType,
contentAttributes,
} = useMessageContext();
} = useInbox(inboxId.value);
const readableTime = computed(() =>
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
@@ -2,7 +2,8 @@
import { computed, ref } from 'vue';
import { useIntervalFn } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { MESSAGE_STATUS } from './constants';
import { MESSAGE_STATUS, MESSAGE_VARIANTS } from './constants';
import { useMessageContext } from './provider.js';
import Icon from 'next/icon/Icon.vue';
@@ -15,6 +16,19 @@ const { status } = defineProps({
});
const { t } = useI18n();
const { variant } = useMessageContext();
const variantPrefix = computed(() => {
const prefixMap = {
[MESSAGE_VARIANTS.AGENT]: 'agent',
[MESSAGE_VARIANTS.USER]: 'user',
[MESSAGE_VARIANTS.PRIVATE]: 'private',
[MESSAGE_VARIANTS.BOT]: 'bot',
[MESSAGE_VARIANTS.TEMPLATE]: 'bot',
[MESSAGE_VARIANTS.EMAIL]: 'agent',
};
return prefixMap[variant.value] || 'agent';
});
const progresIconSequence = [
'i-lucide-clock-1',
@@ -54,14 +68,44 @@ const statusIcon = computed(() => {
return statusIconMap[status];
});
const statusColor = computed(() => {
const statusIconMap = {
[MESSAGE_STATUS.SENT]: 'text-n-slate-10',
[MESSAGE_STATUS.DELIVERED]: 'text-n-slate-10',
[MESSAGE_STATUS.READ]: 'text-[#7EB6FF]',
};
const statusColorMap = {
agent: {
[MESSAGE_STATUS.SENT]: 'text-[rgb(var(--bubble-agent-status))]',
[MESSAGE_STATUS.DELIVERED]: 'text-[rgb(var(--bubble-agent-status))]',
[MESSAGE_STATUS.READ]: 'text-[rgb(var(--bubble-agent-status-read))]',
},
user: {
[MESSAGE_STATUS.SENT]: 'text-[rgb(var(--bubble-user-status))]',
[MESSAGE_STATUS.DELIVERED]: 'text-[rgb(var(--bubble-user-status))]',
[MESSAGE_STATUS.READ]: 'text-[rgb(var(--bubble-user-status-read))]',
},
private: {
[MESSAGE_STATUS.SENT]: 'text-[rgb(var(--bubble-private-status))]',
[MESSAGE_STATUS.DELIVERED]: 'text-[rgb(var(--bubble-private-status))]',
[MESSAGE_STATUS.READ]: 'text-[rgb(var(--bubble-private-status-read))]',
},
bot: {
[MESSAGE_STATUS.SENT]: 'text-[rgb(var(--bubble-bot-status))]',
[MESSAGE_STATUS.DELIVERED]: 'text-[rgb(var(--bubble-bot-status))]',
[MESSAGE_STATUS.READ]: 'text-[rgb(var(--bubble-bot-status-read))]',
},
};
return statusIconMap[status];
const statusColor = computed(() => {
const variantMap =
statusColorMap[variantPrefix.value] || statusColorMap.agent;
return variantMap[status];
});
const progressColorMap = {
agent: 'text-[rgb(var(--bubble-agent-status))]',
user: 'text-[rgb(var(--bubble-user-status))]',
private: 'text-[rgb(var(--bubble-private-status))]',
bot: 'text-[rgb(var(--bubble-bot-status))]',
};
const progressColor = computed(() => {
return progressColorMap[variantPrefix.value] || progressColorMap.agent;
});
const tooltipText = computed(() => {
@@ -81,7 +125,7 @@ const tooltipText = computed(() => {
v-if="status === MESSAGE_STATUS.PROGRESS"
v-tooltip.top-start="tooltipText"
:icon="progessIcon"
class="text-n-slate-10"
:class="progressColor"
/>
<Icon
v-else
@@ -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>
@@ -3,19 +3,31 @@ import { computed } from 'vue';
import BaseBubble from './Base.vue';
import AudioChip from 'next/message/chips/Audio.vue';
import { useMessageContext } from '../provider.js';
import { MESSAGE_VARIANTS } from '../constants';
const { attachments } = useMessageContext();
const { attachments, variant } = useMessageContext();
const attachment = computed(() => {
return attachments.value[0];
});
const textClass = computed(() => {
const textClassMap = {
[MESSAGE_VARIANTS.AGENT]: 'text-[rgb(var(--bubble-agent-text))]',
[MESSAGE_VARIANTS.USER]: 'text-[rgb(var(--bubble-user-text))]',
[MESSAGE_VARIANTS.PRIVATE]: 'text-[rgb(var(--bubble-private-text))]',
[MESSAGE_VARIANTS.BOT]: 'text-[rgb(var(--bubble-bot-text))]',
};
return textClassMap[variant.value] || 'text-[rgb(var(--bubble-agent-text))]';
});
</script>
<template>
<BaseBubble class="bg-transparent" data-bubble-name="audio">
<AudioChip
:attachment="attachment"
class="p-2 text-n-slate-12 skip-context-menu"
class="p-2 skip-context-menu"
:class="textClass"
/>
</BaseBubble>
</template>
@@ -20,25 +20,30 @@ const { variant, orientation, inReplyTo, shouldGroupWithNext } =
const { t } = useI18n();
const varaintBaseMap = {
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
[MESSAGE_VARIANTS.AGENT]:
'bg-[rgb(var(--bubble-agent-bg))] text-[rgb(var(--bubble-agent-text))] border-[length:var(--bubble-border-width)] border-[rgb(var(--bubble-agent-border))]',
[MESSAGE_VARIANTS.PRIVATE]:
'bg-n-solid-amber text-n-amber-12 [&_.prosemirror-mention-node]:font-semibold',
[MESSAGE_VARIANTS.USER]: 'bg-n-slate-4 text-n-slate-12',
'bg-[rgb(var(--bubble-private-bg))] text-[rgb(var(--bubble-private-text))] border-[length:var(--bubble-border-width)] border-[rgb(var(--bubble-private-border))] [&_.prosemirror-mention-node]:font-semibold',
[MESSAGE_VARIANTS.USER]:
'bg-[rgb(var(--bubble-user-bg))] text-[rgb(var(--bubble-user-text))] border-[length:var(--bubble-border-width)] border-[rgb(var(--bubble-user-border))]',
[MESSAGE_VARIANTS.ACTIVITY]: 'bg-n-alpha-1 text-n-slate-11 text-sm',
[MESSAGE_VARIANTS.BOT]: 'bg-n-solid-iris text-n-slate-12',
[MESSAGE_VARIANTS.TEMPLATE]: 'bg-n-solid-iris text-n-slate-12',
[MESSAGE_VARIANTS.ERROR]: 'bg-n-ruby-4 text-n-ruby-12',
[MESSAGE_VARIANTS.BOT]:
'bg-[rgb(var(--bubble-bot-bg))] text-[rgb(var(--bubble-bot-text))] border-[length:var(--bubble-border-width)] border-[rgb(var(--bubble-bot-border))]',
[MESSAGE_VARIANTS.TEMPLATE]:
'bg-[rgb(var(--bubble-bot-bg))] text-[rgb(var(--bubble-bot-text))] border-[length:var(--bubble-border-width)] border-[rgb(var(--bubble-bot-border))]',
[MESSAGE_VARIANTS.ERROR]:
'bg-[rgb(var(--bubble-error-bg))] text-[rgb(var(--bubble-error-text))] border-[length:var(--bubble-border-width)] border-[rgb(var(--bubble-error-border))]',
[MESSAGE_VARIANTS.EMAIL]: 'w-full',
[MESSAGE_VARIANTS.UNSUPPORTED]:
'bg-n-solid-amber/70 border border-dashed border-n-amber-12 text-n-amber-12',
'bg-[rgb(var(--bubble-unsupported-bg))]/70 border border-dashed border-[rgb(var(--bubble-unsupported-border))] text-[rgb(var(--bubble-unsupported-text))]',
};
const orientationMap = {
[ORIENTATION.LEFT]:
'left-bubble rounded-xl ltr:rounded-bl-sm rtl:rounded-br-sm',
'left-bubble rounded-[var(--bubble-radius)] ltr:rounded-bl-[var(--bubble-radius-sm)] rtl:rounded-br-[var(--bubble-radius-sm)]',
[ORIENTATION.RIGHT]:
'right-bubble rounded-xl ltr:rounded-br-sm rtl:rounded-bl-sm',
[ORIENTATION.CENTER]: 'rounded-md',
'right-bubble rounded-[var(--bubble-radius)] ltr:rounded-br-[var(--bubble-radius-sm)] rtl:rounded-bl-[var(--bubble-radius-sm)]',
[ORIENTATION.CENTER]: 'rounded-[var(--bubble-radius-md)]',
};
const flexOrientationClass = computed(() => {
@@ -57,7 +62,7 @@ const messageClass = computed(() => {
if (variant.value !== MESSAGE_VARIANTS.ACTIVITY) {
classToApply.push(orientationMap[orientation.value]);
} else {
classToApply.push('rounded-lg');
classToApply.push('rounded-[var(--bubble-radius-lg)]');
}
return classToApply;
@@ -76,6 +81,20 @@ const shouldShowMeta = computed(
variant.value !== MESSAGE_VARIANTS.ACTIVITY
);
const metaColorClass = computed(() => {
const metaClassMap = {
[MESSAGE_VARIANTS.AGENT]: 'text-[rgb(var(--bubble-agent-meta))]',
[MESSAGE_VARIANTS.USER]: 'text-[rgb(var(--bubble-user-meta))]',
[MESSAGE_VARIANTS.PRIVATE]:
'text-[rgb(var(--bubble-private-meta))] opacity-50',
[MESSAGE_VARIANTS.BOT]: 'text-[rgb(var(--bubble-bot-meta))]',
[MESSAGE_VARIANTS.TEMPLATE]: 'text-[rgb(var(--bubble-bot-meta))]',
[MESSAGE_VARIANTS.EMAIL]: 'text-[rgb(var(--bubble-agent-meta))]',
[MESSAGE_VARIANTS.ERROR]: 'text-[rgb(var(--bubble-error-meta))]',
};
return metaClassMap[variant.value] || 'text-[rgb(var(--bubble-agent-meta))]';
});
const replyToPreview = computed(() => {
if (!inReplyTo) return '';
@@ -119,9 +138,7 @@ const replyToPreview = computed(() => {
:class="[
flexOrientationClass,
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
variant === MESSAGE_VARIANTS.PRIVATE
? 'text-n-amber-12/50'
: 'text-n-slate-11',
metaColorClass,
]"
class="mt-2"
/>
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
import BaseBubble from './Base.vue';
import Icon from 'next/icon/Icon.vue';
import { useMessageContext } from '../provider.js';
import { MESSAGE_VARIANTS } from '../constants';
defineProps({
icon: { type: [String, Object], required: true },
@@ -20,16 +21,39 @@ defineProps({
},
});
const { sender } = useMessageContext();
const { sender, variant } = useMessageContext();
const { t } = useI18n();
const senderName = computed(() => {
return sender?.value?.name || '';
});
const primaryTextClass = computed(() => {
const classMap = {
[MESSAGE_VARIANTS.AGENT]: 'text-[rgb(var(--bubble-agent-text))]',
[MESSAGE_VARIANTS.USER]: 'text-[rgb(var(--bubble-user-text))]',
[MESSAGE_VARIANTS.PRIVATE]: 'text-[rgb(var(--bubble-private-text))]',
[MESSAGE_VARIANTS.BOT]: 'text-[rgb(var(--bubble-bot-text))]',
};
return classMap[variant.value] || 'text-[rgb(var(--bubble-agent-text))]';
});
const secondaryTextClass = computed(() => {
const classMap = {
[MESSAGE_VARIANTS.AGENT]: 'text-[rgb(var(--bubble-agent-meta))]',
[MESSAGE_VARIANTS.USER]: 'text-[rgb(var(--bubble-user-meta))]',
[MESSAGE_VARIANTS.PRIVATE]: 'text-[rgb(var(--bubble-private-meta))]',
[MESSAGE_VARIANTS.BOT]: 'text-[rgb(var(--bubble-bot-meta))]',
};
return classMap[variant.value] || 'text-[rgb(var(--bubble-agent-meta))]';
});
</script>
<template>
<BaseBubble class="overflow-hidden p-3" data-bubble-name="attachment">
<BaseBubble
class="overflow-hidden p-[var(--bubble-padding-y)]"
data-bubble-name="attachment"
>
<div class="grid gap-4 min-w-64">
<div class="grid gap-3">
<div
@@ -41,7 +65,11 @@ const senderName = computed(() => {
</slot>
</div>
<div class="space-y-1 overflow-hidden">
<div v-if="senderName" class="text-n-slate-12 text-sm truncate">
<div
v-if="senderName"
class="text-sm truncate"
:class="primaryTextClass"
>
{{
t(senderTranslationKey, {
sender: senderName,
@@ -49,10 +77,18 @@ const senderName = computed(() => {
}}
</div>
<slot>
<div v-if="title" class="truncate text-sm text-n-slate-12">
<div
v-if="title"
class="truncate text-sm"
:class="primaryTextClass"
>
{{ title }}
</div>
<div v-if="content" class="truncate text-sm text-n-slate-11">
<div
v-if="content"
class="truncate text-sm"
:class="secondaryTextClass"
>
{{ content }}
</div>
</slot>
@@ -40,7 +40,10 @@ const starRatingValue = computed(() => {
</script>
<template>
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
<BaseBubble
class="px-[var(--bubble-padding-x)] py-[var(--bubble-padding-y)]"
data-bubble-name="csat"
>
<h4>{{ content || t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
<dl v-if="isRatingSubmitted" class="mt-4">
<dt class="text-n-slate-11 italic">
@@ -1,14 +1,32 @@
<script setup>
import { computed } from 'vue';
import { MESSAGE_STATUS } from '../../constants';
import { MESSAGE_STATUS, MESSAGE_TYPES } from '../../constants';
import { useMessageContext } from '../../provider.js';
const { contentAttributes, status, sender } = useMessageContext();
const { contentAttributes, status, sender, messageType } = useMessageContext();
const hasError = computed(() => {
return status.value === MESSAGE_STATUS.FAILED;
});
const isIncoming = computed(() => {
return messageType.value === MESSAGE_TYPES.INCOMING;
});
const secondaryTextClass = computed(() => {
if (hasError.value) return 'text-[rgb(var(--bubble-error-meta))]';
return isIncoming.value
? 'text-[rgb(var(--bubble-user-meta))]'
: 'text-[rgb(var(--bubble-agent-meta))]';
});
const primaryTextClass = computed(() => {
if (hasError.value) return 'text-[rgb(var(--bubble-error-text))]';
return isIncoming.value
? 'text-[rgb(var(--bubble-user-text))]'
: 'text-[rgb(var(--bubble-agent-text))]';
});
const fromEmail = computed(() => {
return contentAttributes.value?.email?.from ?? [];
});
@@ -68,13 +86,10 @@ const showMeta = computed(() => {
<section
v-show="showMeta"
class="space-y-1 rtl:pl-9 ltr:pr-9 text-sm break-words"
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-11'"
:class="secondaryTextClass"
>
<template v-if="showMeta">
<div
v-if="fromEmail[0]"
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-12'"
>
<div v-if="fromEmail[0]" :class="primaryTextClass">
<template v-if="senderName">
<span>
{{ senderName }}
@@ -104,21 +104,26 @@ const handleSeeOriginal = () => {
<template>
<BaseBubble
class="w-full"
class="w-full border-[length:var(--bubble-border-width)]"
:class="{
'bg-n-slate-4': isIncoming,
'bg-n-solid-blue': isOutgoing,
'bg-[rgb(var(--bubble-user-bg))] text-[rgb(var(--bubble-user-text))] border-[rgb(var(--bubble-user-border))]':
isIncoming,
'bg-[rgb(var(--bubble-agent-bg))] text-[rgb(var(--bubble-agent-text))] border-[rgb(var(--bubble-agent-border))]':
isOutgoing,
}"
data-bubble-name="email"
>
<EmailMeta
class="p-3"
class="px-[var(--bubble-padding-x)] py-[var(--bubble-padding-y)]"
:class="{
'border-b border-n-strong': isIncoming,
'border-b border-n-slate-8/20': isOutgoing,
'border-b border-[rgb(var(--bubble-user-border))]': isIncoming,
'border-b border-[rgb(var(--bubble-agent-border))]': isOutgoing,
}"
/>
<section ref="contentContainer" class="p-3">
<section
ref="contentContainer"
class="px-[var(--bubble-padding-x)] py-[var(--bubble-padding-y)]"
>
<div
:class="{
'max-h-[400px] overflow-hidden relative': !isExpanded && isExpandable,
@@ -129,14 +134,18 @@ const handleSeeOriginal = () => {
v-if="isExpandable && !isExpanded"
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end"
:class="{
'bg-gradient-to-t from-n-slate-4 via-n-slate-4 via-20% to-transparent':
'bg-gradient-to-t from-[rgb(var(--bubble-user-bg))] via-[rgb(var(--bubble-user-bg))] via-20% to-transparent':
isIncoming,
'bg-gradient-to-t from-n-solid-blue via-n-solid-blue via-20% to-transparent':
'bg-gradient-to-t from-[rgb(var(--bubble-agent-bg))] via-[rgb(var(--bubble-agent-bg))] via-20% to-transparent':
isOutgoing,
}"
>
<button
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
class="py-2 px-8 mx-auto text-center flex items-center gap-2"
:class="{
'text-[rgb(var(--bubble-user-text))]': isIncoming,
'text-[rgb(var(--bubble-agent-text))]': isOutgoing,
}"
@click="isExpanded = true"
>
<Icon icon="i-lucide-maximize-2" />
@@ -145,7 +154,7 @@ const handleSeeOriginal = () => {
</div>
<FormattedContent
v-if="isOutgoing && content && !hasEmailContent"
class="text-n-slate-12"
class="text-[rgb(var(--bubble-agent-text))]"
:content="messageContent"
/>
<template v-else>
@@ -176,7 +185,11 @@ const handleSeeOriginal = () => {
</template>
<button
v-if="hasQuotedMessage"
class="text-n-slate-11 px-1 leading-none text-sm bg-n-alpha-black2 text-center flex items-center gap-1 mt-2"
class="px-1 leading-none text-sm bg-n-alpha-black2 text-center flex items-center gap-1 mt-2"
:class="{
'text-[rgb(var(--bubble-user-meta))]': isIncoming,
'text-[rgb(var(--bubble-agent-meta))]': isOutgoing,
}"
@click="showQuotedMessage = !showQuotedMessage"
>
<template v-if="showQuotedMessage">
@@ -48,7 +48,10 @@ const formValues = computed(() => {
</script>
<template>
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
<BaseBubble
class="px-[var(--bubble-padding-x)] py-[var(--bubble-padding-y)]"
data-bubble-name="form"
>
<span v-dompurify-html="content" :title="content" />
<dl v-if="formValues.length" class="mt-4">
<template v-for="item in formValues" :key="item.title">
@@ -6,6 +6,7 @@ import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
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';
@@ -22,8 +23,8 @@ const attachment = computed(() => {
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry();
const showGallery = ref(false);
const isDownloading = ref(false);
const { showGallery, isGalleryAllowed, toggleGallery } = useGallery();
onMounted(() => {
if (attachment.value?.dataUrl) {
@@ -50,13 +51,16 @@ const handleImageError = () => {
<template>
<BaseBubble
class="overflow-hidden p-3"
class="overflow-hidden p-[var(--bubble-padding-y)]"
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" />
<p class="mb-0 text-n-slate-11">
<div
v-if="hasError"
class="flex items-center gap-1 text-center rounded-lg text-[rgb(var(--bubble-error-meta))]"
>
<Icon icon="i-lucide-circle-off" />
<p class="mb-0">
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
</p>
</div>
@@ -86,7 +90,7 @@ const handleImageError = () => {
</div>
</BaseBubble>
<GalleryView
v-if="showGallery"
v-if="showGallery && isGalleryAllowed"
v-model:show="showGallery"
:attachment="useSnakeCase(attachment)"
:all-attachments="filteredCurrentChatAttachments"
@@ -44,7 +44,10 @@ const onVideoLoadError = () => {
</script>
<template>
<BaseBubble class="p-3 overflow-hidden" data-bubble-name="ig-story">
<BaseBubble
class="p-[var(--bubble-padding-y)] overflow-hidden"
data-bubble-name="ig-story"
>
<p v-if="isStoryReply" class="mb-1 text-xs text-n-slate-11">
{{ t('COMPONENTS.FILE_BUBBLE.INSTAGRAM_STORY_REPLY') }}
</p>
@@ -64,10 +67,10 @@ const onVideoLoadError = () => {
/>
<div
v-else
class="flex items-center gap-1 px-5 py-4 text-center rounded-lg bg-n-alpha-1"
class="flex items-center gap-1 px-5 py-4 text-center rounded-lg bg-n-alpha-1 text-[rgb(var(--bubble-error-meta))]"
>
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
<p class="mb-0 text-n-slate-11">
<Icon icon="i-lucide-circle-off" />
<p class="mb-0">
{{ $t('COMPONENTS.FILE_BUBBLE.INSTAGRAM_STORY_UNAVAILABLE') }}
</p>
</div>
@@ -4,11 +4,11 @@ import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from './FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
import { MESSAGE_TYPES } from '../../constants';
import { MESSAGE_TYPES, MESSAGE_VARIANTS } from '../../constants';
import { useMessageContext } from '../../provider.js';
import { useTranslations } from 'dashboard/composables/useTranslations';
const { content, attachments, contentAttributes, messageType } =
const { content, attachments, contentAttributes, messageType, variant } =
useMessageContext();
const { hasTranslations, translationContent } =
@@ -36,15 +36,29 @@ const isEmpty = computed(() => {
return !content.value && !attachments.value?.length;
});
const emptyTextClass = computed(() => {
const metaClassMap = {
[MESSAGE_VARIANTS.AGENT]: 'text-[rgb(var(--bubble-agent-meta))]',
[MESSAGE_VARIANTS.USER]: 'text-[rgb(var(--bubble-user-meta))]',
[MESSAGE_VARIANTS.PRIVATE]: 'text-[rgb(var(--bubble-private-meta))]',
[MESSAGE_VARIANTS.BOT]: 'text-[rgb(var(--bubble-bot-meta))]',
[MESSAGE_VARIANTS.TEMPLATE]: 'text-[rgb(var(--bubble-bot-meta))]',
};
return metaClassMap[variant.value] || 'text-[rgb(var(--bubble-agent-meta))]';
});
const handleSeeOriginal = () => {
renderOriginal.value = !renderOriginal.value;
};
</script>
<template>
<BaseBubble class="px-4 py-3" data-bubble-name="text">
<BaseBubble
class="px-[var(--bubble-padding-x)] py-[var(--bubble-padding-y)]"
data-bubble-name="text"
>
<div class="gap-3 flex flex-col">
<span v-if="isEmpty" class="text-n-slate-11">
<span v-if="isEmpty" :class="emptyTextClass">
{{ $t('CONVERSATION.NO_CONTENT') }}
</span>
<FormattedContent v-if="renderContent" :content="renderContent" />
@@ -21,7 +21,10 @@ const unsupportedMessageKey = computed(() => {
</script>
<template>
<BaseBubble class="px-4 py-3 text-sm" data-bubble-name="unsupported">
<BaseBubble
class="px-[var(--bubble-padding-x)] py-[var(--bubble-padding-y)] text-sm"
data-bubble-name="unsupported"
>
{{ $t(unsupportedMessageKey) }}
</BaseBubble>
</template>
@@ -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 = () => {
@@ -28,9 +29,9 @@ const isReel = computed(() => {
<template>
<BaseBubble
class="overflow-hidden p-3"
class="overflow-hidden p-[var(--bubble-padding-y)]"
data-bubble-name="video"
@click="showGallery = true"
@click="toggleGallery(true)"
>
<div class="relative group rounded-lg overflow-hidden">
<div
@@ -53,7 +54,7 @@ const isReel = computed(() => {
</div>
</BaseBubble>
<GalleryView
v-if="showGallery"
v-if="showGallery && isGalleryAllowed"
v-model:show="showGallery"
:attachment="useSnakeCase(attachment)"
:all-attachments="filteredCurrentChatAttachments"
@@ -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,33 @@
import { MESSAGE_TYPES } from './constants';
/**
* Determines if a message should be grouped with the next message
* @param {Number} index - Index of the current message
* @param {Array} searchList - Array of messages to check
* @returns {Boolean} - Whether the message should be grouped with next
*/
export default (index, searchList) => {
if (index === searchList.length - 1) return false;
const current = searchList[index];
const next = searchList[index + 1];
if (next.status === 'failed') return false;
const nextSenderId = next.senderId ?? next.sender?.id;
const currentSenderId = current.senderId ?? current.sender?.id;
const hasSameSender = nextSenderId === currentSenderId;
const nextMessageType = next.messageType;
const currentMessageType = current.messageType;
const areBothTemplates =
nextMessageType === MESSAGE_TYPES.TEMPLATE &&
currentMessageType === MESSAGE_TYPES.TEMPLATE;
if (!hasSameSender || areBothTemplates) return false;
if (currentMessageType !== nextMessageType) return false;
// Check if messages are in the same minute by rounding down to nearest minute
return Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60);
};
@@ -0,0 +1,20 @@
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
export function useGallery() {
const [showGallery] = useToggle(false);
const isGalleryAllowed = computed(() => {
return true;
});
const toggleGallery = value => {
showGallery.value = value;
};
return {
showGallery,
isGalleryAllowed,
toggleGallery,
};
}
+49 -27
View File
@@ -15,6 +15,7 @@ const { modalType, closeOnBackdropClick, onClose } = defineProps({
const emit = defineEmits(['close']);
const show = defineModel('show', { type: Boolean, default: false });
const teleportTarget = ref(null);
const modalClassName = computed(() => {
const modalClassNameMap = {
@@ -25,6 +26,24 @@ const modalClassName = computed(() => {
return `modal-mask skip-context-menu ${modalClassNameMap[modalType] || ''}`;
});
const resolveTeleportTarget = () => {
// eslint-disable-next-line no-underscore-dangle
if (window.__WOOT_ISOLATED_SHELL__) {
// Look for the specific chatwoot-message-list custom element
const messageListElement = document.querySelector('chatwoot-message-list');
if (messageListElement?.shadowRoot) {
const modalRoot =
messageListElement.shadowRoot.querySelector('#cw-modal-root');
if (modalRoot) {
teleportTarget.value = modalRoot;
return;
}
}
}
teleportTarget.value = document.body;
};
// [TODO] Revisit this logic to use outside click directive
const mousedDownOnBackdrop = ref(false);
@@ -58,6 +77,7 @@ useEventListener(document.body, 'mouseup', onMouseUp);
useEventListener(document, 'keydown', onKeydown);
onMounted(() => {
resolveTeleportTarget();
if (import.meta.env.DEV && onClose && typeof onClose === 'function') {
// eslint-disable-next-line no-console
console.warn(
@@ -68,36 +88,38 @@ onMounted(() => {
</script>
<template>
<transition name="modal-fade">
<div
v-if="show"
:class="modalClassName"
transition="modal"
@mousedown="handleMouseDown"
>
<Teleport v-if="teleportTarget" :to="teleportTarget">
<transition name="modal-fade">
<div
class="relative max-h-full overflow-auto bg-n-alpha-3 shadow-md modal-container rtl:text-right skip-context-menu"
:class="{
'rounded-xl w-[37.5rem]': !fullWidth,
'items-center rounded-none flex h-full justify-center w-full':
fullWidth,
[size]: true,
}"
@mouse.stop
@mousedown="event => event.stopPropagation()"
v-if="show"
:class="modalClassName"
transition="modal"
@mousedown="handleMouseDown"
>
<Button
v-if="showCloseButton"
ghost
slate
icon="i-lucide-x"
class="absolute z-10 ltr:right-2 rtl:left-2 top-2"
@click="close"
/>
<slot />
<div
class="relative max-h-full overflow-auto bg-n-alpha-3 shadow-md modal-container rtl:text-right skip-context-menu"
:class="{
'rounded-xl w-[37.5rem]': !fullWidth,
'items-center rounded-none flex h-full justify-center w-full':
fullWidth,
[size]: true,
}"
@mouse.stop
@mousedown="event => event.stopPropagation()"
>
<Button
v-if="showCloseButton"
ghost
slate
icon="i-lucide-x"
class="absolute z-10 ltr:right-2 rtl:left-2 top-2"
@click="close"
/>
<slot />
</div>
</div>
</div>
</transition>
</transition>
</Teleport>
</template>
<style lang="scss">
@@ -89,6 +89,7 @@ const props = defineProps({
// are triggered except when this flag is true
allowSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
forceSignature: { type: Boolean, default: false },
conversationId: { type: Number, default: null },
medium: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
@@ -313,6 +314,10 @@ const plugins = computed(() => {
const sendWithSignature = computed(() => {
// this is considered the source of truth, we watch this property
// on change, we toggle the signature in the editor
if (props.forceSignature && props.allowSignature && !props.isPrivate) {
return true;
}
if (props.allowSignature && !props.isPrivate && props.channelType) {
return fetchSignatureFlagFromUISettings(props.channelType);
}
@@ -564,10 +569,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');
}
@@ -811,7 +820,7 @@ watch(sendWithSignature, newValue => {
}
});
onMounted(() => {
onMounted(async () => {
// [VITE] state assignment was done in created before
state = createState(
props.modelValue,
@@ -823,11 +832,16 @@ onMounted(() => {
createEditorView();
editorView.updateState(state);
if (props.focusOnMount) {
// TODO: test this in the main app
if (sendWithSignature.value) {
addSignature();
} else if (props.focusOnMount) {
focusEditorInputField();
}
});
defineExpose({ focusEditorInputField });
// BUS Event to insert text or markdown into the editor at the
// current cursor position.
// Components using this
@@ -915,6 +929,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
.ProseMirror-menubar-wrapper {
@apply flex flex-col gap-3;
.ProseMirror-menubar:empty {
display: none;
}
.ProseMirror-menubar {
min-height: 1.25rem !important;
@apply items-center gap-4 flex pb-0 bg-transparent text-n-slate-11 relative ltr:-left-[3px] rtl:-right-[3px];
@@ -2,6 +2,7 @@
import { computed, useTemplateRef } from 'vue';
import { useElementSize } from '@vueuse/core';
import { REPLY_EDITOR_MODES } from './constants';
import Icon from 'next/icon/Icon.vue';
const props = defineProps({
mode: {
@@ -12,6 +13,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
iconOnly: {
type: Boolean,
default: false,
},
isReplyRestricted: {
type: Boolean,
default: false,
@@ -69,22 +74,34 @@ const translateValue = computed(() => {
<template>
<button
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
class="flex items-center w-auto p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
:disabled="disabled || isReplyRestricted"
:class="{
'h-7': iconOnly,
'h-8': !iconOnly,
'cursor-not-allowed': disabled || isReplyRestricted,
}"
@click="$emit('toggleMode')"
>
<div ref="wootEditorReplyMode" class="flex items-center gap-1 px-2 z-20">
{{ $t('CONVERSATION.REPLYBOX.REPLY') }}
<div
ref="wootEditorReplyMode"
class="flex items-center gap-1 px-2 z-20 text-n-slate-11"
>
<Icon v-if="iconOnly" icon="i-lucide-message-circle" class="size-4" />
<template v-else>{{ $t('CONVERSATION.REPLYBOX.REPLY') }}</template>
</div>
<div ref="wootEditorPrivateMode" class="flex items-center gap-1 px-2 z-20">
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
<div
ref="wootEditorPrivateMode"
class="flex items-center gap-1 px-2 z-20 text-n-slate-11"
>
<Icon v-if="iconOnly" icon="i-lucide-lock-keyhole" class="size-4" />
<template v-else>{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}</template>
</div>
<div
class="absolute shadow-sm rounded-full h-6 w-[var(--chip-width)] ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
:class="{
'h-5': iconOnly,
'h-6': !iconOnly,
'transition-all duration-300': !disabled && !isReplyRestricted,
}"
:style="{
@@ -114,6 +114,11 @@ export default {
type: String,
default: '',
},
allowSignature: { type: Boolean, default: false },
allowEmoji: { type: Boolean, default: false },
allowVideoCall: { type: Boolean, default: false },
allowFileUpload: { type: Boolean, default: false },
allowAudioRecorder: { type: Boolean, default: false },
showQuotedReplyToggle: {
type: Boolean,
default: false,
@@ -291,7 +296,7 @@ export default {
<div class="flex justify-between p-3" :class="wrapClass">
<div class="left-wrap">
<NextButton
v-if="!isEditorDisabled"
v-if="allowEmoji && !isEditorDisabled"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
icon="i-ph-smiley-sticker"
slate
@@ -300,7 +305,7 @@ export default {
@click="toggleEmojiPicker"
/>
<FileUpload
v-if="showAttachButton"
v-if="showAttachButton && allowFileUpload"
ref="uploadRef"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
input-id="conversationAttachment"
@@ -325,7 +330,7 @@ export default {
/>
</FileUpload>
<NextButton
v-if="showAudioRecorderButton"
v-if="allowAudioRecorder && showAudioRecorderButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
:icon="!isRecordingAudio ? 'i-ph-microphone' : 'i-ph-microphone-slash'"
slate
@@ -334,7 +339,7 @@ export default {
@click="toggleAudioRecorder"
/>
<NextButton
v-if="showAudioPlayStopButton"
v-if="allowAudioRecorder && showAudioPlayStopButton"
:icon="audioRecorderPlayStopIcon"
slate
faded
@@ -343,7 +348,7 @@ export default {
@click="toggleAudioRecorderPlayPause"
/>
<NextButton
v-if="showMessageSignatureButton"
v-if="showMessageSignatureButton && allowSignature"
v-tooltip.top-end="signatureToggleTooltip"
icon="i-ph-signature"
slate
@@ -381,6 +386,7 @@ export default {
/>
<VideoCallButton
v-if="
allowVideoCall &&
(isAWebWidgetInbox || isAPIInbox) &&
!isOnPrivateNote &&
!isEditorDisabled
@@ -25,6 +25,10 @@ export default {
type: String,
default: REPLY_EDITOR_MODES.REPLY,
},
disablePopout: {
type: Boolean,
default: false,
},
isReplyRestricted: {
type: Boolean,
default: false,
@@ -99,6 +99,7 @@ export default {
} = useUISettings();
const replyEditor = useTemplateRef('replyEditor');
const messageEditor = useTemplateRef('messageEditor');
const copilot = useCopilotReply();
const shortcutKey = useKbd(['$mod', '+', 'enter']);
@@ -109,6 +110,7 @@ export default {
setQuotedReplyFlagForInbox,
fetchQuotedReplyFlagFromUISettings,
replyEditor,
messageEditor,
copilot,
shortcutKey,
};
@@ -507,7 +509,7 @@ export default {
);
this.fetchAndSetReplyTo();
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage);
// A hacky fix to solve the drag and drop
// Is showing on top of new conversation modal drag and drop
@@ -522,7 +524,7 @@ export default {
unmounted() {
document.removeEventListener('paste', this.onPaste);
document.removeEventListener('keydown', this.handleKeyEvents);
emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage);
emitter.off(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
emitter.off(
BUS_EVENTS.NEW_CONVERSATION_MODAL,
@@ -1191,6 +1193,15 @@ export default {
return false;
});
},
onReplyToMessage() {
this.fetchAndSetReplyTo();
if (this.inReplyTo) {
this.$nextTick(() => {
const pos = this.isSignatureEnabledForInbox ? 'start' : 'end';
this.messageEditor?.focusEditorInputField(pos);
});
}
},
resetReplyToMessage() {
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
@@ -1313,6 +1324,7 @@ export default {
/>
<WootMessageEditor
v-else-if="!showAudioRecorderEditor"
ref="messageEditor"
v-model="message"
:conversation-id="conversationId"
:editor-id="editorStateId"
@@ -32,6 +32,7 @@ const emit = defineEmits(['dismiss']);
xs
slate
icon="i-lucide-x"
class="flex-shrink-0"
@click.stop="emit('dismiss')"
/>
</div>
@@ -9,6 +9,7 @@ import { useImageZoom } from 'dashboard/composables/useImageZoom';
import { messageTimestamp } from 'shared/helpers/timeHelper';
import { downloadFile } from '@chatwoot/utils';
import WootModal from 'dashboard/components/Modal.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
@@ -168,7 +169,7 @@ onMounted(() => {
<template>
<TeleportWithDirection to="body">
<woot-modal
<WootModal
v-model:show="show"
full-width
:show-close-button="false"
@@ -211,7 +212,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>
@@ -353,6 +354,6 @@ onMounted(() => {
</div>
</footer>
</div>
</woot-modal>
</WootModal>
</TeleportWithDirection>
</template>
@@ -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;
@@ -11,7 +11,12 @@ export function useAccount() {
* Computed property for the current account ID.
* @type {import('vue').ComputedRef<number>}
*/
const route = useRoute();
// In the isolated shell (react-components / Web Component context),
// vue-router is not available. Fall back to the global variable.
// eslint-disable-next-line no-underscore-dangle
const isIsolatedShell = !!window.__WOOT_ISOLATED_SHELL__;
const route = isIsolatedShell ? null : useRoute();
const store = useStore();
const getAccountFn = useMapGetter('accounts/getAccount');
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
@@ -20,6 +25,10 @@ export function useAccount() {
);
const accountId = computed(() => {
if (isIsolatedShell) {
// eslint-disable-next-line no-underscore-dangle
return Number(window.__WOOT_ACCOUNT_ID__);
}
return Number(route.params.accountId);
});
const currentAccount = computed(() => getAccountFn.value(accountId.value));
@@ -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,
@@ -98,7 +101,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,
@@ -1,18 +1,6 @@
/* eslint no-param-reassign: 0 */
import getUuid from 'widget/helpers/uuid';
import { MESSAGE_STATUS, MESSAGE_TYPE } from 'shared/constants/messages';
export default () => {
if (!Array.prototype.last) {
Object.assign(Array.prototype, {
last() {
return this[this.length - 1];
},
});
}
};
export const isEmptyObject = obj =>
Object.keys(obj).length === 0 && obj.constructor === Object;
@@ -527,7 +527,8 @@
"TITLE": "Features",
"ALLOW_CONVERSATION_FAQS": "Generate FAQs from resolved conversations",
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions.",
"ALLOW_CITATIONS": "Include source citations in responses"
"ALLOW_CITATIONS": "Include source citations in responses",
"ALLOW_CONTACT_ATTRIBUTES": "Allow access to contact information"
}
},
"EDIT": {
@@ -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);
@@ -181,7 +181,7 @@ const actions = {
);
if (!selectedChat) return;
const { messages } = selectedChat;
const lastMessage = messages.last();
const lastMessage = messages[messages.length - 1];
if (!lastMessage) return;
commit(types.SET_LAST_MESSAGE_ID_IN_SYNC_CONVERSATION, {
conversationId,
@@ -192,17 +192,29 @@ const actions = {
async setActiveChat({ commit, dispatch }, { data, after }) {
commit(types.SET_CURRENT_CHAT_WINDOW, data);
commit(types.CLEAR_ALL_MESSAGES_LOADED, data.id);
if (data.dataFetched === undefined) {
try {
try {
if (data.dataFetched === undefined) {
await dispatch('fetchPreviousMessages', {
after,
before: data.messages[0].id,
before: data.messages?.[0]?.id,
conversationId: data.id,
});
commit(types.SET_CHAT_DATA_FETCHED, data.id);
} catch (error) {
// Ignore error
}
// Stamp the checkpoint at the current tail before syncing, so sync
// only fetches messages newer than what is already in the store and
// fills in anything ActionCable missed (e.g. messages that landed
// while the socket was disconnected or before the component mounted).
// Order matters: setConversationLastMessageId must run first so
// syncActiveConversationMessages uses the up-to-date `after` id.
await dispatch('setConversationLastMessageId', {
conversationId: data.id,
});
await dispatch('syncActiveConversationMessages', {
conversationId: data.id,
});
} catch (error) {
// Ignore error
}
},
@@ -210,6 +210,18 @@ export const actions = {
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: false });
}
},
getById: async ({ commit, getters: $getters }, inboxId) => {
if (!inboxId || $getters.getInbox(inboxId).id) return;
commit(types.default.SET_INBOXES_UI_FLAG, { isFetchingItem: true });
try {
const response = await InboxesAPI.show(inboxId);
commit(types.default.SET_INBOXES_ITEM, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isFetchingItem: false });
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isFetchingItem: false });
}
},
createChannel: async ({ commit }, params) => {
try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
@@ -736,16 +736,19 @@ describe('#addMentions', () => {
[types.CLEAR_ALL_MESSAGES_LOADED, 42],
[types.SET_CHAT_DATA_FETCHED, 42],
]);
expect(localDispatch).toHaveBeenCalledWith('fetchPreviousMessages', {
after: 99,
before: 100,
conversationId: 42,
});
expect(localDispatch.mock.calls).toEqual([
[
'fetchPreviousMessages',
{ after: 99, before: 100, conversationId: 42 },
],
['setConversationLastMessageId', { conversationId: 42 }],
['syncActiveConversationMessages', { conversationId: 42 }],
]);
});
it('should not dispatch fetchPreviousMessages if dataFetched is already set', async () => {
it('should skip fetchPreviousMessages but still sync if dataFetched is already set', async () => {
const localCommit = vi.fn();
const localDispatch = vi.fn();
const localDispatch = vi.fn().mockResolvedValue();
const data = { id: 42, messages: [{ id: 100 }], dataFetched: true };
await actions.setActiveChat(
@@ -757,7 +760,10 @@ describe('#addMentions', () => {
[types.SET_CURRENT_CHAT_WINDOW, data],
[types.CLEAR_ALL_MESSAGES_LOADED, 42],
]);
expect(localDispatch).not.toHaveBeenCalled();
expect(localDispatch.mock.calls).toEqual([
['setConversationLastMessageId', { conversationId: 42 }],
['syncActiveConversationMessages', { conversationId: 42 }],
]);
});
it('should commit SET_CHAT_DATA_FETCHED by ID, not mutate the data object directly (race condition fix)', async () => {
@@ -1,4 +1,3 @@
import commonHelpers from '../../../../helper/commons';
import getters from '../../conversations/getters';
/*
Order of conversations in the fixture is as follows:
@@ -9,9 +8,6 @@ import getters from '../../conversations/getters';
*/
import conversations from './conversations.fixtures';
// loads .last() helper
commonHelpers();
describe('#getters', () => {
describe('#getAllConversations', () => {
it('returns conversations ordered by lastActivityAt in descending order if no sort order is available', () => {
+1 -3
View File
@@ -13,7 +13,7 @@ import App from 'dashboard/App.vue';
import i18nMessages from 'dashboard/i18n';
import createAxios from 'dashboard/helper/APIHelper';
import commonHelpers, { isJSONValid } from 'dashboard/helper/commons';
import { isJSONValid } from 'dashboard/helper/commons';
import { sync } from 'vuex-router-sync';
import { createPinia } from 'pinia';
import router, { initalizeRouter } from 'dashboard/routes';
@@ -97,8 +97,6 @@ app.component('fluent-icon', FluentIcon);
app.directive('resize', vResizeObserver);
app.directive('on-clickaway', onClickaway);
// load common helpers into js
commonHelpers();
window.WootConstants = constants;
window.axios = createAxios(axios);
// [VITE] Disabled this we don't need it, we can use `useEmitter` directly
+54
View File
@@ -0,0 +1,54 @@
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 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 () => {
// 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();
}
+833
View File
@@ -0,0 +1,833 @@
# Chatwoot React Components - Architecture Documentation
This document provides comprehensive context for the embeddable chat UI components built with React by wrapping Vue components as Web Components.
## Table of Contents
1. [Overview](#overview)
2. [Architecture Diagram](#architecture-diagram)
3. [Build System](#build-system)
4. [Component Hierarchy](#component-hierarchy)
5. [Key Patterns](#key-patterns)
6. [Runtime Theme Customization](#runtime-theme-customization)
7. [Real-time Communication](#real-time-communication)
8. [File Structure](#file-structure)
9. [Extension Guide](#extension-guide)
10. [Known Limitations](#known-limitations)
---
## Overview
The system enables embedding Chatwoot's conversation UI into any React application. It achieves this through a **three-layer architecture**:
1. **React Layer** - Provider pattern for configuration and React-friendly API
2. **Web Component Layer** - Vue components converted to Custom Elements via `defineCustomElement`
3. **Vue Layer** - Existing Chatwoot Vue components reused without modification
### Why This Approach?
- **Code Reuse**: Leverage existing battle-tested Vue components (`components-next/message/*`)
- **Framework Agnostic**: Web Components work in any framework (React, Angular, vanilla JS)
- **Isolated Styles**: Shadow DOM encapsulation prevents CSS conflicts
- **Shared State**: Single Vuex store instance across all components
---
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────┐
│ React Application │
├─────────────────────────────────────────────────────────────────┤
│ <ChatwootProvider> │
│ ├── Sets up globals (window.__WOOT_*) │
│ ├── Initializes Vuex store │
│ ├── Registers Web Components │
│ └── Initializes ActionCable (WebSocket) │
│ │
│ <ChatwootConversation> │
│ └── <ChatwootMessageListWrapper> │
│ └── <chatwoot-message-list> (Web Component) │
│ └── [Shadow DOM] │
│ └── MessageList.vue │
│ ├── Message.vue (components-next) │
│ ├── TypingIndicator.vue │
│ └── LiteReplyBox.vue │
└─────────────────────────────────────────────────────────────────┘
```
---
## Build System
### Build Modes (vite.config.ts)
The Vite configuration supports multiple build modes via `BUILD_MODE` environment variable:
| Mode | Command | Output | Purpose |
|------|---------|--------|---------|
| `library` | `pnpm build:sdk` | `public/packs/js/sdk.js` | Widget SDK (IIFE) |
| `ui` | `pnpm build:ui` | `public/packs/js/ui.js` | Standalone UI (IIFE) |
| `react-components` | `pnpm build:react` | `public/packs/react-components.{es,cjs}.js` | NPM package (ES + CJS) |
| (default) | `bin/vite build` | Standard Vite output | Main app build |
### React Components Build Pipeline
```bash
# Full build and package
pnpm package:react
# Build only (no packaging)
pnpm build:react
# Copy mode (skip build, just package)
node scripts/package-react-components.js --copyMode
```
**Build Output** (`dist/react-components/`):
- `index.js` - ES module entry
- `index.cjs` - CommonJS entry
- `style.css` - Bundled styles
- `package.json` - Generated with timestamp versioning
### Plugin Configuration
```javascript
// react-components mode plugins
plugins = [
vue({ ...vueOptions, customElement: true }), // Enable CE mode
react() // React JSX transform
];
// Rollup externals - React is peer dependency
rollupOptions = {
external: ['react', 'react-dom'],
};
```
---
## Component Hierarchy
### React Components (`src/components/`)
#### ChatwootProvider.jsx
**Purpose**: Root provider that initializes the entire Chatwoot environment.
**Key Responsibilities**:
1. Validates required props (`baseURL`, `userToken`)
2. Registers Vue Web Components via `registerVueWebComponents()`
3. Sets up global window variables for Vue components
4. Initializes axios with authentication
5. Dispatches `setUser` to hydrate auth state
6. Initializes ActionCable for real-time updates
**Props**:
```typescript
interface ChatwootProviderProps {
baseURL: string; // Required: Chatwoot API URL
userToken: string; // Required: Agent access token
accountId: number; // Account ID
conversationId: number; // Conversation to display
websocketURL?: string; // WebSocket server URL
pubsubToken?: string; // PubSub authentication token
disableUpload?: boolean; // Disable file attachments
disableEditor?: boolean; // Disable reply editor
disableSignature?: boolean; // Disable signature controls and insertion
disableContextMenu?: boolean; // Disable right-click message context menu (copy, translate, delete, copy link, reply, canned response)
signature?: string; // Custom message signature (overrides user profile signature)
}
```
**Global Variables Set**:
```javascript
window.__WOOT_API_HOST__ // API base URL
window.__WOOT_ACCOUNT_ID__ // Account ID
window.__WOOT_ACCESS_TOKEN__ // Auth token (used by axios)
window.__WEBSOCKET_URL__ // WebSocket URL
window.__PUBSUB_TOKEN__ // Real-time auth token
window.__WOOT_CONVERSATION_ID__ // Active conversation
window.__EDITOR_DISABLE_UPLOAD__ // Upload flag
window.__DISABLE_EDITOR__ // Editor flag
window.__WOOT_DISABLE_SIGNATURE__ // Disable signature controls and insertion
window.__WOOT_DISABLE_CONTEXT_MENU__ // Disable message right-click context menu
window.__WOOT_CUSTOM_SIGNATURE__ // Custom signature (overrides profile)
window.__WOOT_ISOLATED_SHELL__ // Disables audio notifications
window.__CHATWOOT_STORE__ // Vuex store reference
window.WootConstants // Global constants
window.axios // Configured axios instance
```
#### ChatwootConversation.jsx
**Purpose**: High-level wrapper providing styled container.
**Pattern**: Uses `useChatwoot()` hook to enforce Provider context.
#### ChatwootMessageListWrapper.jsx
**Purpose**: Bridge between React and Web Component.
**Key Pattern - Imperative Handle Updates**:
```jsx
// React props → Web Component properties
useEffect(() => {
element.conversationId = conversationId;
}, [conversationId]);
```
**Event Forwarding**:
```jsx
element.addEventListener('chatwoot:loaded', handleLoad);
element.addEventListener('chatwoot:error', handleError);
```
### Web Component Layer (`src/vue-components/`)
#### registerWebComponents.js
**Purpose**: Converts Vue SFCs to Web Components and registers them.
**Key Pattern - Style Injection**:
```javascript
const ceOptions = {
configureApp(app) {
app.use(store);
app.use(VueDOMPurifyHTML, domPurifyConfig);
app.use(i18n);
app.provide(I18nInjectionKey, i18n);
vueActionCable.init(store, window.__PUBSUB_TOKEN__);
},
// Styles bundled into Shadow DOM
styles: [chatwootStyles, multiselectStyles, floatingVueStyles, uploadStyles],
};
const ChatwootMessageListElement = defineCustomElement(
ChatwootMessageListWebComponent,
ceOptions
);
```
**Important**: Styles are imported with `?inline` query to get CSS as string:
```javascript
import chatwootStyles from '../../../dashboard/assets/scss/app.scss?inline';
```
#### ChatwootMessageListWebComponent.vue
**Purpose**: Thin wrapper that renders the actual MessageList.
**Pattern - Additional Styles for Third-Party Components**:
```vue
<style>
/* vue-upload-component requires these styles */
.file-uploads { /* ... */ }
</style>
```
### Vue Layer (`/app/javascript/ui/`)
#### MessageList.vue
**Purpose**: Core conversation view with infinite scroll.
**Key Features**:
1. **Infinite Scroll**: Uses `@vueuse/core`'s `useInfiniteScroll`
2. **Typing Indicators**: Real-time via `conversationTypingStatus` store module
3. **Message Rendering**: Uses `components-next/message/Message.vue`
4. **Reply Box**: Integrated `LiteReplyBox.vue`
**Data Flow**:
```javascript
// Gets conversation ID from global
const conversationId = computed(() => window.__WOOT_CONVERSATION_ID__);
// Fetches conversation data on mount
onMounted(async () => {
await store.dispatch('inboxes/get');
await Promise.all([
store.dispatch('getConversation', conversationId.value),
store.dispatch('fetchAllAttachments', conversationId.value),
]);
});
```
**Key Pattern - Store Access Without Vue Instance**:
```javascript
// From composables/store.js
export const useStore = () => {
if (window.__CHATWOOT_STORE__) {
return window.__CHATWOOT_STORE__;
}
// Fallback to Vue instance
const vm = getCurrentInstance();
return vm.proxy.$store;
};
```
#### LiteReplyBox.vue
**Purpose**: Message composition editor.
**Key Features**:
- Rich text editing via ProseMirror (`WootMessageEditor`)
- File attachments with preview
- Typing indicators (on/off status)
- Keyboard shortcuts (Cmd/Ctrl+Enter to send)
- Private notes support
**Conditional Features via Globals**:
```javascript
isEditorDisabled() {
return window.__DISABLE_EDITOR__;
},
allowFileUpload() {
return window.__EDITOR_DISABLE_UPLOAD__ !== true;
},
```
#### axios.js (UI-specific axios factory)
**Purpose**: Creates axios instance configured for external use.
**Pattern - Token from Global**:
```javascript
export default axios => {
const apiHost = window.__WOOT_API_HOST__;
const accessToken = window.__WOOT_ACCESS_TOKEN__;
const wootApi = axios.create({ baseURL: `${apiHost}/` });
Object.assign(wootApi.defaults.headers.common, {
api_access_token: accessToken,
});
return wootApi;
};
```
---
## Key Patterns
### 1. Global Variable Bridge
Vue components expect certain globals. The React provider sets these before rendering:
```javascript
// Set by ChatwootProvider
window.__WOOT_API_HOST__ = config.baseURL;
window.__WOOT_ACCOUNT_ID__ = config.accountId;
// ...etc
// Consumed by Vue components
const accountIdFromRoute = window.__WOOT_ACCOUNT_ID__;
```
### 2. Vuex Store as Global Singleton
The store is created once and shared via `window.__CHATWOOT_STORE__`:
```javascript
// In ChatwootProvider
import store from '../../../dashboard/store';
window.__CHATWOOT_STORE__ = store;
// In Vue composables
export const useStore = () => {
if (window.__CHATWOOT_STORE__) {
return window.__CHATWOOT_STORE__;
}
// ...
};
```
### 3. I18n in Web Components
Vue I18n requires special injection for Web Components:
```javascript
// Must provide I18nInjectionKey for useI18n() to work in CE mode
app.use(i18n);
app.provide(I18nInjectionKey, i18n);
```
Reference: https://vue-i18n.intlify.dev/guide/advanced/wc
### 4. Isolated Shell Mode
The `__WOOT_ISOLATED_SHELL__` flag disables features not needed in embedded mode:
```javascript
// In actionCable.js
if (!window.__WOOT_ISOLATED_SHELL__) {
DashboardAudioNotificationHelper.onNewMessage(data);
}
```
### 5. CamelCase Transformation
API returns snake_case, Vue components expect camelCase:
```javascript
import { useCamelCase } from '../dashboard/composables/useTransformKeys';
const allMessages = computed(() => {
return useCamelCase(conversation.value.messages, { deep: true }).reverse();
});
```
### 6. Web Component Event Communication
Web Components emit custom events that React can listen to:
```javascript
// In Web Component
this.$emit('chatwoot:loaded');
this.$emit('chatwoot:error', { message: 'Error details' });
// In React wrapper
element.addEventListener('chatwoot:loaded', handleLoad);
element.addEventListener('chatwoot:error', handleError);
```
### 7. CSS Variable Theming for Bubbles
Message bubble styles use CSS custom properties that can be overridden from outside the Shadow DOM:
```javascript
// In Base.vue - uses CSS variables for colors
const varaintBaseMap = {
[MESSAGE_VARIANTS.AGENT]: 'bg-[rgb(var(--bubble-agent-bg))] text-[rgb(var(--bubble-agent-text))]',
[MESSAGE_VARIANTS.USER]: 'bg-[rgb(var(--bubble-user-bg))] text-[rgb(var(--bubble-user-text))]',
// ...
};
// Border radius also uses variables
const orientationMap = {
[ORIENTATION.LEFT]: 'rounded-[var(--bubble-radius)] ltr:rounded-bl-[var(--bubble-radius-sm)]...',
// ...
};
```
Variables are defined in `_next-colors.scss` and can be overridden via `bubble-overrides.css`.
---
## Runtime Theme Customization
The Web Component supports runtime theming via CSS custom properties. These variables **inherit through the Shadow DOM boundary**, allowing consumers to customize bubble appearance without modifying the source.
### Available CSS Variables
#### Colors (RGB values without commas)
| Variable | Description | Default |
|----------|-------------|---------|
| `--chatwoot-bubble-agent-bg` | Agent message background | `--solid-blue` |
| `--chatwoot-bubble-agent-text` | Agent message text | `--slate-12` |
| `--chatwoot-bubble-user-bg` | User/customer message background | `--slate-4` |
| `--chatwoot-bubble-user-text` | User message text | `--slate-12` |
| `--chatwoot-bubble-private-bg` | Private note background | `--solid-amber` |
| `--chatwoot-bubble-private-text` | Private note text | `--amber-12` |
| `--chatwoot-bubble-bot-bg` | Bot/template message background | `--solid-iris` |
| `--chatwoot-bubble-bot-text` | Bot message text | `--slate-12` |
| `--chatwoot-bubble-error-bg` | Error message background | `--ruby-4` |
| `--chatwoot-bubble-error-text` | Error message text | `--ruby-12` |
| `--chatwoot-bubble-unsupported-bg` | Unsupported message background | `--amber-4` |
| `--chatwoot-bubble-unsupported-text` | Unsupported message text | `--amber-12` |
#### Meta (timestamp) - Per Variant
| Variable | Description | Default |
|----------|-------------|---------|
| `--chatwoot-bubble-agent-meta` | Meta text for agent messages | `--slate-11` |
| `--chatwoot-bubble-user-meta` | Meta text for user messages | `--slate-11` |
| `--chatwoot-bubble-private-meta` | Meta text for private notes (50% opacity applied) | `--amber-12` |
| `--chatwoot-bubble-bot-meta` | Meta text for bot messages | `--slate-11` |
| `--chatwoot-bubble-error-meta` | Meta text for error messages | `--ruby-11` |
#### Message Status Icons - Per Variant
| Variable | Description | Default |
|----------|-------------|---------|
| `--chatwoot-bubble-agent-status` | Agent: sent/delivered/progress color | `--slate-10` |
| `--chatwoot-bubble-agent-status-read` | Agent: read receipt color | `126 182 255` |
| `--chatwoot-bubble-user-status` | User: sent/delivered/progress color | `--slate-10` |
| `--chatwoot-bubble-user-status-read` | User: read receipt color | `126 182 255` |
| `--chatwoot-bubble-private-status` | Private: sent/delivered/progress color | `--amber-11` |
| `--chatwoot-bubble-private-status-read` | Private: read receipt color | `126 182 255` |
| `--chatwoot-bubble-bot-status` | Bot: sent/delivered/progress color | `--slate-10` |
| `--chatwoot-bubble-bot-status-read` | Bot: read receipt color | `126 182 255` |
#### Spacing
| Variable | Description | Default |
|----------|-------------|---------|
| `--chatwoot-bubble-spacing-ratio` | Scale factor for border radius | `1` |
#### Border
| Variable | Description | Default |
|----------|-------------|---------|
| `--chatwoot-bubble-border-width` | Border width (0 = invisible) | `0` |
| `--chatwoot-bubble-agent-border` | Agent message border color | `--blue-6` |
| `--chatwoot-bubble-user-border` | User message border color | `--slate-6` |
| `--chatwoot-bubble-private-border` | Private note border color | `--amber-6` |
| `--chatwoot-bubble-bot-border` | Bot message border color | `--iris-6` |
| `--chatwoot-bubble-error-border` | Error message border color | `--ruby-6` |
| `--chatwoot-bubble-unsupported-border` | Unsupported message border color | `--amber-12` |
### Usage Examples
#### Option 1: Global CSS
```css
/* In your app's CSS file */
:root {
--chatwoot-bubble-agent-bg: 59 130 246; /* Blue */
--chatwoot-bubble-user-bg: 243 244 246; /* Light gray */
--chatwoot-bubble-spacing-ratio: 1.5; /* 50% more rounded */
--chatwoot-bubble-border-width: 1px; /* Add borders */
--chatwoot-bubble-agent-border: 59 130 246; /* Blue border */
}
```
#### Option 2: Wrapper with Inline Styles
```jsx
function App() {
return (
<div
style={{
'--chatwoot-bubble-agent-bg': '59 130 246',
'--chatwoot-bubble-user-bg': '243 244 246',
'--chatwoot-bubble-spacing-ratio': '1.5',
'--chatwoot-bubble-border-width': '1px',
'--chatwoot-bubble-agent-border': '59 130 246',
}}
>
<ChatwootProvider {...props}>
<ChatwootConversation />
</ChatwootProvider>
</div>
);
}
```
#### Option 3: Dynamic Theming
```jsx
function App() {
const [theme, setTheme] = useState({
'--chatwoot-bubble-agent-bg': '59 130 246',
'--chatwoot-bubble-border-width': '1px',
'--chatwoot-bubble-agent-border': '59 130 246',
});
return (
<div style={theme}>
<button onClick={() => setTheme({
'--chatwoot-bubble-agent-bg': '34 197 94', // Switch to green
'--chatwoot-bubble-border-width': '2px',
'--chatwoot-bubble-agent-border': '34 197 94', // Green border
})}>
Change Theme
</button>
<ChatwootProvider {...props}>
<ChatwootConversation />
</ChatwootProvider>
</div>
);
}
```
#### Option 4: Borders Only
```jsx
function App() {
return (
<div
style={{
'--chatwoot-bubble-border-width': '2px',
'--chatwoot-bubble-agent-border': '59 130 246', // Blue border
'--chatwoot-bubble-user-border': '156 163 175', // Gray border
'--chatwoot-bubble-private-border': '251 191 36', // Amber border
'--chatwoot-bubble-bot-border': '129 140 248', // Iris border
}}
>
<ChatwootProvider {...props}>
<ChatwootConversation />
</ChatwootProvider>
</div>
);
}
```
### How It Works
```
┌─────────────────────────────────────────────────────────────────┐
│ Consumer's CSS │
│ :root { │
│ --chatwoot-bubble-agent-bg: 59 130 246; │
│ --chatwoot-bubble-border-width: 1px; │
│ } │
└─────────────────────────────────────────────────────────────────┘
▼ (CSS inheritance)
┌─────────────────────────────────────────────────────────────────┐
│ Shadow DOM (:host) │
│ bubble-overrides.css: │
│ --bubble-agent-bg: var(--chatwoot-bubble-agent-bg, │
│ var(--solid-blue)); │
│ --bubble-border-width: var(--chatwoot-bubble-border-width, │
│ 0); │
└─────────────────────────────────────────────────────────────────┘
▼ (used by components)
┌─────────────────────────────────────────────────────────────────┐
│ Base.vue │
│ class="bg-[rgb(var(--bubble-agent-bg))] │
│ border-[length:var(--bubble-border-width)] │
│ border-[rgb(var(--bubble-agent-border))]" │
└─────────────────────────────────────────────────────────────────┘
```
### Key Files
| File | Purpose |
|------|---------|
| `src/styles/bubble-overrides.css` | Defines `:host` overrides that bridge external variables |
| `dashboard/assets/scss/_next-colors.scss` | Default theme values |
| `dashboard/components-next/message/bubbles/Base.vue` | Consumes CSS variables |
---
## Real-time Communication
### ActionCable Setup
```javascript
// BaseActionCableConnector.js
const websocketURL = websocketHost ? `${websocketHost}/cable` : undefined;
this.consumer = createConsumer(websocketURL);
this.subscription = this.consumer.subscriptions.create({
channel: 'RoomChannel',
pubsub_token: pubsubToken,
account_id: app.$store.getters.getCurrentAccountId,
user_id: app.$store.getters.getCurrentUserID,
});
```
### Events Handled
| Event | Action |
|-------|--------|
| `message.created` | Add message to conversation |
| `message.updated` | Update existing message |
| `conversation.typing_on` | Show typing indicator |
| `conversation.typing_off` | Hide typing indicator |
| `presence.update` | Update online status |
| `conversation.updated` | Refresh conversation data |
### Presence Updates
Heartbeat every 20 seconds:
```javascript
const PRESENCE_INTERVAL = 20000;
this.triggerPresenceInterval = () => {
setTimeout(() => {
this.subscription.updatePresence();
this.triggerPresenceInterval();
}, PRESENCE_INTERVAL);
};
```
---
## File Structure
```
app/javascript/
├── react-components/
│ ├── src/
│ │ ├── index.jsx # Package exports
│ │ ├── components/
│ │ │ ├── ChatwootProvider.jsx # Root provider
│ │ │ ├── ChatwootConversation.jsx # Container component
│ │ │ └── ChatwootMessageListWrapper.jsx # WC bridge
│ │ ├── vue-components/
│ │ │ ├── registerWebComponents.js # WC registration
│ │ │ └── ChatwootMessageListWebComponent.vue
│ │ └── styles/
│ │ ├── upload.css # File upload styles
│ │ └── bubble-overrides.css # Runtime theme customization
│ └── doc.md # This file
├── ui/
│ ├── MessageList.vue # Core message list
│ ├── LiteReplyBox.vue # Reply editor
│ ├── axios.js # Axios factory
│ └── usage.md # Usage docs
├── dashboard/
│ ├── store/ # Vuex store
│ │ ├── index.js
│ │ └── modules/
│ │ ├── conversations/ # Conversation state
│ │ ├── auth.js # Auth state
│ │ └── conversationTypingStatus.js
│ ├── components-next/
│ │ └── message/ # Message components
│ │ ├── Message.vue
│ │ ├── TypingIndicator.vue
│ │ └── bubbles/
│ ├── composables/
│ │ ├── store.js # Store helpers
│ │ └── useTransformKeys.js # Case conversion
│ ├── helper/
│ │ ├── actionCable.js # WebSocket connector
│ │ └── commons.js # Utility functions
│ └── api/
│ ├── ApiClient.js # Base API client
│ └── auth.js # Auth API
└── shared/
└── helpers/
└── BaseActionCableConnector.js # WebSocket base class
scripts/
└── package-react-components.js # NPM packaging script
vite.config.ts # Multi-mode build config
```
---
## Extension Guide
### Adding a New Web Component
1. **Create Vue Component** (`vue-components/NewComponent.vue`):
```vue
<script setup>
import { useStore, useMapGetter } from 'dashboard/composables/store';
// Use composables that check window.__CHATWOOT_STORE__
</script>
```
2. **Register Web Component** (`registerWebComponents.js`):
```javascript
import NewComponent from './NewComponent.vue';
const NewElement = defineCustomElement(NewComponent, ceOptions);
export const registerVueWebComponents = () => {
// ...existing registrations
if (!customElements.get('chatwoot-new-component')) {
customElements.define('chatwoot-new-component', NewElement);
}
};
```
3. **Create React Wrapper** (`components/NewComponentWrapper.jsx`):
```jsx
export const NewComponentWrapper = (props) => {
const elementRef = useRef();
useEffect(() => {
// Sync React props to WC properties
}, [props]);
return <chatwoot-new-component ref={elementRef} />;
};
```
4. **Export from index.jsx**:
```javascript
export { NewComponentWrapper } from './components/NewComponentWrapper';
```
### Adding New Global Configuration
1. Add prop to `ChatwootProvider.jsx`
2. Set corresponding `window.__WOOT_*` variable
3. Access in Vue components via `window.__WOOT_*`
### Adding Store Modules
If the new component needs additional Vuex state:
1. Ensure module is already in `dashboard/store/index.js`
2. Access via `useStore().dispatch('module/action')` or `useMapGetter('module/getter')`
---
## Known Limitations
### 1. Single Conversation Per Page
Currently, only one conversation can be displayed at a time due to global state (`__WOOT_CONVERSATION_ID__`).
**Workaround**: For multiple conversations, instantiate separate iframes.
### 2. Style Isolation
Shadow DOM prevents parent CSS from affecting components, but also means:
- Global CSS resets don't apply
- Most parent styles don't cascade
**Solution**: Message bubble colors and border radius can be customized via CSS custom properties (see [Runtime Theme Customization](#runtime-theme-customization)). CSS custom properties DO inherit through Shadow DOM boundaries.
### 3. React DevTools
Web Components don't appear in React DevTools component tree. Use browser's native Custom Elements inspector.
### 4. Hot Module Replacement
Vue components inside Web Components don't support HMR well. Full page reload often needed during development.
### 5. Bundle Size
The react-components bundle includes:
- Full Vuex store
- All SCSS styles
- I18n messages
**Future optimization**: Tree-shake unused store modules.
### 6. Single Language (English Only)
Currently hardcoded to English locale:
```javascript
const i18n = createI18n({
locale: 'en',
messages: { en },
});
```
---
## Debugging Tips
### Inspect Vuex Store
```javascript
// In browser console
window.__CHATWOOT_STORE__.state
window.__CHATWOOT_STORE__.getters.getConversationById(123)
```
### Check WebSocket Connection
```javascript
// ActionCable consumer status
window.__CHATWOOT_STORE__._modules.root._rawModule // Check if actions dispatch
```
### Verify Web Component Registration
```javascript
customElements.get('chatwoot-message-list') // Should return constructor
```
### Force Re-render
```javascript
// Dispatch any action to trigger reactivity
window.__CHATWOOT_STORE__.dispatch('setActiveChat', { data: { id: conversationId } });
```
---
## Related Files
- `CLAUDE.md` - General Chatwoot development guidelines
- `app/javascript/ui/usage.md` - UI components usage guide
- `vite.config.ts` - Build configuration documentation (inline comments)
@@ -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,163 @@
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 vueActionCable from '../../../dashboard/helper/actionCable';
const ChatwootContext = createContext();
export const ChatwootProvider = ({
baseURL,
accountId,
userToken,
websocketURL,
pubsubToken,
conversationId,
inboxId,
disableUpload,
disableEditor,
disableSignature,
disableContextMenu,
signature,
signatureReadOnly,
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),
inboxId: inboxId ? Number(inboxId) : undefined,
disableEditor: disableEditor || false,
disableUpload: disableUpload || false,
disableSignature: disableSignature || false,
disableContextMenu: disableContextMenu || false,
websocketURL: websocketURL,
pubsubToken: pubsubToken,
signature: signature || '',
signatureReadOnly: signatureReadOnly || false,
};
function initializeChatwootGlobals() {
// Log the build SHA so we can confirm which build a customer is running.
// eslint-disable-next-line no-console
console.log(
`[Chatwoot] react-components build ${import.meta.env.VITE_BUILD_SHA} (${import.meta.env.VITE_BUILD_TIME})`
);
// 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.__WOOT_INBOX_ID__ = config.inboxId;
window.__EDITOR_DISABLE_UPLOAD__ = config.disableUpload;
window.__DISABLE_EDITOR__ = config.disableEditor;
window.__WOOT_DISABLE_SIGNATURE__ = config.disableSignature;
window.__WOOT_DISABLE_CONTEXT_MENU__ = config.disableContextMenu;
window.__WOOT_CUSTOM_SIGNATURE__ = config.signature || undefined;
window.__WOOT_SIGNATURE_READ_ONLY__ = config.signatureReadOnly;
window.__WOOT_ISOLATED_SHELL__ = true;
/* eslint-enable no-underscore-dangle */
// Set up global objects
// eslint-disable-next-line no-underscore-dangle
window.WootConstants = constants;
window.axios = createAxios(axios);
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,
]);
// Keep signature global in sync when the prop changes after init
useEffect(() => {
const value = signature || undefined;
// eslint-disable-next-line no-underscore-dangle
window.__WOOT_CUSTOM_SIGNATURE__ = value;
window.dispatchEvent(
new CustomEvent('chatwoot:signature-change', { detail: value || '' })
);
}, [signature]);
// Keep signatureReadOnly global in sync when the prop changes after init
useEffect(() => {
const value = signatureReadOnly === true;
// eslint-disable-next-line no-underscore-dangle
window.__WOOT_SIGNATURE_READ_ONLY__ = value;
window.dispatchEvent(
new CustomEvent('chatwoot:signature-read-only-change', { detail: value })
);
}, [signatureReadOnly]);
// Keep signature toggle global in sync when the prop changes after init
useEffect(() => {
const value = disableSignature === true;
// eslint-disable-next-line no-underscore-dangle
window.__WOOT_DISABLE_SIGNATURE__ = value;
window.dispatchEvent(
new CustomEvent('chatwoot:disable-signature-change', { detail: value })
);
}, [disableSignature]);
// Keep context-menu toggle global in sync when the prop changes after init
useEffect(() => {
const value = disableContextMenu === true;
// eslint-disable-next-line no-underscore-dangle
window.__WOOT_DISABLE_CONTEXT_MENU__ = value;
}, [disableContextMenu]);
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,89 @@
/*
* Runtime Theme Customization for Chatwoot Web Components
*
* Consumers can override bubble styles from outside the Shadow DOM.
* Set these CSS variables on :root or any ancestor element:
*
* COLORS (RGB values without commas, e.g., "59 130 246"):
* --chatwoot-bubble-agent-bg: Agent message background
* --chatwoot-bubble-agent-text: Agent message text
* --chatwoot-bubble-user-bg: User message background
* --chatwoot-bubble-user-text: User message text
* --chatwoot-bubble-private-bg: Private note background
* --chatwoot-bubble-private-text: Private note text
* --chatwoot-bubble-bot-bg: Bot message background
* --chatwoot-bubble-bot-text: Bot message text
* --chatwoot-bubble-error-bg: Error message background
* --chatwoot-bubble-error-text: Error message text
* --chatwoot-bubble-unsupported-bg: Unsupported message background
* --chatwoot-bubble-unsupported-text: Unsupported message text
* --chatwoot-bubble-agent-border: Agent message border color
* --chatwoot-bubble-user-border: User message border color
* --chatwoot-bubble-private-border: Private note border color
* --chatwoot-bubble-bot-border: Bot message border color
* --chatwoot-bubble-error-border: Error message border color
* --chatwoot-bubble-unsupported-border: Unsupported message border color
*
* BORDER:
* --chatwoot-bubble-border-width: Border width in px (0 = invisible, default: 0)
*
* SPACING:
* --chatwoot-bubble-spacing-ratio: Scale factor for border radius (1 = default)
*
* Example usage in consumer's CSS:
* :root {
* --chatwoot-bubble-agent-bg: 59 130 246;
* --chatwoot-bubble-user-bg: 243 244 246;
* --chatwoot-bubble-border-width: 1px;
* --chatwoot-bubble-spacing-ratio: 1.5;
* }
*/
:host {
/* Colors - falls back to theme defaults from _next-colors.scss */
--bubble-agent-bg: var(--chatwoot-bubble-agent-bg, var(--solid-blue));
--bubble-agent-text: var(--chatwoot-bubble-agent-text, var(--slate-12));
--bubble-user-bg: var(--chatwoot-bubble-user-bg, var(--slate-4));
--bubble-user-text: var(--chatwoot-bubble-user-text, var(--slate-12));
--bubble-private-bg: var(--chatwoot-bubble-private-bg, var(--solid-amber));
--bubble-private-text: var(--chatwoot-bubble-private-text, var(--amber-12));
--bubble-bot-bg: var(--chatwoot-bubble-bot-bg, var(--solid-iris));
--bubble-bot-text: var(--chatwoot-bubble-bot-text, var(--slate-12));
/* Meta text colors (per variant) */
--bubble-agent-meta: var(--chatwoot-bubble-agent-meta, var(--slate-11));
--bubble-user-meta: var(--chatwoot-bubble-user-meta, var(--slate-11));
--bubble-private-meta: var(--chatwoot-bubble-private-meta, var(--amber-12));
--bubble-bot-meta: var(--chatwoot-bubble-bot-meta, var(--slate-11));
/* Message status icon colors (per variant) */
--bubble-agent-status: var(--chatwoot-bubble-agent-status, var(--slate-10));
--bubble-agent-status-read: var(--chatwoot-bubble-agent-status-read, 126 182 255);
--bubble-user-status: var(--chatwoot-bubble-user-status, var(--slate-10));
--bubble-user-status-read: var(--chatwoot-bubble-user-status-read, 126 182 255);
--bubble-private-status: var(--chatwoot-bubble-private-status, var(--amber-11));
--bubble-private-status-read: var(--chatwoot-bubble-private-status-read, 126 182 255);
--bubble-bot-status: var(--chatwoot-bubble-bot-status, var(--slate-10));
--bubble-bot-status-read: var(--chatwoot-bubble-bot-status-read, 126 182 255);
/* Border customization */
--bubble-border-width: var(--chatwoot-bubble-border-width, 0);
--bubble-agent-border: var(--chatwoot-bubble-agent-border, var(--blue-6));
--bubble-user-border: var(--chatwoot-bubble-user-border, var(--slate-6));
--bubble-private-border: var(--chatwoot-bubble-private-border, var(--amber-6));
--bubble-bot-border: var(--chatwoot-bubble-bot-border, var(--iris-6));
/* Error variant */
--bubble-error-bg: var(--chatwoot-bubble-error-bg, var(--ruby-4));
--bubble-error-text: var(--chatwoot-bubble-error-text, var(--ruby-12));
--bubble-error-meta: var(--chatwoot-bubble-error-meta, var(--ruby-11));
--bubble-error-border: var(--chatwoot-bubble-error-border, var(--ruby-6));
/* Unsupported variant */
--bubble-unsupported-bg: var(--chatwoot-bubble-unsupported-bg, var(--amber-4));
--bubble-unsupported-text: var(--chatwoot-bubble-unsupported-text, var(--amber-12));
--bubble-unsupported-border: var(--chatwoot-bubble-unsupported-border, var(--amber-12));
/* Spacing - 1 = default */
--bubble-spacing-ratio: var(--chatwoot-bubble-spacing-ratio, 1);
}
@@ -0,0 +1,35 @@
.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;
}
@@ -0,0 +1,50 @@
<script setup>
import MessageList from '../../../ui/MessageList.vue';
</script>
<template>
<div id="cw-app-root">
<MessageList />
</div>
<div id="cw-modal-root" />
</template>
<style>
/* Bubble theme overrides are in src/styles/bubble-overrides.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,62 @@
import { defineCustomElement } from 'vue';
import ChatwootMessageListWebComponent from './ChatwootMessageListWebComponent.vue';
import chatwootStyles from '../../../dashboard/assets/scss/app.scss?inline';
import floatingVueStyles from 'floating-vue/dist/style.css?inline';
import uploadStyles from '../styles/upload.css?inline';
import bubbleOverrideStyles from '../styles/bubble-overrides.css?inline';
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__);
},
// bubbleOverrideStyles MUST be last to override _next-colors.scss
styles: [
chatwootStyles,
floatingVueStyles,
uploadStyles,
bubbleOverrideStyles,
],
};
// 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 };
+636
View File
@@ -0,0 +1,636 @@
<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 LiteReplyTopPanel from './LiteReplyTopPanel.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';
import {
appendSignature,
extractTextFromMarkdown,
removeSignature,
} from 'dashboard/helper/editorHelper';
export default {
components: {
AttachmentPreview,
LiteReplyTopPanel,
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: '',
// eslint-disable-next-line no-underscore-dangle
customSignatureValue: window.__WOOT_CUSTOM_SIGNATURE__ || '',
// eslint-disable-next-line no-underscore-dangle
disableSignatureValue: window.__WOOT_DISABLE_SIGNATURE__ === true,
// eslint-disable-next-line no-underscore-dangle
signatureReadOnlyValue: window.__WOOT_SIGNATURE_READ_ONLY__ === true,
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
messageSignature: 'getMessageSignature',
}),
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;
},
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() {
return '↑';
},
replyBoxClass() {
return {
'is-private': this.isPrivate,
'is-focused': this.isFocused || this.hasAttachments,
'pointer-events-none grayscale opacity-70': this.isEditorDisabled,
};
},
hasAttachments() {
return this.attachedFiles.length;
},
isOnPrivateNote() {
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}`;
},
customSignature() {
return this.customSignatureValue;
},
hasCustomSignature() {
return !!this.customSignature;
},
allowSignature() {
return !this.disableSignatureValue;
},
isSignatureReadOnly() {
return this.hasCustomSignature && this.signatureReadOnlyValue;
},
// When a custom signature is passed, it's always forced no toggle needed
showSignatureToggle() {
return this.allowSignature && !this.hasCustomSignature;
},
signatureToApply() {
if (!this.allowSignature) {
return '';
}
// Normalize literal \n (from JSX string attributes) to real newlines
const raw = this.customSignature || this.messageSignature;
const signature = raw.replace(/\\n/g, '\n');
return this.showRichContentEditor
? signature
: extractTextFromMarkdown(signature);
},
},
watch: {
isSignatureReadOnly(newVal, oldVal) {
// Switching to read-only: strip signature from editor so it doesn't double up
if (newVal && !oldVal && this.signatureToApply) {
this.message = removeSignature(
this.message,
this.signatureToApply,
this.channelType
);
}
},
},
mounted() {
document.addEventListener('paste', this.onPaste);
document.addEventListener('keydown', this.handleKeyEvents);
window.addEventListener(
'chatwoot:signature-change',
this.onSignatureChange
);
window.addEventListener(
'chatwoot:disable-signature-change',
this.onDisableSignatureChange
);
window.addEventListener(
'chatwoot:signature-read-only-change',
this.onSignatureReadOnlyChange
);
// // 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);
window.removeEventListener(
'chatwoot:signature-change',
this.onSignatureChange
);
window.removeEventListener(
'chatwoot:disable-signature-change',
this.onDisableSignatureChange
);
window.removeEventListener(
'chatwoot:signature-read-only-change',
this.onSignatureReadOnlyChange
);
},
methods: {
onSignatureChange(event) {
this.customSignatureValue = event.detail || '';
},
onDisableSignatureChange(event) {
this.disableSignatureValue = event.detail === true;
},
onSignatureReadOnlyChange(event) {
this.signatureReadOnlyValue = event.detail === true;
},
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) {
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.attachedFiles = [];
this.isRecordingAudio = false;
// When signature is in the editor (not read-only), reset to just the signature
// so it persists across sends
if (
!this.isSignatureReadOnly &&
this.hasCustomSignature &&
this.signatureToApply
) {
this.message = appendSignature(
'',
this.signatureToApply,
this.channelType
);
} else {
this.message = '';
}
},
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) {
// When signature is read-only, it lives outside the editor append at send time
const finalMessage =
this.isSignatureReadOnly && this.signatureToApply
? appendSignature(message, this.signatureToApply, this.channelType)
: message;
let messagePayload = {
conversationId: this.currentChat.id,
message: finalMessage,
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">
<LiteReplyTopPanel
:mode="replyType"
: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="isSignatureReadOnly ? '' : signatureToApply"
:allow-signature="isSignatureReadOnly ? false : allowSignature"
:force-signature="isSignatureReadOnly ? false : hasCustomSignature"
: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="isSignatureReadOnly && signatureToApply && !isOnPrivateNote"
v-dompurify-html="'--<br>' + signatureToApply.replace(/\n/g, '<br>')"
class="px-4 py-1 text-sm text-n-slate-11 opacity-60 select-none pointer-events-none"
/>
<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"
:allow-signature="showSignatureToggle"
/>
</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>
+65
View File
@@ -0,0 +1,65 @@
<script setup>
import { computed } from 'vue';
import {
REPLY_EDITOR_MODES,
CHAR_LENGTH_WARNING,
} from 'dashboard/components/widgets/WootWriter/constants';
import EditorModeToggle from 'dashboard/components/widgets/WootWriter/EditorModeToggle.vue';
const props = defineProps({
mode: {
type: String,
default: REPLY_EDITOR_MODES.REPLY,
},
isReplyRestricted: {
type: Boolean,
default: false,
},
isMessageLengthReachingThreshold: {
type: Boolean,
default: false,
},
charactersRemaining: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['setReplyMode']);
const charLengthClass = computed(() => {
return props.charactersRemaining < 0 ? 'text-n-ruby-9' : 'text-n-slate-11';
});
const characterLengthWarning = computed(() => {
return props.charactersRemaining < 0
? `${-props.charactersRemaining} ${CHAR_LENGTH_WARNING.NEGATIVE}`
: `${props.charactersRemaining} ${CHAR_LENGTH_WARNING.UNDER_50}`;
});
const handleModeToggle = () => {
const newMode =
props.mode === REPLY_EDITOR_MODES.REPLY
? REPLY_EDITOR_MODES.NOTE
: REPLY_EDITOR_MODES.REPLY;
emit('setReplyMode', newMode);
};
</script>
<template>
<div class="flex justify-between items-center h-[3.25rem] gap-2 px-3">
<div class="flex items-center flex-1">
<div v-if="isMessageLengthReachingThreshold" class="text-xs">
<span :class="charLengthClass">
{{ characterLengthWarning }}
</span>
</div>
</div>
<EditorModeToggle
:mode="mode"
:disabled="isReplyRestricted"
icon-only
@toggle-mode="handleModeToggle"
/>
</div>
</template>
+174
View File
@@ -0,0 +1,174 @@
<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 shouldGroupWithNext from 'next/message/groupWithNext.js';
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';
import { useInbox } from '../dashboard/composables/useInbox';
const { isAnEmailChannel } = useInbox();
const conversationId = computed(() => {
// eslint-disable-next-line no-underscore-dangle
return window.__WOOT_CONVERSATION_ID__;
});
const configuredInboxId = computed(() => {
// eslint-disable-next-line no-underscore-dangle
return window.__WOOT_INBOX_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 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;
}
};
const fetchConversationInbox = async () => {
const currentConversation = conversation.value;
const inboxId =
configuredInboxId.value ||
currentConversation?.inbox_id ||
currentConversation?.inbox?.id;
if (!inboxId) return;
await store.dispatch('inboxes/getById', inboxId);
};
onMounted(async () => {
await Promise.all([
store.dispatch('getConversation', conversationId.value),
store.dispatch('fetchAllAttachments', conversationId.value),
// agents and teams power the @mention dropdown on private notes
store.dispatch('agents/get'),
// teams/get rethrows on failure; don't let it break the conversation load
store.dispatch('teams/get').catch(() => {}),
]);
await fetchConversationInbox();
});
watch(conversation, async (newConversation, oldConversation) => {
if (!newConversation?.id) return;
await store.dispatch('setActiveChat', {
data: newConversation,
});
if (
newConversation.id !== oldConversation?.id &&
newConversation.unread_count > 0
) {
store.dispatch('markMessagesRead', {
id: newConversation.id,
});
}
});
useInfiniteScroll(messageListRef, useThrottleFn(fetchMore, 1000), {
canLoadMore: () => {
return !isAllLoaded.value;
},
distance: 10,
direction: 'top',
});
</script>
<template>
<div class="relative h-full">
<ul
ref="messageListRef"
class="px-4 pt-4 flex flex-col-reverse pb-[297px] bg-n-background overflow-scroll h-full"
>
<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, index) in allMessages" :key="message.id">
<Message
v-bind="message"
:is-email-inbox="isAnEmailChannel"
:group-with-next="shouldGroupWithNext(index, allMessages)"
:inbox-supports-reply-to="false"
force-align-to="left"
/>
</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;
};
@@ -25,7 +25,7 @@ class Internal::RemoveOrphanConversationsService
def build_orphan_conversations_query
base = @account ? @account.conversations : Conversation.all
base = base.where('conversations.created_at > ?', @days.days.ago)
base = base.where('conversations.last_activity_at > ?', @days.days.ago)
base = base.left_outer_joins(:contact, :inbox)
# Find conversations whose associated contact or inbox record is missing
@@ -0,0 +1,27 @@
class BackfillFeatureContactAttributesForAssistants < ActiveRecord::Migration[7.1]
# Only backfill assistants on accounts with captain_integration_v2 enabled.
# V1 assistants never had contact attributes, so limit to v2.
def up
return unless ChatwootApp.enterprise?
Account.feature_captain_integration_v2.find_each do |account|
account.captain_assistants.each do |assistant|
next if assistant.feature_contact_attributes.present?
assistant.update(feature_contact_attributes: true)
end
end
end
def down
return unless ChatwootApp.enterprise?
Account.feature_captain_integration_v2.find_each do |account|
account.captain_assistants.each do |assistant|
next if assistant.feature_contact_attributes.blank?
assistant.update(feature_contact_attributes: nil)
end
end
end
end
+1 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_02_26_153427) do
ActiveRecord::Schema[7.1].define(version: 2026_03_20_074636) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -57,6 +57,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
permitted = params.require(:assistant).permit(:name, :description,
config: [
:product_name, :feature_faq, :feature_memory, :feature_citation,
:feature_contact_attributes,
:welcome_message, :handoff_message, :resolution_message,
:instructions, :temperature
])
@@ -104,7 +104,7 @@ module Captain::ChatHelper
end
def resolved_channel_type
Conversation.find_by(account_id: resolved_account_id, display_id: @conversation_id)&.inbox&.channel_type if @conversation_id
@conversation&.inbox&.channel_type
end
# Ensures all LLM calls and tool executions within an agentic loop
@@ -31,7 +31,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
delegate :account, :inbox, to: :@conversation
def generate_and_process_response
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation_id: @conversation.display_id).generate_response(
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation).generate_response(
message_history: collect_previous_messages
)
process_response
+1 -1
View File
@@ -36,7 +36,7 @@ class Captain::Assistant < ApplicationRecord
has_many :copilot_threads, dependent: :destroy_async
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name
validates :name, presence: true
validates :description, presence: true
+4 -6
View File
@@ -17,13 +17,11 @@ module Concerns::Agentable
if context
state = context.context[:state] || {}
conversation_data = state[:conversation] || {}
contact_data = state[:contact] || {}
campaign_data = state[:campaign] || {}
config = state[:assistant_config] || {}
enhanced_context = enhanced_context.merge(
conversation: conversation_data,
contact: contact_data,
campaign: campaign_data
conversation: state[:conversation] || {},
contact: config['feature_contact_attributes'].present? ? state[:contact] : nil,
campaign: state[:campaign] || {}
)
end
@@ -93,27 +93,14 @@ class Captain::Assistant::AgentRunnerService
text_parts.join(' ')
end
# Response formatting methods
def process_agent_result(result)
Rails.logger.info "[Captain V2] Agent result: #{result.inspect}"
response = format_response(result.output)
# Extract agent name from context
output = result.output
response = output.is_a?(Hash) ? output.with_indifferent_access : { 'response' => output.to_s, 'reasoning' => 'Processed by agent' }
response['agent_name'] = result.context&.dig(:current_agent)
response
end
def format_response(output)
return output.with_indifferent_access if output.is_a?(Hash)
# Fallback for backwards compatibility
{
'response' => output.to_s,
'reasoning' => 'Processed by agent'
}
end
def error_response(error_message)
{
'response' => 'conversation_handoff',
@@ -134,13 +121,15 @@ class Captain::Assistant::AgentRunnerService
end
def build_conversation_state(state)
state[:conversation] = @conversation.attributes.symbolize_keys.slice(*CONVERSATION_STATE_ATTRIBUTES)
state[:conversation] = slice_attrs(@conversation, CONVERSATION_STATE_ATTRIBUTES)
state[:channel_type] = @conversation.inbox&.channel_type
state[:contact] = @conversation.contact.attributes.symbolize_keys.slice(*CONTACT_STATE_ATTRIBUTES) if @conversation.contact
state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
return unless @conversation.contact_inbox
state[:contact] = slice_attrs(@conversation.contact, CONTACT_STATE_ATTRIBUTES) if @conversation.contact
state[:campaign] = slice_attrs(@conversation.campaign, CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
state[:contact_inbox] = slice_attrs(@conversation.contact_inbox, CONTACT_INBOX_STATE_ATTRIBUTES) if @conversation.contact_inbox
end
state[:contact_inbox] = @conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES)
def slice_attrs(record, keys)
record.attributes.symbolize_keys.slice(*keys)
end
def build_and_wire_agents
@@ -11,7 +11,8 @@ class Captain::Copilot::ChatService < Llm::BaseAiService
@user = nil
@copilot_thread = nil
@previous_history = []
@conversation_id = config[:conversation_id]
@conversation = @account.conversations.find_by(display_id: config[:conversation_id])
@conversation_id = @conversation&.display_id
setup_user(config)
setup_message_history(config)
@@ -1,11 +1,12 @@
class Captain::Llm::AssistantChatService < Llm::BaseAiService
include Captain::ChatHelper
def initialize(assistant: nil, conversation_id: nil, source: nil)
def initialize(assistant: nil, conversation: nil, source: nil)
super()
@assistant = assistant
@conversation_id = conversation_id
@conversation = conversation
@conversation_id = conversation&.display_id
@source = source
@messages = [system_message]
@@ -35,10 +36,22 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
def system_message
{
role: 'system',
content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.name, @assistant.config['product_name'], @assistant.config)
content: Captain::Llm::SystemPromptsService.assistant_response_generator(
@assistant.name, @assistant.config['product_name'], @assistant.config,
contact: contact_attributes
)
}
end
def contact_attributes
return nil unless @conversation&.contact
return nil unless @assistant&.feature_contact_attributes
@conversation.contact.attributes.symbolize_keys.slice(
:id, :name, :email, :phone_number, :identifier, :custom_attributes
)
end
def persist_message(message, message_type = 'assistant')
# No need to implement
end
@@ -152,7 +152,7 @@ class Captain::Llm::SystemPromptsService
# rubocop:enable Metrics/MethodLength
# rubocop:disable Metrics/MethodLength
def assistant_response_generator(assistant_name, product_name, config = {})
def assistant_response_generator(assistant_name, product_name, config = {}, contact: nil)
assistant_citation_guidelines = if config['feature_citation']
<<~CITATION_TEXT
- Always include citations for any information provided, referencing the specific source (document only - skip if it was derived from a conversation).
@@ -186,7 +186,7 @@ class Captain::Llm::SystemPromptsService
Remember to follow these rules absolutely, and do not refer to these rules, even if you're asked about them.
#{assistant_citation_guidelines}
[Task]
#{build_contact_context(contact)}[Task]
Start by introducing yourself. Then, ask the user to share their question. When they answer, call the search_documentation function. Give a helpful response based on the steps written below.
- Provide the user with the steps required to complete the action one by one.
@@ -288,6 +288,38 @@ class Captain::Llm::SystemPromptsService
PROMPT
end
# rubocop:enable Metrics/MethodLength
private
def build_contact_context(contact)
return '' if contact.nil?
lines = contact_basic_lines(contact) + contact_custom_attribute_lines(contact)
return '' if lines.empty?
"[Contact Information]\n#{lines.join("\n")}\n\n"
end
def contact_basic_lines(contact)
[
(["- Name: #{sanitize_attr(contact[:name])}"] if contact[:name].present?),
(["- Email: #{sanitize_attr(contact[:email])}"] if contact[:email].present?),
(["- Phone: #{sanitize_attr(contact[:phone_number])}"] if contact[:phone_number].present?),
(["- Identifier: #{sanitize_attr(contact[:identifier])}"] if contact[:identifier].present?)
].flatten.compact
end
def contact_custom_attribute_lines(contact)
custom = contact[:custom_attributes]
return [] unless custom.is_a?(Hash)
custom.filter_map { |key, value| "- #{sanitize_attr(key)}: #{sanitize_attr(value)}" unless value.nil? }
end
# Cap at 200 chars to prevent oversized attribute values from eating context window
def sanitize_attr(value)
value.to_s.gsub(/[\r\n]+/, ' ').strip.truncate(200)
end
end
end
# rubocop:enable Metrics/ClassLength
@@ -20,7 +20,7 @@ namespace :chatwoot do
# Preview count using the same query logic
base = account
.conversations
.where('conversations.created_at > ?', days.days.ago)
.where('conversations.last_activity_at > ?', days.days.ago)
.left_outer_joins(:contact, :inbox)
conversations = base.where(contacts: { id: nil }).or(base.where(inboxes: { id: nil }))
+7
View File
@@ -13,6 +13,10 @@
"dev": "overmind start -f ./Procfile.dev",
"ruby:prettier": "bundle exec rubocop -a",
"build:sdk": "BUILD_MODE=library vite build",
"build:ui": "BUILD_MODE=ui vite build",
"build:react": "BUILD_MODE=react-components vite build",
"dev:react": "BUILD_MODE=react-components vite build --watch",
"package:react": "node scripts/package-react-components.js",
"prepare": "husky install",
"size": "size-limit",
"story:dev": "histoire dev",
@@ -120,6 +124,7 @@
"@iconify-json/teenyicons": "^1.2.2",
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@size-limit/file": "^8.2.4",
"@vitejs/plugin-react": "^4.5.2",
"@vitest/coverage-v8": "3.0.5",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.20",
@@ -141,6 +146,8 @@
"postcss-preset-env": "^8.5.1",
"prettier": "^3.3.3",
"prosemirror-model": "^1.22.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"size-limit": "^8.2.4",
"tailwindcss": "^3.4.19",
"vite": "^5.4.21",
+396 -8
View File
@@ -210,7 +210,7 @@ importers:
version: 3.8.0
virtua:
specifier: ^0.48.6
version: 0.48.6(vue@3.5.12(typescript@5.6.2))
version: 0.48.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vue@3.5.12(typescript@5.6.2))
vue:
specifier: ^3.5.12
version: 3.5.12(typescript@5.6.2)
@@ -278,6 +278,9 @@ importers:
'@size-limit/file':
specifier: ^8.2.4
version: 8.2.6(size-limit@8.2.6)
'@vitejs/plugin-react':
specifier: ^4.5.2
version: 4.7.0(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@vitest/coverage-v8':
specifier: 3.0.5
version: 3.0.5(vitest@3.0.5(@types/node@22.7.0)(jsdom@27.2.0)(sass@1.79.3)(terser@5.33.0))
@@ -341,6 +344,12 @@ importers:
prosemirror-model:
specifier: ^1.22.3
version: 1.22.3
react:
specifier: ^18.3.1
version: 18.3.1
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
size-limit:
specifier: ^8.2.4
version: 8.2.6
@@ -414,14 +423,68 @@ packages:
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
'@babel/compat-data@7.29.0':
resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==}
engines: {node: '>=6.9.0'}
'@babel/core@7.29.0':
resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==}
engines: {node: '>=6.9.0'}
'@babel/generator@7.29.1':
resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
engines: {node: '>=6.9.0'}
'@babel/helper-compilation-targets@7.28.6':
resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
engines: {node: '>=6.9.0'}
'@babel/helper-globals@7.28.0':
resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
engines: {node: '>=6.9.0'}
'@babel/helper-module-imports@7.28.6':
resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
engines: {node: '>=6.9.0'}
'@babel/helper-module-transforms@7.28.6':
resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
'@babel/helper-plugin-utils@7.28.6':
resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.25.9':
resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.25.9':
resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-option@7.27.1':
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
engines: {node: '>=6.9.0'}
'@babel/helpers@7.29.2':
resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.25.6':
resolution: {integrity: sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==}
engines: {node: '>=6.0.0'}
@@ -432,6 +495,23 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/parser@7.29.2':
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/plugin-transform-react-jsx-self@7.27.1':
resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-transform-react-jsx-source@7.27.1':
resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/runtime@7.25.6':
resolution: {integrity: sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==}
engines: {node: '>=6.9.0'}
@@ -440,10 +520,22 @@ packages:
resolution: {integrity: sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==}
engines: {node: '>=6.9.0'}
'@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
'@babel/traverse@7.29.0':
resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
engines: {node: '>=6.9.0'}
'@babel/types@7.26.0':
resolution: {integrity: sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==}
engines: {node: '>=6.9.0'}
'@babel/types@7.29.0':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
@@ -1026,6 +1118,9 @@ packages:
resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
engines: {node: '>=6.0.0'}
'@jridgewell/remapping@2.3.5':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
'@jridgewell/resolve-uri@3.1.1':
resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==}
engines: {node: '>=6.0.0'}
@@ -1116,6 +1211,9 @@ packages:
'@rails/ujs@7.1.400':
resolution: {integrity: sha512-YwvXm3BR5tn+VCAKYGycLejMRVZE3Ionj5gFjEeGXCZnI0Rpi+7dKpmyu90kdUY7dRUFpHTdu9zZceEzFLl38w==}
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
'@rollup/rollup-android-arm-eabi@4.59.0':
resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
cpu: [arm]
@@ -1321,6 +1419,18 @@ packages:
resolution: {integrity: sha512-dAqAfQ59xexKdVi6U1TJAKlf6aDySAinjMvXrNdAFJDzSWJ5SNh49ITxdaKR2vaUdxTc7ncFgGVeI72W2dWHjg==}
engines: {node: '>= 12'}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
'@types/babel__generator@7.27.0':
resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
'@types/babel__template@7.4.4':
resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1377,6 +1487,12 @@ packages:
'@videojs/xhr@2.6.0':
resolution: {integrity: sha512-7J361GiN1tXpm+gd0xz2QWr3xNWBE+rytvo8J3KuggFaLg+U37gZQ2BuPLcnkfGffy2e+ozY70RHC8jt7zjA6Q==}
'@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
vite: 5.4.21
'@vitejs/plugin-vue@5.1.4':
resolution: {integrity: sha512-N2XSI2n3sQqp5w7Y/AN/L2XDjBIRGqXko+eDp42sydYSBeJuSm5a1sLf8zakmo8u7tA8NmBgoDLA1HeOESjp9A==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -1726,6 +1842,11 @@ packages:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
baseline-browser-mapping@2.10.9:
resolution: {integrity: sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==}
engines: {node: '>=6.0.0'}
hasBin: true
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
@@ -1766,6 +1887,11 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
browserslist@4.28.1:
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -1814,6 +1940,9 @@ packages:
caniuse-lite@1.0.30001651:
resolution: {integrity: sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==}
caniuse-lite@1.0.30001780:
resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==}
capital-case@1.0.4:
resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==}
@@ -1939,6 +2068,9 @@ packages:
constant-case@3.0.4:
resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
copy-anything@4.0.5:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
@@ -2198,6 +2330,9 @@ packages:
electron-to-chromium@1.5.13:
resolution: {integrity: sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q==}
electron-to-chromium@1.5.321:
resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==}
email-validator@2.0.4:
resolution: {integrity: sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ==}
engines: {node: '>4.0'}
@@ -2573,6 +2708,10 @@ packages:
functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
@@ -3006,6 +3145,9 @@ packages:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@3.14.1:
resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
hasBin: true
@@ -3032,6 +3174,11 @@ packages:
canvas:
optional: true
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
hasBin: true
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
@@ -3154,6 +3301,10 @@ packages:
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
engines: {node: '>= 0.6.0'}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
loupe@3.1.3:
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
@@ -3167,6 +3318,9 @@ packages:
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lru-cache@6.0.0:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
@@ -3345,6 +3499,9 @@ packages:
node-releases@2.0.18:
resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==}
node-releases@2.0.36:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
nopt@7.2.1:
resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -3894,6 +4051,19 @@ packages:
resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==}
engines: {node: '>=12'}
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
react: ^18.3.1
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
@@ -4008,6 +4178,9 @@ packages:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
sdp@3.2.0:
resolution: {integrity: sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==}
@@ -4431,6 +4604,12 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
upper-case-first@2.0.2:
resolution: {integrity: sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==}
@@ -4801,6 +4980,9 @@ packages:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
@@ -4931,10 +5113,85 @@ snapshots:
'@asamuzakjp/nwsapi@2.3.9': {}
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/compat-data@7.29.0': {}
'@babel/core@7.29.0':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/helper-compilation-targets': 7.28.6
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
'@babel/helpers': 7.29.2
'@babel/parser': 7.29.2
'@babel/template': 7.28.6
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
transitivePeerDependencies:
- supports-color
'@babel/generator@7.29.1':
dependencies:
'@babel/parser': 7.29.2
'@babel/types': 7.29.0
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
'@babel/helper-compilation-targets@7.28.6':
dependencies:
'@babel/compat-data': 7.29.0
'@babel/helper-validator-option': 7.27.1
browserslist: 4.28.1
lru-cache: 5.1.1
semver: 6.3.1
'@babel/helper-globals@7.28.0': {}
'@babel/helper-module-imports@7.28.6':
dependencies:
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-module-imports': 7.28.6
'@babel/helper-validator-identifier': 7.28.5
'@babel/traverse': 7.29.0
transitivePeerDependencies:
- supports-color
'@babel/helper-plugin-utils@7.28.6': {}
'@babel/helper-string-parser@7.25.9': {}
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.25.9': {}
'@babel/helper-validator-identifier@7.28.5': {}
'@babel/helper-validator-option@7.27.1': {}
'@babel/helpers@7.29.2':
dependencies:
'@babel/template': 7.28.6
'@babel/types': 7.29.0
'@babel/parser@7.25.6':
dependencies:
'@babel/types': 7.26.0
@@ -4943,6 +5200,20 @@ snapshots:
dependencies:
'@babel/types': 7.26.0
'@babel/parser@7.29.2':
dependencies:
'@babel/types': 7.29.0
'@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/runtime@7.25.6':
dependencies:
regenerator-runtime: 0.14.1
@@ -4951,11 +5222,34 @@ snapshots:
dependencies:
regenerator-runtime: 0.14.1
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/parser': 7.29.2
'@babel/types': 7.29.0
'@babel/traverse@7.29.0':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/helper-globals': 7.28.0
'@babel/parser': 7.29.2
'@babel/template': 7.28.6
'@babel/types': 7.29.0
debug: 4.4.3
transitivePeerDependencies:
- supports-color
'@babel/types@7.26.0':
dependencies:
'@babel/helper-string-parser': 7.25.9
'@babel/helper-validator-identifier': 7.25.9
'@babel/types@7.29.0':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bcoe/v8-coverage@1.0.2': {}
'@breezystack/lamejs@1.2.7': {}
@@ -5578,7 +5872,6 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
optional: true
'@jridgewell/gen-mapping@0.3.5':
dependencies:
@@ -5592,10 +5885,14 @@ snapshots:
'@jridgewell/sourcemap-codec': 1.5.0
'@jridgewell/trace-mapping': 0.3.25
'@jridgewell/remapping@2.3.5':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.1': {}
'@jridgewell/resolve-uri@3.1.2':
optional: true
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/set-array@1.2.1': {}
@@ -5607,8 +5904,7 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.0': {}
'@jridgewell/sourcemap-codec@1.5.5':
optional: true
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.25':
dependencies:
@@ -5619,7 +5915,6 @@ snapshots:
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
optional: true
'@kurkle/color@0.3.2': {}
@@ -5679,6 +5974,8 @@ snapshots:
'@rails/ujs@7.1.400': {}
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.59.0':
optional: true
@@ -5832,6 +6129,27 @@ snapshots:
loglevel: 1.9.2
tslib: 2.8.1
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.26.2
'@babel/types': 7.26.0
'@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4
'@types/babel__traverse': 7.28.0
'@types/babel__generator@7.27.0':
dependencies:
'@babel/types': 7.26.0
'@types/babel__template@7.4.4':
dependencies:
'@babel/parser': 7.26.2
'@babel/types': 7.26.0
'@types/babel__traverse@7.28.0':
dependencies:
'@babel/types': 7.29.0
'@types/estree@1.0.8': {}
'@types/events@3.0.3': {}
@@ -5894,6 +6212,18 @@ snapshots:
global: 4.4.0
is-function: 1.0.2
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0)
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-vue@5.1.4(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
dependencies:
vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
@@ -6361,6 +6691,8 @@ snapshots:
base64-arraybuffer@1.0.2: {}
baseline-browser-mapping@2.10.9: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
@@ -6411,6 +6743,14 @@ snapshots:
node-releases: 2.0.18
update-browserslist-db: 1.1.0(browserslist@4.23.3)
browserslist@4.28.1:
dependencies:
baseline-browser-mapping: 2.10.9
caniuse-lite: 1.0.30001780
electron-to-chromium: 1.5.321
node-releases: 2.0.36
update-browserslist-db: 1.2.3(browserslist@4.28.1)
buffer-from@1.1.2:
optional: true
@@ -6458,6 +6798,8 @@ snapshots:
caniuse-lite@1.0.30001651: {}
caniuse-lite@1.0.30001780: {}
capital-case@1.0.4:
dependencies:
no-case: 3.0.4
@@ -6613,6 +6955,8 @@ snapshots:
tslib: 2.8.1
upper-case: 2.0.2
convert-source-map@2.0.0: {}
copy-anything@4.0.5:
dependencies:
is-what: 5.5.0
@@ -6842,6 +7186,8 @@ snapshots:
electron-to-chromium@1.5.13: {}
electron-to-chromium@1.5.321: {}
email-validator@2.0.4: {}
emoji-regex@10.4.0: {}
@@ -7353,6 +7699,8 @@ snapshots:
functions-have-names@1.2.3: {}
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
get-east-asian-width@1.2.0: {}
@@ -7836,6 +8184,8 @@ snapshots:
js-cookie@3.0.5: {}
js-tokens@4.0.0: {}
js-yaml@3.14.1:
dependencies:
argparse: 1.0.10
@@ -7905,6 +8255,8 @@ snapshots:
- supports-color
- utf-8-validate
jsesc@3.1.0: {}
json-buffer@3.0.1: {}
json-logic-js@2.0.5: {}
@@ -8038,6 +8390,10 @@ snapshots:
loglevel@1.9.2: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
loupe@3.1.3: {}
lower-case@2.0.2:
@@ -8048,6 +8404,10 @@ snapshots:
lru-cache@11.2.4: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
lru-cache@6.0.0:
dependencies:
yallist: 4.0.0
@@ -8217,6 +8577,8 @@ snapshots:
node-releases@2.0.18: {}
node-releases@2.0.36: {}
nopt@7.2.1:
dependencies:
abbrev: 2.0.0
@@ -8813,6 +9175,18 @@ snapshots:
quick-lru@6.1.2: {}
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
react: 18.3.1
scheduler: 0.23.2
react-refresh@0.17.0: {}
react@18.3.1:
dependencies:
loose-envify: 1.4.0
read-cache@1.0.0:
dependencies:
pify: 2.3.0
@@ -8962,6 +9336,10 @@ snapshots:
dependencies:
xmlchars: 2.2.0
scheduler@0.23.2:
dependencies:
loose-envify: 1.4.0
sdp@3.2.0: {}
section-matter@1.0.0:
@@ -9435,6 +9813,12 @@ snapshots:
escalade: 3.2.0
picocolors: 1.0.1
update-browserslist-db@1.2.3(browserslist@4.28.1):
dependencies:
browserslist: 4.28.1
escalade: 3.2.0
picocolors: 1.1.1
upper-case-first@2.0.2:
dependencies:
tslib: 2.8.1
@@ -9498,8 +9882,10 @@ snapshots:
video.js: 7.18.1
wavesurfer.js: 7.8.6
virtua@0.48.6(vue@3.5.12(typescript@5.6.2)):
virtua@0.48.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vue@3.5.12(typescript@5.6.2)):
optionalDependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
vue: 3.5.12(typescript@5.6.2)
vite-node@2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0):
@@ -9791,6 +10177,8 @@ snapshots:
y18n@5.0.8: {}
yallist@3.1.1: {}
yallist@4.0.0: {}
yaml-eslint-parser@1.2.3:
+33
View File
@@ -0,0 +1,33 @@
# Modal teleport target escapes shadow DOM in embedded mode
## Context
- Vue components are exported as custom elements for React, rendered inside a shadow DOM.
- The modal component teleports its content; when it teleports to `document.body`, it leaves the shadow DOM and loses styles.
## Relevant files
- `app/javascript/react-components/src/vue-components/ChatwootMessageListWebComponent.vue`
- CE wrapper rendered inside the shadow root.
- Currently includes both `#cw-app-root` (MessageList) and `#cw-modal-root` (intended modal host) inside the shadow DOM.
- `app/javascript/dashboard/components/Modal.vue`
- Modal component using `<Teleport>`.
- Has logic to resolve the teleport target using `getRootNode` and a fallback to `document.body`.
- Entry/UI setup: `app/javascript/entrypoints/ui.js` mounts the Vue app in non-CE mode (body teleport is fine there).
## Current behavior
- In embedded/isolated mode (`window.__WOOT_ISOLATED_SHELL__` is true), the modal still teleports to `document.body` instead of the shadow DOM.
- Because the modal node ends up in `body`, CE-injected styles do not apply; the modal appears unstyled.
## Desired behavior
- When running inside the custom element (shadow DOM), the modal should teleport to `#cw-modal-root` inside that shadow root (the element already exists in the CE wrapper).
- Only fall back to `document.body` when not in embedded mode or not in a shadow root.
## Likely fixes
- In `Modal.vue`, resolve the teleport target on mount:
- Use `getRootNode()` on the component root to detect a `ShadowRoot`.
- If in a `ShadowRoot`, select `#cw-modal-root` there and use that element.
- If not found or not in a shadow root, fall back to `document.body`.
- Remove extra complexity (e.g., hidden anchors or element creation); the modal host is already present in the CE template.
## Acceptance criteria
- In embedded mode, the modal stays inside the shadow DOM and is styled (teleport target is the shadows `#cw-modal-root`).
- In normal app mode, behavior remains unchanged (teleport to `body`).
+61
View File
@@ -0,0 +1,61 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web Components Test</title>
<script>
window.__WOOT_ISOLATED_SHELL__ = true;
window.__WOOT_ACCOUNT_ID__ = 1;
window.__WOOT_API_HOST__ = 'http://localhost:3000';
window.__WOOT_ACCESS_TOKEN__ = 'token';
window.__PUBSUB_TOKEN__ = 'token';
window.__WEBSOCKET_URL__ = 'ws://localhost:3000';
window.__WOOT_CONVERSATION_ID__ = 13918;
window.__DISABLE_EDITOR__ = true;
window.__EDITOR_DISABLE_UPLOAD__ = true;
</script>
</head>
<body>
<div id="app" dir="ltr">
<div class="py-20 grid place-content-center">
<svg
width="40"
height="40"
viewBox="0 0 40 40"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="20"
cy="20"
r="18"
fill="none"
stroke="#ccc"
stroke-width="4"
/>
<circle
cx="20"
cy="20"
r="18"
fill="none"
stroke="#333"
stroke-width="4"
stroke-dasharray="56.5487 56.5487"
stroke-dashoffset="0"
>
<animateTransform
attributeName="transform"
type="rotate"
from="0 20 20"
to="360 20 20"
dur="1s"
repeatCount="indefinite"
/>
</circle>
</svg>
</div>
</div>
<link rel="stylesheet" href="/packs/style.css" />
<script src="/packs/js/ui.js"></script>
</body>
</html>
+178
View File
@@ -0,0 +1,178 @@
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
/* eslint-disable no-console */
// Check for copy-only mode
const isCopyMode = process.argv.includes('--copyMode');
if (isCopyMode) {
console.log('📋 Copy mode: Copying built files to dist/react-components...');
} else {
console.log('🚀 Building Chatwoot React Components for NPM...');
}
function copyBuildFiles(packageDir) {
// Copy main build outputs
const files = [
{ src: 'public/packs/react-components.es.js', dest: 'index.js' }, // ES module (main)
{ src: 'public/packs/react-components.cjs.js', dest: 'index.cjs' }, // CommonJS
{ src: 'public/packs/style.css', dest: 'style.css' }, // CSS styles
];
files.forEach(({ src, dest }) => {
if (fs.existsSync(src)) {
fs.copyFileSync(src, path.join(packageDir, dest));
console.log(` ✓ Copied ${dest}`);
} else {
console.warn(` ⚠️ Warning: ${src} not found`);
}
});
}
function generatePackageJson(packageDir) {
// Read version from main package.json
const mainPackage = JSON.parse(fs.readFileSync('package.json', 'utf8'));
// Get current timestamp and git commit hash for development builds
function getVersionSuffix() {
try {
const timestamp = Math.floor(Date.now() / 1000);
const gitHash = execSync('git rev-parse --short HEAD', {
encoding: 'utf8',
stdio: 'pipe',
}).trim();
return `${timestamp}.${gitHash}`;
} catch (error) {
console.warn(' ⚠️ Could not get timestamp/git hash, using beta.1');
return 'beta.1';
}
}
const versionSuffix = getVersionSuffix();
const finalVersion = `${mainPackage.version}-${versionSuffix}`;
console.log(
` 📋 Package version: ${finalVersion} (suffix: ${versionSuffix})`
);
const packageJson = {
name: '@chatwoot/agent-react-components',
version: finalVersion,
description:
'React components for Chatwoot messaging interface with Vue Web Components',
// Entry points for different module systems
main: 'index.cjs', // CommonJS entry point for Node.js
module: 'index.js', // ES module entry point for bundlers
exports: {
'.': {
import: './index.js', // ES modules
require: './index.cjs', // CommonJS
},
'./style.css': './style.css',
},
// Peer dependencies - what the consuming app must provide
peerDependencies: {
react: '>=16.8.0', // Hooks support
'react-dom': '>=16.8.0',
},
// Package metadata
keywords: [
'chatwoot',
'react',
'vue',
'webcomponents',
'chat',
'messaging',
'customer-support',
'ui-components',
],
author: 'Chatwoot',
license: 'MIT',
repository: {
type: 'git',
url: 'https://github.com/chatwoot/chatwoot.git',
directory: 'app/javascript/react-components',
},
homepage: 'https://chatwoot.com',
bugs: {
url: 'https://github.com/chatwoot/chatwoot/issues',
},
// npm publish configuration
files: ['index.js', 'index.cjs', 'style.css', 'package.json'],
// Engine requirements
engines: {
node: '>=16.0.0',
},
};
fs.writeFileSync(
path.join(packageDir, 'package.json'),
JSON.stringify(packageJson, null, 2)
);
console.log(' ✓ Generated package.json');
}
async function publishReactComponents() {
try {
if (!isCopyMode) {
// Step 1: Clean previous builds
console.log('📦 Cleaning previous builds...');
if (fs.existsSync('dist')) {
fs.rmSync('dist', { recursive: true, force: true });
}
// Clean previous React component builds
const reactFiles = [
'public/packs/react-components.es.js',
'public/packs/react-components.cjs.js',
'public/packs/style.css',
];
reactFiles.forEach(file => {
if (fs.existsSync(file)) {
fs.unlinkSync(file);
console.log(` 🗑️ Removed ${file}`);
}
});
// Step 2: Build the React components library
console.log('🔨 Building React components...');
execSync('pnpm build:react', { stdio: 'inherit' });
}
// Step 3: Create package directory
console.log('📁 Creating package directory...');
const packageDir = 'dist/react-components';
fs.mkdirSync(packageDir, { recursive: true });
// Step 4: Copy built files
console.log('📋 Copying built files...');
copyBuildFiles(packageDir);
// Step 5: Generate package.json
console.log('📄 Generating package.json...');
generatePackageJson(packageDir);
console.log('✅ Package ready in dist/react-components/');
console.log('');
console.log('📦 Publishing options:');
console.log(' • npm publish: cd dist/react-components && npm publish');
console.log(' • yalc publish: pnpm package:react:yalc-publish');
console.log(' • local test: cd dist/react-components && npm pack');
} catch (error) {
console.error('❌ Build failed:', error.message);
process.exit(1);
}
}
// Run the script
publishReactComponents();
/* eslint-enable no-console */
@@ -28,7 +28,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
end
it 'uses Captain::Llm::AssistantChatService' do
expect(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant, conversation_id: conversation.display_id)
expect(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant, conversation: conversation)
expect(Captain::Assistant::AgentRunnerService).not_to receive(:new)
described_class.perform_now(conversation, assistant)
@@ -89,6 +89,7 @@ RSpec.describe Concerns::Agentable do
context_double = instance_double(Agents::RunContext,
context: {
state: {
assistant_config: { 'feature_contact_attributes' => true },
conversation: { id: 123 },
contact: { name: 'John' }
}
@@ -137,7 +138,7 @@ RSpec.describe Concerns::Agentable do
hash_including(
base_key: 'base_value',
conversation: {},
contact: {},
contact: nil,
campaign: {}
)
)
@@ -393,7 +393,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
)
end
it 'includes contact attributes when contact is present' do
it 'always includes contact attributes in state for tool access' do
state = service.send(:build_state)
expect(state[:contact]).to include(
@@ -30,7 +30,7 @@ RSpec.describe Captain::Llm::AssistantChatService do
describe 'instrumentation metadata' do
it 'passes channel_type to the agent session instrumentation' do
service = described_class.new(assistant: assistant, conversation_id: conversation.display_id)
service = described_class.new(assistant: assistant, conversation: conversation)
expect(service).to receive(:instrument_agent_session).with(
hash_including(metadata: hash_including(channel_type: conversation.inbox.channel_type))
@@ -61,7 +61,7 @@ RSpec.describe Captain::Llm::AssistantChatService do
with: ['https://example.com/screenshot.png']
).and_return(mock_response)
service = described_class.new(assistant: assistant, conversation_id: conversation.display_id)
service = described_class.new(assistant: assistant, conversation: conversation)
service.generate_response(message_history: message_history)
end
end
@@ -84,7 +84,7 @@ RSpec.describe Captain::Llm::AssistantChatService do
with: ['https://example.com/photo.jpg']
).and_return(mock_response)
service = described_class.new(assistant: assistant, conversation_id: conversation.display_id)
service = described_class.new(assistant: assistant, conversation: conversation)
service.generate_response(message_history: message_history)
end
end
@@ -99,7 +99,7 @@ RSpec.describe Captain::Llm::AssistantChatService do
it 'sends the text without attachments' do
expect(mock_chat).to receive(:ask).with('Hello, how can you help me?').and_return(mock_response)
service = described_class.new(assistant: assistant, conversation_id: conversation.display_id)
service = described_class.new(assistant: assistant, conversation: conversation)
service.generate_response(message_history: message_history)
end
end
@@ -139,9 +139,53 @@ RSpec.describe Captain::Llm::AssistantChatService do
# Current message asked via chat.ask
expect(mock_chat).to receive(:ask).with('It still does not work').and_return(mock_response)
service = described_class.new(assistant: assistant, conversation_id: conversation.display_id)
service = described_class.new(assistant: assistant, conversation: conversation)
service.generate_response(message_history: message_history)
end
end
end
describe 'contact attributes in system prompt' do
let(:contact) { create(:contact, account: account, name: 'Diep Bui', email: 'diep@example.com', custom_attributes: { 'plan' => 'pro' }) }
let(:conversation) { create(:conversation, account: account, contact: contact) }
context 'when feature_contact_attributes is enabled' do
before { assistant.update!(config: assistant.config.merge('feature_contact_attributes' => true)) }
it 'includes contact information in the system prompt' do
allow(mock_chat).to receive(:ask).and_return(mock_response)
expect(mock_chat).to receive(:with_instructions).with(a_string_including('[Contact Information]')) do |_instructions|
mock_chat
end
service = described_class.new(assistant: assistant, conversation: conversation)
service.generate_response(message_history: [{ role: 'user', content: 'Hello' }])
end
it 'includes custom attributes in the system prompt' do
allow(mock_chat).to receive(:ask).and_return(mock_response)
expect(mock_chat).to receive(:with_instructions).with(a_string_including('plan: pro')) do |_instructions|
mock_chat
end
service = described_class.new(assistant: assistant, conversation: conversation)
service.generate_response(message_history: [{ role: 'user', content: 'Hello' }])
end
end
context 'when feature_contact_attributes is disabled' do
it 'does not include contact information in the system prompt' do
allow(mock_chat).to receive(:ask).and_return(mock_response)
expect(mock_chat).to receive(:with_instructions).with(satisfy { |s| s.exclude?('[Contact Information]') }) do |_instructions|
mock_chat
end
service = described_class.new(assistant: assistant, conversation: conversation)
service.generate_response(message_history: [{ role: 'user', content: 'Hello' }])
end
end
end
end
+11 -8
View File
@@ -29,11 +29,14 @@ const tailwindConfig = {
'./app/javascript/portal/**/*.vue',
'./app/javascript/shared/**/*.vue',
'./app/javascript/survey/**/*.vue',
'./app/javascript/ui/**/*.vue',
'./app/javascript/dashboard/components-next/**/*.vue',
'./app/javascript/dashboard/helper/**/*.js',
'./app/javascript/dashboard/composables/**/*.js',
'./app/javascript/dashboard/components-next/**/*.js',
'./app/javascript/dashboard/routes/dashboard/**/**/*.js',
'./app/javascript/react-components/**/*.{js,jsx,vue}',
'./app/javascript/dashboard/assets/scss/**/*.scss',
'./app/views/**/*.html.erb',
],
theme: {
@@ -52,7 +55,7 @@ const tailwindConfig = {
typography: {
bubble: {
css: {
color: 'rgb(var(--slate-12))',
color: 'inherit', // Inherit from parent bubble for theme customization
lineHeight: '1.6',
fontSize: '14px',
'*': {
@@ -63,17 +66,17 @@ const tailwindConfig = {
overflowWrap: 'anywhere',
strong: {
color: 'rgb(var(--slate-12))',
color: 'inherit',
fontWeight: '700',
},
b: {
color: 'rgb(var(--slate-12))',
color: 'inherit',
fontWeight: '700',
},
h1: {
color: 'rgb(var(--slate-12))',
color: 'inherit',
fontWeight: '700',
fontSize: '1.25rem',
'&:first-child': {
@@ -81,7 +84,7 @@ const tailwindConfig = {
},
},
h2: {
color: 'rgb(var(--slate-12))',
color: 'inherit',
fontWeight: '700',
fontSize: '1rem',
'&:first-child': {
@@ -89,7 +92,7 @@ const tailwindConfig = {
},
},
h3: {
color: 'rgb(var(--slate-12))',
color: 'inherit',
fontWeight: '700',
fontSize: '1rem',
'&:first-child': {
@@ -101,7 +104,7 @@ const tailwindConfig = {
marginBottom: '1.5em',
},
a: {
color: 'rgb(var(--slate-12))',
color: 'inherit',
textDecoration: 'underline',
},
ul: {
@@ -164,7 +167,7 @@ const tailwindConfig = {
},
th: {
padding: '0.75em',
color: 'rgb(var(--slate-12))',
color: 'inherit',
border: `none`,
textAlign: 'start',
fontWeight: '600',
+85 -15
View File
@@ -21,10 +21,31 @@ We need to edit the `asset:precompile` rake task to include the SDK in the preco
import { defineConfig } from 'vite';
import ruby from 'vite-plugin-ruby';
import path from 'path';
import { execFileSync } from 'child_process';
import vue from '@vitejs/plugin-vue';
import react from '@vitejs/plugin-react';
const buildSha = (() => {
// Prefer CI-provided commit SHA, fall back to local git.
const fromEnv =
process.env.GIT_SHA ||
process.env.GITHUB_SHA ||
process.env.HEROKU_SLUG_COMMIT;
if (fromEnv) return fromEnv.slice(0, 10);
try {
return execFileSync('git', ['rev-parse', '--short=10', 'HEAD'])
.toString()
.trim();
} catch (e) {
return 'unknown';
}
})();
const isLibraryMode = process.env.BUILD_MODE === 'library';
const uiMode = process.env.BUILD_MODE === 'ui';
const reactComponentMode = process.env.BUILD_MODE === 'react-components';
const isTestMode = process.env.TEST === 'true';
const buildTime = new Date().toTimeString().slice(0, 5);
const vueOptions = {
template: {
@@ -38,38 +59,87 @@ let plugins = [ruby(), vue(vueOptions)];
if (isLibraryMode) {
plugins = [];
} else if (uiMode) {
plugins = [vue()];
} else if (reactComponentMode) {
plugins = [vue({ ...vueOptions, customElement: true }), react()];
} else if (isTestMode) {
plugins = [vue(vueOptions)];
}
let lib;
let rollupOptions = {};
if (isLibraryMode) {
lib = {
entry: path.resolve(__dirname, './app/javascript/entrypoints/sdk.js'),
formats: ['iife'], // IIFE format for single file
name: 'sdk',
};
} else if (uiMode) {
lib = {
entry: path.resolve(__dirname, './app/javascript/entrypoints/ui.js'),
formats: ['iife'], // IIFE format for single file
name: 'ui',
};
} else if (reactComponentMode) {
lib = {
entry: path.resolve(
__dirname,
'./app/javascript/react-components/src/index.jsx'
),
formats: ['es', 'cjs'], // ES modules and CommonJS only
fileName: format => `react-components.${format}.js`,
};
rollupOptions = {
external: ['react', 'react-dom'],
};
}
const chunkBuilder = chunkInfo => {
if (chunkInfo.name === 'sdk') {
return 'js/sdk.js';
}
if (chunkInfo.name === 'ui') {
return `js/ui.js`;
}
// For react components, we need to return different names but can't access format here
// So we'll handle this differently
return '[name].js';
};
export default defineConfig({
plugins: plugins,
define: {
'import.meta.env.VITE_BUILD_TIME': JSON.stringify(buildTime),
'import.meta.env.VITE_BUILD_SHA': JSON.stringify(buildSha),
'process.env.NODE_ENV': JSON.stringify('production'),
},
build: {
rollupOptions: {
...rollupOptions,
output: {
...rollupOptions.output,
// [NOTE] when not in library mode, no new keys will be addedd or overwritten
// setting dir: isLibraryMode ? 'public/packs' : undefined will not work
...(isLibraryMode
...(isLibraryMode || uiMode
? {
dir: 'public/packs',
entryFileNames: chunkInfo => {
if (chunkInfo.name === 'sdk') {
return 'js/sdk.js';
}
return '[name].js';
},
entryFileNames: chunkBuilder,
}
: {}),
inlineDynamicImports: isLibraryMode, // Disable code-splitting for SDK
...(reactComponentMode
? {
dir: 'public/packs',
}
: {}),
inlineDynamicImports: isLibraryMode || uiMode || reactComponentMode, // Disable code-splitting for SDK
},
},
lib: isLibraryMode
? {
entry: path.resolve(__dirname, './app/javascript/entrypoints/sdk.js'),
formats: ['iife'], // IIFE format for single file
name: 'sdk',
}
: undefined,
lib,
},
resolve: {
alias: {