Merge branch 'release/1.15.0'

This commit is contained in:
Sojan 2021-04-16 23:02:28 +05:30
commit 8720f39ffb
120 changed files with 1831 additions and 318 deletions

View File

@ -105,6 +105,7 @@ Rails/UniqueValidationWithoutIndex:
Exclude:
- 'app/models/channel/twitter_profile.rb'
- 'app/models/webhook.rb'
- 'app/models/contact.rb'
Rails/RenderInline:
Exclude:
- 'app/controllers/swagger_controller.rb'

View File

@ -1,8 +1,7 @@
<p align="center">
<img src="https://s3.us-west-2.amazonaws.com/gh-assets.chatwoot.com/brand.svg" alt="Woot-logo" width="240">
<img src="https://s3.us-west-2.amazonaws.com/gh-assets.chatwoot.com/brand.svg" alt="Woot-logo" width="240" />
<div align="center">A simple and elegant live chat software</div>
<div align="center">An opensource alternative to Intercom, Zendesk, Drift, Crisp etc.</div>
<p align="center">Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.</p>
</p>
<p align="center">
@ -16,11 +15,13 @@ ___
<p align="center">
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/80f9e1a7c72d186289ad/maintainability" alt="Maintainability"></a>
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
<img src="https://img.shields.io/github/license/chatwoot/chatwoot" alt="License">
<img src="https://img.shields.io/github/commit-activity/m/chatwoot/chatwoot" alt="Commits-per-month">
<a title="Crowdin" target="_self" href="https://chatwoot.crowdin.com/chatwoot"><img src="https://badges.crowdin.net/e/37ced7eba411064bd792feb3b7a28b16/localized.svg"></a>
<a href="https://discord.gg/cJXdrwS"><img src="https://img.shields.io/discord/647412545203994635" alt="Discord"></a>
<a href="https://huntr.dev/bounties/disclose"><img src="https://cdn.huntr.dev/huntr_security_badge_mono.svg" alt="Huntr"></a>
</p>
<img src="https://s3.us-west-2.amazonaws.com/gh-assets.chatwoot.com/chatwoot-dashboard-assets.png" width="100%" alt="Chat dashboard"/>

View File

@ -55,7 +55,7 @@ $color-heading: #1f2d3d;
$color-extra-light-blue: #f5f7f9;
$primary-color: $color-woot;
$secondary-color: #35c5ff;
$secondary-color: #5d7592;
$success-color: #44ce4b;
$warning-color: #ffc532;
$alert-color: #ff382d;

View File

@ -0,0 +1,41 @@
class ContactInboxBuilder
pattr_initialize [:contact_id!, :inbox_id!, :source_id]
def perform
@contact = Contact.find(contact_id)
@inbox = @contact.account.inboxes.find(inbox_id)
return unless ['Channel::TwilioSms', 'Channel::Email', 'Channel::Api'].include? @inbox.channel_type
source_id = @source_id || generate_source_id
create_contact_inbox(source_id) if source_id.present?
end
private
def generate_source_id
return twilio_source_id if @inbox.channel_type == 'Channel::TwilioSms'
return @contact.email if @inbox.channel_type == 'Channel::Email'
return SecureRandom.uuid if @inbox.channel_type == 'Channel::Api'
nil
end
def twilio_source_id
return unless @contact.phone_number
case @inbox.channel.medium
when 'sms'
@contact.phone_number
when 'whatsapp'
"whatsapp:#{@contact.phone_number}"
end
end
def create_contact_inbox(source_id)
::ContactInbox.find_or_create_by!(
contact_id: @contact.id,
inbox_id: @inbox.id,
source_id: source_id
)
end
end

View File

@ -8,10 +8,10 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
private
def inbox_ids
if current_user.administrator?
if Current.user.administrator?
Current.account.inboxes.pluck(:id)
elsif current_user.agent?
current_user.assigned_inboxes.pluck(:id)
elsif Current.user.agent?
Current.user.assigned_inboxes.pluck(:id)
else
[]
end

View File

@ -4,7 +4,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search]
before_action :fetch_contact, only: [:show, :update]
before_action :fetch_contact, only: [:show, :update, :contactable_inboxes]
def index
@contacts_count = resolved_contacts.count
@ -40,6 +40,10 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def show; end
def contactable_inboxes
@contactable_inboxes = Contacts::ContactableInboxesService.new(contact: @contact).get
end
def create
ActiveRecord::Base.transaction do
@contact = Current.account.contacts.new(contact_params)

View File

@ -4,7 +4,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end
def create
user = current_user || @resource
user = Current.user || @resource
mb = Messages::MessageBuilder.new(user, @conversation, params)
@message = mb.perform
rescue StandardError => e

View File

@ -22,7 +22,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def create
@conversation = ::Conversation.create!(conversation_params)
ActiveRecord::Base.transaction do
@conversation = ::Conversation.create!(conversation_params)
Messages::MessageBuilder.new(Current.user, @conversation, params[:message]).perform if params[:message].present?
end
end
def show; end
@ -78,16 +81,29 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def contact_inbox
@contact_inbox = build_contact_inbox
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
end
def build_contact_inbox
return if params[:contact_id].blank? || params[:inbox_id].blank?
ContactInboxBuilder.new(
contact_id: params[:contact_id],
inbox_id: params[:inbox_id],
source_id: params[:source_id]
).perform
end
def conversation_params
additional_attributes = params[:additional_attributes]&.permit! || {}
{
account_id: Current.account.id,
inbox_id: @contact_inbox.inbox_id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: params[:additional_attributes]
additional_attributes: additional_attributes
}
end

View File

@ -14,6 +14,7 @@ module AccessTokenAuthHelper
render_unauthorized('Invalid Access Token') && return if @access_token.blank?
@resource = @access_token.owner
Current.user = @resource if current_user.is_a?(User)
end
def super_admin?
@ -21,7 +22,7 @@ module AccessTokenAuthHelper
end
def validate_bot_access_token!
return if current_user.is_a?(User)
return if Current.user.is_a?(User)
return if super_admin?
return if agent_bot_accessible?

View File

@ -61,7 +61,9 @@ export default {
},
isLoggedIn() {
return !!Cookies.getJSON('auth_data');
const hasAuthCookie = !!Cookies.getJSON('auth_data');
const hasUserCookie = !!Cookies.getJSON('user');
return hasAuthCookie && hasUserCookie;
},
isAdmin() {
@ -79,7 +81,9 @@ export default {
},
getPubSubToken() {
if (this.isLoggedIn()) {
return Cookies.getJSON('user').pubsub_token;
const user = Cookies.getJSON('user') || {};
const { pubsub_token: pubsubToken } = user;
return pubsubToken;
}
return null;
},

View File

@ -14,6 +14,10 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/conversations`);
}
getContactableInboxes(contactId) {
return axios.get(`${this.url}/${contactId}/contactable_inboxes`);
}
search(search = '', page = 1) {
return axios.get(`${this.url}/search?q=${search}&page=${page}`);
}

View File

@ -28,8 +28,10 @@ class ConversationApi extends ApiClient {
});
}
toggleStatus(conversationId) {
return axios.post(`${this.url}/${conversationId}/toggle_status`, {});
toggleStatus({ conversationId, status }) {
return axios.post(`${this.url}/${conversationId}/toggle_status`, {
status,
});
}
assignAgent({ conversationId, agentId }) {

View File

@ -49,7 +49,7 @@ $global-font-size: 10px;
$global-width: 100%;
$global-lineheight: 1.5;
$foundation-palette: (primary: $color-woot,
secondary: #35c5ff,
secondary: #5d7592,
success: #44ce4b,
warning: #ffc532,
alert: #ff382d);
@ -260,11 +260,11 @@ color 0.25s ease-out;
// 12. Button Group
// ----------------
$buttongroup-margin: 1rem;
$buttongroup-spacing: 1px;
$buttongroup-margin: 0;
$buttongroup-spacing: 0;
$buttongroup-child-selector: '.button';
$buttongroup-expand-max: 6;
$buttongroup-radius-on-each: true;
$buttongroup-radius-on-each: false;
// 13. Callout
// -----------

View File

@ -55,7 +55,7 @@ $color-heading: #1f2d3d;
$color-extra-light-blue: #f5f7f9;
$primary-color: $color-woot;
$secondary-color: #35c5ff;
$secondary-color: #5d7592;
$success-color: #44ce4b;
$warning-color: #ffc532;
$alert-color: #ff382d;

View File

@ -1,6 +1,8 @@
.dropdown-pane {
@include elegant-card;
@include border-light;
box-sizing: content-box;
width: fit-content;
z-index: 999;
&.dropdown-pane--open {

View File

@ -118,6 +118,8 @@
}
.multiselect__single {
@include text-ellipsis;
display: inline-block;
margin-bottom: 0;
padding: var(--space-slab) var(--space-one);
}

View File

@ -8,20 +8,15 @@ $resolve-button-width: 13.2rem;
@include flex-align($x: justify, $y: middle);
@include border-normal-bottom;
// Resolve Button
.button {
@include margin(0);
@include flex;
}
.multiselect-box {
@include flex;
@include flex-align($x: justify, $y: middle);
@include border-light;
border-radius: $space-smaller;
border: 1px solid var(--color-border);
border-radius: var(--space-smaller);
margin-right: var(--space-small);
width: 20.2rem;
&::before {
.icon {
color: $medium-gray;
font-size: $font-size-default;
line-height: 3.8rem;
@ -31,6 +26,7 @@ $resolve-button-width: 13.2rem;
.multiselect {
margin: 0;
min-width: 0;
.multiselect__tags {
border: 0;
@ -66,26 +62,6 @@ $resolve-button-width: 13.2rem;
}
}
.button.resolve--button {
@include flex-align($x: center, $y: middle);
font-size: var(--font-size-default);
width: $resolve-button-width;
>.icon {
font-size: $font-size-default;
padding-right: $space-small;
}
.spinner {
margin-right: $space-smaller;
padding: 0 $space-one;
&::before {
border-top-color: $color-white;
}
}
}
.header-actions-wrap {
display: flex;

View File

@ -151,6 +151,7 @@
border-top-left-radius: $space-smaller;
color: $color-body;
margin-right: auto;
word-break: break-word;
&.is-image {
border-radius: var(--border-radius-large);
@ -198,6 +199,7 @@
border-bottom-right-radius: $space-smaller;
border-top-right-radius: $space-smaller;
margin-left: auto;
word-break: break-word;
&.is-private {
background: lighten($warning-color, 32%);
@ -228,6 +230,10 @@
}
}
&.center {
justify-content: center;
}
.wrap {
@include margin($zero $space-normal);
max-width: 69%;
@ -247,17 +253,22 @@
}
.activity-wrap {
@include flex;
@include margin($space-small auto);
@include padding($space-small $space-normal);
@include flex-align($x: center, $y: null);
background: lighten($warning-color, 32%);
border: 1px solid lighten($warning-color, 22%);
border-radius: $space-smaller;
font-size: $font-size-small;
background: var(--s-50);
border: 1px solid var(--s-100);
border-radius: var(--border-radius-medium);
display: flex;
font-size: var(--font-size-small);
justify-content: center;
margin: var(--space-small) var(--space-normal);
padding: var(--space-small) var(--space-normal);
.message-text__wrap {
.is-text {
display: inline-block;
text-align: start;
@include breakpoint(xxxlarge up) {
display: flex;
}
}
}
}

View File

@ -67,12 +67,15 @@
flex-direction: column;
position: relative;
&:hover {
background: var(--color-background-light);
}
.dropdown-pane {
bottom: 6rem;
display: block;
left: 5rem;
visibility: visible;
width: 80%;
width: fit-content;
}
.active {

View File

@ -3,7 +3,6 @@
<slot></slot>
<div class="chat-list__top">
<h1 class="page-title text-truncate" :title="pageTitle">
<woot-sidemenu-icon />
{{ pageTitle }}
</h1>
<chat-filter @statusFilterChange="updateStatusType" />

View File

@ -3,7 +3,6 @@
</template>
<script>
/* global bus */
export default {
methods: {
onMenuItemClick() {
@ -12,3 +11,8 @@ export default {
},
};
</script>
<style lang="scss" scoped>
.ion-android-menu {
font-size: var(--font-size-big);
}
</style>

View File

@ -1,29 +1,86 @@
<template>
<button
type="button"
class="button resolve--button"
:class="buttonClass"
@click="toggleStatus"
>
<i v-if="!isLoading" class="icon" :class="buttonIconClass" />
<spinner v-if="isLoading" />
{{ currentStatus }}
</button>
<div class="resolve-actions">
<div class="button-group">
<woot-button
v-if="isOpen"
class-names="resolve"
color-scheme="success"
icon="ion-checkmark"
:is-loading="isLoading"
@click="() => toggleStatus(STATUS_TYPE.RESOLVED)"
>
{{ this.$t('CONVERSATION.HEADER.RESOLVE_ACTION') }}
</woot-button>
<woot-button
v-else-if="isResolved"
class-names="resolve"
color-scheme="warning"
icon="ion-refresh"
:is-loading="isLoading"
@click="() => toggleStatus(STATUS_TYPE.OPEN)"
>
{{ this.$t('CONVERSATION.HEADER.REOPEN_ACTION') }}
</woot-button>
<woot-button
v-else-if="isBot"
class-names="resolve"
color-scheme="primary"
icon="ion-person"
:is-loading="isLoading"
@click="() => toggleStatus(STATUS_TYPE.OPEN)"
>
{{ this.$t('CONVERSATION.HEADER.OPEN_ACTION') }}
</woot-button>
<woot-button
v-if="showDropDown"
class="icon--small"
:color-scheme="buttonClass"
:disabled="isLoading"
icon="ion-arrow-down-b"
@click="openDropdown"
>
</woot-button>
</div>
<div
v-if="showDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open"
>
<woot-dropdown-menu>
<woot-dropdown-item v-if="!isBot">
<woot-button
variant="clear"
@click="() => toggleStatus(STATUS_TYPE.BOT)"
>
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.OPEN_BOT') }}
</woot-button>
</woot-dropdown-item>
</woot-dropdown-menu>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import Spinner from 'shared/components/Spinner';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import wootConstants from '../../constants';
export default {
components: {
Spinner,
WootDropdownItem,
WootDropdownMenu,
},
mixins: [clickaway, alertMixin],
props: { conversationId: { type: [String, Number], required: true } },
data() {
return {
isLoading: false,
showDropdown: false,
STATUS_TYPE: wootConstants.STATUS_TYPE,
};
},
computed: {
@ -33,26 +90,60 @@ export default {
isOpen() {
return this.currentChat.status === wootConstants.STATUS_TYPE.OPEN;
},
currentStatus() {
return this.isOpen
? this.$t('CONVERSATION.HEADER.RESOLVE_ACTION')
: this.$t('CONVERSATION.HEADER.REOPEN_ACTION');
isBot() {
return this.currentChat.status === wootConstants.STATUS_TYPE.BOT;
},
isResolved() {
return this.currentChat.status === wootConstants.STATUS_TYPE.RESOLVED;
},
buttonClass() {
return this.isOpen ? 'success' : 'warning';
if (this.isBot) return 'primary';
if (this.isOpen) return 'success';
if (this.isResolved) return 'warning';
return '';
},
buttonIconClass() {
return this.isOpen ? 'ion-checkmark' : 'ion-refresh';
showDropDown() {
return !this.isBot;
},
},
methods: {
toggleStatus() {
closeDropdown() {
this.showDropdown = false;
},
openDropdown() {
this.showDropdown = true;
},
toggleStatus(status) {
this.closeDropdown();
this.isLoading = true;
this.$store.dispatch('toggleStatus', this.currentChat.id).then(() => {
bus.$emit('newToastMessage', this.$t('CONVERSATION.CHANGE_STATUS'));
this.isLoading = false;
});
this.$store
.dispatch('toggleStatus', {
conversationId: this.currentChat.id,
status,
})
.then(() => {
this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
this.isLoading = false;
});
},
},
};
</script>
<style lang="scss" scoped>
.resolve-actions {
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
}
.dropdown-pane {
left: unset;
top: 4.2rem;
margin-top: var(--space-micro);
right: 0;
max-width: 20rem;
}
.icon--small::v-deep .icon {
font-size: var(--font-size-small);
}
</style>

View File

@ -2,7 +2,7 @@
/* eslint-env browser */
import AvatarUploader from './widgets/forms/AvatarUploader.vue';
import Bar from './widgets/chart/BarChart';
import Button from './widgets/Button';
import Button from './ui/WootButton';
import Code from './Code';
import ColorPicker from './widgets/ColorPicker';
import DeleteModal from './widgets/modal/DeleteModal.vue';

View File

@ -170,6 +170,7 @@ export default {
.status-change {
.dropdown-pane {
top: -132px;
right: var(--space-normal);
}
.status-items {

View File

@ -19,12 +19,12 @@
:menu-item="teamSection"
/>
<sidebar-item
v-if="shouldShowInboxes"
v-if="shouldShowSidebarItem"
:key="inboxSection.toState"
:menu-item="inboxSection"
/>
<sidebar-item
v-if="shouldShowInboxes"
v-if="shouldShowSidebarItem"
:key="labelSection.toState"
:menu-item="labelSection"
/>
@ -128,11 +128,11 @@ export default {
currentRoute() {
return this.$store.state.route.name;
},
shouldShowInboxes() {
shouldShowSidebarItem() {
return this.sidemenuItems.common.routes.includes(this.currentRoute);
},
shouldShowTeams() {
return this.shouldShowInboxes && this.teams.length;
return this.shouldShowSidebarItem && this.teams.length;
},
inboxSection() {
return {

View File

@ -80,3 +80,8 @@ export default {
},
};
</script>
<style lang="scss" scoped>
.dropdown-pane {
right: 0;
}
</style>

View File

@ -12,8 +12,8 @@
@click="handleClick"
>
<spinner v-if="isLoading" size="small" />
<i v-if="icon" :class="icon"></i>
<span v-if="$slots.default"><slot></slot></span>
<i v-else-if="icon" class="icon" :class="icon"></i>
<span v-if="$slots.default" class="button__content"><slot></slot></span>
</button>
</template>
<script>
@ -59,3 +59,20 @@ export default {
},
};
</script>
<style lang="scss" scoped>
.button {
display: flex;
align-items: center;
&.link {
padding: 0;
margin: 0;
}
}
.spinner {
padding: 0 var(--space-small);
}
.icon + .button__content {
padding-left: var(--space-small);
}
</style>

View File

@ -11,9 +11,7 @@
class="image-thumb"
:src="attachment.thumb"
/>
<span v-else class="attachment-thumb">
📄
</span>
<span v-else class="attachment-thumb"> 📄 </span>
</div>
<div class="file-name-wrap">
<span class="item">
@ -37,8 +35,7 @@
</div>
</template>
<script>
import { formatBytes } from 'dashboard/helper/files';
import { formatBytes } from 'shared/helpers/FileHelper';
export default {
props: {
attachments: {

View File

@ -188,6 +188,7 @@ export default {
> .ProseMirror {
padding: 0;
word-break: break-word;
}
}

View File

@ -19,6 +19,7 @@
<contact-panel
v-if="showContactPanel"
:conversation-id="currentChat.id"
:inbox-id="currentChat.inbox_id"
:on-toggle="onToggleContactPanel"
/>
</div>
@ -59,6 +60,13 @@ export default {
return this.isContactPanelOpen && this.currentChat.id;
},
},
watch: {
'currentChat.inbox_id'(inboxId) {
if (inboxId) {
this.$store.dispatch('inboxMembers/fetch', { inboxId });
}
},
},
methods: {
onToggleContactPanel() {
this.$emit('contact-panel-toggle');

View File

@ -18,7 +18,7 @@
size="40px"
/>
<div class="conversation--details columns">
<span v-if="showInboxName" v-tooltip.bottom="inboxName" class="label">
<span v-if="showInboxName" class="label">
<i :class="computedInboxClass" />
{{ inboxName }}
</span>
@ -161,7 +161,11 @@ export default {
},
showInboxName() {
return !this.hideInboxName && this.isInboxNameVisible;
return (
!this.hideInboxName &&
this.isInboxNameVisible &&
this.inboxesList.length > 1
);
},
inboxName() {
const stateInbox = this.chatInbox;
@ -187,6 +191,10 @@ export default {
<style lang="scss" scoped>
.conversation {
align-items: center;
&:hover {
background: var(--color-background-light);
}
}
.has-inbox-name {

View File

@ -30,9 +30,11 @@
class="header-actions-wrap"
:class="{ 'has-open-sidebar': isContactPanelOpen }"
>
<div class="multiselect-box ion-headphone">
<div class="multiselect-box multiselect-wrap--small">
<i class="icon ion-headphone" />
<multiselect
v-model="currentChat.meta.assignee"
:loading="uiFlags.isFetching"
:allow-empty="true"
:deselect-label="$t('CONVERSATION.ASSIGNMENT.REMOVE')"
:options="agentList"
@ -81,7 +83,8 @@ export default {
computed: {
...mapGetters({
agents: 'agents/getVerifiedAgents',
getAgents: 'inboxMembers/getMembersByInbox',
uiFlags: 'inboxMembers/getUIFlags',
currentChat: 'getSelectedChat',
}),
@ -96,6 +99,8 @@ export default {
},
agentList() {
const { inbox_id: inboxId } = this.chat;
const agents = this.getAgents(inboxId) || [];
return [
{
confirmed: true,
@ -105,7 +110,7 @@ export default {
account_id: 0,
email: 'None',
},
...this.agents,
...agents,
];
},
},

View File

@ -143,7 +143,11 @@ export default {
return `https://twitter.com/${screenName}`;
},
alignBubble() {
return !this.data.message_type ? 'left' : 'right';
const { message_type: messageType } = this.data;
if (messageType === MESSAGE_TYPE.ACTIVITY) {
return 'center';
}
return !messageType ? 'left' : 'right';
},
readableTime() {
return this.messageStamp(this.data.created_at, 'LLL d, h:mm a');

View File

@ -1,10 +1,13 @@
<template>
<div class="view-box fill-height">
<div v-if="!currentChat.can_reply" class="banner messenger-policy--banner">
<div
v-if="!currentChat.can_reply && !isATwilioWhatsappChannel"
class="banner messenger-policy--banner"
>
<span>
{{ $t('CONVERSATION.CANNOT_REPLY') }}
<a
href="https://developers.facebook.com/docs/messenger-platform/policy/policy-overview/"
:href="facebookReplyPolicy"
rel="noopener noreferrer nofollow"
target="_blank"
>
@ -12,6 +15,21 @@
</a>
</span>
</div>
<div
v-if="!currentChat.can_reply && isATwilioWhatsappChannel"
class="banner messenger-policy--banner"
>
<span>
{{ $t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY') }}
<a
:href="twilioWhatsAppReplyPolicy"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW') }}
</a>
</span>
</div>
<div v-if="isATweet" class="banner">
<span v-if="!selectedTweetId">
@ -86,20 +104,16 @@ import Message from './Message';
import conversationMixin from '../../../mixins/conversations';
import { getTypingUsersText } from '../../../helper/commons';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
import inboxMixin from 'shared/mixins/inboxMixin';
export default {
components: {
Message,
ReplyBox,
},
mixins: [conversationMixin],
mixins: [conversationMixin, inboxMixin],
props: {
inboxId: {
type: [Number, String],
required: true,
},
isContactPanelOpen: {
type: Boolean,
default: false,
@ -124,6 +138,12 @@ export default {
getUnreadCount: 'getUnreadCount',
loadingChatList: 'getChatListLoadingStatus',
}),
inboxId() {
return this.currentChat.inbox_id;
},
inbox() {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
typingUsersList() {
const userList = this.$store.getters[
@ -191,6 +211,12 @@ export default {
}
return '';
},
facebookReplyPolicy() {
return REPLY_POLICY.FACEBOOK;
},
twilioWhatsAppReplyPolicy() {
return REPLY_POLICY.TWILIO_WHATSAPP;
},
},
watch: {

View File

@ -5,7 +5,10 @@
:status="currentChat.status"
/>
<woot-button
class="clear more--button"
class="more--button"
variant="clear"
size="large"
color-scheme="secondary"
icon="ion-android-more-vertical"
@click="toggleConversationActions"
/>
@ -100,15 +103,6 @@ export default {
align-items: center;
display: flex;
margin-left: var(--space-small);
padding: var(--space-small);
&.clear.more--button {
color: var(--color-body);
}
&:hover {
color: var(--w-800);
}
}
.actions--container {
@ -116,9 +110,8 @@ export default {
}
.dropdown-pane {
right: -12px;
right: var(--space-minus-small);
top: 48px;
width: auto;
}
.icon {

View File

@ -72,6 +72,7 @@
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import EmojiInput from 'shared/components/emoji/EmojiInput';
import CannedResponse from './CannedResponse';
@ -81,6 +82,9 @@ import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages';
import {
isEscape,
isEnter,
@ -100,7 +104,7 @@ export default {
ReplyBottomPanel,
WootMessageEditor,
},
mixins: [clickaway, inboxMixin, uiSettingsMixin],
mixins: [clickaway, inboxMixin, uiSettingsMixin, alertMixin],
props: {
inReplyTo: {
type: [String, Number],
@ -123,17 +127,25 @@ export default {
},
computed: {
showRichContentEditor() {
const {
display_rich_content_editor: displayRichContentEditor,
} = this.uiSettings;
return this.isOnPrivateNote || displayRichContentEditor;
if (this.isOnPrivateNote) {
return true;
}
if (this.isRichEditorEnabled) {
const {
display_rich_content_editor: displayRichContentEditor,
} = this.uiSettings;
return displayRichContentEditor;
}
return false;
},
...mapGetters({ currentChat: 'getSelectedChat' }),
enterToSendEnabled() {
return !!this.uiSettings.enter_to_send_enabled;
},
isPrivate() {
if (this.currentChat.can_reply) {
if (this.currentChat.can_reply || this.isATwilioWhatsappChannel) {
return this.isOnPrivateNote;
}
return true;
@ -215,9 +227,7 @@ export default {
return this.attachedFiles.length;
},
isRichEditorEnabled() {
return (
this.isAWebWidgetInbox || this.isAnEmailChannel || this.isOnPrivateNote
);
return this.isAWebWidgetInbox || this.isAnEmailChannel;
},
isOnPrivateNote() {
return this.replyType === REPLY_EDITOR_MODES.NOTE;
@ -230,7 +240,7 @@ export default {
return;
}
if (canReply) {
if (canReply || this.isATwilioWhatsappChannel) {
this.replyType = REPLY_EDITOR_MODES.REPLY;
} else {
this.replyType = REPLY_EDITOR_MODES.NOTE;
@ -305,7 +315,7 @@ export default {
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
const { can_reply: canReply } = this.currentChat;
if (canReply) this.replyType = mode;
if (canReply || this.isATwilioWhatsappChannel) this.replyType = mode;
if (this.showRichContentEditor) {
return;
}
@ -351,21 +361,28 @@ export default {
}
},
onFileUpload(file) {
this.attachedFiles = [];
if (!file) {
return;
}
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,
});
};
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
this.attachedFiles = [];
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,
});
};
} else {
this.showAlert(
this.$t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_FILE_UPLOAD_SIZE,
})
);
}
},
removeAttachment(itemIndex) {
this.attachedFiles = this.attachedFiles.filter(

View File

@ -95,6 +95,8 @@ export default {
</script>
<style lang="scss" scoped>
@import '~dashboard/assets/scss/app.scss';
.right {
.message-text--metadata {
.time {
@ -136,12 +138,16 @@ export default {
.activity-wrap {
.message-text--metadata {
display: inline-block;
.time {
color: var(--s-300);
display: flex;
text-align: center;
font-size: var(--font-size-micro);
margin-left: var(--space-small);
margin-left: 0;
@include breakpoint(xlarge up) {
margin-left: var(--space-small);
}
}
}
}

View File

@ -22,3 +22,15 @@ export default {
},
};
</script>
<style lang="scss" scoped>
.text-content {
overflow: auto;
&::v-deep {
ul,
ol {
margin-left: var(--space-normal);
}
}
}
</style>

View File

@ -1,11 +0,0 @@
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,18 +0,0 @@
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

@ -88,7 +88,7 @@
"SUBMIT_BUTTON": "Δημιουργία Κιβωτίου"
},
"TWILIO": {
"TITLE": "SMS κανάλι από το Twillio",
"TITLE": "SMS κανάλι από το Twilio",
"DESC": "Ενσωματώστε το Twilio και αρχίστε να υποστηρίζετε τους πελάτες σας μέσω SMS.",
"ACCOUNT_SID": {
"LABEL": "SID Λογαριασμού",
@ -116,7 +116,7 @@
},
"API_CALLBACK": {
"TITLE": "URL επανάκλησης",
"SUBTITLE": "Πρέπει να ρυθμίσετε το callback URL στο Twillio με το URL που αναφέρεται εδώ."
"SUBTITLE": "Πρέπει να ρυθμίσετε το callback URL στο Twilio με το URL που αναφέρεται εδώ."
},
"SUBMIT_BUTTON": "Δημιουργία Καναλιού Twillio",
"API": {

View File

@ -12,6 +12,7 @@
"INITIATED_FROM": "Initiated from",
"INITIATED_AT": "Initiated at",
"IP_ADDRESS": "IP Address",
"NEW_MESSAGE": "New message",
"CONVERSATIONS": {
"NO_RECORDS_FOUND": "There are no previous conversations associated to this contact.",
"TITLE": "Previous Conversations"
@ -71,7 +72,9 @@
},
"PHONE_NUMBER": {
"PLACEHOLDER": "Enter the phone number of the contact",
"LABEL": "Phone Number"
"LABEL": "Phone Number",
"HELP": "Phone number should be of E.164 format eg: +1415555555 [+][country code][area code][local phone number]",
"ERROR": "Phone number should be either empty or of E.164 format"
},
"LOCATION": {
"PLACEHOLDER": "Enter the location of the contact",
@ -104,6 +107,30 @@
"CONTACT_ALREADY_EXIST": "This email address is in use for another contact.",
"ERROR_MESSAGE": "There was an error, please try again"
},
"NEW_CONVERSATION": {
"BUTTON_LABEL": "Start conversation",
"TITLE": "New conversation",
"DESC": "Start a new conversation by sending a new message.",
"NO_INBOX": "Couldn't find an inbox to initiate a new conversation with this contact.",
"FORM": {
"TO": {
"LABEL": "To"
},
"INBOX": {
"LABEL": "Inbox",
"ERROR": "Select an inbox"
},
"MESSAGE": {
"LABEL": "Message",
"PLACEHOLDER": "Write your message here",
"ERROR": "Message can't be empty"
},
"SUBMIT": "Send message",
"CANCEL": "Cancel",
"SUCCESS_MESSAGE": "Message sent!",
"ERROR_MESSAGE": "Couldn't send! try again"
}
},
"CONTACTS_PAGE": {
"HEADER": "Contacts",
"SEARCH_BUTTON": "Search",

View File

@ -20,6 +20,8 @@
"LOADING_CONVERSATIONS": "Loading Conversations",
"CANNOT_REPLY": "You cannot reply due to",
"24_HOURS_WINDOW": "24 hour message window restriction",
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"LAST_INCOMING_TWEET": "You are replying to the last incoming tweet",
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
@ -29,10 +31,14 @@
"HEADER": {
"RESOLVE_ACTION": "Resolve",
"REOPEN_ACTION": "Reopen",
"OPEN_ACTION": "Open",
"OPEN": "More",
"CLOSE": "Close",
"DETAILS": "details"
},
"RESOLVE_DROPDOWN": {
"OPEN_BOT": "Open with bot"
},
"FOOTER": {
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents"
@ -52,6 +58,7 @@
"CHANGE_STATUS": "Conversation status changed",
"CHANGE_AGENT": "Conversation Assignee changed",
"CHANGE_TEAM": "Conversation team changed",
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
"SENT_BY": "Sent by:",
"ASSIGNMENT": {
"SELECT_AGENT": "Select Agent",
@ -108,5 +115,4 @@
"PLACEHOLDER": "None"
}
}
}

View File

@ -5,7 +5,8 @@
"LIST": {
"404": "There are no inboxes attached to this account."
},
"CREATE_FLOW": [{
"CREATE_FLOW": [
{
"title": "Choose Channel",
"route": "settings_inbox_new",
"body": "Choose the provider you want to integrate with Chatwoot."
@ -193,6 +194,7 @@
"TITLE": "Your Inbox is ready!",
"MESSAGE": "You can now engage with your customers through your new Channel. Happy supporting ",
"BUTTON_TEXT": "Take me there",
"MORE_SETTINGS": "More settings",
"WEBSITE_SUCCESS": "You have successfully finished creating a website channel. Copy the code shown below and paste it on your website. Next time a customer use the live chat, the conversation will automatically appear on your inbox."
},
"REAUTH": "Reauthorize",

View File

@ -3,6 +3,8 @@
"LINK": "Profile Settings",
"TITLE": "Profile Settings",
"BTN_TEXT": "Update Profile",
"UPDATE_SUCCESS": "Your profile has been updated successfully",
"PASSWORD_UPDATE_SUCCESS": "Your password has been changed successfully",
"AFTER_EMAIL_CHANGED": "Your profile has been updated successfully, please login again as your login credentials are changed",
"FORM": {
"AVATAR": "Profile Image",
@ -16,7 +18,8 @@
},
"PASSWORD_SECTION": {
"TITLE": "Password",
"NOTE": "Updating your password would reset your logins in multiple devices."
"NOTE": "Updating your password would reset your logins in multiple devices.",
"BTN_TEXT": "Change password"
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
@ -64,11 +67,7 @@
},
"AVAILABILITY": {
"LABEL": "Availability",
"STATUSES_LIST": [
"Online",
"Busy",
"Offline"
]
"STATUSES_LIST": ["Online", "Busy", "Offline"]
},
"EMAIL": {
"LABEL": "Your email address",
@ -147,6 +146,5 @@
},
"SUBMIT": "Submit"
}
}
}

View File

@ -118,7 +118,7 @@
"TITLE": "URL Callback",
"SUBTITLE": "Anda harus mengkonfigurasi pesan URL callback di Twilio dengan URL yang disebutkan di sini."
},
"SUBMIT_BUTTON": "Buat Channel Twillio",
"SUBMIT_BUTTON": "Buat Channel Twilio",
"API": {
"ERROR_MESSAGE": "Kami tidak dapat mengautentikasi kredensial Twilio, harap coba lagi"
}

View File

@ -3,7 +3,7 @@
<span class="close-button" @click="onClose">
<i class="ion-android-close close-icon" />
</span>
<contact-info :contact="contact" />
<contact-info show-new-message :contact="contact" />
<contact-custom-attributes
v-if="hasContactAttributes"
:custom-attributes="contact.custom_attributes"

View File

@ -127,6 +127,10 @@ export default {
type: [Number, String],
required: true,
},
inboxId: {
type: Number,
default: undefined,
},
onToggle: {
type: Function,
default: () => {},
@ -135,8 +139,9 @@ export default {
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
agents: 'agents/getVerifiedAgents',
teams: 'teams/getTeams',
getAgents: 'inboxMembers/getMembersByInbox',
uiFlags: 'inboxMembers/getUIFlags',
}),
currentConversationMetaData() {
return this.$store.getters[
@ -182,7 +187,7 @@ export default {
return '';
}
const countryFlag = countryCode ? flag(countryCode) : '🌎';
return `${countryFlag} ${cityAndCountry}`;
return `${cityAndCountry} ${countryFlag}`;
},
platformName() {
const {
@ -201,7 +206,7 @@ export default {
return this.$store.getters['contacts/getContact'](this.contactId);
},
agentsList() {
return [{ id: 0, name: 'None' }, ...this.agents];
return [{ id: 0, name: 'None' }, ...this.getAgents(this.inboxId)];
},
teamsList() {
return [{ id: 0, name: 'None' }, ...this.teams];

View File

@ -35,12 +35,26 @@
</label>
</div>
<div class="row">
<woot-input
v-model.trim="phoneNumber"
class="columns"
:label="$t('CONTACT_FORM.FORM.PHONE_NUMBER.LABEL')"
:placeholder="$t('CONTACT_FORM.FORM.PHONE_NUMBER.PLACEHOLDER')"
/>
<div class="medium-12 columns">
<label :class="{ error: $v.phoneNumber.$error }">
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.LABEL') }}
<input
v-model.trim="phoneNumber"
type="text"
:placeholder="$t('CONTACT_FORM.FORM.PHONE_NUMBER.PLACEHOLDER')"
@input="$v.phoneNumber.$touch"
/>
<span v-if="$v.phoneNumber.$error" class="message">
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.ERROR') }}
</span>
</label>
<div
v-if="$v.phoneNumber.$error || !phoneNumber"
class="callout small warning"
>
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.HELP') }}
</div>
</div>
</div>
<woot-input
v-model.trim="companyName"
@ -87,6 +101,8 @@ import {
} from 'shared/helpers/CustomErrors';
import { required } from 'vuelidate/lib/validators';
import { isPhoneE164OrEmpty } from 'shared/helpers/Validators';
export default {
mixins: [alertMixin],
props: {
@ -131,7 +147,9 @@ export default {
description: {},
email: {},
companyName: {},
phoneNumber: {},
phoneNumber: {
isPhoneE164OrEmpty,
},
bio: {},
},
@ -190,6 +208,7 @@ export default {
async handleSubmit() {
this.resetDuplicate();
this.$v.$touch();
if (this.$v.$invalid) {
return;
}

View File

@ -50,18 +50,41 @@
</div>
</div>
<woot-button
class="clear edit-contact"
variant="primary small"
v-if="!showNewMessage"
class="edit-contact"
variant="clear link"
size="small"
@click="toggleEditModal"
>
{{ $t('EDIT_CONTACT.BUTTON_LABEL') }}
</woot-button>
<div v-else class="contact-actions">
<woot-button
class="new-message"
size="small expanded"
@click="toggleConversationModal"
>
{{ $t('CONTACT_PANEL.NEW_MESSAGE') }}
</woot-button>
<woot-button
variant="hollow"
size="small expanded"
@click="toggleEditModal"
>
{{ $t('EDIT_CONTACT.BUTTON_LABEL') }}
</woot-button>
</div>
<edit-contact
v-if="showEditModal"
:show="showEditModal"
:contact="contact"
@cancel="toggleEditModal"
/>
<new-conversation
:show="showConversationModal"
:contact="contact"
@cancel="toggleConversationModal"
/>
</div>
</div>
</template>
@ -70,6 +93,7 @@ import ContactInfoRow from './ContactInfoRow';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import SocialIcons from './SocialIcons';
import EditContact from './EditContact';
import NewConversation from './NewConversation';
export default {
components: {
@ -77,6 +101,7 @@ export default {
EditContact,
Thumbnail,
SocialIcons,
NewConversation,
},
props: {
contact: {
@ -87,10 +112,15 @@ export default {
type: String,
default: '',
},
showNewMessage: {
type: Boolean,
default: false,
},
},
data() {
return {
showEditModal: false,
showConversationModal: false,
};
},
computed: {
@ -110,6 +140,9 @@ export default {
toggleEditModal() {
this.showEditModal = !this.showEditModal;
},
toggleConversationModal() {
this.showConversationModal = !this.showConversationModal;
},
},
};
</script>
@ -119,7 +152,7 @@ export default {
@import '~dashboard/assets/scss/mixins';
.contact--profile {
align-items: flex-start;
padding: var(--space-normal) var(--space-normal) var(--space-large);
padding: var(--space-normal) var(--space-normal);
.user-thumbnail-box {
margin-right: $space-normal;
@ -163,10 +196,21 @@ export default {
font-size: $font-weight-normal;
}
}
.contact-actions {
margin: var(--space-small) 0;
}
.button.edit-contact {
margin-left: var(--space-two);
padding-left: var(--space-micro);
}
.edit-contact {
padding: 0 var(--space-slab);
margin-left: var(--space-slab);
margin-top: var(--space-smaller);
.button.new-message {
margin-right: var(--space-small);
}
.contact-actions {
display: flex;
align-items: center;
width: 100%;
}
</style>

View File

@ -0,0 +1,210 @@
<template>
<form class="conversation--form" @submit.prevent="handleSubmit">
<div v-if="showNoInboxAlert" class="callout warning">
<p>
{{ $t('NEW_CONVERSATION.NO_INBOX') }}
</p>
</div>
<div v-else>
<div class="row">
<div class="columns">
<label :class="{ error: $v.targetInbox.$error }">
{{ $t('NEW_CONVERSATION.FORM.INBOX.LABEL') }}
<select v-model="targetInbox">
<option
v-for="contactableInbox in inboxes"
:key="contactableInbox.inbox.id"
:value="contactableInbox"
>
{{ contactableInbox.inbox.name }}
</option>
</select>
<span v-if="$v.targetInbox.$error" class="message">
{{ $t('NEW_CONVERSATION.FORM.INBOX.ERROR') }}
</span>
</label>
</div>
<div class="columns">
<label>
{{ $t('NEW_CONVERSATION.FORM.TO.LABEL') }}
<div class="contact-input">
<thumbnail
:src="contact.thumbnail"
size="24px"
:username="contact.name"
:status="contact.availability_status"
/>
<h4 class="text-block-title contact-name">
{{ contact.name }}
</h4>
</div>
</label>
</div>
</div>
<div class="row">
<div class="columns">
<label :class="{ error: $v.message.$error }">
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.LABEL') }}
<textarea
v-model="message"
class="message-input"
type="text"
:placeholder="$t('NEW_CONVERSATION.FORM.MESSAGE.PLACEHOLDER')"
@input="$v.message.$touch"
/>
<span v-if="$v.message.$error" class="message">
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.ERROR') }}
</span>
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button class="button clear" @click.prevent="onCancel">
{{ $t('NEW_CONVERSATION.FORM.CANCEL') }}
</button>
<woot-button type="submit" :is-loading="conversationsUiFlags.isCreating">
{{ $t('NEW_CONVERSATION.FORM.SUBMIT') }}
</woot-button>
</div>
</form>
</template>
<script>
import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
import alertMixin from 'shared/mixins/alertMixin';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { required } from 'vuelidate/lib/validators';
export default {
components: {
Thumbnail,
},
mixins: [alertMixin],
props: {
contact: {
type: Object,
default: () => ({}),
},
onSubmit: {
type: Function,
default: () => {},
},
},
data() {
return {
name: '',
message: '',
selectedInbox: '',
};
},
validations: {
message: {
required,
},
targetInbox: {
required,
},
},
computed: {
...mapGetters({
uiFlags: 'contacts/getUIFlags',
conversationsUiFlags: 'contactConversations/getUIFlags',
}),
getNewConversation() {
return {
inboxId: this.targetInbox.inbox.id,
sourceId: this.targetInbox.source_id,
contactId: this.contact.id,
message: { content: this.message },
};
},
targetInbox: {
get() {
return this.selectedInbox || '';
},
set(value) {
this.selectedInbox = value;
},
},
showNoInboxAlert() {
if (!this.contact.contactableInboxes) {
return false;
}
return this.inboxes.length === 0 && !this.uiFlags.isFetchingInboxes;
},
inboxes() {
return this.contact.contactableInboxes || [];
},
},
methods: {
onCancel() {
this.$emit('cancel');
},
onSuccess() {
this.$emit('success');
},
async handleSubmit() {
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
try {
const payload = this.getNewConversation;
await this.onSubmit(payload);
this.onSuccess();
this.showAlert(this.$t('NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'));
} catch (error) {
if (error instanceof ExceptionWithMessage) {
this.showAlert(error.data);
} else {
this.showAlert(this.$t('NEW_CONVERSATION.FORM.ERROR_MESSAGE'));
}
}
},
},
};
</script>
<style scoped lang="scss">
.conversation--form {
padding: var(--space-normal) var(--space-large) var(--space-large);
.columns {
padding: 0 var(--space-smaller);
}
}
.input-group-label {
font-size: var(--font-size-small);
}
.contact-input {
display: flex;
align-items: center;
height: 3.9rem;
background: var(--color-background-light);
border: 1px solid var(--color-border);
padding: var(--space-smaller) var(--space-small);
border-radius: var(--border-radius-small);
.contact-name {
margin: 0;
margin-left: var(--space-small);
}
}
.message-input {
min-height: 8rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
}
</style>

View File

@ -0,0 +1,51 @@
<template>
<woot-modal :show.sync="show" :on-close="onCancel">
<div class="column content-box">
<woot-modal-header
:header-title="$t('NEW_CONVERSATION.TITLE')"
:header-content="$t('NEW_CONVERSATION.DESC')"
/>
<conversation-form
:contact="contact"
:on-submit="onSubmit"
@success="onSuccess"
@cancel="onCancel"
/>
</div>
</woot-modal>
</template>
<script>
import ConversationForm from './ConversationForm';
export default {
components: {
ConversationForm,
},
props: {
show: {
type: Boolean,
default: false,
},
contact: {
type: Object,
default: () => ({}),
},
},
mounted() {
const { id } = this.contact;
this.$store.dispatch('contacts/fetchContactableInbox', id);
},
methods: {
onCancel() {
this.$emit('cancel');
},
onSuccess() {
this.$emit('cancel');
},
async onSubmit(contactItem) {
await this.$store.dispatch('contactConversations/create', contactItem);
},
},
};
</script>

View File

@ -1,6 +1,7 @@
<template>
<div v-on-clickaway="closeSearch" class="search-wrap">
<div class="search" :class="{ 'is-active': showSearchBox }">
<woot-sidemenu-icon />
<div class="icon">
<i class="ion-ios-search-strong search--icon" />
</div>

View File

@ -29,15 +29,26 @@
>
</woot-code>
</div>
<router-link
class="button success nice"
:to="{
name: 'inbox_dashboard',
params: { inboxId: this.$route.params.inbox_id },
}"
>
{{ $t('INBOX_MGMT.FINISH.BUTTON_TEXT') }}
</router-link>
<div class="footer">
<router-link
class="button hollow primary settings-button"
:to="{
name: 'settings_inbox_show',
params: { inboxId: this.$route.params.inbox_id },
}"
>
{{ $t('INBOX_MGMT.FINISH.MORE_SETTINGS') }}
</router-link>
<router-link
class="button success"
:to="{
name: 'inbox_dashboard',
params: { inboxId: this.$route.params.inbox_id },
}"
>
{{ $t('INBOX_MGMT.FINISH.BUTTON_TEXT') }}
</router-link>
</div>
</div>
</empty-state>
</div>
@ -90,4 +101,13 @@ export default {
margin: $space-normal auto;
max-width: 70%;
}
.footer {
display: flex;
justify-content: center;
}
.settings-button {
margin-right: var(--space-small);
}
</style>

View File

@ -1,8 +1,8 @@
<template>
<div class="columns profile--settings ">
<form @submit.prevent="updateUser">
<div class="columns profile--settings">
<form @submit.prevent="updateUser('profile')">
<div class="small-12 row profile--settings--row">
<div class="columns small-3 ">
<div class="columns small-3">
<h4 class="block-title">
{{ $t('PROFILE_SETTINGS.FORM.PROFILE_SECTION.TITLE') }}
</h4>
@ -49,10 +49,15 @@
{{ $t('PROFILE_SETTINGS.FORM.EMAIL.ERROR') }}
</span>
</label>
<woot-button type="submit" :is-loading="isProfileUpdating">
{{ $t('PROFILE_SETTINGS.BTN_TEXT') }}
</woot-button>
</div>
</div>
</form>
<form @submit.prevent="updateUser('password')">
<div class="profile--settings--row row">
<div class="columns small-3 ">
<div class="columns small-3">
<h4 class="block-title">
{{ $t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.TITLE') }}
</h4>
@ -85,27 +90,30 @@
{{ $t('PROFILE_SETTINGS.FORM.PASSWORD_CONFIRMATION.ERROR') }}
</span>
</label>
<woot-button
:is-loading="isPasswordChanging"
type="submit"
:disabled="
!passwordConfirmation || !$v.passwordConfirmation.isEqPassword
"
>
{{ $t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.BTN_TEXT') }}
</woot-button>
</div>
</div>
<notification-settings />
<div class="profile--settings--row row">
<div class="columns small-3 ">
<h4 class="block-title">
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE') }}
</h4>
<p>{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE') }}</p>
</div>
<div class="columns small-9 medium-5">
<woot-code :script="currentUser.access_token"></woot-code>
</div>
</div>
<woot-submit-button
class="button nice success button--fixed-right-top"
:button-text="$t('PROFILE_SETTINGS.BTN_TEXT')"
:loading="isUpdating"
>
</woot-submit-button>
</form>
<notification-settings />
<div class="profile--settings--row row">
<div class="columns small-3">
<h4 class="block-title">
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE') }}
</h4>
<p>{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE') }}</p>
</div>
<div class="columns small-9 medium-5">
<woot-code :script="currentUser.access_token"></woot-code>
</div>
</div>
</div>
</template>
@ -120,7 +128,7 @@ export default {
components: {
NotificationSettings,
},
mixin: [alertMixin],
mixins: [alertMixin],
data() {
return {
avatarFile: '',
@ -130,7 +138,8 @@ export default {
email: '',
password: '',
passwordConfirmation: '',
isUpdating: false,
isProfileUpdating: false,
isPasswordChanging: false,
};
},
validations: {
@ -181,13 +190,17 @@ export default {
this.avatarUrl = this.currentUser.avatar_url;
this.displayName = this.currentUser.display_name;
},
async updateUser() {
async updateUser(type) {
this.$v.$touch();
if (this.$v.$invalid) {
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.ERROR'));
return;
}
this.isUpdating = true;
if (type === 'profile') {
this.isProfileUpdating = true;
} else if (type === 'password') {
this.isPasswordChanging = true;
}
const hasEmailChanged = this.currentUser.email !== this.email;
try {
await this.$store.dispatch('updateProfile', {
@ -198,13 +211,20 @@ export default {
displayName: this.displayName,
password_confirmation: this.passwordConfirmation,
});
this.isUpdating = false;
this.isProfileUpdating = false;
this.isPasswordChanging = false;
if (hasEmailChanged) {
clearCookiesOnLogout();
this.showAlert(this.$t('PROFILE_SETTINGS.AFTER_EMAIL_CHANGED'));
}
if (type === 'profile') {
this.showAlert(this.$t('PROFILE_SETTINGS.UPDATE_SUCCESS'));
} else if (type === 'password') {
this.showAlert(this.$t('PROFILE_SETTINGS.PASSWORD_UPDATE_SUCCESS'));
}
} catch (error) {
this.isUpdating = false;
this.isProfileUpdating = false;
this.isPasswordChanging = false;
}
},
handleImageUpload({ file, url }) {

View File

@ -107,6 +107,7 @@ export default {
teamId,
},
});
this.$store.dispatch('teams/get');
} catch (error) {
this.showAlert(error.message);
}

View File

@ -129,6 +129,7 @@ export default {
teamId,
},
});
this.$store.dispatch('teams/get');
} catch (error) {
this.showAlert(error.message);
}

View File

@ -83,7 +83,7 @@ export const actions = {
setUser(response.data.payload.data, getHeaderExpiry(response));
context.commit(types.default.SET_CURRENT_USER);
} catch (error) {
if (error.response.status === 401) {
if (error?.response?.status === 401) {
clearCookiesOnLogout();
}
}

View File

@ -1,6 +1,7 @@
import Vue from 'vue';
import * as types from '../mutation-types';
import ContactAPI from '../../api/contacts';
import ConversationApi from '../../api/conversations';
const state = {
records: {},
@ -19,6 +20,30 @@ export const getters = {
};
export const actions = {
create: async ({ commit }, params) => {
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
isCreating: true,
});
const { inboxId, message, contactId, sourceId } = params;
try {
const { data } = await ConversationApi.create({
inbox_id: inboxId,
contact_id: contactId,
source_id: sourceId,
message,
});
commit(types.default.ADD_CONTACT_CONVERSATION, {
id: contactId,
data,
});
} catch (error) {
throw new Error(error);
} finally {
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
isCreating: false,
});
}
},
get: async ({ commit }, contactId) => {
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
isFetching: true,
@ -53,6 +78,10 @@ export const mutations = {
[types.default.SET_CONTACT_CONVERSATIONS]: ($state, { id, data }) => {
Vue.set($state.records, id, data);
},
[types.default.ADD_CONTACT_CONVERSATION]: ($state, { id, data }) => {
const conversations = $state.records[id] || [];
Vue.set($state.records, id, [...conversations, data]);
},
};
export default {

View File

@ -83,6 +83,26 @@ export const actions = {
}
},
fetchContactableInbox: async ({ commit }, id) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetchingInboxes: true });
try {
const response = await ContactAPI.getContactableInboxes(id);
const contact = {
id,
contactableInboxes: response.data.payload,
};
commit(types.SET_CONTACT_ITEM, contact);
} catch (error) {
if (error.response?.data?.message) {
throw new ExceptionWithMessage(error.response.data.message);
} else {
throw new Error(error);
}
} finally {
commit(types.SET_CONTACT_UI_FLAG, { isFetchingInboxes: false });
}
},
updatePresence: ({ commit }, data) => {
commit(types.UPDATE_CONTACTS_PRESENCE, data);
},

View File

@ -11,6 +11,7 @@ const state = {
uiFlags: {
isFetching: false,
isFetchingItem: false,
isFetchingInboxes: false,
isUpdating: false,
},
};

View File

@ -135,9 +135,12 @@ const actions = {
commit(types.default.ASSIGN_TEAM, team);
},
toggleStatus: async ({ commit }, data) => {
toggleStatus: async ({ commit }, { conversationId, status }) => {
try {
const response = await ConversationApi.toggleStatus(data);
const response = await ConversationApi.toggleStatus({
conversationId,
status,
});
commit(
types.default.RESOLVE_CONVERSATION,
response.data.payload.current_status

View File

@ -1,10 +1,44 @@
import Vue from 'vue';
import InboxMembersAPI from '../../api/inboxMembers';
const state = {};
const state = {
records: {},
uiFlags: {
isFetching: false,
},
};
const getters = {};
export const types = {
SET_INBOX_MEMBERS_UI_FLAG: 'SET_INBOX_MEMBERS_UI_FLAG',
SET_INBOX_MEMBERS: 'SET_INBOX_MEMBERS',
};
const actions = {
export const getters = {
getMembersByInbox: $state => inboxId => {
const allAgents = $state.records[inboxId] || [];
const verifiedAgents = allAgents.filter(record => record.confirmed);
return verifiedAgents;
},
getUIFlags($state) {
return $state.uiFlags;
},
};
export const actions = {
async fetch({ commit }, { inboxId }) {
commit(types.SET_INBOX_MEMBERS_UI_FLAG, { isFetching: true });
try {
const {
data: { payload },
} = await InboxMembersAPI.show(inboxId);
commit(types.SET_INBOX_MEMBERS, { inboxId, members: payload });
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_INBOX_MEMBERS_UI_FLAG, { isFetching: false });
}
},
get(_, { inboxId }) {
return InboxMembersAPI.show(inboxId);
},
@ -13,7 +47,17 @@ const actions = {
},
};
const mutations = {};
export const mutations = {
[types.SET_INBOX_MEMBERS_UI_FLAG]($state, data) {
$state.uiFlags = {
...$state.uiFlags,
...data,
};
},
[types.SET_INBOX_MEMBERS]: ($state, { inboxId, members }) => {
Vue.set($state.records, inboxId, members);
},
};
export default {
namespaced: true,

View File

@ -1,5 +1,6 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import * as types from '../mutation-types';
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
import InboxesAPI from '../../api/inboxes';
import WebChannel from '../../api/channel/webChannel';
import FBChannel from '../../api/channel/fbChannel';
@ -41,6 +42,20 @@ export const getters = {
getInboxes($state) {
return $state.records;
},
getNewConversationInboxes($state) {
return $state.records.filter(inbox => {
const {
channel_type: channelType,
phone_number: phoneNumber = '',
} = inbox;
const isEmailChannel = channelType === INBOX_TYPES.EMAIL;
const isSmsChannel =
channelType === INBOX_TYPES.TWILIO &&
phoneNumber.startsWith('whatsapp');
return isEmailChannel || isSmsChannel;
});
},
getInbox: $state => inboxId => {
const [inbox] = $state.records.filter(
record => record.id === Number(inboxId)

View File

@ -38,4 +38,43 @@ describe('#actions', () => {
]);
});
});
describe('#create', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: conversationList[0] });
await actions.create(
{ commit },
{ inboxId: 1, message: { content: 'hi' }, contactId: 4, sourceId: 5 }
);
expect(commit.mock.calls).toEqual([
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isCreating: true }],
[
types.default.ADD_CONTACT_CONVERSATION,
{ id: 4, data: conversationList[0] },
],
[
types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG,
{ isCreating: false },
],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.create(
{ commit },
{ inboxId: 1, message: { content: 'hi' }, contactId: 4, sourceId: 5 }
)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isCreating: true }],
[
types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG,
{ isCreating: false },
],
]);
});
});
});

View File

@ -26,4 +26,17 @@ describe('#mutations', () => {
});
});
});
describe('#ADD_CONTACT_CONVERSATION', () => {
it('Adds new contact conversation to records', () => {
const state = { records: {} };
mutations[types.default.ADD_CONTACT_CONVERSATION](state, {
id: 1,
data: { id: 1, contact_id: 1, message: 'hello' },
});
expect(state.records).toEqual({
1: [{ id: 1, contact_id: 1, message: 'hello' }],
});
});
});
});

View File

@ -0,0 +1,31 @@
import axios from 'axios';
import { actions, types } from '../../inboxMembers';
import inboxMembers from './fixtures';
const commit = jest.fn();
global.axios = axios;
jest.mock('axios');
describe('#actions', () => {
describe('#fetch', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({ data: { payload: inboxMembers } });
await actions.fetch({ commit }, { inboxId: 1 });
expect(commit.mock.calls).toEqual([
[types.SET_INBOX_MEMBERS_UI_FLAG, { isFetching: true }],
[types.SET_INBOX_MEMBERS, { inboxId: 1, members: inboxMembers }],
[types.SET_INBOX_MEMBERS_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.fetch({ commit }, { inboxId: 1 })).rejects.toThrow(
Error
);
expect(commit.mock.calls).toEqual([
[types.SET_INBOX_MEMBERS_UI_FLAG, { isFetching: true }],
[types.SET_INBOX_MEMBERS_UI_FLAG, { isFetching: false }],
]);
});
});
});

View File

@ -0,0 +1,28 @@
export default [
{
id: 1,
provider: 'email',
uid: 'agent1@chatwoot.com',
name: 'Agent1',
email: 'agent1@chatwoot.com',
account_id: 1,
created_at: '2019-11-18T02:21:06.225Z',
updated_at: '2019-12-20T07:43:35.794Z',
pubsub_token: 'random-1',
role: 'agent',
confirmed: true,
},
{
id: 2,
provider: 'email',
uid: 'agent2@chatwoot.com',
name: 'Agent2',
email: 'agent2@chatwoot.com',
account_id: 1,
created_at: '2019-11-18T02:21:06.225Z',
updated_at: '2019-12-20T07:43:35.794Z',
pubsub_token: 'random-2',
role: 'agent',
confirmed: true,
},
];

View File

@ -0,0 +1,24 @@
import { getters } from '../../teamMembers';
import teamMembers from './fixtures';
describe('#getters', () => {
it('getMembersByInbox', () => {
const state = {
records: {
1: [teamMembers[0]],
},
};
expect(getters.getTeamMembers(state)(1)).toEqual([teamMembers[0]]);
});
it('getUIFlags', () => {
const state = {
uiFlags: {
isFetching: false,
},
};
expect(getters.getUIFlags(state)).toEqual({
isFetching: false,
});
});
});

View File

@ -0,0 +1,16 @@
import { mutations, types } from '../../inboxMembers';
import inboxMembers from './fixtures';
describe('#mutations', () => {
describe('#SET_INBOX_MEMBERS', () => {
it('Adds inbox members to records', () => {
const state = { records: {} };
mutations[types.SET_INBOX_MEMBERS](state, {
members: [...inboxMembers],
inboxId: 1,
});
expect(state.records).toEqual({ 1: inboxMembers });
});
});
});

View File

@ -114,6 +114,7 @@ export default {
// Contact Conversation
SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG',
SET_CONTACT_CONVERSATIONS: 'SET_CONTACT_CONVERSATIONS',
ADD_CONTACT_CONVERSATION: 'ADD_CONTACT_CONVERSATION',
// Conversation Label
SET_CONVERSATION_LABELS_UI_FLAG: 'SET_CONVERSATION_LABELS_UI_FLAG',

View File

@ -12,7 +12,6 @@ import hljs from 'highlight.js';
import Multiselect from 'vue-multiselect';
import WootSwitch from 'components/ui/Switch';
import WootWizard from 'components/ui/Wizard';
import WootButton from 'components/ui/WootButton';
import { sync } from 'vuex-router-sync';
import Vuelidate from 'vuelidate';
import VTooltip from 'v-tooltip';
@ -22,6 +21,7 @@ import i18n from '../dashboard/i18n';
import createAxios from '../dashboard/helper/APIHelper';
import commonHelpers from '../dashboard/helper/commons';
import { getAlertAudio } from '../shared/helpers/AudioNotificationHelper';
import { initFaviconSwitcher } from '../shared/helpers/faviconHelper';
import router from '../dashboard/routes';
import store from '../dashboard/store';
import vueActionCable from '../dashboard/helper/actionCable';
@ -49,7 +49,6 @@ Vue.use(hljs.vuePlugin);
Vue.component('multiselect', Multiselect);
Vue.component('woot-switch', WootSwitch);
Vue.component('woot-wizard', WootWizard);
Vue.component('woot-button', WootButton);
const i18nConfig = new VueI18n({
locale: 'en',
@ -82,4 +81,5 @@ window.addEventListener('load', () => {
})
);
getAlertAudio();
initFaviconSwitcher();
});

View File

@ -23,6 +23,11 @@ export const IFrameHelper = {
return `${baseUrl}/widget?website_token=${websiteToken}`;
},
createFrame: ({ baseUrl, websiteToken }) => {
if (IFrameHelper.getAppFrame()) {
return;
}
loadCSS();
const iframe = document.createElement('iframe');
const cwCookie = Cookies.get('cw_conversation');
let widgetUrl = IFrameHelper.getUrl({ baseUrl, websiteToken });
@ -47,6 +52,7 @@ export const IFrameHelper = {
IFrameHelper.preventDefaultScroll();
},
getAppFrame: () => document.getElementById('chatwoot_live_chat_widget'),
getBubbleHolder: () => document.getElementsByClassName('woot--bubble-holder'),
sendMessage: (key, value) => {
const element = IFrameHelper.getAppFrame();
element.contentWindow.postMessage(
@ -166,7 +172,10 @@ export const IFrameHelper = {
iframe.style.visibility = '';
iframe.setAttribute('id', `chatwoot_live_chat_widget`);
loadCSS();
if (IFrameHelper.getBubbleHolder().length) {
return;
}
createBubbleHolder();
if (!window.$chatwoot.hideMessageBubble) {

View File

@ -1,4 +1,5 @@
export const BUS_EVENTS = {
SET_REFERRER_HOST: 'SET_REFERRER_HOST',
SET_TWEET_REPLY: 'SET_TWEET_REPLY',
ATTACHMENT_SIZE_CHECK_ERROR: 'ATTACHMENT_SIZE_CHECK_ERROR',
};

View File

@ -0,0 +1,6 @@
export const REPLY_POLICY = {
FACEBOOK:
'https://developers.facebook.com/docs/messenger-platform/policy/policy-overview/',
TWILIO_WHATSAPP:
'https://www.twilio.com/docs/whatsapp/tutorial/send-whatsapp-notification-messages-templates#sending-non-template-messages-within-a-24-hour-session',
};

View File

@ -10,3 +10,5 @@ export const MESSAGE_TYPE = {
ACTIVITY: 2,
TEMPLATE: 3,
};
// Size in mega bytes
export const MAXIMUM_FILE_UPLOAD_SIZE = 40;

View File

@ -1,6 +1,7 @@
import { MESSAGE_TYPE } from 'shared/constants/messages';
const notificationAudio = require('shared/assets/audio/ding.mp3');
import axios from 'axios';
import { showBadgeOnFavicon } from './faviconHelper';
export const playNotificationAudio = () => {
try {
@ -74,5 +75,6 @@ export const newMessageNotification = data => {
if (enableAudioAlerts && playAudio) {
window.playAudioAlert();
showBadgeOnFavicon();
}
};

View File

@ -0,0 +1,25 @@
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];
};
export const fileSizeInMegaBytes = bytes => {
if (bytes === 0) {
return 0;
}
const sizeInMB = (bytes / (1024 * 1024)).toFixed(2);
return sizeInMB;
};
export const checkFileSizeLimit = (file, maximumUploadLimit) => {
const fileSize = file?.file?.size;
const fileSizeInMB = fileSizeInMegaBytes(fileSize);
return fileSizeInMB <= maximumUploadLimit;
};

View File

@ -0,0 +1,2 @@
export const isPhoneE164 = value => !!value.match(/^\+[1-9]\d{1,14}$/);
export const isPhoneE164OrEmpty = value => isPhoneE164(value) || value === '';

View File

@ -0,0 +1,21 @@
export const showBadgeOnFavicon = () => {
const favicons = document.querySelectorAll('.favicon');
favicons.forEach(favicon => {
const newFileName = `/favicon-badge-${favicon.sizes[[0]]}.png`;
favicon.href = newFileName;
});
};
export const initFaviconSwitcher = () => {
const favicons = document.querySelectorAll('.favicon');
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
favicons.forEach(favicon => {
const oldFileName = `/favicon-${favicon.sizes[[0]]}.png`;
favicon.href = oldFileName;
});
}
});
};

View File

@ -0,0 +1,38 @@
import {
formatBytes,
fileSizeInMegaBytes,
checkFileSizeLimit,
} from '../FileHelper';
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');
});
});
describe('fileSizeInMegaBytes', () => {
it('should return zero if 0 is passed', () => {
expect(fileSizeInMegaBytes(0)).toBe(0);
});
it('should return 19.07 if 20000000 is passed', () => {
expect(fileSizeInMegaBytes(20000000)).toBe('19.07');
});
});
describe('checkFileSizeLimit', () => {
it('should return false if file with size 62208194 and file size limit 40 are passed', () => {
expect(checkFileSizeLimit({ file: { size: 62208194 } }, 40)).toBe(false);
});
it('should return true if file with size 62208194 and file size limit 40 are passed', () => {
expect(checkFileSizeLimit({ file: { size: 199154 } }, 40)).toBe(true);
});
});
});

View File

@ -4,7 +4,7 @@
accept="image/*, application/pdf, audio/mpeg, video/mp4, audio/ogg, text/csv"
@input-file="onFileUpload"
>
<span class="attachment-button ">
<span class="attachment-button">
<i v-if="!isUploading.image" class="ion-android-attach" />
<spinner v-if="isUploading" size="small" />
</span>
@ -14,6 +14,9 @@
<script>
import FileUpload from 'vue-upload-component';
import Spinner from 'shared/components/Spinner.vue';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
export default {
components: { FileUpload, Spinner },
@ -31,14 +34,21 @@ export default {
return fileType.includes('image') ? 'image' : 'file';
},
async onFileUpload(file) {
if (!file) {
return;
}
this.isUploading = true;
try {
const thumbUrl = window.URL.createObjectURL(file.file);
await this.onAttach({
fileType: this.getFileType(file.type),
file: file.file,
thumbUrl,
});
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
const thumbUrl = window.URL.createObjectURL(file.file);
await this.onAttach({
fileType: this.getFileType(file.type),
file: file.file,
thumbUrl,
});
} else {
window.bus.$emit(BUS_EVENTS.ATTACHMENT_SIZE_CHECK_ERROR);
}
} catch (error) {
// Error
}

View File

@ -37,7 +37,7 @@
:placeholder="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.PLACEHOLDER')"
:error="$v.message.$error ? $t('PRE_CHAT_FORM.FIELDS.MESSAGE.ERROR') : ''"
/>
<woot-button
<custom-button
class="font-medium"
block
:bg-color="widgetColor"
@ -46,12 +46,12 @@
>
<spinner v-if="isCreating" class="p-0" />
{{ $t('START_CONVERSATION') }}
</woot-button>
</custom-button>
</form>
</template>
<script>
import WootButton from 'shared/components/Button';
import CustomButton from 'shared/components/Button';
import FormInput from '../Form/Input';
import FormTextArea from '../Form/TextArea';
import Spinner from 'shared/components/Spinner';
@ -62,7 +62,7 @@ export default {
components: {
FormInput,
FormTextArea,
WootButton,
CustomButton,
Spinner,
},
props: {

View File

@ -15,7 +15,7 @@
</div>
<available-agents v-if="isOnline" :agents="availableAgents" />
</div>
<woot-button
<custom-button
class="font-medium"
block
:bg-color="widgetColor"
@ -23,7 +23,7 @@
@click="startConversation"
>
{{ $t('START_CONVERSATION') }}
</woot-button>
</custom-button>
</div>
</template>
@ -31,7 +31,7 @@
import { mapGetters } from 'vuex';
import AvailableAgents from 'widget/components/AvailableAgents.vue';
import { getContrastingTextColor } from 'shared/helpers/ColorHelper';
import WootButton from 'shared/components/Button';
import CustomButton from 'shared/components/Button';
import configMixin from 'widget/mixins/configMixin';
import availabilityMixin from 'widget/mixins/availability';
@ -39,7 +39,7 @@ export default {
name: 'TeamAvailability',
components: {
AvailableAgents,
WootButton,
CustomButton,
},
mixins: [configMixin, availabilityMixin],
props: {

View File

@ -48,5 +48,6 @@
"ERROR": "Message too short"
}
}
}
},
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit"
}

View File

@ -34,6 +34,15 @@
/>
</transition>
</div>
<div v-if="showAttachmentError" class="banner">
<span>
{{
$t('FILE_SIZE_LIMIT', {
MAXIMUM_FILE_UPLOAD_SIZE: fileUploadSizeLimit,
})
}}
</span>
</div>
<div class="flex flex-1 overflow-auto">
<conversation-wrap
v-if="currentView === 'messageView'"
@ -80,6 +89,8 @@ import configMixin from '../mixins/configMixin';
import TeamAvailability from 'widget/components/TeamAvailability';
import Spinner from 'shared/components/Spinner.vue';
import { mapGetters } from 'vuex';
import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import PreChatForm from '../components/PreChat/Form';
export default {
name: 'Home',
@ -105,7 +116,7 @@ export default {
},
},
data() {
return { isOnCollapsedView: false };
return { isOnCollapsedView: false, showAttachmentError: false };
},
computed: {
...mapGetters({
@ -132,6 +143,9 @@ export default {
isOpen() {
return this.conversationAttributes.status === 'open';
},
fileUploadSizeLimit() {
return MAXIMUM_FILE_UPLOAD_SIZE;
},
showInputTextArea() {
if (this.hideInputForBotConversations) {
if (this.isOpen) {
@ -154,6 +168,14 @@ export default {
);
},
},
mounted() {
bus.$on(BUS_EVENTS.ATTACHMENT_SIZE_CHECK_ERROR, () => {
this.showAttachmentError = true;
setTimeout(() => {
this.showAttachmentError = false;
}, 3000);
});
},
methods: {
startConversation() {
this.isOnCollapsedView = !this.isOnCollapsedView;
@ -221,5 +243,13 @@ export default {
.input-wrap {
padding: 0 $space-normal;
}
.banner {
background: $color-error;
color: $color-white;
font-size: $font-size-default;
font-weight: $font-weight-bold;
padding: $space-slab;
text-align: center;
}
}
</style>

View File

@ -29,13 +29,13 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer
send_mail_with_liquid(to: @agent.email, subject: subject) and return
end
def assigned_conversation_new_message(conversation, agent)
def assigned_conversation_new_message(message, agent)
return unless smtp_config_set_or_development?
# Don't spam with email notifications if agent is online
return if ::OnlineStatusTracker.get_presence(conversation.account.id, 'User', agent.id)
return if ::OnlineStatusTracker.get_presence(message.account_id, 'User', agent.id)
@agent = agent
@conversation = conversation
@conversation = message.conversation
subject = "#{@agent.available_name}, New message in your assigned conversation [ID - #{@conversation.display_id}]."
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
send_mail_with_liquid(to: @agent.email, subject: subject) and return

View File

@ -16,12 +16,19 @@ class ApplicationMailer < ActionMailer::Base
end
end
rescue_from(*ExceptionList::SMTP_EXCEPTIONS, with: :handle_smtp_exceptions)
def smtp_config_set_or_development?
ENV.fetch('SMTP_ADDRESS', nil).present? || Rails.env.development?
end
private
def handle_smtp_exceptions(message)
Rails.logger.info 'Failed to send Email'
Rails.logger.info "Exception: #{message}"
end
def send_mail_with_liquid(*args)
mail(*args) do |format|
# explored sending a multipart email containing both text type and html

View File

@ -31,6 +31,9 @@ class Contact < ApplicationRecord
validates :account_id, presence: true
validates :email, allow_blank: true, uniqueness: { scope: [:account_id], case_sensitive: false }
validates :identifier, allow_blank: true, uniqueness: { scope: [:account_id] }
validates :phone_number,
allow_blank: true, uniqueness: { scope: [:account_id] },
format: { with: /\+[1-9]\d{1,14}\z/, message: 'should be in e164 format' }
belongs_to :account
has_many :conversations, dependent: :destroy

View File

@ -53,10 +53,10 @@ class ContactInbox < ApplicationRecord
def validate_twilio_source_id
# https://www.twilio.com/docs/glossary/what-e164#regex-matching-for-e164
if inbox.channel.medium == 'sms' && !/^\+[1-9]\d{1,14}$/.match?(source_id)
errors.add(:source_id, 'invalid source id for twilio sms inbox. valid Regex /^\+[1-9]\d{1,14}$/')
elsif inbox.channel.medium == 'whatsapp' && !/^whatsapp:\+[1-9]\d{1,14}$/.match?(source_id)
errors.add(:source_id, 'invalid source id for twilio whatsapp inbox. valid Regex /^whatsapp:\+[1-9]\d{1,14}$/')
if inbox.channel.medium == 'sms' && !/\+[1-9]\d{1,14}\z/.match?(source_id)
errors.add(:source_id, 'invalid source id for twilio sms inbox. valid Regex /\+[1-9]\d{1,14}\z/')
elsif inbox.channel.medium == 'whatsapp' && !/whatsapp:\+[1-9]\d{1,14}\z/.match?(source_id)
errors.add(:source_id, 'invalid source id for twilio whatsapp inbox. valid Regex /whatsapp:\+[1-9]\d{1,14}\z/')
end
end

View File

@ -148,7 +148,9 @@ class Message < ApplicationRecord
end
def send_reply
::SendReplyJob.perform_later(id)
# FIXME: Giving it few seconds for the attachment to be uploaded to the service
# active storage attaches the file only after commit
attachments.blank? ? ::SendReplyJob.perform_later(id) : ::SendReplyJob.set(wait: 2.seconds).perform_later(id)
end
def reopen_conversation

View File

@ -20,8 +20,6 @@
# fk_rails_... (account_id => accounts.id)
#
class Team < ApplicationRecord
include RegexHelper
belongs_to :account
has_many :team_members, dependent: :destroy
has_many :members, through: :team_members, source: :user
@ -29,7 +27,6 @@ class Team < ApplicationRecord
validates :name,
presence: { message: 'must not be blank' },
format: { with: UNICODE_CHARACTER_NUMBER_HYPHEN_UNDERSCORE },
uniqueness: { scope: :account_id }
before_validation do

View File

@ -19,6 +19,10 @@ class ContactPolicy < ApplicationPolicy
true
end
def contactable_inboxes?
true
end
def show?
true
end

View File

@ -0,0 +1,52 @@
class Contacts::ContactableInboxesService
pattr_initialize [:contact!]
def get
account = contact.account
account.inboxes.map { |inbox| get_contactable_inbox(inbox) }.compact
end
private
def get_contactable_inbox(inbox)
return twilio_contactable_inbox(inbox) if inbox.channel_type == 'Channel::TwilioSms'
return email_contactable_inbox(inbox) if inbox.channel_type == 'Channel::Email'
return api_contactable_inbox(inbox) if inbox.channel_type == 'Channel::Api'
return website_contactable_inbox(inbox) if inbox.channel_type == 'Channel::WebWidget'
nil
end
def website_contactable_inbox(inbox)
latest_contact_inbox = inbox.contact_inboxes.where(contact: @contact).last
return unless latest_contact_inbox
# FIXME : change this when multiple conversations comes in
return if latest_contact_inbox.conversations.present?
{ source_id: latest_contact_inbox.source_id, inbox: inbox }
end
def api_contactable_inbox(inbox)
latest_contact_inbox = inbox.contact_inboxes.where(contact: @contact).last
source_id = latest_contact_inbox&.source_id || SecureRandom.uuid
{ source_id: source_id, inbox: inbox }
end
def email_contactable_inbox(inbox)
return unless @contact.email
{ source_id: @contact.email, inbox: inbox }
end
def twilio_contactable_inbox(inbox)
return if @contact.phone_number.blank?
case inbox.channel.medium
when 'sms'
{ source_id: @contact.phone_number, inbox: inbox }
when 'whatsapp'
{ source_id: "whatsapp:#{@contact.phone_number}", inbox: inbox }
end
end
end

View File

@ -0,0 +1,8 @@
json.payload do
json.array! @contactable_inboxes do |contactable_inbox|
json.inbox do
json.partial! 'api/v1/models/inbox.json.jbuilder', resource: contactable_inbox[:inbox]
end
json.source_id contactable_inbox[:source_id]
end
end

View File

@ -1,2 +1,4 @@
json.source_id resource.source_id
json.inbox resource.inbox
json.inbox do
json.partial! 'api/v1/models/inbox.json.jbuilder', resource: resource.inbox
end

View File

@ -1,4 +1,10 @@
<% headers = ['Agent name', 'Conversations count', 'Avg first response time (Minutes)', 'Avg resolution time (Minutes)'] %>
<% headers = [
I18n.t('reports.agent_csv.agent_name'),
I18n.t('reports.agent_csv.conversations_count'),
I18n.t('reports.agent_csv.avg_first_response_time'),
I18n.t('reports.agent_csv.avg_resolution_time')
]
%>
<%= CSV.generate_line headers %>
<% Current.account.users.each do |agent| %>
<% agent_report = V2::ReportBuilder.new(Current.account, {

View File

@ -23,9 +23,9 @@
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/android-icon-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link class="favicon" rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link class="favicon" rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
<link class="favicon" rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/manifest.json">
<% end %>
<link rel="icon" type="image/png" sizes="512x512" href="<%= @global_config['LOGO_THUMBNAIL'] %>">

View File

@ -1,5 +1,5 @@
shared: &shared
version: '1.14.3'
version: '1.15.0'
development:
<<: *shared

View File

@ -4,7 +4,3 @@ redis = Rails.env.test? ? MockRedis.new : Redis.new(Redis::Config.app)
# Add here as you use it for more features
# Used for Round Robin, Conversation Emails & Online Presence
$alfred = Redis::Namespace.new('alfred', redis: redis, warning: true)
# https://github.com/mperham/sidekiq/issues/4591
# TODO once sidekiq remove we can remove this
Redis.exists_returns_integer = false

Some files were not shown because too many files have changed in this diff Show More