Compare commits

..
Author SHA1 Message Date
Muhsin Keloth 5343b3f8ae spec cleanup 2024-03-15 00:26:07 +05:30
Muhsin Keloth 11ae25535a code cleanup 2024-03-15 00:14:36 +05:30
Muhsin Keloth d8708e5ee5 Update availabilityHelper.js 2024-03-14 23:45:26 +05:30
Muhsin Keloth 3efdd446a9 Update availabilityHelper.js 2024-03-14 23:41:43 +05:30
Muhsin Keloth 85d970754b cleanup 2024-03-14 23:38:21 +05:30
Muhsin Keloth 7cc6adbd9e feat: add availabilityHelper 2024-03-14 15:16:23 +05:30
Muhsin Keloth 6cad9c683a add helper 2024-03-14 15:13:09 +05:30
29171565ed feat: Add image preview for inline images in vue-letter (#9099)
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-03-13 18:46:35 -07:00
55fcbe2dde feat: Add the ability to zoom on chat images (#9093)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-03-13 18:04:42 -07:00
Vishnu NarayananandGitHub 7f4b2d66d4 feat: add activity message for SLA (#9100)
* feat: add activity message for SLA

* chore: refactor to reduce method complexity

* chore: refactor

* feat: add spec for SLAactivity message

* chore: move SLA spec to enterprise folder

* chore: move team activity methods to a separate module

* chore: fix typo

* chore: move sla activity message spec to conversation model
2024-03-13 20:05:34 +05:30
Muhsin KelothandGitHub 804a42c271 feat: Update the location and country_code when creating or updating the contact (#9106)
* feat: Update the location and country_code when creating or updating the contact.

* chore: improve comments
2024-03-13 16:40:21 +05:30
Vishnu NarayananandGitHub 561fafa198 feat: Update icons for the SLA feature in dashboard (#9101) 2024-03-12 12:45:46 -07:00
32 changed files with 677 additions and 179 deletions
-1
View File
@@ -62,7 +62,6 @@ Metrics/ModuleLength:
Exclude:
- lib/seeders/message_seeder.rb
- spec/support/slack_stubs.rb
- app/mailboxes/mailbox_helper.rb
Rails/ApplicationController:
Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb'
@@ -3,7 +3,6 @@
# for contact inbox logic it uses the contact inbox builder
class ContactInboxWithContactBuilder
include ContactHelper
pattr_initialize [:inbox!, :contact_attributes!, :source_id, :hmac_verified]
def perform
@@ -50,12 +49,8 @@ class ContactInboxWithContactBuilder
end
def create_contact
# TODO: Consider name as first_name and we will change the name to full_name in the future
name, middle_name, last_name = extract_name_parts
account.contacts.create!(
name: name,
middle_name: middle_name,
last_name: last_name,
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email],
identifier: contact_attributes[:identifier],
@@ -64,20 +59,6 @@ class ContactInboxWithContactBuilder
)
end
def extract_name_parts
# Return a generated name if name attribute is blank
return [::Haikunator.haikunate(1000), '', ''] if contact_attributes[:name].blank?
# Return the all name parts if middle_name or last_name is present
if contact_attributes[:middle_name].present? || contact_attributes[:last_name].present?
return [contact_attributes[:name], contact_attributes[:middle_name], contact_attributes[:last_name]]
end
# If name is present, split it into first and last name
name_parts = parse_name(contact_attributes[:name])
[name_parts[:first_name], name_parts[:middle_name], name_parts[:last_name]]
end
def find_contact
contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email])
@@ -104,8 +104,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def process_contact_params_result(result)
{
name: result['first_name'] || 'John',
last_name: result['last_name'] || 'Doe',
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
account_id: @inbox.account_id,
avatar_url: result['profile_pic']
}
+1 -1
View File
@@ -31,7 +31,7 @@ module ContactHelper
end
def valid_number?(full_name)
TelephoneNumber.parse(full_name).valid?
full_name.gsub(/\s+/, '').match?(/\A\+?\d+\z/)
end
def single_word_name_hash(full_name)
@@ -160,7 +160,7 @@ const settings = accountId => ({
beta: true,
},
{
icon: 'key',
icon: 'document-list-clock',
label: 'SLA',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/sla/list`),
@@ -7,11 +7,12 @@
}"
>
<div v-if="!isEmail" v-dompurify-html="message" class="text-content" />
<letter
v-else
class="text-content bg-white dark:bg-white text-slate-900 dark:text-slate-900 p-2 rounded-[4px]"
:html="message"
/>
<div v-else @click="handleClickOnContent">
<letter
class="text-content bg-white dark:bg-white text-slate-900 dark:text-slate-900 p-2 rounded-[4px]"
:html="message"
/>
</div>
<button
v-if="showQuoteToggle"
class="text-slate-300 dark:text-slate-300 cursor-pointer text-xs py-1"
@@ -26,14 +27,23 @@
{{ $t('CHAT_LIST.SHOW_QUOTED_TEXT') }}
</span>
</button>
<gallery-view
v-if="showGalleryViewer"
:show.sync="showGalleryViewer"
:attachment="attachment"
:all-attachments="availableAttachments"
@error="onClose"
@close="onClose"
/>
</div>
</template>
<script>
import Letter from 'vue-letter';
import GalleryView from '../components/GalleryView.vue';
export default {
components: { Letter },
components: { Letter, GalleryView },
props: {
message: {
type: String,
@@ -51,6 +61,9 @@ export default {
data() {
return {
showQuotedContent: false,
showGalleryViewer: false,
attachment: {},
availableAttachments: [],
};
},
computed: {
@@ -71,6 +84,33 @@ export default {
toggleQuotedContent() {
this.showQuotedContent = !this.showQuotedContent;
},
handleClickOnContent(event) {
// if event target is IMG and not close in A tag
// then open image preview
const isImageElement = event.target.tagName === 'IMG';
const isWrappedInLink = event.target.closest('A');
if (isImageElement && !isWrappedInLink) {
this.openImagePreview(event.target.src);
}
},
openImagePreview(src) {
this.showGalleryViewer = true;
this.attachment = {
file_type: 'image',
data_url: src,
message_id: Math.floor(Math.random() * 100),
};
this.availableAttachments = [{ ...this.attachment }];
},
onClose() {
this.showGalleryViewer = false;
this.resetAttachmentData();
},
resetAttachmentData() {
this.attachment = {};
this.availableAttachments = [];
},
},
};
</script>
@@ -82,6 +122,7 @@ export default {
ol {
padding-left: var(--space-two);
}
table {
margin: 0;
border: 0;
@@ -8,45 +8,66 @@
>
<div
v-on-clickaway="onClose"
class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit]"
class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit] overflow-hidden"
@click="onClose"
>
<div class="items-center flex h-16 justify-between py-2 px-6 w-full">
<div class="items-center flex justify-start min-w-[15rem]" @click.stop>
<div
class="bg-white dark:bg-slate-900 z-10 flex items-center justify-between w-full h-16 px-6 py-2"
@click.stop
>
<div
v-if="senderDetails"
class="items-center flex justify-start min-w-[15rem]"
>
<thumbnail
v-if="senderDetails.avatar"
:username="senderDetails.name"
:src="senderDetails.avatar"
/>
<div
class="flex items-start flex-col justify-center ml-2 rtl:ml-0 rtl:mr-2"
class="flex flex-col items-start justify-center ml-2 rtl:ml-0 rtl:mr-2"
>
<h3 class="text-base inline-block leading-[1.4] m-0 p-0 capitalize">
<span
class="text-slate-800 dark:text-slate-100 overflow-hidden whitespace-nowrap text-ellipsis"
class="overflow-hidden text-slate-800 dark:text-slate-100 whitespace-nowrap text-ellipsis"
>
{{ senderDetails.name }}
</span>
</h3>
<span
class="text-xs m-0 p-0 text-slate-400 dark:text-slate-200 overflow-hidden whitespace-nowrap text-ellipsis"
class="p-0 m-0 overflow-hidden text-xs text-slate-400 dark:text-slate-200 whitespace-nowrap text-ellipsis"
>
{{ readableTime }}
</span>
</div>
</div>
<div
class="items-center text-slate-700 dark:text-slate-100 flex font-semibold justify-start min-w-0 p-1 w-auto text-sm"
class="flex items-center justify-start w-auto min-w-0 p-1 text-sm font-semibold text-slate-700 dark:text-slate-100"
>
<span
class="text-slate-700 dark:text-slate-100 overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ fileNameFromDataUrl }}
</span>
v-dompurify-html="fileNameFromDataUrl"
class="overflow-hidden text-slate-700 dark:text-slate-100 whitespace-nowrap text-ellipsis"
/>
</div>
<div
class="items-center flex gap-2 justify-end min-w-[8rem] sm:min-w-[15rem]"
@click.stop
>
<woot-button
v-if="isImage"
size="large"
color-scheme="secondary"
variant="clear"
icon="zoom-in"
@click="onZoom(0.1)"
/>
<woot-button
v-if="isImage"
size="large"
color-scheme="secondary"
variant="clear"
icon="zoom-out"
@click="onZoom(-0.1)"
/>
<woot-button
v-if="isImage"
size="large"
@@ -79,10 +100,11 @@
/>
</div>
</div>
<div class="items-center flex h-full justify-center w-full">
<div class="flex items-center justify-center w-full h-full">
<div class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
<woot-button
v-if="hasMoreThanOneAttachment"
class="z-10"
size="large"
variant="smooth"
color-scheme="primary"
@@ -96,15 +118,16 @@
"
/>
</div>
<div class="flex items-center flex-col justify-center w-full h-full">
<div class="flex flex-col items-center justify-center w-full h-full">
<div>
<img
v-if="isImage"
:key="activeAttachment.message_id"
:src="activeAttachment.data_url"
class="modal-image skip-context-menu my-0 mx-auto"
class="mx-auto my-0 duration-150 ease-in-out transform modal-image skip-context-menu"
:style="imageRotationStyle"
@click.stop
@click.stop="onClickZoomImage"
@wheel.stop="onWheelImageZoom"
/>
<video
v-if="isVideo"
@@ -112,7 +135,7 @@
:src="activeAttachment.data_url"
controls
playsInline
class="modal-video skip-context-menu my-0 mx-auto"
class="mx-auto my-0 modal-video skip-context-menu"
@click.stop
/>
<audio
@@ -129,6 +152,7 @@
<div class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
<woot-button
v-if="hasMoreThanOneAttachment"
class="z-10"
size="large"
variant="smooth"
color-scheme="primary"
@@ -143,7 +167,7 @@
/>
</div>
</div>
<div class="items-center flex h-16 justify-center w-full py-2 px-6">
<div class="flex items-center justify-center w-full h-16 px-6 py-2 z-10">
<div
class="items-center rounded-sm flex font-semibold justify-center min-w-[5rem] p-1 bg-slate-25 dark:bg-slate-800 text-slate-600 dark:text-slate-200 text-sm"
>
@@ -174,6 +198,9 @@ const ALLOWED_FILE_TYPES = {
AUDIO: 'audio',
};
const MAX_ZOOM_LEVEL = 2;
const MIN_ZOOM_LEVEL = 1;
export default {
components: {
Thumbnail,
@@ -195,6 +222,7 @@ export default {
},
data() {
return {
zoomScale: 1,
activeAttachment: {},
activeFileType: '',
activeImageIndex:
@@ -212,12 +240,9 @@ export default {
return this.allAttachments.length > 1;
},
readableTime() {
if (!this.activeAttachment.created_at) return '';
const time = this.messageTimestamp(
this.activeAttachment.created_at,
'LLL d yyyy, h:mm a'
);
return time || '';
const { created_at: createdAt } = this.activeAttachment;
if (!createdAt) return '';
return this.messageTimestamp(createdAt, 'LLL d yyyy, h:mm a') || '';
},
isImage() {
return this.activeFileType === ALLOWED_FILE_TYPES.IMAGE;
@@ -246,11 +271,12 @@ export default {
const { data_url: dataUrl } = this.activeAttachment;
if (!dataUrl) return '';
const fileName = dataUrl?.split('/').pop();
return fileName || '';
return decodeURIComponent(fileName || '');
},
imageRotationStyle() {
return {
transform: `rotate(${this.activeImageRotation}deg)`,
transform: `rotate(${this.activeImageRotation}deg) scale(${this.zoomScale})`,
cursor: this.zoomScale < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out',
};
},
},
@@ -268,6 +294,7 @@ export default {
this.activeImageIndex = index;
this.setImageAndVideoSrc(attachment);
this.activeImageRotation = 0;
this.zoomScale = 1;
},
setImageAndVideoSrc(attachment) {
const { file_type: type } = attachment;
@@ -316,6 +343,37 @@ export default {
this.activeImageRotation += rotation;
}
},
onClickZoomImage() {
this.onZoom(0.1);
},
onZoom(scale) {
if (!this.isImage) {
return;
}
const newZoomScale = this.zoomScale + scale;
// Check if the new zoom scale is within the allowed range
if (newZoomScale > MAX_ZOOM_LEVEL) {
// Set zoom to max but do not reset to default
this.zoomScale = MAX_ZOOM_LEVEL;
return;
}
if (newZoomScale < MIN_ZOOM_LEVEL) {
// Set zoom to min but do not reset to default
this.zoomScale = MIN_ZOOM_LEVEL;
return;
}
// If within bounds, update the zoom scale
this.zoomScale = newZoomScale;
},
onWheelImageZoom(e) {
if (!this.isImage) {
return;
}
const scale = e.deltaY > 0 ? -0.1 : 0.1;
this.onZoom(scale);
},
},
};
</script>
@@ -10,7 +10,7 @@ export default {
component: SettingsContent,
props: {
headerTitle: 'SLA.HEADER',
icon: 'tag',
icon: 'document-list-clock',
showNewButton: true,
},
children: [
@@ -99,6 +99,7 @@
"document-outline": "M18.5 20a.5.5 0 0 1-.5.5H6a.5.5 0 0 1-.5-.5V4a.5.5 0 0 1 .5-.5h6V8a2 2 0 0 0 2 2h4.5v10Zm-5-15.379L17.378 8.5H14a.5.5 0 0 1-.5-.5V4.621Zm5.914 3.793-5.829-5.828c-.026-.026-.058-.046-.085-.07a2.072 2.072 0 0 0-.219-.18c-.04-.027-.086-.045-.128-.068-.071-.04-.141-.084-.216-.116a1.977 1.977 0 0 0-.624-.138C12.266 2.011 12.22 2 12.172 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9.828a2 2 0 0 0-.586-1.414Z",
"document-error-outline": "M6 2a2 2 0 0 0-2 2v5.207a5.48 5.48 0 0 1 1-.185V4a1 1 0 0 1 1-1h4v3.5A1.5 1.5 0 0 0 11.5 8H15v8a1 1 0 0 1-1 1h-3.6a5.507 5.507 0 0 1-.657 1H14a2 2 0 0 0 2-2V7.414a1.5 1.5 0 0 0-.44-1.06l-3.914-3.915A1.5 1.5 0 0 0 10.586 2H6Zm8.793 5H11.5a.5.5 0 0 1-.5-.5V3.207L14.793 7ZM10 14.5a4.5 4.5 0 1 1-9 0a4.5 4.5 0 0 1 9 0ZM5.5 12a.5.5 0 0 0-.5.5v2a.5.5 0 0 0 1 0v-2a.5.5 0 0 0-.5-.5Zm0 5.125a.625.625 0 1 0 0-1.25a.625.625 0 0 0 0 1.25Z",
"document-text-link-outline": "M18 20.5a.5.5 0 0 0 .5-.5V10H14a2 2 0 0 1-2-2V3.5H6a.5.5 0 0 0-.5.5v10h-.75c-.255 0-.506.02-.75.059V4a2 2 0 0 1 2-2h6.172c.028 0 .055.004.082.007.02.003.04.006.059.007.215.015.427.056.624.138.057.024.112.056.166.087l.05.029.047.024a.652.652 0 0 1 .081.044c.078.053.148.116.219.18a.63.63 0 0 0 .036.03.491.491 0 0 1 .049.04l5.829 5.828A2 2 0 0 1 20 9.828V20a2 2 0 0 1-2 2h-6.286c.406-.432.731-.94.953-1.5H18Zm-.622-12L13.5 4.621V8a.5.5 0 0 0 .5.5h3.378Zm-7.603 5.75c.854.29 1.6.815 2.158 1.5h3.317a.75.75 0 0 0 0-1.5H9.775ZM12.667 17c.186.468.3.973.326 1.5h2.257a.75.75 0 0 0 0-1.5h-2.583ZM8.75 11.5a.75.75 0 0 0 0 1.5h6.5a.75.75 0 0 0 0-1.5h-6.5ZM12 18.75A3.75 3.75 0 0 0 8.25 15l-.102.007A.75.75 0 0 0 8.25 16.5l.154.005A2.25 2.25 0 0 1 8.25 21l-.003.005-.102.007a.75.75 0 0 0 .108 1.493V22.5l.2-.005A3.75 3.75 0 0 0 12 18.75Zm-6.5-3a.75.75 0 0 0-.75-.75l-.2.005a3.75 3.75 0 0 0 .2 7.495l.102-.007A.75.75 0 0 0 4.75 21l-.154-.005A2.25 2.25 0 0 1 4.75 16.5l.102-.007a.75.75 0 0 0 .648-.743Zm3.5 3a.75.75 0 0 0-.75-.75h-3.5l-.102.007A.75.75 0 0 0 4.75 19.5h3.5l.102-.007A.75.75 0 0 0 9 18.75Z",
"document-list-clock-outline": "m19.414 8.414-5.829-5.828a.493.493 0 0 0-.049-.04.626.626 0 0 1-.036-.03 2.072 2.072 0 0 0-.219-.18.652.652 0 0 0-.08-.044l-.048-.024-.05-.029c-.054-.031-.109-.063-.166-.087a1.977 1.977 0 0 0-.624-.138c-.02-.001-.04-.004-.059-.007A.605.605 0 0 0 12.172 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h6.81a6.518 6.518 0 0 1-1.078-1.5H6a.5.5 0 0 1-.5-.5V4a.5.5 0 0 1 .5-.5h6V8a2 2 0 0 0 2 2h4.5v1.076c.523.081 1.026.224 1.5.422v-1.67a2 2 0 0 0-.586-1.414ZM13.5 4.621 17.378 8.5H14a.5.5 0 0 1-.5-.5V4.621ZM10.75 17.5H11c0 .516.06 1.018.174 1.5h-.424a.75.75 0 0 1 0-1.5Zm.424-1.5c.125-.528.314-1.03.558-1.5h-.982a.75.75 0 0 0 0 1.5h.424Zm1.636-3a6.511 6.511 0 0 1 2.186-1.5H10.75a.75.75 0 0 0 0 1.5h2.06Zm-5.06-1.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM7 15.25a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm0 3a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm16-.75a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0Zm-5.78.418a.5.5 0 0 1-.219-.489L17 13.5a.5.5 0 1 1 1 0L18.001 17h2.496a.5.5 0 0 1 0 1H17.56a.507.507 0 0 1-.34-.082Z",
"draft-outline": "m20.877 2.826.153.144.145.153a3.579 3.579 0 0 1-.145 4.908L9.062 19.999a2.25 2.25 0 0 1-1 .58l-5.115 1.395a.75.75 0 0 1-.92-.921l1.394-5.116a2.25 2.25 0 0 1 .58-.999L15.97 2.97a3.579 3.579 0 0 1 4.908-.144ZM15 6.06l-9.938 9.938a.75.75 0 0 0-.193.333l-1.05 3.85 3.85-1.05A.75.75 0 0 0 8 18.938L17.94 9 15 6.06ZM6.525 11l-1.5 1.5H2.75a.75.75 0 0 1 0-1.5h3.775Zm4-4-1.5 1.5H2.75a.75.75 0 1 1 0-1.5h7.775Zm6.505-2.97-.97.97 2.939 2.94.97-.97a2.078 2.078 0 1 0-2.939-2.94ZM14.525 3l-1.5 1.5H2.75a.75.75 0 1 1 0-1.5h11.775Z",
"drag-outline": "M15 3.707V8.5a.5.5 0 0 0 1 0V3.707l1.146 1.147a.5.5 0 0 0 .708-.708l-2-2a.499.499 0 0 0-.708 0l-2 2a.5.5 0 0 0 .708.708L15 3.707ZM2 4.5a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5Zm0 5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5Zm.5 4.5a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1h-6ZM15 16.293V11.5a.5.5 0 0 1 1 0v4.793l1.146-1.147a.5.5 0 0 1 .708.708l-2 2a.5.5 0 0 1-.708 0l-2-2a.5.5 0 0 1 .708-.708L15 16.293Z",
"dual-screen-clock-outline": "M10.019 6.002a6.632 6.632 0 0 0 .058 1.5H3.75a.25.25 0 0 0-.25.25v12.494c0 .138.112.25.25.25h7.498l-.001-10.167c.416.57.924 1.07 1.5 1.479v8.69h7.498a.25.25 0 0 0 .25-.25v-8.62a6.535 6.535 0 0 0 1.501-1.656V20.25a1.75 1.75 0 0 1-1.75 1.75h-8.998l-.001-.003H3.75A1.75 1.75 0 0 1 2 20.246V7.751c0-.966.784-1.75 1.75-1.75h6.269Zm6.22 11.498a.75.75 0 0 1 .101 1.493L16.24 19h-1.5a.75.75 0 0 1-.102-1.493l.102-.007h1.5Zm-6.996 0a.75.75 0 0 1 .102 1.493L9.243 19H7.74a.75.75 0 0 1-.102-1.493l.102-.007h1.502ZM16.498 1a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11Zm-1 2a.5.5 0 0 0-.5.5v4a.5.5 0 0 0 .5.5h3.001a.5.5 0 0 0 0-1h-2.501V3.5a.5.5 0 0 0-.5-.5Z",
@@ -125,6 +126,14 @@
"location-outline": "M5.843 4.568a8.707 8.707 0 1 1 12.314 12.314l-1.187 1.174c-.875.858-2.01 1.962-3.406 3.312a2.25 2.25 0 0 1-3.128 0l-3.491-3.396c-.439-.431-.806-.794-1.102-1.09a8.707 8.707 0 0 1 0-12.314Zm11.253 1.06A7.207 7.207 0 1 0 6.904 15.822L8.39 17.29a753.98 753.98 0 0 0 3.088 3 .75.75 0 0 0 1.043 0l3.394-3.3c.47-.461.863-.85 1.18-1.168a7.207 7.207 0 0 0 0-10.192ZM12 7.999a3.002 3.002 0 1 1 0 6.004 3.002 3.002 0 0 1 0-6.003Zm0 1.5a1.501 1.501 0 1 0 0 3.004 1.501 1.501 0 0 0 0-3.003Z",
"lock-closed-outline": "M12 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 20 10.25v9.5A2.25 2.25 0 0 1 17.75 22H6.25A2.25 2.25 0 0 1 4 19.75v-9.5A2.25 2.25 0 0 1 6.25 8H8V6a4 4 0 0 1 4-4Zm5.75 7.5H6.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h11.5a.75.75 0 0 0 .75-.75v-9.5a.75.75 0 0 0-.75-.75Zm-5.75 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 9.5 6v2h5V6A2.5 2.5 0 0 0 12 3.5Z",
"lock-shield-outline": "M10 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 18 10.25V11c-.319 0-.637.11-.896.329l-.107.1c-.164.17-.33.323-.496.457L16.5 10.25a.75.75 0 0 0-.75-.75H4.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h9.888a6.024 6.024 0 0 0 1.54 1.5H4.25A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H6V6a4 4 0 0 1 4-4Zm8.284 10.122c.992 1.036 2.091 1.545 3.316 1.545.193 0 .355.143.392.332l.008.084v2.501c0 2.682-1.313 4.506-3.873 5.395a.385.385 0 0 1-.253 0c-2.476-.86-3.785-2.592-3.87-5.13L14 16.585v-2.5c0-.23.18-.417.4-.417 1.223 0 2.323-.51 3.318-1.545a.389.389 0 0 1 .566 0ZM10 13.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 7.5 6v2h5V6A2.5 2.5 0 0 0 10 3.5Z",
"zoom-in-outline": [
"M13.5 10a.75.75 0 0 0-.75-.75h-2v-2a.75.75 0 0 0-1.5 0v2h-2a.75.75 0 1 0 0 1.5h2v2a.75.75 0 0 0 1.5 0v-2h2a.75.75 0 0 0 .75-.75Z",
"M10 2.75a7.25 7.25 0 0 1 5.63 11.819l4.9 4.9a.75.75 0 0 1-.976 1.134l-.084-.073-4.901-4.9A7.25 7.25 0 1 1 10 2.75Zm0 1.5a5.75 5.75 0 1 0 0 11.5 5.75 5.75 0 0 0 0-11.5Z"
],
"zoom-out-outline": [
"M12.75 9.25a.75.75 0 0 1 0 1.5h-5.5a.75.75 0 0 1 0-1.5h5.5Z",
"M17.25 10a7.25 7.25 0 1 0-2.681 5.63l4.9 4.9.085.073a.75.75 0 0 0 .976-1.133l-4.9-4.901A7.22 7.22 0 0 0 17.25 10Zm-13 0a5.75 5.75 0 1 1 11.5 0 5.75 5.75 0 0 1-11.5 0Z"
],
"mail-inbox-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3h11.5h-11.5ZM4.5 14.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188H4.5v3.25v-3.25Zm13.25-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Z",
"mail-inbox-all-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3Zm2.075 11.5H4.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188Zm9.425-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Zm-11 5h10.5a.75.75 0 0 1 .102 1.493L17.25 11H6.75a.75.75 0 0 1-.102-1.493L6.75 9.5h10.5-10.5Zm0-3h10.5a.75.75 0 0 1 .102 1.493L17.25 8H6.75a.75.75 0 0 1-.102-1.493L6.75 6.5h10.5-10.5Z",
"mail-unread-outline": "M16 6.5H5.25a1.75 1.75 0 0 0-1.744 1.606l-.004.1L11 12.153l6.03-3.174a3.489 3.489 0 0 0 2.97.985v6.786a3.25 3.25 0 0 1-3.066 3.245L16.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-8.5a3.25 3.25 0 0 1 3.066-3.245L5.25 5h11.087A3.487 3.487 0 0 0 16 6.5Zm2.5 3.399-7.15 3.765a.75.75 0 0 1-.603.042l-.096-.042L3.5 9.9v6.85a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.899ZM19.5 4a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z",
@@ -232,7 +241,6 @@
"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",
@@ -0,0 +1,150 @@
import { utcToZonedTime } from 'date-fns-tz';
// Array defining days of the week
const daysOfWeek = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
/**
* Find the next available time for the specified day
* @param {Object[]} workingHours - Array of objects representing working hours for each day
* @param {number} dayOfWeek - Index of the day of the week (0 for Sunday, 1 for Monday, etc.)
* @param {number} currentHour - Current hour of the day (24-hour format)
* @param {number} currentMinutes - Current minutes of the hour
* @returns {Object|null} - Object containing next available time information or null if not available
*/
const findNextAvailableTimeForDay = (
workingHours,
dayOfWeek,
currentHour,
currentMinutes
) => {
const availableHours = workingHours.find(
hours =>
hours.day_of_week === dayOfWeek &&
!hours.closed_all_day &&
(currentHour < hours.close_hour ||
(currentHour === hours.close_hour &&
currentMinutes < hours.close_minutes))
);
if (availableHours) {
return {
hour: availableHours.open_hour,
minutes: availableHours.open_minutes,
};
}
return null;
};
/**
* Find the next available time across days
* @param {Object[]} workingHours - Array of objects representing working hours for each day
* @param {number} currentDayOfWeek - Index of the current day of the week (0 for Sunday, 1 for Monday, etc.)
* @returns {Object|null} - Object containing next available time information or null if not available
*/
const findNextAvailableTimeAcrossDays = (workingHours, currentDayOfWeek) => {
const nextAvailableDays = workingHours.filter(hours => !hours.closed_all_day);
for (let i = 0; i < 7; i += 1) {
const nextDayIndex = (currentDayOfWeek + 1 + i) % 7;
const nextDay = nextAvailableDays.find(
hours => hours.day_of_week === nextDayIndex
);
if (nextDay) {
return {
hour: nextDay.open_hour,
minutes: nextDay.open_minutes,
day: nextDayIndex,
};
}
}
return null;
};
/**
* Function to find the next available time
* @param {Array} workingHours - Array of working hours for each day
* @param {number} currentDayOfWeek - Index of the current day of the week (0 for Sunday, 1 for Monday, etc.)
* @param {number} currentHour - Current hour of the day (24-hour format)
* @param {number} currentMinutes - Current minutes of the hour
* @returns {Object|null} - Object containing next available time information or null if not available
*/
const findNextAvailableTime = (
workingHours,
currentDayOfWeek,
currentHour,
currentMinutes
) => {
const nextAvailableTimeToday = findNextAvailableTimeForDay(
workingHours,
currentDayOfWeek,
currentHour,
currentMinutes
);
if (nextAvailableTimeToday) {
return { ...nextAvailableTimeToday, day: 'today' };
}
return findNextAvailableTimeAcrossDays(workingHours, currentDayOfWeek);
};
/**
* Function to get the next availability message
* @param {Array} workingHours - Array of working hours for each day
* @param {Date} currentTime - Current time object
* @returns {string} - Next availability message
*/
export const getNextAvailabilityMessage = (
workingHours,
currentTime,
timezone
) => {
const utcZonedTime = utcToZonedTime(currentTime, timezone);
const currentDayOfWeek = utcZonedTime.getDay();
const currentHour = utcZonedTime.getHours();
const currentMinutes = utcZonedTime.getMinutes();
const nextAvailableTime = findNextAvailableTime(
workingHours,
currentDayOfWeek,
currentHour,
currentMinutes
);
// Calculate the time difference
if (nextAvailableTime !== null) {
const differenceInMinutes =
nextAvailableTime.hour * 60 +
nextAvailableTime.minutes -
(currentHour * 60 + currentMinutes);
const differenceInHours = Math.floor(differenceInMinutes / 60);
// Check if the next available time is tomorrow
if (nextAvailableTime.day === (currentDayOfWeek + 1) % 7) {
return `We will be back online tomorrow`;
}
// Check if the next available time is on a specific day
if (nextAvailableTime.day !== 'today') {
return `We will be back online on ${daysOfWeek[nextAvailableTime.day]}`;
}
// Generate appropriate message based on time difference
if (differenceInMinutes <= 1) {
return 'We will be back online in less than a minute.';
}
if (differenceInMinutes < 60) {
return `We will be back online in ${differenceInMinutes} minutes.`;
}
if (differenceInMinutes < 1440) {
return `We will be back online in ${differenceInHours} hours`;
}
}
return 'No available time found in working hours.';
};
@@ -0,0 +1,169 @@
import { getNextAvailabilityMessage } from '../availabilityHelper';
const workingHoursCommon = [
{ id: 8, inbox_id: 2, account_id: 1, day_of_week: 0, closed_all_day: true },
{
id: 9,
inbox_id: 2,
account_id: 1,
day_of_week: 1,
closed_all_day: false,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
},
{
id: 10,
inbox_id: 2,
account_id: 1,
day_of_week: 2,
closed_all_day: true,
open_hour: 15,
open_minutes: 0,
close_hour: 23,
close_minutes: 30,
},
{ id: 11, inbox_id: 2, account_id: 1, day_of_week: 3, closed_all_day: true },
{
id: 12,
inbox_id: 2,
account_id: 1,
day_of_week: 4,
closed_all_day: false,
open_hour: 15,
open_minutes: 0,
close_hour: 20,
close_minutes: 0,
},
{
id: 13,
inbox_id: 2,
account_id: 1,
day_of_week: 5,
closed_all_day: false,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
},
{
id: 14,
inbox_id: 2,
account_id: 1,
day_of_week: 6,
closed_all_day: false,
open_hour: 9,
open_minutes: 0,
close_hour: 7,
close_minutes: 0,
},
];
describe('getNextAvailabilityMessage without timezone', () => {
let workingHours;
beforeEach(() => {
workingHours = JSON.parse(JSON.stringify(workingHoursCommon));
});
test('less than a minute', () =>
expect(
getNextAvailabilityMessage(workingHours, new Date('2024-03-14T14:59:59'))
).toBe('We will be back online in less than a minute.'));
test('less than 10 minutes', () =>
expect(
getNextAvailabilityMessage(workingHours, new Date('2024-03-14T14:50:00'))
).toBe('We will be back online in 10 minutes.'));
test('minutes message for less than an hour', () =>
expect(
getNextAvailabilityMessage(workingHours, new Date('2024-03-14T14:45:00'))
).toBe('We will be back online in 15 minutes.'));
test('greater than 2 hours', () =>
expect(
getNextAvailabilityMessage(workingHours, new Date('2024-03-14T12:30:00'))
).toBe('We will be back online in 2 hours'));
test('next day', () => {
workingHours[4].closed_all_day = true;
expect(
getNextAvailabilityMessage(workingHours, new Date('2024-03-14T00:00:00'))
).toBe('We will be back online tomorrow');
});
test('specific day', () => {
workingHours[4].closed_all_day = true;
workingHours[5].closed_all_day = true;
expect(
getNextAvailabilityMessage(workingHours, new Date('2024-03-14T00:00:00'))
).toBe('We will be back online on Saturday');
});
test('next week', () => {
workingHours[5].closed_all_day = true;
workingHours[6].closed_all_day = true;
workingHours[0].closed_all_day = true;
workingHours[1].closed_all_day = true;
workingHours[2].closed_all_day = true;
workingHours[3].closed_all_day = true;
workingHours[4].closed_all_day = false;
expect(
getNextAvailabilityMessage(workingHours, new Date('2024-03-21T21:00:00'))
).toBe('We will be back online on Thursday');
});
});
describe('getNextAvailabilityMessage with timezone', () => {
let workingHours;
beforeEach(() => {
workingHours = JSON.parse(JSON.stringify(workingHoursCommon));
jest.useFakeTimers('modern');
jest.setSystemTime(new Date('2024-03-14T19:50:00'));
});
afterEach(() => jest.useRealTimers());
test('less than a minute', () =>
expect(
getNextAvailabilityMessage(
workingHours,
new Date('2024-03-14T19:50:00'),
'America/New_York'
)
).toBe('We will be back online in less than a minute.'));
test('less than 10 minutes', () =>
expect(
getNextAvailabilityMessage(
workingHours,
new Date('2024-03-14T19:50:00'),
'America/New_York'
)
).toBe('We will be back online in less than a minute.'));
test('greater than 2 hours', () =>
expect(
getNextAvailabilityMessage(
workingHours,
new Date('2024-03-14T14:59:59'),
'America/New_York'
)
).toBe('We will be back online in 4 hours'));
test('next day', () => {
workingHours[4].closed_all_day = true;
expect(
getNextAvailabilityMessage(
workingHours,
new Date('2024-03-15T00:00:00'),
'America/New_York'
)
).toBe('We will be back online tomorrow');
});
test('specific day', () => {
workingHours[4].closed_all_day = true;
workingHours[5].closed_all_day = true;
expect(
getNextAvailabilityMessage(
workingHours,
new Date('2024-03-17T00:00:00'),
'America/New_York'
)
).toBe('We will be back online on Monday');
});
});
+7 -4
View File
@@ -1,6 +1,6 @@
import { utcToZonedTime } from 'date-fns-tz';
import { isTimeAfter } from 'shared/helpers/DateHelper';
import { getNextAvailabilityMessage } from 'widget/helpers/availabilityHelper';
export default {
computed: {
channelConfig() {
@@ -23,10 +23,13 @@ export default {
},
replyWaitMessage() {
const { workingHoursEnabled } = this.channelConfig;
const nextAvailabilityMessage = getNextAvailabilityMessage(
this.channelConfig.workingHours,
new Date(),
this.channelConfig.timezone
);
if (workingHoursEnabled) {
return this.isOnline
? this.replyTimeStatus
: `${this.$t('REPLY_TIME.BACK_IN')} ${this.timeLeftToBackInOnline}`;
return this.isOnline ? this.replyTimeStatus : nextAvailabilityMessage;
}
return this.isOnline
? this.replyTimeStatus
+1 -9
View File
@@ -1,6 +1,4 @@
module MailboxHelper
include ContactHelper
private
def create_message
@@ -98,17 +96,11 @@ module MailboxHelper
end
def create_contact
name_parts = parse_name(identify_contact_name)
first_name = name_parts[:first_name]
middle_name = name_parts[:middle_name]
last_name = name_parts[:last_name]
@contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: processed_mail.original_sender,
inbox: @inbox,
contact_attributes: {
name: first_name,
middle_name: middle_name,
last_name: last_name,
name: identify_contact_name,
email: processed_mail.original_sender,
additional_attributes: {
source_id: "email:#{processed_mail.message_id}"
+1 -6
View File
@@ -20,7 +20,6 @@
class Channel::FacebookPage < ApplicationRecord
include Channelable
include Reauthorizable
include ContactHelper
self.table_name = 'channel_facebook_pages'
@@ -38,14 +37,10 @@ class Channel::FacebookPage < ApplicationRecord
end
def create_contact_inbox(instagram_id, name)
name_parts = parse_name(name)
first_name = name_parts[:first_name]
last_name = name_parts[:last_name]
middle_name = name_parts[:middle_name]
@contact_inbox = ::ContactInboxWithContactBuilder.new({
source_id: instagram_id,
inbox: inbox,
contact_attributes: { name: first_name, last_name: last_name, middle_name: middle_name }
contact_attributes: { name: name }
}).perform
end
+40 -45
View File
@@ -2,14 +2,48 @@ module ActivityMessageHandler
extend ActiveSupport::Concern
include PriorityActivityMessageHandler
include LabelActivityMessageHandler
include SlaActivityMessageHandler
include TeamActivityMessageHandler
private
def create_activity
user_name = Current.user.name if Current.user.present?
status_change_activity(user_name) if saved_change_to_status?
priority_change_activity(user_name) if saved_change_to_priority?
create_label_change(activity_message_ownner(user_name)) if saved_change_to_label_list?
user_name = determine_user_name
handle_status_change(user_name)
handle_priority_change(user_name)
handle_label_change(user_name)
handle_sla_policy_change(user_name)
end
def determine_user_name
Current.user&.name
end
def handle_status_change(user_name)
return unless saved_change_to_status?
status_change_activity(user_name)
end
def handle_priority_change(user_name)
return unless saved_change_to_priority?
priority_change_activity(user_name)
end
def handle_label_change(user_name)
return unless saved_change_to_label_list?
create_label_change(activity_message_owner(user_name))
end
def handle_sla_policy_change(user_name)
return unless saved_change_to_sla_policy_id?
sla_change_type = determine_sla_change_type
create_sla_change_activity(sla_change_type, activity_message_owner(user_name))
end
def status_change_activity(user_name)
@@ -45,21 +79,6 @@ module ActivityMessageHandler
{ account_id: account_id, inbox_id: inbox_id, message_type: :activity, content: content }
end
def create_label_added(user_name, labels = [])
create_label_change_activity('added', user_name, labels)
end
def create_label_removed(user_name, labels = [])
create_label_change_activity('removed', user_name, labels)
end
def create_label_change_activity(change_type, user_name, labels = [])
return unless labels.size.positive?
content = I18n.t("conversations.activity.labels.#{change_type}", user_name: user_name, labels: labels.join(', '))
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
def create_muted_message
create_mute_change_activity('muted')
end
@@ -75,30 +94,6 @@ module ActivityMessageHandler
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
def generate_team_change_activity_key
team = Team.find_by(id: team_id)
key = team.present? ? 'assigned' : 'removed'
key += '_with_assignee' if key == 'assigned' && saved_change_to_assignee_id? && assignee
key
end
def generate_team_name_for_activity
previous_team_id = previous_changes[:team_id][0]
Team.find_by(id: previous_team_id)&.name if previous_team_id.present?
end
def create_team_change_activity(user_name)
user_name = activity_message_ownner(user_name)
return unless user_name
key = generate_team_change_activity_key
params = { assignee_name: assignee&.name, team_name: team&.name, user_name: user_name }
params[:team_name] = generate_team_name_for_activity if key == 'removed'
content = I18n.t("conversations.activity.team.#{key}", **params)
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
def generate_assignee_change_activity_content(user_name)
params = { assignee_name: assignee&.name, user_name: user_name }.compact
key = assignee_id ? 'assigned' : 'removed'
@@ -107,7 +102,7 @@ module ActivityMessageHandler
end
def create_assignee_change_activity(user_name)
user_name = activity_message_ownner(user_name)
user_name = activity_message_owner(user_name)
return unless user_name
@@ -115,7 +110,7 @@ module ActivityMessageHandler
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
def activity_message_ownner(user_name)
def activity_message_owner(user_name)
user_name = 'Automation System' if !user_name && Current.executed_by.present?
user_name
end
@@ -0,0 +1,20 @@
module LabelActivityMessageHandler
extend ActiveSupport::Concern
private
def create_label_added(user_name, labels = [])
create_label_change_activity('added', user_name, labels)
end
def create_label_removed(user_name, labels = [])
create_label_change_activity('removed', user_name, labels)
end
def create_label_change_activity(change_type, user_name, labels = [])
return unless labels.size.positive?
content = I18n.t("conversations.activity.labels.#{change_type}", user_name: user_name, labels: labels.join(', '))
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
end
@@ -0,0 +1,31 @@
module SlaActivityMessageHandler
extend ActiveSupport::Concern
private
def create_sla_change_activity(change_type, user_name)
content = case change_type
when 'added'
I18n.t('conversations.activity.sla.added', user_name: user_name, sla_name: sla_policy_name)
when 'removed'
I18n.t('conversations.activity.sla.removed', user_name: user_name, sla_name: sla_policy_name)
when 'updated'
I18n.t('conversations.activity.sla.updated', user_name: user_name, sla_name: sla_policy_name)
end
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
def sla_policy_name
SlaPolicy.find_by(id: sla_policy_id)&.name || ''
end
def determine_sla_change_type
sla_policy_id_before, sla_policy_id_after = previous_changes[:sla_policy_id]
if sla_policy_id_before.nil? && sla_policy_id_after.present?
'added'
elsif sla_policy_id_before.present? && sla_policy_id_after.nil?
'removed'
end
end
end
@@ -0,0 +1,29 @@
module TeamActivityMessageHandler
extend ActiveSupport::Concern
private
def create_team_change_activity(user_name)
user_name = activity_message_owner(user_name)
return unless user_name
key = generate_team_change_activity_key
params = { assignee_name: assignee&.name, team_name: team&.name, user_name: user_name }
params[:team_name] = generate_team_name_for_activity if key == 'removed'
content = I18n.t("conversations.activity.team.#{key}", **params)
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
def generate_team_change_activity_key
team = Team.find_by(id: team_id)
key = team.present? ? 'assigned' : 'removed'
key += '_with_assignee' if key == 'assigned' && saved_change_to_assignee_id? && assignee
key
end
def generate_team_name_for_activity
previous_team_id = previous_changes[:team_id][0]
Team.find_by(id: previous_team_id)&.name if previous_team_id.present?
end
end
+8 -3
View File
@@ -37,9 +37,6 @@
# rubocop:enable Layout/LineLength
# Notes
# TODO: We are considering the "name" as "first_name". We will update it to "first_name" in the future.
class Contact < ApplicationRecord
include Avatarable
include AvailabilityStatusable
@@ -64,6 +61,7 @@ class Contact < ApplicationRecord
after_create_commit :dispatch_create_event, :ip_lookup
after_update_commit :dispatch_update_event
after_destroy_commit :dispatch_destroy_event
before_save :update_contact_location_and_country_code
enum contact_type: { visitor: 0, lead: 1, customer: 2 }
@@ -209,6 +207,13 @@ class Contact < ApplicationRecord
self.custom_attributes = {} if custom_attributes.blank?
end
def update_contact_location_and_country_code
# TODO: Ensure that location and country_code are updated from additional_attributes.
# We will remove this once all contacts are updated and both the location and country_code fields are standardized throughout the app.
self.location = additional_attributes['city']
self.country_code = additional_attributes['country']
end
def dispatch_create_event
Rails.configuration.dispatcher.dispatch(CONTACT_CREATED, Time.zone.now, contact: self)
end
@@ -71,8 +71,7 @@ class Telegram::IncomingMessageService
def contact_attributes
{
name: telegram_params_first_name,
last_name: telegram_params_last_name,
name: "#{telegram_params_first_name} #{telegram_params_last_name}",
additional_attributes: additional_attributes
}
end
@@ -3,7 +3,7 @@
# https://developers.facebook.com/docs/whatsapp/api/media/
class Whatsapp::IncomingMessageBaseService
include ::Whatsapp::IncomingMessageServiceHelpers
include ContactHelper
pattr_initialize [:inbox!, :params!]
def perform
@@ -84,17 +84,10 @@ class Whatsapp::IncomingMessageBaseService
waid = processed_waid(contact_params[:wa_id])
name = contact_params.dig(:profile, :name)
name_parts = parse_name(name)
first_name = name_parts[:first_name]
middle_name = name_parts[:middle_name]
last_name = name_parts[:last_name]
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: waid,
inbox: inbox,
contact_attributes: { name: first_name, last_name: last_name, middle_name: middle_name,
phone_number: "+#{@processed_params[:messages].first[:from]}" }
contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{@processed_params[:messages].first[:from]}" }
).perform
@contact_inbox = contact_inbox
+3
View File
@@ -158,6 +158,9 @@ en:
labels:
added: "%{user_name} added %{labels}"
removed: "%{user_name} removed %{labels}"
sla:
added: "%{user_name} added SLA policy %{sla_name}"
removed: "%{user_name} removed SLA policy %{sla_name}"
muted: "%{user_name} has muted the conversation"
unmuted: "%{user_name} has unmuted the conversation"
templates:
@@ -13,7 +13,6 @@ describe ContactInboxWithContactBuilder do
inbox: inbox,
contact_attributes: {
name: 'Contact',
last_name: '1',
phone_number: '+1234567890',
email: 'testemail@example.com'
}
@@ -28,7 +27,6 @@ describe ContactInboxWithContactBuilder do
inbox: inbox,
contact_attributes: {
name: 'Contact',
last_name: '1',
phone_number: '+1234567890',
email: 'testemail@example.com',
custom_attributes: { test: 'test' }
@@ -37,7 +35,6 @@ describe ContactInboxWithContactBuilder do
expect(contact_inbox.contact.id).not_to eq(contact.id)
expect(contact_inbox.contact.name).to eq('Contact')
expect(contact_inbox.contact.last_name).to eq('1')
expect(contact_inbox.contact.custom_attributes).to eq({ 'test' => 'test' })
expect(contact_inbox.inbox_id).to eq(inbox.id)
end
@@ -28,8 +28,7 @@ describe Messages::Facebook::MessageBuilder do
contact = facebook_channel.inbox.contacts.first
message = facebook_channel.inbox.messages.first
expect(contact.name).to eq('Jane')
expect(contact.last_name).to eq('Dae')
expect(contact.name).to eq('Jane Dae')
expect(message.content).to eq('facebook message')
end
@@ -54,11 +53,10 @@ describe Messages::Facebook::MessageBuilder do
contact = facebook_channel.inbox.contacts.first
# Refer: https://github.com/chatwoot/chatwoot/pull/3016 for this check
default_name = 'John'
default_name = 'John Doe'
expect(facebook_channel.inbox.reload.contacts.count).to eq(1)
expect(contact.name).to eq(default_name)
expect(contact.last_name).to eq('Doe')
end
end
end
@@ -5,6 +5,36 @@ RSpec.describe Conversation, type: :model do
it { is_expected.to belong_to(:sla_policy).optional }
end
describe 'SLA policy updates' do
let!(:conversation) { create(:conversation) }
let!(:sla_policy) { create(:sla_policy) }
it 'generates an activity message when the SLA policy is updated' do
conversation.update(sla_policy_id: sla_policy.id)
perform_enqueued_jobs
activity_message = conversation.messages.where(message_type: 'activity').last
expect(activity_message).not_to be_nil
expect(activity_message.message_type).to eq('activity')
expect(activity_message.content).to include('added SLA policy')
end
it 'generates an activity message when the SLA policy is removed' do
conversation.update(sla_policy_id: sla_policy.id)
conversation.update(sla_policy_id: nil)
perform_enqueued_jobs
activity_message = conversation.messages.where(message_type: 'activity').last
expect(activity_message).not_to be_nil
expect(activity_message.message_type).to eq('activity')
expect(activity_message.content).to include('removed SLA policy')
end
end
describe 'conversation sentiments' do
include ActiveJob::TestHelper
+2 -2
View File
@@ -63,8 +63,8 @@ RSpec.describe ContactHelper do
end
it 'handle name with mobile number with spaces correctly' do
full_name = '+1 423-423-4234'
expected_result = { first_name: '+1 423-423-4234', last_name: nil, middle_name: nil, prefix: nil, suffix: nil }
full_name = '+1 234 567 890'
expected_result = { first_name: '+1 234 567 890', last_name: nil, middle_name: nil, prefix: nil, suffix: nil }
expect(helper.parse_name(full_name)).to eq(expected_result)
end
+4 -3
View File
@@ -89,9 +89,9 @@ RSpec.describe SupportMailbox do
end
it 'create a new contact as the sender of the email' do
email_sender = Mail::Address.new(support_mail.mail[:from].value).name
expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
expect(conversation.contact.name).to eq('Sony')
expect(conversation.contact.last_name).to eq('Mathew')
expect(conversation.contact.name).to eq(email_sender)
end
it 'add the mail content as new message on the conversation' do
@@ -178,9 +178,10 @@ RSpec.describe SupportMailbox do
it 'create new contact with original sender' do
described_subject
email_sender = Mail::Address.new(group_sender_support_mail.mail[:from].value).name
expect(conversation.contact.email).to eq(group_sender_support_mail.mail['X-Original-Sender'].value)
expect(conversation.contact.name).to eq('ACME')
expect(conversation.contact.name).to eq(email_sender)
end
end
+8
View File
@@ -75,4 +75,12 @@ RSpec.describe Contact do
expect(contact.email).to eq 'test@test.com'
end
end
context 'when city and country code passed in additional attributes' do
it 'updates location and country code' do
contact = create(:contact, additional_attributes: { city: 'New York', country: 'US' })
expect(contact.location).to eq 'New York'
expect(contact.country_code).to eq 'US'
end
end
end
@@ -156,7 +156,7 @@ describe Line::IncomingMessageService do
)
described_class.new(inbox: line_channel.inbox, params: params).perform
expect(line_channel.inbox.conversations).not_to eq(0)
expect(Contact.all.first.name).to eq('LINE')
expect(Contact.all.first.name).to eq('LINE Test')
expect(Contact.all.first.additional_attributes['social_line_user_id']).to eq('U4af4980629')
expect(line_channel.inbox.messages.first.content).to eq('Hello, world')
end
@@ -177,7 +177,7 @@ describe Line::IncomingMessageService do
)
described_class.new(inbox: line_channel.inbox, params: sticker_params).perform
expect(line_channel.inbox.conversations).not_to eq(0)
expect(Contact.all.first.name).to eq('LINE')
expect(Contact.all.first.name).to eq('LINE Test')
expect(line_channel.inbox.messages.first.content).to eq('![sticker-52002738](https://stickershop.line-scdn.net/stickershop/v1/sticker/52002738/iphone/sticker.png)')
end
end
@@ -204,7 +204,7 @@ describe Line::IncomingMessageService do
)
described_class.new(inbox: line_channel.inbox, params: image_params).perform
expect(line_channel.inbox.conversations).not_to eq(0)
expect(Contact.all.first.name).to eq('LINE')
expect(Contact.all.first.name).to eq('LINE Test')
expect(Contact.all.first.additional_attributes['social_line_user_id']).to eq('U4af4980629')
expect(line_channel.inbox.messages.first.content).to be_nil
expect(line_channel.inbox.messages.first.attachments.first.file_type).to eq('image')
@@ -234,7 +234,7 @@ describe Line::IncomingMessageService do
)
described_class.new(inbox: line_channel.inbox, params: video_params).perform
expect(line_channel.inbox.conversations).not_to eq(0)
expect(Contact.all.first.name).to eq('LINE')
expect(Contact.all.first.name).to eq('LINE Test')
expect(Contact.all.first.additional_attributes['social_line_user_id']).to eq('U4af4980629')
expect(line_channel.inbox.messages.first.content).to be_nil
expect(line_channel.inbox.messages.first.attachments.first.file_type).to eq('video')
@@ -51,8 +51,7 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.last_name).to eq('Jose')
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(telegram_channel.inbox.messages.first.content).to eq('test')
end
end
@@ -65,8 +64,7 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.last_name).to eq('Jose')
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(23)
expect(Contact.all.first.additional_attributes['social_telegram_user_name']).to eq('sojan')
expect(telegram_channel.inbox.messages.first.content).to eq('test')
@@ -108,7 +106,7 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(23)
expect(Contact.all.first.additional_attributes['social_telegram_user_name']).to eq('sojan')
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('audio')
@@ -129,7 +127,7 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('image')
end
end
@@ -154,7 +152,7 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('image')
end
end
@@ -176,7 +174,7 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('video')
end
end
@@ -195,7 +193,7 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('audio')
end
end
@@ -216,7 +214,7 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('file')
end
end
@@ -254,7 +252,7 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('location')
end
end
@@ -282,8 +280,7 @@ describe Telegram::IncomingMessageService do
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.last_name).to eq('Jose')
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(5_171_248)
expect(telegram_channel.inbox.messages.first.content).to eq('Option 1')
end
@@ -20,8 +20,7 @@ describe Whatsapp::IncomingMessageService do
it 'creates appropriate conversations, message and contacts' do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.last_name).to eq('Jose')
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
end
@@ -179,7 +178,7 @@ describe Whatsapp::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('First Button')
end
end
@@ -197,7 +196,7 @@ describe Whatsapp::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Yes this is a button')
end
end
@@ -220,7 +219,7 @@ describe Whatsapp::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Check out my product!')
expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be true
end
@@ -241,7 +240,7 @@ describe Whatsapp::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.name).to eq('Sojan Jose')
location_attachment = whatsapp_channel.inbox.messages.first.attachments.first
expect(location_attachment.file_type).to eq('location')
expect(location_attachment.fallback_title).to eq('Bay Bridge, San Francisco, CA, USA')
@@ -291,8 +290,7 @@ describe Whatsapp::IncomingMessageService do
it 'creates appropriate conversations, message and contacts if contact does not exit' do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.last_name).to eq('Jose')
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
end
@@ -339,7 +337,7 @@ describe Whatsapp::IncomingMessageService do
it 'creates contact inbox with the incoming waid' do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
end
@@ -48,8 +48,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.last_name).to eq('Jose')
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Check out my product!')
expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be true
end
@@ -61,7 +60,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Check out my product!')
expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be false
expect(whatsapp_channel.authorization_error_count).to eq(1)
@@ -101,7 +100,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
it 'with attachment errors' do
described_class.new(inbox: whatsapp_channel.inbox, params: error_params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan')
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.count).to eq(0)
end
end