Feature: Contact Panel with conversation details (#397)

* Add Contact panel changes

* Fix parent iframe blocked

* Add Conversation Panel, Contact messages

* Update contact panel with conversation details

* Update designs in sidebar

* Fix specs

* Specs: Add specs for conversationMetadata and contact modules

* Fix currentUrl issues

* Fix spelling

* Set default to empty string
This commit is contained in:
Pranav Raj S 2020-01-01 22:30:43 +05:30 committed by Sojan Jose
parent 434d6c2656
commit 439e064d90
28 changed files with 662 additions and 42 deletions

View file

@ -38,7 +38,9 @@ class Api::V1::Widget::MessagesController < ActionController::Base
inbox_id: inbox.id,
contact_id: cookie_params[:contact_id],
additional_attributes: {
browser: browser_params
browser: browser_params,
referer: permitted_params[:message][:referer_url],
initiated_at: timestamp_params
}
}
end
@ -53,6 +55,12 @@ class Api::V1::Widget::MessagesController < ActionController::Base
}
end
def timestamp_params
{
timestamp: permitted_params[:message][:timestamp]
}
end
def inbox
@inbox ||= ::Inbox.find_by(id: cookie_params[:inbox_id])
end
@ -79,7 +87,7 @@ class Api::V1::Widget::MessagesController < ActionController::Base
end
def permitted_params
params.permit(:before, message: [:content])
params.permit(:before, message: [:content, :referer_url, :timestamp])
end
def secret_key

View file

@ -0,0 +1,9 @@
import ApiClient from './ApiClient';
class ContactAPI extends ApiClient {
constructor() {
super('contacts');
}
}
export default new ContactAPI();

View file

@ -217,3 +217,9 @@
border-left: $size solid transparent;
}
}
@mixin text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View file

@ -8,7 +8,7 @@
:username="chat.meta.sender.name"
/>
<div class="user--profile__meta">
<h3 class="user--name">
<h3 v-if="!isContactPanelOpen" class="user--name text-truncate">
{{ chat.meta.sender.name }}
</h3>
<button
@ -113,3 +113,11 @@ export default {
},
};
</script>
<style lang="scss" scoped>
.text-truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View file

@ -0,0 +1,8 @@
{
"CONTACT_PANEL": {
"BROWSER": "Browser",
"OS": "Operating System",
"INITIATED_FROM": "Initiated from",
"INITIATED_AT": "Initiated at"
}
}

View file

@ -3,6 +3,7 @@ import { default as _agentMgmt } from './agentMgmt.json';
import { default as _billing } from './billing.json';
import { default as _cannedMgmt } from './cannedMgmt.json';
import { default as _chatlist } from './chatlist.json';
import { default as _contact } from './contact.json';
import { default as _conversation } from './conversation.json';
import { default as _inboxMgmt } from './inboxMgmt.json';
import { default as _login } from './login.json';
@ -16,6 +17,7 @@ export default {
..._billing,
..._cannedMgmt,
..._chatlist,
..._contact,
..._conversation,
..._inboxMgmt,
..._login,

View file

@ -0,0 +1,47 @@
<template>
<div class="conv-details--item">
<div class="conv-details--item__label">
<i :class="icon" class="conv-details--item__icon"></i>
{{ title }}
</div>
<div class="conv-details--item__value">
{{ value }}
</div>
</div>
</template>
<script>
export default {
props: {
title: { type: String, required: true },
icon: { type: String, required: true },
value: { type: [String, Number], default: '' },
},
};
</script>
<style lang="scss" scoped>
@import '~dashboard/assets/scss/variables';
@import '~dashboard/assets/scss/mixins';
.conv-details--item {
padding-bottom: $space-normal;
&:last-child {
padding-bottom: 0;
}
.conv-details--item__icon {
padding-right: $space-micro;
}
.conv-details--item__label {
font-weight: $font-weight-medium;
margin-bottom: $space-micro;
}
.conv-details--item__value {
word-break: break-all;
}
}
</style>

View file

@ -1,37 +1,127 @@
<template>
<div class="medium-3 bg-white contact--panel">
<thumbnail
:src="contactImage"
size="80px"
:badge="contact.channel"
:username="contact.name"
/>
<h4>
{{ contact.name }}
</h4>
<div class="contact--profile">
<div class="contact--info">
<thumbnail
:src="contact.avatar_url"
size="56px"
:badge="contact.channel"
:username="contact.name"
/>
<div class="contact--details">
<div class="contact--name">
{{ contact.name }}
</div>
<a
v-if="contact.email"
:href="`mailto:${contact.email}`"
class="contact--email"
>
{{ contact.email }}
</a>
<div class="contact--location">
{{ contact.location }}
</div>
</div>
</div>
<div v-if="contact.bio" class="contact--bio">
{{ contact.bio }}
</div>
</div>
<div v-if="browser" class="conversation--details">
<contact-details-item
v-if="browser.browser_name"
:title="$t('CONTACT_PANEL.BROWSER')"
:value="browserName"
icon="ion-ios-world-outline"
/>
<contact-details-item
v-if="browser.platform_name"
:title="$t('CONTACT_PANEL.OS')"
:value="platformName"
icon="ion-laptop"
/>
<contact-details-item
v-if="referer"
:title="$t('CONTACT_PANEL.INITIATED_FROM')"
:value="referer"
icon="ion-link"
/>
<contact-details-item
v-if="initiatedAt"
:title="$t('CONTACT_PANEL.INITIATED_AT')"
:value="initiatedAt.timestamp"
icon="ion-clock"
/>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import ContactDetailsItem from './ContactDetailsItem.vue';
export default {
components: {
ContactDetailsItem,
Thumbnail,
},
props: {
conversationId: {
type: [Number, String],
required: true,
},
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
}),
currentConversationMetaData() {
return this.$store.getters[
'conversationMetadata/getConversationMetadata'
](this.conversationId);
},
additionalAttributes() {
return this.currentConversationMetaData.additional_attributes || {};
},
browser() {
return this.additionalAttributes.browser || {};
},
referer() {
return this.additionalAttributes.referer;
},
initiatedAt() {
return this.additionalAttributes.initiated_at;
},
browserName() {
return `${this.browser.browser_name || ''} ${this.browser
.browser_version || ''}`;
},
platformName() {
const {
platform_name: platformName,
platform_version: platformVersion,
} = this.browser;
return `${platformName || ''} ${platformVersion || ''}`;
},
contactId() {
return this.currentConversationMetaData.contact_id;
},
contact() {
const { meta: { sender = {} } = {} } = this.currentChat || {};
return sender;
return this.$store.getters['contacts/getContact'](this.contactId);
},
contactImage() {
return `/uploads/avatar/contact/${this.contact.id}/profilepic.jpeg`;
},
watch: {
contactId(newContactId, prevContactId) {
if (newContactId && newContactId !== prevContactId) {
this.$store.dispatch('contacts/show', {
id: this.currentConversationMetaData.contact_id,
});
}
},
},
mounted() {
this.$store.dispatch('contacts/show', {
id: this.currentConversationMetaData.contact_id,
});
},
};
</script>
@ -41,9 +131,70 @@ export default {
.contact--panel {
@include border-normal-left;
font-size: $font-size-small;
overflow-y: auto;
background: $color-white;
overflow: auto;
}
.contact--profile {
width: 100%;
padding: $space-normal $space-medium $zero;
align-items: center;
.user-thumbnail-box {
margin-right: $space-normal;
}
}
.contact--details {
p {
margin-bottom: 0;
}
}
.contact--info {
display: flex;
flex-direction: column;
padding: $space-large $space-normal $space-normal;
align-items: center;
}
.contact--name {
@include text-ellipsis;
font-weight: $font-weight-bold;
font-size: $font-size-default;
}
.contact--email {
@include text-ellipsis;
color: $color-body;
display: block;
line-height: $space-medium;
text-decoration: underline;
}
.contact--bio {
margin-top: $space-normal;
}
.conversation--details {
padding: $space-normal $space-medium;
width: 100%;
}
.conversation--labels {
padding: $space-medium;
.icon {
margin-right: $space-micro;
font-size: $font-size-micro;
color: #fff;
}
.label {
color: #fff;
padding: 0.2rem;
}
}
</style>

View file

@ -11,7 +11,10 @@
@contactPanelToggle="onToggleContactPanel"
>
</conversation-box>
<contact-panel v-if="isContactPanelOpen"></contact-panel>
<contact-panel
v-if="isContactPanelOpen"
:conversation-id="conversationId"
/>
</section>
</template>

View file

@ -6,6 +6,8 @@ import auth from './modules/auth';
import billing from './modules/billing';
import cannedResponse from './modules/cannedResponse';
import Channel from './modules/channels';
import contacts from './modules/contacts';
import conversationMetadata from './modules/conversationMetadata';
import conversations from './modules/conversations';
import inboxes from './modules/inboxes';
import inboxMembers from './modules/inboxMembers';
@ -19,6 +21,8 @@ export default new Vuex.Store({
billing,
cannedResponse,
Channel,
contacts,
conversationMetadata,
conversations,
inboxes,
inboxMembers,

View file

@ -0,0 +1,85 @@
/* eslint no-param-reassign: 0 */
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import * as types from '../mutation-types';
import ContactAPI from '../../api/contacts';
const state = {
records: [],
uiFlags: {
isFetching: false,
isFetchingItem: false,
isUpdating: false,
},
};
export const getters = {
getContacts($state) {
return $state.records;
},
getUIFlags($state) {
return $state.uiFlags;
},
getContact: $state => id => {
const [contact = {}] = $state.records.filter(
record => record.id === Number(id)
);
return contact;
},
};
export const actions = {
get: async ({ commit }) => {
commit(types.default.SET_CONTACT_UI_FLAG, { isFetching: true });
try {
const response = await ContactAPI.get();
commit(types.default.SET_CONTACTS, response.data.payload);
commit(types.default.SET_CONTACT_UI_FLAG, { isFetching: false });
} catch (error) {
commit(types.default.SET_CONTACT_UI_FLAG, { isFetching: false });
}
},
show: async ({ commit }, { id }) => {
commit(types.default.SET_CONTACT_UI_FLAG, { isFetchingItem: true });
try {
const response = await ContactAPI.show(id);
commit(types.default.SET_CONTACT_ITEM, response.data.payload);
commit(types.default.SET_CONTACT_UI_FLAG, { isFetchingItem: false });
} catch (error) {
commit(types.default.SET_CONTACT_UI_FLAG, { isFetchingItem: false });
}
},
update: async ({ commit }, { id, ...updateObj }) => {
commit(types.default.SET_CONTACT_UI_FLAG, { isUpdating: true });
try {
const response = await ContactAPI.update(id, updateObj);
commit(types.default.EDIT_CONTACT, response.data.payload);
commit(types.default.SET_CONTACT_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.default.SET_CONTACT_UI_FLAG, { isUpdating: false });
throw new Error(error);
}
},
};
export const mutations = {
[types.default.SET_CONTACT_UI_FLAG]($state, data) {
$state.uiFlags = {
...$state.uiFlags,
...data,
};
},
[types.default.SET_CONTACTS]: MutationHelpers.set,
[types.default.SET_CONTACT_ITEM]: MutationHelpers.setSingleRecord,
[types.default.EDIT_CONTACT]: MutationHelpers.update,
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View file

@ -0,0 +1,28 @@
import Vue from 'vue';
import * as types from '../mutation-types';
const state = {
records: {},
};
export const getters = {
getConversationMetadata: $state => id => {
return $state.records[Number(id)] || {};
},
};
export const actions = {};
export const mutations = {
[types.default.SET_CONVERSATION_METADATA]: ($state, { id, data }) => {
Vue.set($state.records, id, data);
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View file

@ -31,12 +31,21 @@ const actions = {
fetchPreviousMessages: async ({ commit }, data) => {
try {
const response = await MessageApi.getPreviousMessages(data);
const {
data: { meta, payload },
} = await MessageApi.getPreviousMessages(data);
commit(
`conversationMetadata/${types.default.SET_CONVERSATION_METADATA}`,
{
id: data.conversationId,
data: meta,
}
);
commit(types.default.SET_PREVIOUS_CONVERSATIONS, {
id: data.conversationId,
data: response.data.payload,
data: payload,
});
if (response.data.payload.length < 20) {
if (payload.length < 20) {
commit(types.default.SET_ALL_MESSAGES_LOADED);
}
} catch (error) {

View file

@ -60,7 +60,6 @@ const mutations = {
const [chat] = getSelectedChatConversation(_state);
Vue.set(chat, 'allMessagesLoaded', false);
},
[types.default.CLEAR_CURRENT_CHAT_WINDOW](_state) {
_state.selectedChat.id = null;
_state.selectedChat.agentTyping = 'off';

View file

@ -36,7 +36,7 @@ describe('#mutations', () => {
});
describe('#EDIT_AGENT', () => {
it('sets allMessagesLoaded flag if payload is empty', () => {
it('update agent record', () => {
const state = {
records: [{ id: 1, name: 'Agent1', email: 'agent1@chatwoot.com' }],
};
@ -52,7 +52,7 @@ describe('#mutations', () => {
});
describe('#DELETE_AGENT', () => {
it('sets allMessagesLoaded flag if payload is empty', () => {
it('delete agent record', () => {
const state = {
records: [{ id: 1, name: 'Agent1', email: 'agent1@chatwoot.com' }],
};

View file

@ -0,0 +1,72 @@
import axios from 'axios';
import { actions } from '../../contacts';
import * as types from '../../../mutation-types';
import contactList from './fixtures';
const commit = jest.fn();
global.axios = axios;
jest.mock('axios');
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({ data: { payload: contactList } });
await actions.get({ commit });
expect(commit.mock.calls).toEqual([
[types.default.SET_CONTACT_UI_FLAG, { isFetching: true }],
[types.default.SET_CONTACTS, contactList],
[types.default.SET_CONTACT_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.get({ commit });
expect(commit.mock.calls).toEqual([
[types.default.SET_CONTACT_UI_FLAG, { isFetching: true }],
[types.default.SET_CONTACT_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#show', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({ data: { payload: contactList[0] } });
await actions.show({ commit }, { id: contactList[0].id });
expect(commit.mock.calls).toEqual([
[types.default.SET_CONTACT_UI_FLAG, { isFetchingItem: true }],
[types.default.SET_CONTACT_ITEM, contactList[0]],
[types.default.SET_CONTACT_UI_FLAG, { isFetchingItem: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.show({ commit }, { id: contactList[0].id });
expect(commit.mock.calls).toEqual([
[types.default.SET_CONTACT_UI_FLAG, { isFetchingItem: true }],
[types.default.SET_CONTACT_UI_FLAG, { isFetchingItem: false }],
]);
});
});
describe('#update', () => {
it('sends correct actions if API is success', async () => {
axios.patch.mockResolvedValue({ data: { payload: contactList[0] } });
await actions.update({ commit }, contactList[0]);
expect(commit.mock.calls).toEqual([
[types.default.SET_CONTACT_UI_FLAG, { isUpdating: true }],
[types.default.EDIT_CONTACT, contactList[0]],
[types.default.SET_CONTACT_UI_FLAG, { isUpdating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.update({ commit }, contactList[0])).rejects.toThrow(
Error
);
expect(commit.mock.calls).toEqual([
[types.default.SET_CONTACT_UI_FLAG, { isUpdating: true }],
[types.default.SET_CONTACT_UI_FLAG, { isUpdating: false }],
]);
});
});
});

View file

@ -0,0 +1,30 @@
export default [
{
id: 1,
name: 'Contact 1',
email: 'contact1@chatwoot.com',
phone_number: '9000000001',
thumbnail: 'contact1.png',
},
{
id: 2,
name: 'Contact 2',
email: 'contact2@chatwoot.com',
phone_number: '9000000002',
thumbnail: 'contact2.png',
},
{
id: 3,
name: 'Contact 3',
email: 'contact3@chatwoot.com',
phone_number: '9000000003',
thumbnail: 'contact3.png',
},
{
id: 4,
name: 'Contact 4',
email: 'contact4@chatwoot.com',
phone_number: '9000000004',
thumbnail: 'contact4.png',
},
];

View file

@ -0,0 +1,33 @@
import { getters } from '../../contacts';
import contactList from './fixtures';
describe('#getters', () => {
it('getContacts', () => {
const state = {
records: contactList,
};
expect(getters.getContacts(state)).toEqual(contactList);
});
it('getContact', () => {
const state = {
records: contactList,
};
expect(getters.getContact(state)(2)).toEqual(contactList[1]);
});
it('getUIFlags', () => {
const state = {
uiFlags: {
isFetching: true,
isFetchingItem: true,
isUpdating: false,
},
};
expect(getters.getUIFlags(state)).toEqual({
isFetching: true,
isFetchingItem: true,
isUpdating: false,
});
});
});

View file

@ -0,0 +1,53 @@
import * as types from '../../../mutation-types';
import { mutations } from '../../contacts';
describe('#mutations', () => {
describe('#SET_CONTACTS', () => {
it('set contact records', () => {
const state = { records: [] };
mutations[types.default.SET_CONTACTS](state, [
{ id: 1, name: 'contact1', email: 'contact1@chatwoot.com' },
]);
expect(state.records).toEqual([
{
id: 1,
name: 'contact1',
email: 'contact1@chatwoot.com',
},
]);
});
});
describe('#SET_CONTACT_ITEM', () => {
it('push contact data to the store', () => {
const state = {
records: [{ id: 1, name: 'contact1', email: 'contact1@chatwoot.com' }],
};
mutations[types.default.SET_CONTACT_ITEM](state, {
id: 2,
name: 'contact2',
email: 'contact2@chatwoot.com',
});
expect(state.records).toEqual([
{ id: 1, name: 'contact1', email: 'contact1@chatwoot.com' },
{ id: 2, name: 'contact2', email: 'contact2@chatwoot.com' },
]);
});
});
describe('#EDIT_CONTACT', () => {
it('update contact', () => {
const state = {
records: [{ id: 1, name: 'contact1', email: 'contact1@chatwoot.com' }],
};
mutations[types.default.EDIT_CONTACT](state, {
id: 1,
name: 'contact2',
email: 'contact2@chatwoot.com',
});
expect(state.records).toEqual([
{ id: 1, name: 'contact2', email: 'contact2@chatwoot.com' },
]);
});
});
});

View file

@ -0,0 +1,16 @@
import { getters } from '../../conversationMetadata';
describe('#getters', () => {
it('getConversationMetadata', () => {
const state = {
records: {
1: {
browser: { name: 'Chrome' },
},
},
};
expect(getters.getConversationMetadata(state)(1)).toEqual({
browser: { name: 'Chrome' },
});
});
});

View file

@ -0,0 +1,17 @@
import * as types from '../../../mutation-types';
import { mutations } from '../../conversationMetadata';
describe('#mutations', () => {
describe('#SET_INBOXES', () => {
it('set inbox records', () => {
const state = { records: {} };
mutations[types.default.SET_CONVERSATION_METADATA](state, {
id: 1,
data: { browser: { name: 'Chrome' } },
});
expect(state.records).toEqual({
1: { browser: { name: 'Chrome' } },
});
});
});
});

View file

@ -56,6 +56,12 @@ export default {
EDIT_CANNED: 'EDIT_CANNED',
DELETE_CANNED: 'DELETE_CANNED',
// Contacts
SET_CONTACT_UI_FLAG: 'SET_CONTACT_UI_FLAG',
SET_CONTACT_ITEM: 'SET_CONTACT_ITEM',
SET_CONTACTS: 'SET_CONTACTS',
EDIT_CONTACT: 'EDIT_CONTACT',
// Reports
SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS',
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
@ -64,4 +70,7 @@ export default {
// Billings
SET_SUBSCRIPTION: 'SET_SUBSCRIPTION',
TOGGLE_SUBSCRIPTION_LOADING: 'TOGGLE_SUBSCRIPTION_LOADING',
// Conversation Metadata
SET_CONVERSATION_METADATA: 'SET_CONVERSATION_METADATA',
};

View file

@ -126,14 +126,15 @@ const IFrameHelper = {
}
iframe.src = widgetUrl;
iframe.id = 'chatwoot_web_widget';
iframe.id = 'chatwoot_live_chat_widget';
iframe.style.visibility = 'hidden';
holder.className = 'woot-widget-holder woot--hide';
holder.appendChild(iframe);
body.appendChild(holder);
IFrameHelper.initPostMessageCommunication();
IFrameHelper.initLocationListener();
},
getAppFrame: () => document.getElementById('chatwoot_web_widget'),
getAppFrame: () => document.getElementById('chatwoot_live_chat_widget'),
sendMessage: (key, value) => {
const element = IFrameHelper.getAppFrame();
element.contentWindow.postMessage(
@ -154,9 +155,15 @@ const IFrameHelper = {
Cookies.set('cw_conversation', message.config.authToken);
IFrameHelper.sendMessage('config-set', {});
IFrameHelper.onLoad(message.config.channelConfig);
IFrameHelper.setCurrentUrl();
}
};
},
initLocationListener: () => {
window.onhashchange = () => {
IFrameHelper.setCurrentUrl();
};
},
onLoad: ({ widget_color: widgetColor }) => {
const iframe = IFrameHelper.getAppFrame();
iframe.style.visibility = '';
@ -187,6 +194,12 @@ const IFrameHelper = {
bubbleHolder.appendChild(createNotificationBubble());
onClickChatBubble();
},
setCurrentUrl: () => {
console.log(IFrameHelper.getAppFrame(), document);
IFrameHelper.sendMessage('set-current-url', {
refererURL: window.location.href,
});
},
};
function loadIframe({ baseUrl, websiteToken }) {

View file

@ -20,16 +20,6 @@ export const IFrameHelper = {
export default {
name: 'App',
methods: {
...mapActions('appConfig', ['setWidgetColor']),
...mapActions('conversation', ['fetchOldConversations']),
scrollConversationToBottom() {
const container = this.$el.querySelector('.conversation-wrap');
container.scrollTop = container.scrollHeight;
},
},
mounted() {
if (IFrameHelper.isIFrame()) {
IFrameHelper.sendMessage({
@ -55,9 +45,19 @@ export default {
this.fetchOldConversations();
} else if (message.event === 'widget-visible') {
this.scrollConversationToBottom();
} else if (message.event === 'set-current-url') {
window.refererURL = message.refererURL;
}
});
},
methods: {
...mapActions('appConfig', ['setWidgetColor']),
...mapActions('conversation', ['fetchOldConversations']),
scrollConversationToBottom() {
const container = this.$el.querySelector('.conversation-wrap');
container.scrollTop = container.scrollHeight;
},
},
};
</script>

View file

@ -3,6 +3,8 @@ const sendMessage = content => ({
params: {
message: {
content,
timestamp: new Date().toString(),
referer_url: window.refererURL || '',
},
},
});

View file

@ -2,14 +2,20 @@ import endPoints from '../endPoints';
describe('#sendMessage', () => {
it('returns correct payload', () => {
const spy = jest.spyOn(global, 'Date').mockImplementation(() => ({
toString: () => 'mock date',
}));
expect(endPoints.sendMessage('hello')).toEqual({
url: `/api/v1/widget/messages`,
params: {
message: {
content: 'hello',
referer_url: '',
timestamp: 'mock date',
},
},
});
spy.mockRestore();
});
});

View file

@ -4,6 +4,6 @@ json.payload do
json.name contact.name
json.email contact.email
json.phone_number contact.phone_number
json.thumbnail contact.avatar.thumb.url
json.thumbnail contact.avatar.profile_thumb.url
end
end

View file

@ -1,5 +1,7 @@
json.meta do
json.labels @conversation.label_list
json.additional_attributes @conversation.additional_attributes
json.contact_id @conversation.contact_id
end
json.payload do