Compare 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
82 changed files with 2312 additions and 1523 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
# SENTRY_DSN= # SENTRY_DSN=
## LogRocket
# LOG_ROCKET_PROJECT_ID=xxxxx/some-project
# MICROSOFT CLARITY # MICROSOFT CLARITY
# MS_CLARITY_TOKEN=xxxxxxxxx # MS_CLARITY_TOKEN=xxxxxxxxx
@@ -251,9 +249,6 @@ AZURE_APP_SECRET=
## OpenAI key ## OpenAI key
# OPENAI_API_KEY= # OPENAI_API_KEY=
# Sentiment analysis model file path
SENTIMENT_FILE_PATH=
# Housekeeping/Performance related configurations # Housekeeping/Performance related configurations
# Set to true if you want to remove stale contact inboxes # Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed # 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 # Convert Website HTML to Markdown
gem 'reverse_markdown' gem 'reverse_markdown'
# Sentiment analysis
gem 'informers'
### Gems required only in specific deployment environments ### ### Gems required only in specific deployment environments ###
############################################################## ##############################################################
+14 -26
View File
@@ -151,7 +151,6 @@ GEM
base64 (0.1.1) base64 (0.1.1)
bcrypt (3.1.20) bcrypt (3.1.20)
bindex (0.8.1) bindex (0.8.1)
blingfire (0.1.8)
bootsnap (1.16.0) bootsnap (1.16.0)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (5.4.1) brakeman (5.4.1)
@@ -316,15 +315,15 @@ GEM
google-cloud-translate-v3 (0.6.0) google-cloud-translate-v3 (0.6.0)
gapic-common (>= 0.17.1, < 2.a) gapic-common (>= 0.17.1, < 2.a)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-protobuf (3.25.2) google-protobuf (3.25.3)
google-protobuf (3.25.2-arm64-darwin) google-protobuf (3.25.3-arm64-darwin)
google-protobuf (3.25.2-x86_64-darwin) google-protobuf (3.25.3-x86_64-darwin)
google-protobuf (3.25.2-x86_64-linux) google-protobuf (3.25.3-x86_64-linux)
googleapis-common-protos (1.4.0) googleapis-common-protos (1.4.0)
google-protobuf (~> 3.14) google-protobuf (~> 3.14)
googleapis-common-protos-types (~> 1.2) googleapis-common-protos-types (~> 1.2)
grpc (~> 1.27) grpc (~> 1.27)
googleapis-common-protos-types (1.11.0) googleapis-common-protos-types (1.14.0)
google-protobuf (~> 3.18) google-protobuf (~> 3.18)
googleauth (1.5.2) googleauth (1.5.2)
faraday (>= 0.17.3, < 3.a) faraday (>= 0.17.3, < 3.a)
@@ -335,14 +334,17 @@ GEM
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
groupdate (6.2.1) groupdate (6.2.1)
activesupport (>= 5.2) activesupport (>= 5.2)
grpc (1.54.3) grpc (1.62.0)
google-protobuf (~> 3.21) google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
grpc (1.54.3-x86_64-darwin) grpc (1.62.0-arm64-darwin)
google-protobuf (~> 3.21) google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
grpc (1.54.3-x86_64-linux) grpc (1.62.0-x86_64-darwin)
google-protobuf (~> 3.21) 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) googleapis-common-protos-types (~> 1.0)
haikunator (1.1.1) haikunator (1.1.1)
hairtrigger (1.0.0) hairtrigger (1.0.0)
@@ -371,10 +373,6 @@ GEM
image_processing (1.12.2) image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5) mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3) 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) io-console (0.6.0)
irb (1.7.2) irb (1.7.2)
reline (>= 0.3.6) reline (>= 0.3.6)
@@ -500,7 +498,6 @@ GEM
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.4-x86_64-linux) nokogiri (1.16.4-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
numo-narray (0.9.2.1)
oauth (1.1.0) oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1) oauth-tty (~> 1.0, >= 1.0.1)
snaky_hash (~> 2.0) snaky_hash (~> 2.0)
@@ -529,14 +526,6 @@ GEM
omniauth-rails_csrf_protection (1.0.1) omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2) actionpack (>= 4.2)
omniauth (~> 2.0) 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) openssl (3.1.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
@@ -892,7 +881,6 @@ DEPENDENCIES
hashie hashie
html2text! html2text!
image_processing image_processing
informers
jbuilder jbuilder
json_refs json_refs
json_schemer json_schemer
@@ -6,6 +6,12 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
def show; end 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 private
def portal def portal
+244 -36
View File
@@ -42,19 +42,20 @@
} }
@layer base { @layer base {
// scss-lint:disable PropertySortOrder
:root { :root {
--color-amber-100: 255 243 208;
--color-amber-200: 255 236 183;
--color-amber-25: 254 253 251; --color-amber-25: 254 253 251;
--color-amber-300: 255 224 161;
--color-amber-400: 245 208 140;
--color-amber-50: 255 249 237; --color-amber-50: 255 249 237;
--color-amber-500: 228 187 120; --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-600: 214 163 92;
--color-amber-700: 214 163 92; --color-amber-700: 255 186 26;
--color-amber-800: 255 186 26; --color-amber-800: 145 89 48;
--color-amber-900: 145 89 48; --color-amber-900: 79 52 34;
--color-amber-950: 79 52 34;
--color-ash-100: 235 235 239; --color-ash-100: 235 235 239;
--color-ash-200: 228 228 233; --color-ash-200: 228 228 233;
@@ -69,18 +70,18 @@
--color-ash-800: 96 100 108; --color-ash-800: 96 100 108;
--color-ash-900: 28 32 36; --color-ash-900: 28 32 36;
--color-primary-100: 222 234 255; --color-primary-25: 251 253 255;
--color-primary-200: 206 223 255; --color-primary-50: 245 249 255;
--color-primary-25: 252 253 254; --color-primary-75: 233 243 255;
--color-primary-300: 186 209 255; --color-primary-100: 218 236 255;
--color-primary-400: 163 190 255; --color-primary-200: 201 226 255;
--color-primary-50: 246 249 255; --color-primary-300: 181 213 255;
--color-primary-500: 131 164 250; --color-primary-400: 155 195 252;
--color-primary-600: 69 110 255; --color-primary-500: 117 171 247;
--color-primary-700: 57 95 241; --color-primary-600: 39 129 246;
--color-primary-75: 236 242 255; --color-primary-700: 16 115 233;
--color-primary-800: 53 88 217; --color-primary-800: 8 109 224;
--color-primary-900: 27 44 99; --color-primary-900: 11 50 101;
--color-ruby-100: 255 220 225; --color-ruby-100: 255 220 225;
--color-ruby-200: 255 206 214; --color-ruby-200: 255 206 214;
@@ -107,21 +108,125 @@
--color-teal-75: 236 249 255; --color-teal-75: 236 249 255;
--color-teal-800: 0 133 115; --color-teal-800: 0 133 115;
--color-teal-900: 13 61 56; --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 // scss-lint:disable QualifyingElement
body.dark { 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-100: 57 39 15;
--color-amber-200: 67 46 18; --color-amber-200: 67 46 18;
--color-amber-25: 31 19 0;
--color-amber-300: 83 57 22; --color-amber-300: 83 57 22;
--color-amber-400: 111 77 29; --color-amber-400: 111 77 29;
--color-amber-50: 37 24 4;
--color-amber-500: 169 118 42; --color-amber-500: 169 118 42;
--color-amber-600: 169 118 42; --color-amber-600: 169 118 42;
--color-amber-700: 255 203 71; --color-amber-700: 255 203 71;
--color-amber-800: 255 204 77; --color-amber-800: 255 204 77;
--color-amber-900: 255 231 179; --color-amber-900: 255 231 179;
--color-amber-950: 255 231 179;
--color-ash-100: 46 48 53; --color-ash-100: 46 48 53;
--color-ash-200: 53 55 60; --color-ash-200: 53 55 60;
@@ -136,18 +241,18 @@
--color-ash-800: 173 177 184; --color-ash-800: 173 177 184;
--color-ash-900: 237 238 240; --color-ash-900: 237 238 240;
--color-primary-100: 24 43 109; --color-primary-25: 10 17 28;
--color-primary-200: 31 53 129; --color-primary-50: 15 24 38;
--color-primary-25: 11 16 31; --color-primary-75: 15 39 72;
--color-primary-300: 40 64 146; --color-primary-100: 10 49 99;
--color-primary-400: 48 76 168; --color-primary-200: 18 61 117;
--color-primary-50: 16 23 41; --color-primary-300: 29 74 134;
--color-primary-500: 57 89 196; --color-primary-400: 40 89 156;
--color-primary-600: 69 110 255; --color-primary-500: 48 106 186;
--color-primary-700: 58 96 240; --color-primary-600: 39 129 246;
--color-primary-75: 20 34 81; --color-primary-700: 21 116 231;
--color-primary-800: 140 177 255; --color-primary-800: 126 182 255;
--color-primary-900: 210 225 255; --color-primary-900: 205 227 255;
--color-ruby-100: 78 19 37; --color-ruby-100: 78 19 37;
--color-ruby-200: 94 26 46; --color-ruby-200: 94 26 46;
@@ -174,5 +279,108 @@
--color-teal-75: 13 45 42; --color-teal-75: 13 45 42;
--color-teal-800: 11 216 182; --color-teal-800: 11 216 182;
--color-teal-900: 173 240 221; --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 ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue'; import ConversationItem from './ConversationItem.vue';
import timeMixin from '../mixins/time'; import timeMixin from '../mixins/time';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import conversationMixin from '../mixins/conversations'; import conversationMixin from '../mixins/conversations';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import advancedFilterTypes from './widgets/conversation/advancedFilterItems'; 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 languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries'; import countries from 'shared/constants/countries';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper'; import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
import {
hasPressedAltAndJKey,
hasPressedAltAndKKey,
} from 'shared/helpers/KeyboardHelpers';
import { conversationListPageURL } from '../helper/URLHelper'; import { conversationListPageURL } from '../helper/URLHelper';
import { import {
isOnMentionsView, isOnMentionsView,
@@ -228,7 +223,7 @@ export default {
mixins: [ mixins: [
timeMixin, timeMixin,
conversationMixin, conversationMixin,
eventListenerMixins, keyboardEventListenerMixins,
alertMixin, alertMixin,
filterMixin, filterMixin,
uiSettingsMixin, uiSettingsMixin,
@@ -691,30 +686,40 @@ export default {
lastConversationIndex, lastConversationIndex,
}; };
}, },
handleKeyEvents(e) { handlePreviousConversation() {
if (hasPressedAltAndJKey(e)) { const { allConversations, activeConversationIndex } =
const { allConversations, activeConversationIndex } = this.getKeyboardListenerParams();
this.getKeyboardListenerParams(); if (activeConversationIndex === -1) {
if (activeConversationIndex === -1) { allConversations[0].click();
allConversations[0].click();
}
if (activeConversationIndex >= 1) {
allConversations[activeConversationIndex - 1].click();
}
} }
if (hasPressedAltAndKKey(e)) { if (activeConversationIndex >= 1) {
const { allConversations[activeConversationIndex - 1].click();
allConversations,
activeConversationIndex,
lastConversationIndex,
} = this.getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[lastConversationIndex].click();
} else if (activeConversationIndex < lastConversationIndex) {
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() { resetAndFetchData() {
this.appliedFilter = []; this.appliedFilter = [];
this.resetBulkActions(); this.resetBulkActions();
@@ -91,12 +91,7 @@ import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue'; import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import {
hasPressedAltAndEKey,
hasPressedCommandPlusAltAndEKey,
hasPressedAltAndMKey,
} from 'shared/helpers/KeyboardHelpers';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers'; import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue'; import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@@ -114,7 +109,7 @@ export default {
WootDropdownMenu, WootDropdownMenu,
CustomSnoozeModal, CustomSnoozeModal,
}, },
mixins: [clickaway, alertMixin, eventListenerMixins], mixins: [clickaway, alertMixin, keyboardEventListenerMixins],
props: { conversationId: { type: [String, Number], required: true } }, props: { conversationId: { type: [String, Number], required: true } },
data() { data() {
return { return {
@@ -159,37 +154,52 @@ export default {
bus.$off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation); bus.$off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
}, },
methods: { 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( const allConversations = document.querySelectorAll(
'.conversations-list .conversation' '.conversations-list .conversation'
); );
if (hasPressedAltAndMKey(e)) {
if (this.$refs.arrowDownButton) { const activeConversation = document.querySelector(
this.$refs.arrowDownButton.$el.click(); 'div.conversations-list div.conversation.active'
} );
} const activeConversationIndex = [...allConversations].indexOf(
if (hasPressedAltAndEKey(e)) { activeConversation
const activeConversation = document.querySelector( );
'div.conversations-list div.conversation.active' const lastConversationIndex = allConversations.length - 1;
);
const activeConversationIndex = [...allConversations].indexOf( return {
activeConversation all: allConversations,
); activeIndex: activeConversationIndex,
const lastConversationIndex = allConversations.length - 1; lastIndex: lastConversationIndex,
try { };
await this.toggleStatus(wootConstants.STATUS_TYPE.RESOLVED); },
} catch (error) { async resolveOrToast() {
// error try {
} await this.toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
if (hasPressedCommandPlusAltAndEKey(e)) { } catch (error) {
if (activeConversationIndex < lastConversationIndex) { // error
allConversations[activeConversationIndex + 1].click();
} else if (allConversations.length > 1) {
allConversations[0].click();
document.querySelector('.conversations-list').scrollTop = 0;
}
e.preventDefault();
}
} }
}, },
onCmdSnoozeConversation(snoozeType) { onCmdSnoozeConversation(snoozeType) {
@@ -1,5 +1,5 @@
<template> <template>
<aside class="h-full flex"> <aside class="flex h-full">
<primary-sidebar <primary-sidebar
:logo-source="globalConfig.logoThumbnail" :logo-source="globalConfig.logoThumbnail"
:installation-name="globalConfig.installationName" :installation-name="globalConfig.installationName"
@@ -36,15 +36,7 @@ import alertMixin from 'shared/mixins/alertMixin';
import PrimarySidebar from './sidebarComponents/Primary.vue'; import PrimarySidebar from './sidebarComponents/Primary.vue';
import SecondarySidebar from './sidebarComponents/Secondary.vue'; import SecondarySidebar from './sidebarComponents/Secondary.vue';
import { import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
hasPressedAltAndCKey,
hasPressedAltAndRKey,
hasPressedAltAndSKey,
hasPressedAltAndVKey,
hasPressedCommandAndForwardSlash,
isEscape,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import router from '../../routes'; import router from '../../routes';
export default { export default {
@@ -52,7 +44,7 @@ export default {
PrimarySidebar, PrimarySidebar,
SecondarySidebar, SecondarySidebar,
}, },
mixins: [adminMixin, alertMixin, eventListenerMixins], mixins: [adminMixin, alertMixin, keyboardEventListenerMixins],
props: { props: {
showSecondarySidebar: { showSecondarySidebar: {
type: Boolean, type: Boolean,
@@ -173,30 +165,27 @@ export default {
closeKeyShortcutModal() { closeKeyShortcutModal() {
this.$emit('close-key-shortcut-modal'); this.$emit('close-key-shortcut-modal');
}, },
handleKeyEvents(e) { getKeyboardEvents() {
if (hasPressedCommandAndForwardSlash(e)) { return {
this.toggleKeyShortcutModal(); '$mod+Slash': this.toggleKeyShortcutModal,
} '$mod+Escape': this.closeKeyShortcutModal,
if (isEscape(e)) { 'Alt+KeyC': {
this.closeKeyShortcutModal(); action: () => this.navigateToRoute('home'),
} },
'Alt+KeyV': {
if (hasPressedAltAndCKey(e)) { action: () => this.navigateToRoute('contacts_dashboard'),
if (!this.isCurrentRouteSameAsNavigation('home')) { },
router.push({ name: 'home' }); 'Alt+KeyR': {
} action: () => this.navigateToRoute('account_overview_reports'),
} else if (hasPressedAltAndVKey(e)) { },
if (!this.isCurrentRouteSameAsNavigation('contacts_dashboard')) { 'Alt+KeyS': {
router.push({ name: 'contacts_dashboard' }); action: () => this.navigateToRoute('agent_list'),
} },
} else if (hasPressedAltAndRKey(e)) { };
if (!this.isCurrentRouteSameAsNavigation('settings_account_reports')) { },
router.push({ name: 'settings_account_reports' }); navigateToRoute(routeName) {
} if (!this.isCurrentRouteSameAsNavigation(routeName)) {
} else if (hasPressedAltAndSKey(e)) { router.push({ name: routeName });
if (!this.isCurrentRouteSameAsNavigation('agent_list')) {
router.push({ name: 'agent_list' });
}
} }
}, },
isCurrentRouteSameAsNavigation(routeName) { isCurrentRouteSameAsNavigation(routeName) {
@@ -40,8 +40,7 @@ import AIAssistanceModal from './AIAssistanceModal.vue';
import adminMixin from 'dashboard/mixins/aiMixin'; import adminMixin from 'dashboard/mixins/aiMixin';
import aiMixin from 'dashboard/mixins/isAdmin'; import aiMixin from 'dashboard/mixins/isAdmin';
import { CMD_AI_ASSIST } from 'dashboard/routes/dashboard/commands/commandBarBusEvents'; import { CMD_AI_ASSIST } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue'; import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
@@ -51,7 +50,7 @@ export default {
AICTAModal, AICTAModal,
AIAssistanceCTAButton, AIAssistanceCTAButton,
}, },
mixins: [aiMixin, eventListenerMixins, adminMixin, uiSettingsMixin], mixins: [aiMixin, keyboardEventListenerMixins, adminMixin, uiSettingsMixin],
data: () => ({ data: () => ({
showAIAssistanceModal: false, showAIAssistanceModal: false,
showAICtaModal: false, showAICtaModal: false,
@@ -87,14 +86,17 @@ export default {
}, },
methods: { methods: {
onKeyDownHandler(event) { getKeyboardEvents() {
const keyPattern = buildHotKeys(event); return {
const shouldRevertTheContent = '$mod+KeyZ': {
['meta+z', 'ctrl+z'].includes(keyPattern) && !!this.initialMessage; action: () => {
if (shouldRevertTheContent) { if (this.initialMessage) {
this.$emit('replace-text', this.initialMessage); this.$emit('replace-text', this.initialMessage);
this.initialMessage = ''; this.initialMessage = '';
} }
},
},
};
}, },
hideAIAssistanceModal() { hideAIAssistanceModal() {
this.recordAnalytics('DISMISS_AI_SUGGESTION', { this.recordAnalytics('DISMISS_AI_SUGGESTION', {
@@ -1,17 +1,17 @@
<template> <template>
<div class="preview-item__wrap flex overflow-auto max-h-[12.5rem]"> <div class="flex overflow-auto max-h-[12.5rem]">
<div <div
v-for="(attachment, index) in attachments" v-for="(attachment, index) in nonRecordedAudioAttachments"
:key="attachment.id" :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" 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"> <div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
<img <img
v-if="isTypeImage(attachment.resource)" v-if="isTypeImage(attachment.resource)"
class="image-thumb" class="object-cover w-6 h-6 rounded-sm"
:src="attachment.thumb" :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> </span>
</div> </div>
@@ -23,73 +23,62 @@
</span> </span>
</div> </div>
<div class="w-[30%] justify-center"> <div class="w-[30%] justify-center">
<span <span class="overflow-hidden text-xs text-ellipsis whitespace-nowrap">
class="item overflow-hidden text-xs text-ellipsis whitespace-nowrap"
>
{{ formatFileSize(attachment.resource) }} {{ formatFileSize(attachment.resource) }}
</span> </span>
</div> </div>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<woot-button <woot-button
v-if="!isTypeAudio(attachment.resource)" class="!w-6 !h-6 text-sm rounded-md hover:bg-slate-50 dark:hover:bg-slate-800 clear secondary"
class="remove--attachment clear secondary"
icon="dismiss" icon="dismiss"
@click="() => onRemoveAttachment(index)" @click="onRemoveAttachment(index)"
/> />
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script>
<script setup>
import { computed } from 'vue';
import { formatBytes } from 'shared/helpers/FileHelper'; import { formatBytes } from 'shared/helpers/FileHelper';
export default {
props: { const props = defineProps({
attachments: { attachments: {
type: Array, type: Array,
default: () => [], 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 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> </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> </template>
<script> <script>
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { hasPressedAltAndNKey } from 'shared/helpers/KeyboardHelpers';
export default { export default {
mixins: [eventListenerMixins], mixins: [keyboardEventListenerMixins],
props: { props: {
items: { items: {
type: Array, type: Array,
@@ -31,14 +30,18 @@ export default {
}, },
}, },
methods: { methods: {
handleKeyEvents(e) { getKeyboardEvents() {
if (hasPressedAltAndNKey(e)) { return {
if (this.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) { 'Alt+KeyN': {
this.onTabChange(0); action: () => {
} else { if (this.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
this.onTabChange(this.activeTabIndex + 1); this.onTabChange(0);
} } else {
} this.onTabChange(this.activeTabIndex + 1);
}
},
},
};
}, },
onTabChange(selectedTabIndex) { onTabChange(selectedTabIndex) {
if (this.items[selectedTabIndex].key !== this.activeTab) { if (this.items[selectedTabIndex].key !== this.activeTab) {
@@ -31,15 +31,10 @@
<script> <script>
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue'; 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 LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import adminMixin from 'dashboard/mixins/isAdmin'; import adminMixin from 'dashboard/mixins/isAdmin';
import {
buildHotKeys,
isEscape,
isActiveElementTypeable,
} from 'shared/helpers/KeyboardHelpers';
export default { export default {
components: { components: {
@@ -47,7 +42,7 @@ export default {
LabelDropdown, LabelDropdown,
}, },
mixins: [clickaway, adminMixin, eventListenerMixins], mixins: [clickaway, adminMixin, keyboardEventListenerMixins],
props: { props: {
allLabels: { allLabels: {
@@ -88,17 +83,19 @@ export default {
closeDropdownLabel() { closeDropdownLabel() {
this.showSearchDropdownLabel = false; this.showSearchDropdownLabel = false;
}, },
getKeyboardEvents() {
handleKeyEvents(e) { return {
const keyPattern = buildHotKeys(e); KeyL: {
action: e => {
if (keyPattern === 'l' && !isActiveElementTypeable(e)) { this.toggleLabels();
this.toggleLabels(); e.preventDefault();
e.preventDefault(); },
} else if (isEscape(e) && this.showSearchDropdownLabel) { },
this.closeDropdownLabel(); Escape: {
e.preventDefault(); 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; @apply bg-transparent max-h-60 min-h-[3rem] pt-4 px-0 pb-0 resize-none;
} }
} }
.video-js .vjs-control-bar { // Added to override the default text and bg style to support dark and light mode.
background-color: transparent; .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> </style>
@@ -78,10 +78,8 @@ const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
import { import {
hasPressedEnterAndNotCmdOrShift, hasPressedEnterAndNotCmdOrShift,
hasPressedCommandAndEnter, hasPressedCommandAndEnter,
hasPressedAltAndPKey,
hasPressedAltAndLKey,
} from 'shared/helpers/KeyboardHelpers'; } from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings'; import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import { import {
@@ -121,7 +119,7 @@ const createState = (
export default { export default {
name: 'WootMessageEditor', name: 'WootMessageEditor',
components: { TagAgents, CannedResponse, VariableList }, components: { TagAgents, CannedResponse, VariableList },
mixins: [eventListenerMixins, uiSettingsMixin, alertMixin], mixins: [keyboardEventListenerMixins, uiSettingsMixin, alertMixin],
props: { props: {
value: { type: String, default: '' }, value: { type: String, default: '' },
editorId: { type: String, default: '' }, editorId: { type: String, default: '' },
@@ -522,13 +520,21 @@ export default {
isCmdPlusEnterToSendEnabled() { isCmdPlusEnterToSendEnabled() {
return isEditorHotKeyEnabled(this.uiSettings, 'cmd_enter'); return isEditorHotKeyEnabled(this.uiSettings, 'cmd_enter');
}, },
handleKeyEvents(e) { getKeyboardEvents() {
if (hasPressedAltAndPKey(e)) { return {
this.focusEditorInputField(); 'Alt+KeyP': {
} action: () => {
if (hasPressedAltAndLKey(e)) { this.focusEditorInputField();
this.focusEditorInputField(); },
} allowOnFocusedInput: true,
},
'Alt+KeyL': {
action: () => {
this.focusEditorInputField();
},
allowOnFocusedInput: true,
},
};
}, },
focusEditorInputField(pos = 'end') { focusEditorInputField(pos = 'end') {
const { tr } = this.editorView.state; const { tr } = this.editorView.state;
@@ -25,7 +25,7 @@ import {
} from '@chatwoot/prosemirror-schema'; } from '@chatwoot/prosemirror-schema';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
@@ -51,7 +51,7 @@ const createState = (
}; };
export default { export default {
mixins: [eventListenerMixins, uiSettingsMixin, alertMixin], mixins: [keyboardEventListenerMixins, uiSettingsMixin, alertMixin],
props: { props: {
value: { type: String, default: '' }, value: { type: String, default: '' },
editorId: { type: String, default: '' }, editorId: { type: String, default: '' },
@@ -136,8 +136,7 @@
<script> <script>
import FileUpload from 'vue-upload-component'; import FileUpload from 'vue-upload-component';
import * as ActiveStorage from 'activestorage'; import * as ActiveStorage from 'activestorage';
import { hasPressedAltAndAKey } from 'shared/helpers/KeyboardHelpers'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import inboxMixin from 'shared/mixins/inboxMixin'; import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -154,7 +153,7 @@ import { mapGetters } from 'vuex';
export default { export default {
name: 'ReplyBottomPanel', name: 'ReplyBottomPanel',
components: { FileUpload, VideoCallButton, AIAssistanceButton }, components: { FileUpload, VideoCallButton, AIAssistanceButton },
mixins: [eventListenerMixins, uiSettingsMixin, inboxMixin], mixins: [keyboardEventListenerMixins, uiSettingsMixin, inboxMixin],
props: { props: {
mode: { mode: {
type: String, type: String,
@@ -340,10 +339,15 @@ export default {
ActiveStorage.start(); ActiveStorage.start();
}, },
methods: { methods: {
handleKeyEvents(e) { getKeyboardEvents() {
if (hasPressedAltAndAKey(e)) { return {
this.$refs.upload.$children[1].$el.click(); 'Alt+KeyA': {
} action: () => {
this.$refs.upload.$children[1].$el.click();
},
allowOnFocusedInput: true,
},
};
}, },
toggleMessageSignature() { toggleMessageSignature() {
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature); this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
@@ -1,5 +1,5 @@
<template> <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"> <div class="button-group">
<woot-button <woot-button
variant="clear" variant="clear"
@@ -20,7 +20,7 @@
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }} {{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
</woot-button> </woot-button>
</div> </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"> <div v-if="isMessageLengthReachingThreshold" class="text-xs">
<span :class="charLengthClass"> <span :class="charLengthClass">
{{ characterLengthWarning }} {{ characterLengthWarning }}
@@ -48,14 +48,10 @@
<script> <script>
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants'; import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import { import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
hasPressedAltAndPKey,
hasPressedAltAndLKey,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
export default { export default {
name: 'ReplyTopPanel', name: 'ReplyTopPanel',
mixins: [eventListenerMixins], mixins: [keyboardEventListenerMixins],
props: { props: {
mode: { mode: {
type: String, type: String,
@@ -99,13 +95,17 @@ export default {
}, },
}, },
methods: { methods: {
handleKeyEvents(e) { getKeyboardEvents() {
if (hasPressedAltAndPKey(e)) { return {
this.handleNoteClick(); 'Alt+KeyP': {
} action: () => this.handleNoteClick(),
if (hasPressedAltAndLKey(e)) { allowOnFocusedInput: true,
this.handleReplyClick(); },
} 'Alt+KeyL': {
action: () => this.handleReplyClick(),
allowOnFocusedInput: true,
},
};
}, },
handleReplyClick() { handleReplyClick() {
this.setReplyMode(REPLY_EDITOR_MODES.REPLY); this.setReplyMode(REPLY_EDITOR_MODES.REPLY);
@@ -2,7 +2,7 @@
<div <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="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="{ :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, isActiveChat,
'unread-chat': hasUnread, 'unread-chat': hasUnread,
'has-inbox-name': showInboxName, 'has-inbox-name': showInboxName,
@@ -77,11 +77,10 @@
</div> </div>
</template> </template>
<script> <script>
import { hasPressedAltAndOKey } from 'shared/helpers/KeyboardHelpers';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import agentMixin from '../../../mixins/agentMixin.js'; import agentMixin from '../../../mixins/agentMixin.js';
import BackButton from '../BackButton.vue'; import BackButton from '../BackButton.vue';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import inboxMixin from 'shared/mixins/inboxMixin'; import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue'; import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue'; import MoreActions from './MoreActions.vue';
@@ -99,7 +98,7 @@ export default {
Thumbnail, Thumbnail,
SLACardLabel, SLACardLabel,
}, },
mixins: [inboxMixin, agentMixin, eventListenerMixins], mixins: [inboxMixin, agentMixin, keyboardEventListenerMixins],
props: { props: {
chat: { chat: {
type: Object, type: Object,
@@ -182,10 +181,12 @@ export default {
}, },
methods: { methods: {
handleKeyEvents(e) { getKeyboardEvents() {
if (hasPressedAltAndOKey(e)) { return {
this.$emit('contact-panel-toggle'); 'Alt+KeyO': {
} action: () => this.$emit('contact-panel-toggle'),
},
};
}, },
}, },
}; };
@@ -116,13 +116,12 @@ import conversationMixin, {
} from '../../../mixins/conversations'; } from '../../../mixins/conversations';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin'; import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import configMixin from 'shared/mixins/configMixin'; import configMixin from 'shared/mixins/configMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import aiMixin from 'dashboard/mixins/aiMixin'; import aiMixin from 'dashboard/mixins/aiMixin';
// utils // utils
import { getTypingUsersText } from '../../../helper/commons'; import { getTypingUsersText } from '../../../helper/commons';
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper'; import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers';
import { LocalStorage } from 'shared/helpers/localStorage'; import { LocalStorage } from 'shared/helpers/localStorage';
// constants // constants
@@ -141,7 +140,7 @@ export default {
mixins: [ mixins: [
conversationMixin, conversationMixin,
inboxMixin, inboxMixin,
eventListenerMixins, keyboardEventListenerMixins,
configMixin, configMixin,
aiMixin, aiMixin,
], ],
@@ -418,10 +417,12 @@ export default {
closePopoutReplyBox() { closePopoutReplyBox() {
this.isPopoutReplyBox = false; this.isPopoutReplyBox = false;
}, },
handleKeyEvents(e) { getKeyboardEvents() {
if (isEscape(e)) { return {
this.closePopoutReplyBox(); Escape: {
} action: () => this.closePopoutReplyBox(),
},
};
}, },
addScrollListener() { addScrollListener() {
this.conversationPanel = this.$el.querySelector('.conversation-panel'); this.conversationPanel = this.$el.querySelector('.conversation-panel');
@@ -100,7 +100,7 @@
<attachment-preview <attachment-preview
class="flex-col mt-4" class="flex-col mt-4"
:attachments="attachedFiles" :attachments="attachedFiles"
:remove-attachment="removeAttachment" @remove-attachment="removeAttachment"
/> />
</div> </div>
<message-signature-missing-alert <message-signature-missing-alert
@@ -155,6 +155,7 @@
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import CannedResponse from './CannedResponse.vue'; import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue'; import ReplyToMessage from './ReplyToMessage.vue';
@@ -178,7 +179,6 @@ import {
replaceVariablesInMessage, replaceVariablesInMessage,
} from '@chatwoot/utils'; } from '@chatwoot/utils';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue'; import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper'; import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin'; import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
@@ -225,6 +225,7 @@ export default {
messageFormatterMixin, messageFormatterMixin,
rtlMixin, rtlMixin,
fileUploadMixin, fileUploadMixin,
keyboardEventListenerMixins,
], ],
props: { props: {
popoutReplyBox: { popoutReplyBox: {
@@ -701,24 +702,41 @@ export default {
this.$store.dispatch('draftMessages/delete', { key }); this.$store.dispatch('draftMessages/delete', { key });
} }
}, },
handleKeyEvents(e) { getKeyboardEvents() {
const keyCode = buildHotKeys(e); return {
if (keyCode === 'escape') { Escape: {
this.hideEmojiPicker(); action: () => {
this.hideMentions(); this.hideEmojiPicker();
} else if (keyCode === 'meta+k') { this.hideMentions();
const ninja = document.querySelector('ninja-keys'); },
ninja.open(); allowOnFocusedInput: true,
e.preventDefault(); },
} else if (keyCode === 'enter' && this.isAValidEvent('enter')) { '$mod+KeyK': {
this.onSendReply(); action: e => {
e.preventDefault(); e.preventDefault();
} else if ( const ninja = document.querySelector('ninja-keys');
['meta+enter', 'ctrl+enter'].includes(keyCode) && ninja.open();
this.isAValidEvent('cmd_enter') },
) { allowOnFocusedInput: true,
this.onSendReply(); },
} 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) { isAValidEvent(selectedKey) {
return ( return (
@@ -937,6 +955,13 @@ export default {
this.bccEmails = ''; this.bccEmails = '';
this.toEmails = ''; 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() { toggleEmojiPicker() {
this.showEmojiPicker = !this.showEmojiPicker; this.showEmojiPicker = !this.showEmojiPicker;
}, },
@@ -944,8 +969,7 @@ export default {
this.isRecordingAudio = !this.isRecordingAudio; this.isRecordingAudio = !this.isRecordingAudio;
this.isRecorderAudioStopped = !this.isRecordingAudio; this.isRecorderAudioStopped = !this.isRecordingAudio;
if (!this.isRecordingAudio) { if (!this.isRecordingAudio) {
this.clearMessage(); this.clearRecorder();
this.clearEmailField();
} }
}, },
toggleAudioRecorderPlayPause() { toggleAudioRecorderPlayPause() {
@@ -989,7 +1013,13 @@ export default {
} }
}, },
onFinishRecorder(file) { 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) { toggleTyping(status) {
const conversationId = this.currentChat.id; const conversationId = this.currentChat.id;
@@ -1015,13 +1045,12 @@ export default {
isPrivate: this.isPrivate, isPrivate: this.isPrivate,
thumb: reader.result, thumb: reader.result,
blobSignedId: blob ? blob.signed_id : undefined, blobSignedId: blob ? blob.signed_id : undefined,
isRecordedAudio: file?.isRecordedAudio || false,
}); });
}; };
}, },
removeAttachment(itemIndex) { removeAttachment(attachments) {
this.attachedFiles = this.attachedFiles.filter( this.attachedFiles = attachments;
(item, index) => itemIndex !== index
);
}, },
setReplyToInPayload(payload) { setReplyToInPayload(payload) {
if (this.inReplyTo?.id) { if (this.inReplyTo?.id) {
@@ -75,9 +75,10 @@ export default {
}, },
methods: { methods: {
handleKeyboardEvent(e) { adjustScroll() {
this.processKeyDownEvent(e); this.$nextTick(() => {
this.$el.scrollTop = 50 * this.selectedIndex; this.$el.scrollTop = 50 * this.selectedIndex;
});
}, },
onHover(index) { onHover(index) {
this.selectedIndex = index; this.selectedIndex = index;
@@ -17,7 +17,7 @@
@error="onImgError" @error="onImgError"
@click="onClick" @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()}`" /> <source :src="`${dataUrl}?t=${Date.now()}`" />
</audio> </audio>
<gallery-view <gallery-view
@@ -182,12 +182,7 @@
<script> <script>
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
isEscape,
hasPressedArrowLeftKey,
hasPressedArrowRightKey,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import timeMixin from 'dashboard/mixins/time'; import timeMixin from 'dashboard/mixins/time';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
@@ -205,7 +200,7 @@ export default {
components: { components: {
Thumbnail, Thumbnail,
}, },
mixins: [eventListenerMixins, clickaway, timeMixin], mixins: [keyboardEventListenerMixins, clickaway, timeMixin],
props: { props: {
show: { show: {
type: Boolean, type: Boolean,
@@ -304,20 +299,30 @@ export default {
this.activeAttachment = attachment; this.activeAttachment = attachment;
this.activeFileType = type; this.activeFileType = type;
}, },
onKeyDownHandler(e) { getKeyboardEvents() {
if (isEscape(e)) { return {
this.onClose(); Escape: {
} else if (hasPressedArrowLeftKey(e)) { action: () => {
this.onClickChangeAttachment( this.onClose();
this.allAttachments[this.activeImageIndex - 1], },
this.activeImageIndex - 1 },
); ArrowLeft: {
} else if (hasPressedArrowRightKey(e)) { action: () => {
this.onClickChangeAttachment( this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex + 1], this.allAttachments[this.activeImageIndex - 1],
this.activeImageIndex + 1 this.activeImageIndex - 1
); );
} },
},
ArrowRight: {
action: () => {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex + 1],
this.activeImageIndex + 1
);
},
},
};
}, },
onClickDownload() { onClickDownload() {
const { file_type: type, data_url: url } = this.activeAttachment; const { file_type: type, data_url: url } = this.activeAttachment;
@@ -74,15 +74,10 @@
<script> <script>
import countries from 'shared/constants/countries.js'; import countries from 'shared/constants/countries.js';
import parsePhoneNumber from 'libphonenumber-js'; import parsePhoneNumber from 'libphonenumber-js';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import {
hasPressedArrowUpKey,
hasPressedArrowDownKey,
isEnter,
} from 'shared/helpers/KeyboardHelpers';
export default { export default {
mixins: [eventListenerMixins], mixins: [keyboardEventListenerMixins],
props: { props: {
value: { value: {
type: [String, Number], type: [String, Number],
@@ -183,6 +178,7 @@ export default {
this.$emit('blur', e.target.value); this.$emit('blur', e.target.value);
}, },
dropdownItem() { dropdownItem() {
if (!this.showDropdown) return [];
return Array.from( return Array.from(
this.$refs.dropdown.querySelectorAll( this.$refs.dropdown.querySelectorAll(
'div.country-dropdown div.country-dropdown--item' 'div.country-dropdown div.country-dropdown--item'
@@ -190,34 +186,27 @@ export default {
); );
}, },
focusedItem() { focusedItem() {
if (!this.showDropdown) return [];
return Array.from( return Array.from(
this.$refs.dropdown.querySelectorAll('div.country-dropdown div.focus') this.$refs.dropdown.querySelectorAll('div.country-dropdown div.focus')
); );
}, },
focusedItemIndex() { focusedItemIndex() {
if (!this.showDropdown) return -1;
return Array.from(this.dropdownItem()).indexOf(this.focusedItem()[0]); return Array.from(this.dropdownItem()).indexOf(this.focusedItem()[0]);
}, },
onKeyDownHandler(e) { moveUp() {
const { showDropdown, filteredCountriesBySearch, onSelectCountry } = this; if (!this.showDropdown) return;
const { selectedIndex } = this; this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
if (showDropdown) { },
if (hasPressedArrowDownKey(e)) { moveDown() {
e.preventDefault(); if (!this.showDropdown) return;
this.selectedIndex = Math.min( this.selectedIndex = Math.min(
selectedIndex + 1, this.selectedIndex + 1,
filteredCountriesBySearch.length - 1 this.filteredCountriesBySearch.length - 1
); );
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28; this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
} 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]);
}
}
}, },
onSelectCountry(country) { onSelectCountry(country) {
this.activeCountryCode = country.id; this.activeCountryCode = country.id;
@@ -235,6 +224,33 @@ export default {
this.activeDialCode = number.countryCallingCode; 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() { toggleCountryDropdown() {
this.showDropdown = !this.showDropdown; this.showDropdown = !this.showDropdown;
this.selectedIndex = -1; this.selectedIndex = -1;
@@ -84,9 +84,7 @@ export default {
}, },
}, },
methods: { methods: {
handleKeyboardEvent(e) { adjustScroll() {},
this.processKeyDownEvent(e);
},
onHover(index) { onHover(index) {
this.selectedIndex = index; this.selectedIndex = index;
}, },
@@ -1,12 +1,7 @@
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default { export default {
mounted() { mixins: [keyboardEventListenerMixins],
document.addEventListener('keydown', this.handleKeyboardEvent);
},
beforeDestroy() {
document.removeEventListener('keydown', this.handleKeyboardEvent);
},
methods: { methods: {
moveSelectionUp() { moveSelectionUp() {
if (!this.selectedIndex) { if (!this.selectedIndex) {
@@ -14,6 +9,7 @@ export default {
} else { } else {
this.selectedIndex -= 1; this.selectedIndex -= 1;
} }
this.adjustScroll();
}, },
moveSelectionDown() { moveSelectionDown() {
if (this.selectedIndex === this.items.length - 1) { if (this.selectedIndex === this.items.length - 1) {
@@ -21,19 +17,46 @@ export default {
} else { } else {
this.selectedIndex += 1; this.selectedIndex += 1;
} }
this.adjustScroll();
}, },
processKeyDownEvent(e) { getKeyboardEvents() {
const keyPattern = buildHotKeys(e); return {
if (['arrowup', 'ctrl+p'].includes(keyPattern)) { ArrowUp: {
this.moveSelectionUp(); action: e => {
e.preventDefault(); this.moveSelectionUp();
} else if (['arrowdown', 'ctrl+n'].includes(keyPattern)) { e.preventDefault();
this.moveSelectionDown(); },
e.preventDefault(); allowOnFocusedInput: true,
} else if (keyPattern === 'enter') { },
this.onSelect(); 'Control+KeyP': {
e.preventDefault(); 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, createLocalVue } from '@vue/test-utils';
import { shallowMount } from '@vue/test-utils'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
const localVue = createLocalVue();
const buildComponent = ({ data = {}, methods = {} }) => ({ const buildComponent = ({ data = {}, methods = {} }) => ({
render() {}, render() {},
data() { data() {
return data; return { ...data, selectedIndex: 0, items: [1, 2, 3] };
}, },
methods, methods: { ...methods, onSelect: jest.fn(), adjustScroll: jest.fn() },
mixins: [mentionSelectionKeyboardMixin], mixins: [keyboardEventListenerMixins],
}); });
describe('mentionSelectionKeyboardMixin', () => { describe('mentionSelectionKeyboardMixin', () => {
test('register listeners', () => { let wrapper;
jest.spyOn(document, 'addEventListener');
beforeEach(() => {
const Component = buildComponent({}); const Component = buildComponent({});
shallowMount(Component); wrapper = shallowMount(Component, { localVue });
// undefined expected as the method is not defined in the component
expect(document.addEventListener).toHaveBeenCalledWith(
'keydown',
undefined
);
}); });
test('processKeyDownEvent updates index on arrow up', () => { it('ArrowUp and Control+KeyP update selectedIndex correctly', () => {
const Component = buildComponent({ const preventDefault = jest.fn();
data: { selectedIndex: 0, items: [1, 2, 3] }, const keyboardEvents = wrapper.vm.getKeyboardEvents();
});
const wrapper = shallowMount(Component); if (keyboardEvents && keyboardEvents.ArrowUp) {
wrapper.vm.processKeyDownEvent({ keyboardEvents.ArrowUp.action({ preventDefault });
ctrlKey: true, expect(wrapper.vm.selectedIndex).toBe(2);
key: 'p', expect(preventDefault).toHaveBeenCalled();
preventDefault: jest.fn(), }
});
expect(wrapper.vm.selectedIndex).toBe(2); 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', () => { it('ArrowDown and Control+KeyN update selectedIndex correctly', () => {
const Component = buildComponent({ const preventDefault = jest.fn();
data: { selectedIndex: 0, items: [1, 2, 3] }, const keyboardEvents = wrapper.vm.getKeyboardEvents();
});
const wrapper = shallowMount(Component); if (keyboardEvents && keyboardEvents.ArrowDown) {
wrapper.vm.processKeyDownEvent({ keyboardEvents.ArrowDown.action({ preventDefault });
key: 'ArrowDown', expect(wrapper.vm.selectedIndex).toBe(1);
preventDefault: jest.fn(), expect(preventDefault).toHaveBeenCalled();
}); }
expect(wrapper.vm.selectedIndex).toBe(1);
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', () => { it('Enter key triggers onSelect method', () => {
const onSelectMockFn = jest.fn(); const preventDefault = jest.fn();
const Component = buildComponent({ const keyboardEvents = wrapper.vm.getKeyboardEvents();
data: { selectedIndex: 0, items: [1, 2, 3] },
methods: { onSelect: () => onSelectMockFn('enterKey pressed') }, if (keyboardEvents && keyboardEvents.Enter) {
}); keyboardEvents.Enter.action({ preventDefault });
const wrapper = shallowMount(Component); expect(wrapper.vm.onSelect).toHaveBeenCalled();
wrapper.vm.processKeyDownEvent({ expect(preventDefault).toHaveBeenCalled();
key: 'Enter', }
preventDefault: jest.fn(),
});
expect(onSelectMockFn).toHaveBeenCalledWith('enterKey pressed');
wrapper.vm.onSelect();
}); });
}); });
@@ -53,12 +53,6 @@ export const SHORTCUT_KEYS = [
firstKey: 'Alt / ⌥', firstKey: 'Alt / ⌥',
secondKey: 'S', secondKey: 'S',
}, },
{
id: 10,
label: 'SWITCH_CONVERSATION_STATUS',
firstKey: 'Alt / ⌥',
secondKey: 'B',
},
{ {
id: 11, id: 11,
label: 'SWITCH_TO_PRIVATE_NOTE', 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 AnalyticsHelper from './AnalyticsHelper';
import LogRocket from 'logrocket';
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper'; import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER'; export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER';
@@ -11,12 +10,6 @@ export const ANALYTICS_RESET = 'ANALYTICS_RESET';
export const initializeAnalyticsEvents = () => { export const initializeAnalyticsEvents = () => {
window.bus.$on(ANALYTICS_IDENTITY, ({ user }) => { window.bus.$on(ANALYTICS_IDENTITY, ({ user }) => {
AnalyticsHelper.identify(user); AnalyticsHelper.identify(user);
if (window.logRocketProjectId) {
LogRocket.identify(user.id, {
email: user.email,
name: user.name,
});
}
}); });
}; };
@@ -316,7 +316,6 @@
"GO_TO_REPORTS_SIDEBAR": "Go to Reports sidebar", "GO_TO_REPORTS_SIDEBAR": "Go to Reports sidebar",
"MOVE_TO_NEXT_TAB": "Move to next tab in conversation list", "MOVE_TO_NEXT_TAB": "Move to next tab in conversation list",
"GO_TO_SETTINGS": "Go to Settings", "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_PRIVATE_NOTE": "Switch to Private Note",
"SWITCH_TO_REPLY": "Switch to Reply", "SWITCH_TO_REPLY": "Switch to Reply",
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown" "TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"
@@ -10,7 +10,8 @@
"TITLE": "Upgrade to create SLAs", "TITLE": "Upgrade to create SLAs",
"AVAILABLE_ON": "The SLA feature is only available in the Business and Enterprise plans.", "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_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": { "ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "The SLA feature is only available in the paid plans.", "AVAILABLE_ON": "The SLA feature is only available in the paid plans.",
@@ -104,4 +105,4 @@
"HIDE": "Hide {count} rows" "HIDE": "Hide {count} rows"
} }
} }
} }
@@ -23,12 +23,12 @@
<script> <script>
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import { hasPressedCommandAndEnter } from 'shared/helpers/KeyboardHelpers'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default { export default {
components: { components: {
WootMessageEditor, WootMessageEditor,
}, },
mixins: [keyboardEventListenerMixins],
data() { data() {
return { return {
noteContent: '', noteContent: '',
@@ -40,21 +40,14 @@ export default {
return this.noteContent === ''; return this.noteContent === '';
}, },
}, },
mounted() {
document.addEventListener('keydown', this.onMetaEnter);
},
beforeDestroy() {
document.removeEventListener('keydown', this.onMetaEnter);
},
methods: { methods: {
onMetaEnter(e) { getKeyboardEvents() {
if (hasPressedCommandAndEnter(e)) { return {
e.preventDefault(); '$mod+Enter': {
this.onAdd(); action: () => this.onAdd(),
} allowOnFocusedInput: true,
},
};
}, },
onAdd() { onAdd() {
if (this.noteContent !== '') { if (this.noteContent !== '') {
@@ -23,6 +23,15 @@
:key="element.name" :key="element.name"
class="bg-white dark:bg-gray-800" 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 <div
v-if="element.name === 'conversation_actions'" v-if="element.name === 'conversation_actions'"
class="conversation--actions" class="conversation--actions"
@@ -145,6 +154,7 @@ import CustomAttributes from './customAttributes/CustomAttributes.vue';
import draggable from 'vuedraggable'; import draggable from 'vuedraggable';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import MacrosList from './Macros/List.vue'; import MacrosList from './Macros/List.vue';
import Linear from './linear/index.vue';
export default { export default {
components: { components: {
@@ -157,6 +167,7 @@ export default {
ConversationParticipant, ConversationParticipant,
draggable, draggable,
MacrosList, MacrosList,
Linear,
}, },
mixins: [alertMixin, uiSettingsMixin], mixins: [alertMixin, uiSettingsMixin],
props: { props: {
@@ -50,13 +50,8 @@ import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue'; import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import adminMixin from 'dashboard/mixins/isAdmin'; 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 conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
import {
buildHotKeys,
isEscape,
isActiveElementTypeable,
} from 'shared/helpers/KeyboardHelpers';
export default { export default {
components: { components: {
@@ -65,7 +60,12 @@ export default {
AddLabel, AddLabel,
}, },
mixins: [clickaway, conversationLabelMixin, adminMixin, eventListenerMixins], mixins: [
clickaway,
conversationLabelMixin,
adminMixin,
keyboardEventListenerMixins,
],
props: { props: {
conversationId: { conversationId: {
type: Number, type: Number,
@@ -93,16 +93,23 @@ export default {
closeDropdownLabel() { closeDropdownLabel() {
this.showSearchDropdownLabel = false; this.showSearchDropdownLabel = false;
}, },
handleKeyEvents(e) { getKeyboardEvents() {
const keyPattern = buildHotKeys(e); return {
KeyL: {
if (keyPattern === 'l' && !isActiveElementTypeable(e)) { action: e => {
this.toggleLabels(); e.preventDefault();
e.preventDefault(); this.toggleLabels();
} else if (isEscape(e) && this.showSearchDropdownLabel) { },
this.closeDropdownLabel(); },
e.preventDefault(); 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> </template>
<script> <script>
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import {
buildHotKeys,
isActiveElementTypeable,
} from 'shared/helpers/KeyboardHelpers';
export default { export default {
name: 'ChatwootSearch', name: 'ChatwootSearch',
mixins: [eventListenerMixins], mixins: [keyboardEventListenerMixins],
props: { props: {
title: { title: {
type: String, type: String,
@@ -71,13 +67,15 @@ export default {
onBlur() { onBlur() {
this.isInputFocused = false; this.isInputFocused = false;
}, },
handleKeyEvents(e) { getKeyboardEvents() {
const keyPattern = buildHotKeys(e); return {
Slash: {
if (keyPattern === '/' && !isActiveElementTypeable(e)) { action: e => {
e.preventDefault(); e.preventDefault();
this.$refs.searchInput.focus(); this.$refs.searchInput.focus();
} },
},
};
}, },
}, },
}; };
@@ -35,10 +35,7 @@
<script> <script>
import { debounce } from '@chatwoot/utils'; import { debounce } from '@chatwoot/utils';
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import { import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
isEscape,
isActiveElementTypeable,
} from 'shared/helpers/KeyboardHelpers';
import SearchHeader from './Header.vue'; import SearchHeader from './Header.vue';
import SearchResults from './SearchResults.vue'; import SearchResults from './SearchResults.vue';
@@ -55,7 +52,7 @@ export default {
SearchResults, SearchResults,
ArticleView, ArticleView,
}, },
mixins: [clickaway, portalMixin, alertMixin], mixins: [clickaway, portalMixin, alertMixin, keyboardEventListenerMixins],
props: { props: {
selectedPortalSlug: { selectedPortalSlug: {
type: String, type: String,
@@ -97,10 +94,6 @@ export default {
mounted() { mounted() {
this.fetchArticlesByQuery(this.searchQuery); this.fetchArticlesByQuery(this.searchQuery);
this.debounceSearch = debounce(this.fetchArticlesByQuery, 500, false); this.debounceSearch = debounce(this.fetchArticlesByQuery, 500, false);
document.body.addEventListener('keydown', this.closeOnEsc);
},
beforeDestroy() {
document.body.removeEventListener('keydown', this.closeOnEsc);
}, },
methods: { methods: {
generateArticleUrl(article) { generateArticleUrl(article) {
@@ -158,11 +151,15 @@ export default {
); );
this.onClose(); this.onClose();
}, },
closeOnEsc(e) { getKeyboardEvents() {
if (isEscape(e) && !isActiveElementTypeable(e)) { return {
e.preventDefault(); Escape: {
this.onClose(); 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> <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"> <template v-if="isLoading">
<div class="heatmap-labels"> <div class="grid gap-[5px] flex-shrink-0">
<div <div
v-for="ii in 7" v-for="ii in 7"
:key="ii" :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>
<div class="heatmap-grid"> <div class="grid gap-[5px] w-full min-w-[700px]">
<div v-for="ii in 7" :key="ii" class="heatmap-grid-row"> <div
<div v-for="jj in 24" :key="jj" class="heatmap-tile loading-cell"> v-for="ii in 7"
<div class="heatmap-tile__label loading-cell" /> :key="ii"
</div> 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> </div>
<div class="heatmap-timeline" /> <div />
<div class="heatmap-markers"> <div
<div v-for="ii in 24" :key="ii">{{ ii - 1 }} {{ ii }}</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> </div>
</template> </template>
<template v-else> <template v-else>
<div class="heatmap-labels"> <div class="grid gap-[5px] flex-shrink-0">
<div <div
v-for="dateKey in processedData.keys()" v-for="dateKey in processedData.keys()"
:key="dateKey" :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)) }} {{ 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> </div>
<div class="heatmap-grid"> <div class="grid gap-[5px] w-full min-w-[700px]">
<div <div
v-for="dateKey in processedData.keys()" v-for="dateKey in processedData.keys()"
:key="dateKey" :key="dateKey"
class="heatmap-grid-row" class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
> >
<div <div
v-for="data in processedData.get(dateKey)" v-for="data in processedData.get(dateKey)"
:key="data.timestamp" :key="data.timestamp"
v-tooltip.top="getCountTooltip(data.value)" 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)" :class="getHeatmapLevelClass(data.value)"
> />
<div class="heatmap-tile__label" />
</div>
</div> </div>
</div> </div>
<div class="heatmap-timeline" /> <div />
<div class="heatmap-markers"> <div
<div v-for="ii in 24" :key="ii">{{ ii - 1 }} {{ ii }}</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> </div>
</template> </template>
</div> </div>
</template> </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') }} {{ $t('SLA.ENTERPRISE_PAYWALL.ASK_ADMIN') }}
</span> </span>
</p> </p>
<template v-if="isOnChatwootCloud"> <template v-if="isOnChatwootCloud || true">
<woot-button <woot-button
color-scheme="primary" color-scheme="primary"
class="w-full mt-2 text-center rounded-xl" 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') }} {{ $t('SLA.PAYWALL.UPGRADE_NOW') }}
</woot-button> </woot-button>
<span class="mt-2 text-xs tracking-tight text-center">
{{ $t('SLA.PAYWALL.CANCEL_ANYTIME') }}
</span>
</template> </template>
<template v-if="isSuperAdmin"> <template v-else-if="isSuperAdmin">
<a href="/super_admin" class="block w-full"> <a href="/super_admin" class="block w-full">
<woot-button <woot-button
color-scheme="primary" color-scheme="primary"
-18
View File
@@ -46,26 +46,8 @@ import webhooks from './modules/webhooks';
import draftMessages from './modules/draftMessages'; import draftMessages from './modules/draftMessages';
import SLAReports from './modules/SLAReports'; import SLAReports from './modules/SLAReports';
import LogRocket from 'logrocket';
import createPlugin from 'logrocket-vuex';
const plugins = []; 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); Vue.use(Vuex);
export default new Vuex.Store({ export default new Vuex.Store({
modules: { modules: {
@@ -109,9 +109,10 @@ export default {
generateArticleUrl(article) { generateArticleUrl(article) {
return `/hc/${article.portal.slug}/articles/${article.slug}`; return `/hc/${article.portal.slug}/articles/${article.slug}`;
}, },
handleKeyboardEvent(e) { adjustScroll() {
this.processKeyDownEvent(e); this.$nextTick(() => {
this.$el.scrollTop = 102 * this.selectedIndex; this.$el.scrollTop = 102 * this.selectedIndex;
});
}, },
prepareContent(content) { prepareContent(content) {
return this.highlightContent( return this.highlightContent(
@@ -8,16 +8,12 @@
</ul> </ul>
</template> </template>
<script> <script>
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import {
hasPressedArrowUpKey,
hasPressedArrowDownKey,
} from 'shared/helpers/KeyboardHelpers';
export default { export default {
name: 'WootDropdownMenu', name: 'WootDropdownMenu',
componentName: 'WootDropdownMenu', componentName: 'WootDropdownMenu',
mixins: [eventListenerMixins], mixins: [keyboardEventListenerMixins],
props: { props: {
placement: { placement: {
@@ -31,38 +27,46 @@ export default {
'ul.dropdown li.dropdown-menu__item .button' 'ul.dropdown li.dropdown-menu__item .button'
); );
}, },
activeElementIndex() { getActiveButtonIndex(menuButtons) {
const menuButtons = this.dropdownMenuButtons();
const focusedButton = this.$refs.dropdownMenu.querySelector( const focusedButton = this.$refs.dropdownMenu.querySelector(
'ul.dropdown li.dropdown-menu__item .button:focus' 'ul.dropdown li.dropdown-menu__item .button:focus'
); );
const activeIndex = [...menuButtons].indexOf(focusedButton); return Array.from(menuButtons).indexOf(focusedButton);
return activeIndex;
}, },
handleKeyEvents(e) { getKeyboardEvents() {
const menuButtons = this.dropdownMenuButtons(); 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 (menuButtons.length === 0) return;
menuButtons[newIndex].focus();
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();
}
}
}, },
}, },
}; };
@@ -1,9 +1,9 @@
export const isEnter = e => { export const isEnter = e => {
return e.keyCode === 13; return e.key === 'Enter';
}; };
export const isEscape = e => { export const isEscape = e => {
return e.keyCode === 27; return e.key === 'Escape';
}; };
export const hasPressedShift = e => { export const hasPressedShift = e => {
@@ -19,118 +19,7 @@ export const hasPressedEnterAndNotCmdOrShift = e => {
}; };
export const hasPressedCommandAndEnter = e => { export const hasPressedCommandAndEnter = e => {
return e.metaKey && e.keyCode === 13; return hasPressedCommand(e) && isEnter(e);
};
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;
}; };
/** /**
@@ -3,20 +3,19 @@ import {
isEscape, isEscape,
hasPressedShift, hasPressedShift,
hasPressedCommand, hasPressedCommand,
buildHotKeys,
isActiveElementTypeable, isActiveElementTypeable,
} from '../KeyboardHelpers'; } from '../KeyboardHelpers';
describe('#KeyboardHelpers', () => { describe('#KeyboardHelpers', () => {
describe('#isEnter', () => { describe('#isEnter', () => {
it('return correct values', () => { it('return correct values', () => {
expect(isEnter({ keyCode: 13 })).toEqual(true); expect(isEnter({ key: 'Enter' })).toEqual(true);
}); });
}); });
describe('#isEscape', () => { describe('#isEscape', () => {
it('return correct values', () => { 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); 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', () => { 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> </template>
<script> <script>
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin'; import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import Header from '../../components/playground/Header.vue'; import Header from '../../components/playground/Header.vue';
import UserMessage from '../../components/playground/UserMessage.vue'; import UserMessage from '../../components/playground/UserMessage.vue';
import BotMessage from '../../components/playground/BotMessage.vue'; import BotMessage from '../../components/playground/BotMessage.vue';
@@ -47,7 +47,7 @@ export default {
BotMessage, BotMessage,
TypingIndicator, TypingIndicator,
}, },
mixins: [messageFormatterMixin], mixins: [messageFormatterMixin, keyboardEventListenerMixins],
props: { props: {
componentData: { componentData: {
type: Object, type: Object,
@@ -67,17 +67,17 @@ export default {
}, },
mounted() { mounted() {
this.focusInput(); this.focusInput();
document.addEventListener('keydown', this.handleKeyEvents);
},
beforeDestroy() {
document.removeEventListener('keydown', this.handleKeyEvents);
}, },
methods: { methods: {
handleKeyEvents(e) { getKeyboardEvents() {
const keyCode = buildHotKeys(e); return {
if (['meta+enter', 'ctrl+enter'].includes(keyCode)) { '$mod+Enter': {
this.onMessageSend(); action: () => {
} this.onMessageSend();
},
allowOnFocusedInput: true,
},
};
}, },
focusInput() { focusInput() {
this.$refs.messageInput.focus(); 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 => { export const replaceRouteWithReload = url => {
window.location = 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() { dropdownItem() {
// This function is used to get all the items in the dropdown. // This function is used to get all the items in the dropdown.
if (!this.showDropdown) return [];
return Array.from( return Array.from(
this.$refs.dropdown.querySelectorAll( this.$refs.dropdown?.querySelectorAll(
'div.country-dropdown div.country-dropdown--item' 'div.country-dropdown div.country-dropdown--item'
) )
); );
}, },
focusedOrActiveItem(className) { focusedOrActiveItem(className) {
// This function is used to get the focused or active item in the dropdown. // This function is used to get the focused or active item in the dropdown.
if (!this.showDropdown) return [];
return Array.from( return Array.from(
this.$refs.dropdown.querySelectorAll( this.$refs.dropdown?.querySelectorAll(
`div.country-dropdown div.country-dropdown--item.${className}` `div.country-dropdown div.country-dropdown--item.${className}`
) )
); );
}, },
handleKeyboardEvent(e) { adjustScroll() {
if (this.showDropdown) { this.$nextTick(() => {
this.processKeyDownEvent(e);
this.scrollToFocusedOrActiveItem(this.focusedOrActiveItem('focus')); this.scrollToFocusedOrActiveItem(this.focusedOrActiveItem('focus'));
} });
}, },
onSelect() { onSelect() {
this.onSelectCountry(this.items[this.selectedIndex]); this.onSelectCountry(this.items[this.selectedIndex]);
+5 -5
View File
@@ -3,13 +3,13 @@ require 'net/imap'
class Inboxes::FetchImapEmailsJob < MutexApplicationJob class Inboxes::FetchImapEmailsJob < MutexApplicationJob
queue_as :scheduled_jobs queue_as :scheduled_jobs
def perform(channel) def perform(channel, interval = 1)
return unless should_fetch_email?(channel) return unless should_fetch_email?(channel)
key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id) key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id)
with_lock(key, 5.minutes) do with_lock(key, 5.minutes) do
process_email_for_channel(channel) process_email_for_channel(channel, interval)
end end
rescue *ExceptionList::IMAP_EXCEPTIONS => e rescue *ExceptionList::IMAP_EXCEPTIONS => e
Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}" 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? channel.imap_enabled? && !channel.reauthorization_required?
end end
def process_email_for_channel(channel) def process_email_for_channel(channel, interval)
inbound_emails = if channel.microsoft? inbound_emails = if channel.microsoft?
Imap::MicrosoftFetchEmailService.new(channel: channel).perform Imap::MicrosoftFetchEmailService.new(channel: channel, interval: interval).perform
else else
Imap::FetchEmailService.new(channel: channel).perform Imap::FetchEmailService.new(channel: channel, interval: interval).perform
end end
inbound_emails.map do |inbound_mail| inbound_emails.map do |inbound_mail|
process_mail(inbound_mail, channel) 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) notification, account, unread_count, count = extract_notification_and_account(event)
tokens = [event.data[:notification].user.pubsub_token] 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 end
def account_cache_invalidated(event) def account_cache_invalidated(event)
-1
View File
@@ -310,5 +310,4 @@ class Conversation < ApplicationRecord
end end
Conversation.include_mod_with('Concerns::Conversation') Conversation.include_mod_with('Concerns::Conversation')
Conversation.include_mod_with('SentimentAnalysisHelper')
Conversation.prepend_mod_with('Conversation') Conversation.prepend_mod_with('Conversation')
-5
View File
@@ -269,7 +269,6 @@ class Message < ApplicationRecord
reopen_conversation reopen_conversation
notify_via_mail notify_via_mail
set_conversation_activity set_conversation_activity
update_message_sentiments
dispatch_create_events dispatch_create_events
send_reply send_reply
execute_message_template_hooks execute_message_template_hooks
@@ -406,10 +405,6 @@ class Message < ApplicationRecord
conversation.update_columns(last_activity_at: created_at) conversation.update_columns(last_activity_at: created_at)
# rubocop:enable Rails/SkipsModelValidations # rubocop:enable Rails/SkipsModelValidations
end end
def update_message_sentiments
# override in the enterprise ::Enterprise::SentimentAnalysisJob.perform_later(self)
end
end end
Message.prepend_mod_with('Message') Message.prepend_mod_with('Message')
@@ -1,7 +1,7 @@
require 'net/imap' require 'net/imap'
class Imap::BaseFetchEmailService class Imap::BaseFetchEmailService
pattr_initialize [:channel!] pattr_initialize [:channel!, :interval]
def fetch_emails def fetch_emails
# Override this method # Override this method
@@ -99,10 +99,10 @@ class Imap::BaseFetchEmailService
end end
# Sends a SEARCH command to search the mailbox for messages that were # 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> # Return <message set>
def fetch_available_mail_sequence_numbers def fetch_available_mail_sequence_numbers
imap_client.search(['SINCE', yesterday]) imap_client.search(['SINCE', since])
end end
def build_imap_client def build_imap_client
@@ -123,7 +123,8 @@ class Imap::BaseFetchEmailService
Mail.read_from_string(raw_email_content) Mail.read_from_string(raw_email_content)
end end
def yesterday def since
(Time.zone.today - 1).strftime('%d-%b-%Y') previous_day = Time.zone.today - (interval || 1).to_i
previous_day.strftime('%d-%b-%Y')
end end
end end
+1 -1
View File
@@ -128,7 +128,7 @@ By default, it renders:
tocHeader: '<%= I18n.t('public_portal.toc_header') %>' tocHeader: '<%= I18n.t('public_portal.toc_header') %>'
}; };
</script> </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 %> <%= @portal.channel_web_widget.web_widget_script.html_safe %>
<% end %> <% end %>
</html> </html>
-1
View File
@@ -55,7 +55,6 @@
browser_name: '<%= browser.name %>', browser_name: '<%= browser.name %>',
} }
window.errorLoggingConfig = '<%= ENV.fetch('SENTRY_DSN', '') %>' window.errorLoggingConfig = '<%= ENV.fetch('SENTRY_DSN', '') %>'
window.logRocketProjectId = '<%= ENV.fetch('LOG_ROCKET_PROJECT_ID', '') %>'
</script> </script>
<% if @global_config['ANALYTICS_TOKEN'].present? %> <% if @global_config['ANALYTICS_TOKEN'].present? %>
<script> <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
View File
@@ -393,6 +393,7 @@ Rails.application.routes.draw do
end end
get 'hc/:slug', to: 'public/api/v1/portals#show' 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', to: 'public/api/v1/portals#show'
get 'hc/:slug/:locale/articles', to: 'public/api/v1/portals/articles#index' get 'hc/:slug/:locale/articles', to: 'public/api/v1/portals/articles#index'
get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index' get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'
@@ -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? @custom ||= root.join('custom').exist?
end end
def self.help_center_root
ENV.fetch('HELPCENTER_URL', nil) || ENV.fetch('FRONTEND_URL', nil)
end
def self.extensions def self.extensions
if custom? if custom?
%w[enterprise custom] %w[enterprise custom]
+3 -3
View File
@@ -22,7 +22,7 @@
"size-limit": [ "size-limit": [
{ {
"path": "public/packs/js/widget-*.js", "path": "public/packs/js/widget-*.js",
"limit": "281 KB" "limit": "300 KB"
}, },
{ {
"path": "public/packs/js/sdk.js", "path": "public/packs/js/sdk.js",
@@ -35,6 +35,7 @@
"@chatwoot/utils": "^0.0.23", "@chatwoot/utils": "^0.0.23",
"@hcaptcha/vue-hcaptcha": "^0.3.2", "@hcaptcha/vue-hcaptcha": "^0.3.2",
"@june-so/analytics-next": "^2.0.0", "@june-so/analytics-next": "^2.0.0",
"@linear/sdk": "^20.0.0",
"@radix-ui/colors": "^1.0.1", "@radix-ui/colors": "^1.0.1",
"@rails/actioncable": "6.1.3", "@rails/actioncable": "6.1.3",
"@rails/ujs": "^7.0.3-1", "@rails/ujs": "^7.0.3-1",
@@ -61,8 +62,6 @@
"ionicons": "~2.0.1", "ionicons": "~2.0.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"libphonenumber-js": "^1.10.24", "libphonenumber-js": "^1.10.24",
"logrocket": "^3.0.1",
"logrocket-vuex": "^0.0.3",
"markdown-it": "^13.0.2", "markdown-it": "^13.0.2",
"markdown-it-link-attributes": "^4.0.1", "markdown-it-link-attributes": "^4.0.1",
"md5": "^2.3.0", "md5": "^2.3.0",
@@ -72,6 +71,7 @@
"postcss-loader": "^4.2.0", "postcss-loader": "^4.2.0",
"semver": "7.5.3", "semver": "7.5.3",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"tinykeys": "^2.1.0",
"turbolinks": "^5.2.0", "turbolinks": "^5.2.0",
"url-loader": "^2.0.0", "url-loader": "^2.0.0",
"urlpattern-polyfill": "^6.0.2", "urlpattern-polyfill": "^6.0.2",
@@ -1,12 +1,15 @@
require 'rails_helper' 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!(: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') } let!(:portal) { create(:portal, slug: 'test-portal', account_id: account.id, custom_domain: 'www.example.com') }
before do before do
create(:portal, slug: 'test-portal-1', account_id: account.id) create(:portal, slug: 'test-portal-1', account_id: account.id)
create(:portal, slug: 'test-portal-2', 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 end
describe 'GET /public/api/v1/portals/{portal_slug}' do 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" Please send us an email at support@chatwoot.com with the custom domain name and account API key"
end end
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 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
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 describe 'sla_policy' do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: 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 context 'when the channel is regular imap' do
it 'calls the imap fetch service' do it 'calls the imap fetch service' do
fetch_service = double 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([]) allow(fetch_service).to receive(:perform).and_return([])
described_class.perform_now(imap_email_channel) described_class.perform_now(imap_email_channel)
expect(fetch_service).to have_received(:perform) expect(fetch_service).to have_received(:perform)
end 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 end
context 'when the channel is Microsoft' do context 'when the channel is Microsoft' do
it 'calls the Microsoft fetch service' do it 'calls the Microsoft fetch service' do
fetch_service = double 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([]) allow(fetch_service).to receive(:perform).and_return([])
described_class.perform_now(microsoft_imap_email_channel) described_class.perform_now(microsoft_imap_email_channel)
@@ -62,7 +71,7 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
context 'when IMAP connection errors out' do context 'when IMAP connection errors out' do
it 'mark the connection for authorization required' 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) allow(Redis::Alfred).to receive(:incr)
expect(Redis::Alfred).to receive(:incr).with("AUTHORIZATION_ERROR_COUNT:channel_email:#{imap_email_channel.id}") 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(Imap::ImapMailbox).to receive(:new).and_return(mailbox)
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker) 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]) allow(fetch_service).to receive(:perform).and_return([inbound_mail])
end end
it 'calls the mailbox to create emails' do it 'calls the mailbox to create emails' do
allow(mailbox).to receive(:process) 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(fetch_service).to receive(:perform).and_return([inbound_mail])
expect(mailbox).to receive(:process).with(inbound_mail, imap_email_channel) expect(mailbox).to receive(:process).with(inbound_mail, imap_email_channel)
+3 -1
View File
@@ -142,7 +142,9 @@ describe ActionCableListener do
'notification.deleted', 'notification.deleted',
{ {
account_id: notification.account_id, account_id: notification.account_id,
notification: notification.push_event_data, notification: {
id: notification.id
},
unread_count: 1, unread_count: 1,
count: 1 count: 1
} }
@@ -50,5 +50,31 @@ RSpec.describe Imap::MicrosoftFetchEmailService do
end end
end 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
end end
+3 -171
View File
@@ -1,17 +1,5 @@
const { const { slateDark } = require('@radix-ui/colors');
blue, import { colors } from './theme/colors';
blueDark,
green,
greenDark,
yellow,
yellowDark,
slate,
slateDark,
red,
redDark,
violet,
violetDark,
} = require('@radix-ui/colors');
const defaultTheme = require('tailwindcss/defaultTheme'); const defaultTheme = require('tailwindcss/defaultTheme');
module.exports = { module.exports = {
darkMode: 'class', darkMode: 'class',
@@ -41,163 +29,7 @@ module.exports = {
'modal-backdrop-light': 'rgba(0, 0, 0, 0.4)', 'modal-backdrop-light': 'rgba(0, 0, 0, 0.4)',
'modal-backdrop-dark': 'rgba(0, 0, 0, 0.6)', 'modal-backdrop-dark': 'rgba(0, 0, 0, 0.6)',
current: 'currentColor', current: 'currentColor',
woot: { ...colors,
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>)',
},
body: slateDark.slate7, body: slateDark.slate7,
}, },
keyframes: { 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" resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.50.0.tgz#9e93b850f0f3fa35f5fa59adfd03adae8488e484"
integrity sha512-NCC3zz2+nvYd+Ckfh87rA47zfu2QsQpvc6k1yzTk+b9KzRj0wkGa8LSoGOXN6Zv4lRf/EIoZ80biDh9HOI+RNQ== 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": "@hcaptcha/vue-hcaptcha@^0.3.2":
version "0.3.2" version "0.3.2"
resolved "https://registry.yarnpkg.com/@hcaptcha/vue-hcaptcha/-/vue-hcaptcha-0.3.2.tgz#0f77d6fc19bc47eadb6b2181eee5fc132441a942" resolved "https://registry.yarnpkg.com/@hcaptcha/vue-hcaptcha/-/vue-hcaptcha-0.3.2.tgz#0f77d6fc19bc47eadb6b2181eee5fc132441a942"
@@ -3872,6 +3877,15 @@
typescript "^4.9.5" typescript "^4.9.5"
unfetch "^4.1.0" 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": "@lit-labs/ssr-dom-shim@^1.0.0", "@lit-labs/ssr-dom-shim@^1.1.0":
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.1.tgz#64df34e2f12e68e78ac57e571d25ec07fa460ca9" 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" resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== 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: handle-thing@^2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" 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" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.1.tgz#005fde2f5e6e47068f935ff28573e125ef72f197"
integrity sha512-Hesni4s5UkWkwCGJMQGAh71PaLUmKFM60dHvq0zi/vDhhrzuk+4GgNbTXJ12YYQJn6ZKBDNIjYcuQGKudvqrIw== 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: loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" 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" resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803"
integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA== 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: titleize@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/titleize/-/titleize-3.0.0.tgz#71c12eb7fdd2558aa8a44b0be83b8a76694acd53" resolved "https://registry.yarnpkg.com/titleize/-/titleize-3.0.0.tgz#71c12eb7fdd2558aa8a44b0be83b8a76694acd53"