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:
Nithin David Thomas
2021-01-06 17:56:29 +05:30
committed by GitHub
parent db189e3c26
commit 3d2db95417
17 changed files with 434 additions and 250 deletions

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -1,7 +1,7 @@
<template>
<span class="message-text__wrap">
<div class="message-text__wrap">
<span v-html="message"></span>
</span>
</div>
</template>
<script>