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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 434 additions and 250 deletions

View file

@ -13,24 +13,14 @@ class MessageApi extends ApiClient {
private: isPrivate,
contentAttributes,
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();
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('echo_id', echoId);
return axios({
@ -39,6 +29,12 @@ class MessageApi extends ApiClient {
data: formData,
});
}
getPreviousMessages({ conversationId, before }) {
return axios.get(`${this.url}/${conversationId}/messages`, {
params: { before },
});
}
}
export default new MessageApi();

View file

@ -3,6 +3,7 @@
@import 'shared/assets/stylesheets/spacing';
@import 'shared/assets/stylesheets/font-size';
@import 'shared/assets/stylesheets/font-weights';
@import 'shared/assets/stylesheets/border-radius';
@import 'variables';
@import '~spinkit/scss/spinners/7-three-bounce';

View file

@ -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 {
cursor: pointer;
position: relative;
@ -201,6 +194,10 @@
color: $color-body;
margin-right: auto;
&.is-image {
border-radius: var(--border-radius-large);
}
.link {
color: $color-primary-dark;
}
@ -257,6 +254,10 @@
top: $space-smaller + $space-micro;
}
}
&.is-image {
border-radius: var(--border-radius-large);
}
}
+.left {
@ -296,30 +297,9 @@
border-radius: $space-smaller;
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 {
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;
}
}
}
}
}

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 {
> .bubble {
&.is-image {
padding: 0;
overflow: hidden;
.image {
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 {
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,4 +1,11 @@
<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__top" :class="{ 'is-private': isPrivate }">
<canned-response
@ -30,8 +37,7 @@
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" />
<i class="icon ion-android-attach attachment" />
</file-upload>
<i
class="icon ion-happy-outline"
@ -79,6 +85,7 @@
</button>
</div>
</div>
</div>
</template>
<script>
@ -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>

View file

@ -33,37 +33,19 @@ export const getTypingUsersText = (users = []) => {
export const createPendingMessage = data => {
const timestamp = Math.floor(new Date().getTime() / 1000);
const tempMessageId = getUuid();
const { message, file } = data;
const tempAttachments = [{ id: tempMessageId }];
const pendingMessage = {
...data,
content: data.message,
content: message || null,
id: tempMessageId,
echo_id: tempMessageId,
status: MESSAGE_STATUS.PROGRESS,
created_at: timestamp,
message_type: MESSAGE_TYPE.OUTGOING,
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;
};

View 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];
};

View file

@ -1,8 +1,4 @@
import {
getTypingUsersText,
createPendingMessage,
createPendingAttachment,
} from '../commons';
import { getTypingUsersText, createPendingMessage } from '../commons';
describe('#getTypingUsersText', () => {
it('returns the correct text is there is only one typing user', () => {
@ -58,12 +54,13 @@ describe('#createPendingMessage', () => {
echo_id: pending.id,
});
});
});
describe('#createPendingAttachment', () => {
const message = [1, { isPrivate: false }];
it('returns the pending message with expected new keys', () => {
expect(createPendingAttachment(message)).toHaveProperty(
it('returns the pending message with attachmnet key if file is passed', () => {
const messageWithFile = {
message: 'hi',
file: {},
};
expect(createPendingMessage(messageWithFile)).toHaveProperty(
'content',
'id',
'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', () => {
const pending = createPendingAttachment(message);
const messageWithFile = {
message: 'hi',
file: {},
};
const pending = createPendingMessage(messageWithFile);
expect(pending.attachments.length).toBe(1);
});
});

View 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');
});
});
});

View file

@ -3,10 +3,7 @@ import * as types from '../../mutation-types';
import ConversationApi from '../../../api/inbox/conversation';
import MessageApi from '../../../api/inbox/message';
import { MESSAGE_STATUS, MESSAGE_TYPE } from 'shared/constants/messages';
import {
createPendingMessage,
createPendingAttachment,
} from 'dashboard/helper/commons';
import { createPendingMessage } from 'dashboard/helper/commons';
// actions
const actions = {
@ -215,20 +212,6 @@ const actions = {
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) => {
try {
await ConversationApi.mute(conversationId);

View file

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

View file

@ -14,14 +14,18 @@
</div>
<div class="message-wrap">
<AgentMessageBubble
v-if="!hasAttachments && shouldDisplayAgentMessage"
v-if="shouldDisplayAgentMessage"
:content-type="contentType"
:message-content-attributes="messageContentAttributes"
:message-id="message.id"
:message-type="messageType"
: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">
<file-bubble
v-if="attachment.file_type !== 'image'"
@ -87,6 +91,7 @@ export default {
) {
return false;
}
if (!this.message.content) return false;
return true;
},
hasAttachments() {
@ -167,6 +172,11 @@ export default {
})
);
},
wrapClass() {
return {
'has-text': this.shouldDisplayAgentMessage,
};
},
},
};
</script>
@ -213,6 +223,10 @@ export default {
.has-attachment {
padding: 0;
overflow: hidden;
&.has-text {
margin-top: $space-smaller;
}
}
.agent-message-wrap {

View file

@ -15,7 +15,7 @@
v-for="message in unreadMessages"
:key="message.id"
:message-id="message.id"
:message="message.content"
:message="getMessageContent(message)"
/>
</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>

View file

@ -6,13 +6,18 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
end
def perform_reply
result = FacebookBot::Bot.deliver(delivery_params, access_token: message.channel_token)
message.update!(source_id: JSON.parse(result)['message_id'])
send_message_to_facebook fb_text_message_params if message.content.present?
send_message_to_facebook fb_attachment_message_params if message.attachments.present?
rescue Facebook::Messenger::FacebookError => e
Rails.logger.info e
channel.authorization_error!
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
{
recipient: { id: contact.get_source_id(inbox.id) },
@ -49,29 +54,6 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
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?
# we can send max 1 message after 24 hour window
conversation.messages.outgoing.where('id > ?', last_incoming_message.id).count == 1

View file

@ -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')
message.save!
::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