Compare commits

...
Author SHA1 Message Date
Shivam Mishra 52f139561e feat: hide attachment icon 2023-10-10 19:22:11 +05:30
Shivam Mishra f509a734f9 refactor: use message preview in reply-to-message 2023-10-10 19:22:11 +05:30
Shivam Mishra e61f5a4e62 refactor: styles for message preview 2023-10-10 19:21:46 +05:30
Shivam Mishra 33cdd2c0ed refactor: split message-preview out as a component 2023-10-10 19:21:45 +05:30
Shivam Mishra a607bf4b60 refactor: visually balance y-padding 2023-10-10 19:21:45 +05:30
Shivam Mishra 7added2d48 feat: support attachments in bubble 2023-10-10 19:21:45 +05:30
Shivam Mishra d3dd20e4b9 fix: prop value 2023-10-10 19:21:45 +05:30
Shivam Mishra 8db377d29b feat: allow translation 2023-10-10 19:21:39 +05:30
Shivam Mishra 603c45a907 refactor: use vue 2 syntax 2023-10-10 19:21:38 +05:30
Shivam Mishra 1dcaec1186 feat: handle attachments in ReplyToMessage 2023-10-10 19:20:29 +05:30
Shivam Mishra d26959f57c feat: include attachments in props 2023-10-10 19:19:01 +05:30
Shivam Mishra c114cc7fa0 refactor: move ATTACHMENT_ICONS to constants 2023-10-10 19:18:34 +05:30
Shivam Mishra 7abe5b01dc fix: condition 2023-10-10 19:18:34 +05:30
Shivam Mishra 047f1f6bc3 refactor: handle translation externally 2023-10-10 19:18:34 +05:30
Shivam Mishra 86ea2bbbe3 refactor: handle missing message content 2023-10-10 19:18:34 +05:30
Shivam Mishra 53ac68f0c0 refactor: disable reply to bubble 2023-10-10 19:18:33 +05:30
Shivam Mishra a2af5e25e1 feat: allow showing in-reply-to message 2023-10-10 19:18:33 +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
b71a580573 feat: Adds dark theme support for public portal [CW-2525] (#7979)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-10-04 11:56:00 +05:30
Sojan JoseandGitHub 6a73953003 chore: Add delay before running dataimport job (#8039)
- We have observed some failures for data import jobs in the cloud due to race conditions with job executions and active storage file uploading. This PR adds delays and retries to accommodate that.
2023-10-03 22:18:57 -07:00
Sojan JoseandGitHub 336af1ac9a chore: Create client API conversations with custom attributes (#8040)
- Update client API create conversations endpoint to accept custom attributes as well.
2023-10-03 22:18:35 -07:00
e4de366b8d feat: Add an option to listen to the start conversation click events (#8038)
This PR will add a new event chatwoot:on-start-conversation to the chat widget, which you can listen to and trigger custom interactions on your end.

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-10-03 18:01:40 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
373c3dcd6c chore(deps): bump postcss from 8.4.30 to 8.4.31 (#8034)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-03 10:08:18 -07:00
Shivam MishraandGitHub 4b12dd89b0 fix: videojs-record version (#8029) 2023-10-03 20:57:23 +05:30
c19cfeaa81 feat: Adds message signature for new email conversations (#7946)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2023-10-03 13:45:28 +05:30
fa118ad18c fix: Issue on the status filter field (#7949)
Co-authored-by: AqidaHaidari <42426077+AqidaHaidari@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-10-03 09:20:46 +05:30
249bfe44bb fix: issue of translation for TimeAgo.vue (#7951)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2023-10-03 08:41:30 +05:30
Sojan JoseandGitHub 826d9ec5a7 chore: Refactor Response Bot Data Schema (#8011)
This PR refactors the schema we introduced in #7518 based on the feedback from production tests. Here is the change log

- Decouple Inbox association to a new table inbox_response_sources -> this lets us share the same response source between multiple inboxes
- Add a status field to responses. This ensures that, by default, responses are created in pending status. You can do quality assurance before making them active. In future, this status can be leveraged by the bot to auto-generate response questions from conversations which require a handoff
- Add response_source association to responses and remove hard dependency from response_documents. This lets users write free-form question answers based on conversations, which doesn't necessarily need a response source.
2023-10-01 19:31:38 -07:00
d8b53f5d2f chore: [Snyk] Security upgrade audited from 5.3.3 to 5.4.0 (#8023)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-09-30 19:49:03 -07:00
Jordan BroughandGitHub 08ced452e1 chore: Log errors handled by RequestExceptionHandler (#8013)
- Logging this info is invaluable when using logs to track down the reason why a particular request failed.
2023-09-30 19:46:58 -07:00
24fe3805d8 feat: Slack link unfurling (#7940)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-09-29 19:35:56 +05:30
Sivin VargheseandGitHub 845b0c0a68 fix: Editor file upload browser showing bug (#8019) 2023-09-29 17:53:54 +05:30
166 changed files with 4513 additions and 1901 deletions
+1 -1
View File
@@ -199,7 +199,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
+1
View File
@@ -58,6 +58,7 @@ Metrics/BlockLength:
Metrics/ModuleLength:
Exclude:
- lib/seeders/message_seeder.rb
- spec/support/slack_stubs.rb
Rails/ApplicationController:
Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb'
+2 -2
View File
@@ -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'
@@ -159,7 +159,7 @@ gem 'lograge', '~> 0.13.0', require: false
# worked with microsoft refresh token
gem 'omniauth-oauth2'
gem 'audited', '~> 5.3'
gem 'audited', '~> 5.4', '>= 5.4.0'
# need for google auth
gem 'omniauth'
+6 -7
View File
@@ -126,8 +126,8 @@ GEM
rake (>= 10.4, < 14.0)
ast (2.4.2)
attr_extras (7.1.0)
audited (5.3.3)
activerecord (>= 5.0, < 7.1)
audited (5.4.0)
activerecord (>= 5.0, < 7.2)
request_store (~> 1.2)
aws-eventstream (1.2.0)
aws-partitions (1.760.0)
@@ -273,7 +273,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)
@@ -732,13 +732,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)
@@ -839,7 +838,7 @@ DEPENDENCIES
administrate-field-belongs_to_search
annotate
attr_extras
audited (~> 5.3)
audited (~> 5.4, >= 5.4.0)
aws-sdk-s3
azure-storage-blob!
barnes
@@ -935,7 +934,7 @@ DEPENDENCIES
sidekiq (>= 7.1.3)
sidekiq-cron (>= 1.10.1)
simplecov (= 0.17.1)
slack-ruby-client (~> 2.0.0)
slack-ruby-client (~> 2.2.0)
spring
spring-watcher-listen
squasher
+1 -1
View File
@@ -1 +1 @@
2.4.0
2.5.0
@@ -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
@@ -1,7 +1,15 @@
class Api::V1::Integrations::WebhooksController < ApplicationController
def create
builder = Integrations::Slack::IncomingMessageBuilder.new(params)
builder = Integrations::Slack::IncomingMessageBuilder.new(permitted_params)
response = builder.perform
render json: response
end
private
# TODO: This is a temporary solution to permit all params for slack unfurling job.
# We should only permit the params that we use. Handle all the params based on events and send it to the respective services.
def permitted_params
params.permit!
end
end
@@ -9,11 +9,14 @@ module RequestExceptionHandler
def handle_with_exception
yield
rescue ActiveRecord::RecordNotFound
rescue ActiveRecord::RecordNotFound => e
log_handled_error(e)
render_not_found_error('Resource could not be found')
rescue Pundit::NotAuthorizedError
rescue Pundit::NotAuthorizedError => e
log_handled_error(e)
render_unauthorized('You are not authorized to do this action')
rescue ActionController::ParameterMissing => e
log_handled_error(e)
render_could_not_create_error(e.message)
ensure
# to address the thread variable leak issues in Puma/Thin webserver
@@ -41,6 +44,7 @@ module RequestExceptionHandler
end
def render_record_invalid(exception)
log_handled_error(exception)
render json: {
message: exception.record.errors.full_messages.join(', '),
attributes: exception.record.errors.attribute_names
@@ -48,6 +52,11 @@ module RequestExceptionHandler
end
def render_error_response(exception)
log_handled_error(exception)
render json: exception.to_hash, status: exception.http_status
end
def log_handled_error(exception)
logger.info("Handled error: #{exception.inspect}")
end
end
@@ -33,7 +33,7 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox
end
def create_conversation
::Conversation.create!(conversation_params)
ConversationBuilder.new(params: conversation_params, contact_inbox: @contact_inbox).perform
end
def trigger_typing_event(event)
@@ -41,11 +41,6 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox
end
def conversation_params
{
account_id: @contact_inbox.contact.account_id,
inbox_id: @contact_inbox.inbox_id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id
}
params.permit(custom_attributes: {})
end
end
@@ -1,5 +1,6 @@
class Public::Api::V1::Portals::BaseController < PublicController
before_action :show_plain_layout
before_action :set_color_scheme
around_action :set_locale
after_action :allow_iframe_requests
@@ -9,6 +10,14 @@ class Public::Api::V1::Portals::BaseController < PublicController
@is_plain_layout_enabled = params[:show_plain_layout] == 'true'
end
def set_color_scheme
@theme = if %w[dark light].include?(params[:theme])
params[:theme]
else
''
end
end
def set_locale(&)
switch_locale_with_portal(&) if params[:locale].present?
switch_locale_with_article(&) if params[:article_slug].present?
+11
View File
@@ -0,0 +1,11 @@
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})"
end
def generate_portal_bg(portal_color, theme)
bg_image = theme == 'dark' ? 'grid_dark.svg' : 'grid.svg'
"background: url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}"
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>
@@ -149,15 +149,11 @@
}
.multiselect-wrap--small {
.multiselect__tags,
.multiselect__input {
@apply items-center flex;
}
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 rounded-[5px] text-sm h-10 min-h-[2.5rem];
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 rounded-[5px] text-sm min-h-[2.5rem];
}
.multiselect__input {
@@ -165,11 +161,15 @@
}
.multiselect__single {
@apply items-center flex m-0 text-sm max-h-[2.375rem] text-slate-800 dark:text-slate-100 bg-white dark:bg-slate-900 py-1 px-0.5;
@apply items-center flex m-0 text-sm max-h-[2.375rem] text-slate-800 dark:text-slate-100 bg-white dark:bg-slate-900 py-3 px-0.5;
}
.multiselect__placeholder {
@apply m-0 py-1 px-0.5;
@apply m-0 py-2 px-0.5;
}
.multiselect__tag {
@apply py-[6px] my-[1px];
}
.multiselect__select {
@@ -181,9 +181,6 @@
@apply bg-transparent;
}
.multiselect__tags-wrap {
@apply flex-shrink-0;
}
}
.multiselect-wrap--medium {
@@ -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;
+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'],
@@ -53,16 +53,24 @@ export default {
const createdTimeDiff = Date.now() - this.createdAtTimestamp * 1000;
const isBeforeAMonth = createdTimeDiff > DAY_IN_MILLI_SECONDS * 30;
return !isBeforeAMonth
? `Created ${this.createdAtTimeAgo}`
: `Created at: ${this.dateFormat(this.createdAtTimestamp)}`;
? `${this.$t('CHAT_LIST.CHAT_TIME_STAMP.CREATED.LATEST')} ${
this.createdAtTimeAgo
}`
: `${this.$t(
'CHAT_LIST.CHAT_TIME_STAMP.CREATED.OLDEST'
)} ${this.dateFormat(this.createdAtTimestamp)}`;
},
lastActivity() {
const lastActivityTimeDiff =
Date.now() - this.lastActivityTimestamp * 1000;
const isNotActive = lastActivityTimeDiff > DAY_IN_MILLI_SECONDS * 30;
return !isNotActive
? `Last activity ${this.lastActivityAtTimeAgo}`
: `Last activity: ${this.dateFormat(this.lastActivityTimestamp)}`;
? `${this.$t('CHAT_LIST.CHAT_TIME_STAMP.LAST_ACTIVITY.ACTIVE')} ${
this.lastActivityAtTimeAgo
}`
: `${this.$t(
'CHAT_LIST.CHAT_TIME_STAMP.LAST_ACTIVITY.NOT_ACTIVE'
)} ${this.dateFormat(this.lastActivityTimestamp)}`;
},
tooltipText() {
return `${this.createdAt}
@@ -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,24 @@
@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>
@@ -40,6 +58,7 @@ import {
suggestionsPlugin,
triggerCharacters,
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import TagAgents from '../conversation/TagAgents.vue';
import CannedResponse from '../conversation/CannedResponse.vue';
@@ -47,6 +66,10 @@ import VariableList from '../conversation/VariableList.vue';
import {
appendSignature,
removeSignature,
insertAtCursor,
scrollCursorIntoView,
findNodeToInsertImage,
setURLWithQueryAndSize,
} from 'dashboard/helper/editorHelper';
const TYPING_INDICATOR_IDLE_TIME = 4000;
@@ -66,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,
@@ -110,6 +135,7 @@ export default {
// allowSignature is a kill switch, ensuring no signature methods
// are triggered except when this flag is true
allowSignature: { type: Boolean, default: false },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
},
data() {
return {
@@ -122,9 +148,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);
},
@@ -272,6 +305,7 @@ export default {
const tr = this.editorView.state.tr.replaceSelectionWith(node);
this.editorView.focus();
this.state = this.editorView.state.apply(tr);
this.editorView.updateState(this.state);
this.emitOnChange();
this.$emit('clear-selection');
}
@@ -297,7 +331,17 @@ export default {
mounted() {
this.createEditorView();
this.editorView.updateState(this.state);
this.focusEditor(this.value);
this.focusEditorInputField();
// BUS Event to insert text or markdown into the editor at the
// current cursor position.
// Components using this
// 1. SearchPopover.vue
bus.$on(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.insertContentIntoEditor);
},
beforeDestroy() {
bus.$off(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.insertContentIntoEditor);
},
methods: {
reloadState(content = this.value) {
@@ -384,6 +428,7 @@ export default {
state: this.state,
dispatchTransaction: tx => {
this.state = this.state.apply(tx);
this.editorView.updateState(this.state);
this.emitOnChange();
},
handleDOMEvents: {
@@ -396,6 +441,9 @@ export default {
focus: () => {
this.onFocus();
},
click: () => {
// this.isEditorMouseFocusedOnAnImage(); Enable it when the backend supports for message resize is done.
},
blur: () => {
this.onBlur();
},
@@ -408,6 +456,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');
},
@@ -440,11 +532,7 @@ export default {
userFullName: mentionItem.name,
});
const tr = this.editorView.state.tr
.replaceWith(this.range.from, this.range.to, node)
.insertText(` `);
this.state = this.editorView.state.apply(tr);
this.emitOnChange();
this.insertNodeIntoEditor(node, this.range.from, this.range.to);
this.$track(CONVERSATION_EVENTS.USED_MENTIONS);
return false;
@@ -454,30 +542,22 @@ export default {
message: cannedItem,
variables: this.variables,
});
if (!this.editorView) {
return null;
}
let from = this.range.from - 1;
let node = new MessageMarkdownTransformer(messageSchema).parse(
updatedMessage
);
if (node.textContent === updatedMessage) {
node = this.editorView.state.schema.text(updatedMessage);
from = this.range.from;
}
const from =
node.textContent === updatedMessage
? this.range.from
: this.range.from - 1;
const tr = this.editorView.state.tr.replaceWith(
from,
this.range.to,
node
);
this.insertNodeIntoEditor(node, from, this.range.to);
this.state = this.editorView.state.apply(tr);
this.emitOnChange();
tr.scrollIntoView();
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
return false;
},
@@ -485,23 +565,14 @@ export default {
if (!this.editorView) {
return null;
}
let node = this.editorView.state.schema.text(`{{${variable}}}`);
const from = this.range.from;
const tr = this.editorView.state.tr.replaceWith(
from,
this.range.to,
node
);
const content = `{{${variable}}}`;
let node = this.editorView.state.schema.text(content);
const { from, to } = this.range;
this.state = this.editorView.state.apply(tr);
this.emitOnChange();
// The `{{ }}` are added to the message, but the cursor is placed
// and onExit of suggestionsPlugin is not called. So we need to manually hide
this.insertNodeIntoEditor(node, from, to);
this.showVariables = false;
this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
tr.scrollIntoView();
return false;
},
openFileBrowser() {
@@ -557,8 +628,6 @@ export default {
},
emitOnChange() {
this.editorView.updateState(this.state);
this.$emit('input', this.contentFromEditor);
},
@@ -601,6 +670,7 @@ export default {
() => this.resetTyping(),
TYPING_INDICATOR_IDLE_TIME
);
// this.updateImgToolbarOnDelete(); Enable it when the backend supports for message resize is done.
},
onKeydown(event) {
if (this.isEnterToSendEnabled()) {
@@ -618,6 +688,19 @@ export default {
onFocus() {
this.$emit('focus');
},
insertContentIntoEditor(content, defaultFrom = 0) {
const from = defaultFrom || this.editorView.state.selection.from || 0;
let node = new MessageMarkdownTransformer(messageSchema).parse(content);
this.insertNodeIntoEditor(node, from, undefined);
},
insertNodeIntoEditor(node, from = 0, to = 0) {
this.state = insertAtCursor(this.editorView, node, from, to);
this.emitOnChange();
this.$nextTick(() => {
scrollCursorIntoView(this.editorView);
});
},
},
};
</script>
@@ -711,6 +794,6 @@ export default {
}
.editor-warning__message {
@apply text-red-400 dark:text-red-400 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,9 +101,9 @@
<transition name="modal-fade">
<div
v-show="$refs.upload && $refs.upload.dropActive"
class="modal-mask"
class="flex items-center justify-center gap-2 fixed left-0 right-0 top-0 bottom-0 w-full h-full z-20 text-slate-600 dark:text-slate-200 bg-white_transparent dark:bg-black_transparent flex-col"
>
<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>
@@ -228,6 +229,10 @@ export default {
type: String,
default: '',
},
newConversationModalActive: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({
@@ -274,6 +279,9 @@ export default {
}
return ALLOWED_FILE_TYPES;
},
enableDragAndDrop() {
return !this.newConversationModalActive;
},
audioRecorderPlayStopIcon() {
switch (this.recordingAudioState) {
// playing paused recording stopped inactive destroyed
@@ -346,12 +354,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>
@@ -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 mt-0 mb-2 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] text-sm"
/>
<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);
@@ -34,6 +34,13 @@
:url="storyUrl"
/>
</blockquote>
<bubble-reply-to
v-if="inReplyToMessageId && inboxSupportsReplyTo"
:content="inReplyTo.content"
:attachments="inReplyTo.attachments"
:default-empty-message="$t('CONVERSATION.REPLY_MESSAGE_NOT_FOUND')"
@click="navigateToMessage"
/>
<bubble-text
v-if="data.content"
:message="message"
@@ -124,6 +131,7 @@
:message="data"
@open="openContextMenu"
@close="closeContextMenu"
@replyTo="handleReplyTo"
/>
</div>
</li>
@@ -140,6 +148,7 @@ import BubbleLocation from './bubble/Location.vue';
import BubbleMailHead from './bubble/MailHead.vue';
import BubbleText from './bubble/Text.vue';
import BubbleContact from './bubble/Contact.vue';
import BubbleReplyTo from './bubble/ReplyTo.vue';
import Spinner from 'shared/components/Spinner.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
import instagramImageErrorPlaceholder from './instagramImageErrorPlaceholder.vue';
@@ -149,6 +158,8 @@ 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: {
@@ -162,6 +173,7 @@ export default {
BubbleMailHead,
BubbleText,
BubbleContact,
BubbleReplyTo,
ContextMenu,
Spinner,
instagramImageErrorPlaceholder,
@@ -172,6 +184,10 @@ export default {
type: Object,
required: true,
},
currentChat: {
type: Object,
required: true,
},
isATweet: {
type: Boolean,
default: false,
@@ -188,6 +204,10 @@ export default {
type: Boolean,
default: false,
},
inboxSupportsReplyTo: {
type: Boolean,
default: false,
},
},
data() {
return {
@@ -264,11 +284,27 @@ export default {
) + botMessageContent
);
},
inReplyToMessageId() {
return this.data.content_attributes?.in_reply_to;
},
inReplyTo() {
if (!this.inReplyToMessageId) return null;
const replyTo = this.currentChat?.messages.find(message => {
if (message.id === this.inReplyToMessageId) {
return true;
}
return false;
});
return replyTo ?? {};
},
contextMenuEnabledOptions() {
return {
copy: this.hasText,
delete: this.hasText || this.hasAttachments,
cannedResponse: this.isOutgoing && this.hasText,
replyTo: !this.data.private && this.inboxSupportsReplyTo,
};
},
contentAttributes() {
@@ -318,6 +354,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,
};
@@ -494,6 +532,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;
@@ -505,6 +550,13 @@ export default {
this.showBackgroundHighlight = false;
}, HIGHLIGHT_TIMER);
},
navigateToMessage() {
this.$nextTick(() => {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
messageId: this.inReplyToMessageId,
});
});
},
},
};
</script>
@@ -613,6 +665,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;
}
@@ -0,0 +1,91 @@
<template>
<span
class="overflow-hidden text-slate-700 dark:text-slate-200 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>
{{ $t('CHAT_LIST.NO_CONTENT') }}
</span>
</span>
</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,
},
},
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>
@@ -0,0 +1,28 @@
<template>
<div
class="my-0 mx-4 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-slate-25 dark:hover:bg-slate-800 border border-dashed border-slate-100 dark:border-slate-700 rounded-sm overflow-auto"
>
<p class="w-fit !m-0">
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
<woot-button
color-scheme="primary"
variant="link"
@click="openProfileSettings"
>
{{ $t('CONVERSATION.FOOTER.CLICK_HERE') }}
</woot-button>
</p>
</div>
</template>
<script>
export default {
methods: {
openProfileSettings() {
return this.$router.push({ name: 'profile_settings_index' });
},
},
};
</script>
<style></style>
@@ -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,15 +7,6 @@
: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"
@@ -42,6 +33,8 @@
:is-a-whatsapp-channel="isAWhatsAppChannel"
:has-instagram-story="hasInstagramStory"
:is-web-widget-inbox="isAWebWidgetInbox"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-chat="currentChat"
/>
<li v-show="unreadMessageCount != 0" class="unread--toast">
<span>
@@ -63,6 +56,8 @@
:is-a-whatsapp-channel="isAWhatsAppChannel"
:has-instagram-story="hasInstagramStory"
:is-web-widget-inbox="isAWebWidgetInbox"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-chat="currentChat"
/>
<conversation-label-suggestion
v-if="shouldShowLabelSuggestions"
@@ -87,8 +82,6 @@
</div>
<reply-box
:conversation-id="currentChat.id"
:is-a-tweet="isATweet"
:selected-tweet="selectedTweet"
:popout-reply-box.sync="isPopoutReplyBox"
@click="showPopoutReplyBox"
/>
@@ -110,7 +103,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';
@@ -123,6 +116,7 @@ import { LocalStorage } from 'shared/helpers/localStorage';
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -153,7 +147,6 @@ export default {
isLoadingPrevious: true,
heightBeforeLoad: null,
conversationPanel: null,
selectedTweetId: null,
hasUserScrolled: false,
isProgrammaticScroll: false,
isPopoutReplyBox: false,
@@ -164,12 +157,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 +184,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'
@@ -257,17 +242,6 @@ export default {
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 +290,15 @@ export default {
unreadMessageCount() {
return this.currentChat.unread_count || 0;
},
inboxSupportsReplyTo() {
return (
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.MESSAGE_REPLY_TO
)
);
},
},
watch: {
@@ -326,7 +309,6 @@ export default {
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
this.messageSentSinceOpened = false;
this.selectedTweetId = null;
},
},
@@ -334,7 +316,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, () => {
@@ -405,10 +386,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,9 +508,6 @@ export default {
makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
},
removeTweetSelection() {
this.selectedTweetId = null;
},
},
};
</script>
@@ -19,6 +19,12 @@
@click="$emit('click')"
/>
<div class="reply-box__top">
<reply-to-message
v-if="shouldShowReplyToMessage"
:message="inReplyTo"
@dismiss="resetReplyToMessage"
@navigate-to-message="navigateToMessage"
/>
<canned-response
v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideMentions"
@@ -86,21 +92,14 @@
</div>
<div v-if="hasAttachments" class="attachment-preview-box" @paste="onPaste">
<attachment-preview
class="flex-col mt-4"
:attachments="attachedFiles"
:remove-attachment="removeAttachment"
/>
</div>
<div
<message-signature-missing-alert
v-if="isSignatureEnabledForInbox && !isSignatureAvailable"
class="message-signature-wrap"
>
<p class="message-signature">
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
<router-link :to="profilePath">
{{ $t('CONVERSATION.FOOTER.CLICK_HERE') }}
</router-link>
</p>
</div>
/>
<reply-bottom-panel
:conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload"
@@ -123,6 +122,7 @@
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker"
:message="message"
:new-conversation-modal-active="newConversationModalActive"
@selectWhatsappTemplate="openWhatsappTemplateModal"
@toggle-editor="toggleRichContentEditor"
@replace-text="replaceText"
@@ -149,11 +149,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 MessageSignatureMissingAlert from './MessageSignatureMissingAlert';
import Banner from 'dashboard/components/ui/Banner.vue';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
@@ -169,9 +171,8 @@ 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 { frontendURL } from '../../../helper/URLHelper';
import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
@@ -184,6 +185,10 @@ import {
replaceSignature,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
const EmojiInput = () => import('shared/components/emoji/EmojiInput');
@@ -191,6 +196,7 @@ export default {
components: {
EmojiInput,
CannedResponse,
ReplyToMessage,
ResizableTextArea,
AttachmentPreview,
ReplyTopPanel,
@@ -200,6 +206,7 @@ export default {
WootAudioRecorder,
Banner,
WhatsappTemplates,
MessageSignatureMissingAlert,
},
mixins: [
clickaway,
@@ -211,14 +218,6 @@ export default {
fileUploadMixin,
],
props: {
selectedTweet: {
type: [Object, String],
default: () => ({}),
},
isATweet: {
type: Boolean,
default: false,
},
popoutReplyBox: {
type: Boolean,
default: false,
@@ -227,6 +226,7 @@ export default {
data() {
return {
message: '',
inReplyTo: {},
isFocused: false,
showEmojiPicker: false,
attachedFiles: [],
@@ -248,6 +248,7 @@ export default {
showUserMentions: false,
showCannedMenu: false,
showVariablesMenu: false,
newConversationModalActive: false,
};
},
computed: {
@@ -258,7 +259,19 @@ 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) &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.MESSAGE_REPLY_TO
)
);
},
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
@@ -330,10 +343,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 (
@@ -366,11 +376,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() {
@@ -388,8 +393,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')
? '(⌘ + ↵)'
@@ -423,10 +426,6 @@ export default {
isOnPrivateNote() {
return this.replyType === REPLY_EDITOR_MODES.NOTE;
},
inReplyTo() {
const selectedTweet = this.selectedTweet || {};
return selectedTweet.id;
},
isOnExpandedLayout() {
const {
LAYOUT_TYPES: { CONDENSED },
@@ -444,15 +443,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;
@@ -480,9 +470,6 @@ export default {
const { send_with_signature: isEnabled } = this.uiSettings;
return isEnabled;
},
profilePath() {
return frontendURL(`accounts/${this.accountId}/profile/settings`);
},
editorMessageKey() {
const { editor_message_key: isEnabled } = this.uiSettings;
return isEnabled;
@@ -565,7 +552,7 @@ export default {
mounted() {
this.getFromDraft();
// Donot use the keyboard listener mixin here as the events here are supposed to be
// Don't use the keyboard listener mixin here as the events here are supposed to be
// working even if input/textarea is focussed.
document.addEventListener('paste', this.onPaste);
document.addEventListener('keydown', this.handleKeyEvents);
@@ -577,10 +564,28 @@ 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: {
toggleRichContentEditor() {
@@ -865,6 +870,7 @@ export default {
}
this.attachedFiles = [];
this.isRecordingAudio = false;
this.resetReplyToMessage();
},
clearEmailField() {
this.ccEmails = '';
@@ -996,8 +1002,10 @@ export default {
sender: this.sender,
};
if (this.inReplyTo) {
messagePayload.contentAttributes = { in_reply_to: this.inReplyTo };
if (this.inReplyTo?.id) {
messagePayload.contentAttributes = {
in_reply_to: this.inReplyTo.id,
};
}
if (this.attachedFiles && this.attachedFiles.length) {
@@ -1070,6 +1078,38 @@ 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);
},
navigateToMessage(messageId) {
this.$nextTick(() => {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
});
},
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;
},
},
};
</script>
@@ -1083,14 +1123,6 @@ export default {
@apply py-2;
}
.message-signature-wrap {
@apply my-0 mx-4 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-slate-25 dark:hover:bg-slate-800 border border-dashed border-slate-100 dark:border-slate-700 rounded-sm overflow-auto;
}
.message-signature {
@apply w-fit m-0;
}
.attachment-preview-box {
@apply bg-transparent py-0 px-4;
}
@@ -1136,13 +1168,6 @@ export default {
@apply left-1 -bottom-2;
}
}
.message-signature {
@apply mb-0;
::v-deep p:last-child {
@apply mb-0;
}
}
.normal-editor__canned-box {
width: calc(100% - 2 * var(--space-normal));
@@ -0,0 +1,45 @@
<template>
<div
class="reply-editor bg-slate-50 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2 cursor-pointer"
@click="$emit('navigate-to-message', messageId)"
>
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" icon-size="14" />
<div class="flex items-center flex-grow gap-1 text-xs">
{{ $t('CONVERSATION.REPLYBOX.REPLYING_TO') }}
<message-preview :message="message" :show-message-type="false" />
</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';
@@ -246,11 +234,6 @@ export default {
return false;
},
},
methods: {
onTweetReply() {
bus.$emit(BUS_EVENTS.SET_TWEET_REPLY, this.id);
},
},
};
</script>
@@ -0,0 +1,69 @@
<template>
<div
class="px-2 py-1.5 -mx-2 rounded-md bg-woot-600 min-w-[15rem] mb-2 cursor-pointer"
@click="$emit('click')"
>
<div v-if="content" class="line-clamp-3">
{{ cleanedContent }}
</div>
<div v-else-if="hasAttachments" class="line-clamp-3">
{{ attachmentLabel }}
</div>
<template v-else> {{ defaultEmptyMessage }} </template>
</div>
</template>
<script>
import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
export default {
name: 'ReplyTo',
props: {
content: {
type: String,
default: '',
},
attachments: {
type: Array,
default: () => [],
},
defaultEmptyMessage: {
type: String,
required: true,
},
},
computed: {
cleanedContent() {
return extractTextFromMarkdown(this.content);
},
hasAttachments() {
return this.attachments.length > 0;
},
attachmentLabel() {
if (!this.attachments.length) return null;
const firstFileName = this.attachments[0].data_url?.split('/').pop();
if (this.attachments.length > 1) {
if (firstFileName) {
return this.$t(
'CONVERSATION.REPLY_TO_ATTACHMENT.FILE_PLUS_MULTIPLE',
{
first: firstFileName,
count: (this.attachments.length - 1).toLocaleString(),
}
);
}
return this.$t('CONVERSATION.REPLY_TO_ATTACHMENT.COUNT', {
count: this.attachments.length.toLocaleString(),
});
}
return (
firstFileName ?? this.$t('CONVERSATION.REPLY_TO_ATTACHMENT.ATTACHMENT')
);
},
},
};
</script>
@@ -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',
MESSAGE_REPLY_TO: 'message_reply_to',
};
@@ -156,3 +156,128 @@ export function extractTextFromMarkdown(markdown) {
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
.trim(); // Trim any extra space
}
/**
* Scrolls the editor view into current cursor position
*
* @param {EditorView} view - The Prosemirror EditorView
*
*/
export const scrollCursorIntoView = view => {
// Get the current selection's head position (where the cursor is).
const pos = view.state.selection.head;
// Get the corresponding DOM node for that position.
const domAtPos = view.domAtPos(pos);
const node = domAtPos.node;
// Scroll the node into view.
if (node && node.scrollIntoView) {
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
};
/**
* Returns a transaction that inserts a node into editor at the given position
* Has an optional param 'content' to check if the
*
* @param {Node} node - The prosemirror node that needs to be inserted into the editor
* @param {number} from - Position in the editor where the node needs to be inserted
* @param {number} to - Position in the editor where the node needs to be replaced
*
*/
export function insertAtCursor(editorView, node, from, to) {
if (!editorView) {
return undefined;
}
// This is a workaround to prevent inserting content into new line rather than on the exiting line
// If the node is of type 'doc' and has only one child which is a paragraph,
// then extract its inline content to be inserted as inline.
const isWrappedInParagraph =
node.type.name === 'doc' &&
node.childCount === 1 &&
node.firstChild.type.name === 'paragraph';
if (isWrappedInParagraph) {
node = node.firstChild.content;
}
let tr;
if (to) {
tr = editorView.state.tr.replaceWith(from, to, node).insertText(` `);
} else {
tr = editorView.state.tr.insert(from, node);
}
const state = editorView.state.apply(tr);
editorView.updateState(state);
editorView.focus();
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,
};
};
@@ -5,7 +5,43 @@ import {
replaceSignature,
cleanSignature,
extractTextFromMarkdown,
insertAtCursor,
findNodeToInsertImage,
setURLWithQueryAndSize,
} from '../editorHelper';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { Schema } from 'prosemirror-model';
// Define a basic ProseMirror schema
const schema = new Schema({
nodes: {
doc: { content: 'paragraph+' },
paragraph: {
content: 'text*',
toDOM: () => ['p', 0], // Represents a paragraph as a <p> tag in the DOM.
},
text: {
toDOM: node => node.text, // Represents text as its actual string value.
},
},
});
// Initialize a basic EditorState for testing
const createEditorState = (content = '') => {
if (!content) {
return EditorState.create({
schema,
doc: schema.node('doc', null, [schema.node('paragraph')]),
});
}
return EditorState.create({
schema,
doc: schema.node('doc', null, [
schema.node('paragraph', null, [schema.text(content)]),
]),
});
};
const NEW_SIGNATURE = 'This is a new signature';
@@ -198,3 +234,208 @@ describe('extractTextFromMarkdown', () => {
expect(extractTextFromMarkdown(markdown)).toEqual(expected);
});
});
describe('insertAtCursor', () => {
it('should return undefined if editorView is not provided', () => {
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
expect(result).toBeUndefined();
});
it('should unwrap doc nodes that are wrapped in a paragraph', () => {
const docNode = schema.node('doc', null, [
schema.node('paragraph', null, [schema.text('Hello')]),
]);
const editorState = createEditorState();
const editorView = new EditorView(document.body, { state: editorState });
insertAtCursor(editorView, docNode, 0);
// Check if node was unwrapped and inserted correctly
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
});
it('should insert node without replacing any content if "to" is not provided', () => {
const editorState = createEditorState();
const editorView = new EditorView(document.body, { state: editorState });
insertAtCursor(editorView, schema.text('Hello'), 0);
// Check if node was inserted correctly
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
});
it('should replace content between "from" and "to" with the provided node', () => {
const editorState = createEditorState('ReplaceMe');
const editorView = new EditorView(document.body, { state: editorState });
insertAtCursor(editorView, schema.text('Hello'), 0, 8);
// Check if content was replaced correctly
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 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": "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,12 @@
"NO_RESPONSE": "No response",
"RATING_TITLE": "Rating",
"FEEDBACK_TITLE": "Feedback",
"REPLY_MESSAGE_NOT_FOUND": "Not available, click to scroll to the message",
"REPLY_TO_ATTACHMENT": {
"FILE_PLUS_MULTIPLE": "%{first} and %{count} others",
"COUNT": "%{count} files",
"ATTACHMENT": "Attachment"
},
"CARD": {
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels"
@@ -139,7 +144,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 +200,7 @@
},
"CONTEXT_MENU": {
"COPY": "Copy",
"REPLY_TO": "Reply to this message",
"DELETE": "Delete",
"CREATE_A_CANNED_RESPONSE": "Add to canned responses",
"TRANSLATE": "Translate",
@@ -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."
}
}
}
}
}
@@ -238,7 +238,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",
@@ -7,7 +7,7 @@ export default {
case 'send_email_to_team':
return this.teams;
case 'assign_agent':
return this.agents;
return [{ id: 'self', name: 'Self' }, ...this.agents];
case 'add_label':
case 'remove_label':
return this.labels.map(i => {
@@ -37,7 +37,10 @@ describe('webhookMixin', () => {
expect(wrapper.vm.getDropdownValues('assign_team')).toEqual(teams);
expect(wrapper.vm.getDropdownValues('send_email_to_team')).toEqual(teams);
expect(wrapper.vm.getDropdownValues('add_label')).toEqual(resolvedLabels);
expect(wrapper.vm.getDropdownValues('assign_agent')).toEqual(agents);
expect(wrapper.vm.getDropdownValues('assign_agent')).toEqual([
{ id: 'self', name: 'Self' },
...agents,
]);
expect(wrapper.vm.getDropdownValues('change_priority')).toEqual(
PRIORITY_CONDITION_VALUES
);
@@ -73,12 +73,12 @@ export default {
this.isSearching = false;
}
},
async onMergeContacts(childContactId) {
async onMergeContacts(parentContactId) {
this.$track(CONTACTS_EVENTS.MERGED_CONTACTS);
try {
await this.$store.dispatch('contacts/merge', {
childId: childContactId,
parentId: this.primaryContact.id,
childId: this.primaryContact.id,
parentId: parentContactId,
});
this.showAlert(this.$t('MERGE_CONTACTS.FORM.SUCCESS_MESSAGE'));
this.onClose();
@@ -1,62 +1,29 @@
<template>
<form @submit.prevent="onSubmit">
<div class="merge-contacts">
<div class="multiselect-wrap--medium">
<label class="multiselect__label">
{{ $t('MERGE_CONTACTS.PRIMARY.TITLE') }}
<woot-label
:title="$t('MERGE_CONTACTS.PRIMARY.HELP_LABEL')"
color-scheme="success"
small
class="label--merge-warning"
/>
</label>
<multiselect
:value="primaryContact"
disabled
:options="[]"
:show-labels="false"
label="name"
track-by="id"
>
<template slot="singleLabel" slot-scope="props">
<contact-dropdown-item
:thumbnail="props.option.thumbnail"
:name="props.option.name"
:identifier="props.option.id"
:email="props.option.email"
:phone-number="props.option.phoneNumber"
/>
</template>
</multiselect>
</div>
<div class="child-contact-wrap">
<div class="child-arrow">
<fluent-icon icon="arrow-up" class="up" size="17" />
</div>
<div>
<div>
<div
class="child-contact multiselect-wrap--medium"
:class="{ error: $v.childContact.$error }"
class="mt-1 multiselect-wrap--medium"
:class="{ error: $v.parentContact.$error }"
>
<label class="multiselect__label">
{{ $t('MERGE_CONTACTS.CHILD.TITLE')
}}<woot-label
:title="$t('MERGE_CONTACTS.CHILD.HELP_LABEL')"
color-scheme="alert"
{{ $t('MERGE_CONTACTS.PARENT.TITLE') }}
<woot-label
:title="$t('MERGE_CONTACTS.PARENT.HELP_LABEL')"
color-scheme="success"
small
class="label--merge-warning"
class="ml-2"
/>
</label>
<multiselect
v-model="childContact"
v-model="parentContact"
:options="searchResults"
label="name"
track-by="id"
:internal-search="false"
:clear-on-select="false"
:show-labels="false"
:placeholder="$t('MERGE_CONTACTS.CHILD.PLACEHOLDER')"
:placeholder="$t('MERGE_CONTACTS.PARENT.PLACEHOLDER')"
:allow-empty="true"
:loading="isSearching"
:max-height="150"
@@ -85,17 +52,57 @@
{{ $t('AGENT_MGMT.SEARCH.NO_RESULTS') }}
</span>
</multiselect>
<span v-if="$v.childContact.$error" class="message">
<span v-if="$v.parentContact.$error" class="message">
{{ $t('MERGE_CONTACTS.FORM.CHILD_CONTACT.ERROR') }}
</span>
</div>
</div>
<div class="multiselect-wrap--medium flex">
<div
class="w-8 relative text-base text-slate-100 dark:text-slate-600 after:content-[''] after:h-12 after:w-0 after:left-4 after:absolute after:border-l after:border-solid after:border-slate-100 after:dark:border-slate-600 before:content-[''] before:h-0 before:w-4 before:left-4 before:top-12 before:absolute before:border-b before:border-solid before:border-slate-100 before:dark:border-slate-600"
>
<fluent-icon
icon="arrow-up"
class="absolute -top-1 left-2"
size="17"
/>
</div>
<div class="flex flex-col w-full">
<label class="multiselect__label">
{{ $t('MERGE_CONTACTS.PRIMARY.TITLE') }}
<woot-label
:title="$t('MERGE_CONTACTS.PRIMARY.HELP_LABEL')"
color-scheme="alert"
small
class="ml-2"
/>
</label>
<multiselect
:value="primaryContact"
disabled
:options="[]"
:show-labels="false"
label="name"
track-by="id"
>
<template slot="singleLabel" slot-scope="props">
<contact-dropdown-item
:thumbnail="props.option.thumbnail"
:name="props.option.name"
:identifier="props.option.id"
:email="props.option.email"
:phone-number="props.option.phoneNumber"
/>
</template>
</multiselect>
</div>
</div>
</div>
<merge-contact-summary
:primary-contact-name="primaryContact.name"
:child-contact-name="childContactName"
:parent-contact-name="parentContactName"
/>
<div class="footer">
<div class="mt-6 flex justify-end">
<woot-button variant="clear" @click.prevent="onCancel">
{{ $t('MERGE_CONTACTS.FORM.CANCEL') }}
</woot-button>
@@ -138,19 +145,19 @@ export default {
primaryContact: {
required,
},
childContact: {
parentContact: {
required,
},
},
data() {
return {
childContact: undefined,
parentContact: undefined,
};
},
computed: {
childContactName() {
return this.childContact ? this.childContact.name : '';
parentContactName() {
return this.parentContact ? this.parentContact.name : '';
},
},
methods: {
@@ -162,7 +169,7 @@ export default {
if (this.$v.$invalid) {
return;
}
this.$emit('submit', this.childContact.id);
this.$emit('submit', this.parentContact.id);
},
onCancel() {
this.$emit('cancel');
@@ -172,51 +179,24 @@ export default {
</script>
<style lang="scss" scoped>
.child-contact-wrap {
@apply flex w-full;
}
.child-contact {
@apply min-w-0 flex-grow flex-shrink-0;
}
.child-arrow {
@apply w-8 relative text-base text-slate-100 dark:text-slate-600;
}
.multiselect {
@apply mb-2;
}
.child-contact {
@apply mt-1;
}
.child-arrow::after {
@apply content-[''] h-12 w-0 left-5 absolute border-l border-solid border-slate-100 dark:border-slate-600;
}
.child-arrow::before {
@apply content-[''] h-0 w-4 left-5 top-12 absolute border-b border-solid border-slate-100 dark:border-slate-600;
}
.up {
@apply absolute -top-1 left-3;
}
.footer {
@apply mt-6 flex justify-end;
}
/* TDOD: Clean errors in forms style */
.error .message {
@apply mt-0;
}
.label--merge-warning {
@apply ml-2;
}
::v-deep {
.multiselect {
@apply rounded-md;
}
.multiselect--disabled {
@apply border-0;
.multiselect__tags {
@apply border;
}
}
.multiselect__tags {
@apply h-[52px];
}
@@ -1,26 +1,29 @@
<template>
<div v-if="childContactName" class="merge-summary callout">
<h5 class="text-block-title">
<div
v-if="parentContactName"
class="my-4 relative p-2.5 border rounded-[4px] text-slate-800 dark:text-slate-100 border-slate-100 dark:border-slate-700 bg-white dark:bg-slate-800"
>
<h5 class="text-base font-medium text-slate-900 dark:text-white">
{{ $t('MERGE_CONTACTS.SUMMARY.TITLE') }}
</h5>
<ul class="summary-items">
<ul class="ml-0 list-none">
<li>
<span class="bullet">❌</span>
<span class="inline-block mr-1">❌</span>
<span
v-dompurify-html="
$t('MERGE_CONTACTS.SUMMARY.DELETE_WARNING', {
childContactName,
primaryContactName,
})
"
/>
</li>
<li>
<span class="bullet">✅</span>
<span class="inline-block mr-1">✅</span>
<span
v-dompurify-html="
$t('MERGE_CONTACTS.SUMMARY.ATTRIBUTE_WARNING', {
childContactName,
primaryContactName,
parentContactName,
})
"
/>
@@ -36,32 +39,10 @@ export default {
type: String,
default: '',
},
childContactName: {
parentContactName: {
type: String,
default: '',
},
},
computed: {},
};
</script>
<style lang="scss" scoped>
.merge-summary {
margin-top: var(--space-normal);
}
.summary-items {
margin-left: 0;
list-style: none;
li {
margin-bottom: var(--space-smaller);
}
}
.bullet {
display: inline-block;
margin-right: var(--space-smaller);
}
</style>
@@ -44,6 +44,15 @@
@close="handleClose"
>
<div class="menu-container">
<menu-item
v-if="enabledOptions['replyTo']"
:option="{
icon: 'arrow-reply',
label: $t('CONVERSATION.CONTEXT_MENU.REPLY_TO'),
}"
variant="icon"
@click="handleReplyTo"
/>
<menu-item
v-if="enabledOptions['copy']"
:option="{
@@ -204,10 +213,13 @@ export default {
this.handleClose();
this.showTranslateModal = true;
},
handleReplyTo() {
this.$emit('replyTo', this.message);
this.handleClose();
},
onCloseTranslateModal() {
this.showTranslateModal = false;
},
openDeleteModal() {
this.handleClose();
this.showDeleteModal = true;
@@ -177,6 +177,7 @@ import alertMixin from 'shared/mixins/alertMixin';
import adminMixin from '../../../../mixins/isAdmin';
import { mapGetters } from 'vuex';
import { getCountryFlag } from 'dashboard/helper/flag';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
isAConversationRoute,
getConversationDashboardRoute,
@@ -268,6 +269,7 @@ export default {
},
toggleConversationModal() {
this.showConversationModal = !this.showConversationModal;
bus.$emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, this.showConversationModal);
},
toggleDeleteModal() {
this.showDeleteModal = !this.showDeleteModal;
@@ -11,7 +11,10 @@
<label>
{{ $t('NEW_CONVERSATION.FORM.INBOX.LABEL') }}
</label>
<div class="multiselect-wrap--small">
<div
class="multiselect-wrap--small"
:class="{ 'has-multi-select-error': $v.targetInbox.$error }"
>
<multiselect
v-model="targetInbox"
track-by="id"
@@ -99,26 +102,46 @@
<div v-if="isEmailOrWebWidgetInbox">
<label>
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.LABEL') }}
<reply-email-head
v-if="isAnEmailInbox"
:cc-emails.sync="ccEmails"
:bcc-emails.sync="bccEmails"
/>
<label class="editor-wrap">
<woot-message-editor
v-model="message"
class="message-editor"
:class="{ editor_warning: $v.message.$error }"
:enable-variables="true"
:placeholder="$t('NEW_CONVERSATION.FORM.MESSAGE.PLACEHOLDER')"
@toggle-canned-menu="toggleCannedMenu"
@blur="$v.message.$touch"
/>
<span v-if="$v.message.$error" class="editor-warning__message">
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.ERROR') }}
</span>
</label>
</label>
<reply-email-head
v-if="isAnEmailInbox"
:cc-emails.sync="ccEmails"
:bcc-emails.sync="bccEmails"
/>
<div class="editor-wrap">
<woot-message-editor
v-model="message"
class="message-editor"
:class="{ editor_warning: $v.message.$error }"
:enable-variables="true"
:signature="signatureToApply"
:allow-signature="true"
:placeholder="$t('NEW_CONVERSATION.FORM.MESSAGE.PLACEHOLDER')"
@toggle-canned-menu="toggleCannedMenu"
@blur="$v.message.$touch"
>
<template #footer>
<message-signature-missing-alert
v-if="isSignatureEnabledForInbox && !messageSignature"
class="!mx-0 mb-1"
/>
<div v-if="isAnEmailInbox" class="mb-3 mt-px">
<woot-button
v-tooltip.top-end="signatureToggleTooltip"
icon="signature"
color-scheme="secondary"
variant="smooth"
size="small"
:title="signatureToggleTooltip"
@click.prevent="toggleMessageSignature"
/>
</div>
</template>
</woot-message-editor>
<span v-if="$v.message.$error" class="editor-warning__message">
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.ERROR') }}
</span>
</div>
</div>
<whatsapp-templates
v-else-if="hasWhatsappTemplates"
@@ -139,9 +162,52 @@
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.ERROR') }}
</span>
</label>
<div v-if="isEmailOrWebWidgetInbox" class="flex flex-col">
<file-upload
ref="uploadAttachment"
input-id="newConversationAttachment"
:size="4096 * 4096"
:accept="allowedFileTypes"
:multiple="true"
:drop="true"
:drop-directory="false"
:data="{
direct_upload_url: '/rails/active_storage/direct_uploads',
direct_upload: true,
}"
@input-file="onFileUpload"
>
<woot-button
class-names="button--upload"
icon="attach"
emoji="📎"
color-scheme="secondary"
variant="smooth"
size="small"
>
{{ $t('NEW_CONVERSATION.FORM.ATTACHMENTS.SELECT') }}
</woot-button>
<span
class="text-slate-500 ltr:ml-1 rtl:mr-1 font-medium text-xs dark:text-slate-400"
>
{{ $t('NEW_CONVERSATION.FORM.ATTACHMENTS.HELP_TEXT') }}
</span>
</file-upload>
<div
v-if="hasAttachments"
class="max-h-20 overflow-y-auto mb-4 mt-1.5"
>
<attachment-preview
class="[&>.preview-item]:dark:bg-slate-700 flex-row flex-wrap gap-x-3 gap-y-1"
:attachments="attachedFiles"
:remove-attachment="removeAttachment"
/>
</div>
</div>
</div>
</div>
</div>
<div
v-if="!hasWhatsappTemplates"
class="flex flex-row justify-end gap-2 py-2 px-0 w-full"
@@ -153,6 +219,18 @@
{{ $t('NEW_CONVERSATION.FORM.SUBMIT') }}
</woot-button>
</div>
<transition v-if="isEmailOrWebWidgetInbox" name="modal-fade">
<div
v-show="$refs.uploadAttachment && $refs.uploadAttachment.dropActive"
class="flex top-0 bottom-0 z-30 gap-2 right-0 left-0 items-center justify-center flex-col absolute w-full h-full bg-white/80 dark:bg-slate-700/80"
>
<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>
</form>
</template>
@@ -162,6 +240,7 @@ import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import ReplyEmailHead from 'dashboard/components/widgets/conversation/ReplyEmailHead.vue';
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
import MessageSignatureMissingAlert from 'dashboard/components/widgets/conversation/MessageSignatureMissingAlert';
import InboxDropdownItem from 'dashboard/components/widgets/InboxDropdownItem.vue';
import WhatsappTemplates from './WhatsappTemplates.vue';
import alertMixin from 'shared/mixins/alertMixin';
@@ -169,6 +248,16 @@ import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { getInboxSource } from 'dashboard/helper/inbox';
import { required, requiredIf } from 'vuelidate/lib/validators';
import inboxMixin from 'shared/mixins/inboxMixin';
import FileUpload from 'vue-upload-component';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
appendSignature,
removeSignature,
} from 'dashboard/helper/editorHelper';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
export default {
components: {
@@ -178,8 +267,11 @@ export default {
CannedResponse,
WhatsappTemplates,
InboxDropdownItem,
FileUpload,
AttachmentPreview,
MessageSignatureMissingAlert,
},
mixins: [alertMixin],
mixins: [alertMixin, uiSettingsMixin, inboxMixin, fileUploadMixin],
props: {
contact: {
type: Object,
@@ -201,6 +293,7 @@ export default {
ccEmails: '',
targetInbox: {},
whatsappTemplateSelected: false,
attachedFiles: [],
};
},
validations: {
@@ -219,8 +312,17 @@ export default {
uiFlags: 'contacts/getUIFlags',
conversationsUiFlags: 'contactConversations/getUIFlags',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
messageSignature: 'getMessageSignature',
}),
emailMessagePayload() {
sendWithSignature() {
const { send_with_signature: isEnabled } = this.uiSettings;
return isEnabled;
},
signatureToApply() {
return this.messageSignature;
},
newMessagePayload() {
const payload = {
inboxId: this.targetInbox.id,
sourceId: this.targetInbox.sourceId,
@@ -229,6 +331,12 @@ export default {
mailSubject: this.subject,
assigneeId: this.currentUser.id,
};
if (this.attachedFiles && this.attachedFiles.length) {
payload.files = [];
this.setAttachmentPayload(payload);
}
if (this.ccEmails) {
payload.message.cc_emails = this.ccEmails;
}
@@ -259,6 +367,14 @@ export default {
}
return this.inboxes.length === 0 && !this.uiFlags.isFetchingInboxes;
},
isSignatureEnabledForInbox() {
return this.isAnEmailInbox && this.sendWithSignature;
},
signatureToggleTooltip() {
return this.sendWithSignature
? this.$t('CONVERSATION.FOOTER.DISABLE_SIGN_TOOLTIP')
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
},
inboxes() {
const inboxList = this.contact.contactableInboxes || [];
return inboxList.map(inbox => ({
@@ -284,6 +400,15 @@ export default {
hasWhatsappTemplates() {
return !!this.selectedInbox.inbox?.message_templates;
},
hasAttachments() {
return this.attachedFiles.length;
},
inbox() {
return this.targetInbox;
},
allowedFileTypes() {
return ALLOWED_FILE_TYPES;
},
},
watch: {
message(value) {
@@ -298,8 +423,50 @@ export default {
this.showCannedResponseMenu = false;
}
},
targetInbox() {
this.setSignature();
},
},
mounted() {
this.setSignature();
},
methods: {
setSignature() {
if (this.messageSignature) {
if (this.isSignatureEnabledForInbox) {
this.message = appendSignature(this.message, this.signatureToApply);
} else {
this.message = removeSignature(this.message, this.signatureToApply);
}
}
},
setAttachmentPayload(payload) {
this.attachedFiles.forEach(attachment => {
if (this.globalConfig.directUploadsEnabled) {
payload.files.push(attachment.blobSignedId);
} else {
payload.files.push(attachment.resource.file);
}
});
},
attachFile({ blob, file }) {
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
this.attachedFiles.push({
currentChatId: this.contact.id,
resource: blob || file,
isPrivate: this.isPrivate,
thumb: reader.result,
blobSignedId: blob ? blob.signed_id : undefined,
});
};
},
removeAttachment(itemIndex) {
this.attachedFiles = this.attachedFiles.filter(
(item, index) => itemIndex !== index
);
},
onCancel() {
this.$emit('cancel');
},
@@ -323,15 +490,19 @@ export default {
return payload;
},
onFormSubmit() {
const isFromWhatsApp = false;
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
this.createConversation(this.emailMessagePayload);
this.createConversation({
payload: this.newMessagePayload,
isFromWhatsApp,
});
},
async createConversation(payload) {
async createConversation({ payload, isFromWhatsApp }) {
try {
const data = await this.onSubmit(payload);
const data = await this.onSubmit(payload, isFromWhatsApp);
const action = {
type: 'link',
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
@@ -355,8 +526,9 @@ export default {
this.whatsappTemplateSelected = val;
},
async onSendWhatsAppReply(messagePayload) {
const isFromWhatsApp = true;
const payload = this.prepareWhatsAppMessagePayload(messagePayload);
await this.createConversation(payload);
await this.createConversation({ payload, isFromWhatsApp });
},
inboxReadableIdentifier(inbox) {
return `${inbox.name} (${inbox.channel_type})`;
@@ -370,6 +542,12 @@ export default {
);
return classByType;
},
toggleMessageSignature() {
this.updateUISettings({
send_with_signature: !this.sendWithSignature,
});
this.setSignature();
},
},
};
</script>
@@ -389,6 +567,17 @@ export default {
}
}
.file-uploads {
@apply text-start;
}
.multiselect-wrap--small.has-multi-select-error {
::v-deep {
.multiselect__tags {
@apply border-red-500;
}
}
}
::v-deep {
.mention--box {
@apply left-0 m-auto right-0 top-auto h-fit;
@@ -49,11 +49,11 @@ export default {
onSuccess() {
this.$emit('cancel');
},
async onSubmit(contactItem) {
const data = await this.$store.dispatch(
'contactConversations/create',
contactItem
);
async onSubmit(params, isFromWhatsApp) {
const data = await this.$store.dispatch('contactConversations/create', {
params,
isFromWhatsApp,
});
return data;
},
},
@@ -19,6 +19,7 @@
@open-modal="onClickOpenAddCategoryModal"
/>
<section
v-if="isHelpCenterEnabled"
class="flex h-full min-h-0 overflow-hidden flex-1 px-0 bg-white dark:bg-slate-900"
>
<router-view />
@@ -53,11 +54,12 @@
@cancel="onClickCloseAddCategoryModal"
/>
</section>
<upgrade-page v-else />
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import UpgradePage from './UpgradePage';
import { frontendURL } from '../../../../helper/URLHelper';
import Sidebar from 'dashboard/components/layout/Sidebar.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
@@ -70,17 +72,19 @@ import NotificationPanel from 'dashboard/routes/dashboard/notifications/componen
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import portalMixin from '../mixins/portalMixin';
import AddCategory from '../pages/categories/AddCategory.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
export default {
components: {
Sidebar,
HelpCenterSidebar,
AccountSelector,
AddCategory,
CommandBar,
WootKeyShortcutModal,
HelpCenterSidebar,
NotificationPanel,
PortalPopover,
AddCategory,
AccountSelector,
Sidebar,
UpgradePage,
WootKeyShortcutModal,
},
mixins: [portalMixin, uiSettingsMixin],
data() {
@@ -102,13 +106,25 @@ export default {
categories: 'categories/allCategories',
meta: 'portals/getMeta',
isFetching: 'portals/isFetchingPortals',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
isHelpCenterEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.HELP_CENTER
);
},
isSidebarOpen() {
const { show_help_center_secondary_sidebar: showSecondarySidebar } =
this.uiSettings;
return showSecondarySidebar;
},
showHelpCenterSidebar() {
if (!this.isHelpCenterEnabled) {
return false;
}
return this.portals.length === 0 ? false : this.isSidebarOpen;
},
selectedPortal() {
@@ -0,0 +1,135 @@
<template>
<div
class="flex flex-col gap-12 sm:gap-16 items-center justify-center py-0 px-4 md:px-0 w-full h-full max-w-full overflow-auto bg-white dark:bg-slate-900"
>
<div class="flex flex-col justify-start sm:justify-center gap-6">
<div class="flex flex-col gap-1.5 items-start sm:items-center">
<h1
class="text-slate-900 dark:text-white text-left sm:text-center text-4xl sm:text-5xl mb-6 font-semibold"
>
{{ $t('HELP_CENTER.UPGRADE_PAGE.TITLE') }}
</h1>
<p
class="max-w-2xl text-base font-normal leading-6 text-left sm:text-center text-slate-700 dark:text-slate-200"
>
{{
isOnChatwootCloud
? $t('HELP_CENTER.UPGRADE_PAGE.DESCRIPTION')
: $t('HELP_CENTER.UPGRADE_PAGE.SELF_HOSTED_DESCRIPTION')
}}
</p>
</div>
<div
v-if="isOnChatwootCloud"
class="flex flex-row gap-3 justify-start items-center sm:justify-center"
>
<woot-button
size="medium"
variant="hollow"
color-scheme="primary"
@click="openHelpCenterDocs"
>
{{ $t('HELP_CENTER.UPGRADE_PAGE.BUTTON.LEARN_MORE') }}
</woot-button>
<woot-button
size="medium"
color-scheme="primary"
@click="openBillingPage"
>
{{ $t('HELP_CENTER.UPGRADE_PAGE.BUTTON.UPGRADE') }}
</woot-button>
</div>
</div>
<div
class="grid grid-cols-1 sm:grid-cols-2 gap-6 sm:gap-16 w-full max-w-2xl overflow-auto"
>
<div
v-for="feature in upgradeFeature"
:key="feature.key"
class="w-64 min-w-full"
>
<div class="flex gap-2 flex-row">
<div>
<fluent-icon
:icon="feature.icon"
icon-lib="lucide"
:size="26"
class="mt-px text-slate-800 dark:text-slate-25"
/>
</div>
<div>
<h5 class="font-semibold text-lg text-slate-800 dark:text-slate-25">
{{ feature.title }}
</h5>
<p class="text-sm leading-6 text-slate-700 dark:text-slate-100">
{{ feature.description }}
</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import wootConstants from 'dashboard/constants/globals';
export default {
data() {
return {
helpCenterDocsURL: wootConstants.HELP_CENTER_DOCS_URL,
upgradeFeature: [
{
key: 1,
title: this.$t('HELP_CENTER.UPGRADE_PAGE.FEATURES.PORTALS.TITLE'),
icon: 'book-copy',
description: this.$t(
'HELP_CENTER.UPGRADE_PAGE.FEATURES.PORTALS.DESCRIPTION'
),
},
{
key: 2,
title: this.$t('HELP_CENTER.UPGRADE_PAGE.FEATURES.LOCALES.TITLE'),
icon: 'globe-line',
description: this.$t(
'HELP_CENTER.UPGRADE_PAGE.FEATURES.LOCALES.DESCRIPTION'
),
},
{
key: 3,
title: this.$t('HELP_CENTER.UPGRADE_PAGE.FEATURES.SEO.TITLE'),
icon: 'heart-handshake',
description: this.$t(
'HELP_CENTER.UPGRADE_PAGE.FEATURES.SEO.DESCRIPTION'
),
},
{
key: 4,
title: this.$t('HELP_CENTER.UPGRADE_PAGE.FEATURES.API.TITLE'),
icon: 'search-check',
description: this.$t(
'HELP_CENTER.UPGRADE_PAGE.FEATURES.API.DESCRIPTION'
),
},
],
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud', // Pending change text
}),
},
methods: {
openBillingPage() {
this.$router.push({
name: 'billing_settings_index',
params: { accountId: this.accountId },
});
},
openHelpCenterDocs() {
window.open(this.helpCenterDocsURL, '_blank');
},
},
};
</script>
@@ -220,14 +220,11 @@ export default {
},
},
mounted() {
if (!this.id) {
this.initializeAccount();
}
this.initializeAccount();
},
methods: {
async initializeAccount() {
try {
await this.$store.dispatch('accounts/get');
const {
name,
locale,
@@ -72,8 +72,6 @@ export default {
},
methods: {
async fetchAccountDetails() {
await this.$store.dispatch('accounts/get');
if (!this.hasABillingPlan) {
this.$store.dispatch('accounts/subscription');
}
@@ -16,19 +16,23 @@
@blur="$v.title.$touch"
/>
<label v-if="isOngoingType" class="editor-wrap">
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.LABEL') }}
<woot-message-editor
v-model="message"
class="message-editor"
:class="{ editor_warning: $v.message.$error }"
:placeholder="$t('CAMPAIGN.ADD.FORM.MESSAGE.PLACEHOLDER')"
@blur="$v.message.$touch"
/>
<span v-if="$v.message.$error" class="editor-warning__message">
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.ERROR') }}
</span>
</label>
<div v-if="isOngoingType" class="editor-wrap">
<label>
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.LABEL') }}
</label>
<div>
<woot-message-editor
v-model="message"
class="message-editor"
:class="{ editor_warning: $v.message.$error }"
:placeholder="$t('CAMPAIGN.ADD.FORM.MESSAGE.PLACEHOLDER')"
@blur="$v.message.$touch"
/>
<span v-if="$v.message.$error" class="editor-warning__message">
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.ERROR') }}
</span>
</div>
</div>
<label v-else :class="{ error: $v.message.$error }">
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.LABEL') }}
@@ -12,8 +12,10 @@
:placeholder="$t('CAMPAIGN.ADD.FORM.TITLE.PLACEHOLDER')"
@blur="$v.title.$touch"
/>
<label class="editor-wrap">
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.LABEL') }}
<div class="editor-wrap">
<label>
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.LABEL') }}
</label>
<woot-message-editor
v-model="message"
class="message-editor"
@@ -25,7 +27,7 @@
<span v-if="$v.message.$error" class="editor-warning__message">
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.ERROR') }}
</span>
</label>
</div>
<label :class="{ error: $v.selectedInbox.$error }">
{{ $t('CAMPAIGN.ADD.FORM.INBOX.LABEL') }}
@@ -70,7 +70,6 @@ export default {
},
methods: {
async initializeEnabledFeatures() {
await this.$store.dispatch('accounts/get', this.accountId);
this.enabledFeatures = this.account.features;
},
initChannelAuth(channel) {
@@ -16,7 +16,7 @@
</select>
</label>
<div v-if="preChatFormEnabled">
<label class="w-[70%]">
<div class="w-[70%]">
<label>
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.PRE_CHAT_MESSAGE.LABEL') }}
</label>
@@ -27,7 +27,7 @@
$t('INBOX_MGMT.PRE_CHAT_FORM.PRE_CHAT_MESSAGE.PLACEHOLDER')
"
/>
</label>
</div>
<div class="w-[70%] mt-4">
<label>{{ $t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS') }}</label>
<table class="table table-striped w-full">
@@ -1,6 +1,6 @@
<template>
<div
class="settings bg-white dark:bg-slate-800 flex-grow flex-shrink pr-0 pl-0 overflow-auto min-w-0 w-full"
class="flex-grow flex-shrink w-full min-w-0 pl-0 pr-0 overflow-auto bg-white settings dark:bg-slate-800"
>
<setting-intro-banner
:header-image="inbox.avatarUrl"
@@ -48,18 +48,6 @@
"
@blur="$v.selectedInboxName.$touch"
/>
<label
v-if="isATwitterInbox"
for="toggle-business-hours"
class="toggle-input-wrap"
>
<input
v-model="tweetsEnabled"
type="checkbox"
name="toggle-business-hours"
/>
{{ $t('INBOX_MGMT.ADD.TWITTER.TWEETS.ENABLE') }}
</label>
<woot-input
v-if="isAPIInbox"
v-model.trim="webhookUrl"
@@ -145,7 +133,7 @@
}}
</option>
</select>
<p class="text-slate-600 dark:text-slate-400 pb-1 text-sm not-italic">
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
{{
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_TOGGLE.HELP_TEXT'
@@ -187,7 +175,7 @@
</option>
</select>
<p class="text-slate-600 dark:text-slate-400 pb-1 text-sm not-italic">
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.REPLY_TIME.HELP_TEXT') }}
</p>
</label>
@@ -202,7 +190,7 @@
{{ $t('INBOX_MGMT.EDIT.EMAIL_COLLECT_BOX.DISABLED') }}
</option>
</select>
<p class="text-slate-600 dark:text-slate-400 pb-1 text-sm not-italic">
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
{{
$t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT')
}}
@@ -219,7 +207,7 @@
{{ $t('INBOX_MGMT.EDIT.ENABLE_CSAT.DISABLED') }}
</option>
</select>
<p class="text-slate-600 dark:text-slate-400 pb-1 text-sm not-italic">
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CSAT_SUB_TEXT') }}
</p>
</label>
@@ -234,7 +222,7 @@
{{ $t('INBOX_MGMT.EDIT.ALLOW_MESSAGES_AFTER_RESOLVED.DISABLED') }}
</option>
</select>
<p class="text-slate-600 dark:text-slate-400 pb-1 text-sm not-italic">
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
{{
$t(
'INBOX_MGMT.SETTINGS_POPUP.ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT'
@@ -253,7 +241,7 @@
{{ $t('INBOX_MGMT.EDIT.ENABLE_CONTINUITY_VIA_EMAIL.DISABLED') }}
</option>
</select>
<p class="text-slate-600 dark:text-slate-400 pb-1 text-sm not-italic">
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
{{
$t(
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT'
@@ -273,7 +261,7 @@
{{ p.name }}
</option>
</select>
<p class="text-slate-600 dark:text-slate-400 pb-1 text-sm not-italic">
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
{{ $t('INBOX_MGMT.HELP_CENTER.SUB_TEXT') }}
</p>
</div>
@@ -287,7 +275,7 @@
{{ $t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.DISABLED') }}
</option>
</select>
<p class="text-slate-600 dark:text-slate-400 pb-1 text-sm not-italic">
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
{{
$t(
'INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT'
@@ -472,7 +460,6 @@ export default {
avatarFile: null,
avatarUrl: '',
greetingEnabled: true,
tweetsEnabled: true,
greetingMessage: '',
emailCollectEnabled: false,
csatSurveyEnabled: false,
@@ -668,7 +655,6 @@ export default {
this.selectedInboxName = this.inbox.name;
this.webhookUrl = this.inbox.webhook_url;
this.greetingEnabled = this.inbox.greeting_enabled || false;
this.tweetsEnabled = this.inbox.tweets_enabled || false;
this.greetingMessage = this.inbox.greeting_message || '';
this.emailCollectEnabled = this.inbox.enable_email_collect;
this.csatSurveyEnabled = this.inbox.csat_survey_enabled;
@@ -714,7 +700,6 @@ export default {
welcome_tagline: this.channelWelcomeTagline || '',
selectedFeatureFlags: this.selectedFeatureFlags,
reply_time: this.replyTime || 'in_a_few_minutes',
tweets_enabled: this.tweetsEnabled,
continuity_via_email: this.continuityViaEmail,
},
};
@@ -15,9 +15,11 @@
</label>
<p>{{ $t('INBOX_MGMT.BUSINESS_HOURS.TOGGLE_HELP') }}</p>
<div v-if="isBusinessHoursEnabled" class="mb-6">
<label class="unavailable-input-wrap">
{{ $t('INBOX_MGMT.BUSINESS_HOURS.UNAVAILABLE_MESSAGE_LABEL') }}
<label
<div class="max-w-[37.5rem]">
<label class="unavailable-input-wrap">
{{ $t('INBOX_MGMT.BUSINESS_HOURS.UNAVAILABLE_MESSAGE_LABEL') }}
</label>
<div
v-if="isRichEditorEnabled"
class="py-0 px-4 border border-solid border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-900 rounded-md mx-0 mt-0 mb-4"
>
@@ -28,9 +30,9 @@
class="message-editor"
:min-height="4"
/>
</label>
</div>
<textarea v-else v-model="unavailableMessage" type="text" />
</label>
</div>
<div class="timezone-input-wrap">
<label>
{{ $t('INBOX_MGMT.BUSINESS_HOURS.TIMEZONE_LABEL') }}
@@ -1,40 +1,40 @@
<template>
<form @submit.prevent="updateSignature()">
<div class="profile--settings--row text-black-900 dark:text-slate-300 row">
<div class="w-[25%] py-4 pr-6 ml-0">
<h4 class="block-title text-black-900 dark:text-slate-200">
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.TITLE') }}
</h4>
<p>{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.NOTE') }}</p>
</div>
<div class="p-4 w-[45%]">
<div>
<label for="message-signature-input">{{
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.LABEL')
}}</label>
<woot-message-editor
id="message-signature-input"
v-model="messageSignature"
class="message-editor h-[10rem]"
:is-format-mode="true"
:placeholder="
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')
"
:enabled-menu-options="customEditorMenuList"
:enable-suggestions="false"
@blur="$v.messageSignature.$touch"
/>
</div>
<woot-button
:is-loading="isUpdating"
type="submit"
:is-disabled="$v.messageSignature.$invalid"
>
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT') }}
</woot-button>
</div>
<div class="profile--settings--row text-black-900 dark:text-slate-300 row">
<div class="w-[25%] py-4 pr-6 ml-0">
<h4 class="block-title text-black-900 dark:text-slate-200">
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.TITLE') }}
</h4>
<p>{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.NOTE') }}</p>
</div>
</form>
<div class="p-4 w-[45%]">
<div>
<label for="message-signature-input">{{
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.LABEL')
}}</label>
<woot-message-editor
id="message-signature-input"
v-model="messageSignature"
class="message-editor h-[10rem]"
:is-format-mode="true"
:placeholder="
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')
"
:enabled-menu-options="customEditorMenuList"
:enable-suggestions="false"
:show-image-resize-toolbar="true"
@blur="$v.messageSignature.$touch"
/>
</div>
<woot-button
:is-loading="isUpdating"
type="button"
:is-disabled="$v.messageSignature.$invalid"
@click.prevent="updateSignature()"
>
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT') }}
</woot-button>
</div>
</div>
</template>
<script>
@@ -3,6 +3,64 @@ import * as types from '../mutation-types';
import ContactAPI from '../../api/contacts';
import ConversationApi from '../../api/conversations';
export const createMessagePayload = (payload, message) => {
const { content, cc_emails, bcc_emails } = message;
payload.append('message[content]', content);
if (cc_emails) payload.append('message[cc_emails]', cc_emails);
if (bcc_emails) payload.append('message[bcc_emails]', bcc_emails);
};
export const createConversationPayload = ({ params, contactId, files }) => {
const { inboxId, message, sourceId, mailSubject, assigneeId } = params;
const payload = new FormData();
if (message) {
createMessagePayload(payload, message);
}
if (files && files.length > 0) {
files.forEach(file => payload.append('message[attachments][]', file));
}
payload.append('inbox_id', inboxId);
payload.append('contact_id', contactId);
payload.append('source_id', sourceId);
payload.append('additional_attributes[mail_subject]', mailSubject);
payload.append('assignee_id', assigneeId);
return payload;
};
export const createWhatsAppConversationPayload = ({ params }) => {
const { inboxId, message, contactId, sourceId, assigneeId } = params;
const payload = {
inbox_id: inboxId,
contact_id: contactId,
source_id: sourceId,
message,
assignee_id: assigneeId,
};
return payload;
};
const setNewConversationPayload = ({
isFromWhatsApp,
params,
contactId,
files,
}) => {
if (isFromWhatsApp) {
return createWhatsAppConversationPayload({ params });
}
return createConversationPayload({
params,
contactId,
files,
});
};
const state = {
records: {},
uiFlags: {
@@ -20,27 +78,25 @@ export const getters = {
};
export const actions = {
create: async ({ commit }, params) => {
create: async ({ commit }, { params, isFromWhatsApp }) => {
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
isCreating: true,
});
const { inboxId, message, contactId, sourceId, mailSubject, assigneeId } =
params;
const { contactId, files } = params;
try {
const { data } = await ConversationApi.create({
inbox_id: inboxId,
contact_id: contactId,
source_id: sourceId,
additional_attributes: {
mail_subject: mailSubject,
},
message,
assignee_id: assigneeId,
const payload = setNewConversationPayload({
isFromWhatsApp,
params,
contactId,
files,
});
const { data } = await ConversationApi.create(payload);
commit(types.default.ADD_CONTACT_CONVERSATION, {
id: contactId,
data,
});
return data;
} catch (error) {
throw new Error(error);
@@ -1,5 +1,10 @@
import axios from 'axios';
import { actions } from '../../contactConversations';
import {
actions,
createMessagePayload,
createConversationPayload,
createWhatsAppConversationPayload,
} from '../../contactConversations';
import * as types from '../../../mutation-types';
import conversationList from './fixtures';
@@ -44,16 +49,84 @@ describe('#actions', () => {
await actions.create(
{ commit },
{
inboxId: 1,
message: { content: 'hi' },
contactId: 4,
sourceId: 5,
mailSubject: 'Mail Subject',
params: {
inboxId: 1,
message: { content: 'hi' },
contactId: 4,
sourceId: 5,
mailSubject: 'Mail Subject',
assigneeId: 6,
files: [],
},
isFromWhatsApp: false,
}
);
expect(commit.mock.calls).toEqual([
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isCreating: true }],
[
types.default.ADD_CONTACT_CONVERSATION,
{ id: 4, data: conversationList[0] },
],
[
types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG,
{ isCreating: false },
],
]);
});
it('sends correct actions with files if API is success', async () => {
axios.post.mockResolvedValue({ data: conversationList[0] });
await actions.create(
{ commit },
{
params: {
inboxId: 1,
message: { content: 'hi' },
contactId: 4,
sourceId: 5,
mailSubject: 'Mail Subject',
assigneeId: 6,
files: ['file1.pdf', 'file2.jpg'],
},
isFromWhatsApp: false,
}
);
expect(commit.mock.calls).toEqual([
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isCreating: true }],
[
types.default.ADD_CONTACT_CONVERSATION,
{ id: 4, data: conversationList[0] },
],
[
types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG,
{ isCreating: false },
],
]);
});
it('sends correct actions actions if API is success for whatsapp conversation', async () => {
axios.post.mockResolvedValue({ data: conversationList[0] });
await actions.create(
{ commit },
{
params: {
inboxId: 1,
message: {
content: 'hi',
template_params: {
name: 'hello_world',
category: 'MARKETING',
language: 'en_US',
processed_params: {},
},
},
contactId: 4,
sourceId: 5,
assigneeId: 6,
},
isFromWhatsApp: true,
}
);
expect(commit.mock.calls).toEqual([
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isCreating: true }],
[
types.default.ADD_CONTACT_CONVERSATION,
{ id: 4, data: conversationList[0] },
@@ -66,16 +139,46 @@ describe('#actions', () => {
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.create(
{ commit },
{
inboxId: 1,
message: { content: 'hi' },
contactId: 4,
sourceId: 5,
mailSubject: 'Mail Subject',
params: {
inboxId: 1,
message: { content: 'hi' },
contactId: 4,
assigneeId: 6,
sourceId: 5,
mailSubject: 'Mail Subject',
},
isFromWhatsApp: false,
}
)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isCreating: true }],
[
types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG,
{ isCreating: false },
],
]);
});
it('sends correct actions with files if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.create(
{ commit },
{
params: {
inboxId: 1,
message: { content: 'hi' },
contactId: 4,
sourceId: 5,
mailSubject: 'Mail Subject',
assigneeId: 6,
files: ['file1.pdf', 'file2.jpg'],
},
isFromWhatsApp: false,
}
)
).rejects.toThrow(Error);
@@ -89,3 +192,116 @@ describe('#actions', () => {
});
});
});
describe('createMessagePayload', () => {
it('creates message payload with cc and bcc emails', () => {
const payload = new FormData();
const message = {
content: 'Test message content',
cc_emails: 'cc@example.com',
bcc_emails: 'bcc@example.com',
};
createMessagePayload(payload, message);
expect(payload.get('message[content]')).toBe(message.content);
expect(payload.get('message[cc_emails]')).toBe(message.cc_emails);
expect(payload.get('message[bcc_emails]')).toBe(message.bcc_emails);
});
it('creates message payload without cc and bcc emails', () => {
const payload = new FormData();
const message = {
content: 'Test message content',
};
createMessagePayload(payload, message);
expect(payload.get('message[content]')).toBe(message.content);
expect(payload.get('message[cc_emails]')).toBeNull();
expect(payload.get('message[bcc_emails]')).toBeNull();
});
});
describe('createConversationPayload', () => {
it('creates conversation payload with message and attachments', () => {
const options = {
params: {
inboxId: '1',
message: {
content: 'Test message content',
},
sourceId: '12',
mailSubject: 'Test Subject',
assigneeId: '123',
},
contactId: '23',
files: ['file1.pdf', 'file2.jpg'],
};
const payload = createConversationPayload(options);
expect(payload.get('message[content]')).toBe(
options.params.message.content
);
expect(payload.get('inbox_id')).toBe(options.params.inboxId);
expect(payload.get('contact_id')).toBe(options.contactId);
expect(payload.get('source_id')).toBe(options.params.sourceId);
expect(payload.get('additional_attributes[mail_subject]')).toBe(
options.params.mailSubject
);
expect(payload.get('assignee_id')).toBe(options.params.assigneeId);
expect(payload.getAll('message[attachments][]')).toEqual(options.files);
});
it('creates conversation payload with message and without attachments', () => {
const options = {
params: {
inboxId: '1',
message: {
content: 'Test message content',
},
sourceId: '12',
mailSubject: 'Test Subject',
assigneeId: '123',
},
contactId: '23',
};
const payload = createConversationPayload(options);
expect(payload.get('message[content]')).toBe(
options.params.message.content
);
expect(payload.get('inbox_id')).toBe(options.params.inboxId);
expect(payload.get('contact_id')).toBe(options.contactId);
expect(payload.get('source_id')).toBe(options.params.sourceId);
expect(payload.get('additional_attributes[mail_subject]')).toBe(
options.params.mailSubject
);
expect(payload.get('assignee_id')).toBe(options.params.assigneeId);
expect(payload.getAll('message[attachments][]')).toEqual([]);
});
});
describe('createWhatsAppConversationPayload', () => {
it('creates conversation payload with message', () => {
const options = {
params: {
inboxId: '1',
message: {
content: 'Test message content',
},
sourceId: '12',
assigneeId: '123',
},
};
const payload = createWhatsAppConversationPayload(options);
expect(payload.message).toBe(options.params.message);
expect(payload.inbox_id).toBe(options.params.inboxId);
expect(payload.source_id).toBe(options.params.sourceId);
expect(payload.assignee_id).toBe(options.params.assigneeId);
});
});
@@ -1,16 +1,16 @@
<template>
<div
class="w-full flex items-center rounded-md border-solid border-2 h-16 bg-white px-4 py-2 text-slate-600"
class="w-full flex items-center rounded-md border-solid border-2 h-16 bg-white dark:bg-slate-900 px-4 py-2 text-slate-600 dark:text-slate-200"
:class="{
'shadow border-woot-100': isFocused,
'border-slate-50 shadow-sm': !isFocused,
'shadow border-woot-100 dark:border-woot-700': isFocused,
'border-slate-50 dark:border-slate-800 shadow-sm': !isFocused,
}"
>
<fluent-icon icon="search" />
<input
:value="value"
type="text"
class="w-full focus:outline-none text-base h-full bg-white px-2 py-2 text-slate-700 placeholder-slate-500"
class="w-full focus:outline-none text-base h-full bg-white dark:bg-slate-900 px-2 py-2 text-slate-700 dark:text-slate-100 placeholder-slate-500"
:placeholder="searchPlaceholder"
role="search"
@input="onChange"
@@ -1,16 +1,22 @@
<template>
<div
class="shadow-md bg-white mt-2 max-h-72 scroll-py-2 p-4 rounded overflow-y-auto text-sm text-slate-700"
class="shadow-md bg-white dark:bg-slate-900 mt-2 max-h-72 scroll-py-2 p-4 rounded overflow-y-auto text-sm text-slate-700 dark:text-slate-100"
>
<div v-if="isLoading" class="font-medium text-sm text-slate-400">
<div
v-if="isLoading"
class="font-medium text-sm text-slate-400 dark:text-slate-700"
>
{{ loadingPlaceholder }}
</div>
<h3 v-if="shouldShowResults" class="font-medium text-sm text-slate-400">
<h3
v-if="shouldShowResults"
class="font-medium text-sm text-slate-400 dark:text-slate-700"
>
{{ resultsTitle }}
</h3>
<ul
v-if="shouldShowResults"
class="bg-white mt-2 max-h-72 scroll-py-2 overflow-y-auto text-sm text-slate-700"
class="bg-white dark:bg-slate-900 mt-2 max-h-72 scroll-py-2 overflow-y-auto text-sm text-slate-700 dark:text-slate-100"
role="listbox"
>
<li
@@ -18,7 +24,7 @@
:id="article.id"
:key="article.id"
class="group flex cursor-default select-none items-center rounded-md p-2 mb-1"
:class="{ 'bg-slate-25': index === selectedIndex }"
:class="{ 'bg-slate-25 dark:bg-slate-800': index === selectedIndex }"
role="option"
tabindex="-1"
@mouseover="onHover(index)"
@@ -32,7 +38,10 @@
</li>
</ul>
<div v-if="showEmptyResults" class="font-medium text-sm text-slate-400">
<div
v-if="showEmptyResults"
class="font-medium text-sm text-slate-400 dark:text-slate-700"
>
{{ emptyPlaceholder }}
</div>
</div>
@@ -4,7 +4,7 @@
<nav class="max-w-2xl">
<h2
id="on-this-page-title"
class="text-slate-800 font-semibold tracking-wide border-b mb-3 leading-7"
class="text-slate-800 dark:text-slate-50 font-semibold tracking-wide border-b mb-3 leading-7"
>
{{ tocHeader }}
</h2>
@@ -14,7 +14,7 @@
<a
:href="`#${element.slug}`"
data-turbolinks="false"
class="text-base text-slate-800 cursor-pointer"
class="text-base text-slate-800 dark:text-slate-50 cursor-pointer"
>
{{ element.title }}
</a>
+3 -7
View File
@@ -23,11 +23,7 @@ import {
} from './bubbleHelpers';
import { isWidgetColorLighter } from 'shared/helpers/colorHelper';
import { dispatchWindowEvent } from 'shared/helpers/CustomEventHelper';
import {
CHATWOOT_ERROR,
CHATWOOT_ON_MESSAGE,
CHATWOOT_READY,
} from '../widget/constants/sdkEvents';
import { CHATWOOT_ERROR, CHATWOOT_READY } from '../widget/constants/sdkEvents';
import { SET_USER_ERROR } from '../widget/constants/errorTypes';
import { getUserCookieName, setCookieWithDomain } from './cookieHelpers';
import {
@@ -191,8 +187,8 @@ export const IFrameHelper = {
Cookies.remove(getUserCookieName());
}
},
onMessage({ data }) {
dispatchWindowEvent({ eventName: CHATWOOT_ON_MESSAGE, data });
onEvent({ eventIdentifier: eventName, data }) {
dispatchWindowEvent({ eventName, data });
},
setBubbleLabel(message) {
setBubbleText(window.$chatwoot.launcherTitle || message.label);
@@ -9,6 +9,7 @@
</template>
<script>
// 🚨 This component is deprecated. Please use fluent-icon instead.
import { hasEmojiSupport } from 'shared/helpers/emoji';
import { mapGetters } from 'vuex';
@@ -5,6 +5,7 @@
:type="type"
:icons="icons"
:view-box="viewBox"
:icon-lib="iconLib"
/>
</template>
<script>
@@ -33,6 +34,10 @@ export default {
type: String,
default: '0 0 24 24',
},
iconLib: {
type: String,
default: 'fluent',
},
},
data() {
return { icons };
@@ -1,5 +1,6 @@
<template>
<svg
v-if="iconLib === 'fluent'"
:width="size"
:height="size"
fill="none"
@@ -13,6 +14,23 @@
fill="currentColor"
/>
</svg>
<svg
v-else
:width="size"
:height="size"
fill="none"
:viewBox="viewBox"
xmlns="http://www.w3.org/2000/svg"
>
<g v-for="(pathData, index) in pathSource" :key="index">
<path
:key="pathData"
:d="pathData"
stroke="currentColor"
stroke-width="1.66667"
/>
</g>
</svg>
</template>
<script>
export default {
@@ -37,6 +55,10 @@ export default {
type: String,
default: '0 0 24 24',
},
iconLib: {
type: String,
default: 'fluent',
},
},
computed: {
@@ -5,6 +5,7 @@
:type="type"
:icons="icons"
:view-box="viewBox"
:icon-lib="iconLib"
/>
</template>
<script>
@@ -33,6 +34,10 @@ export default {
type: String,
default: '0 0 24 24',
},
iconLib: {
type: String,
default: 'fluent',
},
},
data() {
return { icons };
@@ -224,5 +224,27 @@
"M6 3.99988L5.728 4.01854C5.24919 4.08427 4.81038 4.32115 4.49271 4.68539C4.17505 5.04962 4.00002 5.51656 4 5.99986V7.99984H2L1.728 8.01851C1.24919 8.08423 0.810374 8.32112 0.492709 8.68535C0.175044 9.04959 2.04617e-05 9.51653 0 9.99982L0.0186659 10.2718C0.0843951 10.7506 0.321281 11.1894 0.685517 11.5071C1.04975 11.8248 1.5167 11.9998 2 11.9998H4V13.9998L4.01867 14.2718C4.0844 14.7506 4.32128 15.1894 4.68551 15.5071C5.04975 15.8247 5.5167 15.9997 6 15.9998L6.272 15.9811C7.248 15.8478 8 15.0131 8 13.9998V11.9998H10L10.272 11.9811C11.248 11.8478 12 11.0131 12 9.99982L11.9813 9.72782C11.9156 9.24902 11.6787 8.81021 11.3145 8.49255C10.9502 8.17489 10.4833 7.99986 10 7.99984H8V5.99986L7.98133 5.72786C7.9156 5.24906 7.67872 4.81025 7.31448 4.49258C6.95024 4.17492 6.4833 3.9999 6 3.99988Z",
"M14 0L13.9093 0.00622212C13.7497 0.0281315 13.6035 0.107093 13.4976 0.228504C13.3917 0.349915 13.3333 0.505562 13.3333 0.666661V1.33332H12.6667L12.576 1.33954C12.4164 1.36145 12.2701 1.44041 12.1642 1.56183C12.0584 1.68324 12 1.83888 12 1.99998L12.0062 2.09065C12.0281 2.25025 12.1071 2.39652 12.2285 2.50241C12.3499 2.60829 12.5056 2.66664 12.6667 2.66664H13.3333V3.3333L13.3396 3.42397C13.3615 3.58357 13.4404 3.72984 13.5618 3.83573C13.6833 3.94162 13.8389 3.99996 14 3.99996L14.0907 3.99374C14.416 3.9493 14.6667 3.67108 14.6667 3.3333V2.66664H15.3333L15.424 2.66042C15.7493 2.61598 16 2.33776 16 1.99998L15.9938 1.90932C15.9719 1.74971 15.8929 1.60345 15.7715 1.49756C15.6501 1.39167 15.4944 1.33333 15.3333 1.33332H14.6667V0.666661L14.6605 0.575995C14.6385 0.416393 14.5596 0.270123 14.4382 0.164236C14.3168 0.0583485 14.1611 6.79363e-06 14 0Z",
"M16 12.0001L15.8187 12.0125C15.4995 12.0563 15.2069 12.2143 14.9951 12.4571C14.7834 12.6999 14.6667 13.0112 14.6667 13.3334V14.6667H13.3333L13.152 14.6792C12.8328 14.723 12.5403 14.8809 12.3285 15.1237C12.1167 15.3665 12 15.6778 12 16L12.0124 16.1814C12.0563 16.5006 12.2142 16.7931 12.457 17.0049C12.6998 17.2167 13.0111 17.3333 13.3333 17.3334H14.6667V18.6667L14.6791 18.848C14.7229 19.1672 14.8809 19.4598 15.1237 19.6715C15.3665 19.8833 15.6778 20 16 20L16.1813 19.9876C16.832 19.8987 17.3333 19.3422 17.3333 18.6667V17.3334H18.6667L18.848 17.3209C19.4987 17.232 20 16.6756 20 16L19.9876 15.8187C19.9437 15.4995 19.7858 15.207 19.543 14.9952C19.3002 14.7834 18.9889 14.6667 18.6667 14.6667H17.3333V13.3334L17.3209 13.1521C17.2771 12.8329 17.1191 12.5403 16.8763 12.3285C16.6335 12.1168 16.3222 12.0001 16 12.0001Z"
],
"book-copy-outline": [
"M3.66675 15.3334V5.33341C3.66675 4.89139 3.84234 4.46746 4.1549 4.1549C4.46746 3.84234 4.89139 3.66675 5.33341 3.66675H14.5001",
"M6.16675 13.6667H5.33341C4.89139 13.6667 4.46746 13.8423 4.1549 14.1549C3.84234 14.4675 3.66675 14.8914 3.66675 15.3334C3.66675 15.7754 3.84234 16.1994 4.1549 16.5119C4.46746 16.8245 4.89139 17.0001 5.33341 17.0001H6.16675",
"M20.3333 17H11.1667C10.7246 17 10.3007 17.1756 9.98816 17.4882C9.6756 17.8007 9.5 18.2246 9.5 18.6667M9.5 18.6667C9.5 19.1087 9.6756 19.5326 9.98816 19.8452C10.3007 20.1577 10.7246 20.3333 11.1667 20.3333H20.3333V7H11.1667C10.7246 7 10.3007 7.17559 9.98816 7.48816C9.6756 7.80072 9.5 8.22464 9.5 8.66667V18.6667Z"
],
"globe-line-outline": [
"M12.0001 20.3334C16.6025 20.3334 20.3334 16.6025 20.3334 12.0001C20.3334 7.39771 16.6025 3.66675 12.0001 3.66675C7.39771 3.66675 3.66675 7.39771 3.66675 12.0001C3.66675 16.6025 7.39771 20.3334 12.0001 20.3334Z",
"M3.66675 12H20.3334",
"M12.0001 3.66675C14.0845 5.94871 15.269 8.91011 15.3334 12.0001C15.269 15.0901 14.0845 18.0515 12.0001 20.3334C9.91568 18.0515 8.73112 15.0901 8.66675 12.0001C8.73112 8.91011 9.91568 5.94871 12.0001 3.66675Z"
],
"heart-handshake-outline": [
"M17.8334 13.6667C19.0751 12.45 20.3334 10.9917 20.3334 9.08333C20.3334 7.86776 19.8505 6.70197 18.991 5.84243C18.1314 4.98289 16.9657 4.5 15.7501 4.5C14.2834 4.5 13.2501 4.91667 12.0001 6.16667C10.7501 4.91667 9.71675 4.5 8.25008 4.5C7.03451 4.5 5.86872 4.98289 5.00918 5.84243C4.14963 6.70197 3.66675 7.86776 3.66675 9.08333C3.66675 11 4.91675 12.4583 6.16675 13.6667L12.0001 19.5L17.8334 13.6667Z",
"M12 6.16675L9.53334 8.63341C9.36402 8.8015 9.22964 9.00143 9.13794 9.22169C9.04624 9.44194 8.99902 9.67817 8.99902 9.91675C8.99902 10.1553 9.04624 10.3916 9.13794 10.6118C9.22964 10.8321 9.36402 11.032 9.53334 11.2001C10.2167 11.8834 11.3083 11.9084 12.0333 11.2584L13.7583 9.67508C14.1907 9.28274 14.7537 9.06541 15.3375 9.06541C15.9214 9.06541 16.4843 9.28274 16.9167 9.67508L19.3833 11.8917",
"M16.9999 14.4999L15.3333 12.8333",
"M14.4999 16.9999L12.8333 15.3333"
],
"search-check-outline": [
"M8.66675 11.1667L10.3334 12.8333L13.6667 9.5",
"M11.1667 17.8333C14.8486 17.8333 17.8333 14.8486 17.8333 11.1667C17.8333 7.48477 14.8486 4.5 11.1667 4.5C7.48477 4.5 4.5 7.48477 4.5 11.1667C4.5 14.8486 7.48477 17.8333 11.1667 17.8333Z",
"M19.5001 19.5001L15.9167 15.9167"
]
}
@@ -1,22 +1,25 @@
<template>
<section>
<label v-if="richtext" class="greetings--richtext">
<section class="w-[75%]">
<div
v-if="richtext"
class="py-0 px-4 rounded-md border border-solid border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-900 mt-0 mx-0 mb-4"
>
<woot-message-editor
v-model="greetingsMessage"
:is-format-mode="true"
:enable-variables="true"
class="input"
class="input bg-white dark:bg-slate-900"
:placeholder="placeholder"
:min-height="4"
@input="handleInput"
/>
</label>
</div>
<resizable-text-area
v-else
v-model="greetingsMessage"
rows="4"
type="text"
class="medium-9 greetings--textarea"
class="greetings--textarea"
:label="label"
:placeholder="placeholder"
@input="handleInput"
@@ -68,12 +71,3 @@ export default {
},
};
</script>
<style scoped>
.greetings--richtext {
padding: 0 var(--space-normal);
border-radius: var(--border-radius-normal);
border: 1px solid var(--color-border);
margin: 0 0 var(--space-normal);
}
</style>
+2 -1
View File
@@ -1,5 +1,4 @@
export const BUS_EVENTS = {
SET_TWEET_REPLY: 'SET_TWEET_REPLY',
SHOW_ALERT: 'SHOW_ALERT',
START_NEW_CONVERSATION: 'START_NEW_CONVERSATION',
FOCUS_CUSTOM_ATTRIBUTE: 'FOCUS_CUSTOM_ATTRIBUTE',
@@ -9,5 +8,7 @@ export const BUS_EVENTS = {
TOGGLE_SIDEMENU: 'TOGGLE_SIDEMENU',
ON_MESSAGE_LIST_SCROLL: 'ON_MESSAGE_LIST_SCROLL',
WEBSOCKET_DISCONNECT: 'WEBSOCKET_DISCONNECT',
TOGGLE_REPLY_TO_MESSAGE: 'TOGGLE_REPLY_TO_MESSAGE',
SHOW_TOAST: 'newToastMessage',
NEW_CONVERSATION_MODAL: 'newConversationModal',
};
@@ -146,3 +146,12 @@ export const MESSAGE_VARIABLES = [
key: 'agent.email',
},
];
export const ATTACHMENT_ICONS = {
image: 'image',
audio: 'headphones-sound-wave',
video: 'video',
file: 'document',
location: 'location',
fallback: 'link',
};
@@ -7,5 +7,4 @@ export const MESSAGE_MAX_LENGTH = {
FACEBOOK: 1000,
TWILIO_SMS: 320,
TWILIO_WHATSAPP: 1600,
TWEET: 280,
};
+36 -1
View File
@@ -19,7 +19,7 @@ export const LocalStorage = {
} else {
window.localStorage.setItem(key, value);
}
window.localStorage.setItem(key + ':ts', Date.now());
window.localStorage.setItem(key + ':ts', Date.now().toString());
},
setFlag(store, accountId, key, expiry = 24 * 60 * 60 * 1000) {
@@ -46,4 +46,39 @@ export const LocalStorage = {
window.localStorage.removeItem(key);
window.localStorage.removeItem(key + ':ts');
},
updateJsonStore(storeName, key, value) {
try {
const storedValue = this.get(storeName) || {};
storedValue[key] = value;
this.set(storeName, storedValue);
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error updating JSON store in localStorage', e);
}
},
getFromJsonStore(storeName, key) {
try {
const storedValue = this.get(storeName) || {};
return storedValue[key] || null;
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error getting value from JSON store in localStorage', e);
return null;
}
},
deleteFromJsonStore(storeName, key) {
try {
const storedValue = this.get(storeName);
if (storedValue && key in storedValue) {
delete storedValue[key];
this.set(storeName, storedValue);
}
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error deleting entry from JSON store in localStorage', e);
}
},
};
@@ -0,0 +1,107 @@
import { LocalStorage } from '../localStorage';
// Mocking localStorage
const localStorageMock = (() => {
let store = {};
return {
getItem: key => store[key] || null,
setItem: (key, value) => {
store[key] = String(value);
},
removeItem: key => delete store[key],
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
describe('LocalStorage utility', () => {
beforeEach(() => {
localStorage.clear();
});
it('set and get methods', () => {
LocalStorage.set('testKey', { a: 1 });
expect(LocalStorage.get('testKey')).toEqual({ a: 1 });
});
it('remove method', () => {
LocalStorage.set('testKey', 'testValue');
LocalStorage.remove('testKey');
expect(LocalStorage.get('testKey')).toBeNull();
});
it('updateJsonStore method', () => {
LocalStorage.updateJsonStore('testStore', 'testKey', 'testValue');
expect(LocalStorage.get('testStore')).toEqual({ testKey: 'testValue' });
});
it('getFromJsonStore method', () => {
LocalStorage.set('testStore', { testKey: 'testValue' });
expect(LocalStorage.getFromJsonStore('testStore', 'testKey')).toBe(
'testValue'
);
});
it('deleteFromJsonStore method', () => {
LocalStorage.set('testStore', { testKey: 'testValue' });
LocalStorage.deleteFromJsonStore('testStore', 'testKey');
expect(LocalStorage.getFromJsonStore('testStore', 'testKey')).toBeNull();
});
it('setFlag and getFlag methods', () => {
const store = 'testStore';
const accountId = '123';
const key = 'flagKey';
const expiry = 1000; // 1 second
// Set flag and verify it's set
LocalStorage.setFlag(store, accountId, key, expiry);
expect(LocalStorage.getFlag(store, accountId, key)).toBe(true);
// Wait for expiry and verify flag is not set
return new Promise(resolve => {
setTimeout(() => {
expect(LocalStorage.getFlag(store, accountId, key)).toBe(false);
resolve();
}, expiry + 100); // wait a bit more than expiry time to ensure the flag has expired
});
});
it('clearAll method', () => {
LocalStorage.set('testKey1', 'testValue1');
LocalStorage.set('testKey2', 'testValue2');
LocalStorage.clearAll();
expect(LocalStorage.get('testKey1')).toBeNull();
expect(LocalStorage.get('testKey2')).toBeNull();
});
it('set method with non-object value', () => {
LocalStorage.set('testKey', 'testValue');
expect(LocalStorage.get('testKey')).toBe('testValue');
});
it('set and get methods with null value', () => {
LocalStorage.set('testKey', null);
expect(LocalStorage.get('testKey')).toBeNull();
});
it('set and get methods with undefined value', () => {
LocalStorage.set('testKey', undefined);
expect(LocalStorage.get('testKey')).toBe('undefined');
});
it('set and get methods with boolean value', () => {
LocalStorage.set('testKey', true);
expect(LocalStorage.get('testKey')).toBe(true);
});
it('set and get methods with number value', () => {
LocalStorage.set('testKey', 42);
expect(LocalStorage.get('testKey')).toBe(42);
});
});
@@ -11,6 +11,24 @@ export const INBOX_TYPES = {
SMS: 'Channel::Sms',
};
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',
};
// This is a single source of truth for inbox features
// This is used to check if a feature is available for a particular inbox or not
export const INBOX_FEATURE_MAP = {
[INBOX_FEATURES.REPLY_TO]: [
INBOX_TYPES.WEB,
INBOX_TYPES.FB,
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.LINE,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.API,
],
};
export default {
computed: {
channelType() {
@@ -102,4 +120,9 @@ export default {
);
},
},
methods: {
inboxHasFeature(feature) {
return INBOX_FEATURE_MAP[feature]?.includes(this.channelType) ?? false;
},
},
};
@@ -1,218 +1,286 @@
import { shallowMount } from '@vue/test-utils';
import inboxMixin from '../inboxMixin';
function getComponentConfigForInbox(channelType, additionalConfig = {}) {
return {
render() {},
mixins: [inboxMixin],
data() {
return {
inbox: {
channel_type: channelType,
...additionalConfig,
},
};
},
};
}
function getComponentConfigForChat(chat) {
return {
render() {},
mixins: [inboxMixin],
data() {
return {
chat,
};
},
};
}
describe('inboxMixin', () => {
it('returns the correct channel type', () => {
const Component = {
render() {},
mixins: [inboxMixin],
data() {
return { inbox: { channel_type: 'Channel::WebWidget' } };
},
};
const Component = getComponentConfigForInbox('Channel::WebWidget');
const wrapper = shallowMount(Component);
expect(wrapper.vm.channelType).toBe('Channel::WebWidget');
});
it('isAPIInbox returns true if channel type is API', () => {
const Component = {
render() {},
mixins: [inboxMixin],
data() {
return { inbox: { channel_type: 'Channel::Api' } };
},
};
const Component = getComponentConfigForInbox('Channel::Api');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isAPIInbox).toBe(true);
});
it('isATwitterInbox returns true if channel type is twitter', () => {
const Component = {
render() {},
mixins: [inboxMixin],
data() {
return { inbox: { channel_type: 'Channel::TwitterProfile' } };
},
};
const Component = getComponentConfigForInbox('Channel::TwitterProfile');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwitterInbox).toBe(true);
});
it('isAFacebookInbox returns true if channel type is Facebook', () => {
const Component = {
render() {},
mixins: [inboxMixin],
data() {
return { inbox: { channel_type: 'Channel::FacebookPage' } };
},
};
const Component = getComponentConfigForInbox('Channel::FacebookPage');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isAFacebookInbox).toBe(true);
});
it('isAWebWidgetInbox returns true if channel type is Facebook', () => {
const Component = {
render() {},
mixins: [inboxMixin],
data() {
return { inbox: { channel_type: 'Channel::WebWidget' } };
},
};
const Component = getComponentConfigForInbox('Channel::WebWidget');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isAWebWidgetInbox).toBe(true);
});
it('isASmsInbox returns true if channel type is sms', () => {
const Component = {
render() {},
mixins: [inboxMixin],
data() {
return { inbox: { channel_type: 'Channel::Sms' } };
},
};
const Component = getComponentConfigForInbox('Channel::Sms');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isASmsInbox).toBe(true);
});
it('isASmsInbox returns true if channel type is twilio sms', () => {
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
medium: 'sms',
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.isASmsInbox).toBe(true);
});
it('isALineChannel returns true if channel type is Line', () => {
const Component = getComponentConfigForInbox('Channel::Line');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isALineChannel).toBe(true);
});
it('isATelegramChannel returns true if channel type is Telegram', () => {
const Component = getComponentConfigForInbox('Channel::Telegram');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATelegramChannel).toBe(true);
});
it('isATwilioChannel returns true if channel type is Twilio', () => {
const Component = {
render() {},
mixins: [inboxMixin],
data() {
return {
inbox: {
channel_type: 'Channel::TwilioSms',
},
};
},
};
const Component = getComponentConfigForInbox('Channel::TwilioSms');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwilioChannel).toBe(true);
});
it('isATwilioSMSChannel returns true if channel type is Twilio and medium is SMS', () => {
const Component = {
render() {},
mixins: [inboxMixin],
data() {
return {
inbox: {
channel_type: 'Channel::TwilioSms',
medium: 'sms',
},
};
},
};
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwilioChannel).toBe(true);
expect(wrapper.vm.isATwilioSMSChannel).toBe(true);
expect(wrapper.vm.isASmsInbox).toBe(true);
});
describe('isATwilioSMSChannel', () => {
it('returns true if channel type is Twilio and medium is SMS', () => {
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
medium: 'sms',
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwilioSMSChannel).toBe(true);
});
it('isATwilioWhatsAppChannel returns true if channel type is Twilio and medium is WhatsApp', () => {
const Component = {
render() {},
mixins: [inboxMixin],
data() {
return {
inbox: {
channel_type: 'Channel::TwilioSms',
medium: 'whatsapp',
},
};
},
};
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwilioChannel).toBe(true);
expect(wrapper.vm.isATwilioWhatsAppChannel).toBe(true);
it('returns false if channel type is Twilio but medium is not SMS', () => {
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
medium: 'whatsapp',
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwilioSMSChannel).toBe(false);
});
it('returns false if channel type is not Twilio but medium is SMS', () => {
const Component = getComponentConfigForInbox('Channel::NotTwilio', {
medium: 'sms',
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwilioSMSChannel).toBe(false);
});
it('returns false if neither channel type is Twilio nor medium is SMS', () => {
const Component = getComponentConfigForInbox('Channel::NotTwilio', {
medium: 'not_sms',
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwilioSMSChannel).toBe(false);
});
it('returns false if channel type is Twilio but medium is empty', () => {
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
medium: undefined,
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwilioSMSChannel).toBe(false);
});
});
it('isAnEmailChannel returns true if channel type is email', () => {
const Component = {
render() {},
mixins: [inboxMixin],
data() {
return {
inbox: { channel_type: 'Channel::Email' },
};
},
};
const Component = getComponentConfigForInbox('Channel::Email');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isAnEmailChannel).toBe(true);
});
it('isTwitterInboxTweet returns true if Twitter channel type is tweet', () => {
const Component = {
render() {},
mixins: [inboxMixin],
data() {
return {
chat: {
channel_type: 'Channel::TwitterProfile',
additional_attributes: {
type: 'tweet',
},
},
};
const Component = getComponentConfigForChat({
channel_type: 'Channel::TwitterProfile',
additional_attributes: {
type: 'tweet',
},
};
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.isTwitterInboxTweet).toBe(true);
});
it('twilioBadge returns string sms if channel type is Twilio and medium is sms', () => {
const Component = {
render() {},
mixins: [inboxMixin],
data() {
return {
inbox: {
channel_type: 'Channel::TwilioSms',
medium: 'sms',
},
};
},
};
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
medium: 'sms',
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwilioSMSChannel).toBe(true);
expect(wrapper.vm.twilioBadge).toBe('sms');
});
it('twitterBadge returns string twitter-tweet if Twitter channel type is tweet', () => {
const Component = {
render() {},
mixins: [inboxMixin],
data() {
return {
chat: {
id: 1,
additional_attributes: {
type: 'tweet',
},
},
};
const Component = getComponentConfigForChat({
id: 1,
additional_attributes: {
type: 'tweet',
},
};
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.isTwitterInboxTweet).toBe(true);
expect(wrapper.vm.twitterBadge).toBe('twitter-tweet');
});
it('inboxBadge returns string Channel::Telegram if isATwilioChannel and isATwitterInbox is false', () => {
const Component = {
render() {},
mixins: [inboxMixin],
data() {
return {
inbox: {
channel_type: 'Channel::Telegram',
},
};
},
};
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwilioChannel).toBe(false);
expect(wrapper.vm.isATwitterInbox).toBe(false);
expect(wrapper.vm.channelType).toBe('Channel::Telegram');
describe('Badges', () => {
it('inboxBadge returns string Channel::Telegram if isATwilioChannel and isATwitterInbox is false', () => {
const Component = getComponentConfigForInbox('Channel::Telegram');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwilioChannel).toBe(false);
expect(wrapper.vm.isATwitterInbox).toBe(false);
expect(wrapper.vm.channelType).toBe('Channel::Telegram');
});
it('inboxBadge returns correct badge for WhatsApp channel', () => {
const Component = getComponentConfigForInbox('Channel::Whatsapp');
const wrapper = shallowMount(Component);
expect(wrapper.vm.inboxBadge).toBe('whatsapp');
});
it('inboxBadge returns the twitterBadge when isATwitterInbox is true', () => {
const Component = getComponentConfigForInbox('Channel::TwitterProfile');
const wrapper = shallowMount(Component);
expect(wrapper.vm.inboxBadge).toBe('twitter-dm');
});
it('inboxBadge returns the facebookBadge when isAFacebookInbox is true', () => {
const Component = getComponentConfigForInbox('Channel::FacebookPage');
const wrapper = shallowMount(Component);
expect(wrapper.vm.inboxBadge).toBe('facebook');
});
it('inboxBadge returns the twilioBadge when isATwilioChannel is true', () => {
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
medium: 'sms',
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.inboxBadge).toBe('sms');
});
it('inboxBadge returns "whatsapp" when isAWhatsAppChannel is true', () => {
const Component = getComponentConfigForInbox('Channel::Whatsapp');
const wrapper = shallowMount(Component);
expect(wrapper.vm.inboxBadge).toBe('whatsapp');
});
it('inboxBadge returns the channelType when no specific condition is true', () => {
const Component = getComponentConfigForInbox('Channel::Email');
const wrapper = shallowMount(Component);
expect(wrapper.vm.inboxBadge).toBe('Channel::Email');
});
});
describe('#inboxHasFeature', () => {
it('detects the correct feature', () => {
const Component = getComponentConfigForInbox('Channel::Telegram');
const wrapper = shallowMount(Component);
expect(wrapper.vm.inboxHasFeature('replyTo')).toBe(true);
expect(wrapper.vm.inboxHasFeature('feature-does-not-exist')).toBe(false);
});
it('returns false for feature not included', () => {
const Component = getComponentConfigForInbox('Channel::Sms');
const wrapper = shallowMount(Component);
expect(wrapper.vm.inboxHasFeature('replyTo')).toBe(false);
expect(wrapper.vm.inboxHasFeature('feature-does-not-exist')).toBe(false);
});
});
describe('WhatsApp channel', () => {
it('returns correct whatsAppAPIProvider', () => {
const Component = getComponentConfigForInbox('Channel::Whatsapp', {
provider: 'whatsapp_cloud',
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.whatsAppAPIProvider).toBe('whatsapp_cloud');
});
it('returns empty whatsAppAPIProvider if nothing is present', () => {
const Component = getComponentConfigForInbox('Channel::Whatsapp', {
provider: undefined,
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.whatsAppAPIProvider).toBe('');
});
it('isAWhatsAppCloudChannel returns true if channel type is WhatsApp and provider is whatsapp_cloud', () => {
const Component = getComponentConfigForInbox('Channel::Whatsapp', {
provider: 'whatsapp_cloud',
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.isAWhatsAppCloudChannel).toBe(true);
});
it('is360DialogWhatsAppChannel returns true if channel type is WhatsApp and provider is default', () => {
const Component = getComponentConfigForInbox('Channel::Whatsapp', {
provider: 'default',
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.is360DialogWhatsAppChannel).toBe(true);
});
it('isAWhatsAppChannel returns true if channel type is WhatsApp', () => {
const Component = getComponentConfigForInbox('Channel::Whatsapp');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
});
it('isAWhatsAppChannel returns true if channel type is Twilio and medium is WhatsApp', () => {
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
medium: 'whatsapp',
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
});
});
});
@@ -38,6 +38,8 @@ import ChatInputWrap from 'widget/components/ChatInputWrap.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { sendEmailTranscript } from 'widget/api/conversation';
import routerMixin from 'widget/mixins/routerMixin';
import { IFrameHelper } from '../helpers/utils';
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
export default {
components: {
ChatInputWrap,
@@ -96,6 +98,10 @@ export default {
this.clearConversations();
this.clearConversationAttributes();
this.replaceRoute('prechat-form');
IFrameHelper.sendMessage({
event: 'onEvent',
eventIdentifier: CHATWOOT_ON_START_CONVERSATION,
});
},
async sendTranscript() {
const { email } = this.currentUser;
@@ -40,6 +40,9 @@ import AvailableAgents from 'widget/components/AvailableAgents.vue';
import configMixin from 'widget/mixins/configMixin';
import availabilityMixin from 'widget/mixins/availability';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { IFrameHelper } from 'widget/helpers/utils';
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
export default {
name: 'TeamAvailability',
components: {
@@ -82,6 +85,12 @@ export default {
methods: {
startConversation() {
this.$emit('start-conversation');
if (!this.hasConversation) {
IFrameHelper.sendMessage({
event: 'onEvent',
eventIdentifier: CHATWOOT_ON_START_CONVERSATION,
});
}
},
},
};
@@ -1,3 +1,4 @@
export const CHATWOOT_ERROR = 'chatwoot:error';
export const CHATWOOT_ON_MESSAGE = 'chatwoot:on-message';
export const CHATWOOT_ON_START_CONVERSATION = 'chatwoot:on-start-conversation';
export const CHATWOOT_READY = 'chatwoot:ready';
+11 -2
View File
@@ -3,6 +3,7 @@ import { playNewMessageNotificationInWidget } from 'widget/helpers/WidgetAudioNo
import { ON_AGENT_MESSAGE_RECEIVED } from '../constants/widgetBusEvents';
import { IFrameHelper } from 'widget/helpers/utils';
import { shouldTriggerMessageUpdateEvent } from './IframeEventHelper';
import { CHATWOOT_ON_MESSAGE } from '../constants/sdkEvents';
const isMessageInActiveConversation = (getters, message) => {
const { conversation_id: conversationId } = message;
@@ -58,7 +59,11 @@ class ActionCableConnector extends BaseActionCableConnector {
.dispatch('conversation/addOrUpdateMessage', data)
.then(() => window.bus.$emit(ON_AGENT_MESSAGE_RECEIVED));
IFrameHelper.sendMessage({ event: 'onMessage', data });
IFrameHelper.sendMessage({
event: 'onEvent',
eventIdentifier: CHATWOOT_ON_MESSAGE,
data,
});
if (data.sender_type === 'User') {
playNewMessageNotificationInWidget();
}
@@ -70,7 +75,11 @@ class ActionCableConnector extends BaseActionCableConnector {
}
if (shouldTriggerMessageUpdateEvent(data)) {
IFrameHelper.sendMessage({ event: 'onMessage', data });
IFrameHelper.sendMessage({
event: 'onEvent',
eventIdentifier: CHATWOOT_ON_MESSAGE,
data,
});
}
this.app.$store.dispatch('conversation/addOrUpdateMessage', data);
+1
View File
@@ -3,6 +3,7 @@
class DataImportJob < ApplicationJob
queue_as :low
retry_on ActiveStorage::FileNotFoundError, wait: 1.minute, attempts: 3
def perform(data_import)
@data_import = data_import
+49
View File
@@ -0,0 +1,49 @@
class SlackUnfurlJob < ApplicationJob
queue_as :low
def perform(params)
@params = params
set_integration_hook
return unless channel_has_access
Integrations::Slack::SlackLinkUnfurlService.new(params: @params, integration_hook: @integration_hook).perform
end
private
# Find the integration hook by taking first link from array of links
# Assume that all the links are from the same account, how ever there is a possibility that the links are from different accounts.
# TODO: Fix this edge case later
def set_integration_hook
url = extract_url
return unless url
account_id = extract_account_id(url)
@integration_hook = Integrations::Hook.find_by(account_id: account_id, app_id: 'slack')
end
def extract_url
@params.dig(:event, :links)&.first&.[](:url)
end
def extract_account_id(url)
account_id_regex = %r{/accounts/(\d+)}
match_data = url.match(account_id_regex)
match_data[1] if match_data
end
# Check the channel has access to the bot to unfurl the links
def channel_has_access
return if @integration_hook.blank?
slack_client = Slack::Web::Client.new(token: @integration_hook.access_token)
response = slack_client.conversations_members(channel: @params.dig(:event, :channel))
response['ok']
rescue Slack::Web::Api::Errors::ChannelNotFound => e
# The link unfurl event will not work for private channels and other accounts channels
# So we can ignore the error
Rails.logger.error "Exception in SlackUnfurlJob: #{e.message}"
false
end
end
+2 -1
View File
@@ -29,6 +29,7 @@ class DataImport < ApplicationRecord
private
def process_data_import
DataImportJob.perform_later(self)
# we wait for the file to be uploaded to the cloud
DataImportJob.set(wait: 1.minute).perform_later(self)
end
end
+1 -1
View File
@@ -35,7 +35,7 @@ class Contacts::FilterService < FilterService
" (contacts.#{attribute_key})::#{current_filter['data_type']} #{filter_operator_value}#{current_filter['data_type']} #{query_operator} "
when 'standard'
if attribute_key == 'labels'
tag_filter_query('Contact', 'contacts', query_hash, current_index)
" #{tag_filter_query('Contact', 'contacts', query_hash, current_index)} "
else
" LOWER(contacts.#{attribute_key}) #{filter_operator_value} #{query_operator} "
end
+1 -1
View File
@@ -46,7 +46,7 @@ class Conversations::FilterService < FilterService
" (conversations.#{attribute_key})::#{current_filter['data_type']} #{filter_operator_value}#{current_filter['data_type']} #{query_operator} "
when 'standard'
if attribute_key == 'labels'
tag_filter_query('Conversation', 'conversations', query_hash, current_index)
" #{tag_filter_query('Conversation', 'conversations', query_hash, current_index)} "
else
" conversations.#{attribute_key} #{filter_operator_value} #{query_operator} "
end
+5
View File
@@ -22,6 +22,11 @@ class Macros::ExecutionService < ActionService
private
def assign_agent(agent_ids)
agent_ids = agent_ids.map { |id| id == 'self' ? @user.id : id }
super(agent_ids)
end
def add_private_note(message)
return if conversation_a_tweet?
@@ -0,0 +1 @@
json.partial! 'api/v1/models/contact', formats: [:json], resource: @base_contact
@@ -2,7 +2,6 @@ json.id resource.id
json.name resource.name
json.source_link resource.source_link
json.source_type resource.source_type
json.inbox_id resource.inbox_id
json.account_id resource.account_id
json.created_at resource.created_at.to_i
json.updated_at resource.updated_at.to_i
+7 -2
View File
@@ -28,8 +28,8 @@ By default, it renders:
<% end %>
</head>
<body>
<div class="antialiased">
<main class="main-content min-h-screen flex flex-col" role="main">
<div class="antialiased <%= @theme %>">
<main class="main-content min-h-screen flex flex-col bg-white dark:bg-slate-900" role="main">
<% if !@is_plain_layout_enabled %>
<%= render "public/api/v1/portals/header", portal: @portal %>
<% end %>
@@ -40,6 +40,11 @@ By default, it renders:
</main>
</div>
</body>
<style>
:root {
color-scheme: '<%= @theme %>';
}
</style>
<script>
window.portalConfig = {
portalSlug: '<%= @portal.slug %>',

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