Merge branch 'develop' into feature/conversation-refactor

This commit is contained in:
Pranav Raj Sreepuram 2020-05-02 11:32:24 +05:30
commit 08175617af
66 changed files with 803 additions and 363 deletions

View file

@ -0,0 +1,32 @@
class NotificationBuilder
pattr_initialize [:notification_type!, :user!, :account!, :primary_actor!]
def perform
return unless user_subscribed_to_notification?
build_notification
end
private
def secondary_actor
Current.user
end
def user_subscribed_to_notification?
notification_setting = user.notification_settings.find_by(account_id: account.id)
return true if notification_setting.public_send("email_#{notification_type}?")
return true if notification_setting.public_send("push_#{notification_type}?")
false
end
def build_notification
user.notifications.create!(
notification_type: notification_type,
account: account,
primary_actor: primary_actor,
secondary_actor: secondary_actor
)
end
end

View file

@ -4,11 +4,6 @@ class Api::V1::Accounts::ContactsController < Api::BaseController
before_action :check_authorization before_action :check_authorization
before_action :fetch_contact, only: [:show, :update] before_action :fetch_contact, only: [:show, :update]
skip_before_action :authenticate_user!, only: [:create]
skip_before_action :set_current_user, only: [:create]
skip_before_action :check_subscription, only: [:create]
skip_around_action :handle_with_exception, only: [:create]
def index def index
@contacts = current_account.contacts @contacts = current_account.contacts
end end

View file

@ -0,0 +1,21 @@
class Api::V1::Accounts::NotificationsController < Api::BaseController
protect_from_forgery with: :null_session
before_action :fetch_notification, only: [:update]
def index
@notifications = current_user.notifications.where(account_id: current_account.id)
render json: @notifications
end
def update
@notification.update(read_at: DateTime.now.utc)
render json: @notification
end
private
def fetch_notification
@notification = current_user.notifications.find(params[:id])
end
end

View file

@ -0,0 +1,18 @@
class Api::V1::NotificationSubscriptionsController < Api::BaseController
before_action :set_user
def create
notification_subscription = @user.notification_subscriptions.create(notification_subscription_params)
render json: notification_subscription
end
private
def set_user
@user = current_user
end
def notification_subscription_params
params.require(:notification_subscription).permit(:subscription_type, subscription_attributes: {})
end
end

View file

@ -9,8 +9,7 @@ class AsyncDispatcher < BaseDispatcher
end end
def listeners def listeners
listeners = [EmailNotificationListener.instance, ReportingListener.instance, WebhookListener.instance] listeners = [EventListener.instance, ReportingListener.instance, WebhookListener.instance]
listeners << EventListener.instance
listeners << SubscriptionListener.instance if ENV['BILLING_ENABLED'] listeners << SubscriptionListener.instance if ENV['BILLING_ENABLED']
listeners listeners
end end

View file

@ -5,6 +5,6 @@ class SyncDispatcher < BaseDispatcher
end end
def listeners def listeners
[ActionCableListener.instance, AgentBotListener.instance] [ActionCableListener.instance, AgentBotListener.instance, NotificationListener.instance]
end end
end end

View file

@ -49,10 +49,10 @@ $global-font-size: 10px;
$global-width: 100%; $global-width: 100%;
$global-lineheight: 1.5; $global-lineheight: 1.5;
$foundation-palette: (primary: $color-woot, $foundation-palette: (primary: $color-woot,
secondary: #777, secondary: #35c5ff,
success: #13ce66, success: #44ce4b,
warning: #ffc82c, warning: #ffc532,
alert: #ff4949); alert: #ff382d);
$light-gray: #c0ccda; $light-gray: #c0ccda;
$medium-gray: #8492a6; $medium-gray: #8492a6;
$dark-gray: $color-gray; $dark-gray: $color-gray;
@ -127,7 +127,7 @@ $header-styles: (small: ("h1": ("font-size": 24),
$header-text-rendering: optimizeLegibility; $header-text-rendering: optimizeLegibility;
$small-font-size: 80%; $small-font-size: 80%;
$header-small-font-color: $medium-gray; $header-small-font-color: $medium-gray;
$paragraph-lineheight: 1.6; $paragraph-lineheight: 1.45;
$paragraph-margin-bottom: 1rem; $paragraph-margin-bottom: 1rem;
$paragraph-text-rendering: optimizeLegibility; $paragraph-text-rendering: optimizeLegibility;
$code-color: $black; $code-color: $black;

View file

@ -29,7 +29,7 @@
background: $color-white; background: $color-white;
border-radius: $space-large; border-radius: $space-large;
left: 0; left: 0;
margin: $space-slab 0 auto; margin: $space-slab auto;
padding: $space-normal; padding: $space-normal;
top: 0; top: 0;

View file

@ -46,7 +46,8 @@ $color-gray: #6e6f73;
$color-light-gray: #999a9b; $color-light-gray: #999a9b;
$color-border: #e0e6ed; $color-border: #e0e6ed;
$color-border-light: #f0f4f5; $color-border-light: #f0f4f5;
$color-background: #eff2f7; $color-background: #f4f6fb;
$color-border-dark: #cad0d4;
$color-background-light: #f9fafc; $color-background-light: #f9fafc;
$color-white: #fff; $color-white: #fff;
$color-body: #3c4858; $color-body: #3c4858;
@ -54,11 +55,10 @@ $color-heading: #1f2d3d;
$color-extra-light-blue: #f5f7f9; $color-extra-light-blue: #f5f7f9;
$primary-color: $color-woot; $primary-color: $color-woot;
$secondary-color: #ff5216; $secondary-color: #35c5ff;
$success-color: #13ce66; $success-color: #44ce4b;
$warning-color: #ffc82c; $warning-color: #ffc532;
$alert-color: #ff4949; $alert-color: #ff382d;
// Color-palettes // Color-palettes
$color-primary-light: #c7e3ff; $color-primary-light: #c7e3ff;

View file

@ -5,6 +5,7 @@
@include padding($space-normal $space-two $zero); @include padding($space-normal $space-two $zero);
} }
} }
// Conversation header - Light BG // Conversation header - Light BG
.settings-header { .settings-header {
@include padding($space-small $space-normal); @include padding($space-small $space-normal);
@ -14,6 +15,7 @@
@include border-normal-bottom; @include border-normal-bottom;
height: $header-height; height: $header-height;
min-height: $header-height; min-height: $header-height;
// Resolve Button // Resolve Button
.button { .button {
@include margin(0); @include margin(0);
@ -83,7 +85,7 @@
background: $color-woot; background: $color-woot;
} }
& + .item { &+.item {
&::before { &::before {
background: $color-woot; background: $color-woot;
} }
@ -112,7 +114,7 @@
background: $color-border; background: $color-border;
border-radius: 20px; border-radius: 20px;
color: $color-white; color: $color-white;
font-size: $font-size-small; font-size: $font-size-micro;
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
height: $space-normal; height: $space-normal;
left: $space-normal; left: $space-normal;
@ -161,6 +163,7 @@
&:hover { &:hover {
@include background-gray; @include background-gray;
.arrow { .arrow {
opacity: 1; opacity: 1;
transform: translateX($space-small); transform: translateX($space-small);
@ -228,7 +231,7 @@
@include padding($space-medium); @include padding($space-medium);
} }
> a > img { >a>img {
width: $space-larger * 5; width: $space-larger * 5;
} }
} }

View file

@ -1,8 +1,8 @@
.integrations-wrap { .integrations-wrap {
.integration { .integration {
background: $color-white; background: $color-white;
border: 2px solid $color-border; border: 1px solid $color-border;
border-radius: $space-slab; border-radius: $space-smaller;
padding: $space-normal; padding: $space-normal;
.integration--image { .integration--image {

View file

@ -45,6 +45,7 @@
.user--name { .user--name {
@include margin(0); @include margin(0);
font-size: $font-size-medium; font-size: $font-size-medium;
line-height: 1.3;
text-transform: capitalize; text-transform: capitalize;
} }
@ -65,6 +66,8 @@
} }
.button.resolve--button { .button.resolve--button {
width: 13.2rem;
>.icon { >.icon {
font-size: $font-size-default; font-size: $font-size-default;
padding-right: $space-small; padding-right: $space-small;

View file

@ -2,11 +2,13 @@
@include flex; @include flex;
@include flex-shrink; @include flex-shrink;
@include padding($space-normal $zero $zero $space-normal); @include padding($space-normal $zero $zero $space-normal);
border-left: 4px solid transparent;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
&.active { &.active {
background: $color-background; background: $color-background;
border-left-color: $color-woot;
} }
.conversation--details { .conversation--details {
@ -64,7 +66,7 @@
background: darken($success-color, 3%); background: darken($success-color, 3%);
color: $color-white; color: $color-white;
display: none; display: none;
font-size: $font-size-mini; font-size: $font-size-micro;
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
height: $unread-size; height: $unread-size;
line-height: $unread-size; line-height: $unread-size;

View file

@ -1,10 +1,11 @@
@mixin bubble-with-tyes { @mixin bubble-with-types {
@include padding($space-smaller $space-one); @include padding($space-one $space-normal);
@include margin($zero); @include margin($zero);
background: $color-primary-light; background: $color-woot;
border-radius: $space-small; border-radius: $space-one;
color: $color-heading; color: $color-white;
font-size: $font-size-small; font-size: $font-size-small;
font-weight: $font-weight-normal;
position: relative; position: relative;
.icon { .icon {
@ -15,6 +16,17 @@
.message-text__wrap { .message-text__wrap {
position: relative; position: relative;
.time {
color: $color-primary-light;
display: block;
font-size: $font-size-micro;
line-height: 1.8;
}
.link {
color: $color-white;
}
} }
.message-text { .message-text {
@ -51,8 +63,7 @@
} }
&::before { &::before {
$color-black: #000; background-image: linear-gradient(-180deg, transparent 3%, $color-heading 130%);
background-image: linear-gradient(-180deg, transparent 3%, $color-black 70%);
bottom: 0; bottom: 0;
content: ''; content: '';
height: 20%; height: 20%;
@ -94,6 +105,7 @@
.load-more-conversations { .load-more-conversations {
font-size: $font-size-small; font-size: $font-size-small;
margin: 0;
padding: $space-normal; padding: $space-normal;
width: 100%; width: 100%;
} }
@ -122,10 +134,10 @@
.status--filter { .status--filter {
@include padding($zero null $zero $space-normal); @include padding($zero null $zero $space-normal);
@include border-light;
@include round-corner; @include round-corner;
@include margin($space-smaller $space-slab $zero $zero); @include margin($space-smaller $space-slab $zero $zero);
background-color: $color-background; background-color: $color-background-light;
border: 1px solid $color-border;
float: right; float: right;
font-size: $font-size-mini; font-size: $font-size-mini;
height: $space-medium; height: $space-medium;
@ -192,162 +204,192 @@
height: 100%; height: 100%;
margin-bottom: $space-small; margin-bottom: $space-small;
overflow-y: auto; overflow-y: auto;
}
li { .conversation-panel>li {
@include flex; @include flex;
@include flex-shrink; @include flex-shrink;
@include margin($zero $zero $space-smaller); @include margin($zero $zero $space-micro);
&:first-child { &:first-child {
margin-top: auto; margin-top: auto;
}
&:last-child {
margin-bottom: $space-small;
}
&.unread--toast {
span {
@include elegant-card;
@include round-corner;
background: $color-woot;
color: $color-white;
font-size: $font-size-mini;
font-weight: $font-weight-medium;
margin: $space-one auto;
padding: $space-smaller $space-two;
} }
}
&:last-child { .bubble {
margin-bottom: $space-small; @include bubble-with-types;
max-width: 50rem;
text-align: left;
word-wrap: break-word;
.aplayer {
box-shadow: none;
font-family: inherit;
} }
}
&.unread--toast { &.left {
span {
@include elegant-card; .bubble {
@include round-corner; @include border-normal;
background: $color-woot; background: $white;
color: $color-white; border-bottom-left-radius: $space-smaller;
font-size: $font-size-mini; border-top-left-radius: $space-smaller;
font-weight: $font-weight-medium; color: $color-body;
margin: $space-one auto; margin-right: auto;
padding: $space-smaller $space-two;
.time {
color: $color-light-gray;
} }
.image .time {
color: $color-white;
}
.link {
color: $color-primary-dark;
}
.file {
.text-block-title {
color: $color-body;
}
.icon-wrap {
color: $color-woot;
}
.download {
color: $color-primary-dark;
}
}
}
+.right {
margin-top: $space-one;
.bubble {
border-top-right-radius: $space-one;
}
}
}
&.right {
@include flex-align(right, null);
.wrap {
margin-right: $space-normal;
text-align: right;
} }
.bubble { .bubble {
@include bubble-with-tyes; border-bottom-right-radius: $space-smaller;
max-width: 50rem; border-top-right-radius: $space-smaller;
text-align: left; margin-left: auto;
word-wrap: break-word;
.aplayer { &.is-private {
box-shadow: none; background: lighten($warning-color, 32%);
font-family: inherit; border: 1px solid $color-border;
}
}
&.left {
.bubble {
background: $white;
border-bottom-left-radius: 0;
border-top-left-radius: 0;
color: $color-heading; color: $color-heading;
margin-right: auto; padding-right: $space-large;
} position: relative;
+.right { &::before {
margin-top: $space-one; bottom: 0;
color: $medium-gray;
position: absolute;
right: $space-one;
top: $space-smaller + $space-micro;
}
.bubble { .time {
border-top-right-radius: $space-small; color: $color-light-gray;
} }
} }
} }
&.right { +.left {
@include flex-align(right, null); margin-top: $space-one;
.wrap {
margin-right: $space-normal;
text-align: right;
}
.bubble { .bubble {
border-bottom-right-radius: 0; border-top-left-radius: $space-one;
border-top-right-radius: 0;
margin-left: auto;
&.is-private {
background: lighten($warning-color, 32%);
color: $color-heading;
padding-right: $space-large;
position: relative;
&::before {
bottom: 0;
color: $medium-gray;
position: absolute;
right: $space-one;
top: $space-smaller + $space-micro;
}
}
}
+.left {
margin-top: $space-one;
.bubble {
border-top-left-radius: $space-small;
}
} }
} }
}
.wrap { .wrap {
@include margin($zero $space-normal); @include margin($zero $space-normal);
max-width: 69%; max-width: 69%;
.sender--name { .sender--name {
font-size: $font-size-mini; font-size: $font-size-mini;
margin-bottom: $space-smaller; margin-bottom: $space-smaller;
}
} }
}
.sender--thumbnail {
@include round-corner();
height: $space-slab;
margin-right: $space-one;
margin-top: $space-micro;
width: $space-slab;
}
.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;
.activity-wrap { p {
@include flex; color: $color-heading;
@include margin($space-small auto); margin-bottom: $zero;
@include padding($space-smaller $space-normal);
@include flex-align($x: center, $y: null);
background: lighten($warning-color, 32%);
border: 1px solid lighten($warning-color, 26%);
border-radius: $space-smaller;
font-size: $font-size-small;
p { .ion-person {
color: $color-heading; color: $color-body;
margin-bottom: $zero; font-size: $font-size-default;
margin-right: $space-small;
.ion-person { position: relative;
color: $color-body; top: $space-micro;
font-size: $font-size-default;
margin-right: $space-small;
position: relative;
top: $space-micro;
}
.message-text__wrap {
position: relative;
}
.message-text {
&::after {
content: ' \00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0';
display: inline;
}
}
} }
.time { .message-text__wrap {
color: $medium-gray; position: relative;
}
.message-text {
&::after {
content: ' \00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0';
display: inline;
}
} }
} }
.time { .time {
bottom: -$space-micro; color: $medium-gray;
color: $color-gray;
float: right;
font-size: $font-size-micro; font-size: $font-size-micro;
font-style: italic;
margin-left: $space-slab; margin-left: $space-slab;
right: -$space-micro;
text-align: right;
} }
} }
} }

View file

@ -42,22 +42,26 @@
font-size: $font-size-default; font-size: $font-size-default;
input { input {
padding: $space-slab;
height: $space-larger;
font-size: $font-size-default; font-size: $font-size-default;
height: $space-larger;
padding: $space-slab;
} }
.error { .error {
font-size: $font-size-small; font-size: $font-size-small;
} }
} }
.button {
height: $space-larger;
}
} }
.sigin__footer { .sigin__footer {
font-size: $font-size-default; font-size: $font-size-default;
padding: $space-medium; padding: $space-medium;
> a { >a {
font-weight: $font-weight-bold; font-weight: $font-weight-bold;
} }
} }

View file

@ -48,7 +48,7 @@
margin-bottom: $space-micro; margin-bottom: $space-micro;
margin-top: $space-micro; margin-top: $space-micro;
>.inbox-icon { .inbox-icon {
display: inline-block; display: inline-block;
margin-right: $space-micro; margin-right: $space-micro;
min-width: $space-normal; min-width: $space-normal;

View file

@ -1,33 +1,32 @@
.ui-snackbar-container { .ui-snackbar-container {
position: absolute; left: 0;
margin: 0 auto;
max-width: 40rem;
overflow: hidden; overflow: hidden;
z-index: 9999; position: absolute;
top: $space-normal; right: 0;
left: $space-normal;
width: 100%;
text-align: center; text-align: center;
top: $space-normal;
z-index: 9999;
} }
.ui-snackbar { .ui-snackbar {
text-align: left; @include padding($space-slab $space-medium);
@include shadow;
background-color: $woot-snackbar-bg;
border-radius: $space-smaller;
display: inline-block; display: inline-block;
min-width: 24rem; margin-bottom: $space-small;
max-width: 40rem; max-width: 40rem;
min-height: 3rem; min-height: 3rem;
background-color: $woot-snackbar-bg; min-width: 24rem;
@include padding($space-slab $space-medium); text-align: left;
@include border-top-radius($space-micro);
@include border-right-radius($space-micro);
@include border-bottom-radius($space-micro);
@include border-left-radius($space-micro);
margin-bottom: $space-small;
// box-shadow: 0 1px 3px alpha(black, 0.12), 0 1px 2px alpha(black, 0.24);
} }
.ui-snackbar-text { .ui-snackbar-text {
font-size: $font-size-small;
color: $color-white; color: $color-white;
font-size: $font-size-small;
font-weight: $font-weight-medium;
} }
.ui-snackbar-action { .ui-snackbar-action {
@ -35,12 +34,12 @@
padding-left: 3rem; padding-left: 3rem;
button { button {
@include margin(0);
@include padding(0);
background: none; background: none;
border: 0; border: 0;
color: $woot-snackbar-button; color: $woot-snackbar-button;
font-size: $font-size-small; font-size: $font-size-small;
text-transform: uppercase; text-transform: uppercase;
@include margin(0);
@include padding(0);
} }
} }

View file

@ -1,6 +1,10 @@
<template> <template>
<transition-group name="toast-fade" tag="div" class="ui-snackbar-container"> <transition-group name="toast-fade" tag="div" class="ui-snackbar-container">
<woot-snackbar :message="snackMessage" v-for="snackMessage in snackMessages" v-bind:key="snackMessage" /> <woot-snackbar
v-for="snackMessage in snackMessages"
:key="snackMessage"
:message="snackMessage"
/>
</transition-group> </transition-group>
</template> </template>
@ -9,8 +13,12 @@
import WootSnackbar from './Snackbar'; import WootSnackbar from './Snackbar';
export default { export default {
components: {
WootSnackbar,
},
props: { props: {
duration: { duration: {
type: Number,
default: 2500, default: 2500,
}, },
}, },
@ -22,16 +30,12 @@ export default {
}, },
mounted() { mounted() {
bus.$on('newToastMessage', (message) => { bus.$on('newToastMessage', message => {
this.snackMessages.push(message); this.snackMessages.push(message);
window.setTimeout(() => { window.setTimeout(() => {
this.snackMessages.splice(0, 1); this.snackMessages.splice(0, 1);
}, this.duration); }, this.duration);
}); });
}, },
components: {
WootSnackbar,
},
}; };
</script> </script>

View file

@ -44,12 +44,12 @@ export default {
.file { .file {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
padding: $space-normal; padding: $space-smaller 0;
cursor: pointer; cursor: pointer;
.icon-wrap { .icon-wrap {
font-size: $font-size-giga; font-size: $font-size-giga;
color: $color-woot; color: $color-white;
line-height: 1; line-height: 1;
margin-left: $space-smaller; margin-left: $space-smaller;
margin-right: $space-slab; margin-right: $space-slab;
@ -57,15 +57,22 @@ export default {
.text-block-title { .text-block-title {
margin: 0; margin: 0;
color: $color-white;
font-weight: $font-weight-bold;
} }
.button { .button {
padding: 0; padding: 0;
margin: 0; margin: 0;
color: $color-primary-light;
} }
.meta { .meta {
padding-right: $space-two; padding-right: $space-two;
} }
.time {
min-width: $space-larger;
}
} }
</style> </style>

View file

@ -1,7 +1,7 @@
<template> <template>
<span class="message-text__wrap"> <span class="message-text__wrap">
<span class="time">{{ readableTime }}</span>
<span v-html="message"></span> <span v-html="message"></span>
<span class="time">{{ readableTime }}</span>
</span> </span>
</template> </template>

View file

@ -16,6 +16,6 @@
}, },
"FORGOT_PASSWORD": "Forgot your password?", "FORGOT_PASSWORD": "Forgot your password?",
"CREATE_NEW_ACCOUNT": "Create new account", "CREATE_NEW_ACCOUNT": "Create new account",
"SUBMIT": "Sign In" "SUBMIT": "Login"
} }
} }

View file

@ -146,7 +146,7 @@ export default {
return `${platformName || ''} ${platformVersion || ''}`; return `${platformName || ''} ${platformVersion || ''}`;
}, },
contactId() { contactId() {
return this.currentConversationMetaData.contact_id; return this.currentConversationMetaData.contact?.id;
}, },
contact() { contact() {
return this.$store.getters['contacts/getContact'](this.contactId); return this.$store.getters['contacts/getContact'](this.contactId);
@ -155,16 +155,12 @@ export default {
watch: { watch: {
contactId(newContactId, prevContactId) { contactId(newContactId, prevContactId) {
if (newContactId && newContactId !== prevContactId) { if (newContactId && newContactId !== prevContactId) {
this.$store.dispatch('contacts/show', { this.$store.dispatch('contacts/show', { id: newContactId });
id: this.currentConversationMetaData.contact_id,
});
} }
}, },
}, },
mounted() { mounted() {
this.$store.dispatch('contacts/show', { this.$store.dispatch('contacts/show', { id: this.contactId });
id: this.currentConversationMetaData.contact_id,
});
}, },
methods: { methods: {
onPanelToggle() { onPanelToggle() {
@ -189,7 +185,7 @@ export default {
.close-button { .close-button {
position: absolute; position: absolute;
right: $space-slab; right: $space-normal;
top: $space-slab; top: $space-slab;
font-size: $font-size-default; font-size: $font-size-default;
color: $color-heading; color: $color-heading;

View file

@ -12,7 +12,7 @@
v-model="selectedNotifications" v-model="selectedNotifications"
class="email-notification--checkbox" class="email-notification--checkbox"
type="checkbox" type="checkbox"
value="conversation_creation" value="email_conversation_creation"
@input="handleInput" @input="handleInput"
/> />
<label for="conversation_creation"> <label for="conversation_creation">
@ -29,7 +29,7 @@
v-model="selectedNotifications" v-model="selectedNotifications"
class="email-notification--checkbox" class="email-notification--checkbox"
type="checkbox" type="checkbox"
value="conversation_assignment" value="email_conversation_assignment"
@input="handleInput" @input="handleInput"
/> />
<label for="conversation_assignment"> <label for="conversation_assignment">

View file

@ -16,7 +16,7 @@ class MessageFormatter {
return this.message.replace( return this.message.replace(
urlRegex, urlRegex,
url => url =>
`<a rel="noreferrer noopener nofollow" href="${url}" target="_blank">${url}</a>` `<a rel="noreferrer noopener nofollow" href="${url}" class="link" target="_blank">${url}</a>`
); );
} }

View file

@ -6,7 +6,7 @@ describe('#MessageFormatter', () => {
const message = const message =
'Chatwoot is an opensource tool\nSee more at https://www.chatwoot.com'; 'Chatwoot is an opensource tool\nSee more at https://www.chatwoot.com';
expect(new MessageFormatter(message).formattedMessage).toEqual( expect(new MessageFormatter(message).formattedMessage).toEqual(
'Chatwoot is an opensource tool<br>See more at <a rel="noreferrer noopener nofollow" href="https://www.chatwoot.com" target="_blank">https://www.chatwoot.com</a>' 'Chatwoot is an opensource tool<br>See more at <a rel="noreferrer noopener nofollow" href="https://www.chatwoot.com" class="link" target="_blank">https://www.chatwoot.com</a>'
); );
}); });
}); });

View file

@ -22,7 +22,10 @@ $input-height: $space-two * 2;
outline: none; outline: none;
padding: $space-smaller; padding: $space-smaller;
position: relative; position: relative;
transition: background .2s, border .2s, box-shadow .2s, color .2s; transition: background .2s,
border .2s,
box-shadow .2s,
color .2s;
width: 100%; width: 100%;
&:focus { &:focus {
@ -37,7 +40,7 @@ $input-height: $space-two * 2;
&.small { &.small {
font-size: $font-size-small; font-size: $font-size-small;
height: $space-large; height: $space-large;
padding: $space-small $space-slab; padding: $space-small $space-one;
} }
&.default { &.default {

View file

@ -52,12 +52,13 @@ $color-light-gray: #999a9b;
$color-border: #e0e6ed; $color-border: #e0e6ed;
$color-border-transparent: rgba(224, 230, 237, 0.5); $color-border-transparent: rgba(224, 230, 237, 0.5);
$color-border-light: #f0f4f5; $color-border-light: #f0f4f5;
$color-background: #ecf3f9; $color-border-dark: #cad0d4;
$color-background: #f4f6fb;
$color-background-light: #fafafa; $color-background-light: #fafafa;
$color-white: #fff; $color-white: #fff;
$color-body: #3c4858; $color-body: #3c4858;
$color-heading: #1f2d3d; $color-heading: #1f2d3d;
$color-error: #ff4949; $color-error: #ff382d;
// Thumbnail // Thumbnail

View file

@ -1,5 +1,8 @@
<template> <template>
<div class="agent-bubble"> <div
class="agent-message-wrap"
:class="{ 'has-response': hasRecordedResponse }"
>
<div class="agent-message"> <div class="agent-message">
<div class="avatar-wrap"> <div class="avatar-wrap">
<thumbnail <thumbnail
@ -108,7 +111,7 @@ export default {
}, },
avatarUrl() { avatarUrl() {
// eslint-disable-next-line // eslint-disable-next-line
const BotImage = require('dashboard/assets/images/chatwoot_bot.png') const BotImage = require('dashboard/assets/images/chatwoot_bot.png');
if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) { if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) {
return BotImage; return BotImage;
} }
@ -146,17 +149,6 @@ export default {
@import '~widget/assets/scss/variables.scss'; @import '~widget/assets/scss/variables.scss';
.conversation-wrap { .conversation-wrap {
.agent-bubble {
margin-bottom: $space-micro;
& + .agent-bubble {
.agent-message {
.chat-bubble {
border-top-left-radius: $space-smaller;
}
}
}
}
.agent-message { .agent-message {
align-items: flex-end; align-items: flex-end;
display: flex; display: flex;
@ -165,10 +157,6 @@ export default {
margin: 0 0 $space-micro $space-small; margin: 0 0 $space-micro $space-small;
max-width: 88%; max-width: 88%;
& + .user-message {
margin-top: $space-one;
}
.avatar-wrap { .avatar-wrap {
height: $space-medium; height: $space-medium;
width: $space-medium; width: $space-medium;
@ -199,5 +187,26 @@ export default {
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
} }
.agent-message-wrap {
+ .agent-message-wrap {
margin-top: $space-micro;
.agent-message .chat-bubble {
border-top-left-radius: $space-smaller;
}
}
+ .user-message-wrap {
margin-top: $space-normal;
}
&.has-response + .user-message-wrap {
margin-top: $space-micro;
.chat-bubble {
border-top-right-radius: $space-smaller;
}
}
}
} }
</style> </style>

View file

@ -124,6 +124,11 @@ export default {
background: $color-white; background: $color-white;
border-bottom-left-radius: $space-smaller; border-bottom-left-radius: $space-smaller;
color: $color-body; color: $color-body;
.link {
word-break: break-word;
color: $color-woot;
}
} }
} }
</style> </style>

View file

@ -64,7 +64,7 @@ export default {
font-size: $font-size-default; font-size: $font-size-default;
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
color: $color-white; color: $color-white;
padding: $space-one $space-normal $space-one $space-small; padding: $space-small $space-normal $space-small $space-small;
line-height: 1.4; line-height: 1.4;
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;

View file

@ -102,7 +102,7 @@ export default {
.conversation-wrap { .conversation-wrap {
flex: 1; flex: 1;
padding: $space-large $space-small $zero $space-small; padding: $space-large $space-small $space-small $space-small;
} }
.message--loader { .message--loader {

View file

@ -59,11 +59,11 @@ export default {
.file { .file {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
padding: $space-one $space-slab; padding: $space-slab;
cursor: pointer; cursor: pointer;
.icon-wrap { .icon-wrap {
font-size: $font-size-bigger; font-size: $font-size-mega;
color: $color-woot; color: $color-woot;
line-height: 1; line-height: 1;
margin-left: $space-smaller; margin-left: $space-smaller;
@ -72,11 +72,14 @@ export default {
.title { .title {
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
font-size: $font-size-small; font-size: $font-size-default;
margin: 0; margin: 0;
} }
.download { .download {
color: $color-woot;
font-weight: $font-weight-medium;
font-size: $font-size-mini;
padding: 0; padding: 0;
margin: 0; margin: 0;
font-size: $font-size-small; font-size: $font-size-small;

View file

@ -29,11 +29,10 @@ export default {
max-width: 100%; max-width: 100%;
&::before { &::before {
$color-black: #000;
background-image: linear-gradient( background-image: linear-gradient(
-180deg, -180deg,
transparent 3%, transparent 3%,
$color-black 70% $color-heading 130%
); );
bottom: 0; bottom: 0;
content: ''; content: '';
@ -55,7 +54,7 @@ export default {
bottom: $space-smaller; bottom: $space-smaller;
color: $color-white; color: $color-white;
position: absolute; position: absolute;
right: $space-small; right: $space-slab;
white-space: nowrap; white-space: nowrap;
} }
} }

View file

@ -1,24 +1,26 @@
<template> <template>
<div class="user-message"> <div class="user-message-wrap">
<div class="message-wrap" :class="{ 'in-progress': isInProgress }"> <div class="user-message">
<UserMessageBubble <div class="message-wrap" :class="{ 'in-progress': isInProgress }">
v-if="showTextBubble" <UserMessageBubble
:message="message.content" v-if="showTextBubble"
:status="message.status" :message="message.content"
/> :status="message.status"
<div v-if="hasAttachments" class="chat-bubble has-attachment user"> />
<div v-for="attachment in message.attachments" :key="attachment.id"> <div v-if="hasAttachments" class="chat-bubble has-attachment user">
<file-bubble <div v-for="attachment in message.attachments" :key="attachment.id">
v-if="attachment.file_type !== 'image'" <file-bubble
:url="attachment.data_url" v-if="attachment.file_type !== 'image'"
:is-in-progress="isInProgress" :url="attachment.data_url"
/> :is-in-progress="isInProgress"
<image-bubble />
v-else <image-bubble
:url="attachment.data_url" v-else
:thumb="attachment.thumb_url" :url="attachment.data_url"
:readable-time="readableTime" :thumb="attachment.thumb_url"
/> :readable-time="readableTime"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -80,16 +82,6 @@ export default {
max-width: 85%; max-width: 85%;
text-align: right; text-align: right;
& + .user-message {
margin-bottom: $space-micro;
.chat-bubble {
border-top-right-radius: $space-smaller;
}
}
& + .agent-message {
margin-top: $space-normal;
margin-bottom: $space-micro;
}
.message-wrap { .message-wrap {
margin-right: $space-small; margin-right: $space-small;
} }
@ -103,13 +95,28 @@ export default {
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
} }
.user.has-attachment { .user.has-attachment {
.icon-wrap { .icon-wrap {
color: $color-white; color: $color-white;
} }
.download { .download {
opacity: 0.8; color: $color-white;
}
}
.user-message-wrap {
+ .user-message-wrap {
margin-top: $space-micro;
.user-message .chat-bubble {
border-top-right-radius: $space-smaller;
}
}
+ .agent-message-wrap {
margin-top: $space-normal;
} }
} }
} }

View file

@ -45,7 +45,7 @@ export default {
display: inline-block; display: inline-block;
font-size: $font-size-default; font-size: $font-size-default;
line-height: 1.5; line-height: 1.5;
padding: $space-small $space-normal; padding: $space-slab $space-normal $space-slab $space-normal;
text-align: left; text-align: left;
> a { > a {

View file

@ -86,7 +86,7 @@ export default {
.header-wrap { .header-wrap {
flex-shrink: 0; flex-shrink: 0;
border-radius: $space-normal; border-radius: $space-normal $space-normal $space-small $space-small;
background: white; background: white;
z-index: 99; z-index: 99;
@include shadow-large; @include shadow-large;

View file

@ -4,86 +4,88 @@ class ActionCableListener < BaseListener
def conversation_created(event) def conversation_created(event)
conversation, account, timestamp = extract_conversation_and_account(event) conversation, account, timestamp = extract_conversation_and_account(event)
send_to_agents(account, conversation.inbox.members, CONVERSATION_CREATED, conversation.push_event_data) broadcast(user_tokens(account, conversation.inbox.members), CONVERSATION_CREATED, conversation.push_event_data)
end end
def conversation_read(event) def conversation_read(event)
conversation, account, timestamp = extract_conversation_and_account(event) conversation, account, timestamp = extract_conversation_and_account(event)
send_to_agents(account, conversation.inbox.members, CONVERSATION_READ, conversation.push_event_data) broadcast(user_tokens(account, conversation.inbox.members), CONVERSATION_READ, conversation.push_event_data)
end end
def message_created(event) def message_created(event)
message, account, timestamp = extract_message_and_account(event) message, account, timestamp = extract_message_and_account(event)
conversation = message.conversation conversation = message.conversation
tokens = user_tokens(account, conversation.inbox.members) + contact_token(conversation.contact, message)
send_to_agents(account, conversation.inbox.members, MESSAGE_CREATED, message.push_event_data) broadcast(tokens, MESSAGE_CREATED, message.push_event_data)
send_to_contact(conversation.contact, MESSAGE_CREATED, message)
end end
def message_updated(event) def message_updated(event)
message, account, timestamp = extract_message_and_account(event) message, account, timestamp = extract_message_and_account(event)
conversation = message.conversation conversation = message.conversation
contact = conversation.contact contact = conversation.contact
tokens = user_tokens(account, conversation.inbox.members) + contact_token(conversation.contact, message)
send_to_agents(account, conversation.inbox.members, MESSAGE_UPDATED, message.push_event_data) broadcast(tokens, MESSAGE_UPDATED, message.push_event_data)
send_to_contact(contact, MESSAGE_UPDATED, message)
end end
def conversation_resolved(event) def conversation_resolved(event)
conversation, account, timestamp = extract_conversation_and_account(event) conversation, account, timestamp = extract_conversation_and_account(event)
send_to_agents(account, conversation.inbox.members, CONVERSATION_RESOLVED, conversation.push_event_data) broadcast(user_tokens(account, conversation.inbox.members), CONVERSATION_RESOLVED, conversation.push_event_data)
end end
def conversation_opened(event) def conversation_opened(event)
conversation, account, timestamp = extract_conversation_and_account(event) conversation, account, timestamp = extract_conversation_and_account(event)
send_to_agents(account, conversation.inbox.members, CONVERSATION_OPENED, conversation.push_event_data) broadcast(user_tokens(account, conversation.inbox.members), CONVERSATION_OPENED, conversation.push_event_data)
end end
def conversation_lock_toggle(event) def conversation_lock_toggle(event)
conversation, account, timestamp = extract_conversation_and_account(event) conversation, account, timestamp = extract_conversation_and_account(event)
send_to_agents(account, conversation.inbox.members, CONVERSATION_LOCK_TOGGLE, conversation.lock_event_data) broadcast(user_tokens(account, conversation.inbox.members), CONVERSATION_LOCK_TOGGLE, conversation.lock_event_data)
end end
def assignee_changed(event) def assignee_changed(event)
conversation, account, timestamp = extract_conversation_and_account(event) conversation, account, timestamp = extract_conversation_and_account(event)
send_to_agents(account, conversation.inbox.members, ASSIGNEE_CHANGED, conversation.push_event_data) broadcast(user_tokens(account, conversation.inbox.members), ASSIGNEE_CHANGED, conversation.push_event_data)
end end
def contact_created(event) def contact_created(event)
contact, account, timestamp = extract_contact_and_account(event) contact, account, timestamp = extract_contact_and_account(event)
send_to_agents(account, account.agents, CONTACT_CREATED, contact.push_event_data) broadcast(user_tokens(account, account.agents), CONTACT_CREATED, contact.push_event_data)
end end
def contact_updated(event) def contact_updated(event)
contact, account, timestamp = extract_contact_and_account(event) contact, account, timestamp = extract_contact_and_account(event)
send_to_agents(account, account.agents, CONTACT_UPDATED, contact.push_event_data) broadcast(user_tokens(account, account.agents), CONTACT_UPDATED, contact.push_event_data)
end end
private private
def send_to_agents(account, agents, event_name, data) def user_tokens(account, agents)
agent_tokens = agents.pluck(:pubsub_token) agent_tokens = agents.pluck(:pubsub_token)
admin_tokens = account.administrators.pluck(:pubsub_token) admin_tokens = account.administrators.pluck(:pubsub_token)
pubsub_tokens = (agent_tokens + admin_tokens).uniq pubsub_tokens = (agent_tokens + admin_tokens).uniq
pubsub_tokens
return if pubsub_tokens.blank?
::ActionCableBroadcastJob.perform_later(pubsub_tokens, event_name, data)
end end
def send_to_contact(contact, event_name, message) def contact_token(contact, message)
return if message.private? return [] if message.private?
return if message.activity? return [] if message.activity?
return if contact.nil? return [] if contact.nil?
::ActionCableBroadcastJob.perform_later([contact.pubsub_token], event_name, message.push_event_data) [contact.pubsub_token]
end
def broadcast(tokens, event_name, data)
return if tokens.blank?
::ActionCableBroadcastJob.perform_later(tokens.uniq, event_name, data)
end end
end end

View file

@ -1,12 +0,0 @@
class EmailNotificationListener < BaseListener
def conversation_created(event)
conversation, _account, _timestamp = extract_conversation_and_account(event)
return if conversation.bot?
conversation.inbox.members.each do |agent|
next unless agent.notification_settings.find_by(account_id: conversation.account_id).conversation_creation?
AgentNotifications::ConversationNotificationsMailer.conversation_created(conversation, agent).deliver_later
end
end
end

View file

@ -0,0 +1,29 @@
class NotificationListener < BaseListener
def conversation_created(event)
conversation, account, _timestamp = extract_conversation_and_account(event)
return if conversation.bot?
conversation.inbox.members.each do |agent|
NotificationBuilder.new(
notification_type: 'conversation_creation',
user: agent,
account: account,
primary_actor: conversation
).perform
end
end
def assignee_changed(event)
conversation, account, _timestamp = extract_conversation_and_account(event)
assignee = conversation.assignee
return unless conversation.notifiable_assignee_change?
return if conversation.bot?
NotificationBuilder.new(
notification_type: 'conversation_assignment',
user: assignee,
account: account,
primary_actor: conversation
).perform
end
end

View file

@ -2,7 +2,7 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer
default from: ENV.fetch('MAILER_SENDER_EMAIL', 'accounts@chatwoot.com') default from: ENV.fetch('MAILER_SENDER_EMAIL', 'accounts@chatwoot.com')
layout 'mailer' layout 'mailer'
def conversation_created(conversation, agent) def conversation_creation(conversation, agent)
return unless smtp_config_set_or_development? return unless smtp_config_set_or_development?
@agent = agent @agent = agent
@ -11,7 +11,7 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer
mail(to: @agent.email, subject: subject) mail(to: @agent.email, subject: subject)
end end
def conversation_assigned(conversation, agent) def conversation_assignment(conversation, agent)
return unless smtp_config_set_or_development? return unless smtp_config_set_or_development?
@agent = agent @agent = agent

View file

@ -37,7 +37,8 @@ class AccountUser < ApplicationRecord
def create_notification_setting def create_notification_setting
setting = user.notification_settings.new(account_id: account.id) setting = user.notification_settings.new(account_id: account.id)
setting.selected_email_flags = [:conversation_assignment] setting.selected_email_flags = [:email_conversation_assignment]
setting.selected_push_flags = [:push_conversation_assignment]
setting.save! setting.save!
end end

View file

@ -53,7 +53,7 @@ class Conversation < ApplicationRecord
before_create :set_bot_conversation before_create :set_bot_conversation
after_update :notify_status_change, :create_activity, :send_email_notification_to_assignee after_update :notify_status_change, :create_activity
after_create :notify_conversation_creation, :run_round_robin after_create :notify_conversation_creation, :run_round_robin
@ -105,16 +105,6 @@ class Conversation < ApplicationRecord
} }
end end
private
def set_bot_conversation
self.status = :bot if inbox.agent_bot_inbox&.active?
end
def notify_conversation_creation
dispatcher_dispatch(CONVERSATION_CREATED)
end
def notifiable_assignee_change? def notifiable_assignee_change?
return false if self_assign?(assignee_id) return false if self_assign?(assignee_id)
return false unless saved_change_to_assignee_id? return false unless saved_change_to_assignee_id?
@ -123,12 +113,14 @@ class Conversation < ApplicationRecord
true true
end end
def send_email_notification_to_assignee private
return unless notifiable_assignee_change?
return if assignee.notification_settings.find_by(account_id: account_id).not_conversation_assignment?
return if bot?
AgentNotifications::ConversationNotificationsMailer.conversation_assigned(self, assignee).deliver_later def set_bot_conversation
self.status = :bot if inbox.agent_bot_inbox&.active?
end
def notify_conversation_creation
dispatcher_dispatch(CONVERSATION_CREATED)
end end
def self_assign?(assignee_id) def self_assign?(assignee_id)

View file

@ -75,7 +75,7 @@ class Message < ApplicationRecord
:notify_via_mail :notify_via_mail
# we need to wait for the active storage attachments to be available # we need to wait for the active storage attachments to be available
after_create_commit :dispatch_event, :send_reply after_create_commit :dispatch_create_events, :send_reply
after_update :dispatch_update_event after_update :dispatch_update_event
@ -117,7 +117,7 @@ class Message < ApplicationRecord
private private
def dispatch_event def dispatch_create_events
Rails.configuration.dispatcher.dispatch(MESSAGE_CREATED, Time.zone.now, message: self) Rails.configuration.dispatcher.dispatch(MESSAGE_CREATED, Time.zone.now, message: self)
if outgoing? && conversation.messages.outgoing.count == 1 if outgoing? && conversation.messages.outgoing.count == 1

View file

@ -0,0 +1,47 @@
# == Schema Information
#
# Table name: notifications
#
# id :bigint not null, primary key
# notification_type :integer not null
# primary_actor_type :string not null
# read_at :datetime
# secondary_actor_type :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# primary_actor_id :bigint not null
# secondary_actor_id :bigint
# user_id :bigint not null
#
# Indexes
#
# index_notifications_on_account_id (account_id)
# index_notifications_on_user_id (user_id)
# uniq_primary_actor_per_account_notifications (primary_actor_type,primary_actor_id)
# uniq_secondary_actor_per_account_notifications (secondary_actor_type,secondary_actor_id)
#
class Notification < ApplicationRecord
belongs_to :account
belongs_to :user
belongs_to :primary_actor, polymorphic: true
belongs_to :secondary_actor, polymorphic: true, optional: true
NOTIFICATION_TYPES = {
conversation_creation: 1,
conversation_assignment: 2
}.freeze
enum notification_type: NOTIFICATION_TYPES
after_create_commit :process_notification_delivery
private
def process_notification_delivery
Notification::EmailNotificationService.new(notification: self).perform
# Notification::PushNotificationService.new(notification: self).perform
end
end

View file

@ -4,6 +4,7 @@
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# email_flags :integer default(0), not null # email_flags :integer default(0), not null
# push_flags :integer default(0), not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_id :integer # account_id :integer
@ -25,10 +26,9 @@ class NotificationSetting < ApplicationRecord
flag_query_mode: :bit_operator flag_query_mode: :bit_operator
}.freeze }.freeze
EMAIL_NOTIFICATION_FLAGS = { EMAIL_NOTIFICATION_FLAGS = ::Notification::NOTIFICATION_TYPES.transform_keys { |key| "email_#{key}".to_sym }.invert.freeze
1 => :conversation_creation, PUSH_NOTIFICATION_FLAGS = ::Notification::NOTIFICATION_TYPES.transform_keys { |key| "push_#{key}".to_sym }.invert.freeze
2 => :conversation_assignment
}.freeze
has_flags EMAIL_NOTIFICATION_FLAGS.merge(column: 'email_flags').merge(DEFAULT_QUERY_SETTING) has_flags EMAIL_NOTIFICATION_FLAGS.merge(column: 'email_flags').merge(DEFAULT_QUERY_SETTING)
has_flags PUSH_NOTIFICATION_FLAGS.merge(column: 'push_flags').merge(DEFAULT_QUERY_SETTING)
end end

View file

@ -0,0 +1,26 @@
# == Schema Information
#
# Table name: notification_subscriptions
#
# id :bigint not null, primary key
# subscription_attributes :jsonb not null
# subscription_type :integer not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint not null
#
# Indexes
#
# index_notification_subscriptions_on_user_id (user_id)
#
class NotificationSubscription < ApplicationRecord
belongs_to :user
SUBSCRIPTION_TYPES = {
browser_push: 1,
gcm: 2
}.freeze
enum subscription_type: SUBSCRIPTION_TYPES
end

View file

@ -67,7 +67,10 @@ class User < ApplicationRecord
has_many :assigned_inboxes, through: :inbox_members, source: :inbox has_many :assigned_inboxes, through: :inbox_members, source: :inbox
has_many :messages has_many :messages
has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', dependent: :nullify has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', dependent: :nullify
has_many :notifications, dependent: :destroy
has_many :notification_settings, dependent: :destroy has_many :notification_settings, dependent: :destroy
has_many :notification_subscriptions, dependent: :destroy
before_validation :set_password_and_uid, on: :create before_validation :set_password_and_uid, on: :create
@ -119,12 +122,6 @@ class User < ApplicationRecord
Rails.configuration.dispatcher.dispatch(AGENT_ADDED, Time.zone.now, account: account) Rails.configuration.dispatcher.dispatch(AGENT_ADDED, Time.zone.now, account: account)
end end
def create_notification_setting
setting = notification_settings.new(account_id: account.id)
setting.selected_email_flags = [:conversation_assignment]
setting.save!
end
def notify_deletion def notify_deletion
Rails.configuration.dispatcher.dispatch(AGENT_REMOVED, Time.zone.now, account: account) Rails.configuration.dispatcher.dispatch(AGENT_REMOVED, Time.zone.now, account: account)
end end

View file

@ -0,0 +1,20 @@
class Notification::EmailNotificationService
pattr_initialize [:notification!]
def perform
return unless user_subscribed_to_notification?
# TODO : Clean up whatever happening over here
AgentNotifications::ConversationNotificationsMailer.public_send(notification
.notification_type.to_s, notification.primary_actor, notification.user).deliver_later
end
private
def user_subscribed_to_notification?
notification_setting = notification.user.notification_settings.find_by(account_id: notification.account.id)
return true if notification_setting.public_send("email_#{notification.notification_type}?")
false
end
end

View file

@ -0,0 +1,17 @@
class Notification::PushNotificationService
pattr_initialize [:notification!]
def perform
return unless user_subscribed_to_notification?
# TODO: implement the push delivery logic here
end
private
def user_subscribed_to_notification?
notification_setting = notification.user.notification_settings.find_by(account_id: notification.account.id)
return true if notification_setting.public_send("push_#{notification.notification_type}?")
false
end
end

View file

@ -1,7 +1,9 @@
json.meta do json.meta do
json.labels @conversation.label_list json.labels @conversation.label_list
json.additional_attributes @conversation.additional_attributes json.additional_attributes @conversation.additional_attributes
json.contact_id @conversation.contact_id json.contact @conversation.contact.push_event_data
json.assignee @conversation.assignee.push_event_data if @conversation.assignee.present?
json.agent_last_seen_at @conversation.agent_last_seen_at
end end
json.payload do json.payload do

View file

@ -76,6 +76,7 @@ Rails.application.routes.draw do
end end
end end
resources :notifications, only: [:index, :update]
resource :notification_settings, only: [:show, :update] resource :notification_settings, only: [:show, :update]
resources :reports, only: [] do resources :reports, only: [] do
@ -103,6 +104,7 @@ Rails.application.routes.draw do
# ---------------------------------- # ----------------------------------
resource :profile, only: [:show, :update] resource :profile, only: [:show, :update]
resource :notification_subscriptions, only: [:create]
resources :agent_bots, only: [:index] resources :agent_bots, only: [:index]

View file

@ -0,0 +1,34 @@
class CreateNotifications < ActiveRecord::Migration[6.0]
def change
create_table :notifications do |t|
t.references :account, index: true, null: false
t.references :user, index: true, null: false
t.integer :notification_type, null: false
t.references :primary_actor, polymorphic: true, null: false, index: { name: 'uniq_primary_actor_per_account_notifications' }
t.references :secondary_actor, polymorphic: true, index: { name: 'uniq_secondary_actor_per_account_notifications' }
t.timestamp :read_at, default: nil
t.timestamps
end
create_table :notification_subscriptions do |t|
t.references :user, index: true, null: false
t.integer :subscription_type, null: false
t.jsonb :subscription_attributes, null: false, default: '{}'
t.timestamps
end
add_column :notification_settings, :push_flags, :integer, default: 0, null: false
add_push_settings_to_users
end
def add_push_settings_to_users
::User.find_in_batches do |users_batch|
users_batch.each do |user|
user_notification_setting = user.notification_settings.first
user_notification_setting.push_conversation_assignment = true
user_notification_setting.save!
end
end
end
end

View file

@ -273,9 +273,36 @@ ActiveRecord::Schema.define(version: 2020_04_29_082655) do
t.integer "email_flags", default: 0, null: false t.integer "email_flags", default: 0, null: false
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.integer "push_flags", default: 0, null: false
t.index ["account_id", "user_id"], name: "by_account_user", unique: true t.index ["account_id", "user_id"], name: "by_account_user", unique: true
end end
create_table "notification_subscriptions", force: :cascade do |t|
t.bigint "user_id", null: false
t.integer "subscription_type", null: false
t.jsonb "subscription_attributes", default: "{}", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["user_id"], name: "index_notification_subscriptions_on_user_id"
end
create_table "notifications", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "user_id", null: false
t.integer "notification_type", null: false
t.string "primary_actor_type", null: false
t.bigint "primary_actor_id", null: false
t.string "secondary_actor_type"
t.bigint "secondary_actor_id"
t.datetime "read_at"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["account_id"], name: "index_notifications_on_account_id"
t.index ["primary_actor_type", "primary_actor_id"], name: "uniq_primary_actor_per_account_notifications"
t.index ["secondary_actor_type", "secondary_actor_id"], name: "uniq_secondary_actor_per_account_notifications"
t.index ["user_id"], name: "index_notifications_on_user_id"
end
create_table "subscriptions", id: :serial, force: :cascade do |t| create_table "subscriptions", id: :serial, force: :cascade do |t|
t.string "pricing_version" t.string "pricing_version"
t.integer "account_id" t.integer "account_id"

View file

@ -56,10 +56,10 @@ RSpec.describe 'Contacts API', type: :request do
let(:valid_params) { { contact: { account_id: account.id } } } let(:valid_params) { { contact: { account_id: account.id } } }
context 'when it is an unauthenticated user' do context 'when it is an unauthenticated user' do
it 'creates the contact' do it 'returns unauthorized' do
expect { post "/api/v1/accounts/#{account.id}/contacts", params: valid_params }.to change(Contact, :count).by(1) expect { post "/api/v1/accounts/#{account.id}/contacts", params: valid_params }.to change(Contact, :count).by(0)
expect(response).to have_http_status(:success) expect(response).to have_http_status(:unauthorized)
end end
end end

View file

@ -116,7 +116,7 @@ RSpec.describe 'Conversation Messages API', type: :request do
as: :json as: :json
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
expect(JSON.parse(response.body, symbolize_names: true)[:meta][:contact_id]).to eq(conversation.contact_id) expect(JSON.parse(response.body, symbolize_names: true)[:meta][:contact][:id]).to eq(conversation.contact_id)
end end
end end
end end

View file

@ -42,7 +42,7 @@ RSpec.describe 'Notification Settings API', type: :request do
it 'updates the email related notification flags' do it 'updates the email related notification flags' do
put "/api/v1/accounts/#{account.id}/notification_settings", put "/api/v1/accounts/#{account.id}/notification_settings",
params: { notification_settings: { selected_email_flags: ['conversation_assignment'] } }, params: { notification_settings: { selected_email_flags: ['email_conversation_assignment'] } },
headers: agent.create_new_auth_token, headers: agent.create_new_auth_token,
as: :json as: :json
@ -51,7 +51,7 @@ RSpec.describe 'Notification Settings API', type: :request do
agent.reload agent.reload
expect(json_response['user_id']).to eq(agent.id) expect(json_response['user_id']).to eq(agent.id)
expect(json_response['account_id']).to eq(account.id) expect(json_response['account_id']).to eq(account.id)
expect(json_response['selected_email_flags']).to eq(['conversation_assignment']) expect(json_response['selected_email_flags']).to eq(['email_conversation_assignment'])
end end
end end
end end

View file

@ -0,0 +1,57 @@
require 'rails_helper'
RSpec.describe 'Notifications API', type: :request do
let(:account) { create(:account) }
describe 'GET /api/v1/accounts/{account.id}/notifications' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/notifications"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let!(:notification) { create(:notification, account: account, user: admin) }
it 'returns all notifications' do
get "/api/v1/accounts/#{account.id}/notifications",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.body).to include(notification.notification_type)
end
end
end
describe 'PATCH /api/v1/accounts/{account.id}/notifications/:id' do
let(:admin) { create(:user, account: account, role: :administrator) }
let!(:notification) { create(:notification, account: account, user: admin) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
put "/api/v1/accounts/#{account.id}/notifications/#{notification.id}",
params: { read_at: true }
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
it 'updates the notification read at' do
patch "/api/v1/accounts/#{account.id}/notifications/#{notification.id}",
headers: admin.create_new_auth_token,
params: { read_at: true },
as: :json
expect(response).to have_http_status(:success)
expect(notification.reload.read_at).not_to eq('')
end
end
end
end

View file

@ -0,0 +1,31 @@
require 'rails_helper'
RSpec.describe 'Notifications Subscriptions API', type: :request do
let(:account) { create(:account) }
describe 'POST /api/v1/notification_subscriptions' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post '/api/v1/notification_subscriptions'
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'creates a notification subscriptions' do
post '/api/v1/notification_subscriptions',
params: { notification_subscription: { subscription_type: 'browser_push', 'subscription_attributes': { test: 'test' } } },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response['subscription_type']).to eq('browser_push')
expect(json_response['subscription_attributes']).to eq({ 'test' => 'test' })
end
end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
FactoryBot.define do
factory :notification do
primary_actor { create(:conversation, account: account) }
notification_type { 'conversation_assignment' }
user
account
end
end

View file

@ -6,11 +6,6 @@ describe ActionCableListener do
let!(:inbox) { create(:inbox, account: account) } let!(:inbox) { create(:inbox, account: account) }
let!(:agent) { create(:user, account: account, role: :agent) } let!(:agent) { create(:user, account: account, role: :agent) }
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) } let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
let!(:message) do
create(:message, message_type: 'outgoing',
account: account, inbox: inbox, conversation: conversation)
end
let!(:event) { Events::Base.new(event_name, Time.zone.now, message: message) }
before do before do
create(:inbox_member, inbox: inbox, user: agent) create(:inbox_member, inbox: inbox, user: agent)
@ -18,13 +13,18 @@ describe ActionCableListener do
describe '#message_created' do describe '#message_created' do
let(:event_name) { :'message.created' } let(:event_name) { :'message.created' }
let!(:message) do
create(:message, message_type: 'outgoing',
account: account, inbox: inbox, conversation: conversation)
end
let!(:event) { Events::Base.new(event_name, Time.zone.now, message: message) }
it 'sends message to account admins, inbox agents and the contact' do it 'sends message to account admins, inbox agents and the contact' do
# HACK: to reload conversation inbox members
expect(conversation.inbox.reload.inbox_members.count).to eq(1)
expect(ActionCableBroadcastJob).to receive(:perform_later).with( expect(ActionCableBroadcastJob).to receive(:perform_later).with(
[agent.pubsub_token, admin.pubsub_token], 'message.created', message.push_event_data [agent.pubsub_token, admin.pubsub_token, conversation.contact.pubsub_token], 'message.created', message.push_event_data
)
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
[conversation.contact.pubsub_token], 'message.created', message.push_event_data
) )
listener.message_created(event) listener.message_created(event)
end end

View file

@ -1,5 +1,5 @@
require 'rails_helper' require 'rails_helper'
describe EmailNotificationListener do describe NotificationListener do
let(:listener) { described_class.instance } let(:listener) { described_class.instance }
let!(:account) { create(:account) } let!(:account) { create(:account) }
let!(:user) { create(:user, account: account) } let!(:user) { create(:user, account: account) }
@ -13,14 +13,14 @@ describe EmailNotificationListener do
before do before do
creation_mailer = double creation_mailer = double
allow(AgentNotifications::ConversationNotificationsMailer).to receive(:conversation_created).and_return(creation_mailer) allow(AgentNotifications::ConversationNotificationsMailer).to receive(:conversation_creation).and_return(creation_mailer)
allow(creation_mailer).to receive(:deliver_later).and_return(true) allow(creation_mailer).to receive(:deliver_later).and_return(true)
end end
context 'when conversation is created' do context 'when conversation is created' do
it 'sends email to inbox members who have notifications turned on' do it 'sends email to inbox members who have notifications turned on' do
notification_setting = agent_with_notification.notification_settings.first notification_setting = agent_with_notification.notification_settings.first
notification_setting.selected_email_flags = [:conversation_creation] notification_setting.selected_email_flags = [:email_conversation_creation]
notification_setting.save! notification_setting.save!
create(:inbox_member, user: agent_with_notification, inbox: inbox) create(:inbox_member, user: agent_with_notification, inbox: inbox)
@ -29,7 +29,7 @@ describe EmailNotificationListener do
event = Events::Base.new(event_name, Time.zone.now, conversation: conversation) event = Events::Base.new(event_name, Time.zone.now, conversation: conversation)
listener.conversation_created(event) listener.conversation_created(event)
expect(AgentNotifications::ConversationNotificationsMailer).to have_received(:conversation_created) expect(AgentNotifications::ConversationNotificationsMailer).to have_received(:conversation_creation)
.with(conversation, agent_with_notification) .with(conversation, agent_with_notification)
end end
@ -44,7 +44,7 @@ describe EmailNotificationListener do
event = Events::Base.new(event_name, Time.zone.now, conversation: conversation) event = Events::Base.new(event_name, Time.zone.now, conversation: conversation)
listener.conversation_created(event) listener.conversation_created(event)
expect(AgentNotifications::ConversationNotificationsMailer).not_to have_received(:conversation_created) expect(AgentNotifications::ConversationNotificationsMailer).not_to have_received(:conversation_creation)
.with(conversation, agent_with_out_notification) .with(conversation, agent_with_out_notification)
end end
end end

View file

@ -12,8 +12,8 @@ RSpec.describe AgentNotifications::ConversationNotificationsMailer, type: :maile
allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true) allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
end end
describe 'conversation_created' do describe 'conversation_creation' do
let(:mail) { described_class.conversation_created(conversation, agent).deliver_now } let(:mail) { described_class.conversation_creation(conversation, agent).deliver_now }
it 'renders the subject' do it 'renders the subject' do
expect(mail.subject).to eq("#{agent.name}, A new conversation [ID - #{conversation expect(mail.subject).to eq("#{agent.name}, A new conversation [ID - #{conversation
@ -25,8 +25,8 @@ RSpec.describe AgentNotifications::ConversationNotificationsMailer, type: :maile
end end
end end
describe 'conversation_assigned' do describe 'conversation_assignment' do
let(:mail) { described_class.conversation_assigned(conversation, agent).deliver_now } let(:mail) { described_class.conversation_assignment(conversation, agent).deliver_now }
it 'renders the subject' do it 'renders the subject' do
expect(mail.subject).to eq("#{agent.name}, A new conversation [ID - #{conversation.display_id}] has been assigned to you.") expect(mail.subject).to eq("#{agent.name}, A new conversation [ID - #{conversation.display_id}] has been assigned to you.")

View file

@ -9,8 +9,8 @@ RSpec.describe User do
it 'gets created with the right default settings' do it 'gets created with the right default settings' do
expect(account_user.user.notification_settings).not_to eq(nil) expect(account_user.user.notification_settings).not_to eq(nil)
expect(account_user.user.notification_settings.first.conversation_creation?).to eq(false) expect(account_user.user.notification_settings.first.email_conversation_creation?).to eq(false)
expect(account_user.user.notification_settings.first.conversation_assignment?).to eq(true) expect(account_user.user.notification_settings.first.email_conversation_assignment?).to eq(true)
end end
end end
end end

View file

@ -39,8 +39,6 @@ RSpec.describe Conversation, type: :model do
new_assignee new_assignee
allow(Rails.configuration.dispatcher).to receive(:dispatch) allow(Rails.configuration.dispatcher).to receive(:dispatch)
allow(AgentNotifications::ConversationNotificationsMailer).to receive(:conversation_assigned).and_return(assignment_mailer)
allow(assignment_mailer).to receive(:deliver_later)
Current.user = old_assignee Current.user = old_assignee
conversation.update( conversation.update(
@ -61,11 +59,6 @@ RSpec.describe Conversation, type: :model do
.with(described_class::CONVERSATION_LOCK_TOGGLE, kind_of(Time), conversation: conversation) .with(described_class::CONVERSATION_LOCK_TOGGLE, kind_of(Time), conversation: conversation)
expect(Rails.configuration.dispatcher).to have_received(:dispatch) expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(described_class::ASSIGNEE_CHANGED, kind_of(Time), conversation: conversation) .with(described_class::ASSIGNEE_CHANGED, kind_of(Time), conversation: conversation)
# send_email_notification_to_assignee
expect(AgentNotifications::ConversationNotificationsMailer).to have_received(:conversation_assigned).with(conversation, new_assignee)
expect(assignment_mailer).to have_received(:deliver_later) if ENV.fetch('SMTP_ADDRESS', nil).present?
end end
it 'creates conversation activities' do it 'creates conversation activities' do
@ -129,15 +122,28 @@ RSpec.describe Conversation, type: :model do
expect(conversation.reload.assignee).to eq(agent) expect(conversation.reload.assignee).to eq(agent)
end end
it 'send assignment mailer' do
allow(AgentNotifications::ConversationNotificationsMailer).to receive(:conversation_assignment).and_return(assignment_mailer)
allow(assignment_mailer).to receive(:deliver_later)
Current.user = conversation.assignee
expect(update_assignee).to eq(true)
# send_email_notification_to_assignee
expect(AgentNotifications::ConversationNotificationsMailer).to have_received(:conversation_assignment).with(conversation, agent)
expect(assignment_mailer).to have_received(:deliver_later) if ENV.fetch('SMTP_ADDRESS', nil).present?
end
it 'does not send assignment mailer if notification setting is turned off' do it 'does not send assignment mailer if notification setting is turned off' do
allow(AgentNotifications::ConversationNotificationsMailer).to receive(:conversation_assigned).and_return(assignment_mailer) allow(AgentNotifications::ConversationNotificationsMailer).to receive(:conversation_assignment).and_return(assignment_mailer)
notification_setting = agent.notification_settings.first notification_setting = agent.notification_settings.first
notification_setting.unselect_all_email_flags notification_setting.unselect_all_email_flags
notification_setting.save! notification_setting.save!
expect(update_assignee).to eq(true) expect(update_assignee).to eq(true)
expect(AgentNotifications::ConversationNotificationsMailer).not_to have_received(:conversation_assigned).with(conversation, agent) expect(AgentNotifications::ConversationNotificationsMailer).not_to have_received(:conversation_assignment).with(conversation, agent)
end end
end end