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:
committed by
GitHub
parent
db189e3c26
commit
3d2db95417
@@ -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') }}
|
||||
</span>
|
||||
<span v-if="!isPending && hasAttachments">
|
||||
<span v-for="attachment in data.attachments" :key="attachment.id">
|
||||
<div v-if="!isPending && hasAttachments">
|
||||
<div v-for="attachment in data.attachments" :key="attachment.id">
|
||||
<bubble-image
|
||||
v-if="attachment.file_type === 'image'"
|
||||
:url="attachment.data_url"
|
||||
@@ -26,8 +26,8 @@
|
||||
:url="attachment.data_url"
|
||||
:readable-time="readableTime"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<bubble-actions
|
||||
:id="data.id"
|
||||
@@ -138,6 +138,9 @@ export default {
|
||||
}
|
||||
return false;
|
||||
},
|
||||
hasText() {
|
||||
return !!this.data.content;
|
||||
},
|
||||
sentByMessage() {
|
||||
const { sender } = this;
|
||||
|
||||
@@ -160,6 +163,7 @@ export default {
|
||||
bubble: this.isBubble,
|
||||
'is-private': this.data.private,
|
||||
'is-image': this.hasImageAttachment,
|
||||
'is-text': this.hasText,
|
||||
};
|
||||
},
|
||||
isPending() {
|
||||
@@ -170,15 +174,27 @@ export default {
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.wrap {
|
||||
> .is-image.bubble {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.image {
|
||||
max-width: 32rem;
|
||||
> .bubble {
|
||||
&.is-image {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.image {
|
||||
max-width: 32rem;
|
||||
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 {
|
||||
position: relative;
|
||||
opacity: 0.8;
|
||||
@@ -188,6 +204,10 @@ export default {
|
||||
bottom: var(--space-smaller);
|
||||
right: var(--space-smaller);
|
||||
}
|
||||
|
||||
> .is-image.is-text.bubble > .message-text__wrap {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,82 +1,89 @@
|
||||
<template>
|
||||
<div class="reply-box" :class="replyBoxClass">
|
||||
<div class="reply-box__top" :class="{ 'is-private': isPrivate }">
|
||||
<canned-response
|
||||
v-if="showCannedResponsesList"
|
||||
v-on-clickaway="hideCannedResponse"
|
||||
data-dropdown-menu
|
||||
:on-keyenter="replaceText"
|
||||
:on-click="replaceText"
|
||||
/>
|
||||
<emoji-input
|
||||
v-if="showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
:on-click="emojiOnClick"
|
||||
/>
|
||||
<resizable-text-area
|
||||
ref="messageInput"
|
||||
v-model="message"
|
||||
class="input"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:min-height="4"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<file-upload
|
||||
v-if="showFileUpload"
|
||||
:size="4096 * 4096"
|
||||
accept="image/*, application/pdf, audio/mpeg, video/mp4, audio/ogg, text/csv"
|
||||
@input-file="onFileUpload"
|
||||
>
|
||||
<i v-if="!isUploading" class="icon ion-android-attach attachment" />
|
||||
<woot-spinner v-if="isUploading" />
|
||||
</file-upload>
|
||||
<i
|
||||
class="icon ion-happy-outline"
|
||||
:class="{ active: showEmojiPicker }"
|
||||
@click="toggleEmojiPicker"
|
||||
<div>
|
||||
<div v-if="hasAttachments" class="attachment-preview-box">
|
||||
<attachment-preview
|
||||
:attachments="attachedFiles"
|
||||
:remove-attachment="removeAttachment"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="reply-box__bottom">
|
||||
<ul class="tabs">
|
||||
<li class="tabs-title" :class="{ 'is-active': !isPrivate }">
|
||||
<a href="#" @click="setReplyMode">{{
|
||||
$t('CONVERSATION.REPLYBOX.REPLY')
|
||||
}}</a>
|
||||
</li>
|
||||
<li class="tabs-title is-private" :class="{ 'is-active': isPrivate }">
|
||||
<a href="#" @click="setPrivateReplyMode">
|
||||
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="message.length" class="tabs-title message-length">
|
||||
<a :class="{ 'message-error': isMessageLengthReachingThreshold }">
|
||||
{{ characterCountIndicator }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
class="button send-button"
|
||||
:disabled="isReplyButtonDisabled"
|
||||
:class="{
|
||||
disabled: isReplyButtonDisabled,
|
||||
warning: isPrivate,
|
||||
}"
|
||||
@click="sendMessage"
|
||||
>
|
||||
{{ replyButtonLabel }}
|
||||
<i
|
||||
class="icon"
|
||||
:class="{
|
||||
'ion-android-send': !isPrivate,
|
||||
'ion-android-lock': isPrivate,
|
||||
}"
|
||||
<div class="reply-box" :class="replyBoxClass">
|
||||
<div class="reply-box__top" :class="{ 'is-private': isPrivate }">
|
||||
<canned-response
|
||||
v-if="showCannedResponsesList"
|
||||
v-on-clickaway="hideCannedResponse"
|
||||
data-dropdown-menu
|
||||
:on-keyenter="replaceText"
|
||||
:on-click="replaceText"
|
||||
/>
|
||||
</button>
|
||||
<emoji-input
|
||||
v-if="showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
:on-click="emojiOnClick"
|
||||
/>
|
||||
<resizable-text-area
|
||||
ref="messageInput"
|
||||
v-model="message"
|
||||
class="input"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:min-height="4"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<file-upload
|
||||
v-if="showFileUpload"
|
||||
:size="4096 * 4096"
|
||||
accept="image/*, application/pdf, audio/mpeg, video/mp4, audio/ogg, text/csv"
|
||||
@input-file="onFileUpload"
|
||||
>
|
||||
<i class="icon ion-android-attach attachment" />
|
||||
</file-upload>
|
||||
<i
|
||||
class="icon ion-happy-outline"
|
||||
:class="{ active: showEmojiPicker }"
|
||||
@click="toggleEmojiPicker"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="reply-box__bottom">
|
||||
<ul class="tabs">
|
||||
<li class="tabs-title" :class="{ 'is-active': !isPrivate }">
|
||||
<a href="#" @click="setReplyMode">{{
|
||||
$t('CONVERSATION.REPLYBOX.REPLY')
|
||||
}}</a>
|
||||
</li>
|
||||
<li class="tabs-title is-private" :class="{ 'is-active': isPrivate }">
|
||||
<a href="#" @click="setPrivateReplyMode">
|
||||
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="message.length" class="tabs-title message-length">
|
||||
<a :class="{ 'message-error': isMessageLengthReachingThreshold }">
|
||||
{{ characterCountIndicator }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
class="button send-button"
|
||||
:disabled="isReplyButtonDisabled"
|
||||
:class="{
|
||||
disabled: isReplyButtonDisabled,
|
||||
warning: isPrivate,
|
||||
}"
|
||||
@click="sendMessage"
|
||||
>
|
||||
{{ replyButtonLabel }}
|
||||
<i
|
||||
class="icon"
|
||||
:class="{
|
||||
'ion-android-send': !isPrivate,
|
||||
'ion-android-lock': isPrivate,
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -89,6 +96,7 @@ import FileUpload from 'vue-upload-component';
|
||||
import EmojiInput from 'shared/components/emoji/EmojiInput';
|
||||
import CannedResponse from './CannedResponse';
|
||||
import ResizableTextArea from 'shared/components/ResizableTextArea';
|
||||
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview';
|
||||
import {
|
||||
isEscape,
|
||||
isEnter,
|
||||
@@ -103,6 +111,7 @@ export default {
|
||||
CannedResponse,
|
||||
FileUpload,
|
||||
ResizableTextArea,
|
||||
AttachmentPreview,
|
||||
},
|
||||
mixins: [clickaway, inboxMixin],
|
||||
props: {
|
||||
@@ -118,7 +127,7 @@ export default {
|
||||
isFocused: false,
|
||||
showEmojiPicker: false,
|
||||
showCannedResponsesList: false,
|
||||
isUploading: false,
|
||||
attachedFiles: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -148,6 +157,8 @@ export default {
|
||||
},
|
||||
isReplyButtonDisabled() {
|
||||
const isMessageEmpty = !this.message.trim().replace(/\n/g, '').length;
|
||||
|
||||
if (this.hasAttachments) return false;
|
||||
return (
|
||||
isMessageEmpty ||
|
||||
this.message.length === 0 ||
|
||||
@@ -196,9 +207,12 @@ export default {
|
||||
},
|
||||
replyBoxClass() {
|
||||
return {
|
||||
'is-focused': this.isFocused,
|
||||
'is-focused': this.isFocused || this.hasAttachments,
|
||||
};
|
||||
},
|
||||
hasAttachments() {
|
||||
return this.attachedFiles.length;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentChat(conversation) {
|
||||
@@ -250,18 +264,11 @@ export default {
|
||||
if (this.isReplyButtonDisabled) {
|
||||
return;
|
||||
}
|
||||
const newMessage = this.message;
|
||||
if (!this.showCannedResponsesList) {
|
||||
const newMessage = this.message;
|
||||
const messagePayload = this.getMessagePayload(newMessage);
|
||||
this.clearMessage();
|
||||
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);
|
||||
this.$emit('scrollToMessage');
|
||||
} catch (error) {
|
||||
@@ -288,6 +295,7 @@ export default {
|
||||
},
|
||||
clearMessage() {
|
||||
this.message = '';
|
||||
this.attachedFiles = [];
|
||||
},
|
||||
toggleEmojiPicker() {
|
||||
this.showEmojiPicker = !this.showEmojiPicker;
|
||||
@@ -322,30 +330,63 @@ export default {
|
||||
}
|
||||
},
|
||||
onFileUpload(file) {
|
||||
this.attachedFiles = [];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
this.isUploading = true;
|
||||
this.$store
|
||||
.dispatch('sendAttachment', [
|
||||
this.currentChat.id,
|
||||
{ file: file.file, isPrivate: this.isPrivate },
|
||||
])
|
||||
.then(() => {
|
||||
this.isUploading = false;
|
||||
this.$emit('scrollToMessage');
|
||||
})
|
||||
.catch(() => {
|
||||
this.isUploading = false;
|
||||
this.$emit('scrollToMessage');
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file.file);
|
||||
|
||||
reader.onloadend = () => {
|
||||
this.attachedFiles.push({
|
||||
currentChatId: this.currentChat.id,
|
||||
resource: file,
|
||||
isPrivate: this.isPrivate,
|
||||
thumb: reader.result,
|
||||
});
|
||||
};
|
||||
},
|
||||
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>
|
||||
|
||||
<style lang="scss">
|
||||
@import '~widget/assets/scss/mixins';
|
||||
|
||||
.send-button {
|
||||
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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<span class="message-text__wrap">
|
||||
<div class="message-text__wrap">
|
||||
<span v-html="message"></span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
Reference in New Issue
Block a user