Compare commits

...

109 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
1004 changed files with 33751 additions and 4025 deletions

View file

@ -56,13 +56,14 @@ RAILS_MAX_THREADS=5
# The email from which all outgoing emails are sent # The email from which all outgoing emails are sent
# could user either `email@yourdomain.com` or `BrandName <email@yourdomain.com>` # 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 key is set up for HELO checking
SMTP_DOMAIN=chatwoot.com 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 # 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_PORT=1025
SMTP_USERNAME= SMTP_USERNAME=
SMTP_PASSWORD= SMTP_PASSWORD=

2
.gitignore vendored
View file

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

View file

@ -16,7 +16,6 @@ Metrics/ClassLength:
- 'app/models/message.rb' - 'app/models/message.rb'
- 'app/builders/messages/facebook/message_builder.rb' - 'app/builders/messages/facebook/message_builder.rb'
- 'app/controllers/api/v1/accounts/contacts_controller.rb' - 'app/controllers/api/v1/accounts/contacts_controller.rb'
- 'app/controllers/api/v1/accounts/conversations_controller.rb'
- 'app/listeners/action_cable_listener.rb' - 'app/listeners/action_cable_listener.rb'
- 'app/models/conversation.rb' - 'app/models/conversation.rb'
RSpec/ExampleLength: RSpec/ExampleLength:

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

View file

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

View file

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

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

@ -46,6 +46,7 @@ class Messages::Messenger::MessageBuilder
end end
def update_attachment_file_type(attachment) def update_attachment_file_type(attachment)
return if @message.reload.attachments.blank?
return unless attachment.file_type == 'share' || attachment.file_type == 'story_mention' return unless attachment.file_type == 'share' || attachment.file_type == 'story_mention'
attachment.file_type = file_type(attachment.file&.content_type) attachment.file_type = file_type(attachment.file&.content_type)
@ -62,6 +63,7 @@ class Messages::Messenger::MessageBuilder
story_sender = result['from']['username'] story_sender = result['from']['username']
message.content_attributes[:story_sender] = story_sender message.content_attributes[:story_sender] = story_sender
message.content_attributes[:story_id] = story_id 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.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender)
message.save! message.save!
end end
@ -74,6 +76,7 @@ class Messages::Messenger::MessageBuilder
raise raise
rescue Koala::Facebook::ClientError => e rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story. # 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')) @message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e Rails.logger.error e
{} {}

View file

@ -2,7 +2,7 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
def index def index
@conversations = Current.account.conversations.includes( @conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings :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 end
private private

View file

@ -24,7 +24,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def create def create
ActiveRecord::Base.transaction do 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? Messages::MessageBuilder.new(Current.user, @conversation, params[:message]).perform if params[:message].present?
end end
end end
@ -75,10 +75,13 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def update_last_seen def update_last_seen
# rubocop:disable Rails/SkipsModelValidations update_last_seen_on_conversation(DateTime.now.utc, assignee?)
@conversation.update_column(:agent_last_seen_at, DateTime.now.utc) end
@conversation.update_column(:assignee_last_seen_at, DateTime.now.utc) if assignee?
# rubocop:enable Rails/SkipsModelValidations 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 end
def custom_attributes def custom_attributes
@ -88,9 +91,18 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
private 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 def set_conversation_status
status = params[:status] == 'bot' ? 'pending' : params[:status] # TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
@conversation.status = status # 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] @conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
end end
@ -142,31 +154,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
).perform ).perform
end 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 def conversation_finder
@conversation_finder ||= ConversationFinder.new(current_user, params) @conversation_finder ||= ConversationFinder.new(Current.user, params)
end end
def assignee? def assignee?
@conversation.assignee_id? && current_user == @conversation.assignee @conversation.assignee_id? && Current.user == @conversation.assignee
end end
end end

View file

@ -113,7 +113,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def inbox_attributes def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, [: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 end
def permitted_params(channel_attributes = []) def permitted_params(channel_attributes = [])

View file

@ -1,6 +1,6 @@
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_macro, only: [:show, :update, :destroy, :execute] before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
def index def index
@macros = Macro.with_visibility(current_user, params) @macros = Macro.with_visibility(current_user, params)
@ -55,6 +55,8 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
head :ok head :ok
end end
private
def process_attachments def process_attachments
actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' } actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
return if actions.blank? return if actions.blank?
@ -80,4 +82,8 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
def fetch_macro def fetch_macro
@macro = Current.account.macros.find_by(id: params[:id]) @macro = Current.account.macros.find_by(id: params[:id])
end end
def check_authorization
authorize(@macro) if @macro.present?
end
end end

View file

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

View file

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

View file

@ -50,7 +50,9 @@ class Api::V1::Widget::BaseController < ApplicationController
end end
def contact_name 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 end
def contact_phone_number def contact_phone_number

View file

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

View file

@ -1,14 +1,16 @@
class Platform::Api::V1::AccountsController < PlatformController class Platform::Api::V1::AccountsController < PlatformController
def create def create
@resource = Account.new(account_params) @resource = Account.create!(account_params)
@resource.save! update_resource_features
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource) @platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end end
def show; end def show; end
def update def update
@resource.update!(account_params) @resource.assign_attributes(account_params)
update_resource_features
@resource.save!
end end
def destroy def destroy
@ -23,14 +25,18 @@ class Platform::Api::V1::AccountsController < PlatformController
end end
def account_params def account_params
if permitted_params[:enabled_features] permitted_params.except(:features)
return permitted_params.except(:enabled_features).merge(selected_feature_flags: permitted_params[:enabled_features].map(&:to_sym))
end end
permitted_params 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 end
def permitted_params def permitted_params
params.permit(:name, :locale, enabled_features: [], limits: {}) params.permit(:name, :locale, :domain, :support_email, :status, features: {}, limits: {}, custom_attributes: {})
end end
end end

View file

@ -7,7 +7,7 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
@contact_inbox = ::ContactInboxWithContactBuilder.new( @contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: source_id, source_id: source_id,
inbox: @inbox_channel.inbox, inbox: @inbox_channel.inbox,
contact_attributes: permitted_params.except(:identifier, :identifier_hash) contact_attributes: permitted_params.except(:identifier_hash)
).perform ).perform
end end

View file

@ -56,7 +56,6 @@ class ConversationFinder
filter_by_team if @team filter_by_team if @team
filter_by_labels if params[:labels] filter_by_labels if params[:labels]
filter_by_query if params[:q] filter_by_query if params[:q]
filter_by_reply_status
end end
def set_inboxes def set_inboxes
@ -76,12 +75,9 @@ class ConversationFinder
end end
def find_all_conversations 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) @conversations = current_account.conversations.where(inbox_id: @inbox_ids)
end filter_by_conversation_type if params[:conversation_type]
@conversations
end end
def filter_by_assignee_type def filter_by_assignee_type
@ -96,8 +92,15 @@ class ConversationFinder
@conversations @conversations
end end
def filter_by_reply_status def filter_by_conversation_type
@conversations = @conversations.where(first_reply_created_at: nil) if params[:reply_status] == 'unattended' 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 end
def filter_by_query def filter_by_query

View file

@ -21,7 +21,9 @@ class MessageFinder
end end
def current_messages 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 messages.reorder('created_at desc').where('id < ?', @params[:before].to_i).limit(20).reverse
else else
messages.reorder('created_at desc').limit(20).reverse messages.reorder('created_at desc').limit(20).reverse

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() { deleteAvatar() {
return axios.delete(endPoints('deleteAvatar').url); return axios.delete(endPoints('deleteAvatar').url);
}, },

View file

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

View file

@ -68,6 +68,10 @@ class ConversationApi extends ApiClient {
return axios.post(`${this.url}/${id}/update_last_seen`); return axios.post(`${this.url}/${id}/update_last_seen`);
} }
markMessagesUnread({ id }) {
return axios.post(`${this.url}/${id}/unread`);
}
toggleTyping({ conversationId, status, isPrivate }) { toggleTyping({ conversationId, status, isPrivate }) {
return axios.post(`${this.url}/${conversationId}/toggle_typing_status`, { return axios.post(`${this.url}/${conversationId}/toggle_typing_status`, {
typing_status: status, typing_status: status,
@ -105,6 +109,16 @@ class ConversationApi extends ApiClient {
custom_attributes: customAttributes, 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(); export default new ConversationApi();

View file

@ -13,6 +13,16 @@ class Inboxes extends ApiClient {
deleteInboxAvatar(inboxId) { deleteInboxAvatar(inboxId) {
return axios.delete(`${this.url}/${inboxId}/avatar`); 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(); export default new Inboxes();

View file

@ -11,6 +11,8 @@ describe('#InboxesAPI', () => {
expect(inboxesAPI).toHaveProperty('update'); expect(inboxesAPI).toHaveProperty('update');
expect(inboxesAPI).toHaveProperty('delete'); expect(inboxesAPI).toHaveProperty('delete');
expect(inboxesAPI).toHaveProperty('getCampaigns'); expect(inboxesAPI).toHaveProperty('getCampaigns');
expect(inboxesAPI).toHaveProperty('getAgentBot');
expect(inboxesAPI).toHaveProperty('setAgentBot');
}); });
describeWithAPIMock('API calls', context => { describeWithAPIMock('API calls', context => {
it('#getCampaigns', () => { 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, Arial,
sans-serif; sans-serif;
$body-antialiased: true; $body-antialiased: true;
$global-margin: $space-one; $global-margin: $space-small;
$global-padding: $space-one; $global-padding: $space-micro;
$global-weight-normal: normal; $global-weight-normal: normal;
$global-weight-bold: bold; $global-weight-bold: bold;
$global-radius: 0; $global-radius: 0;

View file

@ -20,6 +20,24 @@
@include foundation-everything($flex: true); @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 'typography';
@import 'layout'; @import 'layout';
@import 'animations'; @import 'animations';

View file

@ -155,12 +155,20 @@ $default-button-height: 4.0rem;
// Sizes // Sizes
&.tiny { &.tiny {
height: var(--space-medium); height: var(--space-medium);
.icon+.button__content {
padding-left: var(--space-micro);
}
} }
&.small { &.small {
height: var(--space-large); height: var(--space-large);
padding-bottom: var(--space-smaller); padding-bottom: var(--space-smaller);
padding-top: var(--space-smaller); padding-top: var(--space-smaller);
.icon+.button__content {
padding-left: var(--space-smaller);
}
} }
&.large { &.large {
@ -190,6 +198,10 @@ $default-button-height: 4.0rem;
height: auto; height: auto;
margin: 0; margin: 0;
padding: 0; padding: 0;
&:hover {
text-decoration: underline;
}
} }
} }

View file

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

View file

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

View file

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

View file

@ -18,12 +18,35 @@
</woot-button> </woot-button>
</woot-dropdown-item> </woot-dropdown-item>
<woot-dropdown-divider /> <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> </woot-dropdown-menu>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem'; import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader'; import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader';
@ -41,7 +64,7 @@ export default {
AvailabilityStatusBadge, AvailabilityStatusBadge,
}, },
mixins: [clickaway], mixins: [clickaway, alertMixin],
data() { data() {
return { return {
@ -54,6 +77,7 @@ export default {
...mapGetters({ ...mapGetters({
getCurrentUserAvailability: 'getCurrentUserAvailability', getCurrentUserAvailability: 'getCurrentUserAvailability',
currentAccountId: 'getCurrentAccountId', currentAccountId: 'getCurrentAccountId',
currentUserAutoOffline: 'getCurrentUserAutoOffline',
}), }),
availabilityDisplayLabel() { availabilityDisplayLabel() {
const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex( const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex(
@ -85,21 +109,30 @@ export default {
closeStatusMenu() { closeStatusMenu() {
this.isStatusMenuOpened = false; this.isStatusMenuOpened = false;
}, },
updateAutoOffline(autoOffline) {
this.$store.dispatch('updateAutoOffline', {
accountId: this.currentAccountId,
autoOffline,
});
},
changeAvailabilityStatus(availability) { changeAvailabilityStatus(availability) {
const accountId = this.currentAccountId;
if (this.isUpdating) { if (this.isUpdating) {
return; return;
} }
this.isUpdating = true; this.isUpdating = true;
this.$store try {
.dispatch('updateAvailability', { this.$store.dispatch('updateAvailability', {
availability: availability, availability,
account_id: accountId, account_id: this.currentAccountId,
})
.finally(() => {
this.isUpdating = false;
}); });
} 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; 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> </style>

View file

@ -16,6 +16,8 @@ const conversations = accountId => ({
'conversation_through_mentions', 'conversation_through_mentions',
'folder_conversations', 'folder_conversations',
'conversations_through_folders', 'conversations_through_folders',
'conversation_unattended',
'conversation_through_unattended',
], ],
menuItems: [ menuItems: [
{ {
@ -33,6 +35,13 @@ const conversations = accountId => ({
toState: frontendURL(`accounts/${accountId}/mentions/conversations`), toState: frontendURL(`accounts/${accountId}/mentions/conversations`),
toStateName: 'conversation_mentions', 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'; import { frontendURL } from '../../../../helper/URLHelper';
const primaryMenuItems = accountId => [ const primaryMenuItems = accountId => [
@ -13,6 +14,7 @@ const primaryMenuItems = accountId => [
icon: 'book-contacts', icon: 'book-contacts',
key: 'contacts', key: 'contacts',
label: 'CONTACTS', label: 'CONTACTS',
featureFlag: FEATURE_FLAGS.CRM,
toState: frontendURL(`accounts/${accountId}/contacts`), toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard', toStateName: 'contacts_dashboard',
roles: ['administrator', 'agent'], roles: ['administrator', 'agent'],
@ -21,6 +23,7 @@ const primaryMenuItems = accountId => [
icon: 'arrow-trending-lines', icon: 'arrow-trending-lines',
key: 'reports', key: 'reports',
label: 'REPORTS', label: 'REPORTS',
featureFlag: FEATURE_FLAGS.REPORTS,
toState: frontendURL(`accounts/${accountId}/reports`), toState: frontendURL(`accounts/${accountId}/reports`),
toStateName: 'settings_account_reports', toStateName: 'settings_account_reports',
roles: ['administrator'], roles: ['administrator'],
@ -29,6 +32,7 @@ const primaryMenuItems = accountId => [
icon: 'megaphone', icon: 'megaphone',
key: 'campaigns', key: 'campaigns',
label: 'CAMPAIGNS', label: 'CAMPAIGNS',
featureFlag: FEATURE_FLAGS.CAMPAIGNS,
toState: frontendURL(`accounts/${accountId}/campaigns`), toState: frontendURL(`accounts/${accountId}/campaigns`),
toStateName: 'settings_account_campaigns', toStateName: 'settings_account_campaigns',
roles: ['administrator'], roles: ['administrator'],
@ -37,7 +41,7 @@ const primaryMenuItems = accountId => [
icon: 'library', icon: 'library',
key: 'helpcenter', key: 'helpcenter',
label: 'HELP_CENTER.TITLE', label: 'HELP_CENTER.TITLE',
featureFlag: 'help_center', featureFlag: FEATURE_FLAGS.HELP_CENTER,
toState: frontendURL(`accounts/${accountId}/portals`), toState: frontendURL(`accounts/${accountId}/portals`),
toStateName: 'default_portal_articles', toStateName: 'default_portal_articles',
roles: ['administrator'], roles: ['administrator'],

View file

@ -102,6 +102,7 @@ const settings = accountId => ({
label: 'AGENT_BOTS', label: 'AGENT_BOTS',
beta: true, beta: true,
hasSubMenu: false, hasSubMenu: false,
globalConfigFlag: 'csmlEditorHost',
toState: frontendURL(`accounts/${accountId}/settings/agent-bots`), toState: frontendURL(`accounts/${accountId}/settings/agent-bots`),
toStateName: 'agent_bots', toStateName: 'agent_bots',
featureFlag: FEATURE_FLAGS.AGENT_BOTS, featureFlag: FEATURE_FLAGS.AGENT_BOTS,

View file

@ -61,6 +61,24 @@
</a> </a>
</router-link> </router-link>
</woot-dropdown-item> </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-dropdown-item>
<woot-button <woot-button
variant="clear" variant="clear"
@ -135,7 +153,7 @@ export default {
.dropdown-pane { .dropdown-pane {
left: var(--space-slab); left: var(--space-slab);
bottom: var(--space-larger); bottom: var(--space-larger);
min-width: 16.8rem; min-width: 22rem;
z-index: var(--z-index-much-higher); z-index: var(--z-index-low);
} }
</style> </style>

View file

@ -261,14 +261,7 @@ export default {
width: 20rem; width: 20rem;
flex-shrink: 0; flex-shrink: 0;
overflow-y: hidden; overflow-y: hidden;
@include breakpoint(xlarge down) {
position: absolute;
}
@include breakpoint(xlarge up) {
position: unset; position: unset;
}
&:hover { &:hover {
overflow-y: hidden; overflow-y: hidden;

View file

@ -112,6 +112,7 @@ $label-badge-size: var(--space-slab);
padding: var(--space-smaller) var(--space-smaller); padding: var(--space-smaller) var(--space-smaller);
margin: var(--space-smaller) 0; margin: var(--space-smaller) 0;
text-align: left; text-align: left;
line-height: 1.2;
&:hover { &:hover {
background: var(--s-25); background: var(--s-25);
@ -135,8 +136,6 @@ $label-badge-size: var(--space-slab);
.menu-label { .menu-label {
flex-grow: 1; flex-grow: 1;
display: inline-flex;
align-items: center;
} }
.inbox-icon { .inbox-icon {

View file

@ -87,6 +87,10 @@ import {
} from 'dashboard/helper/inbox'; } from 'dashboard/helper/inbox';
import SecondaryChildNavItem from './SecondaryChildNavItem'; import SecondaryChildNavItem from './SecondaryChildNavItem';
import {
isOnMentionsView,
isOnUnattendedView,
} from '../../../store/modules/conversations/helpers/actionHelpers';
export default { export default {
components: { SecondaryChildNavItem }, components: { SecondaryChildNavItem },
@ -102,32 +106,48 @@ export default {
activeInbox: 'getSelectedInbox', activeInbox: 'getSelectedInbox',
accountId: 'getCurrentAccountId', accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
globalConfig: 'globalConfig/get',
}), }),
hasSubMenu() { hasSubMenu() {
return !!this.menuItem.children; return !!this.menuItem.children;
}, },
isMenuItemVisible() { isMenuItemVisible() {
if (!this.menuItem.featureFlag) { if (this.menuItem.globalConfigFlag) {
return true; return !!this.globalConfig[this.menuItem.globalConfigFlag];
} }
if (this.menuItem.featureFlag) {
return this.isFeatureEnabledonAccount( return this.isFeatureEnabledonAccount(
this.accountId, this.accountId,
this.menuItem.featureFlag this.menuItem.featureFlag
); );
}
return true;
}, },
isInboxConversation() { isAllConversations() {
return ( return (
this.$store.state.route.name === 'inbox_conversation' && this.$store.state.route.name === 'inbox_conversation' &&
this.menuItem.toStateName === 'home' 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() { isTeamsSettings() {
return ( return (
this.$store.state.route.name === 'settings_teams_edit' && this.$store.state.route.name === 'settings_teams_edit' &&
this.menuItem.toStateName === 'settings_teams_list' this.menuItem.toStateName === 'settings_teams_list'
); );
}, },
isInboxsSettings() { isInboxSettings() {
return ( return (
this.$store.state.route.name === 'settings_inbox_show' && this.$store.state.route.name === 'settings_inbox_show' &&
this.menuItem.toStateName === 'settings_inbox_list' this.menuItem.toStateName === 'settings_inbox_list'
@ -150,14 +170,20 @@ export default {
}, },
computedClass() { computedClass() {
// If active Inbox is present // If active inbox is present, do not highlight conversations
// donot highlight conversations
if (this.activeInbox) return ' '; if (this.activeInbox) return ' ';
if (
this.isAllConversations ||
this.isMentions ||
this.isUnattended ||
this.isCurrentRoute
) {
return 'is-active';
}
if (this.hasSubMenu) { if (this.hasSubMenu) {
if ( if (
this.isInboxConversation ||
this.isTeamsSettings || this.isTeamsSettings ||
this.isInboxsSettings || this.isInboxSettings ||
this.isIntegrationsSettings || this.isIntegrationsSettings ||
this.isApplicationsSettings this.isApplicationsSettings
) { ) {
@ -166,10 +192,6 @@ export default {
return ' '; return ' ';
} }
if (this.isCurrentRoute) {
return 'is-active';
}
return ''; return '';
}, },
}, },

View file

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

View file

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

View file

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

View file

@ -18,14 +18,32 @@
<div v-if="showActionInput" class="filter__answer--wrap"> <div v-if="showActionInput" class="filter__answer--wrap">
<div v-if="inputType"> <div v-if="inputType">
<div <div
v-if="inputType === 'multi_select'" v-if="inputType === 'search_select'"
class="multiselect-wrap--small" class="multiselect-wrap--small"
> >
<multiselect <multiselect
v-model="action_params" v-model="action_params"
track-by="id" track-by="id"
label="name" 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" :multiple="true"
selected-label selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')" :select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
@ -33,6 +51,7 @@
:max-height="160" :max-height="160"
:options="dropdownValues" :options="dropdownValues"
:allow-empty="false" :allow-empty="false"
:option-height="104"
/> />
</div> </div>
<input <input
@ -260,6 +279,6 @@ export default {
margin-bottom: var(--space-zero); margin-bottom: var(--space-zero);
} }
.action-message { .action-message {
margin: var(--space-small) 0 0; margin: var(--space-small) var(--space-zero) var(--space-zero);
} }
</style> </style>

View file

@ -48,5 +48,6 @@ export default {
text-align: center; text-align: center;
background-image: linear-gradient(to top, var(--w-100) 0%, var(--w-75) 100%); background-image: linear-gradient(to top, var(--w-100) 0%, var(--w-75) 100%);
color: var(--w-600); color: var(--w-600);
cursor: default;
} }
</style> </style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,13 +10,14 @@ describe('Thumbnail.vue', () => {
}, },
data() { data() {
return { return {
hasImageLoaded: true,
imgError: false, imgError: false,
}; };
}, },
}); });
expect(wrapper.find('.user-thumbnail').exists()).toBe(true); expect(wrapper.find('.user-thumbnail').exists()).toBe(true);
const avatarComponent = wrapper.findComponent(Avatar); const avatarComponent = wrapper.findComponent(Avatar);
expect(avatarComponent.exists()).toBe(false); expect(avatarComponent.isVisible()).toBe(false);
}); });
it('should render the avatar component if invalid image is passed', () => { it('should render the avatar component if invalid image is passed', () => {
@ -26,13 +27,14 @@ describe('Thumbnail.vue', () => {
}, },
data() { data() {
return { return {
hasImageLoaded: true,
imgError: true, imgError: true,
}; };
}, },
}); });
expect(wrapper.find('.avatar-container').exists()).toBe(true); expect(wrapper.find('#image').exists()).toBe(false);
const avatarComponent = wrapper.findComponent(Avatar); const avatarComponent = wrapper.findComponent(Avatar);
expect(avatarComponent.exists()).toBe(true); expect(avatarComponent.isVisible()).toBe(true);
}); });
it('should the initial of the name if no image is passed', () => { it('should the initial of the name if no image is passed', () => {

View file

@ -1,13 +1,19 @@
<template> <template>
<div :class="thumbnailBoxClass" :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 <img
v-if="!imgError && src" v-show="shouldShowImage"
:src="src" :src="src"
:class="thumbnailClass" :class="thumbnailClass"
@load="onImgLoad"
@error="onImgError" @error="onImgError"
/> />
<Avatar <Avatar
v-else v-show="!shouldShowImage"
:username="userNameWithoutEmoji" :username="userNameWithoutEmoji"
:class="thumbnailClass" :class="thumbnailClass"
:size="avatarSize" :size="avatarSize"
@ -70,6 +76,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
title: {
type: String,
default: '',
},
variant: { variant: {
type: String, type: String,
default: 'circle', default: 'circle',
@ -77,6 +87,7 @@ export default {
}, },
data() { data() {
return { return {
hasImageLoaded: false,
imgError: false, imgError: false,
}; };
}, },
@ -124,6 +135,15 @@ export default {
const boxClass = this.variant === 'circle' ? 'is-rounded' : ''; const boxClass = this.variant === 'circle' ? 'is-rounded' : '';
return `user-thumbnail-box ${boxClass}`; return `user-thumbnail-box ${boxClass}`;
}, },
shouldShowImage() {
if (!this.src) {
return false;
}
if (this.hasImageLoaded) {
return !this.imgError;
}
return false;
},
}, },
watch: { watch: {
src(value, oldValue) { src(value, oldValue) {
@ -136,6 +156,9 @@ export default {
onImgError() { onImgError() {
this.imgError = true; this.imgError = true;
}, },
onImgLoad() {
this.hasImageLoaded = true;
},
}, },
}; };
</script> </script>
@ -159,6 +182,7 @@ export default {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
object-fit: cover; object-fit: cover;
vertical-align: initial;
&.border { &.border {
border: 1px solid white; border: 1px solid white;

View file

@ -3,11 +3,13 @@
<thumbnail <thumbnail
v-for="user in usersList" v-for="user in usersList"
:key="user.id" :key="user.id"
v-tooltip="user.name"
:title="user.name"
:src="user.thumbnail" :src="user.thumbnail"
:username="user.name" :username="user.name"
:has-border="true" :has-border="true"
:size="size" :size="size"
class="overlapping-thumbnail" :class="`overlapping-thumbnail gap-${gap}`"
/> />
<span v-if="showMoreThumbnailsCount" class="thumbnail-more-text"> <span v-if="showMoreThumbnailsCount" class="thumbnail-more-text">
{{ moreThumbnailsText }} {{ moreThumbnailsText }}
@ -38,6 +40,14 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
gap: {
type: String,
default: 'normal',
validator(value) {
// The value must match one of these strings
return ['normal', '', 'tight'].includes(value);
},
},
}, },
}; };
</script> </script>
@ -52,6 +62,10 @@ export default {
box-shadow: var(--shadow-small); box-shadow: var(--shadow-small);
&:not(:first-child) { &:not(:first-child) {
margin-left: var(--space-minus-smaller);
}
.gap-tight {
margin-left: var(--space-minus-small); margin-left: var(--space-minus-small);
} }
} }

View file

@ -10,11 +10,11 @@ import 'videojs-record/dist/css/videojs.record.css';
import videojs from 'video.js'; import videojs from 'video.js';
import inboxMixin from '../../../../shared/mixins/inboxMixin';
import alertMixin from '../../../../shared/mixins/alertMixin'; import alertMixin from '../../../../shared/mixins/alertMixin';
import Recorder from 'opus-recorder'; import Recorder from 'opus-recorder';
import encoderWorker from 'opus-recorder/dist/encoderWorker.min'; import encoderWorker from 'opus-recorder/dist/encoderWorker.min';
import waveWorker from 'opus-recorder/dist/waveWorker.min';
import WaveSurfer from 'wavesurfer.js'; import WaveSurfer from 'wavesurfer.js';
import MicrophonePlugin from 'wavesurfer.js/dist/plugin/wavesurfer.microphone.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/videojs.record.js';
import 'videojs-record/dist/plugins/videojs.record.opus-recorder.js'; import 'videojs-record/dist/plugins/videojs.record.opus-recorder.js';
import { format, addSeconds } from 'date-fns'; import { format, addSeconds } from 'date-fns';
import { AUDIO_FORMATS } from 'shared/constants/messages';
WaveSurfer.microphone = MicrophonePlugin; WaveSurfer.microphone = MicrophonePlugin;
export default { export default {
name: 'WootAudioRecorder', name: 'WootAudioRecorder',
mixins: [inboxMixin, alertMixin], mixins: [alertMixin],
props: {
audioRecordFormat: {
type: String,
default: AUDIO_FORMATS.WEBM,
},
},
data() { data() {
return { return {
player: false, player: false,
recordingDateStarted: new Date(0), recordingDateStarted: new Date(0),
initialTimeDuration: '00:00', initialTimeDuration: '00:00',
recorderOptions: { recorderOptions: {
debug: true,
controls: true, controls: true,
bigPlayButton: false, bigPlayButton: false,
fluid: false, fluid: false,
@ -70,13 +76,28 @@ export default {
record: { record: {
audio: true, audio: true,
video: false, video: false,
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, displayMilliseconds: false,
maxLength: 300,
audioEngine: 'opus-recorder', audioEngine: 'opus-recorder',
audioWorkerURL: encoderWorker, audioWorkerURL: encoderWorker,
audioChannels: 1, audioChannels: 1,
audioSampleRate: 48000, audioSampleRate: 48000,
audioBitRate: 128, audioBitRate: 128,
}),
}, },
}, },
}, },

View file

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

View file

@ -232,11 +232,18 @@ export default {
return this.showFileUpload || this.isNote; return this.showFileUpload || this.isNote;
}, },
showAudioRecorderButton() { showAudioRecorderButton() {
// Disable audio recorder for safari browser as recording is not supported
const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(
navigator.userAgent
);
return ( return (
this.isFeatureEnabledonAccount( this.isFeatureEnabledonAccount(
this.accountId, this.accountId,
FEATURE_FLAGS.VOICE_RECORDER FEATURE_FLAGS.VOICE_RECORDER
) && this.showAudioRecorder ) &&
this.showAudioRecorder &&
!isSafari
); );
}, },
showAudioPlayStopButton() { showAudioPlayStopButton() {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -37,6 +37,7 @@
<woot-audio-recorder <woot-audio-recorder
v-if="showAudioRecorderEditor" v-if="showAudioRecorderEditor"
ref="audioRecorderInput" ref="audioRecorderInput"
:audio-record-format="audioRecordFormat"
@state-recorder-progress-changed="onStateProgressRecorderChanged" @state-recorder-progress-changed="onStateProgressRecorderChanged"
@state-recorder-changed="onStateRecorderChanged" @state-recorder-changed="onStateRecorderChanged"
@finish-record="onFinishRecorder" @finish-record="onFinishRecorder"
@ -60,6 +61,7 @@
class="input" class="input"
:is-private="isOnPrivateNote" :is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder" :placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4" :min-height="4"
@typing-off="onTypingOff" @typing-off="onTypingOff"
@typing-on="onTypingOn" @typing-on="onTypingOn"
@ -67,6 +69,7 @@
@blur="onBlur" @blur="onBlur"
@toggle-user-mention="toggleUserMention" @toggle-user-mention="toggleUserMention"
@toggle-canned-menu="toggleCannedMenu" @toggle-canned-menu="toggleCannedMenu"
@clear-selection="clearEditorSelection"
/> />
</div> </div>
<div v-if="hasAttachments" class="attachment-preview-box" @paste="onPaste"> <div v-if="hasAttachments" class="attachment-preview-box" @paste="onPaste">
@ -130,7 +133,6 @@ import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import EmojiInput from 'shared/components/emoji/EmojiInput';
import CannedResponse from './CannedResponse'; import CannedResponse from './CannedResponse';
import ResizableTextArea from 'shared/components/ResizableTextArea'; import ResizableTextArea from 'shared/components/ResizableTextArea';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview'; import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview';
@ -146,6 +148,7 @@ import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { import {
MAXIMUM_FILE_UPLOAD_SIZE, MAXIMUM_FILE_UPLOAD_SIZE,
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL, MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL,
AUDIO_FORMATS,
} from 'shared/constants/messages'; } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
@ -160,6 +163,11 @@ import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../../helper/localStorage';
import { trimContent, debounce } from '@chatwoot/utils'; import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants'; import wootConstants from 'dashboard/constants';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings'; import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../helper/AnalyticsHelper';
const EmojiInput = () => import('shared/components/emoji/EmojiInput');
export default { export default {
components: { components: {
@ -215,6 +223,7 @@ export default {
ccEmails: '', ccEmails: '',
doAutoSaveDraft: () => {}, doAutoSaveDraft: () => {},
showWhatsAppTemplatesModal: false, showWhatsAppTemplatesModal: false,
updateEditorSelectionWith: '',
}; };
}, },
computed: { computed: {
@ -398,7 +407,7 @@ export default {
return conversationDisplayType !== CONDENSED; return conversationDisplayType !== CONDENSED;
}, },
emojiDialogClassOnExpanedLayout() { emojiDialogClassOnExpanedLayout() {
return this.isOnExpandedLayout && !this.popoutReplyBox return this.isOnExpandedLayout || this.popoutReplyBox
? 'emoji-dialog--expanded' ? 'emoji-dialog--expanded'
: ''; : '';
}, },
@ -450,12 +459,17 @@ export default {
return this.currentChat.id; return this.currentChat.id;
}, },
conversationIdByRoute() { conversationIdByRoute() {
const { conversation_id: conversationId } = this.$route.params; return this.conversationId;
return conversationId;
}, },
editorStateId() { editorStateId() {
return `draft-${this.conversationIdByRoute}-${this.replyType}`; return `draft-${this.conversationIdByRoute}-${this.replyType}`;
}, },
audioRecordFormat() {
if (this.isAWebWidgetInbox) {
return AUDIO_FORMATS.WEBM;
}
return AUDIO_FORMATS.OGG;
},
}, },
watch: { watch: {
currentChat(conversation) { currentChat(conversation) {
@ -587,6 +601,7 @@ export default {
e.preventDefault(); e.preventDefault();
} else if (keyCode === 'enter' && this.isAValidEvent('enter')) { } else if (keyCode === 'enter' && this.isAValidEvent('enter')) {
this.onSendReply(); this.onSendReply();
e.preventDefault();
} else if ( } else if (
['meta+enter', 'ctrl+enter'].includes(keyCode) && ['meta+enter', 'ctrl+enter'].includes(keyCode) &&
this.isAValidEvent('cmd_enter') this.isAValidEvent('cmd_enter')
@ -694,6 +709,7 @@ export default {
}, },
replaceText(message) { replaceText(message) {
setTimeout(() => { setTimeout(() => {
AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE);
this.message = message; this.message = message;
}, 100); }, 100);
}, },
@ -708,8 +724,26 @@ export default {
} }
this.$nextTick(() => this.$refs.messageInput.focus()); 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) { 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() { clearMessage() {
this.message = ''; this.message = '';
@ -964,13 +998,13 @@ export default {
.emoji-dialog { .emoji-dialog {
top: unset; top: unset;
bottom: 12px; bottom: var(--space-normal);
left: -320px; left: -320px;
right: unset; right: unset;
&::before { &::before {
right: -16px; right: var(--space-minus-normal);
bottom: 10px; bottom: var(--space-small);
transform: rotate(270deg); transform: rotate(270deg);
filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.08)); filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.08));
} }
@ -984,7 +1018,7 @@ export default {
&::before { &::before {
transform: rotate(0deg); transform: rotate(0deg);
left: var(--space-smaller); left: var(--space-smaller);
bottom: var(--space-minus-slab); bottom: var(--space-minus-small);
} }
} }
.message-signature { .message-signature {

View file

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

View file

@ -1,5 +1,11 @@
<template> <template>
<div class="menu-container"> <div class="menu-container">
<menu-item
v-if="!hasUnreadMessages"
:option="unreadOption"
variant="icon"
@click="$emit('mark-as-unread')"
/>
<template v-for="option in statusMenuConfig"> <template v-for="option in statusMenuConfig">
<menu-item <menu-item
v-if="show(option.key)" v-if="show(option.key)"
@ -79,6 +85,10 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
hasUnreadMessages: {
type: Boolean,
default: false,
},
inboxId: { inboxId: {
type: Number, type: Number,
default: null, default: null,
@ -87,6 +97,10 @@ export default {
data() { data() {
return { return {
STATUS_TYPE: wootConstants.STATUS_TYPE, STATUS_TYPE: wootConstants.STATUS_TYPE,
unreadOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'),
icon: 'mail',
},
statusMenuConfig: [ statusMenuConfig: [
{ {
key: wootConstants.STATUS_TYPE.RESOLVED, key: wootConstants.STATUS_TYPE.RESOLVED,

View file

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

View file

@ -43,25 +43,26 @@
variant="smooth" variant="smooth"
color-scheme="secondary" color-scheme="secondary"
icon="person-assign" icon="person-assign"
class="margin-right-smaller"
@click="toggleAgentList" @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> </div>
<transition name="popover-animation"> <transition name="popover-animation">
<label-actions <label-actions
v-if="showLabelActions" v-if="showLabelActions"
triangle-position="8.5"
@assign="assignLabels" @assign="assignLabels"
@close="showLabelActions = false" @close="showLabelActions = false"
/> />
</transition> </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"> <transition name="popover-animation">
<update-actions <update-actions
v-if="showUpdateActions" v-if="showUpdateActions"
@ -70,10 +71,29 @@
:show-resolve="!showResolvedAction" :show-resolve="!showResolvedAction"
:show-reopen="!showOpenAction" :show-reopen="!showOpenAction"
:show-snooze="!showSnoozedAction" :show-snooze="!showSnoozedAction"
triangle-position="5.6"
@update="updateConversations" @update="updateConversations"
@close="showUpdateActions = false" @close="showUpdateActions = false"
/> />
</transition> </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>
<div v-if="allConversationsSelected" class="bulk-action__alert"> <div v-if="allConversationsSelected" class="bulk-action__alert">
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }} {{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
@ -85,11 +105,13 @@
import AgentSelector from './AgentSelector.vue'; import AgentSelector from './AgentSelector.vue';
import UpdateActions from './UpdateActions.vue'; import UpdateActions from './UpdateActions.vue';
import LabelActions from './LabelActions.vue'; import LabelActions from './LabelActions.vue';
import TeamActions from './TeamActions.vue';
export default { export default {
components: { components: {
AgentSelector, AgentSelector,
UpdateActions, UpdateActions,
LabelActions, LabelActions,
TeamActions,
}, },
props: { props: {
conversations: { conversations: {
@ -122,6 +144,8 @@ export default {
showAgentsList: false, showAgentsList: false,
showUpdateActions: false, showUpdateActions: false,
showLabelActions: false, showLabelActions: false,
showTeamsList: false,
popoverPositions: {},
}; };
}, },
methods: { methods: {
@ -137,6 +161,9 @@ export default {
assignLabels(labels) { assignLabels(labels) {
this.$emit('assign-labels', labels); this.$emit('assign-labels', labels);
}, },
assignTeam(team) {
this.$emit('assign-team', team);
},
resolveConversations() { resolveConversations() {
this.$emit('resolve-conversations'); this.$emit('resolve-conversations');
}, },
@ -149,6 +176,9 @@ export default {
toggleAgentList() { toggleAgentList() {
this.showAgentsList = !this.showAgentsList; this.showAgentsList = !this.showAgentsList;
}, },
toggleTeamsList() {
this.showTeamsList = !this.showTeamsList;
},
}, },
}; };
</script> </script>

View file

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

View file

@ -1,6 +1,6 @@
<template> <template>
<div v-on-clickaway="onClose" class="actions-container"> <div v-on-clickaway="onClose" class="actions-container">
<div class="triangle"> <div class="triangle" :style="cssVars">
<svg height="12" viewBox="0 0 24 12" width="24"> <svg height="12" viewBox="0 0 24 12" width="24">
<path <path
d="M20 12l-8-8-12 12" d="M20 12l-8-8-12 12"
@ -45,12 +45,14 @@
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue'; import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import bulkActionsMixin from 'dashboard/mixins/bulkActionsMixin.js';
export default { export default {
components: { components: {
WootDropdownItem, WootDropdownItem,
WootDropdownMenu, WootDropdownMenu,
}, },
mixins: [clickaway], mixins: [clickaway, bulkActionsMixin],
props: { props: {
selectedInboxes: { selectedInboxes: {
type: Array, type: Array,
@ -131,7 +133,7 @@ export default {
box-shadow: var(--shadow-dropdown-pane); box-shadow: var(--shadow-dropdown-pane);
position: absolute; position: absolute;
right: var(--space-small); right: var(--space-small);
top: 48px; top: var(--space-larger);
transform-origin: top right; transform-origin: top right;
width: auto; width: auto;
z-index: var(--z-index-twenty); z-index: var(--z-index-twenty);
@ -152,7 +154,7 @@ export default {
.triangle { .triangle {
display: block; display: block;
position: absolute; position: absolute;
right: 2.8rem; right: var(--triangle-position);
text-align: left; text-align: left;
top: calc(var(--space-slab) * -1); top: calc(var(--space-slab) * -1);
z-index: var(--z-index-one); z-index: var(--z-index-one);

View file

@ -0,0 +1,136 @@
<template>
<div
v-show="activeLabels.length"
ref="labelContainer"
class="label-container"
>
<div class="labels-wrap" :class="{ expand: showAllLabels }">
<woot-label
v-for="(label, index) in activeLabels"
:key="label.id"
:title="label.title"
:description="label.description"
:color="label.color"
variant="smooth"
small
:class="{ hidden: !showAllLabels && index > labelPosition }"
/>
<woot-button
v-if="showExpandLabelButton"
:title="
showAllLabels
? $t('CONVERSATION.CARD.HIDE_LABELS')
: $t('CONVERSATION.CARD.SHOW_LABELS')
"
class="show-more--button"
color-scheme="secondary"
variant="hollow"
:icon="showAllLabels ? 'chevron-left' : 'chevron-right'"
size="tiny"
@click="onShowLabels"
/>
</div>
</div>
</template>
<script>
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
export default {
mixins: [conversationLabelMixin],
props: {
conversationId: {
type: Number,
required: true,
},
},
data() {
return {
showAllLabels: false,
showExpandLabelButton: false,
labelPosition: -1,
};
},
watch: {
activeLabels() {
this.$nextTick(() => this.computeVisibleLabelPosition());
},
},
mounted() {
this.computeVisibleLabelPosition();
},
methods: {
onShowLabels(e) {
e.stopPropagation();
this.showAllLabels = !this.showAllLabels;
},
computeVisibleLabelPosition() {
const labelContainer = this.$refs.labelContainer;
const labels = this.$refs.labelContainer.querySelectorAll('.label');
let labelOffset = 0;
this.showExpandLabelButton = false;
Array.from(labels).forEach((label, index) => {
labelOffset += label.offsetWidth + 8;
if (labelOffset < labelContainer.clientWidth - 16) {
this.labelPosition = index;
} else {
this.showExpandLabelButton = true;
}
});
},
},
};
</script>
<style lang="scss" scoped>
.show-more--button {
height: var(--space-two);
position: sticky;
flex-shrink: 0;
right: 0;
margin-right: var(--space-medium);
&.secondary:focus {
color: var(--s-700);
border-color: var(--s-300);
}
}
.label-container {
margin-top: var(--space-micro);
}
.labels-wrap {
display: flex;
align-items: center;
min-width: 0;
flex-shrink: 1;
&.expand {
height: auto;
overflow: visible;
flex-flow: row wrap;
.label {
margin-bottom: var(--space-smaller);
}
.show-more--button {
margin-bottom: var(--space-smaller);
}
}
.secondary {
border: 1px solid var(--s-100);
}
.label {
margin-bottom: 0;
}
}
.hidden {
visibility: hidden;
position: absolute;
}
</style>

View file

@ -0,0 +1,34 @@
import LocationBubble from '../bubble/Location.vue';
export default {
title: 'Components/Help Center',
component: LocationBubble,
argTypes: {
latitude: {
defaultValue: 1,
control: {
type: 'number',
},
},
longitude: {
defaultValue: 1,
control: {
type: 'number',
},
},
name: {
defaultValue: '420, Dope street',
control: {
type: 'string',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { LocationBubble },
template: '<location-bubble v-bind="$props" />',
});
export const LocationBubbleView = Template.bind({});

View file

@ -22,5 +22,6 @@ export default {
EXPANDED: 'expanded', EXPANDED: 'expanded',
}, },
DOCS_URL: '//www.chatwoot.com/docs/product/', DOCS_URL: '//www.chatwoot.com/docs/product/',
TESTIMONIAL_URL: 'https://testimonials.cdn.chatwoot.com/content.json',
}; };
export const DEFAULT_REDIRECT_URL = '/app/'; export const DEFAULT_REDIRECT_URL = '/app/';

View file

@ -1,13 +1,18 @@
export const FEATURE_FLAGS = { export const FEATURE_FLAGS = {
AGENT_BOTS: 'agent_bots', AGENT_BOTS: 'agent_bots',
AGENT_MANAGEMENT: 'agent_management', AGENT_MANAGEMENT: 'agent_management',
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
AUTOMATIONS: 'automations', AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns',
CANNED_RESPONSES: 'canned_responses', CANNED_RESPONSES: 'canned_responses',
CRM: 'crm',
CUSTOM_ATTRIBUTES: 'custom_attributes', CUSTOM_ATTRIBUTES: 'custom_attributes',
INBOX_MANAGEMENT: 'inbox_management', INBOX_MANAGEMENT: 'inbox_management',
INTEGRATIONS: 'integrations', INTEGRATIONS: 'integrations',
LABELS: 'labels', LABELS: 'labels',
MACROS: 'macros', MACROS: 'macros',
HELP_CENTER: 'help_center',
REPORTS: 'reports',
TEAM_MANAGEMENT: 'team_management', TEAM_MANAGEMENT: 'team_management',
VOICE_RECORDER: 'voice_recorder', VOICE_RECORDER: 'voice_recorder',
}; };

View file

@ -0,0 +1,9 @@
export const EXECUTED_A_MACRO = 'Executed a macro';
export const SENT_MESSAGE = 'Sent a message';
export const SENT_PRIVATE_NOTE = 'Sent a private note';
export const INSERTED_A_CANNED_RESPONSE = 'Inserted a canned response';
export const USED_MENTIONS = 'Used mentions';
export const MERGED_CONTACTS = 'Used merge contact option';
export const ADDED_TO_CANNED_RESPONSE = 'Used added to canned response option';
export const ADDED_A_CUSTOM_ATTRIBUTE = 'Added a custom attribute';
export const ADDED_AN_INBOX = 'Added an inbox';

View file

@ -0,0 +1,67 @@
import { AnalyticsBrowser } from '@june-so/analytics-next';
class AnalyticsHelper {
constructor({ token: analyticsToken } = {}) {
this.analyticsToken = analyticsToken;
this.analytics = null;
this.user = {};
}
async init() {
if (!this.analyticsToken) {
return;
}
let [analytics] = await AnalyticsBrowser.load({
writeKey: this.analyticsToken,
});
this.analytics = analytics;
}
identify(user) {
if (!this.analytics) {
return;
}
this.user = user;
this.analytics.identify(this.user.email, {
userId: this.user.id,
email: this.user.email,
name: this.user.name,
avatar: this.user.avatar_url,
});
const { accounts, account_id: accountId } = this.user;
const [currentAccount] = accounts.filter(
account => account.id === accountId
);
if (currentAccount) {
this.analytics.group(currentAccount.id, this.user.id, {
name: currentAccount.name,
});
}
}
track(eventName, properties = {}) {
if (!this.analytics) {
return;
}
this.analytics.track({
userId: this.user.id,
event: eventName,
properties,
});
}
page(params) {
if (!this.analytics) {
return;
}
this.analytics.page(params);
}
}
export * as ANALYTICS_EVENTS from './events';
export default new AnalyticsHelper(window.analyticsConfig);

View file

@ -56,6 +56,8 @@ export const conversationUrl = ({
url = `accounts/${accountId}/custom_view/${foldersId}/conversations/${id}`; url = `accounts/${accountId}/custom_view/${foldersId}/conversations/${id}`;
} else if (conversationType === 'mention') { } else if (conversationType === 'mention') {
url = `accounts/${accountId}/mentions/conversations/${id}`; url = `accounts/${accountId}/mentions/conversations/${id}`;
} else if (conversationType === 'unattended') {
url = `accounts/${accountId}/unattended/conversations/${id}`;
} }
return url; return url;
}; };
@ -66,16 +68,23 @@ export const conversationListPageURL = ({
inboxId, inboxId,
label, label,
teamId, teamId,
customViewId,
}) => { }) => {
let url = `accounts/${accountId}/dashboard`; let url = `accounts/${accountId}/dashboard`;
if (label) { if (label) {
url = `accounts/${accountId}/label/${label}`; url = `accounts/${accountId}/label/${label}`;
} else if (teamId) { } else if (teamId) {
url = `accounts/${accountId}/team/${teamId}`; url = `accounts/${accountId}/team/${teamId}`;
} else if (conversationType === 'mention') {
url = `accounts/${accountId}/mentions/conversations`;
} else if (inboxId) { } else if (inboxId) {
url = `accounts/${accountId}/inbox/${inboxId}`; url = `accounts/${accountId}/inbox/${inboxId}`;
} else if (customViewId) {
url = `accounts/${accountId}/custom_view/${customViewId}`;
} else if (conversationType) {
const urlMap = {
mention: 'mentions/conversations',
unattended: 'unattended/conversations',
};
url = `accounts/${accountId}/${urlMap[conversationType]}`;
} }
return frontendURL(url); return frontendURL(url);
}; };

View file

@ -17,13 +17,22 @@ const formatArray = params => {
return params; return params;
}; };
const generatePayloadForObject = item => {
if (item.action_params.id) {
item.action_params = [item.action_params.id];
} else {
item.action_params = [item.action_params];
}
return item.action_params;
};
const generatePayload = data => { const generatePayload = data => {
const actions = JSON.parse(JSON.stringify(data)); const actions = JSON.parse(JSON.stringify(data));
let payload = actions.map(item => { let payload = actions.map(item => {
if (Array.isArray(item.action_params)) { if (Array.isArray(item.action_params)) {
item.action_params = formatArray(item.action_params); item.action_params = formatArray(item.action_params);
} else if (typeof item.values === 'object') { } else if (typeof item.action_params === 'object') {
item.action_params = [item.action_params.id]; item.action_params = generatePayloadForObject(item);
} else if (!item.action_params) { } else if (!item.action_params) {
item.action_params = []; item.action_params = [];
} else { } else {

View file

@ -0,0 +1,242 @@
import {
OPERATOR_TYPES_1,
OPERATOR_TYPES_3,
OPERATOR_TYPES_4,
} from 'dashboard/routes/dashboard/settings/automation/operators';
import filterQueryGenerator from './filterQueryGenerator';
import actionQueryGenerator from './actionQueryGenerator';
const MESSAGE_CONDITION_VALUES = [
{
id: 'incoming',
name: 'Incoming Message',
},
{
id: 'outgoing',
name: 'Outgoing Message',
},
];
export const getCustomAttributeInputType = key => {
const customAttributeMap = {
date: 'date',
text: 'plain_text',
list: 'search_select',
checkbox: 'search_select',
};
return customAttributeMap[key] || 'plain_text';
};
export const isACustomAttribute = (customAttributes, key) => {
return customAttributes.find(attr => {
return attr.attribute_key === key;
});
};
export const getCustomAttributeListDropdownValues = (
customAttributes,
type
) => {
return customAttributes
.find(attr => attr.attribute_key === type)
.attribute_values.map(item => {
return {
id: item,
name: item,
};
});
};
export const isCustomAttributeCheckbox = (customAttributes, key) => {
return customAttributes.find(attr => {
return (
attr.attribute_key === key && attr.attribute_display_type === 'checkbox'
);
});
};
export const isCustomAttributeList = (customAttributes, type) => {
return customAttributes.find(attr => {
return (
attr.attribute_key === type && attr.attribute_display_type === 'list'
);
});
};
export const getOperatorTypes = key => {
const operatorMap = {
list: OPERATOR_TYPES_1,
text: OPERATOR_TYPES_3,
number: OPERATOR_TYPES_1,
link: OPERATOR_TYPES_1,
date: OPERATOR_TYPES_4,
checkbox: OPERATOR_TYPES_1,
};
return operatorMap[key] || OPERATOR_TYPES_1;
};
export const generateCustomAttributeTypes = (customAttributes, type) => {
return customAttributes.map(attr => {
return {
key: attr.attribute_key,
name: attr.attribute_display_name,
inputType: getCustomAttributeInputType(attr.attribute_display_type),
filterOperators: getOperatorTypes(attr.attribute_display_type),
customAttributeType: type,
};
});
};
export const generateConditionOptions = (options, key = 'id') => {
return options.map(i => {
return {
id: i[key],
name: i.title,
};
});
};
export const getActionOptions = ({ teams, labels, type }) => {
const actionsMap = {
assign_team: teams,
send_email_to_team: teams,
add_label: generateConditionOptions(labels, 'title'),
};
return actionsMap[type];
};
export const getConditionOptions = ({
agents,
booleanFilterOptions,
campaigns,
contacts,
countries,
customAttributes,
inboxes,
languages,
statusFilterOptions,
teams,
type,
}) => {
if (isCustomAttributeCheckbox(customAttributes, type)) {
return booleanFilterOptions;
}
if (isCustomAttributeList(customAttributes, type)) {
return getCustomAttributeListDropdownValues(customAttributes, type);
}
const conditionFilterMaps = {
status: statusFilterOptions,
assignee_id: agents,
contact: contacts,
inbox_id: inboxes,
team_id: teams,
campaigns: generateConditionOptions(campaigns),
browser_language: languages,
country_code: countries,
message_type: MESSAGE_CONDITION_VALUES,
};
return conditionFilterMaps[type];
};
export const getFileName = (action, files = []) => {
const blobId = action.action_params[0];
if (!blobId) return '';
if (action.action_name === 'send_attachment') {
const file = files.find(item => item.blob_id === blobId);
if (file) return file.filename.toString();
}
return '';
};
export const getDefaultConditions = eventName => {
if (eventName === 'message_created') {
return [
{
attribute_key: 'message_type',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
},
];
}
return [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
},
];
};
export const getDefaultActions = () => {
return [
{
action_name: 'assign_team',
action_params: [],
},
];
};
export const filterCustomAttributes = customAttributes => {
return customAttributes.map(attr => {
return {
key: attr.attribute_key,
name: attr.attribute_display_name,
type: attr.attribute_display_type,
};
});
};
export const getStandardAttributeInputType = (automationTypes, event, key) => {
return automationTypes[event].conditions.find(item => item.key === key)
.inputType;
};
export const generateAutomationPayload = payload => {
const automation = JSON.parse(JSON.stringify(payload));
automation.conditions[automation.conditions.length - 1].query_operator = null;
automation.conditions = filterQueryGenerator(automation.conditions).payload;
automation.actions = actionQueryGenerator(automation.actions);
return automation;
};
export const isCustomAttribute = (attrs, key) => {
return attrs.find(attr => attr.key === key);
};
export const generateCustomAttributes = (
conversationAttributes = [],
contactAttribtues = [],
conversationlabel,
contactlabel
) => {
const customAttributes = [];
if (conversationAttributes.length) {
customAttributes.push(
{
key: `conversation_custom_attribute`,
name: conversationlabel,
disabled: true,
},
...conversationAttributes
);
}
if (contactAttribtues.length) {
customAttributes.push(
{
key: `contact_custom_attribute`,
name: contactlabel,
disabled: true,
},
...contactAttribtues
);
}
return customAttributes;
};

View file

@ -1,15 +1,3 @@
const lowerCaseValues = (operator, values) => {
if (operator === 'equal_to' || operator === 'not_equal_to') {
values = values.map(val => {
if (typeof val === 'string') {
return val.toLowerCase();
}
return val;
});
}
return values;
};
const generatePayload = data => { const generatePayload = data => {
// Make a copy of data to avoid vue data reactivity issues // Make a copy of data to avoid vue data reactivity issues
const filters = JSON.parse(JSON.stringify(data)); const filters = JSON.parse(JSON.stringify(data));
@ -23,8 +11,6 @@ const generatePayload = data => {
} else { } else {
item.values = [item.values]; item.values = [item.values];
} }
// Convert all values to lowerCase if operator_type is 'equal_to' or 'not_equal_to'
item.values = lowerCaseValues(item.filter_operator, item.values);
return item; return item;
}); });
// For every query added, the query_operator is set default to and so the // For every query added, the query_operator is set default to and so the

View file

@ -60,15 +60,11 @@ export const getFormattedPreChatFields = ({ preChatFields }) => {
return { return {
...item, ...item,
label: getLabel({ label: getLabel({
key: standardFieldKeys[item.name] key: item.name,
? standardFieldKeys[item.name].key
: item.name,
label: item.label ? item.label : item.name, label: item.label ? item.label : item.name,
}), }),
placeholder: getPlaceHolder({ placeholder: getPlaceHolder({
key: standardFieldKeys[item.name] key: item.name,
? standardFieldKeys[item.name].key
: item.name,
placeholder: item.placeholder ? item.placeholder : item.name, placeholder: item.placeholder ? item.placeholder : item.name,
}), }),
}; };

View file

@ -1,4 +1,4 @@
import posthog from 'posthog-js'; import AnalyticsHelper from './AnalyticsHelper';
export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER'; export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER';
export const CHATWOOT_RESET = 'CHATWOOT_RESET'; export const CHATWOOT_RESET = 'CHATWOOT_RESET';
@ -8,16 +8,9 @@ export const ANALYTICS_RESET = 'ANALYTICS_RESET';
export const initializeAnalyticsEvents = () => { export const initializeAnalyticsEvents = () => {
window.bus.$on(ANALYTICS_IDENTITY, ({ user }) => { window.bus.$on(ANALYTICS_IDENTITY, ({ user }) => {
if (window.analyticsConfig) { AnalyticsHelper.identify(user);
posthog.identify(user.id, { name: user.name, email: user.email });
}
});
window.bus.$on(ANALYTICS_RESET, () => {
if (window.analyticsConfig) {
posthog.reset();
}
}); });
window.bus.$on(ANALYTICS_RESET, () => {});
}; };
export const initializeChatwootEvents = () => { export const initializeChatwootEvents = () => {

View file

@ -29,6 +29,12 @@ describe('#URL Helpers', () => {
'/app/accounts/1/team/1' '/app/accounts/1/team/1'
); );
}); });
it('should return url to custom view', () => {
expect(conversationListPageURL({ accountId: 1, customViewId: 1 })).toBe(
'/app/accounts/1/custom_view/1'
);
});
}); });
describe('conversationUrl', () => { describe('conversationUrl', () => {
it('should return direct conversation URL if activeInbox is nil', () => { it('should return direct conversation URL if activeInbox is nil', () => {

View file

@ -5,7 +5,7 @@ const testData = [
attribute_key: 'status', attribute_key: 'status',
filter_operator: 'equal_to', filter_operator: 'equal_to',
values: [ values: [
{ id: 'PENDING', name: 'Pending' }, { id: 'pending', name: 'Pending' },
{ id: 'resolved', name: 'Resolved' }, { id: 'resolved', name: 'Resolved' },
], ],
query_operator: 'and', query_operator: 'and',
@ -18,7 +18,7 @@ const testData = [
account_id: 1, account_id: 1,
auto_offline: true, auto_offline: true,
confirmed: true, confirmed: true,
email: 'fayazara@gmail.com', email: 'fayaz@test.com',
available_name: 'Fayaz', available_name: 'Fayaz',
name: 'Fayaz', name: 'Fayaz',
role: 'agent', role: 'agent',
@ -52,7 +52,7 @@ const finalResult = {
{ {
attribute_key: 'id', attribute_key: 'id',
filter_operator: 'equal_to', filter_operator: 'equal_to',
values: ['this is a test'], values: ['This is a test'],
}, },
], ],
}; };

View file

@ -70,6 +70,34 @@ export const labels = [
}, },
]; ];
export const agents = [
{
id: 1,
account_id: 1,
availability_status: 'offline',
auto_offline: true,
confirmed: true,
email: 'john@doe.com',
available_name: 'John Doe',
name: 'John Doe',
role: 'agent',
thumbnail:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBUZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--746506837470c1a3dd063e90211ba2386963d52f/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2QzNKbGMybDZaVWtpRERJMU1IZ3lOVEFHT3daVSIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--e0e35266e8ed66e90c51be02408be8a022aca545/batman_90804.png',
},
{
id: 9,
account_id: 1,
availability_status: 'offline',
auto_offline: true,
confirmed: true,
email: 'clark@kent.com',
available_name: 'Clark Kent',
name: 'Clark Kent',
role: 'agent',
thumbnail: '',
},
];
export const files = [ export const files = [
{ {
id: 76, id: 76,

View file

@ -4,9 +4,10 @@ import {
resolveLabels, resolveLabels,
resolveTeamIds, resolveTeamIds,
getFileName, getFileName,
resolveAgents,
} from '../../routes/dashboard/settings/macros/macroHelper'; } from '../../routes/dashboard/settings/macros/macroHelper';
import { MACRO_ACTION_TYPES } from '../../routes/dashboard/settings/macros/constants'; import { MACRO_ACTION_TYPES } from '../../routes/dashboard/settings/macros/constants';
import { teams, labels, files } from './macrosFixtures'; import { teams, labels, files, agents } from './macrosFixtures';
describe('#emptyMacro', () => { describe('#emptyMacro', () => {
const defaultMacro = { const defaultMacro = {
@ -52,6 +53,13 @@ describe('#resolveLabels', () => {
}); });
}); });
describe('#resolveAgents', () => {
it('resolves agents names from ids and returns a joined string', () => {
const resolvedAgents = 'John Doe';
expect(resolveAgents(agents, [1])).toEqual(resolvedAgents);
});
});
describe('#getFileName', () => { describe('#getFileName', () => {
it('returns the correct file name from the list of files', () => { it('returns the correct file name from the list of files', () => {
expect(getFileName(files[0].blob_id, 'send_attachment', files)).toEqual( expect(getFileName(files[0].blob_id, 'send_attachment', files)).toEqual(

View file

@ -15,6 +15,7 @@ import id from './locale/id';
import it from './locale/it'; import it from './locale/it';
import ja from './locale/ja'; import ja from './locale/ja';
import ko from './locale/ko'; import ko from './locale/ko';
import lv from './locale/lv';
import ml from './locale/ml'; import ml from './locale/ml';
import nl from './locale/nl'; import nl from './locale/nl';
import no from './locale/no'; import no from './locale/no';
@ -52,6 +53,7 @@ export default {
ja, ja,
ko, ko,
ml, ml,
lv,
nl, nl,
no, no,
pl, pl,

View file

@ -1,5 +1,70 @@
{ {
"AGENT_BOTS": { "AGENT_BOTS": {
"HEADER": "Bots" "HEADER": "Bots",
"LOADING_EDITOR": "Loading Editor...",
"HEADER_BTN_TXT": "Add Bot Configuration",
"SIDEBAR_TXT": "<p><b>Agent Bots</b> <p>Agent bots allows you to automate the conversations</p>",
"CSML_BOT_EDITOR": {
"NAME": {
"LABEL": "Bot Name",
"PLACEHOLDER": "Give your bot a name",
"ERROR": "Bot name is required"
},
"DESCRIPTION": {
"LABEL": "Bot Description",
"PLACEHOLDER": "What does this bot do?"
},
"BOT_CONFIG": {
"ERROR": "Please enter your CSML bot configuration above",
"API_ERROR": "Your CSML configuration is invalid, please fix it and try again."
},
"SUBMIT": "Validate and save"
},
"BOT_CONFIGURATION": {
"TITLE": "Select an agent bot",
"DESC": "You can set an agent bot from the list to this inbox. The bot can initially handle the conversation and transfer it to an agent when needed.",
"SUBMIT": "تحديث",
"SUCCESS_MESSAGE": "Successfully updated the agent bot",
"ERROR_MESSAGE": "Could not update the agent bot, please try again later",
"SELECT_PLACEHOLDER": "Select Bot"
},
"ADD": {
"TITLE": "Configure new bot",
"CANCEL_BUTTON_TEXT": "إلغاء",
"API": {
"SUCCESS_MESSAGE": "Bot added successfully",
"ERROR_MESSAGE": "Could not add bot, Please try again later"
}
},
"LIST": {
"404": "No Bots found, you can create a bot by clicking the 'Configure new bot' Button ↗",
"LOADING": "Fetching Bots...",
"TYPE": "Bot Type"
},
"DELETE": {
"BUTTON_TEXT": "حذف",
"TITLE": "Delete Bot",
"SUBMIT": "حذف",
"CANCEL_BUTTON_TEXT": "إلغاء",
"DESCRIPTION": "Are you sure you want to delete this bot? This action is irreversible",
"API": {
"SUCCESS_MESSAGE": "Bot deleted successfully",
"ERROR_MESSAGE": "Could not able to delete bot, Please try again later"
}
},
"EDIT": {
"BUTTON_TEXT": "تعديل",
"LOADING": "Fetching Bots...",
"TITLE": "Edit Bot",
"CANCEL_BUTTON_TEXT": "إلغاء",
"API": {
"SUCCESS_MESSAGE": "Bot updated successfully",
"ERROR_MESSAGE": "Could not update bot, Please try again later"
}
},
"TYPES": {
"WEBHOOK": "Webhook Bot",
"CSML": "CSML Bot"
}
} }
} }

View file

@ -86,7 +86,9 @@
"RESET_MESSAGE": "تغيير نوع الحدث سوف يعيد تعيين الشروط والأحداث التي أضفتها أدناه" "RESET_MESSAGE": "تغيير نوع الحدث سوف يعيد تعيين الشروط والأحداث التي أضفتها أدناه"
}, },
"CONDITION": { "CONDITION": {
"DELETE_MESSAGE": "يجب أن يكون لديك على الأقل شرط واحد للحفظ" "DELETE_MESSAGE": "يجب أن يكون لديك على الأقل شرط واحد للحفظ",
"CONTACT_CUSTOM_ATTR_LABEL": "Contact Custom Attributes",
"CONVERSATION_CUSTOM_ATTR_LABEL": "Conversation Custom Attributes"
}, },
"ACTION": { "ACTION": {
"DELETE_MESSAGE": "يجب أن يكون لديك على الأقل شرط واحد للحفظ", "DELETE_MESSAGE": "يجب أن يكون لديك على الأقل شرط واحد للحفظ",
@ -109,7 +111,7 @@
"UPLOAD_ERROR": "تعذر تحميل المرفق، الرجاء المحاولة مرة أخرى", "UPLOAD_ERROR": "تعذر تحميل المرفق، الرجاء المحاولة مرة أخرى",
"LABEL_IDLE": "ارفع المرفق", "LABEL_IDLE": "ارفع المرفق",
"LABEL_UPLOADING": "جاري الرفع...", "LABEL_UPLOADING": "جاري الرفع...",
"LABEL_UPLOADED": "تم الرفع بنجاح", "LABEL_UPLOADED": "Successfully Uploaded",
"LABEL_UPLOAD_FAILED": "فشل الرفع" "LABEL_UPLOAD_FAILED": "فشل الرفع"
} }
} }

View file

@ -8,6 +8,7 @@
"ASSIGN_LABEL": "تكليف", "ASSIGN_LABEL": "تكليف",
"YES": "نعم", "YES": "نعم",
"ASSIGN_AGENT_TOOLTIP": "إسناد وكيل", "ASSIGN_AGENT_TOOLTIP": "إسناد وكيل",
"ASSIGN_TEAM_TOOLTIP": "تعيين فريق",
"ASSIGN_SUCCESFUL": "تم تعيين المحادثات بنجاح", "ASSIGN_SUCCESFUL": "تم تعيين المحادثات بنجاح",
"ASSIGN_FAILED": "فشل في تعيين المحادثات، الرجاء المحاولة مرة أخرى", "ASSIGN_FAILED": "فشل في تعيين المحادثات، الرجاء المحاولة مرة أخرى",
"RESOLVE_SUCCESFUL": "تم تسوية المحادثات بنجاح", "RESOLVE_SUCCESFUL": "تم تسوية المحادثات بنجاح",
@ -26,6 +27,14 @@
"ASSIGN_SELECTED_LABELS": "تعيين التسميات المحددة", "ASSIGN_SELECTED_LABELS": "تعيين التسميات المحددة",
"ASSIGN_SUCCESFUL": "تم تعيين التسميات بنجاح", "ASSIGN_SUCCESFUL": "تم تعيين التسميات بنجاح",
"ASSIGN_FAILED": "فشل في تعيين التسميات ، الرجاء المحاولة مرة أخرى" "ASSIGN_FAILED": "فشل في تعيين التسميات ، الرجاء المحاولة مرة أخرى"
},
"TEAMS": {
"TEAM_SELECT_LABEL": "اختيار فريق",
"NONE": "لا شيء",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team",
"ASSIGN_SUCCESFUL": "Teams assiged successfully",
"ASSIGN_FAILED": "Failed to assign team, please try again"
} }
} }
} }

View file

@ -8,6 +8,7 @@
}, },
"TAB_HEADING": "المحادثات", "TAB_HEADING": "المحادثات",
"MENTION_HEADING": "الإشارات", "MENTION_HEADING": "الإشارات",
"UNATTENDED_HEADING": "بدون حضور",
"SEARCH": { "SEARCH": {
"INPUT": "البحث عن جهات الاتصال، المحادثات، قوالب الردود الجاهزة .." "INPUT": "البحث عن جهات الاتصال، المحادثات، قوالب الردود الجاهزة .."
}, },
@ -56,6 +57,8 @@
"REPLY_TO_TWEET": "الرد على هذه التغريدة", "REPLY_TO_TWEET": "الرد على هذه التغريدة",
"LINK_TO_STORY": "الذهاب إلى قصة الإنستقرام", "LINK_TO_STORY": "الذهاب إلى قصة الإنستقرام",
"SENT": "Sent successfully", "SENT": "Sent successfully",
"READ": "Read successfully",
"DELIVERED": "Delivered successfully",
"NO_MESSAGES": "لا توجد رسائل", "NO_MESSAGES": "لا توجد رسائل",
"NO_CONTENT": "لم يتم العثور على محتوى", "NO_CONTENT": "لم يتم العثور على محتوى",
"HIDE_QUOTED_TEXT": "Hide Quoted Text", "HIDE_QUOTED_TEXT": "Hide Quoted Text",

View file

@ -41,6 +41,10 @@
"NO_RESPONSE": "لا توجد استجابة", "NO_RESPONSE": "لا توجد استجابة",
"RATING_TITLE": "التقييم", "RATING_TITLE": "التقييم",
"FEEDBACK_TITLE": "الملاحظات", "FEEDBACK_TITLE": "الملاحظات",
"CARD": {
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels"
},
"HEADER": { "HEADER": {
"RESOLVE_ACTION": "إغلاق المحادثة", "RESOLVE_ACTION": "إغلاق المحادثة",
"REOPEN_ACTION": "إعادة فتح", "REOPEN_ACTION": "إعادة فتح",
@ -64,6 +68,7 @@
"CARD_CONTEXT_MENU": { "CARD_CONTEXT_MENU": {
"PENDING": "تحديد كمعلق", "PENDING": "تحديد كمعلق",
"RESOLVED": "تحديد كمحلولة", "RESOLVED": "تحديد كمحلولة",
"MARK_AS_UNREAD": "Mark as unread",
"REOPEN": "إعادة فتح المحادثة", "REOPEN": "إعادة فتح المحادثة",
"SNOOZE": { "SNOOZE": {
"TITLE": "غفوة", "TITLE": "غفوة",
@ -208,7 +213,8 @@
"CONVERSATION_LABELS": "وسوم المحادثة", "CONVERSATION_LABELS": "وسوم المحادثة",
"CONVERSATION_INFO": "معلومات المحادثة", "CONVERSATION_INFO": "معلومات المحادثة",
"CONTACT_ATTRIBUTES": "سمات جهة الاتصال", "CONTACT_ATTRIBUTES": "سمات جهة الاتصال",
"PREVIOUS_CONVERSATION": "المحادثات السابقة" "PREVIOUS_CONVERSATION": "المحادثات السابقة",
"MACROS": "Macros"
} }
}, },
"CONVERSATION_CUSTOM_ATTRIBUTES": { "CONVERSATION_CUSTOM_ATTRIBUTES": {

View file

@ -0,0 +1,6 @@
{
"EMOJI": {
"PLACEHOLDER": "Search emojis",
"NOT_FOUND": "No emoji match your search"
}
}

View file

@ -23,7 +23,7 @@
"ERROR": "الرجاء إدخال اسم حساب صحيح" "ERROR": "الرجاء إدخال اسم حساب صحيح"
}, },
"LANGUAGE": { "LANGUAGE": {
"LABEL": "لغة الموقع (تجريبي)", "LABEL": "Site language",
"PLACEHOLDER": "اسم الحساب الخاص بك", "PLACEHOLDER": "اسم الحساب الخاص بك",
"ERROR": "" "ERROR": ""
}, },
@ -54,7 +54,8 @@
"MULTISELECT": { "MULTISELECT": {
"ENTER_TO_SELECT": "اضغط على زر الإدخال للاختيار", "ENTER_TO_SELECT": "اضغط على زر الإدخال للاختيار",
"ENTER_TO_REMOVE": "اضغط على زر الإدخال للحذف", "ENTER_TO_REMOVE": "اضغط على زر الإدخال للحذف",
"SELECT_ONE": "اختر واحدا" "SELECT_ONE": "اختر واحدا",
"SELECT": "Select"
} }
}, },
"NOTIFICATIONS_PAGE": { "NOTIFICATIONS_PAGE": {
@ -136,5 +137,8 @@
"UNTIL_NEXT_WEEK": "حتى الأسبوع القادم", "UNTIL_NEXT_WEEK": "حتى الأسبوع القادم",
"UNTIL_TOMORROW": "حتى الغد" "UNTIL_TOMORROW": "حتى الغد"
} }
},
"DASHBOARD_APPS": {
"LOADING_MESSAGE": "Loading Dashboard App..."
} }
} }

View file

@ -217,14 +217,14 @@
"DOMAIN": { "DOMAIN": {
"LABEL": "نطاق مخصص", "LABEL": "نطاق مخصص",
"PLACEHOLDER": "نطاق البوابة المخصص", "PLACEHOLDER": "نطاق البوابة المخصص",
"HELP_TEXT": "أضف فقط إذا كنت ترغب في استخدام نطاق مخصص للبوابات الخاصة بك.", "HELP_TEXT": "Add only If you want to use a custom domain for your portals. Eg: https://example.com",
"ERROR": "النطاق المخصص مطلوب" "ERROR": "Enter a valid domain URL"
}, },
"HOME_PAGE_LINK": { "HOME_PAGE_LINK": {
"LABEL": "رابط الصفحة الرئيسية", "LABEL": "رابط الصفحة الرئيسية",
"PLACEHOLDER": "رابط الصفحة الرئيسية للبوابة", "PLACEHOLDER": "رابط الصفحة الرئيسية للبوابة",
"HELP_TEXT": "الرابط المستخدم للعودة من البوابة إلى الصفحة الرئيسية.", "HELP_TEXT": "The link used to return from the portal to the home page. Eg: https://example.com",
"ERROR": "رابط الصفحة الرئيسية مطلوب" "ERROR": "Enter a valid home page URL"
}, },
"THEME_COLOR": { "THEME_COLOR": {
"LABEL": "لون قالب البوابة", "LABEL": "لون قالب البوابة",

View file

@ -134,7 +134,7 @@
"PHONE_NUMBER": { "PHONE_NUMBER": {
"LABEL": "رقم الهاتف", "LABEL": "رقم الهاتف",
"PLACEHOLDER": "الرجاء إدخال رقم الهاتف الذي سيتم إرسال الرسائل منه.", "PLACEHOLDER": "الرجاء إدخال رقم الهاتف الذي سيتم إرسال الرسائل منه.",
"ERROR": "الرجاء إدخال قيمة صحيحة. يجب أن يبدأ رقم الهاتف بعلامة `+`." "ERROR": "Please provide a valid phone number that starts with a `+` sign and does not contain any spaces."
}, },
"API_CALLBACK": { "API_CALLBACK": {
"TITLE": "عنوان Callback URL", "TITLE": "عنوان Callback URL",
@ -185,7 +185,7 @@
"PHONE_NUMBER": { "PHONE_NUMBER": {
"LABEL": "رقم الهاتف", "LABEL": "رقم الهاتف",
"PLACEHOLDER": "الرجاء إدخال رقم الهاتف الذي سيتم إرسال الرسائل منه.", "PLACEHOLDER": "الرجاء إدخال رقم الهاتف الذي سيتم إرسال الرسائل منه.",
"ERROR": "الرجاء إدخال قيمة صحيحة. يجب أن يبدأ رقم الهاتف بعلامة `+`." "ERROR": "Please provide a valid phone number that starts with a `+` sign and does not contain any spaces."
}, },
"SUBMIT_BUTTON": "إنشاء قناة عرض التردد", "SUBMIT_BUTTON": "إنشاء قناة عرض التردد",
"API": { "API": {
@ -214,7 +214,7 @@
"PHONE_NUMBER": { "PHONE_NUMBER": {
"LABEL": "رقم الهاتف", "LABEL": "رقم الهاتف",
"PLACEHOLDER": "الرجاء إدخال رقم الهاتف الذي سيتم إرسال الرسائل منه.", "PLACEHOLDER": "الرجاء إدخال رقم الهاتف الذي سيتم إرسال الرسائل منه.",
"ERROR": "الرجاء إدخال قيمة صحيحة. يجب أن يبدأ رقم الهاتف بعلامة `+`." "ERROR": "Please provide a valid phone number that starts with a `+` sign and does not contain any spaces."
}, },
"PHONE_NUMBER_ID": { "PHONE_NUMBER_ID": {
"LABEL": "رقم الهاتف", "LABEL": "رقم الهاتف",
@ -388,6 +388,10 @@
"ENABLED": "مفعل", "ENABLED": "مفعل",
"DISABLED": "معطّل" "DISABLED": "معطّل"
}, },
"LOCK_TO_SINGLE_CONVERSATION": {
"ENABLED": "مفعل",
"DISABLED": "معطّل"
},
"ENABLE_HMAC": { "ENABLE_HMAC": {
"LABEL": "تمكين" "LABEL": "تمكين"
} }
@ -416,7 +420,8 @@
"CAMPAIGN": "الحملات", "CAMPAIGN": "الحملات",
"PRE_CHAT_FORM": "نموذج ما قبل الدردشة", "PRE_CHAT_FORM": "نموذج ما قبل الدردشة",
"BUSINESS_HOURS": "ساعات العمل", "BUSINESS_HOURS": "ساعات العمل",
"WIDGET_BUILDER": "منشئ اللايف شات" "WIDGET_BUILDER": "منشئ اللايف شات",
"BOT_CONFIGURATION": "Bot Configuration"
}, },
"SETTINGS": "الإعدادات", "SETTINGS": "الإعدادات",
"FEATURES": { "FEATURES": {
@ -440,6 +445,8 @@
"ENABLE_CSAT_SUB_TEXT": "تمكين/تعطيل تقييم خدمة العملاء بعد إنتهاء المحادثة", "ENABLE_CSAT_SUB_TEXT": "تمكين/تعطيل تقييم خدمة العملاء بعد إنتهاء المحادثة",
"ENABLE_CONTINUITY_VIA_EMAIL": "تمكين استمرارية المحادثة عبر البريد الإلكتروني", "ENABLE_CONTINUITY_VIA_EMAIL": "تمكين استمرارية المحادثة عبر البريد الإلكتروني",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "المحادثات ستستمر عبر البريد الإلكتروني إذا كان عنوان البريد الإلكتروني لجهة الاتصال متاحاً.", "ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "المحادثات ستستمر عبر البريد الإلكتروني إذا كان عنوان البريد الإلكتروني لجهة الاتصال متاحاً.",
"LOCK_TO_SINGLE_CONVERSATION": "Lock to single conversation",
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Enable or disable multiple conversations for the same contact in this inbox",
"INBOX_UPDATE_TITLE": "إعدادات قناة التواصل", "INBOX_UPDATE_TITLE": "إعدادات قناة التواصل",
"INBOX_UPDATE_SUB_TEXT": "تحديث إعدادات قناة التواصل", "INBOX_UPDATE_SUB_TEXT": "تحديث إعدادات قناة التواصل",
"AUTO_ASSIGNMENT_SUB_TEXT": "تمكين أو تعطيل الإسناد التلقائي للمحادثات الجديدة إلى الموظفين المضافين إلى قناة التواصل هذه.", "AUTO_ASSIGNMENT_SUB_TEXT": "تمكين أو تعطيل الإسناد التلقائي للمحادثات الجديدة إلى الموظفين المضافين إلى قناة التواصل هذه.",

View file

@ -1,5 +1,78 @@
{ {
"MACROS": { "MACROS": {
"HEADER": "Macros" "HEADER": "Macros",
"HEADER_BTN_TXT": "Add a new macro",
"HEADER_BTN_TXT_SAVE": "Save macro",
"LOADING": "Fetching macros",
"SIDEBAR_TXT": "<p><b>Macros</b><p>A macro is a set of saved actions that help customer service agents easily complete tasks. The agents can define a set of actions like tagging a conversation with a label, sending an email transcript, updating a custom attribute, etc., and they can run these actions in a single click. When the agents run the macro, the actions would be performed sequentially in the order they are defined. Macros improve productivity and increase consistency in actions. </p><p>A macro can be helpful in 2 ways. </p><p><b>As an agent assist:</b> If an agent performs a set of actions multiple times, they can save it as a macro and execute all the actions together using a single click.</p><p><b>As an option to onboard a team member:</b> Every agent has to perform many different checks/actions during each conversation. Onboarding a new support team member will be easy if pre-defined macros are available on the account. Instead of describing each step in detail, the manager/team lead can point to the macros used in different scenarios.</p>",
"ERROR": "Something went wrong. Please try again",
"ORDER_INFO": "Macros will run in the order you add your actions. You can rearrange them by dragging them by the handle beside each node.",
"ADD": {
"FORM": {
"NAME": {
"LABEL": "Macro name",
"PLACEHOLDER": "Enter a name for your macro",
"ERROR": "Name is required for creating a macro"
},
"ACTIONS": {
"LABEL": "الإجراءات"
}
},
"API": {
"SUCCESS_MESSAGE": "Macro added successfully",
"ERROR_MESSAGE": "Unable to create macro, Please try again later"
}
},
"LIST": {
"TABLE_HEADER": [
"الاسم",
"Created by",
"Last updated by",
"Visibility"
],
"404": "No macros found"
},
"DELETE": {
"TOOLTIP": "Delete macro",
"CONFIRM": {
"MESSAGE": "هل أنت متأكد من الحذف ",
"YES": "نعم، احذف",
"NO": "لا"
},
"API": {
"SUCCESS_MESSAGE": "Macro deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the macro. Please try again later"
}
},
"EDIT": {
"TOOLTIP": "Edit macro",
"API": {
"SUCCESS_MESSAGE": "Macro updated successfully",
"ERROR_MESSAGE": "Could not update Macro, Please try again later"
}
},
"EDITOR": {
"START_FLOW": "Start Flow",
"END_FLOW": "End Flow",
"LOADING": "Fetching macro",
"ADD_BTN_TOOLTIP": "Add new action",
"DELETE_BTN_TOOLTIP": "Delete Action",
"VISIBILITY": {
"LABEL": "Macro Visibility",
"GLOBAL": {
"LABEL": "Public",
"DESCRIPTION": "This macro is available publicly for all agents in this account."
},
"PERSONAL": {
"LABEL": "Private",
"DESCRIPTION": "This macro will be private to you and not be available to others."
}
}
},
"EXECUTE": {
"BUTTON_TOOLTIP": "Execute",
"PREVIEW": "Preview Macro",
"EXECUTED_SUCCESSFULLY": "Macro executed successfully"
}
} }
} }

View file

@ -103,7 +103,9 @@
"متصل", "متصل",
"مشغول", "مشغول",
"غير متصل" "غير متصل"
] ],
"SET_AVAILABILITY_SUCCESS": "Availability has been set successfully",
"SET_AVAILABILITY_ERROR": "Couldn't set availability, please try again"
}, },
"EMAIL": { "EMAIL": {
"LABEL": "عنوان البريد الإلكتروني الخاص بك", "LABEL": "عنوان البريد الإلكتروني الخاص بك",
@ -134,6 +136,7 @@
"SELECTOR_SUBTITLE": "اختر حساباً من القائمة التالية", "SELECTOR_SUBTITLE": "اختر حساباً من القائمة التالية",
"PROFILE_SETTINGS": "إعدادات الملف الشخصي", "PROFILE_SETTINGS": "إعدادات الملف الشخصي",
"KEYBOARD_SHORTCUTS": "اختصارات لوحة المفاتيح", "KEYBOARD_SHORTCUTS": "اختصارات لوحة المفاتيح",
"SUPER_ADMIN_CONSOLE": "Super Admin Console",
"LOGOUT": "تسجيل الخروج" "LOGOUT": "تسجيل الخروج"
}, },
"APP_GLOBAL": { "APP_GLOBAL": {
@ -158,6 +161,9 @@
"DOWNLOAD": "تنزيل", "DOWNLOAD": "تنزيل",
"UPLOADING": "جاري الرفع..." "UPLOADING": "جاري الرفع..."
}, },
"LOCATION_BUBBLE": {
"SEE_ON_MAP": "See on map"
},
"FORM_BUBBLE": { "FORM_BUBBLE": {
"SUBMIT": "إرسال" "SUBMIT": "إرسال"
} }
@ -174,6 +180,7 @@
"CONVERSATIONS": "المحادثات", "CONVERSATIONS": "المحادثات",
"ALL_CONVERSATIONS": "كل المحادثات", "ALL_CONVERSATIONS": "كل المحادثات",
"MENTIONED_CONVERSATIONS": "الإشارات", "MENTIONED_CONVERSATIONS": "الإشارات",
"UNATTENDED_CONVERSATIONS": "بدون حضور",
"REPORTS": "التقارير", "REPORTS": "التقارير",
"SETTINGS": "الإعدادات", "SETTINGS": "الإعدادات",
"CONTACTS": "جهات الاتصال", "CONTACTS": "جهات الاتصال",
@ -222,6 +229,10 @@
"CATEGORY": "الفئة", "CATEGORY": "الفئة",
"CATEGORY_EMPTY_MESSAGE": "لم يتم العثور على فئات" "CATEGORY_EMPTY_MESSAGE": "لم يتم العثور على فئات"
}, },
"SET_AUTO_OFFLINE": {
"TEXT": "Mark offline automatically",
"INFO_TEXT": "Let the system automatically mark you offline when you aren't using the app or dashboard."
},
"DOCS": "قراءة المستندات" "DOCS": "قراءة المستندات"
}, },
"BILLING_SETTINGS": { "BILLING_SETTINGS": {
@ -253,7 +264,7 @@
}, },
"FORM": { "FORM": {
"NAME": { "NAME": {
"LABEL": "اسم الحساب", "LABEL": "اسم الشركة",
"PLACEHOLDER": "مؤسسة Wayne" "PLACEHOLDER": "مؤسسة Wayne"
}, },
"SUBMIT": "إرسال" "SUBMIT": "إرسال"

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