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
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
64 changed files with 3760 additions and 245 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
@@ -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,7 +832,10 @@ onMounted(() => {
createEditorView();
editorView.updateState(state);
if (props.focusOnMount) {
// TODO: test this in the main app
if (sendWithSignature.value) {
addSignature();
} else if (props.focusOnMount) {
focusEditorInputField();
}
});
@@ -917,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,
@@ -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;
@@ -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;
};
+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 */
+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: {