Compare commits

..
Author SHA1 Message Date
Vishnu Narayanan cd016c5ad4 chore: log webhook response data on error 2023-11-24 14:18:13 +05:30
Sojan dcd18072b4 Merge branch 'release/3.3.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-11-18 10:24:25 -08:00
Sojan 4244945e7b Bump version to 3.3.1 2023-11-18 10:23:18 -08:00
Clairton Rodrigo HeinzenandGitHub 17725e4dd0 fix: Breakage of message pane when Openai integration isn't enabled (#8381)
Error when the Openai integration isn't enabled

fixes: https://github.com/chatwoot/chatwoot/issues/8379
2023-11-18 10:18:55 -08:00
Sojan d55bb63aa0 Merge branch 'release/3.3.0' into develop 2023-11-17 20:37:44 -08:00
Sojan bda2d1d9a4 Merge branch 'release/3.3.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-11-17 20:37:25 -08:00
Sojan 6e22695b85 Bump version to 3.3.0 2023-11-17 20:34:39 -08:00
Pranav Raj SandGitHub 146e46d79f fix: Disable AI Assist CTA if not required (#8375)
- Show CTAs only on production
2023-11-17 20:10:46 -08:00
Nithin David ThomasandGitHub 0af27a2387 feat: Adds support for logo in portal settings page [CW-2585] (#8354) 2023-11-17 19:58:27 -08:00
7380f0e7ce chore: Making OpenAI label suggestions optional (#8374)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-11-17 19:54:15 -08:00
Pranav Raj SandGitHub 2c5a0abcce fix: Update the height of the input based on the rows (#8373) 2023-11-17 17:29:44 -08:00
Sojan JoseandGitHub f23e33c1a9 perf: Remove unwanted conversation count query (#8372) 2023-11-17 16:40:40 -08:00
5588d6e344 feat: convert feature_flag to bigint [CW-2767] (#8350)
Co-authored-by: Sojan <sojan@pepalo.com>
2023-11-17 15:50:24 -08:00
59ace66c6f chore: [Snyk] Fix for vulnerabilities (#8340)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-11-17 15:24:00 -08:00
Sojan JoseandGitHub decef1ad52 chore: Add rails_panel gem for query debugging (#8370) 2023-11-18 02:30:35 +05:30
Sojan JoseandGitHub 0e19a4196b chore: Endpoint to purge portal logo (#8365) 2023-11-18 01:13:22 +05:30
Muhsin KelothandGitHub 04dd65687b feat: Changes to include delivery reports for all channels (#8329) 2023-11-16 19:43:35 +05:30
Muhsin KelothandGitHub 5f503b1a57 feat: Change slack bot and activity message appearance (#8349) 2023-11-16 12:59:52 +05:30
Sojan JoseandGitHub 5f6e974531 chore: Add website_token into request logs (#8362)
- Log website_token into request logs to identify specific widgets
2023-11-15 20:32:21 -08:00
Sojan JoseandGitHub a023eabf59 chore: Add logging for facebook errors (#8360)
- Add logging for Facebook errors
2023-11-15 14:56:21 -08:00
Sivin VargheseandGitHub cb1e25a487 feat: Change the highlight in article page on scrolling (#8330) 2023-11-15 13:51:46 +05:30
Sojan JoseandGitHub bcf7c6c45a chore: Improve account seeding (#8357) 2023-11-15 13:29:00 +05:30
Shivam MishraandGitHub b411b6ca21 feat: remove feature flag checks for message reply to (#8352) 2023-11-15 13:05:58 +05:30
Shivam MishraandGitHub cd217a3f86 feat: allow automation rules to remove labels (#8346) 2023-11-13 13:10:55 +05:30
Pranav Raj SandGitHub 6c79642450 chore: Update dashboard screenshots and the logo (#8342) 2023-11-11 23:08:05 +05:30
Nithin David ThomasandGitHub 201d476720 chore: Add meta viewport tag to public portal (#8335) 2023-11-09 20:30:58 -08:00
Muhsin KelothandGitHub c1a2e67c9e fix: Change the route change logic based on bus events, after creating a new conversation in the widget. (#8328) 2023-11-10 09:44:59 +05:30
1948bef9ac fix: Fix line break issue with link in portal header (#8323)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-11-08 20:38:38 -08:00
Sivin VargheseandGitHub 5486f39f7e feat: Update uncategorized block design (#8324) 2023-11-09 08:47:38 +05:30
f018b0013c feat: Updated the design of the article page (#8166)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-11-08 18:05:13 -08:00
950f085e80 feat: Updated the design of the category page (#8165)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-11-08 18:03:16 -08:00
268e26625b feat: Adds the ability to see the popular articles (#8152)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-11-08 18:01:07 -08:00
7041d86e4d feat: Updated home page design (#8106)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-11-08 17:59:03 -08:00
78398ee800 feat: Updated public portal header design (#8089)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-11-08 17:56:59 -08:00
Sojan JoseandGitHub 7b09b02737 chore: Fix trigger message for response Bot (#8322)
- Fix the bug where wrong messages was used to search response sources
2023-11-08 17:02:48 -08:00
Muhsin KelothandGitHub af7631d9f1 chore: Update MarkMessagesAsReadJob to accept delivered status (#8319) 2023-11-08 13:44:57 -08:00
Sivin VargheseandGitHub e0f33e62af feat: Adds support for image resize in the message bubble (#8182) 2023-11-08 14:11:47 +05:30
5d224f2e04 feat: allow inbox specific flags for signature toggle (#8280)
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
2023-11-08 10:47:22 +05:30
Gabriel QuinaandGitHub 4cb981d81b fix: Semantics across portuguese translations (#8291) 2023-11-08 10:42:57 +05:30
f891cfbf47 chore: [Snyk] Security upgrade sidekiq-cron from 1.10.1 to 1.11.0 (#8294)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-11-08 10:32:04 +05:30
Sojan JoseandGitHub e54c8a857d chore: Debug ChatGpt response (#8316)
- Add loggers to debug ChatGPT requests and responses
2023-11-07 20:07:29 -08:00
66229b0d6b chore: [Snyk] Security upgrade audited from 5.4.0 to 5.4.1 (#8305)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-11-07 17:45:32 -08:00
Muhsin KelothandGitHub 3eb1d49577 fix: Handle all types of errors in API channel webhooks (#8307) 2023-11-07 12:54:49 +05:30
Vishnu NarayananandGitHub 677888bcde feat: Add job to remove stale contact inboxes (#8096) 2023-11-06 20:24:30 -08:00
abbb4180ea chore: Add facebook business_management scope (#8004)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-11-06 18:37:41 -08:00
Pranav Raj SandGitHub d1cbc56d67 chore: Update widget config API to migrate the widget (#8303)
- Update the keys in the widget config API to support loading the widget separately from the Rails App.
2023-11-06 15:27:29 -08:00
EmanuelandGitHub b579ff832e chore: Update the case in the translation files (#8298) 2023-11-06 08:01:54 -08:00
Nithin David ThomasandGitHub 39d0748a5b feat: Lets users insert connected portal article into replies [CW-2282] (#8117)
- Lets users insert connected portal articles into replies

https://linear.app/chatwoot/issue/CW-2282/list-all-the-top-articles-from-the-connected-help-center
https://linear.app/chatwoot/issue/CW-1453/container-view-for-showing-search-input-and-result-items
2023-11-04 02:57:25 -07:00
Muhsin KelothandGitHub b4d20689b7 feat: Mark the messages as failed if the API channel webhooks fail for any reason. (#8277) 2023-11-04 12:26:28 +05:30
3b84b0fc47 feat: Creates 404 page for public portal [CW-2727] (#8293)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-11-04 04:59:44 +05:30
Nithin David ThomasandGitHub 23ea829510 chore: Refactors help center article url helper (#8269) 2023-11-04 04:18:34 +05:30
ebe9daea00 chore: Swagger documentation for contact labels APIs (#8279)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-11-03 15:09:12 +05:30
Shivam MishraandGitHub 36b6c0cb9c feat: support image height in markdown rendering of messages (#8177)
- This PR adds BaseMarkdownRenderer, it takes all the required attributes from the image node, parses the cw_image_height query and renders it.
2023-11-02 13:51:54 -07:00
Muhsin KelothandGitHub 16c36a78f0 chore: Remove sentry exceptions for the message status failed errors (#8285)
- Remove sending exceptions to Sentry after capturing the message failed errors.
2023-11-02 12:00:22 -07:00
Nithin David ThomasandGitHub 4999f7ed23 fix: Fixes wrong page size in category filter in articles page (#8270) 2023-11-02 15:19:19 +05:30
Chatwoot BotandGitHub e8f4ac632f chore: Update translations (#8255) 2023-11-01 15:13:32 -07:00
3c7854ccdf fix: Fixes error in ChatList component (#8266)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-11-02 03:23:29 +05:30
Pranav Raj SandGitHub 1e70223ed7 chore: Upgrade Cypress to 13.4.0 (#8271) 2023-10-31 19:39:34 -07:00
f34cd3ea61 chore: [Snyk] Security upgrade newrelic-sidekiq-metrics from 1.6.1 to 1.6.2 (#8254)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-10-31 18:24:18 -07:00
Muhsin KelothandGitHub 68b8d721a7 fix: Update the message status to failed if the outgoing Facebook message fails. (#8258) 2023-10-31 17:40:17 -07:00
Nithin David ThomasandGitHub d876419b27 fix: Update text area height while typing [CW-2438] (#8268) 2023-10-31 17:39:25 -07:00
8455186e9f feat: allow instagram reply_to [CW-2609] (#8248)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-10-31 17:31:12 -07:00
b6584ec68b feat: toggle reply to on widget based on feature flag (#8261)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-10-31 21:33:14 +05:30
Muhsin KelothandGitHub 29f18c7f18 fix: Handle Twilio::REST::RestError (#8257) 2023-10-31 16:23:34 +05:30
Muhsin KelothandGitHub 11b27f9805 feat: Handle Line send message/attachments errors (#8200) 2023-10-31 07:42:30 +05:30
Pranav Raj SandGitHub 653e0335c0 fix: Handle PermissionDeniedError for Dialogflow processor (#8252) 2023-10-30 13:24:03 -07:00
013dab7da6 fix: Add support for dark mode for the calendars (date and time picker). (#8250)
Co-authored-by: Liam Ashdown <liam.ashdown@debbiesvillas.co.uk>
2023-10-30 11:48:16 -07:00
Pranav Raj SandGitHub 2ba81830f3 fix: Update Instagram story rendering (#8240) 2023-10-30 11:16:14 -07:00
Sivin VargheseandGitHub dfba4770bd feat: Save sort Conversations filter (#8237) 2023-10-30 15:25:21 +05:30
LiamandGitHub 63e702ca12 fix: Fix an issue where Chat Widget Unread Message is not correctly d… (#8245) 2023-10-30 12:52:26 +05:30
Will KramerandGitHub 394fece846 docs: Update .env.example for clearer inbound Postmark instructions (#8235)
Updated the .env.example file to provide clearer instructions for setting up the Postmark inbound webhook. Many users, including myself, found the initial instructions ambiguous, leading to difficulties during setup. This change aims to simplify the process for future users by providing clearer instructions and examples.

Fixes: #8234
2023-10-27 16:06:27 -07:00
Muhsin KelothandGitHub 24fbab94c3 chore: Refactor MarkMessagesAsReadJob based on conversation id and time stamp (#8217)
- Mark all messages as read by providing the conversation ID and timestamp.
- For Instagram, ensure all previous messages that weren't marked as failed are now marked as read. This is because the read events are only triggered for the most recent message and not for any previous ones.
2023-10-27 15:21:39 -07:00
Muhsin KelothandGitHub 61e03fa33a feat: Bandwidth sms channel delivery reports (#8198)
Fixes: https://linear.app/chatwoot/issue/CW-2566/delivery-report-for-bandwidth-sms
2023-10-27 14:07:15 -07:00
Pranav Raj SandGitHub 6c4b92f7f6 fix: Revert the changes for SyncCustomFilterCountJob (#8238) 2023-10-27 10:45:48 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f023325b0e chore(deps): bump browserify-sign from 4.2.1 to 4.2.2 (#8233)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-27 07:39:48 -07:00
dd05482cb0 chore: update UI copy (#8229)
Co-authored-by: Hricha Shandily <hrichashandily@gmail.com>
2023-10-27 16:05:42 +05:30
d94108bf3f feat: show ReplyTo in widget UI (#8094)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-10-27 13:35:02 +05:30
ab872beb1d feat: Helper to support dynamic system theme in public portal (#8206)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-10-26 12:59:51 +05:30
d02cfff4cc fix: canned responses not working when signature is present (#8176)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-10-26 11:35:46 +05:30
Robert CokerandGitHub 736f269c49 fix: Always reset CC/BCC emails when switching conversations (#8157) 2023-10-25 21:42:36 -07:00
Pranav Raj SandGitHub b067acfd4a fix: Update the alignment of the info icon on contact info panel (#8216) 2023-10-25 21:30:14 -07:00
Sivin VargheseandGitHub 232fa6fd18 fix: Revert missing files for search (#8215) 2023-10-26 09:08:28 +05:30
Chatwoot BotandGitHub 483d55df91 chore: Update translations (#8161) 2023-10-25 19:20:30 -07:00
3e54d3654b feat: Updated the search result fly-out menu design (#8203)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-10-25 19:16:49 -07:00
Sojan JoseandGitHub b6831d464e chore: Update API documentation for whatsapp templates (#8211)
- Update API documentation for whatsapp templates
2023-10-25 17:06:58 -07:00
Sojan JoseandGitHub 8c67ea56e7 chore: Campaign conversations should be created in open state (#8209)
- Ensure that conversations originating from campaigns are created in the open state.
2023-10-25 13:03:08 -07:00
Muhsin KelothandGitHub 54bc4c23df fix: Telegram model specs (#8207) 2023-10-26 00:14:09 +05:30
Muhsin KelothandGitHub 58f47eb02c feat: Handle Facebook send message/attachments errors (#8197) 2023-10-25 08:59:25 -07:00
4fc62ed9ae feat: Handle Instagram send message/attachments errors (#8174)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-10-25 08:56:09 -07:00
4a89bab23a feat: Handle Telegram send message/attachments errors (#8173)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-10-25 08:36:31 -07:00
Muhsin KelothandGitHub 7438f3b157 fix: Rescue slack ChannelNotFound error (#8196) 2023-10-25 13:53:30 +05:30
Shivam MishraandGitHub 9551dbc25f feat: add gtm to vueapp (#8154) 2023-10-24 15:51:21 +05:30
Sivin VargheseandGitHub 2e54f4a67c feat: Adds an event for the Google Translate usage (#8175) 2023-10-24 15:34:51 +05:30
Shivam MishraandGitHub 6d9b2a6b33 refactor: UI flags for Reply To (#8147) 2023-10-20 14:19:19 +05:30
Sivin VargheseandGitHub 35a9acf099 feat: Adds the ability to set an emoji for help center category (#8111) 2023-10-20 13:52:30 +05:30
b9694a0818 feat: support reply to for Telegram (#8105)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-10-20 13:14:20 +05:30
Shivam MishraandGitHub 7416bbb25e feat: support reply to for outgoing message in WhatsApp (#8107)
- This PR enables replies to WhatsApp.
2023-10-19 13:24:46 -07:00
Muhsin KelothandGitHub b94c89ebf1 fix: Show sent status for messenger channel (#8145) 2023-10-19 13:22:19 +05:30
Muhsin KelothandGitHub 78ce8a4652 feat: Add support for Instagram delivery reports (#8125) 2023-10-18 23:42:34 -07:00
Muhsin KelothandGitHub 04c874fe35 feat: Add delivery reports for API channel (#8116) 2023-10-18 10:11:13 +05:30
Sojan eabe548520 Merge branch 'release/3.2.0' into develop 2023-10-17 17:55:42 -07:00
Sojan 83231b9678 Merge branch 'release/3.2.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-10-17 17:55:34 -07:00
Sojan f96b54e57f Bump version to 3.2.0 2023-10-17 17:54:38 -07:00
Pranav Raj SandGitHub d3bf828524 chore: Adds hasConversation flag in chatwoot:on-start-conversation event (#8132) 2023-10-17 16:24:00 -07:00
c20415f3ba test: reset Current.user before each example (#8126)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-10-17 15:42:45 -07:00
Pranav Raj SandGitHub 6b2ed4fe79 fix: Add reset password for edit agent screen (#8130) 2023-10-17 12:54:05 -07:00
Shivam MishraandGitHub eca9ca90f1 feat: Add June event for reply to (#8118) 2023-10-17 09:51:28 -07:00
Shivam MishraandGitHub 9018ec9678 test: reset Current.user after spec (#8123) 2023-10-17 18:06:54 +05:30
Shivam MishraandGitHub e55fe33f1b test: fix flaky test by adding margin for time diff (#8121) 2023-10-17 14:20:40 +05:30
3b0788c3be chore: Update translations (#8024)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-10-16 19:55:25 -07:00
Vishnu NarayananandGitHub 626e67b859 feat: add autoupdate for cwctl (#8108)
- During cwctl --upgrade, check for the latest version of cwctl from master branch
- Upgrade cwctl if a newer version is found
2023-10-16 19:43:44 -07:00
Nithin David ThomasandGitHub 28db18ebbd chore: Shows articles in dark mode for widget (#8115) 2023-10-16 16:59:15 +05:30
b28721e10b feat: Creates components for Article Search in Reply [CW-2285] (#7957)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-10-16 15:02:02 +05:30
Shivam MishraandGitHub f77db4d814 refactor: disable bubble by flag (#8109) 2023-10-13 21:34:22 +05:30
62d8ec7edb feat: support reply to for incoming messages on facebook (#8076)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-10-13 16:33:50 +05:30
7b09fa4a03 chore(snyk): Upgrade gems to fix SNYK-RUBY-RACK-1061917 (#8104)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-10-13 09:24:50 +05:30
Muhsin KelothandGitHub 980013abae feat: Add delivery reports for live chat (#8092) 2023-10-12 20:48:20 +05:30
Shivam MishraandGitHub 5c729ccea5 chore: Add null checks (#8100) 2023-10-12 19:39:19 +05:30
Vishnu NarayananandGitHub ae52ca87aa fix: Handle empty status in conversation controller (#8091)
Fixes CW-2644
2023-10-12 17:11:04 +05:30
Vishnu NarayananandGitHub 415bb23c37 fix: Handle invalid metric in ReportsController (#8086)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>

Fixes CW-2643
2023-10-12 17:01:23 +05:30
Pranav Raj SandGitHub 9e173fca3c fix: Return 404 if conversation is nil (#8098) 2023-10-12 17:00:51 +05:30
Muhsin KelothandGitHub bd918ee506 fix: Change the message status to failed if the Twilio message delivery status is undelivered (#8097) 2023-10-12 15:27:38 +05:30
Shivam MishraandGitHub 7c9884b853 feat: Remove unused prop (#8093) 2023-10-12 12:14:39 +05:30
7ffa669c5c feat: Implement message bubble reply to (#8068)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-10-11 22:04:12 +05:30
0bc20873f6 feat: Add delivery status for Twilio Channel (#8082)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-10-11 21:17:46 +05:30
88de3359a5 fix: Long names hide the resolve button (#8083)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-10-11 13:25:20 +05:30
Sojan JoseandGitHub a3d008da06 chore: Ensure null validation for private attribute in messages (#8085) 2023-10-11 13:16:59 +05:30
cbae95422d feat: Implement reply to for reply editor (#8063)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-10-10 19:13:12 +05:30
Shivam MishraandGitHub 081c845c56 chore: Remove twitter actions (#8079) 2023-10-10 16:48:58 +05:30
Pranav Raj SandGitHub 1e54d6deb3 fix: Update duplicate ids for dashboard app frame (#8077) 2023-10-10 16:46:32 +05:30
Shivam MishraandGitHub bb5c6a416a fix: insert position for canned responses (#8069) 2023-10-10 10:36:10 +05:30
6a07251007 feat: Add the ability to self-assign conversations in macros (#8048)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-10-10 09:38:23 +05:30
Sivin VargheseandGitHub 759a66dd21 feat: Add the ability to send attachment in new conversation (#7913) 2023-10-10 09:34:36 +05:30
Vishnu NarayananandGitHub 8189dd932c feat: Report cwctl events to hub (#8009) 2023-10-10 09:16:03 +05:30
b6ba0f343e feat: Reverse the contact merge (#8057)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-10-10 08:20:48 +05:30
Sivin VargheseandGitHub 86ca90aa15 feat: Support image resize in message signature (#8042) 2023-10-09 13:20:12 +05:30
Muhsin KelothandGitHub 26e8877cd9 feat: Support link unfurling for all the channels within the same connected channel account. (#8033) 2023-10-08 17:55:03 +05:30
1b63adfb2e feat: add option for reply to in context menu (#8043)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-10-05 20:01:20 +05:30
e27274a5a8 chore: Adds a bus event to insert text at cursor in editor (#7968)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-10-05 14:37:01 +05:30
e5c198f839 refactor: Remove redundant accounts/get (#8056)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-10-05 13:54:04 +05:30
Sojan JoseandGitHub 72ba429159 fix: Show sources in bot response only if documents are present (#8055)
- show sources section in bot only based on appropriate conditions
2023-10-05 00:11:01 -07:00
Pranav Raj SandGitHub 3227cf7ff3 fix: Update query to support multiple tag conditions (#8054) 2023-10-05 10:47:42 +05:30
f0740120a8 chore: Add a fix for mouse up event in modal (#7950)
Co-authored-by: BikashSah999 <51731962+BikashSah999@users.noreply.github.com>
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-10-05 10:44:31 +05:30
3ea54065b1 feat: Upgrade prompt for help center (#8010)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-10-05 08:34:21 +05:30
Sojan JoseandGitHub 6f19546c3c feat: Add article messages along with bot responses (#7993)
ref: https://linear.app/chatwoot/issue/CW-2464/bot-should-also-return-links-to-the-information
2023-10-04 15:40:59 -07:00
Mazen KhalilandGitHub 5c9ab21617 fix: Application not loading after setting RACK_ATTACK_LIMIT ENV variable [CW-2587] (#8044) 2023-10-04 15:56:20 +05:30
773 changed files with 12941 additions and 4180 deletions
+21 -2
View File
@@ -81,7 +81,7 @@ SMTP_OPENSSL_VERIFY_MODE=peer
# Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled
MAILER_INBOUND_EMAIL_DOMAIN=
# Set this to appropriate ingress channel with regards to incoming emails
# Set this to the appropriate ingress channel with regards to incoming emails
# Possible values are :
# relay for Exim, Postfix, Qmail
# mailgun for Mailgun
@@ -91,10 +91,20 @@ MAILER_INBOUND_EMAIL_DOMAIN=
RAILS_INBOUND_EMAIL_SERVICE=
# Use one of the following based on the email ingress service
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
# Set this to a password of your choice and use it in the Inbound webhook
RAILS_INBOUND_EMAIL_PASSWORD=
MAILGUN_INGRESS_SIGNING_KEY=
MANDRILL_INGRESS_API_KEY=
# Creating Your Inbound Webhook Instructions for Postmark and Sendgrid:
# Inbound webhook URL format:
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
# Note: Replace the values inside the brackets; do not include the brackets themselves.
# Example: https://actionmailbox:mYRandomPassword3@chatwoot.example.com/rails/action_mailbox/postmark/inbound_emails
# For Postmark
# Ensure the 'Include raw email content in JSON payload' checkbox is selected in the inbound webhook section.
# Storage
ACTIVE_STORAGE_SERVICE=local
@@ -176,6 +186,9 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# MICROSOFT CLARITY
# MS_CLARITY_TOKEN=xxxxxxxxx
# GOOGLE_TAG_MANAGER
# GOOGLE_TAG = GTM-XXXXXXX
## Scout
## https://scoutapm.com/docs/ruby/configuration
# SCOUT_KEY=YOURKEY
@@ -199,7 +212,7 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
## Rack Attack configuration
## To prevent and throttle abusive requests
# ENABLE_RACK_ATTACK=true
# RACK_ATTACK_IP_LIMIT=3000
# RACK_ATTACK_LIMIT=300
# ENABLE_RACK_ATTACK_WIDGET_API=true
## Running chatwoot as an API only server
@@ -240,3 +253,9 @@ AZURE_APP_SECRET=
# Sentiment analysis model file path
SENTIMENT_FILE_PATH=
# Housekeeping/Performance related configurations
# Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
+1
View File
@@ -4,6 +4,7 @@ module.exports = {
'prettier',
'plugin:vue/recommended',
'plugin:storybook/recommended',
'plugin:cypress/recommended',
],
parserOptions: {
parser: '@babel/eslint-parser',
+11 -9
View File
@@ -15,7 +15,7 @@ gem 'browser'
gem 'hashie'
gem 'jbuilder'
gem 'kaminari'
gem 'responders'
gem 'responders', '>= 3.1.1'
gem 'rest-client'
gem 'telephone_number'
gem 'time_diff'
@@ -67,7 +67,7 @@ gem 'webpacker'
gem 'barnes'
##--- gems for authentication & authorization ---##
gem 'devise'
gem 'devise', '>= 4.9.3'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth'
# authorization
@@ -92,7 +92,7 @@ gem 'twitty', '~> 0.1.5'
# facebook client
gem 'koala'
# slack client
gem 'slack-ruby-client', '~> 2.0.0'
gem 'slack-ruby-client', '~> 2.2.0'
# for dialogflow integrations
gem 'google-cloud-dialogflow-v2'
gem 'grpc'
@@ -107,16 +107,16 @@ gem 'google-cloud-translate-v3'
gem 'ddtrace', require: false
gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
gem 'scout_apm', require: false
gem 'sentry-rails', '>= 5.11.0', require: false
gem 'sentry-rails', '>= 5.13.0', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.11.0', require: false
gem 'sentry-sidekiq', '>= 5.13.0', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.1.3'
# We want cron jobs
gem 'sidekiq-cron', '>= 1.10.1'
gem 'sidekiq-cron', '>= 1.11.0'
##-- Push notification service --##
gem 'fcm'
@@ -154,12 +154,12 @@ gem 'stripe'
gem 'faker'
# Include logrange conditionally in intializer using env variable
gem 'lograge', '~> 0.13.0', require: false
gem 'lograge', '~> 0.14.0', require: false
# worked with microsoft refresh token
gem 'omniauth-oauth2'
gem 'audited', '~> 5.4', '>= 5.4.0'
gem 'audited', '~> 5.4', '>= 5.4.1'
# need for google auth
gem 'omniauth'
@@ -200,6 +200,8 @@ group :development do
# profiling
gem 'rack-mini-profiler', '>= 3.1.1', require: false
gem 'stackprof'
# Should install the associated chrome extension to view query logs
gem 'meta_request'
end
group :test do
+45 -38
View File
@@ -126,9 +126,9 @@ GEM
rake (>= 10.4, < 14.0)
ast (2.4.2)
attr_extras (7.1.0)
audited (5.4.0)
activerecord (>= 5.0, < 7.2)
request_store (~> 1.2)
audited (5.4.1)
activerecord (>= 5.0, < 7.7)
activesupport (>= 5.0, < 7.7)
aws-eventstream (1.2.0)
aws-partitions (1.760.0)
aws-sdk-core (3.171.1)
@@ -148,7 +148,8 @@ GEM
barnes (0.0.9)
multi_json (~> 1)
statsd-ruby (~> 1.1)
bcrypt (3.1.18)
base64 (0.1.1)
bcrypt (3.1.19)
bindex (0.8.1)
blingfire (0.1.8)
bootsnap (1.16.0)
@@ -174,7 +175,7 @@ GEM
rexml
crass (1.0.6)
csv-safe (3.2.1)
cypress-on-rails (1.13.1)
cypress-on-rails (1.16.0)
rack
database_cleaner (2.0.2)
database_cleaner-active_record (>= 2, < 3)
@@ -193,7 +194,7 @@ GEM
irb (>= 1.5.0)
reline (>= 0.3.1)
declarative (0.0.20)
devise (4.9.2)
devise (4.9.3)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
@@ -261,7 +262,7 @@ GEM
rake
flag_shih_tzu (0.3.23)
foreman (0.87.2)
fugit (1.8.1)
fugit (1.9.0)
et-orbi (~> 1, >= 1.2.7)
raabro (~> 1.4)
gapic-common (0.18.0)
@@ -273,7 +274,7 @@ GEM
googleauth (~> 1.0)
grpc (~> 1.36)
geocoder (1.8.1)
gli (2.21.0)
gli (2.21.1)
globalid (1.2.1)
activesupport (>= 6.1)
gmail_xoauth (0.4.2)
@@ -434,12 +435,12 @@ GEM
llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
lograge (0.13.0)
lograge (0.14.0)
actionpack (>= 4)
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.21.3)
loofah (2.21.4)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -450,13 +451,16 @@ GEM
marcel (1.0.2)
maxminddb (0.1.22)
memoist (0.16.2)
meta_request (0.7.4)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 7.1)
method_source (1.0.0)
mime-types (3.4.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2023.0218.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.4)
mini_portile2 (2.8.5)
minitest (5.20.0)
mock_redis (0.36.0)
ruby2_keywords
@@ -478,10 +482,11 @@ GEM
net-smtp (0.3.3)
net-protocol
netrc (0.11.0)
newrelic-sidekiq-metrics (1.6.1)
newrelic_rpm (~> 8)
newrelic-sidekiq-metrics (1.6.2)
newrelic_rpm (>= 8.0.0)
sidekiq
newrelic_rpm (8.16.0)
newrelic_rpm (9.6.0)
base64
nio4r (2.5.9)
nokogiri (1.15.4)
mini_portile2 (~> 2.8.2)
@@ -553,10 +558,12 @@ GEM
pundit (2.3.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.7.1)
racc (1.7.3)
rack (2.2.8)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.4.0)
rack (< 4)
rack-cors (2.0.1)
rack (>= 2.0.0)
rack-mini-profiler (3.1.1)
@@ -597,13 +604,13 @@ GEM
thor (~> 1.0)
zeitwerk (~> 2.5)
rainbow (3.1.1)
rake (13.0.6)
rake (13.1.0)
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.17.0)
redis-client (0.18.0)
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
@@ -616,7 +623,7 @@ GEM
uber (< 0.2.0)
request_store (1.5.1)
rack (>= 1.4)
responders (3.1.0)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
rest-client (2.1.0)
@@ -702,23 +709,23 @@ GEM
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.11.0)
sentry-rails (5.13.0)
railties (>= 5.0)
sentry-ruby (~> 5.11.0)
sentry-ruby (5.11.0)
sentry-ruby (~> 5.13.0)
sentry-ruby (5.13.0)
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.11.0)
sentry-ruby (~> 5.11.0)
sentry-sidekiq (5.13.0)
sentry-ruby (~> 5.13.0)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
sidekiq (7.1.3)
sidekiq (7.2.0)
concurrent-ruby (< 2)
connection_pool (>= 2.3.0)
rack (>= 2.2.4)
redis-client (>= 0.14.0)
sidekiq-cron (1.10.1)
sidekiq-cron (1.11.0)
fugit (~> 1.8)
globalid (>= 1.0.1)
sidekiq (>= 6)
@@ -732,13 +739,12 @@ GEM
json (>= 1.8, < 3)
simplecov-html (~> 0.10.0)
simplecov-html (0.10.2)
slack-ruby-client (2.0.0)
slack-ruby-client (2.2.0)
faraday (>= 2.0)
faraday-mashify
faraday-multipart
gli
hashie
websocket-driver
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
@@ -759,7 +765,7 @@ GEM
stripe (8.5.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.2.2)
thor (1.3.0)
tilt (2.2.0)
time_diff (0.3.0)
activesupport
@@ -817,7 +823,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.6.11)
zeitwerk (2.6.12)
PLATFORMS
arm64-darwin-20
@@ -839,7 +845,7 @@ DEPENDENCIES
administrate-field-belongs_to_search
annotate
attr_extras
audited (~> 5.4, >= 5.4.0)
audited (~> 5.4, >= 5.4.1)
aws-sdk-s3
azure-storage-blob!
barnes
@@ -856,7 +862,7 @@ DEPENDENCIES
database_cleaner
ddtrace
debug (~> 1.8)
devise
devise (>= 4.9.3)
devise-secure_password!
devise_token_auth
dotenv-rails
@@ -892,11 +898,12 @@ DEPENDENCIES
line-bot-api
liquid
listen
lograge (~> 0.13.0)
lograge (~> 0.14.0)
maxminddb
meta_request
mock_redis
neighbor
newrelic-sidekiq-metrics
newrelic-sidekiq-metrics (>= 1.6.2)
newrelic_rpm
omniauth
omniauth-google-oauth2
@@ -916,7 +923,7 @@ DEPENDENCIES
rails (~> 7.0.8.0)
redis
redis-namespace
responders
responders (>= 3.1.1)
rest-client
reverse_markdown
rspec-rails
@@ -928,14 +935,14 @@ DEPENDENCIES
scout_apm
scss_lint
seed_dump
sentry-rails (>= 5.11.0)
sentry-rails (>= 5.13.0)
sentry-ruby
sentry-sidekiq (>= 5.11.0)
sentry-sidekiq (>= 5.13.0)
shoulda-matchers
sidekiq (>= 7.1.3)
sidekiq-cron (>= 1.10.1)
sidekiq-cron (>= 1.11.0)
simplecov (= 0.17.1)
slack-ruby-client (~> 2.0.0)
slack-ruby-client (~> 2.2.0)
spring
spring-watcher-listen
squasher
+2 -2
View File
@@ -1,3 +1,3 @@
backend: RAILS_ENV=test bin/rails s -p 5050
frontend: bin/webpack-dev-server
worker: dotenv RAILS_ENV=test bundle exec sidekiq -C config/sidekiq.yml
frontend: export NODE_OPTIONS=--openssl-legacy-provider && bin/webpack-dev-server
worker: RAILS_ENV=test dotenv bundle exec sidekiq -C config/sidekiq.yml
+10 -11
View File
@@ -1,10 +1,12 @@
<p align="center">
<img src="https://s3.us-west-2.amazonaws.com/gh-assets.chatwoot.com/brand.svg" alt="Woot-logo" width="240" />
<img src="https://user-images.githubusercontent.com/2246121/282256557-1570674b-d142-4198-9740-69404cc6a339.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
<img src="https://user-images.githubusercontent.com/2246121/282256632-87f6a01b-6467-4e0e-8a93-7bbf66d03a17.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
<p align="center">Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.</p>
</p>
___
<p align="center">
# Chatwoot
Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p>
<a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku">
<img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
</a>
@@ -13,9 +15,7 @@
</a>
</p>
___
<p align="center">
<p>
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
@@ -29,9 +29,8 @@ ___
<a href="https://artifacthub.io/packages/helm/chatwoot/chatwoot"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/artifact-hub" alt="Artifact HUB"></a>
</p>
<img src="https://chatwoot-public-assets.s3.amazonaws.com/github/screenshot.png" width="100%" alt="Chat dashboard"/>
<img src="https://user-images.githubusercontent.com/2246121/282255783-ee8a50c9-f42d-4752-8201-2d59965a663d.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
<img src="https://user-images.githubusercontent.com/2246121/282255784-3d1994ec-d895-4ff5-ac68-d819987e1869.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
Chatwoot is an open-source, self-hosted customer engagement suite. Chatwoot lets you view and manage your customer data, communicate with them irrespective of which medium they use, and re-engage them based on their profile.
+1 -1
View File
@@ -1 +1 @@
2.4.0
2.6.0
@@ -93,6 +93,9 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
message_type: @message_type,
content: response.content,
source_id: response.identifier,
content_attributes: {
in_reply_to_external_id: response.in_reply_to_external_id
},
sender: @outgoing_echo ? nil : @contact_inbox.contact
}
end
@@ -76,6 +76,10 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def message_reply_attributes
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
end
def build_message
return if @outgoing_echo && already_sent_from_chatwoot?
return if message_content.blank? && all_unsupported_files?
@@ -118,7 +122,10 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact
sender: @outgoing_echo ? nil : contact,
content_attributes: {
in_reply_to_external_id: message_reply_attributes
}
}
end
+35 -3
View File
@@ -9,11 +9,11 @@ class Messages::MessageBuilder
@user = user
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@automation_rule = @params&.dig(:content_attributes, :automation_rule_id)
@automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters)
@in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to)
@items = params.to_unsafe_h&.dig(:content_attributes, :items)
@in_reply_to = content_attributes&.dig(:in_reply_to)
@items = content_attributes&.dig(:items)
end
def perform
@@ -26,6 +26,38 @@ class Messages::MessageBuilder
private
# Extracts content attributes from the given params.
# - Converts ActionController::Parameters to a regular hash if needed.
# - Attempts to parse a JSON string if content is a string.
# - Returns an empty hash if content is not present, if there's a parsing error, or if it's an unexpected type.
def content_attributes
params = convert_to_hash(@params)
content_attributes = params.fetch(:content_attributes, {})
return parse_json(content_attributes) if content_attributes.is_a?(String)
return content_attributes if content_attributes.is_a?(Hash)
{}
end
# Converts the given object to a hash.
# If it's an instance of ActionController::Parameters, converts it to an unsafe hash.
# Otherwise, returns the object as-is.
def convert_to_hash(obj)
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
obj
end
# Attempts to parse a string as JSON.
# If successful, returns the parsed hash with symbolized names.
# If unsuccessful, returns nil.
def parse_json(content)
JSON.parse(content, symbolize_names: true)
rescue JSON::ParserError
{}
end
def process_attachments
return if @attachments.blank?
+14 -1
View File
@@ -15,7 +15,10 @@ class V2::ReportBuilder
end
def timeseries
send(params[:metric])
return send(params[:metric]) if metric_valid?
Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}"
{}
end
# For backward compatible with old report
@@ -53,6 +56,16 @@ class V2::ReportBuilder
private
def metric_valid?
%w[conversations_count
incoming_messages_count
outgoing_messages_count
avg_first_response_time
avg_resolution_time reply_time
resolutions_count
reply_time].include?(params[:metric])
end
def inbox
@inbox ||= account.inboxes.find(params[:id])
end
@@ -9,7 +9,6 @@ class Api::V1::Accounts::Actions::ContactMergesController < Api::V1::Accounts::B
mergee_contact: @mergee_contact
)
contact_merge_action.perform
render json: @base_contact
end
private
@@ -10,7 +10,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
@articles_count = @all_articles.count
@articles = if list_params[:category_slug].present?
@all_articles.order_by_position.page(@current_page).per(50)
@all_articles.order_by_position.page(@current_page)
else
@all_articles.order_by_updated_at.page(@current_page)
end
@@ -48,7 +48,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
def category_params
params.require(:category).permit(
:name, :description, :position, :slug, :locale, :parent_category_id, :associated_category_id
:name, :description, :position, :slug, :locale, :icon, :parent_category_id, :associated_category_id
)
end
@@ -60,7 +60,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def toggle_status
if params[:status]
if params[:status].present?
set_conversation_status
@status = @conversation.save!
else
@@ -47,6 +47,11 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
head :ok
end
def logo
@portal.logo.purge if @portal.logo.attached?
head :ok
end
def process_attached_logo
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_by(id: blob_id)
@@ -79,6 +79,9 @@ class Api::V1::Widget::BaseController < ApplicationController
sender: @contact,
content: permitted_params[:message][:content],
inbox_id: conversation.inbox_id,
content_attributes: {
in_reply_to: permitted_params[:message][:reply_to]
},
echo_id: permitted_params[:message][:echo_id],
message_type: :incoming
}
@@ -1,5 +1,6 @@
class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
include Events::Types
before_action :render_not_found_if_empty, only: [:toggle_typing, :toggle_status, :set_custom_attributes, :destroy_custom_attributes]
def index
@conversation = conversation
@@ -27,6 +28,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
conversation.contact_last_seen_at = DateTime.now.utc
conversation.save!
::Conversations::UpdateMessageStatusJob.perform_later(conversation.id, conversation.contact_last_seen_at)
head :ok
end
@@ -41,8 +43,6 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def toggle_typing
head :ok && return if conversation.nil?
case permitted_params[:typing_status]
when 'on'
trigger_typing_event(CONVERSATION_TYPING_ON)
@@ -54,8 +54,6 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def toggle_status
return head :not_found if conversation.nil?
return head :forbidden unless @web_widget.end_conversation?
unless conversation.resolved?
@@ -81,6 +79,10 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact)
end
def render_not_found_if_empty
return head :not_found if conversation.nil?
end
def permitted_params
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
message: [:content, :referer_url, :timestamp, :echo_id],
@@ -64,7 +64,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, :after, :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, :reply_to])
end
def set_message
@@ -23,6 +23,7 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox
def update_last_seen
@conversation.contact_last_seen_at = DateTime.now.utc
@conversation.save!
::Conversations::UpdateMessageStatusJob.perform_later(@conversation.id, @conversation.contact_last_seen_at)
head :ok
end
@@ -39,10 +39,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
)
end
def portal
@portal ||= Portal.find_by!(slug: permitted_params[:slug], archived: false)
end
def list_params
params.permit(:query, :locale, :sort, :status)
end
@@ -14,10 +14,14 @@ class Public::Api::V1::Portals::BaseController < PublicController
@theme = if %w[dark light].include?(params[:theme])
params[:theme]
else
''
'system'
end
end
def portal
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
end
def set_locale(&)
switch_locale_with_portal(&) if params[:locale].present?
switch_locale_with_article(&) if params[:article_slug].present?
@@ -40,6 +44,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
def switch_locale_with_article(&)
article = Article.find_by(slug: params[:article_slug])
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
render_404 && return if article.blank?
@locale = if article.category.present?
article.category.locale
@@ -53,4 +59,9 @@ class Public::Api::V1::Portals::BaseController < PublicController
def allow_iframe_requests
response.headers.delete('X-Frame-Options') if @is_plain_layout_enabled
end
def render_404
portal
render 'public/api/v1/portals/error/404', status: :not_found
end
end
@@ -13,10 +13,9 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals:
private
def set_category
@category = @portal.categories.find_by!(locale: params[:locale], slug: params[:category_slug])
end
@category = @portal.categories.find_by(locale: params[:locale], slug: params[:category_slug])
def portal
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
Rails.logger.info "Category: not found for slug: #{params[:category_slug]}"
render_404 && return if @category.blank?
end
end
@@ -0,0 +1,21 @@
class Twilio::DeliveryStatusController < ApplicationController
def create
::Twilio::DeliveryStatusService.new(params: permitted_params).perform
head :no_content
end
private
def permitted_params
params.permit(
:AccountSid,
:From,
:MessageSid,
:MessagingServiceSid,
:MessageStatus,
:ErrorCode,
:ErrorMessage
)
end
end
+39 -2
View File
@@ -1,11 +1,48 @@
module PortalHelper
def generate_portal_bg_color(portal_color, theme)
base_color = theme == 'dark' ? 'black' : 'white'
"color-mix(in srgb, #{portal_color} 10%, #{base_color})"
"color-mix(in srgb, #{portal_color} 20%, #{base_color})"
end
def generate_portal_bg(portal_color, theme)
bg_image = theme == 'dark' ? 'grid_dark.svg' : 'grid.svg'
bg_image = theme == 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg'
"background: url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}"
end
def language_name(locale)
language_map = YAML.load_file(Rails.root.join('config/languages/language_map.yml'))
language_map[locale] || locale
end
def get_theme_names(theme)
if theme == 'light'
I18n.t('public_portal.header.appearance.light')
elsif theme == 'dark'
I18n.t('public_portal.header.appearance.dark')
else
I18n.t('public_portal.header.appearance.system')
end
end
def get_theme_icon(theme)
if theme == 'light'
'icons/sun'
elsif theme == 'dark'
'icons/moon'
else
'icons/monitor'
end
end
def generate_gradient_to_bottom(theme)
"background-image: linear-gradient(to bottom, transparent, #{theme == 'dark' ? '#151718' : 'white'})"
end
def generate_article_link(portal_slug, article_slug, theme)
"/hc/#{portal_slug}/articles/#{article_slug}#{theme.present? && theme != 'system' ? "?theme=#{theme}" : ''}"
end
def render_category_content(content)
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
end
end
+2 -2
View File
@@ -1,13 +1,13 @@
<template>
<div
v-if="!authUIFlags.isFetching"
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app"
class="app-wrapper h-full flex-grow-0 min-h-0 w-full"
:class="{ 'app-rtl--wrapper': isRTLView }"
:dir="isRTLView ? 'rtl' : 'ltr'"
>
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
<template v-if="!accountUIFlags.isFetchingItem && currentAccountId">
<template v-if="currentAccountId">
<payment-pending-banner />
<upgrade-banner />
</template>
+5
View File
@@ -84,6 +84,11 @@ export default {
return axios.delete(endPoints('deleteAvatar').url);
},
resetPassword({ email }) {
const urlData = endPoints('resetPassword');
return axios.post(urlData.url, { email });
},
setActiveAccount({ accountId }) {
const urlData = endPoints('setActiveAccount');
return axios.put(urlData.url, {
@@ -1,6 +1,7 @@
/* global axios */
import PortalsAPI from './portals';
import { getArticleSearchURL } from 'dashboard/helper/URLHelper.js';
class ArticlesAPI extends PortalsAPI {
constructor() {
@@ -12,14 +13,31 @@ class ArticlesAPI extends PortalsAPI {
portalSlug,
locale,
status,
author_id,
category_slug,
authorId,
categorySlug,
sort,
}) {
let baseUrl = `${this.url}/${portalSlug}/articles?page=${pageNumber}&locale=${locale}`;
if (status !== undefined) baseUrl += `&status=${status}`;
if (author_id) baseUrl += `&author_id=${author_id}`;
if (category_slug) baseUrl += `&category_slug=${category_slug}`;
return axios.get(baseUrl);
const url = getArticleSearchURL({
pageNumber,
portalSlug,
locale,
status,
authorId,
categorySlug,
sort,
host: this.url,
});
return axios.get(url);
}
searchArticles({ portalSlug, query }) {
const url = getArticleSearchURL({
portalSlug,
query,
host: this.url,
});
return axios.get(url);
}
getArticle({ id, portalSlug }) {
@@ -17,6 +17,10 @@ class PortalsAPI extends ApiClient {
deletePortal(portalSlug) {
return axios.delete(`${this.url}/${portalSlug}`);
}
deleteLogo(portalSlug) {
return axios.delete(`${this.url}/${portalSlug}/logo`);
}
}
export default PortalsAPI;
@@ -26,9 +26,13 @@ export const buildCreatePayload = ({
payload.append('echo_id', echoId);
payload.append('cc_emails', ccEmails);
payload.append('bcc_emails', bccEmails);
if (toEmails) {
payload.append('to_emails', toEmails);
}
if (contentAttributes) {
payload.append('content_attributes', JSON.stringify(contentAttributes));
}
} else {
payload = {
content: message,
@@ -34,7 +34,7 @@ describe('#PortalAPI', () => {
portalSlug: 'room-rental',
locale: 'en-US',
status: 'published',
author_id: '1',
authorId: '1',
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1'
@@ -85,6 +85,33 @@ describe('#PortalAPI', () => {
window.axios = originalAxios;
});
it('#searchArticles', () => {
articlesAPI.searchArticles({
query: 'test',
portalSlug: 'room-rental',
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/portals/room-rental/articles?query=test'
);
});
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#updateArticle', () => {
articlesAPI.updateArticle({
articleId: 1,
@@ -50,6 +50,7 @@ describe('#ConversationAPI', () => {
message: 'test content',
echoId: 12,
isPrivate: true,
contentAttributes: { in_reply_to: 12 },
files: [new Blob(['test-content'], { type: 'application/pdf' })],
});
expect(formPayload).toBeInstanceOf(FormData);
@@ -57,6 +58,10 @@ describe('#ConversationAPI', () => {
expect(formPayload.get('echo_id')).toEqual('12');
expect(formPayload.get('private')).toEqual('true');
expect(formPayload.get('cc_emails')).toEqual('');
expect(formPayload.get('bcc_emails')).toEqual('');
expect(formPayload.get('content_attributes')).toEqual(
'{"in_reply_to":12}'
);
});
it('builds object payload if file is not available', () => {
@@ -1,9 +1,5 @@
@import '~vue2-datepicker/scss/index';
.mx-datepicker-popup {
@apply z-[99999];
}
.date-picker {
&.no-margin {
.mx-input {
@@ -29,10 +25,52 @@
.mx-input[readonly] {
@apply bg-white dark:bg-slate-900 cursor-pointer;
}
.mx-icon-calendar {
@apply dark:text-slate-500;
}
}
.mx-calendar-content .cell:hover {
@apply bg-slate-75 dark:bg-slate-700 text-slate-900 dark:text-slate-100;
.mx-datepicker-main {
@apply border-0 bg-white dark:bg-slate-800;
.cell {
&.disabled {
@apply bg-slate-25 dark:bg-slate-900 text-slate-200 dark:text-slate-300;
}
&:hover,
&.hover-in-range,
&.in-range {
@apply bg-slate-75 dark:bg-slate-700 text-slate-900 dark:text-slate-100;
}
}
.mx-time {
@apply border-0 bg-white dark:bg-slate-800;
.mx-time-header {
@apply border-0;
}
.mx-time-item {
&.disabled {
@apply bg-slate-25 dark:bg-slate-900;
}
&:hover {
@apply bg-slate-75 dark:bg-slate-700;
}
}
}
.today {
@apply font-semibold;
}
}
.mx-datepicker-popup {
@apply z-[99999];
}
.mx-datepicker-inline {
@@ -41,24 +79,4 @@
.mx-calendar {
@apply w-full;
}
.cell.disabled {
@apply bg-slate-25 dark:bg-slate-900 text-slate-200 dark:text-slate-300;
}
.mx-time-item.disabled {
@apply bg-slate-25 dark:bg-slate-900;
}
.today {
@apply font-semibold;
}
.mx-datepicker-main {
@apply border-0 bg-white dark:bg-slate-800;
}
.mx-time-header {
@apply border-0;
}
}
@@ -16,3 +16,23 @@
width: 100%;
}
}
.integration-hooks {
.formulate-input[data-type='checkbox'] {
.formulate-input-wrapper {
@apply flex;
.formulate-input-element {
@apply pr-2;
input {
@apply mb-0;
}
}
}
.formulate-input-element-decorator {
@apply hidden;
}
}
}
@@ -34,26 +34,26 @@
}
&.hollow {
@apply border border-slate-200 dark:border-slate-600 text-woot-700 dark:text-woot-100 hover:bg-woot-50 dark:hover:bg-woot-900;
@apply border border-woot-500 dark:border-woot-500 text-woot-500 dark:text-woot-500 hover:bg-woot-50 dark:hover:bg-woot-900;
&.secondary {
@apply text-slate-700 border-slate-200 dark:border-slate-600 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700;
}
&.success {
@apply text-green-700 dark:text-green-100 hover:bg-green-50 dark:hover:bg-green-800;
@apply text-green-700 dark:text-green-400 border-green-100 dark:border-green-600 hover:bg-green-50 dark:hover:bg-green-800;
}
&.alert {
@apply text-red-700 dark:text-red-100 hover:bg-red-50 dark:hover:bg-red-800;
@apply text-red-700 dark:text-red-400 border-red-100 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-800;
}
&.warning {
@apply text-yellow-700 dark:text-yellow-100 hover:bg-yellow-50 dark:hover:bg-yellow-800;
@apply text-yellow-600 dark:text-yellow-600 border-yellow-600 dark:border-yellow-700 hover:bg-yellow-50 dark:hover:bg-yellow-800;
}
&:hover {
@apply bg-slate-75 dark:bg-slate-900 border-slate-100 dark:border-slate-700;
@apply bg-woot-75 dark:bg-woot-800 border-slate-100 dark:border-woot-600 dark:text-woot-400;
&.secondary {
@apply border-slate-100 dark:border-slate-700 text-slate-800 dark:text-slate-100;
@@ -68,7 +68,7 @@
}
&.warning {
@apply border-slate-100 dark:border-slate-700 text-yellow-700 dark:text-yellow-700;
@apply border-slate-100 dark:border-slate-700 text-yellow-700 dark:text-yellow-500;
}
}
}
@@ -152,6 +152,10 @@
&.is-image {
@apply rounded-lg;
.message__mail-head {
@apply px-4 py-2;
}
}
}
@@ -4,10 +4,6 @@
@apply flex items-center justify-center bg-modal dark:bg-modal z-[9990] h-full left-0 fixed top-0 w-full;
}
.modal--close {
@apply absolute right-2 rtl:right-[unset] rtl:left-2 top-2;
}
.page-top-bar {
@apply px-8 pt-9 pb-0;
@@ -207,6 +207,7 @@ 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 uiSettingsMixin from 'dashboard/mixins/uiSettings';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
@@ -238,6 +239,7 @@ export default {
eventListenerMixins,
alertMixin,
filterMixin,
uiSettingsMixin,
],
props: {
conversationInbox: {
@@ -408,8 +410,12 @@ export default {
},
conversationListPagination() {
const conversationsPerPage = 25;
const hasChatsOnView =
this.chatsOnView &&
Array.isArray(this.chatsOnView) &&
!this.chatsOnView.length;
const isNoFiltersOrFoldersAndChatListNotEmpty =
!this.hasAppliedFiltersOrActiveFolders && this.chatsOnView !== [];
!this.hasAppliedFiltersOrActiveFolders && hasChatsOnView;
const isUnderPerPage =
this.chatsOnView.length < conversationsPerPage &&
this.activeAssigneeTabCount < conversationsPerPage &&
@@ -515,6 +521,7 @@ export default {
},
},
mounted() {
this.setFiltersFromUISettings();
this.$store.dispatch('setChatStatusFilter', this.activeStatus);
this.$store.dispatch('setChatSortFilter', this.activeSortBy);
this.resetAndFetchData();
@@ -544,6 +551,12 @@ export default {
this.$store.dispatch('customViews/update', payloadData);
this.closeAdvanceFiltersModal();
},
setFiltersFromUISettings() {
const { conversations_filter_by: filterBy = {} } = this.uiSettings;
const { status, order_by: orderBy } = filterBy;
this.activeStatus = status || wootConstants.STATUS_TYPE.OPEN;
this.activeSortBy = orderBy || wootConstants.SORT_BY_TYPE.LATEST;
},
onClickOpenAddFoldersModal() {
this.showAddFoldersModal = true;
},
+23 -5
View File
@@ -4,15 +4,19 @@
v-if="show"
:class="modalClassName"
transition="modal"
@click="onBackDropClick"
@mousedown="handleMouseDown"
>
<div :class="modalContainerClassName" @click.stop>
<div
:class="modalContainerClassName"
@mouse.stop
@mousedown="event => event.stopPropagation()"
>
<woot-button
v-if="showCloseButton"
color-scheme="secondary"
icon="dismiss"
variant="clear"
class="modal--close"
class="absolute ltr:right-2 rtl:left-2 top-2 z-10"
@click="close"
/>
<slot />
@@ -50,6 +54,11 @@ export default {
default: '',
},
},
data() {
return {
mousedDownOnBackdrop: false,
};
},
computed: {
modalContainerClassName() {
let className =
@@ -77,13 +86,22 @@ export default {
this.onClose();
}
});
document.body.addEventListener('mouseup', this.onMouseUp);
},
beforeDestroy() {
document.body.removeEventListener('mouseup', this.onMouseUp);
},
methods: {
handleMouseDown() {
this.mousedDownOnBackdrop = true;
},
close() {
this.onClose();
},
onBackDropClick() {
if (this.closeOnBackdropClick) {
onMouseUp() {
if (this.mousedDownOnBackdrop) {
this.mousedDownOnBackdrop = false;
this.onClose();
}
},
@@ -11,7 +11,6 @@
@key-shortcut-modal="toggleKeyShortcutModal"
@open-notification-panel="openNotificationPanel"
/>
<secondary-sidebar
v-if="showSecondarySidebar"
:class="sidebarClassName"
@@ -114,7 +113,12 @@ export default {
if (!isAvailableForTheUser) {
return false;
}
if (
menuItem.alwaysVisibleOnChatwootInstances &&
!this.isACustomBrandedInstance
) {
return true;
}
if (menuItem.featureFlag) {
return this.isFeatureEnabledonAccount(
this.accountId,
@@ -42,6 +42,7 @@ const primaryMenuItems = accountId => [
key: 'helpcenter',
label: 'HELP_CENTER.TITLE',
featureFlag: FEATURE_FLAGS.HELP_CENTER,
alwaysVisibleOnChatwootInstances: true,
toState: frontendURL(`accounts/${accountId}/portals`),
toStateName: 'default_portal_articles',
roles: ['administrator'],
@@ -136,6 +136,7 @@ export default {
toStateName: 'labels_list',
showModalForNewItem: true,
modalName: 'AddLabel',
dataTestid: 'sidebar-new-label-button',
children: this.labels.map(label => ({
id: label.id,
label: label.title,
@@ -78,6 +78,7 @@
variant="clear"
color-scheme="secondary"
icon="add"
:data-testid="menuItem.dataTestid"
@click="e => newLinkClick(e, navigate)"
>
{{ $t(`SIDEBAR.${menuItem.newLinkTag}`) }}
@@ -61,6 +61,7 @@ export default {
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
isAChatwootInstance: 'globalConfig/isAChatwootInstance',
}),
isAICTAModalDismissed() {
return this.uiSettings.is_open_ai_cta_modal_dismissed;
@@ -70,7 +71,8 @@ export default {
return (
this.isAdmin &&
!this.isAIIntegrationEnabled &&
!this.isAICTAModalDismissed
!this.isAICTAModalDismissed &&
this.isAChatwootInstance
);
},
// Display a AI CTA button for agents and other admins who have not yet opened the AI assistance modal.
@@ -1,11 +1,9 @@
<template>
<div
class="preview-item__wrap flex flex-col overflow-auto mt-4 max-h-[12.5rem]"
>
<div class="preview-item__wrap flex overflow-auto max-h-[12.5rem]">
<div
v-for="(attachment, index) in attachments"
:key="attachment.id"
class="preview-item flex p-1 bg-slate-50 dark:bg-slate-800 rounded-md w-[15rem] mb-1"
class="preview-item flex items-center p-1 bg-slate-50 dark:bg-slate-800 gap-1 rounded-md w-[15rem] mb-1"
>
<div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
<img
@@ -17,7 +15,7 @@
📄
</span>
</div>
<div class="max-w-[60%] min-w-[50%] overflow-hidden text-ellipsis ml-2">
<div class="max-w-[60%] min-w-[50%] overflow-hidden text-ellipsis">
<span
class="h-4 overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap"
>
@@ -12,7 +12,7 @@
/>
<iframe
v-if="configItem.type === 'frame' && configItem.url"
:id="`dashboard-app--frame-${index}`"
:id="getFrameId(index)"
:src="configItem.url"
@load="() => onIframeLoad(index)"
/>
@@ -39,6 +39,10 @@ export default {
type: Boolean,
default: false,
},
position: {
type: Number,
required: true,
},
},
data() {
return {
@@ -82,10 +86,14 @@ export default {
};
},
methods: {
getFrameId(index) {
return `dashboard-app--frame-${this.position}-${index}`;
},
onIframeLoad(index) {
const frameElement = document.getElementById(
`dashboard-app--frame-${index}`
);
// A possible alternative is to use ref instead of document.getElementById
// However, when ref is used together with v-for, the ref you get will be
// an array containing the child components mirroring the data source.
const frameElement = document.getElementById(this.getFrameId(index));
const eventData = { event: 'appContext', data: this.dashboardAppContext };
frameElement.contentWindow.postMessage(JSON.stringify(eventData), '*');
this.iframeLoading = false;
@@ -1,5 +1,5 @@
<template>
<div class="editor-root">
<div ref="editorRoot" class="relative editor-root">
<tag-agents
v-if="showUserMentions && isPrivate"
:search-key="mentionSearchKey"
@@ -23,6 +23,23 @@
@change="onFileChange"
/>
<div ref="editor" />
<div
v-show="isImageNodeSelected && showImageResizeToolbar"
class="absolute shadow-md rounded-[4px] flex gap-1 py-1 px-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-50"
:style="{
top: toolbarPosition.top,
left: toolbarPosition.left,
}"
>
<button
v-for="size in sizes"
:key="size.name"
class="text-xs font-medium rounded-[4px] border border-solid border-slate-200 dark:border-slate-600 px-1.5 py-0.5 hover:bg-slate-100 dark:hover:bg-slate-800"
@click="setURLWithQueryAndImageSize(size)"
>
{{ size.name }}
</button>
</div>
<slot name="footer" />
</div>
</template>
@@ -51,6 +68,8 @@ import {
removeSignature,
insertAtCursor,
scrollCursorIntoView,
findNodeToInsertImage,
setURLWithQueryAndSize,
} from 'dashboard/helper/editorHelper';
const TYPING_INDICATOR_IDLE_TIME = 4000;
@@ -70,8 +89,10 @@ import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import alertMixin from 'shared/mixins/alertMixin';
import { findNodeToInsertImage } from 'dashboard/helper/messageEditorHelper';
import { MESSAGE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import {
MESSAGE_EDITOR_MENU_OPTIONS,
MESSAGE_EDITOR_IMAGE_RESIZES,
} from 'dashboard/constants/editor';
const createState = (
content,
@@ -114,6 +135,8 @@ export default {
// allowSignature is a kill switch, ensuring no signature methods
// are triggered except when this flag is true
allowSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
},
data() {
return {
@@ -126,9 +149,16 @@ export default {
editorView: null,
range: null,
state: undefined,
isImageNodeSelected: false,
toolbarPosition: { top: 0, left: 0 },
sizes: MESSAGE_EDITOR_IMAGE_RESIZES,
selectedImageNode: null,
};
},
computed: {
editorRoot() {
return this.$refs.editorRoot;
},
contentFromEditor() {
return MessageMarkdownSerializer.serialize(this.editorView.state.doc);
},
@@ -237,8 +267,11 @@ export default {
sendWithSignature() {
// this is considered the source of truth, we watch this property
// on change, we toggle the signature in the editor
const { send_with_signature: isEnabled } = this.uiSettings;
return isEnabled && this.allowSignature && !this.isPrivate;
if (this.allowSignature && !this.isPrivate && this.channelType) {
return this.fetchSignatureFlagFromUiSettings(this.channelType);
}
return false;
},
},
watch: {
@@ -412,6 +445,9 @@ export default {
focus: () => {
this.onFocus();
},
click: () => {
this.isEditorMouseFocusedOnAnImage();
},
blur: () => {
this.onBlur();
},
@@ -424,6 +460,50 @@ export default {
},
});
},
isEditorMouseFocusedOnAnImage() {
if (!this.showImageResizeToolbar) {
return;
}
this.selectedImageNode = document.querySelector(
'img.ProseMirror-selectednode'
);
if (this.selectedImageNode) {
this.isImageNodeSelected = !!this.selectedImageNode;
// Get the position of the selected node
this.setToolbarPosition();
} else {
this.isImageNodeSelected = false;
}
},
setToolbarPosition() {
const editorRect = this.editorRoot.getBoundingClientRect();
const rect = this.selectedImageNode.getBoundingClientRect();
this.toolbarPosition = {
top: `${rect.top - editorRect.top - 30}px`,
left: `${rect.left - editorRect.left - 4}px`,
};
},
setURLWithQueryAndImageSize(size) {
if (!this.showImageResizeToolbar) {
return;
}
setURLWithQueryAndSize(this.selectedImageNode, size, this.editorView);
this.isImageNodeSelected = false;
},
updateImgToolbarOnDelete() {
// check if the selected node is present or not on keyup
// this is needed because the user can select an image and then delete it
// in that case, the selected node will be null and we need to hide the toolbar
// otherwise, the toolbar will be visible even when the image is deleted and cause some errors
if (this.selectedImageNode) {
const hasImgSelectedNode = document.querySelector(
'img.ProseMirror-selectednode'
);
if (!hasImgSelectedNode) {
this.isImageNodeSelected = false;
}
}
},
isEnterToSendEnabled() {
return isEditorHotKeyEnabled(this.uiSettings, 'enter');
},
@@ -466,6 +546,7 @@ export default {
message: cannedItem,
variables: this.variables,
});
if (!this.editorView) {
return null;
}
@@ -474,7 +555,12 @@ export default {
updatedMessage
);
this.insertNodeIntoEditor(node, this.range.from, this.range.to);
const from =
node.textContent === updatedMessage
? this.range.from
: this.range.from - 1;
this.insertNodeIntoEditor(node, from, this.range.to);
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
return false;
@@ -588,6 +674,7 @@ export default {
() => this.resetTyping(),
TYPING_INDICATOR_IDLE_TIME
);
this.updateImgToolbarOnDelete();
},
onKeydown(event) {
if (this.isEnterToSendEnabled()) {
@@ -711,6 +798,6 @@ export default {
}
.editor-warning__message {
@apply text-red-400 dark:text-red-400 text-sm font-normal pt-1 pb-0 px-0;
@apply text-red-400 dark:text-red-400 font-normal text-sm pt-1 pb-0 px-0;
}
</style>
@@ -14,10 +14,11 @@
<file-upload
ref="upload"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
input-id="conversationAttachment"
:size="4096 * 4096"
:accept="allowedFileTypes"
:multiple="enableMultipleFileUpload"
:drop="true"
:drop="enableDragAndDrop"
:drop-directory="false"
:data="{
direct_upload_url: '/rails/active_storage/direct_uploads',
@@ -100,14 +101,24 @@
<transition name="modal-fade">
<div
v-show="$refs.upload && $refs.upload.dropActive"
class="modal-mask"
class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-slate-600 dark:text-slate-200 bg-white_transparent dark:bg-black_transparent"
>
<fluent-icon icon="cloud-backup" />
<fluent-icon icon="cloud-backup" size="40" />
<h4 class="page-sub-title text-slate-600 dark:text-slate-200">
{{ $t('CONVERSATION.REPLYBOX.DRAG_DROP') }}
</h4>
</div>
</transition>
<woot-button
v-if="enableInsertArticleInReply"
v-tooltip.top-end="$t('HELP_CENTER.ARTICLE_SEARCH.OPEN_ARTICLE_SEARCH')"
icon="document-text-link"
color-scheme="secondary"
variant="smooth"
size="small"
:title="$t('HELP_CENTER.ARTICLE_SEARCH.OPEN_ARTICLE_SEARCH')"
@click="toggleInsertArticle"
/>
</div>
<div class="right-wrap">
<woot-button
@@ -228,6 +239,14 @@ export default {
type: String,
default: '',
},
newConversationModalActive: {
type: Boolean,
default: false,
},
portalSlug: {
type: String,
required: true,
},
},
computed: {
...mapGetters({
@@ -274,6 +293,9 @@ export default {
}
return ALLOWED_FILE_TYPES;
},
enableDragAndDrop() {
return !this.newConversationModalActive;
},
audioRecorderPlayStopIcon() {
switch (this.recordingAudioState) {
// playing paused recording stopped inactive destroyed
@@ -291,14 +313,21 @@ export default {
return !this.isOnPrivateNote;
},
sendWithSignature() {
const { send_with_signature: isEnabled } = this.uiSettings;
return isEnabled;
// channelType is sourced from inboxMixin
return this.fetchSignatureFlagFromUiSettings(this.channelType);
},
signatureToggleTooltip() {
return this.sendWithSignature
? this.$t('CONVERSATION.FOOTER.DISABLE_SIGN_TOOLTIP')
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
},
enableInsertArticleInReply() {
const isFeatEnabled = this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.INSERT_ARTICLE_IN_REPLY
);
return isFeatEnabled && this.portalSlug;
},
},
mounted() {
ActiveStorage.start();
@@ -310,13 +339,14 @@ export default {
}
},
toggleMessageSignature() {
this.updateUISettings({
send_with_signature: !this.sendWithSignature,
});
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
},
replaceText(text) {
this.$emit('replace-text', text);
},
toggleInsertArticle() {
this.$emit('toggle-insert-article');
},
},
};
</script>
@@ -346,12 +376,4 @@ export default {
@apply dark:bg-slate-800 bg-slate-100;
}
}
.modal-mask {
@apply text-slate-600 dark:text-slate-200 bg-white_transparent dark:bg-black_transparent flex-col;
}
.icon {
@apply text-[5rem];
}
</style>
@@ -32,7 +32,7 @@
}}</span>
<filter-item
type="sort"
:selected-value="chatSortFilter"
:selected-value="sortFilter"
:items="chatSortItems"
path-prefix="CHAT_LIST.CHAT_SORT_FILTER_ITEMS"
@onChangeFilter="onChangeFilter"
@@ -47,12 +47,13 @@ import wootConstants from 'dashboard/constants/globals';
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import FilterItem from './FilterItem.vue';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
export default {
components: {
FilterItem,
},
mixins: [clickaway],
mixins: [clickaway, uiSettingsMixin],
data() {
return {
showActionsDropdown: false,
@@ -83,8 +84,17 @@ export default {
closeDropdown() {
this.showActionsDropdown = false;
},
onChangeFilter(type, value) {
this.$emit('changeFilter', type, value);
onChangeFilter(value, type) {
this.$emit('changeFilter', value, type);
this.saveSelectedFilter(type, value);
},
saveSelectedFilter(type, value) {
this.updateUISettings({
conversations_filter_by: {
status: type === 'status' ? value : this.chatStatus,
order_by: type === 'sort' ? value : this.sortFilter,
},
});
},
},
};
@@ -52,6 +52,7 @@
:key="currentChat.id + '-' + dashboardApp.id"
:is-visible="activeIndex - 1 === index"
:config="dashboardApps[index].content"
:position="index"
:current-chat="currentChat"
/>
</div>
@@ -1,6 +1,6 @@
<template>
<div
class="conversation flex flex-shrink-0 flex-grow-0 w-auto max-w-full cursor-pointer relative py-0 px-4 border-transparent border-l-2 border-t-0 border-b-0 border-r-0 border-solid items-start hover:bg-slate-25 dark:hover:bg-slate-800 group"
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-slate-25 dark:hover:bg-slate-800 group"
:class="{
'active bg-slate-25 dark:bg-slate-800 border-woot-500': isActiveChat,
'unread-chat': hasUnread,
@@ -31,7 +31,7 @@
size="40px"
/>
<div
class="py-3 px-0 border-b group-last:border-transparent group-hover:border-transparent border-slate-50 dark:border-slate-800/75 columns"
class="px-0 py-3 border-b group-last:border-transparent group-hover:border-transparent border-slate-50 dark:border-slate-800/75 columns"
>
<div class="flex justify-between">
<inbox-name v-if="showInboxName" :inbox="inbox" />
@@ -55,44 +55,11 @@
>
{{ currentContact.name }}
</h4>
<p
<message-preview
v-if="lastMessageInChat"
class="conversation--message text-slate-700 dark:text-slate-200 text-sm my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] overflow-hidden text-ellipsis whitespace-nowrap"
>
<fluent-icon
v-if="isMessagePrivate"
size="16"
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
icon="lock-closed"
/>
<fluent-icon
v-else-if="messageByAgent"
size="16"
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
icon="arrow-reply"
/>
<fluent-icon
v-else-if="isMessageAnActivity"
size="16"
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
icon="info"
/>
<span v-if="lastMessageInChat.content">
{{ parsedLastMessage }}
</span>
<span v-else-if="lastMessageInChat.attachments">
<fluent-icon
v-if="attachmentIcon"
size="16"
class="-mt-0.5 align-middle inline-block text-slate-600 dark:text-slate-300"
:icon="attachmentIcon"
/>
{{ $t(`${attachmentMessageContent}`) }}
</span>
<span v-else>
{{ $t('CHAT_LIST.NO_CONTENT') }}
</span>
</p>
:message="lastMessageInChat"
class="conversation--message my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] text-sm text-slate-700 dark:text-slate-200"
/>
<p
v-else
class="conversation--message text-slate-700 dark:text-slate-200 text-sm my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] overflow-hidden text-ellipsis whitespace-nowrap"
@@ -106,8 +73,8 @@
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span>
</p>
<div class="conversation--meta flex flex-col absolute right-4 top-4">
<span class="text-black-600 text-xxs font-normal leading-4 ml-auto">
<div class="absolute flex flex-col conversation--meta right-4 top-4">
<span class="ml-auto font-normal leading-4 text-black-600 text-xxs">
<time-ago
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
@@ -145,9 +112,8 @@
</template>
<script>
import { mapGetters } from 'vuex';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import Thumbnail from '../Thumbnail.vue';
import MessagePreview from './MessagePreview.vue';
import conversationMixin from '../../../mixins/conversations';
import timeMixin from '../../../mixins/time';
import router from '../../../routes';
@@ -159,14 +125,6 @@ import alertMixin from 'shared/mixins/alertMixin';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue';
import PriorityMark from './PriorityMark.vue';
const ATTACHMENT_ICONS = {
image: 'image',
audio: 'headphones-sound-wave',
video: 'video',
file: 'document',
location: 'location',
fallback: 'link',
};
export default {
components: {
@@ -175,16 +133,11 @@ export default {
Thumbnail,
ConversationContextMenu,
TimeAgo,
MessagePreview,
PriorityMark,
},
mixins: [
inboxMixin,
timeMixin,
conversationMixin,
messageFormatterMixin,
alertMixin,
],
mixins: [inboxMixin, timeMixin, conversationMixin, alertMixin],
props: {
activeLabel: {
type: String,
@@ -258,20 +211,6 @@ export default {
);
},
lastMessageFileType() {
const lastMessage = this.lastMessageInChat;
const [{ file_type: fileType } = {}] = lastMessage.attachments;
return fileType;
},
attachmentIcon() {
return ATTACHMENT_ICONS[this.lastMessageFileType];
},
attachmentMessageContent() {
return `CHAT_LIST.ATTACHMENTS.${this.lastMessageFileType}.CONTENT`;
},
isActiveChat() {
return this.currentChat.id === this.chat.id;
},
@@ -292,30 +231,6 @@ export default {
return this.lastMessage(this.chat);
},
messageByAgent() {
const lastMessage = this.lastMessageInChat;
const { message_type: messageType } = lastMessage;
return messageType === MESSAGE_TYPE.OUTGOING;
},
isMessageAnActivity() {
const lastMessage = this.lastMessageInChat;
const { message_type: messageType } = lastMessage;
return messageType === MESSAGE_TYPE.ACTIVITY;
},
isMessagePrivate() {
const lastMessage = this.lastMessageInChat;
const { private: isPrivate } = lastMessage;
return isPrivate;
},
parsedLastMessage() {
const { content_attributes: contentAttributes } = this.lastMessageInChat;
const { email: { subject } = {} } = contentAttributes || {};
return this.getPlainText(subject || this.lastMessageInChat.content);
},
inbox() {
const { inbox_id: inboxId } = this.chat;
const stateInbox = this.$store.getters['inboxes/getInbox'](inboxId);
@@ -3,10 +3,10 @@
class="bg-white dark:bg-slate-900 flex justify-between items-center py-2 px-4 border-b border-slate-50 dark:border-slate-800/50 flex-col md:flex-row"
>
<div
class="flex-1 w-100 flex flex-col md:flex-row items-center justify-center"
class="flex-1 w-full min-w-0 flex flex-col md:flex-row items-center justify-center"
>
<div
class="flex justify-center items-center mr-4 rtl:mr-0 rtl:ml-4 min-w-0"
class="flex justify-start items-center mr-4 rtl:mr-0 rtl:ml-4 min-w-0 w-[inherit]"
>
<back-button
v-if="showBackButton"
@@ -19,26 +19,31 @@
:username="currentContact.name"
:status="currentContact.availability_status"
/>
<div class="items-start flex flex-col ml-2 rtl:ml-0 rtl:mr-2 min-w-0">
<woot-button
variant="link"
color-scheme="secondary"
class="overflow-hidden whitespace-nowrap text-ellipsis"
@click.prevent="$emit('contact-panel-toggle')"
>
<h3
class="text-base inline-block leading-tight capitalize m-0 p-0 text-ellipsis overflow-hidden whitespace-nowrap text-slate-900 dark:text-slate-100"
<div
class="items-start flex flex-col ml-2 rtl:ml-0 rtl:mr-2 min-w-0 w-[inherit] overflow-hidden"
>
<div class="flex items-center flex-row gap-1 m-0 p-0 w-[inherit]">
<woot-button
variant="link"
color-scheme="secondary"
class="[&>span]:overflow-hidden [&>span]:whitespace-nowrap [&>span]:text-ellipsis min-w-0"
@click.prevent="$emit('contact-panel-toggle')"
>
<span>{{ currentContact.name }}</span>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14"
class="text-yellow-600 dark:text-yellow-500 my-0 mx-0.5"
icon="warning"
/>
</h3>
</woot-button>
<span
class="text-base leading-tight text-slate-900 dark:text-slate-100"
>
{{ currentContact.name }}
</span>
</woot-button>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14"
class="text-yellow-600 dark:text-yellow-500 my-0 mx-0 min-w-[14px]"
icon="warning"
/>
</div>
<div
class="conversation--header--actions items-center flex text-xs gap-2 text-ellipsis overflow-hidden whitespace-nowrap"
>
@@ -22,18 +22,13 @@
:bcc="emailHeadAttributes.bcc"
:is-incoming="isIncoming"
/>
<blockquote v-if="storyReply" class="story-reply-quote">
<span>{{ $t('CONVERSATION.REPLIED_TO_STORY') }}</span>
<bubble-image
v-if="!hasImgStoryError && storyUrl"
:url="storyUrl"
@error="onStoryLoadError"
/>
<bubble-video
v-else-if="hasImgStoryError && storyUrl"
:url="storyUrl"
/>
</blockquote>
<instagram-story-reply v-if="storyUrl" :story-url="storyUrl" />
<bubble-reply-to
v-if="inReplyToMessageId && inboxSupportsReplyTo.incoming"
:message="inReplyTo"
:message-type="data.message_type"
:parent-has-attachments="hasAttachments"
/>
<bubble-text
v-if="data.content"
:message="message"
@@ -53,10 +48,15 @@
</span>
<div v-if="!isPending && hasAttachments">
<div v-for="attachment in attachments" :key="attachment.id">
<instagram-story
v-if="isAnInstagramStory"
:story-url="attachment.data_url"
@error="onMediaLoadError"
/>
<bubble-image-audio-video
v-if="isAttachmentImageVideoAudio(attachment.file_type)"
v-else-if="isAttachmentImageVideoAudio(attachment.file_type)"
:attachment="attachment"
@error="onImageLoadError"
@error="onMediaLoadError"
/>
<bubble-location
v-else-if="attachment.file_type === 'location'"
@@ -69,9 +69,6 @@
:name="data.content"
:phone-number="attachment.fallback_title"
/>
<instagram-image-error-placeholder
v-else-if="hasImageError && hasInstagramStory"
/>
<bubble-file v-else :url="attachment.data_url" />
</div>
</div>
@@ -83,7 +80,6 @@
:story-id="`${storyId}`"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:has-instagram-story="hasInstagramStory"
:is-email="isEmailContentType"
:is-private="data.private"
:message-type="data.message_type"
@@ -124,6 +120,7 @@
:message="data"
@open="openContextMenu"
@close="closeContextMenu"
@replyTo="handleReplyTo"
/>
</div>
</li>
@@ -131,40 +128,42 @@
<script>
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import BubbleActions from './bubble/Actions.vue';
import BubbleContact from './bubble/Contact.vue';
import BubbleFile from './bubble/File.vue';
import BubbleImage from './bubble/Image.vue';
import BubbleVideo from './bubble/Video.vue';
import BubbleImageAudioVideo from './bubble/ImageAudioVideo.vue';
import BubbleIntegration from './bubble/Integration.vue';
import BubbleLocation from './bubble/Location.vue';
import BubbleMailHead from './bubble/MailHead.vue';
import BubbleReplyTo from './bubble/ReplyTo.vue';
import BubbleText from './bubble/Text.vue';
import BubbleContact from './bubble/Contact.vue';
import Spinner from 'shared/components/Spinner.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
import instagramImageErrorPlaceholder from './instagramImageErrorPlaceholder.vue';
import InstagramStory from './bubble/InstagramStory.vue';
import InstagramStoryReply from './bubble/InstagramStoryReply.vue';
import Spinner from 'shared/components/Spinner.vue';
import alertMixin from 'shared/mixins/alertMixin';
import contentTypeMixin from 'shared/mixins/contentTypeMixin';
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
import { generateBotMessageContent } from './helpers/botMessageContentHelper';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
export default {
components: {
BubbleActions,
BubbleContact,
BubbleFile,
BubbleImage,
BubbleVideo,
BubbleImageAudioVideo,
BubbleIntegration,
BubbleLocation,
BubbleMailHead,
BubbleReplyTo,
BubbleText,
BubbleContact,
ContextMenu,
InstagramStory,
InstagramStoryReply,
Spinner,
instagramImageErrorPlaceholder,
},
mixins: [alertMixin, messageFormatterMixin, contentTypeMixin],
props: {
@@ -180,22 +179,25 @@ export default {
type: Boolean,
default: false,
},
hasInstagramStory: {
type: Boolean,
default: false,
},
isWebWidgetInbox: {
type: Boolean,
default: false,
},
inboxSupportsReplyTo: {
type: Object,
default: () => ({}),
},
inReplyTo: {
type: Object,
default: () => ({}),
},
},
data() {
return {
showContextMenu: false,
hasImageError: false,
hasMediaLoadError: false,
contextMenuPosition: {},
showBackgroundHighlight: false,
hasImgStoryError: false,
};
},
computed: {
@@ -264,18 +266,29 @@ export default {
) + botMessageContent
);
},
inReplyToMessageId() {
// Why not use the inReplyTo object directly?
// Glad you asked! The inReplyTo object may or may not be available
// depending on the current scroll position of the message list
// since old messages are only loaded when the user scrolls up
return this.data.content_attributes?.in_reply_to;
},
isAnInstagramStory() {
return this.contentAttributes.image_type === 'story_mention';
},
contextMenuEnabledOptions() {
return {
copy: this.hasText,
delete: this.hasText || this.hasAttachments,
cannedResponse: this.isOutgoing && this.hasText,
replyTo: !this.data.private && this.inboxSupportsReplyTo.outgoing,
};
},
contentAttributes() {
return this.data.content_attributes || {};
},
externalError() {
return this.contentAttributes.external_error || null;
return this.contentAttributes.external_error || '';
},
sender() {
return this.data.sender || {};
@@ -292,9 +305,6 @@ export default {
storyUrl() {
return this.contentAttributes.story_url || null;
},
storyReply() {
return this.storyUrl && this.hasInstagramStory;
},
contentType() {
const {
data: { content_type: contentType },
@@ -318,6 +328,8 @@ export default {
left: isLeftAligned,
right: isRightAligned,
'has-context-menu': this.showContextMenu,
// this handles the offset required to align the context menu button
// extra alignment is required since a tweet message has a the user name and avatar below it
'has-tweet-menu': this.isATweet,
'has-bg': this.showBackgroundHighlight,
};
@@ -373,7 +385,7 @@ export default {
return false;
}
if (this.isFailed) {
return this.externalError ? '' : this.$t(`CONVERSATION.SEND_FAILED`);
return this.externalError || this.$t(`CONVERSATION.SEND_FAILED`);
}
return false;
},
@@ -431,13 +443,11 @@ export default {
},
watch: {
data() {
this.hasImageError = false;
this.hasImgStoryError = false;
this.hasMediaLoadError = false;
},
},
mounted() {
this.hasImageError = false;
this.hasImgStoryError = false;
this.hasMediaLoadError = false;
bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
this.setupHighlightTimer();
},
@@ -447,13 +457,13 @@ export default {
},
methods: {
isAttachmentImageVideoAudio(fileType) {
return ['image', 'audio', 'video'].includes(fileType);
return ['image', 'audio', 'video', 'story_mention'].includes(fileType);
},
hasMediaAttachment(type) {
if (this.hasAttachments && this.data.attachments.length > 0) {
const { attachments = [{}] } = this.data;
const { file_type: fileType } = attachments[0];
return fileType === type && !this.hasImageError;
return fileType === type && !this.hasMediaLoadError;
}
if (this.storyReply) {
return true;
@@ -466,11 +476,8 @@ export default {
async retrySendMessage() {
await this.$store.dispatch('sendMessageWithData', this.data);
},
onImageLoadError() {
this.hasImageError = true;
},
onStoryLoadError() {
this.hasImgStoryError = true;
onMediaLoadError() {
this.hasMediaLoadError = true;
},
openContextMenu(e) {
const shouldSkipContextMenu =
@@ -494,6 +501,13 @@ export default {
this.showContextMenu = false;
this.contextMenuPosition = { x: null, y: null };
},
handleReplyTo() {
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
const { conversation_id: conversationId, id: replyTo } = this.data;
LocalStorage.updateJsonStore(replyStorageKey, conversationId, replyTo);
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.data);
},
setupHighlightTimer() {
if (Number(this.$route.query.messageId) !== Number(this.data.id)) {
return;
@@ -613,6 +627,8 @@ li.right {
}
li.left.has-tweet-menu .context-menu {
// this handles the offset required to align the context menu button
// extra alignment is required since a tweet message has a the user name and avatar below it
@apply mb-6;
}
@@ -693,8 +709,4 @@ li.right {
}
}
}
.story-reply-quote {
@apply mt-2 mx-4 mb-0 px-2 pb-0 pt-2 border-l-4 border-solid border-slate-75 dark:border-slate-600 text-slate-600 dark:text-slate-200;
}
</style>
@@ -0,0 +1,93 @@
<template>
<div class="overflow-hidden text-ellipsis whitespace-nowrap">
<template v-if="showMessageType">
<fluent-icon
v-if="isMessagePrivate"
size="16"
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
icon="lock-closed"
/>
<fluent-icon
v-else-if="messageByAgent"
size="16"
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
icon="arrow-reply"
/>
<fluent-icon
v-else-if="isMessageAnActivity"
size="16"
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
icon="info"
/>
</template>
<span v-if="message.content">
{{ parsedLastMessage }}
</span>
<span v-else-if="message.attachments">
<fluent-icon
v-if="attachmentIcon && showMessageType"
size="16"
class="-mt-0.5 align-middle inline-block text-slate-600 dark:text-slate-300"
:icon="attachmentIcon"
/>
{{ $t(`${attachmentMessageContent}`) }}
</span>
<span v-else>
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
</span>
</div>
</template>
<script>
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { ATTACHMENT_ICONS } from 'shared/constants/messages';
export default {
name: 'MessagePreview',
mixins: [messageFormatterMixin],
props: {
message: {
type: Object,
required: true,
},
showMessageType: {
type: Boolean,
default: true,
},
defaultEmptyMessage: {
type: String,
default: '',
},
},
computed: {
messageByAgent() {
const { message_type: messageType } = this.message;
return messageType === MESSAGE_TYPE.OUTGOING;
},
isMessageAnActivity() {
const { message_type: messageType } = this.message;
return messageType === MESSAGE_TYPE.ACTIVITY;
},
isMessagePrivate() {
const { private: isPrivate } = this.message;
return isPrivate;
},
parsedLastMessage() {
const { content_attributes: contentAttributes } = this.message;
const { email: { subject } = {} } = contentAttributes || {};
return this.getPlainText(subject || this.message.content);
},
lastMessageFileType() {
const [{ file_type: fileType } = {}] = this.message.attachments;
return fileType;
},
attachmentIcon() {
return ATTACHMENT_ICONS[this.lastMessageFileType];
},
attachmentMessageContent() {
return `CHAT_LIST.ATTACHMENTS.${this.lastMessageFileType}.CONTENT`;
},
},
};
</script>
@@ -1,5 +1,5 @@
<template>
<div class="m-0 flex flex-col justify-between h-full flex-grow min-w-0">
<div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0">
<banner
v-if="!currentChat.can_reply"
color-scheme="alert"
@@ -7,21 +7,12 @@
:href-link="replyWindowLink"
:href-link-text="replyWindowLinkText"
/>
<banner
v-if="isATweet"
color-scheme="gray"
:banner-message="tweetBannerText"
:has-close-button="hasSelectedTweetId"
@close="removeTweetSelection"
/>
<div class="flex justify-end">
<woot-button
variant="smooth"
size="tiny"
color-scheme="secondary"
class="rounded-bl-calc rtl:rotate-180 rounded-tl-calc fixed top-[6.25rem] z-10 bg-white dark:bg-slate-700 border-slate-50 dark:border-slate-600 border-solid border border-r-0 box-border"
class="rounded-bl-calc rtl:rotate-180 rounded-tl-calc fixed top-[9.5rem] md:top-[6.25rem] z-10 bg-white dark:bg-slate-700 border-slate-50 dark:border-slate-600 border-solid border border-r-0 box-border"
:icon="isRightOrLeftIcon"
@click="onToggleContactPanel"
/>
@@ -40,8 +31,9 @@
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:has-instagram-story="hasInstagramStory"
:is-web-widget-inbox="isAWebWidgetInbox"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:in-reply-to="getInReplyToMessage(message)"
/>
<li v-show="unreadMessageCount != 0" class="unread--toast">
<span>
@@ -61,8 +53,9 @@
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:has-instagram-story="hasInstagramStory"
:is-web-widget-inbox="isAWebWidgetInbox"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:in-reply-to="getInReplyToMessage(message)"
/>
<conversation-label-suggestion
v-if="shouldShowLabelSuggestions"
@@ -87,8 +80,6 @@
</div>
<reply-box
:conversation-id="currentChat.id"
:is-a-tweet="isATweet"
:selected-tweet="selectedTweet"
:popout-reply-box.sync="isPopoutReplyBox"
@click="showPopoutReplyBox"
/>
@@ -110,7 +101,7 @@ import { mapGetters } from 'vuex';
import conversationMixin, {
filterDuplicateSourceMessages,
} from '../../../mixins/conversations';
import inboxMixin from 'shared/mixins/inboxMixin';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import configMixin from 'shared/mixins/configMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import aiMixin from 'dashboard/mixins/aiMixin';
@@ -153,7 +144,6 @@ export default {
isLoadingPrevious: true,
heightBeforeLoad: null,
conversationPanel: null,
selectedTweetId: null,
hasUserScrolled: false,
isProgrammaticScroll: false,
isPopoutReplyBox: false,
@@ -164,12 +154,14 @@ export default {
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
currentChat: 'getSelectedChat',
allConversations: 'getAllConversations',
inboxesList: 'inboxes/getInboxes',
listLoadingStatus: 'getAllMessagesLoaded',
loadingChatList: 'getChatListLoadingStatus',
appIntegrations: 'integrations/getAppIntegrations',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
currentAccountId: 'getCurrentAccountId',
}),
isOpen() {
@@ -189,16 +181,6 @@ export default {
inbox() {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
hasSelectedTweetId() {
return !!this.selectedTweetId;
},
tweetBannerText() {
return !this.selectedTweetId
? this.$t('CONVERSATION.SELECT_A_TWEET_TO_REPLY')
: `
${this.$t('CONVERSATION.REPLYING_TO')}
${this.selectedTweet.content}` || '';
},
typingUsersList() {
const userList = this.$store.getters[
'conversationTypingStatus/getUserList'
@@ -253,21 +235,6 @@ export default {
isATweet() {
return this.conversationType === 'tweet';
},
hasInstagramStory() {
return this.conversationType === 'instagram_direct_message';
},
selectedTweet() {
if (this.selectedTweetId) {
const { messages = [] } = this.currentChat;
const [selectedMessage] = messages.filter(
message => message.id === this.selectedTweetId
);
return selectedMessage || {};
}
return '';
},
isRightOrLeftIcon() {
if (this.isContactPanelOpen) {
return 'arrow-chevron-right';
@@ -316,6 +283,12 @@ export default {
unreadMessageCount() {
return this.currentChat.unread_count || 0;
},
inboxSupportsReplyTo() {
return {
incoming: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO),
outgoing: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING),
};
},
},
watch: {
@@ -326,7 +299,6 @@ export default {
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
this.messageSentSinceOpened = false;
this.selectedTweetId = null;
},
},
@@ -334,7 +306,6 @@ export default {
bus.$on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
// when a new message comes in, we refetch the label suggestions
bus.$on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions);
bus.$on(BUS_EVENTS.SET_TWEET_REPLY, this.setSelectedTweet);
// when a message is sent we set the flag to true this hides the label suggestions,
// until the chat is changed and the flag is reset in the watch for currentChat
bus.$on(BUS_EVENTS.MESSAGE_SENT, () => {
@@ -369,7 +340,7 @@ export default {
// method available in mixin, need to ensure that integrations are present
await this.fetchIntegrationsIfRequired();
if (!this.isAIIntegrationEnabled) {
if (!this.isLabelSuggestionFeatureEnabled) {
return;
}
@@ -405,10 +376,6 @@ export default {
},
removeBusListeners() {
bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
bus.$off(BUS_EVENTS.SET_TWEET_REPLY, this.setSelectedTweet);
},
setSelectedTweet(tweetId) {
this.selectedTweetId = tweetId;
},
onScrollToMessage({ messageId = '' } = {}) {
this.$nextTick(() => {
@@ -531,8 +498,18 @@ export default {
makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
},
removeTweetSelection() {
this.selectedTweetId = null;
getInReplyToMessage(parentMessage) {
if (!parentMessage) return {};
const inReplyToMessageId = parentMessage.content_attributes?.in_reply_to;
if (!inReplyToMessageId) return {};
return this.currentChat?.messages.find(message => {
if (message.id === inReplyToMessageId) {
return true;
}
return false;
});
},
},
};
@@ -18,7 +18,18 @@
:popout-reply-box="popoutReplyBox"
@click="$emit('click')"
/>
<article-search-popover
v-if="showArticleSearchPopover && connectedPortalSlug"
:selected-portal-slug="connectedPortalSlug"
@insert="handleInsert"
@close="onSearchPopoverClose"
/>
<div class="reply-box__top">
<reply-to-message
v-if="shouldShowReplyToMessage"
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<canned-response
v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideMentions"
@@ -30,7 +41,7 @@
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
:class="emojiDialogClassOnExpandedLayoutAndRTLView"
:on-click="emojiOnClick"
:on-click="addIntoEditor"
/>
<reply-email-head
v-if="showReplyHead"
@@ -74,6 +85,7 @@
:variables="messageVariables"
:signature="signatureToApply"
:allow-signature="true"
:channel-type="channelType"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@@ -86,6 +98,7 @@
</div>
<div v-if="hasAttachments" class="attachment-preview-box" @paste="onPaste">
<attachment-preview
class="flex-col mt-4"
:attachments="attachedFiles"
:remove-attachment="removeAttachment"
/>
@@ -115,9 +128,12 @@
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker"
:message="message"
:portal-slug="connectedPortalSlug"
:new-conversation-modal-active="newConversationModalActive"
@selectWhatsappTemplate="openWhatsappTemplateModal"
@toggle-editor="toggleRichContentEditor"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
/>
<whatsapp-templates
:inbox-id="inbox.id"
@@ -141,11 +157,13 @@ import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue';
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert';
import Banner from 'dashboard/components/ui/Banner.vue';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
@@ -162,7 +180,7 @@ import {
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin from 'shared/mixins/inboxMixin';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals';
@@ -177,12 +195,16 @@ import {
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
const EmojiInput = () => import('shared/components/emoji/EmojiInput');
export default {
components: {
EmojiInput,
CannedResponse,
ReplyToMessage,
ResizableTextArea,
AttachmentPreview,
ReplyTopPanel,
@@ -193,6 +215,7 @@ export default {
Banner,
WhatsappTemplates,
MessageSignatureMissingAlert,
ArticleSearchPopover,
},
mixins: [
clickaway,
@@ -204,14 +227,6 @@ export default {
fileUploadMixin,
],
props: {
selectedTweet: {
type: [Object, String],
default: () => ({}),
},
isATweet: {
type: Boolean,
default: false,
},
popoutReplyBox: {
type: Boolean,
default: false,
@@ -220,6 +235,7 @@ export default {
data() {
return {
message: '',
inReplyTo: {},
isFocused: false,
showEmojiPicker: false,
attachedFiles: [],
@@ -241,6 +257,8 @@ export default {
showUserMentions: false,
showCannedMenu: false,
showVariablesMenu: false,
newConversationModalActive: false,
showArticleSearchPopover: false,
};
},
computed: {
@@ -251,7 +269,15 @@ export default {
lastEmail: 'getLastEmailInSelectedChat',
globalConfig: 'globalConfig/get',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
shouldShowReplyToMessage() {
return (
this.inReplyTo?.id &&
!this.isPrivate &&
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO)
);
},
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
@@ -323,10 +349,7 @@ export default {
return this.maxLength - this.message.length;
},
isReplyButtonDisabled() {
if (this.isATweet && !this.inReplyTo && !this.isOnPrivateNote) {
return true;
}
if (this.isATwitterInbox) return true;
if (this.hasAttachments || this.hasRecordedAudio) return false;
return (
@@ -359,11 +382,6 @@ export default {
if (this.isASmsInbox) {
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
}
if (this.isATwitterInbox) {
if (this.conversationType === 'tweet') {
return MESSAGE_MAX_LENGTH.TWEET - this.replyToUserLength - 2;
}
}
return MESSAGE_MAX_LENGTH.GENERAL;
},
showFileUpload() {
@@ -381,8 +399,6 @@ export default {
let sendMessageText = this.$t('CONVERSATION.REPLYBOX.SEND');
if (this.isPrivate) {
sendMessageText = this.$t('CONVERSATION.REPLYBOX.CREATE');
} else if (this.conversationType === 'tweet') {
sendMessageText = this.$t('CONVERSATION.REPLYBOX.TWEET');
}
const keyLabel = isEditorHotKeyEnabled(this.uiSettings, 'cmd_enter')
? '(⌘ + ↵)'
@@ -416,10 +432,6 @@ export default {
isOnPrivateNote() {
return this.replyType === REPLY_EDITOR_MODES.NOTE;
},
inReplyTo() {
const selectedTweet = this.selectedTweet || {};
return selectedTweet.id;
},
isOnExpandedLayout() {
const {
LAYOUT_TYPES: { CONDENSED },
@@ -437,15 +449,6 @@ export default {
}
return '';
},
replyToUserLength() {
const selectedTweet = this.selectedTweet || {};
const {
sender: {
additional_attributes: { screen_name: screenName = '' } = {},
} = {},
} = selectedTweet;
return screenName ? screenName.length : 0;
},
isMessageEmpty() {
if (!this.message) {
return true;
@@ -470,8 +473,7 @@ export default {
return !!this.signatureToApply;
},
sendWithSignature() {
const { send_with_signature: isEnabled } = this.uiSettings;
return isEnabled;
return this.fetchSignatureFlagFromUiSettings(this.channelType);
},
editorMessageKey() {
const { editor_message_key: isEnabled } = this.uiSettings;
@@ -510,11 +512,18 @@ export default {
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
},
connectedPortalSlug() {
const { help_center: portal = {} } = this.inbox;
const { slug = '' } = portal;
return slug;
},
},
watch: {
currentChat(conversation) {
const { can_reply: canReply } = conversation;
this.setCCAndToEmailsFromLastChat();
if (this.isOnPrivateNote) {
return;
}
@@ -525,7 +534,7 @@ export default {
this.replyType = REPLY_EDITOR_MODES.NOTE;
}
this.setCCAndToEmailsFromLastChat();
this.fetchAndSetReplyTo();
},
conversationIdByRoute(conversationId, oldConversationId) {
if (conversationId !== oldConversationId) {
@@ -534,17 +543,25 @@ export default {
}
},
message(updatedMessage) {
this.hasSlashCommand =
updatedMessage[0] === '/' && !this.showRichContentEditor;
const hasNextWord = updatedMessage.includes(' ');
const isShortCodeActive = this.hasSlashCommand && !hasNextWord;
if (isShortCodeActive) {
this.mentionSearchKey = updatedMessage.substring(1);
this.showMentions = true;
} else {
this.mentionSearchKey = '';
this.showMentions = false;
}
// Check if the message starts with a slash.
const bodyWithoutSignature = removeSignature(
updatedMessage,
this.signatureToApply
);
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Determine if the user is potentially typing a slash command.
// This is true if the message starts with a slash and the rich content editor is not active.
this.hasSlashCommand = startsWithSlash && !this.showRichContentEditor;
this.showMentions = this.hasSlashCommand;
// If a slash command is active, extract the command text after the slash.
// If not, reset the mentionSearchKey.
this.mentionSearchKey = this.hasSlashCommand
? bodyWithoutSignature.substring(1)
: '';
// Autosave the current message draft.
this.doAutoSaveDraft();
},
replyType(updatedReplyType, oldReplyType) {
@@ -567,12 +584,47 @@ export default {
500,
true
);
this.fetchAndSetReplyTo();
bus.$on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
// A hacky fix to solve the drag and drop
// Is showing on top of new conversation modal drag and drop
// TODO need to find a better solution
bus.$on(
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive
);
},
destroyed() {
document.removeEventListener('paste', this.onPaste);
document.removeEventListener('keydown', this.handleKeyEvents);
bus.$off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
},
beforeDestroy() {
bus.$off(
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive
);
},
methods: {
handleInsert(article) {
const { url, title } = article;
if (this.isRichEditorEnabled) {
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
bus.$emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
} else {
this.addIntoEditor(
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
);
}
},
toggleRichContentEditor() {
this.updateUISettings({
display_rich_content_editor: !this.showRichContentEditor,
@@ -753,6 +805,7 @@ export default {
: this.$track(CONVERSATION_EVENTS.SENT_MESSAGE, {
channelType: this.channelType,
signatureEnabled: this.sendWithSignature,
hasReplyTo: !!this.inReplyTo?.id,
});
},
async onSendReply() {
@@ -803,10 +856,18 @@ export default {
this.hideWhatsappTemplatesModal();
},
replaceText(message) {
if (this.sendWithSignature && !this.private) {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
message = appendSignature(message, this.signatureToApply);
}
const updatedMessage = replaceVariablesInMessage({
message,
variables: this.messageVariables,
});
setTimeout(() => {
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
this.message = updatedMessage;
@@ -829,22 +890,22 @@ export default {
clearEditorSelection() {
this.updateEditorSelectionWith = '';
},
insertEmoji(emoji, selectionStart, selectionEnd) {
insertIntoTextEditor(text, selectionStart, selectionEnd) {
const { message } = this;
const newMessage =
message.slice(0, selectionStart) +
emoji +
text +
message.slice(selectionEnd, message.length);
this.message = newMessage;
},
emojiOnClick(emoji) {
addIntoEditor(content) {
if (this.showRichContentEditor) {
this.updateEditorSelectionWith = emoji;
this.updateEditorSelectionWith = content;
this.onFocus();
}
if (!this.showRichContentEditor) {
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
this.insertEmoji(emoji, selectionStart, selectionEnd);
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
}
},
clearMessage() {
@@ -855,6 +916,7 @@ export default {
}
this.attachedFiles = [];
this.isRecordingAudio = false;
this.resetReplyToMessage();
},
clearEmailField() {
this.ccEmails = '';
@@ -947,6 +1009,19 @@ export default {
(item, index) => itemIndex !== index
);
},
setReplyToInPayload(payload) {
if (this.inReplyTo?.id) {
return {
...payload,
contentAttributes: {
...payload.contentAttributes,
in_reply_to: this.inReplyTo.id,
},
};
}
return payload;
},
getMessagePayloadForWhatsapp(message) {
const multipleMessagePayload = [];
@@ -956,39 +1031,41 @@ export default {
const attachedFile = this.globalConfig.directUploadsEnabled
? attachment.blobSignedId
: attachment.resource.file;
const attachmentPayload = {
let attachmentPayload = {
conversationId: this.currentChat.id,
files: [attachedFile],
private: false,
message: caption,
sender: this.sender,
};
attachmentPayload = this.setReplyToInPayload(attachmentPayload);
multipleMessagePayload.push(attachmentPayload);
caption = '';
});
} else {
const messagePayload = {
let messagePayload = {
conversationId: this.currentChat.id,
message,
private: false,
sender: this.sender,
};
messagePayload = this.setReplyToInPayload(messagePayload);
multipleMessagePayload.push(messagePayload);
}
return multipleMessagePayload;
},
getMessagePayload(message) {
const messagePayload = {
let messagePayload = {
conversationId: this.currentChat.id,
message,
private: this.isPrivate,
sender: this.sender,
};
if (this.inReplyTo) {
messagePayload.contentAttributes = { in_reply_to: this.inReplyTo };
}
messagePayload = this.setReplyToInPayload(messagePayload);
if (this.attachedFiles && this.attachedFiles.length) {
messagePayload.files = [];
@@ -1060,6 +1137,39 @@ export default {
this.bccEmails = bcc.join(', ');
this.toEmails = to.join(', ');
},
fetchAndSetReplyTo() {
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
const replyToMessageId = LocalStorage.getFromJsonStore(
replyStorageKey,
this.conversationId
);
this.inReplyTo = this.currentChat?.messages?.find(message => {
if (message.id === replyToMessageId) {
return true;
}
return false;
});
},
resetReplyToMessage() {
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE);
},
onNewConversationModalActive(isActive) {
// Issue is if the new conversation modal is open and we drag and drop the file
// then the file is not getting attached to the new conversation modal
// and it is getting attached to the current conversation reply box
// so to fix this we are removing the drag and drop event listener from the current conversation reply box
// When new conversation modal is open
this.newConversationModalActive = isActive;
},
onSearchPopoverClose() {
this.showArticleSearchPopover = false;
},
toggleInsertArticle() {
this.showArticleSearchPopover = !this.showArticleSearchPopover;
},
},
};
</script>
@@ -1078,7 +1188,7 @@ export default {
}
.reply-box {
@apply border-t border-slate-50 dark:border-slate-700 bg-white dark:bg-slate-900;
@apply relative border-t border-slate-50 dark:border-slate-700 bg-white dark:bg-slate-900;
&.is-private {
@apply bg-yellow-50 dark:bg-yellow-200;
@@ -0,0 +1,49 @@
<template>
<div
class="reply-editor bg-slate-50 dark:bg-slate-800 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
>
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
<div class="flex-grow gap-1 mt-px text-xs truncate">
{{ $t('CONVERSATION.REPLYBOX.REPLYING_TO') }}
<message-preview
:message="message"
:show-message-type="false"
:default-empty-message="$t('CONVERSATION.REPLY_MESSAGE_NOT_FOUND')"
class="inline"
/>
</div>
<woot-button
v-tooltip="$t('CONVERSATION.REPLYBOX.DISMISS_REPLY')"
color-scheme="secondary"
icon="dismiss"
variant="clear"
size="tiny"
class="flex-shrink-0"
@click.stop="$emit('dismiss')"
/>
</div>
</template>
<script>
import MessagePreview from 'dashboard/components/widgets/conversation/MessagePreview.vue';
export default {
components: { MessagePreview },
props: {
message: {
type: Object,
required: true,
},
},
};
</script>
<style lang="scss">
// TODO: Remove this
// override for dashboard/assets/scss/widgets/_reply-box.scss
.reply-editor {
.icon {
margin-right: 0px !important;
}
}
</style>
@@ -33,7 +33,7 @@
<woot-button variant="smooth" @click="$emit('resetTemplate')">
{{ $t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL') }}
</woot-button>
<woot-button @click="sendMessage">
<woot-button type="button" @click="sendMessage">
{{ $t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL') }}
</woot-button>
</footer>
@@ -57,17 +57,6 @@
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
/>
<button
v-if="isATweet && (isIncoming || isOutgoing) && sourceId"
@click="onTweetReply"
>
<fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.REPLY_TO_TWEET')"
icon="arrow-reply"
class="action--icon cursor-pointer"
size="16"
/>
</button>
<a
v-if="isATweet && (isOutgoing || isIncoming) && linkToTweet"
:href="linkToTweet"
@@ -77,7 +66,7 @@
<fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.VIEW_TWEET_IN_TWITTER')"
icon="open"
class="action--icon cursor-pointer"
class="cursor-pointer action--icon"
size="16"
/>
</a>
@@ -86,7 +75,6 @@
<script>
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import inboxMixin from 'shared/mixins/inboxMixin';
import { mapGetters } from 'vuex';
import timeMixin from '../../../../mixins/time';
@@ -126,10 +114,6 @@ export default {
type: Boolean,
default: true,
},
hasInstagramStory: {
type: Boolean,
default: true,
},
messageType: {
type: Number,
default: 1,
@@ -208,24 +192,46 @@ export default {
if (!this.showStatusIndicators) {
return false;
}
// Messages will be marked as sent for the Email channel if they have a source ID.
if (this.isAnEmailChannel) {
return !!this.sourceId;
}
if (this.isAWhatsAppChannel) {
if (
this.isAWhatsAppChannel ||
this.isATwilioChannel ||
this.isAFacebookInbox ||
this.isASmsInbox ||
this.isATelegramChannel
) {
return this.sourceId && this.isSent;
}
// All messages will be mark as sent for the Line channel, as there is no source ID.
if (this.isALineChannel) {
return true;
}
return false;
},
showDeliveredIndicator() {
if (!this.showStatusIndicators) {
return false;
}
if (this.isAWhatsAppChannel) {
if (
this.isAWhatsAppChannel ||
this.isATwilioChannel ||
this.isASmsInbox ||
this.isAFacebookInbox
) {
return this.sourceId && this.isDelivered;
}
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
if (this.isAWebWidgetInbox || this.isAPIInbox) {
return this.isSent;
}
if (this.isALineChannel) {
return this.isDelivered;
}
return false;
},
@@ -233,24 +239,21 @@ export default {
if (!this.showStatusIndicators) {
return false;
}
if (this.isAWebWidgetInbox || this.isAPIInbox) {
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
return contactLastSeenAt >= this.createdAt;
}
if (this.isAWhatsAppChannel) {
if (
this.isAWhatsAppChannel ||
this.isATwilioChannel ||
this.isAFacebookInbox
) {
return this.sourceId && this.isRead;
}
if (this.isAWebWidgetInbox || this.isAPIInbox) {
return this.isRead;
}
return false;
},
},
methods: {
onTweetReply() {
bus.$emit(BUS_EVENTS.SET_TWEET_REPLY, this.id);
},
},
};
</script>
@@ -1,6 +1,6 @@
<template>
<div class="image message-text__wrap">
<img :src="url" @click="onClick" @error="onImgError()" />
<img :src="url" @click="onClick" @error="$emit('error')" />
<woot-modal :full-width="true" :show.sync="show" :on-close="onClose">
<img :src="url" class="modal-image skip-context-menu" />
</woot-modal>
@@ -28,9 +28,6 @@ export default {
onClick() {
this.show = true;
},
onImgError() {
this.$emit('error');
},
},
};
</script>
@@ -4,13 +4,14 @@
v-if="isImage && !isImageError"
:src="attachment.data_url"
@click="onClick"
@error="onImgError()"
@error="onImgError"
/>
<video
v-if="isVideo"
:src="attachment.data_url"
muted
playsInline
@error="onImgError"
@click="onClick"
/>
<audio v-else-if="isAudio" controls class="skip-context-menu">
@@ -21,7 +22,7 @@
:show.sync="show"
:attachment="attachment"
:all-attachments="filteredCurrentChatAttachments"
@error="onImgError()"
@error="onImgError"
@close="onClose"
/>
</div>
@@ -0,0 +1,51 @@
<template>
<bubble-image
v-if="!hasImgStoryError"
:url="storyUrl"
@error="onImageLoadError"
/>
<bubble-video
v-else-if="!hasVideoStoryError"
:url="storyUrl"
@error="onVideoLoadError"
/>
<instagram-story-error-place-holder v-else />
</template>
<script>
import BubbleImage from './Image.vue';
import BubbleVideo from './Video.vue';
import InstagramStoryErrorPlaceHolder from './InstagramStoryErrorPlaceHolder.vue';
export default {
components: {
BubbleImage,
BubbleVideo,
InstagramStoryErrorPlaceHolder,
},
props: {
storyUrl: {
type: String,
default: '',
},
},
data() {
return {
hasImgStoryError: false,
hasVideoStoryError: false,
};
},
methods: {
onImageLoadError() {
this.hasImgStoryError = true;
this.emitError();
},
onVideoLoadError() {
this.hasVideoStoryError = true;
this.emitError();
},
emitError() {
this.$emit('error');
},
},
};
</script>
@@ -0,0 +1,14 @@
<template>
<div
class="flex items-center justify-center px-8 h-28 w-full bg-slate-100 text-slate-700 dark:bg-slate-500 dark:text-slate-75"
>
<fluent-icon icon="document-error" size="32" />
<p class="mb-0 text-slate-700 dark:text-slate-75">
{{ $t('COMPONENTS.FILE_BUBBLE.INSTAGRAM_STORY_UNAVAILABLE') }}
</p>
</div>
</template>
<script>
export default {};
</script>
@@ -0,0 +1,35 @@
<template>
<blockquote
class="my-0 px-2 pb-0 pt-0 border-l-4 border-solid border-slate-75 dark:border-slate-600 text-slate-600 dark:text-slate-200"
>
<span>{{ $t('CONVERSATION.REPLIED_TO_STORY') }}</span>
<instagram-story :story-url="storyUrl" class="mt-3 rounded-md" />
</blockquote>
</template>
<script>
import InstagramStory from './InstagramStory.vue';
export default {
components: { InstagramStory },
props: {
storyUrl: {
type: String,
default: '',
},
},
data() {
return {
hasImgStoryError: false,
hasVideoStoryError: false,
};
},
methods: {
onImageLoadError() {
this.hasImgStoryError = true;
},
onVideoLoadError() {
this.hasVideoStoryError = true;
},
},
};
</script>
@@ -0,0 +1,46 @@
<template>
<div
class="px-2 py-1.5 rounded-sm min-w-[10rem] mb-2"
:class="{
'bg-slate-50 dark:bg-slate-600 dark:text-slate-50':
messageType === MESSAGE_TYPE.INCOMING,
'bg-woot-600 text-woot-50': messageType === MESSAGE_TYPE.OUTGOING,
'-mx-2': !parentHasAttachments,
}"
>
<message-preview
:message="message"
:show-message-type="false"
:default-empty-message="$t('CONVERSATION.REPLY_MESSAGE_NOT_FOUND')"
/>
</div>
</template>
<script>
import MessagePreview from 'dashboard/components/widgets/conversation/MessagePreview.vue';
import { MESSAGE_TYPE } from 'shared/constants/messages';
export default {
name: 'ReplyTo',
components: {
MessagePreview,
},
props: {
message: {
type: Object,
required: true,
},
messageType: {
type: Number,
required: true,
},
parentHasAttachments: {
type: Boolean,
required: true,
},
},
data() {
return { MESSAGE_TYPE };
},
};
</script>
@@ -1,12 +1,12 @@
<template>
<div class="video message-text__wrap">
<video :src="url" muted playsInline @click="onClick" />
<video ref="videoElement" :src="url" muted playsInline @click="onClick" />
<woot-modal :show.sync="show" :on-close="onClose">
<video
:src="url"
controls
playsInline
class="modal-video skip-context-menu"
class="modal-video skip-context-menu mx-auto"
/>
</woot-modal>
</div>
@@ -25,6 +25,11 @@ export default {
show: false,
};
},
mounted() {
this.$refs.videoElement.onerror = () => {
this.$emit('error');
};
},
methods: {
onClose() {
this.show = false;
@@ -1,26 +0,0 @@
<template>
<div class="image-placeholder">
<fluent-icon icon="document-error" size="32" />
<p>{{ $t('COMPONENTS.FILE_BUBBLE.INSTAGRAM_STORY_UNAVAILABLE') }}</p>
</div>
</template>
<script>
export default {};
</script>
<style scoped>
.image-placeholder {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
padding: var(--space-slab);
height: calc(var(--space-large) * 10);
margin-top: var(--space-one);
width: 100%;
background-color: var(--s-75);
color: var(--s-800);
border-radius: var(--border-radius-normal);
}
</style>
@@ -32,3 +32,22 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'imageUpload',
'code',
];
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{
name: 'Small',
height: '24px',
},
{
name: 'Medium',
height: '48px',
},
{
name: 'Large',
height: '72px',
},
{
name: 'Original Size',
height: 'auto',
},
];
@@ -28,6 +28,7 @@ export default {
EXPANDED: 'expanded',
},
DOCS_URL: '//www.chatwoot.com/docs/product/',
HELP_CENTER_DOCS_URL: '//www.chatwoot.com/docs/product/others/help-center',
TESTIMONIAL_URL: 'https://testimonials.cdn.chatwoot.com/content.json',
SMALL_SCREEN_BREAKPOINT: 1024,
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
@@ -4,4 +4,5 @@ export const LOCAL_STORAGE_KEYS = {
DRAFT_MESSAGES: 'draftMessages',
COLOR_SCHEME: 'color_scheme',
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
MESSAGE_REPLY_TO: 'messageReplyTo',
};
+1
View File
@@ -16,4 +16,5 @@ export const FEATURE_FLAGS = {
TEAM_MANAGEMENT: 'team_management',
VOICE_RECORDER: 'voice_recorder',
AUDIT_LOGS: 'audit_logs',
INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply',
};
@@ -3,6 +3,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
SENT_MESSAGE: 'Sent a message',
SENT_PRIVATE_NOTE: 'Sent a private note',
INSERTED_A_CANNED_RESPONSE: 'Inserted a canned response',
TRANSLATE_A_MESSAGE: 'Translated a message',
INSERTED_A_VARIABLE: 'Inserted a variable',
USED_MENTIONS: 'Used mentions',
SEARCH_CONVERSATION: 'Searched conversations',
@@ -64,3 +64,47 @@ export const isValidURL = value => {
/^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/gm;
return URL_REGEX.test(value);
};
export const getArticleSearchURL = ({
host,
portalSlug,
pageNumber,
locale,
status,
authorId,
categorySlug,
sort,
query,
}) => {
const queryParams = new URLSearchParams({});
const params = {
page: pageNumber,
locale,
status,
author_id: authorId,
category_slug: categorySlug,
sort,
query,
};
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
queryParams.set(key, value);
}
});
return `${host}/${portalSlug}/articles?${queryParams.toString()}`;
};
export const hasValidAvatarUrl = avatarUrl => {
try {
const { host: avatarUrlHost } = new URL(avatarUrl);
const isFromGravatar = ['www.gravatar.com', 'gravatar'].includes(
avatarUrlHost
);
return avatarUrl && !isFromGravatar;
} catch (error) {
return false;
}
};
@@ -135,6 +135,7 @@ export const getActionOptions = ({ agents, teams, labels, type }) => {
assign_team: teams,
send_email_to_team: teams,
add_label: generateConditionOptions(labels, 'title'),
remove_label: generateConditionOptions(labels, 'title'),
change_priority: PRIORITY_CONDITION_VALUES,
};
return actionsMap[type];
@@ -215,3 +215,69 @@ export function insertAtCursor(editorView, node, from, to) {
return state;
}
/**
* Determines the appropriate node and position to insert an image in the editor.
*
* Based on the current editor state and the provided image URL, this function finds out the correct node (either
* a standalone image node or an image wrapped in a paragraph) and its respective position in the editor.
*
* 1. If the current node is a paragraph and doesn't contain an image or text, the image is inserted directly into it.
* 2. If the current node isn't a paragraph or it's a paragraph containing text, the image will be wrapped
* in a new paragraph and then inserted.
* 3. If the current node is a paragraph containing an image, the new image will be inserted directly into it.
*
* @param {Object} editorState - The current state of the editor. It provides necessary details like selection, schema, etc.
* @param {string} fileUrl - The URL of the image to be inserted into the editor.
* @returns {Object|null} An object containing details about the node to be inserted and its position. It returns null if no image node can be created.
* @property {Node} node - The ProseMirror node to be inserted (either an image node or a paragraph containing the image).
* @property {number} pos - The position where the new node should be inserted in the editor.
*/
export const findNodeToInsertImage = (editorState, fileUrl) => {
const { selection, schema } = editorState;
const { nodes } = schema;
const currentNode = selection.$from.node();
const {
type: { name: typeName },
content: { size, content },
} = currentNode;
const imageNode = nodes.image.create({ src: fileUrl });
if (!imageNode) return null;
const isInParagraph = typeName === 'paragraph';
const needsNewLine =
!content.some(n => n.type.name === 'image') && size !== 0 ? 1 : 0;
return {
node: isInParagraph ? imageNode : nodes.paragraph.create({}, imageNode),
pos: selection.from + needsNewLine,
};
};
/**
* Set URL with query and size.
*
* @param {Object} selectedImageNode - The current selected node.
* @param {Object} size - The size to set.
* @param {Object} editorView - The editor view.
*/
export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
if (selectedImageNode) {
// Create and apply the transaction
const tr = editorView.state.tr.setNodeMarkup(
editorView.state.selection.from,
null,
{
src: selectedImageNode.src,
height: size.height,
}
);
if (tr.docChanged) {
editorView.dispatch(tr);
}
}
}
@@ -1,40 +0,0 @@
/**
* Determines the appropriate node and position to insert an image in the editor.
*
* Based on the current editor state and the provided image URL, this function finds out the correct node (either
* a standalone image node or an image wrapped in a paragraph) and its respective position in the editor.
*
* 1. If the current node is a paragraph and doesn't contain an image or text, the image is inserted directly into it.
* 2. If the current node isn't a paragraph or it's a paragraph containing text, the image will be wrapped
* in a new paragraph and then inserted.
* 3. If the current node is a paragraph containing an image, the new image will be inserted directly into it.
*
* @param {Object} editorState - The current state of the editor. It provides necessary details like selection, schema, etc.
* @param {string} fileUrl - The URL of the image to be inserted into the editor.
* @returns {Object|null} An object containing details about the node to be inserted and its position. It returns null if no image node can be created.
* @property {Node} node - The ProseMirror node to be inserted (either an image node or a paragraph containing the image).
* @property {number} pos - The position where the new node should be inserted in the editor.
*/
export const findNodeToInsertImage = (editorState, fileUrl) => {
const { selection, schema } = editorState;
const { nodes } = schema;
const currentNode = selection.$from.node();
const {
type: { name: typeName },
content: { size, content },
} = currentNode;
const imageNode = nodes.image.create({ src: fileUrl });
if (!imageNode) return null;
const isInParagraph = typeName === 'paragraph';
const needsNewLine =
!content.some(n => n.type.name === 'image') && size !== 0 ? 1 : 0;
return {
node: isInParagraph ? imageNode : nodes.paragraph.create({}, imageNode),
pos: selection.from + needsNewLine,
};
};
@@ -3,6 +3,8 @@ import {
conversationUrl,
isValidURL,
conversationListPageURL,
getArticleSearchURL,
hasValidAvatarUrl,
} from '../URLHelper';
describe('#URL Helpers', () => {
@@ -75,4 +77,117 @@ describe('#URL Helpers', () => {
expect(isValidURL('alert.window')).toBe(false);
});
});
describe('getArticleSearchURL', () => {
it('should generate a basic URL without optional parameters', () => {
const url = getArticleSearchURL({
portalSlug: 'news',
pageNumber: 1,
locale: 'en',
host: 'myurl.com',
});
expect(url).toBe('myurl.com/news/articles?page=1&locale=en');
});
it('should include status parameter if provided', () => {
const url = getArticleSearchURL({
portalSlug: 'news',
pageNumber: 1,
locale: 'en',
status: 'published',
host: 'myurl.com',
});
expect(url).toBe(
'myurl.com/news/articles?page=1&locale=en&status=published'
);
});
it('should include author_id parameter if provided', () => {
const url = getArticleSearchURL({
portalSlug: 'news',
pageNumber: 1,
locale: 'en',
authorId: 123,
host: 'myurl.com',
});
expect(url).toBe(
'myurl.com/news/articles?page=1&locale=en&author_id=123'
);
});
it('should include category_slug parameter if provided', () => {
const url = getArticleSearchURL({
portalSlug: 'news',
pageNumber: 1,
locale: 'en',
categorySlug: 'technology',
host: 'myurl.com',
});
expect(url).toBe(
'myurl.com/news/articles?page=1&locale=en&category_slug=technology'
);
});
it('should include sort parameter if provided', () => {
const url = getArticleSearchURL({
portalSlug: 'news',
pageNumber: 1,
locale: 'en',
sort: 'views',
host: 'myurl.com',
});
expect(url).toBe('myurl.com/news/articles?page=1&locale=en&sort=views');
});
it('should handle multiple optional parameters', () => {
const url = getArticleSearchURL({
portalSlug: 'news',
pageNumber: 1,
locale: 'en',
status: 'draft',
authorId: 456,
categorySlug: 'science',
sort: 'views',
host: 'myurl.com',
});
expect(url).toBe(
'myurl.com/news/articles?page=1&locale=en&status=draft&author_id=456&category_slug=science&sort=views'
);
});
it('should handle missing optional parameters gracefully', () => {
const url = getArticleSearchURL({
portalSlug: 'news',
pageNumber: 1,
locale: 'en',
host: 'myurl.com',
});
expect(url).toBe('myurl.com/news/articles?page=1&locale=en');
});
});
describe('hasValidAvatarUrl', () => {
test('should return true for valid non-Gravatar URL', () => {
expect(hasValidAvatarUrl('https://chatwoot.com/avatar.jpg')).toBe(true);
});
test('should return false for a Gravatar URL (www.gravatar.com)', () => {
expect(hasValidAvatarUrl('https://www.gravatar.com/avatar.jpg')).toBe(
false
);
});
test('should return false for a Gravatar URL (gravatar)', () => {
expect(hasValidAvatarUrl('https://gravatar/avatar.jpg')).toBe(false);
});
test('should handle invalid URL', () => {
expect(hasValidAvatarUrl('invalid-url')).toBe(false); // or expect an error, depending on function design
});
test('should return false for empty or undefined URL', () => {
expect(hasValidAvatarUrl('')).toBe(false);
expect(hasValidAvatarUrl()).toBe(false);
});
});
});
@@ -6,6 +6,8 @@ import {
cleanSignature,
extractTextFromMarkdown,
insertAtCursor,
findNodeToInsertImage,
setURLWithQueryAndSize,
} from '../editorHelper';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
@@ -273,3 +275,167 @@ describe('insertAtCursor', () => {
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello Me');
});
});
describe('findNodeToInsertImage', () => {
let mockEditorState;
beforeEach(() => {
mockEditorState = {
selection: {
$from: {
node: jest.fn(() => ({})),
},
from: 0,
},
schema: {
nodes: {
image: {
create: jest.fn(attrs => ({ type: { name: 'image' }, attrs })),
},
paragraph: {
create: jest.fn((_, node) => ({
type: { name: 'paragraph' },
content: [node],
})),
},
},
},
};
});
it('should insert image directly into an empty paragraph', () => {
const mockNode = {
type: { name: 'paragraph' },
content: { size: 0, content: [] },
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result).toEqual({
node: { type: { name: 'image' }, attrs: { src: 'image-url' } },
pos: 0,
});
});
it('should insert image directly into a paragraph without an image but with other content', () => {
const mockNode = {
type: { name: 'paragraph' },
content: {
size: 1,
content: [
{
type: { name: 'text' },
},
],
},
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
mockEditorState.selection.from = 1;
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result).toEqual({
node: { type: { name: 'image' }, attrs: { src: 'image-url' } },
pos: 2, // Because it should insert after the text, on a new line.
});
});
it("should wrap image in a new paragraph when the current node isn't a paragraph", () => {
const mockNode = {
type: { name: 'not-a-paragraph' },
content: { size: 0, content: [] },
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result.node.type.name).toBe('paragraph');
expect(result.node.content[0].type.name).toBe('image');
expect(result.node.content[0].attrs.src).toBe('image-url');
expect(result.pos).toBe(0);
});
it('should insert a new image directly into the paragraph that already contains an image', () => {
const mockNode = {
type: { name: 'paragraph' },
content: {
size: 1,
content: [
{
type: { name: 'image', attrs: { src: 'existing-image-url' } },
},
],
},
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
mockEditorState.selection.from = 1;
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result.node.type.name).toBe('image');
expect(result.node.attrs.src).toBe('image-url');
expect(result.pos).toBe(1);
});
});
describe('setURLWithQueryAndSize', () => {
let selectedNode;
let editorView;
beforeEach(() => {
selectedNode = {
setAttribute: jest.fn(),
};
const tr = {
setNodeMarkup: jest.fn().mockReturnValue({
docChanged: true,
}),
};
const state = {
selection: { from: 0 },
tr,
};
editorView = {
state,
dispatch: jest.fn(),
};
});
it('updates the URL with the given size and updates the editor view', () => {
const size = { height: '20px' };
setURLWithQueryAndSize(selectedNode, size, editorView);
// Check if the editor view is updated
expect(editorView.dispatch).toHaveBeenCalledTimes(1);
});
it('updates the URL with the given size and updates the editor view with original size', () => {
const size = { height: 'auto' };
setURLWithQueryAndSize(selectedNode, size, editorView);
// Check if the editor view is updated
expect(editorView.dispatch).toHaveBeenCalledTimes(1);
});
it('does not update the editor view if the document has not changed', () => {
editorView.state.tr.setNodeMarkup = jest.fn().mockReturnValue({
docChanged: false,
});
const size = { height: '20px' };
setURLWithQueryAndSize(selectedNode, size, editorView);
// Check if the editor view dispatch was not called
expect(editorView.dispatch).not.toHaveBeenCalled();
});
it('does not perform any operations if selectedNode is not provided', () => {
setURLWithQueryAndSize(null, { height: '20px' }, editorView);
// Ensure the dispatch method wasn't called
expect(editorView.dispatch).not.toHaveBeenCalled();
});
});
@@ -1,100 +0,0 @@
import { findNodeToInsertImage } from '../messageEditorHelper';
describe('findNodeToInsertImage', () => {
let mockEditorState;
beforeEach(() => {
mockEditorState = {
selection: {
$from: {
node: jest.fn(() => ({})),
},
from: 0,
},
schema: {
nodes: {
image: {
create: jest.fn(attrs => ({ type: { name: 'image' }, attrs })),
},
paragraph: {
create: jest.fn((_, node) => ({
type: { name: 'paragraph' },
content: [node],
})),
},
},
},
};
});
it('should insert image directly into an empty paragraph', () => {
const mockNode = {
type: { name: 'paragraph' },
content: { size: 0, content: [] },
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result).toEqual({
node: { type: { name: 'image' }, attrs: { src: 'image-url' } },
pos: 0,
});
});
it('should insert image directly into a paragraph without an image but with other content', () => {
const mockNode = {
type: { name: 'paragraph' },
content: {
size: 1,
content: [
{
type: { name: 'text' },
},
],
},
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
mockEditorState.selection.from = 1;
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result).toEqual({
node: { type: { name: 'image' }, attrs: { src: 'image-url' } },
pos: 2, // Because it should insert after the text, on a new line.
});
});
it("should wrap image in a new paragraph when the current node isn't a paragraph", () => {
const mockNode = {
type: { name: 'not-a-paragraph' },
content: { size: 0, content: [] },
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result.node.type.name).toBe('paragraph');
expect(result.node.content[0].type.name).toBe('image');
expect(result.node.content[0].attrs.src).toBe('image-url');
expect(result.pos).toBe(0);
});
it('should insert a new image directly into the paragraph that already contains an image', () => {
const mockNode = {
type: { name: 'paragraph' },
content: {
size: 1,
content: [
{
type: { name: 'image', attrs: { src: 'existing-image-url' } },
},
],
},
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
mockEditorState.selection.from = 1;
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result.node.type.name).toBe('image');
expect(result.node.attrs.src).toBe('image-url');
expect(result.pos).toBe(1);
});
});
@@ -41,6 +41,16 @@
"STATUS": "Status",
"ORDER_BY": "Order by"
},
"CHAT_TIME_STAMP": {
"CREATED": {
"LATEST": "Created",
"OLDEST": "Created at:"
},
"LAST_ACTIVITY": {
"NOT_ACTIVE": "Last activity:",
"ACTIVE": "Last activity"
}
},
"CHAT_SORT_FILTER_ITEMS": {
"latest": {
"TEXT": "Last activity"
@@ -204,6 +204,10 @@
"PLACEHOLDER": "Write your message here",
"ERROR": "Message can't be empty"
},
"ATTACHMENTS": {
"SELECT": "Choose files",
"HELP_TEXT": "Drag and drop files here or choose files to attach"
},
"SUBMIT": "Send message",
"CANCEL": "Cancel",
"SUCCESS_MESSAGE": "Message sent!",
@@ -343,17 +347,17 @@
"DESCRIPTION": "Merge contacts to combine two profiles into one, including all attributes and conversations. In case of conflict, the Primary contacts attributes will take precedence.",
"PRIMARY": {
"TITLE": "Primary contact",
"HELP_LABEL": "To be kept"
"HELP_LABEL": "To be deleted"
},
"CHILD": {
"PARENT": {
"TITLE": "Contact to merge",
"PLACEHOLDER": "Search for a contact",
"HELP_LABEL": "To be deleted"
"HELP_LABEL": "To be kept"
},
"SUMMARY": {
"TITLE": "Summary",
"DELETE_WARNING": "Contact of <strong>%{childContactName}</strong> will be deleted.",
"ATTRIBUTE_WARNING": "Contact details of <strong>%{childContactName}</strong> will be copied to <strong>%{primaryContactName}</strong>."
"DELETE_WARNING": "Contact of <strong>%{primaryContactName}</strong> will be deleted.",
"ATTRIBUTE_WARNING": "Contact details of <strong>%{primaryContactName}</strong> will be copied to <strong>%{parentContactName}</strong>."
},
"SEARCH": {
"ERROR": "ERROR_MESSAGE"
@@ -34,7 +34,6 @@
"ASSIGN_TO_ME": "Assign to me",
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"SELECT_A_TWEET_TO_REPLY": "Please select a tweet to reply to.",
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
@@ -47,6 +46,7 @@
"NO_RESPONSE": "No response",
"RATING_TITLE": "Rating",
"FEEDBACK_TITLE": "Feedback",
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
"CARD": {
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels"
@@ -139,7 +139,8 @@
"PRIVATE_NOTE": "Private Note",
"SEND": "Send",
"CREATE": "Add Note",
"TWEET": "Tweet",
"DISMISS_REPLY": "Dismiss reply",
"REPLYING_TO": "Replying to:",
"TIP_FORMAT_ICON": "Show rich text editor",
"TIP_EMOJI_ICON": "Show emoji selector",
"TIP_ATTACH_ICON": "Attach files",
@@ -194,6 +195,7 @@
},
"CONTEXT_MENU": {
"COPY": "Copy",
"REPLY_TO": "Reply to this message",
"DELETE": "Delete",
"CREATE_A_CANNED_RESPONSE": "Add to canned responses",
"TRANSLATE": "Translate",
@@ -1,6 +1,7 @@
{
"EMOJI": {
"PLACEHOLDER": "Search emojis",
"NOT_FOUND": "No emoji match your search"
"NOT_FOUND": "No emoji match your search",
"REMOVE": "Remove"
}
}
@@ -365,7 +365,7 @@
"NAME": {
"LABEL": "Name",
"PLACEHOLDER": "Category name",
"HELP_TEXT": "The category name will be used in the public facing portal to categorize articles.",
"HELP_TEXT": "The category name and icon will be used in the public facing portal to categorize articles.",
"ERROR": "Name is required"
},
"SLUG": {
@@ -396,7 +396,7 @@
"NAME": {
"LABEL": "Name",
"PLACEHOLDER": "Category name",
"HELP_TEXT": "The category name will be used in the public facing portal to categorize articles.",
"HELP_TEXT": "The category name and icon will be used in the public facing portal to categorize articles.",
"ERROR": "Name is required"
},
"SLUG": {
@@ -425,6 +425,33 @@
"ERROR_MESSAGE": "Unable to delete category"
}
}
},
"UPGRADE_PAGE": {
"TITLE": "Help Center",
"DESCRIPTION": "Create user-friendly self-service portals. Help your users to access the articles and get support 24/7. Upgrade your subscription to enable this feature.",
"SELF_HOSTED_DESCRIPTION": "Create user-friendly self-service portals. Help your users to access the articles and get support 24/7. Please contact your administrator to enable this feature.",
"BUTTON": {
"LEARN_MORE": "Learn more",
"UPGRADE": "Upgrade"
},
"FEATURES": {
"PORTALS": {
"TITLE": "Multiple portals",
"DESCRIPTION": "Create multiple help center portals for different products using the same account."
},
"LOCALES": {
"TITLE": "Full support for locales",
"DESCRIPTION": "Localize the portal in your language. We support all locales and allow translations for every article."
},
"SEO": {
"TITLE": "SEO-friendly design",
"DESCRIPTION": "Customize your meta tags to improve your visibility on search engines with our SEO-friendly pages."
},
"API": {
"TITLE": "Full API support",
"DESCRIPTION": "Use the portal as a headless CMS with third party front-end frameworks using our APIs."
}
}
}
}
}
@@ -242,7 +242,7 @@
"REPORTS_OVERVIEW": "Overview",
"FACEBOOK_REAUTHORIZE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services",
"HELP_CENTER": {
"TITLE": "Help Center (Beta)",
"TITLE": "Help Center",
"ALL_ARTICLES": "All Articles",
"MY_ARTICLES": "My Articles",
"DRAFT": "Draft",
@@ -41,6 +41,16 @@
"STATUS": "الحالة",
"ORDER_BY": "الترتيب بواسطة"
},
"CHAT_TIME_STAMP": {
"CREATED": {
"LATEST": "Created",
"OLDEST": "تم إنشاؤها في:"
},
"LAST_ACTIVITY": {
"NOT_ACTIVE": "النشاط الأخير:",
"ACTIVE": "النشاط الأخير"
}
},
"CHAT_SORT_FILTER_ITEMS": {
"latest": {
"TEXT": "النشاط الأخير"
@@ -204,6 +204,10 @@
"PLACEHOLDER": "اكتب رسالتك هنا",
"ERROR": "لا يمكن أن تكون الرسالة فارغة"
},
"ATTACHMENTS": {
"SELECT": "Choose files",
"HELP_TEXT": "Drag and drop files here or choose files to attach"
},
"SUBMIT": "إرسال الرسالة",
"CANCEL": "إلغاء",
"SUCCESS_MESSAGE": "تم إرسال الرسالة!",
@@ -343,17 +347,17 @@
"DESCRIPTION": "دمج جهات الاتصال للجمع بين ملفّين شخصيين في واحد، بما في ذلك جميع السمات والمحادثات. في حالة التعارض، تكون لخصائص جهة الاتصال الرئيسية الأسبقية.",
"PRIMARY": {
"TITLE": "جهة الاتصال الرئيسية",
"HELP_LABEL": "سيتم الاحتفاظ بها"
"HELP_LABEL": "ليتم حذفها"
},
"CHILD": {
"PARENT": {
"TITLE": "دمج جهة الإتصال",
"PLACEHOLDER": "بحث عن جهات الاتصال",
"HELP_LABEL": "ليتم حذفها"
"HELP_LABEL": "سيتم الاحتفاظ بها"
},
"SUMMARY": {
"TITLE": "ملخص",
"DELETE_WARNING": "سيتم حذف جهة الاتصال بـ <strong>%{childContactName}</strong>.",
"ATTRIBUTE_WARNING": "سيتم نسخ تفاصيل الاتصال بـ <strong>%{childContactName}</strong> إلى <strong>%{primaryContactName}</strong>."
"DELETE_WARNING": "سيتم حذف جهة الاتصال بـ <strong>%{primaryContactName}</strong>.",
"ATTRIBUTE_WARNING": "سيتم نسخ تفاصيل الاتصال بـ <strong>%{primaryContactName}</strong> إلى <strong>%{parentContactName}</strong>."
},
"SEARCH": {
"ERROR": "رسالة_خطأ"
@@ -34,7 +34,6 @@
"ASSIGN_TO_ME": "إسناد لي",
"TWILIO_WHATSAPP_CAN_REPLY": "يمكنك فقط الرد على هذه المحادثة باستخدام رسالة قالب بسبب",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "قيد نافذة الـ 24 ساعة",
"SELECT_A_TWEET_TO_REPLY": "الرجاء تحديد تغريدة للرد عليها.",
"REPLYING_TO": "أنت ترد على:",
"REMOVE_SELECTION": "إزالة التحديد",
"DOWNLOAD": "تنزيل",
@@ -47,6 +46,7 @@
"NO_RESPONSE": "لا توجد استجابة",
"RATING_TITLE": "التقييم",
"FEEDBACK_TITLE": "الملاحظات",
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
"CARD": {
"SHOW_LABELS": "إظهار السمات",
"HIDE_LABELS": "إخفاء السمات"
@@ -139,7 +139,8 @@
"PRIVATE_NOTE": "إضافة ملاحظة خاصة",
"SEND": "إرسال",
"CREATE": "إضافة ملاحظة",
"TWEET": "تغريد",
"DISMISS_REPLY": "Dismiss reply",
"REPLYING_TO": "Replying to:",
"TIP_FORMAT_ICON": "عرض محرر النصوص",
"TIP_EMOJI_ICON": "إظهار قائمة الرموز التعبيرية",
"TIP_ATTACH_ICON": "إرفاق الملفات",
@@ -194,6 +195,7 @@
},
"CONTEXT_MENU": {
"COPY": "نسخ",
"REPLY_TO": "Reply to this message",
"DELETE": "حذف",
"CREATE_A_CANNED_RESPONSE": "إضافة إلى الردود السريعة",
"TRANSLATE": "ترجم",
@@ -1,6 +1,7 @@
{
"EMOJI": {
"PLACEHOLDER": "ابحث في الايموجي",
"NOT_FOUND": "لا يوجد إيموجي يطابق بحثك"
"NOT_FOUND": "لا يوجد إيموجي يطابق بحثك",
"REMOVE": "حذف"
}
}
@@ -365,7 +365,7 @@
"NAME": {
"LABEL": "الاسم",
"PLACEHOLDER": "Category name",
"HELP_TEXT": "The category name will be used in the public facing portal to categorize articles.",
"HELP_TEXT": "The category name and icon will be used in the public facing portal to categorize articles.",
"ERROR": "الاسم مطلوب"
},
"SLUG": {
@@ -396,7 +396,7 @@
"NAME": {
"LABEL": "الاسم",
"PLACEHOLDER": "Category name",
"HELP_TEXT": "The category name will be used in the public facing portal to categorize articles.",
"HELP_TEXT": "The category name and icon will be used in the public facing portal to categorize articles.",
"ERROR": "الاسم مطلوب"
},
"SLUG": {
@@ -425,6 +425,33 @@
"ERROR_MESSAGE": "Unable to delete category"
}
}
},
"UPGRADE_PAGE": {
"TITLE": "Help Center",
"DESCRIPTION": "Create user-friendly self-service portals. Help your users to access the articles and get support 24/7. Upgrade your subscription to enable this feature.",
"SELF_HOSTED_DESCRIPTION": "Create user-friendly self-service portals. Help your users to access the articles and get support 24/7. Please contact your administrator to enable this feature.",
"BUTTON": {
"LEARN_MORE": "اعرف المزيد",
"UPGRADE": "Upgrade"
},
"FEATURES": {
"PORTALS": {
"TITLE": "Multiple portals",
"DESCRIPTION": "Create multiple help center portals for different products using the same account."
},
"LOCALES": {
"TITLE": "Full support for locales",
"DESCRIPTION": "Localize the portal in your language. We support all locales and allow translations for every article."
},
"SEO": {
"TITLE": "SEO-friendly design",
"DESCRIPTION": "Customize your meta tags to improve your visibility on search engines with our SEO-friendly pages."
},
"API": {
"TITLE": "Full API support",
"DESCRIPTION": "Use the portal as a headless CMS with third party front-end frameworks using our APIs."
}
}
}
}
}
@@ -242,7 +242,7 @@
"REPORTS_OVERVIEW": "نظرة عامة",
"FACEBOOK_REAUTHORIZE": "انتهت صلاحية اتصال الفيسبوك الخاص بك، يرجى إعادة الاتصال بصفحة الفيسبوك الخاصة بك لمواصلة الخدمات",
"HELP_CENTER": {
"TITLE": "مركز المساعدة (نسخة تجريبية)",
"TITLE": "Help Center",
"ALL_ARTICLES": "جميع المقالات",
"MY_ARTICLES": "مقالاتي",
"DRAFT": "مسودة",
@@ -41,6 +41,16 @@
"STATUS": "Статус",
"ORDER_BY": "Order by"
},
"CHAT_TIME_STAMP": {
"CREATED": {
"LATEST": "Created",
"OLDEST": "Създаден в:"
},
"LAST_ACTIVITY": {
"NOT_ACTIVE": "Last activity:",
"ACTIVE": "Last activity"
}
},
"CHAT_SORT_FILTER_ITEMS": {
"latest": {
"TEXT": "Last activity"
@@ -204,6 +204,10 @@
"PLACEHOLDER": "Напишете съобщението си тук",
"ERROR": "Съобщението не може да е празно"
},
"ATTACHMENTS": {
"SELECT": "Choose files",
"HELP_TEXT": "Drag and drop files here or choose files to attach"
},
"SUBMIT": "Изпрати съобщение",
"CANCEL": "Отмени",
"SUCCESS_MESSAGE": "Съобщението е изпратено!",
@@ -343,17 +347,17 @@
"DESCRIPTION": "Обединете контактите, за да комбинирате два профила в един, включително всички атрибути и разговори. В случай на конфликт атрибутите на основния контакт ще имат предимство.",
"PRIMARY": {
"TITLE": "Основен контакт",
"HELP_LABEL": "Да бъде запазено"
"HELP_LABEL": "Да бъде изтрит"
},
"CHILD": {
"PARENT": {
"TITLE": "Контакт за обединяване",
"PLACEHOLDER": "Търсене на контакт",
"HELP_LABEL": "Да бъде изтрит"
"HELP_LABEL": "Да бъде запазено"
},
"SUMMARY": {
"TITLE": "Резюме",
"DELETE_WARNING": "Контакт от <strong>%{childContactName}</strong> ще бъде изтрит.",
"ATTRIBUTE_WARNING": "Детайлите на контакт <strong>%{childContactName}</strong> ще бъдат копирани в <strong>%{primaryContactName}</strong>."
"DELETE_WARNING": "Контакт от <strong>%{primaryContactName}</strong> ще бъде изтрит.",
"ATTRIBUTE_WARNING": "Детайлите на контакт <strong>%{primaryContactName}</strong> ще бъдат копирани в <strong>%{parentContactName}</strong>."
},
"SEARCH": {
"ERROR": "ГРЕШКА"
@@ -34,7 +34,6 @@
"ASSIGN_TO_ME": "Assign to me",
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"SELECT_A_TWEET_TO_REPLY": "Please select a tweet to reply to.",
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
@@ -47,6 +46,7 @@
"NO_RESPONSE": "No response",
"RATING_TITLE": "Rating",
"FEEDBACK_TITLE": "Feedback",
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
"CARD": {
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels"
@@ -139,7 +139,8 @@
"PRIVATE_NOTE": "Private Note",
"SEND": "Send",
"CREATE": "Add Note",
"TWEET": "Tweet",
"DISMISS_REPLY": "Dismiss reply",
"REPLYING_TO": "Replying to:",
"TIP_FORMAT_ICON": "Show rich text editor",
"TIP_EMOJI_ICON": "Show emoji selector",
"TIP_ATTACH_ICON": "Attach files",
@@ -194,6 +195,7 @@
},
"CONTEXT_MENU": {
"COPY": "Copy",
"REPLY_TO": "Reply to this message",
"DELETE": "Изтрий",
"CREATE_A_CANNED_RESPONSE": "Add to canned responses",
"TRANSLATE": "Translate",
@@ -1,6 +1,7 @@
{
"EMOJI": {
"PLACEHOLDER": "Search emojis",
"NOT_FOUND": "No emoji match your search"
"NOT_FOUND": "No emoji match your search",
"REMOVE": "Remove"
}
}
@@ -365,7 +365,7 @@
"NAME": {
"LABEL": "Име",
"PLACEHOLDER": "Category name",
"HELP_TEXT": "The category name will be used in the public facing portal to categorize articles.",
"HELP_TEXT": "The category name and icon will be used in the public facing portal to categorize articles.",
"ERROR": "Name is required"
},
"SLUG": {
@@ -396,7 +396,7 @@
"NAME": {
"LABEL": "Име",
"PLACEHOLDER": "Category name",
"HELP_TEXT": "The category name will be used in the public facing portal to categorize articles.",
"HELP_TEXT": "The category name and icon will be used in the public facing portal to categorize articles.",
"ERROR": "Name is required"
},
"SLUG": {
@@ -425,6 +425,33 @@
"ERROR_MESSAGE": "Unable to delete category"
}
}
},
"UPGRADE_PAGE": {
"TITLE": "Help Center",
"DESCRIPTION": "Create user-friendly self-service portals. Help your users to access the articles and get support 24/7. Upgrade your subscription to enable this feature.",
"SELF_HOSTED_DESCRIPTION": "Create user-friendly self-service portals. Help your users to access the articles and get support 24/7. Please contact your administrator to enable this feature.",
"BUTTON": {
"LEARN_MORE": "Learn more",
"UPGRADE": "Upgrade"
},
"FEATURES": {
"PORTALS": {
"TITLE": "Multiple portals",
"DESCRIPTION": "Create multiple help center portals for different products using the same account."
},
"LOCALES": {
"TITLE": "Full support for locales",
"DESCRIPTION": "Localize the portal in your language. We support all locales and allow translations for every article."
},
"SEO": {
"TITLE": "SEO-friendly design",
"DESCRIPTION": "Customize your meta tags to improve your visibility on search engines with our SEO-friendly pages."
},
"API": {
"TITLE": "Full API support",
"DESCRIPTION": "Use the portal as a headless CMS with third party front-end frameworks using our APIs."
}
}
}
}
}
@@ -242,7 +242,7 @@
"REPORTS_OVERVIEW": "Overview",
"FACEBOOK_REAUTHORIZE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services",
"HELP_CENTER": {
"TITLE": "Help Center (Beta)",
"TITLE": "Help Center",
"ALL_ARTICLES": "All Articles",
"MY_ARTICLES": "My Articles",
"DRAFT": "Draft",
@@ -41,6 +41,16 @@
"STATUS": "Estat",
"ORDER_BY": "Order by"
},
"CHAT_TIME_STAMP": {
"CREATED": {
"LATEST": "Created",
"OLDEST": "Created at:"
},
"LAST_ACTIVITY": {
"NOT_ACTIVE": "Last activity:",
"ACTIVE": "Last activity"
}
},
"CHAT_SORT_FILTER_ITEMS": {
"latest": {
"TEXT": "Last activity"
@@ -204,6 +204,10 @@
"PLACEHOLDER": "Write your message here",
"ERROR": "Message can't be empty"
},
"ATTACHMENTS": {
"SELECT": "Choose files",
"HELP_TEXT": "Drag and drop files here or choose files to attach"
},
"SUBMIT": "Send message",
"CANCEL": "Cancel·la",
"SUCCESS_MESSAGE": "Message sent!",
@@ -343,17 +347,17 @@
"DESCRIPTION": "Merge contacts to combine two profiles into one, including all attributes and conversations. In case of conflict, the Primary contact s attributes will take precedence.",
"PRIMARY": {
"TITLE": "Primary contact",
"HELP_LABEL": "To be kept"
"HELP_LABEL": "To be deleted"
},
"CHILD": {
"PARENT": {
"TITLE": "Contact to merge",
"PLACEHOLDER": "Search for a contact",
"HELP_LABEL": "To be deleted"
"HELP_LABEL": "To be kept"
},
"SUMMARY": {
"TITLE": "Summary",
"DELETE_WARNING": "Contact of <strong>%{childContactName}</strong> will be deleted.",
"ATTRIBUTE_WARNING": "Contact details of <strong>%{childContactName}</strong> will be copied to <strong>%{primaryContactName}</strong>."
"DELETE_WARNING": "Contact of <strong>%{primaryContactName}</strong> will be deleted.",
"ATTRIBUTE_WARNING": "Contact details of <strong>%{primaryContactName}</strong> will be copied to <strong>%{parentContactName}</strong>."
},
"SEARCH": {
"ERROR": "ERROR_MESSAGE"
@@ -34,7 +34,6 @@
"ASSIGN_TO_ME": "Assign to me",
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "Restricció de finestra de missatges de 24 hores",
"SELECT_A_TWEET_TO_REPLY": "Please select a tweet to reply to.",
"REPLYING_TO": "Estas responent a:",
"REMOVE_SELECTION": "Elimina la selecció",
"DOWNLOAD": "Descarrega",
@@ -47,6 +46,7 @@
"NO_RESPONSE": "Sense resposta",
"RATING_TITLE": "Rating",
"FEEDBACK_TITLE": "Feedback",
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
"CARD": {
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels"
@@ -139,7 +139,8 @@
"PRIVATE_NOTE": "Nota privada",
"SEND": "Envia",
"CREATE": "Afegeix una nota",
"TWEET": "Tuit",
"DISMISS_REPLY": "Dismiss reply",
"REPLYING_TO": "Replying to:",
"TIP_FORMAT_ICON": "Mostra l'editor de text enriquit",
"TIP_EMOJI_ICON": "Mostra la selecció d'emoticones",
"TIP_ATTACH_ICON": "Ajuntar fitxers",
@@ -194,6 +195,7 @@
},
"CONTEXT_MENU": {
"COPY": "Copia",
"REPLY_TO": "Reply to this message",
"DELETE": "Esborrar",
"CREATE_A_CANNED_RESPONSE": "Add to canned responses",
"TRANSLATE": "Translate",

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