Compare commits

...
Author SHA1 Message Date
Shivam Mishra 11807f7d6f feat: use rails_blob_url instead of url_for 2023-05-11 14:47:48 +05:30
Muhsin KelothandGitHub 87f758ee1f feat: Order conversations by priority (#7053) 2023-05-11 12:56:43 +05:30
Shivam MishraandGitHub d0a1ad746a feat: add index migration (#7050) 2023-05-10 17:55:14 +05:30
86384bf876 feat: Reply suggestion and summary generation using OpenAI (#7029)
Add `reply_suggestions` and `summary_generation` options for OpenAI integration

Fixes: https://linear.app/chatwoot/issue/CW-1595/frontend-for-generating-conversation-summary

Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-05-10 17:50:37 +05:30
Muhsin KelothandGitHub 55e5fa2205 chore: Add the specs for sort conversation (#7047) 2023-05-10 17:49:17 +05:30
Sojan 65bad90b69 Revert "fix: Welcome email copy changes and canned response API error handling [cw-1290] (#6905)"
This reverts commit 07aaa046c1.
2023-05-10 16:43:18 +05:30
07aaa046c1 fix: Welcome email copy changes and canned response API error handling [cw-1290] (#6905)
* fix: Welcome email copy changes and canned response API error handling

* Review fixes

* Uses mixin for alerts in canned page

* Typo fixes

* Copy changes

* Fixes broken tests

* Fixes review comments

* Fixes typo errors with mail template

* Removes unwanted case

* Fixes repetitive texts

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-05-10 15:55:48 +05:30
giquieuandGitHub 520bdabefe fix: Record audio in wav format for web/facebook (#7046) 2023-05-10 12:29:56 +05:30
Shivam MishraandGitHub 662967b5d3 feat(perf): add index to messages created at (#7044)
* feat: add index to messages created at

* feat: run migration
2023-05-10 11:34:49 +05:30
6137a45214 chore: Update link in the reply summary email (#7024)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-05-09 17:39:09 -07:00
Pranav Raj SandGitHub 5e0ce7793c fix: Re-order gem list to fix rubocop errors (#7043) 2023-05-08 16:15:27 -07:00
Pranav Raj SandGitHub ce3e38df0f chore: Update the design of the help center portal (#6775) 2023-05-08 15:31:38 -07:00
2b88ecdfc4 feat: Add a feature flag for Audit Log UI (#7035)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-05-08 12:44:15 -07:00
Pranav Raj SandGitHub 683f259771 fix: Fix rendering issues in Help Center (#7042) 2023-05-08 12:23:26 -07:00
Vishnu NarayananandGitHub 51fb3b7e8e fix: enable lograge in superadmin path (#7026)
* fix: lograge superadmin path
* chore: add spec for superadmin devise session controller
* chore: address review comment
2023-05-08 13:43:55 +05:30
3dedfee350 chore: Update translations (#7033)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-05-06 15:10:50 +05:30
Sojan JoseandGitHub 022383d942 chore: Upgrade to Rails 7 (#6719)
fixes: #6736
2023-05-06 10:44:52 +05:30
59433d9d3c feat: Adds the ability to sort conversations (#6853)
* Add sort filter

* Change UI

* Change filter

* Complete sort by filters

* Style fixes

* Fix default sort

* Update app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue

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

* Update app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue

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

* Update app/javascript/dashboard/components/ChatList.vue

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

* Added translation

* Added review fixes

* Add more updates

* Code cleanups

* Update last_activity_at on message received event

* Cleans up the design for chatlist and icons

* Fix sort

* Remove inline styles

* Add tag along with the title

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
2023-05-05 17:08:32 -07:00
Sojan JoseandGitHub 85e57c2e94 chore: Reorganize Sidekiq Queues (#6976)
- Rearrange and reprioritize current sidekiq queues
- Trim the unnecessary queues

ref: https://linear.app/chatwoot/issue/CW-1480/chore-run-all-sidekiq-jobs-async
2023-05-04 15:44:16 +05:30
Tejaswini ChileandGitHub b081fe08b8 feat: whatsapp duplicate message (#7004) 2023-05-03 15:18:20 +05:30
Muhsin KelothandGitHub 0d014d5d4b chore: Bump utils from 0.0.15 to 0.0.16 (#7022) 2023-05-03 14:36:55 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
937338e3ea chore(deps): bump audited from 5.2.0 to 5.3.3 (#7016)
Bumps [audited](https://github.com/collectiveidea/audited) from 5.2.0 to 5.3.3.
- [Release notes](https://github.com/collectiveidea/audited/releases)
- [Changelog](https://github.com/collectiveidea/audited/blob/main/CHANGELOG.md)
- [Commits](https://github.com/collectiveidea/audited/compare/v5.2.0...v5.3.3)

---
updated-dependencies:
- dependency-name: audited
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-03 14:13:04 +05:30
Pranav Raj S 6e506b070e Merge branch 'hotfix/2.16.1' into develop 2023-05-02 15:54:49 -07:00
Pranav Raj S 257a294166 Bump the version to 2.16.1 2023-05-02 15:52:50 -07:00
Pranav Raj S 12f121f0d8 fix: Provide a default to name if name is not present 2023-05-02 15:52:33 -07:00
Tejaswini ChileandGitHub 847d7ea082 feat: Add support to uncategorized articles (#6912) 2023-05-02 15:35:26 +05:30
c8041392dc feat: Enable features on successful subscription (#6953)
* feat: Enable features on successful subscription

* fix: Add specs

* disable features for default plan

* Remove mark as cloud customers

---------

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-05-01 14:58:39 -07:00
be3c75e858 chore: Update translations (#6999)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-04-28 20:28:03 +05:30
Sojan JoseandGitHub 02c467b6db chore: GPT Reply suggestion & summarize endpoints (#7011)
Adds additional endpoints for OpenAI integration which will allow
- Reply Suggestions
- Summarization

ref: #6436
fixes: https://linear.app/chatwoot/issue/CW-1596/backend-for-generating-conversation-summary
2023-04-28 19:57:25 +05:30
Muhsin KelothandGitHub 13fe439d9f Move the reconnect logic from the update presence (#6992) 2023-04-28 14:02:30 +05:30
Shivam MishraandGitHub 2313edd244 feat: update identity validation docs link (#6994)
* feat: update identity validation docs link

* fix: copy

* fix: grammar
2023-04-28 13:36:58 +05:30
Muhsin KelothandGitHub 09c9300c21 chore: Use canned response variable helpers from utils (#7007) 2023-04-27 17:01:34 +05:30
c0e905b5d8 fix: Search improvements and bug fixes [CW-1604] (#6985)
* fix: Search improvements and bug fixes

* Resets tab to all on search

* Fix index bug with tabs

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-04-27 13:20:29 +05:30
Pranav Raj SandGitHub 04da8aa8dc fix: Remove duplicates message in WhatsApp Channel (#7003) 2023-04-26 19:57:06 -07:00
Shivam MishraandGitHub 32f7342cd6 chore: Add issue forms for GitHub (#6982) 2023-04-26 11:25:02 -07:00
74afb785c2 fix: Update spacing for the priority icon in the inbox views (#6993)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-04-26 10:21:30 -07:00
Shivam MishraandGitHub cae1e30160 feat: Update demo data to include better messages (#6995) 2023-04-26 10:20:56 -07:00
Tejaswini ChileandGitHub 99dfe1d5af feat: Enable template variables in channel greeting messages (#6971) 2023-04-26 20:23:46 +05:30
Sojan JoseandGitHub 3fa654f5c6 chore: Add Index for widget contact lookup (#6998)
- Adds an index to improve the contact lookup performance while calling setUser from widget
2023-04-26 19:22:16 +05:30
shaheer-haiderandGitHub 37fde64c67 fix: installation of gem bundles (lograge, rack-mini-profiler, stackprof, nokogiri) in docker (#6897) 2023-04-26 17:04:17 +05:30
Tejaswini ChileandGitHub 3c2d6faf68 feat: Instagram story replies will display the original story link (#6846) 2023-04-26 15:27:07 +05:30
Muhsin KelothandGitHub 5d30dabf97 feat: Command bar action for priority (#6989)
* Add command action for priority assignment

* Fix icon path

* Update conversationHotKeys.js
2023-04-26 13:29:01 +05:30
cef1f97d18 feat: update product copy [CWM-45] (#6372)
* chore: Update non-logged-in-page copy

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

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>

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

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

---------

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-04-25 22:02:15 -07:00
Pranav Raj SandGitHub 92705723f1 fix: Add /api/v1 to the team_members API documentation (#6987) 2023-04-25 20:17:15 -07:00
Shivam MishraandGitHub af971c9b18 fix: whatsapp template params validation (#6986) 2023-04-26 08:46:30 +05:30
2f2cdd7e7c feat: Hides dismissed campaigns while browsing [cw-33] (#6842)
* Chore: moves localstorage helper as a shared utility and refactors constants

* Refactors constants file

* feat: Hides dismissed campaigns while browsing

* Snoozes all campaigns for an hour after dismissing

* Fixes error with date parsing

* Snooze ongoing campaigns

* Review fixes

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-04-25 22:38:36 +05:30
Shivam MishraandGitHub d95283f5c2 feat: improvements to priority (#6981)
* fix: colors for urgent icon

* feat: trigger updated event on priority change

* fix: specs
2023-04-25 22:28:19 +05:30
402428fb4d feat: Splits search api by resources to improve query time [cw-47] (#6942)
* feat: Splits search api by resources to improve query time

* Review fixes

* Spacing fixes

* Update app/javascript/dashboard/modules/search/components/SearchView.vue

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

* Review fixes

* Refactor searchview

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-04-25 17:59:38 +05:30
Shivam MishraandGitHub 5600b518ac fix: validate template_params for WhatsApp (#6881)
- Add JsonSchemaValidator, which takes a declarative schema and validates it for a given property.
- Add specs for JsonSchemaValidator
- Enable the validator for template_params
2023-04-25 16:50:36 +05:30
0bbb28c432 feat: sort conversation on priority (#6943)
* feat: update seed script to include prioritt

* feat: add sort_handler for conversations

* test: sort on priority order

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-04-25 09:47:47 +05:30
Sojan JoseandGitHub cf91e9eb58 chore: Use find_each instead of .all.each (#6975)
- Enable the rubocop Rails/FindEach
- Replace the .all.each with .find_each

This should let us avoid potential memory usage.
Motivation from the speedshop newsletter by Nate Berkopec

ref: https://www.rubyinrails.com/2017/11/16/use-find-each-instead-of-all-each-in-rails/
ref: https://linear.app/chatwoot/issue/CW-1480/chore-run-all-sidekiq-jobs-async
2023-04-25 09:32:35 +05:30
b529baa5eb fix: Pagination bug in chat list (#6899)
* fix: Pagination bug in chat list

* chore: Review fixes

* Improves variable namings

---------

Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
2023-04-25 09:01:54 +05:30
Pranav Raj SandGitHub c071f66cdd fix: Update the icon for low priority (#6978) 2023-04-24 13:52:14 -07:00
92fa9c4fdc feat: Ability to improve drafts in the editor using GPT integration (#6957)
ref: https://github.com/chatwoot/chatwoot/issues/6436
fixes: https://linear.app/chatwoot/issue/CW-1552/ability-to-rephrase-text-in-the-editor-using-gpt-integration

---------

Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
2023-04-24 23:52:23 +05:30
f6e0453bb2 fix vertical overflow scroll on BackButton (#6914)
* fix: vertical overflow scroll on BackButton

Co-authored-by: raph941 <45232708+raph941@users.noreply.github.com>

* chore: adds suggested improvement to handle RTS view

Co-authored-by: raph941 <45232708+raph941@users.noreply.github.com>

---------

Co-authored-by: raph941 <45232708+raph941@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-04-24 22:57:58 +05:30
Pranav Raj SandGitHub 1a07828b1b fix: Disable the entire element if priority is missing (#6972) 2023-04-24 10:21:39 -07:00
Shivam MishraandGitHub 54880b2342 feat: Track conversation priority events (#6968) 2023-04-24 20:21:43 +05:30
Muhsin KelothandGitHub e32f3e71e4 feat: Right click context menu action to change the priority (#6947)
* Right click context menu action to change the priority

* Review comments

* Update Index.vue

* Remove selected priority  from menu

* Code cleanup

* Update conversation.json
2023-04-24 20:07:50 +05:30
Shivam MishraandGitHub 0874aeee2d feat: priority UI (#6966) 2023-04-24 19:00:08 +05:30
Muhsin KelothandGitHub f1fc658a0d feat: Add an action in Macro to change the priority (#6940) 2023-04-24 17:14:30 +05:30
Tejaswini ChileandGitHub e3193dcabc feat: Link help center portal to an Inbox (#6903) 2023-04-24 12:49:52 +05:30
Vishnu NarayananandGitHub f825a22997 feat: add Makefile (#6948)
* feat: add  Makefile
* chore: refactor
2023-04-24 11:32:17 +05:30
Muhsin KelothandGitHub 815322b27a feat: Refetch the active conversation messages on action cable reconnect (#6790) 2023-04-24 10:17:12 +05:30
Pranav Raj SandGitHub 474e65f4c8 feat: Save in_reply_to from WhatsApp messages (#6964) 2023-04-23 18:28:14 -07:00
3a35281b3f chore: Update translations (#6941)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-04-21 15:28:56 +05:30
Muhsin KelothandGitHub ac6de50b4d feat: Add a condition for filters based on the priority of automation (#6939) 2023-04-20 18:10:05 +05:30
a34729c153 feat: add activity message for priority change (#6933)
* feat: add priority const

* feat: add toggle priority method

* feat: update controller route and specs

* refactor: status change method

* refactor: abstract label change and mute activity

* feat: add priority change_activity

* fix: interpolation for previous_changes

* refactor: reduce cognitive complexity of priority_change_activity

* refactor: move priority activity message handler to a separate module

* refactor: move typing logic to a service

* refactor: tests to reduce complexity

* fix: typo

* fix: constants

* fix: priority conditions

* fix: add a response

* fix: argument destructuring in I18n.t

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-04-20 16:41:53 +05:30
6b2736aa63 fix: inconsistency in report and summary for metric counts (#6817)
* feat: include timezone offset in summary calculation

* fix: exlcude end in date range

* test: explicit end of day

* fix: test for report builder

* fix: reports.spec.js

---------

Co-authored-by: Tejaswini Chile <tejaswini@chatwoot.com>
2023-04-20 12:55:04 +05:30
Muhsin KelothandGitHub d1584eea72 feat: Add an action on automation to change the priority (#6925) 2023-04-20 11:31:33 +05:30
Sivin VargheseandGitHub 527042afd1 fix: Code fix from PR #6823 (#6934) 2023-04-19 23:09:37 +05:30
Nithin David ThomasandGitHub bd1e69e4b4 feat: Adds new inbox selector with more info for new message modal [cw-1358] (#6823) 2023-04-19 23:02:50 +05:30
Vishnu NarayananandGitHub 76d4c22c2d fix: build_id in heroku installations (#6932) 2023-04-19 21:39:10 +05:30
Tejaswini ChileandGitHub 5cdc7d654a feat: Support multiple emails in email transcript automation (#6924) 2023-04-19 16:55:25 +05:30
Tejaswini ChileandGitHub 821d49943a Delete inbox api doc update (#6930) 2023-04-19 16:43:51 +05:30
Shivam MishraandGitHub 9c0259da6b feat: add priority field to conversation (#6921) (#6927)
* feat: add priority

* feat: add indexes
2023-04-19 13:23:14 +05:30
Pranav Raj SandGitHub ea2c442328 chore: Add account_id as custom_attribute for cloud users (#6926) 2023-04-18 22:35:11 -07:00
Volodymyr MakukhandGitHub b93b8cdab6 Fix flaky reporting_event_listener_spec (#6923) 2023-04-19 10:42:45 +05:30
Pranav Raj SandGitHub e6505fc7a4 chore: Cache the dashboard app on the first load (#6774) 2023-04-18 19:44:57 -07:00
Pranav Raj SandGitHub c5c36af529 fix: Remove fallback phone_number search in WhatsApp event processing (#6904) 2023-04-18 18:30:01 -07:00
026e03c307 fix: Handle spaces in CC/BCC email lists (#6788)
When the CC field is generated in the UI, the email values are joined together
with ", " but when they are parsed, we currently split by just ",".

This causes an error on the backend and on the frontend.

It seems reasonable to update the code to allow whitespace in the input and to
split by `\s*,\s` and also to trim leading and trailing whitespace from the CC
list.

---------

Co-authored-by: Sojan <sojan@pepalo.com>
2023-04-18 19:18:23 +05:30
Sojan 37b7098673 Merge branch 'release/2.16.0' into develop 2023-04-18 01:04:27 +05:30
Sojan 7bd400772d Merge branch 'release/2.16.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-04-18 01:04:19 +05:30
Sojan cb2f86b983 Bump version to 2.16.0 2023-04-18 01:02:19 +05:30
905e77048f chore: Contact import improvements (CW-1362) (#6747)
Fixes: https://linear.app/chatwoot/issue/CW-1362/csv-imports-are-not-working-properly
Fixes: #3462

---------

Co-authored-by: Sojan <sojan@pepalo.com>
2023-04-18 00:40:55 +05:30
Vishnu NarayananandGitHub 4505c5dda3 chore: add build id to settings page (#6873)
- Adds a build Id to the settings page
2023-04-18 00:35:35 +05:30
Sivin VargheseGitHubMuhsin Keloth <muhsinkeramam@gmail.com>, Nithin David
c9ce9e5b8f feat: Improved country code in contact form view. (#6801)
* feat: Improved country code in contact.

* chore: Minor fixes

* chore: Minor fixes

* chore: Adds arrow key navigation and cursor pointer

* chore: Minor fix

* chore: Code clean up

* chore: Handle outside click

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>, Nithin David
2023-04-17 20:32:09 +05:30
9e2f991484 feat: audit logs UI (#6803)
* feat: init auditlogs ui

* chore: add api

* fix: action

* chore: add action,username,time

* feat: add pagination support

* chore: format time

* chore: refactor

* chore: refactor auditlogs api response

* chore: update icon

* chore: rubocop fixes

* Fixes the way meta is handled in store

* Fixes meta not appearing issue

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
2023-04-17 19:11:05 +05:30
Sivin VargheseandGitHub 80dcd17f6e fix: Border color for CSAT component in widget (#6915) 2023-04-17 17:50:30 +05:30
Shivam MishraandGitHub a38ecf3dde feat: show webhook verify token for WhatsApp (#6916)
- Display WhatsApp webhook verify token in configuration settings
2023-04-17 17:14:08 +05:30
9090eabb8a chore: Update translations (#6895)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-04-17 14:45:49 +05:30
ca2506a941 feat: allow sorting of articles (#6833)
* feat: sort by position

* chore: whitespace change

* feat: add border bottom color to list item

* feat: allow dragging articles

* feat: add migration to reorder all articles

* feat: add onsort method

* feat: finish UI sorting

* feat: show 50 per page in articles list

* feat: add article sorting methods

* feat: patch up reorder action with the API

* refactor: better naming

* chore: add comments

* feat: attach position to article before create

* feat: move article to end if moved between categories

* chore: add comments

* chore: update version

* fix: don't change position if previous category was nil

* fix: condition to trigger update on category change

* refactor: store new_position

* refactor: use grid instead of table

* feat: add snug spacing

* feat: add grab-icon

* feat: add grab icon to list

* refactor: show draggable only for category page

* feat: add update_positions as a class method

---------

Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
2023-04-17 14:43:10 +05:30
Shivam MishraandGitHub 1886d4ce08 fix: response body in twitter callback (#6907)
* fix: response body

* fix: tests
2023-04-14 16:48:28 +05:30
Shivam MishraandGitHub 4d49b81f1c fix: use response.status instead of success (#6906)
* fix: use response.status instead of success

* refactor: use sentry to capture exception

* refactor: explicitly convert to i
2023-04-14 15:22:08 +05:30
Tejaswini ChileandGitHub 2b736f4698 fix: Update from_email in the name to fix the syntax error (#6900) 2023-04-13 10:42:38 -07:00
Tejaswini ChileandGitHub 9ca21df9fd feat: Route emails based on x-original-to in email channel (#6901)
Fixes: #6608
ref: https://linear.app/chatwoot/issue/CW-30/emails-not-routed-based-on-x-original-to
2023-04-13 14:52:12 +05:30
Tejaswini ChileandGitHub 44837aa657 Fix: save twitter profile for inbox (#6667)
Fixes: #737
2023-04-12 14:16:24 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Sojan Jose
d04344c094 chore(deps): bump commonmarker from 0.23.7 to 0.23.9 (#6892)
Bumps [commonmarker](https://github.com/gjtorikian/commonmarker) from 0.23.7 to 0.23.9.
- [Release notes](https://github.com/gjtorikian/commonmarker/releases)
- [Changelog](https://github.com/gjtorikian/commonmarker/blob/main/CHANGELOG.md)
- [Commits](https://github.com/gjtorikian/commonmarker/compare/v0.23.7...v0.23.9)

---
updated-dependencies:
- dependency-name: commonmarker
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-04-12 14:08:14 +05:30
d45512df72 feat: Account deletion with deleteObjectJob (#6885)
Fixes: https://linear.app/chatwoot/issue/CW-1365/allow-super-admin-to-delete-an-account

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-04-12 13:54:01 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2731c2a5be chore(deps): bump nokogiri from 1.14.2 to 1.14.3 (#6893)
Bumps [nokogiri](https://github.com/sparklemotion/nokogiri) from 1.14.2 to 1.14.3.
- [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.14.2...v1.14.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-12 13:29:57 +05:30
Tejaswini ChileandGitHub 610463c980 fix: flatten template array for whatsapp templates (#6880)
- Refactor the WhatsApp template sync job
- Fix the issue when fetching the next set of templates
2023-04-11 17:48:17 +05:30
09ce85b30d Chore: moves localstorage helper as a shared utility (#6838)
* Chore: moves localstorage helper as a shared utility and refactors constants

* Refactors constants file

* Fixes merge conflicts

* Delete constants.js

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-04-11 15:50:46 +05:30
Sojan 3e6d23e071 Merge branch 'release/2.15.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-03-15 19:58:19 +05:30
Sojan 0e3eb51d7a Merge branch 'release/2.14.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-02-16 13:42:21 +05:30
Vishnu Narayanan 4ffe73a1ce Merge branch 'hotfix/2.13.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-02-03 18:47:31 +05:30
1145 changed files with 18986 additions and 6235 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ defaults: &defaults
working_directory: ~/build
docker:
# specify the version you desire here
- image: cimg/ruby:3.1.3-browsers
- image: cimg/ruby:3.2.2-browsers
# Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images
+1 -1
View File
@@ -30,7 +30,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
npm
# Install rbenv and ruby
ARG RUBY_VERSION="3.1.3"
ARG RUBY_VERSION="3.2.2"
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc
-2
View File
@@ -1,8 +1,6 @@
.bundle
.env
.env.*
.git
.gitignore
docker-compose.*
docker/Dockerfile
docker/dockerfiles
-78
View File
@@ -1,78 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: 'Bug'
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See the error
**Expected behavior**
Share a clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Browser logs**
Share the browser logs to debug the issue further.
**Server logs**
Share the server logs to debug the issue further.
**Environment**
Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self-hosted installation of Chatwoot. If you are using a self-hosted installation of Chatwoot, describe the type of deployment (Docker/Linux VM installation/Heroku/Kubernetes/Other).
- [ ] app.chatwoot.com (Chatwoot Cloud)
- [ ] Self-hosted
- - [ ] Linux VM
- - [ ] Docker
- - [ ] Kubernetes
- - [ ] Heroku
- - [ ] Other (Please specify)
**Desktop (please complete the following information)** (If applicable)
- OS: [e.g. Linux, Windows, MacOS]
- Browser [e.g. chrome, firefox, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information)** (If applicable)
- Device: [e.g. iPhone6, Pixel7]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, firefox, safari]
- Version [e.g. 22]
**Docker** (If applicable)
Please share the output of the following.
- `docker version`
- `docker info`
- `docker-compose version`
**Cloud Provider** (If applicable)
- [ ] AWS
- [ ] GCP
- [ ] Azure
- [ ] DigitalOcean
- [ ] Others
**Additional context**
Add any other context about the problem here.
+78
View File
@@ -0,0 +1,78 @@
name: 🐞 Bug report
description: Create a report to help us improve
labels: 'Bug'
body:
- type: textarea
attributes:
label: Describe the bug
description: A concise description of what you expected to happen along with screenshots if applicable.
validations:
required: true
- type: textarea
attributes:
label: To Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. In this environment...
2. With this config...
3. Run '...'
4. See error...
validations:
required: true
- type: textarea
attributes:
label: Expected behavior
description: A concise description of what you expected to happen.
- type: dropdown
id: environment
attributes:
label: Environment
description: Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self-hosted installation of Chatwoot. If you are using a self-hosted installation of Chatwoot, describe the type of deployment (Docker/Linux VM installation/Heroku/Kubernetes/Other).
options:
- app.chatwoot.com
- Linux VM
- Docker
- Kubernetes
- Heroku
- Other [please specify in the description]
validations:
required: true
- type: dropdown
id: provider
attributes:
label: Cloud Provider
description:
options:
- AWS
- GCP
- Azure
- DigitalOcean
- Other [please specify in the description]
- type: dropdown
id: platform
attributes:
label: Platform
description: Describe the platform you are using
options:
- Browser
- Mobile
- type: input
attributes:
label: Operating system
description: The operating system and the version you are using.
- type: input
attributes:
label: Browser and version
description: The name of the browser and version you are using.
- type: textarea
attributes:
label: Docker (if applicable)
description: |
Please share the output of the following.
- `docker version`
- `docker info`
- `docker-compose version`
- type: textarea
attributes:
label: Additional context
description: Add any other context about the problem here.
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Report a security issue
url: https://www.chatwoot.com/docs/contributing-guide/security-reports/
about: Guidelines and steps to report a security vulnerability. Please report security vulnerabilities here.
- name: Product Documentation
url: https://www.chatwoot.com/help-center
about: If you have questions, are confused, or just want to understand our product better, please check out our documentation.
@@ -1,20 +0,0 @@
---
name: Enhancement request
about: Suggest any enhancements for this project
title: ''
labels: ''
assignees: ''
---
**Is your enhancement request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions you've considered.
**Additional context**
Add any other context or screenshots about the enhancement request here.
-20
View File
@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
@@ -0,0 +1,28 @@
name: 🧙 Feature request
description: Suggest an idea for this project
labels: 'feature-request'
body:
- type: textarea
attributes:
label: Is your feature or enhancement related to a problem? Please describe.
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
validations:
required: true
- type: textarea
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: false
- type: textarea
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
validations:
required: false
+11 -1
View File
@@ -61,6 +61,16 @@ test/cypress/videos/*
/config/master.key
/config/*.enc
.vscode/settings.json
#ignore files under .vscode directory
.vscode
# yalc for local testing
.yalc
yalc.lock
/public/packs
/public/packs-test
/node_modules
/yarn-error.log
yarn-debug.log*
.yarn-integrity
+37
View File
@@ -71,6 +71,10 @@ Rails/ApplicationController:
- 'app/controllers/platform_controller.rb'
- 'app/controllers/public_controller.rb'
- 'app/controllers/survey/responses_controller.rb'
Rails/FindEach:
Enabled: true
Include:
- 'app/**/*.rb'
Rails/CompactBlank:
Enabled: false
Rails/EnvironmentVariableAccess:
@@ -160,6 +164,39 @@ Performance/CollectionLiteralInLoop:
- 'db/migrate/20210315101919_enable_email_channel.rb'
RSpec/NamedSubject:
Enabled: false
Style/RedundantConstantBase:
Enabled: false
Rails/RootPathnameMethods:
Enabled: false
RSpec/Rails/MinitestAssertions:
Enabled: false
RSpec/Rails/InferredSpecType:
Enabled: false
RSpec/IndexedLet:
Enabled: false
RSpec/MatchArray:
Enabled: false
Rails/ResponseParsedBody:
Enabled: false
RSpec/FactoryBot/ConsistentParenthesesStyle:
Enabled: false
Rails/ThreeStateBooleanColumn:
Enabled: false
Rails/Pluck:
Enabled: false
Rails/TopLevelHashWithIndifferentAccess:
Enabled: false
Rails/ActionOrder:
Enabled: false
Style/ArrayIntersect:
Enabled: false
RSpec/NoExpectationExample:
Enabled: false
Style/RedundantReturn:
Enabled: false
Rails/I18nLocaleTexts:
Enabled: false
# we should bring this down
RSpec/MultipleMemoizedHelpers:
Max: 14
+1 -1
View File
@@ -1 +1 @@
3.1.3
3.2.2
+1
View File
@@ -0,0 +1 @@
/spec
+27 -24
View File
@@ -1,10 +1,10 @@
source 'https://rubygems.org'
ruby '3.1.3'
ruby '3.2.2'
##-- base gems for rails --##
gem 'rack-cors', require: 'rack/cors'
gem 'rails', '~> 6.1', '>= 6.1.7.3'
gem 'rails', '~> 7'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false
@@ -36,19 +36,19 @@ gem 'json_schemer'
# Rack middleware for blocking & throttling abusive requests
gem 'rack-attack'
# a utility tool for streaming, flexible and safe downloading of remote files
gem 'down', '~> 5.0'
gem 'down'
# authentication type to fetch and send mail over oauth2.0
gem 'gmail_xoauth'
# Prevent CSV injection
gem 'csv-safe'
# Support message translation
gem 'google-cloud-translate'
##-- for active storage --##
gem 'aws-sdk-s3', require: false
gem 'azure-storage-blob', require: false
# original gem isn't maintained actively
# we wanted updated version of faraday which is a dependency for slack-ruby-client
gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false
gem 'google-cloud-storage', require: false
gem 'image_processing', '~> 1.12.2'
gem 'image_processing'
##-- gems for database --#
gem 'groupdate'
@@ -62,13 +62,13 @@ gem 'activerecord-import'
gem 'dotenv-rails'
gem 'foreman'
gem 'puma'
gem 'webpacker', '~> 5.4', '>= 5.4.3'
gem 'webpacker'
# metrics on heroku
gem 'barnes'
##--- gems for authentication & authorization ---##
gem 'devise'
gem 'devise-secure_password', '~> 2.0', git: 'https://github.com/chatwoot/devise-secure_password'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth'
# authorization
gem 'jwt'
@@ -81,19 +81,23 @@ gem 'administrate'
gem 'wisper', '2.0.0'
##--- gems for channels ---##
# TODO: bump up gem to 2.0
gem 'facebook-messenger'
gem 'line-bot-api'
gem 'twilio-ruby', '~> 5.66'
# twitty will handle subscription of twitter account events
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
gem 'twitty'
gem 'twitty', '~> 0.1.5'
# facebook client
gem 'koala'
# slack client
gem 'slack-ruby-client'
gem 'slack-ruby-client', '~> 2.0.0'
# for dialogflow integrations
gem 'google-cloud-dialogflow'
gem 'google-cloud-dialogflow-v2'
gem 'grpc'
# Translate integrations
# 'google-cloud-translate' gem depends on faraday 2.0 version
# this dependency breaks the slack-ruby-client gem
gem 'google-cloud-translate-v3'
##-- apm and error monitoring ---#
# loaded only when environment variables are set.
@@ -108,9 +112,9 @@ gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', require: false
##-- background job processing --##
gem 'sidekiq', '~> 6.4.2'
gem 'sidekiq'
# We want cron jobs
gem 'sidekiq-cron', '~> 1.6', '>= 1.6.0'
gem 'sidekiq-cron'
##-- Push notification service --##
gem 'fcm'
@@ -129,7 +133,10 @@ gem 'procore-sift'
# parse email
gem 'email_reply_trimmer'
gem 'html2text'
# TODO: we might have to fork this gem since 0.3.1 has hard depency on nokogir 1.10.
# and this gem hasn't been updated for a while.
gem 'html2text', git: 'https://github.com/chatwoot/html2text_ruby', branch: 'chatwoot'
# to calculate working hours
gem 'working_hours'
@@ -144,18 +151,13 @@ gem 'stripe'
## to populate db with sample data
gem 'faker'
# Can remove this in rails 7
gem 'net-imap', require: false
gem 'net-pop', require: false
gem 'net-smtp', require: false
# Include logrange conditionally in intializer using env variable
gem 'lograge', '~> 0.12.0', require: false
# worked with microsoft refresh token
gem 'omniauth-oauth2'
gem 'audited', '~> 5.2'
gem 'audited', '~> 5.3'
# need for google auth
gem 'omniauth'
@@ -174,6 +176,7 @@ group :development do
gem 'annotate'
gem 'bullet'
gem 'letter_opener'
gem 'scss_lint', require: false
gem 'web-console'
# used in swagger build
@@ -189,7 +192,7 @@ end
group :test do
# Cypress in rails.
gem 'cypress-on-rails', '~> 1.13', '>= 1.13.1'
gem 'cypress-on-rails'
# fast cleaning of database
gem 'database_cleaner'
# mock http calls
@@ -211,7 +214,7 @@ group :development, :test do
gem 'mock_redis'
gem 'pry-rails'
gem 'rspec_junit_formatter'
gem 'rspec-rails', '~> 5.0.3'
gem 'rspec-rails'
gem 'rubocop', require: false
gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false
+347 -330
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
# Variables
APP_NAME := chatwoot
RAILS_ENV ?= development
# Targets
setup:
gem install bundler
bundle install
yarn install
db_create:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:create
db_migrate:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:migrate
db_seed:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:seed
db:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:chatwoot_prepare
console:
RAILS_ENV=$(RAILS_ENV) bundle exec rails console
server:
RAILS_ENV=$(RAILS_ENV) bundle exec rails server -b 0.0.0.0 -p 3000
burn:
bundle && yarn
run:
overmind start -f Procfile.dev
docker:
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db console server burn docker run
+1 -1
View File
@@ -1,3 +1,3 @@
release: POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rails db:chatwoot_prepare
release: POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rails db:chatwoot_prepare && echo $SOURCE_VERSION > .git_sha
web: bundle exec rails ip_lookup:setup && bin/rails server -p $PORT -e $RAILS_ENV
worker: bundle exec rails ip_lookup:setup && bundle exec sidekiq -C config/sidekiq.yml
@@ -70,17 +70,28 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
@messaging[:message][:text]
end
def story_reply_attributes
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def build_message
return if @outgoing_echo && already_sent_from_chatwoot?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end
end
def save_story_id
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
+2 -2
View File
@@ -49,10 +49,10 @@ class Messages::MessageBuilder
return unless @conversation.inbox&.inbox_type == 'Email'
cc_emails = []
cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails].present?
cc_emails = @params[:cc_emails].gsub(/\s+/, '').split(',') if @params[:cc_emails].present?
bcc_emails = []
bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails].present?
bcc_emails = @params[:bcc_emails].gsub(/\s+/, '').split(',') if @params[:bcc_emails].present?
all_email_addresses = cc_emails + bcc_emails
validate_email_addresses(all_email_addresses)
@@ -1,14 +1,19 @@
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :fetch_article, except: [:index, :create, :attach_file]
before_action :fetch_article, except: [:index, :create, :attach_file, :reorder]
before_action :set_current_page, only: [:index]
def index
@portal_articles = @portal.articles
@all_articles = @portal_articles.search(list_params)
@articles_count = @all_articles.count
@articles = @all_articles.order_by_updated_at.page(@current_page)
@articles = if list_params[:category_slug].present?
@all_articles.order_by_position.page(@current_page).per(50)
else
@all_articles.order_by_updated_at.page(@current_page)
end
end
def create
@@ -40,7 +45,12 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
content_type: params[:background_image].content_type
)
file_blob.save!
render json: { file_url: url_for(file_blob) }
render json: { file_url: rails_blob_url(file_blob) }
end
def reorder
Article.update_positions(params[:positions_hash])
head :ok
end
private
@@ -64,13 +64,14 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
assign_conversation if @conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
end
def toggle_priority
@conversation.toggle_priority(params[:priority])
head :ok
end
def toggle_typing_status
case params[:typing_status]
when 'on'
trigger_typing_event(CONVERSATION_TYPING_ON, params[:is_private])
when 'off'
trigger_typing_event(CONVERSATION_TYPING_OFF, params[:is_private])
end
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params)
typing_status_manager.toggle_typing_status
head :ok
end
@@ -111,11 +112,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.update_assignee(@agent)
end
def trigger_typing_event(event, is_private)
user = current_user.presence || @resource
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user, is_private: is_private)
end
def conversation
@conversation ||= Current.account.conversations.find_by!(display_id: params[:id])
authorize @conversation.inbox, :show?
@@ -114,10 +114,13 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
:lock_to_single_conversation]
:lock_to_single_conversation, :portal_id]
end
def permitted_params(channel_attributes = [])
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
params.permit(
*inbox_attributes,
channel: [:type, *channel_attributes]
@@ -1,5 +1,5 @@
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
before_action :fetch_hook, only: [:update, :destroy]
before_action :fetch_hook, except: [:create]
before_action :check_authorization
def create
@@ -10,6 +10,10 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
@hook.update!(permitted_params.slice(:status, :settings))
end
def process_event
render json: { message: @hook.process_event(params[:event]) }
end
def destroy
@hook.destroy!
head :ok
@@ -30,7 +30,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
ActiveRecord::Base.transaction do
@portal.update!(portal_params) if params[:portal].present?
# @portal.custom_domain = parsed_custom_domain
process_attached_logo
process_attached_logo if params[:blob_id].present?
rescue StandardError => e
Rails.logger.error e
render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity
@@ -72,14 +72,16 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
def current_summary_params
common_params.merge({
since: range[:current][:since],
until: range[:current][:until]
until: range[:current][:until],
timezone_offset: params[:timezone_offset]
})
end
def previous_summary_params
common_params.merge({
since: range[:previous][:since],
until: range[:previous][:until]
until: range[:previous][:until],
timezone_offset: params[:timezone_offset]
})
end
+1 -1
View File
@@ -3,7 +3,7 @@ class ApiController < ApplicationController
def index
render json: { version: Chatwoot.config[:version],
timestamp: Time.now.utc.to_formatted_s(:db),
timestamp: Time.now.utc.to_fs(:db),
queue_services: redis_status,
data_services: postgres_status }
end
+2 -1
View File
@@ -56,7 +56,8 @@ class DashboardController < ActionController::Base
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
FACEBOOK_API_VERSION: 'v14.0',
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', '')
AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH
}
end
end
@@ -1,7 +1,7 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_category, except: [:index]
before_action :set_category, except: [:index, :show]
before_action :set_article, only: [:show]
layout 'portal'
@@ -16,7 +16,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
private
def set_article
@article = @category.articles.find(permitted_params[:id])
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@article.increment_view_count
@parsed_content = render_article_content(@article.content)
end
@@ -39,7 +39,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end
def permitted_params
params.permit(:slug, :category_slug, :locale, :id)
params.permit(:slug, :category_slug, :locale, :id, :article_slug)
end
def render_article_content(content)
@@ -5,6 +5,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
def set_locale(&)
switch_locale_with_portal(&) if params[:locale].present?
switch_locale_with_article(&) if params[:article_slug].present?
end
def switch_locale_with_portal(&)
@@ -19,4 +20,16 @@ class Public::Api::V1::Portals::BaseController < PublicController
I18n.with_locale(@locale, &)
end
def switch_locale_with_article(&)
article = Article.find_by(slug: params[:article_slug])
@locale = if article.category.present?
article.category.locale
else
'en'
end
I18n.with_locale(@locale, &)
end
end
@@ -47,4 +47,11 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
Internal::SeedAccountJob.perform_later(requested_resource)
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account seeding triggered')
end
def destroy
account = Account.find(params[:id])
DeleteObjectJob.perform_later(account) if account.present?
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account deletion is in progress.')
end
end
@@ -14,7 +14,7 @@ class Twitter::CallbacksController < Twitter::BaseController
redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
end
rescue StandardError => e
Rails.logger.error e
ChatwootExceptionTracker.new(e).capture_exception
redirect_to twitter_app_redirect_url
end
@@ -49,10 +49,22 @@ class Twitter::CallbacksController < Twitter::BaseController
twitter_access_token_secret: parsed_body['oauth_token_secret'],
profile_id: parsed_body['user_id']
)
account.inboxes.create!(
inbox = account.inboxes.create!(
name: parsed_body['screen_name'],
channel: twitter_profile
)
save_profile_image(inbox)
inbox
end
def save_profile_image(inbox)
response = twitter_client.user_show(screen_name: inbox.name)
return unless response.status.to_i == 200
parsed_user_profile = response.body
::Avatar::AvatarFromUrlJob.perform_later(inbox, parsed_user_profile['profile_image_url_https'])
end
def permitted_params
+1 -1
View File
@@ -41,7 +41,7 @@ class WidgetsController < ActionController::Base
source_id: @auth_token_params[:source_id]
)
@contact = @contact_inbox ? @contact_inbox.contact : nil
@contact = @contact_inbox&.contact
end
def build_contact
+3
View File
@@ -1,2 +1,5 @@
class AccountDrop < BaseDrop
def name
@obj.try(:name)
end
end
+2 -1
View File
@@ -5,7 +5,8 @@ class ConversationFinder
SORT_OPTIONS = {
latest: 'latest',
sort_on_created_at: 'sort_on_created_at',
last_user_message_at: 'last_user_message_at'
last_user_message_at: 'last_user_message_at',
sort_on_priority: 'sort_on_priority'
}.with_indifferent_access
# assumptions
+7 -1
View File
@@ -7,12 +7,18 @@ class EmailChannelFinder
def perform
channel = nil
recipient_mails = @email_object.to.to_a + @email_object.cc.to_a
recipient_mails.each do |email|
normalized_email = normalize_email_with_plus_addressing(email)
channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email)
break if channel.present?
end
channel
end
def recipient_mails
recipient_addresses = @email_object.to.to_a + @email_object.cc.to_a + @email_object.bcc.to_a + [@email_object['X-Original-To'].try(:value)]
recipient_addresses.flatten.compact
end
end
+1 -1
View File
@@ -7,7 +7,7 @@ module DateRangeHelper
def range
return if params[:since].blank? || params[:until].blank?
parse_date_time(params[:since])..parse_date_time(params[:until])
parse_date_time(params[:since])...parse_date_time(params[:until])
end
def parse_date_time(datetime)
+22
View File
@@ -10,4 +10,26 @@ module EmailHelper
def normalize_email_with_plus_addressing(email)
"#{email.split('@').first.split('+').first}@#{email.split('@').last}".downcase
end
def parse_email_variables(conversation, email)
case email
when modified_liquid_content(email)
template = Liquid::Template.parse(modified_liquid_content(email))
template.render(message_drops(conversation))
when URI::MailTo::EMAIL_REGEXP
email
end
end
def modified_liquid_content(email)
# This regex is used to match the code blocks in the content
# We don't want to process liquid in code blocks
email.gsub(/`(.*?)`/m, '{% raw %}`\\1`{% endraw %}')
end
def message_drops(conversation)
{
'contact' => ContactDrop.new(conversation.contact)
}
end
end
+16
View File
@@ -0,0 +1,16 @@
/* global axios */
import ApiClient from './ApiClient';
class AuditLogs extends ApiClient {
constructor() {
super('audit_logs', { accountScoped: true });
}
get({ page }) {
const url = page ? `${this.url}?page=${page}` : this.url;
return axios.get(url);
}
}
export default new AuditLogs();
@@ -60,6 +60,13 @@ class ArticlesAPI extends PortalsAPI {
}
);
}
reorderArticles({ portalSlug, reorderedGroup, categorySlug }) {
return axios.post(`${this.url}/${portalSlug}/articles/reorder`, {
positions_hash: reorderedGroup,
category_slug: categorySlug,
});
}
}
export default new ArticlesAPI();
@@ -14,6 +14,7 @@ class ConversationApi extends ApiClient {
labels,
teamId,
conversationType,
sortBy,
}) {
return axios.get(this.url, {
params: {
@@ -24,6 +25,7 @@ class ConversationApi extends ApiClient {
page,
labels,
conversation_type: conversationType,
sort_by: sortBy,
},
});
}
@@ -52,6 +54,12 @@ class ConversationApi extends ApiClient {
});
}
togglePriority({ conversationId, priority }) {
return axios.post(`${this.url}/${conversationId}/toggle_priority`, {
priority,
});
}
assignAgent({ conversationId, agentId }) {
return axios.post(
`${this.url}/${conversationId}/assignments?assignee_id=${agentId}`,
@@ -0,0 +1,31 @@
/* global axios */
import ApiClient from '../ApiClient';
class OpenAIAPI extends ApiClient {
constructor() {
super('integrations', { accountScoped: true });
}
processEvent({ type = 'rephrase', content, tone, conversationId, hookId }) {
let data = {
tone,
content,
};
if (type === 'reply_suggestion' || type === 'summarize') {
data = {
conversation_display_id: conversationId,
};
}
return axios.post(`${this.url}/hooks/${hookId}/process_event`, {
event: {
name: type,
data,
},
});
}
}
export default new OpenAIAPI();
+1
View File
@@ -40,6 +40,7 @@ class ReportsAPI extends ApiClient {
id,
group_by: groupBy,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
},
});
}
+24
View File
@@ -13,6 +13,30 @@ class SearchAPI extends ApiClient {
},
});
}
contacts({ q }) {
return axios.get(`${this.url}/contacts`, {
params: {
q,
},
});
}
conversations({ q }) {
return axios.get(`${this.url}/conversations`, {
params: {
q,
},
});
}
messages({ q }) {
return axios.get(`${this.url}/messages`, {
params: {
q,
},
});
}
}
export default new SearchAPI();
@@ -42,9 +42,13 @@ describe('#Reports API', () => {
'/api/v2/reports/summary',
{
params: {
business_hours: undefined,
group_by: undefined,
id: undefined,
since: 1621103400,
until: 1621621800,
timezone_offset: -0,
type: 'account',
until: 1621621800,
},
}
);
+1 -1
View File
@@ -1,5 +1,5 @@
/* global axios */
import wootConstants from 'dashboard/constants';
import wootConstants from 'dashboard/constants/globals';
export const getTestimonialContent = () => {
return axios.get(wootConstants.TESTIMONIAL_URL);
@@ -254,7 +254,7 @@ $button-sizes: (tiny: var(--font-size-micro),
default: var(--font-size-small),
large: var(--font-size-medium));
$button-palette: $foundation-palette;
$button-opacity-disabled: 0.25;
$button-opacity-disabled: 0.4;
$button-background-hover-lightness: -20%;
$button-hollow-hover-lightness: -50%;
$button-transition: background-color 0.25s ease-out,
@@ -85,7 +85,7 @@
}
}
.settings.back-button {
.header-section.back-button {
direction: initial;
margin-left: var(--space-normal);
margin-right: var(--space-smaller);
@@ -21,7 +21,7 @@
.multiselect--active {
>.multiselect__tags {
border-color: $color-woot;
border-color: var(--w-500);
}
}
@@ -75,16 +75,13 @@
}
&.multiselect__option--selected {
background: var(--w-400);
color: var(--white);
background: var(--w-75);
&.multiselect__option--highlight:hover {
background: var(--w-600);
color: var(--white);
background: var(--w-75);
&::after {
background: transparent;
color: var(--white);
}
&::after:hover {
@@ -196,6 +193,7 @@
display: flex;
font-size: var(--font-size-small);
margin: 0;
max-height: 3.8rem;
padding: var(--space-smaller) var(--space-micro);
}
@@ -95,10 +95,6 @@
align-items: center;
justify-content: space-between;
padding: 0 var(--space-normal);
.page-title {
margin-bottom: 0;
}
}
.content-box {
+103 -20
View File
@@ -11,15 +11,23 @@
class="chat-list__top"
:class="{ filter__applied: hasAppliedFiltersOrActiveFolders }"
>
<h1 class="page-title text-truncate" :title="pageTitle">
{{ pageTitle }}
</h1>
<div class="filter--actions">
<chat-filter
<div class="flex-center chat-list__title">
<h1
class="page-sub-title text-truncate margin-bottom-0"
:title="pageTitle"
>
{{ pageTitle }}
</h1>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
@statusFilterChange="updateStatusType"
/>
class="conversation--status-pill"
>
{{
this.$t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`)
}}
</span>
</div>
<div class="filter--actions">
<div v-if="hasAppliedFilters && !hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
@@ -48,7 +56,6 @@
@click="onClickOpenDeleteFoldersModal"
/>
</div>
<woot-button
v-else
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
@@ -58,6 +65,10 @@
size="tiny"
@click="onToggleAdvanceFiltersModal"
/>
<conversation-basic-filter
v-if="!hasAppliedFiltersOrActiveFolders"
@changeFilter="onBasicFilterChange"
/>
</div>
</div>
@@ -125,6 +136,7 @@
@update-conversation-status="toggleConversationStatus"
@context-menu-toggle="onContextMenuToggle"
@mark-as-unread="markAsUnread"
@assign-priority="assignPriority"
/>
<div v-if="chatListLoading" class="text-center">
@@ -166,14 +178,14 @@
<script>
import { mapGetters } from 'vuex';
import ChatFilter from './widgets/conversation/ChatFilter';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter';
import ChatTypeTabs from './widgets/ChatTypeTabs';
import ConversationCard from './widgets/conversation/ConversationCard';
import timeMixin from '../mixins/time';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import conversationMixin from '../mixins/conversations';
import wootConstants from '../constants';
import wootConstants from 'dashboard/constants/globals';
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews';
@@ -191,16 +203,17 @@ import {
isOnMentionsView,
isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers';
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
export default {
components: {
AddCustomViews,
ChatTypeTabs,
ConversationCard,
ChatFilter,
ConversationAdvancedFilter,
DeleteCustomViews,
ConversationBulkActions,
ConversationBasicFilter,
},
mixins: [
timeMixin,
@@ -243,11 +256,15 @@ export default {
return {
activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME,
activeStatus: wootConstants.STATUS_TYPE.OPEN,
activeSortBy: wootConstants.SORT_BY_TYPE.LATEST,
showAdvancedFilters: false,
advancedFilterTypes: advancedFilterTypes.map(filter => ({
...filter,
attributeName: this.$t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
})),
// chatsOnView is to store the chats that are currently visible on the screen,
// which mirrors the conversationList.
chatsOnView: [],
foldersQuery: {},
showAddFoldersModal: false,
showDeleteFoldersModal: false,
@@ -347,18 +364,40 @@ export default {
this.currentPageFilterKey
);
},
activeAssigneeTabCount() {
const { activeAssigneeTab } = this;
const count = this.assigneeTabItems.find(
item => item.key === activeAssigneeTab
).count;
return count;
},
conversationFilters() {
return {
inboxId: this.conversationInbox ? this.conversationInbox : undefined,
assigneeType: this.activeAssigneeTab,
status: this.activeStatus,
page: this.currentPage + 1,
sortBy: this.activeSortBy,
page: this.conversationListPagination,
labels: this.label ? [this.label] : undefined,
teamId: this.teamId || undefined,
conversationType: this.conversationType || undefined,
folders: this.hasActiveFolders ? this.savedFoldersValue : undefined,
};
},
conversationListPagination() {
const conversationsPerPage = 25;
const isNoFiltersOrFoldersAndChatListNotEmpty =
!this.hasAppliedFiltersOrActiveFolders && this.chatsOnView !== [];
const isUnderPerPage =
this.chatsOnView.length < conversationsPerPage &&
this.activeAssigneeTabCount < conversationsPerPage &&
this.activeAssigneeTabCount > this.chatsOnView.length;
if (isNoFiltersOrFoldersAndChatListNotEmpty && isUnderPerPage) {
return 1;
}
return this.currentPage + 1;
},
pageTitle() {
if (this.hasAppliedFilters) {
return this.$t('CHAT_LIST.TAB_HEADING');
@@ -400,7 +439,6 @@ export default {
} else {
conversationList = [...this.chatLists];
}
return conversationList;
},
activeFolder() {
@@ -449,9 +487,13 @@ export default {
this.resetAndFetchData();
}
},
chatLists() {
this.chatsOnView = this.conversationList;
},
},
mounted() {
this.$store.dispatch('setChatFilter', this.activeStatus);
this.$store.dispatch('setChatStatusFilter', this.activeStatus);
this.$store.dispatch('setChatSortFilter', this.activeSortBy);
this.resetAndFetchData();
bus.$on('fetch_conversation_stats', () => {
@@ -597,11 +639,13 @@ export default {
this.selectedConversations = [];
this.selectedInboxes = [];
},
updateStatusType(index) {
if (this.activeStatus !== index) {
this.activeStatus = index;
this.resetAndFetchData();
onBasicFilterChange(value, type) {
if (type === 'status') {
this.activeStatus = value;
} else {
this.activeSortBy = value;
}
this.resetAndFetchData();
},
openLastSavedItemInFolder() {
const lastItemOfFolder = this.folders[this.folders.length - 1];
@@ -670,6 +714,26 @@ export default {
this.showAlert(this.$t('BULK_ACTION.ASSIGN_FAILED'));
}
},
async assignPriority(priority, conversationId = null) {
this.$store.dispatch('setCurrentChatPriority', {
priority,
conversationId,
});
this.$store
.dispatch('assignPriority', { conversationId, priority })
.then(() => {
this.$track(CONVERSATION_EVENTS.CHANGE_PRIORITY, {
newValue: priority,
from: 'Context menu',
});
this.showAlert(
this.$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SUCCESSFUL', {
priority,
conversationId,
})
);
});
},
async markAsUnread(conversationId) {
try {
await this.$store.dispatch('markMessagesUnread', {
@@ -830,11 +894,15 @@ export default {
&.list--full-width {
flex-basis: 100%;
}
.page-sub-title {
font-size: var(--font-size-two);
}
}
.filter--actions {
display: flex;
align-items: center;
gap: var(--space-micro);
gap: var(--space-smaller);
}
.filter__applied {
@@ -851,4 +919,19 @@ export default {
}
}
}
.conversation--status-pill {
background: var(--color-background);
border-radius: var(--border-radius-small);
color: var(--color-medium-gray);
font-size: var(--font-size-micro);
font-weight: var(--font-weight-medium);
margin: var(--space-micro) var(--space-small) 0;
padding: var(--space-smaller);
text-transform: capitalize;
}
.chat-list__title {
max-width: 85%;
}
</style>
@@ -5,7 +5,9 @@
{{ title }}
</p>
<p class="sub-head">
{{ subTitle }}
<slot name="subTitle">
{{ subTitle }}
</slot>
</p>
<p v-if="note">
<span class="note">{{ $t('INBOX_MGMT.NOTE') }}</span>
@@ -12,7 +12,8 @@
</template>
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../helper/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { mapGetters } from 'vuex';
import adminMixin from 'dashboard/mixins/isAdmin';
import { hasAnUpdateAvailable } from './versionCheckHelper';
@@ -126,7 +126,7 @@ import WootDropdownSubMenu from 'shared/components/ui/dropdown/DropdownSubMenu.v
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
import wootConstants from '../../constants';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION,
@@ -13,6 +13,7 @@ import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import FeatureToggle from './widgets/FeatureToggle';
import HorizontalBar from './widgets/chart/HorizontalBarChart';
import Input from './widgets/forms/Input.vue';
import PhoneInput from './widgets/forms/PhoneInput.vue';
import Label from './ui/Label';
import LoadingState from './widgets/LoadingState';
import Modal from './Modal';
@@ -40,6 +41,7 @@ const WootUIKit = {
FeatureToggle,
HorizontalBar,
Input,
PhoneInput,
Label,
LoadingState,
Modal,
@@ -52,7 +52,7 @@ import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader';
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
import AvailabilityStatusBadge from '../widgets/conversation/AvailabilityStatusBadge';
import wootConstants from 'dashboard/constants';
import wootConstants from 'dashboard/constants/globals';
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
@@ -8,6 +8,7 @@ const settings = accountId => ({
'agent_list',
'attributes_list',
'automation_list',
'auditlogs_list',
'billing_settings_index',
'canned_list',
'general_settings_index',
@@ -150,6 +151,15 @@ const settings = accountId => ({
toStateName: 'billing_settings_index',
showOnlyOnCloud: true,
},
{
icon: 'key',
label: 'AUDIT_LOGS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/audit-log/list`),
toStateName: 'auditlogs_list',
featureFlag: FEATURE_FLAGS.AUDIT_LOGS,
beta: true,
},
],
});
@@ -41,7 +41,7 @@ import PrimaryNavItem from './PrimaryNavItem';
import OptionsMenu from './OptionsMenu';
import AgentDetails from './AgentDetails';
import NotificationBell from './NotificationBell';
import wootConstants from 'dashboard/constants';
import wootConstants from 'dashboard/constants/globals';
import { frontendURL } from 'dashboard/helper/URLHelper';
export default {
@@ -6,7 +6,11 @@
:disabled="isDisabled || isLoading"
@click="handleClick"
>
<spinner v-if="isLoading" size="small" />
<spinner
v-if="isLoading"
size="small"
:color-scheme="showDarkSpinner ? 'dark' : ''"
/>
<emoji-or-icon
v-else-if="icon || emoji"
class="icon"
@@ -108,6 +112,14 @@ export default {
return 16;
}
},
showDarkSpinner() {
return (
this.colorScheme === 'secondary' ||
this.variant === 'clear' ||
this.variant === 'link' ||
this.variant === 'hollow'
);
},
},
methods: {
handleClick(evt) {
@@ -0,0 +1,205 @@
<template>
<div v-if="isAIIntegrationEnabled" class="position-relative">
<div v-if="!message">
<woot-button
v-if="isPrivateNote"
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.SUMMARY_TITLE')"
icon="book-pulse"
color-scheme="secondary"
variant="smooth"
size="small"
:is-loading="uiFlags.summarize"
@click="processEvent('summarize')"
/>
<woot-button
v-else
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_TITLE')"
icon="wand"
color-scheme="secondary"
variant="smooth"
size="small"
:is-loading="uiFlags.reply_suggestion"
@click="processEvent('reply_suggestion')"
/>
</div>
<div v-else>
<woot-button
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.TITLE')"
icon="text-grammar-wand"
color-scheme="secondary"
variant="smooth"
size="small"
@click="toggleDropdown"
/>
<div
v-if="showDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open ai-modal"
>
<h4 class="sub-block-title margin-top-1">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TITLE') }}
</h4>
<p>
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.SUBTITLE') }}
</p>
<label>
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TONE.TITLE') }}
</label>
<div class="tone__item">
<select v-model="activeTone" class="status--filter small">
<option v-for="tone in tones" :key="tone.key" :value="tone.key">
{{ tone.value }}
</option>
</select>
</div>
<div class="modal-footer flex-container align-right">
<woot-button variant="clear" size="small" @click="closeDropdown">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.CANCEL') }}
</woot-button>
<woot-button
:is-loading="uiFlags.rephrase"
size="small"
@click="processEvent('rephrase')"
>
{{ buttonText }}
</woot-button>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import OpenAPI from 'dashboard/api/integrations/openapi';
import alertMixin from 'shared/mixins/alertMixin';
export default {
mixins: [alertMixin, clickaway],
props: {
conversationId: {
type: Number,
default: 0,
},
message: {
type: String,
default: '',
},
isPrivateNote: {
type: Boolean,
default: false,
},
},
data() {
return {
uiFlags: {
rephrase: false,
reply_suggestion: false,
summarize: false,
},
showDropdown: false,
activeTone: 'professional',
tones: [
{
key: 'professional',
value: this.$t(
'INTEGRATION_SETTINGS.OPEN_AI.TONE.OPTIONS.PROFESSIONAL'
),
},
{
key: 'friendly',
value: this.$t('INTEGRATION_SETTINGS.OPEN_AI.TONE.OPTIONS.FRIENDLY'),
},
],
};
},
computed: {
...mapGetters({ appIntegrations: 'integrations/getAppIntegrations' }),
isAIIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
);
},
hookId() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
).hooks[0].id;
},
buttonText() {
return this.uiFlags.isRephrasing
? this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATING')
: this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATE');
},
},
mounted() {
if (!this.appIntegrations.length) {
this.$store.dispatch('integrations/get');
}
},
methods: {
toggleDropdown() {
this.showDropdown = !this.showDropdown;
},
closeDropdown() {
this.showDropdown = false;
},
async processEvent(type = 'rephrase') {
this.uiFlags[type] = true;
try {
const result = await OpenAPI.processEvent({
hookId: this.hookId,
type,
content: this.message,
tone: this.activeTone,
conversationId: this.conversationId,
});
const {
data: { message: generatedMessage },
} = result;
this.$emit('replace-text', generatedMessage || this.message);
this.closeDropdown();
} catch (error) {
this.showAlert(this.$t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR'));
} finally {
this.uiFlags[type] = false;
}
},
},
};
</script>
<style lang="scss" scoped>
.ai-modal {
width: 400px;
right: 0;
left: 0;
padding: var(--space-normal);
bottom: 34px;
position: absolute;
span {
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
}
p {
color: var(--s-600);
}
label {
margin-bottom: var(--space-smaller);
}
.status--filter {
background-color: var(--color-background-light);
border: 1px solid var(--color-border);
font-size: var(--font-size-small);
height: var(--space-large);
padding: 0 var(--space-medium) 0 var(--space-small);
}
.modal-footer {
gap: var(--space-smaller);
}
}
</style>
@@ -1,5 +1,5 @@
<template>
<button class="settings back-button" @click.capture="goBack">
<button class="header-section back-button" @click.capture="goBack">
<fluent-icon icon="chevron-left" />
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
</button>
@@ -9,7 +9,7 @@
</woot-tabs>
</template>
<script>
import wootConstants from '../../constants';
import wootConstants from 'dashboard/constants/globals';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { hasPressedAltAndNKey } from 'shared/helpers/KeyboardHelpers';
@@ -1,5 +1,5 @@
<template>
<div class="dashboard-app--container">
<div v-if="hasOpenedAtleastOnce" class="dashboard-app--container">
<div
v-for="(configItem, index) in config"
:key="index"
@@ -35,9 +35,14 @@ export default {
type: Object,
default: () => ({}),
},
isVisible: {
type: Boolean,
default: false,
},
},
data() {
return {
hasOpenedAtleastOnce: false,
iframeLoading: true,
};
},
@@ -57,6 +62,13 @@ export default {
return { id, name, email };
},
},
watch: {
isVisible() {
if (this.isVisible) {
this.hasOpenedAtleastOnce = true;
}
},
},
mounted() {
window.onmessage = e => {
@@ -0,0 +1,37 @@
import InboxDropdownItem from './InboxDropdownItem';
export default {
title: 'Components/DropDowns/InboxDropdownItem',
component: InboxDropdownItem,
argTypes: {
name: {
defaultValue: 'My new inbox',
control: {
type: 'text',
},
},
inboxIdentifier: {
defaultValue: 'nithin@mail.com',
control: {
type: 'text',
},
},
channelType: {
defaultValue: 'email',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { InboxDropdownItem },
template: '<inbox-dropdown-item v-bind="$props" ></inbox-dropdown-item>',
});
export const Banner = Template.bind({});
Banner.args = {};
@@ -0,0 +1,102 @@
<template>
<div class="option-item--inbox">
<span class="badge--icon">
<fluent-icon :icon="computedInboxIcon" size="14" />
</span>
<div class="option__user-data">
<h5 class="option__title">
{{ name }}
</h5>
<p class="option__body text-truncate" :title="inboxIdentifier">
{{ inboxIdentifier || computedInboxType }}
</p>
</div>
</div>
</template>
<script>
import {
getInboxClassByType,
getReadableInboxByType,
} from 'dashboard/helper/inbox';
export default {
components: {},
props: {
name: {
type: String,
default: '',
},
inboxIdentifier: {
type: String,
default: '',
},
channelType: {
type: String,
default: '',
},
},
computed: {
computedInboxIcon() {
if (!this.channelType) return 'chat';
const classByType = getInboxClassByType(
this.channelType,
this.inboxIdentifier
);
return classByType;
},
computedInboxType() {
if (!this.channelType) return 'chat';
const classByType = getReadableInboxByType(
this.channelType,
this.inboxIdentifier
);
return classByType;
},
},
};
</script>
<style lang="scss" scoped>
.option-item--inbox {
display: flex;
align-items: center;
height: 3.8rem;
min-width: 0;
padding: 0 var(--space-smaller);
}
.badge--icon {
display: inline-flex;
border-radius: var(--border-radius-small);
margin-right: var(--space-smaller);
background: var(--s-25);
padding: var(--space-micro);
align-items: center;
flex-shrink: 0;
justify-content: center;
width: var(--space-medium);
height: var(--space-medium);
}
.option__user-data {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
margin-left: var(--space-smaller);
margin-right: var(--space-smaller);
}
.option__body {
display: inline-block;
color: var(--s-600);
font-size: var(--font-size-small);
line-height: 1.3;
min-width: 0;
margin: 0;
}
.option__title {
line-height: 1.1;
font-size: var(--font-size-mini);
margin: 0;
}
</style>
@@ -13,15 +13,19 @@ import videojs from 'video.js';
import alertMixin from '../../../../shared/mixins/alertMixin';
import Recorder from 'opus-recorder';
// Workers to record Audio .ogg and .wav
import encoderWorker from 'opus-recorder/dist/encoderWorker.min';
import waveWorker from 'opus-recorder/dist/waveWorker.min';
import WaveSurfer from 'wavesurfer.js';
import MicrophonePlugin from 'wavesurfer.js/dist/plugin/wavesurfer.microphone.js';
import 'videojs-wavesurfer/dist/videojs.wavesurfer.js';
import 'videojs-wavesurfer/dist/videojs.wavesurfer.js';
import 'videojs-record/dist/videojs.record.js';
import 'videojs-record/dist/plugins/videojs.record.opus-recorder.js';
import OpusRecorderEngine from 'videojs-record/dist/plugins/videojs.record.opus-recorder.js';
import { format, addSeconds } from 'date-fns';
import { AUDIO_FORMATS } from 'shared/constants/messages';
@@ -33,7 +37,7 @@ export default {
props: {
audioRecordFormat: {
type: String,
default: AUDIO_FORMATS.WEBM,
default: AUDIO_FORMATS.WAV,
},
},
data() {
@@ -79,24 +83,18 @@ export default {
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,
displayMilliseconds: false,
audioChannels: 1,
audioSampleRate: 48000,
audioBitRate: 128,
audioEngine: 'opus-recorder',
...(this.audioRecordFormat === AUDIO_FORMATS.WAV && {
audioMimeType: 'audio/wav',
audioWorkerURL: waveWorker,
}),
...(this.audioRecordFormat === AUDIO_FORMATS.OGG && {
displayMilliseconds: false,
audioEngine: 'opus-recorder',
audioMimeType: 'audio/ogg',
audioWorkerURL: encoderWorker,
audioChannels: 1,
audioSampleRate: 48000,
audioBitRate: 128,
}),
},
},
@@ -134,6 +132,11 @@ export default {
},
methods: {
deviceReady() {
if (this.player.record().engine instanceof OpusRecorderEngine) {
if (this.audioRecordFormat === AUDIO_FORMATS.WAV) {
this.player.record().engine.audioType = 'audio/wav';
}
}
this.player.record().start();
},
startRecord() {
@@ -49,7 +49,7 @@ import {
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import { replaceVariablesInMessage } from 'dashboard/helper/messageHelper';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
const createState = (content, placeholder, plugins = []) => {
@@ -38,12 +38,12 @@
</file-upload>
<woot-button
v-if="showAudioRecorderButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
:icon="!isRecordingAudio ? 'microphone' : 'microphone-off'"
emoji="🎤"
:color-scheme="!isRecordingAudio ? 'secondary' : 'alert'"
variant="smooth"
size="small"
:title="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
@click="toggleAudioRecorder"
/>
<woot-button
@@ -91,6 +91,12 @@
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
:conversation-id="conversationId"
/>
<AIAssistanceButton
:conversation-id="conversationId"
:is-private-note="isOnPrivateNote"
:message="message"
@replace-text="replaceText"
/>
<transition name="modal-fade">
<div
v-show="$refs.upload && $refs.upload.dropActive"
@@ -129,12 +135,13 @@ import {
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
} from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton';
import AIAssistanceButton from '../AIAssistanceButton.vue';
import { REPLY_EDITOR_MODES } from './constants';
import { mapGetters } from 'vuex';
export default {
name: 'ReplyBottomPanel',
components: { FileUpload, VideoCallButton },
components: { FileUpload, VideoCallButton, AIAssistanceButton },
mixins: [eventListenerMixins, uiSettingsMixin, inboxMixin],
props: {
mode: {
@@ -217,6 +224,10 @@ export default {
type: Number,
required: true,
},
message: {
type: String,
default: '',
},
},
computed: {
...mapGetters({
@@ -303,6 +314,9 @@ export default {
send_with_signature: !this.sendWithSignature,
});
},
replaceText(text) {
this.$emit('replace-text', text);
},
},
};
</script>
@@ -11,7 +11,7 @@
</template>
<script>
import wootConstants from '../../../constants';
import wootConstants from 'dashboard/constants/globals';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { hasPressedAltAndBKey } from 'shared/helpers/KeyboardHelpers';
@@ -38,8 +38,8 @@ export default {
this.onTabChange();
},
onTabChange() {
this.$store.dispatch('setChatFilter', this.activeStatus);
this.$emit('statusFilterChange', this.activeStatus);
this.$store.dispatch('setChatStatusFilter', this.activeStatus);
this.$emit('onChangeFilter', this.activeStatus);
},
},
};
@@ -0,0 +1,122 @@
<template>
<div class="position-relative">
<woot-button
v-tooltip.right="$t('CHAT_LIST.SORT_TOOLTIP_LABEL')"
variant="smooth"
size="tiny"
color-scheme="secondary"
class="selector-button"
icon="sort-icon"
@click="toggleDropdown"
/>
<div
v-if="showActionsDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open basic-filter"
>
<div class="filter__item">
<span>{{ this.$t('CHAT_LIST.CHAT_SORT.STATUS') }}</span>
<filter-item
type="status"
:selected-value="chatStatus"
:items="chatStatusItems"
path-prefix="CHAT_LIST.CHAT_STATUS_FILTER_ITEMS"
@onChangeFilter="onChangeFilter"
/>
</div>
<div class="filter__item">
<span>{{ this.$t('CHAT_LIST.CHAT_SORT.ORDER_BY') }}</span>
<filter-item
type="sort"
:selected-value="chatSortFilter"
:items="chatSortItems"
path-prefix="CHAT_LIST.CHAT_SORT_FILTER_ITEMS"
@onChangeFilter="onChangeFilter"
/>
</div>
</div>
</div>
</template>
<script>
import wootConstants from 'dashboard/constants/globals';
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import FilterItem from './FilterItem';
export default {
components: {
FilterItem,
},
mixins: [clickaway],
data() {
return {
showActionsDropdown: false,
chatStatusItems: this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS'),
chatSortItems: this.$t('CHAT_LIST.CHAT_SORT_FILTER_ITEMS'),
};
},
computed: {
...mapGetters({
chatStatusFilter: 'getChatStatusFilter',
chatSortFilter: 'getChatSortFilter',
}),
chatStatus() {
return this.chatStatusFilter || wootConstants.STATUS_TYPE.OPEN;
},
sortFilter() {
return this.chatSortFilter || wootConstants.SORT_BY_TYPE.LATEST;
},
},
methods: {
onTabChange(value) {
this.$emit('changeFilter', value);
this.closeDropdown();
},
toggleDropdown() {
this.showActionsDropdown = !this.showActionsDropdown;
},
closeDropdown() {
this.showActionsDropdown = false;
},
onChangeFilter(type, value) {
this.$emit('changeFilter', type, value);
},
},
};
</script>
<style lang="scss" scoped>
.basic-filter {
margin-top: var(--space-smaller);
padding: var(--space-normal);
right: 0;
width: 21rem;
span {
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
}
.filter__item {
align-items: center;
display: flex;
justify-content: space-between;
&:last-child {
margin-top: var(--space-normal);
}
span {
font-size: var(--font-size-mini);
}
}
}
.icon {
margin-right: var(--space-smaller);
}
.dropdown-icon {
margin-left: var(--space-smaller);
}
</style>
@@ -23,7 +23,7 @@
:show-badge="false"
/>
</woot-tabs>
<div v-if="!activeIndex" class="messages-and-sidebar">
<div v-show="!activeIndex" class="messages-and-sidebar">
<messages-view
v-if="currentChat.id"
:inbox-id="inboxId"
@@ -41,9 +41,11 @@
</div>
</div>
<dashboard-app-frame
v-else
:key="currentChat.id + '-' + activeIndex"
:config="dashboardApps[activeIndex - 1].content"
v-for="(dashboardApp, index) in dashboardApps"
v-show="activeIndex - 1 === index"
:key="currentChat.id + '-' + dashboardApp.id"
:is-visible="activeIndex - 1 === index"
:config="dashboardApps[index].content"
:current-chat="currentChat"
/>
</div>
@@ -112,6 +114,7 @@ export default {
},
'currentChat.id'() {
this.fetchLabels();
this.activeIndex = 0;
},
},
mounted() {
@@ -33,13 +33,16 @@
<div class="conversation--details columns">
<div class="conversation--metadata">
<inbox-name v-if="showInboxName" :inbox="inbox" />
<span
v-if="showAssignee && assignee.name"
class="label assignee-label text-truncate"
>
<fluent-icon icon="person" size="12" />
{{ assignee.name }}
</span>
<div class="conversation-metadata-attributes">
<span
v-if="showAssignee && assignee.name"
class="label assignee-label text-truncate"
>
<fluent-icon icon="person" size="12" />
{{ assignee.name }}
</span>
<priority-mark :priority="chat.priority" />
</div>
</div>
<h4 class="conversation--user">
{{ currentContact.name }}
@@ -106,12 +109,14 @@
<conversation-context-menu
:status="chat.status"
:inbox-id="inbox.id"
:priority="chat.priority"
:has-unread-messages="hasUnread"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@assign-team="onAssignTeam"
@mark-as-unread="markAsUnread"
@assign-priority="assignPriority"
/>
</woot-context-menu>
</div>
@@ -131,6 +136,7 @@ import ConversationContextMenu from './contextMenu/Index.vue';
import alertMixin from 'shared/mixins/alertMixin';
import TimeAgo from 'dashboard/components/ui/TimeAgo';
import CardLabels from './conversationCardComponents/CardLabels.vue';
import PriorityMark from './PriorityMark.vue';
const ATTACHMENT_ICONS = {
image: 'image',
audio: 'headphones-sound-wave',
@@ -147,6 +153,7 @@ export default {
Thumbnail,
ConversationContextMenu,
TimeAgo,
PriorityMark,
},
mixins: [
@@ -370,6 +377,10 @@ export default {
this.$emit('mark-as-unread', this.chat.id);
this.closeContextMenu();
},
async assignPriority(priority) {
this.$emit('assign-priority', priority, this.chat.id);
this.closeContextMenu();
},
},
};
</script>
@@ -429,9 +440,14 @@ export default {
padding: var(--space-micro) 0 var(--space-micro) 0;
}
.conversation-metadata-attributes {
display: flex;
gap: var(--space-small);
margin-left: var(--space-small);
}
.assignee-label {
display: inline-flex;
margin-left: var(--space-small);
max-width: 50%;
}
}
@@ -63,7 +63,7 @@ import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName';
import MoreActions from './MoreActions';
import Thumbnail from '../Thumbnail';
import wootConstants from '../../../constants';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
export default {
components: {
@@ -0,0 +1,55 @@
<template>
<select v-model="activeValue" class="status--filter" @change="onTabChange()">
<option v-for="(value, status) in items" :key="status" :value="status">
{{ $t(`${pathPrefix}.${status}.TEXT`) }}
</option>
</select>
</template>
<script>
export default {
props: {
selectedValue: {
type: String,
required: true,
},
items: {
type: Object,
required: true,
},
type: {
type: String,
required: true,
},
pathPrefix: {
type: String,
required: true,
},
},
data() {
return {
activeValue: this.selectedValue,
};
},
methods: {
onTabChange() {
if (this.type === 'status') {
this.$store.dispatch('setChatStatusFilter', this.activeValue);
} else {
this.$store.dispatch('setChatSortFilter', this.activeValue);
}
this.$emit('onChangeFilter', this.activeValue, this.type);
},
},
};
</script>
<style lang="scss" scoped>
.status--filter {
background-color: var(--color-background-light);
border: 1px solid var(--color-border);
font-size: var(--font-size-mini);
height: var(--space-medium);
margin: 0 var(--space-smaller);
padding: 0 var(--space-medium) 0 var(--space-small);
width: 126px;
}
</style>
@@ -22,6 +22,14 @@
:bcc="emailHeadAttributes.bcc"
:is-incoming="isIncoming"
/>
<blockquote v-if="storyReply" class="story-reply-quote">
<span>{{ $t('CONVERSATION.REPLIED_TO_STORY') }}</span>
<bubble-image
v-if="!hasStoryError"
:url="storyUrl"
@error="onStoryLoadError"
/>
</blockquote>
<bubble-text
v-if="data.content"
:message="message"
@@ -79,7 +87,7 @@
:sender="data.sender"
:story-sender="storySender"
:external-error="externalError"
:story-id="storyId"
:story-id="`${storyId}`"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:has-instagram-story="hasInstagramStory"
@@ -192,6 +200,7 @@ export default {
hasImageError: false,
contextMenuPosition: {},
showBackgroundHighlight: false,
hasStoryError: false,
};
},
computed: {
@@ -277,6 +286,12 @@ export default {
storyId() {
return this.contentAttributes.story_id || null;
},
storyUrl() {
return this.contentAttributes.story_url || null;
},
storyReply() {
return this.storyUrl && this.hasInstagramStory;
},
contentType() {
const {
data: { content_type: contentType },
@@ -414,10 +429,12 @@ export default {
watch: {
data() {
this.hasImageError = false;
this.hasStoryError = false;
},
},
mounted() {
this.hasImageError = false;
this.hasStoryError = false;
bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
this.setupHighlightTimer();
},
@@ -432,6 +449,9 @@ export default {
const { file_type: fileType } = attachments[0];
return fileType === type && !this.hasImageError;
}
if (this.storyReply) {
return true;
}
return false;
},
handleContextMenuClick() {
@@ -443,6 +463,9 @@ export default {
onImageLoadError() {
this.hasImageError = true;
},
onStoryLoadError() {
this.hasStoryError = true;
},
openContextMenu(e) {
const shouldSkipContextMenu =
e.target?.classList.contains('skip-context-menu') ||
@@ -672,7 +695,6 @@ li.right {
blockquote {
border-left: var(--space-micro) solid var(--s-75);
color: var(--s-800);
padding: var(--space-smaller) var(--space-small);
margin: var(--space-smaller) 0;
padding: var(--space-small) var(--space-small) 0 var(--space-normal);
}
@@ -704,4 +726,11 @@ li.right {
}
}
}
.story-reply-quote {
border-left: var(--space-micro) solid var(--s-75);
color: var(--s-600);
margin: var(--space-small) var(--space-normal) 0;
padding: var(--space-small) var(--space-small) 0 var(--space-small);
}
</style>
@@ -93,7 +93,9 @@ import { mapGetters } from 'vuex';
import ReplyBox from './ReplyBox';
import Message from './Message';
import conversationMixin from '../../../mixins/conversations';
import conversationMixin, {
filterDuplicateSourceMessages,
} from '../../../mixins/conversations';
import Banner from 'dashboard/components/ui/Banner.vue';
import { getTypingUsersText } from '../../../helper/commons';
import { BUS_EVENTS } from 'shared/constants/busEvents';
@@ -171,24 +173,28 @@ export default {
return '';
},
getMessages() {
const [chat] = this.allConversations.filter(
c => c.id === this.currentChat.id
);
return chat;
const messages = this.currentChat.messages || [];
if (this.isAWhatsAppChannel) {
return filterDuplicateSourceMessages(messages);
}
return messages;
},
getReadMessages() {
const chat = this.getMessages;
return chat === undefined ? null : this.readMessages(chat);
return this.readMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
},
getUnReadMessages() {
const chat = this.getMessages;
return chat === undefined ? null : this.unReadMessages(chat);
return this.unReadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
},
shouldShowSpinner() {
return (
(this.getMessages && this.getMessages.dataFetched === undefined) ||
(this.currentChat && this.currentChat.dataFetched === undefined) ||
(!this.listLoadingStatus && this.isLoadingPrevious)
);
},
@@ -208,7 +214,7 @@ export default {
selectedTweet() {
if (this.selectedTweetId) {
const { messages = [] } = this.getMessages;
const { messages = [] } = this.currentChat;
const [selectedMessage] = messages.filter(
message => message.id === this.selectedTweetId
);
@@ -360,7 +366,7 @@ export default {
async fetchPreviousMessages(scrollTop = 0) {
this.setScrollParams();
const shouldLoadMoreMessages =
this.getMessages.dataFetched === true &&
this.currentChat.dataFetched === true &&
!this.listLoadingStatus &&
!this.isLoadingPrevious;
@@ -373,7 +379,7 @@ export default {
try {
await this.$store.dispatch('fetchPreviousMessages', {
conversationId: this.currentChat.id,
before: this.getMessages.messages[0].id,
before: this.currentChat.messages[0].id,
});
const heightDifference =
this.conversationPanel.scrollHeight - this.heightBeforeLoad;
@@ -0,0 +1,64 @@
<template>
<span
v-if="priority"
v-tooltip="{
content: tooltipText,
delay: { show: 1500, hide: 0 },
hideOnClick: true,
}"
class="conversation-priority-mark"
:class="{ urgent: priority === CONVERSATION_PRIORITY.URGENT }"
>
<fluent-icon
:icon="`priority-${priority.toLowerCase()}`"
size="14"
view-box="0 0 14 14"
/>
</span>
</template>
<script>
import { CONVERSATION_PRIORITY } from '../../../../shared/constants/messages';
export default {
name: 'PriorityMark',
props: {
priority: {
type: String,
default: '',
validate: value =>
[...Object.values(CONVERSATION_PRIORITY), ''].includes(value),
},
},
data() {
return {
CONVERSATION_PRIORITY,
};
},
computed: {
tooltipText() {
return this.$t(
`CONVERSATION.PRIORITY.OPTIONS.${this.priority.toUpperCase()}`
);
},
},
};
</script>
<style scoped lang="scss">
.conversation-priority-mark {
align-items: center;
flex-shrink: 0;
background: var(--s-50);
border-radius: var(--border-radius-small);
color: var(--s-600);
display: inline-flex;
width: var(--space-snug);
height: var(--space-snug);
&.urgent {
background: var(--r-50);
color: var(--r-500);
}
}
</style>
@@ -120,8 +120,10 @@
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker"
:message="message"
@selectWhatsappTemplate="openWhatsappTemplateModal"
@toggle-editor="toggleRichContentEditor"
@replace-text="replaceText"
/>
<whatsapp-templates
:inbox-id="inbox.id"
@@ -162,11 +164,11 @@ import {
AUDIO_FORMATS,
} from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { replaceVariablesInMessage } from 'dashboard/helper/messageHelper';
import {
getMessageVariables,
getUndefinedVariablesInMessage,
} from 'dashboard/helper/messageHelper';
replaceVariablesInMessage,
} from '@chatwoot/utils';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
@@ -174,9 +176,10 @@ import inboxMixin from 'shared/mixins/inboxMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { DirectUpload } from 'activestorage';
import { frontendURL } from '../../../helper/URLHelper';
import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../../helper/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants';
import wootConstants from 'dashboard/constants/globals';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import rtlMixin from 'shared/mixins/rtlMixin';
@@ -498,10 +501,10 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
if (this.isAWebWidgetInbox) {
return AUDIO_FORMATS.WEBM;
if (this.isAWhatsAppChannel) {
return AUDIO_FORMATS.OGG;
}
return AUDIO_FORMATS.OGG;
return AUDIO_FORMATS.WAV;
},
messageVariables() {
const variables = getMessageVariables({
@@ -195,7 +195,7 @@ export default {
return '';
}
const { storySender, storyId } = this;
return `https://www.instagram.com/stories/${storySender}/${storyId}`;
return `https://www.instagram.com/stories/direct/${storySender}_${storyId}`;
},
showStatusIndicators() {
if ((this.isOutgoing || this.isTemplate) && !this.isPrivate) {
@@ -23,6 +23,14 @@
@click="snoozeConversation(option.snoozedUntil)"
/>
</menu-item-with-submenu>
<menu-item-with-submenu :option="priorityConfig">
<menu-item
v-for="(option, i) in priorityConfig.options"
:key="i"
:option="option"
@click="assignPriority(option.key)"
/>
</menu-item-with-submenu>
<menu-item-with-submenu
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
@@ -69,7 +77,7 @@
<script>
import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants.js';
import wootConstants from 'dashboard/constants/globals';
import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin';
import { mapGetters } from 'vuex';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
@@ -93,6 +101,10 @@ export default {
type: Number,
default: null,
},
priority: {
type: String,
default: null,
},
},
data() {
return {
@@ -140,6 +152,33 @@ export default {
},
],
},
priorityConfig: {
key: 'priority',
label: this.$t('CONVERSATION.PRIORITY.TITLE'),
icon: 'warning',
options: [
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
key: null,
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
key: 'urgent',
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
key: 'high',
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
key: 'medium',
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
key: 'low',
},
].filter(item => item.key !== this.priority),
},
labelMenuConfig: {
key: 'label',
icon: 'tag',
@@ -193,6 +232,9 @@ export default {
this.snoozeTimes[snoozedUntil] || null
);
},
assignPriority(priority) {
this.$emit('assign-priority', priority);
},
show(key) {
// If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved.
@@ -1,6 +1,6 @@
<template>
<div v-on-clickaway="onCloseAgentList" class="bulk-action__agents">
<div class="triangle" :style="cssVars">
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path
d="M20 12l-8-8-12 12"
@@ -104,14 +104,13 @@ import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Spinner from 'shared/components/Spinner';
import { mixin as clickaway } from 'vue-clickaway';
import bulkActionsMixin from 'dashboard/mixins/bulkActionsMixin.js';
export default {
components: {
Thumbnail,
Spinner,
},
mixins: [clickaway, bulkActionsMixin],
mixins: [clickaway],
props: {
selectedInboxes: {
type: Array,
@@ -240,7 +239,7 @@ export default {
display: block;
z-index: var(--z-index-one);
position: absolute;
top: calc(var(--space-slab) * -1);
top: var(--space-minus-slab);
right: var(--triangle-position);
text-align: left;
}
@@ -58,7 +58,7 @@
<transition name="popover-animation">
<label-actions
v-if="showLabelActions"
triangle-position="8.5"
class="label-actions-box"
@assign="assignLabels"
@close="showLabelActions = false"
/>
@@ -66,12 +66,12 @@
<transition name="popover-animation">
<update-actions
v-if="showUpdateActions"
class="update-actions-box"
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
:show-resolve="!showResolvedAction"
:show-reopen="!showOpenAction"
:show-snooze="!showSnoozedAction"
triangle-position="5.6"
@update="updateConversations"
@close="showUpdateActions = false"
/>
@@ -79,9 +79,9 @@
<transition name="popover-animation">
<agent-selector
v-if="showAgentsList"
class="agent-actions-box"
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
triangle-position="2.8"
@select="submit"
@close="showAgentsList = false"
/>
@@ -89,7 +89,7 @@
<transition name="popover-animation">
<team-actions
v-if="showTeamsList"
triangle-position="0.2"
class="team-actions-box"
@assign-team="assignTeam"
@close="showTeamsList = false"
/>
@@ -247,4 +247,17 @@ export default {
opacity: 0;
transform: scale(0.95);
}
.label-actions-box {
--triangle-position: 8.5rem;
}
.update-actions-box {
--triangle-position: 5.6rem;
}
.agent-actions-box {
--triangle-position: 2.8rem;
}
.team-actions-box {
--triangle-position: 0.2rem;
}
</style>
@@ -1,6 +1,6 @@
<template>
<div v-on-clickaway="onClose" class="labels-container">
<div class="triangle" :style="cssVars">
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path
d="M20 12l-8-8-12 12"
@@ -75,10 +75,9 @@
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import bulkActionsMixin from 'dashboard/mixins/bulkActionsMixin.js';
export default {
mixins: [clickaway, bulkActionsMixin],
mixins: [clickaway],
data() {
return {
query: '',
@@ -207,7 +206,7 @@ export default {
position: absolute;
right: var(--triangle-position);
text-align: left;
top: calc(var(--space-slab) * -1);
top: var(--space-minus-slab);
z-index: var(--z-index-one);
}
}
@@ -1,6 +1,6 @@
<template>
<div v-on-clickaway="onClose" class="bulk-action__teams">
<div class="triangle" :style="cssVars">
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path
d="M20 12l-8-8-12 12"
@@ -59,9 +59,8 @@
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import bulkActionsMixin from 'dashboard/mixins/bulkActionsMixin.js';
export default {
mixins: [clickaway, bulkActionsMixin],
mixins: [clickaway],
data() {
return {
query: '',
@@ -141,7 +140,7 @@ export default {
display: block;
z-index: var(--z-index-one);
position: absolute;
top: calc(var(--space-slab) * -1);
top: var(--space-minus-slab);
right: var(--triangle-position);
text-align: left;
}
@@ -1,6 +1,6 @@
<template>
<div v-on-clickaway="onClose" class="actions-container">
<div class="triangle" :style="cssVars">
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path
d="M20 12l-8-8-12 12"
@@ -45,14 +45,13 @@
import { mixin as clickaway } from 'vue-clickaway';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import bulkActionsMixin from 'dashboard/mixins/bulkActionsMixin.js';
export default {
components: {
WootDropdownItem,
WootDropdownMenu,
},
mixins: [clickaway, bulkActionsMixin],
mixins: [clickaway],
props: {
selectedInboxes: {
type: Array,
@@ -156,7 +155,7 @@ export default {
position: absolute;
right: var(--triangle-position);
text-align: left;
top: calc(var(--space-slab) * -1);
top: var(--space-minus-slab);
z-index: var(--z-index-one);
}
}
@@ -2,6 +2,6 @@ import emailValidator from 'vuelidate/lib/validators/email';
export const validEmailsByComma = value => {
if (!value.length) return true;
const emails = value.split(',');
const emails = value.replace(/\s+/g, '').split(',');
return emails.every(email => emailValidator(email));
};
@@ -10,4 +10,7 @@ describe('#validEmailsByComma', () => {
it('returns false when one of the email passed is invalid', () => {
expect(validEmailsByComma('ni@njan.com,pova.da')).toEqual(false);
});
it('strips spaces between emails before validating', () => {
expect(validEmailsByComma('1@test.com , 2@test.com')).toEqual(true);
});
});
@@ -0,0 +1,361 @@
<template>
<div class="phone-input--wrap">
<div class="phone-input" :class="{ 'has-error': error }">
<div class="country-emoji--wrap" @click="toggleCountryDropdown">
<h5 v-if="activeCountry.emoji">{{ activeCountry.emoji }}</h5>
<fluent-icon v-else icon="globe" class="fluent-icon" size="16" />
<fluent-icon icon="chevron-down" class="fluent-icon" size="12" />
</div>
<span v-if="activeDialCode" class="country-dial--code">
{{ activeDialCode }}
</span>
<input
:value="phoneNumber"
type="tel"
class="phone-input--field"
:placeholder="placeholder"
:readonly="readonly"
:style="styles"
@input="onChange"
@blur="onBlur"
/>
</div>
<div v-if="showDropdown" ref="dropdown" class="country-dropdown">
<div class="dropdown-search--wrap">
<input
ref="searchbar"
v-model="searchCountry"
type="text"
placeholder="Search"
class="dropdown-search"
/>
</div>
<div
v-for="(country, index) in filteredCountriesBySearch"
ref="dropdownItem"
:key="index"
class="country-dropdown--item"
:class="{
active: country.id === activeCountryCode,
focus: index === selectedIndex,
}"
@click="onSelectCountry(country)"
>
<span class="country-emoji">{{ country.emoji }}</span>
<span class="country-name">
{{ country.name }}
</span>
<span class="country-dial-code">{{ country.dial_code }}</span>
</div>
<div v-if="filteredCountriesBySearch.length === 0">
<span class="no-results">No results found</span>
</div>
</div>
</div>
</template>
<script>
import countries from 'shared/constants/countries.js';
import parsePhoneNumber from 'libphonenumber-js';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import {
hasPressedArrowUpKey,
hasPressedArrowDownKey,
isEnter,
} from 'shared/helpers/KeyboardHelpers';
export default {
mixins: [eventListenerMixins],
props: {
value: {
type: [String, Number],
default: '',
},
placeholder: {
type: String,
default: '',
},
readonly: {
type: Boolean,
default: false,
},
styles: {
type: Object,
default: () => {},
},
error: {
type: Boolean,
default: false,
},
},
data() {
return {
countries: [
{
name: 'Select Country',
dial_code: '',
emoji: '',
id: '',
},
...countries,
],
selectedIndex: -1,
showDropdown: false,
searchCountry: '',
activeCountryCode: '',
activeDialCode: '',
phoneNumber: this.value,
};
},
computed: {
filteredCountriesBySearch() {
return this.countries.filter(country => {
const { name, dial_code, id } = country;
const search = this.searchCountry.toLowerCase();
return (
name.toLowerCase().includes(search) ||
dial_code.toLowerCase().includes(search) ||
id.toLowerCase().includes(search)
);
});
},
activeCountry() {
if (this.activeCountryCode) {
return this.countries.find(
country => country.id === this.activeCountryCode
);
}
return '';
},
},
watch: {
value() {
const number = parsePhoneNumber(this.value);
if (number) {
this.activeCountryCode = number.country;
this.activeDialCode = `+${number.countryCallingCode}`;
this.phoneNumber = this.value.replace(
`+${number.countryCallingCode}`,
''
);
}
},
},
mounted() {
window.addEventListener('mouseup', this.onOutsideClick);
this.setActiveCountry();
},
beforeDestroy() {
window.removeEventListener('mouseup', this.onOutsideClick);
},
methods: {
onOutsideClick(e) {
if (
this.showDropdown &&
e.target !== this.$refs.dropdown &&
!this.$refs.dropdown.contains(e.target)
) {
this.closeDropdown();
}
},
onChange(e) {
this.phoneNumber = e.target.value;
this.$emit('input', e.target.value, this.activeDialCode);
},
onBlur(e) {
this.$emit('blur', e.target.value);
},
dropdownItem() {
return Array.from(
this.$refs.dropdown.querySelectorAll(
'div.country-dropdown div.country-dropdown--item'
)
);
},
focusedItem() {
return Array.from(
this.$refs.dropdown.querySelectorAll('div.country-dropdown div.focus')
);
},
focusedItemIndex() {
return Array.from(this.dropdownItem()).indexOf(this.focusedItem()[0]);
},
onKeyDownHandler(e) {
const { showDropdown, filteredCountriesBySearch, onSelectCountry } = this;
const { selectedIndex } = this;
if (showDropdown) {
if (hasPressedArrowDownKey(e)) {
e.preventDefault();
this.selectedIndex = Math.min(
selectedIndex + 1,
filteredCountriesBySearch.length - 1
);
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28;
} else if (hasPressedArrowUpKey(e)) {
e.preventDefault();
this.selectedIndex = Math.max(selectedIndex - 1, 0);
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
} else if (isEnter(e)) {
e.preventDefault();
onSelectCountry(filteredCountriesBySearch[selectedIndex]);
}
}
},
onSelectCountry(country) {
this.activeCountryCode = country.id;
this.searchCountry = '';
this.activeDialCode = country.dial_code;
this.$emit('setCode', country.dial_code);
this.closeDropdown();
},
setActiveCountry() {
const { phoneNumber } = this;
if (!phoneNumber) return;
const number = parsePhoneNumber(phoneNumber);
if (number) {
this.activeCountryCode = number.country;
this.activeDialCode = number.countryCallingCode;
}
},
toggleCountryDropdown() {
this.showDropdown = !this.showDropdown;
this.selectedIndex = -1;
if (this.showDropdown) {
this.$nextTick(() => {
this.$refs.searchbar.focus();
});
}
},
closeDropdown() {
this.selectedIndex = -1;
this.showDropdown = false;
},
},
};
</script>
<style scoped lang="scss">
.phone-input--wrap {
position: relative;
.phone-input {
display: flex;
align-items: center;
justify-content: flex-start;
margin-bottom: var(--space-normal);
border: 1px solid var(--s-200);
border-radius: var(--border-radius-normal);
&.has-error {
border: 1px solid var(--r-400);
}
}
.country-emoji--wrap {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-small);
background: var(--s-25);
height: 4rem;
width: 5.2rem;
border-radius: var(--border-radius-normal) 0 0 var(--border-radius-normal);
padding: var(--space-small) var(--space-smaller) var(--space-small)
var(--space-small);
h5 {
margin-bottom: 0;
}
}
.country-dial--code {
display: flex;
color: var(--s-300);
font-size: var(--space-normal);
font-weight: normal;
line-height: 1.5;
padding: var(--space-small) 0 var(--space-small) var(--space-small);
}
.phone-input--field {
margin-bottom: 0;
border: 0;
}
.country-dropdown {
z-index: var(--z-index-low);
position: absolute;
height: var(--space-giga);
width: 20rem;
overflow-y: auto;
top: 4rem;
border-radius: var(--border-radius-default);
padding: 0 0 var(--space-smaller) 0;
background-color: var(--white);
box-shadow: var(--shadow-context-menu);
border-radius: var(--border-radius-normal);
.dropdown-search--wrap {
top: 0;
position: sticky;
background-color: var(--white);
padding: var(--space-smaller);
.dropdown-search {
height: var(--space-large);
margin-bottom: 0;
font-size: var(--font-size-small);
border: 1px solid var(--s-200) !important;
}
}
.country-dropdown--item {
display: flex;
align-items: center;
height: 2.8rem;
padding: 0 var(--space-smaller);
cursor: pointer;
&.active {
background-color: var(--s-50);
}
&.focus {
background-color: var(--s-25);
}
&:hover {
background-color: var(--s-50);
}
.country-emoji {
font-size: var(--font-size-default);
margin-right: var(--space-smaller);
}
.country-name {
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.country-dial-code {
margin-left: var(--space-smaller);
color: var(--s-300);
font-size: var(--font-size-mini);
}
}
.no-results {
display: flex;
align-items: center;
justify-content: center;
color: var(--s-500);
margin-top: var(--space-normal);
}
}
}
</style>
@@ -12,6 +12,11 @@ export default {
SNOOZED: 'snoozed',
ALL: 'all',
},
SORT_BY_TYPE: {
LATEST: 'latest',
CREATED_AT: 'sort_on_created_at',
PRIORITY: 'sort_on_priority',
},
ARTICLE_STATUS_TYPES: {
DRAFT: 0,
PUBLISH: 1,
@@ -0,0 +1,5 @@
export const LOCAL_STORAGE_KEYS = {
DISMISSED_UPDATES: 'dismissedUpdates',
WIDGET_BUILDER: 'widgetBubble_',
DRAFT_MESSAGES: 'draftMessages',
};
+1
View File
@@ -15,4 +15,5 @@ export const FEATURE_FLAGS = {
REPORTS: 'reports',
TEAM_MANAGEMENT: 'team_management',
VOICE_RECORDER: 'voice_recorder',
AUDIT_LOGS: 'audit_logs',
};
@@ -7,6 +7,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
USED_MENTIONS: 'Used mentions',
SEARCH_CONVERSATION: 'Searched conversations',
APPLY_FILTER: 'Applied filters in the conversation list',
CHANGE_PRIORITY: 'Assigned priority to a conversation',
});
export const ACCOUNT_EVENTS = Object.freeze({
+1 -1
View File
@@ -1,4 +1,4 @@
import { DEFAULT_REDIRECT_URL } from '../constants';
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
export const frontendURL = (path, params) => {
const stringifiedParams = params ? `?${new URLSearchParams(params)}` : '';
@@ -30,6 +30,36 @@ class ActionCableConnector extends BaseActionCableConnector {
};
}
onReconnect = () => {
this.syncActiveConversationMessages();
};
onDisconnected = () => {
this.setActiveConversationLastMessageId();
};
setActiveConversationLastMessageId = () => {
const {
params: { conversation_id },
} = this.app.$route;
if (conversation_id) {
this.app.$store.dispatch('setConversationLastMessageId', {
conversationId: Number(conversation_id),
});
}
};
syncActiveConversationMessages = () => {
const {
params: { conversation_id },
} = this.app.$route;
if (conversation_id) {
this.app.$store.dispatch('syncActiveConversationMessages', {
conversationId: Number(conversation_id),
});
}
};
isAValidEvent = data => {
return this.app.$store.getters.getCurrentAccountId === data.account_id;
};
@@ -75,8 +105,16 @@ class ActionCableConnector extends BaseActionCableConnector {
onLogout = () => AuthAPI.logout();
onMessageCreated = data => {
const {
conversation: { last_activity_at: lastActivityAt },
conversation_id: conversationId,
} = data;
DashboardAudioNotificationHelper.onNewMessage(data);
this.app.$store.dispatch('addMessage', data);
this.app.$store.dispatch('updateConversationLastActivity', {
lastActivityAt,
conversationId,
});
};
onReload = () => window.location.reload();
@@ -16,6 +16,29 @@ const MESSAGE_CONDITION_VALUES = [
},
];
export const PRIORITY_CONDITION_VALUES = [
{
id: 'nil',
name: 'None',
},
{
id: 'low',
name: 'Low',
},
{
id: 'medium',
name: 'Medium',
},
{
id: 'high',
name: 'High',
},
{
id: 'urgent',
name: 'Urgent',
},
];
export const getCustomAttributeInputType = key => {
const customAttributeMap = {
date: 'date',
@@ -103,6 +126,7 @@ export const getActionOptions = ({ agents, teams, labels, type }) => {
assign_team: teams,
send_email_to_team: teams,
add_label: generateConditionOptions(labels, 'title'),
change_priority: PRIORITY_CONDITION_VALUES,
};
return actionsMap[type];
};
@@ -139,6 +163,7 @@ export const getConditionOptions = ({
conversation_language: languages,
country_code: countries,
message_type: MESSAGE_CONDITION_VALUES,
priority: PRIORITY_CONDITION_VALUES,
};
return conditionFilterMaps[type];
+50
View File
@@ -1,5 +1,55 @@
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
export const getInboxSource = (type, phoneNumber, inbox) => {
switch (type) {
case INBOX_TYPES.WEB:
return inbox.website_url || '';
case INBOX_TYPES.TWILIO:
case INBOX_TYPES.WHATSAPP:
return phoneNumber || '';
case INBOX_TYPES.EMAIL:
return inbox.email || '';
default:
return '';
}
};
export const getReadableInboxByType = (type, phoneNumber) => {
switch (type) {
case INBOX_TYPES.WEB:
return 'livechat';
case INBOX_TYPES.FB:
return 'facebook';
case INBOX_TYPES.TWITTER:
return 'twitter';
case INBOX_TYPES.TWILIO:
return phoneNumber?.startsWith('whatsapp') ? 'whatsapp' : 'sms';
case INBOX_TYPES.WHATSAPP:
return 'whatsapp';
case INBOX_TYPES.API:
return 'api';
case INBOX_TYPES.EMAIL:
return 'email';
case INBOX_TYPES.TELEGRAM:
return 'telegram';
case INBOX_TYPES.LINE:
return 'line';
default:
return 'chat';
}
};
export const getInboxClassByType = (type, phoneNumber) => {
switch (type) {
case INBOX_TYPES.WEB:
@@ -1,66 +0,0 @@
const MESSAGE_VARIABLES_REGEX = /{{(.*?)}}/g;
export const replaceVariablesInMessage = ({ message, variables }) => {
return message.replace(MESSAGE_VARIABLES_REGEX, (match, replace) => {
return variables[replace.trim()]
? variables[replace.trim().toLowerCase()]
: '';
});
};
export const capitalizeName = name => {
return name.replace(/\b(\w)/g, s => s.toUpperCase());
};
const skipCodeBlocks = str => str.replace(/```(?:.|\n)+?```/g, '');
export const getFirstName = ({ user }) => {
const firstName = user?.name ? user.name.split(' ').shift() : '';
return capitalizeName(firstName);
};
export const getLastName = ({ user }) => {
if (user && user.name) {
const lastName =
user.name.split(' ').length > 1 ? user.name.split(' ').pop() : '';
return capitalizeName(lastName);
}
return '';
};
export const getMessageVariables = ({ conversation }) => {
const {
meta: { assignee = {}, sender = {} },
id,
} = conversation;
return {
'contact.name': capitalizeName(sender?.name),
'contact.first_name': getFirstName({ user: sender }),
'contact.last_name': getLastName({ user: sender }),
'contact.email': sender?.email,
'contact.phone': sender?.phone_number,
'contact.id': sender?.id,
'conversation.id': id,
'agent.name': capitalizeName(assignee?.name || ''),
'agent.first_name': getFirstName({ user: assignee }),
'agent.last_name': getLastName({ user: assignee }),
'agent.email': assignee?.email ?? '',
};
};
export const getUndefinedVariablesInMessage = ({ message, variables }) => {
const messageWithOutCodeBlocks = skipCodeBlocks(message);
const matches = messageWithOutCodeBlocks.match(MESSAGE_VARIABLES_REGEX);
if (!matches) return [];
return matches
.map(match => {
return match
.replace('{{', '')
.replace('}}', '')
.trim();
})
.filter(variable => {
return !variables[variable];
});
};
@@ -8,8 +8,8 @@ export const buildPortalArticleURL = (
portalSlug,
categorySlug,
locale,
articleId
articleSlug
) => {
const portalURL = buildPortalURL(portalSlug);
return `${portalURL}/${locale}/${categorySlug}/${articleId}`;
return `${portalURL}/articles/${articleSlug}`;
};
@@ -57,6 +57,7 @@ export const initializeChatwootEvents = () => {
window.$chatwoot.setCustomAttributes({
signedUpAt: user.created_at,
cloudCustomer: 'true',
account_id: user.account_id,
});
}
@@ -36,6 +36,7 @@ describe('#resolveActionName', () => {
expect(resolveActionName(MACRO_ACTION_TYPES[1].key)).not.toEqual(
MACRO_ACTION_TYPES[0].label
);
expect(resolveActionName('change_priority')).toEqual('Change Priority');
});
});
@@ -1,166 +0,0 @@
import {
replaceVariablesInMessage,
getFirstName,
getLastName,
getMessageVariables,
getUndefinedVariablesInMessage,
capitalizeName,
} from '../messageHelper';
const variables = {
'contact.name': 'John Doe',
'contact.first_name': 'John',
'contact.last_name': 'Doe',
'contact.email': 'john.p@example.com',
'contact.phone': '1234567890',
'conversation.id': 1,
'agent.first_name': 'Samuel',
'agent.last_name': 'Smith',
'agent.email': 'samuel@gmail.com',
};
describe('#replaceVariablesInMessage', () => {
it('returns the message with variable name', () => {
const message =
'No issues. Hey {{contact.first_name}}, we will send the reset instructions to your email {{ contact.email}}. The {{ agent.first_name }} {{ agent.last_name }} will take care of everything. Your conversation id is {{ conversation.id }}.';
expect(replaceVariablesInMessage({ message, variables })).toBe(
'No issues. Hey John, we will send the reset instructions to your email john.p@example.com. The Samuel Smith will take care of everything. Your conversation id is 1.'
);
});
it('returns the message with variable name having white space', () => {
const message = 'hey {{contact.name}} how may I help you?';
expect(replaceVariablesInMessage({ message, variables })).toBe(
'hey John Doe how may I help you?'
);
});
it('returns the message with variable email', () => {
const message =
'No issues. We will send the reset instructions to your email at {{contact.email}}';
expect(replaceVariablesInMessage({ message, variables })).toBe(
'No issues. We will send the reset instructions to your email at john.p@example.com'
);
});
it('returns the message with multiple variables', () => {
const message =
'hey {{ contact.name }}, no issues. We will send the reset instructions to your email at {{contact.email}}';
expect(replaceVariablesInMessage({ message, variables })).toBe(
'hey John Doe, no issues. We will send the reset instructions to your email at john.p@example.com'
);
});
it('returns the message if the variable is not present in variables', () => {
const message = 'Please dm me at {{contact.twitter}}';
expect(replaceVariablesInMessage({ message, variables })).toBe(
'Please dm me at '
);
});
});
describe('#getFirstName', () => {
it('returns the first name of the contact', () => {
const assignee = { name: 'John Doe' };
expect(getFirstName({ user: assignee })).toBe('John');
});
it('returns the first name of the contact with multiple names', () => {
const assignee = { name: 'John Doe Smith' };
expect(getFirstName({ user: assignee })).toBe('John');
});
});
describe('#getLastName', () => {
it('returns the last name of the contact', () => {
const assignee = { name: 'John Doe' };
expect(getLastName({ user: assignee })).toBe('Doe');
});
it('returns the last name of the contact with multiple names', () => {
const assignee = { name: 'John Doe Smith' };
expect(getLastName({ user: assignee })).toBe('Smith');
});
});
describe('#getMessageVariables', () => {
it('returns the variables', () => {
const conversation = {
meta: {
assignee: {
name: 'samuel Smith',
email: 'samuel@example.com',
},
sender: {
name: 'john Doe',
email: 'john.doe@gmail.com',
phone_number: '1234567890',
},
},
id: 1,
};
expect(getMessageVariables({ conversation })).toEqual({
'contact.name': 'John Doe',
'contact.first_name': 'John',
'contact.last_name': 'Doe',
'contact.email': 'john.doe@gmail.com',
'contact.phone': '1234567890',
'conversation.id': 1,
'agent.name': 'Samuel Smith',
'agent.first_name': 'Samuel',
'agent.last_name': 'Smith',
'agent.email': 'samuel@example.com',
});
});
});
describe('#getUndefinedVariablesInMessage', () => {
it('returns the undefined variables', () => {
const message = 'Please dm me at {{contact.twitter}}';
expect(
getUndefinedVariablesInMessage({ message, variables }).length
).toEqual(1);
expect(getUndefinedVariablesInMessage({ message, variables })).toEqual(
expect.arrayContaining(['contact.twitter'])
);
});
it('skip variables in string with code blocks', () => {
const message =
'hey {{contact_name}} how are you? ``` code: {{contact_name}} ```';
const undefinedVariables = getUndefinedVariablesInMessage({
message,
variables,
});
expect(undefinedVariables.length).toEqual(1);
expect(undefinedVariables).toEqual(
expect.arrayContaining(['contact_name'])
);
});
});
describe('#capitalizeName', () => {
it('capitalize name if name is passed', () => {
const string = 'john peter';
expect(capitalizeName(string)).toBe('John Peter');
});
it('capitalize first name if full name is passed', () => {
const string = 'john Doe';
expect(capitalizeName(string)).toBe('John Doe');
});
it('returns empty string if the string is empty', () => {
const string = '';
expect(capitalizeName(string)).toBe('');
});
it('capitalize last name if last name is passed', () => {
const string = 'john doe';
expect(capitalizeName(string)).toBe('John Doe');
});
it('capitalize first name if first name is passed', () => {
const string = 'john';
expect(capitalizeName(string)).toBe('John');
});
it('capitalize last name if last name is passed', () => {
const string = 'doe';
expect(capitalizeName(string)).toBe('Doe');
});
});

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