Compare commits

...

185 commits

Author SHA1 Message Date
Sivin Varghese
98c289dc3e
chore: Fixes issue showing the CSAT error message (#6136)
Approved by Muhsin
2022-12-28 12:49:11 +05:30
Nithin David Thomas
3e91765472
fix: Expand title height of textarea on load (#6103) 2022-12-22 14:14:30 -08:00
Sojan Jose
1bf23055df
chore: Update translations (#6113) 2022-12-22 14:08:08 -08:00
Sivin Varghese
2af337be10
feat: Add the ability to toggle the secondary sidebar in all display breakpoints (#6118)
Co-authored-by: Nithin David <1277421+nithindavid@users.noreply.github.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-12-22 14:07:11 -08:00
Muhsin Keloth
dbb6c0a074
chore: Increase the max concurrent number of devices (#6121) 2022-12-22 19:13:54 +05:30
Sivin Varghese
8c88344170
chore: Helpcenter improvements (#6098) 2022-12-22 18:51:24 +05:30
giquieu
6a78254701
feat: Send audio longer than 10 seconds and Add Prop audio-record-format (#6108)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2022-12-22 13:36:03 +05:30
Pranav Raj S
26ada8b342
fix: Update the logic to show read status for web (#6107) 2022-12-21 09:58:56 -08:00
Sivin Varghese
3c6bd2c8fd
fix: Use canned response menu from the editor in contact messages (#6109) 2022-12-21 16:01:50 +05:30
Sivin Varghese
2c2c47d7fd
chore: Hide inbox name only has one inbox (#6115) 2022-12-21 13:31:53 +05:30
Sojan
34f7405689 Merge branch 'release/2.12.0' into develop 2022-12-19 22:48:01 +05:30
Sojan
3ebfb3a140 Bump version to 2.12.0 2022-12-19 22:44:43 +05:30
Pranav Raj S
2dfe38ae4d
chore: Cleanup feature flags (#6096)
- Add more feature flags for CRM, auto_resolution, and reports
- Add a SuperAdmin link in the sidebar if the user is a super-admin
- SuperAdmin could view all the features on an account irrespective of whether the feature is enabled.
2022-12-19 22:38:30 +05:30
Sojan Jose
ca88eb55f4
chore: Update translations from Crowdin 2022-12-19 22:34:49 +05:30
Nithin David Thomas
d1a26e80f4
fix: Hide show more labels button when there's no overflow (#6097) 2022-12-19 08:54:20 -08:00
Nithin David Thomas
022d0b0ea3
chore: Enable prototyping classes for foundation (#5945)
* chore: Enable prototyping classes for foundation

* Marcros css clean up

* Imports utilities separately

* Fix macro position

* Changes global margin

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2022-12-19 14:11:11 +05:30
Tejaswini Chile
5541d9e00b
Fix: automation email improvement (#6061) 2022-12-19 13:21:33 +05:30
Pranav Raj S
38587b3aa1
fix: Update Slack integration to fix message delivery issues (#6093) 2022-12-17 16:41:11 -08:00
Pranav Raj S
4d2b7c37a0
feat: Display labels in the conversation card (#6088)
Co-authored-by: Nithin David Thomas <webofnithin@gmail.com>
2022-12-17 13:11:28 -08:00
Pranav Raj S
aaacf9d4d2
feat: Allow users to disable marking offline automatically (#6079)
Co-authored-by: Nithin David <1277421+nithindavid@users.noreply.github.com>
2022-12-16 11:59:27 -08:00
Sivin Varghese
82d3398932
fix: Add improvements to the Help Center module (#6081) 2022-12-16 11:41:55 -08:00
Muhsin Keloth
9106f6278d
fix: Allow editing label and placeholder for standard attributes in pre chat form (#6067)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2022-12-15 09:36:18 -08:00
Sojan Jose
f8e6308caf
chore: [Snyk] Fix for 7 vulnerabilities (#6075)
* fix: Gemfile to reduce vulnerabilities

The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-RUBY-LOOFAH-3168317
- https://snyk.io/vuln/SNYK-RUBY-LOOFAH-3168318
- https://snyk.io/vuln/SNYK-RUBY-LOOFAH-3168649
- https://snyk.io/vuln/SNYK-RUBY-RAILSHTMLSANITIZER-3168316
- https://snyk.io/vuln/SNYK-RUBY-RAILSHTMLSANITIZER-3168646
- https://snyk.io/vuln/SNYK-RUBY-RAILSHTMLSANITIZER-3168647
- https://snyk.io/vuln/SNYK-RUBY-RAILSHTMLSANITIZER-3168648

* chore: update gemlock

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2022-12-15 16:40:50 +05:30
Sojan Jose
72fcaa739c
chore: Update translations from Crowdin 2022-12-15 14:11:15 +05:30
smartdev58
9292653bf9
fix: Update enabled_features logic to fix superadmin edit action (#5959)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-12-14 18:24:02 -08:00
smartdev58
2a1a38f986
chore: Add feature flags for campaigns and website channel (#5778)
Co-authored-by: Tejaswini Chile <tejaswini@chatwoot.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-12-14 16:06:26 -08:00
Sivin Varghese
2972319026
fix: Update colors in widget buttons to fix invalid colors (#6033)
Co-authored-by: nithindavid <1277421+nithindavid@users.noreply.github.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-12-14 15:21:20 -08:00
dependabot[bot]
26e05de642
chore(deps): bump loofah from 2.18.0 to 2.19.1 (#6072)
Bumps [loofah](https://github.com/flavorjones/loofah) from 2.18.0 to 2.19.1.
- [Release notes](https://github.com/flavorjones/loofah/releases)
- [Changelog](https://github.com/flavorjones/loofah/blob/main/CHANGELOG.md)
- [Commits](https://github.com/flavorjones/loofah/compare/v2.18.0...v2.19.1)

---
updated-dependencies:
- dependency-name: loofah
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-14 12:51:36 -08:00
dependabot[bot]
8222a47154
chore(deps): bump rails-html-sanitizer from 1.4.3 to 1.4.4 (#6074)
Bumps [rails-html-sanitizer](https://github.com/rails/rails-html-sanitizer) from 1.4.3 to 1.4.4.
- [Release notes](https://github.com/rails/rails-html-sanitizer/releases)
- [Changelog](https://github.com/rails/rails-html-sanitizer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rails/rails-html-sanitizer/compare/v1.4.3...v1.4.4)

---
updated-dependencies:
- dependency-name: rails-html-sanitizer
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-14 12:51:12 -08:00
Sivin Varghese
9d78f0d6c6
feat: Adds number validation for WhatsApp inbox at the creation step (#6043)
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
2022-12-12 19:52:37 -08:00
Sivin Varghese
86958278cd
fix: Unable to save automation "send email to team" (#6052)
* fix: Unable to save automation "send email to team"

* chore: Minor fixes
2022-12-12 20:10:33 +05:30
Pranav Raj S
823c836906
feat: Allow wildcard URL in the campaigns (#6056) 2022-12-09 16:43:09 -08:00
Pranav Raj S
6200559123
chore: Update analytics events (#6050) 2022-12-08 20:53:13 -08:00
Tejaswini Chile
7dc790a7e0
fix: Automatically remove expired story mention (#5300)
When a user mentions the connected Instagram page in a story, the story's content is downloaded in Chatwoot, then if the user deletes the story, the content persists in the platform.

fixes: #5258
2022-12-08 15:55:24 +03:00
dependabot[bot]
431e2931c4
chore(deps): bump nokogiri from 1.13.9 to 1.13.10 (#6040)
Bumps [nokogiri](https://github.com/sparklemotion/nokogiri) from 1.13.9 to 1.13.10.
- [Release notes](https://github.com/sparklemotion/nokogiri/releases)
- [Changelog](https://github.com/sparklemotion/nokogiri/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sparklemotion/nokogiri/compare/v1.13.9...v1.13.10)

---
updated-dependencies:
- dependency-name: nokogiri
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-08 14:01:52 +03:00
Pranav Raj S
52ea201070
fix: Remove duplicate submit action (#6039) 2022-12-07 16:28:45 -08:00
Pranav Raj S
779bcf5e0d
feat: Update Signup screen (#6002)
* feat: Update Signup page designs

* feat: Update the signup page with dynamic testimonials

* Remove the images

* chore: Minor UI fixes

* chore: Form aligned to centre

* Update app/javascript/dashboard/routes/auth/components/Signup/Form.vue

* Design improvements

* Update company name key

* Revert "chore: Minor UI fixes"

This reverts commit 1556f4ca835d9aa0d9620fd6a3d52d259f0d7d65.

* Revert "Design improvements

This reverts commit dfb2364cf2f0cc93123698fde92e5f9e00536cc2.

* Remove footer

* Fix spacing

* Update app/views/installation/onboarding/index.html.erb

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
2022-12-07 15:55:03 -08:00
Pranav Raj S
6064aad38f
chore: Add business email validation on signup (#6037) 2022-12-07 13:03:51 -08:00
Nithin David Thomas
caa45d1d92
feat: Pass logged in agent context to dashboard app (#6034)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-12-07 12:02:27 -08:00
Sivin Varghese
f1d1bb84fd
fix: Filters are not applied unless I'm on the All Conversations screen (#6006)
* fix: Filters are not applied unless I'm on the All Conversations screen

* chore: Review fixes

* chore: Minor sidebar fixes

* chore: Review fixes

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-12-07 12:00:51 +05:30
dependabot[bot]
01cc3d7c9c
chore(deps): bump qs from 6.5.2 to 6.5.3 (#6028)
Bumps [qs](https://github.com/ljharb/qs) from 6.5.2 to 6.5.3.
- [Release notes](https://github.com/ljharb/qs/releases)
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.5.2...v6.5.3)

---
updated-dependencies:
- dependency-name: qs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-06 12:49:00 -08:00
Sivin Varghese
89cfc5bbf3
fix: Send message with "enter" also do new line (#5961)
* fix: Send message with "enter" also do new line

* chore: Review fixes

* chore: Naming fixes

* chore: Minor fixes

* chore: Fix line break issue when cmd plus enter enabled
2022-12-06 11:25:49 +05:30
OMAR.A
a82b9991b3
fix: Update the link used for email address change in the confirmation mail (#5937) 2022-12-05 16:17:27 -08:00
Sojan Jose
06434bc655
chore: Update translations from Crowdin (#5952) 2022-12-05 16:04:49 -08:00
Jordan Brough
b9fd1d88ea
Escape search term before building regular expression (#5994)
When doing a conversation search, if the search term includes any regular
expression characters and the search returns results, then this function would
throw an exception.

For example, if a conversation includes the text "+15555550111" and you search
for "+15555550111" then you get an exception like:

> Invalid regular expression: /(+15555550111)/: Nothing to repeat

Because the "+" is not escaped.
2022-12-05 16:02:43 -08:00
dependabot[bot]
8004f67efe
chore(deps): bump decode-uri-component from 0.2.0 to 0.2.2 (#6016)
Bumps [decode-uri-component](https://github.com/SamVerschueren/decode-uri-component) from 0.2.0 to 0.2.2.
- [Release notes](https://github.com/SamVerschueren/decode-uri-component/releases)
- [Commits](https://github.com/SamVerschueren/decode-uri-component/compare/v0.2.0...v0.2.2)

---
updated-dependencies:
- dependency-name: decode-uri-component
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-05 16:01:27 -08:00
Sivin Varghese
87ef39ad9c
feat: Add the ability to search emojis (#5928) 2022-12-05 16:00:42 -08:00
Vishnu Narayanan
c3b6e1a732
fix: update heroku app.json to use premium plans (#5349)
* fix: update heroku app.json to use premium plans

Use premium/paid dynos and addons as Heroku is set to deprecate free dynos/addons.

* fix: set default stack to heroku-20

* chore: update heroku app.json to use new dyno types

web and worker to use basic dynos
redis and postgres to use mini
2022-12-05 21:15:44 +05:30
Muhsin Keloth
c9cae01cb4
fix: Support audio in safari browser (#5943) 2022-12-05 12:30:56 +05:30
Sivin Varghese
613fb0b064
fix: Unable to add emoji exactly where the cursor is at (#5865)
* fix: Unable to add emoji exactly where the cursor is at

* chore: Minor fixes

* chore: Review fixes

* chore: Code clean up

* chore: Review fixes

* chore: Minor fixes

* chore: Review fixes
2022-12-05 11:16:00 +05:30
Tejaswini Chile
0b5c82ad5f
fix: Save hostname for the custom domain in the portal (#5984)
* fix: Save hostname for the custom domain in the portal
2022-12-03 20:37:56 +05:30
Sivin Varghese
c8ec397c79
fix: Update missing features in unattended / mentions view (#6009)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-12-02 15:07:38 -08:00
Tejaswini Chile
a08099bbcc
fix: Custom attr filter on conversations (#5978) 2022-12-02 10:37:32 +05:30
Pranav Raj S
e35638588a
fix: Avoid conversationId getting undefined in unattended view (#6001) 2022-11-30 20:37:58 -08:00
Tejaswini Chile
3083f74d45
fix: Update inbox json, removing password (#5981)
- Filter restricted inbox attributes in APIs for agents 

Fixes chatwoot/product#668

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2022-11-30 13:04:46 +03:00
Nithin David Thomas
85b52a1d3f
feat: Add a view for unattended conversations (#5890)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-11-29 08:18:00 -08:00
Nithin David Thomas
c94ba16565
fix: Updates logic to insert canned response into editor (#5880)
* fix: Updates logic to insert canned response into editor

* Removes commented code

* Parse incoming canned text as markdown
2022-11-29 19:46:55 +05:30
giquieu
0cad3bed71
fix: Files in Whatsapp arrives with a different Name (#5907)
- Add file name parameter to the WhatsApp attachment payload

fixes: #4481

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2022-11-29 16:54:49 +03:00
Clairton Rodrigo Heinzen
edcbd53425
feat: Read/Delivery status for Whatsapp Cloud API (#5157)
Process field statuses received in webhook WhatsApp cloud API

ref: #1021

Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: Nithin David <1277421+nithindavid@users.noreply.github.com>
2022-11-29 15:51:37 +03:00
Tejaswini Chile
a397f01692
fix: unassign team activity message (#5969) 2022-11-29 13:35:08 +05:30
Vishnu Narayanan
4755031e1d
feat: use sendmail for email as default (#5899)
* feat: use sendmail for the email if SMTP_ADDRESS is empty
2022-11-29 09:13:27 +05:30
Tejaswini Chile
fc9fc5a661
fix: destroy bulk service (#5921) 2022-11-25 22:46:50 +05:30
Sojan Jose
b05d06a28a
feat: Ability to lock to single conversation (#5881)
Adds the ability to lock conversation to a single thread for Whatsapp and Sms Inboxes when using outbound messages.

demo: https://www.loom.com/share/c9e1e563c8914837a4139dfdd2503fef

fixes: #4975

Co-authored-by: Nithin David <1277421+nithindavid@users.noreply.github.com>
2022-11-25 13:01:04 +03:00
Vishnu Narayanan
8813c77907
chore: add ph-no-capture css class to messages (#5932)
This ensure posthog replaces the corresponding elements with an empty
block in recordings.

ref: https://posthog.com/manual/recordings#ignoring-sensitive-elements
2022-11-24 10:42:28 -08:00
Sojan Jose
8ea0660862
chore: Add reauthorization prompt for Whatsapp Channel (#5929)
- Add reauthorization prompt for Whatsapp Channel

fixes: #5782
2022-11-24 14:50:32 +03:00
Sojan Jose
606fc9046a
feat: Allow users to mark a conversation as unread (#5924)
Allow users to mark conversations as unread.
Loom video: https://www.loom.com/share/ab70552d3c9c48b685da7dfa64be8bb3

fixes: #5552

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-11-24 10:55:45 +03:00
Pranav Raj S
e593e516b8
chore: Enable Latvian (lv) language (#5920) 2022-11-22 12:54:13 -08:00
Sojan Jose
b5f8524167
chore: Update translations from Crowdin (#5883) 2022-11-22 11:42:29 -08:00
Sivin Varghese
b765e17457
fix: Sidebar missing at 1200px width (#5917) 2022-11-22 10:48:15 -08:00
Fayaz Ahmed
db37bfea06
fix: Add word-break to canned response list table content 2022-11-22 23:53:15 +05:30
Pranav Raj S
16bfd68d95
chore: Allow admins to choose the agent bot from the UI (#5895) 2022-11-18 08:54:32 -08:00
Fayaz Ahmed
33aacb3401
Add team option in bulk actions (#5885) 2022-11-18 14:44:36 +05:30
Fayaz Ahmed
47676c3cce
feat: Allow agent-bots to be created from the UI (#4153)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-11-17 22:15:58 -08:00
Tejaswini Chile
9bfbd528ef
feat: Assign team with teams none option (#5871) 2022-11-17 14:15:16 +05:30
Tejaswini Chile
e85f998a08
feat: Remove labels in macro (#5875) 2022-11-17 12:30:47 +05:30
Pranav Raj S
66044a0dc3
feat: Show last non-activity messages in the chat list (#5864) 2022-11-16 15:43:55 -08:00
Pranav Raj S
9b9c019de0
feat: Add support for after param in messages API (#5861) 2022-11-16 08:11:48 -08:00
dependabot[bot]
86e0ff76c5
chore(deps): bump loader-utils from 1.4.1 to 1.4.2 (#5859)
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 1.4.1 to 1.4.2.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v1.4.2/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v1.4.1...v1.4.2)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-15 18:56:59 -08:00
dependabot[bot]
abbb6ac676
chore(deps): bump minimatch from 3.0.4 to 3.1.2 (#5860)
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.0.4 to 3.1.2.
- [Release notes](https://github.com/isaacs/minimatch/releases)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.0.4...v3.1.2)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-15 18:56:50 -08:00
Pranav Raj S
42b466bda2
chore: Cleanup the design in widget builder (#5852) 2022-11-15 18:56:24 -08:00
Sojan
956837ded5 Merge branch 'release/2.11.0' into develop 2022-11-16 00:45:43 +00:00
Sojan
f0ef497005 Bump version to 2.11.0 2022-11-16 00:40:38 +00:00
Sojan Jose
8e2da837d4
chore: Update translations from Crowdin 2022-11-16 00:37:29 +00:00
Sojan Jose
e7f1a9ab4d
chore: Enable Macros for all accounts (#5858)
- migrations to enable macros for all accounts
2022-11-16 00:33:09 +00:00
Tejaswini Chile
826a735cdb
fix: Assign agent action changes (#5827) 2022-11-15 13:15:27 +05:30
Tejaswini Chile
38ab3c36db
fix: send label list not object in event data presenter (#5853) 2022-11-15 12:20:06 +05:30
Muhsin Keloth
b5f7be0cd2
fix: Full name update when creating a conversation without an email id (#5832)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-11-14 19:39:46 -08:00
Tejaswini Chile
efceaec950
fix: Activity message generation for team assignemnt automation (#5846) 2022-11-11 19:07:24 +05:30
Fayaz Ahmed
6aba352e0d
fix: Single select for agents and teams in Macros actions (#5837)
* Ignore settings.json

* Single select dropdown for teams and agents
2022-11-10 20:09:33 +05:30
Fayaz Ahmed
9eb861a3b7
feat: Custom attributes in automations and refactor (#4548)
* Custom attributes

* Custom Attrs Manifest

* Fix dropdown values for custom attributes

* Handle edit mode for custom attributes

* Ported duplicate logic to a mixin

* fix Code climate issue

* Fix Codeclimate complexity warning

* Bug fix - Custom attributes getting duplicated

* Bug fixes and Code Climate issue fix

* Code Climate Issues Breakdown

* Fix test spec

* Add labels for Custom attributes in dropdown

* Refactor

* Refactor Automion mixin

* Refactor Mixin

* Refactor getOperator

* Fix getOperatorType

* File name method refactor

* Refactor appendNewCondition

* spec update

* Refactor methods

* Mixin Spec update

* Automation Mixins Test Specs

* Mixin Spec Rerun

* Automation validations mixin spec

* Automation helper test spec

* Send custom_attr key

* Fix spec fixtures

* fix: Changes for custom attribute type and lower case search

* fix: Specs

* fix: Specs

* fix: Ruby version change

* fix: Ruby version change

* Removes Lowercased values and fix label value in api payload

* Fix specs

* Fixed Query Spec

* Removed disabled labels if no attributes are present

* Code Climate Fixes

* fix: custom attribute with indifferent access

* fix: custom attribute with indifferent access

* Fix specs

* Minor label fix

* REtrigger circle ci build

* Update app/javascript/shared/mixins/specs/automationMixin.spec.js

* Update app/javascript/shared/mixins/specs/automationMixin.spec.js

* fix: Custom attribute case insensitivity search

* Add missing reset action method to input

* Set team_input to single select instead of multiple

* fix: remove value case check for date,boolean and number data type

* fix: cognitive complexity

* fix: cognitive complexity

* fix: Fixed activity message for automation system

* fix: Fixed activity message for automation system

* fix: Fixed activity message for automation system

* fix: codeclimate

* fix: codeclimate

* fix: action cable events for label update

* fix: codeclimate, conversation modela number of methods

* fix: codeclimate, conversation modela number of methods

* fix: codeclimate, conversation modela number of methods

* fix: codeclimate, conversation modela number of methods

* Fix margin bottom for attachment button

* Remove margin bottom to avoid conflict from macros

* Fix automation action query generator using the right key

* fix: not running message created event for activity message

* fix: not running message created event for activity message

* codeclimate fix

* codeclimate fix

* codeclimate fix

* Update app/javascript/dashboard/mixins/automations/methodsMixin.js

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

* Update app/javascript/shared/mixins/specs/automationHelper.spec.js

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

* Update app/javascript/dashboard/helper/automationHelper.js

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

* Update app/javascript/dashboard/mixins/automations/methodsMixin.js

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

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
Co-authored-by: Tejaswini <tejaswini@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2022-11-10 10:53:29 +05:30
Sojan Jose
3184c8964d
chore: Update translations from Crowdin (#5831) 2022-11-09 20:26:30 -08:00
Nithin David Thomas
865346223b
fix: Add missing dropdown method (#5830)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-11-09 17:27:04 -08:00
Nithin David Thomas
4f82859bba
chore: Design improvements for thumbnail and dropdown (#5822) 2022-11-09 16:52:30 -08:00
Fayaz Ahmed
47c90e2085
feat: Add a loader till the dashboard app is loaded (#5814)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2022-11-09 10:06:09 -08:00
Tejaswini Chile
7352b928da
feat: Add assign agent option in macro (#5821)
Co-authored-by: fayazara <fayazara@gmail.com>
2022-11-09 09:52:48 -08:00
Muhsin Keloth
5febdde938
chore: Add feature flag for mobile v2 (#5797) 2022-11-08 21:56:02 -08:00
Nithin David Thomas
d39ace5a6b
feat: Improve image loading for thumbnails (#5823) 2022-11-08 21:05:13 -08:00
Sojan Jose
e2059cfc5b
fix: SocketError: getaddrinfo: for imap channels (#5824)
fixes: #5431
2022-11-08 20:23:46 -08:00
Sojan Jose
2e42821c48
chore: Update translations from Crowdin (#5810) 2022-11-08 09:36:24 -08:00
dependabot[bot]
16d59f4bb0
chore(deps): bump loader-utils from 1.4.0 to 1.4.1 (#5816)
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 1.4.0 to 1.4.1.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v1.4.1/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v1.4.0...v1.4.1)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-08 09:33:35 -08:00
Nithin David Thomas
0d9ed0674b
feat: Add store for conversation watchers (#5808)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-11-08 09:33:14 -08:00
salman652
6ff0c93659
feat: Ability to receive location on whatsapp inbox (#5742)
- Ability to  receive location messages on WhatsApp Inbox

ref: https://github.com/chatwoot/chatwoot/issues/3398

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2022-11-07 21:36:47 -08:00
Nithin David Thomas
20406dce01
feat: Adds component to show location based messages (#5809)
- Adds a component to show location-type messages in the dashboard
2022-11-07 20:50:07 -08:00
smartdev58
b50890d1b5
chore: Update data format for platform API feature management (#5718)
Updated JSON data format for Platform API for account creation update API endpoints features flags to accept false values on each feature. 

fixes: #5717
2022-11-07 17:49:45 -08:00
Tejaswini Chile
48373628a1
fix: Macros authorizations (#5779)
Macros policy update.

ref: #5730
2022-11-07 17:46:00 -08:00
CristianDuta
479d88a480
fix: Identifier not persisted on customer created via Inbox API Channel (#5804)
- Fixes the identifier not being used to identify the contact, this results in having a new contact created every time the email or phone is not supplied.

Fixes: #5704
2022-11-07 16:02:45 -08:00
Pranav Raj S
526722dffa
fix: Update invalid payload for template messages (#5802) 2022-11-07 15:21:47 -08:00
Tejaswini Chile
a23974d8b9
Feat/5733 Add private note action in macros (#5805) 2022-11-07 22:12:10 +05:30
matenauta
894234e777
chore: Remove quotes from SMTP_USERNAME (#5800)
Removing quotes related to https://github.com/chatwoot/chatwoot/issues/4787
2022-11-06 16:06:18 -08:00
Nithin David Thomas
dc3ee3464b
feat: Add component for grouped thumbnails (#5796) 2022-11-03 22:38:45 -07:00
Pranav Raj S
4bb5e812ba
fix: Allow ending the conversation if snoozed or pending (#5793) 2022-11-03 13:47:24 -07:00
Nithin David Thomas
8bd5ba187a
fix: Fix widget thumbnail not rendering background colors (#5791) 2022-11-02 22:13:12 -07:00
mjattiot
c121b44df4
fix: GCP Redis managed don't support redis client setname command (#4422)
If using Redis on GCP, actionable will attempt to use the Redis client setname command. However, the command is not supported.
So Introducing `REDIS_DISABLE_CLIENT_COMMAND` environment variable will let you solve this issue. 

ref: https://github.com/rails/rails/issues/38244#issuecomment-575454444
2022-11-02 19:14:51 -07:00
CristianDuta
4c43330b15
feat: Add inbox details endpoint (#5549)
This change targets the public API and is related to the Inbox with channel type API.
Exposes public inbox details under /public/api/v1/inboxes/{inbox_identifier}. This allows access to feature flags and business hours configured for the inbox.

ref: #5514
2022-11-02 19:05:03 -07:00
Sojan Jose
8b659de73d
chore: Use connection_pool for redis (#5790)
fixes: #3199
2022-11-02 17:31:20 -07:00
Tejaswini Chile
936c2ec7e2
fix: Ensure Automation rule executions are account scoped (#5768)
This is one possible fix to tackle with cached automation rules when automation gets called on two different accounts with the same event name.

Fixes: https://github.com/chatwoot/product/issues/605
2022-11-02 17:24:35 -07:00
smartdev58
9c0cce0392
chore: Mark conversations as pending instead of reopen when bot is present (#5751)
With this change, conversations are marked as pending instead of reopening when a bot is connected to that Inbox. 

Fixes: #5668
2022-11-02 13:54:56 -07:00
Arkadiy Ayvazyan
86ca7f4a8d
fix: Autoloading during initialization deprecation warning (#5628)
Autoloading during initialization deprecation warning fix
fixes: #4012
2022-11-01 20:19:42 -07:00
Sojan Jose
6cfd594d85
fix: flaky test whatsapp_cloud_service_spec.rb:17 (#5786)
Fix flaky test: flaky /spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb:17

example build: https://app.circleci.com/pipelines/github/chatwoot/chatwoot/48955/workflows/a2959d25-19ea-4812-ba15-5aac69c43265/jobs/49523

Cause: factory bot can create phone numbers of length 15 digits, which is valid e164, while our regex only handled up to 14 digits
2022-11-01 19:51:42 -07:00
Sivin Varghese
e1190fd9bf
fix: Text colour is getting merged with the light background colour (#5773) 2022-11-02 05:52:00 +05:30
Stephen Paul Weber
f2753df8df
chore: Use multiple connections in Redis connection pool (#5574)
- The initializer set up a connection pool, but both pools created namespace wrappers around a single global connection. Splitting them up.
2022-11-01 16:49:26 -07:00
Tejaswini Chile
00e06e5139
fix: Unsupported message type (#5783)
- fix handling unsupported message types
2022-11-01 15:21:45 -07:00
Fayaz Ahmed
be516a5ea6
fix: Filename issue while editing a macro with attachment action (#5775) 2022-11-01 16:20:09 +05:30
givetimetolife
f8d9a27d7a
fix: db/seeds.rb field contact_inbox is undefined (#5743)
fixes rails db:chatwoot_prepare error: field contact_inbox is undefined
2022-11-01 00:52:07 -07:00
Muhsin Keloth
92724576af
fix: Update conversation read status indicator logic (#5777) 2022-10-31 09:35:48 -07:00
Tejaswini Chile
a0606d36f6
fix: Set message sender to macros excution (#5769) 2022-10-31 19:33:12 +05:30
Muhsin Keloth
2073a23d5c
chore: Fix Thumbnail component specs (#5776) 2022-10-28 14:19:12 +05:30
Sivin Varghese
b20f5e5cef
feat: Adds URL validation for domain and home page links (#5761) 2022-10-28 06:21:38 +05:30
Nithin David Thomas
3b09840d39
chore: Fix import paths in storybook (#5772) 2022-10-28 01:42:24 +05:30
David Kubeš
2aa99ee137
fix: Fix timestamp auto-update in conversations chat list (#5640)
* fix: Fix timestamp auto-update

* fix: rewrite `setInterval` to `setTimeout`

* fix: change refresh time logic

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-28 01:05:39 +05:30
David Kubeš
89d7e4ead6
chore: refactor thumbnail (#5682) 2022-10-28 01:02:23 +05:30
Sojan Jose
12cd15b6ad
chore: Disable email processing for suspended accounts (#5762)
- Disable email processing for suspended accounts
2022-10-26 03:40:47 -07:00
Sivin Varghese
352558dd11
fix: Update color for the action button used in card messages (#5740) 2022-10-25 16:17:05 -07:00
Pranav Raj S
bedb2cab63
Use @page-change instead of on-page-change (#5749) 2022-10-26 03:46:09 +05:30
Nithin David Thomas
abe439594e
fix: Add preference to choose browser lang for widget settings page (#5726)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-25 15:04:29 -07:00
David Kubeš
bcde84b5b5
feat: Add ability to paste file/image from clipboard (#5627)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2022-10-25 13:35:11 +05:30
Fayaz Ahmed
06e2219110
chore: Improve macros stability (#5700)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-24 23:49:19 -07:00
Fayaz Ahmed
c3ec1d4f8a
feat: Add the ability for the agents to execute a macro (#5698)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-24 20:33:59 -07:00
Nithin David Thomas
d54392cb53
fix: Update max-width to fix overflow for emoji selector (#5727) 2022-10-24 19:59:36 -07:00
Sojan Jose
3a71fe3260
chore: Stop duplicate websocket updates for conversation updates (#5711)
- remove extra dispatch for label updates
2022-10-21 18:06:02 -07:00
jacsonsantospht
af020f446e
fix: check the content type for the file when uploading from cloud storage (#5378)
When sending the message with audio, only the signed id of the file is sent.
In the back end check only the UploadedFile type.
The attachment has the default file type image, now it gets the content type from the signed id

Fixes: #5375

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2022-10-21 18:05:36 -07:00
Pranav Raj S
6823b04e5b
fix: Update public partials to fix help center search (#5713) 2022-10-21 17:32:01 -07:00
Nithin David Thomas
8d5a9a9daa
fix: Show account switch modal from portal dashboard (#5712) 2022-10-21 17:24:52 -07:00
Nithin David Thomas
c3426929d7
chore: Refactor sidebar components used in help center (#5695)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-21 16:43:43 -07:00
Tejaswini Chile
4a299a9441
fix: Add an action cable events for label updates (#5694)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-21 16:12:35 -07:00
Tejaswini Chile
782165478b
fix: Update article count in portal admin dashboard (#5647)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-21 13:43:15 -07:00
Pranav Raj S
95cc55d043
fix: Update pagination logic in the help center (#5693) 2022-10-20 20:05:17 -07:00
Tejaswini Chile
a274a1702a
chore: Macros enhancement (#5609)
- Fixed send_attachment and send_email_transcript
- Fixed duplicate activity messages
- Fixed Order of execution

Fixes: #5584
2022-10-20 19:41:48 -07:00
Chad Burggraf
4d0b302802
Avoid crashing when AudioContext is not available. (#5641)
#4942. We've also had a number of crash reports show up in our logs related to this. Typically we see "undefined is not a constructor" because of the `window.AudioContext || window.webkitAudioContext` returns `undefined`.
2022-10-20 19:13:20 -07:00
Pranav Raj S
a8561cd798
fix: Add portal pack to tailwind config (#5686) 2022-10-19 21:49:34 -07:00
smartdev58
fa73b5290c
chore: Allow feature flag parameters in platform API for account creation (#5589)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-19 21:16:36 -07:00
DanielGannage
a3277b45af
feat: Update conversation attributes in realtime when they changed (#5542)
Co-authored-by: daniel gannage <daniel@pxdel.com>
Co-authored-by: Tejaswini Chile <tejaswini@chatwoot.com>
2022-10-19 17:26:10 -07:00
Nusret Ozates
10d86fbb35
chore: Ability to Remove password info from sentinel config (#4550)
Introduce the REDIS_SENTINEL_PASSWORD environment variable to customize the behaviour of sentinel passwords. 

Co-authored-by: EXT02D22861 <nusret.ozates@consultant.turkcell.com.tr>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2022-10-19 17:25:16 -07:00
Fayaz Ahmed
22d5703b92
feat: Macros listing and Editor (#5606)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-19 17:13:13 -07:00
Nithin David Thomas
1fb1be3ddc
feat: Add search functionality for public portal (#5683)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-19 17:09:32 -07:00
Vishnu Narayanan
bce0bb8acb
chore: Improve pr and bug report templates (#5556)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-19 13:45:32 -07:00
Tejaswini Chile
ceaffe862a
Fix: Handled IG unsupported file type (#5650)
We get 'unsupported_type' in the web-hook event only when Instagram faces issues processing the attachments. https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook/ according to their document, we are handling the given types and are ignoring this one for now.

Fixes: #5428
2022-10-19 13:44:17 -07:00
Sojan Jose
199f462af4
chore: Update translations from Crowdin (#5663) 2022-10-19 13:27:10 -07:00
David Kubeš
c542d2e0ff
chore: Refactor urlParamsHelper.js (#5639) 2022-10-19 13:20:40 -07:00
Fayaz Ahmed
6bc34db932
fix: Make links more accessible on helpcenter public views (#5681)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-19 13:07:44 -07:00
Tejaswini Chile
0343acdb7e
fix: ensure contact_inbox if contact exists(#5667)
- Fixing Instagram issue for existing contacts in the inbox
2022-10-19 12:56:39 -07:00
Nitin Ramnani
f740727177
Added testcases for AddReminder Component (#5538)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2022-10-20 00:54:08 +05:30
Fayaz Ahmed
3de8f256cb
fix: Avoid overflowing submenus in conversation context menu (#5113)
* Scroll overflowing content inside submenus

* Disable submenus if options are not available
2022-10-19 12:23:53 -07:00
Muhsin Keloth
2e7ab484bd
fix: Discard invalid contact attributes in widget conversation end point (#5664)
Fixes: chatwoot/product#601
2022-10-18 13:16:29 -07:00
smartdev58
e34e975776
chore: ability to delete user in super admin console
fixes: #4164
2022-10-18 01:05:28 -07:00
Sojan
a1ce188dab Merge branch 'release/2.10.0' into develop 2022-10-17 19:38:42 -07:00
Sojan
2f7a16ae16 Bump version to 2.10.0 2022-10-17 19:31:53 -07:00
Pranav Raj S
71ca530292
fix: Fix typo in help center (#5661) 2022-10-17 18:59:22 -07:00
Pranav Raj S
e19c6d5671
chore: Add editor toggle for API inbox (#5660) 2022-10-17 18:52:51 -07:00
Sojan Jose
2423def8e8
chore: Add attachments key to message_created webhook payload (#5659)
- Add attachments key to `message_created` webhook payload
2022-10-17 17:36:56 -07:00
Nithin David Thomas
1c44e43c43
fix: Fix overflow issue for category name in article list (#5658)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-17 15:59:18 -07:00
Sivin Varghese
444809cc68
fix: Added validation for check box in the pre-chat form (#5648) 2022-10-17 15:00:02 -07:00
Pranav Raj S
20b4a91122
chore: Add feature flags in the settings console (#5657) 2022-10-17 14:59:44 -07:00
Nithin David Thomas
73f5595762
chore: Sync colors from dashboard to tailwind config (#5656) 2022-10-17 13:13:02 -07:00
Sojan Jose
704554d453
chore: Update translations (#5644) 2022-10-17 11:14:29 -07:00
Muhsin Keloth
706ab872f3
fix: Disable name in pre-chat form if the name is already provided in setUser (#5466) 2022-10-17 20:59:17 +05:30
Nithin David Thomas
252eda14c6
chore: Add woot button to message context menu (#5638) 2022-10-16 16:48:32 -07:00
Simon Pankovski
ce3730d640
fix: Avoid email icon getting distorted (#5633)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-14 13:38:08 -07:00
Sojan Jose
db53af91e7
chore (#5636)
* New translations conversation.json (Latvian)

* New translations bulkActions.json (Portuguese, Brazilian)

* New translations agentMgmt.json (Latvian)

* New translations teamsSettings.json (Latvian)

* New translations contactFilters.json (Latvian)

* New translations automation.json (Latvian)

* New translations attributesMgmt.json (Latvian)

* New translations labelsMgmt.json (Latvian)

* New translations settings.json (Latvian)

* New translations integrations.json (Latvian)

* New translations inboxMgmt.json (Latvian)

* New translations generalSettings.json (Latvian)

* New translations contact.json (Latvian)

* New translations helpCenter.json (Latvian)
2022-10-14 12:46:41 -07:00
David Kubeš
a6960dc2d3
chore: Refactor widget (#5621) 2022-10-14 09:13:11 +05:30
Sojan Jose
e310230f62
chore: Refactor Contact Inbox Builders (#5617)
- Remove duplicate code and move everything to builders
- fixes: #4680
2022-10-13 15:12:04 -07:00
Nithin David Thomas
1f271356ca
feat: Update the design for dropdown buttons (#5625) 2022-10-13 13:36:42 -07:00
Nithin David Thomas
bf4338ef9e
feat: Make category name in article table clickable (#5626)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-13 13:35:11 -07:00
Tejaswini Chile
a533f43fbf
fix: Stop raising errors for unsupported Whatsapp messages (#5541)
- Handle unsupported Whatsapp messages
2022-10-13 13:31:49 -07:00
Vishnu Narayanan
8f4944fda0
chore: revert arm64 docker build in gh action (#5619)
ref: https://github.com/chatwoot/chatwoot/pull/5575

https://github.com/chatwoot/chatwoot/pull/5575#issuecomment-1277208625
2022-10-13 13:46:28 +05:30
1197 changed files with 39354 additions and 5458 deletions

View file

@ -54,3 +54,5 @@ exclude_patterns:
- 'app/javascript/widget/i18n/index.js'
- 'app/javascript/survey/i18n/index.js'
- 'app/javascript/shared/constants/locales.js'
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'

View file

@ -34,6 +34,11 @@ REDIS_SENTINELS=
# You can find list of master using "SENTINEL masters" command
REDIS_SENTINEL_MASTER_NAME=
# By default Chatwoot will pass REDIS_PASSWORD as the password value for sentinels
# Use the following environment variable to customize passwords for sentinels.
# Use empty string if sentinels are configured with out passwords
# REDIS_SENTINEL_PASSWORD=
# Redis premium breakage in heroku fix
# enable the following configuration
# ref: https://github.com/chatwoot/chatwoot/issues/2420
@ -51,13 +56,14 @@ RAILS_MAX_THREADS=5
# The email from which all outgoing emails are sent
# could user either `email@yourdomain.com` or `BrandName <email@yourdomain.com>`
MAILER_SENDER_EMAIL="Chatwoot <accounts@chatwoot.com>"
MAILER_SENDER_EMAIL=Chatwoot <accounts@chatwoot.com>
#SMTP domain key is set up for HELO checking
SMTP_DOMAIN=chatwoot.com
# the default value is set "mailhog" and is used by docker-compose for development environments,
# Set the value to "mailhog" if using docker-compose for development environments,
# Set the value as "localhost" or your SMTP address in other environments
SMTP_ADDRESS=mailhog
# If SMTP_ADDRESS is empty, Chatwoot would try to use sendmail(postfix)
SMTP_ADDRESS=
SMTP_PORT=1025
SMTP_USERNAME=
SMTP_PASSWORD=

View file

@ -6,6 +6,7 @@ labels: 'Bug'
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
@ -16,11 +17,11 @@ Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
4. See the error
**Expected behavior**
A clear and concise description of what you expected to happen.
Share a clear and concise description of what you expected to happen.
**Screenshots**
@ -28,27 +29,50 @@ If applicable, add screenshots to help explain your problem.
**Browser logs**
Share the browser logs to debug the issue further
Share the browser logs to debug the issue further.
**Server logs**
Share the server logs to debug the issue further
Share the server logs to debug the issue further.
**Environment**
Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self hosted installation of Chatwoot. If you are using a self hosted installation of Chatwoot describe the type of deployment (Docker/Linux VM installation/Heroku)
Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self-hosted installation of Chatwoot. If you are using a self-hosted installation of Chatwoot, describe the type of deployment (Docker/Linux VM installation/Heroku/Kubernetes/Other).
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- [ ] app.chatwoot.com (Chatwoot Cloud)
- [ ] Self-hosted
- - [ ] Linux VM
- - [ ] Docker
- - [ ] Kubernetes
- - [ ] Heroku
- - [ ] Other (Please specify)
**Desktop (please complete the following information)** (If applicable)
- OS: [e.g. Linux, Windows, MacOS]
- Browser [e.g. chrome, firefox, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
**Smartphone (please complete the following information)** (If applicable)
- Device: [e.g. iPhone6, Pixel7]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Browser [e.g. stock browser, firefox, safari]
- Version [e.g. 22]
**Docker** (If applicable)
Please share the output of the following.
- `docker version`
- `docker info`
- `docker-compose version`
**Cloud Provider** (If applicable)
- [ ] AWS
- [ ] GCP
- [ ] Azure
- [ ] DigitalOcean
- [ ] Others
**Additional context**
Add any other context about the problem here.

View file

@ -2,8 +2,7 @@
## Description
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
Please include a summary of the change and issue(s) fixed. Also, mention relevant motivation, context, and any dependencies that this change requires.
Fixes # (issue)
## Type of change
@ -12,18 +11,18 @@ Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected)
- [ ] This change requires a documentation update
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration.
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings

View file

@ -58,6 +58,6 @@ jobs:
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64,linux/arm64
platforms: linux/amd64
push: true
tags: ${{ env.DOCKER_TAG }}

2
.gitignore vendored
View file

@ -60,3 +60,5 @@ test/cypress/videos/*
/config/master.key
/config/*.enc
.vscode/settings.json

1
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1 @@
{}

View file

@ -4,7 +4,7 @@ ruby '3.0.4'
##-- base gems for rails --##
gem 'rack-cors', require: 'rack/cors'
gem 'rails', '~>6.1'
gem 'rails', '~> 6.1', '>= 6.1.6.1'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false
@ -56,7 +56,7 @@ gem 'activerecord-import'
gem 'dotenv-rails'
gem 'foreman'
gem 'puma'
gem 'webpacker', '~> 5.x'
gem 'webpacker', '~> 5.4', '>= 5.4.3'
# metrics on heroku
gem 'barnes'
@ -94,7 +94,7 @@ gem 'ddtrace'
gem 'elastic-apm'
gem 'newrelic_rpm'
gem 'scout_apm'
gem 'sentry-rails', '~> 5.3'
gem 'sentry-rails', '~> 5.3', '>= 5.3.1'
gem 'sentry-ruby', '~> 5.3'
gem 'sentry-sidekiq', '~> 5.3'
@ -175,7 +175,7 @@ group :development, :test do
gem 'mock_redis'
gem 'pry-rails'
gem 'rspec_junit_formatter'
gem 'rspec-rails', '~> 5.0.0'
gem 'rspec-rails', '~> 5.0.3'
gem 'rubocop', require: false
gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false

View file

@ -398,7 +398,7 @@ GEM
llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
loofah (2.18.0)
loofah (2.19.1)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@ -427,14 +427,14 @@ GEM
netrc (0.11.0)
newrelic_rpm (8.9.0)
nio4r (2.5.8)
nokogiri (1.13.7)
nokogiri (1.13.10)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
nokogiri (1.13.7-arm64-darwin)
nokogiri (1.13.10-arm64-darwin)
racc (~> 1.4)
nokogiri (1.13.7-x86_64-darwin)
nokogiri (1.13.10-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.13.7-x86_64-linux)
nokogiri (1.13.10-x86_64-linux)
racc (~> 1.4)
oauth (0.5.10)
orm_adapter (0.5.0)
@ -459,7 +459,7 @@ GEM
pundit (2.2.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.6.0)
racc (1.6.1)
rack (2.2.4)
rack-attack (6.6.1)
rack (>= 1.0, < 3)
@ -488,8 +488,8 @@ GEM
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.4.3)
loofah (~> 2.3)
rails-html-sanitizer (1.4.4)
loofah (~> 2.19, >= 2.19.1)
railties (6.1.6.1)
actionpack (= 6.1.6.1)
activesupport (= 6.1.6.1)
@ -765,12 +765,12 @@ DEPENDENCIES
rack-attack
rack-cors
rack-timeout
rails (~> 6.1)
rails (~> 6.1, >= 6.1.6.1)
redis
redis-namespace
responders
rest-client
rspec-rails (~> 5.0.0)
rspec-rails (~> 5.0.3)
rspec_junit_formatter
rubocop
rubocop-performance
@ -778,7 +778,7 @@ DEPENDENCIES
rubocop-rspec
scout_apm
seed_dump
sentry-rails (~> 5.3)
sentry-rails (~> 5.3, >= 5.3.1)
sentry-ruby (~> 5.3)
sentry-sidekiq (~> 5.3)
shoulda-matchers
@ -799,7 +799,7 @@ DEPENDENCIES
valid_email2
web-console
webmock
webpacker (~> 5.x)
webpacker (~> 5.4, >= 5.4.3)
webpush
wisper (= 2.0.0)
working_hours
@ -808,4 +808,4 @@ RUBY VERSION
ruby 3.0.4p208
BUNDLED WITH
2.3.18
2.3.16

View file

@ -41,16 +41,24 @@
"formation": {
"web": {
"quantity": 1,
"size": "FREE"
"size": "basic"
},
"worker": {
"quantity": 1,
"size": "FREE"
"size": "basic"
}
},
"stack": "heroku-20",
"image": "heroku/ruby",
"addons": [ "heroku-redis", "heroku-postgresql"],
"addons": [
{
"plan": "heroku-redis:mini"
},
{
"plan": "heroku-postgresql:mini"
}
],
"stack": "heroku-20",
"buildpacks": [
{
"url": "heroku/ruby"

View file

@ -1,13 +1,12 @@
# This Builder will create a contact inbox with specified attributes. If the contact inbox already exists, it will be returned.
# For Specific Channels like whatsapp, email etc . it smartly generated appropriate the source id when none is provided.
class ContactInboxBuilder
pattr_initialize [:contact_id!, :inbox_id!, :source_id]
pattr_initialize [:contact, :inbox, :source_id, { hmac_verified: false }]
def perform
@contact = Contact.find(contact_id)
@inbox = @contact.account.inboxes.find(inbox_id)
return unless ['Channel::TwilioSms', 'Channel::Sms', 'Channel::Email', 'Channel::Api', 'Channel::Whatsapp'].include? @inbox.channel_type
source_id = @source_id || generate_source_id
create_contact_inbox(source_id) if source_id.present?
@source_id ||= generate_source_id
create_contact_inbox if source_id.present?
end
private
@ -19,23 +18,37 @@ class ContactInboxBuilder
when 'Channel::Whatsapp'
wa_source_id
when 'Channel::Email'
@contact.email
email_source_id
when 'Channel::Sms'
@contact.phone_number
when 'Channel::Api'
phone_source_id
when 'Channel::Api', 'Channel::WebWidget'
SecureRandom.uuid
else
raise "Unsupported operation for this channel: #{@inbox.channel_type}"
end
end
def email_source_id
raise ActionController::ParameterMissing, 'contact email' unless @contact.email
@contact.email
end
def phone_source_id
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
@contact.phone_number
end
def wa_source_id
return unless @contact.phone_number
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
# whatsapp doesn't want the + in e164 format
@contact.phone_number.delete('+').to_s
end
def twilio_source_id
return unless @contact.phone_number
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
case @inbox.channel.medium
when 'sms'
@ -45,11 +58,11 @@ class ContactInboxBuilder
end
end
def create_contact_inbox(source_id)
::ContactInbox.find_or_create_by!(
def create_contact_inbox
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
contact_id: @contact.id,
inbox_id: @inbox.id,
source_id: source_id
source_id: @source_id
)
end
end

View file

@ -1,25 +1,47 @@
class ContactBuilder
pattr_initialize [:source_id!, :inbox!, :contact_attributes!, :hmac_verified]
# This Builder will create a contact and contact inbox with specified attributes.
# If an existing identified contact exisits, it will be returned.
# for contact inbox logic it uses the contact inbox builder
class ContactInboxWithContactBuilder
pattr_initialize [:inbox!, :contact_attributes!, :source_id, :hmac_verified]
def perform
contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id)
return contact_inbox if contact_inbox
find_or_create_contact_and_contact_inbox
# in case of race conditions where contact is created by another thread
# we will try to find the contact and create a contact inbox
rescue ActiveRecord::RecordNotUnique
find_or_create_contact_and_contact_inbox
end
build_contact_inbox
def find_or_create_contact_and_contact_inbox
@contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id) if source_id.present?
return @contact_inbox if @contact_inbox
ActiveRecord::Base.transaction(requires_new: true) do
build_contact_with_contact_inbox
update_contact_avatar(@contact) unless @contact.avatar.attached?
@contact_inbox
end
end
private
def build_contact_with_contact_inbox
@contact = find_contact || create_contact
@contact_inbox = create_contact_inbox
end
def account
@account ||= inbox.account
end
def create_contact_inbox(contact)
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
contact_id: contact.id,
inbox_id: inbox.id,
source_id: source_id
)
def create_contact_inbox
ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
source_id: @source_id,
hmac_verified: hmac_verified
).perform
end
def update_contact_avatar(contact)
@ -61,16 +83,4 @@ class ContactBuilder
account.contacts.find_by(phone_number: phone_number)
end
def build_contact_inbox
ActiveRecord::Base.transaction do
contact = find_contact || create_contact
contact_inbox = create_contact_inbox(contact)
update_contact_avatar(contact)
contact_inbox
rescue StandardError => e
Rails.logger.error e
raise e
end
end
end

View file

@ -0,0 +1,40 @@
class ConversationBuilder
pattr_initialize [:params!, :contact_inbox!]
def perform
look_up_exising_conversation || create_new_conversation
end
private
def look_up_exising_conversation
return unless @contact_inbox.inbox.lock_to_single_conversation?
@contact_inbox.conversations.last
end
def create_new_conversation
::Conversation.create!(conversation_params)
end
def conversation_params
additional_attributes = params[:additional_attributes]&.permit! || {}
custom_attributes = params[:custom_attributes]&.permit! || {}
status = params[:status].present? ? { status: params[:status] } : {}
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
# commenting this out to see if there are any errors, if not we can remove this in subsequent releases
# status = { status: 'pending' } if status[:status] == 'bot'
{
account_id: @contact_inbox.inbox.account_id,
inbox_id: @contact_inbox.inbox_id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_attributes,
custom_attributes: custom_attributes,
snoozed_until: params[:snoozed_until],
assignee_id: params[:assignee_id],
team_id: params[:team_id]
}.merge(status)
end
end

View file

@ -22,10 +22,9 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_contact
build_contact_inbox
build_message
end
ensure_contact_avatar
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
rescue StandardError => e
@ -35,15 +34,12 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
private
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact
end
def build_contact
return if contact.present?
@contact = Contact.create!(contact_params.except(:remote_avatar_url))
@contact_inbox = ContactInbox.find_or_create_by!(contact: contact, inbox: @inbox, source_id: @sender_id)
def build_contact_inbox
@contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: @sender_id,
inbox: @inbox,
contact_attributes: contact_params
).perform
end
def build_message
@ -54,19 +50,11 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
end
def ensure_contact_avatar
return if contact_params[:remote_avatar_url].blank?
return if @contact.avatar.attached?
Avatar::AvatarFromUrlJob.perform_later(@contact, contact_params[:remote_avatar_url])
end
def conversation
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: @sender_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id
))
@ -94,7 +82,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
contact_id: @contact_inbox.contact_id
}
end
@ -105,7 +93,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
message_type: @message_type,
content: response.content,
source_id: response.identifier,
sender: @outgoing_echo ? nil : contact
sender: @outgoing_echo ? nil : @contact_inbox.contact
}
end
@ -113,7 +101,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
{
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
account_id: @inbox.account_id,
remote_avatar_url: result['profile_pic'] || ''
avatar_url: result['profile_pic']
}
end

View file

@ -72,6 +72,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
def build_message
return if @outgoing_echo && already_sent_from_chatwoot?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
@ -117,6 +118,13 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
### Sample response
# {
# "object": "instagram",

View file

@ -35,7 +35,13 @@ class Messages::MessageBuilder
file: uploaded_attachment
)
attachment.file_type = file_type(uploaded_attachment&.content_type) if uploaded_attachment.is_a?(ActionDispatch::Http::UploadedFile)
attachment.file_type = if uploaded_attachment.is_a?(String)
file_type_by_signed_id(
uploaded_attachment
)
else
file_type(uploaded_attachment&.content_type)
end
end
end

View file

@ -2,7 +2,8 @@ class Messages::Messenger::MessageBuilder
include ::FileTypeHelper
def process_attachment(attachment)
return if attachment['type'].to_sym == :template
# This check handles very rare case if there are multiple files to attach with only one usupported file
return if unsupported_file_type?(attachment['type'])
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
@ -45,6 +46,7 @@ class Messages::Messenger::MessageBuilder
end
def update_attachment_file_type(attachment)
return if @message.reload.attachments.blank?
return unless attachment.file_type == 'share' || attachment.file_type == 'story_mention'
attachment.file_type = file_type(attachment.file&.content_type)
@ -61,6 +63,7 @@ class Messages::Messenger::MessageBuilder
story_sender = result['from']['username']
message.content_attributes[:story_sender] = story_sender
message.content_attributes[:story_id] = story_id
message.content_attributes[:image_type] = 'story_mention'
message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender)
message.save!
end
@ -73,6 +76,7 @@ class Messages::Messenger::MessageBuilder
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
@ -80,4 +84,10 @@ class Messages::Messenger::MessageBuilder
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{}
end
private
def unsupported_file_type?(attachment_type)
[:template, :unsupported_type].include? attachment_type.to_sym
end
end

View file

@ -5,9 +5,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
before_action :set_current_page, only: [:index]
def index
@articles_count = @portal.articles.count
@articles = @portal.articles
@articles = @articles.search(list_params) if list_params.present?
@portal_articles = @portal.articles
@all_articles = @portal_articles.search(list_params)
@articles_count = @all_articles.count
@articles = @all_articles.page(@current_page)
end
def create
@ -37,7 +38,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def portal
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
def article_params

View file

@ -5,6 +5,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
before_action :set_current_page, only: [:index]
def index
@current_locale = params[:locale]
@categories = @portal.categories.search(params)
end

View file

@ -2,8 +2,11 @@ class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts:
before_action :ensure_inbox, only: [:create]
def create
source_id = params[:source_id] || SecureRandom.uuid
@contact_inbox = ContactInbox.create!(contact: @contact, inbox: @inbox, source_id: source_id)
@contact_inbox = ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
source_id: params[:source_id]
).perform
end
private

View file

@ -2,7 +2,7 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
def index
@conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings
).where(inbox_id: inbox_ids, contact_id: @contact.id)
).where(inbox_id: inbox_ids, contact_id: @contact.id).order(id: :desc).limit(20)
end
private

View file

@ -134,8 +134,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
return if params[:inbox_id].blank?
inbox = Current.account.inboxes.find(params[:inbox_id])
source_id = params[:source_id] || SecureRandom.uuid
ContactInbox.create!(contact: @contact, inbox: inbox, source_id: source_id)
ContactInboxBuilder.new(
contact: @contact,
inbox: inbox,
source_id: params[:source_id]
).perform
end
def permitted_params

View file

@ -3,7 +3,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
include DateRangeHelper
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :contact_inbox, only: [:create]
before_action :inbox, :contact, :contact_inbox, only: [:create]
def index
result = conversation_finder.perform
@ -24,7 +24,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def create
ActiveRecord::Base.transaction do
@conversation = ::Conversation.create!(conversation_params)
@conversation = ConversationBuilder.new(params: params, contact_inbox: @contact_inbox).perform
Messages::MessageBuilder.new(Current.user, @conversation, params[:message]).perform if params[:message].present?
end
end
@ -75,10 +75,13 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def update_last_seen
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_column(:agent_last_seen_at, DateTime.now.utc)
@conversation.update_column(:assignee_last_seen_at, DateTime.now.utc) if assignee?
# rubocop:enable Rails/SkipsModelValidations
update_last_seen_on_conversation(DateTime.now.utc, assignee?)
end
def unread
last_incoming_message = @conversation.messages.incoming.last
last_seen_at = last_incoming_message.created_at - 1.second if last_incoming_message.present?
update_last_seen_on_conversation(last_seen_at, true)
end
def custom_attributes
@ -88,9 +91,18 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
private
def update_last_seen_on_conversation(last_seen_at, update_assignee)
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_column(:agent_last_seen_at, last_seen_at)
@conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present?
# rubocop:enable Rails/SkipsModelValidations
end
def set_conversation_status
status = params[:status] == 'bot' ? 'pending' : params[:status]
@conversation.status = status
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
# commenting this out to see if there are any errors, if not we can remove this in subsequent releases
# status = params[:status] == 'bot' ? 'pending' : params[:status]
@conversation.status = params[:status]
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
end
@ -109,51 +121,44 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
authorize @conversation.inbox, :show?
end
def inbox
return if params[:inbox_id].blank?
@inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @inbox, :show?
end
def contact
return if params[:contact_id].blank?
@contact = Current.account.contacts.find(params[:contact_id])
end
def contact_inbox
@contact_inbox = build_contact_inbox
# fallback for the old case where we do look up only using source id
# In future we need to change this and make sure we do look up on combination of inbox_id and source_id
# and deprecate the support of passing only source_id as the param
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
authorize @contact_inbox.inbox, :show?
end
def build_contact_inbox
return if params[:contact_id].blank? || params[:inbox_id].blank?
inbox = Current.account.inboxes.find(params[:inbox_id])
authorize inbox, :show?
return if @inbox.blank? || @contact.blank?
ContactInboxBuilder.new(
contact_id: params[:contact_id],
inbox_id: inbox.id,
contact: @contact,
inbox: @inbox,
source_id: params[:source_id]
).perform
end
def conversation_params
additional_attributes = params[:additional_attributes]&.permit! || {}
custom_attributes = params[:custom_attributes]&.permit! || {}
status = params[:status].present? ? { status: params[:status] } : {}
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
status = { status: 'pending' } if status[:status] == 'bot'
{
account_id: Current.account.id,
inbox_id: @contact_inbox.inbox_id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_attributes,
custom_attributes: custom_attributes,
snoozed_until: params[:snoozed_until],
assignee_id: params[:assignee_id],
team_id: params[:team_id]
}.merge(status)
end
def conversation_finder
@conversation_finder ||= ConversationFinder.new(current_user, params)
@conversation_finder ||= ConversationFinder.new(Current.user, params)
end
def assignee?
@conversation.assignee_id? && current_user == @conversation.assignee
@conversation.assignee_id? && Current.user == @conversation.assignee
end
end

View file

@ -113,7 +113,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved]
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
:lock_to_single_conversation]
end
def permitted_params(channel_attributes = [])

View file

@ -1,6 +1,6 @@
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
def index
@macros = Macro.with_visibility(current_user, params)
@ -14,6 +14,8 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
@macro.save!
process_attachments
@macro
end
def show
@ -25,10 +27,21 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
head :ok
end
def attach_file
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:attachment].tempfile,
filename: params[:attachment].original_filename,
content_type: params[:attachment].content_type
)
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end
def update
ActiveRecord::Base.transaction do
@macro.update!(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
process_attachments
@macro.save!
rescue StandardError => e
Rails.logger.error e
@ -42,6 +55,19 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
head :ok
end
private
def process_attachments
actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
return if actions.blank?
actions.each do |action|
blob_id = action['action_params']
blob = ActiveStorage::Blob.find_by(id: blob_id)
@macro.files.attach(blob)
end
end
def permitted_params
params.permit(
:name, :account_id, :visibility,
@ -56,4 +82,8 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
def fetch_macro
@macro = Current.account.macros.find_by(id: params[:id])
end
def check_authorization
authorize(@macro) if @macro.present?
end
end

View file

@ -14,10 +14,14 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portal.members << agents
end
def show; end
def show
@all_articles = @portal.articles
@articles = @all_articles.search(locale: params[:locale])
end
def create
@portal = Current.account.portals.build(portal_params)
@portal.custom_domain = parsed_custom_domain
@portal.save!
process_attached_logo
end
@ -25,6 +29,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def update
ActiveRecord::Base.transaction do
@portal.update!(portal_params) if params[:portal].present?
# @portal.custom_domain = parsed_custom_domain
process_attached_logo
rescue StandardError => e
Rails.logger.error e
@ -70,4 +75,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def set_current_page
@current_page = params[:page] || 1
end
def parsed_custom_domain
domain = URI.parse(@portal.custom_domain)
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
end
end

View file

@ -18,6 +18,10 @@ class Api::V1::ProfilesController < Api::BaseController
head :ok
end
def auto_offline
@user.account_users.find_by!(account_id: auto_offline_params[:account_id]).update!(auto_offline: auto_offline_params[:auto_offline] || false)
end
def availability
@user.account_users.find_by!(account_id: availability_params[:account_id]).update!(availability: availability_params[:availability])
end
@ -37,6 +41,10 @@ class Api::V1::ProfilesController < Api::BaseController
params.require(:profile).permit(:account_id, :availability)
end
def auto_offline_params
params.require(:profile).permit(:account_id, :auto_offline)
end
def profile_params
params.require(:profile).permit(
:email,

View file

@ -50,7 +50,9 @@ class Api::V1::Widget::BaseController < ApplicationController
end
def contact_name
params[:contact][:name] || contact_email.split('@')[0] if contact_email.present?
return if @contact.email.present? || @contact.phone_number.present? || @contact.identifier.present?
permitted_params.dig(:contact, :name) || (contact_email.split('@')[0] if contact_email.present?)
end
def contact_phone_number

View file

@ -17,7 +17,8 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
@contact = ContactIdentifyAction.new(
contact: @contact,
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
retain_original_contact_name: true
retain_original_contact_name: true,
discard_invalid_attrs: true
).perform
end

View file

@ -13,6 +13,8 @@ module RequestExceptionHandler
render_not_found_error('Resource could not be found')
rescue Pundit::NotAuthorizedError
render_unauthorized('You are not authorized to do this action')
rescue ActionController::ParameterMissing => e
render_could_not_create_error(e.message)
ensure
# to address the thread variable leak issues in Puma/Thin webserver
Current.reset

View file

@ -16,8 +16,7 @@ class DashboardController < ActionController::Base
@global_config = GlobalConfig.get(
'LOGO', 'LOGO_THUMBNAIL',
'INSTALLATION_NAME',
'WIDGET_BRAND_URL',
'TERMS_URL',
'WIDGET_BRAND_URL', 'TERMS_URL',
'PRIVACY_URL',
'DISPLAY_MANIFEST',
'CREATE_NEW_ACCOUNT_FROM_DASHBOARD',
@ -25,12 +24,12 @@ class DashboardController < ActionController::Base
'API_CHANNEL_NAME',
'API_CHANNEL_THUMBNAIL',
'ANALYTICS_TOKEN',
'ANALYTICS_HOST',
'DIRECT_UPLOADS_ENABLED',
'HCAPTCHA_SITE_KEY',
'LOGOUT_REDIRECT_LINK',
'DISABLE_USER_PROFILE_UPDATE',
'DEPLOYMENT_ENV'
'DEPLOYMENT_ENV',
'CSML_EDITOR_HOST'
).merge(app_config)
end

View file

@ -1,18 +1,16 @@
class Platform::Api::V1::AccountsController < PlatformController
def create
@resource = Account.new(account_params)
@resource.save!
@resource = Account.create!(account_params)
update_resource_features
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
render json: @resource
end
def show
render json: @resource
end
def show; end
def update
@resource.update!(account_params)
render json: @resource
@resource.assign_attributes(account_params)
update_resource_features
@resource.save!
end
def destroy
@ -27,6 +25,18 @@ class Platform::Api::V1::AccountsController < PlatformController
end
def account_params
params.permit(:name, :locale)
permitted_params.except(:features)
end
def update_resource_features
return if permitted_params[:features].blank?
permitted_params[:features].each do |key, value|
value.present? ? @resource.enable_features(key) : @resource.disable_features(key)
end
end
def permitted_params
params.permit(:name, :locale, :domain, :support_email, :status, features: {}, limits: {}, custom_attributes: {})
end
end

View file

@ -4,10 +4,10 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
def create
source_id = params[:source_id] || SecureRandom.uuid
@contact_inbox = ::ContactBuilder.new(
@contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: source_id,
inbox: @inbox_channel.inbox,
contact_attributes: permitted_params.except(:identifier, :identifier_hash)
contact_attributes: permitted_params.except(:identifier_hash)
).perform
end

View file

@ -3,9 +3,15 @@ class Public::Api::V1::InboxesController < PublicController
before_action :set_contact_inbox
before_action :set_conversation
def show
@inbox_channel = ::Channel::Api.find_by!(identifier: params[:id])
end
private
def set_inbox_channel
return if params[:inbox_id].blank?
@inbox_channel = ::Channel::Api.find_by!(identifier: params[:inbox_id])
end

View file

@ -1,7 +1,7 @@
class Public::Api::V1::Portals::ArticlesController < PublicController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_category
before_action :set_category, except: [:index]
before_action :set_article, only: [:show]
layout 'portal'
@ -20,7 +20,7 @@ class Public::Api::V1::Portals::ArticlesController < PublicController
end
def set_category
@category = @portal.categories.find_by!(slug: params[:category_slug])
@category = @portal.categories.find_by!(slug: params[:category_slug]) if params[:category_slug].present?
end
def portal

View file

@ -56,7 +56,6 @@ class ConversationFinder
filter_by_team if @team
filter_by_labels if params[:labels]
filter_by_query if params[:q]
filter_by_reply_status
end
def set_inboxes
@ -76,12 +75,9 @@ class ConversationFinder
end
def find_all_conversations
if params[:conversation_type] == 'mention'
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
@conversations = current_account.conversations.where(id: conversation_ids)
else
@conversations = current_account.conversations.where(inbox_id: @inbox_ids)
end
@conversations = current_account.conversations.where(inbox_id: @inbox_ids)
filter_by_conversation_type if params[:conversation_type]
@conversations
end
def filter_by_assignee_type
@ -96,8 +92,15 @@ class ConversationFinder
@conversations
end
def filter_by_reply_status
@conversations = @conversations.where(first_reply_created_at: nil) if params[:reply_status] == 'unattended'
def filter_by_conversation_type
case @params[:conversation_type]
when 'mention'
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
@conversations = @conversations.where(id: conversation_ids)
when 'unattended'
@conversations = @conversations.where(first_reply_created_at: nil)
end
@conversations
end
def filter_by_query

View file

@ -21,7 +21,9 @@ class MessageFinder
end
def current_messages
if @params[:before].present?
if @params[:after].present?
messages.reorder('created_at asc').where('id >= ?', @params[:before].to_i).limit(20)
elsif @params[:before].present?
messages.reorder('created_at desc').where('id < ?', @params[:before].to_i).limit(20).reverse
else
messages.reorder('created_at desc').limit(20).reverse

View file

@ -8,6 +8,12 @@ module FileTypeHelper
:file
end
# Used in case of DIRECT_UPLOADS_ENABLED=true
def file_type_by_signed_id(signed_id)
blob = ActiveStorage::Blob.find_signed(signed_id)
file_type(blob&.content_type)
end
def image_file?(content_type)
[
'image/jpeg',

View file

@ -144,6 +144,12 @@ export default {
});
},
updateAutoOffline(accountId, autoOffline = false) {
return axios.post(endPoints('autoOffline').url, {
profile: { account_id: accountId, auto_offline: autoOffline },
});
},
deleteAvatar() {
return axios.delete(endPoints('deleteAvatar').url);
},

View file

@ -16,6 +16,9 @@ const endPoints = {
availabilityUpdate: {
url: '/api/v1/profile/availability',
},
autoOffline: {
url: '/api/v1/profile/auto_offline',
},
logout: {
url: 'auth/sign_out',
},

View file

@ -7,8 +7,8 @@ class CategoriesAPI extends PortalsAPI {
super('categories', { accountScoped: true });
}
get({ portalSlug }) {
return axios.get(`${this.url}/${portalSlug}/categories`);
get({ portalSlug, locale }) {
return axios.get(`${this.url}/${portalSlug}/categories?locale=${locale}`);
}
create({ portalSlug, categoryObj }) {

View file

@ -6,6 +6,10 @@ class PortalsAPI extends ApiClient {
super('portals', { accountScoped: true });
}
getPortal({ portalSlug, locale }) {
return axios.get(`${this.url}/${portalSlug}?locale=${locale}`);
}
updatePortal({ portalSlug, portalObj }) {
return axios.patch(`${this.url}/${portalSlug}`, portalObj);
}

View file

@ -68,6 +68,10 @@ class ConversationApi extends ApiClient {
return axios.post(`${this.url}/${id}/update_last_seen`);
}
markMessagesUnread({ id }) {
return axios.post(`${this.url}/${id}/unread`);
}
toggleTyping({ conversationId, status, isPrivate }) {
return axios.post(`${this.url}/${conversationId}/toggle_typing_status`, {
typing_status: status,
@ -105,6 +109,16 @@ class ConversationApi extends ApiClient {
custom_attributes: customAttributes,
});
}
fetchParticipants(conversationId) {
return axios.get(`${this.url}/${conversationId}/participants`);
}
updateParticipants({ conversationId, userIds }) {
return axios.patch(`${this.url}/${conversationId}/participants`, {
user_ids: userIds,
});
}
}
export default new ConversationApi();

View file

@ -13,6 +13,16 @@ class Inboxes extends ApiClient {
deleteInboxAvatar(inboxId) {
return axios.delete(`${this.url}/${inboxId}/avatar`);
}
getAgentBot(inboxId) {
return axios.get(`${this.url}/${inboxId}/agent_bot`);
}
setAgentBot(inboxId, botId) {
return axios.post(`${this.url}/${inboxId}/set_agent_bot`, {
agent_bot: botId,
});
}
}
export default new Inboxes();

View file

@ -11,6 +11,8 @@ describe('#InboxesAPI', () => {
expect(inboxesAPI).toHaveProperty('update');
expect(inboxesAPI).toHaveProperty('delete');
expect(inboxesAPI).toHaveProperty('getCampaigns');
expect(inboxesAPI).toHaveProperty('getAgentBot');
expect(inboxesAPI).toHaveProperty('setAgentBot');
});
describeWithAPIMock('API calls', context => {
it('#getCampaigns', () => {

View file

@ -0,0 +1,6 @@
/* global axios */
import wootConstants from 'dashboard/constants';
export const getTestimonialContent = () => {
return axios.get(wootConstants.TESTIMONIAL_URL);
};

View file

@ -74,8 +74,8 @@ Tahoma,
Arial,
sans-serif;
$body-antialiased: true;
$global-margin: $space-one;
$global-padding: $space-one;
$global-margin: $space-small;
$global-padding: $space-micro;
$global-weight-normal: normal;
$global-weight-bold: bold;
$global-radius: 0;

View file

@ -20,6 +20,24 @@
@include foundation-everything($flex: true);
@include foundation-prototype-text-utilities;
@include foundation-prototype-text-transformation;
@include foundation-prototype-text-decoration;
@include foundation-prototype-font-styling;
@include foundation-prototype-list-style-type;
@include foundation-prototype-rounded;
@include foundation-prototype-bordered;
@include foundation-prototype-shadow;
@include foundation-prototype-separator;
@include foundation-prototype-overflow;
@include foundation-prototype-display;
@include foundation-prototype-position;
@include foundation-prototype-border-box;
@include foundation-prototype-border-none;
@include foundation-prototype-sizing;
@include foundation-prototype-spacing;
@import 'typography';
@import 'layout';
@import 'animations';

View file

@ -113,9 +113,22 @@ $default-button-height: 4.0rem;
}
&.clear {
color: var(--w-700);
&.secondary {
color: var(--s-700)
}
&.success {
color: var(--g-700)
}
&.alert {
color: var(--r-700)
}
&.warning {
color: var(--y-600);
color: var(--y-700)
}
&:hover {
@ -142,10 +155,20 @@ $default-button-height: 4.0rem;
// Sizes
&.tiny {
height: var(--space-medium);
.icon+.button__content {
padding-left: var(--space-micro);
}
}
&.small {
height: var(--space-large);
padding-bottom: var(--space-smaller);
padding-top: var(--space-smaller);
.icon+.button__content {
padding-left: var(--space-smaller);
}
}
&.large {
@ -175,6 +198,10 @@ $default-button-height: 4.0rem;
height: auto;
margin: 0;
padding: 0;
&:hover {
text-decoration: underline;
}
}
}

View file

@ -14,15 +14,9 @@
}
.modal--close {
border-radius: 50%;
color: $color-heading;
cursor: pointer;
font-size: $font-size-big;
line-height: $space-normal;
padding: $space-normal;
position: absolute;
right: $space-micro;
top: $space-micro;
right: $space-small;
top: $space-small;
&:hover {
background: $color-background;

View file

@ -59,12 +59,8 @@
.hamburger--menu {
cursor: pointer;
display: none;
display: block;
margin-right: $space-normal;
@media screen and (max-width: 1200px) {
display: block;
}
}
.header--icon {

View file

@ -102,6 +102,7 @@
@assign-agent="onAssignAgent"
@update-conversations="onUpdateConversations"
@assign-labels="onAssignLabels"
@assign-team="onAssignTeamsForBulk"
/>
<div
ref="activeConversation"
@ -125,6 +126,7 @@
@assign-label="onAssignLabels"
@update-conversation-status="toggleConversationStatus"
@context-menu-toggle="onContextMenuToggle"
@mark-as-unread="markAsUnread"
/>
<div v-if="chatListLoading" class="text-center">
@ -184,6 +186,11 @@ import {
hasPressedAltAndJKey,
hasPressedAltAndKKey,
} from 'shared/helpers/KeyboardHelpers';
import { conversationListPageURL } from '../helper/URLHelper';
import {
isOnMentionsView,
isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers';
export default {
components: {
@ -332,14 +339,15 @@ export default {
status: this.activeStatus,
page: this.currentPage + 1,
labels: this.label ? [this.label] : undefined,
teamId: this.teamId ? this.teamId : undefined,
conversationType: this.conversationType
? this.conversationType
: undefined,
teamId: this.teamId || undefined,
conversationType: this.conversationType || undefined,
folders: this.hasActiveFolders ? this.savedFoldersValue : undefined,
};
},
pageTitle() {
if (this.hasAppliedFilters) {
return this.$t('CHAT_LIST.TAB_HEADING');
}
if (this.inbox.name) {
return this.inbox.name;
}
@ -352,6 +360,9 @@ export default {
if (this.conversationType === 'mention') {
return this.$t('CHAT_LIST.MENTION_HEADING');
}
if (this.conversationType === 'unattended') {
return this.$t('CHAT_LIST.UNATTENDED_HEADING');
}
if (this.hasActiveFolders) {
return this.activeFolder.name;
}
@ -431,9 +442,6 @@ export default {
},
methods: {
onApplyFilter(payload) {
if (this.$route.name !== 'home') {
this.$router.push({ name: 'home' });
}
this.resetBulkActions();
this.foldersQuery = filterQueryGenerator(payload);
this.$store.dispatch('conversationPage/reset');
@ -636,6 +644,35 @@ export default {
this.showAlert(this.$t('BULK_ACTION.ASSIGN_FAILED'));
}
},
async markAsUnread(conversationId) {
try {
await this.$store.dispatch('markMessagesUnread', {
id: conversationId,
});
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = this.$route;
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
this.$router.push(
conversationListPageURL({
accountId,
conversationType: conversationType,
customViewId: this.foldersId,
inboxId,
label,
teamId,
})
);
} catch (error) {
// Ignore error
}
},
async onAssignTeam(team, conversationId = null) {
try {
await this.$store.dispatch('assignTeam', {
@ -685,6 +722,21 @@ export default {
this.showAlert(this.$t('BULK_ACTION.LABELS.ASSIGN_FAILED'));
}
},
async onAssignTeamsForBulk(team) {
try {
await this.$store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: this.selectedConversations,
fields: {
team_id: team.id,
},
});
this.selectedConversations = [];
this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_SUCCESFUL'));
} catch (err) {
this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_FAILED'));
}
},
async onUpdateConversations(status) {
try {
await this.$store.dispatch('bulkActions/process', {

View file

@ -7,9 +7,13 @@
@click="onBackDropClick"
>
<div :class="modalContainerClassName" @click.stop>
<button class="modal--close" @click="close">
<fluent-icon icon="dismiss" />
</button>
<woot-button
color-scheme="secondary"
icon="dismiss"
variant="clear"
class="modal--close"
@click="close"
/>
<slot />
</div>
</div>

View file

@ -1,7 +1,12 @@
<template>
<button @click="onMenuItemClick">
<fluent-icon class="hamburger--menu" icon="list" />
</button>
<woot-button
size="small"
variant="clear"
color-scheme="secondary"
icon="list"
class="toggle-sidebar"
@click="onMenuItemClick"
/>
</template>
<script>
@ -16,13 +21,8 @@ export default {
};
</script>
<style scoped lang="scss">
.hamburger--menu {
cursor: pointer;
display: none;
margin-right: var(--space-normal);
@media screen and (max-width: 1200px) {
display: block;
}
.toggle-sidebar {
margin-right: var(--space-small);
margin-left: var(--space-minus-small);
}
</style>

View file

@ -5,9 +5,12 @@ import Button from './ui/WootButton';
import Code from './Code';
import ColorPicker from './widgets/ColorPicker';
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
import ConfirmModal from './widgets/modal/ConfirmationModal.vue';
import ContextMenu from './ui/ContextMenu.vue';
import DeleteModal from './widgets/modal/DeleteModal.vue';
import DropdownItem from 'shared/components/ui/dropdown/DropdownItem';
import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import FeatureToggle from './widgets/FeatureToggle';
import HorizontalBar from './widgets/chart/HorizontalBarChart';
import Input from './widgets/forms/Input.vue';
import Label from './ui/Label';
@ -21,8 +24,6 @@ import SubmitButton from './buttons/FormSubmitButton';
import Tabs from './ui/Tabs/Tabs';
import TabsItem from './ui/Tabs/TabsItem';
import Thumbnail from './widgets/Thumbnail.vue';
import ConfirmModal from './widgets/modal/ConfirmationModal.vue';
import ContextMenu from './ui/ContextMenu.vue';
const WootUIKit = {
AvatarUploader,
@ -31,9 +32,12 @@ const WootUIKit = {
Code,
ColorPicker,
ConfirmDeleteModal,
ConfirmModal,
ContextMenu,
DeleteModal,
DropdownItem,
DropdownMenu,
FeatureToggle,
HorizontalBar,
Input,
Label,
@ -47,8 +51,6 @@ const WootUIKit = {
Tabs,
TabsItem,
Thumbnail,
ConfirmModal,
ContextMenu,
install(Vue) {
const keys = Object.keys(this);
keys.pop(); // remove 'install' from keys

View file

@ -18,12 +18,35 @@
</woot-button>
</woot-dropdown-item>
<woot-dropdown-divider />
<woot-dropdown-item class="auto-offline--toggle">
<div class="info-wrap">
<fluent-icon
v-tooltip.right-start="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_TEXT')"
icon="info"
size="14"
class="info-icon"
/>
<span class="auto-offline--text">
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
</span>
</div>
<woot-switch
size="small"
class="auto-offline--switch"
:value="currentUserAutoOffline"
@input="updateAutoOffline"
/>
</woot-dropdown-item>
<woot-dropdown-divider />
</woot-dropdown-menu>
</template>
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader';
@ -41,7 +64,7 @@ export default {
AvailabilityStatusBadge,
},
mixins: [clickaway],
mixins: [clickaway, alertMixin],
data() {
return {
@ -54,6 +77,7 @@ export default {
...mapGetters({
getCurrentUserAvailability: 'getCurrentUserAvailability',
currentAccountId: 'getCurrentAccountId',
currentUserAutoOffline: 'getCurrentUserAutoOffline',
}),
availabilityDisplayLabel() {
const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex(
@ -85,21 +109,30 @@ export default {
closeStatusMenu() {
this.isStatusMenuOpened = false;
},
updateAutoOffline(autoOffline) {
this.$store.dispatch('updateAutoOffline', {
accountId: this.currentAccountId,
autoOffline,
});
},
changeAvailabilityStatus(availability) {
const accountId = this.currentAccountId;
if (this.isUpdating) {
return;
}
this.isUpdating = true;
this.$store
.dispatch('updateAvailability', {
availability: availability,
account_id: accountId,
})
.finally(() => {
this.isUpdating = false;
try {
this.$store.dispatch('updateAvailability', {
availability,
account_id: this.currentAccountId,
});
} catch (error) {
this.showAlert(
this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.SET_AVAILABILITY_ERROR')
);
} finally {
this.isUpdating = false;
}
},
},
};
@ -143,4 +176,32 @@ export default {
align-items: baseline;
}
}
.auto-offline--toggle {
align-items: center;
display: flex;
justify-content: space-between;
padding: var(--space-smaller) 0 var(--space-smaller) var(--space-small);
margin: 0;
.info-wrap {
display: flex;
align-items: center;
}
.info-icon {
margin-top: -1px;
}
.auto-offline--switch {
margin: -1px var(--space-micro) 0;
}
.auto-offline--text {
margin: 0 var(--space-smaller);
font-size: var(--font-size-mini);
font-weight: var(--font-weight-medium);
color: var(--s-700);
}
}
</style>

View file

@ -73,14 +73,14 @@ export default {
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
isACustomBrandedInstance: 'globalConfig/isACustomBrandedInstance',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
inboxes: 'inboxes/getInboxes',
accountId: 'getCurrentAccountId',
currentRole: 'getCurrentRole',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
inboxes: 'inboxes/getInboxes',
isACustomBrandedInstance: 'globalConfig/isACustomBrandedInstance',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
labels: 'labels/getLabelsOnSidebar',
teams: 'teams/getMyTeams',
}),

View file

@ -16,6 +16,8 @@ const conversations = accountId => ({
'conversation_through_mentions',
'folder_conversations',
'conversations_through_folders',
'conversation_unattended',
'conversation_through_unattended',
],
menuItems: [
{
@ -33,6 +35,13 @@ const conversations = accountId => ({
toState: frontendURL(`accounts/${accountId}/mentions/conversations`),
toStateName: 'conversation_mentions',
},
{
icon: 'mail-unread',
label: 'UNATTENDED_CONVERSATIONS',
key: 'conversation_unattended',
toState: frontendURL(`accounts/${accountId}/unattended/conversations`),
toStateName: 'conversation_unattended',
},
],
});

View file

@ -1,3 +1,4 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import { frontendURL } from '../../../../helper/URLHelper';
const primaryMenuItems = accountId => [
@ -13,6 +14,7 @@ const primaryMenuItems = accountId => [
icon: 'book-contacts',
key: 'contacts',
label: 'CONTACTS',
featureFlag: FEATURE_FLAGS.CRM,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
roles: ['administrator', 'agent'],
@ -21,6 +23,7 @@ const primaryMenuItems = accountId => [
icon: 'arrow-trending-lines',
key: 'reports',
label: 'REPORTS',
featureFlag: FEATURE_FLAGS.REPORTS,
toState: frontendURL(`accounts/${accountId}/reports`),
toStateName: 'settings_account_reports',
roles: ['administrator'],
@ -29,6 +32,7 @@ const primaryMenuItems = accountId => [
icon: 'megaphone',
key: 'campaigns',
label: 'CAMPAIGNS',
featureFlag: FEATURE_FLAGS.CAMPAIGNS,
toState: frontendURL(`accounts/${accountId}/campaigns`),
toStateName: 'settings_account_campaigns',
roles: ['administrator'],
@ -37,7 +41,7 @@ const primaryMenuItems = accountId => [
icon: 'library',
key: 'helpcenter',
label: 'HELP_CENTER.TITLE',
featureFlag: 'help_center',
featureFlag: FEATURE_FLAGS.HELP_CENTER,
toState: frontendURL(`accounts/${accountId}/portals`),
toStateName: 'default_portal_articles',
roles: ['administrator'],

View file

@ -1,3 +1,4 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import { frontendURL } from '../../../../helper/URLHelper';
const settings = accountId => ({
@ -38,12 +39,20 @@ const settings = accountId => ({
'settings_teams_new',
],
menuItems: [
{
icon: 'briefcase',
label: 'ACCOUNT_SETTINGS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/general`),
toStateName: 'general_settings_index',
},
{
icon: 'people',
label: 'AGENTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/agents/list`),
toStateName: 'agent_list',
featureFlag: FEATURE_FLAGS.AGENT_MANAGEMENT,
},
{
icon: 'people-team',
@ -51,6 +60,7 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/teams/list`),
toStateName: 'settings_teams_list',
featureFlag: FEATURE_FLAGS.TEAM_MANAGEMENT,
},
{
icon: 'mail-inbox-all',
@ -58,6 +68,7 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/inboxes/list`),
toStateName: 'settings_inbox_list',
featureFlag: FEATURE_FLAGS.INBOX_MANAGEMENT,
},
{
icon: 'tag',
@ -65,6 +76,7 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/labels/list`),
toStateName: 'labels_list',
featureFlag: FEATURE_FLAGS.LABELS,
},
{
icon: 'code',
@ -74,6 +86,7 @@ const settings = accountId => ({
`accounts/${accountId}/settings/custom-attributes/list`
),
toStateName: 'attributes_list',
featureFlag: FEATURE_FLAGS.CUSTOM_ATTRIBUTES,
},
{
icon: 'automation',
@ -82,15 +95,17 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/automation/list`),
toStateName: 'automation_list',
featureFlag: FEATURE_FLAGS.AUTOMATIONS,
},
{
icon: 'bot',
label: 'AGENT_BOTS',
beta: true,
hasSubMenu: false,
globalConfigFlag: 'csmlEditorHost',
toState: frontendURL(`accounts/${accountId}/settings/agent-bots`),
toStateName: 'agent_bots',
featureFlagKey: 'agent_bots',
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
},
{
icon: 'flash-settings',
@ -99,7 +114,7 @@ const settings = accountId => ({
toState: frontendURL(`accounts/${accountId}/settings/macros`),
toStateName: 'macros_wrapper',
beta: true,
featureFlagKey: 'macros',
featureFlag: FEATURE_FLAGS.MACROS,
},
{
icon: 'chat-multiple',
@ -109,6 +124,7 @@ const settings = accountId => ({
`accounts/${accountId}/settings/canned-response/list`
),
toStateName: 'canned_list',
featureFlag: FEATURE_FLAGS.CANNED_RESPONSES,
},
{
icon: 'flash-on',
@ -116,6 +132,7 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/integrations`),
toStateName: 'settings_integrations',
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
},
{
icon: 'star-emphasis',
@ -123,6 +140,7 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/applications`),
toStateName: 'settings_applications',
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
},
{
icon: 'credit-card-person',
@ -132,13 +150,6 @@ const settings = accountId => ({
toStateName: 'billing_settings_index',
showOnlyOnCloud: true,
},
{
icon: 'settings',
label: 'ACCOUNT_SETTINGS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/general`),
toStateName: 'general_settings_index',
},
],
});

View file

@ -61,6 +61,24 @@
</a>
</router-link>
</woot-dropdown-item>
<woot-dropdown-item v-if="currentUser.type === 'SuperAdmin'">
<a
href="/super_admin"
class="button small clear secondary"
target="_blank"
rel="noopener nofollow noreferrer"
@click="$emit('close')"
>
<fluent-icon
icon="content-settings"
size="14"
class="icon icon--font"
/>
<span class="button__content">
{{ $t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE') }}
</span>
</a>
</woot-dropdown-item>
<woot-dropdown-item>
<woot-button
variant="clear"
@ -135,7 +153,7 @@ export default {
.dropdown-pane {
left: var(--space-slab);
bottom: var(--space-larger);
min-width: 16.8rem;
z-index: var(--z-index-much-higher);
min-width: 22rem;
z-index: var(--z-index-low);
}
</style>

View file

@ -20,6 +20,8 @@
import { frontendURL } from '../../../helper/URLHelper';
import SecondaryNavItem from './SecondaryNavItem.vue';
import AccountContext from './AccountContext.vue';
import { mapGetters } from 'vuex';
import { FEATURE_FLAGS } from '../../../featureFlags';
export default {
components: {
@ -61,6 +63,9 @@ export default {
},
},
computed: {
...mapGetters({
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
hasSecondaryMenu() {
return this.menuConfig.menuItems && this.menuConfig.menuItems.length;
},
@ -89,7 +94,7 @@ export default {
icon: 'folder',
label: 'INBOXES',
hasSubMenu: true,
newLink: true,
newLink: this.showNewLink(FEATURE_FLAGS.INBOX_MANAGEMENT),
newLinkTag: 'NEW_INBOX',
key: 'inbox',
toState: frontendURL(`accounts/${this.accountId}/settings/inboxes/new`),
@ -117,7 +122,7 @@ export default {
icon: 'number-symbol',
label: 'LABELS',
hasSubMenu: true,
newLink: true,
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
newLinkTag: 'NEW_LABEL',
key: 'label',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
@ -141,7 +146,7 @@ export default {
label: 'TAGGED_WITH',
hasSubMenu: true,
key: 'label',
newLink: true,
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
newLinkTag: 'NEW_LABEL',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
toStateName: 'labels_list',
@ -163,7 +168,7 @@ export default {
icon: 'people-team',
label: 'TEAMS',
hasSubMenu: true,
newLink: true,
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
newLinkTag: 'NEW_TEAM',
key: 'team',
toState: frontendURL(`accounts/${this.accountId}/settings/teams/new`),
@ -238,6 +243,9 @@ export default {
toggleAccountModal() {
this.$emit('toggle-accounts');
},
showNewLink(featureFlag) {
return this.isFeatureEnabledonAccount(this.accountId, featureFlag);
},
},
};
</script>
@ -253,14 +261,7 @@ export default {
width: 20rem;
flex-shrink: 0;
overflow-y: hidden;
@include breakpoint(xlarge down) {
position: absolute;
}
@include breakpoint(xlarge up) {
position: unset;
}
position: unset;
&:hover {
overflow-y: hidden;

View file

@ -26,7 +26,7 @@
:class="{ 'text-truncate': shouldTruncate }"
>
{{ label }}
<span v-if="isHelpCenterSidebar && childItemCount" class="count-view">
<span v-if="showChildCount" class="count-view">
{{ childItemCount }}
</span>
</span>
@ -76,7 +76,7 @@ export default {
type: String,
default: '',
},
isHelpCenterSidebar: {
showChildCount: {
type: Boolean,
default: false,
},
@ -112,6 +112,7 @@ $label-badge-size: var(--space-slab);
padding: var(--space-smaller) var(--space-smaller);
margin: var(--space-smaller) 0;
text-align: left;
line-height: 1.2;
&:hover {
background: var(--s-25);
@ -127,11 +128,14 @@ $label-badge-size: var(--space-slab);
color: var(--w-500);
border-color: var(--w-25);
}
&.is-active .count-view {
background: var(--w-75);
color: var(--w-500);
}
}
.menu-label {
flex-grow: 1;
line-height: var(--space-two);
}
.inbox-icon {
@ -175,10 +179,6 @@ $label-badge-size: var(--space-slab);
font-weight: var(--font-weight-bold);
margin-left: var(--space-smaller);
padding: var(--space-zero) var(--space-smaller);
&.is-active {
background: var(--w-50);
color: var(--w-500);
}
line-height: var(--font-size-small);
}
</style>

View file

@ -4,16 +4,15 @@
<span class="secondary-menu--header fs-small">
{{ $t(`SIDEBAR.${menuItem.label}`) }}
</span>
<div v-if="isHelpCenterSidebar" class="submenu-icons">
<div v-if="menuItem.showNewButton" class="submenu-icons">
<woot-button
size="tiny"
variant="clear"
color-scheme="secondary"
icon="add"
class="submenu-icon"
@click="onClickOpen"
>
<fluent-icon icon="add" size="16" />
</woot-button>
/>
</div>
</div>
<router-link
@ -28,11 +27,7 @@
size="14"
/>
{{ $t(`SIDEBAR.${menuItem.label}`) }}
<span
v-if="isHelpCenterSidebar"
class="count-view"
:class="computedClass"
>
<span v-if="showChildCount(menuItem.count)" class="count-view">
{{ `${menuItem.count}` }}
</span>
<span
@ -55,7 +50,7 @@
:should-truncate="child.truncateLabel"
:icon="computedInboxClass(child)"
:warning-icon="computedInboxErrorClass(child)"
:is-help-center-sidebar="isHelpCenterSidebar"
:show-child-count="showChildCount(child.count)"
:child-item-count="child.count"
/>
<router-link
@ -64,10 +59,10 @@
:to="menuItem.toState"
custom
>
<li>
<li class="menu-item--new">
<a
:href="href"
class="button small clear menu-item--new secondary"
class="button small link clear secondary"
:class="{ 'is-active': isActive }"
@click="e => newLinkClick(e, navigate)"
>
@ -78,9 +73,6 @@
</a>
</li>
</router-link>
<p v-if="isHelpCenterSidebar && isCategoryEmpty" class="empty-text">
{{ $t('SIDEBAR.HELP_CENTER.CATEGORY_EMPTY_MESSAGE') }}
</p>
</ul>
</li>
</template>
@ -95,6 +87,10 @@ import {
} from 'dashboard/helper/inbox';
import SecondaryChildNavItem from './SecondaryChildNavItem';
import {
isOnMentionsView,
isOnUnattendedView,
} from '../../../store/modules/conversations/helpers/actionHelpers';
export default {
components: { SecondaryChildNavItem },
@ -104,46 +100,54 @@ export default {
type: Object,
default: () => ({}),
},
isHelpCenterSidebar: {
type: Boolean,
default: false,
},
isCategoryEmpty: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({
activeInbox: 'getSelectedInbox',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
globalConfig: 'globalConfig/get',
}),
hasSubMenu() {
return !!this.menuItem.children;
},
isMenuItemVisible() {
if (!this.menuItem.featureFlagKey) {
return true;
if (this.menuItem.globalConfigFlag) {
return !!this.globalConfig[this.menuItem.globalConfigFlag];
}
return this.isFeatureEnabledonAccount(
this.accountId,
this.menuItem.featureFlagKey
);
if (this.menuItem.featureFlag) {
return this.isFeatureEnabledonAccount(
this.accountId,
this.menuItem.featureFlag
);
}
return true;
},
isInboxConversation() {
isAllConversations() {
return (
this.$store.state.route.name === 'inbox_conversation' &&
this.menuItem.toStateName === 'home'
);
},
isMentions() {
return (
isOnMentionsView({ route: this.$route }) &&
this.menuItem.toStateName === 'conversation_mentions'
);
},
isUnattended() {
return (
isOnUnattendedView({ route: this.$route }) &&
this.menuItem.toStateName === 'conversation_unattended'
);
},
isTeamsSettings() {
return (
this.$store.state.route.name === 'settings_teams_edit' &&
this.menuItem.toStateName === 'settings_teams_list'
);
},
isInboxsSettings() {
isInboxSettings() {
return (
this.$store.state.route.name === 'settings_inbox_show' &&
this.menuItem.toStateName === 'settings_inbox_list'
@ -161,19 +165,25 @@ export default {
this.menuItem.toStateName === 'settings_applications'
);
},
isArticlesView() {
return this.$store.state.route.name === this.menuItem.toStateName;
isCurrentRoute() {
return this.$store.state.route.name.includes(this.menuItem.toStateName);
},
computedClass() {
// If active Inbox is present
// donot highlight conversations
// If active inbox is present, do not highlight conversations
if (this.activeInbox) return ' ';
if (
this.isAllConversations ||
this.isMentions ||
this.isUnattended ||
this.isCurrentRoute
) {
return 'is-active';
}
if (this.hasSubMenu) {
if (
this.isInboxConversation ||
this.isTeamsSettings ||
this.isInboxsSettings ||
this.isInboxSettings ||
this.isIntegrationsSettings ||
this.isApplicationsSettings
) {
@ -181,12 +191,7 @@ export default {
}
return ' ';
}
if (this.isHelpCenterSidebar) {
if (this.isArticlesView) {
return 'is-active';
}
return ' ';
}
return '';
},
},
@ -217,11 +222,14 @@ export default {
}
},
showItem(item) {
return this.isAdmin && item.newLink !== undefined;
return this.isAdmin && !!item.newLink;
},
onClickOpen() {
this.$emit('open');
},
showChildCount(count) {
return Number.isInteger(count);
},
},
};
</script>
@ -277,6 +285,11 @@ export default {
color: var(--w-500);
border-color: var(--w-25);
}
&.is-active .count-view {
background: var(--w-75);
color: var(--w-600);
}
}
.secondary-menu--icon {
@ -306,22 +319,19 @@ export default {
top: -1px;
}
.sidebar-item .button.menu-item--new {
display: inline-flex;
height: var(--space-medium);
margin: var(--space-smaller) 0;
padding: var(--space-smaller);
color: var(--s-500);
.sidebar-item .menu-item--new {
padding: var(--space-small) 0;
&:hover {
color: var(--w-500);
.button {
display: inline-flex;
color: var(--s-500);
}
}
.beta {
padding-right: var(--space-smaller) !important;
padding-left: var(--space-smaller) !important;
margin-left: var(--space-half) !important;
margin-left: var(--space-smaller) !important;
display: inline-block;
font-size: var(--font-size-micro);
font-weight: var(--font-weight-medium);
@ -340,11 +350,6 @@ export default {
font-weight: var(--font-weight-bold);
margin-left: var(--space-smaller);
padding: var(--space-zero) var(--space-smaller);
&.is-active {
background: var(--w-50);
color: var(--w-500);
}
}
.submenu-icons {
@ -356,10 +361,4 @@ export default {
margin-left: var(--space-small);
}
}
.empty-text {
color: var(--s-500);
font-size: var(--font-size-small);
margin: var(--space-smaller);
}
</style>

View file

@ -1,10 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SidemenuIcon matches snapshot 1`] = `
<button>
<fluent-icon
class="hamburger--menu"
icon="list"
/>
</button>
<woot-button
class="toggle-sidebar"
color-scheme="secondary"
icon="list"
size="small"
variant="clear"
/>
`;

View file

@ -4,7 +4,7 @@
<fluent-icon :icon="icon" size="12" class="label--icon" />
</span>
<span
v-if="variant === 'smooth'"
v-if="variant === 'smooth' && title && !icon"
:style="{ background: color }"
class="label-color-dot"
/>
@ -117,14 +117,16 @@ export default {
height: var(--space-medium);
&.small {
font-size: var(--font-size-micro);
font-size: var(--font-size-mini);
padding: var(--space-micro) var(--space-smaller);
line-height: 1.2;
letter-spacing: 0.15px;
height: var(--space-two);
}
.label--icon {
cursor: pointer;
}
.label-color-dot {
margin-right: var(--space-smaller);
}
@ -199,8 +201,8 @@ export default {
&.smooth {
background: transparent;
border: 1px solid var(--s-75);
color: var(--s-800);
border: 1px solid var(--s-100);
color: var(--s-700);
}
}
@ -221,14 +223,22 @@ export default {
}
.label-action--button {
margin-bottom: var(--space-minus-micro);
display: flex;
margin-right: var(--space-smaller);
}
.label-color-dot {
display: inline-block;
width: var(--space-one);
height: var(--space-one);
width: var(--space-slab);
height: var(--space-slab);
border-radius: var(--border-radius-small);
margin-right: var(--space-smaller);
box-shadow: var(--shadow-small);
}
.label.small .label-color-dot {
width: var(--space-small);
height: var(--space-small);
border-radius: var(--border-radius-small);
box-shadow: var(--shadow-small);
}
</style>

View file

@ -2,7 +2,7 @@
<button
type="button"
class="toggle-button"
:class="{ active: value }"
:class="{ active: value, small: size === 'small' }"
role="switch"
:aria-checked="value.toString()"
@click="onClick"
@ -15,6 +15,7 @@
export default {
props: {
value: { type: Boolean, default: false },
size: { type: String, default: '' },
},
methods: {
onClick() {
@ -45,6 +46,20 @@ export default {
background-color: var(--w-500);
}
&.small {
width: 22px;
height: 14px;
span {
height: var(--space-one);
width: var(--space-one);
&.active {
transform: translate(var(--space-small), var(--space-zero));
}
}
}
span {
--space-one-point-five: 1.5rem;
background-color: var(--white);

View file

@ -1,17 +1,15 @@
<template>
<span class="time-ago">
<span> {{ timeAgo }}</span>
<span>{{ timeAgo }}</span>
</span>
</template>
<script>
const ZERO = 0;
const MINUTE_IN_MILLI_SECONDS = 60000;
const HOUR_IN_MILLI_SECONDS = MINUTE_IN_MILLI_SECONDS * 60;
const DAY_IN_MILLI_SECONDS = HOUR_IN_MILLI_SECONDS * 24;
import timeMixin from 'dashboard/mixins/time';
import { differenceInMilliseconds } from 'date-fns';
export default {
name: 'TimeAgo',
@ -28,51 +26,40 @@ export default {
},
data() {
return {
timeAgo: '',
timeAgo: this.dynamicTime(this.timestamp),
timer: null,
};
},
computed: {
watch: {
timestamp() {
this.timeAgo = this.dynamicTime(this.timestamp);
},
},
mounted() {
if (this.isAutoRefreshEnabled) {
this.createTimer();
}
},
beforeDestroy() {
clearTimeout(this.timer);
},
methods: {
createTimer() {
this.timer = setTimeout(() => {
this.timeAgo = this.dynamicTime(this.timestamp);
this.createTimer();
}, this.refreshTime());
},
refreshTime() {
const timeDiff = differenceInMilliseconds(
new Date(),
new Date(this.timestamp * 1000)
);
const timeDiff = Date.now() - this.timestamp * 1000;
if (timeDiff > DAY_IN_MILLI_SECONDS) {
return DAY_IN_MILLI_SECONDS;
}
if (timeDiff > HOUR_IN_MILLI_SECONDS) {
return HOUR_IN_MILLI_SECONDS;
}
if (timeDiff > MINUTE_IN_MILLI_SECONDS) {
return MINUTE_IN_MILLI_SECONDS;
}
return ZERO;
},
},
mounted() {
this.timeAgo = this.dynamicTime(this.timestamp);
if (this.isAutoRefreshEnabled) {
this.createTimer();
}
},
beforeDestroy() {
this.clearTimer();
},
methods: {
createTimer() {
const refreshTime = this.refreshTime;
if (refreshTime > ZERO) {
this.timer = setTimeout(() => {
this.timeAgo = this.dynamicTime(this.timestamp);
this.createTimer();
}, refreshTime);
}
},
clearTimer() {
if (this.timer) {
clearTimeout(this.timer);
}
return MINUTE_IN_MILLI_SECONDS;
},
},
};

View file

@ -1,8 +1,5 @@
<template>
<div
class="filter"
:class="{ error: v.action_params.$dirty && v.action_params.$error }"
>
<div class="filter" :class="actionInputStyles">
<div class="filter-inputs">
<select
v-model="action_name"
@ -21,14 +18,32 @@
<div v-if="showActionInput" class="filter__answer--wrap">
<div v-if="inputType">
<div
v-if="inputType === 'multi_select'"
v-if="inputType === 'search_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="action_params"
track-by="id"
label="name"
:placeholder="'Select'"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
</div>
<div
v-else-if="inputType === 'multi_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="action_params"
track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
:multiple="true"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
@ -36,6 +51,7 @@
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
</div>
<input
@ -60,6 +76,7 @@
</div>
</div>
<woot-button
v-if="!isMacro"
icon="dismiss"
variant="clear"
color-scheme="secondary"
@ -120,6 +137,10 @@ export default {
type: String,
default: '',
},
isMacro: {
type: Boolean,
default: false,
},
},
computed: {
action_name: {
@ -146,6 +167,12 @@ export default {
return this.actionTypes.find(action => action.key === this.action_name)
.inputType;
},
actionInputStyles() {
return {
'has-error': this.v.action_params.$dirty && this.v.action_params.$error,
'is-a-macro': this.isMacro,
};
},
},
methods: {
removeAction() {
@ -165,9 +192,21 @@ export default {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-medium);
margin-bottom: var(--space-small);
&.is-a-macro {
margin-bottom: 0;
background: var(--white);
padding: var(--space-zero);
border: unset;
border-radius: unset;
}
}
.filter.error {
.no-margin-bottom {
margin-bottom: 0;
}
.filter.has-error {
background: var(--r-50);
}
@ -240,6 +279,6 @@ export default {
margin-bottom: var(--space-zero);
}
.action-message {
margin: var(--space-small) 0 0;
margin: var(--space-small) var(--space-zero) var(--space-zero);
}
</style>

View file

@ -113,5 +113,6 @@ input[type='file'] {
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
margin-bottom: 0;
}
</style>

View file

@ -1,10 +1,6 @@
<template>
<div
class="avatar-container"
:style="[style, customStyle]"
aria-hidden="true"
>
<span>{{ userInitial }}</span>
<div class="avatar-container" :style="style" aria-hidden="true">
{{ userInitial }}
</div>
</template>
@ -16,69 +12,26 @@ export default {
type: String,
default: '',
},
backgroundColor: {
type: String,
default: '#c2e1ff',
},
color: {
type: String,
default: '#1976cc',
},
customStyle: {
type: Object,
default: undefined,
},
size: {
type: Number,
default: 40,
},
src: {
type: String,
default: '',
},
rounded: {
type: Boolean,
default: true,
},
variant: {
type: String,
default: 'circle',
},
},
computed: {
style() {
let style = {
width: `${this.size}px`,
height: `${this.size}px`,
borderRadius:
this.variant === 'square' ? 'var(--border-radius-large)' : '50%',
lineHeight: `${this.size + Math.floor(this.size / 20)}px`,
return {
fontSize: `${Math.floor(this.size / 2.5)}px`,
};
if (this.backgroundColor) {
style = { ...style, backgroundColor: this.backgroundColor };
}
if (this.color) {
style = { ...style, color: this.color };
}
return style;
},
userInitial() {
return this.initials || this.initial(this.username);
},
},
methods: {
initial(username) {
const parts = username ? username.split(/[ -]/) : [];
let initials = '';
for (let i = 0; i < parts.length; i += 1) {
initials += parts[i].charAt(0);
}
const parts = this.username.split(/[ -]/);
let initials = parts.reduce((acc, curr) => acc + curr.charAt(0), '');
if (initials.length > 2 && initials.search(/[A-Z]/) !== -1) {
initials = initials.replace(/[a-z]+/g, '');
}
initials = initials.substring(0, 2).toUpperCase();
return initials;
},
},
@ -88,11 +41,13 @@ export default {
<style lang="scss" scoped>
.avatar-container {
display: flex;
line-height: 100%;
font-weight: 500;
align-items: center;
justify-content: center;
text-align: center;
background-image: linear-gradient(to top, var(--w-100) 0%, var(--w-75) 100%);
color: var(--w-600);
cursor: default;
}
</style>

View file

@ -67,6 +67,9 @@ export default {
if (Object.keys(this.enabledFeatures).length === 0) {
return false;
}
if (key === 'website') {
return this.enabledFeatures.channel_website;
}
if (key === 'facebook') {
return this.enabledFeatures.channel_facebook;
}

View file

@ -61,6 +61,7 @@ export default {
}
.colorpicker--selected {
border: 1px solid var(--color-border-light);
border-radius: $space-smaller;
cursor: pointer;
height: $space-large;

View file

@ -5,6 +5,11 @@
:key="index"
class="dashboard-app--list"
>
<loading-state
v-if="iframeLoading"
:message="$t('DASHBOARD_APPS.LOADING_MESSAGE')"
class="dashboard-app_loading-container"
/>
<iframe
v-if="configItem.type === 'frame' && configItem.url"
:id="`dashboard-app--frame-${index}`"
@ -16,7 +21,11 @@
</template>
<script>
import LoadingState from 'dashboard/components/widgets/LoadingState';
export default {
components: {
LoadingState,
},
props: {
config: {
type: Array,
@ -27,16 +36,26 @@ export default {
default: () => ({}),
},
},
data() {
return {
iframeLoading: true,
};
},
computed: {
dashboardAppContext() {
return {
conversation: this.currentChat,
contact: this.$store.getters['contacts/getContact'](this.contactId),
currentAgent: this.currentAgent,
};
},
contactId() {
return this.currentChat?.meta?.sender?.id;
},
currentAgent() {
const { id, name, email } = this.$store.getters.getCurrentUser;
return { id, name, email };
},
},
mounted() {
@ -57,6 +76,7 @@ export default {
);
const eventData = { event: 'appContext', data: this.dashboardAppContext };
frameElement.contentWindow.postMessage(JSON.stringify(eventData), '*');
this.iframeLoading = false;
},
},
};
@ -73,4 +93,11 @@ export default {
.dashboard-app--list iframe {
border: 0;
}
.dashboard-app_loading-container {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
</style>

View file

@ -0,0 +1,25 @@
<template>
<div v-if="isFeatureEnabled">
<slot />
</div>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
props: {
featureKey: {
type: String,
required: true,
},
},
computed: {
...mapGetters({
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
accountId: 'getCurrentAccountId',
}),
isFeatureEnabled() {
return this.isFeatureEnabledonAccount(this.accountId, this.featureKey);
},
},
};
</script>

View file

@ -32,6 +32,7 @@
v-for="attribute in filterAttributes"
:key="attribute.key"
:value="attribute.key"
:disabled="attribute.disabled"
>
{{ attribute.name }}
</option>
@ -173,6 +174,10 @@ export default {
type: Array,
default: () => [],
},
customAttributeType: {
type: String,
default: '',
},
},
computed: {
attributeKey: {

View file

@ -1,7 +1,11 @@
<template>
<span>
{{ textToBeDisplayed }}
<button class="show-more--button" @click="toggleShowMore">
<button
v-if="text.length > limit"
class="show-more--button"
@click="toggleShowMore"
>
{{ buttonLabel }}
</button>
</span>
@ -25,7 +29,7 @@ export default {
},
computed: {
textToBeDisplayed() {
if (this.showMore) {
if (this.showMore || this.text.length <= this.limit) {
return this.text;
}

View file

@ -83,75 +83,71 @@ export default {
},
pageSize: {
type: Number,
default: 15,
default: 25,
},
totalCount: {
type: Number,
default: 0,
},
onPageChange: {
type: Function,
default: () => {},
},
},
computed: {
isFooterVisible() {
return this.totalCount && !(this.firstIndex > this.totalCount);
},
firstIndex() {
const firstIndex = this.pageSize * (this.currentPage - 1) + 1;
return firstIndex;
return this.pageSize * (this.currentPage - 1) + 1;
},
lastIndex() {
const index = Math.min(this.totalCount, this.pageSize * this.currentPage);
return index;
return Math.min(this.totalCount, this.pageSize * this.currentPage);
},
searchButtonClass() {
return this.searchQuery !== '' ? 'show' : '';
},
hasLastPage() {
const isDisabled =
this.currentPage === Math.ceil(this.totalCount / this.pageSize);
return isDisabled;
return !!Math.ceil(this.totalCount / this.pageSize);
},
hasFirstPage() {
const isDisabled = this.currentPage === 1;
return isDisabled;
return this.currentPage === 1;
},
hasNextPage() {
const isDisabled =
this.currentPage === Math.ceil(this.totalCount / this.pageSize);
return isDisabled;
return this.currentPage === Math.ceil(this.totalCount / this.pageSize);
},
hasPrevPage() {
const isDisabled = this.currentPage === 1;
return isDisabled;
return this.currentPage === 1;
},
},
methods: {
onNextPage() {
if (this.hasNextPage) return;
if (this.hasNextPage) {
return;
}
const newPage = this.currentPage + 1;
this.onPageChange(newPage);
},
onPrevPage() {
if (this.hasPrevPage) return;
if (this.hasPrevPage) {
return;
}
const newPage = this.currentPage - 1;
this.onPageChange(newPage);
},
onFirstPage() {
if (this.hasFirstPage) return;
if (this.hasFirstPage) {
return;
}
const newPage = 1;
this.onPageChange(newPage);
},
onLastPage() {
if (this.hasLastPage) return;
if (this.hasLastPage) {
return;
}
const newPage = Math.ceil(this.totalCount / this.pageSize);
this.onPageChange(newPage);
},
onPageChange(page) {
this.$emit('page-change', page);
},
},
};
</script>

View file

@ -2,49 +2,47 @@ import { mount } from '@vue/test-utils';
import Avatar from './Avatar.vue';
import Thumbnail from './Thumbnail.vue';
describe(`when there are NO errors loading the thumbnail`, () => {
it(`should render the agent thumbnail`, () => {
describe('Thumbnail.vue', () => {
it('should render the agent thumbnail if valid image is passed', () => {
const wrapper = mount(Thumbnail, {
propsData: {
src: 'https://some_valid_url.com',
},
data() {
return {
hasImageLoaded: true,
imgError: false,
};
},
});
expect(wrapper.find('#image').exists()).toBe(true);
expect(wrapper.find('.user-thumbnail').exists()).toBe(true);
const avatarComponent = wrapper.findComponent(Avatar);
expect(avatarComponent.exists()).toBe(false);
expect(avatarComponent.isVisible()).toBe(false);
});
});
describe(`when there ARE errors loading the thumbnail`, () => {
it(`should render the agent avatar`, () => {
it('should render the avatar component if invalid image is passed', () => {
const wrapper = mount(Thumbnail, {
propsData: {
src: 'https://some_invalid_url.com',
},
data() {
return {
hasImageLoaded: true,
imgError: true,
};
},
});
expect(wrapper.find('#image').exists()).toBe(false);
const avatarComponent = wrapper.findComponent(Avatar);
expect(avatarComponent.exists()).toBe(true);
expect(avatarComponent.isVisible()).toBe(true);
});
});
describe(`when Avatar shows`, () => {
it(`initials shold correspond to username`, () => {
it('should the initial of the name if no image is passed', () => {
const wrapper = mount(Avatar, {
propsData: {
username: 'Angie Rojas',
},
});
expect(wrapper.find('span').text()).toBe('AR');
expect(wrapper.find('div').text()).toBe('AR');
});
});

View file

@ -1,74 +1,29 @@
<template>
<div class="user-thumbnail-box" :style="{ height: size, width: size }">
<div
:class="thumbnailBoxClass"
:style="{ height: size, width: size }"
:title="title"
>
<!-- Using v-show instead of v-if to avoid flickering as v-if removes dom elements. -->
<img
v-if="!imgError && Boolean(src)"
id="image"
v-show="shouldShowImage"
:src="src"
:class="thumbnailClass"
@error="onImgError()"
@load="onImgLoad"
@error="onImgError"
/>
<Avatar
v-else
v-show="!shouldShowImage"
:username="userNameWithoutEmoji"
:class="thumbnailClass"
:size="avatarSize"
:variant="variant"
/>
<img
v-if="badge === 'instagram_direct_message'"
id="badge"
v-if="badgeSrc"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/instagram-dm.png"
/>
<img
v-else-if="badge === 'facebook'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/messenger.png"
/>
<img
v-else-if="badge === 'twitter-tweet'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/twitter-tweet.png"
/>
<img
v-else-if="badge === 'twitter-dm'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/twitter-dm.png"
/>
<img
v-else-if="badge === 'whatsapp'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/whatsapp.png"
/>
<img
v-else-if="badge === 'sms'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/sms.png"
/>
<img
v-else-if="badge === 'Channel::Line'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/line.png"
/>
<img
v-else-if="badge === 'Channel::Telegram'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/telegram.png"
:src="`/integrations/channels/badges/${badgeSrc}.png`"
alt="Badge"
/>
<div
v-if="showStatusIndicator"
@ -83,7 +38,7 @@
* Src - source for round image
* Size - Size of the thumbnail
* Badge - Chat source indication { fb / telegram }
* Username - User name for avatar
* Username - Username for avatar
*/
import Avatar from './Avatar';
import { removeEmoji } from 'shared/helpers/emoji';
@ -103,7 +58,7 @@ export default {
},
badge: {
type: String,
default: 'fb',
default: '',
},
username: {
type: String,
@ -121,6 +76,10 @@ export default {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
variant: {
type: String,
default: 'circle',
@ -128,6 +87,7 @@ export default {
},
data() {
return {
hasImageLoaded: false,
imgError: false,
};
},
@ -142,6 +102,19 @@ export default {
avatarSize() {
return Number(this.size.replace(/\D+/g, ''));
},
badgeSrc() {
return {
instagram_direct_message: 'instagram-dm',
facebook: 'messenger',
'twitter-tweet': 'twitter-tweet',
'twitter-dm': 'twitter-dm',
whatsapp: 'whatsapp',
sms: 'sms',
'Channel::Line': 'line',
'Channel::Telegram': 'telegram',
'Channel::WebWidget': '',
}[this.badge];
},
badgeStyle() {
const size = Math.floor(this.avatarSize / 3);
const badgeSize = `${size + 2}px`;
@ -158,20 +131,34 @@ export default {
this.variant === 'circle' ? 'thumbnail-rounded' : 'thumbnail-square';
return `user-thumbnail ${classname} ${variant}`;
},
thumbnailBoxClass() {
const boxClass = this.variant === 'circle' ? 'is-rounded' : '';
return `user-thumbnail-box ${boxClass}`;
},
shouldShowImage() {
if (!this.src) {
return false;
}
if (this.hasImageLoaded) {
return !this.imgError;
}
return false;
},
},
watch: {
src: {
handler(value, oldValue) {
if (value !== oldValue && this.imgError) {
this.imgError = false;
}
},
src(value, oldValue) {
if (value !== oldValue && this.imgError) {
this.imgError = false;
}
},
},
methods: {
onImgError() {
this.imgError = true;
},
onImgLoad() {
this.hasImageLoaded = true;
},
},
};
</script>
@ -182,6 +169,10 @@ export default {
max-width: 100%;
position: relative;
&.is-rounded {
border-radius: 50%;
}
.user-thumbnail {
border-radius: 50%;
&.thumbnail-square {
@ -191,6 +182,7 @@ export default {
width: 100%;
box-sizing: border-box;
object-fit: cover;
vertical-align: initial;
&.border {
border: 1px solid white;
@ -229,9 +221,5 @@ export default {
.user-online-status--offline {
background: var(--s-500);
}
.user-online-status--offline {
background: var(--s-500);
}
}
</style>

View file

@ -0,0 +1,89 @@
<template>
<div class="overlapping-thumbnails">
<thumbnail
v-for="user in usersList"
:key="user.id"
v-tooltip="user.name"
:title="user.name"
:src="user.thumbnail"
:username="user.name"
:has-border="true"
:size="size"
:class="`overlapping-thumbnail gap-${gap}`"
/>
<span v-if="showMoreThumbnailsCount" class="thumbnail-more-text">
{{ moreThumbnailsText }}
</span>
</div>
</template>
<script>
import Thumbnail from './Thumbnail';
export default {
components: {
Thumbnail,
},
props: {
usersList: {
type: Array,
default: () => [],
},
size: {
type: String,
default: '24px',
},
showMoreThumbnailsCount: {
type: Boolean,
default: false,
},
moreThumbnailsText: {
type: String,
default: '',
},
gap: {
type: String,
default: 'normal',
validator(value) {
// The value must match one of these strings
return ['normal', '', 'tight'].includes(value);
},
},
},
};
</script>
<style lang="scss" scoped>
.overlapping-thumbnails {
display: flex;
}
.overlapping-thumbnail {
position: relative;
box-shadow: var(--shadow-small);
&:not(:first-child) {
margin-left: var(--space-minus-smaller);
}
.gap-tight {
margin-left: var(--space-minus-small);
}
}
.thumbnail-more-text {
display: inline-flex;
align-items: center;
position: relative;
margin-left: var(--space-minus-small);
padding: 0 var(--space-small);
box-shadow: var(--shadow-small);
background: var(--color-background);
border-radius: var(--space-giga);
border: 1px solid var(--white);
color: var(--s-600);
font-size: var(--font-size-mini);
font-weight: var(--font-weight-medium);
}
</style>

View file

@ -10,11 +10,11 @@ import 'videojs-record/dist/css/videojs.record.css';
import videojs from 'video.js';
import inboxMixin from '../../../../shared/mixins/inboxMixin';
import alertMixin from '../../../../shared/mixins/alertMixin';
import Recorder from 'opus-recorder';
import encoderWorker from 'opus-recorder/dist/encoderWorker.min';
import waveWorker from 'opus-recorder/dist/waveWorker.min';
import WaveSurfer from 'wavesurfer.js';
import MicrophonePlugin from 'wavesurfer.js/dist/plugin/wavesurfer.microphone.js';
@ -23,19 +23,25 @@ import 'videojs-wavesurfer/dist/videojs.wavesurfer.js';
import 'videojs-record/dist/videojs.record.js';
import 'videojs-record/dist/plugins/videojs.record.opus-recorder.js';
import { format, addSeconds } from 'date-fns';
import { AUDIO_FORMATS } from 'shared/constants/messages';
WaveSurfer.microphone = MicrophonePlugin;
export default {
name: 'WootAudioRecorder',
mixins: [inboxMixin, alertMixin],
mixins: [alertMixin],
props: {
audioRecordFormat: {
type: String,
default: AUDIO_FORMATS.WEBM,
},
},
data() {
return {
player: false,
recordingDateStarted: new Date(0),
initialTimeDuration: '00:00',
recorderOptions: {
debug: true,
controls: true,
bigPlayButton: false,
fluid: false,
@ -70,13 +76,28 @@ export default {
record: {
audio: true,
video: false,
displayMilliseconds: false,
maxLength: 300,
audioEngine: 'opus-recorder',
audioWorkerURL: encoderWorker,
audioChannels: 1,
audioSampleRate: 48000,
audioBitRate: 128,
maxLength: 900,
timeSlice: 1000,
maxFileSize: 15 * 1024 * 1024,
...(this.audioRecordFormat === AUDIO_FORMATS.WEBM && {
monitorGain: 0,
recordingGain: 1,
numberOfChannels: 1,
encoderSampleRate: 16000,
originalSampleRateOverride: 16000,
streamPages: true,
maxFramesPerPage: 1,
encoderFrameSize: 1,
encoderPath: waveWorker,
}),
...(this.audioRecordFormat === AUDIO_FORMATS.OGG && {
displayMilliseconds: false,
audioEngine: 'opus-recorder',
audioWorkerURL: encoderWorker,
audioChannels: 1,
audioSampleRate: 48000,
audioBitRate: 128,
}),
},
},
},

View file

@ -39,10 +39,17 @@ const TYPING_INDICATOR_IDLE_TIME = 4000;
import '@chatwoot/prosemirror-schema/src/woot-editor.css';
import {
hasPressedEnterAndNotCmdOrShift,
hasPressedCommandAndEnter,
hasPressedAltAndPKey,
hasPressedAltAndLKey,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../helper/AnalyticsHelper';
const createState = (content, placeholder, plugins = []) => {
return EditorState.create({
@ -58,13 +65,15 @@ const createState = (content, placeholder, plugins = []) => {
export default {
name: 'WootMessageEditor',
components: { TagAgents, CannedResponse },
mixins: [eventListenerMixins],
mixins: [eventListenerMixins, uiSettingsMixin],
props: {
value: { type: String, default: '' },
editorId: { type: String, default: '' },
placeholder: { type: String, default: '' },
isPrivate: { type: Boolean, default: false },
enableSuggestions: { type: Boolean, default: true },
overrideLineBreaks: { type: Boolean, default: false },
updateSelectionWith: { type: String, default: '' },
},
data() {
return {
@ -162,6 +171,25 @@ export default {
isPrivate() {
this.reloadState();
},
updateSelectionWith(newValue, oldValue) {
if (!this.editorView) {
return null;
}
if (newValue !== oldValue) {
if (this.updateSelectionWith !== '') {
const node = this.editorView.state.schema.text(
this.updateSelectionWith
);
const tr = this.editorView.state.tr.replaceSelectionWith(node);
this.editorView.focus();
this.state = this.editorView.state.apply(tr);
this.emitOnChange();
this.$emit('clear-selection');
}
}
return null;
},
},
created() {
this.state = createState(this.value, this.placeholder, this.plugins);
@ -188,6 +216,9 @@ export default {
keyup: () => {
this.onKeyup();
},
keydown: (view, event) => {
this.onKeydown(event);
},
focus: () => {
this.onFocus();
},
@ -203,6 +234,12 @@ export default {
},
});
},
isEnterToSendEnabled() {
return isEditorHotKeyEnabled(this.uiSettings, 'enter');
},
isCmdPlusEnterToSendEnabled() {
return isEditorHotKeyEnabled(this.uiSettings, 'cmd_enter');
},
handleKeyEvents(e) {
if (hasPressedAltAndPKey(e)) {
this.focusEditorInputField();
@ -233,7 +270,10 @@ export default {
node
);
this.state = this.editorView.state.apply(tr);
return this.emitOnChange();
this.emitOnChange();
AnalyticsHelper.track(ANALYTICS_EVENTS.USED_MENTIONS);
return false;
},
insertCannedResponse(cannedItem) {
@ -241,22 +281,27 @@ export default {
return null;
}
const tr = this.editorView.state.tr.insertText(
cannedItem,
this.range.from,
this.range.to
let from = this.range.from - 1;
let node = addMentionsToMarkdownParser(defaultMarkdownParser).parse(
cannedItem
);
if (node.childCount === 1) {
node = this.editorView.state.schema.text(cannedItem);
from = this.range.from;
}
const tr = this.editorView.state.tr.replaceWith(
from,
this.range.to,
node
);
this.state = this.editorView.state.apply(tr);
this.emitOnChange();
// Hacky fix for #5501
this.state = createState(
this.contentFromEditor,
this.placeholder,
this.plugins
);
this.editorView.updateState(this.state);
this.focusEditorInputField();
tr.scrollIntoView();
AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE);
return false;
},
@ -278,6 +323,24 @@ export default {
clearTimeout(this.idleTimer);
}
},
handleLineBreakWhenEnterToSendEnabled(event) {
if (
hasPressedEnterAndNotCmdOrShift(event) &&
this.isEnterToSendEnabled() &&
!this.overrideLineBreaks
) {
event.preventDefault();
}
},
handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
if (
hasPressedCommandAndEnter(event) &&
this.isCmdPlusEnterToSendEnabled() &&
!this.overrideLineBreaks
) {
event.preventDefault();
}
},
onKeyup() {
if (!this.idleTimer) {
this.$emit('typing-on');
@ -288,6 +351,14 @@ export default {
TYPING_INDICATOR_IDLE_TIME
);
},
onKeydown(event) {
if (this.isEnterToSendEnabled()) {
this.handleLineBreakWhenEnterToSendEnabled(event);
}
if (this.isCmdPlusEnterToSendEnabled()) {
this.handleLineBreakWhenCmdAndEnterToSendEnabled(event);
}
},
onBlur() {
this.turnOffIdleTimer();
this.resetTyping();

View file

@ -11,7 +11,6 @@
size="small"
@click="toggleEmojiPicker"
/>
<!-- ensure the same validations for attachment types are implemented in backend models as well -->
<file-upload
ref="upload"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
@ -47,6 +46,16 @@
:title="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
@click="toggleAudioRecorder"
/>
<woot-button
v-if="showEditorToggle"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
icon="quote"
emoji="🖊️"
color-scheme="secondary"
variant="smooth"
size="small"
@click="$emit('toggle-editor')"
/>
<woot-button
v-if="showAudioPlayStopButton"
:icon="audioRecorderPlayStopIcon"
@ -110,13 +119,15 @@ import { hasPressedAltAndAKey } from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import {
ALLOWED_FILE_TYPES,
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
} from 'shared/constants/messages';
import { REPLY_EDITOR_MODES } from './constants';
import { mapGetters } from 'vuex';
export default {
name: 'ReplyBottomPanel',
components: { FileUpload },
@ -182,7 +193,7 @@ export default {
type: Boolean,
default: false,
},
isFormatMode: {
showEditorToggle: {
type: Boolean,
default: false,
},
@ -200,6 +211,10 @@ export default {
},
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
isNote() {
return this.mode === REPLY_EDITOR_MODES.NOTE;
},
@ -217,7 +232,19 @@ export default {
return this.showFileUpload || this.isNote;
},
showAudioRecorderButton() {
return this.showAudioRecorder;
// Disable audio recorder for safari browser as recording is not supported
const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(
navigator.userAgent
);
return (
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.VOICE_RECORDER
) &&
this.showAudioRecorder &&
!isSafari
);
},
showAudioPlayStopButton() {
return this.showAudioRecorder && this.isRecordingAudio;

View file

@ -42,7 +42,7 @@
</div>
<dashboard-app-frame
v-else
:key="currentChat.id"
:key="currentChat.id + '-' + activeIndex"
:config="dashboardApps[activeIndex - 1].content"
:current-chat="currentChat"
/>

View file

@ -91,6 +91,7 @@
</span>
<span class="unread">{{ unreadCount > 9 ? '9+' : unreadCount }}</span>
</div>
<card-labels :conversation-id="chat.id" />
</div>
<woot-context-menu
v-if="showContextMenu"
@ -102,10 +103,12 @@
<conversation-context-menu
:status="chat.status"
:inbox-id="inbox.id"
:has-unread-messages="hasUnread"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@assign-team="onAssignTeam"
@mark-as-unread="markAsUnread"
/>
</woot-context-menu>
</div>
@ -123,8 +126,8 @@ import InboxName from '../InboxName';
import inboxMixin from 'shared/mixins/inboxMixin';
import ConversationContextMenu from './contextMenu/Index.vue';
import alertMixin from 'shared/mixins/alertMixin';
import timeAgo from 'dashboard/components/ui/TimeAgo';
import TimeAgo from 'dashboard/components/ui/TimeAgo';
import CardLabels from './conversationCardComponents/CardLabels.vue';
const ATTACHMENT_ICONS = {
image: 'image',
audio: 'headphones-sound-wave',
@ -136,10 +139,11 @@ const ATTACHMENT_ICONS = {
export default {
components: {
CardLabels,
InboxName,
Thumbnail,
ConversationContextMenu,
timeAgo,
TimeAgo,
},
mixins: [
@ -241,7 +245,7 @@ export default {
},
unreadCount() {
return this.unreadMessagesCount(this.chat);
return this.chat.unread_count;
},
hasUnread() {
@ -359,16 +363,24 @@ export default {
this.$emit('assign-team', team, this.chat.id);
this.closeContextMenu();
},
async markAsUnread() {
this.$emit('mark-as-unread', this.chat.id);
this.closeContextMenu();
},
},
};
</script>
<style lang="scss" scoped>
.conversation {
align-items: center;
align-items: flex-start;
&:hover {
background: var(--color-background-light);
}
&::v-deep .user-thumbnail-box {
margin-top: var(--space-normal);
}
}
.conversation-selected {
@ -377,8 +389,10 @@ export default {
.has-inbox-name {
&::v-deep .user-thumbnail-box {
margin-top: var(--space-normal);
align-items: flex-start;
margin-top: var(--space-large);
}
.checkbox-wrapper {
margin-top: var(--space-large);
}
.conversation--meta {
margin-top: var(--space-normal);
@ -423,6 +437,7 @@ export default {
margin-top: var(--space-minus-micro);
vertical-align: middle;
}
.checkbox-wrapper {
height: 40px;
width: 40px;
@ -432,6 +447,7 @@ export default {
border-radius: 100%;
margin-top: var(--space-normal);
cursor: pointer;
&:hover {
background-color: var(--w-100);
}

View file

@ -21,7 +21,11 @@
/>
</h3>
<div class="conversation--header--actions">
<inbox-name :inbox="inbox" class="margin-right-small" />
<inbox-name
v-if="hasMultipleInboxes"
:inbox="inbox"
class="margin-right-small"
/>
<span
v-if="isSnoozed"
class="snoozed--display-text margin-right-small"
@ -145,6 +149,9 @@ export default {
const { inbox_id: inboxId } = this.chat;
return this.$store.getters['inboxes/getInbox'](inboxId);
},
hasMultipleInboxes() {
return this.$store.getters['inboxes/getInboxes'].length > 1;
},
},
methods: {

View file

@ -15,7 +15,6 @@
v-if="data.content"
:message="message"
:is-email="isEmailContentType"
:readable-time="readableTime"
:display-quoted-button="displayQuotedButton"
/>
<span
@ -29,7 +28,6 @@
<bubble-image
v-if="attachment.file_type === 'image' && !hasImageError"
:url="attachment.data_url"
:readable-time="readableTime"
@error="onImageLoadError"
/>
<audio v-else-if="attachment.file_type === 'audio'" controls>
@ -38,13 +36,14 @@
<bubble-video
v-else-if="attachment.file_type === 'video'"
:url="attachment.data_url"
:readable-time="readableTime"
/>
<bubble-file
v-else
:url="attachment.data_url"
:readable-time="readableTime"
<bubble-location
v-else-if="attachment.file_type === 'location'"
:latitude="attachment.coordinates_lat"
:longitude="attachment.coordinates_long"
:name="attachment.fallback_title"
/>
<bubble-file v-else :url="attachment.data_url" />
</div>
</div>
<bubble-actions
@ -53,14 +52,15 @@
:story-sender="storySender"
:story-id="storyId"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:has-instagram-story="hasInstagramStory"
:is-email="isEmailContentType"
:is-private="data.private"
:message-type="data.message_type"
:readable-time="readableTime"
:message-status="status"
:source-id="data.source_id"
:inbox-id="data.inbox_id"
:message-read="showReadTicks"
:created-at="createdAt"
/>
</div>
<spinner v-if="isPending" size="tiny" />
@ -111,14 +111,13 @@
</template>
<script>
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import timeMixin from '../../../mixins/time';
import BubbleMailHead from './bubble/MailHead';
import BubbleText from './bubble/Text';
import BubbleImage from './bubble/Image';
import BubbleFile from './bubble/File';
import BubbleVideo from './bubble/Video.vue';
import BubbleActions from './bubble/Actions';
import BubbleLocation from './bubble/Location';
import Spinner from 'shared/components/Spinner';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu';
@ -136,10 +135,11 @@ export default {
BubbleFile,
BubbleVideo,
BubbleMailHead,
BubbleLocation,
ContextMenu,
Spinner,
},
mixins: [alertMixin, timeMixin, messageFormatterMixin, contentTypeMixin],
mixins: [alertMixin, messageFormatterMixin, contentTypeMixin],
props: {
data: {
type: Object,
@ -149,11 +149,11 @@ export default {
type: Boolean,
default: false,
},
hasInstagramStory: {
isAWhatsAppChannel: {
type: Boolean,
default: false,
},
hasUserReadMessage: {
hasInstagramStory: {
type: Boolean,
default: false,
},
@ -223,6 +223,9 @@ export default {
sender() {
return this.data.sender || {};
},
status() {
return this.data.status;
},
storySender() {
return this.contentAttributes.story_sender || null;
},
@ -256,11 +259,8 @@ export default {
'has-tweet-menu': this.isATweet,
};
},
readableTime() {
return this.messageStamp(
this.contentAttributes.external_created_at || this.data.created_at,
'LLL d, h:mm a'
);
createdAt() {
return this.contentAttributes.external_created_at || this.data.created_at;
},
isBubble() {
return [0, 1, 3].includes(this.data.message_type);
@ -271,14 +271,6 @@ export default {
isOutgoing() {
return this.data.message_type === MESSAGE_TYPE.OUTGOING;
},
showReadTicks() {
return (
(this.isOutgoing || this.isTemplate) &&
this.hasUserReadMessage &&
this.isWebWidgetInbox &&
!this.data.private
);
},
isTemplate() {
return this.data.message_type === MESSAGE_TYPE.TEMPLATE;
},
@ -420,6 +412,8 @@ export default {
<style lang="scss">
.wrap {
> .bubble {
min-width: 128px;
&.is-image,
&.is-video {
padding: 0;

View file

@ -35,20 +35,18 @@
<message
v-for="message in getReadMessages"
:key="message.id"
class="message--read"
class="message--read ph-no-capture"
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:has-instagram-story="hasInstagramStory"
:has-user-read-message="
hasUserReadMessage(message.created_at, getLastSeenAt)
"
:is-web-widget-inbox="isAWebWidgetInbox"
/>
<li v-show="getUnreadCount != 0" class="unread--toast">
<li v-show="unreadMessageCount != 0" class="unread--toast">
<span class="text-uppercase">
{{ getUnreadCount }}
{{ unreadMessageCount }}
{{
getUnreadCount > 1
unreadMessageCount > 1
? $t('CONVERSATION.UNREAD_MESSAGES')
: $t('CONVERSATION.UNREAD_MESSAGE')
}}
@ -57,13 +55,11 @@
<message
v-for="message in getUnReadMessages"
:key="message.id"
class="message--unread"
class="message--unread ph-no-capture"
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:has-instagram-story="hasInstagramStory"
:has-user-read-message="
hasUserReadMessage(message.created_at, getLastSeenAt)
"
:is-web-widget-inbox="isAWebWidgetInbox"
/>
</ul>
@ -137,9 +133,7 @@ export default {
allConversations: 'getAllConversations',
inboxesList: 'inboxes/getInboxes',
listLoadingStatus: 'getAllMessagesLoaded',
getUnreadCount: 'getUnreadCount',
loadingChatList: 'getChatListLoadingStatus',
conversationLastSeen: 'getConversationLastSeen',
}),
inboxId() {
return this.currentChat.inbox_id;
@ -234,7 +228,6 @@ export default {
return 'arrow-chevron-left';
},
getLastSeenAt() {
if (this.conversationLastSeen) return this.conversationLastSeen;
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
return contactLastSeenAt;
},
@ -273,6 +266,9 @@ export default {
}
return '';
},
unreadMessageCount() {
return this.currentChat.unread_count;
},
},
watch: {
@ -333,7 +329,7 @@ export default {
},
scrollToBottom() {
let relevantMessages = [];
if (this.getUnreadCount > 0) {
if (this.unreadMessageCount > 0) {
// capturing only the unread messages
relevantMessages = this.conversationPanel.querySelectorAll(
'.message--unread'
@ -431,12 +427,7 @@ export default {
position: fixed;
left: unset;
position: absolute;
&::before {
transform: rotate(0deg);
left: var(--space-half);
bottom: var(--space-minus-slab);
}
bottom: var(--space-smaller);
}
}
}

View file

@ -37,6 +37,7 @@
<woot-audio-recorder
v-if="showAudioRecorderEditor"
ref="audioRecorderInput"
:audio-record-format="audioRecordFormat"
@state-recorder-progress-changed="onStateProgressRecorderChanged"
@state-recorder-changed="onStateRecorderChanged"
@finish-record="onFinishRecorder"
@ -60,6 +61,7 @@
class="input"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@ -67,6 +69,7 @@
@blur="onBlur"
@toggle-user-mention="toggleUserMention"
@toggle-canned-menu="toggleCannedMenu"
@clear-selection="clearEditorSelection"
/>
</div>
<div v-if="hasAttachments" class="attachment-preview-box" @paste="onPaste">
@ -109,10 +112,11 @@
:recording-audio-state="recordingAudioState"
:is-recording-audio="isRecordingAudio"
:is-on-private-note="isOnPrivateNote"
:is-format-mode="showRichContentEditor"
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
:enable-multiple-file-upload="enableMultipleFileUpload"
:has-whatsapp-templates="hasWhatsappTemplates"
@selectWhatsappTemplate="openWhatsappTemplateModal"
@toggle-editor="toggleRichContentEditor"
/>
<whatsapp-templates
:inbox-id="inbox.id"
@ -129,7 +133,6 @@ import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import EmojiInput from 'shared/components/emoji/EmojiInput';
import CannedResponse from './CannedResponse';
import ResizableTextArea from 'shared/components/ResizableTextArea';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview';
@ -145,6 +148,7 @@ import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import {
MAXIMUM_FILE_UPLOAD_SIZE,
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL,
AUDIO_FORMATS,
} from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
@ -159,6 +163,11 @@ import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../../helper/localStorage';
import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../helper/AnalyticsHelper';
const EmojiInput = () => import('shared/components/emoji/EmojiInput');
export default {
components: {
@ -214,6 +223,7 @@ export default {
ccEmails: '',
doAutoSaveDraft: () => {},
showWhatsAppTemplatesModal: false,
updateEditorSelectionWith: '',
};
},
computed: {
@ -230,6 +240,13 @@ export default {
return true;
}
if (this.isAPIInbox) {
const {
display_rich_content_editor: displayRichContentEditor = false,
} = this.uiSettings;
return displayRichContentEditor;
}
return false;
},
assignedAgent: {
@ -365,7 +382,7 @@ export default {
);
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
return this.isAWebWidgetInbox || this.isAnEmailChannel;
},
showAudioRecorder() {
return !this.isOnPrivateNote && this.showFileUpload;
@ -390,7 +407,7 @@ export default {
return conversationDisplayType !== CONDENSED;
},
emojiDialogClassOnExpanedLayout() {
return this.isOnExpandedLayout && !this.popoutReplyBox
return this.isOnExpandedLayout || this.popoutReplyBox
? 'emoji-dialog--expanded'
: '';
},
@ -442,12 +459,17 @@ export default {
return this.currentChat.id;
},
conversationIdByRoute() {
const { conversation_id: conversationId } = this.$route.params;
return conversationId;
return this.conversationId;
},
editorStateId() {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
if (this.isAWebWidgetInbox) {
return AUDIO_FORMATS.WEBM;
}
return AUDIO_FORMATS.OGG;
},
},
watch: {
currentChat(conversation) {
@ -511,6 +533,11 @@ export default {
document.removeEventListener('keydown', this.handleKeyEvents);
},
methods: {
toggleRichContentEditor() {
this.updateUISettings({
display_rich_content_editor: !this.showRichContentEditor,
});
},
getSavedDraftMessages() {
return LocalStorage.get(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES) || {};
},
@ -574,6 +601,7 @@ export default {
e.preventDefault();
} else if (keyCode === 'enter' && this.isAValidEvent('enter')) {
this.onSendReply();
e.preventDefault();
} else if (
['meta+enter', 'ctrl+enter'].includes(keyCode) &&
this.isAValidEvent('cmd_enter')
@ -681,6 +709,7 @@ export default {
},
replaceText(message) {
setTimeout(() => {
AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE);
this.message = message;
}, 100);
},
@ -695,8 +724,26 @@ export default {
}
this.$nextTick(() => this.$refs.messageInput.focus());
},
clearEditorSelection() {
this.updateEditorSelectionWith = '';
},
insertEmoji(emoji, selectionStart, selectionEnd) {
const { message } = this;
const newMessage =
message.slice(0, selectionStart) +
emoji +
message.slice(selectionEnd, message.length);
this.message = newMessage;
},
emojiOnClick(emoji) {
this.message = `${this.message}${emoji} `;
if (this.showRichContentEditor) {
this.updateEditorSelectionWith = emoji;
this.onFocus();
}
if (!this.showRichContentEditor) {
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
this.insertEmoji(emoji, selectionStart, selectionEnd);
}
},
clearMessage() {
this.message = '';
@ -951,13 +998,13 @@ export default {
.emoji-dialog {
top: unset;
bottom: 12px;
bottom: var(--space-normal);
left: -320px;
right: unset;
&::before {
right: -16px;
bottom: 10px;
right: var(--space-minus-normal);
bottom: var(--space-small);
transform: rotate(270deg);
filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.08));
}
@ -970,8 +1017,8 @@ export default {
&::before {
transform: rotate(0deg);
left: var(--space-half);
bottom: var(--space-minus-slab);
left: var(--space-smaller);
bottom: var(--space-minus-small);
}
}
.message-signature {

View file

@ -1,22 +1,38 @@
<template>
<div class="message-text--metadata">
<span class="time" :class="{ delivered: messageRead }">{{
readableTime
}}</span>
<span v-if="showSentIndicator" class="time">
<span
class="time"
:class="{
'has-status-icon':
showSentIndicator || showDeliveredIndicator || showReadIndicator,
}"
>
{{ readableTime }}
</span>
<span v-if="showReadIndicator" class="read-indicator-wrap">
<fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.SENT')"
icon="checkmark"
v-tooltip.top-start="$t('CHAT_LIST.MESSAGE_READ')"
icon="checkmark-double"
class="action--icon read-tick read-indicator"
size="14"
/>
</span>
<span v-else-if="showDeliveredIndicator" class="read-indicator-wrap">
<fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.DELIVERED')"
icon="checkmark-double"
class="action--icon read-tick"
size="14"
/>
</span>
<span v-else-if="showSentIndicator" class="read-indicator-wrap">
<fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.SENT')"
icon="checkmark"
class="action--icon read-tick"
size="14"
/>
</span>
<fluent-icon
v-if="messageRead"
v-tooltip.top-start="$t('CHAT_LIST.MESSAGE_READ')"
icon="checkmark-double"
class="action--icon read-tick"
size="12"
/>
<fluent-icon
v-if="isEmail"
v-tooltip.top-start="$t('CHAT_LIST.RECEIVED_VIA_EMAIL')"
@ -44,19 +60,6 @@
size="16"
/>
</button>
<a
v-if="hasInstagramStory && (isIncoming || isOutgoing) && linkToStory"
:href="linkToStory"
target="_blank"
rel="noopener noreferrer nofollow"
>
<fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.LINK_TO_STORY')"
icon="open"
class="action--icon cursor-pointer"
size="16"
/>
</a>
<a
v-if="isATweet && (isOutgoing || isIncoming) && linkToTweet"
:href="linkToTweet"
@ -74,20 +77,22 @@
</template>
<script>
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import inboxMixin from 'shared/mixins/inboxMixin';
import { mapGetters } from 'vuex';
import timeMixin from '../../../../mixins/time';
export default {
mixins: [inboxMixin],
mixins: [inboxMixin, timeMixin],
props: {
sender: {
type: Object,
default: () => ({}),
},
readableTime: {
type: String,
default: '',
createdAt: {
type: Number,
default: 0,
},
storySender: {
type: String,
@ -117,6 +122,10 @@ export default {
type: Number,
default: 1,
},
messageStatus: {
type: String,
default: '',
},
sourceId: {
type: String,
default: '',
@ -129,12 +138,9 @@ export default {
type: [String, Number],
default: 0,
},
messageRead: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({ currentChat: 'getSelectedChat' }),
inbox() {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
@ -144,6 +150,21 @@ export default {
isOutgoing() {
return MESSAGE_TYPE.OUTGOING === this.messageType;
},
isTemplate() {
return MESSAGE_TYPE.TEMPLATE === this.messageType;
},
isDelivered() {
return MESSAGE_STATUS.DELIVERED === this.messageStatus;
},
isRead() {
return MESSAGE_STATUS.READ === this.messageStatus;
},
isSent() {
return MESSAGE_STATUS.SENT === this.messageStatus;
},
readableTime() {
return this.messageStamp(this.createdAt, 'LLL d, h:mm a');
},
screenName() {
const { additional_attributes: additionalAttributes = {} } =
this.sender || {};
@ -164,12 +185,52 @@ export default {
const { storySender, storyId } = this;
return `https://www.instagram.com/stories/${storySender}/${storyId}`;
},
showStatusIndicators() {
if ((this.isOutgoing || this.isTemplate) && !this.private) {
return true;
}
return false;
},
showSentIndicator() {
return (
this.isOutgoing &&
this.sourceId &&
(this.isAnEmailChannel || this.isAWhatsAppChannel)
);
if (!this.showStatusIndicators) {
return false;
}
if (this.isAnEmailChannel) {
return !!this.sourceId;
}
if (this.isAWhatsAppChannel) {
return this.sourceId && this.isSent;
}
return false;
},
showDeliveredIndicator() {
if (!this.showStatusIndicators) {
return false;
}
if (this.isAWhatsAppChannel) {
return this.sourceId && this.isDelivered;
}
return false;
},
showReadIndicator() {
if (!this.showStatusIndicators) {
return false;
}
if (this.isAWebWidgetInbox) {
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
return contactLastSeenAt >= this.createdAt;
}
if (this.isAWhatsAppChannel) {
return this.sourceId && this.isRead;
}
return false;
},
},
methods: {
@ -185,16 +246,21 @@ export default {
.right {
.message-text--metadata {
align-items: center;
.time {
color: var(--w-100);
}
.action--icon {
color: var(--white);
&.read-tick {
color: var(--v-100);
margin-top: calc(var(--space-micro) + var(--space-micro) / 2);
}
color: var(--white);
&.read-indicator {
color: var(--g-200);
}
}
.lock--icon--private {
@ -258,8 +324,9 @@ export default {
position: absolute;
right: var(--space-small);
white-space: nowrap;
&.delivered {
right: var(--space-medium);
&.has-status-icon {
right: var(--space-large);
line-height: 2;
}
}
@ -296,4 +363,10 @@ export default {
.delivered-icon {
margin-left: -var(--space-normal);
}
.read-indicator-wrap {
line-height: 1;
display: flex;
align-items: center;
}
</style>

View file

@ -0,0 +1,78 @@
<template>
<div class="location message-text__wrap">
<div class="icon-wrap">
<fluent-icon icon="location" class="file--icon" size="32" />
</div>
<div class="meta">
<h5 class="text-block-title text-truncate">
{{ name }}
</h5>
<div class="link-wrap">
<a
class="download clear link button small"
rel="noreferrer noopener nofollow"
target="_blank"
:href="mapUrl"
>
{{ $t('COMPONENTS.LOCATION_BUBBLE.SEE_ON_MAP') }}
</a>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
latitude: {
type: Number,
default: undefined,
},
longitude: {
type: Number,
default: undefined,
},
name: {
type: String,
default: '',
},
},
computed: {
mapUrl() {
return `https://maps.google.com/?q=${this.latitude},${this.longitude}`;
},
},
};
</script>
<style lang="scss" scoped>
.location {
display: flex;
flex-direction: row;
padding: var(--space-smaller) 0;
cursor: pointer;
.icon-wrap {
color: var(--s-600);
line-height: 1;
margin: 0 var(--space-smaller);
}
.text-block-title {
margin: 0;
color: var(--s-800);
word-break: break-word;
}
.meta {
display: flex;
flex-direction: column;
align-items: flex-start;
padding-right: var(--space-normal);
}
.link-wrap {
display: flex;
}
}
</style>

View file

@ -35,10 +35,6 @@ export default {
type: String,
default: '',
},
readableTime: {
type: String,
default: '',
},
isEmail: {
type: Boolean,
default: true,

View file

@ -1,5 +1,11 @@
<template>
<div class="menu-container">
<menu-item
v-if="!hasUnreadMessages"
:option="unreadOption"
variant="icon"
@click="$emit('mark-as-unread')"
/>
<template v-for="option in statusMenuConfig">
<menu-item
v-if="show(option.key)"
@ -17,7 +23,10 @@
@click="snoozeConversation(option.snoozedUntil)"
/>
</menu-item-with-submenu>
<menu-item-with-submenu :option="labelMenuConfig">
<menu-item-with-submenu
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
>
<template>
<menu-item
v-for="label in labels"
@ -28,7 +37,10 @@
/>
</template>
</menu-item-with-submenu>
<menu-item-with-submenu :option="agentMenuConfig">
<menu-item-with-submenu
:option="agentMenuConfig"
:sub-menu-available="!!assignableAgents.length"
>
<agent-loading-placeholder v-if="assignableAgentsUiFlags.isFetching" />
<template v-else>
<menu-item
@ -40,7 +52,10 @@
/>
</template>
</menu-item-with-submenu>
<menu-item-with-submenu :option="teamMenuConfig">
<menu-item-with-submenu
:option="teamMenuConfig"
:sub-menu-available="!!teams.length"
>
<menu-item
v-for="team in teams"
:key="team.id"
@ -70,6 +85,10 @@ export default {
type: String,
default: '',
},
hasUnreadMessages: {
type: Boolean,
default: false,
},
inboxId: {
type: Number,
default: null,
@ -78,6 +97,10 @@ export default {
data() {
return {
STATUS_TYPE: wootConstants.STATUS_TYPE,
unreadOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'),
icon: 'mail',
},
statusMenuConfig: [
{
key: wootConstants.STATUS_TYPE.RESOLVED,

View file

@ -18,7 +18,7 @@
size="20px"
class="agent-thumbnail"
/>
<p class="menu-label truncate-text">{{ option.label }}</p>
<p class="menu-label text-truncate">{{ option.label }}</p>
</div>
</template>
@ -50,7 +50,6 @@ export default {
padding: var(--space-smaller);
border-radius: var(--border-radius-small);
overflow: hidden;
.menu-label {
margin: 0;
font-size: var(--font-size-mini);

View file

@ -1,11 +1,14 @@
<template>
<div class="menu-with-submenu flex-between">
<div
class="menu-with-submenu flex-between"
:class="{ disabled: !subMenuAvailable }"
>
<div class="menu-left">
<fluent-icon :icon="option.icon" size="14" class="menu-icon" />
<p class="menu-label">{{ option.label }}</p>
</div>
<fluent-icon icon="chevron-right" size="12" />
<div class="submenu">
<div v-if="subMenuAvailable" class="submenu">
<slot />
</div>
</div>
@ -18,6 +21,10 @@ export default {
type: Object,
default: () => {},
},
subMenuAvailable: {
type: Boolean,
default: true,
},
},
};
</script>
@ -55,6 +62,11 @@ export default {
left: 100%;
top: 0;
display: none;
min-height: min-content;
max-height: var(--space-giga);
overflow-y: auto;
// Need this because Firefox adds a horizontal scrollbar, if a text is truncated inside.
overflow-x: hidden;
}
&:hover {
@ -73,5 +85,10 @@ export default {
clip-path: polygon(100% 0, 0% 0%, 100% 100%);
}
}
&.disabled {
opacity: 50%;
cursor: not-allowed;
}
}
</style>

View file

@ -1,6 +1,6 @@
<template>
<div class="bulk-action__agents">
<div class="triangle">
<div class="triangle" :style="cssVars">
<svg height="12" viewBox="0 0 24 12" width="24">
<path
d="M20 12l-8-8-12 12"
@ -105,13 +105,14 @@ import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Spinner from 'shared/components/Spinner';
import { mixin as clickaway } from 'vue-clickaway';
import bulkActionsMixin from 'dashboard/mixins/bulkActionsMixin.js';
export default {
components: {
Thumbnail,
Spinner,
},
mixins: [clickaway],
mixins: [clickaway, bulkActionsMixin],
props: {
selectedInboxes: {
type: Array,
@ -233,7 +234,7 @@ export default {
z-index: var(--z-index-one);
position: absolute;
top: calc(var(--space-slab) * -1);
right: var(--space-micro);
right: var(--triangle-position);
text-align: left;
}
}

View file

@ -43,25 +43,26 @@
variant="smooth"
color-scheme="secondary"
icon="person-assign"
class="margin-right-smaller"
@click="toggleAgentList"
/>
<woot-button
v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="people-team-add"
@click="toggleTeamsList"
/>
</div>
<transition name="popover-animation">
<label-actions
v-if="showLabelActions"
triangle-position="8.5"
@assign="assignLabels"
@close="showLabelActions = false"
/>
</transition>
<transition name="popover-animation">
<agent-selector
v-if="showAgentsList"
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
@select="submit"
@close="showAgentsList = false"
/>
</transition>
<transition name="popover-animation">
<update-actions
v-if="showUpdateActions"
@ -70,10 +71,29 @@
:show-resolve="!showResolvedAction"
:show-reopen="!showOpenAction"
:show-snooze="!showSnoozedAction"
triangle-position="5.6"
@update="updateConversations"
@close="showUpdateActions = false"
/>
</transition>
<transition name="popover-animation">
<agent-selector
v-if="showAgentsList"
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
triangle-position="2.8"
@select="submit"
@close="showAgentsList = false"
/>
</transition>
<transition name="popover-animation">
<team-actions
v-if="showTeamsList"
triangle-position="0.2"
@assign-team="assignTeam"
@close="showTeamsList = false"
/>
</transition>
</div>
<div v-if="allConversationsSelected" class="bulk-action__alert">
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
@ -85,11 +105,13 @@
import AgentSelector from './AgentSelector.vue';
import UpdateActions from './UpdateActions.vue';
import LabelActions from './LabelActions.vue';
import TeamActions from './TeamActions.vue';
export default {
components: {
AgentSelector,
UpdateActions,
LabelActions,
TeamActions,
},
props: {
conversations: {
@ -122,6 +144,8 @@ export default {
showAgentsList: false,
showUpdateActions: false,
showLabelActions: false,
showTeamsList: false,
popoverPositions: {},
};
},
methods: {
@ -137,6 +161,9 @@ export default {
assignLabels(labels) {
this.$emit('assign-labels', labels);
},
assignTeam(team) {
this.$emit('assign-team', team);
},
resolveConversations() {
this.$emit('resolve-conversations');
},
@ -149,6 +176,9 @@ export default {
toggleAgentList() {
this.showAgentsList = !this.showAgentsList;
},
toggleTeamsList() {
this.showTeamsList = !this.showTeamsList;
},
},
};
</script>
@ -181,7 +211,7 @@ export default {
color: var(--y-700);
font-size: var(--font-size-mini);
margin-top: var(--space-small);
padding: var(--space-half) var(--space-one);
padding: var(--space-smaller) var(--space-small);
}
.popover-animation-enter-active,

View file

@ -1,6 +1,6 @@
<template>
<div v-on-clickaway="onClose" class="labels-container">
<div class="triangle">
<div class="triangle" :style="cssVars">
<svg height="12" viewBox="0 0 24 12" width="24">
<path
d="M20 12l-8-8-12 12"
@ -75,9 +75,10 @@
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import bulkActionsMixin from 'dashboard/mixins/bulkActionsMixin.js';
export default {
mixins: [clickaway],
mixins: [clickaway, bulkActionsMixin],
data() {
return {
query: '',
@ -160,7 +161,7 @@ export default {
max-width: var(--space-giga);
min-width: var(--space-giga);
position: absolute;
right: 4.5rem;
right: var(--space-small);
top: var(--space-larger);
transform-origin: top right;
width: auto;
@ -204,7 +205,7 @@ export default {
.triangle {
display: block;
position: absolute;
right: var(--space-two);
right: var(--triangle-position);
text-align: left;
top: calc(var(--space-slab) * -1);
z-index: var(--z-index-one);

View file

@ -0,0 +1,174 @@
<template>
<div v-on-clickaway="onClose" class="bulk-action__teams">
<div class="triangle" :style="cssVars">
<svg height="12" viewBox="0 0 24 12" width="24">
<path
d="M20 12l-8-8-12 12"
fill="var(--white)"
fill-rule="evenodd"
stroke="var(--s-50)"
stroke-width="1px"
/>
</svg>
</div>
<div class="header flex-between">
<span>{{ $t('BULK_ACTION.TEAMS.TEAM_SELECT_LABEL') }}</span>
<woot-button
size="tiny"
variant="clear"
color-scheme="secondary"
icon="dismiss"
@click="onClose"
/>
</div>
<div class="container">
<div class="team__list-container">
<ul>
<li class="search-container">
<div class="agent-list-search flex-between">
<fluent-icon icon="search" class="search-icon" size="16" />
<input
ref="search"
v-model="query"
type="search"
placeholder="Search"
class="agent--search_input"
/>
</div>
</li>
<template v-if="filteredTeams.length">
<li v-for="team in filteredTeams" :key="team.id">
<div class="team__list-item" @click="assignTeam(team)">
<span class="reports-option__title">{{ team.name }}</span>
</div>
</li>
</template>
<li v-else>
<div class="team__list-item">
<span class="reports-option__title">{{
$t('BULK_ACTION.TEAMS.NO_TEAMS_AVAILABLE')
}}</span>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import bulkActionsMixin from 'dashboard/mixins/bulkActionsMixin.js';
export default {
mixins: [clickaway, bulkActionsMixin],
data() {
return {
query: '',
selectedteams: [],
};
},
computed: {
...mapGetters({ teams: 'teams/getTeams' }),
filteredTeams() {
return [
{ name: 'None', id: 0 },
...this.teams.filter(team =>
team.name.toLowerCase().includes(this.query.toLowerCase())
),
];
},
},
methods: {
assignTeam(key) {
this.$emit('assign-team', key);
},
onClose() {
this.$emit('close');
},
},
};
</script>
<style scoped lang="scss">
.bulk-action__teams {
background-color: var(--white);
border-radius: var(--border-radius-large);
border: 1px solid var(--s-50);
box-shadow: var(--shadow-dropdown-pane);
max-width: 75%;
position: absolute;
right: var(--space-small);
top: var(--space-larger);
transform-origin: top right;
width: auto;
z-index: var(--z-index-twenty);
min-width: var(--space-giga);
.header {
padding: var(--space-one);
span {
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
}
}
.container {
max-height: var(--space-giga);
overflow-y: auto;
.team__list-container {
height: 100%;
}
.agent-list-search {
padding: 0 var(--space-one);
border: 1px solid var(--s-100);
border-radius: var(--border-radius-medium);
background-color: var(--s-50);
.search-icon {
color: var(--s-400);
}
.agent--search_input {
border: 0;
font-size: var(--font-size-mini);
margin: 0;
background-color: transparent;
height: unset;
}
}
}
.triangle {
display: block;
z-index: var(--z-index-one);
position: absolute;
top: calc(var(--space-slab) * -1);
right: var(--triangle-position);
text-align: left;
}
}
ul {
margin: 0;
list-style: none;
}
.team__list-item {
display: flex;
align-items: center;
padding: var(--space-one);
cursor: pointer;
&:hover {
background-color: var(--s-50);
}
span {
font-size: var(--font-size-small);
}
}
.search-container {
padding: 0 var(--space-one);
position: sticky;
top: 0;
z-index: var(--z-index-twenty);
background-color: var(--white);
}
</style>

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