Compare commits

...
154 Commits
Author SHA1 Message Date
Muhsin Keloth 9ce1175d9d OpenAI: Add the option to revert to the original text 2023-06-21 16:43:54 +05:30
Sojan JoseandGitHub 595e6e79f0 chore: Add sla policy association to conversation (#7360)
Adds the sla policy association to the conversation

Fixes: https://linear.app/chatwoot/issue/CW-1615/applying-an-sla-to-the-conversation
2023-06-21 14:48:50 +05:30
Sivin VargheseandGitHub 93d8157a55 feat: Adds Lithuanian language selectable as site language (#7354) 2023-06-20 17:29:05 +05:30
Vishnu NarayananandGitHub 6bea2cbc4a feat: add audit trail for macros (#7352) 2023-06-20 13:04:59 +05:30
Tejaswini ChileandGitHub afb7e67795 fix: empty account variable for custom_attrbute_definition running in background job (#7351) 2023-06-20 12:15:36 +05:30
bc813b71dd feat: refresh the audit page on navigation without manual reload (#7292)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2023-06-20 09:36:16 +05:30
9ac4a1eb30 refactor: multiple fixes to contact header [CW-2081] (#7331)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-06-19 21:58:33 +05:30
Muhsin KelothandGitHub 9bb455ec2d feat: Ability to choose the custom time for snooze conversation (#7257) 2023-06-19 18:23:35 +05:30
c410fe333a chore: Update translations (#7347)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-06-19 17:27:43 +05:30
Jordan BroughandGitHub 5e807c5875 chore: Remove unneeded include EnsureCurrentAccountHelper (#7339)
This class inherits from Api::V1::Accounts::BaseController, which already includes EnsureCurrentAccountHelper and also sets up before_action :current_account.
2023-06-19 17:18:07 +05:30
Muhsin KelothandGitHub 9bed57c7d1 feat: Add more snooze options (#7344) 2023-06-19 17:05:16 +05:30
Sojan JoseandGitHub 5c5381c0a9 chore(ci): Ensure PR titles are in semantic format (#7346) 2023-06-19 16:48:45 +05:30
Jordan BroughandGitHub 138630b4ae chore: Update method to access "secret_key_base" (#7337)
This will look at environment variables and Rails Credentials, and Rails Secrets.
See documentation here: https://api.rubyonrails.org/v7.0.5/classes/Rails/Application.html#method-i-secret_key_base
2023-06-19 16:11:19 +05:30
Tejaswini ChileandGitHub 9d0de04f7c fix: set custom filter count in redis (#7164) 2023-06-19 16:10:03 +05:30
Muhsin KelothandGitHub 2c3337b117 feat: Move snooze actions to command bar (#7343) 2023-06-19 13:57:59 +05:30
Shivam MishraandGitHub 237358af24 refactor: use as_json for formatting the string (#7332) 2023-06-19 13:33:48 +05:30
Sivin VargheseandGitHub 35dfff0a5b feat: Attachment view improvements (#7314) 2023-06-19 11:16:28 +05:30
86b2896333 feat: Show alerts when the limit is reached in accounts (#7323)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2023-06-15 20:47:11 -07:00
Shivam MishraandGitHub e8a27bea4b feat: Add APIs for limit check in accounts (#7242) 2023-06-15 20:11:40 -07:00
Sojan 0d465362ac Merge branch 'release/2.18.0' into develop 2023-06-15 18:45:01 +05:30
Sojan e383e9259b Bump version to 2.18.0 2023-06-15 18:44:13 +05:30
Muhsin KelothandGitHub 2c34dca347 fix: NS_ERROR_STORAGE_BUSY warnings in firefox (#7319)
Fixes: https://linear.app/chatwoot/issue/CW-2075/ns-error-storage-busy-no-error-message
2023-06-15 17:53:26 +05:30
Pranav Raj SandGitHub 69b9123963 fix: Use available_name only (#7312)
We have display_name and the actual name for an agent. display_name is used in all public facing parts. The https://github.com/chatwoot/chatwoot/pull/7232/files PR missed this and used actual name instead.

This PR fixes it.
2023-06-15 17:52:53 +05:30
Shivam MishraandGitHub cac9fe1880 Revert "feat(perf): update query to reduce N+1 impact [CW-1926] (#7228)" (#7271)
This reverts commit f28533bc47.
2023-06-15 16:23:56 +05:30
Nithin David ThomasandGitHub 06ac7c829b fix: Fixes wrong variable usage in transcript file (#7316) 2023-06-15 16:00:21 +05:30
Shivam MishraandGitHub 9985f1c143 refactor: remove redundant taggable declaration (#7310) 2023-06-15 12:57:56 +05:30
Tejaswini ChileandGitHub 1ee6a8fe90 chore: Add validation for processed content field (#7306)
* logging the messages id for message validation exception

* Update the processed_message_content validation over length

* codeclimate

* specs failing for contacts
2023-06-14 18:28:42 +05:30
Muhsin KelothandGitHub 6eb4fa41ff feat: Add sender details and message create date in the attachment API (#7308) 2023-06-14 17:29:13 +05:30
8b91f0ffac fix: Gemfile.lock to reduce vulnerabilities (#7280)
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-RUBY-RACK-1061917

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-06-14 15:40:24 +05:30
d2aa19579e feat: Adds support for superscript in help center articles (#7279)
- Adds support for superscript when rendering article markdown
- Chatwoot Markdown Render to render markdown everywhere

Co-authored-by: Sojan <sojan@pepalo.com>
2023-06-14 15:39:00 +05:30
Shivam MishraandGitHub 2e79a32db7 fix: calculation for resolution count (#7293)
* fix: calculation for resolution count

* test: resolution count bug fix

- ensure enqueued jobs are run
- fix the dates check, conversations resolved today should show up in today

* feat: ensure conversations are resolved

* test: do not count extra events if the conversation is not resolved currently

* fix: typo
2023-06-14 13:50:10 +05:30
Shivam MishraandGitHub a86e236d19 feat: update cache headers for cache_keys (#7283)
Update the cache headers for cache_keys to max-age=10, private, stale-while-revalidate=300

1. The cache will be fresh for 10 seconds (max-age=10). During this time, the browser will use the cached version without checking with the server.
2. After the initial 10 seconds, the browser can continue to serve the stale cache for up to 5 minutes (stale-while-revalidate=300). During this period, it will also try to revalidate and update the cache in the background.
3. After 310 seconds in total (10 seconds fresh, 300 seconds stale), if the browser has not been able to revalidate the cache, it will attempt to fetch the fresh resource directly from the server for subsequent requests, causing potential latency equivalent to a network request.

This means that the data will be directly revalidated only every 5 mins. Other times, it will stay fresh for 10 seconds and revalidate in the background. In most cases, we won't have to rely on a cache validation check because there is a WebSocket event for revalidation, so we know if something changes.

Right now the stale-while-revalidate is 5 minutes, we can then move it to 15 minutes.

> The stale-while-revalidate header is not supported in Safari, for Safari the cache keys will only stay in memory for 10 seconds before being marked stale
2023-06-14 13:21:51 +05:30
c755978352 feat: update banner design [CW-1491] (#7296)
* refactor: banner styling

* refactor: update button style for reply assign banner

* feat: remove update-banner classes

---------

Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
2023-06-14 12:56:42 +05:30
Tejaswini ChileandGitHub 23ca6d56f9 feat: Contact Exports (#7258) 2023-06-13 09:18:43 +05:30
Shivam MishraandGitHub 429ec7194f feat: add index to reporting events [CW-1960] (#7294)
* fix: annotation

* feat: add better index for reporting_events
2023-06-12 18:23:42 +05:30
Tejaswini ChileandGitHub c99d9f9557 fix: search for nil in-reply-to messages (#7286) 2023-06-12 16:01:56 +05:30
Vishnu NarayananandGitHub 9ff5978d6e feat: Add audit trail events for team (#7285) 2023-06-09 17:30:36 -07:00
Muhsin KelothandGitHub cd28f401ba fix: Custom attribute date is not working in some time zone (#7224) 2023-06-09 16:55:03 -07:00
Tejaswini ChileandGitHub 879a244f93 fix: Automations condition based quoted text (#7272) 2023-06-09 17:00:05 +05:30
Tejaswini ChileandGitHub f64f2138db fix: Update mail check for html_part (#7273) 2023-06-09 16:08:40 +05:30
Sojan JoseandGitHub 48f2e58e59 feat: Ability to update avatars from super admin (#7264)
- Ability to update user avatars from super admin
- Ability to update bot avatars from super admin

fixes: #7060
2023-06-09 15:32:24 +05:30
c715e396f0 feat: added input_select type message support for whatsapp (#6886)
- Added input_select message type support for Whatsapp Cloud API and Whatsapp 360dialog.

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-06-09 15:20:45 +05:30
Pranav Raj SandGitHub c8fac08e0b fix: Cannot read properties of undefined (reading 'some') (#7278)
Fixes https://linear.app/chatwoot/issue/CW-2041/typeerror-cannot-read-properties-of-undefined-reading-some
Fixes https://linear.app/chatwoot/issue/CW-2042/typeerror-cannot-read-properties-of-undefined-reading-filter
2023-06-08 20:12:14 -07:00
7fd220c177 feat: Adds the ability to edit saved segments (#7254)
* feat: Ability to edit saved filters

* chore: Adds edit contact segment

* chore: Minor fixes

* fix: code climate

* chore: Minor fixes

* chore: Adds ability to custom view name

* chore: Minor fixes

* chore: Adds spec for helper

* chore: Revert contact filter to split to new PR

* Delete editSegmentMixin.js

* chore: Revert fixes

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

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>

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

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>

* chore: Moved from mixin to helper for reusing in segments

* Delete editFolderMixin.js

* chore: Fix specs and added new specs

* chore: review comment fixes

* chore: Minor fixes

* fix: Not resetting applied filter

* feat: Adds the ability to edit saved segments

* feat: Adds specs for API part

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2023-06-08 21:25:51 +05:30
Pranav Raj SandGitHub b3b76d00ff fix: Do not use the default value if the out of office message is empty (#7268) 2023-06-08 08:22:10 -07:00
Shivam MishraandGitHub 2f2ae88cba fix: unattended count mismatch in report and list (#7263) 2023-06-08 17:58:13 +05:30
Tejaswini ChileandGitHub 2b7989f52b fix: reply mailer with sender name (#7232) 2023-06-08 17:07:08 +05:30
d7314079c9 feat: Ability to edit saved folders (#7236)
* feat: Ability to edit saved filters

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2023-06-08 14:58:57 +05:30
1017903ee1 fix: Adds domain option to user cookies set by SDK [CW-352] (#7070)
* fix: Adds domain option to user cookies set by SDK

* Adds domain to init event from chatwootSettings variable

* Testing multiple domains on heroku

* Updates with sdk from staging

* Removes sdk init code

* Testing why cookie is not getting set

* Cleans up testing code

* Refactors code to fix codeclimate issues

* Update app/javascript/sdk/cookieHelpers.js

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>

* Adds test cases for setCookieWithDomain

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2023-06-08 14:27:49 +05:30
Tejaswini ChileandGitHub d25e7fd54e fix: add email logging for source-id email-id and message-id (#7262)
* fix: mail logger for source-id email-id and message-id

* added mail provided to the tracking

* change the logger verb from info to error

* app/jobs/inboxes/fetch_imap_emails_job.rb

* codeclimate fix

* remove extra filter

* fixes: specs
2023-06-07 18:24:37 +05:30
Sojan JoseandGitHub 7e89e3a34a chore: Ensure template_sync timestamp is updated (#7265)
Fix for the cases where there are multiple channels with invalid provider config, which results in the templates sync scheduler failing to schedule jobs for valid channels.

fixes: https://linear.app/chatwoot/issue/CW-2032/bug-whatsapp-template-sync-failing-in-cloud
2023-06-07 17:18:24 +05:30
Pranav Raj SandGitHub 39eaed7766 fix: Cannot read properties of undefined (reading 'always_play_audio_alert') (#7260) 2023-06-06 15:16:25 -07:00
Pranav Raj SandGitHub b1a4907ede fix: around_action not yielding is params are present (#7259) 2023-06-06 14:55:08 -07:00
a01d81a7e1 feat: Update display text for auditlog entries (#7226)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-06-05 12:51:45 -07:00
Sojan JoseandGitHub 117d5301b4 chore: API documentation updates for CSAT surveys (#7255)
- Expose conversation uuid in APIs
- swagger documentation for cast survey update via public/message/update endpoints
- swagger documentation for survey/responses/conversation_uuid endpoint

Fixes: #6328
2023-06-05 21:14:01 +05:30
Tejaswini ChileandGitHub aae6081d73 fix: check content disposition, for inline messages in mail (#7231) 2023-06-05 20:28:32 +05:30
Sojan JoseandGitHub d03924b846 feat: Enable sending template messages in webhooks (#7252)
fixes: https://github.com/chatwoot/chatwoot/issues/5291
2023-06-05 20:27:28 +05:30
b333d0c986 feat: Attachments view (#7156)
* feat: Attachments view with key shortcuts and dynamically updates when user delete or sent new attachments

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-06-05 19:21:47 +05:30
Shivam MishraandGitHub 9f3d155822 feat: Add size limit action (#7253)
* chore: update browserlist

* feat: add size limit action

* feat: set limits

* feat: add size limit in CI

* chore: remove railsenv

* chore: update limits
2023-06-05 18:56:15 +05:30
TOMMYandGitHub 9203b097d2 chore: Show chatwoot_edition in Instance details (#7200)
- Displays the chatwoot edition in the instance details
2023-06-05 17:32:21 +05:30
Marcel StörandGitHub 12cb97db5f fix: Quote database env variables (#7216)
Database credentials, and passwords, in particular, can easily contain characters that are reserved in YAML and must be quoted. 
Example: a case with a password starting with ']'.
2023-06-05 14:19:35 +05:30
Tejaswini ChileandGitHub 09971fd613 fix: Wrap references string into array (#7243) 2023-06-03 07:33:36 +05:30
Pranav Raj SandGitHub ca9d108741 fix: Update documentation of filter APIs (#7245) 2023-06-02 15:36:41 -07:00
Shivam MishraandGitHub f28533bc47 feat(perf): update query to reduce N+1 impact [CW-1926] (#7228)
* feat: update query to reduce N+1 impact

* feat: remove channel eager loading

fixes the error: ActiveRecord::EagerLoadPolymorphicError Exception: Cannot eagerly load the polymorphic association :channel

* test: fix search test

* chore: revert change
2023-06-02 11:11:55 +05:30
Pranav Raj SandGitHub 68f82c11a5 Revert "fix: Updates editor to fix the extra slash issue [cw-1505][cw-1526] (#7091)" (#7238)
This reverts commit 1d0930ef63.
2023-06-01 13:26:02 -07:00
Tejaswini ChileandGitHub abc27fa791 fix: find mail message by references (#7220) 2023-05-31 19:23:29 +05:30
Sojan JoseandGitHub d93a8d05bc chore: Increase character limit for external url fields (#7230)
- Increase the external url field validation to 2048 characters

fixes: https://github.com/chatwoot/chatwoot/issues/7098
2023-05-31 19:17:24 +05:30
Sojan JoseandGitHub 373f5f5b64 chore: Disable coverage reports for .vue files (#7214)
- Disable coverage reporting for .vue files 

fixes: https://linear.app/chatwoot/issue/CW-1331
2023-05-30 19:18:19 +05:30
Sivin VargheseandGitHub ae7df60a75 fix: Special characters not being decoded (#7218) 2023-05-30 18:54:51 +05:30
412d750b6a feat: auditlogs design refactor cw1764 (#7181)
* chore: refactor auditlogs design

* chore: refactor aduit log text

* chore: fix 60% width for activity column

* chore: improve log text formatting

* Apply suggestions from code review

Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>

* feat: show agent names if available in auditlogs

* chore: add sign_out

* Apply suggestions from code review

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

* chore: handle custom user actions

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
2023-05-30 14:41:29 +05:30
f1a77ba934 feat: allow superadmins to reset cache keys for IndexedDB (#7180)
Allows super admins to reset the cache for an account. This will force the front end to fetch the data again on the next load.

fixes: https://linear.app/chatwoot/issue/CW-1817

Co-authored-by: Sojan <sojan@pepalo.com>
2023-05-29 23:05:17 +05:30
Sojan JoseandGitHub ffc6364690 chore: Improve Whatsapp Templates Sync (#7210)
- update the templates updated at, even if the API request fails ( to prevent jobs from stacking up in case of API failures upstream )
- sequence the job in batches of 25 requests per minutes schedule ( in case API response time is high, also not to send too many requests in a single batch )
- move the sync job re-rerun to 3 hours ( since we are updating the updated at even in case of failures )(prev 15 minutes )

Fixes: https://linear.app/chatwoot/issue/CW-1590
2023-05-29 22:23:22 +05:30
Sojan JoseandGitHub 3a7633b564 feat: API to create HMAC verified conversations (#7209)
Fixes: #6744
2023-05-29 21:57:24 +05:30
amplitudesandGitHub 91d1d44aa9 fix: profile picture distortion (#7203)
By applying pr-1 on a container div instead of directly on the image (which distorts it), it creates the intended padding effect without any image distortion.
2023-05-29 15:00:14 +05:30
Sojan JoseandGitHub afc97faa8b chore: Rotate pubsub token on password change (#7194)
Fixes: https://linear.app/chatwoot/issue/CW-1350/
2023-05-27 13:24:31 +05:30
1d0930ef63 fix: Updates editor to fix the extra slash issue [cw-1505][cw-1526] (#7091)
* fix: Updates editor to fix the extra slash issue

* Updates yarn package

* Updates yarn package for editor schema

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-05-26 00:54:37 +05:30
Tejaswini ChileandGitHub 776ea6d39a fix: Help center api doc (#7189) 2023-05-25 18:50:00 +05:30
Muhsin KelothandGitHub 2c85098698 fix: Mentions in assigned/participation notification(#7185) 2023-05-25 18:18:56 +05:30
Sivin VargheseandGitHub 6bd0e074dc feat: Sort agents on availability status (#7174) 2023-05-25 14:49:56 +05:30
Vishnu NarayananandGitHub 123fc73394 feat: add audit trail for sign_in and sign_out (#7158)
* feat: add audit_trail for sign_in event

* chore: ignore unrelated User model columns for auditing

* chore: fix prepend call for webhook/automation rule

* chore: add spec for sign_in event

* chore: refactor sign_in auditlog method to enterprise namespace

* feat: add sign_out audit trail

* feat: review comments
2023-05-25 14:27:30 +05:30
88cef88d80 fix: Instagram story with video not rendering (#7184)
* fix: instagram story with video not rendering

* chore: Minor fixes

---------

Co-authored-by: Tejaswini Chile <tejaswini@chatwoot.com>
2023-05-25 12:20:42 +05:30
Tejaswini ChileandGitHub 8e79cf72e1 fix: issue with slack job (#7179) 2023-05-24 20:22:34 +05:30
Sojan JoseandGitHub bfaca851f1 chore: Throttle conversation transcript endpoints (#7155)
- Throttle conversation transcripts to 20 per hour via rack attack

Fixes: https://linear.app/chatwoot/issue/CW-1630/throttle-transcript-endpoint
2023-05-24 19:56:23 +05:30
Muhsin KelothandGitHub 5618b5ebd3 fix: Enable ogg format for audio recording in API channel (#7178) 2023-05-24 15:05:31 +05:30
Muhsin KelothandGitHub 4807052c37 doc: Swagger for conversation priority API (#7172) 2023-05-23 20:39:15 +05:30
Shivam MishraandGitHub 44b1047b90 feat(perf): contacts query performance (#7175)
* feat: use more indexable where condition

* feat: add index concurrently

* chore: update schema

* refactor: update index name
2023-05-23 17:22:47 +05:30
Shivam MishraandGitHub 9c6c19c3e5 fix: CSAT filter metrics rendering & conversation reports not working [CW-1840, CW-1818] (#7170)
* fix: emoji rendering for CSAT

* feat: add tests for CSAT Metrics

* fix: allow rating in metrics

* refactor: hide satisfaction score & total response chart if rating filter is enabled

* refactor: optional chaining in group by

* fix: spacing using autofill

* test: update csat metrics tests

* test: CSAT metric card
2023-05-23 16:47:04 +05:30
Nithin David ThomasandGitHub 2764338453 fix: Fixes wrong copy being sent to users via email invite [cw-1290] (#7159)
* fix: Fixes invite email copy

* fix: Fixes wrong copy being sent to users via email invite
2023-05-23 09:37:40 +05:30
Shivam MishraandGitHub 4f0fcfcbcd feat: allow Microsoft Clarity from CDN (#7163)
* feat: add microsoft clarity to main app

* feat: mask messages

* fix: spacing
2023-05-23 08:00:01 +05:30
Pranav Raj SandGitHub fdbaf98c3b fix: Update breaking specs (#7169) 2023-05-22 17:01:27 -07:00
03bbd048a7 chore: Add action to set dark mode from the react-native-widget (#7167)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-05-22 15:34:09 -07:00
Tejaswini ChileandGitHub d481b9fbcf feat: Limit the number of custom filters per user (#7101) 2023-05-22 18:03:15 +05:30
Vishnu NarayananandGitHub 68dedc37ba chore: update auditlog timestamp format (#7128) 2023-05-22 17:08:50 +05:30
Shivam MishraandGitHub 958c0d7946 feat(perf): improve performance of conversation filter API [CW-1605] (#7120)
* feat(perf): don't use count

* feat(perf): include messages in the conversation query

* feat: include contact inboxes

* refactor: use blank?
2023-05-22 16:03:11 +05:30
Tejaswini ChileandGitHub 9553329092 chore: find instagram_direct_message query (#7157) 2023-05-22 16:01:31 +05:30
Nithin David ThomasandGitHub d01f2063d0 feat: Creates component to show articles search results [CW-1451] (#7126)
* feat: Creates component to show articles search results

* Adds story for the component
2023-05-22 15:15:19 +05:30
Tejaswini ChileandGitHub 590e4e9c1c Fix: prevent IMAPBadResponse exception from sending the authorization mail (#7154) 2023-05-22 13:51:56 +05:30
Tejaswini ChileandGitHub 0e903d2365 chore: Add delay for slack job when message has attachments (#7107) 2023-05-22 13:51:14 +05:30
Shivam MishraandGitHub d6ce1ceeeb fix: CSAT chart showing incorrect data (#7152)
* fix: computation of data for csat chart

* fix: make horizontal chart reactive
2023-05-22 11:01:01 +05:30
Sivin VargheseandGitHub e8f56d0e56 chore: Update greeting message label in settings (#7088) 2023-05-21 21:07:37 -07:00
Pranav Raj SandGitHub b8dae07c0f feat: Add an option to use darkMode only in the widget (#7151) 2023-05-22 09:05:05 +05:30
Sojan JoseandGitHub e3f4be97c0 fix: Reauthorize when channel settings updated (#7134)
Fixes: https://linear.app/chatwoot/issue/CW-1803/bug-inbox-doesnt-update-reauthorisation-required-automattically
2023-05-19 22:04:34 +05:30
Sojan 1f4d096804 Merge branch 'hotfix/2.17.1' into develop 2023-05-19 20:56:16 +05:30
Sivin VargheseandGitHub 1bcc438b21 fix: Problem when using Brazilian Portuguese (#7135)
Fixes:  #7127
2023-05-19 20:55:01 +05:30
Vishnu NarayananandGitHub 8b3d13a920 fix: add libvips to Dockerfile for rails7 (#7133)
Fixes: #7124
Fixes: https://linear.app/chatwoot/issue/CW-1805/investigate-217-upgrade-issues-for-dockerk8s-installations
2023-05-19 17:59:33 +05:30
Sojan dcdd58bae5 Bump version to 2.17.1 2023-05-19 16:00:27 +05:30
Sojan JoseandGitHub 7b9a9831c1 chore: Fix rubococop failures (#7130)
- fixes rubocop failures on develop
2023-05-19 15:32:25 +05:30
Sojan JoseandGitHub 7ab7bac6bf chore: Enable the new Rubocop rules (#7122)
fixes: https://linear.app/chatwoot/issue/CW-1574/renable-the-disabled-rubocop-rules
2023-05-19 14:37:10 +05:30
Shivam MishraandGitHub b988a01df3 fix: exclude private and activity messages from public API (#7123)
- Exclude private messages
2023-05-19 14:24:49 +05:30
105f9a27d2 feat: more CSAT filters (#7038)
* refactor: use grid instead of flex

* refactor: let the parent layout decide the spacing

* feat: add a separate date-range component

* refactor: use new date-range component

* fix: destructure all options

* refactor: separate group by component

* refactor: better handle group by data

* fix: defaul group by

* refactor: variable naming

* refactor: use DATE_RANGE_OPTIONS directly

* chore: update platform in gemfile.lock

* refactor: trigger fetch on filter change

* refactor: remove redundant method

* refactor: simplify methods and emitting

* refactor: simplify filter logic

* refactor: simplify fetching

* refactor: imports

* refactor: prop name

* refactor: CSAT response to use new APIs

* refactor: use common filter event

* refactor: use computed value for validGroupBy

* refactor: better function names

* refactor: rename prop

* refactor: remove redundant props

* refactor: separate agents filter component

* feat: add labels filter

* feat: add inboxes filter

* fix: event

* refactor: send label and inbox along with request payload

* feat: add inbox filter

* feat: add inbox to download

* refactor: use request payload from computed property

* refactor: params

* feat: add team to csat filters

* feat: add team to csat filters

* feat: add filter for rating

* feat: reverse options

* feat: add labels for ratings and translations

* feat: update translation

* fix: margin and spacing

* fix: trailing whitespace

* feat: add tests for filters

* chore: move files

* feat: add try catch with alerts

* feat: update import

* fix: imports

* Updates broken imports

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
2023-05-18 22:50:46 +05:30
132cf802d5 fix: Open AI Integration should respond in conversation language (#7092)
Appended "Reply in the user's language." to obtain the answer in the user's language.
Fixes: https://linear.app/chatwoot/issue/CW-1735/chore-use-account-language-for-the-prompts


Co-authored-by: Sojan <sojan@pepalo.com>
2023-05-18 18:50:48 +05:30
Shivam MishraandGitHub f2f33038f2 fix: Make category non-mandatory (#7117)
Creating articles in a new help center without categories throws an error. This PR fixes that.
2023-05-18 18:42:28 +05:30
Vishnu NarayananandGitHub 590ce788b9 fix: update linux script(cwctl) to account for rails7 upgrade (#7106)
* chore: modify cwctl to fetch latest redis version on new installations

* fix: add libvips for activestorage imageprocessing support

* chore: update cwctl version

* feat: upgrade redis and install libvps for existing installations
2023-05-18 16:53:47 +05:30
Clairton Rodrigo HeinzenandGitHub a25179c342 fix: Instagram events job NoMethodError: undefined method for nil:NilClass (#7105)
- Sometimes tag entry does not have messaging or standby and cause a error
2023-05-18 15:22:01 +05:30
Sivin VargheseandGitHub aea9470b6a fix: RTL issue for basic filter dropdown (#7118) 2023-05-18 15:06:18 +05:30
Muhsin KelothandGitHub 6382707146 chore: Check assignee exists or not before rendering the assignee avatar (#7104) 2023-05-17 15:41:23 +05:30
Pranav Raj SandGitHub 49ef4e54ca feat: Add cmd/ctrl click open on the conversation cards (#7100) 2023-05-16 16:57:25 -07:00
Sojan db0d32f01d Merge branch 'release/2.17.0' into develop 2023-05-16 12:34:00 +05:30
Sojan de1fb6ba4e Merge branch 'release/2.17.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-05-16 12:33:47 +05:30
Sojan 7b8bcaba11 Bump version to 2.17.0 2023-05-16 12:33:16 +05:30
Pranav Raj SandGitHub 3f39b7ad1f fix: Avoid styles getting purged on prod build (#7086) 2023-05-15 19:05:07 -07:00
Pranav Raj SandGitHub a3547c5a1f feat: Show Table of Contents in the article sidebar (#7085) 2023-05-15 18:43:16 -07:00
Tejaswini ChileandGitHub 0f776a173c Fix: Mail ate compare with nil date (#7084)
fixes: https://chatwoot-p3.sentry.io/issues/4185144468/?project=6382945
2023-05-15 21:42:22 +05:30
Sojan JoseandGitHub d320605556 chore: Fix Activerecord querry cancelled when fetching email (#7083)
limiting the query to 2 days to ensure that it runs timebound.

fixes: https://linear.app/chatwoot/issue/CW-1747/activerecordquerycanceled-pgquerycanceled-error-canceling-statement
2023-05-15 21:23:17 +05:30
Sojan JoseandGitHub ca85d4e1c6 fix: External url validation in attachment (#7082)
fixes: https://linear.app/chatwoot/issue/CW-1746/activerecordrecordinvalid-validation-failed-external-url-is-too-long
2023-05-15 20:33:58 +05:30
Tejaswini ChileandGitHub b994706265 fix: Email date attribute not being set (#7081)
Fixes: https://linear.app/chatwoot/issue/CW-1738/typeerror-no-implicit-conversion-of-nil-into-string-typeerror
2023-05-15 20:19:03 +05:30
d9f2cd0a6e chore: Enable Hebrew language for widget (#7079)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-05-15 16:56:04 +05:30
Sojan JoseandGitHub f2a34ad8aa Chore: Remove deprecation warning post rails upgrade (#7080)
Fixes: https://linear.app/chatwoot/issue/CW-1575/fix-deprecations-post-upgrade-to-rails-7
2023-05-15 16:32:27 +05:30
Tejaswini ChileandGitHub 65e91f6a6f fix: Duplicate instagram conversations for echo messages (#7078) 2023-05-15 16:25:15 +05:30
Nithin David ThomasandGitHub 7dd1562b40 fix: Welcome email copy changes and canned response API error handling [cw-1290] (#7054)
Fixes: https://linear.app/chatwoot/issue/CW-1290/minor-ui-issues
2023-05-15 16:17:55 +05:30
Shivam MishraandGitHub c70367bbd7 feat: add migration to fix image signed keys (#7076) 2023-05-15 15:13:26 +05:30
Nithin David ThomasandGitHub 5a8eff35b5 chore: Enable support for hebrew in dashboard [CW-1736] (#7077) 2023-05-15 14:03:29 +05:30
Muhsin KelothandGitHub 1936369b12 feat: Add analytics event for OpenAI (#7074)
* Add analytics event for OpenAI

* Add more variations
2023-05-15 11:57:56 +05:30
Tejaswini ChileandGitHub 354010a6e1 chore: fetch mails with multiple attachments (#7030) 2023-05-14 10:02:36 +05:30
Sojan JoseandGitHub 385eab6b96 chore: Add max length validation to text fields (#7073)
Introduces a default max length validation for all string and text fields to prevent processing large payloads.
2023-05-12 22:12:21 +05:30
198cd9b28d feat: Show next available day/hour and minutes on widget (#6902)
* feat: Show next available hour and minutes on widget

* chore: Adds spec

* chore: Show days

* chore: Code clean up

* chore: Review fixes

* chore: Minor fixes

* chore: Review suggestion fixes

* chore: Minor fixes

* Added timezone to widget payload

* chore: Adds time zone

* chore: Review fixes

* chore: Adds comments

* chore: Rounded up min with nearest multiple of 5

* chore: Review fixes

* chore: Review fixes

* chore: Review fixes

* chore: Review fixes

* chore: Fix specs

* chore: Review fixes

* chore: Fix specs

* chore: Review fixes

* chore: Moved day names to i18n

* chore: Review fixes

* chore: Fix specs

---------

Co-authored-by: Tejaswini Chile <tejaswini@chatwoot.com>
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2023-05-12 19:25:51 +05:30
Nithin David ThomasandGitHub abdf00d2cf fixes: Editor adding slash char while creating block quotes [cw-1505] (#7069)
* fixes: Editor adding slash char while creating block quotes [cw-1505]

* Update with latest package
2023-05-12 16:39:18 +05:30
Muhsin KelothandGitHub 2c3160cfee feat: API to list all attachments for a conversation (#7059)
Fixes: https://linear.app/chatwoot/issue/CW-1678/api-to-list-all-attachments-for-a-conversation
2023-05-12 15:48:06 +05:30
Muhsin KelothandGitHub 708bddf4db feat: Refetch the latest messages on action cable reconnect in widget (#6996) 2023-05-12 14:05:22 +05:30
Pranav Raj SandGitHub 020dcc4dc7 fix: Avoid audio URLs getting cached at the frontend (#7062) 2023-05-11 12:14:56 -07:00
271263bcc2 feat: SLA CRUD APIs (EE) (#7027)
Fixes: https://linear.app/chatwoot/issue/CW-1613/sla-api

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-05-11 22:42:56 +05:30
Muhsin KelothandGitHub c97d6021e0 chore: Add migration to set the default empty string value for contact name (#7052)
Fixes: https://linear.app/chatwoot/issue/CW-1650/issue-with-the-contact-name
2023-05-11 17:35:19 +05:30
d99997d17d feat: Add ability to filter Conversations by underlying source_id (#6979)
This change adds the ability to include a `source_id` param when querying the `/api/v1/accounts/{account_id}/conversations/search` endpoint. It restricts to results to only conversations related to a contact_inbox with the provided parameter. My motivation for adding this feature was to allow an external API to communicate with a specific conversation with only an awareness of the conversation `source_id` from the client.


Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-05-11 17:32:29 +05:30
Sivin VargheseandGitHub 9c5d062efc fix: Fix greeting message label in settings (#7056) 2023-05-11 17:30:07 +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 S 18c5c47836 Merge branch 'hotfix/2.16.1' 2023-05-02 15:53:33 -07:00
1328 changed files with 27753 additions and 6494 deletions
+1 -1
View File
@@ -157,7 +157,7 @@ jobs:
- run:
name: Code Climate Test Coverage
command: |
~/tmp/cc-test-reporter format-coverage -t lcov -o coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json buildreports/lcov.info
~/tmp/cc-test-reporter format-coverage -t lcov -o "coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
- persist_to_workspace:
root: coverage
+3
View File
@@ -173,6 +173,9 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
## LogRocket
# LOG_ROCKET_PROJECT_ID=xxxxx/some-project
# MICROSOFT CLARITY
# MS_CLARITY_TOKEN=xxxxxxxxx
## Scout
## https://scoutapm.com/docs/ruby/configuration
# SCOUT_KEY=YOURKEY
+23
View File
@@ -0,0 +1,23 @@
# ref: https://github.com/amannn/action-semantic-pull-request
# ensure PR title is in semantic format
name: "Lint PR"
on:
pull_request_target:
types:
- opened
- edited
- synchronize
permissions:
pull-requests: read
jobs:
main:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+38
View File
@@ -0,0 +1,38 @@
name: Run Size Limit Check
on:
pull_request:
branches:
- develop
jobs:
test:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: actions/setup-node@v3
with:
node-version: 16
- name: yarn
run: yarn install
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Run asset compile
run: bundle exec rake assets:precompile
- name: Size Check
run: yarn run size
+23 -31
View File
@@ -162,41 +162,33 @@ Rails/RenderInline:
Performance/CollectionLiteralInLoop:
Exclude:
- '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
Rails/ThreeStateBooleanColumn:
Exclude:
- 'db/migrate/20200509044639_add_hide_input_flag_to_bot_config.rb'
- 'db/migrate/20200605130625_agent_away_message_to_auto_reply.rb'
- 'db/migrate/20200606132552_create_labels.rb'
- 'db/migrate/20201027135006_create_working_hours.rb'
- 'db/migrate/20210112174124_add_hmac_token_to_inbox.rb'
- 'db/migrate/20210114202310_create_teams.rb'
- 'db/migrate/20210212154240_add_request_for_email_on_channel_web_widget.rb'
- 'db/migrate/20210428135041_add_campaigns.rb'
- 'db/migrate/20210602182058_add_hmac_to_api_channel.rb'
- 'db/migrate/20210609133433_add_email_collect_to_inboxes.rb'
- 'db/migrate/20210618095823_add_csat_toggle_for_inbox.rb'
- 'db/migrate/20210927062350_add_trigger_only_during_business_hours_collect_to_campaigns.rb'
- 'db/migrate/20211027073553_add_imap_smtp_config_to_channel_email.rb'
- 'db/migrate/20211109143122_add_tweet_enabled_flag_to_twitter_channel.rb'
- 'db/migrate/20211216110209_add_allow_messages_after_resolved_to_inbox.rb'
- 'db/migrate/20220116103902_add_open_ssl_verify_mode_to_channel_email.rb'
- 'db/migrate/20220216151613_add_open_all_day_to_working_hour.rb'
- 'db/migrate/20220511072655_add_archive_column_to_portal.rb'
- 'db/migrate/20230503101201_create_sla_policies.rb'
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:
RSpec/NamedSubject:
Enabled: false
# we should bring this down
RSpec/MultipleMemoizedHelpers:
Max: 14
+1
View File
@@ -75,6 +75,7 @@ gem 'jwt'
gem 'pundit'
# super admin
gem 'administrate'
gem 'administrate-field-active_storage'
##--- gems for pubsub service ---##
# https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/
+74 -68
View File
@@ -33,70 +33,70 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.4.3)
actionpack (= 7.0.4.3)
activesupport (= 7.0.4.3)
actioncable (7.0.5)
actionpack (= 7.0.5)
activesupport (= 7.0.5)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.4.3)
actionpack (= 7.0.4.3)
activejob (= 7.0.4.3)
activerecord (= 7.0.4.3)
activestorage (= 7.0.4.3)
activesupport (= 7.0.4.3)
actionmailbox (7.0.5)
actionpack (= 7.0.5)
activejob (= 7.0.5)
activerecord (= 7.0.5)
activestorage (= 7.0.5)
activesupport (= 7.0.5)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.4.3)
actionpack (= 7.0.4.3)
actionview (= 7.0.4.3)
activejob (= 7.0.4.3)
activesupport (= 7.0.4.3)
actionmailer (7.0.5)
actionpack (= 7.0.5)
actionview (= 7.0.5)
activejob (= 7.0.5)
activesupport (= 7.0.5)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.4.3)
actionview (= 7.0.4.3)
activesupport (= 7.0.4.3)
rack (~> 2.0, >= 2.2.0)
actionpack (7.0.5)
actionview (= 7.0.5)
activesupport (= 7.0.5)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.4.3)
actionpack (= 7.0.4.3)
activerecord (= 7.0.4.3)
activestorage (= 7.0.4.3)
activesupport (= 7.0.4.3)
actiontext (7.0.5)
actionpack (= 7.0.5)
activerecord (= 7.0.5)
activestorage (= 7.0.5)
activesupport (= 7.0.5)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.4.3)
activesupport (= 7.0.4.3)
actionview (7.0.5)
activesupport (= 7.0.5)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (7.0.4.3)
activesupport (= 7.0.4.3)
activejob (7.0.5)
activesupport (= 7.0.5)
globalid (>= 0.3.6)
activemodel (7.0.4.3)
activesupport (= 7.0.4.3)
activerecord (7.0.4.3)
activemodel (= 7.0.4.3)
activesupport (= 7.0.4.3)
activemodel (7.0.5)
activesupport (= 7.0.5)
activerecord (7.0.5)
activemodel (= 7.0.5)
activesupport (= 7.0.5)
activerecord-import (1.4.1)
activerecord (>= 4.2)
activestorage (7.0.4.3)
actionpack (= 7.0.4.3)
activejob (= 7.0.4.3)
activerecord (= 7.0.4.3)
activesupport (= 7.0.4.3)
activestorage (7.0.5)
actionpack (= 7.0.5)
activejob (= 7.0.5)
activerecord (= 7.0.5)
activesupport (= 7.0.5)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.4.3)
activesupport (7.0.5)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -113,6 +113,9 @@ GEM
kaminari (>= 1.0)
sassc-rails (~> 2.1)
selectize-rails (~> 0.6)
administrate-field-active_storage (0.4.2)
administrate (>= 0.2.2)
rails (>= 7.0)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
@@ -353,7 +356,7 @@ GEM
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.13.0)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
@@ -420,9 +423,9 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.20.0)
loofah (2.21.3)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
nokogiri (>= 1.12.0)
mail (2.8.1)
mini_mime (>= 0.1.1)
net-imap
@@ -462,14 +465,14 @@ GEM
sidekiq
newrelic_rpm (8.16.0)
nio4r (2.5.9)
nokogiri (1.14.3)
mini_portile2 (~> 2.8.0)
nokogiri (1.15.2)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.14.3-arm64-darwin)
nokogiri (1.15.2-arm64-darwin)
racc (~> 1.4)
nokogiri (1.14.3-x86_64-darwin)
nokogiri (1.15.2-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.14.3-x86_64-linux)
nokogiri (1.15.2-x86_64-linux)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -522,7 +525,7 @@ GEM
pundit (2.3.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.6.2)
racc (1.7.0)
rack (2.2.7)
rack-attack (6.6.1)
rack (>= 1.0, < 3)
@@ -537,28 +540,29 @@ GEM
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (7.0.4.3)
actioncable (= 7.0.4.3)
actionmailbox (= 7.0.4.3)
actionmailer (= 7.0.4.3)
actionpack (= 7.0.4.3)
actiontext (= 7.0.4.3)
actionview (= 7.0.4.3)
activejob (= 7.0.4.3)
activemodel (= 7.0.4.3)
activerecord (= 7.0.4.3)
activestorage (= 7.0.4.3)
activesupport (= 7.0.4.3)
rails (7.0.5)
actioncable (= 7.0.5)
actionmailbox (= 7.0.5)
actionmailer (= 7.0.5)
actionpack (= 7.0.5)
actiontext (= 7.0.5)
actionview (= 7.0.5)
activejob (= 7.0.5)
activemodel (= 7.0.5)
activerecord (= 7.0.5)
activestorage (= 7.0.5)
activesupport (= 7.0.5)
bundler (>= 1.15.0)
railties (= 7.0.4.3)
railties (= 7.0.5)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.5.0)
loofah (~> 2.19, >= 2.19.1)
railties (7.0.4.3)
actionpack (= 7.0.4.3)
activesupport (= 7.0.4.3)
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.0.5)
actionpack (= 7.0.5)
activesupport (= 7.0.5)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -722,8 +726,8 @@ GEM
stripe (8.5.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.2.1)
tilt (2.1.0)
thor (1.2.2)
tilt (2.2.0)
time_diff (0.3.0)
activesupport
i18n
@@ -785,6 +789,7 @@ GEM
PLATFORMS
arm64-darwin-20
arm64-darwin-21
arm64-darwin-22
ruby
x86_64-darwin-18
x86_64-darwin-20
@@ -797,6 +802,7 @@ DEPENDENCIES
activerecord-import
acts-as-taggable-on
administrate
administrate-field-active_storage
annotate
attr_extras
audited (~> 5.3)
@@ -915,4 +921,4 @@ RUBY VERSION
ruby 3.2.2p185
BUNDLED WITH
2.4.10
2.4.6
+1 -1
View File
@@ -1 +1 @@
2.2.0
2.17.0
+1 -1
View File
@@ -1 +1 @@
2.1.0
2.3.0
+1
View File
@@ -1,5 +1,6 @@
//= link_tree ../images
//= link administrate/application.css
//= link administrate/application.js
//= link administrate-field-active_storage/application.css
//= link dashboardChart.js
//= link secretField.js
@@ -63,7 +63,9 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
end
def conversation
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
@conversation ||= Conversation.where(conversation_params).find_by(
"additional_attributes ->> 'type' = 'instagram_direct_message'"
) || build_conversation
end
def message_content
@@ -94,8 +96,10 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id
contact_inbox_id: @contact_inbox.id,
additional_attributes: { type: 'instagram_direct_message' }
))
end
@@ -103,10 +107,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id,
additional_attributes: {
type: 'instagram_direct_message'
}
contact_id: contact.id
}
end
+1 -2
View File
@@ -96,10 +96,9 @@ class V2::ReportBuilder
def conversations
@open_conversations = scope.conversations.where(account_id: @account.id).open
first_response_count = @account.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count
metric = {
open: @open_conversations.count,
unattended: @open_conversations.count - first_response_count
unattended: @open_conversations.unattended.count
}
metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account)
metric
@@ -16,6 +16,9 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
end
def show; end
def edit; end
def create
@article = @portal.articles.create!(article_params)
@article.associate_root_article(article_params[:associated_article_id])
@@ -23,10 +26,6 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end
def edit; end
def show; end
def update
@article.update!(article_params) if params[:article].present?
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
@@ -6,6 +6,8 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rules = Current.account.automation_rules
end
def show; end
def create
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
@automation_rule.actions = params[:actions]
@@ -28,8 +30,6 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end
def show; end
def update
ActiveRecord::Base.transaction do
automation_rule_update
@@ -6,21 +6,21 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
@campaigns = Current.account.campaigns
end
def show; end
def create
@campaign = Current.account.campaigns.create!(campaign_params)
end
def update
@campaign.update!(campaign_params)
end
def destroy
@campaign.destroy!
head :ok
end
def show; end
def update
@campaign.update!(campaign_params)
end
private
def campaign
@@ -9,6 +9,8 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
@categories = @portal.categories.search(params)
end
def show; end
def create
@category = @portal.categories.create!(category_params)
@category.related_categories << related_categories_records
@@ -17,8 +19,6 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
@category.save!
end
def show; end
def update
@category.update!(category_params)
@category.related_categories = related_categories_records if related_categories_records.any?
@@ -1,11 +1,13 @@
class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts::Contacts::BaseController
include HmacConcern
before_action :ensure_inbox, only: [:create]
def create
@contact_inbox = ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
source_id: params[:source_id]
source_id: params[:source_id],
hmac_verified: hmac_verified?
).perform
end
@@ -5,21 +5,21 @@ class Api::V1::Accounts::Contacts::NotesController < Api::V1::Accounts::Contacts
@notes = @contact.notes.latest.includes(:user)
end
def show; end
def create
@note = @contact.notes.create!(note_params)
end
def update
@note.update(note_params)
end
def destroy
@note.destroy!
head :ok
end
def show; end
def update
@note.update(note_params)
end
private
def note
@@ -42,6 +42,12 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
head :ok
end
def export
column_names = params['column_names']
Account::ContactsExportJob.perform_later(Current.account.id, column_names)
head :ok, message: I18n.t('errors.contacts.export.success')
end
# returns online contacts
def active
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::BaseController
include EnsureCurrentAccountHelper
before_action :conversation
private
@@ -1,6 +1,7 @@
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController
include Events::Types
include DateRangeHelper
include HmacConcern
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :inbox, :contact, :contact_inbox, only: [:create]
@@ -22,6 +23,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversations_count = result[:count]
end
def attachments
@attachments = @conversation.attachments
end
def show; end
def create
ActiveRecord::Base.transaction do
@conversation = ConversationBuilder.new(params: params, contact_inbox: @contact_inbox).perform
@@ -29,8 +36,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
end
def show; end
def filter
result = ::Conversations::FilterService.new(params.permit!, current_user).perform
@conversations = result[:conversations]
@@ -100,9 +105,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def set_conversation_status
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
# commenting this out to see if there are any errors, if not we can remove this in subsequent releases
# status = params[:status] == 'bot' ? 'pending' : params[:status]
@conversation.status = params[:status]
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
end
@@ -148,7 +150,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
source_id: params[:source_id]
source_id: params[:source_id],
hmac_verified: hmac_verified?
).perform
end
@@ -34,9 +34,12 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
end
def set_csat_survey_responses
@csat_survey_responses = filtrate(
Current.account.csat_survey_responses.includes([:conversation, :assigned_agent, :contact])
).filter_by_created_at(range).filter_by_assigned_agent_id(params[:user_ids])
base_query = Current.account.csat_survey_responses.includes([:conversation, :assigned_agent, :contact])
@csat_survey_responses = filtrate(base_query).filter_by_created_at(range)
.filter_by_assigned_agent_id(params[:user_ids])
.filter_by_inbox_id(params[:inbox_id])
.filter_by_team_id(params[:team_id])
.filter_by_rating(params[:rating])
end
def set_current_page_surveys
@@ -1,4 +1,5 @@
class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_custom_filters, except: [:create]
before_action :fetch_custom_filter, only: [:show, :update, :destroy]
DEFAULT_FILTER_TYPE = 'conversation'.freeze
@@ -11,6 +12,7 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
@custom_filter = current_user.custom_filters.create!(
permitted_payload.merge(account_id: Current.account.id)
)
render json: { error: @custom_filter.errors.messages }, status: :unprocessable_entity and return unless @custom_filter.valid?
end
def update
@@ -2,6 +2,11 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
before_action :fetch_inbox
before_action :current_agents_ids, only: [:create, :update]
def show
authorize @inbox, :show?
fetch_updated_agents
end
def create
authorize @inbox, :create?
ActiveRecord::Base.transaction do
@@ -10,11 +15,6 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
fetch_updated_agents
end
def show
authorize @inbox, :show?
fetch_updated_agents
end
def update
authorize @inbox, :update?
update_agents_list
@@ -44,26 +44,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def update
@inbox.update!(permitted_params.except(:channel))
update_inbox_working_hours
channel_attributes = get_channel_attributes(@inbox.channel_type)
# Inbox update doesn't necessarily need channel attributes
return if permitted_params(channel_attributes)[:channel].blank?
if @inbox.inbox_type == 'Email'
begin
validate_email_channel(channel_attributes)
rescue StandardError => e
render json: { message: e }, status: :unprocessable_entity and return
end
@inbox.channel.reauthorized!
end
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
update_channel_feature_flags
end
def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
update_channel if channel_update_required?
end
def agent_bot
@@ -103,6 +84,35 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
end
def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
end
def update_channel
channel_attributes = get_channel_attributes(@inbox.channel_type)
return if permitted_params(channel_attributes)[:channel].blank?
validate_and_update_email_channel(channel_attributes) if @inbox.inbox_type == 'Email'
reauthorize_and_update_channel(channel_attributes)
update_channel_feature_flags
end
def channel_update_required?
permitted_params(get_channel_attributes(@inbox.channel_type))[:channel].present?
end
def validate_and_update_email_channel(channel_attributes)
validate_email_channel(channel_attributes)
rescue StandardError => e
render json: { message: e }, status: :unprocessable_entity and return
end
def reauthorize_and_update_channel(channel_attributes)
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
end
def update_channel_feature_flags
return unless @inbox.web_widget?
return unless permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel].key? :selected_feature_flags
@@ -6,6 +6,10 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
@macros = Macro.with_visibility(current_user, params)
end
def show
head :not_found if @macro.nil?
end
def create
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
@macro.set_visibility(current_user, permitted_params)
@@ -18,8 +22,16 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
@macro
end
def show
head :not_found if @macro.nil?
def update
ActiveRecord::Base.transaction do
@macro.update!(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
process_attachments
@macro.save!
rescue StandardError => e
Rails.logger.error e
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
end
end
def destroy
@@ -37,18 +49,6 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end
def update
ActiveRecord::Base.transaction do
@macro.update!(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
process_attachments
@macro.save!
rescue StandardError => e
Rails.logger.error e
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
end
end
def execute
::MacrosExecutionJob.perform_later(@macro, conversation_ids: params[:conversation_ids], user: Current.user)
@@ -14,6 +14,11 @@ class Api::V1::AccountsController < Api::BaseController
CustomExceptions::Account::UserErrors,
with: :render_error_response
def show
@latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION)
render 'api/v1/accounts/show', format: :json
end
def create
@user, @account = AccountBuilder.new(
account_name: account_params[:account_name],
@@ -32,14 +37,10 @@ class Api::V1::AccountsController < Api::BaseController
end
def cache_keys
expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes
render json: { cache_keys: get_cache_keys }, status: :ok
end
def show
@latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION)
render 'api/v1/accounts/show', format: :json
end
def update
@account.update!(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration))
end
@@ -48,7 +48,8 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def message_finder_params
{
filter_internal_messages: true,
before: permitted_params[:before]
before: permitted_params[:before],
after: permitted_params[:after]
}
end
@@ -62,7 +63,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def permitted_params
# timestamp parameter is used in create conversation method
params.permit(:id, :before, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
end
def set_message
+5
View File
@@ -0,0 +1,5 @@
module HmacConcern
def hmac_verified?
ActiveModel::Type::Boolean.new.cast(params[:hmac_verified]).present?
end
end
@@ -4,6 +4,16 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
skip_before_action :require_no_authentication, raise: false
skip_before_action :authenticate_user!, raise: false
def create
@user = User.find_by(email: params[:email])
if @user
@user.send_reset_password_instructions
build_response(I18n.t('messages.reset_password_success'), 200)
else
build_response(I18n.t('messages.reset_password_failure'), 404)
end
end
def update
# params: reset_password_token, password, password_confirmation
original_token = params[:reset_password_token]
@@ -17,16 +27,6 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
end
end
def create
@user = User.find_by(email: params[:email])
if @user
@user.send_reset_password_instructions
build_response(I18n.t('messages.reset_password_success'), 200)
else
build_response(I18n.t('messages.reset_password_failure'), 404)
end
end
private
def reset_password_and_confirmation(recoverable)
@@ -1,4 +1,4 @@
class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsController
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
# Prevent session parameter from being passed
# Unpermitted parameter: session
wrap_parameters format: []
@@ -37,3 +37,5 @@ class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsControlle
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
end
end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
@@ -1,4 +1,4 @@
class DeviseOverrides::TokenValidationsController < ::DeviseTokenAuth::TokenValidationsController
class DeviseOverrides::TokenValidationsController < DeviseTokenAuth::TokenValidationsController
def validate_token
# @resource will have been set by set_user_by_token concern
if @resource
@@ -1,12 +1,12 @@
class Platform::Api::V1::AccountsController < PlatformController
def show; end
def create
@resource = Account.create!(account_params)
update_resource_features
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end
def show; end
def update
@resource.assign_attributes(account_params)
update_resource_features
@@ -6,14 +6,14 @@ class Platform::Api::V1::AgentBotsController < PlatformController
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
end
def show; end
def create
@resource = AgentBot.new(agent_bot_params)
@resource.save!
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end
def show; end
def update
@resource.update!(agent_bot_params)
end
@@ -5,6 +5,8 @@ class Platform::Api::V1::UsersController < PlatformController
before_action(only: [:login]) { set_resource }
before_action(only: [:login]) { validate_platform_app_permissible }
def show; end
def create
@resource = (User.find_by(email: user_params[:email]) || User.new(user_params))
@resource.skip_confirmation!
@@ -16,8 +18,6 @@ class Platform::Api::V1::UsersController < PlatformController
render json: { url: @resource.generate_sso_link }
end
def show; end
def update
@resource.assign_attributes(user_update_params)
@@ -2,6 +2,8 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
before_action :contact_inbox, except: [:create]
before_action :process_hmac
def show; end
def create
source_id = params[:source_id] || SecureRandom.uuid
@contact_inbox = ::ContactInboxWithContactBuilder.new(
@@ -11,8 +13,6 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
).perform
end
def show; end
def update
contact_identify_action = ContactIdentifyAction.new(
contact: @contact_inbox.contact,
@@ -43,8 +43,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end
def render_article_content(content)
# rubocop:disable Rails/OutputSafety
CommonMarker.render_html(content).html_safe
# rubocop:enable Rails/OutputSafety
ChatwootMarkdownRenderer.new(content).render_article
end
end
@@ -6,6 +6,8 @@ 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?
yield
end
def switch_locale_with_portal(&)
@@ -45,13 +45,24 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
def seed
Internal::SeedAccountJob.perform_later(requested_resource)
# rubocop:disable Rails/I18nLocaleTexts
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account seeding triggered')
# rubocop:enable Rails/I18nLocaleTexts
end
def reset_cache
requested_resource.reset_cache_keys
# rubocop:disable Rails/I18nLocaleTexts
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Cache keys cleared')
# rubocop:enable Rails/I18nLocaleTexts
end
def destroy
account = Account.find(params[:id])
DeleteObjectJob.perform_later(account) if account.present?
# rubocop:disable Rails/I18nLocaleTexts
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account deletion is in progress.')
# rubocop:enable Rails/I18nLocaleTexts
end
end
@@ -41,4 +41,14 @@ class SuperAdmin::AgentBotsController < SuperAdmin::ApplicationController
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
# for more information
def destroy_avatar
avatar = requested_resource.avatar
avatar.purge
redirect_back(fallback_location: super_admin_agent_bots_path)
end
def scoped_resource
resource_class.with_attached_avatar
end
end
@@ -5,6 +5,17 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
sha
postgres_status
redis_metrics
chatwoot_edition
end
def chatwoot_edition
@metrics['Chatwoot edition'] = if ChatwootApp.enterprise?
'Enterprise'
elsif ChatwootApp.custom?
'Custom'
else
'Community'
end
end
def chatwoot_version
@@ -45,6 +45,17 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController
# empty values into nil values. It uses other APIs such as `resource_class`
# and `dashboard`:
#
def destroy_avatar
avatar = requested_resource.avatar
avatar.purge
redirect_back(fallback_location: super_admin_users_path)
end
def scoped_resource
resource_class.with_attached_avatar
end
def resource_params
permitted_params = super
permitted_params.delete(:password) if permitted_params[:password].blank?
+1 -1
View File
@@ -1,7 +1,7 @@
class SwaggerController < ApplicationController
def respond
if Rails.env.development? || Rails.env.test?
render inline: File.read(Rails.root.join('swagger', derived_path))
render inline: Rails.root.join('swagger', derived_path).read
else
head :not_found
end
+7
View File
@@ -10,6 +10,11 @@ class AgentBotDashboard < Administrate::BaseDashboard
ATTRIBUTE_TYPES = {
access_token: Field::HasOne,
avatar_url: AvatarField,
avatar: Field::ActiveStorage.with_options(
destroy_url: proc do |_namespace, _resource, attachment|
[:avatar_super_admin_agent_bot, { attachment_id: attachment.id }]
end
),
id: Field::Number,
name: Field::String,
account: Field::BelongsTo.with_options(searchable: true, searchable_field: 'name', order: 'id DESC'),
@@ -36,6 +41,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
# an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = %i[
id
avatar_url
account
name
description
@@ -47,6 +53,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
# on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[
name
avatar
account
description
outgoing_url
+6
View File
@@ -11,6 +11,11 @@ class UserDashboard < Administrate::BaseDashboard
account_users: Field::HasMany,
id: Field::Number,
avatar_url: AvatarField,
avatar: Field::ActiveStorage.with_options(
destroy_url: proc do |_namespace, _resource, attachment|
[:avatar_super_admin_user, { attachment_id: attachment.id }]
end
),
provider: Field::String,
uid: Field::String,
password: Field::Password,
@@ -69,6 +74,7 @@ class UserDashboard < Administrate::BaseDashboard
# on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[
name
avatar
display_name
email
password
+20 -5
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
@@ -53,9 +54,10 @@ class ConversationFinder
find_all_conversations
filter_by_status unless params[:q]
filter_by_team if @team
filter_by_labels if params[:labels]
filter_by_query if params[:q]
filter_by_team
filter_by_labels
filter_by_query
filter_by_source_id
end
def set_inboxes
@@ -100,12 +102,14 @@ class ConversationFinder
when 'participating'
@conversations = current_user.participating_conversations.where(account_id: current_account.id)
when 'unattended'
@conversations = @conversations.where(first_reply_created_at: nil)
@conversations = @conversations.unattended
end
@conversations
end
def filter_by_query
return unless params[:q]
allowed_message_types = [Message.message_types[:incoming], Message.message_types[:outgoing]]
@conversations = conversations.joins(:messages).where('messages.content ILIKE :search', search: "%#{params[:q]}%")
.where(messages: { message_type: allowed_message_types }).includes(:messages)
@@ -120,13 +124,24 @@ class ConversationFinder
end
def filter_by_team
return unless @team
@conversations = @conversations.where(team: @team)
end
def filter_by_labels
return unless params[:labels]
@conversations = @conversations.tagged_with(params[:labels], any: true)
end
def filter_by_source_id
return unless params[:source_id]
@conversations = @conversations.joins(:contact_inbox)
@conversations = @conversations.where(contact_inboxes: { source_id: params[:source_id] })
end
def set_count_for_all_conversations
[
@conversations.assigned_to(current_user).count,
+21
View File
@@ -0,0 +1,21 @@
module BillingHelper
private
def default_plan?(account)
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
default_plan = installation_config&.value&.first
# Return false if not plans are configured, so that no checks are enforced
return false if default_plan.blank?
account.custom_attributes['plan_name'].nil? || account.custom_attributes['plan_name'] == default_plan['name']
end
def conversations_this_month(account)
account.conversations.where('created_at > ?', 30.days.ago).count
end
def non_web_inboxes(account)
account.inboxes.where.not(channel_type: Channel::WebWidget.to_s).count
end
end
+1 -3
View File
@@ -7,8 +7,6 @@ module MessageFormatHelper
end
def render_message_content(message_content)
# rubocop:disable Rails/OutputSafety
CommonMarker.render_html(message_content).html_safe
# rubocop:enable Rails/OutputSafety
ChatwootMarkdownRenderer.new(message_content).render_message
end
end
+3 -1
View File
@@ -29,7 +29,9 @@ module ReportHelper
end
def resolutions_count
(get_grouped_values scope.conversations.where(account_id: account.id).resolved).count
object_scope = scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_resolved,
conversations: { status: :resolved }).distinct
(get_grouped_values object_scope).count
end
def avg_first_response_time
+9 -5
View File
@@ -6,6 +6,10 @@
:class="{ 'app-rtl--wrapper': isRTLView }"
>
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
<template v-if="!accountUIFlags.isFetchingItem && currentAccountId">
<payment-pending-banner />
<upgrade-banner />
</template>
<transition name="fade" mode="out-in">
<router-view />
</transition>
@@ -25,6 +29,8 @@ import AddAccountModal from '../dashboard/components/layout/sidebarComponents/Ad
import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification';
import UpdateBanner from './components/app/UpdateBanner.vue';
import UpgradeBanner from './components/app/UpgradeBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import vueActionCable from './helper/actionCable';
import WootSnackbarBox from './components/SnackbarContainer';
import rtlMixin from 'shared/mixins/rtlMixin';
@@ -41,7 +47,9 @@ export default {
LoadingState,
NetworkNotification,
UpdateBanner,
PaymentPendingBanner,
WootSnackbarBox,
UpgradeBanner,
},
mixins: [rtlMixin],
@@ -59,6 +67,7 @@ export default {
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
currentAccountId: 'getCurrentAccountId',
}),
hasAccounts() {
@@ -115,11 +124,6 @@ export default {
<style lang="scss">
@import './assets/scss/app';
.update-banner {
height: var(--space-larger);
align-items: center;
font-size: var(--font-size-small) !important;
}
</style>
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
+4
View File
@@ -75,6 +75,10 @@ class ContactAPI extends ApiClient {
destroyAvatar(contactId) {
return axios.delete(`${this.url}/${contactId}/avatar`);
}
exportContacts() {
return axios.get(`${this.url}/export`);
}
}
export default new ContactAPI();
+11 -4
View File
@@ -6,7 +6,7 @@ class CSATReportsAPI extends ApiClient {
super('csat_survey_responses', { accountScoped: true });
}
get({ page, from, to, user_ids } = {}) {
get({ page, from, to, user_ids, inbox_id, team_id, rating } = {}) {
return axios.get(this.url, {
params: {
page,
@@ -14,24 +14,31 @@ class CSATReportsAPI extends ApiClient {
until: to,
sort: '-created_at',
user_ids,
inbox_id,
team_id,
rating,
},
});
}
download({ from, to, user_ids } = {}) {
download({ from, to, user_ids, inbox_id, team_id, rating } = {}) {
return axios.get(`${this.url}/download`, {
params: {
since: from,
until: to,
sort: '-created_at',
user_ids,
inbox_id,
team_id,
rating,
},
});
}
getMetrics({ from, to, user_ids } = {}) {
getMetrics({ from, to, user_ids, inbox_id, team_id, rating } = {}) {
// no ratings for metrics
return axios.get(`${this.url}/metrics`, {
params: { since: from, until: to, user_ids },
params: { since: from, until: to, user_ids, inbox_id, team_id, rating },
});
}
}
@@ -13,6 +13,10 @@ class EnterpriseAccountAPI extends ApiClient {
subscription() {
return axios.post(`${this.url}subscription`);
}
getLimits() {
return axios.get(`${this.url}limits`);
}
}
export default new EnterpriseAccountAPI();
@@ -127,6 +127,10 @@ class ConversationApi extends ApiClient {
user_ids: userIds,
});
}
getAllAttachments(conversationId) {
return axios.get(`${this.url}/${conversationId}/attachments`);
}
}
export default new ConversationApi();
@@ -7,14 +7,22 @@ class OpenAIAPI extends ApiClient {
super('integrations', { accountScoped: true });
}
processEvent({ name = 'rephrase', content, tone, hookId }) {
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: name,
data: {
tone,
content,
},
name: type,
data,
},
});
}
@@ -210,5 +210,12 @@ describe('#ConversationAPI', () => {
{ params: { page: payload.page } }
);
});
it('#getAllAttachments', () => {
conversationAPI.getAllAttachments(1);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/conversations/1/attachments'
);
});
});
});
@@ -5,12 +5,20 @@
}
.date-picker {
.mx-datepicker {
width: 100%;
&.no-margin {
.mx-input {
margin-bottom: 0;
}
}
.mx-datepicker-range {
width: 320px;
&:not(.auto-width) {
.mx-datepicker-range {
width: 320px;
}
}
.mx-datepicker {
width: 100%;
}
.mx-input {
@@ -18,7 +26,7 @@
border-radius: var(--border-radius-normal);
box-shadow: none;
display: flex;
height: 4.0rem;
height: 4rem;
}
.mx-input:disabled,
@@ -27,3 +35,32 @@
cursor: pointer;
}
}
.mx-datepicker-inline {
width: 100%;
.mx-calendar {
width: 100%;
}
.cell.disabled {
background-color: var(--s-25);
color: var(--s-200);
}
.mx-time-item.disabled {
background-color: var(--s-25);
}
.today {
font-weight: var(--font-weight-bold);
}
.mx-datepicker-main {
border: 0;
}
.mx-time-header {
border: 0;
}
}
@@ -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,
@@ -261,6 +261,12 @@
}
}
// Basic filter dropdown
.basic-filter {
left: 0;
right: unset;
}
// Card label
.label-container {
.label {
@@ -13,7 +13,9 @@
}
.multiselect {
margin-bottom: var(--space-normal);
&:not(.no-margin) {
margin-bottom: var(--space-normal);
}
&.multiselect--disabled {
opacity: 0.8;
@@ -29,13 +29,13 @@
}
.modal-image {
max-height: 90%;
max-width: 90%;
max-height: 76vh;
max-width: 76vw;
}
.modal-video {
max-height: 75vh;
max-width: 100%;
max-height: 76vh;
max-width: 76vw;
}
&::before {
@@ -53,16 +53,6 @@
width: 100%;
}
}
.video {
.modal-container {
width: auto;
.modal--close {
z-index: var(--z-index-low);
}
}
}
}
.conversations-list-wrap {
@@ -400,4 +390,3 @@
margin-bottom: 0;
}
}
@@ -47,6 +47,14 @@
/>
</div>
<div v-if="hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="onToggleAdvanceFiltersModal"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
size="tiny"
@@ -168,8 +176,11 @@
v-if="showAdvancedFilters"
:initial-filter-types="advancedFilterTypes"
:initial-applied-filters="appliedFilter"
:active-folder-name="activeFolderName"
:on-close="closeAdvanceFiltersModal"
:is-folder-view="hasActiveFolders"
@applyFilter="onApplyFilter"
@updateFolder="onUpdateSavedFilter"
/>
</woot-modal>
</div>
@@ -193,6 +204,9 @@ import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCust
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import alertMixin from 'shared/mixins/alertMixin';
import filterMixin from 'shared/mixins/filterMixin';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
import {
hasPressedAltAndJKey,
@@ -289,6 +303,11 @@ export default {
appliedFilters: 'getAppliedConversationFilters',
folders: 'customViews/getCustomViews',
inboxes: 'inboxes/getInboxes',
agentList: 'agents/getAgents',
teamsList: 'teams/getTeams',
inboxesList: 'inboxes/getInboxes',
campaigns: 'campaigns/getAllCampaigns',
labels: 'labels/getLabels',
}),
hasAppliedFilters() {
return this.appliedFilters.length !== 0;
@@ -451,6 +470,9 @@ export default {
}
return undefined;
},
activeFolderName() {
return this.activeFolder?.name;
},
activeTeam() {
if (this.teamId) {
return this.$store.getters['teams/getTeam'](this.teamId);
@@ -483,9 +505,7 @@ export default {
this.resetAndFetchData();
},
activeFolder() {
if (!this.hasAppliedFilters) {
this.resetAndFetchData();
}
this.resetAndFetchData();
},
chatLists() {
this.chatsOnView = this.conversationList;
@@ -496,6 +516,10 @@ export default {
this.$store.dispatch('setChatSortFilter', this.activeSortBy);
this.resetAndFetchData();
if (this.hasActiveFolders) {
this.$store.dispatch('campaigns/get');
}
bus.$on('fetch_conversation_stats', () => {
this.$store.dispatch('conversationStats/get', this.conversationFilters);
});
@@ -508,6 +532,15 @@ export default {
this.$store.dispatch('emptyAllConversations');
this.fetchFilteredConversations(payload);
},
onUpdateSavedFilter(payload, folderName) {
const payloadData = {
...this.activeFolder,
name: folderName,
query: filterQueryGenerator(payload),
};
this.$store.dispatch('customViews/update', payloadData);
this.closeAdvanceFiltersModal();
},
onClickOpenAddFoldersModal() {
this.showAddFoldersModal = true;
},
@@ -521,15 +554,70 @@ export default {
this.showDeleteFoldersModal = false;
},
onToggleAdvanceFiltersModal() {
if (!this.hasAppliedFilters) {
if (!this.hasAppliedFilters && !this.hasActiveFolders) {
this.initializeExistingFilterToModal();
}
if (this.hasActiveFolders) {
this.initializeFolderToFilterModal(this.activeFolder);
}
this.showAdvancedFilters = true;
},
closeAdvanceFiltersModal() {
this.showAdvancedFilters = false;
this.appliedFilter = [];
},
setParamsForEditFolderModal() {
// Here we are setting the params for edit folder modal to show the existing values.
// For agent, team, inboxes,and campaigns we get only the id's from the query.
// So we are mapping the id's to the actual values.
// For labels we get the name of the label from the query.
// If we delete the label from the label list then we will not be able to show the label name.
// For custom attributes we get only attribute key.
// So we are mapping it to find the input type of the attribute to show in the edit folder modal.
const params = {
agents: this.agentList,
teams: this.teamsList,
inboxes: this.inboxesList,
labels: this.labels,
campaigns: this.campaigns,
languages: languages,
countries: countries,
filterTypes: advancedFilterTypes,
allCustomAttributes: this.$store.getters[
'attributes/getAttributesByModel'
]('conversation_attribute'),
};
return params;
},
initializeFolderToFilterModal(activeFolder) {
// Here we are setting the params for edit folder modal.
// To show the existing values. when we click on edit folder button.
// Here we get the query from the active folder.
// And we are mapping the query to the actual values.
// To show in the edit folder modal by the help of generateValuesForEditCustomViews helper.
const query = activeFolder?.query?.payload;
if (!Array.isArray(query)) return;
this.appliedFilter.push(
...query.map(filter => ({
attribute_key: filter.attribute_key,
attribute_model: filter.attribute_model,
filter_operator: filter.filter_operator,
values: Array.isArray(filter.values)
? generateValuesForEditCustomViews(
filter,
this.setParamsForEditFolderModal()
)
: [],
query_operator: filter.query_operator,
custom_attribute_type: filter.custom_attribute_type,
}))
);
},
getKeyboardListenerParams() {
const allConversations = this.$refs.activeConversation.querySelectorAll(
'div.conversations-list div.conversation'
@@ -575,6 +663,7 @@ export default {
}
},
resetAndFetchData() {
this.appliedFilter = [];
this.resetBulkActions();
this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations');
@@ -587,7 +676,6 @@ export default {
return;
}
this.fetchConversations();
this.appliedFilter = [];
},
fetchConversations() {
this.$store
@@ -115,7 +115,7 @@
</template>
<script>
import format from 'date-fns/format';
import { format, parseISO } from 'date-fns';
import { required, url } from 'vuelidate/lib/validators';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
@@ -143,15 +143,20 @@ export default {
},
computed: {
formattedValue() {
displayValue() {
if (this.isAttributeTypeDate) {
return format(new Date(this.value || new Date()), DATE_FORMAT);
return new Date(this.value || new Date()).toLocaleDateString();
}
if (this.isAttributeTypeCheckbox) {
return this.value === 'false' ? false : this.value;
}
return this.value;
},
formattedValue() {
return this.isAttributeTypeDate
? format(this.value ? new Date(this.value) : new Date(), DATE_FORMAT)
: this.value;
},
listOptions() {
return this.values.map((value, index) => ({
id: index + 1,
@@ -192,17 +197,11 @@ export default {
}
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
},
displayValue() {
if (this.attributeType === 'date') {
return format(new Date(this.editedValue), 'dd-MM-yyyy');
}
return this.editedValue;
},
},
watch: {
value() {
this.isEditing = false;
this.editedValue = this.value;
this.editedValue = this.formattedValue;
},
},
@@ -249,9 +248,8 @@ export default {
onUpdate() {
const updatedValue =
this.attributeType === 'date'
? format(new Date(this.editedValue), DATE_FORMAT)
? parseISO(this.editedValue)
: this.editedValue;
this.$v.$touch();
if (this.$v.$invalid) {
return;
@@ -0,0 +1,75 @@
<template>
<div class="column">
<woot-modal-header :header-title="$t('CONVERSATION.CUSTOM_SNOOZE.TITLE')" />
<form class="row modal-content" @submit.prevent="chooseTime">
<date-picker
v-model="snoozeTime"
type="datetime"
inline
:lang="lang"
:disabled-date="disabledDate"
:disabled-time="disabledTime"
:popup-style="{ width: '100%' }"
/>
<div class="modal-footer justify-content-end w-full">
<woot-button variant="clear" @click.prevent="onClose">
{{ this.$t('CONVERSATION.CUSTOM_SNOOZE.CANCEL') }}
</woot-button>
<woot-button>
{{ this.$t('CONVERSATION.CUSTOM_SNOOZE.APPLY') }}
</woot-button>
</div>
</form>
</div>
</template>
<script>
import DatePicker from 'vue2-datepicker';
export default {
components: {
DatePicker,
},
data() {
return {
snoozeTime: null,
lang: {
days: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
yearFormat: 'YYYY',
monthFormat: 'MMMM',
},
};
},
methods: {
onClose() {
this.$emit('close');
},
chooseTime() {
this.$emit('choose-time', this.snoozeTime);
},
disabledDate(date) {
// Disable all the previous dates
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return date < yesterday;
},
disabledTime(date) {
// Allow only time after 1 hour
const now = new Date();
now.setHours(now.getHours() + 1);
return date < now;
},
},
};
</script>
<style lang="scss" scoped>
.modal-footer {
padding: var(--space-two);
}
.modal-content {
padding: var(--space-small) var(--space-two) var(--space-zero);
}
</style>
@@ -8,6 +8,7 @@
>
<div :class="modalContainerClassName" @click.stop>
<woot-button
v-if="showCloseButton"
color-scheme="secondary"
icon="dismiss"
variant="clear"
@@ -28,6 +29,10 @@ export default {
default: true,
},
show: Boolean,
showCloseButton: {
type: Boolean,
default: true,
},
onClose: {
type: Function,
required: true,
@@ -0,0 +1,84 @@
<template>
<banner
v-if="shouldShowBanner"
color-scheme="alert"
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
has-action-button
@click="routeToBilling"
/>
</template>
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
import adminMixin from 'dashboard/mixins/isAdmin';
import accountMixin from 'dashboard/mixins/account';
const EMPTY_SUBSCRIPTION_INFO = {
status: null,
endsOn: null,
};
export default {
components: { Banner },
mixins: [adminMixin, accountMixin],
computed: {
...mapGetters({
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
getAccount: 'accounts/getAccount',
}),
bannerMessage() {
return this.$t('GENERAL_SETTINGS.PAYMENT_PENDING');
},
actionButtonMessage() {
return this.$t('GENERAL_SETTINGS.OPEN_BILLING');
},
shouldShowBanner() {
if (!this.isOnChatwootCloud) {
return false;
}
if (!this.isAdmin) {
return false;
}
return this.isPaymentPending();
},
},
methods: {
routeToBilling() {
this.$router.push({
name: 'billing_settings_index',
params: { accountId: this.accountId },
});
},
isPaymentPending() {
const { status, endsOn } = this.getSubscriptionInfo();
if (status && endsOn) {
const now = new Date();
if (status === 'past_due' && endsOn < now) {
return true;
}
}
return false;
},
getSubscriptionInfo() {
const account = this.getAccount(this.accountId);
if (!account) return EMPTY_SUBSCRIPTION_INFO;
const { custom_attributes: subscription } = account;
if (!subscription) return EMPTY_SUBSCRIPTION_INFO;
const {
subscription_status: status,
subscription_ends_on: endsOn,
} = subscription;
return { status, endsOn: new Date(endsOn) };
},
},
};
</script>
@@ -1,7 +1,6 @@
<template>
<banner
v-if="shouldShowBanner"
class="update-banner"
color-scheme="primary"
:banner-message="bannerMessage"
href-link="https://github.com/chatwoot/chatwoot/releases"
@@ -0,0 +1,89 @@
<template>
<banner
v-if="shouldShowBanner"
color-scheme="alert"
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
has-action-button
@click="routeToBilling"
/>
</template>
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
import adminMixin from 'dashboard/mixins/isAdmin';
import accountMixin from 'dashboard/mixins/account';
import { differenceInDays } from 'date-fns';
export default {
components: { Banner },
mixins: [adminMixin, accountMixin],
data() {
return { conversationMeta: {} };
},
computed: {
...mapGetters({
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
getAccount: 'accounts/getAccount',
}),
bannerMessage() {
return this.$t('GENERAL_SETTINGS.LIMITS_UPGRADE');
},
actionButtonMessage() {
return this.$t('GENERAL_SETTINGS.OPEN_BILLING');
},
shouldShowBanner() {
if (!this.isOnChatwootCloud) {
return false;
}
if (this.isTrialAccount()) {
return false;
}
return this.isLimitExceeded();
},
},
mounted() {
if (this.isOnChatwootCloud) {
this.fetchLimits();
}
},
methods: {
fetchLimits() {
this.$store.dispatch('accounts/limits');
},
routeToBilling() {
this.$router.push({
name: 'billing_settings_index',
params: { accountId: this.accountId },
});
},
isTrialAccount() {
// check if account is less than 15 days old
const account = this.getAccount(this.accountId);
if (!account) return false;
const createdAt = new Date(account.created_at);
const diffDays = differenceInDays(new Date(), createdAt);
return diffDays <= 15;
},
isLimitExceeded() {
const account = this.getAccount(this.accountId);
if (!account) return false;
const { limits } = account;
if (!limits) return false;
const { conversation, non_web_inboxes: nonWebInboxes } = limits;
return this.testLimit(conversation) || this.testLimit(nonWebInboxes);
},
testLimit({ allowed, consumed }) {
return consumed > allowed;
},
},
};
</script>
@@ -59,72 +59,45 @@
>
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }}
</woot-button>
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="snooze"
@click="() => openSnoozeModal()"
>
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE_UNTIL') }}
</woot-button>
</woot-dropdown-item>
<woot-dropdown-divider v-if="isOpen" />
<woot-dropdown-sub-menu
v-if="isOpen"
:title="this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.TITLE')"
>
<woot-dropdown-item>
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="send-clock"
@click="() => toggleStatus(STATUS_TYPE.SNOOZED, null)"
>
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.NEXT_REPLY') }}
</woot-button>
</woot-dropdown-item>
<woot-dropdown-item>
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="dual-screen-clock"
@click="
() => toggleStatus(STATUS_TYPE.SNOOZED, snoozeTimes.tomorrow)
"
>
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.TOMORROW') }}
</woot-button>
</woot-dropdown-item>
<woot-dropdown-item>
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="calendar-clock"
@click="
() => toggleStatus(STATUS_TYPE.SNOOZED, snoozeTimes.nextWeek)
"
>
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.NEXT_WEEK') }}
</woot-button>
</woot-dropdown-item>
</woot-dropdown-sub-menu>
</woot-dropdown-menu>
</div>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="chooseSnoozeTime"
/>
</woot-modal>
</div>
</template>
<script>
import { getUnixTime } from 'date-fns';
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin.js';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import {
hasPressedAltAndEKey,
hasPressedCommandPlusAltAndEKey,
hasPressedAltAndMKey,
} from 'shared/helpers/KeyboardHelpers';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownSubMenu from 'shared/components/ui/dropdown/DropdownSubMenu.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
import wootConstants from 'dashboard/constants/globals';
import {
@@ -137,16 +110,16 @@ export default {
components: {
WootDropdownItem,
WootDropdownMenu,
WootDropdownSubMenu,
WootDropdownDivider,
CustomSnoozeModal,
},
mixins: [clickaway, alertMixin, eventListenerMixins, snoozeTimesMixin],
mixins: [clickaway, alertMixin, eventListenerMixins],
props: { conversationId: { type: [String, Number], required: true } },
data() {
return {
isLoading: false,
showActionsDropdown: false,
STATUS_TYPE: wootConstants.STATUS_TYPE,
showCustomSnoozeModal: false,
};
},
computed: {
@@ -218,10 +191,26 @@ export default {
}
},
onCmdSnoozeConversation(snoozeType) {
this.toggleStatus(
this.STATUS_TYPE.SNOOZED,
this.snoozeTimes[snoozeType] || null
);
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
this.toggleStatus(
this.STATUS_TYPE.SNOOZED,
findSnoozeTime(snoozeType) || null
);
}
},
chooseSnoozeTime(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
this.toggleStatus(
this.STATUS_TYPE.SNOOZED,
getUnixTime(customSnoozeTime)
);
}
},
hideCustomSnoozeModal() {
this.showCustomSnoozeModal = false;
},
onCmdOpenConversation() {
this.toggleStatus(this.STATUS_TYPE.OPEN);
@@ -252,6 +241,10 @@ export default {
this.isLoading = false;
});
},
openSnoozeModal() {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_conversation' });
},
},
};
</script>
@@ -270,5 +263,9 @@ export default {
right: 0;
max-width: 20rem;
min-width: 15.6rem;
.dropdown-menu__item {
margin-bottom: 0;
}
}
</style>
@@ -11,26 +11,29 @@
{{ hrefLinkText }}
</a>
</span>
<woot-button
v-if="hasActionButton"
size="small"
variant="link"
icon="arrow-right"
color-scheme="primary"
class-names="banner-action__button"
@click="onClick"
>
{{ actionButtonLabel }}
</woot-button>
<woot-button
v-if="hasCloseButton"
size="small"
variant="link"
color-scheme="secondary"
icon="dismiss-circle"
class-names="banner-action__button"
@click="onClickClose"
/>
<div class="actions">
<woot-button
v-if="hasActionButton"
size="tiny"
icon="arrow-right"
:variant="actionButtonVariant"
color-scheme="primary"
class-names="banner-action__button"
@click="onClick"
>
{{ actionButtonLabel }}
</woot-button>
<woot-button
v-if="hasCloseButton"
size="tiny"
:color-scheme="colorScheme"
icon="dismiss-circle"
class-names="banner-action__button"
@click="onClickClose"
>
{{ $t('GENERAL_SETTINGS.DISMISS') }}
</woot-button>
</div>
</div>
</template>
@@ -53,6 +56,10 @@ export default {
type: Boolean,
default: false,
},
actionButtonVariant: {
type: String,
default: '',
},
actionButtonLabel: {
type: String,
default: '',
@@ -68,7 +75,12 @@ export default {
},
computed: {
bannerClasses() {
return [this.colorScheme];
const classList = [this.colorScheme];
if (this.hasActionButton || this.hasCloseButton) {
classList.push('has-button');
}
return classList;
},
},
methods: {
@@ -84,17 +96,26 @@ export default {
<style lang="scss" scoped>
.banner {
--x-padding: var(--space-normal);
--y-padding: var(--space-slab);
display: flex;
gap: var(--x-padding);
color: var(--white);
font-size: var(--font-size-mini);
padding: var(--space-slab) var(--space-normal);
padding: var(--y-padding) var(--x-padding);
justify-content: center;
position: sticky;
&.primary {
background: var(--w-500);
.banner-action__button {
background: var(--w-600);
border: none;
color: var(--white);
&:hover {
background: var(--w-800);
}
}
}
@@ -107,7 +128,16 @@ export default {
}
&.alert {
background: var(--r-400);
background: var(--r-500);
.banner-action__button {
background: var(--r-700);
border: none;
color: var(--white);
&:hover {
background: var(--r-800);
}
}
}
&.warning {
@@ -133,8 +163,6 @@ export default {
}
.banner-action__button {
margin: 0 var(--space-smaller);
::v-deep .button__content {
white-space: nowrap;
}
@@ -144,5 +172,11 @@ export default {
display: flex;
align-items: center;
}
.actions {
display: flex;
gap: var(--space-smaller);
right: var(--y-padding);
}
}
</style>
@@ -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) {
@@ -1,45 +1,82 @@
<template>
<div v-if="isAIIntegrationEnabled" class="position-relative">
<woot-button
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.TITLE')"
icon="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="isGenerating"
size="small"
@click="processText"
>
{{ buttonText }}
</woot-button>
<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 v-if="uiFlags.isContentGenerated">
<label>
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_TITLE') }}
</label>
<p>
{{ generatedMessage }}
</p>
</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
v-if="!uiFlags.isContentGenerated"
:is-loading="uiFlags.rephrase"
size="small"
@click="processEvent('rephrase')"
>
{{ buttonText }}
</woot-button>
<woot-button v-else size="small" @click="replaceText('rephrase')">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.REPLACE') }}
</woot-button>
</div>
</div>
</div>
</div>
@@ -49,6 +86,7 @@ import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import OpenAPI from 'dashboard/api/integrations/openapi';
import alertMixin from 'shared/mixins/alertMixin';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default {
mixins: [alertMixin, clickaway],
@@ -61,10 +99,20 @@ export default {
type: String,
default: '',
},
isPrivateNote: {
type: Boolean,
default: false,
},
},
data() {
return {
isGenerating: false,
uiFlags: {
rephrase: false,
reply_suggestion: false,
summarize: false,
isisContentGenerated: false,
},
generatedMessage: '',
showDropdown: false,
activeTone: 'professional',
tones: [
@@ -94,7 +142,7 @@ export default {
).hooks[0].id;
},
buttonText() {
return this.isGenerating
return this.uiFlags.isRephrasing
? this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATING')
: this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATE');
},
@@ -106,29 +154,50 @@ export default {
},
methods: {
toggleDropdown() {
this.uiFlags.isContentGenerated = false;
this.generatedMessage = '';
this.showDropdown = !this.showDropdown;
},
closeDropdown() {
this.showDropdown = false;
},
async processText() {
this.isGenerating = true;
async recordAnalytics({ type, tone }) {
const event = OPEN_AI_EVENTS[type.toUpperCase()];
if (event) {
this.$track(event, {
type,
tone,
});
}
},
replaceText(type) {
this.$emit('replace-text', this.generatedMessage || this.message);
this.recordAnalytics({ type, tone: this.activeTone });
this.closeDropdown();
},
async processEvent(type = 'rephrase') {
this.uiFlags[type] = true;
try {
const result = await OpenAPI.processEvent({
hookId: this.hookId,
type: 'rephrase',
type,
content: this.message,
tone: this.activeTone,
conversationId: this.conversationId,
});
const {
data: { message: generatedMessage },
} = result;
this.$emit('replace-text', generatedMessage || this.message);
this.closeDropdown();
this.uiFlags.isContentGenerated = true;
this.generatedMessage = generatedMessage;
// It the type is not rephrase, replace the text
if (type !== 'rephrase') {
this.replaceText(type);
}
} catch (error) {
this.showAlert(this.$t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR'));
} finally {
this.isGenerating = false;
this.uiFlags[type] = false;
}
},
},
@@ -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() {
@@ -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
@@ -92,8 +92,8 @@
:conversation-id="conversationId"
/>
<AIAssistanceButton
v-if="message"
:conversation-id="conversationId"
:is-private-note="isOnPrivateNote"
:message="message"
@replace-text="replaceText"
/>
@@ -45,6 +45,14 @@ export default {
default: () => {},
},
},
watch: {
collection() {
this.renderChart(this.collection, {
...chartOptions,
...this.chartOptions,
});
},
},
mounted() {
this.renderChart(this.collection, {
...chartOptions,
@@ -1,9 +1,21 @@
<template>
<div class="column">
<woot-modal-header :header-title="$t('FILTER.TITLE')">
<p>{{ $t('FILTER.SUBTITLE') }}</p>
<woot-modal-header :header-title="filterModalHeaderTitle">
<p>{{ filterModalSubTitle }}</p>
</woot-modal-header>
<div class="row modal-content">
<div class="column modal-content">
<div v-if="isFolderView" class="columns">
<label class="input-label" :class="{ error: !activeFolderNewName }">
{{ $t('FILTER.FOLDER_LABEL') }}
<input v-model="activeFolderNewName" type="text" class="name-input" />
<span v-if="!activeFolderNewName" class="message">
{{ $t('FILTER.EMPTY_VALUE_ERROR') }}
</span>
</label>
<label class="input-label">
{{ $t('FILTER.FOLDER_QUERY_LABEL') }}
</label>
</div>
<div class="medium-12 columns filters-wrap">
<filter-input-box
v-for="(filter, i) in appliedFilters"
@@ -42,7 +54,14 @@
<woot-button class="button clear" @click.prevent="onClose">
{{ $t('FILTER.CANCEL_BUTTON_LABEL') }}
</woot-button>
<woot-button @click="submitFilterQuery">
<woot-button
v-if="isFolderView"
:disabled="!activeFolderNewName"
@click="updateSavedCustomViews"
>
{{ $t('FILTER.UPDATE_BUTTON_LABEL') }}
</woot-button>
<woot-button v-else @click="submitFilterQuery">
{{ $t('FILTER.SUBMIT_BUTTON_LABEL') }}
</woot-button>
</div>
@@ -81,6 +100,14 @@ export default {
type: Array,
default: () => [],
},
activeFolderName: {
type: String,
default: '',
},
isFolderView: {
type: Boolean,
default: false,
},
},
validations: {
appliedFilters: {
@@ -107,6 +134,7 @@ export default {
return {
show: true,
appliedFilters: this.initialAppliedFilters,
activeFolderNewName: this.activeFolderName,
filterTypes: this.initialFilterTypes,
filterAttributeGroups,
filterGroups: [],
@@ -119,6 +147,16 @@ export default {
...mapGetters({
getAppliedConversationFilters: 'getAppliedConversationFilters',
}),
filterModalHeaderTitle() {
return !this.isFolderView
? this.$t('FILTER.TITLE')
: this.$t('FILTER.EDIT_CUSTOM_FILTER');
},
filterModalSubTitle() {
return !this.isFolderView
? this.$t('FILTER.SUBTITLE')
: this.$t('FILTER.CUSTOM_VIEWS_SUBTITLE');
},
},
mounted() {
this.setFilterAttributes();
@@ -126,7 +164,7 @@ export default {
if (this.getAppliedConversationFilters.length) {
this.appliedFilters = [];
this.appliedFilters = [...this.getAppliedConversationFilters];
} else {
} else if (!this.isFolderView) {
this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
@@ -177,11 +215,11 @@ export default {
if (key === 'created_at' || key === 'last_activity_at')
if (operator === 'days_before') return 'plain_text';
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.inputType;
return type?.inputType;
},
getOperators(key) {
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.filterOperators;
return type?.filterOperators;
},
getDropdownValues(type) {
const statusFilters = this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS');
@@ -267,11 +305,30 @@ export default {
}
},
appendNewFilter() {
this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
if (this.isFolderView) {
this.setQueryOperatorOnLastQuery();
} else {
this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
}
},
setQueryOperatorOnLastQuery() {
const lastItemIndex = this.appliedFilters.length - 1;
this.appliedFilters[lastItemIndex] = {
...this.appliedFilters[lastItemIndex],
query_operator: 'and',
};
this.$nextTick(() => {
this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
});
},
removeFilter(index) {
@@ -296,6 +353,9 @@ export default {
})),
});
},
updateSavedCustomViews() {
this.$emit('updateFolder', this.appliedFilters, this.activeFolderNewName);
},
resetFilter(index, currentFilter) {
this.appliedFilters[index].filter_operator = this.filterTypes.find(
filter => filter.attributeKey === currentFilter.attribute_key
@@ -322,4 +382,12 @@ export default {
.filter-actions {
margin-top: var(--space-normal);
}
.input-label {
margin-bottom: var(--space-smaller);
.name-input {
width: 50%;
}
}
</style>
@@ -9,7 +9,7 @@
}"
@mouseenter="onCardHover"
@mouseleave="onCardLeave"
@click="cardClick(chat)"
@click="onCardClick"
@contextmenu="openContextMenu($event)"
>
<label v-if="hovered || selected" class="checkbox-wrapper" @click.stop>
@@ -313,21 +313,33 @@ export default {
},
},
methods: {
cardClick(chat) {
const { activeInbox } = this;
const path = conversationUrl({
accountId: this.accountId,
activeInbox,
id: chat.id,
label: this.activeLabel,
teamId: this.teamId,
foldersId: this.foldersId,
conversationType: this.conversationType,
});
onCardClick(e) {
const { activeInbox, chat } = this;
const path = frontendURL(
conversationUrl({
accountId: this.accountId,
activeInbox,
id: chat.id,
label: this.activeLabel,
teamId: this.teamId,
foldersId: this.foldersId,
conversationType: this.conversationType,
})
);
if (e.metaKey || e.ctrlKey) {
window.open(
window.chatwootConfig.hostURL + path,
'_blank',
'noopener noreferrer nofollow'
);
return;
}
if (this.isActiveChat) {
return;
}
router.push({ path: frontendURL(path) });
router.push({ path });
},
onCardHover() {
this.hovered = !this.hideThumbnail;
@@ -57,7 +57,6 @@ import { hasPressedAltAndOKey } from 'shared/helpers/KeyboardHelpers';
import { mapGetters } from 'vuex';
import agentMixin from '../../../mixins/agentMixin.js';
import BackButton from '../BackButton';
import differenceInHours from 'date-fns/differenceInHours';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName';
@@ -65,6 +64,8 @@ import MoreActions from './MoreActions';
import Thumbnail from '../Thumbnail';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { conversationReopenTime } from 'dashboard/helper/snoozeHelpers';
export default {
components: {
BackButton,
@@ -125,17 +126,9 @@ export default {
snoozedDisplayText() {
const { snoozed_until: snoozedUntil } = this.currentChat;
if (snoozedUntil) {
// When the snooze is applied, it schedules the unsnooze event to next day/week 9AM.
// By that logic if the time difference is less than or equal to 24 + 9 hours we can consider it tomorrow.
const MAX_TIME_DIFFERENCE = 33;
const isSnoozedUntilTomorrow =
differenceInHours(new Date(snoozedUntil), new Date()) <=
MAX_TIME_DIFFERENCE;
return this.$t(
isSnoozedUntilTomorrow
? 'CONVERSATION.HEADER.SNOOZED_UNTIL_TOMORROW'
: 'CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_WEEK'
);
return `${this.$t(
'CONVERSATION.HEADER.SNOOZED_UNTIL'
)} ${conversationReopenTime(snoozedUntil)}`;
}
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
},
@@ -25,10 +25,14 @@
<blockquote v-if="storyReply" class="story-reply-quote">
<span>{{ $t('CONVERSATION.REPLIED_TO_STORY') }}</span>
<bubble-image
v-if="!hasStoryError"
v-if="!hasImgStoryError && storyUrl"
:url="storyUrl"
@error="onStoryLoadError"
/>
<bubble-video
v-else-if="hasImgStoryError && storyUrl"
:url="storyUrl"
/>
</blockquote>
<bubble-text
v-if="data.content"
@@ -48,23 +52,12 @@
{{ $t('CONVERSATION.UPLOADING_ATTACHMENTS') }}
</span>
<div v-if="!isPending && hasAttachments">
<div v-for="attachment in data.attachments" :key="attachment.id">
<bubble-image
v-if="attachment.file_type === 'image' && !hasImageError"
:url="attachment.data_url"
<div v-for="attachment in attachments" :key="attachment.id">
<bubble-image-audio-video
v-if="isAttachmentImageVideoAudio(attachment.file_type)"
:attachment="attachment"
@error="onImageLoadError"
/>
<audio
v-else-if="attachment.file_type === 'audio'"
controls
class="skip-context-menu"
>
<source :src="attachment.data_url" />
</audio>
<bubble-video
v-else-if="attachment.file_type === 'video'"
:url="attachment.data_url"
/>
<bubble-location
v-else-if="attachment.file_type === 'location'"
:latitude="attachment.coordinates_lat"
@@ -140,11 +133,12 @@ import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import BubbleActions from './bubble/Actions';
import BubbleFile from './bubble/File';
import BubbleImage from './bubble/Image';
import BubbleVideo from './bubble/Video';
import BubbleImageAudioVideo from './bubble/ImageAudioVideo';
import BubbleIntegration from './bubble/Integration.vue';
import BubbleLocation from './bubble/Location';
import BubbleMailHead from './bubble/MailHead';
import BubbleText from './bubble/Text';
import BubbleVideo from './bubble/Video.vue';
import BubbleContact from './bubble/Contact';
import Spinner from 'shared/components/Spinner';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu';
@@ -161,11 +155,12 @@ export default {
BubbleActions,
BubbleFile,
BubbleImage,
BubbleVideo,
BubbleImageAudioVideo,
BubbleIntegration,
BubbleLocation,
BubbleMailHead,
BubbleText,
BubbleVideo,
BubbleContact,
ContextMenu,
Spinner,
@@ -200,10 +195,18 @@ export default {
hasImageError: false,
contextMenuPosition: {},
showBackgroundHighlight: false,
hasStoryError: false,
hasImgStoryError: false,
};
},
computed: {
attachments() {
// Here it is used to get sender and created_at for each attachment
return this.data?.attachments.map(attachment => ({
...attachment,
sender: this.data.sender || {},
created_at: this.data.created_at || '',
}));
},
shouldRenderMessage() {
return (
this.hasAttachments ||
@@ -429,12 +432,12 @@ export default {
watch: {
data() {
this.hasImageError = false;
this.hasStoryError = false;
this.hasImgStoryError = false;
},
},
mounted() {
this.hasImageError = false;
this.hasStoryError = false;
this.hasImgStoryError = false;
bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
this.setupHighlightTimer();
},
@@ -443,6 +446,9 @@ export default {
clearTimeout(this.higlightTimeout);
},
methods: {
isAttachmentImageVideoAudio(fileType) {
return ['image', 'audio', 'video'].includes(fileType);
},
hasMediaAttachment(type) {
if (this.hasAttachments && this.data.attachments.length > 0) {
const { attachments = [{}] } = this.data;
@@ -464,7 +470,7 @@ export default {
this.hasImageError = true;
},
onStoryLoadError() {
this.hasStoryError = true;
this.hasImgStoryError = true;
},
openContextMenu(e) {
const shouldSkipContextMenu =
@@ -36,6 +36,7 @@
v-for="message in getReadMessages"
:key="message.id"
class="message--read ph-no-capture"
data-clarity-mask="True"
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
@@ -56,6 +57,7 @@
v-for="message in getUnReadMessages"
:key="message.id"
class="message--unread ph-no-capture"
data-clarity-mask="True"
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
@@ -277,6 +279,7 @@ export default {
if (newChat.id === oldChat.id) {
return;
}
this.fetchAllAttachmentsFromCurrentChat();
this.selectedTweetId = null;
},
},
@@ -288,6 +291,7 @@ export default {
mounted() {
this.addScrollListener();
this.fetchAllAttachmentsFromCurrentChat();
},
beforeDestroy() {
@@ -296,6 +300,9 @@ export default {
},
methods: {
fetchAllAttachmentsFromCurrentChat() {
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
},
removeBusListeners() {
bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
bus.$off(BUS_EVENTS.SET_TWEET_REPLY, this.setSelectedTweet);
@@ -2,6 +2,7 @@
<div class="reply-box" :class="replyBoxClass">
<banner
v-if="showSelfAssignBanner"
action-button-variant="link"
color-scheme="secondary"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
:has-action-button="true"
@@ -501,10 +502,10 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
if (this.isAWebWidgetInbox) {
return AUDIO_FORMATS.WEBM;
if (this.isAWhatsAppChannel || this.isAPIInbox) {
return AUDIO_FORMATS.OGG;
}
return AUDIO_FORMATS.OGG;
return AUDIO_FORMATS.WAV;
},
messageVariables() {
const variables = getMessageVariables({
@@ -0,0 +1,106 @@
<template>
<div class="message-text__wrap" :class="attachmentTypeClasses">
<img
v-if="isImage && !isImageError"
:src="attachment.data_url"
@click="onClick"
@error="onImgError()"
/>
<video
v-if="isVideo"
:src="attachment.data_url"
muted
playsInline
@click="onClick"
/>
<audio v-else-if="isAudio" controls class="skip-context-menu">
<source :src="`${attachment.data_url}?t=${Date.now()}`" />
</audio>
<gallery-view
v-if="show"
:show.sync="show"
:attachment="attachment"
:all-attachments="filteredCurrentChatAttachments"
@error="onImgError()"
@close="onClose"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { hasPressedCommand } from 'shared/helpers/KeyboardHelpers';
import GalleryView from '../components/GalleryView';
const ALLOWED_FILE_TYPES = {
IMAGE: 'image',
VIDEO: 'video',
AUDIO: 'audio',
};
export default {
components: {
GalleryView,
},
props: {
attachment: {
type: Object,
required: true,
},
},
data() {
return {
show: false,
isImageError: false,
};
},
computed: {
...mapGetters({
currentChatAttachments: 'getSelectedChatAttachments',
}),
isImage() {
return this.attachment.file_type === ALLOWED_FILE_TYPES.IMAGE;
},
isVideo() {
return this.attachment.file_type === ALLOWED_FILE_TYPES.VIDEO;
},
isAudio() {
return this.attachment.file_type === ALLOWED_FILE_TYPES.AUDIO;
},
attachmentTypeClasses() {
return {
image: this.isImage,
video: this.isVideo,
};
},
filteredCurrentChatAttachments() {
const attachments = this.currentChatAttachments.filter(attachment =>
['image', 'video', 'audio'].includes(attachment.file_type)
);
return attachments;
},
},
watch: {
attachment() {
this.isImageError = false;
},
},
methods: {
onClose() {
this.show = false;
},
onClick(e) {
if (hasPressedCommand(e)) {
window.open(this.attachment.data_url, '_blank');
return;
}
this.show = true;
},
onImgError() {
this.isImageError = true;
this.$emit('error');
},
},
};
</script>
@@ -0,0 +1,374 @@
<template>
<woot-modal
full-width
:show.sync="show"
:show-close-button="false"
:on-close="onClose"
>
<div v-on-clickaway="onClose" class="gallery-modal--wrap" @click="onClose">
<div class="gallery-modal--header">
<div class="header-info--wrap" @click.stop>
<thumbnail
:username="senderDetails.name"
:src="senderDetails.avatar"
/>
<div class="header-info">
<h3 class="sub-block-title sender-name">
<span class="text-truncate">{{ senderDetails.name }}</span>
</h3>
<span class="time-stamp text-truncate">{{ readableTime }}</span>
</div>
</div>
<div class="file-name--header text-block-title">
<span class="text-truncate">
{{ fileNameFromDataUrl }}
</span>
</div>
<div class="header-actions" @click.stop>
<woot-button
size="large"
color-scheme="secondary"
variant="clear"
icon="arrow-download"
@click="onClickDownload"
/>
<woot-button
size="large"
color-scheme="secondary"
variant="clear"
icon="dismiss"
@click="onClose"
/>
</div>
</div>
<div class="gallery-modal--body">
<div class="attachment-toggle--button">
<woot-button
v-if="hasMoreThanOneAttachment"
size="large"
variant="smooth"
color-scheme="primary"
icon="chevron-left"
:disabled="activeImageIndex === 0"
@click.stop="
onClickChangeAttachment(
allAttachments[activeImageIndex - 1],
activeImageIndex - 1
)
"
/>
</div>
<div class="attachments-viewer">
<div class="attachment-view">
<img
v-if="isImage"
:key="activeAttachment.message_id"
:src="activeAttachment.data_url"
class="modal-image skip-context-menu"
@click.stop
/>
<video
v-if="isVideo"
:key="activeAttachment.message_id"
:src="activeAttachment.data_url"
controls
playsInline
class="modal-video skip-context-menu"
@click.stop
/>
<audio
v-if="isAudio"
:key="activeAttachment.message_id"
controls
class="skip-context-menu"
@click.stop
>
<source :src="`${activeAttachment.data_url}?t=${Date.now()}`" />
</audio>
</div>
</div>
<div class="attachment-toggle--button">
<woot-button
v-if="hasMoreThanOneAttachment"
size="large"
variant="smooth"
color-scheme="primary"
:disabled="activeImageIndex === allAttachments.length - 1"
icon="chevron-right"
@click.stop="
onClickChangeAttachment(
allAttachments[activeImageIndex + 1],
activeImageIndex + 1
)
"
/>
</div>
</div>
<div class="gallery-modal--footer">
<div class="header-count text-block-title">
<span class="count">
{{ `${activeImageIndex + 1} / ${allAttachments.length}` }}
</span>
</div>
</div>
</div>
</woot-modal>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import {
isEscape,
hasPressedArrowLeftKey,
hasPressedArrowRightKey,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import timeMixin from 'dashboard/mixins/time';
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
const ALLOWED_FILE_TYPES = {
IMAGE: 'image',
VIDEO: 'video',
AUDIO: 'audio',
};
export default {
components: {
Thumbnail,
},
mixins: [eventListenerMixins, clickaway, timeMixin],
props: {
show: {
type: Boolean,
required: true,
},
attachment: {
type: Object,
required: true,
},
allAttachments: {
type: Array,
required: true,
},
},
data() {
return {
activeAttachment: {},
activeFileType: '',
activeImageIndex:
this.allAttachments.findIndex(
attachment => attachment.message_id === this.attachment.message_id
) || 0,
};
},
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
}),
hasMoreThanOneAttachment() {
return this.allAttachments.length > 1;
},
readableTime() {
if (!this.activeAttachment.created_at) return '';
const time = this.messageTimestamp(
this.activeAttachment.created_at,
'LLL d yyyy, h:mm a'
);
return time || '';
},
isImage() {
return this.activeFileType === ALLOWED_FILE_TYPES.IMAGE;
},
isVideo() {
return this.activeFileType === ALLOWED_FILE_TYPES.VIDEO;
},
isAudio() {
return this.activeFileType === ALLOWED_FILE_TYPES.AUDIO;
},
senderDetails() {
const { name, available_name: availableName, avatar_url, thumbnail, id } =
this.activeAttachment?.sender || this.attachment?.sender;
const currentUserID = this.currentUser?.id;
return {
name: currentUserID === id ? 'You' : name || availableName || '',
avatar: thumbnail || avatar_url || '',
};
},
fileNameFromDataUrl() {
const { data_url: dataUrl } = this.activeAttachment;
if (!dataUrl) return '';
const fileName = dataUrl?.split('/').pop();
return fileName || '';
},
},
mounted() {
this.setImageAndVideoSrc(this.attachment);
},
methods: {
onClose() {
this.$emit('close');
},
onClickChangeAttachment(attachment, index) {
if (!attachment) {
return;
}
this.activeImageIndex = index;
this.setImageAndVideoSrc(attachment);
},
setImageAndVideoSrc(attachment) {
const { file_type: type } = attachment;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
this.activeAttachment = attachment;
this.activeFileType = type;
},
onKeyDownHandler(e) {
if (isEscape(e)) {
this.onClose();
} else if (hasPressedArrowLeftKey(e)) {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex - 1],
this.activeImageIndex - 1
);
} else if (hasPressedArrowRightKey(e)) {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex + 1],
this.activeImageIndex + 1
);
}
},
onClickDownload() {
const { file_type: type, data_url: url } = this.activeAttachment;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
const link = document.createElement('a');
link.href = url;
link.download = `attachment.${type}`;
link.click();
},
},
};
</script>
<style lang="scss" scoped>
.gallery-modal--wrap {
display: flex;
flex-direction: column;
height: inherit;
width: inherit;
.gallery-modal--header {
align-items: center;
display: flex;
height: var(--space-jumbo);
justify-content: space-between;
padding: var(--space-small) var(--space-medium);
width: 100%;
.header-info--wrap {
align-items: center;
display: flex;
justify-content: flex-start;
min-width: var(--space-giga);
.header-info {
align-items: flex-start;
display: flex;
flex-direction: column;
justify-content: center;
margin-left: var(--space-small);
.sender-name {
display: inline-block;
line-height: 1.4;
margin: 0;
padding: 0;
text-transform: capitalize;
}
.time-stamp {
color: var(--s-400);
font-size: var(--font-size-mini);
margin: 0;
padding: 0;
}
}
}
.file-name--header {
align-items: center;
color: var(--s-700);
display: flex;
font-weight: var(--font-weight-bold);
justify-content: flex-start;
min-width: 0;
padding: var(--space-smaller);
width: auto;
}
.header-actions {
align-items: center;
display: flex;
gap: var(--space-small);
justify-content: flex-end;
min-width: var(--space-giga);
}
}
.gallery-modal--body {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
width: 100%;
}
.gallery-modal--footer {
align-items: center;
display: flex;
height: var(--space-jumbo);
justify-content: center;
padding: var(--space-small) var(--space-medium);
width: 100%;
.header-count {
align-items: center;
border-radius: var(--border-radius-small);
background-color: var(--s-25);
color: var(--s-600);
display: flex;
font-weight: var(--font-weight-bold);
justify-content: center;
min-width: 8rem;
padding: var(--space-smaller);
}
}
.attachments-viewer {
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
width: 100%;
.attachment-view {
img {
margin: 0 auto;
}
video {
margin: 0 auto;
}
}
}
.attachment-toggle--button {
display: flex;
justify-content: center;
min-width: var(--space-mega);
width: var(--space-mega);
}
}
</style>
@@ -15,14 +15,13 @@
@click="toggleStatus(option.key, null)"
/>
</template>
<menu-item-with-submenu :option="snoozeMenuConfig">
<menu-item
v-for="(option, i) in snoozeMenuConfig.options"
:key="i"
:option="option"
@click="snoozeConversation(option.snoozedUntil)"
/>
</menu-item-with-submenu>
<menu-item
v-if="show(snoozeOption.key)"
:option="snoozeOption"
variant="icon"
@click="snoozeConversation()"
/>
<menu-item-with-submenu :option="priorityConfig">
<menu-item
v-for="(option, i) in priorityConfig.options"
@@ -78,7 +77,7 @@
import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin';
import agentMixin from 'dashboard/mixins/agentMixin';
import { mapGetters } from 'vuex';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
export default {
@@ -87,7 +86,7 @@ export default {
MenuItemWithSubmenu,
AgentLoadingPlaceholder,
},
mixins: [snoozeTimesMixin],
mixins: [agentMixin],
props: {
status: {
type: String,
@@ -130,27 +129,10 @@ export default {
icon: 'arrow-redo',
},
],
snoozeMenuConfig: {
key: 'snooze',
snoozeOption: {
key: wootConstants.STATUS_TYPE.SNOOZED,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.TITLE'),
icon: 'snooze',
options: [
{
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.NEXT_REPLY'),
key: 'next-reply',
snoozedUntil: null,
},
{
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.TOMORROW'),
key: 'tomorrow',
snoozedUntil: 'tomorrow',
},
{
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.NEXT_WEEK'),
key: 'next-week',
snoozedUntil: 'nextWeek',
},
],
},
priorityConfig: {
key: 'priority',
@@ -202,6 +184,16 @@ export default {
teams: 'teams/getTeams',
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
}),
filteredAgentOnAvailability() {
const agents = this.$store.getters[
'inboxAssignableAgents/getAssignableAgents'
](this.inboxId);
const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents);
const filteredAgents = this.sortedAgentsByAvailability(
agentsByUpdatedPresence
);
return filteredAgents;
},
assignableAgents() {
return [
{
@@ -212,9 +204,7 @@ export default {
account_id: 0,
email: 'None',
},
...this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.inboxId
),
...this.filteredAgentOnAvailability,
];
},
},
@@ -225,12 +215,9 @@ export default {
toggleStatus(status, snoozedUntil) {
this.$emit('update-conversation', status, snoozedUntil);
},
snoozeConversation(snoozedUntil) {
this.$emit(
'update-conversation',
this.STATUS_TYPE.SNOOZED,
this.snoozeTimes[snoozedUntil] || null
);
snoozeConversation() {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_conversation' });
},
assignPriority(priority) {
this.$emit('assign-priority', priority);
@@ -246,6 +233,7 @@ export default {
...(type === 'icon' && { icon: option.icon }),
...(type === 'label' && { color: option.color }),
...(type === 'agent' && { thumbnail: option.thumbnail }),
...(type === 'agent' && { status: option.availability_status }),
...(type === 'text' && { label: option.label }),
...(type === 'label' && { label: option.title }),
...(type === 'agent' && { label: option.name }),
@@ -15,6 +15,7 @@
v-if="variant === 'agent'"
:username="option.label"
:src="option.thumbnail"
:status="option.status"
size="20px"
class="agent-thumbnail"
/>
@@ -15,6 +15,7 @@ export default {
SORT_BY_TYPE: {
LATEST: 'latest',
CREATED_AT: 'sort_on_created_at',
PRIORITY: 'sort_on_priority',
},
ARTICLE_STATUS_TYPES: {
DRAFT: 0,
@@ -29,5 +30,13 @@ export default {
TESTIMONIAL_URL: 'https://testimonials.cdn.chatwoot.com/content.json',
SMALL_SCREEN_BREAKPOINT: 1024,
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
SNOOZE_OPTIONS: {
UNTIL_NEXT_REPLY: 'until_next_reply',
AN_HOUR_FROM_NOW: 'an_hour_from_now',
UNTIL_TOMORROW: 'until_tomorrow',
UNTIL_NEXT_WEEK: 'until_next_week',
UNTIL_NEXT_MONTH: 'until_next_month',
UNTIL_CUSTOM_TIME: 'until_custom_time',
},
};
export const DEFAULT_REDIRECT_URL = '/app/';
@@ -76,3 +76,9 @@ export const PORTALS_EVENTS = Object.freeze({
DELETE_ARTICLE: 'Deleted an article',
PREVIEW_ARTICLE: 'Previewed article',
});
export const OPEN_AI_EVENTS = Object.freeze({
SUMMARIZE: 'OpenAI: Used summarize',
REPLY_SUGGESTION: 'OpenAI: Used reply suggestion',
REPHRASE: 'OpenAI: Used rephrase',
});
@@ -0,0 +1,119 @@
export const getInputType = (key, operator, filterTypes) => {
if (key === 'created_at' || key === 'last_activity_at')
if (operator === 'days_before') return 'plain_text';
const type = filterTypes.find(filter => filter.attributeKey === key);
return type?.inputType;
};
export const generateCustomAttributesInputType = type => {
const filterInputTypes = {
text: 'string',
number: 'string',
date: 'string',
checkbox: 'multi_select',
list: 'multi_select',
link: 'string',
};
return filterInputTypes[type];
};
export const getAttributeInputType = (key, allCustomAttributes) => {
const customAttribute = allCustomAttributes.find(
attr => attr.attribute_key === key
);
const { attribute_display_type } = customAttribute;
const filterInputTypes = generateCustomAttributesInputType(
attribute_display_type
);
return filterInputTypes;
};
export const getValuesName = (values, list, idKey, nameKey) => {
const item = list?.find(v => v[idKey] === values[0]);
return {
id: values[0],
name: item ? item[nameKey] : values[0],
};
};
const getValuesForLabels = (values, labels) => {
const selectedLabels = labels.filter(label => values.includes(label.title));
return selectedLabels.map(({ title }) => ({
id: title,
name: title,
}));
};
const getValuesForLanguages = (values, languages) => {
const selectedLanguages = languages.filter(language =>
values.includes(language.id)
);
return selectedLanguages.map(({ id, name }) => ({
id: id.toLowerCase(),
name: name,
}));
};
const getValuesForCountries = (values, countries) => {
const selectedCountries = countries.filter(country =>
values.includes(country.id)
);
return selectedCountries.map(({ id, name }) => ({
id: id,
name: name,
}));
};
export const getValuesForFilter = (filter, params) => {
const { attribute_key, values } = filter;
const {
languages,
countries,
agents,
inboxes,
teams,
campaigns,
labels,
} = params;
switch (attribute_key) {
case 'assignee_id':
return getValuesName(values, agents, 'id', 'name');
case 'inbox_id':
return getValuesName(values, inboxes, 'id', 'name');
case 'team_id':
return getValuesName(values, teams, 'id', 'name');
case 'campaign_id':
return getValuesName(values, campaigns, 'id', 'title');
case 'labels': {
return getValuesForLabels(values, labels);
}
case 'browser_language': {
return getValuesForLanguages(values, languages);
}
case 'country_code': {
return getValuesForCountries(values, countries);
}
default:
return { id: values[0], name: values[0] };
}
};
export const generateValuesForEditCustomViews = (filter, params) => {
const { attribute_key, filter_operator, values } = filter;
const { filterTypes, allCustomAttributes } = params;
const inboxType = getInputType(attribute_key, filter_operator, filterTypes);
if (inboxType === undefined) {
const filterInputTypes = getAttributeInputType(
attribute_key,
allCustomAttributes
);
return filterInputTypes === 'string'
? values[0].toString()
: { id: values[0], name: values[0] };
}
return inboxType === 'multi_select' || inboxType === 'search_select'
? getValuesForFilter(filter, params)
: values[0].toString();
};
@@ -1,9 +1,12 @@
const setArrayValues = item => {
return item.values[0]?.id ? item.values.map(val => val.id) : item.values;
};
const generatePayload = data => {
// Make a copy of data to avoid vue data reactivity issues
const filters = JSON.parse(JSON.stringify(data));
let payload = filters.map(item => {
if (Array.isArray(item.values)) {
item.values = item.values.map(val => val.id);
item.values = setArrayValues(item);
} else if (typeof item.values === 'object') {
item.values = [item.values.id];
} else if (!item.values) {
@@ -18,18 +18,18 @@ export const initializeAnalyticsEvents = () => {
});
}
});
window.bus.$on(ANALYTICS_RESET, () => {});
};
const initializeAudioAlerts = user => {
// InitializeAudioNotifications
const { ui_settings: uiSettings } = user || {};
const {
always_play_audio_alert: alwaysPlayAudioAlert,
enable_audio_alerts: audioAlertType,
alert_if_unread_assigned_conversation_exist: alertIfUnreadConversationExist,
notification_tone: audioAlertTone,
} = uiSettings;
// UI Settings can be undefined initally as we don't send the
// entire payload for the user during the signup process.
} = uiSettings || {};
DashboardAudioNotificationHelper.setInstanceValues({
currentUserId: user.id,
@@ -0,0 +1,66 @@
import {
getUnixTime,
format,
add,
startOfWeek,
addWeeks,
startOfMonth,
isMonday,
isToday,
setHours,
} from 'date-fns';
import wootConstants from 'dashboard/constants/globals';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
export const findStartOfNextWeek = currentDate => {
const startOfNextWeek = startOfWeek(addWeeks(currentDate, 1));
return isMonday(startOfNextWeek)
? startOfNextWeek
: add(startOfNextWeek, {
days: (8 - startOfNextWeek.getDay()) % 7,
});
};
export const findStartOfNextMonth = currentDate => {
const startOfNextMonth = startOfMonth(add(currentDate, { months: 1 }));
return isMonday(startOfNextMonth)
? startOfNextMonth
: add(startOfNextMonth, {
days: (8 - startOfNextMonth.getDay()) % 7,
});
};
export const findNextDay = currentDate => {
return add(currentDate, { days: 1 });
};
export const setHoursToNine = date => {
return setHours(date, 9, 0, 0);
};
export const findSnoozeTime = (snoozeType, currentDate = new Date()) => {
let parsedDate = null;
if (snoozeType === SNOOZE_OPTIONS.AN_HOUR_FROM_NOW) {
parsedDate = add(currentDate, { hours: 1 });
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_TOMORROW) {
parsedDate = setHoursToNine(findNextDay(currentDate));
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_WEEK) {
parsedDate = setHoursToNine(findStartOfNextWeek(currentDate));
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_MONTH) {
parsedDate = setHoursToNine(findStartOfNextMonth(currentDate));
}
return parsedDate ? getUnixTime(parsedDate) : null;
};
export const conversationReopenTime = snoozedUntil => {
if (!snoozedUntil) {
return null;
}
const date = new Date(snoozedUntil);
if (isToday(date)) {
return format(date, 'h.mmaaa');
}
return snoozedUntil ? format(date, 'd MMM, h.mmaaa') : null;
};
@@ -0,0 +1,278 @@
import {
getAttributeInputType,
getInputType,
getValuesName,
getValuesForFilter,
generateValuesForEditCustomViews,
generateCustomAttributesInputType,
} from '../customViewsHelper';
import advancedFilterTypes from 'dashboard/components/widgets/conversation/advancedFilterItems/index';
describe('customViewsHelper', () => {
describe('#getInputType', () => {
it('should return plain_text if key is created_at or last_activity_at and operator is days_before', () => {
const filterTypes = [
{ attributeKey: 'created_at', inputType: 'date' },
{ attributeKey: 'last_activity_at', inputType: 'date' },
];
expect(getInputType('created_at', 'days_before', filterTypes)).toEqual(
'plain_text'
);
expect(
getInputType('last_activity_at', 'days_before', filterTypes)
).toEqual('plain_text');
});
it('should return inputType if key is not created_at or last_activity_at', () => {
const filterTypes = [
{ attributeKey: 'created_at', inputType: 'date' },
{ attributeKey: 'last_activity_at', inputType: 'date' },
{ attributeKey: 'test', inputType: 'string' },
];
expect(getInputType('test', 'days_before', filterTypes)).toEqual(
'string'
);
});
it('should return undefined if key is not created_at or last_activity_at and inputType is not present', () => {
const filterTypes = [
{ attributeKey: 'created_at', inputType: 'date' },
{ attributeKey: 'last_activity_at', inputType: 'date' },
{ attributeKey: 'test', inputType: 'string' },
];
expect(getInputType('test', 'days_before', filterTypes)).toEqual(
'string'
);
});
});
describe('#getAttributeInputType', () => {
it('should return multi_select if attribute_display_type is checkbox or list', () => {
const allCustomAttributes = [
{ attribute_key: 'test', attribute_display_type: 'checkbox' },
{ attribute_key: 'test2', attribute_display_type: 'list' },
];
expect(getAttributeInputType('test', allCustomAttributes)).toEqual(
'multi_select'
);
expect(getAttributeInputType('test2', allCustomAttributes)).toEqual(
'multi_select'
);
});
it('should return string if attribute_display_type is text, number, date or link', () => {
const allCustomAttributes = [
{ attribute_key: 'test', attribute_display_type: 'text' },
{ attribute_key: 'test2', attribute_display_type: 'number' },
{ attribute_key: 'test3', attribute_display_type: 'date' },
{ attribute_key: 'test4', attribute_display_type: 'link' },
];
expect(getAttributeInputType('test', allCustomAttributes)).toEqual(
'string'
);
expect(getAttributeInputType('test2', allCustomAttributes)).toEqual(
'string'
);
expect(getAttributeInputType('test3', allCustomAttributes)).toEqual(
'string'
);
expect(getAttributeInputType('test4', allCustomAttributes)).toEqual(
'string'
);
});
});
describe('#getValuesName', () => {
it('should return id and name if item is present', () => {
const list = [{ id: 1, name: 'test' }];
const idKey = 'id';
const nameKey = 'name';
const values = [1];
expect(getValuesName(values, list, idKey, nameKey)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and value if item is not present', () => {
const list = [{ id: 1, name: 'test' }];
const idKey = 'id';
const nameKey = 'name';
const values = [2];
expect(getValuesName(values, list, idKey, nameKey)).toEqual({
id: 2,
name: 2,
});
});
});
describe('#getValuesForFilter', () => {
it('should return id and name if attribute_key is assignee_id', () => {
const filter = { attribute_key: 'assignee_id', values: [1] };
const params = { agents: [{ id: 1, name: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if attribute_key is inbox_id', () => {
const filter = { attribute_key: 'inbox_id', values: [1] };
const params = { inboxes: [{ id: 1, name: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if attribute_key is team_id', () => {
const filter = { attribute_key: 'team_id', values: [1] };
const params = { teams: [{ id: 1, name: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and title if attribute_key is campaign_id', () => {
const filter = { attribute_key: 'campaign_id', values: [1] };
const params = { campaigns: [{ id: 1, title: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and title if attribute_key is labels', () => {
const filter = { attribute_key: 'labels', values: ['test'] };
const params = { labels: [{ title: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual([
{ id: 'test', name: 'test' },
]);
});
it('should return id and name if attribute_key is browser_language', () => {
const filter = { attribute_key: 'browser_language', values: ['en'] };
const params = { languages: [{ id: 'en', name: 'English' }] };
expect(getValuesForFilter(filter, params)).toEqual([
{ id: 'en', name: 'English' },
]);
});
it('should return id and name if attribute_key is country_code', () => {
const filter = { attribute_key: 'country_code', values: ['IN'] };
const params = { countries: [{ id: 'IN', name: 'India' }] };
expect(getValuesForFilter(filter, params)).toEqual([
{ id: 'IN', name: 'India' },
]);
});
it('should return id and name if attribute_key is not present', () => {
const filter = { attribute_key: 'test', values: [1] };
const params = {};
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 1,
});
});
});
describe('#generateValuesForEditCustomViews', () => {
it('should return id and name if inboxType is multi_select or search_select', () => {
const filter = {
attribute_key: 'assignee_id',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
agents: [{ id: 1, name: 'test' }],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if inboxType is not multi_select or search_select', () => {
const filter = {
attribute_key: 'assignee_id',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
agents: [{ id: 1, name: 'test' }],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if inboxType is undefined', () => {
const filter = {
attribute_key: 'test2',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [
{ attribute_key: 'test', attribute_display_type: 'checkbox' },
{ attribute_key: 'test2', attribute_display_type: 'list' },
],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 1,
name: 1,
});
});
it('should return value as string if filterInputTypes is string', () => {
const filter = {
attribute_key: 'test',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [
{ attribute_key: 'test', attribute_display_type: 'date' },
{ attribute_key: 'test2', attribute_display_type: 'list' },
],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual('1');
});
});
describe('#generateCustomAttributesInputType', () => {
it('should return string if type is text', () => {
expect(generateCustomAttributesInputType('text')).toEqual('string');
});
it('should return string if type is number', () => {
expect(generateCustomAttributesInputType('number')).toEqual('string');
});
it('should return string if type is date', () => {
expect(generateCustomAttributesInputType('date')).toEqual('string');
});
it('should return multi_select if type is checkbox', () => {
expect(generateCustomAttributesInputType('checkbox')).toEqual(
'multi_select'
);
});
it('should return multi_select if type is list', () => {
expect(generateCustomAttributesInputType('list')).toEqual('multi_select');
});
it('should return string if type is link', () => {
expect(generateCustomAttributesInputType('link')).toEqual('string');
});
});
});
@@ -0,0 +1,105 @@
import {
findSnoozeTime,
conversationReopenTime,
findStartOfNextWeek,
findStartOfNextMonth,
findNextDay,
setHoursToNine,
} from '../snoozeHelpers';
describe('#Snooze Helpers', () => {
describe('findStartOfNextWeek', () => {
it('should return first working day of next week if a date is passed', () => {
const today = new Date('06/16/2023');
const startOfNextWeek = new Date('06/19/2023');
expect(findStartOfNextWeek(today)).toEqual(startOfNextWeek);
});
it('should return first working day of next week if a date is passed', () => {
const today = new Date('06/03/2023');
const startOfNextWeek = new Date('06/05/2023');
expect(findStartOfNextWeek(today)).toEqual(startOfNextWeek);
});
});
describe('findStartOfNextMonth', () => {
it('should return first working day of next month if a valid date is passed', () => {
const today = new Date('06/21/2023');
const startOfNextMonth = new Date('07/03/2023');
expect(findStartOfNextMonth(today)).toEqual(startOfNextMonth);
});
it('should return first working day of next month if a valid date is passed', () => {
const today = new Date('02/28/2023');
const startOfNextMonth = new Date('03/06/2023');
expect(findStartOfNextMonth(today)).toEqual(startOfNextMonth);
});
});
describe('setHoursToNine', () => {
it('should return date with 9.00AM time', () => {
const nextDay = new Date('06/17/2023');
nextDay.setHours(9, 0, 0, 0);
expect(setHoursToNine(nextDay)).toEqual(nextDay);
});
});
describe('findSnoozeTime', () => {
it('should return nil if until_next_reply is passed', () => {
expect(findSnoozeTime('until_next_reply')).toEqual(null);
});
it('should return next hour time stamp if an_hour_from_now is passed', () => {
const nextHour = new Date();
nextHour.setHours(nextHour.getHours() + 1);
expect(findSnoozeTime('an_hour_from_now')).toBeCloseTo(
Math.floor(nextHour.getTime() / 1000)
);
});
it('should return next day 9.00AM time stamp until_tomorrow is passed', () => {
const today = new Date('06/16/2023');
const nextDay = new Date('06/17/2023');
nextDay.setHours(9, 0, 0, 0);
expect(findSnoozeTime('until_tomorrow', today)).toBeCloseTo(
nextDay.getTime() / 1000
);
});
it('should return next week monday 9.00AM time stamp if until_next_week is passed', () => {
const today = new Date('06/16/2023');
const startOfNextWeek = new Date('06/19/2023');
startOfNextWeek.setHours(9, 0, 0, 0);
expect(findSnoozeTime('until_next_week', today)).toBeCloseTo(
startOfNextWeek.getTime() / 1000
);
});
it('should return next month 9.00AM time stamp if until_next_month is passed', () => {
const today = new Date('06/21/2023');
const startOfNextMonth = new Date('07/03/2023');
startOfNextMonth.setHours(9, 0, 0, 0);
expect(findSnoozeTime('until_next_month', today)).toBeCloseTo(
startOfNextMonth.getTime() / 1000
);
});
});
describe('conversationReopenTime', () => {
it('should return nil if snoozedUntil is nil', () => {
expect(conversationReopenTime(null)).toEqual(null);
});
it('should return formatted date if snoozedUntil is not nil', () => {
expect(conversationReopenTime('2023-06-07T09:00:00.000Z')).toEqual(
'7 Jun, 9.00am'
);
});
});
describe('findNextDay', () => {
it('should return next day', () => {
const today = new Date('06/16/2023');
const nextDay = new Date('06/17/2023');
expect(findNextDay(today)).toEqual(nextDay);
});
});
});
+4
View File
@@ -9,6 +9,7 @@ import es from './locale/es';
import fa from './locale/fa';
import fi from './locale/fi';
import fr from './locale/fr';
import he from './locale/he';
import hi from './locale/hi';
import hu from './locale/hu';
import id from './locale/id';
@@ -34,6 +35,7 @@ import vi from './locale/vi';
import zh_CN from './locale/zh_CN';
import zh_TW from './locale/zh_TW';
import is from './locale/is';
import lt from './locale/lt';
export default {
ar,
@@ -47,6 +49,7 @@ export default {
fa,
fi,
fr,
he,
hi,
hu,
id,
@@ -72,4 +75,5 @@ export default {
zh_CN,
zh_TW,
is,
lt,
};
@@ -2,13 +2,18 @@
"FILTER": {
"TITLE": "تصفية المحادثات",
"SUBTITLE": "إضافة فلاتر أدناه واضغط على 'إرسال' لتصفية المحادثات.",
"ADD_NEW_FILTER": "إضافة فلتر",
"FILTER_DELETE_ERROR": "يجب ان يكون لديك فلتر واحد على الاقل",
"SUBMIT_BUTTON_LABEL": "تطبيق الفلاتر",
"EDIT_CUSTOM_FILTER": "Edit Folder",
"CUSTOM_VIEWS_SUBTITLE": "Add or remove filters and update your folder.",
"ADD_NEW_FILTER": "إضافة عامل تصفية",
"FILTER_DELETE_ERROR": "يجب أن يكون لديك عامل تصفية واحد على الأقل للحفظ",
"SUBMIT_BUTTON_LABEL": "تطبيق عامل التصفية",
"UPDATE_BUTTON_LABEL": "Update folder",
"CANCEL_BUTTON_LABEL": "إلغاء",
"CLEAR_BUTTON_LABEL": "مسح الفلاتر",
"CLEAR_BUTTON_LABEL": "مسح عامل التصفية",
"EMPTY_VALUE_ERROR": "القيمة مطلوبة",
"TOOLTIP_LABEL": "تصفية المحادثة",
"FOLDER_LABEL": "Folder Name",
"FOLDER_QUERY_LABEL": "Folder Query",
"TOOLTIP_LABEL": "تصفية المحادثات",
"QUERY_DROPDOWN_LABELS": {
"AND": "و",
"OR": "أو"
@@ -23,7 +28,7 @@
"is_greater_than": "هو أكبر من",
"is_less_than": "هو أقل من",
"days_before": "قبل x أيام",
"starts_with": "Starts with"
"starts_with": "يبدأ بـ"
},
"ATTRIBUTE_LABELS": {
"TRUE": "صحيح",
@@ -38,7 +43,7 @@
"CAMPAIGN_NAME": "اسم الحملة",
"LABELS": "الوسوم",
"BROWSER_LANGUAGE": "لغة المتصفح",
"PRIORITY": "Priority",
"PRIORITY": "الأولوية",
"COUNTRY_NAME": "اسم الدولة",
"REFERER_LINK": "رابط المرجع",
"CUSTOM_ATTRIBUTE_LIST": "القائمة",
@@ -71,6 +76,9 @@
"ERROR_MESSAGE": "خطأ أثناء إنشاء طريقة عرض مخصصة"
}
},
"EDIT": {
"EDIT_BUTTON": "Edit folder"
},
"DELETE": {
"DELETE_BUTTON": "حذف الفلتر",
"MODAL": {
@@ -76,8 +76,8 @@
},
"AGENT_AVAILABILITY": {
"LABEL": "التوفر",
"PLACEHOLDER": "Please select an availability status",
"ERROR": "Availability is required"
"PLACEHOLDER": "الرجاء تحديد حالة التوفر",
"ERROR": "حالة التوفر مطلوبة"
},
"SUBMIT": "تعديل حساب الموظف"
},
@@ -111,7 +111,7 @@
"PLACEHOLDER": {
"AGENT": "البحث عن وكلاء",
"TEAM": "البحث عن فريق",
"INPUT": "Search for agents"
"INPUT": "البحث عن ممثلى الخدمة"
}
}
}
@@ -12,13 +12,37 @@
"TABLE_HEADER": [
"User",
"Action",
"عنوان IP",
"Time"
"عنوان IP"
]
},
"API": {
"SUCCESS_MESSAGE": "AuditLogs retrieved successfully",
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
},
"DEFAULT_USER": "System",
"AUTOMATION_RULE": {
"ADD": "%{agentName} created a new automation rule (#%{id})",
"EDIT": "%{agentName} updated an automation rule (#%{id})",
"DELETE": "%{agentName} deleted an automation rule (#%{id})"
},
"INBOX": {
"ADD": "%{agentName} created a new inbox (#%{id})",
"EDIT": "%{agentName} updated an inbox (#%{id})",
"DELETE": "%{agentName} deleted an inbox (#%{id})"
},
"WEBHOOK": {
"ADD": "%{agentName} created a new webhook (#%{id})",
"EDIT": "%{agentName} updated a webhook (#%{id})",
"DELETE": "%{agentName} deleted a webhook (#%{id})"
},
"USER_ACTION": {
"SIGN_IN": "%{agentName} signed in",
"SIGN_OUT": "%{agentName} signed out"
},
"TEAM": {
"ADD": "%{agentName} created a new team (#%{id})",
"EDIT": "%{agentName} updated a team (#%{id})",
"DELETE": "%{agentName} deleted a team (#%{id})"
}
}
}
@@ -36,17 +36,20 @@
}
},
"VIEW_FILTER": "عرض",
"SORT_TOOLTIP_LABEL": "Sort conversations",
"SORT_TOOLTIP_LABEL": "ترتيب المحادثات",
"CHAT_SORT": {
"STATUS": "الحالة",
"ORDER_BY": "Order by"
"ORDER_BY": "الترتيب بواسطة"
},
"CHAT_SORT_FILTER_ITEMS": {
"latest": {
"TEXT": "Last activity"
"TEXT": "النشاط الأخير"
},
"sort_on_created_at": {
"TEXT": "تم إنشاؤها في"
},
"sort_on_priority": {
"TEXT": "الأولوية"
}
},
"ATTACHMENTS": {
@@ -70,12 +73,12 @@
}
},
"CHAT_SORT_BY_FILTER": {
"TITLE": "Sort conversation",
"TITLE": "ترتيب المحادثات",
"DROPDOWN_TITLE": "ترتيب حسب",
"ITEMS": {
"LATEST": {
"NAME": "Last activity at",
"LABEL": "Last activity"
"NAME": "النشاط الأخير في",
"LABEL": "النشاط الأخير"
},
"CREATED_AT": {
"NAME": "تم إنشاؤها في",
@@ -73,6 +73,13 @@
"SUCCESS_MESSAGE": "تم حفظ جهة الاتصال بنجاح",
"ERROR_MESSAGE": "حدث خطأ، الرجاء المحاولة مرة أخرى"
},
"EXPORT_CONTACTS": {
"BUTTON_LABEL": "Export",
"TITLE": "Export Contacts",
"DESC": "Export contacts to a CSV file.",
"SUCCESS_MESSAGE": "Export is in progress, You will be notified via email when export file is ready to dowanlod.",
"ERROR_MESSAGE": "حدث خطأ، الرجاء المحاولة مرة أخرى"
},
"DELETE_NOTE": {
"CONFIRM": {
"TITLE": "تأكيد الحذف",
@@ -120,7 +127,7 @@
"PHONE_NUMBER": {
"PLACEHOLDER": "أدخل رقم الهاتف الخاص بجهة الاتصال",
"LABEL": "رقم الهاتف",
"HELP": "Phone number should be of E.164 format eg: +1415555555 [+][country code][area code][local phone number]. You can select the dial code from the dropdown.",
"HELP": "يجب ان يحتوى رقم الهاتف على كود دولتك تسبقها علامة +\nمثال: +20101243567",
"ERROR": "يجب ان تكون خانة رقم الهاتف إما فارغة او مكتملة مع رمز الدولة",
"DIAL_CODE_ERROR": "Please select a dial code from the list",
"DUPLICATE": "رقم الهاتف هذا مستخدم لجهة اتصال أخرى."
@@ -182,7 +189,7 @@
"LABEL": "إلى"
},
"INBOX": {
"LABEL": "Via Inbox",
"LABEL": "صندوق الوارد",
"PLACEHOLDER": "Choose source inbox",
"ERROR": "حدد صندوق الوارد"
},
@@ -211,6 +218,7 @@
"FILTER_CONTACTS": "فلترة",
"FILTER_CONTACTS_SAVE": "حفظ الفلتر",
"FILTER_CONTACTS_DELETE": "حذف الفلتر",
"FILTER_CONTACTS_EDIT": "Edit segment",
"LIST": {
"LOADING_MESSAGE": "جاري تحميل جهات الاتصال...",
"404": "لا توجد جهات اتصال تطابق بحثك 🔍",
@@ -1,50 +1,55 @@
{
"CONTACTS_FILTER": {
"TITLE": "تصفية جهات الاتصال",
"SUBTITLE": "إضافة فلاتر أدناه واضغط على 'إرسال' لتصفية جهات الاتصال.",
"ADD_NEW_FILTER": "إضافة فلتر",
"CLEAR_ALL_FILTERS": "مسح جميع الفلاتر",
"FILTER_DELETE_ERROR": "يجب ان يكون لديك فلتر واحد على الاقل",
"SUBMIT_BUTTON_LABEL": "إرسال",
"CANCEL_BUTTON_LABEL": "إلغاء",
"CLEAR_BUTTON_LABEL": "مسح الفلاتر",
"EMPTY_VALUE_ERROR": "القيمة مطلوبة",
"TOOLTIP_LABEL": "تصفية جهات الاتصال",
"QUERY_DROPDOWN_LABELS": {
"AND": ",
"OR": "أو"
},
"OPERATOR_LABELS": {
"equal_to": "يساوي",
"not_equal_to": "لا يساوي",
"contains": "يحتوي",
"does_not_contain": "لا يحتوي",
"is_present": "موجود",
"is_not_present": "غير موجود",
"is_greater_than": "هو أكبر من",
"is_lesser_than": "هو أقل من",
"days_before": "قبل x أيام"
},
"ATTRIBUTES": {
"NAME": "الاسم",
"EMAIL": "البريد الإلكتروني",
"PHONE_NUMBER": "رقم الهاتف",
"IDENTIFIER": "المعرف",
"CITY": "المدينة",
"COUNTRY": "الدولة",
"CUSTOM_ATTRIBUTE_LIST": "القائمة",
"CUSTOM_ATTRIBUTE_TEXT": "النص",
"CUSTOM_ATTRIBUTE_NUMBER": "العدد",
"CUSTOM_ATTRIBUTE_LINK": "الرابط",
"CUSTOM_ATTRIBUTE_CHECKBOX": "مربع",
"CREATED_AT": "تم إنشاؤها في",
"LAST_ACTIVITY": "آخر نشاط",
"REFERER_LINK": "رابط المرجع"
},
"GROUPS": {
"STANDARD_FILTERS": "الفلاتر القياسية",
"ADDITIONAL_FILTERS": "فلاتر إضافية",
"CUSTOM_ATTRIBUTES": "سمات مخصصة"
}
"CONTACTS_FILTER": {
"TITLE": "تصفية جهات الاتصال",
"SUBTITLE": "إضافة فلاتر أدناه واضغط على 'إرسال' لتصفية جهات الاتصال.",
"EDIT_CUSTOM_SEGMENT": "Edit Segment",
"CUSTOM_VIEWS_SUBTITLE": "Add or remove filters and update your segment.",
"ADD_NEW_FILTER": "إضافة فلتر",
"CLEAR_ALL_FILTERS": "مسح جميع الفلاتر",
"FILTER_DELETE_ERROR": "يجب ان يكون لديك فلتر واحد على الاقل",
"SUBMIT_BUTTON_LABEL": "إرسال",
"UPDATE_BUTTON_LABEL": "Update Segment",
"CANCEL_BUTTON_LABEL": "إلغاء",
"CLEAR_BUTTON_LABEL": "مسح الفلاتر",
"EMPTY_VALUE_ERROR": "القيمة مطلوبة",
"SEGMENT_LABEL": "Segment Name",
"SEGMENT_QUERY_LABEL": "Segment Query",
"TOOLTIP_LABEL": "تصفية جهات الاتصال",
"QUERY_DROPDOWN_LABELS": {
"AND": ",
"OR": "أو"
},
"OPERATOR_LABELS": {
"equal_to": "يساوي",
"not_equal_to": "لا يساوي",
"contains": "يحتوي",
"does_not_contain": "لا يحتوي",
"is_present": "موجود",
"is_not_present": "غير موجود",
"is_greater_than": "هو أكبر من",
"is_lesser_than": "هو أقل من",
"days_before": "قبل x أيام"
},
"ATTRIBUTES": {
"NAME": "الاسم",
"EMAIL": "البريد الإلكتروني",
"PHONE_NUMBER": "رقم الهاتف",
"IDENTIFIER": "المعرف",
"CITY": "المدينة",
"COUNTRY": "الدولة",
"CUSTOM_ATTRIBUTE_LIST": "القائمة",
"CUSTOM_ATTRIBUTE_TEXT": "النص",
"CUSTOM_ATTRIBUTE_NUMBER": "العدد",
"CUSTOM_ATTRIBUTE_LINK": "الرابط",
"CUSTOM_ATTRIBUTE_CHECKBOX": "مربع",
"CREATED_AT": "تم إنشاؤها في",
"LAST_ACTIVITY": "آخر نشاط",
"REFERER_LINK": "رابط المرجع"
},
"GROUPS": {
"STANDARD_FILTERS": "الفلاتر القياسية",
"ADDITIONAL_FILTERS": "فلاتر إضافية",
"CUSTOM_ATTRIBUTES": "سمات مخصصة"
}
}
}

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