Compare commits

..
Author SHA1 Message Date
Shivam Mishra 164b4df679 feat: implement handleReplyTo
* save to localstorage
* trigger a event on the bus
2023-10-05 14:38:55 +05:30
Shivam Mishra 2014a82ff8 test: localstorage utils 2023-10-05 14:35:11 +05:30
Shivam Mishra f60ab0eb1d feat: add json store utils 2023-10-05 14:28:21 +05:30
Shivam Mishra dc6757df3d refactor: use feature flag to toggle reply to 2023-10-05 11:30:32 +05:30
Shivam Mishra cfeddcbec2 feat: setup message_reply_to feature flag 2023-10-05 11:30:32 +05:30
Shivam MishraandGitHub 3aa79dffef fix: event name 2023-10-04 18:13:52 +05:30
Shivam MishraandGitHub 758b7bf061 Merge branch 'develop' into feature/cw-2594 2023-10-04 16:00:29 +05:30
Shivam Mishra 93d126af7b chore: add comment for INBOX_FEATURE_MAP 2023-10-04 15:55:54 +05:30
Shivam Mishra 3c6c57ca0e refactor: better variable name 2023-10-04 15:54:48 +05:30
Shivam Mishra b952608eeb feat: disable reply to for all 2023-10-04 15:52:17 +05:30
Shivam Mishra eb6f1e4031 feat: allow reply to for outgoing messages too 2023-10-04 15:51:48 +05:30
Shivam Mishra 94909255a8 test: cover all inboxMixin functions 2023-10-04 15:50:01 +05:30
Shivam Mishra 91788255f2 test: missing tests for line and telegram 2023-10-04 15:30:15 +05:30
Shivam Mishra 515b9ba0b5 refactor: use common config generator 2023-10-04 15:26:34 +05:30
Shivam Mishra e5c5ebc398 test: inboxHasFeature 2023-10-04 15:20:11 +05:30
Shivam Mishra 21212d9bc3 chore: add dummy handler 2023-10-04 12:13:57 +05:30
Shivam Mishra 0af9e974bd feat: add feature flag map for inbox specific features 2023-10-04 12:12:15 +05:30
Shivam Mishra b3141de50b feat: add context menu option for reply to 2023-10-04 12:01:09 +05:30
Shivam Mishra 1da6962467 feat: add copy for reply to 2023-10-04 12:00:43 +05:30
83 changed files with 745 additions and 2249 deletions
+1 -1
View File
@@ -92,7 +92,7 @@ gem 'twitty', '~> 0.1.5'
# facebook client
gem 'koala'
# slack client
gem 'slack-ruby-client', '~> 2.2.0'
gem 'slack-ruby-client', '~> 2.0.0'
# for dialogflow integrations
gem 'google-cloud-dialogflow-v2'
gem 'grpc'
+4 -3
View File
@@ -273,7 +273,7 @@ GEM
googleauth (~> 1.0)
grpc (~> 1.36)
geocoder (1.8.1)
gli (2.21.1)
gli (2.21.0)
globalid (1.2.1)
activesupport (>= 6.1)
gmail_xoauth (0.4.2)
@@ -732,12 +732,13 @@ GEM
json (>= 1.8, < 3)
simplecov-html (~> 0.10.0)
simplecov-html (0.10.2)
slack-ruby-client (2.2.0)
slack-ruby-client (2.0.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)
@@ -934,7 +935,7 @@ DEPENDENCIES
sidekiq (>= 7.1.3)
sidekiq-cron (>= 1.10.1)
simplecov (= 0.17.1)
slack-ruby-client (~> 2.2.0)
slack-ruby-client (~> 2.0.0)
spring
spring-watcher-listen
squasher
+1 -1
View File
@@ -1 +1 @@
2.5.0
2.4.0
@@ -9,6 +9,7 @@ class Api::V1::Accounts::Actions::ContactMergesController < Api::V1::Accounts::B
mergee_contact: @mergee_contact
)
contact_merge_action.perform
render json: @base_contact
end
private
+2 -2
View File
@@ -1,13 +1,13 @@
<template>
<div
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
v-if="!authUIFlags.isFetching"
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="currentAccountId">
<template v-if="!accountUIFlags.isFetchingItem && currentAccountId">
<payment-pending-banner />
<upgrade-banner />
</template>
@@ -34,26 +34,26 @@
}
&.hollow {
@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;
@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;
&.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-400 border-green-100 dark:border-green-600 hover:bg-green-50 dark:hover:bg-green-800;
@apply text-green-700 dark:text-green-100 hover:bg-green-50 dark:hover:bg-green-800;
}
&.alert {
@apply text-red-700 dark:text-red-400 border-red-100 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-800;
@apply text-red-700 dark:text-red-100 hover:bg-red-50 dark:hover:bg-red-800;
}
&.warning {
@apply text-yellow-600 dark:text-yellow-600 border-yellow-600 dark:border-yellow-700 hover:bg-yellow-50 dark:hover:bg-yellow-800;
@apply text-yellow-700 dark:text-yellow-100 hover:bg-yellow-50 dark:hover:bg-yellow-800;
}
&:hover {
@apply bg-woot-75 dark:bg-woot-800 border-slate-100 dark:border-woot-600 dark:text-woot-400;
@apply bg-slate-75 dark:bg-slate-900 border-slate-100 dark:border-slate-700;
&.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-500;
@apply border-slate-100 dark:border-slate-700 text-yellow-700 dark:text-yellow-700;
}
}
}
@@ -152,10 +152,6 @@
&.is-image {
@apply rounded-lg;
.message__mail-head {
@apply px-4 py-2;
}
}
}
@@ -4,6 +4,10 @@
@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;
+5 -23
View File
@@ -4,19 +4,15 @@
v-if="show"
:class="modalClassName"
transition="modal"
@mousedown="handleMouseDown"
@click="onBackDropClick"
>
<div
:class="modalContainerClassName"
@mouse.stop
@mousedown="event => event.stopPropagation()"
>
<div :class="modalContainerClassName" @click.stop>
<woot-button
v-if="showCloseButton"
color-scheme="secondary"
icon="dismiss"
variant="clear"
class="absolute ltr:right-2 rtl:left-2 top-2 z-10"
class="modal--close"
@click="close"
/>
<slot />
@@ -54,11 +50,6 @@ export default {
default: '',
},
},
data() {
return {
mousedDownOnBackdrop: false,
};
},
computed: {
modalContainerClassName() {
let className =
@@ -86,22 +77,13 @@ 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();
},
onMouseUp() {
if (this.mousedDownOnBackdrop) {
this.mousedDownOnBackdrop = false;
onBackDropClick() {
if (this.closeOnBackdropClick) {
this.onClose();
}
},
@@ -11,6 +11,7 @@
@key-shortcut-modal="toggleKeyShortcutModal"
@open-notification-panel="openNotificationPanel"
/>
<secondary-sidebar
v-if="showSecondarySidebar"
:class="sidebarClassName"
@@ -113,12 +114,7 @@ export default {
if (!isAvailableForTheUser) {
return false;
}
if (
menuItem.alwaysVisibleOnChatwootInstances &&
!this.isACustomBrandedInstance
) {
return true;
}
if (menuItem.featureFlag) {
return this.isFeatureEnabledonAccount(
this.accountId,
@@ -42,7 +42,6 @@ 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'],
@@ -1,9 +1,11 @@
<template>
<div class="preview-item__wrap flex overflow-auto max-h-[12.5rem]">
<div
class="preview-item__wrap flex flex-col overflow-auto mt-4 max-h-[12.5rem]"
>
<div
v-for="(attachment, index) in attachments"
:key="attachment.id"
class="preview-item flex items-center p-1 bg-slate-50 dark:bg-slate-800 gap-1 rounded-md w-[15rem] mb-1"
class="preview-item flex p-1 bg-slate-50 dark:bg-slate-800 rounded-md w-[15rem] mb-1"
>
<div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
<img
@@ -15,7 +17,7 @@
📄
</span>
</div>
<div class="max-w-[60%] min-w-[50%] overflow-hidden text-ellipsis">
<div class="max-w-[60%] min-w-[50%] overflow-hidden text-ellipsis ml-2">
<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="getFrameId(index)"
:id="`dashboard-app--frame-${index}`"
:src="configItem.url"
@load="() => onIframeLoad(index)"
/>
@@ -39,10 +39,6 @@ export default {
type: Boolean,
default: false,
},
position: {
type: Number,
required: true,
},
},
data() {
return {
@@ -86,14 +82,10 @@ export default {
};
},
methods: {
getFrameId(index) {
return `dashboard-app--frame-${this.position}-${index}`;
},
onIframeLoad(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 frameElement = document.getElementById(
`dashboard-app--frame-${index}`
);
const eventData = { event: 'appContext', data: this.dashboardAppContext };
frameElement.contentWindow.postMessage(JSON.stringify(eventData), '*');
this.iframeLoading = false;
@@ -1,5 +1,5 @@
<template>
<div ref="editorRoot" class="relative editor-root">
<div class="editor-root">
<tag-agents
v-if="showUserMentions && isPrivate"
:search-key="mentionSearchKey"
@@ -23,23 +23,6 @@
@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>
@@ -58,7 +41,6 @@ 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';
@@ -66,10 +48,6 @@ import VariableList from '../conversation/VariableList.vue';
import {
appendSignature,
removeSignature,
insertAtCursor,
scrollCursorIntoView,
findNodeToInsertImage,
setURLWithQueryAndSize,
} from 'dashboard/helper/editorHelper';
const TYPING_INDICATOR_IDLE_TIME = 4000;
@@ -89,10 +67,8 @@ 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 {
MESSAGE_EDITOR_MENU_OPTIONS,
MESSAGE_EDITOR_IMAGE_RESIZES,
} from 'dashboard/constants/editor';
import { findNodeToInsertImage } from 'dashboard/helper/messageEditorHelper';
import { MESSAGE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
const createState = (
content,
@@ -135,7 +111,6 @@ 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 {
@@ -148,16 +123,9 @@ 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);
},
@@ -305,7 +273,6 @@ 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');
}
@@ -331,17 +298,7 @@ export default {
mounted() {
this.createEditorView();
this.editorView.updateState(this.state);
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);
this.focusEditor(this.value);
},
methods: {
reloadState(content = this.value) {
@@ -428,7 +385,6 @@ export default {
state: this.state,
dispatchTransaction: tx => {
this.state = this.state.apply(tx);
this.editorView.updateState(this.state);
this.emitOnChange();
},
handleDOMEvents: {
@@ -441,9 +397,6 @@ export default {
focus: () => {
this.onFocus();
},
click: () => {
// this.isEditorMouseFocusedOnAnImage(); Enable it when the backend supports for message resize is done.
},
blur: () => {
this.onBlur();
},
@@ -456,50 +409,6 @@ 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');
},
@@ -532,7 +441,11 @@ export default {
userFullName: mentionItem.name,
});
this.insertNodeIntoEditor(node, this.range.from, this.range.to);
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.$track(CONVERSATION_EVENTS.USED_MENTIONS);
return false;
@@ -542,22 +455,30 @@ 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
);
const from =
node.textContent === updatedMessage
? this.range.from
: this.range.from - 1;
if (node.textContent === updatedMessage) {
node = this.editorView.state.schema.text(updatedMessage);
from = this.range.from;
}
this.insertNodeIntoEditor(node, from, this.range.to);
const tr = this.editorView.state.tr.replaceWith(
from,
this.range.to,
node
);
this.state = this.editorView.state.apply(tr);
this.emitOnChange();
tr.scrollIntoView();
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
return false;
},
@@ -565,14 +486,23 @@ export default {
if (!this.editorView) {
return null;
}
let node = this.editorView.state.schema.text(`{{${variable}}}`);
const from = this.range.from;
const content = `{{${variable}}}`;
let node = this.editorView.state.schema.text(content);
const { from, to } = this.range;
const tr = this.editorView.state.tr.replaceWith(
from,
this.range.to,
node
);
this.insertNodeIntoEditor(node, from, to);
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.showVariables = false;
this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
tr.scrollIntoView();
return false;
},
openFileBrowser() {
@@ -628,6 +558,8 @@ export default {
},
emitOnChange() {
this.editorView.updateState(this.state);
this.$emit('input', this.contentFromEditor);
},
@@ -670,7 +602,6 @@ 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()) {
@@ -688,19 +619,6 @@ 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>
@@ -794,6 +712,6 @@ export default {
}
.editor-warning__message {
@apply text-red-400 dark:text-red-400 font-normal text-sm pt-1 pb-0 px-0;
@apply text-red-400 dark:text-red-400 text-sm font-normal pt-1 pb-0 px-0;
}
</style>
@@ -14,11 +14,10 @@
<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="enableDragAndDrop"
:drop="true"
:drop-directory="false"
:data="{
direct_upload_url: '/rails/active_storage/direct_uploads',
@@ -101,9 +100,9 @@
<transition name="modal-fade">
<div
v-show="$refs.upload && $refs.upload.dropActive"
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"
class="modal-mask"
>
<fluent-icon icon="cloud-backup" size="40" />
<fluent-icon icon="cloud-backup" />
<h4 class="page-sub-title text-slate-600 dark:text-slate-200">
{{ $t('CONVERSATION.REPLYBOX.DRAG_DROP') }}
</h4>
@@ -229,10 +228,6 @@ export default {
type: String,
default: '',
},
newConversationModalActive: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({
@@ -279,9 +274,6 @@ export default {
}
return ALLOWED_FILE_TYPES;
},
enableDragAndDrop() {
return !this.newConversationModalActive;
},
audioRecorderPlayStopIcon() {
switch (this.recordingAudioState) {
// playing paused recording stopped inactive destroyed
@@ -354,4 +346,12 @@ 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,7 +52,6 @@
: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="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="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="{
'active bg-slate-25 dark:bg-slate-800 border-woot-500': isActiveChat,
'unread-chat': hasUnread,
@@ -31,7 +31,7 @@
size="40px"
/>
<div
class="px-0 py-3 border-b group-last:border-transparent group-hover:border-transparent border-slate-50 dark:border-slate-800/75 columns"
class="py-3 px-0 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,11 +55,44 @@
>
{{ currentContact.name }}
</h4>
<message-preview
<p
v-if="lastMessageInChat"
:message="lastMessageInChat"
class="conversation--message mt-0 mb-2 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] text-sm text-slate-700 dark:text-slate-200"
/>
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>
<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"
@@ -73,8 +106,8 @@
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span>
</p>
<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">
<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">
<time-ago
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
@@ -112,8 +145,9 @@
</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';
@@ -125,6 +159,14 @@ 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: {
@@ -133,11 +175,16 @@ export default {
Thumbnail,
ConversationContextMenu,
TimeAgo,
MessagePreview,
PriorityMark,
},
mixins: [inboxMixin, timeMixin, conversationMixin, alertMixin],
mixins: [
inboxMixin,
timeMixin,
conversationMixin,
messageFormatterMixin,
alertMixin,
],
props: {
activeLabel: {
type: String,
@@ -211,6 +258,20 @@ 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;
},
@@ -231,6 +292,30 @@ 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,11 +34,6 @@
:url="storyUrl"
/>
</blockquote>
<bubble-reply-to
v-if="inReplyToMessageId && inboxSupportsReplyTo"
:message="inReplyTo"
@click="navigateToMessage"
/>
<bubble-text
v-if="data.content"
:message="message"
@@ -146,7 +141,6 @@ 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';
@@ -171,7 +165,6 @@ export default {
BubbleMailHead,
BubbleText,
BubbleContact,
BubbleReplyTo,
ContextMenu,
Spinner,
instagramImageErrorPlaceholder,
@@ -182,10 +175,6 @@ export default {
type: Object,
required: true,
},
currentChat: {
type: Object,
required: true,
},
isATweet: {
type: Boolean,
default: false,
@@ -282,21 +271,6 @@ 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,
@@ -352,8 +326,6 @@ 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,
};
@@ -548,13 +520,6 @@ export default {
this.showBackgroundHighlight = false;
}, HIGHLIGHT_TIMER);
},
navigateToMessage() {
this.$nextTick(() => {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
messageId: this.inReplyToMessageId,
});
});
},
},
};
</script>
@@ -663,8 +628,6 @@ 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;
}
@@ -1,93 +0,0 @@
<template>
<span class="overflow-hidden text-ellipsis whitespace-nowrap">
<template v-if="showMessageType">
<fluent-icon
v-if="isMessagePrivate"
size="16"
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
icon="lock-closed"
/>
<fluent-icon
v-else-if="messageByAgent"
size="16"
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
icon="arrow-reply"
/>
<fluent-icon
v-else-if="isMessageAnActivity"
size="16"
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
icon="info"
/>
</template>
<span v-if="message.content">
{{ parsedLastMessage }}
</span>
<span v-else-if="message.attachments">
<fluent-icon
v-if="attachmentIcon && showMessageType"
size="16"
class="-mt-0.5 align-middle inline-block text-slate-600 dark:text-slate-300"
:icon="attachmentIcon"
/>
{{ $t(`${attachmentMessageContent}`) }}
</span>
<span v-else>
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
</span>
</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,
},
defaultEmptyMessage: {
type: String,
default: '',
},
},
computed: {
messageByAgent() {
const { message_type: messageType } = this.message;
return messageType === MESSAGE_TYPE.OUTGOING;
},
isMessageAnActivity() {
const { message_type: messageType } = this.message;
return messageType === MESSAGE_TYPE.ACTIVITY;
},
isMessagePrivate() {
const { private: isPrivate } = this.message;
return isPrivate;
},
parsedLastMessage() {
const { content_attributes: contentAttributes } = this.message;
const { email: { subject } = {} } = contentAttributes || {};
return this.getPlainText(subject || this.message.content);
},
lastMessageFileType() {
const [{ file_type: fileType } = {}] = this.message.attachments;
return fileType;
},
attachmentIcon() {
return ATTACHMENT_ICONS[this.lastMessageFileType];
},
attachmentMessageContent() {
return `CHAT_LIST.ATTACHMENTS.${this.lastMessageFileType}.CONTENT`;
},
},
};
</script>
@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0">
<div class="m-0 flex flex-col justify-between h-full flex-grow min-w-0">
<banner
v-if="!currentChat.can_reply"
color-scheme="alert"
@@ -7,6 +7,15 @@
: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"
@@ -34,7 +43,6 @@
: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>
@@ -56,8 +64,6 @@
: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"
@@ -82,6 +88,8 @@
</div>
<reply-box
:conversation-id="currentChat.id"
:is-a-tweet="isATweet"
:selected-tweet="selectedTweet"
:popout-reply-box.sync="isPopoutReplyBox"
@click="showPopoutReplyBox"
/>
@@ -147,6 +155,7 @@ export default {
isLoadingPrevious: true,
heightBeforeLoad: null,
conversationPanel: null,
selectedTweetId: null,
hasUserScrolled: false,
isProgrammaticScroll: false,
isPopoutReplyBox: false,
@@ -184,6 +193,16 @@ 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'
@@ -242,6 +261,17 @@ 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';
@@ -309,6 +339,7 @@ export default {
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
this.messageSentSinceOpened = false;
this.selectedTweetId = null;
},
},
@@ -316,6 +347,7 @@ 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, () => {
@@ -386,6 +418,10 @@ 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(() => {
@@ -508,6 +544,9 @@ export default {
makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
},
removeTweetSelection() {
this.selectedTweetId = null;
},
},
};
</script>
@@ -19,12 +19,6 @@
@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"
@@ -92,7 +86,6 @@
</div>
<div v-if="hasAttachments" class="attachment-preview-box" @paste="onPaste">
<attachment-preview
class="flex-col mt-4"
:attachments="attachedFiles"
:remove-attachment="removeAttachment"
/>
@@ -122,7 +115,6 @@
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker"
:message="message"
:new-conversation-modal-active="newConversationModalActive"
@selectWhatsappTemplate="openWhatsappTemplateModal"
@toggle-editor="toggleRichContentEditor"
@replace-text="replaceText"
@@ -149,7 +141,6 @@ 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';
@@ -171,7 +162,7 @@ import {
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import inboxMixin from 'shared/mixins/inboxMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals';
@@ -185,10 +176,6 @@ 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');
@@ -196,7 +183,6 @@ export default {
components: {
EmojiInput,
CannedResponse,
ReplyToMessage,
ResizableTextArea,
AttachmentPreview,
ReplyTopPanel,
@@ -218,6 +204,14 @@ export default {
fileUploadMixin,
],
props: {
selectedTweet: {
type: [Object, String],
default: () => ({}),
},
isATweet: {
type: Boolean,
default: false,
},
popoutReplyBox: {
type: Boolean,
default: false,
@@ -226,7 +220,6 @@ export default {
data() {
return {
message: '',
inReplyTo: {},
isFocused: false,
showEmojiPicker: false,
attachedFiles: [],
@@ -248,7 +241,6 @@ export default {
showUserMentions: false,
showCannedMenu: false,
showVariablesMenu: false,
newConversationModalActive: false,
};
},
computed: {
@@ -259,19 +251,7 @@ 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;
@@ -343,7 +323,10 @@ export default {
return this.maxLength - this.message.length;
},
isReplyButtonDisabled() {
if (this.isATwitterInbox) return true;
if (this.isATweet && !this.inReplyTo && !this.isOnPrivateNote) {
return true;
}
if (this.hasAttachments || this.hasRecordedAudio) return false;
return (
@@ -376,6 +359,11 @@ 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() {
@@ -393,6 +381,8 @@ 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')
? '(⌘ + ↵)'
@@ -426,6 +416,10 @@ export default {
isOnPrivateNote() {
return this.replyType === REPLY_EDITOR_MODES.NOTE;
},
inReplyTo() {
const selectedTweet = this.selectedTweet || {};
return selectedTweet.id;
},
isOnExpandedLayout() {
const {
LAYOUT_TYPES: { CONDENSED },
@@ -443,6 +437,15 @@ 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;
@@ -564,28 +567,10 @@ 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() {
@@ -870,7 +855,6 @@ export default {
}
this.attachedFiles = [];
this.isRecordingAudio = false;
this.resetReplyToMessage();
},
clearEmailField() {
this.ccEmails = '';
@@ -1002,10 +986,8 @@ export default {
sender: this.sender,
};
if (this.inReplyTo?.id) {
messagePayload.contentAttributes = {
in_reply_to: this.inReplyTo.id,
};
if (this.inReplyTo) {
messagePayload.contentAttributes = { in_reply_to: this.inReplyTo };
}
if (this.attachedFiles && this.attachedFiles.length) {
@@ -1078,38 +1060,6 @@ 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>
@@ -1,45 +0,0 @@
<template>
<div
class="reply-editor bg-slate-50 dark:bg-slate-800 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2 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 type="button" @click="sendMessage">
<woot-button @click="sendMessage">
{{ $t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL') }}
</woot-button>
</footer>
@@ -57,6 +57,17 @@
@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"
@@ -66,7 +77,7 @@
<fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.VIEW_TWEET_IN_TWITTER')"
icon="open"
class="cursor-pointer action--icon"
class="action--icon cursor-pointer"
size="16"
/>
</a>
@@ -75,6 +86,7 @@
<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';
@@ -234,6 +246,11 @@ export default {
return false;
},
},
methods: {
onTweetReply() {
bus.$emit(BUS_EVENTS.SET_TWEET_REPLY, this.id);
},
},
};
</script>
@@ -1,29 +0,0 @@
<template>
<div
class="px-2 py-1.5 -mx-2 rounded-md bg-woot-600 text-woot-50 min-w-[15rem] mb-2 cursor-pointer"
@click="$emit('click')"
>
<message-preview
:message="message"
:show-message-type="false"
::default-empty-message="$t('CONVERSATION.REPLY_MESSAGE_NOT_FOUND')"
/>
</div>
</template>
<script>
import MessagePreview from 'dashboard/components/widgets/conversation/MessagePreview.vue';
export default {
name: 'ReplyTo',
components: {
MessagePreview,
},
props: {
message: {
type: Object,
required: true,
},
},
};
</script>
@@ -32,22 +32,3 @@ 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,7 +28,6 @@ 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'],
@@ -156,128 +156,3 @@ 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);
}
}
}
@@ -0,0 +1,40 @@
/**
* 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,43 +5,7 @@ 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';
@@ -234,208 +198,3 @@ 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();
});
});
@@ -0,0 +1,100 @@
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);
});
});
@@ -204,10 +204,6 @@
"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!",
@@ -347,17 +343,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 deleted"
"HELP_LABEL": "To be kept"
},
"PARENT": {
"CHILD": {
"TITLE": "Contact to merge",
"PLACEHOLDER": "Search for a contact",
"HELP_LABEL": "To be kept"
"HELP_LABEL": "To be deleted"
},
"SUMMARY": {
"TITLE": "Summary",
"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>."
"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>."
},
"SEARCH": {
"ERROR": "ERROR_MESSAGE"
@@ -34,6 +34,7 @@
"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",
@@ -46,12 +47,6 @@
"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"
@@ -144,8 +139,7 @@
"PRIVATE_NOTE": "Private Note",
"SEND": "Send",
"CREATE": "Add Note",
"DISMISS_REPLY": "Dismiss reply",
"REPLYING_TO": "Replying to:",
"TWEET": "Tweet",
"TIP_FORMAT_ICON": "Show rich text editor",
"TIP_EMOJI_ICON": "Show emoji selector",
"TIP_ATTACH_ICON": "Attach files",
@@ -425,33 +425,6 @@
"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",
"TITLE": "Help Center (Beta)",
"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 [{ id: 'self', name: 'Self' }, ...this.agents];
return this.agents;
case 'add_label':
case 'remove_label':
return this.labels.map(i => {
@@ -37,10 +37,7 @@ 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([
{ id: 'self', name: 'Self' },
...agents,
]);
expect(wrapper.vm.getDropdownValues('assign_agent')).toEqual(agents);
expect(wrapper.vm.getDropdownValues('change_priority')).toEqual(
PRIORITY_CONDITION_VALUES
);
@@ -73,12 +73,12 @@ export default {
this.isSearching = false;
}
},
async onMergeContacts(parentContactId) {
async onMergeContacts(childContactId) {
this.$track(CONTACTS_EVENTS.MERGED_CONTACTS);
try {
await this.$store.dispatch('contacts/merge', {
childId: this.primaryContact.id,
parentId: parentContactId,
childId: childContactId,
parentId: this.primaryContact.id,
});
this.showAlert(this.$t('MERGE_CONTACTS.FORM.SUCCESS_MESSAGE'));
this.onClose();
@@ -1,29 +1,62 @@
<template>
<form @submit.prevent="onSubmit">
<div>
<div>
<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
class="mt-1 multiselect-wrap--medium"
:class="{ error: $v.parentContact.$error }"
class="child-contact multiselect-wrap--medium"
:class="{ error: $v.childContact.$error }"
>
<label class="multiselect__label">
{{ $t('MERGE_CONTACTS.PARENT.TITLE') }}
<woot-label
:title="$t('MERGE_CONTACTS.PARENT.HELP_LABEL')"
color-scheme="success"
{{ $t('MERGE_CONTACTS.CHILD.TITLE')
}}<woot-label
:title="$t('MERGE_CONTACTS.CHILD.HELP_LABEL')"
color-scheme="alert"
small
class="ml-2"
class="label--merge-warning"
/>
</label>
<multiselect
v-model="parentContact"
v-model="childContact"
:options="searchResults"
label="name"
track-by="id"
:internal-search="false"
:clear-on-select="false"
:show-labels="false"
:placeholder="$t('MERGE_CONTACTS.PARENT.PLACEHOLDER')"
:placeholder="$t('MERGE_CONTACTS.CHILD.PLACEHOLDER')"
:allow-empty="true"
:loading="isSearching"
:max-height="150"
@@ -52,57 +85,17 @@
{{ $t('AGENT_MGMT.SEARCH.NO_RESULTS') }}
</span>
</multiselect>
<span v-if="$v.parentContact.$error" class="message">
<span v-if="$v.childContact.$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"
:parent-contact-name="parentContactName"
:child-contact-name="childContactName"
/>
<div class="mt-6 flex justify-end">
<div class="footer">
<woot-button variant="clear" @click.prevent="onCancel">
{{ $t('MERGE_CONTACTS.FORM.CANCEL') }}
</woot-button>
@@ -145,19 +138,19 @@ export default {
primaryContact: {
required,
},
parentContact: {
childContact: {
required,
},
},
data() {
return {
parentContact: undefined,
childContact: undefined,
};
},
computed: {
parentContactName() {
return this.parentContact ? this.parentContact.name : '';
childContactName() {
return this.childContact ? this.childContact.name : '';
},
},
methods: {
@@ -169,7 +162,7 @@ export default {
if (this.$v.$invalid) {
return;
}
this.$emit('submit', this.parentContact.id);
this.$emit('submit', this.childContact.id);
},
onCancel() {
this.$emit('cancel');
@@ -179,24 +172,51 @@ 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,29 +1,26 @@
<template>
<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">
<div v-if="childContactName" class="merge-summary callout">
<h5 class="text-block-title">
{{ $t('MERGE_CONTACTS.SUMMARY.TITLE') }}
</h5>
<ul class="ml-0 list-none">
<ul class="summary-items">
<li>
<span class="inline-block mr-1">❌</span>
<span class="bullet">❌</span>
<span
v-dompurify-html="
$t('MERGE_CONTACTS.SUMMARY.DELETE_WARNING', {
primaryContactName,
childContactName,
})
"
/>
</li>
<li>
<span class="inline-block mr-1">✅</span>
<span class="bullet">✅</span>
<span
v-dompurify-html="
$t('MERGE_CONTACTS.SUMMARY.ATTRIBUTE_WARNING', {
childContactName,
primaryContactName,
parentContactName,
})
"
/>
@@ -39,10 +36,32 @@ export default {
type: String,
default: '',
},
parentContactName: {
childContactName: {
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>
@@ -177,7 +177,6 @@ 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,
@@ -269,7 +268,6 @@ export default {
},
toggleConversationModal() {
this.showConversationModal = !this.showConversationModal;
bus.$emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, this.showConversationModal);
},
toggleDeleteModal() {
this.showDeleteModal = !this.showDeleteModal;
@@ -11,10 +11,7 @@
<label>
{{ $t('NEW_CONVERSATION.FORM.INBOX.LABEL') }}
</label>
<div
class="multiselect-wrap--small"
:class="{ 'has-multi-select-error': $v.targetInbox.$error }"
>
<div class="multiselect-wrap--small">
<multiselect
v-model="targetInbox"
track-by="id"
@@ -162,52 +159,9 @@
{{ $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"
@@ -219,18 +173,6 @@
{{ $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>
@@ -248,11 +190,6 @@ 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,
@@ -267,11 +204,9 @@ export default {
CannedResponse,
WhatsappTemplates,
InboxDropdownItem,
FileUpload,
AttachmentPreview,
MessageSignatureMissingAlert,
},
mixins: [alertMixin, uiSettingsMixin, inboxMixin, fileUploadMixin],
mixins: [alertMixin, uiSettingsMixin],
props: {
contact: {
type: Object,
@@ -293,7 +228,6 @@ export default {
ccEmails: '',
targetInbox: {},
whatsappTemplateSelected: false,
attachedFiles: [],
};
},
validations: {
@@ -312,7 +246,6 @@ export default {
uiFlags: 'contacts/getUIFlags',
conversationsUiFlags: 'contactConversations/getUIFlags',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
messageSignature: 'getMessageSignature',
}),
sendWithSignature() {
@@ -322,7 +255,7 @@ export default {
signatureToApply() {
return this.messageSignature;
},
newMessagePayload() {
emailMessagePayload() {
const payload = {
inboxId: this.targetInbox.id,
sourceId: this.targetInbox.sourceId,
@@ -331,12 +264,6 @@ 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;
}
@@ -400,15 +327,6 @@ 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) {
@@ -440,33 +358,6 @@ export default {
}
}
},
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');
},
@@ -490,19 +381,15 @@ export default {
return payload;
},
onFormSubmit() {
const isFromWhatsApp = false;
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
this.createConversation({
payload: this.newMessagePayload,
isFromWhatsApp,
});
this.createConversation(this.emailMessagePayload);
},
async createConversation({ payload, isFromWhatsApp }) {
async createConversation(payload) {
try {
const data = await this.onSubmit(payload, isFromWhatsApp);
const data = await this.onSubmit(payload);
const action = {
type: 'link',
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
@@ -526,9 +413,8 @@ export default {
this.whatsappTemplateSelected = val;
},
async onSendWhatsAppReply(messagePayload) {
const isFromWhatsApp = true;
const payload = this.prepareWhatsAppMessagePayload(messagePayload);
await this.createConversation({ payload, isFromWhatsApp });
await this.createConversation(payload);
},
inboxReadableIdentifier(inbox) {
return `${inbox.name} (${inbox.channel_type})`;
@@ -567,17 +453,6 @@ 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(params, isFromWhatsApp) {
const data = await this.$store.dispatch('contactConversations/create', {
params,
isFromWhatsApp,
});
async onSubmit(contactItem) {
const data = await this.$store.dispatch(
'contactConversations/create',
contactItem
);
return data;
},
},
@@ -19,7 +19,6 @@
@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 />
@@ -54,12 +53,11 @@
@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';
@@ -72,19 +70,17 @@ 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: {
AccountSelector,
AddCategory,
CommandBar,
Sidebar,
HelpCenterSidebar,
CommandBar,
WootKeyShortcutModal,
NotificationPanel,
PortalPopover,
Sidebar,
UpgradePage,
WootKeyShortcutModal,
AddCategory,
AccountSelector,
},
mixins: [portalMixin, uiSettingsMixin],
data() {
@@ -106,25 +102,13 @@ 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() {
@@ -1,135 +0,0 @@
<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,11 +220,14 @@ export default {
},
},
mounted() {
this.initializeAccount();
if (!this.id) {
this.initializeAccount();
}
},
methods: {
async initializeAccount() {
try {
await this.$store.dispatch('accounts/get');
const {
name,
locale,
@@ -72,6 +72,8 @@ export default {
},
methods: {
async fetchAccountDetails() {
await this.$store.dispatch('accounts/get');
if (!this.hasABillingPlan) {
this.$store.dispatch('accounts/subscription');
}
@@ -70,6 +70,7 @@ export default {
},
methods: {
async initializeEnabledFeatures() {
await this.$store.dispatch('accounts/get', this.accountId);
this.enabledFeatures = this.account.features;
},
initChannelAuth(channel) {
@@ -1,6 +1,6 @@
<template>
<div
class="flex-grow flex-shrink w-full min-w-0 pl-0 pr-0 overflow-auto bg-white settings dark:bg-slate-800"
class="settings bg-white dark:bg-slate-800 flex-grow flex-shrink pr-0 pl-0 overflow-auto min-w-0 w-full"
>
<setting-intro-banner
:header-image="inbox.avatarUrl"
@@ -48,6 +48,18 @@
"
@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"
@@ -133,7 +145,7 @@
}}
</option>
</select>
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
<p class="text-slate-600 dark:text-slate-400 pb-1 text-sm not-italic">
{{
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_TOGGLE.HELP_TEXT'
@@ -175,7 +187,7 @@
</option>
</select>
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
<p class="text-slate-600 dark:text-slate-400 pb-1 text-sm not-italic">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.REPLY_TIME.HELP_TEXT') }}
</p>
</label>
@@ -190,7 +202,7 @@
{{ $t('INBOX_MGMT.EDIT.EMAIL_COLLECT_BOX.DISABLED') }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
<p class="text-slate-600 dark:text-slate-400 pb-1 text-sm not-italic">
{{
$t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT')
}}
@@ -207,7 +219,7 @@
{{ $t('INBOX_MGMT.EDIT.ENABLE_CSAT.DISABLED') }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
<p class="text-slate-600 dark:text-slate-400 pb-1 text-sm not-italic">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CSAT_SUB_TEXT') }}
</p>
</label>
@@ -222,7 +234,7 @@
{{ $t('INBOX_MGMT.EDIT.ALLOW_MESSAGES_AFTER_RESOLVED.DISABLED') }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
<p class="text-slate-600 dark:text-slate-400 pb-1 text-sm not-italic">
{{
$t(
'INBOX_MGMT.SETTINGS_POPUP.ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT'
@@ -241,7 +253,7 @@
{{ $t('INBOX_MGMT.EDIT.ENABLE_CONTINUITY_VIA_EMAIL.DISABLED') }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
<p class="text-slate-600 dark:text-slate-400 pb-1 text-sm not-italic">
{{
$t(
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT'
@@ -261,7 +273,7 @@
{{ p.name }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
<p class="text-slate-600 dark:text-slate-400 pb-1 text-sm not-italic">
{{ $t('INBOX_MGMT.HELP_CENTER.SUB_TEXT') }}
</p>
</div>
@@ -275,7 +287,7 @@
{{ $t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.DISABLED') }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
<p class="text-slate-600 dark:text-slate-400 pb-1 text-sm not-italic">
{{
$t(
'INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT'
@@ -460,6 +472,7 @@ export default {
avatarFile: null,
avatarUrl: '',
greetingEnabled: true,
tweetsEnabled: true,
greetingMessage: '',
emailCollectEnabled: false,
csatSurveyEnabled: false,
@@ -655,6 +668,7 @@ 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;
@@ -700,6 +714,7 @@ 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,
},
};
@@ -1,40 +1,40 @@
<template>
<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"
:show-image-resize-toolbar="true"
@blur="$v.messageSignature.$touch"
/>
<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>
<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>
</form>
</template>
<script>
@@ -3,64 +3,6 @@ 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: {
@@ -78,25 +20,27 @@ export const getters = {
};
export const actions = {
create: async ({ commit }, { params, isFromWhatsApp }) => {
create: async ({ commit }, params) => {
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
isCreating: true,
});
const { contactId, files } = params;
const { inboxId, message, contactId, sourceId, mailSubject, assigneeId } =
params;
try {
const payload = setNewConversationPayload({
isFromWhatsApp,
params,
contactId,
files,
const { data } = await ConversationApi.create({
inbox_id: inboxId,
contact_id: contactId,
source_id: sourceId,
additional_attributes: {
mail_subject: mailSubject,
},
message,
assignee_id: assigneeId,
});
const { data } = await ConversationApi.create(payload);
commit(types.default.ADD_CONTACT_CONVERSATION, {
id: contactId,
data,
});
return data;
} catch (error) {
throw new Error(error);
@@ -1,10 +1,5 @@
import axios from 'axios';
import {
actions,
createMessagePayload,
createConversationPayload,
createWhatsAppConversationPayload,
} from '../../contactConversations';
import { actions } from '../../contactConversations';
import * as types from '../../../mutation-types';
import conversationList from './fixtures';
@@ -49,84 +44,16 @@ describe('#actions', () => {
await actions.create(
{ commit },
{
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,
inboxId: 1,
message: { content: 'hi' },
contactId: 4,
sourceId: 5,
mailSubject: 'Mail Subject',
}
);
expect(commit.mock.calls).toEqual([
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isCreating: true }],
[
types.default.ADD_CONTACT_CONVERSATION,
{ id: 4, data: conversationList[0] },
@@ -139,46 +66,16 @@ describe('#actions', () => {
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.create(
{ commit },
{
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,
inboxId: 1,
message: { content: 'hi' },
contactId: 4,
sourceId: 5,
mailSubject: 'Mail Subject',
}
)
).rejects.toThrow(Error);
@@ -192,116 +89,3 @@ 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);
});
});
@@ -9,7 +9,6 @@
</template>
<script>
// 🚨 This component is deprecated. Please use fluent-icon instead.
import { hasEmojiSupport } from 'shared/helpers/emoji';
import { mapGetters } from 'vuex';
@@ -5,7 +5,6 @@
:type="type"
:icons="icons"
:view-box="viewBox"
:icon-lib="iconLib"
/>
</template>
<script>
@@ -34,10 +33,6 @@ export default {
type: String,
default: '0 0 24 24',
},
iconLib: {
type: String,
default: 'fluent',
},
},
data() {
return { icons };
@@ -1,6 +1,5 @@
<template>
<svg
v-if="iconLib === 'fluent'"
:width="size"
:height="size"
fill="none"
@@ -14,23 +13,6 @@
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 {
@@ -55,10 +37,6 @@ export default {
type: String,
default: '0 0 24 24',
},
iconLib: {
type: String,
default: 'fluent',
},
},
computed: {
@@ -5,7 +5,6 @@
:type="type"
:icons="icons"
:view-box="viewBox"
:icon-lib="iconLib"
/>
</template>
<script>
@@ -34,10 +33,6 @@ export default {
type: String,
default: '0 0 24 24',
},
iconLib: {
type: String,
default: 'fluent',
},
},
data() {
return { icons };
@@ -224,27 +224,5 @@
"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,7 +1,6 @@
{
"arrow-clockwise-outline": "M12 4.75a7.25 7.25 0 1 0 7.201 6.406c-.068-.588.358-1.156.95-1.156.515 0 .968.358 1.03.87a9.25 9.25 0 1 1-3.432-6.116V4.25a1 1 0 1 1 2.001 0v2.698l.034.052h-.034v.25a1 1 0 0 1-1 1h-3a1 1 0 1 1 0-2h.666A7.219 7.219 0 0 0 12 4.75Z",
"arrow-right-outline": "M13.267 4.209a.75.75 0 0 0-1.034 1.086l6.251 5.955H3.75a.75.75 0 0 0 0 1.5h14.734l-6.251 5.954a.75.75 0 0 0 1.034 1.087l7.42-7.067a.996.996 0 0 0 .3-.58.758.758 0 0 0-.001-.29.995.995 0 0 0-.3-.578l-7.419-7.067Z",
"arrow-reply-outline": "M9.277 16.221a.75.75 0 0 1-1.061 1.06l-4.997-5.003a.75.75 0 0 1 0-1.06L8.217 6.22a.75.75 0 0 1 1.061 1.06L5.557 11h7.842c1.595 0 2.81.242 3.889.764l.246.126a6.203 6.203 0 0 1 2.576 2.576c.61 1.14.89 2.418.89 4.135a.75.75 0 0 1-1.5 0c0-1.484-.228-2.52-.713-3.428a4.702 4.702 0 0 0-1.96-1.96c-.838-.448-1.786-.676-3.094-.709L13.4 12.5H5.562l3.715 3.721Z",
"attach-outline": "M11.772 3.743a6 6 0 0 1 8.66 8.302l-.19.197-8.8 8.798-.036.03a3.723 3.723 0 0 1-5.489-4.973.764.764 0 0 1 .085-.13l.054-.06.086-.088.142-.148.002.003 7.436-7.454a.75.75 0 0 1 .977-.074l.084.073a.75.75 0 0 1 .074.976l-.073.084-7.594 7.613a2.23 2.23 0 0 0 3.174 3.106l8.832-8.83A4.502 4.502 0 0 0 13 4.644l-.168.16-.013.014-9.536 9.536a.75.75 0 0 1-1.133-.977l.072-.084 9.549-9.55h.002Z",
"chat-outline": "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a9.96 9.96 0 0 1-4.587-1.112l-3.826 1.067a1.25 1.25 0 0 1-1.54-1.54l1.068-3.823A9.96 9.96 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 1.5A8.5 8.5 0 0 0 3.5 12c0 1.47.373 2.883 1.073 4.137l.15.27-1.112 3.984 3.987-1.112.27.15A8.5 8.5 0 1 0 12 3.5ZM8.75 13h4.498a.75.75 0 0 1 .102 1.493l-.102.007H8.75a.75.75 0 0 1-.102-1.493L8.75 13h4.498H8.75Zm0-3.5h6.505a.75.75 0 0 1 .101 1.493l-.101.007H8.75a.75.75 0 0 1-.102-1.493L8.75 9.5h6.505H8.75Z",
"checkmark-outline": "M4.53 12.97a.75.75 0 0 0-1.06 1.06l4.5 4.5a.75.75 0 0 0 1.06 0l11-11a.75.75 0 0 0-1.06-1.06L8.5 16.94l-3.97-3.97Z",
+1 -1
View File
@@ -1,4 +1,5 @@
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',
@@ -10,5 +11,4 @@ export const BUS_EVENTS = {
WEBSOCKET_DISCONNECT: 'WEBSOCKET_DISCONNECT',
TOGGLE_REPLY_TO_MESSAGE: 'TOGGLE_REPLY_TO_MESSAGE',
SHOW_TOAST: 'newToastMessage',
NEW_CONVERSATION_MODAL: 'newConversationModal',
};
@@ -146,12 +146,3 @@ 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,4 +7,5 @@ export const MESSAGE_MAX_LENGTH = {
FACEBOOK: 1000,
TWILIO_SMS: 320,
TWILIO_WHATSAPP: 1600,
TWEET: 280,
};
@@ -13,23 +13,6 @@
/>
</div>
<div class="message-wrap">
<div class="flex mb-1 text-xs">
<button
v-if="replyTo && (replyTo.content || replyTo.attachments)"
class="px-1.5 py-0.5 rounded-md text-slate-600 bg-slate-50 hover:bg-slate-100 hover:text-slate-800 cursor-pointer flex items-center gap-1.5"
@click="navigateTo(replyTo.id)"
>
<FluentIcon icon="arrow-reply" size="12" class="flex-shrink-0" />
<div
v-if="replyTo.content"
v-dompurify-html="formatMessage(replyTo.content, false)"
class="reply-to-truncate"
/>
<div v-else-if="replyTo.attachments" class="reply-to-truncate">
<p>{{ replyToAttachment }}</p>
</div>
</button>
</div>
<AgentMessageBubble
v-if="shouldDisplayAgentMessage"
:content-type="contentType"
@@ -89,8 +72,6 @@ import configMixin from '../mixins/configMixin';
import messageMixin from '../mixins/messageMixin';
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
export default {
name: 'AgentMessage',
@@ -100,24 +81,13 @@ export default {
Thumbnail,
UserMessage,
FileBubble,
FluentIcon,
},
mixins: [
timeMixin,
configMixin,
messageMixin,
darkModeMixin,
messageFormatterMixin,
],
mixins: [timeMixin, configMixin, messageMixin, darkModeMixin],
props: {
message: {
type: Object,
default: () => {},
},
replyTo: {
type: Object,
default: () => {},
},
},
data() {
return {
@@ -210,14 +180,6 @@ export default {
'has-text': this.shouldDisplayAgentMessage,
};
},
replyToAttachment() {
if (!this.replyTo?.attachments.length) {
return '';
}
const [{ file_type: fileType } = {}] = this.replyTo.attachments;
return this.$t(`ATTACHMENTS.${fileType}.CONTENT`);
},
},
watch: {
message() {
@@ -231,18 +193,6 @@ export default {
onImageLoadError() {
this.hasImageError = true;
},
navigateTo(id) {
const elementId = `cwmsg-${id}`;
this.$nextTick(() => {
document.getElementById(elementId).scrollIntoView();
});
},
},
};
</script>
<style lang="scss">
.reply-to-truncate > p {
@apply overflow-hidden truncate max-w-[8rem];
}
</style>
@@ -1,22 +1,11 @@
<template>
<UserMessage
v-if="isUserMessage"
:id="`cwmsg-${message.id}`"
:message="message"
:reply-to="replyTo"
/>
<AgentMessage
v-else
:id="`cwmsg-${message.id}`"
:message="message"
:reply-to="replyTo"
/>
<UserMessage v-if="isUserMessage" :message="message" />
<AgentMessage v-else :message="message" />
</template>
<script>
import AgentMessage from 'widget/components/AgentMessage.vue';
import UserMessage from 'widget/components/UserMessage.vue';
import { mapGetters } from 'vuex';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
export default {
@@ -31,16 +20,9 @@ export default {
},
},
computed: {
...mapGetters({
allMessages: 'conversation/getConversation',
}),
isUserMessage() {
return this.message.message_type === MESSAGE_TYPE.INCOMING;
},
replyTo() {
const replyTo = this.message?.content_attributes?.in_reply_to;
return replyTo ? this.allMessages[replyTo] : null;
},
},
};
</script>
+2 -44
View File
@@ -1,49 +1,7 @@
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
def perform(params, integration_hook)
Integrations::Slack::SlackLinkUnfurlService.new(params: params, integration_hook: integration_hook).perform
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,11 +22,6 @@ 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?
@@ -1 +0,0 @@
json.partial! 'api/v1/models/contact', formats: [:json], resource: @base_contact
+2 -62
View File
@@ -2,7 +2,7 @@
# Description: Install and manage a Chatwoot installation.
# OS: Ubuntu 20.04 LTS
# Script Version: 2.5.0
# Script Version: 2.4.0
# Run this script as root
set -eu -o errexit -o pipefail -o noclobber -o nounset
@@ -19,9 +19,8 @@ fi
# option --output/-o requires 1 argument
LONGOPTS=console,debug,help,install,Install:,logs:,restart,ssl,upgrade,webserver,version
OPTIONS=cdhiI:l:rsuwv
CWCTL_VERSION="2.5.0"
CWCTL_VERSION="2.4.0"
pg_pass=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 15 ; echo '')
CHATWOOT_HUB_URL="https://hub.2.chatwoot.com/events"
# if user does not specify an option
if [ "$#" -eq 0 ]; then
@@ -849,56 +848,6 @@ function webserver() {
#TODO(@vn): allow installing nginx only without SSL
}
##############################################################################
# Report cwctl events to hub
# Globals:
# CHATWOOT_HUB_URL
# Arguments:
# event_name: Name of the event to report
# event_data: Data to report
# installation_identifier: Installation identifier
# Outputs:
# None
##############################################################################
function report_event() {
local event_name="$1"
local event_data="$2"
CHATWOOT_HUB_URL="https://hub.2.chatwoot.com/events"
# get installation identifier
local installation_identifier=$(get_installation_identifier)
# Prepare the data for the request
local data="{\"installation_identifier\":\"$installation_identifier\",\"event_name\":\"$event_name\",\"event_data\":{\"action\":\"$event_data\"}}"
# Make the curl request to report the event
curl -X POST -H "Content-Type: application/json" -d "$data" "$CHATWOOT_HUB_URL" -s -o /dev/null
}
##############################################################################
# Get installation identifier
# Globals:
# None
# Arguments:
# None
# Outputs:
# installation_identifier
##############################################################################
function get_installation_identifier() {
local installation_identifier
installation_identifier=$(sudo -i -u chatwoot << "EOF"
cd chatwoot
RAILS_ENV=production bundle exec rake instance_id:get_installation_identifier
EOF
)
echo "$installation_identifier"
}
##############################################################################
# Print cwctl version (-v/--version)
# Globals:
@@ -925,47 +874,38 @@ function main() {
setup_logging
if [ "$c" == "y" ]; then
report_event "cwctl" "console" > /dev/null 2>&1
get_console
fi
if [ "$h" == "y" ]; then
report_event "cwctl" "help" > /dev/null 2>&1
help
fi
if [ "$i" == "y" ] || [ "$I" == "y" ]; then
install
report_event "cwctl" "install" > /dev/null 2>&1
fi
if [ "$l" == "y" ]; then
report_event "cwctl" "logs" > /dev/null 2>&1
get_logs
fi
if [ "$r" == "y" ]; then
report_event "cwctl" "restart" > /dev/null 2>&1
restart
fi
if [ "$s" == "y" ]; then
report_event "cwctl" "ssl" > /dev/null 2>&1
ssl
fi
if [ "$u" == "y" ]; then
report_event "cwctl" "upgrade" > /dev/null 2>&1
upgrade
fi
if [ "$w" == "y" ]; then
report_event "cwctl" "webserver" > /dev/null 2>&1
webserver
fi
if [ "$v" == "y" ]; then
report_event "cwctl" "version" > /dev/null 2>&1
version
fi
@@ -3,8 +3,8 @@ class Enterprise::MessageTemplates::ResponseBotService
def perform
ActiveRecord::Base.transaction do
@response = get_response(conversation.messages.last.content)
process_response
response = get_response(conversation.messages.last.content)
process_response(response['response'])
end
rescue StandardError => e
process_action('handoff') # something went wrong, pass to agent
@@ -44,11 +44,11 @@ class Enterprise::MessageTemplates::ResponseBotService
message.message_type == 'incoming' ? 'user' : 'system'
end
def process_response
if @response['response'] == 'conversation_handoff'
def process_response(response)
if response == 'conversation_handoff'
process_action('handoff')
else
create_messages
create_messages(response)
end
end
@@ -61,46 +61,61 @@ class Enterprise::MessageTemplates::ResponseBotService
end
end
def create_messages
message_content = @response['response']
message_content += generate_sources_section if @response['context_ids'].present?
create_outgoing_message(message_content)
def create_messages(response)
response = process_response_content(response).first
create_outgoing_message(response)
end
def generate_sources_section
article_ids = @response['context_ids']
sources_content = ''
def process_response_content(response)
# Regular expression to match '{context_ids: [ids]}'
regex = /{context_ids: \[(\d+(?:, *\d+)*)\]}/
articles_hash = get_article_hash(article_ids.uniq)
# Extract ids from string
id_string = response[regex, 1] # This will give you '42, 43'
article_ids = id_string.split(',').map(&:to_i) if id_string # This will give you [42, 43]
articles_hash.first(3).each do |article_hash|
sources_content += " - [#{article_hash[:response].question}](#{article_hash[:response_document].document_link}) \n"
end
sources_content = "\n \n \n **Sources** \n#{sources_content}" if sources_content.present?
sources_content
# Remove '{context_ids: [ids]}' from string
response = response.sub(regex, '')
[response, article_ids]
end
def create_outgoing_message(message_content)
def create_outgoing_message(response)
conversation.messages.create!(
{
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: message_content
content: response
}
)
end
def create_outgoing_message_with_cards(article_ids, conversation)
content_attributes = get_article_hash(article_ids.uniq)
return if content_attributes.blank?
conversation.messages.create!(
{
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: 'suggested articles',
content_type: 'article',
content_attributes: content_attributes
}
)
end
def get_article_hash(article_ids)
seen_documents = Set.new
article_ids.uniq.filter_map do |article_id|
items = []
article_ids.each do |article_id|
response = Response.find(article_id)
response_document = response.response_document
next if response_document.blank? || seen_documents.include?(response_document)
next if response.nil?
seen_documents << response_document
{ response: response, response_document: response_document }
items << { title: response.question, description: response.answer[0, 120], link: response.response_document.document_link }
end
items.present? ? { items: items } : {}
end
end
@@ -8,7 +8,7 @@ class Openai::EmbeddingsService
def fetch_embeddings(input)
url = 'https://api.openai.com/v1/embeddings'
headers = {
'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY', '')}",
'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY')}",
'Content-Type' => 'application/json'
}
data = {
@@ -17,6 +17,6 @@ class Openai::EmbeddingsService
}
response = Net::HTTP.post(URI(url), data.to_json, headers)
JSON.parse(response.body)['data']&.pick('embedding')
JSON.parse(response.body)['data'].pick('embedding')
end
end
@@ -19,7 +19,7 @@ class Integrations::Slack::IncomingMessageBuilder
elsif create_message?
create_message
elsif link_shared?
SlackUnfurlJob.perform_later(params)
SlackUnfurlJob.perform_later(params, integration_hook)
end
end
@@ -72,7 +72,7 @@ class Integrations::Slack::IncomingMessageBuilder
end
def link_shared?
params[:event][:type] == 'link_shared'
params[:event][:type] == 'link_shared' && integration_hook
end
def message
-8
View File
@@ -1,8 +0,0 @@
namespace :instance_id do
desc 'Get the installation identifier'
task :get_installation_identifier => :environment do
identifier = InstallationConfig.find_by(name: 'INSTALLATION_IDENTIFIER')&.value
identifier ||= InstallationConfig.create!(name: 'INSTALLATION_IDENTIFIER', value: SecureRandom.uuid).value
puts identifier
end
end
+1 -1
View File
@@ -30,7 +30,7 @@
],
"dependencies": {
"@braid/vue-formulate": "^2.5.2",
"@chatwoot/prosemirror-schema": "1.0.3",
"@chatwoot/prosemirror-schema": "^1.0.1",
"@chatwoot/utils": "^0.0.16",
"@hcaptcha/vue-hcaptcha": "^0.3.2",
"@june-so/analytics-next": "^1.36.5",
@@ -4,62 +4,27 @@ RSpec.describe Enterprise::MessageTemplates::ResponseBotService, type: :service
let!(:conversation) { create(:conversation, status: :pending) }
let(:service) { described_class.new(conversation: conversation) }
let(:chat_gpt_double) { instance_double(ChatGpt) }
let(:response_source) { create(:response_source, account: conversation.account) }
let(:response_object) { instance_double(Response, id: 1, question: 'Q1', answer: 'A1') }
before do
# Uncomment if you want to run the spec in your local machine
# Features::ResponseBotService.new.enable_in_installation
skip('Skipping since vector is not enabled in this environment') unless Features::ResponseBotService.new.vector_extension_enabled?
stub_request(:post, 'https://api.openai.com/v1/embeddings').to_return(status: 200, body: {}.to_json,
headers: { Content_Type: 'application/json' })
create(:message, message_type: :incoming, conversation: conversation, content: 'Hi')
4.times { create(:response, response_source: response_source) }
allow(ChatGpt).to receive(:new).and_return(chat_gpt_double)
allow(chat_gpt_double).to receive(:generate_response).and_return({ 'response' => 'some_response', 'context_ids' => Response.all.map(&:id) })
allow(chat_gpt_double).to receive(:generate_response).and_return({ 'response' => 'some_response', :context_ids => [1, 2] })
allow(conversation.inbox).to receive(:get_responses).and_return([response_object])
end
describe '#perform' do
context 'when successful' do
it 'creates an outgoing message along with article references' do
it 'creates an outgoing message' do
expect do
service.perform
end.to change { conversation.messages.where(message_type: :outgoing).count }.by(1)
last_message = conversation.messages.last
expect(last_message.content).to include('some_response')
expect(last_message.content).to include(Response.first.question)
expect(last_message.content).to include('**Sources**')
end
end
context 'when context_ids are not present' do
it 'creates an outgoing message without article references' do
allow(chat_gpt_double).to receive(:generate_response).and_return({ 'response' => 'some_response' })
expect do
service.perform
end.to change { conversation.messages.where(message_type: :outgoing).count }.by(1)
last_message = conversation.messages.last
expect(last_message.content).to include('some_response')
expect(last_message.content).not_to include('**Sources**')
end
end
context 'when response doesnt have response document' do
it 'creates an outgoing message without article references' do
response = create(:response, response_source: response_source, response_document: nil)
allow(chat_gpt_double).to receive(:generate_response).and_return({ 'response' => 'some_response', 'context_ids' => [response.id] })
expect do
service.perform
end.to change { conversation.messages.where(message_type: :outgoing).count }.by(1)
last_message = conversation.messages.last
expect(last_message.content).to include('some_response')
expect(last_message.content).not_to include('**Sources**')
expect(conversation.messages.last.content).to eq('some_response')
end
end
-11
View File
@@ -1,11 +0,0 @@
# frozen_string_literal: true
FactoryBot.define do
factory :response do
response_document
response_source
question { Faker::Lorem.sentence }
answer { Faker::Lorem.paragraph }
account
end
end
-10
View File
@@ -1,10 +0,0 @@
# frozen_string_literal: true
FactoryBot.define do
factory :response_document do
response_source
content { Faker::Lorem.paragraph }
document_link { Faker::Internet.url }
account
end
end
+12 -79
View File
@@ -7,22 +7,15 @@ RSpec.describe SlackUnfurlJob do
let(:hook) { create(:integrations_hook, account: account) }
let(:inbox) { create(:inbox, account: account) }
let!(:conversation) { create(:conversation, inbox: inbox) }
let(:slack_client) { double }
let(:link_shared) do
{
team_id: 'TLST3048H',
api_app_id: 'A012S5UETV4',
event: {
type: 'link_shared',
user: 'ULYPAKE5S',
source: 'conversations_history',
unfurl_id: 'C7NQEAE5Q.1695111587.937099.7e240338c6d2053fb49f56808e7c1f619f6ef317c39ebc59fc4af1cc30dce49b',
channel: 'G01354F6A6Q',
links: [{
url: "https://qa.chatwoot.com/app/accounts/#{hook.account_id}/conversations/#{conversation.display_id}",
domain: 'qa.chatwoot.com'
}]
},
event: link_shared_event.merge(links: [{
url: "https://qa.chatwoot.com/app/accounts/1/conversations/#{conversation.display_id}",
domain: 'qa.chatwoot.com'
}]),
type: 'event_callback',
event_time: 1_588_623_033
}
@@ -33,73 +26,13 @@ RSpec.describe SlackUnfurlJob do
.on_queue('low')
end
context 'when the calls the slack unfurl job' do
let(:slack_link_unfurl_service) { instance_double(Integrations::Slack::SlackLinkUnfurlService) }
it 'calls the SlackLinkUnfurlService' do
slack_link_unfurl_service = instance_double(Integrations::Slack::SlackLinkUnfurlService)
before do
allow(Integrations::Slack::SlackLinkUnfurlService).to receive(:new)
.with(params: link_shared, integration_hook: hook)
.and_return(slack_link_unfurl_service)
end
context 'when the URL is shared in the channel' do
let(:expected_body) { { channel: link_shared[:event][:channel] } }
before do
stub_request(:post, 'https://slack.com/api/conversations.members')
.with(body: expected_body)
.to_return(status: 200, body: { 'ok' => true }.to_json, headers: {})
end
it 'does the unfurl' do
expect(slack_link_unfurl_service).to receive(:perform)
described_class.perform_now(link_shared)
end
end
context 'when the URL is shared in a different channel under the same account' do
let(:expected_body) { { channel: 'XSDSFSFS' } }
before do
link_shared[:event][:channel] = 'XSDSFSFS'
stub_request(:post, 'https://slack.com/api/conversations.members')
.with(body: expected_body)
.to_return(status: 200, body: { 'ok' => true }.to_json, headers: {})
end
it 'does the unfurl' do
expect(slack_link_unfurl_service).to receive(:perform)
described_class.perform_now(link_shared)
end
end
context 'when another account URL is shared' do
before do
link_shared[:event][:links][0][:url] = 'https://qa.chatwoot.com/app/accounts/123/conversations/123'
end
it 'does not unfurl' do
expect(Integrations::Slack::SlackLinkUnfurlService).not_to receive(:new)
expect(slack_link_unfurl_service).not_to receive(:perform)
described_class.perform_now(link_shared)
end
end
context 'when the URL is shared in a channel under a different account' do
let(:expected_body) { { channel: 'GDF4F6A6Q' } }
before do
link_shared[:event][:channel] = 'GDF4F6A6Q'
stub_request(:post, 'https://slack.com/api/conversations.members')
.with(body: expected_body)
.to_return(status: 404, body: { 'ok' => false }.to_json, headers: {})
end
it 'does not unfurl' do
expect(Integrations::Slack::SlackLinkUnfurlService).not_to receive(:new)
expect(slack_link_unfurl_service).not_to receive(:perform)
described_class.perform_now(link_shared)
end
end
expect(Integrations::Slack::SlackLinkUnfurlService).to receive(:new)
.with(params: link_shared, integration_hook: hook)
.and_return(slack_link_unfurl_service)
expect(slack_link_unfurl_service).to receive(:perform)
described_class.perform_now(link_shared, hook)
end
end
@@ -163,7 +163,7 @@ describe Integrations::Slack::IncomingMessageBuilder do
it 'unfurls link' do
builder = described_class.new(link_shared)
expect(SlackUnfurlJob).to receive(:perform_later).with(link_shared)
expect(SlackUnfurlJob).to receive(:perform_later).with(link_shared, hook)
builder.perform
end
end
+2
View File
@@ -40,4 +40,6 @@ describe ActionService do
expect(conversation.reload.assignee).to be_nil
end
end
# TODO: Expand this test suite
end
@@ -140,20 +140,19 @@ describe Conversations::FilterService do
{
attribute_key: 'assignee_id',
filter_operator: 'equal_to',
values: [user_1.id, user_2.id],
query_operator: 'AND'
values: [
user_1.id,
user_2.id
],
query_operator: 'AND',
custom_attribute_type: ''
}.with_indifferent_access,
{
attribute_key: 'labels',
filter_operator: 'equal_to',
values: ['support'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'labels',
filter_operator: 'not_equal_to',
values: ['random-label'],
query_operator: nil
query_operator: nil,
custom_attribute_type: ''
}.with_indifferent_access
]
result = filter_service.new(params, user_1).perform
@@ -1,150 +0,0 @@
require 'rails_helper'
RSpec.describe Macros::ExecutionService, type: :service do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:user) { create(:user, account: account) }
let(:macro) { create(:macro, account: account) }
let(:service) { described_class.new(macro, conversation, user) }
before do
create(:inbox_member, user: user, inbox: conversation.inbox)
end
describe '#perform' do
context 'when actions are present' do
before do
allow(macro).to receive(:actions).and_return([
{ action_name: 'assign_agent', action_params: ['self'] },
{ action_name: 'add_private_note', action_params: ['Test note'] },
{ action_name: 'send_message', action_params: ['Test message'] },
{ action_name: 'send_attachment', action_params: [1, 2] }
])
end
it 'executes the actions' do
expect(service).to receive(:assign_agent).with(['self']).and_call_original
expect(service).to receive(:add_private_note).with(['Test note']).and_call_original
expect(service).to receive(:send_message).with(['Test message']).and_call_original
expect(service).to receive(:send_attachment).with([1, 2]).and_call_original
service.perform
end
context 'when an action raises an error' do
let(:exception_tracker) { instance_spy(ChatwootExceptionTracker) }
before do
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
end
it 'captures the exception' do
allow(service).to receive(:assign_agent).and_raise(StandardError.new('Random error'))
expect(exception_tracker).to receive(:capture_exception)
service.perform
end
end
end
end
describe '#assign_agent' do
context 'when agent_ids contains self' do
it 'updates the conversation assignee to the current user' do
service.send(:assign_agent, ['self'])
expect(conversation.reload.assignee).to eq(user)
end
end
context 'when agent_ids does not contain self' do
let(:other_user) { create(:user, account: account) }
before do
create(:inbox_member, user: other_user, inbox: conversation.inbox)
end
it 'calls the super method' do
service.send(:assign_agent, [other_user.id])
expect(conversation.reload.assignee).to eq(other_user)
end
end
end
describe '#add_private_note' do
context 'when conversation is not a tweet' do
it 'creates a new private message' do
expect do
service.send(:add_private_note, ['Test private note'])
end.to change(Message, :count).by(1)
message = Message.last
expect(message.content).to eq('Test private note')
expect(message.private).to be(true)
end
end
context 'when conversation is a tweet' do
before { allow(service).to receive(:conversation_a_tweet?).and_return(true) }
it 'does not create a new message' do
expect do
service.send(:add_private_note, ['Test private note'])
end.not_to change(Message, :count)
end
end
end
describe '#send_message' do
context 'when conversation is not a tweet' do
it 'creates a new public message' do
expect do
service.send(:send_message, ['Test message'])
end.to change(Message, :count).by(1)
message = Message.last
expect(message.content).to eq('Test message')
expect(message.private).to be(false)
end
end
context 'when conversation is a tweet' do
before { allow(service).to receive(:conversation_a_tweet?).and_return(true) }
it 'does not create a new message' do
expect do
service.send(:send_message, ['Test message'])
end.not_to change(Message, :count)
end
end
end
describe '#send_attachment' do
before do
macro.files.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
macro.save!
end
context 'when conversation is not a tweet and macro has files attached' do
before { allow(service).to receive(:conversation_a_tweet?).and_return(false) }
it 'creates a new message with attachments' do
expect do
service.send(:send_attachment, [macro.files.first.blob_id])
end.to change(Message, :count).by(1)
message = Message.last
expect(message.attachments).to be_present
end
end
context 'when conversation is a tweet or macro has no files attached' do
before { allow(service).to receive(:conversation_a_tweet?).and_return(true) }
it 'does not create a new message' do
expect do
service.send(:send_attachment, [macro.files.first.blob_id])
end.not_to change(Message, :count)
end
end
end
end
+4 -4
View File
@@ -3156,10 +3156,10 @@
"@braid/vue-formulate-i18n" "^1.16.0"
is-plain-object "^3.0.1"
"@chatwoot/prosemirror-schema@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.3.tgz#8599e517d42cb31fabf977554ade666eb6316ef0"
integrity sha512-BIVxV7c8x0vbWtWxGPk/VnBrtC1CV0TzZd+GPZC49irVcQQ2vAwgOYaZ/1qcFe9M3jv0kWAWOPqQAfbB5RBB7g==
"@chatwoot/prosemirror-schema@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.1.tgz#7beb7a9303fbf5281d3a7e6c470ac78cce21be04"
integrity sha512-fnT2zSzAmiAh1ElEWqBhISavGZF73DLUzQyml0iiXDy6IU7HS3TtQPI/AGoCK3CkCxvoqBtzhRLGZrHsftoQ9w==
dependencies:
markdown-it-sup "^1.0.0"
prosemirror-commands "^1.1.4"