Compare commits

...
23 Commits
Author SHA1 Message Date
Muhsin Keloth 6e90600c03 chore: add packages 2024-04-30 09:13:36 +05:30
Muhsin Keloth 19410dfa3d feat: add linear 2024-04-29 13:27:18 +05:30
Shivam MishraandGitHub 78f94511ff feat: add sitemap for help center (#9280)
* feat: start sitemap

* feat: add base url and last mod to sitemap

* fix: typo

* test: sitemap generation

* test: add draft articles

* fix: escape dots in regex matching

* feat: perpend protocol to the url

* feat: use ChatwootApp.help_center_root

* feat: don't parse the URL

* fix: function declaration
2024-04-26 21:36:39 +05:30
d88d0bdd80 feat: Add user profile avatar (#9298)
* feat: add avatar

* chore: add more colors

* chore: add helpers

* chore: build prettier issues

* chore: refactor shouldShowImage

* chore: code cleanup

* Update app/javascript/v3/components/Form/InitialsAvatar.vue

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>

* chore: revire comments

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-04-26 16:02:10 +05:30
47f8b2cd0c refactor: handling keyboard shortcuts (#9242)
* fix: Resolve and go next keyboard shortcuts doesn't work

* refactor: use buildHotKeys instead of  hasPressedCommandPlusAltAndEKey

* feat: install tinykeys

* refactor: use tinykeys

* test: update buildKeyEvents

* fix: remove stray import

* feat: handle action list globally

* feat: allow configuring `allowOnFocusedInput`

* chore: Navigate chat list item

* chore: Navigate dashboard

* feat: Navigate editor top panel

* feat: Toggle file upload

* chore: More keyboard shortcuts

* chore: Update mention selection mixin

* chore: Phone input

* chore: Clean up

* chore: Clean up

* chore: Dropdown and editor

* chore: Enter key to send and clean up

* chore: Rename mixin

* chore: Review fixes

* chore: Removed unused shortcut from modal

* fix: Specs

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-04-26 15:41:02 +05:30
PranavandGitHub ffd47081bd chore(cleanup): Delete sentiment feature (#9304)
- The feature is unused, removing it for now, will bring it back with better models later.
2024-04-25 22:49:10 -07:00
PranavandGitHub 77db0d0701 feat: Add configurable interval for IMAP sync (#9302) 2024-04-25 18:58:20 -07:00
Shivam MishraandGitHub e757237029 feat: don't show widget on plain layout (#9297) 2024-04-25 15:26:25 +05:30
04ae38076f feat: Add button component (#9276)
* feat: Add button component

* chore: code cleanup

* chore: code cleanup

* feat: update styles

* chore: cleanup button

* chore: review comments

* chore: code cleanup

* chore: review comments

* chore: fix spacing

* chore: remove unused code

* chore: remove custom click event

* feat: update props validation

* chore: review comments

* refactor: use trailingIcon as a boolean prop

* fix: build issues

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-04-25 14:21:23 +05:30
Sivin VargheseandGitHub 2ddf613c58 fix: Audio attachment issues (#9260)
* fix: Audio attachment issues

* chore: Style fix

* chore: Minor fix

* chore: Minor fix

* chore: Review fixes
2024-04-25 13:42:46 +05:30
Muhsin KelothandGitHub 1bf3ad9a08 fix: Remove unnecessary attributes from notification deleted action cable event payload (#9295)
fix: simplify notification deleted payload event
2024-04-25 11:25:19 +05:30
9086650fe2 fix: heatmap colors for dark mode [CW-3241] (#9278)
* feat: add new heatmap colors

* fix: loader

* fix: move new styles to tailwind

* feat: update tw classes

* refactor: update styles

* feat: add useI18n composable

* feat: use composition api

* fix: empty div

* chore: don't import defineProps

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-04-25 10:23:15 +05:30
Shivam MishraandGitHub 8c813097db feat: update SLA upgrade banner (#9291)
feat: add cancel anytime notice
2024-04-24 17:19:35 +05:30
12403b75d6 feat: remove log rocket (#9279)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-04-24 12:36:36 +05:30
Shivam MishraandGitHub 480cf4952d fix: set size limit to 300KB (#9289) 2024-04-24 12:09:31 +05:30
Muhsin KelothandGitHub d7e928df26 feat: Add loom video embed support for help center articles (#9288)
* feat: Add loom support for helpcenter articles

* fix: responsiveness

* fix: style issues

* fix: review comments

* Update custom_markdown_renderer.rb
2024-04-24 11:44:10 +05:30
Muhsin KelothandGitHub 8957277e5d feat: Add new colors (#9258) 2024-04-22 14:08:12 +05:30
Sojan JoseandGitHub 60b355b500 fix: Avoid vector search if query is not present in the params (#9265) 2024-04-19 14:50:49 -07:00
Sivin VargheseandGitHub d2e324b5fd fix: Remove duplicate tooltip for failed messages on the conversation UI (#9259) 2024-04-19 09:47:41 -07:00
Sojan JoseandGitHub 15638e9b8b chore: Add validation to prevent message flooding (#9254)
- Add a validation to limit messages created per minute to avoid message flooding cases.
2024-04-18 00:14:59 -07:00
ca2fa5ff06 fix: Clicking on contact attributes doesn't do anything if contact attributes list is empty (#9257)
* fix: Clicking on contact attributes doesn't do anything if contact attributes list is empty

* Update app/javascript/dashboard/i18n/locale/en/conversation.json

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>

* Update app/javascript/dashboard/i18n/locale/en/contact.json

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-04-18 12:31:32 +05:30
PranavandGitHub 888d3cb3fa chore: Increase the rate limit for contact search (#9256) 2024-04-17 16:59:38 -07:00
Sojan 8fcf476206 Merge branch 'release/3.8.0' into develop 2024-04-16 17:09:17 -07:00
93 changed files with 2516 additions and 1460 deletions
-5
View File
@@ -180,8 +180,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
## Sentry
# SENTRY_DSN=
## LogRocket
# LOG_ROCKET_PROJECT_ID=xxxxx/some-project
# MICROSOFT CLARITY
# MS_CLARITY_TOKEN=xxxxxxxxx
@@ -251,9 +249,6 @@ AZURE_APP_SECRET=
## OpenAI key
# OPENAI_API_KEY=
# Sentiment analysis model file path
SENTIMENT_FILE_PATH=
# Housekeeping/Performance related configurations
# Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed
-3
View File
@@ -175,9 +175,6 @@ gem 'pgvector'
# Convert Website HTML to Markdown
gem 'reverse_markdown'
# Sentiment analysis
gem 'informers'
### Gems required only in specific deployment environments ###
##############################################################
+14 -26
View File
@@ -151,7 +151,6 @@ GEM
base64 (0.1.1)
bcrypt (3.1.20)
bindex (0.8.1)
blingfire (0.1.8)
bootsnap (1.16.0)
msgpack (~> 1.2)
brakeman (5.4.1)
@@ -316,15 +315,15 @@ GEM
google-cloud-translate-v3 (0.6.0)
gapic-common (>= 0.17.1, < 2.a)
google-cloud-errors (~> 1.0)
google-protobuf (3.25.2)
google-protobuf (3.25.2-arm64-darwin)
google-protobuf (3.25.2-x86_64-darwin)
google-protobuf (3.25.2-x86_64-linux)
google-protobuf (3.25.3)
google-protobuf (3.25.3-arm64-darwin)
google-protobuf (3.25.3-x86_64-darwin)
google-protobuf (3.25.3-x86_64-linux)
googleapis-common-protos (1.4.0)
google-protobuf (~> 3.14)
googleapis-common-protos-types (~> 1.2)
grpc (~> 1.27)
googleapis-common-protos-types (1.11.0)
googleapis-common-protos-types (1.14.0)
google-protobuf (~> 3.18)
googleauth (1.5.2)
faraday (>= 0.17.3, < 3.a)
@@ -335,14 +334,17 @@ GEM
signet (>= 0.16, < 2.a)
groupdate (6.2.1)
activesupport (>= 5.2)
grpc (1.54.3)
google-protobuf (~> 3.21)
grpc (1.62.0)
google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0)
grpc (1.54.3-x86_64-darwin)
google-protobuf (~> 3.21)
grpc (1.62.0-arm64-darwin)
google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0)
grpc (1.54.3-x86_64-linux)
google-protobuf (~> 3.21)
grpc (1.62.0-x86_64-darwin)
google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0)
grpc (1.62.0-x86_64-linux)
google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0)
haikunator (1.1.1)
hairtrigger (1.0.0)
@@ -371,10 +373,6 @@ GEM
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
informers (0.2.0)
blingfire (>= 0.1.7)
numo-narray
onnxruntime (>= 0.5.1)
io-console (0.6.0)
irb (1.7.2)
reline (>= 0.3.6)
@@ -500,7 +498,6 @@ GEM
racc (~> 1.4)
nokogiri (1.16.4-x86_64-linux)
racc (~> 1.4)
numo-narray (0.9.2.1)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
snaky_hash (~> 2.0)
@@ -529,14 +526,6 @@ GEM
omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
onnxruntime (0.7.6)
ffi
onnxruntime (0.7.6-arm64-darwin)
ffi
onnxruntime (0.7.6-x86_64-darwin)
ffi
onnxruntime (0.7.6-x86_64-linux)
ffi
openssl (3.1.0)
orm_adapter (0.5.0)
os (1.1.4)
@@ -892,7 +881,6 @@ DEPENDENCIES
hashie
html2text!
image_processing
informers
jbuilder
json_refs
json_schemer
@@ -6,6 +6,12 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
def show; end
def sitemap
@help_center_url = @portal.custom_domain || ChatwootApp.help_center_root
# if help_center_url does not contain a protocol, prepend it with https
@help_center_url = "https://#{@help_center_url}" unless @help_center_url.include?('://')
end
private
def portal
@@ -40,3 +40,347 @@
.hide {
@apply hidden;
}
@layer base {
// scss-lint:disable PropertySortOrder
:root {
--color-amber-25: 254 253 251;
--color-amber-50: 255 249 237;
--color-amber-75: 255 243 208;
--color-amber-100: 255 236 183;
--color-amber-200: 255 224 161;
--color-amber-300: 245 208 140;
--color-amber-400: 228 187 120;
--color-amber-500: 214 163 92;
--color-amber-600: 214 163 92;
--color-amber-700: 255 186 26;
--color-amber-800: 145 89 48;
--color-amber-900: 79 52 34;
--color-ash-100: 235 235 239;
--color-ash-200: 228 228 233;
--color-ash-25: 252 252 253;
--color-ash-300: 221 221 227;
--color-ash-400: 211 212 219;
--color-ash-50: 249 249 251;
--color-ash-500: 185 187 198;
--color-ash-600: 139 141 152;
--color-ash-700: 126 128 138;
--color-ash-75: 242 242 245;
--color-ash-800: 96 100 108;
--color-ash-900: 28 32 36;
--color-primary-25: 251 253 255;
--color-primary-50: 245 249 255;
--color-primary-75: 233 243 255;
--color-primary-100: 218 236 255;
--color-primary-200: 201 226 255;
--color-primary-300: 181 213 255;
--color-primary-400: 155 195 252;
--color-primary-500: 117 171 247;
--color-primary-600: 39 129 246;
--color-primary-700: 16 115 233;
--color-primary-800: 8 109 224;
--color-primary-900: 11 50 101;
--color-ruby-100: 255 220 225;
--color-ruby-200: 255 206 214;
--color-ruby-25: 255 252 253;
--color-ruby-300: 248 191 200;
--color-ruby-400: 239 172 184;
--color-ruby-50: 255 247 248;
--color-ruby-500: 229 146 163;
--color-ruby-600: 229 70 102;
--color-ruby-700: 220 59 93;
--color-ruby-75: 254 234 237;
--color-ruby-800: 202 36 77;
--color-ruby-900: 100 23 43;
--color-teal-100: 224 248 243;
--color-teal-200: 204 243 234;
--color-teal-25: 250 254 253;
--color-teal-300: 184 234 224;
--color-teal-400: 161 222 210;
--color-teal-50: 243 251 249;
--color-teal-500: 83 185 171;
--color-teal-600: 18 165 148;
--color-teal-700: 13 155 138;
--color-teal-75: 236 249 255;
--color-teal-800: 0 133 115;
--color-teal-900: 13 61 56;
--color-green-25: 251 254 252;
--color-green-50: 244 251 246;
--color-green-75: 230 246 235;
--color-green-100: 214 241 223;
--color-green-200: 196 232 209;
--color-green-300: 173 221 192;
--color-green-400: 142 206 170;
--color-green-500: 91 185 139;
--color-green-600: 48 164 108;
--color-green-700: 43 154 102;
--color-green-800: 33 131 88;
--color-green-900: 25 59 45;
--color-mint-25: 249 254 253;
--color-mint-50: 242 251 249;
--color-mint-75: 221 249 242;
--color-mint-100: 200 244 233;
--color-mint-200: 179 236 222;
--color-mint-300: 156 224 208;
--color-mint-400: 126 207 189;
--color-mint-500: 76 187 165;
--color-mint-600: 134 234 212;
--color-mint-700: 125 224 203;
--color-mint-800: 2 120 100;
--color-mint-900: 22 67 60;
--color-sky-25: 249 254 255;
--color-sky-50: 241 250 253;
--color-sky-75: 225 246 253;
--color-sky-100: 209 240 250;
--color-sky-200: 190 231 245;
--color-sky-300: 169 218 237;
--color-sky-400: 141 202 227;
--color-sky-500: 96 179 215;
--color-sky-600: 124 226 254;
--color-sky-700: 116 218 248;
--color-sky-800: 0 116 158;
--color-sky-900: 29 62 86;
--color-indigo-25: 253 253 254;
--color-indigo-50: 247 249 255;
--color-indigo-75: 237 242 254;
--color-indigo-100: 225 233 255;
--color-indigo-200: 210 222 255;
--color-indigo-300: 193 208 255;
--color-indigo-400: 171 189 249;
--color-indigo-500: 141 164 239;
--color-indigo-600: 62 99 221;
--color-indigo-700: 51 88 212;
--color-indigo-800: 58 91 199;
--color-indigo-900: 31 45 92;
--color-iris-25: 253 253 255;
--color-iris-50: 248 248 255;
--color-iris-75: 240 241 254;
--color-iris-100: 230 231 255;
--color-iris-200: 218 220 255;
--color-iris-300: 203 205 255;
--color-iris-400: 184 186 248;
--color-iris-500: 155 158 240;
--color-iris-600: 91 91 214;
--color-iris-700: 81 81 205;
--color-iris-800: 87 83 198;
--color-iris-900: 39 41 98;
--color-violet-25: 253 252 254;
--color-violet-50: 250 248 255;
--color-violet-75: 244 240 254;
--color-violet-100: 235 228 255;
--color-violet-200: 225 217 255;
--color-violet-300: 212 202 254;
--color-violet-400: 194 181 245;
--color-violet-500: 170 153 236;
--color-violet-600: 110 86 207;
--color-violet-700: 101 77 196;
--color-violet-800: 101 80 185;
--color-violet-900: 47 38 95;
--color-pink-25: 255 252 254;
--color-pink-50: 254 247 251;
--color-pink-75: 254 233 245;
--color-pink-100: 251 220 239;
--color-pink-200: 246 206 231;
--color-pink-300: 239 191 221;
--color-pink-400: 231 172 208;
--color-pink-500: 221 147 194;
--color-pink-600: 214 64 159;
--color-pink-700: 207 56 151;
--color-pink-800: 194 41 138;
--color-pink-900: 101 18 73;
--color-orange-25: 254 252 251;
--color-orange-50: 255 247 237;
--color-orange-75: 255 239 214;
--color-orange-100: 255 223 181;
--color-orange-200: 255 209 154;
--color-orange-300: 255 193 130;
--color-orange-400: 245 174 115;
--color-orange-500: 236 148 85;
--color-orange-600: 247 107 21;
--color-orange-700: 239 95 0;
--color-orange-800: 204 78 0;
--color-orange-900: 88 45 29;
}
// scss-lint:disable QualifyingElement
body.dark {
--color-amber-25: 31 19 0;
--color-amber-50: 37 24 4;
--color-amber-75: 48 32 11;
--color-amber-100: 57 39 15;
--color-amber-200: 67 46 18;
--color-amber-300: 83 57 22;
--color-amber-400: 111 77 29;
--color-amber-500: 169 118 42;
--color-amber-600: 169 118 42;
--color-amber-700: 255 203 71;
--color-amber-800: 255 204 77;
--color-amber-900: 255 231 179;
--color-ash-100: 46 48 53;
--color-ash-200: 53 55 60;
--color-ash-25: 24 24 26;
--color-ash-300: 60 63 68;
--color-ash-400: 70 75 80;
--color-ash-50: 27 27 31;
--color-ash-500: 90 97 101;
--color-ash-600: 105 110 119;
--color-ash-700: 120 127 133;
--color-ash-75: 39 40 45;
--color-ash-800: 173 177 184;
--color-ash-900: 237 238 240;
--color-primary-25: 10 17 28;
--color-primary-50: 15 24 38;
--color-primary-75: 15 39 72;
--color-primary-100: 10 49 99;
--color-primary-200: 18 61 117;
--color-primary-300: 29 74 134;
--color-primary-400: 40 89 156;
--color-primary-500: 48 106 186;
--color-primary-600: 39 129 246;
--color-primary-700: 21 116 231;
--color-primary-800: 126 182 255;
--color-primary-900: 205 227 255;
--color-ruby-100: 78 19 37;
--color-ruby-200: 94 26 46;
--color-ruby-25: 25 17 19;
--color-ruby-300: 111 37 57;
--color-ruby-400: 136 52 71;
--color-ruby-50: 30 21 23;
--color-ruby-500: 179 68 90;
--color-ruby-600: 229 70 102;
--color-ruby-700: 236 90 114;
--color-ruby-75: 58 20 30;
--color-ruby-800: 255 148 157;
--color-ruby-900: 254 210 225;
--color-teal-100: 2 59 55;
--color-teal-200: 8 72 67;
--color-teal-25: 13 21 20;
--color-teal-300: 28 105 97;
--color-teal-400: 28 105 97;
--color-teal-50: 17 28 27;
--color-teal-500: 32 126 115;
--color-teal-600: 41 163 131;
--color-teal-700: 14 179 158;
--color-teal-75: 13 45 42;
--color-teal-800: 11 216 182;
--color-teal-900: 173 240 221;
--color-green-25: 14 21 18;
--color-green-50: 18 27 23;
--color-green-75: 19 45 33;
--color-green-100: 17 59 41;
--color-green-200: 23 73 51;
--color-green-300: 32 87 62;
--color-green-400: 40 104 74;
--color-green-500: 47 124 87;
--color-green-600: 48 164 108;
--color-green-700: 51 176 116;
--color-green-800: 61 214 140;
--color-green-900: 177 241 203;
--color-mint-25: 14 21 21;
--color-mint-50: 15 27 27;
--color-mint-75: 9 44 43;
--color-mint-100: 0 58 56;
--color-mint-200: 0 71 68;
--color-mint-300: 16 86 80;
--color-mint-400: 30 104 95;
--color-mint-500: 39 127 112;
--color-mint-600: 134 234 212;
--color-mint-700: 168 245 229;
--color-mint-800: 88 213 186;
--color-mint-900: 196 245 225;
--color-sky-25: 14 21 21;
--color-sky-50: 15 27 27;
--color-sky-75: 9 44 43;
--color-sky-100: 0 58 56;
--color-sky-200: 0 71 68;
--color-sky-300: 16 86 80;
--color-sky-400: 30 104 95;
--color-sky-500: 39 127 112;
--color-sky-600: 134 234 212;
--color-sky-700: 168 245 229;
--color-sky-800: 88 213 186;
--color-sky-900: 196 245 225;
--color-indigo-25: 17 19 31;
--color-indigo-50: 20 23 38;
--color-indigo-75: 24 36 73;
--color-indigo-100: 29 46 98;
--color-indigo-200: 37 57 116;
--color-indigo-300: 48 67 132;
--color-indigo-400: 58 79 151;
--color-indigo-500: 67 93 177;
--color-indigo-600: 62 99 221;
--color-indigo-700: 84 114 228;
--color-indigo-800: 158 177 255;
--color-indigo-900: 214 225 255;
--color-iris-25: 19 19 30;
--color-iris-50: 23 22 37;
--color-iris-75: 32 34 72;
--color-iris-100: 38 42 101;
--color-iris-200: 48 51 116;
--color-iris-300: 61 62 130;
--color-iris-400: 74 74 149;
--color-iris-500: 89 88 177;
--color-iris-600: 91 91 214;
--color-iris-700: 110 106 222;
--color-iris-800: 177 169 255;
--color-iris-900: 224 223 254;
--color-violet-25: 20 18 31;
--color-violet-50: 27 21 37;
--color-violet-75: 41 31 67;
--color-violet-100: 51 37 91;
--color-violet-200: 60 46 105;
--color-violet-300: 71 56 118;
--color-violet-400: 86 70 139;
--color-violet-500: 105 88 173;
--color-violet-600: 110 86 207;
--color-violet-700: 125 102 217;
--color-violet-800: 186 167 255;
--color-violet-900: 226 221 254;
--color-pink-25: 25 17 23;
--color-pink-50: 33 18 29;
--color-pink-75: 55 23 47;
--color-pink-100: 75 20 61;
--color-pink-200: 89 28 71;
--color-pink-300: 105 41 85;
--color-pink-400: 131 56 105;
--color-pink-500: 168 72 133;
--color-pink-600: 214 64 159;
--color-pink-700: 222 81 168;
--color-pink-800: 255 141 204;
--color-pink-900: 253 209 234;
--color-orange-25: 23 18 14;
--color-orange-50: 30 22 15;
--color-orange-75: 51 30 11;
--color-orange-100: 70 33 0;
--color-orange-200: 86 40 0;
--color-orange-300: 102 53 12;
--color-orange-400: 126 69 29;
--color-orange-500: 163 88 41;
--color-orange-600: 247 107 21;
--color-orange-700: 255 128 31;
--color-orange-800: 255 160 87;
--color-orange-900: 255 224 194;
}
}
@@ -185,7 +185,7 @@ import ConversationBasicFilter from './widgets/conversation/ConversationBasicFil
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import timeMixin from '../mixins/time';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import conversationMixin from '../mixins/conversations';
import wootConstants from 'dashboard/constants/globals';
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
@@ -199,11 +199,6 @@ import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
import {
hasPressedAltAndJKey,
hasPressedAltAndKKey,
} from 'shared/helpers/KeyboardHelpers';
import { conversationListPageURL } from '../helper/URLHelper';
import {
isOnMentionsView,
@@ -228,7 +223,7 @@ export default {
mixins: [
timeMixin,
conversationMixin,
eventListenerMixins,
keyboardEventListenerMixins,
alertMixin,
filterMixin,
uiSettingsMixin,
@@ -691,30 +686,40 @@ export default {
lastConversationIndex,
};
},
handleKeyEvents(e) {
if (hasPressedAltAndJKey(e)) {
const { allConversations, activeConversationIndex } =
this.getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[0].click();
}
if (activeConversationIndex >= 1) {
allConversations[activeConversationIndex - 1].click();
}
handlePreviousConversation() {
const { allConversations, activeConversationIndex } =
this.getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[0].click();
}
if (hasPressedAltAndKKey(e)) {
const {
allConversations,
activeConversationIndex,
lastConversationIndex,
} = this.getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[lastConversationIndex].click();
} else if (activeConversationIndex < lastConversationIndex) {
allConversations[activeConversationIndex + 1].click();
}
if (activeConversationIndex >= 1) {
allConversations[activeConversationIndex - 1].click();
}
},
handleNextConversation() {
const {
allConversations,
activeConversationIndex,
lastConversationIndex,
} = this.getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[lastConversationIndex].click();
} else if (activeConversationIndex < lastConversationIndex) {
allConversations[activeConversationIndex + 1].click();
}
},
getKeyboardEvents() {
return {
'Alt+KeyJ': {
action: () => this.handlePreviousConversation(),
allowOnFocusedInput: true,
},
'Alt+KeyK': {
action: () => this.handleNextConversation(),
allowOnFocusedInput: true,
},
};
},
resetAndFetchData() {
this.appliedFilter = [];
this.resetBulkActions();
@@ -91,12 +91,7 @@ import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import {
hasPressedAltAndEKey,
hasPressedCommandPlusAltAndEKey,
hasPressedAltAndMKey,
} from 'shared/helpers/KeyboardHelpers';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@@ -114,7 +109,7 @@ export default {
WootDropdownMenu,
CustomSnoozeModal,
},
mixins: [clickaway, alertMixin, eventListenerMixins],
mixins: [clickaway, alertMixin, keyboardEventListenerMixins],
props: { conversationId: { type: [String, Number], required: true } },
data() {
return {
@@ -159,37 +154,52 @@ export default {
bus.$off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
},
methods: {
async handleKeyEvents(e) {
getKeyboardEvents() {
return {
'Alt+KeyM': {
action: () => this.$refs.arrowDownButton?.$el.click(),
allowOnFocusedInput: true,
},
'Alt+KeyE': this.resolveOrToast,
'$mod+Alt+KeyE': async event => {
const { all, activeIndex, lastIndex } = this.getConversationParams();
await this.resolveOrToast();
if (activeIndex < lastIndex) {
all[activeIndex + 1].click();
} else if (all.length > 1) {
all[0].click();
document.querySelector('.conversations-list').scrollTop = 0;
}
event.preventDefault();
},
};
},
getConversationParams() {
const allConversations = document.querySelectorAll(
'.conversations-list .conversation'
);
if (hasPressedAltAndMKey(e)) {
if (this.$refs.arrowDownButton) {
this.$refs.arrowDownButton.$el.click();
}
}
if (hasPressedAltAndEKey(e)) {
const activeConversation = document.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
try {
await this.toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
} catch (error) {
// error
}
if (hasPressedCommandPlusAltAndEKey(e)) {
if (activeConversationIndex < lastConversationIndex) {
allConversations[activeConversationIndex + 1].click();
} else if (allConversations.length > 1) {
allConversations[0].click();
document.querySelector('.conversations-list').scrollTop = 0;
}
e.preventDefault();
}
const activeConversation = document.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
all: allConversations,
activeIndex: activeConversationIndex,
lastIndex: lastConversationIndex,
};
},
async resolveOrToast() {
try {
await this.toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
} catch (error) {
// error
}
},
onCmdSnoozeConversation(snoozeType) {
@@ -1,5 +1,5 @@
<template>
<aside class="h-full flex">
<aside class="flex h-full">
<primary-sidebar
:logo-source="globalConfig.logoThumbnail"
:installation-name="globalConfig.installationName"
@@ -36,15 +36,7 @@ import alertMixin from 'shared/mixins/alertMixin';
import PrimarySidebar from './sidebarComponents/Primary.vue';
import SecondarySidebar from './sidebarComponents/Secondary.vue';
import {
hasPressedAltAndCKey,
hasPressedAltAndRKey,
hasPressedAltAndSKey,
hasPressedAltAndVKey,
hasPressedCommandAndForwardSlash,
isEscape,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import router from '../../routes';
export default {
@@ -52,7 +44,7 @@ export default {
PrimarySidebar,
SecondarySidebar,
},
mixins: [adminMixin, alertMixin, eventListenerMixins],
mixins: [adminMixin, alertMixin, keyboardEventListenerMixins],
props: {
showSecondarySidebar: {
type: Boolean,
@@ -173,30 +165,27 @@ export default {
closeKeyShortcutModal() {
this.$emit('close-key-shortcut-modal');
},
handleKeyEvents(e) {
if (hasPressedCommandAndForwardSlash(e)) {
this.toggleKeyShortcutModal();
}
if (isEscape(e)) {
this.closeKeyShortcutModal();
}
if (hasPressedAltAndCKey(e)) {
if (!this.isCurrentRouteSameAsNavigation('home')) {
router.push({ name: 'home' });
}
} else if (hasPressedAltAndVKey(e)) {
if (!this.isCurrentRouteSameAsNavigation('contacts_dashboard')) {
router.push({ name: 'contacts_dashboard' });
}
} else if (hasPressedAltAndRKey(e)) {
if (!this.isCurrentRouteSameAsNavigation('settings_account_reports')) {
router.push({ name: 'settings_account_reports' });
}
} else if (hasPressedAltAndSKey(e)) {
if (!this.isCurrentRouteSameAsNavigation('agent_list')) {
router.push({ name: 'agent_list' });
}
getKeyboardEvents() {
return {
'$mod+Slash': this.toggleKeyShortcutModal,
'$mod+Escape': this.closeKeyShortcutModal,
'Alt+KeyC': {
action: () => this.navigateToRoute('home'),
},
'Alt+KeyV': {
action: () => this.navigateToRoute('contacts_dashboard'),
},
'Alt+KeyR': {
action: () => this.navigateToRoute('account_overview_reports'),
},
'Alt+KeyS': {
action: () => this.navigateToRoute('agent_list'),
},
};
},
navigateToRoute(routeName) {
if (!this.isCurrentRouteSameAsNavigation(routeName)) {
router.push({ name: routeName });
}
},
isCurrentRouteSameAsNavigation(routeName) {
@@ -40,8 +40,7 @@ import AIAssistanceModal from './AIAssistanceModal.vue';
import adminMixin from 'dashboard/mixins/aiMixin';
import aiMixin from 'dashboard/mixins/isAdmin';
import { CMD_AI_ASSIST } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
@@ -51,7 +50,7 @@ export default {
AICTAModal,
AIAssistanceCTAButton,
},
mixins: [aiMixin, eventListenerMixins, adminMixin, uiSettingsMixin],
mixins: [aiMixin, keyboardEventListenerMixins, adminMixin, uiSettingsMixin],
data: () => ({
showAIAssistanceModal: false,
showAICtaModal: false,
@@ -87,14 +86,17 @@ export default {
},
methods: {
onKeyDownHandler(event) {
const keyPattern = buildHotKeys(event);
const shouldRevertTheContent =
['meta+z', 'ctrl+z'].includes(keyPattern) && !!this.initialMessage;
if (shouldRevertTheContent) {
this.$emit('replace-text', this.initialMessage);
this.initialMessage = '';
}
getKeyboardEvents() {
return {
'$mod+KeyZ': {
action: () => {
if (this.initialMessage) {
this.$emit('replace-text', this.initialMessage);
this.initialMessage = '';
}
},
},
};
},
hideAIAssistanceModal() {
this.recordAnalytics('DISMISS_AI_SUGGESTION', {
@@ -1,17 +1,17 @@
<template>
<div class="preview-item__wrap flex overflow-auto max-h-[12.5rem]">
<div class="flex overflow-auto max-h-[12.5rem]">
<div
v-for="(attachment, index) in attachments"
v-for="(attachment, index) in nonRecordedAudioAttachments"
:key="attachment.id"
class="preview-item flex items-center p-1 bg-slate-50 dark:bg-slate-800 gap-1 rounded-md w-[15rem] mb-1"
>
<div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
<img
v-if="isTypeImage(attachment.resource)"
class="image-thumb"
class="object-cover w-6 h-6 rounded-sm"
:src="attachment.thumb"
/>
<span v-else class="w-6 h-6 text-lg relative -top-px text-left">
<span v-else class="relative w-6 h-6 text-lg text-left -top-px">
📄
</span>
</div>
@@ -23,73 +23,62 @@
</span>
</div>
<div class="w-[30%] justify-center">
<span
class="item overflow-hidden text-xs text-ellipsis whitespace-nowrap"
>
<span class="overflow-hidden text-xs text-ellipsis whitespace-nowrap">
{{ formatFileSize(attachment.resource) }}
</span>
</div>
<div class="flex items-center justify-center">
<woot-button
v-if="!isTypeAudio(attachment.resource)"
class="remove--attachment clear secondary"
class="!w-6 !h-6 text-sm rounded-md hover:bg-slate-50 dark:hover:bg-slate-800 clear secondary"
icon="dismiss"
@click="() => onRemoveAttachment(index)"
@click="onRemoveAttachment(index)"
/>
</div>
</div>
</div>
</template>
<script>
<script setup>
import { computed } from 'vue';
import { formatBytes } from 'shared/helpers/FileHelper';
export default {
props: {
attachments: {
type: Array,
default: () => [],
},
removeAttachment: {
type: Function,
default: () => {},
},
},
methods: {
onRemoveAttachment(index) {
this.removeAttachment(index);
},
formatFileSize(file) {
const size = file.byte_size || file.size;
return formatBytes(size, 0);
},
isTypeImage(file) {
const type = file.content_type || file.type;
return type.includes('image');
},
isTypeAudio(file) {
const type = file.content_type || file.type;
return type.includes('audio');
},
fileName(file) {
return file.filename || file.name;
},
const props = defineProps({
attachments: {
type: Array,
default: () => [],
},
});
const emits = defineEmits(['remove-attachment']);
const nonRecordedAudioAttachments = computed(() => {
return props.attachments.filter(attachment => !attachment?.isRecordedAudio);
});
const recordedAudioAttachments = computed(() =>
props.attachments.filter(attachment => attachment.isRecordedAudio)
);
const onRemoveAttachment = itemIndex => {
emits(
'remove-attachment',
nonRecordedAudioAttachments.value
.filter((_, index) => index !== itemIndex)
.concat(recordedAudioAttachments.value)
);
};
const formatFileSize = file => {
const size = file.byte_size || file.size;
return formatBytes(size, 0);
};
const isTypeImage = file => {
const type = file.content_type || file.type;
return type.includes('image');
};
const fileName = file => {
return file.filename || file.name;
};
</script>
<style lang="scss" scoped>
.image-thumb {
@apply w-6 h-6 object-cover rounded-sm;
}
.file-name-wrap,
.file-size-wrap {
@apply flex items-center py-0 px-1;
> .item {
@apply m-0 overflow-hidden text-xs font-medium;
}
}
.remove--attachment {
@apply w-6 h-6 rounded-md text-sm cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800;
}
</style>
@@ -10,11 +10,10 @@
</template>
<script>
import wootConstants from 'dashboard/constants/globals';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { hasPressedAltAndNKey } from 'shared/helpers/KeyboardHelpers';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
mixins: [eventListenerMixins],
mixins: [keyboardEventListenerMixins],
props: {
items: {
type: Array,
@@ -31,14 +30,18 @@ export default {
},
},
methods: {
handleKeyEvents(e) {
if (hasPressedAltAndNKey(e)) {
if (this.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
this.onTabChange(0);
} else {
this.onTabChange(this.activeTabIndex + 1);
}
}
getKeyboardEvents() {
return {
'Alt+KeyN': {
action: () => {
if (this.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
this.onTabChange(0);
} else {
this.onTabChange(this.activeTabIndex + 1);
}
},
},
};
},
onTabChange(selectedTabIndex) {
if (this.items[selectedTabIndex].key !== this.activeTab) {
@@ -31,15 +31,10 @@
<script>
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import { mixin as clickaway } from 'vue-clickaway';
import adminMixin from 'dashboard/mixins/isAdmin';
import {
buildHotKeys,
isEscape,
isActiveElementTypeable,
} from 'shared/helpers/KeyboardHelpers';
export default {
components: {
@@ -47,7 +42,7 @@ export default {
LabelDropdown,
},
mixins: [clickaway, adminMixin, eventListenerMixins],
mixins: [clickaway, adminMixin, keyboardEventListenerMixins],
props: {
allLabels: {
@@ -88,17 +83,19 @@ export default {
closeDropdownLabel() {
this.showSearchDropdownLabel = false;
},
handleKeyEvents(e) {
const keyPattern = buildHotKeys(e);
if (keyPattern === 'l' && !isActiveElementTypeable(e)) {
this.toggleLabels();
e.preventDefault();
} else if (isEscape(e) && this.showSearchDropdownLabel) {
this.closeDropdownLabel();
e.preventDefault();
}
getKeyboardEvents() {
return {
KeyL: {
action: e => {
this.toggleLabels();
e.preventDefault();
},
},
Escape: {
action: () => this.closeDropdownLabel(),
allowOnFocusedInput: true,
},
};
},
},
};
@@ -231,7 +231,18 @@ export default {
@apply bg-transparent max-h-60 min-h-[3rem] pt-4 px-0 pb-0 resize-none;
}
}
.video-js .vjs-control-bar {
background-color: transparent;
// Added to override the default text and bg style to support dark and light mode.
.video-js .vjs-control-bar,
.vjs-record.video-js .vjs-control.vjs-record-indicator:before {
@apply text-slate-600 dark:text-slate-200 bg-transparent dark:bg-transparent;
}
// Added to fix div overlays the screen and takes over the button clicks
// https://github.com/collab-project/videojs-record/issues/688
// https://github.com/collab-project/videojs-record/pull/709
.vjs-record.video-js .vjs-control.vjs-record-indicator.vjs-hidden,
.vjs-record.video-js .vjs-control.vjs-record-indicator,
.vjs-record.video-js .vjs-control.vjs-record-indicator:before,
.vjs-record.video-js .vjs-control.vjs-record-indicator:after {
@apply pointer-events-none;
}
</style>
@@ -78,10 +78,8 @@ const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
import {
hasPressedEnterAndNotCmdOrShift,
hasPressedCommandAndEnter,
hasPressedAltAndPKey,
hasPressedAltAndLKey,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import {
@@ -121,7 +119,7 @@ const createState = (
export default {
name: 'WootMessageEditor',
components: { TagAgents, CannedResponse, VariableList },
mixins: [eventListenerMixins, uiSettingsMixin, alertMixin],
mixins: [keyboardEventListenerMixins, uiSettingsMixin, alertMixin],
props: {
value: { type: String, default: '' },
editorId: { type: String, default: '' },
@@ -522,13 +520,21 @@ export default {
isCmdPlusEnterToSendEnabled() {
return isEditorHotKeyEnabled(this.uiSettings, 'cmd_enter');
},
handleKeyEvents(e) {
if (hasPressedAltAndPKey(e)) {
this.focusEditorInputField();
}
if (hasPressedAltAndLKey(e)) {
this.focusEditorInputField();
}
getKeyboardEvents() {
return {
'Alt+KeyP': {
action: () => {
this.focusEditorInputField();
},
allowOnFocusedInput: true,
},
'Alt+KeyL': {
action: () => {
this.focusEditorInputField();
},
allowOnFocusedInput: true,
},
};
},
focusEditorInputField(pos = 'end') {
const { tr } = this.editorView.state;
@@ -25,7 +25,7 @@ import {
} from '@chatwoot/prosemirror-schema';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import alertMixin from 'shared/mixins/alertMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
@@ -51,7 +51,7 @@ const createState = (
};
export default {
mixins: [eventListenerMixins, uiSettingsMixin, alertMixin],
mixins: [keyboardEventListenerMixins, uiSettingsMixin, alertMixin],
props: {
value: { type: String, default: '' },
editorId: { type: String, default: '' },
@@ -136,8 +136,7 @@
<script>
import FileUpload from 'vue-upload-component';
import * as ActiveStorage from 'activestorage';
import { hasPressedAltAndAKey } from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -154,7 +153,7 @@ import { mapGetters } from 'vuex';
export default {
name: 'ReplyBottomPanel',
components: { FileUpload, VideoCallButton, AIAssistanceButton },
mixins: [eventListenerMixins, uiSettingsMixin, inboxMixin],
mixins: [keyboardEventListenerMixins, uiSettingsMixin, inboxMixin],
props: {
mode: {
type: String,
@@ -340,10 +339,15 @@ export default {
ActiveStorage.start();
},
methods: {
handleKeyEvents(e) {
if (hasPressedAltAndAKey(e)) {
this.$refs.upload.$children[1].$el.click();
}
getKeyboardEvents() {
return {
'Alt+KeyA': {
action: () => {
this.$refs.upload.$children[1].$el.click();
},
allowOnFocusedInput: true,
},
};
},
toggleMessageSignature() {
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
@@ -1,5 +1,5 @@
<template>
<div class="bg-black-50 flex justify-between dark:bg-slate-800">
<div class="flex justify-between bg-black-50 dark:bg-slate-800">
<div class="button-group">
<woot-button
variant="clear"
@@ -20,7 +20,7 @@
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
</woot-button>
</div>
<div class="flex items-center my-0 mx-4">
<div class="flex items-center mx-4 my-0">
<div v-if="isMessageLengthReachingThreshold" class="text-xs">
<span :class="charLengthClass">
{{ characterLengthWarning }}
@@ -48,14 +48,10 @@
<script>
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import {
hasPressedAltAndPKey,
hasPressedAltAndLKey,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
name: 'ReplyTopPanel',
mixins: [eventListenerMixins],
mixins: [keyboardEventListenerMixins],
props: {
mode: {
type: String,
@@ -99,13 +95,17 @@ export default {
},
},
methods: {
handleKeyEvents(e) {
if (hasPressedAltAndPKey(e)) {
this.handleNoteClick();
}
if (hasPressedAltAndLKey(e)) {
this.handleReplyClick();
}
getKeyboardEvents() {
return {
'Alt+KeyP': {
action: () => this.handleNoteClick(),
allowOnFocusedInput: true,
},
'Alt+KeyL': {
action: () => this.handleReplyClick(),
allowOnFocusedInput: true,
},
};
},
handleReplyClick() {
this.setReplyMode(REPLY_EDITOR_MODES.REPLY);
@@ -2,7 +2,7 @@
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-slate-25 dark:hover:bg-slate-800 group"
:class="{
'animate-card-select bg-slate-25 dark:bg-slate-800 border-woot-500':
'active animate-card-select bg-slate-25 dark:bg-slate-800 border-woot-500':
isActiveChat,
'unread-chat': hasUnread,
'has-inbox-name': showInboxName,
@@ -77,11 +77,10 @@
</div>
</template>
<script>
import { hasPressedAltAndOKey } from 'shared/helpers/KeyboardHelpers';
import { mapGetters } from 'vuex';
import agentMixin from '../../../mixins/agentMixin.js';
import BackButton from '../BackButton.vue';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
@@ -99,7 +98,7 @@ export default {
Thumbnail,
SLACardLabel,
},
mixins: [inboxMixin, agentMixin, eventListenerMixins],
mixins: [inboxMixin, agentMixin, keyboardEventListenerMixins],
props: {
chat: {
type: Object,
@@ -182,10 +181,12 @@ export default {
},
methods: {
handleKeyEvents(e) {
if (hasPressedAltAndOKey(e)) {
this.$emit('contact-panel-toggle');
}
getKeyboardEvents() {
return {
'Alt+KeyO': {
action: () => this.$emit('contact-panel-toggle'),
},
};
},
},
};
@@ -14,11 +14,7 @@
@click="retrySendMessage"
/>
</div>
<div
v-tooltip.top-start="messageToolTip"
:class="bubbleClass"
@contextmenu="openContextMenu($event)"
>
<div :class="bubbleClass" @contextmenu="openContextMenu($event)">
<bubble-mail-head
:email-attributes="contentAttributes.email"
:cc="emailHeadAttributes.cc"
@@ -90,7 +86,7 @@
:id="data.id"
:sender="data.sender"
:story-sender="storySender"
:external-error="externalError"
:external-error="errorMessageTooltip"
:story-id="`${storyId}`"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
@@ -412,14 +408,11 @@ export default {
}
: false;
},
messageToolTip() {
if (this.isMessageDeleted) {
return false;
}
errorMessageTooltip() {
if (this.isFailed) {
return this.externalError || this.$t(`CONVERSATION.SEND_FAILED`);
}
return false;
return '';
},
wrapClass() {
return {
@@ -116,13 +116,12 @@ import conversationMixin, {
} from '../../../mixins/conversations';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import configMixin from 'shared/mixins/configMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import aiMixin from 'dashboard/mixins/aiMixin';
// utils
import { getTypingUsersText } from '../../../helper/commons';
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers';
import { LocalStorage } from 'shared/helpers/localStorage';
// constants
@@ -141,7 +140,7 @@ export default {
mixins: [
conversationMixin,
inboxMixin,
eventListenerMixins,
keyboardEventListenerMixins,
configMixin,
aiMixin,
],
@@ -418,10 +417,12 @@ export default {
closePopoutReplyBox() {
this.isPopoutReplyBox = false;
},
handleKeyEvents(e) {
if (isEscape(e)) {
this.closePopoutReplyBox();
}
getKeyboardEvents() {
return {
Escape: {
action: () => this.closePopoutReplyBox(),
},
};
},
addScrollListener() {
this.conversationPanel = this.$el.querySelector('.conversation-panel');
@@ -100,7 +100,7 @@
<attachment-preview
class="flex-col mt-4"
:attachments="attachedFiles"
:remove-attachment="removeAttachment"
@remove-attachment="removeAttachment"
/>
</div>
<message-signature-missing-alert
@@ -155,6 +155,7 @@
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue';
@@ -178,7 +179,6 @@ import {
replaceVariablesInMessage,
} from '@chatwoot/utils';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
@@ -225,6 +225,7 @@ export default {
messageFormatterMixin,
rtlMixin,
fileUploadMixin,
keyboardEventListenerMixins,
],
props: {
popoutReplyBox: {
@@ -701,24 +702,41 @@ export default {
this.$store.dispatch('draftMessages/delete', { key });
}
},
handleKeyEvents(e) {
const keyCode = buildHotKeys(e);
if (keyCode === 'escape') {
this.hideEmojiPicker();
this.hideMentions();
} else if (keyCode === 'meta+k') {
const ninja = document.querySelector('ninja-keys');
ninja.open();
e.preventDefault();
} else if (keyCode === 'enter' && this.isAValidEvent('enter')) {
this.onSendReply();
e.preventDefault();
} else if (
['meta+enter', 'ctrl+enter'].includes(keyCode) &&
this.isAValidEvent('cmd_enter')
) {
this.onSendReply();
}
getKeyboardEvents() {
return {
Escape: {
action: () => {
this.hideEmojiPicker();
this.hideMentions();
},
allowOnFocusedInput: true,
},
'$mod+KeyK': {
action: e => {
e.preventDefault();
const ninja = document.querySelector('ninja-keys');
ninja.open();
},
allowOnFocusedInput: true,
},
Enter: {
action: e => {
if (this.isAValidEvent('enter')) {
this.onSendReply();
e.preventDefault();
}
},
allowOnFocusedInput: true,
},
'$mod+Enter': {
action: () => {
if (this.isAValidEvent('cmd_enter')) {
this.onSendReply();
}
},
allowOnFocusedInput: true,
},
};
},
isAValidEvent(selectedKey) {
return (
@@ -937,6 +955,13 @@ export default {
this.bccEmails = '';
this.toEmails = '';
},
clearRecorder() {
this.isRecordingAudio = false;
// Only clear the recorded audio when we click toggle button.
this.attachedFiles = this.attachedFiles.filter(
file => !file?.isRecordedAudio
);
},
toggleEmojiPicker() {
this.showEmojiPicker = !this.showEmojiPicker;
},
@@ -944,8 +969,7 @@ export default {
this.isRecordingAudio = !this.isRecordingAudio;
this.isRecorderAudioStopped = !this.isRecordingAudio;
if (!this.isRecordingAudio) {
this.clearMessage();
this.clearEmailField();
this.clearRecorder();
}
},
toggleAudioRecorderPlayPause() {
@@ -989,7 +1013,13 @@ export default {
}
},
onFinishRecorder(file) {
return file && this.onFileUpload(file);
// Added a new key isRecordedAudio to the file to find it's and recorded audio
// Because to filter and show only non recorded audio and other attachments
const autoRecordedFile = {
...file,
isRecordedAudio: true,
};
return file && this.onFileUpload(autoRecordedFile);
},
toggleTyping(status) {
const conversationId = this.currentChat.id;
@@ -1015,13 +1045,12 @@ export default {
isPrivate: this.isPrivate,
thumb: reader.result,
blobSignedId: blob ? blob.signed_id : undefined,
isRecordedAudio: file?.isRecordedAudio || false,
});
};
},
removeAttachment(itemIndex) {
this.attachedFiles = this.attachedFiles.filter(
(item, index) => itemIndex !== index
);
removeAttachment(attachments) {
this.attachedFiles = attachments;
},
setReplyToInPayload(payload) {
if (this.inReplyTo?.id) {
@@ -75,9 +75,10 @@ export default {
},
methods: {
handleKeyboardEvent(e) {
this.processKeyDownEvent(e);
this.$el.scrollTop = 50 * this.selectedIndex;
adjustScroll() {
this.$nextTick(() => {
this.$el.scrollTop = 50 * this.selectedIndex;
});
},
onHover(index) {
this.selectedIndex = index;
@@ -17,7 +17,7 @@
@error="onImgError"
@click="onClick"
/>
<audio v-else-if="isAudio" controls class="skip-context-menu">
<audio v-else-if="isAudio" controls class="skip-context-menu mb-0.5">
<source :src="`${dataUrl}?t=${Date.now()}`" />
</audio>
<gallery-view
@@ -182,12 +182,7 @@
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import {
isEscape,
hasPressedArrowLeftKey,
hasPressedArrowRightKey,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import timeMixin from 'dashboard/mixins/time';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
@@ -205,7 +200,7 @@ export default {
components: {
Thumbnail,
},
mixins: [eventListenerMixins, clickaway, timeMixin],
mixins: [keyboardEventListenerMixins, clickaway, timeMixin],
props: {
show: {
type: Boolean,
@@ -304,20 +299,30 @@ export default {
this.activeAttachment = attachment;
this.activeFileType = type;
},
onKeyDownHandler(e) {
if (isEscape(e)) {
this.onClose();
} else if (hasPressedArrowLeftKey(e)) {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex - 1],
this.activeImageIndex - 1
);
} else if (hasPressedArrowRightKey(e)) {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex + 1],
this.activeImageIndex + 1
);
}
getKeyboardEvents() {
return {
Escape: {
action: () => {
this.onClose();
},
},
ArrowLeft: {
action: () => {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex - 1],
this.activeImageIndex - 1
);
},
},
ArrowRight: {
action: () => {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex + 1],
this.activeImageIndex + 1
);
},
},
};
},
onClickDownload() {
const { file_type: type, data_url: url } = this.activeAttachment;
@@ -74,15 +74,10 @@
<script>
import countries from 'shared/constants/countries.js';
import parsePhoneNumber from 'libphonenumber-js';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import {
hasPressedArrowUpKey,
hasPressedArrowDownKey,
isEnter,
} from 'shared/helpers/KeyboardHelpers';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
mixins: [eventListenerMixins],
mixins: [keyboardEventListenerMixins],
props: {
value: {
type: [String, Number],
@@ -183,6 +178,7 @@ export default {
this.$emit('blur', e.target.value);
},
dropdownItem() {
if (!this.showDropdown) return [];
return Array.from(
this.$refs.dropdown.querySelectorAll(
'div.country-dropdown div.country-dropdown--item'
@@ -190,34 +186,27 @@ export default {
);
},
focusedItem() {
if (!this.showDropdown) return [];
return Array.from(
this.$refs.dropdown.querySelectorAll('div.country-dropdown div.focus')
);
},
focusedItemIndex() {
if (!this.showDropdown) return -1;
return Array.from(this.dropdownItem()).indexOf(this.focusedItem()[0]);
},
onKeyDownHandler(e) {
const { showDropdown, filteredCountriesBySearch, onSelectCountry } = this;
const { selectedIndex } = this;
if (showDropdown) {
if (hasPressedArrowDownKey(e)) {
e.preventDefault();
this.selectedIndex = Math.min(
selectedIndex + 1,
filteredCountriesBySearch.length - 1
);
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28;
} else if (hasPressedArrowUpKey(e)) {
e.preventDefault();
this.selectedIndex = Math.max(selectedIndex - 1, 0);
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
} else if (isEnter(e)) {
e.preventDefault();
onSelectCountry(filteredCountriesBySearch[selectedIndex]);
}
}
moveUp() {
if (!this.showDropdown) return;
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
},
moveDown() {
if (!this.showDropdown) return;
this.selectedIndex = Math.min(
this.selectedIndex + 1,
this.filteredCountriesBySearch.length - 1
);
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
},
onSelectCountry(country) {
this.activeCountryCode = country.id;
@@ -235,6 +224,33 @@ export default {
this.activeDialCode = number.countryCallingCode;
}
},
getKeyboardEvents() {
return {
ArrowUp: {
action: e => {
e.preventDefault();
this.moveUp();
},
allowOnFocusedInput: true,
},
ArrowDown: {
action: e => {
e.preventDefault();
this.moveDown();
},
allowOnFocusedInput: true,
},
Enter: {
action: e => {
e.preventDefault();
this.onSelectCountry(
this.filteredCountriesBySearch[this.selectedIndex]
);
},
allowOnFocusedInput: true,
},
};
},
toggleCountryDropdown() {
this.showDropdown = !this.showDropdown;
this.selectedIndex = -1;
@@ -84,9 +84,7 @@ export default {
},
},
methods: {
handleKeyboardEvent(e) {
this.processKeyDownEvent(e);
},
adjustScroll() {},
onHover(index) {
this.selectedIndex = index;
},
@@ -1,12 +1,7 @@
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
mounted() {
document.addEventListener('keydown', this.handleKeyboardEvent);
},
beforeDestroy() {
document.removeEventListener('keydown', this.handleKeyboardEvent);
},
mixins: [keyboardEventListenerMixins],
methods: {
moveSelectionUp() {
if (!this.selectedIndex) {
@@ -14,6 +9,7 @@ export default {
} else {
this.selectedIndex -= 1;
}
this.adjustScroll();
},
moveSelectionDown() {
if (this.selectedIndex === this.items.length - 1) {
@@ -21,19 +17,46 @@ export default {
} else {
this.selectedIndex += 1;
}
this.adjustScroll();
},
processKeyDownEvent(e) {
const keyPattern = buildHotKeys(e);
if (['arrowup', 'ctrl+p'].includes(keyPattern)) {
this.moveSelectionUp();
e.preventDefault();
} else if (['arrowdown', 'ctrl+n'].includes(keyPattern)) {
this.moveSelectionDown();
e.preventDefault();
} else if (keyPattern === 'enter') {
this.onSelect();
e.preventDefault();
}
getKeyboardEvents() {
return {
ArrowUp: {
action: e => {
this.moveSelectionUp();
e.preventDefault();
},
allowOnFocusedInput: true,
},
'Control+KeyP': {
action: e => {
this.moveSelectionUp();
e.preventDefault();
},
allowOnFocusedInput: true,
},
ArrowDown: {
action: e => {
this.moveSelectionDown();
e.preventDefault();
},
allowOnFocusedInput: true,
},
'Control+KeyN': {
action: e => {
this.moveSelectionDown();
e.preventDefault();
},
allowOnFocusedInput: true,
},
Enter: {
action: e => {
this.onSelect();
e.preventDefault();
},
allowOnFocusedInput: true,
},
};
},
},
};
@@ -1,64 +1,69 @@
import mentionSelectionKeyboardMixin from '../mentionSelectionKeyboardMixin';
import { shallowMount } from '@vue/test-utils';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
const localVue = createLocalVue();
const buildComponent = ({ data = {}, methods = {} }) => ({
render() {},
data() {
return data;
return { ...data, selectedIndex: 0, items: [1, 2, 3] };
},
methods,
mixins: [mentionSelectionKeyboardMixin],
methods: { ...methods, onSelect: jest.fn(), adjustScroll: jest.fn() },
mixins: [keyboardEventListenerMixins],
});
describe('mentionSelectionKeyboardMixin', () => {
test('register listeners', () => {
jest.spyOn(document, 'addEventListener');
let wrapper;
beforeEach(() => {
const Component = buildComponent({});
shallowMount(Component);
// undefined expected as the method is not defined in the component
expect(document.addEventListener).toHaveBeenCalledWith(
'keydown',
undefined
);
wrapper = shallowMount(Component, { localVue });
});
test('processKeyDownEvent updates index on arrow up', () => {
const Component = buildComponent({
data: { selectedIndex: 0, items: [1, 2, 3] },
});
const wrapper = shallowMount(Component);
wrapper.vm.processKeyDownEvent({
ctrlKey: true,
key: 'p',
preventDefault: jest.fn(),
});
expect(wrapper.vm.selectedIndex).toBe(2);
it('ArrowUp and Control+KeyP update selectedIndex correctly', () => {
const preventDefault = jest.fn();
const keyboardEvents = wrapper.vm.getKeyboardEvents();
if (keyboardEvents && keyboardEvents.ArrowUp) {
keyboardEvents.ArrowUp.action({ preventDefault });
expect(wrapper.vm.selectedIndex).toBe(2);
expect(preventDefault).toHaveBeenCalled();
}
wrapper.setData({ selectedIndex: 1 });
if (keyboardEvents && keyboardEvents['Control+KeyP']) {
keyboardEvents['Control+KeyP'].action({ preventDefault });
expect(wrapper.vm.selectedIndex).toBe(0);
expect(preventDefault).toHaveBeenCalledTimes(2);
}
});
test('processKeyDownEvent updates index on arrow down', () => {
const Component = buildComponent({
data: { selectedIndex: 0, items: [1, 2, 3] },
});
const wrapper = shallowMount(Component);
wrapper.vm.processKeyDownEvent({
key: 'ArrowDown',
preventDefault: jest.fn(),
});
expect(wrapper.vm.selectedIndex).toBe(1);
it('ArrowDown and Control+KeyN update selectedIndex correctly', () => {
const preventDefault = jest.fn();
const keyboardEvents = wrapper.vm.getKeyboardEvents();
if (keyboardEvents && keyboardEvents.ArrowDown) {
keyboardEvents.ArrowDown.action({ preventDefault });
expect(wrapper.vm.selectedIndex).toBe(1);
expect(preventDefault).toHaveBeenCalled();
}
wrapper.setData({ selectedIndex: 1 });
if (keyboardEvents && keyboardEvents['Control+KeyN']) {
keyboardEvents['Control+KeyN'].action({ preventDefault });
expect(wrapper.vm.selectedIndex).toBe(2);
expect(preventDefault).toHaveBeenCalledTimes(2);
}
});
test('processKeyDownEvent calls select methods on Enter Key', () => {
const onSelectMockFn = jest.fn();
const Component = buildComponent({
data: { selectedIndex: 0, items: [1, 2, 3] },
methods: { onSelect: () => onSelectMockFn('enterKey pressed') },
});
const wrapper = shallowMount(Component);
wrapper.vm.processKeyDownEvent({
key: 'Enter',
preventDefault: jest.fn(),
});
expect(onSelectMockFn).toHaveBeenCalledWith('enterKey pressed');
wrapper.vm.onSelect();
it('Enter key triggers onSelect method', () => {
const preventDefault = jest.fn();
const keyboardEvents = wrapper.vm.getKeyboardEvents();
if (keyboardEvents && keyboardEvents.Enter) {
keyboardEvents.Enter.action({ preventDefault });
expect(wrapper.vm.onSelect).toHaveBeenCalled();
expect(preventDefault).toHaveBeenCalled();
}
});
});
@@ -53,12 +53,6 @@ export const SHORTCUT_KEYS = [
firstKey: 'Alt / ⌥',
secondKey: 'S',
},
{
id: 10,
label: 'SWITCH_CONVERSATION_STATUS',
firstKey: 'Alt / ⌥',
secondKey: 'B',
},
{
id: 11,
label: 'SWITCH_TO_PRIVATE_NOTE',
@@ -0,0 +1,32 @@
import { computed, getCurrentInstance } from 'vue';
import Vue from 'vue';
import VueI18n from 'vue-i18n';
let i18nInstance = VueI18n;
export function useI18n() {
if (!i18nInstance) throw new Error('vue-i18n not initialized');
const i18n = i18nInstance;
const instance = getCurrentInstance();
const vm = instance?.proxy || instance || new Vue({});
const locale = computed({
get() {
return i18n.locale;
},
set(v) {
i18n.locale = v;
},
});
return {
locale,
t: vm.$t.bind(vm),
tc: vm.$tc.bind(vm),
d: vm.$d.bind(vm),
te: vm.$te.bind(vm),
n: vm.$n.bind(vm),
};
}
@@ -1,5 +1,4 @@
import AnalyticsHelper from './AnalyticsHelper';
import LogRocket from 'logrocket';
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER';
@@ -11,12 +10,6 @@ export const ANALYTICS_RESET = 'ANALYTICS_RESET';
export const initializeAnalyticsEvents = () => {
window.bus.$on(ANALYTICS_IDENTITY, ({ user }) => {
AnalyticsHelper.identify(user);
if (window.logRocketProjectId) {
LogRocket.identify(user.id, {
email: user.email,
name: user.name,
});
}
});
};
@@ -48,7 +48,8 @@
"SIDEBAR_SECTIONS": {
"CUSTOM_ATTRIBUTES": "Custom Attributes",
"CONTACT_LABELS": "Contact Labels",
"PREVIOUS_CONVERSATIONS": "Previous Conversations"
"PREVIOUS_CONVERSATIONS": "Previous Conversations",
"NO_RECORDS_FOUND": "No attributes found"
}
},
"EDIT_CONTACT": {
@@ -280,6 +280,7 @@
},
"CONVERSATION_CUSTOM_ATTRIBUTES": {
"ADD_BUTTON_TEXT": "Create attribute",
"NO_RECORDS_FOUND": "No attributes found",
"UPDATE": {
"SUCCESS": "Attribute updated successfully",
"ERROR": "Unable to update attribute. Please try again later"
@@ -316,7 +316,6 @@
"GO_TO_REPORTS_SIDEBAR": "Go to Reports sidebar",
"MOVE_TO_NEXT_TAB": "Move to next tab in conversation list",
"GO_TO_SETTINGS": "Go to Settings",
"SWITCH_CONVERSATION_STATUS": "Switch to the next conversation status",
"SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note",
"SWITCH_TO_REPLY": "Switch to Reply",
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"
@@ -10,7 +10,8 @@
"TITLE": "Upgrade to create SLAs",
"AVAILABLE_ON": "The SLA feature is only available in the Business and Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to advanced features like team management, automations, custom attributes, and more.",
"UPGRADE_NOW": "Upgrade now"
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "The SLA feature is only available in the paid plans.",
@@ -104,4 +105,4 @@
"HIDE": "Hide {count} rows"
}
}
}
}
@@ -23,12 +23,12 @@
<script>
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import { hasPressedCommandAndEnter } from 'shared/helpers/KeyboardHelpers';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
components: {
WootMessageEditor,
},
mixins: [keyboardEventListenerMixins],
data() {
return {
noteContent: '',
@@ -40,21 +40,14 @@ export default {
return this.noteContent === '';
},
},
mounted() {
document.addEventListener('keydown', this.onMetaEnter);
},
beforeDestroy() {
document.removeEventListener('keydown', this.onMetaEnter);
},
methods: {
onMetaEnter(e) {
if (hasPressedCommandAndEnter(e)) {
e.preventDefault();
this.onAdd();
}
getKeyboardEvents() {
return {
'$mod+Enter': {
action: () => this.onAdd(),
allowOnFocusedInput: true,
},
};
},
onAdd() {
if (this.noteContent !== '') {
@@ -1,6 +1,6 @@
<template>
<div
class="w-1/4 bg-white dark:bg-slate-900 border-slate-50 dark:border-slate-800/50 h-full text-sm overflow-y-auto relative"
class="relative w-1/4 h-full overflow-y-auto text-sm bg-white dark:bg-slate-900 border-slate-50 dark:border-slate-800/50"
:class="showAvatar ? 'border-l border-solid ' : 'border-r border-solid'"
>
<contact-info
@@ -40,6 +40,9 @@
attribute-class="conversation--attribute"
attribute-from="contact_panel"
:custom-attributes="contact.custom_attributes"
:empty-state-message="
$t('CONTACT_PANEL.SIDEBAR_SECTIONS.NO_RECORDS_FOUND')
"
class="even"
/>
</accordion-item>
@@ -1,6 +1,6 @@
<template>
<div
class="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-300 border-slate-50 dark:border-slate-800/50 border-l rtl:border-l-0 rtl:border-r contact--panel overflow-y-auto"
class="overflow-y-auto bg-white border-l dark:bg-slate-900 text-slate-900 dark:text-slate-300 border-slate-50 dark:border-slate-800/50 rtl:border-l-0 rtl:border-r contact--panel"
>
<contact-info
:contact="contact"
@@ -23,6 +23,15 @@
:key="element.name"
class="bg-white dark:bg-gray-800"
>
<div v-if="element.name === 'linear'" class="conversation--actions">
<accordion-item
title="Linear"
:is-open="isContactSidebarItemOpen('is_linear_open')"
@click="value => toggleSidebarUIState('is_linear_open', value)"
>
<linear :conversation-id="conversationId" />
</accordion-item>
</div>
<div
v-if="element.name === 'conversation_actions'"
class="conversation--actions"
@@ -89,6 +98,9 @@
class="even"
attribute-from="conversation_contact_panel"
:contact-id="contact.id"
:empty-state-message="
$t('CONVERSATION_CUSTOM_ATTRIBUTES.NO_RECORDS_FOUND')
"
/>
</accordion-item>
</div>
@@ -142,6 +154,7 @@ import CustomAttributes from './customAttributes/CustomAttributes.vue';
import draggable from 'vuedraggable';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import MacrosList from './Macros/List.vue';
import Linear from './linear/index.vue';
export default {
components: {
@@ -154,6 +167,7 @@ export default {
ConversationParticipant,
draggable,
MacrosList,
Linear,
},
mixins: [alertMixin, uiSettingsMixin],
props: {
@@ -17,6 +17,12 @@
@delete="onDelete"
@copy="onCopy"
/>
<p
v-if="!displayedAttributes.length && emptyStateMessage"
class="p-3 text-center"
>
{{ emptyStateMessage }}
</p>
<!-- Show more and show less buttons show it if the filteredAttributes length is greater than 5 -->
<div v-if="filteredAttributes.length > 5" class="flex px-2 py-2">
<woot-button
@@ -59,6 +65,10 @@ export default {
type: String,
required: true,
},
emptyStateMessage: {
type: String,
default: '',
},
},
data() {
return {
@@ -50,13 +50,8 @@ import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import { mixin as clickaway } from 'vue-clickaway';
import adminMixin from 'dashboard/mixins/isAdmin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
import {
buildHotKeys,
isEscape,
isActiveElementTypeable,
} from 'shared/helpers/KeyboardHelpers';
export default {
components: {
@@ -65,7 +60,12 @@ export default {
AddLabel,
},
mixins: [clickaway, conversationLabelMixin, adminMixin, eventListenerMixins],
mixins: [
clickaway,
conversationLabelMixin,
adminMixin,
keyboardEventListenerMixins,
],
props: {
conversationId: {
type: Number,
@@ -93,16 +93,23 @@ export default {
closeDropdownLabel() {
this.showSearchDropdownLabel = false;
},
handleKeyEvents(e) {
const keyPattern = buildHotKeys(e);
if (keyPattern === 'l' && !isActiveElementTypeable(e)) {
this.toggleLabels();
e.preventDefault();
} else if (isEscape(e) && this.showSearchDropdownLabel) {
this.closeDropdownLabel();
e.preventDefault();
}
getKeyboardEvents() {
return {
KeyL: {
action: e => {
e.preventDefault();
this.toggleLabels();
},
},
Escape: {
action: () => {
if (this.showSearchDropdownLabel) {
this.toggleLabels();
}
},
allowOnFocusedInput: true,
},
};
},
},
};
@@ -0,0 +1,242 @@
<template>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
header-title="Create/link linear issue"
header-content="Quickly create/link a linear issue from this conversation"
/>
<div class="flex flex-col h-auto overflow-auto">
<div class="flex flex-col px-8 pb-4" @submit.prevent="onSubmit">
<woot-input
v-model="title"
label="Title"
class="w-full"
placeholder="Enter title"
type="text"
@blur="$v.title.$touch"
/>
<label>
Description
<textarea
v-model="description"
rows="6"
type="text"
placeholder="Enter description"
@blur="$v.description.$touch"
/>
</label>
<label>
Team
<select v-model="teamId" @change="onChangeTeam($event)">
<option v-for="item in teams" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
Assignee
<select v-model="assigneeId">
<option v-for="item in assignees" :key="item.id" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
Priority
<select v-model="priority">
<option v-for="item in priorities" :key="item.id" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
Label
<select v-model="labelId">
<option v-for="item in labels" :key="item.id" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
Status
<select v-model="stateId">
<option v-for="item in statuses" :key="item.id" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<div class="flex items-center justify-end w-full gap-2 mt-8">
<woot-button
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
@click.prevent="onClose"
>
{{ $t('SLA.FORM.CANCEL') }}
</woot-button>
<woot-button
:is-disabled="isSubmitDisabled"
class="px-4 rounded-xl"
:is-loading="isCreating"
@click.prevent="createIssue"
>
Create
</woot-button>
</div>
</div>
</div>
</div>
</template>
<script>
import alertMixin from 'shared/mixins/alertMixin';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper.js';
import { LinearClient } from '@linear/sdk';
// const chatwootAPIKey = '';
const localAPIKey = '';
const linearClient = new LinearClient({
apiKey: localAPIKey,
});
// const BASE_URL = 'https:app.chatwoot.com';
const BASE_URL = 'http://localhost:3000';
import validations from './validations';
export default {
mixins: [alertMixin],
props: {
accountId: {
type: [Number, String],
required: true,
},
conversationId: {
type: [Number, String],
required: true,
},
},
data() {
return {
title: '',
description: '',
teamId: '',
assigneeId: '',
stateId: '',
labelId: '',
priority: 0,
teams: [],
assignees: [],
labels: [],
statuses: [],
searchTerm: '',
priorities: [
{
id: 0,
name: 'No priority',
},
{
id: 1,
name: 'Urgent',
},
{
id: 2,
name: 'High',
},
{
id: 3,
name: 'Normal',
},
{
id: 4,
name: 'Low',
},
],
isCreating: false,
searchResults: [
{
id: 1,
title: 'Test',
},
],
};
},
validations,
computed: {
isSubmitDisabled() {
return this.$v.title.$invalid || this.isCreating;
},
},
mounted() {
this.getTeams();
this.getAssignees();
this.getLabels();
this.getWorkflowStates();
},
methods: {
onClose() {
this.$emit('close');
},
onChangeTeam(event) {
this.teamId = event.target.value;
this.getLabels();
this.getWorkflowStates();
this.getAssignees();
},
async getTeams() {
const teams = await linearClient.teams();
this.teams = teams.nodes;
},
async getAssignees() {
const assignees = await linearClient.users();
this.assignees = assignees.nodes;
},
async getLabels() {
const labels = await linearClient.issueLabels({
teamId: this.teamId,
});
this.labels = labels.nodes;
},
async getWorkflowStates() {
const states = await linearClient.workflowStates();
this.statuses = states.nodes;
},
async createIssue() {
this.isCreating = true;
const response = await linearClient.createIssue({
teamId: this.teamId,
title: this.title,
description: this.description,
assigneeId: this.assigneeId,
priority: this.priority,
labelIds: this.labelId ? [this.labelId] : [],
});
const stringifyResponse = JSON.stringify(response);
const {
_issue: { id: issueId },
} = JSON.parse(stringifyResponse);
this.attachmentLinkURL(issueId);
this.showAlert('Linear issue created successfully');
},
async attachmentLinkURL(issueId) {
const url =
BASE_URL +
frontendURL(
conversationUrl({
accountId: this.accountId,
id: this.conversationId,
})
);
await linearClient.attachmentLinkURL(issueId, url);
this.isCreating = false;
this.onClose();
},
openSearch() {
this.showSearchBox = true;
},
},
};
</script>
@@ -0,0 +1,128 @@
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between group">
<!-- <woot-button
size="tiny"
variant="link"
color-scheme="secondary"
icon="delete"
@click="deleteIssue(issue)"
/> -->
<a
:href="issue.link"
target="_blank"
rel="noopener noreferrer"
class="inline-block rounded-sm mb-0 break-all py-0.5 text-primary-600"
>
{{ `${issue.identifier} ${issue.title}` }}
</a>
<woot-button
size="small"
variant="clear"
color-scheme="secondary"
class="!px-2 hover:!bg-transparent hidden dark:hover:!bg-transparent underline group-hover:block"
@click="deleteIssue(issue)"
>
Unlink
</woot-button>
</div>
<span class="text-sm font-normal text-slate-900 dark:text-slate-25">
{{ issue.description }}
</span>
</div>
<div class="flex justify-between w-full">
<span
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
>
Status
</span>
<div class="flex flex-col w-full gap-2">
<span
class="text-sm font-normal text-left text-slate-900 dark:text-slate-25 tabular-nums"
>
Todo
</span>
</div>
</div>
<div class="flex justify-between w-full">
<span
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
>
Priority
</span>
<div class="flex flex-col w-full gap-2">
<span
class="text-sm font-normal text-left text-slate-900 dark:text-slate-25 tabular-nums"
>
{{ issue.priorityLabel }}
</span>
</div>
</div>
<div class="flex justify-between w-full">
<span
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
>
Assignee
</span>
<div class="flex flex-col w-full gap-2">
<span
class="text-sm font-normal text-left text-slate-900 dark:text-slate-25 tabular-nums"
>
Muhsin
</span>
</div>
</div>
<div class="flex justify-between w-full">
<span
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
>
Labels
</span>
<div class="flex flex-col w-full gap-2">
<span
class="text-sm font-normal text-left text-slate-900 dark:text-slate-25 tabular-nums"
>
---
</span>
</div>
</div>
<div class="flex justify-between w-full">
<span
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
>
Created At
</span>
<div class="flex flex-col w-full gap-2">
<span
class="text-sm font-normal text-left text-slate-900 dark:text-slate-25 tabular-nums"
>
{{ formatDate(issue.createdAt) }}
</span>
</div>
</div>
</div>
</template>
<script>
import { format } from 'date-fns';
export default {
props: {
issue: {
type: Object,
required: true,
},
},
methods: {
deleteIssue(issue) {
this.$emit('delete', issue);
},
formatDate(timestamp) {
return format(timestamp, 'MMM dd, hh:mm a');
},
},
};
</script>
@@ -0,0 +1,147 @@
<template>
<div class="flex flex-col">
<div class="flex flex-row gap-2">
<woot-button
v-if="!isLoading && !issues.length"
variant="smooth"
icon="add"
size="tiny"
@click="shoeCreateIssuePopup"
>
Create or Link Issue
</woot-button>
</div>
<div v-if="isLoading" class="flex items-center justify-center">
<span class="text-sm font-medium text-slate-800 dark:text-slate-100">
Loading..
</span>
</div>
<div v-if="!isLoading && issues.length" class="flex flex-col gap-2">
<issue-item
v-for="issue in issues"
:key="issue.id"
:issue="issue"
:conversation-id="conversationId"
@delete="deleteIssue"
/>
</div>
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
<add-issue
:conversation-id="conversationId"
:account-id="currentAccountId"
@close="hideAddPopup"
/>
</woot-modal>
</div>
</template>
<script>
import { LinearClient } from '@linear/sdk';
import { mapGetters } from 'vuex';
import IssueItem from './IssueItem.vue';
import accountMixin from 'dashboard/mixins/account.js';
import AddIssue from './AddIssue.vue';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper.js';
// const chatwootAPIKey = '';
const localAPIKey = '';
const linearClient = new LinearClient({
apiKey: localAPIKey,
});
// const BASE_URL = 'https:app.chatwoot.com';
const BASE_URL = 'http://localhost:3000';
export default {
components: {
IssueItem,
AddIssue,
},
mixins: [accountMixin],
props: {
conversationId: {
type: [Number, String],
required: true,
},
},
data() {
return {
isLoading: false,
showAddPopup: false,
issues: [],
};
},
computed: {
...mapGetters({
currentAccountId: 'getCurrentAccountId',
}),
},
mounted() {
this.loadIssues();
},
methods: {
shoeCreateIssuePopup() {
this.showAddPopup = true;
},
hideAddPopup() {
this.showAddPopup = false;
this.loadIssues();
},
async loadIssues() {
const url =
BASE_URL +
frontendURL(
conversationUrl({
accountId: this.accountId,
id: this.conversationId,
})
);
this.isLoading = true;
linearClient.attachmentsForURL(url).then(issues => {
if (issues.nodes.length) {
issues.nodes.map(issue1 => {
const linearIssueId = issue1._issue.id;
const attachmentId = issue1.id;
if (linearIssueId) {
linearClient.issue(linearIssueId).then(issue2 => {
console.log('Issue:', issue2, attachmentId);
this.issues.push({
id: issue2.id,
title: issue2.title,
link: issue2.url,
identifier: issue2.identifier,
attachmentId,
...issue2,
});
this.isLoading = false;
});
}
});
} else {
this.isLoading = false;
console.log('No issues');
}
});
},
async deleteIssue(issue) {
const index = this.issues.findIndex(i => i.id === issue.id);
if (index > -1) {
this.issues.splice(index, 1);
}
await linearClient.deleteAttachment(issue.attachmentId);
},
},
};
</script>
<style scoped lang="scss">
.macros_list--empty-state {
padding: var(--space-slab);
p {
margin: 0;
}
}
.macros_add-button {
margin: var(--space-small) auto 0;
}
</style>
@@ -0,0 +1,12 @@
import { required } from 'vuelidate/lib/validators';
export default {
title: {
required,
},
description: {
required,
},
teamId: {},
assigneeId: {},
};
@@ -34,15 +34,11 @@
</template>
<script>
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import {
buildHotKeys,
isActiveElementTypeable,
} from 'shared/helpers/KeyboardHelpers';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
name: 'ChatwootSearch',
mixins: [eventListenerMixins],
mixins: [keyboardEventListenerMixins],
props: {
title: {
type: String,
@@ -71,13 +67,15 @@ export default {
onBlur() {
this.isInputFocused = false;
},
handleKeyEvents(e) {
const keyPattern = buildHotKeys(e);
if (keyPattern === '/' && !isActiveElementTypeable(e)) {
e.preventDefault();
this.$refs.searchInput.focus();
}
getKeyboardEvents() {
return {
Slash: {
action: e => {
e.preventDefault();
this.$refs.searchInput.focus();
},
},
};
},
},
};
@@ -35,10 +35,7 @@
<script>
import { debounce } from '@chatwoot/utils';
import { mixin as clickaway } from 'vue-clickaway';
import {
isEscape,
isActiveElementTypeable,
} from 'shared/helpers/KeyboardHelpers';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import SearchHeader from './Header.vue';
import SearchResults from './SearchResults.vue';
@@ -55,7 +52,7 @@ export default {
SearchResults,
ArticleView,
},
mixins: [clickaway, portalMixin, alertMixin],
mixins: [clickaway, portalMixin, alertMixin, keyboardEventListenerMixins],
props: {
selectedPortalSlug: {
type: String,
@@ -97,10 +94,6 @@ export default {
mounted() {
this.fetchArticlesByQuery(this.searchQuery);
this.debounceSearch = debounce(this.fetchArticlesByQuery, 500, false);
document.body.addEventListener('keydown', this.closeOnEsc);
},
beforeDestroy() {
document.body.removeEventListener('keydown', this.closeOnEsc);
},
methods: {
generateArticleUrl(article) {
@@ -158,11 +151,15 @@ export default {
);
this.onClose();
},
closeOnEsc(e) {
if (isEscape(e) && !isActiveElementTypeable(e)) {
e.preventDefault();
this.onClose();
}
getKeyboardEvents() {
return {
Escape: {
action: () => {
this.onClose();
},
allowOnFocusedInput: true,
},
};
},
},
};
@@ -1,308 +1,173 @@
<script setup>
import { computed } from 'vue';
import format from 'date-fns/format';
import getDay from 'date-fns/getDay';
import { getQuantileIntervals } from '@chatwoot/utils';
import { groupHeatmapByDay } from 'helpers/ReportsDataHelper';
import { useI18n } from 'dashboard/composables/useI18n';
const { t } = useI18n();
const props = defineProps({
heatData: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const processedData = computed(() => {
return groupHeatmapByDay(props.heatData);
});
const quantileRange = computed(() => {
const flattendedData = props.heatData.map(data => data.value);
return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]);
});
function getCountTooltip(value) {
if (!value) {
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.NO_CONVERSATIONS');
}
if (value === 1) {
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATION', {
count: value,
});
}
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATIONS', {
count: value,
});
}
function formatDate(dateString) {
return format(new Date(dateString), 'MMM d, yyyy');
}
function getDayOfTheWeek(date) {
const dayIndex = getDay(date);
const days = [
t('DAYS_OF_WEEK.SUNDAY'),
t('DAYS_OF_WEEK.MONDAY'),
t('DAYS_OF_WEEK.TUESDAY'),
t('DAYS_OF_WEEK.WEDNESDAY'),
t('DAYS_OF_WEEK.THURSDAY'),
t('DAYS_OF_WEEK.FRIDAY'),
t('DAYS_OF_WEEK.SATURDAY'),
];
return days[dayIndex];
}
function getHeatmapLevelClass(value) {
if (!value)
return 'outline-slate-100 dark:outline-slate-700 dark:bg-slate-700/40 bg-slate-50/50';
let level = [...quantileRange.value, Infinity].findIndex(
range => value <= range && value > 0
);
if (level > 6) level = 5;
if (level === 0) {
return 'outline-slate-100 dark:outline-slate-700 dark:bg-slate-700/40 bg-slate-50/50';
}
const classes = [
'bg-woot-50 dark:bg-woot-800/40 dark:outline-woot-800/80',
'bg-woot-100 dark:bg-woot-800/30 dark:outline-woot-800/80',
'bg-woot-200 dark:bg-woot-500/40 dark:outline-woot-700/80',
'bg-woot-300 dark:bg-woot-500/60 dark:outline-woot-600/80',
'bg-woot-600 dark:bg-woot-500/80 dark:outline-woot-500/80',
'bg-woot-800 dark:bg-woot-500 dark:outline-woot-400/80',
];
return classes[level - 1];
}
</script>
<template>
<div class="heatmap-container">
<div
class="grid relative w-full gap-x-4 gap-y-2.5 overflow-y-scroll md:overflow-visible grid-cols-[80px_1fr] min-h-72"
>
<template v-if="isLoading">
<div class="heatmap-labels">
<div class="grid gap-[5px] flex-shrink-0">
<div
v-for="ii in 7"
:key="ii"
class="loading-cell heatmap-axis-label"
class="w-full rounded-sm bg-slate-100 dark:bg-slate-900 animate-loader-pulse h-8 min-w-[70px]"
/>
</div>
<div class="heatmap-grid">
<div v-for="ii in 7" :key="ii" class="heatmap-grid-row">
<div v-for="jj in 24" :key="jj" class="heatmap-tile loading-cell">
<div class="heatmap-tile__label loading-cell" />
</div>
<div class="grid gap-[5px] w-full min-w-[700px]">
<div
v-for="ii in 7"
:key="ii"
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
>
<div
v-for="jj in 24"
:key="jj"
class="w-full h-8 rounded-sm bg-slate-100 dark:bg-slate-900 animate-loader-pulse"
/>
</div>
</div>
<div class="heatmap-timeline" />
<div class="heatmap-markers">
<div v-for="ii in 24" :key="ii">{{ ii - 1 }} {{ ii }}</div>
<div />
<div
class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-slate-800 dark:text-slate-200"
>
<div
v-for="ii in 24"
:key="ii"
class="flex items-center justify-center"
>
{{ ii - 1 }} {{ ii }}
</div>
</div>
</template>
<template v-else>
<div class="heatmap-labels">
<div class="grid gap-[5px] flex-shrink-0">
<div
v-for="dateKey in processedData.keys()"
:key="dateKey"
class="heatmap-axis-label"
class="h-8 min-w-[70px] text-slate-800 dark:text-slate-200 text-[10px] font-semibold flex flex-col items-end justify-center"
>
{{ getDayOfTheWeek(new Date(dateKey)) }}
<time>{{ formatDate(dateKey) }}</time>
<time class="font-normal text-slate-700 dark:text-slate-200">
{{ formatDate(dateKey) }}
</time>
</div>
</div>
<div class="heatmap-grid">
<div class="grid gap-[5px] w-full min-w-[700px]">
<div
v-for="dateKey in processedData.keys()"
:key="dateKey"
class="heatmap-grid-row"
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
>
<div
v-for="data in processedData.get(dateKey)"
:key="data.timestamp"
v-tooltip.top="getCountTooltip(data.value)"
class="heatmap-tile"
class="h-8 rounded-sm shadow-inner dark:outline dark:outline-1 shadow-black"
:class="getHeatmapLevelClass(data.value)"
>
<div class="heatmap-tile__label" />
</div>
/>
</div>
</div>
<div class="heatmap-timeline" />
<div class="heatmap-markers">
<div v-for="ii in 24" :key="ii">{{ ii - 1 }} {{ ii }}</div>
<div />
<div
class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-slate-800 dark:text-slate-200"
>
<div
v-for="ii in 24"
:key="ii"
class="flex items-center justify-center"
>
{{ ii - 1 }} {{ ii }}
</div>
</div>
</template>
</div>
</template>
<script>
import { getQuantileIntervals } from '@chatwoot/utils';
import format from 'date-fns/format';
import getDay from 'date-fns/getDay';
import { groupHeatmapByDay } from 'helpers/ReportsDataHelper';
export default {
name: 'Heatmap',
props: {
heatData: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
},
computed: {
processedData() {
return groupHeatmapByDay(this.heatData);
},
quantileRange() {
const flattendedData = this.heatData.map(data => data.value);
return getQuantileIntervals(
flattendedData,
[0.2, 0.4, 0.6, 0.8, 0.9, 0.99]
);
},
},
methods: {
getCountTooltip(value) {
if (!value) {
return this.$t(
'OVERVIEW_REPORTS.CONVERSATION_HEATMAP.NO_CONVERSATIONS'
);
}
if (value === 1) {
return this.$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATION', {
count: value,
});
}
return this.$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATIONS', {
count: value,
});
},
formatDate(dateString) {
return format(new Date(dateString), 'MMM d, yyyy');
},
getDayOfTheWeek(date) {
const dayIndex = getDay(date);
const days = [
this.$t('DAYS_OF_WEEK.SUNDAY'),
this.$t('DAYS_OF_WEEK.MONDAY'),
this.$t('DAYS_OF_WEEK.TUESDAY'),
this.$t('DAYS_OF_WEEK.WEDNESDAY'),
this.$t('DAYS_OF_WEEK.THURSDAY'),
this.$t('DAYS_OF_WEEK.FRIDAY'),
this.$t('DAYS_OF_WEEK.SATURDAY'),
];
return days[dayIndex];
},
getHeatmapLevelClass(value) {
if (!value) return '';
const level = [...this.quantileRange, Infinity].findIndex(
range => value <= range && value > 0
);
if (level > 6) {
return 'l6';
}
return `l${level}`;
},
},
};
</script>
<style scoped lang="scss">
$heatmap-colors: (
level-1: var(--w-50),
level-2: var(--w-100),
level-3: var(--w-300),
level-4: var(--w-500),
level-5: var(--w-700),
level-6: var(--w-900),
);
$heatmap-hover-border-color: (
level-1: var(--w-25),
level-2: var(--w-50),
level-3: var(--w-100),
level-4: var(--w-300),
level-5: var(--w-500),
level-6: var(--w-700),
);
$tile-height: 1.875rem;
$tile-gap: var(--space-smaller);
$container-gap-row: var(--space-one);
$container-gap-column: var(--space-two);
$marker-height: var(--space-two);
@mixin heatmap-level($level) {
$color: map-get($heatmap-colors, 'level-#{$level}');
background-color: $color;
&:hover {
border: 1px solid map-get($heatmap-hover-border-color, 'level-#{$level}');
}
}
@media screen and (max-width: 768px) {
.heatmap-container {
overflow-y: auto;
}
}
.loading-cell {
background-color: var(--color-background-light);
border: 0px;
animation: loading-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes loading-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.heatmap-container {
display: grid;
position: relative;
width: 100%;
gap: $container-gap-row $container-gap-column;
grid-template-columns: 80px 1fr;
min-height: calc(
7 * #{$tile-height} + 6 * #{$tile-gap} + #{$container-gap-row} + #{$marker-height}
);
}
.heatmap-labels {
display: grid;
grid-template-rows: 1fr;
gap: $tile-gap;
flex-shrink: 0;
.heatmap-axis-label {
height: $tile-height;
min-width: 70px;
font-size: var(--font-size-micro);
font-weight: var(--font-weight-bold);
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
@apply text-slate-800 dark:text-slate-200;
time {
font-size: var(--font-size-micro);
font-weight: var(--font-weight-normal);
@apply text-slate-700 dark:text-slate-200;
}
}
}
.heatmap-grid {
display: grid;
grid-template-rows: 1fr;
gap: $tile-gap;
min-width: 700px;
width: 100%;
.heatmap-grid-row {
display: grid;
gap: $tile-gap;
grid-template-columns: repeat(24, 1fr);
}
.heatmap-tile {
width: auto;
height: $tile-height;
border-radius: var(--border-radius-normal);
&:hover {
box-shadow: var(--shadow-large);
transform: translateY(-2px);
transition:
transform 0.2s ease-in-out,
box-shadow 0.2s ease-in-out;
}
&:not(.l1):not(.l2):not(.l3):not(.l4):not(.l5):not(.l6) {
background-color: var(--color-background-light);
border: 1px solid var(--color-border-light);
&:hover {
transform: translateY(0);
box-shadow: none;
border: 1px solid var(--color-border-light);
}
}
&.l1 {
@include heatmap-level(1);
}
&.l2 {
@include heatmap-level(2);
}
&.l3 {
@include heatmap-level(3);
}
&.l4 {
@include heatmap-level(4);
}
&.l5 {
@include heatmap-level(5);
}
&.l6 {
@include heatmap-level(6);
}
}
}
.heatmap-markers {
display: grid;
grid-template-columns: repeat(24, 1fr);
gap: $tile-gap;
width: 100%;
font-size: var(--font-size-nano);
font-weight: var(--font-weight-bold);
height: $marker-height;
color: var(--color-body);
@apply text-slate-800 dark:text-slate-200;
div {
display: flex;
align-items: center;
justify-content: center;
}
}
</style>
@@ -45,7 +45,7 @@ const i18nKey = props.isOnChatwootCloud ? 'PAYWALL' : 'ENTERPRISE_PAYWALL';
{{ $t('SLA.ENTERPRISE_PAYWALL.ASK_ADMIN') }}
</span>
</p>
<template v-if="isOnChatwootCloud">
<template v-if="isOnChatwootCloud || true">
<woot-button
color-scheme="primary"
class="w-full mt-2 text-center rounded-xl"
@@ -55,8 +55,11 @@ const i18nKey = props.isOnChatwootCloud ? 'PAYWALL' : 'ENTERPRISE_PAYWALL';
>
{{ $t('SLA.PAYWALL.UPGRADE_NOW') }}
</woot-button>
<span class="mt-2 text-xs tracking-tight text-center">
{{ $t('SLA.PAYWALL.CANCEL_ANYTIME') }}
</span>
</template>
<template v-if="isSuperAdmin">
<template v-else-if="isSuperAdmin">
<a href="/super_admin" class="block w-full">
<woot-button
color-scheme="primary"
-18
View File
@@ -46,26 +46,8 @@ import webhooks from './modules/webhooks';
import draftMessages from './modules/draftMessages';
import SLAReports from './modules/SLAReports';
import LogRocket from 'logrocket';
import createPlugin from 'logrocket-vuex';
const plugins = [];
if (window.logRocketProjectId) {
LogRocket.init(window.logRocketProjectId);
// eslint-disable-next-line func-names
const logRocketPlugin = createPlugin(LogRocket, function (mutation) {
const eventsToIgnore = ['SET_CURRENT_USER', 'AUTHENTICATE', 'CLEAR_USER'];
if (eventsToIgnore.includes(mutation.type)) {
return null;
}
return mutation;
});
plugins.push(logRocketPlugin);
}
Vue.use(Vuex);
export default new Vuex.Store({
modules: {
@@ -109,9 +109,10 @@ export default {
generateArticleUrl(article) {
return `/hc/${article.portal.slug}/articles/${article.slug}`;
},
handleKeyboardEvent(e) {
this.processKeyDownEvent(e);
this.$el.scrollTop = 102 * this.selectedIndex;
adjustScroll() {
this.$nextTick(() => {
this.$el.scrollTop = 102 * this.selectedIndex;
});
},
prepareContent(content) {
return this.highlightContent(
@@ -8,16 +8,12 @@
</ul>
</template>
<script>
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import {
hasPressedArrowUpKey,
hasPressedArrowDownKey,
} from 'shared/helpers/KeyboardHelpers';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
name: 'WootDropdownMenu',
componentName: 'WootDropdownMenu',
mixins: [eventListenerMixins],
mixins: [keyboardEventListenerMixins],
props: {
placement: {
@@ -31,38 +27,46 @@ export default {
'ul.dropdown li.dropdown-menu__item .button'
);
},
activeElementIndex() {
const menuButtons = this.dropdownMenuButtons();
getActiveButtonIndex(menuButtons) {
const focusedButton = this.$refs.dropdownMenu.querySelector(
'ul.dropdown li.dropdown-menu__item .button:focus'
);
const activeIndex = [...menuButtons].indexOf(focusedButton);
return activeIndex;
return Array.from(menuButtons).indexOf(focusedButton);
},
handleKeyEvents(e) {
getKeyboardEvents() {
const menuButtons = this.dropdownMenuButtons();
const lastElementIndex = menuButtons.length - 1;
return {
ArrowUp: {
action: e => {
e.preventDefault();
this.focusPreviousButton(menuButtons);
},
allowOnFocusedInput: true,
},
ArrowDown: {
action: e => {
e.preventDefault();
this.focusNextButton(menuButtons);
},
allowOnFocusedInput: true,
},
};
},
focusPreviousButton(menuButtons) {
const activeIndex = this.getActiveButtonIndex(menuButtons);
const newIndex =
activeIndex >= 1 ? activeIndex - 1 : menuButtons.length - 1;
this.focusButton(menuButtons, newIndex);
},
focusNextButton(menuButtons) {
const activeIndex = this.getActiveButtonIndex(menuButtons);
const newIndex =
activeIndex === menuButtons.length - 1 ? 0 : activeIndex + 1;
this.focusButton(menuButtons, newIndex);
},
focusButton(menuButtons, newIndex) {
if (menuButtons.length === 0) return;
if (hasPressedArrowUpKey(e)) {
const activeIndex = this.activeElementIndex();
if (activeIndex >= 1) {
menuButtons[activeIndex - 1].focus();
} else {
menuButtons[lastElementIndex].focus();
}
}
if (hasPressedArrowDownKey(e)) {
const activeIndex = this.activeElementIndex();
if (activeIndex === lastElementIndex) {
menuButtons[0].focus();
} else {
menuButtons[activeIndex + 1].focus();
}
}
menuButtons[newIndex].focus();
},
},
};
@@ -1,9 +1,9 @@
export const isEnter = e => {
return e.keyCode === 13;
return e.key === 'Enter';
};
export const isEscape = e => {
return e.keyCode === 27;
return e.key === 'Escape';
};
export const hasPressedShift = e => {
@@ -19,118 +19,7 @@ export const hasPressedEnterAndNotCmdOrShift = e => {
};
export const hasPressedCommandAndEnter = e => {
return e.metaKey && e.keyCode === 13;
};
export const hasPressedCommandAndForwardSlash = e => {
return e.metaKey && e.keyCode === 191;
};
export const hasPressedAltAndCKey = e => {
return e.altKey && e.keyCode === 67;
};
export const hasPressedAltAndVKey = e => {
return e.altKey && e.keyCode === 86;
};
export const hasPressedAltAndRKey = e => {
return e.altKey && e.keyCode === 82;
};
export const hasPressedAltAndSKey = e => {
return e.altKey && e.keyCode === 83;
};
export const hasPressedAltAndBKey = e => {
return e.altKey && e.keyCode === 66;
};
export const hasPressedAltAndNKey = e => {
return e.altKey && e.keyCode === 78;
};
export const hasPressedAltAndAKey = e => {
return e.altKey && e.keyCode === 65;
};
export const hasPressedAltAndPKey = e => {
return e.altKey && e.keyCode === 80;
};
export const hasPressedAltAndLKey = e => {
return e.altKey && e.keyCode === 76;
};
export const hasPressedAltAndEKey = e => {
return e.altKey && e.keyCode === 69;
};
export const hasPressedCommandPlusAltAndEKey = e => {
return e.metaKey && e.altKey && e.keyCode === 69;
};
export const hasPressedAltAndOKey = e => {
return e.altKey && e.keyCode === 79;
};
export const hasPressedAltAndJKey = e => {
return e.altKey && e.keyCode === 74;
};
export const hasPressedAltAndKKey = e => {
return e.altKey && e.keyCode === 75;
};
export const hasPressedAltAndMKey = e => {
return e.altKey && e.keyCode === 77;
};
export const hasPressedArrowUpKey = e => {
return e.keyCode === 38;
};
export const hasPressedArrowDownKey = e => {
return e.keyCode === 40;
};
export const hasPressedArrowLeftKey = e => {
return e.keyCode === 37;
};
export const hasPressedArrowRightKey = e => {
return e.keyCode === 39;
};
export const hasPressedCommandPlusKKey = e => {
return e.metaKey && e.keyCode === 75;
};
/**
* Returns a string representation of the hotkey pattern based on the provided event object.
* @param {KeyboardEvent} e - The keyboard event object.
* @returns {string} - The hotkey pattern string.
*/
export const buildHotKeys = e => {
const key = e.key.toLowerCase();
if (['shift', 'meta', 'alt', 'control'].includes(key)) {
return key;
}
let hotKeyPattern = '';
if (e.altKey) {
hotKeyPattern += 'alt+';
}
if (e.ctrlKey) {
hotKeyPattern += 'ctrl+';
}
if (e.metaKey && !e.ctrlKey) {
hotKeyPattern += 'meta+';
}
if (e.shiftKey) {
hotKeyPattern += 'shift+';
}
hotKeyPattern += key;
return hotKeyPattern;
return hasPressedCommand(e) && isEnter(e);
};
/**
@@ -3,20 +3,19 @@ import {
isEscape,
hasPressedShift,
hasPressedCommand,
buildHotKeys,
isActiveElementTypeable,
} from '../KeyboardHelpers';
describe('#KeyboardHelpers', () => {
describe('#isEnter', () => {
it('return correct values', () => {
expect(isEnter({ keyCode: 13 })).toEqual(true);
expect(isEnter({ key: 'Enter' })).toEqual(true);
});
});
describe('#isEscape', () => {
it('return correct values', () => {
expect(isEscape({ keyCode: 27 })).toEqual(true);
expect(isEscape({ key: 'Escape' })).toEqual(true);
});
});
@@ -31,14 +30,6 @@ describe('#KeyboardHelpers', () => {
expect(hasPressedCommand({ metaKey: true })).toEqual(true);
});
});
describe('#buildHotKeys', () => {
it('returns hot keys', () => {
expect(buildHotKeys({ altKey: true, key: 'alt' })).toEqual('alt');
expect(buildHotKeys({ ctrlKey: true, key: 'a' })).toEqual('ctrl+a');
expect(buildHotKeys({ metaKey: true, key: 'b' })).toEqual('meta+b');
});
});
});
describe('isActiveElementTypeable', () => {
@@ -1,24 +0,0 @@
import { isActiveElementTypeable, isEscape } from '../helpers/KeyboardHelpers';
export default {
mounted() {
document.addEventListener('keydown', this.onKeyDownHandler);
},
beforeDestroy() {
document.removeEventListener('keydown', this.onKeyDownHandler);
},
methods: {
onKeyDownHandler(e) {
const isTypeable = isActiveElementTypeable(e);
if (isTypeable) {
if (isEscape(e)) {
e.target.blur();
}
return;
}
this.handleKeyEvents(e);
},
},
};
@@ -0,0 +1,63 @@
import { isActiveElementTypeable, isEscape } from '../helpers/KeyboardHelpers';
import { createKeybindingsHandler } from 'tinykeys';
// this is a store that stores the handler globally, and only gets reset on reload
const taggedHandlers = [];
export default {
mounted() {
const events = this.getKeyboardEvents();
if (events) {
const wrappedEvents = this.wrapEventsInKeybindingsHandler(events);
const keydownHandler = createKeybindingsHandler(wrappedEvents);
this.appendToHandler(keydownHandler);
document.addEventListener('keydown', keydownHandler);
}
},
beforeDestroy() {
if (this.$el && this.$el.dataset.keydownHandlerIndex) {
const handlerToRemove =
taggedHandlers[this.$el.dataset.keydownHandlerIndex];
document.removeEventListener('keydown', handlerToRemove);
}
},
methods: {
appendToHandler(keydownHandler) {
const indexToAppend = taggedHandlers.push(keydownHandler) - 1;
const root = this.$el;
root.dataset.keydownHandlerIndex = indexToAppend;
},
getKeyboardEvents() {
return null;
},
wrapEventsInKeybindingsHandler(events) {
const wrappedEvents = {};
Object.keys(events).forEach(eventName => {
wrappedEvents[eventName] = this.keydownWrapper(events[eventName]);
});
return wrappedEvents;
},
keydownWrapper(handler) {
return e => {
const actionToPerform =
typeof handler === 'function' ? handler : handler.action;
const allowOnFocusedInput =
typeof handler === 'function' ? false : handler.allowOnFocusedInput;
const isTypeable = isActiveElementTypeable(e);
if (isTypeable) {
if (isEscape(e)) {
e.target.blur();
}
if (!allowOnFocusedInput) return;
}
actionToPerform(e);
};
},
},
};
@@ -33,8 +33,8 @@
</template>
<script>
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import Header from '../../components/playground/Header.vue';
import UserMessage from '../../components/playground/UserMessage.vue';
import BotMessage from '../../components/playground/BotMessage.vue';
@@ -47,7 +47,7 @@ export default {
BotMessage,
TypingIndicator,
},
mixins: [messageFormatterMixin],
mixins: [messageFormatterMixin, keyboardEventListenerMixins],
props: {
componentData: {
type: Object,
@@ -67,17 +67,17 @@ export default {
},
mounted() {
this.focusInput();
document.addEventListener('keydown', this.handleKeyEvents);
},
beforeDestroy() {
document.removeEventListener('keydown', this.handleKeyEvents);
},
methods: {
handleKeyEvents(e) {
const keyCode = buildHotKeys(e);
if (['meta+enter', 'ctrl+enter'].includes(keyCode)) {
this.onMessageSend();
}
getKeyboardEvents() {
return {
'$mod+Enter': {
action: () => {
this.onMessageSend();
},
allowOnFocusedInput: true,
},
};
},
focusInput() {
this.$refs.messageInput.focus();
@@ -0,0 +1,99 @@
<template>
<button
class="inline-flex items-center gap-1 text-sm font-medium reset-base rounded-xl w-fit"
:class="buttonClasses"
v-bind="$attrs"
>
<fluent-icon
v-if="icon && !trailingIcon"
size="1.16em"
:icon="icon"
class="flex-shrink-0"
/>
<span
v-if="$slots.default"
class="text-sm font-medium truncate ltr:text-left rtl:text-right"
>
<slot />
</span>
<fluent-icon
v-if="icon && trailingIcon"
size="1.16em"
:icon="icon"
class="flex-shrink-0"
/>
</button>
</template>
<script setup>
import { computed, defineProps, useAttrs } from 'vue';
const props = defineProps({
variant: {
type: String,
default: 'solid',
validator: value => ['outline', 'ghost', 'solid'].includes(value),
},
size: {
type: String,
default: 'large',
validator: value => ['medium', 'large'].includes(value),
},
icon: {
type: String,
default: '',
},
colorScheme: {
type: String,
default: 'primary',
validator: value => ['primary', 'secondary', 'danger'].includes(value),
},
trailingIcon: {
type: Boolean,
default: false,
},
});
const attrs = useAttrs();
const baseClasses = {
outline: 'outline outline-1 -outline-offset-1 focus:ring focus:ring-offset-1',
ghost: 'hover:text-600 active:text-600 focus:ring focus:ring-offset-1',
solid: 'hover:bg-700 active:bg-700 focus:ring focus:ring-offset-1',
};
const colorClass = computed(() => {
if (attrs.disabled) {
return 'bg-ash-200 text-ash-600 cursor-not-allowed';
}
const styleMap = {
primary: {
outline: `${baseClasses.outline} outline-primary-400 hover:text-primary-600 active:text-primary-600 focus:ring-primary-400`,
ghost: `${baseClasses.ghost} focus:ring-primary-400`,
solid: `${baseClasses.solid} bg-primary-600 text-white focus:ring-primary-400`,
},
secondary: {
outline: `${baseClasses.outline} outline-ash-400 hover:text-ash-600 active:text-ash-600 focus:ring-ash-400`,
ghost: `${baseClasses.ghost} focus:ring-ash-400`,
solid: `${baseClasses.solid} bg-ash-100 text-ash-900 focus:ring-ash-400`,
},
danger: {
outline: `${baseClasses.outline} outline-ruby-400 hover:text-ruby-600 active:text-ruby-600 focus:ring-ruby-400`,
ghost: `${baseClasses.ghost} focus:ring-ruby-400`,
solid: `${baseClasses.solid} bg-ruby-600 text-white focus:ring-ruby-400`,
},
};
const schemeStyles = styleMap[props.colorScheme];
return schemeStyles[props.variant] || schemeStyles.solid;
});
const sizeClass = computed(() => {
if (props.size === 'medium') {
return 'h-8 px-3 py-1.5 text-sm';
}
return 'h-10 px-4 py-2.5 text';
});
const buttonClasses = computed(() => [colorClass.value, sizeClass.value]);
</script>
@@ -0,0 +1,43 @@
<template>
<div
class="rounded-xl flex leading-[100%] font-medium items-center justify-center text-center cursor-default"
:class="`h-[${size}px] w-[${size}px] ${colorClass}`"
:style="style"
aria-hidden="true"
>
<slot>{{ initial }}</slot>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { userInitial } from 'v3/helpers/CommonHelper';
const colors = {
1: 'bg-ash-200 text-ash-900',
2: 'bg-amber-200 text-amber-900',
3: 'bg-pink-100 text-pink-800',
4: 'bg-purple-100 text-purple-800',
5: 'bg-indigo-100 text-indigo-800',
6: 'bg-grass-100 text-grass-800',
7: 'bg-mint-100 text-mint-800',
8: 'bg-orange-100 text-orange-800',
};
const props = defineProps({
name: {
type: String,
default: '',
},
size: {
type: Number,
default: 72,
},
});
const style = computed(() => ({
fontSize: `${Math.floor(props.size / 2.5)}px`,
}));
const colorClass = computed(() => {
return colors[(props.name.length % 8) + 1];
});
const initial = computed(() => userInitial(props.name));
</script>
@@ -0,0 +1,84 @@
<template>
<div class="relative rounded-xl h-[72px] w-[72px] cursor-pointe group">
<img
v-if="shouldShowImage"
class="rounded-xl h-[72px] w-[72px]"
:alt="name"
:src="src"
draggable="false"
@load="onImageLoad"
@error="onImageLoadError"
/>
<initials-avatar v-else-if="!shouldShowImage" :name="name" :size="72" />
<input
ref="fileInputRef"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
hidden
@change="onImageUpload"
/>
<div class="hidden group-hover:block">
<button
v-if="src"
class="absolute z-10 flex items-center justify-center w-6 h-6 p-1 border border-white rounded-full select-none dark:border-ash-75 reset-base -top-2 -right-2 bg-ash-300"
@click="onAvatarDelete"
>
<fluent-icon icon="dismiss" size="16" class="text-ash-900" />
</button>
<button
class="reset-base absolute h-[72px] w-[72px] top-0 left-0 rounded-xl select-none flex items-center justify-center bg-modal-backdrop-dark dark:bg-modal-backdrop-dark"
@click="openFileInput"
>
<fluent-icon icon="avatar-upload" size="32" class="text-white" />
</button>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
import InitialsAvatar from './InitialsAvatar.vue';
const props = defineProps({
src: {
type: String,
default: '',
},
name: {
type: String,
default: '',
},
});
const emits = defineEmits(['change', 'delete']);
const hasImageLoaded = ref(false);
const imageLoadedError = ref(false);
const fileInputRef = ref(null);
const shouldShowImage = computed(() => props.src && !imageLoadedError.value);
const onImageLoadError = () => {
imageLoadedError.value = true;
};
const onImageLoad = () => {
hasImageLoaded.value = true;
imageLoadedError.value = false;
};
const openFileInput = () => {
fileInputRef.value.click();
};
const onImageUpload = event => {
const [file] = event.target.files;
emits('change', {
file,
url: file ? URL.createObjectURL(file) : null,
});
};
const onAvatarDelete = () => {
emits('delete');
};
</script>
@@ -1,3 +1,9 @@
export const replaceRouteWithReload = url => {
window.location = url;
};
export const userInitial = name => {
const parts = name.split(/[ -]/).filter(Boolean);
let initials = parts.map(part => part[0].toUpperCase()).join('');
return initials.slice(0, 2);
};
@@ -0,0 +1,10 @@
import { userInitial } from '../CommonHelper';
describe('#userInitial', () => {
it('returns the initials of the user', () => {
expect(userInitial('John Doe')).toEqual('JD');
expect(userInitial('John')).toEqual('J');
expect(userInitial('John-Doe')).toEqual('JD');
expect(userInitial('John Doe Smith')).toEqual('JD');
});
});
@@ -220,25 +220,26 @@ export default {
},
dropdownItem() {
// This function is used to get all the items in the dropdown.
if (!this.showDropdown) return [];
return Array.from(
this.$refs.dropdown.querySelectorAll(
this.$refs.dropdown?.querySelectorAll(
'div.country-dropdown div.country-dropdown--item'
)
);
},
focusedOrActiveItem(className) {
// This function is used to get the focused or active item in the dropdown.
if (!this.showDropdown) return [];
return Array.from(
this.$refs.dropdown.querySelectorAll(
this.$refs.dropdown?.querySelectorAll(
`div.country-dropdown div.country-dropdown--item.${className}`
)
);
},
handleKeyboardEvent(e) {
if (this.showDropdown) {
this.processKeyDownEvent(e);
adjustScroll() {
this.$nextTick(() => {
this.scrollToFocusedOrActiveItem(this.focusedOrActiveItem('focus'));
}
});
},
onSelect() {
this.onSelectCountry(this.items[this.selectedIndex]);
+5 -5
View File
@@ -3,13 +3,13 @@ require 'net/imap'
class Inboxes::FetchImapEmailsJob < MutexApplicationJob
queue_as :scheduled_jobs
def perform(channel)
def perform(channel, interval = 1)
return unless should_fetch_email?(channel)
key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id)
with_lock(key, 5.minutes) do
process_email_for_channel(channel)
process_email_for_channel(channel, interval)
end
rescue *ExceptionList::IMAP_EXCEPTIONS => e
Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}"
@@ -28,11 +28,11 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
channel.imap_enabled? && !channel.reauthorization_required?
end
def process_email_for_channel(channel)
def process_email_for_channel(channel, interval)
inbound_emails = if channel.microsoft?
Imap::MicrosoftFetchEmailService.new(channel: channel).perform
Imap::MicrosoftFetchEmailService.new(channel: channel, interval: interval).perform
else
Imap::FetchEmailService.new(channel: channel).perform
Imap::FetchEmailService.new(channel: channel, interval: interval).perform
end
inbound_emails.map do |inbound_mail|
process_mail(inbound_mail, channel)
+1 -1
View File
@@ -18,7 +18,7 @@ class ActionCableListener < BaseListener
notification, account, unread_count, count = extract_notification_and_account(event)
tokens = [event.data[:notification].user.pubsub_token]
broadcast(account, tokens, NOTIFICATION_DELETED, { notification: notification.push_event_data, unread_count: unread_count, count: count })
broadcast(account, tokens, NOTIFICATION_DELETED, { notification: { id: notification.id }, unread_count: unread_count, count: count })
end
def account_cache_invalidated(event)
-1
View File
@@ -310,5 +310,4 @@ class Conversation < ApplicationRecord
end
Conversation.include_mod_with('Concerns::Conversation')
Conversation.include_mod_with('SentimentAnalysisHelper')
Conversation.prepend_mod_with('Conversation')
+13 -5
View File
@@ -59,6 +59,7 @@ class Message < ApplicationRecord
}.to_json.freeze
before_validation :ensure_content_type
before_validation :prevent_message_flooding
before_save :ensure_processed_message_content
before_save :ensure_in_reply_to
@@ -227,6 +228,18 @@ class Message < ApplicationRecord
private
def prevent_message_flooding
# Added this to cover the validation specs in messages
# We can revisit and see if we can remove this later
return if conversation.blank?
# there are cases where automations can result in message loops, we need to prevent such cases.
if conversation.messages.where('created_at >= ?', 1.minute.ago).count >= Limits.conversation_message_per_minute_limit
Rails.logger.error "Too many message: Account Id - #{account_id} : Conversation id - #{conversation_id}"
errors.add(:base, 'Too many messages')
end
end
def ensure_processed_message_content
text_content_quoted = content_attributes.dig(:email, :text_content, :quoted)
html_content_quoted = content_attributes.dig(:email, :html_content, :quoted)
@@ -256,7 +269,6 @@ class Message < ApplicationRecord
reopen_conversation
notify_via_mail
set_conversation_activity
update_message_sentiments
dispatch_create_events
send_reply
execute_message_template_hooks
@@ -393,10 +405,6 @@ class Message < ApplicationRecord
conversation.update_columns(last_activity_at: created_at)
# rubocop:enable Rails/SkipsModelValidations
end
def update_message_sentiments
# override in the enterprise ::Enterprise::SentimentAnalysisJob.perform_later(self)
end
end
Message.prepend_mod_with('Message')
@@ -1,7 +1,7 @@
require 'net/imap'
class Imap::BaseFetchEmailService
pattr_initialize [:channel!]
pattr_initialize [:channel!, :interval]
def fetch_emails
# Override this method
@@ -99,10 +99,10 @@ class Imap::BaseFetchEmailService
end
# Sends a SEARCH command to search the mailbox for messages that were
# created between yesterday and today and returns message sequence numbers.
# created between yesterday (or given date) and today and returns message sequence numbers.
# Return <message set>
def fetch_available_mail_sequence_numbers
imap_client.search(['SINCE', yesterday])
imap_client.search(['SINCE', since])
end
def build_imap_client
@@ -123,7 +123,8 @@ class Imap::BaseFetchEmailService
Mail.read_from_string(raw_email_content)
end
def yesterday
(Time.zone.today - 1).strftime('%d-%b-%Y')
def since
previous_day = Time.zone.today - (interval || 1).to_i
previous_day.strftime('%d-%b-%Y')
end
end
+1 -1
View File
@@ -128,7 +128,7 @@ By default, it renders:
tocHeader: '<%= I18n.t('public_portal.toc_header') %>'
};
</script>
<% if @portal.channel_web_widget.present? %>
<% if @portal.channel_web_widget.present? && !@is_plain_layout_enabled %>
<%= @portal.channel_web_widget.web_widget_script.html_safe %>
<% end %>
</html>
-1
View File
@@ -55,7 +55,6 @@
browser_name: '<%= browser.name %>',
}
window.errorLoggingConfig = '<%= ENV.fetch('SENTRY_DSN', '') %>'
window.logRocketProjectId = '<%= ENV.fetch('LOG_ROCKET_PROJECT_ID', '') %>'
</script>
<% if @global_config['ANALYTICS_TOKEN'].present? %>
<script>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex>
<% @portal.articles.where(status: :published).each do |article| %>
<sitemap>
<loc><%= @help_center_url %><%= generate_article_link(@portal.slug, article.slug, false, false) %></loc>
<lastmod><%= article.updated_at.strftime("%Y-%m-%d") %></lastmod>
</sitemap>
<% end %>
</sitemapindex>
+1 -1
View File
@@ -149,7 +149,7 @@ class Rack::Attack
end
## Prevent abuse of contact search api
throttle('/api/v1/accounts/:account_id/contacts/search', limit: 5, period: 1.minute) do |req|
throttle('/api/v1/accounts/:account_id/contacts/search', limit: ENV.fetch('RATE_LIMIT_CONTACT_SEARCH', '100').to_i, period: 1.minute) do |req|
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/contacts/search}.match(req.path)
match_data[:account_id] if match_data.present?
end
+1
View File
@@ -393,6 +393,7 @@ Rails.application.routes.draw do
end
get 'hc/:slug', to: 'public/api/v1/portals#show'
get 'hc/:slug/sitemap.xml', to: 'public/api/v1/portals#sitemap'
get 'hc/:slug/:locale', to: 'public/api/v1/portals#show'
get 'hc/:slug/:locale/articles', to: 'public/api/v1/portals/articles#index'
get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'
@@ -3,7 +3,7 @@ module Enterprise::Public::Api::V1::Portals::ArticlesController
def search_articles
if @portal.account.feature_enabled?('help_center_embedding_search')
@articles = @articles.vector_search(list_params) if list_params.present?
@articles = @articles.vector_search(list_params) if list_params[:query].present?
else
super
end
@@ -1,57 +0,0 @@
class Enterprise::SentimentAnalysisJob < ApplicationJob
queue_as :low
def perform(message)
return if message.account.locale != 'en' || !valid_incoming_message?(message)
save_message_sentiment(message)
rescue StandardError => e
Rails.logger.error("Sentiment Analysis Error for message #{message.id}: #{e}")
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
end
def save_message_sentiment(message)
# We are truncating the data here to avoind the OnnxRuntime::Error
# Indices element out of data bounds, idx=512 must be within the inclusive range [-512,511]
# While gathering the maningfull node the Array/tensor index is going out of bound
text = message.content&.truncate(2900)
return if model.blank?
sentiment = model.predict(text)
message.sentiment = sentiment.merge(value: label_val(sentiment))
message.save!
end
# Model initializes OnnxRuntime::Model, with given file for inference session and to create the tensor
def model
model_file = save_and_open_sentiment_file
return if File.empty?(model_file)
Informers::SentimentAnalysis.new(model_file)
end
def label_val(sentiment)
sentiment[:label] == 'positive' ? 1 : -1
end
def valid_incoming_message?(message)
message.incoming? && message.content.present? && !message.private?
end
# returns the sentiment file from vendor folder else download it to the path from AWS-S3
def save_and_open_sentiment_file
model_path = ENV.fetch('SENTIMENT_FILE_PATH', nil)
sentiment_file = Rails.root.join('vendor/db/sentiment-analysis.onnx')
return sentiment_file if File.exist?(sentiment_file)
source_file = Down.download(model_path) # Download file from AWS-S3
File.rename(source_file, sentiment_file) # Change the file path
sentiment_file
end
end
@@ -1,5 +0,0 @@
module Enterprise::Message
def update_message_sentiments
::Enterprise::SentimentAnalysisJob.perform_later(self) if ENV.fetch('SENTIMENT_FILE_PATH', nil).present?
end
end
@@ -1,45 +0,0 @@
module Enterprise::SentimentAnalysisHelper
extend ActiveSupport::Concern
included do
def opening_sentiments
records = incoming_messages.first(average_message_count)
average_sentiment(records)
end
def closing_sentiments
return unless resolved?
records = incoming_messages.last(average_message_count)
average_sentiment(records)
end
def average_sentiment(records)
{
label: average_sentiment_label(records),
score: average_sentiment_score(records)
}
end
private
def average_sentiment_label(records)
value = records.pluck(:sentiment).sum { |a| a['value'].to_i }
value.negative? ? 'negative' : 'positive'
end
def average_sentiment_score(records)
total = records.pluck(:sentiment).sum { |a| a['score'].to_f }
total / average_message_count
end
def average_message_count
# incoming_messages.count >= 10 ? 5 : ((incoming_messages.count / 2) - 1)
5
end
def incoming_messages
messages.incoming.where(private: false)
end
end
end
+4
View File
@@ -21,6 +21,10 @@ module ChatwootApp
@custom ||= root.join('custom').exist?
end
def self.help_center_root
ENV.fetch('HELPCENTER_URL', nil) || ENV.fetch('FRONTEND_URL', nil)
end
def self.extensions
if custom?
%w[enterprise custom]
+34 -25
View File
@@ -1,5 +1,6 @@
class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
YOUTUBE_REGEX = %r{https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)([^&/]+)}
LOOM_REGEX = %r{https?://(?:www\.)?loom\.com/share/([^&/]+)}
VIMEO_REGEX = %r{https?://(?:www\.)?vimeo\.com/(\d+)}
MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
@@ -41,23 +42,19 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
def render_embedded_content(node)
link_url = node.url
embedding_methods = {
YOUTUBE_REGEX => :make_youtube_embed,
VIMEO_REGEX => :make_vimeo_embed,
MP4_REGEX => :make_video_embed,
LOOM_REGEX => :make_loom_embed
}
youtube_match = link_url.match(YOUTUBE_REGEX)
if youtube_match
out(make_youtube_embed(youtube_match))
return true
end
vimeo_match = link_url.match(VIMEO_REGEX)
if vimeo_match
out(make_vimeo_embed(vimeo_match))
return true
end
mp4_match = link_url.match(MP4_REGEX)
if mp4_match
out(make_video_embed(link_url))
return true
embedding_methods.each do |regex, method|
match = link_url.match(regex)
if match
out(send(method, match))
return true
end
end
false
@@ -76,28 +73,40 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
def make_youtube_embed(youtube_match)
video_id = youtube_match[1]
%(
<iframe
width="560"
height="315"
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://www.youtube.com/embed/#{video_id}"
frameborder="0"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
allowfullscreen></iframe>
</div>
)
end
def make_loom_embed(loom_match)
video_id = loom_match[1]
%(
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://www.loom.com/embed/#{video_id}"
frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
</div>
)
end
def make_vimeo_embed(vimeo_match)
video_id = vimeo_match[1]
%(
<iframe
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://player.vimeo.com/video/#{video_id}"
width="640"
height="360"
frameborder="0"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
></iframe>
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
</div>
)
end
+4
View File
@@ -4,4 +4,8 @@ module Limits
URL_LENGTH_LIMIT = 2048 # https://stackoverflow.com/questions/417142
OUT_OF_OFFICE_MESSAGE_MAX_LENGTH = 10_000
GREETING_MESSAGE_MAX_LENGTH = 10_000
def self.conversation_message_per_minute_limit
ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i
end
end
+3 -3
View File
@@ -22,7 +22,7 @@
"size-limit": [
{
"path": "public/packs/js/widget-*.js",
"limit": "281 KB"
"limit": "300 KB"
},
{
"path": "public/packs/js/sdk.js",
@@ -35,6 +35,7 @@
"@chatwoot/utils": "^0.0.23",
"@hcaptcha/vue-hcaptcha": "^0.3.2",
"@june-so/analytics-next": "^2.0.0",
"@linear/sdk": "^20.0.0",
"@radix-ui/colors": "^1.0.1",
"@rails/actioncable": "6.1.3",
"@rails/ujs": "^7.0.3-1",
@@ -61,8 +62,6 @@
"ionicons": "~2.0.1",
"js-cookie": "^3.0.5",
"libphonenumber-js": "^1.10.24",
"logrocket": "^3.0.1",
"logrocket-vuex": "^0.0.3",
"markdown-it": "^13.0.2",
"markdown-it-link-attributes": "^4.0.1",
"md5": "^2.3.0",
@@ -72,6 +71,7 @@
"postcss-loader": "^4.2.0",
"semver": "7.5.3",
"tailwindcss": "^3.3.2",
"tinykeys": "^2.1.0",
"turbolinks": "^5.2.0",
"url-loader": "^2.0.0",
"urlpattern-polyfill": "^6.0.2",
@@ -1,12 +1,15 @@
require 'rails_helper'
RSpec.describe 'Public Portals API', type: :request do
RSpec.describe Public::Api::V1::PortalsController, type: :request do
let!(:account) { create(:account) }
let!(:agent) { create(:user, account: account, role: :agent) }
let!(:portal) { create(:portal, slug: 'test-portal', account_id: account.id, custom_domain: 'www.example.com') }
before do
create(:portal, slug: 'test-portal-1', account_id: account.id)
create(:portal, slug: 'test-portal-2', account_id: account.id)
create_list(:article, 3, account: account, author: agent, portal: portal, status: :published)
create_list(:article, 2, account: account, author: agent, portal: portal, status: :draft)
end
describe 'GET /public/api/v1/portals/{portal_slug}' do
@@ -28,4 +31,28 @@ RSpec.describe 'Public Portals API', type: :request do
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
end
end
describe 'GET /public/api/v1/portals/{portal_slug}/sitemap' do
context 'when custom_domain is present' do
it 'gets a valid sitemap' do
get "/hc/#{portal.slug}/sitemap.xml"
expect(response).to have_http_status(:success)
expect(response.body).to match(/<sitemap/)
expect(Nokogiri::XML(response.body).errors).to be_empty
end
it 'has valid sitemap links' do
get "/hc/#{portal.slug}/sitemap.xml"
expect(response).to have_http_status(:success)
parsed_xml = Nokogiri::XML(response.body)
links = parsed_xml.css('loc')
links.each do |link|
expect(link.text).to match(%r{https://www\.example\.com/hc/test-portal/articles/\d+})
end
expect(links.length).to eq 3
end
end
end
end
@@ -1,117 +0,0 @@
require 'rails_helper'
RSpec.describe Enterprise::SentimentAnalysisJob do
context 'when account locale set to english language' do
let(:account) { create(:account, locale: 'en') }
let(:message) { build(:message, content_type: nil, account: account) }
context 'when update the message sentiments' do
let(:model_path) { Rails.root.join('vendor/db/sentiment-analysis.onnx') }
let(:model) { double }
before do
allow(Informers::SentimentAnalysis).to receive(:new).with(model_path).and_return(model)
allow(model).to receive(:predict).and_return({ label: 'positive', score: '0.6' })
end
it 'with incoming message' do
with_modified_env SENTIMENT_FILE_PATH: 'sentiment-analysis.onnx' do
message.update(message_type: :incoming)
described_class.perform_now(message)
expect(message.sentiment).not_to be_empty
end
end
it 'update sentiment label for positive message' do
with_modified_env SENTIMENT_FILE_PATH: 'sentiment-analysis.onnx' do
message.update(message_type: :incoming, content: 'I like your product')
described_class.perform_now(message)
expect(message.sentiment).not_to be_empty
expect(message.sentiment['label']).to eq('positive')
expect(message.sentiment['value']).to eq(1)
end
end
it 'update sentiment label for negative message' do
with_modified_env SENTIMENT_FILE_PATH: 'sentiment-analysis.onnx' do
message.update(message_type: :incoming, content: 'I did not like your product')
allow(model).to receive(:predict).and_return({ label: 'negative', score: '0.6' })
described_class.perform_now(message)
expect(message.sentiment).not_to be_empty
expect(message.sentiment['label']).to eq('negative')
expect(message.sentiment['value']).to eq(-1)
end
end
end
context 'with download sentiment files' do
let(:model_path) { nil }
let(:model) { double }
before do
allow(Informers::SentimentAnalysis).to receive(:new).with(model_path).and_return(model)
allow(model).to receive(:predict).and_return({ label: 'positive', score: '0.6' })
end
it 'fetch saved file in the server' do
with_modified_env SENTIMENT_FILE_PATH: 'sentiment-analysis.onnx' do
message.update(message_type: :incoming, content: 'I did not like your product')
described_class.new(message).save_and_open_sentiment_file
sentiment_file = Rails.root.join('vendor/db/sentiment-analysis.onnx')
expect(File).to exist(sentiment_file)
end
end
it 'fetch file from the storage' do
with_modified_env SENTIMENT_FILE_PATH: 'sentiment-analysis.onnx' do
message.update(message_type: :incoming, content: 'I did not like your product')
allow(File).to receive(:exist?).and_return(false)
allow(Down).to receive(:download).and_return('./sentiment-analysis.onnx')
allow(File).to receive(:rename).and_return(100)
described_class.new(message).save_and_open_sentiment_file
sentiment_file = Rails.root.join('vendor/db/sentiment-analysis.onnx')
expect(sentiment_file).to be_present
end
end
end
context 'when does not update the message sentiments' do
it 'with outgoing message' do
message.update(message_type: :outgoing)
described_class.perform_now(message)
expect(message.sentiment).to be_empty
end
it 'with private message' do
message.update(private: true)
described_class.perform_now(message)
expect(message.sentiment).to be_empty
end
end
end
context 'when account locale is not set to english language' do
let(:account) { create(:account, locale: 'es') }
let(:message) { build(:message, content_type: nil, account: account) }
it 'does not update the message sentiments' do
described_class.perform_now(message)
expect(message.sentiment).to be_empty
end
end
end
@@ -36,36 +36,6 @@ RSpec.describe Conversation, type: :model do
# end
end
describe 'conversation sentiments' do
include ActiveJob::TestHelper
let(:conversation) { create(:conversation, additional_attributes: { referer: 'https://www.chatwoot.com/' }) }
before do
10.times do
message = create(:message, conversation_id: conversation.id, account_id: conversation.account_id, message_type: 'incoming')
message.update(sentiment: { 'label': 'positive', score: '0.4' })
end
end
it 'returns opening sentiments' do
sentiments = conversation.opening_sentiments
expect(sentiments[:label]).to eq('positive')
end
it 'returns closing sentiments if conversation is not resolved' do
sentiments = conversation.closing_sentiments
expect(sentiments).to be_nil
end
it 'returns closing sentiments if it is resolved' do
conversation.resolved!
sentiments = conversation.closing_sentiments
expect(sentiments[:label]).to eq('positive')
end
end
describe 'sla_policy' do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
-20
View File
@@ -1,20 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/liquidable_shared.rb'
RSpec.describe Message do
context 'with sentiment analysis' do
let(:message) { build(:message, message_type: :incoming, content_type: nil, account: create(:account)) }
it 'calls SentimentAnalysisJob' do
with_modified_env SENTIMENT_FILE_PATH: 'sentiment-analysis.onnx' do
allow(Enterprise::SentimentAnalysisJob).to receive(:perform_later).and_return(:perform_later).with(message)
message.save!
expect(Enterprise::SentimentAnalysisJob).to have_received(:perform_later)
end
end
end
end
@@ -41,18 +41,27 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
context 'when the channel is regular imap' do
it 'calls the imap fetch service' do
fetch_service = double
allow(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel).and_return(fetch_service)
allow(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel, interval: 1).and_return(fetch_service)
allow(fetch_service).to receive(:perform).and_return([])
described_class.perform_now(imap_email_channel)
expect(fetch_service).to have_received(:perform)
end
it 'calls the imap fetch service with the correct interval' do
fetch_service = double
allow(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel, interval: 4).and_return(fetch_service)
allow(fetch_service).to receive(:perform).and_return([])
described_class.perform_now(imap_email_channel, 4)
expect(fetch_service).to have_received(:perform)
end
end
context 'when the channel is Microsoft' do
it 'calls the Microsoft fetch service' do
fetch_service = double
allow(Imap::MicrosoftFetchEmailService).to receive(:new).with(channel: microsoft_imap_email_channel).and_return(fetch_service)
allow(Imap::MicrosoftFetchEmailService).to receive(:new).with(channel: microsoft_imap_email_channel, interval: 1).and_return(fetch_service)
allow(fetch_service).to receive(:perform).and_return([])
described_class.perform_now(microsoft_imap_email_channel)
@@ -62,7 +71,7 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
context 'when IMAP connection errors out' do
it 'mark the connection for authorization required' do
allow(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel).and_raise(Errno::ECONNREFUSED)
allow(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel, interval: 1).and_raise(Errno::ECONNREFUSED)
allow(Redis::Alfred).to receive(:incr)
expect(Redis::Alfred).to receive(:incr).with("AUTHORIZATION_ERROR_COUNT:channel_email:#{imap_email_channel.id}")
@@ -80,14 +89,14 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
allow(Imap::ImapMailbox).to receive(:new).and_return(mailbox)
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
allow(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel).and_return(fetch_service)
allow(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel, interval: 1).and_return(fetch_service)
allow(fetch_service).to receive(:perform).and_return([inbound_mail])
end
it 'calls the mailbox to create emails' do
allow(mailbox).to receive(:process)
expect(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel).and_return(fetch_service)
expect(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel, interval: 1).and_return(fetch_service)
expect(fetch_service).to receive(:perform).and_return([inbound_mail])
expect(mailbox).to receive(:process).with(inbound_mail, imap_email_channel)
+14
View File
@@ -68,6 +68,20 @@ describe CustomMarkdownRenderer do
end
end
context 'when link is a Loom URL' do
let(:loom_url) { 'https://www.loom.com/share/VIDEO_ID' }
it 'renders an iframe with Loom embed code' do
output = render_markdown_link(loom_url)
expect(output).to include(`
<iframe
width="640"
height="360"
src="https://www.loom.com/embed/VIDEO_ID"
`)
end
end
context 'when link is a Vimeo URL' do
let(:vimeo_url) { 'https://vimeo.com/1234567' }
+3 -1
View File
@@ -142,7 +142,9 @@ describe ActionCableListener do
'notification.deleted',
{
account_id: notification.account_id,
notification: notification.push_event_data,
notification: {
id: notification.id
},
unread_count: 1,
count: 1
}
+12 -1
View File
@@ -11,7 +11,7 @@ RSpec.describe Message do
end
describe 'length validations' do
let(:message) { create(:message) }
let!(:message) { create(:message) }
context 'when it validates name length' do
it 'valid when within limit' do
@@ -27,6 +27,17 @@ RSpec.describe Message do
expect(message.errors[:processed_message_content]).to include('is too long (maximum is 150000 characters)')
expect(message.errors[:content]).to include('is too long (maximum is 150000 characters)')
end
it 'adds error in case of message flooding' do
with_modified_env 'CONVERSATION_MESSAGE_PER_MINUTE_LIMIT': '2' do
conversation = message.conversation
create(:message, conversation: conversation)
conv_new_message = build(:message, conversation: message.conversation)
expect(conv_new_message.valid?).to be false
expect(conv_new_message.errors[:base]).to eq(['Too many messages'])
end
end
end
end
@@ -50,5 +50,31 @@ RSpec.describe Imap::MicrosoftFetchEmailService do
end
end
end
context 'when the interval is passed during an IMAP Sync' do
it 'fetches the emails based on the interval specified in the job' do
travel_to '26.10.2020 10:00'.to_datetime do
email_object = create_inbound_email_from_fixture('only_text.eml')
email_header = Net::IMAP::FetchData.new(1, 'BODY[HEADER]' => eml_content_with_message_id)
imap_fetch_mail = Net::IMAP::FetchData.new(1, 'RFC822' => eml_content_with_message_id)
allow(imap).to receive(:search).with(%w[SINCE 18-Oct-2020]).and_return([1])
allow(imap).to receive(:fetch).with([1], 'BODY.PEEK[HEADER]').and_return([email_header])
allow(imap).to receive(:fetch).with(1, 'RFC822').and_return([imap_fetch_mail])
allow(imap).to receive(:logout)
result = described_class.new(channel: microsoft_channel, interval: 8).perform
expect(refresh_token_service).to have_received(:access_token)
expect(result.length).to eq 1
expect(result[0].message_id).to eq email_object.message_id
expect(imap).to have_received(:search).with(%w[SINCE 18-Oct-2020])
expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]')
expect(imap).to have_received(:fetch).with(1, 'RFC822')
expect(logger).to have_received(:info).with("[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{microsoft_channel.email}, found 1.")
end
end
end
end
end
+3 -102
View File
@@ -1,17 +1,5 @@
const {
blue,
blueDark,
green,
greenDark,
yellow,
yellowDark,
slate,
slateDark,
red,
redDark,
violet,
violetDark,
} = require('@radix-ui/colors');
const { slateDark } = require('@radix-ui/colors');
import { colors } from './theme/colors';
const defaultTheme = require('tailwindcss/defaultTheme');
module.exports = {
darkMode: 'class',
@@ -41,94 +29,7 @@ module.exports = {
'modal-backdrop-light': 'rgba(0, 0, 0, 0.4)',
'modal-backdrop-dark': 'rgba(0, 0, 0, 0.6)',
current: 'currentColor',
woot: {
25: blue.blue2,
50: blue.blue3,
75: blue.blue4,
100: blue.blue5,
200: blue.blue7,
300: blue.blue8,
400: blueDark.blue11,
500: blueDark.blue10,
600: blueDark.blue9,
700: blueDark.blue8,
800: blueDark.blue6,
900: blueDark.blue2,
},
green: {
50: greenDark.green12,
100: green.green6,
200: green.green7,
300: green.green8,
400: greenDark.green10,
500: greenDark.green9,
600: green.green10,
700: green.green11,
800: greenDark.green7,
900: greenDark.green6,
},
yellow: {
50: yellow.yellow2,
100: yellow.yellow3,
200: yellow.yellow5,
300: yellowDark.yellow10,
400: yellowDark.yellow9,
500: yellowDark.yellow11,
600: yellow.yellow8,
700: yellowDark.yellow7,
800: yellowDark.yellow2,
900: yellowDark.yellow1,
},
slate: {
25: slate.slate2,
50: slate.slate3,
75: slate.slate4,
100: slate.slate5,
200: slate.slate7,
300: slate.slate8,
400: slateDark.slate11,
500: slateDark.slate10,
600: slate.slate11,
700: slateDark.slate8,
800: slateDark.slate4,
900: slateDark.slate1,
},
black: {
50: slate.slate2,
100: slateDark.slate12,
200: slate.slate7,
300: slate.slate8,
400: slateDark.slate11,
500: slate.slate9,
600: slateDark.slate9,
700: slateDark.slate8,
800: slateDark.slate7,
900: slateDark.slate2,
},
red: {
50: redDark.red12,
100: red.red6,
200: red.red8,
300: redDark.red11,
400: redDark.red10,
500: red.red9,
600: red.red10,
700: red.red11,
800: redDark.red8,
900: red.red12,
},
violet: {
50: violet.violet1,
100: violetDark.violet12,
200: violet.violet6,
300: violet.violet8,
400: violet.violet11,
500: violet.violet9,
600: violetDark.violet8,
700: violetDark.violet7,
800: violetDark.violet6,
900: violet.violet12,
},
...colors,
body: slateDark.slate7,
},
keyframes: {
+285
View File
@@ -0,0 +1,285 @@
const {
blue,
blueDark,
green,
greenDark,
yellow,
yellowDark,
slate,
slateDark,
red,
redDark,
violet,
violetDark,
} = require('@radix-ui/colors');
export const colors = {
woot: {
25: blue.blue2,
50: blue.blue3,
75: blue.blue4,
100: blue.blue5,
200: blue.blue7,
300: blue.blue8,
400: blueDark.blue11,
500: blueDark.blue10,
600: blueDark.blue9,
700: blueDark.blue8,
800: blueDark.blue6,
900: blueDark.blue2,
},
green: {
50: greenDark.green12,
100: green.green6,
200: green.green7,
300: green.green8,
400: greenDark.green10,
500: greenDark.green9,
600: green.green10,
700: green.green11,
800: greenDark.green7,
900: greenDark.green6,
},
yellow: {
50: yellow.yellow2,
100: yellow.yellow3,
200: yellow.yellow5,
300: yellowDark.yellow10,
400: yellowDark.yellow9,
500: yellowDark.yellow11,
600: yellow.yellow8,
700: yellowDark.yellow7,
800: yellowDark.yellow2,
900: yellowDark.yellow1,
},
slate: {
25: slate.slate2,
50: slate.slate3,
75: slate.slate4,
100: slate.slate5,
200: slate.slate7,
300: slate.slate8,
400: slateDark.slate11,
500: slateDark.slate10,
600: slate.slate11,
700: slateDark.slate8,
800: slateDark.slate4,
900: slateDark.slate1,
},
black: {
50: slate.slate2,
100: slateDark.slate12,
200: slate.slate7,
300: slate.slate8,
400: slateDark.slate11,
500: slate.slate9,
600: slateDark.slate9,
700: slateDark.slate8,
800: slateDark.slate7,
900: slateDark.slate2,
},
red: {
50: redDark.red12,
100: red.red6,
200: red.red8,
300: redDark.red11,
400: redDark.red10,
500: red.red9,
600: red.red10,
700: red.red11,
800: redDark.red8,
900: red.red12,
},
violet: {
50: violet.violet1,
100: violetDark.violet12,
200: violet.violet6,
300: violet.violet8,
400: violet.violet11,
500: violet.violet9,
600: violetDark.violet8,
700: violetDark.violet7,
800: violetDark.violet6,
900: violet.violet12,
},
primary: {
25: 'rgb(var(--color-primary-25) / <alpha-value>)',
50: 'rgb(var(--color-primary-50) / <alpha-value>)',
75: 'rgb(var(--color-primary-75) / <alpha-value>)',
100: 'rgb(var(--color-primary-100) / <alpha-value>)',
200: 'rgb(var(--color-primary-200) / <alpha-value>)',
300: 'rgb(var(--color-primary-300) / <alpha-value>)',
400: 'rgb(var(--color-primary-400) / <alpha-value>)',
500: 'rgb(var(--color-primary-500) / <alpha-value>)',
600: 'rgb(var(--color-primary-600) / <alpha-value>)',
700: 'rgb(var(--color-primary-700) / <alpha-value>)',
800: 'rgb(var(--color-primary-800) / <alpha-value>)',
900: 'rgb(var(--color-primary-900) / <alpha-value>)',
},
ash: {
25: 'rgb(var(--color-ash-25) / <alpha-value>)',
50: 'rgb(var(--color-ash-50) / <alpha-value>)',
75: 'rgb(var(--color-ash-75) / <alpha-value>)',
100: 'rgb(var(--color-ash-100) / <alpha-value>)',
200: 'rgb(var(--color-ash-200) / <alpha-value>)',
300: 'rgb(var(--color-ash-300) / <alpha-value>)',
400: 'rgb(var(--color-ash-400) / <alpha-value>)',
500: 'rgb(var(--color-ash-500) / <alpha-value>)',
600: 'rgb(var(--color-ash-600) / <alpha-value>)',
700: 'rgb(var(--color-ash-700) / <alpha-value>)',
800: 'rgb(var(--color-ash-800) / <alpha-value>)',
900: 'rgb(var(--color-ash-900) / <alpha-value>)',
},
teal: {
25: 'rgb(var(--color-teal-25) / <alpha-value>)',
50: 'rgb(var(--color-teal-50) / <alpha-value>)',
100: 'rgb(var(--color-teal-100) / <alpha-value>)',
200: 'rgb(var(--color-teal-200) / <alpha-value>)',
300: 'rgb(var(--color-teal-300) / <alpha-value>)',
400: 'rgb(var(--color-teal-400) / <alpha-value>)',
500: 'rgb(var(--color-teal-500) / <alpha-value>)',
600: 'rgb(var(--color-teal-600) / <alpha-value>)',
700: 'rgb(var(--color-teal-700) / <alpha-value>)',
800: 'rgb(var(--color-teal-800) / <alpha-value>)',
900: 'rgb(var(--color-teal-900) / <alpha-value>)',
},
amber: {
25: 'rgb(var(--color-amber-25) / <alpha-value>)',
50: 'rgb(var(--color-amber-50) / <alpha-value>)',
75: 'rgb(var(--color-amber-75) / <alpha-value>)',
100: 'rgb(var(--color-amber-100) / <alpha-value>)',
200: 'rgb(var(--color-amber-200) / <alpha-value>)',
300: 'rgb(var(--color-amber-300) / <alpha-value>)',
400: 'rgb(var(--color-amber-400) / <alpha-value>)',
500: 'rgb(var(--color-amber-500) / <alpha-value>)',
600: 'rgb(var(--color-amber-600) / <alpha-value>)',
700: 'rgb(var(--color-amber-700) / <alpha-value>)',
800: 'rgb(var(--color-amber-800) / <alpha-value>)',
900: 'rgb(var(--color-amber-900) / <alpha-value>)',
},
ruby: {
25: 'rgb(var(--color-ruby-25) / <alpha-value>)',
50: 'rgb(var(--color-ruby-50) / <alpha-value>)',
75: 'rgb(var(--color-ruby-75) / <alpha-value>)',
100: 'rgb(var(--color-ruby-100) / <alpha-value>)',
200: 'rgb(var(--color-ruby-200) / <alpha-value>)',
300: 'rgb(var(--color-ruby-300) / <alpha-value>)',
400: 'rgb(var(--color-ruby-400) / <alpha-value>)',
500: 'rgb(var(--color-ruby-500) / <alpha-value>)',
600: 'rgb(var(--color-ruby-600) / <alpha-value>)',
700: 'rgb(var(--color-ruby-700) / <alpha-value>)',
800: 'rgb(var(--color-ruby-800) / <alpha-value>)',
900: 'rgb(var(--color-ruby-900) / <alpha-value>)',
},
grass: {
25: 'rgb(var(--color-green-25) / <alpha-value>)',
50: 'rgb(var(--color-green-50) / <alpha-value>)',
75: 'rgb(var(--color-green-75) / <alpha-value>)',
100: 'rgb(var(--color-green-100) / <alpha-value>)',
200: 'rgb(var(--color-green-200) / <alpha-value>)',
300: 'rgb(var(--color-green-300) / <alpha-value>)',
400: 'rgb(var(--color-green-400) / <alpha-value>)',
500: 'rgb(var(--color-green-500) / <alpha-value>)',
600: 'rgb(var(--color-green-600) / <alpha-value>)',
700: 'rgb(var(--color-green-700) / <alpha-value>)',
800: 'rgb(var(--color-green-800) / <alpha-value>)',
900: 'rgb(var(--color-green-900) / <alpha-value>)',
},
mint: {
25: 'rgb(var(--color-mint-25) / <alpha-value>)',
50: 'rgb(var(--color-mint-50) / <alpha-value>)',
75: 'rgb(var(--color-mint-75) / <alpha-value>)',
100: 'rgb(var(--color-mint-100) / <alpha-value>)',
200: 'rgb(var(--color-mint-200) / <alpha-value>)',
300: 'rgb(var(--color-mint-300) / <alpha-value>)',
400: 'rgb(var(--color-mint-400) / <alpha-value>)',
500: 'rgb(var(--color-mint-500) / <alpha-value>)',
600: 'rgb(var(--color-mint-600) / <alpha-value>)',
700: 'rgb(var(--color-mint-700) / <alpha-value>)',
800: 'rgb(var(--color-mint-800) / <alpha-value>)',
900: 'rgb(var(--color-mint-900) / <alpha-value>)',
},
sky: {
25: 'rgb(var(--color-sky-25) / <alpha-value>)',
50: 'rgb(var(--color-sky-50) / <alpha-value>)',
75: 'rgb(var(--color-sky-75) / <alpha-value>)',
100: 'rgb(var(--color-sky-100) / <alpha-value>)',
200: 'rgb(var(--color-sky-200) / <alpha-value>)',
300: 'rgb(var(--color-sky-300) / <alpha-value>)',
400: 'rgb(var(--color-sky-400) / <alpha-value>)',
500: 'rgb(var(--color-sky-500) / <alpha-value>)',
600: 'rgb(var(--color-sky-600) / <alpha-value>)',
700: 'rgb(var(--color-sky-700) / <alpha-value>)',
800: 'rgb(var(--color-sky-800) / <alpha-value>)',
900: 'rgb(var(--color-sky-900) / <alpha-value>)',
},
indigo: {
25: 'rgb(var(--color-indigo-25) / <alpha-value>)',
50: 'rgb(var(--color-indigo-50) / <alpha-value>)',
75: 'rgb(var(--color-indigo-75) / <alpha-value>)',
100: 'rgb(var(--color-indigo-100) / <alpha-value>)',
200: 'rgb(var(--color-indigo-200) / <alpha-value>)',
300: 'rgb(var(--color-indigo-300) / <alpha-value>)',
400: 'rgb(var(--color-indigo-400) / <alpha-value>)',
500: 'rgb(var(--color-indigo-500) / <alpha-value>)',
600: 'rgb(var(--color-indigo-600) / <alpha-value>)',
700: 'rgb(var(--color-indigo-700) / <alpha-value>)',
800: 'rgb(var(--color-indigo-800) / <alpha-value>)',
900: 'rgb(var(--color-indigo-900) / <alpha-value>)',
},
iris: {
25: 'rgb(var(--color-iris-25) / <alpha-value>)',
50: 'rgb(var(--color-iris-50) / <alpha-value>)',
75: 'rgb(var(--color-iris-75) / <alpha-value>)',
100: 'rgb(var(--color-iris-100) / <alpha-value>)',
200: 'rgb(var(--color-iris-200) / <alpha-value>)',
300: 'rgb(var(--color-iris-300) / <alpha-value>)',
400: 'rgb(var(--color-iris-400) / <alpha-value>)',
500: 'rgb(var(--color-iris-500) / <alpha-value>)',
600: 'rgb(var(--color-iris-600) / <alpha-value>)',
700: 'rgb(var(--color-iris-700) / <alpha-value>)',
800: 'rgb(var(--color-iris-800) / <alpha-value>)',
900: 'rgb(var(--color-iris-900) / <alpha-value>)',
},
purple: {
25: 'rgb(var(--color-violet-25) / <alpha-value>)',
50: 'rgb(var(--color-violet-50) / <alpha-value>)',
75: 'rgb(var(--color-violet-75) / <alpha-value>)',
100: 'rgb(var(--color-violet-100) / <alpha-value>)',
200: 'rgb(var(--color-violet-200) / <alpha-value>)',
300: 'rgb(var(--color-violet-300) / <alpha-value>)',
400: 'rgb(var(--color-violet-400) / <alpha-value>)',
500: 'rgb(var(--color-violet-500) / <alpha-value>)',
600: 'rgb(var(--color-violet-600) / <alpha-value>)',
700: 'rgb(var(--color-violet-700) / <alpha-value>)',
800: 'rgb(var(--color-violet-800) / <alpha-value>)',
900: 'rgb(var(--color-violet-900) / <alpha-value>)',
},
pink: {
25: 'rgb(var(--color-pink-25) / <alpha-value>)',
50: 'rgb(var(--color-pink-50) / <alpha-value>)',
75: 'rgb(var(--color-pink-75) / <alpha-value>)',
100: 'rgb(var(--color-pink-100) / <alpha-value>)',
200: 'rgb(var(--color-pink-200) / <alpha-value>)',
300: 'rgb(var(--color-pink-300) / <alpha-value>)',
400: 'rgb(var(--color-pink-400) / <alpha-value>)',
500: 'rgb(var(--color-pink-500) / <alpha-value>)',
600: 'rgb(var(--color-pink-600) / <alpha-value>)',
700: 'rgb(var(--color-pink-700) / <alpha-value>)',
800: 'rgb(var(--color-pink-800) / <alpha-value>)',
900: 'rgb(var(--color-pink-900) / <alpha-value>)',
},
orange: {
25: 'rgb(var(--color-orange-25) / <alpha-value>)',
50: 'rgb(var(--color-orange-50) / <alpha-value>)',
75: 'rgb(var(--color-orange-75) / <alpha-value>)',
100: 'rgb(var(--color-orange-100) / <alpha-value>)',
200: 'rgb(var(--color-orange-200) / <alpha-value>)',
300: 'rgb(var(--color-orange-300) / <alpha-value>)',
400: 'rgb(var(--color-orange-400) / <alpha-value>)',
500: 'rgb(var(--color-orange-500) / <alpha-value>)',
600: 'rgb(var(--color-orange-600) / <alpha-value>)',
700: 'rgb(var(--color-orange-700) / <alpha-value>)',
800: 'rgb(var(--color-orange-800) / <alpha-value>)',
900: 'rgb(var(--color-orange-900) / <alpha-value>)',
},
};
+24 -10
View File
@@ -3501,6 +3501,11 @@
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.50.0.tgz#9e93b850f0f3fa35f5fa59adfd03adae8488e484"
integrity sha512-NCC3zz2+nvYd+Ckfh87rA47zfu2QsQpvc6k1yzTk+b9KzRj0wkGa8LSoGOXN6Zv4lRf/EIoZ80biDh9HOI+RNQ==
"@graphql-typed-document-node/core@^3.1.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861"
integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==
"@hcaptcha/vue-hcaptcha@^0.3.2":
version "0.3.2"
resolved "https://registry.yarnpkg.com/@hcaptcha/vue-hcaptcha/-/vue-hcaptcha-0.3.2.tgz#0f77d6fc19bc47eadb6b2181eee5fc132441a942"
@@ -3872,6 +3877,15 @@
typescript "^4.9.5"
unfetch "^4.1.0"
"@linear/sdk@^20.0.0":
version "20.0.0"
resolved "https://registry.yarnpkg.com/@linear/sdk/-/sdk-20.0.0.tgz#a15afb3abf3cd2d48375f42d39d99d127dc2aa17"
integrity sha512-LljgBsSRzU++Jl7eIJhL8Z8Btso+rFN4lWeV3fg0RVfONlW0PEPLkaKJDBdA2NQ2TzpMTCJ6xcDwvIvDSQo6Ww==
dependencies:
"@graphql-typed-document-node/core" "^3.1.0"
graphql "^15.4.0"
isomorphic-unfetch "^3.1.0"
"@lit-labs/ssr-dom-shim@^1.0.0", "@lit-labs/ssr-dom-shim@^1.1.0":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.1.tgz#64df34e2f12e68e78ac57e571d25ec07fa460ca9"
@@ -11934,6 +11948,11 @@ graphemer@^1.4.0:
resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==
graphql@^15.4.0:
version "15.8.0"
resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.8.0.tgz#33410e96b012fa3bdb1091cc99a94769db212b38"
integrity sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw==
handle-thing@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e"
@@ -14396,16 +14415,6 @@ loglevel@^1.6.8:
resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.1.tgz#005fde2f5e6e47068f935ff28573e125ef72f197"
integrity sha512-Hesni4s5UkWkwCGJMQGAh71PaLUmKFM60dHvq0zi/vDhhrzuk+4GgNbTXJ12YYQJn6ZKBDNIjYcuQGKudvqrIw==
logrocket-vuex@^0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/logrocket-vuex/-/logrocket-vuex-0.0.3.tgz#e11d65d384f0c9fdac72f984436cc038adf3db90"
integrity sha512-Xnl3pBEkc/bbtZ5JEtFwqNjVuLzVEohlBpyQHP47BlO+/6sFmSM2JcNWojysYUo4cp3IWpmWXcezax2+Cq4RsA==
logrocket@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/logrocket/-/logrocket-3.0.1.tgz#c746e8df3d5fee999b152975e51db1908357a6f0"
integrity sha512-jOWG+jEzobKVxGytzZ+4KGm2kiMQMRTHab2uDWQyVZcHfEF38BlCH1yjQVY4LCmuQUwZitP9biMzJZnyUQ0dtQ==
loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
@@ -19625,6 +19634,11 @@ tinycolor2@^1.1.2:
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803"
integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA==
tinykeys@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/tinykeys/-/tinykeys-2.1.0.tgz#1341563e92a7fac9ca90053fddaf2b7553500298"
integrity sha512-/MESnqBD1xItZJn5oGQ4OsNORQgJfPP96XSGoyu4eLpwpL0ifO0SYR5OD76u0YMhMXsqkb0UqvI9+yXTh4xv8Q==
titleize@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/titleize/-/titleize-3.0.0.tgz#71c12eb7fdd2558aa8a44b0be83b8a76694acd53"