feat: Add preview for attachment messages (#1562)
Add preview for pending messages and attachments Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
parent
db189e3c26
commit
3d2db95417
17 changed files with 434 additions and 250 deletions
|
@ -13,24 +13,14 @@ class MessageApi extends ApiClient {
|
||||||
private: isPrivate,
|
private: isPrivate,
|
||||||
contentAttributes,
|
contentAttributes,
|
||||||
echo_id: echoId,
|
echo_id: echoId,
|
||||||
|
file,
|
||||||
}) {
|
}) {
|
||||||
return axios.post(`${this.url}/${conversationId}/messages`, {
|
|
||||||
content: message,
|
|
||||||
private: isPrivate,
|
|
||||||
echo_id: echoId,
|
|
||||||
content_attributes: contentAttributes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getPreviousMessages({ conversationId, before }) {
|
|
||||||
return axios.get(`${this.url}/${conversationId}/messages`, {
|
|
||||||
params: { before },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
sendAttachment([conversationId, { file, isPrivate = false }, echoId]) {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('attachments[]', file, file.name);
|
if (file) formData.append('attachments[]', file, file.name);
|
||||||
|
if (message) formData.append('content', message);
|
||||||
|
if (contentAttributes)
|
||||||
|
formData.append('content_attributes', JSON.stringify(contentAttributes));
|
||||||
|
|
||||||
formData.append('private', isPrivate);
|
formData.append('private', isPrivate);
|
||||||
formData.append('echo_id', echoId);
|
formData.append('echo_id', echoId);
|
||||||
return axios({
|
return axios({
|
||||||
|
@ -39,6 +29,12 @@ class MessageApi extends ApiClient {
|
||||||
data: formData,
|
data: formData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getPreviousMessages({ conversationId, before }) {
|
||||||
|
return axios.get(`${this.url}/${conversationId}/messages`, {
|
||||||
|
params: { before },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new MessageApi();
|
export default new MessageApi();
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
@import 'shared/assets/stylesheets/spacing';
|
@import 'shared/assets/stylesheets/spacing';
|
||||||
@import 'shared/assets/stylesheets/font-size';
|
@import 'shared/assets/stylesheets/font-size';
|
||||||
@import 'shared/assets/stylesheets/font-weights';
|
@import 'shared/assets/stylesheets/font-weights';
|
||||||
|
@import 'shared/assets/stylesheets/border-radius';
|
||||||
@import 'variables';
|
@import 'variables';
|
||||||
|
|
||||||
@import '~spinkit/scss/spinners/7-three-bounce';
|
@import '~spinkit/scss/spinners/7-three-bounce';
|
||||||
|
|
|
@ -18,13 +18,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-text {
|
|
||||||
&::after {
|
|
||||||
content: ' \00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0';
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.image {
|
.image {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -201,6 +194,10 @@
|
||||||
color: $color-body;
|
color: $color-body;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
|
||||||
|
&.is-image {
|
||||||
|
border-radius: var(--border-radius-large);
|
||||||
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
color: $color-primary-dark;
|
color: $color-primary-dark;
|
||||||
}
|
}
|
||||||
|
@ -257,6 +254,10 @@
|
||||||
top: $space-smaller + $space-micro;
|
top: $space-smaller + $space-micro;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.is-image {
|
||||||
|
border-radius: var(--border-radius-large);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+.left {
|
+.left {
|
||||||
|
@ -296,30 +297,9 @@
|
||||||
border-radius: $space-smaller;
|
border-radius: $space-smaller;
|
||||||
font-size: $font-size-small;
|
font-size: $font-size-small;
|
||||||
|
|
||||||
p {
|
|
||||||
color: $color-heading;
|
|
||||||
margin-bottom: $zero;
|
|
||||||
|
|
||||||
.ion-person {
|
|
||||||
color: $color-body;
|
|
||||||
font-size: $font-size-default;
|
|
||||||
margin-right: $space-small;
|
|
||||||
position: relative;
|
|
||||||
top: $space-micro;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-text__wrap {
|
.message-text__wrap {
|
||||||
position: relative;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-text {
|
|
||||||
&::after {
|
|
||||||
content: ' \00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0';
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,137 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
v-for="(attachment, index) in attachments"
|
||||||
|
:key="attachment.id"
|
||||||
|
class="preview-item"
|
||||||
|
>
|
||||||
|
<div class="thumb-wrap">
|
||||||
|
<img
|
||||||
|
v-if="isTypeImage(attachment.resource.type)"
|
||||||
|
class="image-thumb"
|
||||||
|
:src="attachment.thumb"
|
||||||
|
/>
|
||||||
|
<span v-else class="attachment-thumb">
|
||||||
|
📄
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-name-wrap">
|
||||||
|
<span class="item">
|
||||||
|
{{ attachment.resource.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-size-wrap">
|
||||||
|
<span class="item">
|
||||||
|
{{ formatFileSize(attachment.resource.size) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="remove-file-wrap">
|
||||||
|
<button
|
||||||
|
class="remove--attachment"
|
||||||
|
@click="() => onRemoveAttachment(index)"
|
||||||
|
>
|
||||||
|
<i class="ion-android-close"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { formatBytes } from 'dashboard/helper/files';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
attachments: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
removeAttachment: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onRemoveAttachment(index) {
|
||||||
|
this.removeAttachment(index);
|
||||||
|
},
|
||||||
|
formatFileSize(size) {
|
||||||
|
return formatBytes(size, 0);
|
||||||
|
},
|
||||||
|
isTypeImage(type) {
|
||||||
|
return type.includes('image');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.preview-item {
|
||||||
|
display: flex;
|
||||||
|
padding: 0 var(--space-small) var(--space-smaller);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-wrap {
|
||||||
|
max-width: var(--space-jumbo);
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: var(--space-medium);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-thumb {
|
||||||
|
width: var(--space-medium);
|
||||||
|
height: var(--space-medium);
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-thumb {
|
||||||
|
width: var(--space-medium);
|
||||||
|
height: var(--space-medium);
|
||||||
|
font-size: var(--font-size-medium);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name-wrap,
|
||||||
|
.file-size-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 var(--space-one);
|
||||||
|
|
||||||
|
> .item {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-mini);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
padding: var(--space-slab) var(--space-slab) 0 var(--space-slab);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name-wrap {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size-wrap {
|
||||||
|
width: 20%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-file-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove--attachment {
|
||||||
|
width: var(--space-medium);
|
||||||
|
height: var(--space-medium);
|
||||||
|
border-radius: var(--space-medium);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -14,8 +14,8 @@
|
||||||
>
|
>
|
||||||
{{ $t('CONVERSATION.UPLOADING_ATTACHMENTS') }}
|
{{ $t('CONVERSATION.UPLOADING_ATTACHMENTS') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!isPending && hasAttachments">
|
<div v-if="!isPending && hasAttachments">
|
||||||
<span v-for="attachment in data.attachments" :key="attachment.id">
|
<div v-for="attachment in data.attachments" :key="attachment.id">
|
||||||
<bubble-image
|
<bubble-image
|
||||||
v-if="attachment.file_type === 'image'"
|
v-if="attachment.file_type === 'image'"
|
||||||
:url="attachment.data_url"
|
:url="attachment.data_url"
|
||||||
|
@ -26,8 +26,8 @@
|
||||||
:url="attachment.data_url"
|
:url="attachment.data_url"
|
||||||
:readable-time="readableTime"
|
:readable-time="readableTime"
|
||||||
/>
|
/>
|
||||||
</span>
|
</div>
|
||||||
</span>
|
</div>
|
||||||
|
|
||||||
<bubble-actions
|
<bubble-actions
|
||||||
:id="data.id"
|
:id="data.id"
|
||||||
|
@ -138,6 +138,9 @@ export default {
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
hasText() {
|
||||||
|
return !!this.data.content;
|
||||||
|
},
|
||||||
sentByMessage() {
|
sentByMessage() {
|
||||||
const { sender } = this;
|
const { sender } = this;
|
||||||
|
|
||||||
|
@ -160,6 +163,7 @@ export default {
|
||||||
bubble: this.isBubble,
|
bubble: this.isBubble,
|
||||||
'is-private': this.data.private,
|
'is-private': this.data.private,
|
||||||
'is-image': this.hasImageAttachment,
|
'is-image': this.hasImageAttachment,
|
||||||
|
'is-text': this.hasText,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
isPending() {
|
isPending() {
|
||||||
|
@ -170,15 +174,27 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.wrap {
|
.wrap {
|
||||||
> .is-image.bubble {
|
> .bubble {
|
||||||
|
&.is-image {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.image {
|
.image {
|
||||||
max-width: 32rem;
|
max-width: 32rem;
|
||||||
padding: 0;
|
padding: var(--space-micro);
|
||||||
|
|
||||||
|
> img {
|
||||||
|
border-radius: var(--border-radius-medium);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-image.is-text > .message-text__wrap {
|
||||||
|
max-width: 32rem;
|
||||||
|
padding: var(--space-small) var(--space-normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.is-pending {
|
&.is-pending {
|
||||||
position: relative;
|
position: relative;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
@ -188,6 +204,10 @@ export default {
|
||||||
bottom: var(--space-smaller);
|
bottom: var(--space-smaller);
|
||||||
right: var(--space-smaller);
|
right: var(--space-smaller);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> .is-image.is-text.bubble > .message-text__wrap {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="hasAttachments" class="attachment-preview-box">
|
||||||
|
<attachment-preview
|
||||||
|
:attachments="attachedFiles"
|
||||||
|
:remove-attachment="removeAttachment"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="reply-box" :class="replyBoxClass">
|
<div class="reply-box" :class="replyBoxClass">
|
||||||
<div class="reply-box__top" :class="{ 'is-private': isPrivate }">
|
<div class="reply-box__top" :class="{ 'is-private': isPrivate }">
|
||||||
<canned-response
|
<canned-response
|
||||||
|
@ -30,8 +37,7 @@
|
||||||
accept="image/*, application/pdf, audio/mpeg, video/mp4, audio/ogg, text/csv"
|
accept="image/*, application/pdf, audio/mpeg, video/mp4, audio/ogg, text/csv"
|
||||||
@input-file="onFileUpload"
|
@input-file="onFileUpload"
|
||||||
>
|
>
|
||||||
<i v-if="!isUploading" class="icon ion-android-attach attachment" />
|
<i class="icon ion-android-attach attachment" />
|
||||||
<woot-spinner v-if="isUploading" />
|
|
||||||
</file-upload>
|
</file-upload>
|
||||||
<i
|
<i
|
||||||
class="icon ion-happy-outline"
|
class="icon ion-happy-outline"
|
||||||
|
@ -79,6 +85,7 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
@ -89,6 +96,7 @@ import FileUpload from 'vue-upload-component';
|
||||||
import EmojiInput from 'shared/components/emoji/EmojiInput';
|
import EmojiInput from 'shared/components/emoji/EmojiInput';
|
||||||
import CannedResponse from './CannedResponse';
|
import CannedResponse from './CannedResponse';
|
||||||
import ResizableTextArea from 'shared/components/ResizableTextArea';
|
import ResizableTextArea from 'shared/components/ResizableTextArea';
|
||||||
|
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview';
|
||||||
import {
|
import {
|
||||||
isEscape,
|
isEscape,
|
||||||
isEnter,
|
isEnter,
|
||||||
|
@ -103,6 +111,7 @@ export default {
|
||||||
CannedResponse,
|
CannedResponse,
|
||||||
FileUpload,
|
FileUpload,
|
||||||
ResizableTextArea,
|
ResizableTextArea,
|
||||||
|
AttachmentPreview,
|
||||||
},
|
},
|
||||||
mixins: [clickaway, inboxMixin],
|
mixins: [clickaway, inboxMixin],
|
||||||
props: {
|
props: {
|
||||||
|
@ -118,7 +127,7 @@ export default {
|
||||||
isFocused: false,
|
isFocused: false,
|
||||||
showEmojiPicker: false,
|
showEmojiPicker: false,
|
||||||
showCannedResponsesList: false,
|
showCannedResponsesList: false,
|
||||||
isUploading: false,
|
attachedFiles: [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -148,6 +157,8 @@ export default {
|
||||||
},
|
},
|
||||||
isReplyButtonDisabled() {
|
isReplyButtonDisabled() {
|
||||||
const isMessageEmpty = !this.message.trim().replace(/\n/g, '').length;
|
const isMessageEmpty = !this.message.trim().replace(/\n/g, '').length;
|
||||||
|
|
||||||
|
if (this.hasAttachments) return false;
|
||||||
return (
|
return (
|
||||||
isMessageEmpty ||
|
isMessageEmpty ||
|
||||||
this.message.length === 0 ||
|
this.message.length === 0 ||
|
||||||
|
@ -196,9 +207,12 @@ export default {
|
||||||
},
|
},
|
||||||
replyBoxClass() {
|
replyBoxClass() {
|
||||||
return {
|
return {
|
||||||
'is-focused': this.isFocused,
|
'is-focused': this.isFocused || this.hasAttachments,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
hasAttachments() {
|
||||||
|
return this.attachedFiles.length;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
currentChat(conversation) {
|
currentChat(conversation) {
|
||||||
|
@ -250,18 +264,11 @@ export default {
|
||||||
if (this.isReplyButtonDisabled) {
|
if (this.isReplyButtonDisabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newMessage = this.message;
|
|
||||||
if (!this.showCannedResponsesList) {
|
if (!this.showCannedResponsesList) {
|
||||||
|
const newMessage = this.message;
|
||||||
|
const messagePayload = this.getMessagePayload(newMessage);
|
||||||
this.clearMessage();
|
this.clearMessage();
|
||||||
try {
|
try {
|
||||||
const messagePayload = {
|
|
||||||
conversationId: this.currentChat.id,
|
|
||||||
message: newMessage,
|
|
||||||
private: this.isPrivate,
|
|
||||||
};
|
|
||||||
if (this.inReplyTo) {
|
|
||||||
messagePayload.contentAttributes = { in_reply_to: this.inReplyTo };
|
|
||||||
}
|
|
||||||
await this.$store.dispatch('sendMessage', messagePayload);
|
await this.$store.dispatch('sendMessage', messagePayload);
|
||||||
this.$emit('scrollToMessage');
|
this.$emit('scrollToMessage');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -288,6 +295,7 @@ export default {
|
||||||
},
|
},
|
||||||
clearMessage() {
|
clearMessage() {
|
||||||
this.message = '';
|
this.message = '';
|
||||||
|
this.attachedFiles = [];
|
||||||
},
|
},
|
||||||
toggleEmojiPicker() {
|
toggleEmojiPicker() {
|
||||||
this.showEmojiPicker = !this.showEmojiPicker;
|
this.showEmojiPicker = !this.showEmojiPicker;
|
||||||
|
@ -322,30 +330,63 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onFileUpload(file) {
|
onFileUpload(file) {
|
||||||
|
this.attachedFiles = [];
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.isUploading = true;
|
const reader = new FileReader();
|
||||||
this.$store
|
reader.readAsDataURL(file.file);
|
||||||
.dispatch('sendAttachment', [
|
|
||||||
this.currentChat.id,
|
reader.onloadend = () => {
|
||||||
{ file: file.file, isPrivate: this.isPrivate },
|
this.attachedFiles.push({
|
||||||
])
|
currentChatId: this.currentChat.id,
|
||||||
.then(() => {
|
resource: file,
|
||||||
this.isUploading = false;
|
isPrivate: this.isPrivate,
|
||||||
this.$emit('scrollToMessage');
|
thumb: reader.result,
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
this.isUploading = false;
|
|
||||||
this.$emit('scrollToMessage');
|
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
removeAttachment(itemIndex) {
|
||||||
|
this.attachedFiles = this.attachedFiles.filter(
|
||||||
|
(item, index) => itemIndex !== index
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getMessagePayload(message) {
|
||||||
|
const [attachment] = this.attachedFiles;
|
||||||
|
const messagePayload = {
|
||||||
|
conversationId: this.currentChat.id,
|
||||||
|
message,
|
||||||
|
private: this.isPrivate,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.inReplyTo) {
|
||||||
|
messagePayload.contentAttributes = { in_reply_to: this.inReplyTo };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachment) {
|
||||||
|
messagePayload.file = attachment.resource.file;
|
||||||
|
}
|
||||||
|
|
||||||
|
return messagePayload;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
@import '~widget/assets/scss/mixins';
|
||||||
|
|
||||||
.send-button {
|
.send-button {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
.attachment-preview-box {
|
||||||
|
margin: 0 var(--space-normal);
|
||||||
|
background: var(--white);
|
||||||
|
margin-bottom: var(--space-minus-slab);
|
||||||
|
padding-top: var(--space-small);
|
||||||
|
padding-bottom: var(--space-normal);
|
||||||
|
border-top-left-radius: var(--border-radius-medium);
|
||||||
|
border-top-right-radius: var(--border-radius-medium);
|
||||||
|
@include shadow;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<span class="message-text__wrap">
|
<div class="message-text__wrap">
|
||||||
<span v-html="message"></span>
|
<span v-html="message"></span>
|
||||||
</span>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
|
@ -33,37 +33,19 @@ export const getTypingUsersText = (users = []) => {
|
||||||
export const createPendingMessage = data => {
|
export const createPendingMessage = data => {
|
||||||
const timestamp = Math.floor(new Date().getTime() / 1000);
|
const timestamp = Math.floor(new Date().getTime() / 1000);
|
||||||
const tempMessageId = getUuid();
|
const tempMessageId = getUuid();
|
||||||
|
const { message, file } = data;
|
||||||
|
const tempAttachments = [{ id: tempMessageId }];
|
||||||
const pendingMessage = {
|
const pendingMessage = {
|
||||||
...data,
|
...data,
|
||||||
content: data.message,
|
content: message || null,
|
||||||
id: tempMessageId,
|
id: tempMessageId,
|
||||||
echo_id: tempMessageId,
|
echo_id: tempMessageId,
|
||||||
status: MESSAGE_STATUS.PROGRESS,
|
status: MESSAGE_STATUS.PROGRESS,
|
||||||
created_at: timestamp,
|
created_at: timestamp,
|
||||||
message_type: MESSAGE_TYPE.OUTGOING,
|
message_type: MESSAGE_TYPE.OUTGOING,
|
||||||
conversation_id: data.conversationId,
|
conversation_id: data.conversationId,
|
||||||
|
attachments: file ? tempAttachments : null,
|
||||||
};
|
};
|
||||||
return pendingMessage;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createPendingAttachment = data => {
|
|
||||||
const [conversationId, { isPrivate = false }] = data;
|
|
||||||
const timestamp = Math.floor(new Date().getTime() / 1000);
|
|
||||||
const tempMessageId = getUuid();
|
|
||||||
const pendingMessage = {
|
|
||||||
id: tempMessageId,
|
|
||||||
echo_id: tempMessageId,
|
|
||||||
status: MESSAGE_STATUS.PROGRESS,
|
|
||||||
created_at: timestamp,
|
|
||||||
message_type: MESSAGE_TYPE.OUTGOING,
|
|
||||||
conversation_id: conversationId,
|
|
||||||
attachments: [
|
|
||||||
{
|
|
||||||
id: tempMessageId,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
private: isPrivate,
|
|
||||||
content: null,
|
|
||||||
};
|
|
||||||
return pendingMessage;
|
return pendingMessage;
|
||||||
};
|
};
|
||||||
|
|
11
app/javascript/dashboard/helper/files.js
Normal file
11
app/javascript/dashboard/helper/files.js
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
export const formatBytes = (bytes, decimals = 2) => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||||
|
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
|
};
|
|
@ -1,8 +1,4 @@
|
||||||
import {
|
import { getTypingUsersText, createPendingMessage } from '../commons';
|
||||||
getTypingUsersText,
|
|
||||||
createPendingMessage,
|
|
||||||
createPendingAttachment,
|
|
||||||
} from '../commons';
|
|
||||||
|
|
||||||
describe('#getTypingUsersText', () => {
|
describe('#getTypingUsersText', () => {
|
||||||
it('returns the correct text is there is only one typing user', () => {
|
it('returns the correct text is there is only one typing user', () => {
|
||||||
|
@ -58,12 +54,13 @@ describe('#createPendingMessage', () => {
|
||||||
echo_id: pending.id,
|
echo_id: pending.id,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('#createPendingAttachment', () => {
|
it('returns the pending message with attachmnet key if file is passed', () => {
|
||||||
const message = [1, { isPrivate: false }];
|
const messageWithFile = {
|
||||||
it('returns the pending message with expected new keys', () => {
|
message: 'hi',
|
||||||
expect(createPendingAttachment(message)).toHaveProperty(
|
file: {},
|
||||||
|
};
|
||||||
|
expect(createPendingMessage(messageWithFile)).toHaveProperty(
|
||||||
'content',
|
'content',
|
||||||
'id',
|
'id',
|
||||||
'status',
|
'status',
|
||||||
|
@ -77,21 +74,12 @@ describe('#createPendingAttachment', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the pending message with status progress', () => {
|
|
||||||
expect(createPendingAttachment(message)).toMatchObject({
|
|
||||||
status: 'progress',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the pending message with same id and echo_id', () => {
|
|
||||||
const pending = createPendingAttachment(message);
|
|
||||||
expect(pending).toMatchObject({
|
|
||||||
echo_id: pending.id,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the pending message to have one attachment', () => {
|
it('returns the pending message to have one attachment', () => {
|
||||||
const pending = createPendingAttachment(message);
|
const messageWithFile = {
|
||||||
|
message: 'hi',
|
||||||
|
file: {},
|
||||||
|
};
|
||||||
|
const pending = createPendingMessage(messageWithFile);
|
||||||
expect(pending.attachments.length).toBe(1);
|
expect(pending.attachments.length).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
18
app/javascript/dashboard/helper/specs/files.spec.js
Normal file
18
app/javascript/dashboard/helper/specs/files.spec.js
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { formatBytes } from '../files';
|
||||||
|
|
||||||
|
describe('#File Helpers', () => {
|
||||||
|
describe('formatBytes', () => {
|
||||||
|
it('should return zero bytes if 0 is passed', () => {
|
||||||
|
expect(formatBytes(0)).toBe('0 Bytes');
|
||||||
|
});
|
||||||
|
it('should return in bytes if 1000 is passed', () => {
|
||||||
|
expect(formatBytes(1000)).toBe('1000 Bytes');
|
||||||
|
});
|
||||||
|
it('should return in KB if 100000 is passed', () => {
|
||||||
|
expect(formatBytes(10000)).toBe('9.77 KB');
|
||||||
|
});
|
||||||
|
it('should return in MB if 10000000 is passed', () => {
|
||||||
|
expect(formatBytes(10000000)).toBe('9.54 MB');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -3,10 +3,7 @@ import * as types from '../../mutation-types';
|
||||||
import ConversationApi from '../../../api/inbox/conversation';
|
import ConversationApi from '../../../api/inbox/conversation';
|
||||||
import MessageApi from '../../../api/inbox/message';
|
import MessageApi from '../../../api/inbox/message';
|
||||||
import { MESSAGE_STATUS, MESSAGE_TYPE } from 'shared/constants/messages';
|
import { MESSAGE_STATUS, MESSAGE_TYPE } from 'shared/constants/messages';
|
||||||
import {
|
import { createPendingMessage } from 'dashboard/helper/commons';
|
||||||
createPendingMessage,
|
|
||||||
createPendingAttachment,
|
|
||||||
} from 'dashboard/helper/commons';
|
|
||||||
|
|
||||||
// actions
|
// actions
|
||||||
const actions = {
|
const actions = {
|
||||||
|
@ -215,20 +212,6 @@ const actions = {
|
||||||
commit(types.default.SET_ACTIVE_INBOX, inboxId);
|
commit(types.default.SET_ACTIVE_INBOX, inboxId);
|
||||||
},
|
},
|
||||||
|
|
||||||
sendAttachment: async ({ commit }, data) => {
|
|
||||||
try {
|
|
||||||
const pendingMessage = createPendingAttachment(data);
|
|
||||||
commit(types.default.ADD_MESSAGE, pendingMessage);
|
|
||||||
const response = await MessageApi.sendAttachment([
|
|
||||||
...data,
|
|
||||||
pendingMessage.id,
|
|
||||||
]);
|
|
||||||
commit(types.default.ADD_MESSAGE, response.data);
|
|
||||||
} catch (error) {
|
|
||||||
// Handle error
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
muteConversation: async ({ commit }, conversationId) => {
|
muteConversation: async ({ commit }, conversationId) => {
|
||||||
try {
|
try {
|
||||||
await ConversationApi.mute(conversationId);
|
await ConversationApi.mute(conversationId);
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
:root {
|
||||||
|
// border-radius
|
||||||
|
--border-radius-small: 0.3rem;
|
||||||
|
--border-radius-normal: 0.5rem;
|
||||||
|
--border-radius-medium: 0.7rem;
|
||||||
|
--border-radius-large: 0.9rem;
|
||||||
|
}
|
|
@ -14,14 +14,18 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="message-wrap">
|
<div class="message-wrap">
|
||||||
<AgentMessageBubble
|
<AgentMessageBubble
|
||||||
v-if="!hasAttachments && shouldDisplayAgentMessage"
|
v-if="shouldDisplayAgentMessage"
|
||||||
:content-type="contentType"
|
:content-type="contentType"
|
||||||
:message-content-attributes="messageContentAttributes"
|
:message-content-attributes="messageContentAttributes"
|
||||||
:message-id="message.id"
|
:message-id="message.id"
|
||||||
:message-type="messageType"
|
:message-type="messageType"
|
||||||
:message="message.content"
|
:message="message.content"
|
||||||
/>
|
/>
|
||||||
<div v-if="hasAttachments" class="chat-bubble has-attachment agent">
|
<div
|
||||||
|
v-if="hasAttachments"
|
||||||
|
class="chat-bubble has-attachment agent"
|
||||||
|
:class="wrapClass"
|
||||||
|
>
|
||||||
<div v-for="attachment in message.attachments" :key="attachment.id">
|
<div v-for="attachment in message.attachments" :key="attachment.id">
|
||||||
<file-bubble
|
<file-bubble
|
||||||
v-if="attachment.file_type !== 'image'"
|
v-if="attachment.file_type !== 'image'"
|
||||||
|
@ -87,6 +91,7 @@ export default {
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!this.message.content) return false;
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
hasAttachments() {
|
hasAttachments() {
|
||||||
|
@ -167,6 +172,11 @@ export default {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
wrapClass() {
|
||||||
|
return {
|
||||||
|
'has-text': this.shouldDisplayAgentMessage,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -213,6 +223,10 @@ export default {
|
||||||
.has-attachment {
|
.has-attachment {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.has-text {
|
||||||
|
margin-top: $space-smaller;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-message-wrap {
|
.agent-message-wrap {
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
v-for="message in unreadMessages"
|
v-for="message in unreadMessages"
|
||||||
:key="message.id"
|
:key="message.id"
|
||||||
:message-id="message.id"
|
:message-id="message.id"
|
||||||
:message="message.content"
|
:message="getMessageContent(message)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
@ -88,6 +88,16 @@ export default {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
getMessageContent(message) {
|
||||||
|
const { attachments, content } = message;
|
||||||
|
const hasAttachments = attachments && attachments.length;
|
||||||
|
|
||||||
|
if (content) return content;
|
||||||
|
|
||||||
|
if (hasAttachments) return `📑`;
|
||||||
|
|
||||||
|
return '';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -6,13 +6,18 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
|
||||||
end
|
end
|
||||||
|
|
||||||
def perform_reply
|
def perform_reply
|
||||||
result = FacebookBot::Bot.deliver(delivery_params, access_token: message.channel_token)
|
send_message_to_facebook fb_text_message_params if message.content.present?
|
||||||
message.update!(source_id: JSON.parse(result)['message_id'])
|
send_message_to_facebook fb_attachment_message_params if message.attachments.present?
|
||||||
rescue Facebook::Messenger::FacebookError => e
|
rescue Facebook::Messenger::FacebookError => e
|
||||||
Rails.logger.info e
|
Rails.logger.info e
|
||||||
channel.authorization_error!
|
channel.authorization_error!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def send_message_to_facebook(delivery_params)
|
||||||
|
result = FacebookBot::Bot.deliver(delivery_params, access_token: message.channel_token)
|
||||||
|
message.update!(source_id: JSON.parse(result)['message_id'])
|
||||||
|
end
|
||||||
|
|
||||||
def fb_text_message_params
|
def fb_text_message_params
|
||||||
{
|
{
|
||||||
recipient: { id: contact.get_source_id(inbox.id) },
|
recipient: { id: contact.get_source_id(inbox.id) },
|
||||||
|
@ -49,29 +54,6 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def delivery_params
|
|
||||||
if twenty_four_hour_window_over?
|
|
||||||
fb_message_params.merge(tag: 'ISSUE_RESOLUTION')
|
|
||||||
else
|
|
||||||
fb_message_params
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def twenty_four_hour_window_over?
|
|
||||||
return false unless after_24_hours?
|
|
||||||
return false if last_incoming_and_outgoing_message_after_one_day?
|
|
||||||
|
|
||||||
true
|
|
||||||
end
|
|
||||||
|
|
||||||
def last_incoming_and_outgoing_message_after_one_day?
|
|
||||||
last_incoming_message && sent_first_outgoing_message_after_24_hours?
|
|
||||||
end
|
|
||||||
|
|
||||||
def after_24_hours?
|
|
||||||
(Time.current - last_incoming_message.created_at) / 3600 >= 24
|
|
||||||
end
|
|
||||||
|
|
||||||
def sent_first_outgoing_message_after_24_hours?
|
def sent_first_outgoing_message_after_24_hours?
|
||||||
# we can send max 1 message after 24 hour window
|
# we can send max 1 message after 24 hour window
|
||||||
conversation.messages.outgoing.where('id > ?', last_incoming_message.id).count == 1
|
conversation.messages.outgoing.where('id > ?', last_incoming_message.id).count == 1
|
||||||
|
|
|
@ -58,7 +58,21 @@ describe Facebook::SendOnFacebookService do
|
||||||
attachment.file.attach(io: File.open(Rails.root.join('spec/assets/avatar.png')), filename: 'avatar.png', content_type: 'image/png')
|
attachment.file.attach(io: File.open(Rails.root.join('spec/assets/avatar.png')), filename: 'avatar.png', content_type: 'image/png')
|
||||||
message.save!
|
message.save!
|
||||||
::Facebook::SendOnFacebookService.new(message: message).perform
|
::Facebook::SendOnFacebookService.new(message: message).perform
|
||||||
expect(bot).to have_received(:deliver)
|
expect(bot).to have_received(:deliver).with({
|
||||||
|
recipient: { id: contact_inbox.source_id },
|
||||||
|
message: { text: message.content }
|
||||||
|
}, { access_token: facebook_channel.page_access_token })
|
||||||
|
expect(bot).to have_received(:deliver).with({
|
||||||
|
recipient: { id: contact_inbox.source_id },
|
||||||
|
message: {
|
||||||
|
attachment: {
|
||||||
|
type: 'image',
|
||||||
|
payload: {
|
||||||
|
url: attachment.file_url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { access_token: facebook_channel.page_access_token })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in a new issue