Feature: Send images from widget

This commit is contained in:
Nithin David Thomas 2020-03-30 12:15:06 +05:30 committed by GitHub
parent e56132c506
commit 6c4e1fdaac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 305 additions and 67 deletions

View file

@ -7,10 +7,16 @@ const sendMessageAPI = async content => {
return result; return result;
}; };
const sendAttachmentAPI = async attachment => {
const urlData = endPoints.sendAttachmnet(attachment);
const result = await API.post(urlData.url, urlData.params);
return result;
};
const getConversationAPI = async ({ before }) => { const getConversationAPI = async ({ before }) => {
const urlData = endPoints.getConversation({ before }); const urlData = endPoints.getConversation({ before });
const result = await API.get(urlData.url, { params: urlData.params }); const result = await API.get(urlData.url, { params: urlData.params });
return result; return result;
}; };
export { sendMessageAPI, getConversationAPI }; export { sendMessageAPI, getConversationAPI, sendAttachmentAPI };

View file

@ -9,6 +9,22 @@ const sendMessage = content => ({
}, },
}); });
const sendAttachmnet = ({ attachment }) => {
const { refererURL = '' } = window;
const timestamp = new Date().toString();
const { file, file_type: fileType } = attachment;
const formData = new FormData();
formData.append('message[attachment][file]', file);
formData.append('message[attachment][file_type]', fileType);
formData.append('message[referer_url]', refererURL);
formData.append('message[timestamp]', timestamp);
return {
url: `/api/v1/widget/messages${window.location.search}`,
params: formData,
};
};
const getConversation = ({ before }) => ({ const getConversation = ({ before }) => ({
url: `/api/v1/widget/messages${window.location.search}`, url: `/api/v1/widget/messages${window.location.search}`,
params: { before }, params: { before },
@ -27,6 +43,7 @@ const getAvailableAgents = token => ({
export default { export default {
sendMessage, sendMessage,
sendAttachmnet,
getConversation, getConversation,
updateContact, updateContact,
getAvailableAgents, getAvailableAgents,

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-paperclip"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg>

After

Width:  |  Height:  |  Size: 352 B

View file

@ -17,10 +17,10 @@
:message-type="messageType" :message-type="messageType"
:message="message.content" :message="message.content"
/> />
<div v-else class="chat-bubble has-attachment agent"> <div v-if="hasImage" class="chat-bubble has-attachment agent">
<image-bubble <image-bubble
v-if="message.attachment && message.attachment.file_type === 'image'"
:url="message.attachment.data_url" :url="message.attachment.data_url"
:thumb="message.attachment.thumb_url"
:readable-time="readableTime" :readable-time="readableTime"
/> />
</div> </div>
@ -53,9 +53,14 @@ export default {
}, },
}, },
computed: { computed: {
hasImage() {
const { attachment = {} } = this.message;
const { file_type: fileType } = attachment;
return fileType === 'image';
},
showTextBubble() { showTextBubble() {
const { message } = this; const { message } = this;
return !!message.content && !message.attachment; return !!message.content;
}, },
readableTime() { readableTime() {
const { created_at: createdAt = '' } = this.message; const { created_at: createdAt = '' } = this.message;

View file

@ -0,0 +1,66 @@
<template>
<file-upload accept="image/*" @input-file="onFileUpload">
<span class="attachment-button ">
<i v-if="!isUploading.image"></i>
<spinner v-if="isUploading" size="small" />
</span>
</file-upload>
</template>
<script>
import FileUpload from 'vue-upload-component';
import Spinner from 'shared/components/Spinner.vue';
export default {
components: { FileUpload, Spinner },
props: {
onAttach: {
type: Function,
default: () => {},
},
},
data() {
return { isUploading: false };
},
methods: {
async onFileUpload(file) {
this.isUploading = true;
try {
const thumbUrl = window.URL.createObjectURL(file.file);
await this.onAttach({
file_type: file.type,
file: file.file,
thumbUrl,
});
} catch (error) {
// Error
}
this.isUploading = false;
},
},
};
</script>
<style scoped lang="scss">
@import '~widget/assets/scss/variables.scss';
.attachment-button {
background: transparent;
border: 0;
cursor: pointer;
position: relative;
padding-right: $space-smaller;
display: block;
width: 20px;
height: 20px;
i {
padding: 0;
width: 100%;
height: 100%;
display: block;
background: white center center no-repeat;
background-size: contain;
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23999a9b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-paperclip'%3E%3Cpath d='M21 11l-9 9a6 6 0 01-8-8l9-9a4 4 0 016 5L9 17a2 2 0 01-2-2l8-9' /%3E%3C/svg%3E");
}
}
</style>

View file

@ -1,10 +1,14 @@
<template> <template>
<footer class="footer"> <footer class="footer">
<ChatInputWrap :on-send-message="onSendMessage" /> <ChatInputWrap
:on-send-message="handleSendMessage"
:on-send-attachment="handleSendAttachment"
/>
</footer> </footer>
</template> </template>
<script> <script>
import { mapActions } from 'vuex';
import ChatInputWrap from 'widget/components/ChatInputWrap.vue'; import ChatInputWrap from 'widget/components/ChatInputWrap.vue';
export default { export default {
@ -16,9 +20,16 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
onSendMessage: { },
type: Function, methods: {
default: () => {}, ...mapActions('conversation', ['sendMessage', 'sendAttachment']),
handleSendMessage(content) {
this.sendMessage({
content,
});
},
handleSendAttachment(attachment) {
this.sendAttachment({ attachment });
}, },
}, },
}; };

View file

@ -1,5 +1,6 @@
<template> <template>
<div class="chat-message--input"> <div class="chat-message--input">
<chat-attchment-button :on-attach="onSendAttachment" />
<ChatInputArea v-model="userInput" :placeholder="placeholder" /> <ChatInputArea v-model="userInput" :placeholder="placeholder" />
<ChatSendButton <ChatSendButton
:on-click="handleButtonClick" :on-click="handleButtonClick"
@ -12,11 +13,13 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import ChatSendButton from 'widget/components/ChatSendButton.vue'; import ChatSendButton from 'widget/components/ChatSendButton.vue';
import ChatAttchmentButton from 'widget/components/ChatAttachment.vue';
import ChatInputArea from 'widget/components/ChatInputArea.vue'; import ChatInputArea from 'widget/components/ChatInputArea.vue';
export default { export default {
name: 'ChatInputWrap', name: 'ChatInputWrap',
components: { components: {
ChatAttchmentButton,
ChatSendButton, ChatSendButton,
ChatInputArea, ChatInputArea,
}, },
@ -30,6 +33,10 @@ export default {
type: Function, type: Function,
default: () => {}, default: () => {},
}, },
onSendAttachment: {
type: Function,
default: () => {},
},
}, },
data() { data() {

View file

@ -1,9 +1,5 @@
<template> <template>
<UserMessage <UserMessage v-if="isUserMessage" :message="message" />
v-if="isUserMessage"
:message="message.content"
:status="message.status"
/>
<AgentMessage v-else :message="message" /> <AgentMessage v-else :message="message" />
</template> </template>

View file

@ -1,23 +1,45 @@
<template> <template>
<a :href="url" target="_blank" class="image message-text__wrap"> <a :href="url" target="_blank" class="image">
<img :src="url" alt="Picture message" /> <div class="wrap">
<span class="time">{{ readableTime }}</span> <img :src="thumb" alt="Picture message" />
<span class="time">{{ readableTime }}</span>
</div>
</a> </a>
</template> </template>
<script> <script>
export default { export default {
props: ['url', 'readableTime'], props: ['url', 'thumb', 'readableTime'],
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '~widget/assets/scss/variables.scss'; @import '~widget/assets/scss/variables.scss';
.image { .image {
max-width: 100%;
position: relative;
display: block; display: block;
.wrap {
position: relative;
display: flex;
max-width: 100%;
&::before {
$color-black: #000;
background-image: linear-gradient(
-180deg,
transparent 3%,
$color-black 70%
);
bottom: 0;
content: '';
height: 20%;
left: 0;
opacity: 0.8;
position: absolute;
width: 100%;
}
}
img { img {
width: 100%; width: 100%;
} }
@ -30,21 +52,5 @@ export default {
right: $space-small; right: $space-small;
white-space: nowrap; white-space: nowrap;
} }
&::before {
$color-black: #000;
background-image: linear-gradient(
-180deg,
transparent 3%,
$color-black 70%
);
bottom: 0;
content: '';
height: 20%;
left: 0;
opacity: 0.8;
position: absolute;
width: 100%;
}
} }
</style> </style>

View file

@ -1,25 +1,58 @@
<template> <template>
<div class="user-message"> <div class="user-message">
<div class="message-wrap"> <div class="message-wrap" :class="{ 'in-progress': isInProgress }">
<UserMessageBubble :message="message" :status="status" /> <UserMessageBubble
v-if="showTextBubble"
:message="message.content"
:status="message.status"
/>
<div v-if="hasImage" class="chat-bubble has-attachment user">
<image-bubble
:url="message.attachment.data_url"
:thumb="message.attachment.thumb_url"
:readable-time="readableTime"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import UserMessageBubble from 'widget/components/UserMessageBubble.vue'; import UserMessageBubble from 'widget/components/UserMessageBubble';
import ImageBubble from 'widget/components/ImageBubble';
import timeMixin from 'dashboard/mixins/time';
export default { export default {
name: 'UserMessage', name: 'UserMessage',
components: { components: {
UserMessageBubble, UserMessageBubble,
ImageBubble,
}, },
mixins: [timeMixin],
props: { props: {
avatarUrl: String, message: {
message: String, type: Object,
status: { default: () => {},
type: String, },
default: '', },
computed: {
isInProgress() {
const { status = '' } = this.message;
return status === 'in_progress';
},
hasImage() {
const { attachment = {} } = this.message;
const { file_type: fileType } = attachment;
return fileType === 'image';
},
showTextBubble() {
const { message } = this;
return !!message.content;
},
readableTime() {
const { created_at: createdAt = '' } = this.message;
return this.messageStamp(createdAt);
}, },
}, },
}; };
@ -51,6 +84,15 @@ export default {
.message-wrap { .message-wrap {
margin-right: $space-small; margin-right: $space-small;
} }
.in-progress {
opacity: 0.6;
}
}
.has-attachment {
padding: 0;
overflow: hidden;
} }
} }
</style> </style>

View file

@ -1,7 +1,7 @@
<template> <template>
<div <div
class="chat-bubble user" class="chat-bubble user"
:style="{ background: backgroundColor }" :style="{ background: widgetColor }"
v-html="formatMessage(message)" v-html="formatMessage(message)"
></div> ></div>
</template> </template>
@ -12,14 +12,6 @@ import { mapGetters } from 'vuex';
export default { export default {
name: 'UserMessageBubble', name: 'UserMessageBubble',
computed: {
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
}),
backgroundColor() {
return this.status !== 'in_progress' ? this.widgetColor : '#c0ccda';
},
},
mixins: [messageFormatterMixin], mixins: [messageFormatterMixin],
props: { props: {
message: String, message: String,
@ -27,6 +19,16 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
computed: {
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
}),
},
},
computed: {
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
}),
}, },
}; };
</script> </script>

View file

@ -1,6 +1,10 @@
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import Vue from 'vue'; import Vue from 'vue';
import { sendMessageAPI, getConversationAPI } from 'widget/api/conversation'; import {
sendMessageAPI,
getConversationAPI,
sendAttachmentAPI,
} from 'widget/api/conversation';
import { MESSAGE_TYPE } from 'widget/helpers/constants'; import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { playNotificationAudio } from 'shared/helpers/AudioNotificationHelper'; import { playNotificationAudio } from 'shared/helpers/AudioNotificationHelper';
import getUuid from '../../helpers/uuid'; import getUuid from '../../helpers/uuid';
@ -8,11 +12,12 @@ import DateHelper from '../../../shared/helpers/DateHelper';
const groupBy = require('lodash.groupby'); const groupBy = require('lodash.groupby');
export const createTemporaryMessage = content => { export const createTemporaryMessage = ({ attachment, content }) => {
const timestamp = new Date().getTime() / 1000; const timestamp = new Date().getTime() / 1000;
return { return {
id: getUuid(), id: getUuid(),
content, content,
attachment,
status: 'in_progress', status: 'in_progress',
created_at: timestamp, created_at: timestamp,
message_type: MESSAGE_TYPE.INCOMING, message_type: MESSAGE_TYPE.INCOMING,
@ -78,10 +83,29 @@ export const getters = {
export const actions = { export const actions = {
sendMessage: async ({ commit }, params) => { sendMessage: async ({ commit }, params) => {
const { content } = params; const { content } = params;
commit('pushMessageToConversation', createTemporaryMessage(content)); commit('pushMessageToConversation', createTemporaryMessage({ content }));
await sendMessageAPI(content); await sendMessageAPI(content);
}, },
sendAttachment: async ({ commit }, params) => {
const { attachment } = params;
const { thumbUrl } = attachment;
const attachmentBlob = {
thumb_url: thumbUrl,
data_url: thumbUrl,
file_type: 'image',
status: 'in_progress',
};
const tempMessage = createTemporaryMessage({ attachment: attachmentBlob });
commit('pushMessageToConversation', tempMessage);
try {
const { data } = await sendAttachmentAPI(params);
commit('setMessageStatus', { message: data, tempId: tempMessage.id });
} catch (error) {
// Show error
}
},
fetchOldConversations: async ({ commit }, { before } = {}) => { fetchOldConversations: async ({ commit }, { before } = {}) => {
try { try {
commit('setConversationListLoading', true); commit('setConversationListLoading', true);
@ -126,6 +150,19 @@ export const mutations = {
} }
}, },
setMessageStatus($state, { message, tempId }) {
const { status, id } = message;
const messagesInbox = $state.conversations;
const messageInConversation = messagesInbox[tempId];
if (messageInConversation) {
Vue.delete(messagesInbox, tempId);
const newMessage = { ...messageInConversation };
Vue.set(messagesInbox, id, { ...newMessage, id, status });
}
},
setConversationListLoading($state, status) { setConversationListLoading($state, status) {
$state.uiFlags.isFetchingList = status; $state.uiFlags.isFetchingList = status;
}, },

View file

@ -42,4 +42,30 @@ describe('#actions', () => {
}); });
}); });
}); });
describe('#sendAttachment', () => {
it('sends correct mutations', () => {
const mockDate = new Date(1466424490000);
getUuid.mockImplementationOnce(() => '1111');
const spy = jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
const thumbUrl = '';
const attachment = { thumbUrl };
actions.sendAttachment({ commit }, { attachment });
spy.mockRestore();
expect(commit).toBeCalledWith('pushMessageToConversation', {
id: '1111',
content: undefined,
status: 'in_progress',
created_at: 1466424490,
message_type: 0,
attachment: {
thumb_url: '',
data_url: '',
file_type: 'image',
status: 'in_progress',
},
});
});
});
}); });

View file

@ -92,4 +92,29 @@ describe('#mutations', () => {
expect(state.uiFlags.allMessagesLoaded).toEqual(false); expect(state.uiFlags.allMessagesLoaded).toEqual(false);
}); });
}); });
describe('#setMessageStatus', () => {
it('Updates status of loading messages if payload is not empty', () => {
const state = {
conversations: {
rand_id_123: {
content: '',
id: 'rand_id_123',
message_type: 0,
status: 'in_progress',
},
},
};
const message = {
id: '1',
content: '',
status: 'sent',
};
mutations.setMessageStatus(state, { message, tempId: 'rand_id_123' });
expect(state.conversations).toEqual({
1: { id: '1', content: '', message_type: 0, status: 'sent' },
});
});
});
}); });

View file

@ -30,7 +30,7 @@ describe('#findUndeliveredMessage', () => {
describe('#createTemporaryMessage', () => { describe('#createTemporaryMessage', () => {
it('returns message object', () => { it('returns message object', () => {
const message = createTemporaryMessage('hello'); const message = createTemporaryMessage({ content: 'hello' });
expect(message.content).toBe('hello'); expect(message.content).toBe('hello');
expect(message.status).toBe('in_progress'); expect(message.status).toBe('in_progress');
}); });

View file

@ -8,7 +8,7 @@
<ConversationWrap :grouped-messages="groupedMessages" /> <ConversationWrap :grouped-messages="groupedMessages" />
<div class="footer-wrap"> <div class="footer-wrap">
<div class="input-wrap"> <div class="input-wrap">
<ChatFooter :on-send-message="handleSendMessage" /> <ChatFooter />
</div> </div>
<branding></branding> <branding></branding>
</div> </div>
@ -16,7 +16,7 @@
</template> </template>
<script> <script>
import { mapActions, mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import Branding from 'widget/components/Branding.vue'; import Branding from 'widget/components/Branding.vue';
import ChatFooter from 'widget/components/ChatFooter.vue'; import ChatFooter from 'widget/components/ChatFooter.vue';
@ -52,15 +52,6 @@ export default {
return this.availableAgents.length > 0 && this.conversationSize < 1; return this.availableAgents.length > 0 && this.conversationSize < 1;
}, },
}, },
methods: {
...mapActions('conversation', ['sendMessage']),
handleSendMessage(content) {
this.sendMessage({
content,
});
},
},
}; };
</script> </script>