feat: Tag agents in a private note (#1688)

Co-authored-by: Sojan <sojan@pepalo.com>
This commit is contained in:
Pranav Raj S 2021-01-27 00:04:11 +05:30 committed by GitHub
parent b894b13e14
commit b93388b330
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 424 additions and 93 deletions

View file

@ -1,55 +1,113 @@
<template> <template>
<div ref="editor" class="editor-root"></div> <div class="editor-root">
<tag-agents
v-if="showUserMentions && isPrivate"
:search-key="mentionSearchKey"
@click="insertMentionNode"
/>
<div ref="editor"></div>
</div>
</template> </template>
<script> <script>
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view'; import { EditorView } from 'prosemirror-view';
import { import { defaultMarkdownSerializer } from 'prosemirror-markdown';
schema,
defaultMarkdownParser,
defaultMarkdownSerializer,
} from 'prosemirror-markdown';
import { wootWriterSetup } from '@chatwoot/prosemirror-schema';
const TYPING_INDICATOR_IDLE_TIME = 4000; const TYPING_INDICATOR_IDLE_TIME = 4000;
const createState = (content, placeholder) => import {
EditorState.create({ addMentionsToMarkdownSerializer,
doc: defaultMarkdownParser.parse(content), addMentionsToMarkdownParser,
plugins: wootWriterSetup({ schema, placeholder }), schemaWithMentions,
} from '@chatwoot/prosemirror-schema/src/mentions/schema';
import {
suggestionsPlugin,
triggerCharacters,
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
import TagAgents from '../conversation/TagAgents.vue';
import { EditorState } from 'prosemirror-state';
import { defaultMarkdownParser } from 'prosemirror-markdown';
import { wootWriterSetup } from '@chatwoot/prosemirror-schema';
const createState = (content, placeholder, plugins = []) => {
return EditorState.create({
doc: addMentionsToMarkdownParser(defaultMarkdownParser).parse(content),
plugins: wootWriterSetup({
schema: schemaWithMentions,
placeholder,
plugins,
}),
}); });
};
export default { export default {
name: 'WootMessageEditor', name: 'WootMessageEditor',
components: { TagAgents },
props: { props: {
value: { type: String, default: '' }, value: { type: String, default: '' },
placeholder: { type: String, default: '' }, placeholder: { type: String, default: '' },
isPrivate: { type: Boolean, default: false },
}, },
data() { data() {
return { return {
lastValue: null, lastValue: null,
showUserMentions: false,
mentionSearchKey: '',
editorView: null,
range: null,
}; };
}, },
computed: {
plugins() {
return [
suggestionsPlugin({
matcher: triggerCharacters('@'),
onEnter: args => {
this.showUserMentions = true;
this.range = args.range;
this.editorView = args.view;
return false;
},
onChange: args => {
this.editorView = args.view;
this.range = args.range;
this.mentionSearchKey = args.text.replace('@', '');
return false;
},
onExit: () => {
this.mentionSearchKey = '';
this.showUserMentions = false;
this.editorView = null;
return false;
},
onKeyDown: ({ event }) => {
return event.keyCode === 13 && this.showUserMentions;
},
}),
];
},
},
watch: { watch: {
showUserMentions(updatedValue) {
this.$emit('toggle-user-mention', this.isPrivate && updatedValue);
},
value(newValue) { value(newValue) {
if (newValue !== this.lastValue) { if (newValue !== this.lastValue) {
this.state = createState(newValue, this.placeholder); this.state = createState(newValue, this.placeholder, this.plugins);
this.view.updateState(this.state); this.view.updateState(this.state);
} }
}, },
}, },
created() { created() {
this.state = createState(this.value, this.placeholder); this.state = createState(this.value, this.placeholder, this.plugins);
}, },
mounted() { mounted() {
this.view = new EditorView(this.$refs.editor, { this.view = new EditorView(this.$refs.editor, {
state: this.state, state: this.state,
dispatchTransaction: tx => { dispatchTransaction: tx => {
this.state = this.state.apply(tx); this.state = this.state.apply(tx);
this.view.updateState(this.state); this.emitOnChange();
this.lastValue = defaultMarkdownSerializer.serialize(this.state.doc);
this.$emit('input', this.lastValue);
}, },
handleDOMEvents: { handleDOMEvents: {
keyup: () => { keyup: () => {
@ -65,6 +123,33 @@ export default {
}); });
}, },
methods: { methods: {
insertMentionNode(mentionItem) {
if (!this.view) {
return null;
}
const node = this.view.state.schema.nodes.mention.create({
userId: mentionItem.key,
userFullName: mentionItem.label,
});
const tr = this.view.state.tr.replaceWith(
this.range.from,
this.range.to,
node
);
this.state = this.view.state.apply(tr);
return this.emitOnChange();
},
emitOnChange() {
this.view.updateState(this.state);
this.lastValue = addMentionsToMarkdownSerializer(
defaultMarkdownSerializer
).serialize(this.state.doc);
this.$emit('input', this.lastValue);
},
hideMentions() {
this.showUserMentions = false;
},
resetTyping() { resetTyping() {
this.$emit('typing-off'); this.$emit('typing-off');
this.idleTimer = null; this.idleTimer = null;
@ -115,4 +200,14 @@ export default {
max-height: 12rem; max-height: 12rem;
overflow: auto; overflow: auto;
} }
.is-private {
.prosemirror-mention-node {
font-weight: var(--font-weight-medium);
background: var(--s-300);
border-radius: var(--border-radius-small);
padding: 1px 4px;
color: var(--white);
}
}
</style> </style>

View file

@ -22,7 +22,7 @@
</file-upload> </file-upload>
</button> </button>
<button <button
v-if="enableRichEditor" v-if="enableRichEditor && !isOnPrivateNote"
class="button clear button--emoji" class="button clear button--emoji"
:title="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')" :title="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
@click="toggleFormatMode" @click="toggleFormatMode"
@ -102,6 +102,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isOnPrivateNote: {
type: Boolean,
default: false,
},
enableRichEditor: { enableRichEditor: {
type: Boolean, type: Boolean,
default: false, default: false,

View file

@ -8,9 +8,9 @@
/> />
<div class="reply-box__top"> <div class="reply-box__top">
<canned-response <canned-response
v-if="showCannedResponsesList" v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideCannedResponse" v-on-clickaway="hideMentions"
:search-key="cannedResponseSearchKey" :search-key="mentionSearchKey"
@click="replaceText" @click="replaceText"
/> />
<emoji-input <emoji-input
@ -19,7 +19,7 @@
:on-click="emojiOnClick" :on-click="emojiOnClick"
/> />
<resizable-text-area <resizable-text-area
v-if="!isFormatMode" v-if="!showRichContentEditor"
ref="messageInput" ref="messageInput"
v-model="message" v-model="message"
class="input" class="input"
@ -34,12 +34,14 @@
v-else v-else
v-model="message" v-model="message"
class="input" class="input"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder" :placeholder="messagePlaceHolder"
:min-height="4" :min-height="4"
@typing-off="onTypingOff" @typing-off="onTypingOff"
@typing-on="onTypingOn" @typing-on="onTypingOn"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
@toggle-user-mention="toggleUserMention"
/> />
</div> </div>
<div v-if="hasAttachments" class="attachment-preview-box"> <div v-if="hasAttachments" class="attachment-preview-box">
@ -58,7 +60,8 @@
:on-send="sendMessage" :on-send="sendMessage"
:is-send-disabled="isReplyButtonDisabled" :is-send-disabled="isReplyButtonDisabled"
:set-format-mode="setFormatMode" :set-format-mode="setFormatMode"
:is-format-mode="isFormatMode" :is-on-private-note="isOnPrivateNote"
:is-format-mode="showRichContentEditor"
:enable-rich-editor="isRichEditorEnabled" :enable-rich-editor="isRichEditorEnabled"
:enter-to-send-enabled="enterToSendEnabled" :enter-to-send-enabled="enterToSendEnabled"
@toggleEnterToSend="toggleEnterToSend" @toggleEnterToSend="toggleEnterToSend"
@ -108,15 +111,23 @@ export default {
message: '', message: '',
isFocused: false, isFocused: false,
showEmojiPicker: false, showEmojiPicker: false,
showCannedResponsesList: false, showMentions: false,
attachedFiles: [], attachedFiles: [],
isUploading: false, isUploading: false,
replyType: REPLY_EDITOR_MODES.REPLY, replyType: REPLY_EDITOR_MODES.REPLY,
isFormatMode: false, isFormatMode: false,
cannedResponseSearchKey: '', mentionSearchKey: '',
hasUserMention: false,
hasSlashCommand: false,
}; };
}, },
computed: { computed: {
showRichContentEditor() {
if (this.isOnPrivateNote) {
return true;
}
return this.isFormatMode;
},
...mapGetters({ ...mapGetters({
currentChat: 'getSelectedChat', currentChat: 'getSelectedChat',
uiSettings: 'getUISettings', uiSettings: 'getUISettings',
@ -126,7 +137,7 @@ export default {
}, },
isPrivate() { isPrivate() {
if (this.currentChat.can_reply) { if (this.currentChat.can_reply) {
return this.replyType === REPLY_EDITOR_MODES.NOTE; return this.isOnPrivateNote;
} }
return true; return true;
}, },
@ -208,18 +219,17 @@ export default {
}, },
isRichEditorEnabled() { isRichEditorEnabled() {
return ( return (
this.isAWebWidgetInbox || this.isAWebWidgetInbox || this.isAnEmailChannel || this.isOnPrivateNote
this.isAnEmailChannel ||
this.replyType === REPLY_EDITOR_MODES.NOTE
); );
}, },
isOnPrivateNote() {
return this.replyType === REPLY_EDITOR_MODES.NOTE;
},
}, },
watch: { watch: {
currentChat(conversation) { currentChat(conversation) {
const { can_reply: canReply } = conversation; const { can_reply: canReply } = conversation;
const isUserReplyingOnPrivate = if (this.isOnPrivateNote) {
this.replyType === REPLY_EDITOR_MODES.NOTE;
if (isUserReplyingOnPrivate) {
return; return;
} }
@ -230,18 +240,15 @@ export default {
} }
}, },
message(updatedMessage) { message(updatedMessage) {
const isSlashCommand = updatedMessage[0] === '/'; this.hasSlashCommand = updatedMessage[0] === '/';
const hasNextWord = updatedMessage.includes(' '); const hasNextWord = updatedMessage.includes(' ');
const isShortCodeActive = isSlashCommand && !hasNextWord; const isShortCodeActive = this.hasSlashCommand && !hasNextWord;
if (isShortCodeActive) { if (isShortCodeActive) {
this.cannedResponseSearchKey = updatedMessage.substr( this.mentionSearchKey = updatedMessage.substr(1, updatedMessage.length);
1, this.showMentions = true;
updatedMessage.length
);
this.showCannedResponsesList = true;
} else { } else {
this.cannedResponseSearchKey = ''; this.mentionSearchKey = '';
this.showCannedResponsesList = false; this.showMentions = false;
} }
}, },
}, },
@ -252,13 +259,19 @@ export default {
document.removeEventListener('keydown', this.handleKeyEvents); document.removeEventListener('keydown', this.handleKeyEvents);
}, },
methods: { methods: {
toggleUserMention(currentMentionState) {
this.hasUserMention = currentMentionState;
},
handleKeyEvents(e) { handleKeyEvents(e) {
if (isEscape(e)) { if (isEscape(e)) {
this.hideEmojiPicker(); this.hideEmojiPicker();
this.hideCannedResponse(); this.hideMentions();
} else if (isEnter(e)) { } else if (isEnter(e)) {
const hasSendOnEnterEnabled = const hasSendOnEnterEnabled =
(this.isFormatMode && this.enterToSendEnabled) || !this.isFormatMode; (this.showRichContentEditor &&
this.enterToSendEnabled &&
!this.hasUserMention) ||
!this.showRichContentEditor;
const shouldSendMessage = const shouldSendMessage =
hasSendOnEnterEnabled && !hasPressedShift(e) && this.isFocused; hasSendOnEnterEnabled && !hasPressedShift(e) && this.isFocused;
if (shouldSendMessage) { if (shouldSendMessage) {
@ -279,7 +292,7 @@ export default {
if (this.isReplyButtonDisabled) { if (this.isReplyButtonDisabled) {
return; return;
} }
if (!this.showCannedResponsesList) { if (!this.showMentions) {
const newMessage = this.message; const newMessage = this.message;
const messagePayload = this.getMessagePayload(newMessage); const messagePayload = this.getMessagePayload(newMessage);
this.clearMessage(); this.clearMessage();
@ -318,8 +331,8 @@ export default {
this.toggleEmojiPicker(); this.toggleEmojiPicker();
} }
}, },
hideCannedResponse() { hideMentions() {
this.showCannedResponsesList = false; this.showMentions = false;
}, },
onTypingOn() { onTypingOn() {
this.toggleTyping('on'); this.toggleTyping('on');

View file

@ -0,0 +1,49 @@
<template>
<mention-box :items="items" @mention-select="handleMentionClick" />
</template>
<script>
import { mapGetters } from 'vuex';
import MentionBox from '../mentions/MentionBox.vue';
export default {
components: { MentionBox },
props: {
searchKey: {
type: String,
default: '',
},
},
computed: {
...mapGetters({
agents: 'agents/getVerifiedAgents',
}),
items() {
if (!this.searchKey) {
return this.agents.map(agent => ({
label: agent.name,
key: agent.id,
description: agent.email,
}));
}
return this.agents
.filter(agent =>
agent.name
.toLocaleLowerCase()
.includes(this.searchKey.toLocaleLowerCase())
)
.map(agent => ({
label: agent.name,
key: agent.id,
description: agent.email,
}));
},
},
methods: {
handleMentionClick(item = {}) {
this.$emit('click', item);
},
},
};
</script>

View file

@ -68,7 +68,8 @@
"TYPE_LABEL": { "TYPE_LABEL": {
"conversation_creation": "New conversation", "conversation_creation": "New conversation",
"conversation_assignment": "Conversation Assigned", "conversation_assignment": "Conversation Assigned",
"assigned_conversation_new_message": "New Message" "assigned_conversation_new_message": "New Message",
"conversation_mention": "Mention"
} }
} }
} }

View file

@ -27,6 +27,7 @@
"NOTE": "Update your email notification preferences here", "NOTE": "Update your email notification preferences here",
"CONVERSATION_ASSIGNMENT": "Send email notifications when a conversation is assigned to me", "CONVERSATION_ASSIGNMENT": "Send email notifications when a conversation is assigned to me",
"CONVERSATION_CREATION": "Send email notifications when a new conversation is created", "CONVERSATION_CREATION": "Send email notifications when a new conversation is created",
"CONVERSATION_MENTION": "Send email notifications when you are mentioned in a conversation",
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in an assigned conversation" "ASSIGNED_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in an assigned conversation"
}, },
"API": { "API": {
@ -38,6 +39,7 @@
"NOTE": "Update your push notification preferences here", "NOTE": "Update your push notification preferences here",
"CONVERSATION_ASSIGNMENT": "Send push notifications when a conversation is assigned to me", "CONVERSATION_ASSIGNMENT": "Send push notifications when a conversation is assigned to me",
"CONVERSATION_CREATION": "Send push notifications when a new conversation is created", "CONVERSATION_CREATION": "Send push notifications when a new conversation is created",
"CONVERSATION_MENTION": "Send push notifications when you are mentioned in a conversation",
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "Send push notifications when a new message is created in an assigned conversation", "ASSIGNED_CONVERSATION_NEW_MESSAGE": "Send push notifications when a new message is created in an assigned conversation",
"HAS_ENABLED_PUSH": "You have enabled push for this browser.", "HAS_ENABLED_PUSH": "You have enabled push for this browser.",
"REQUEST_PUSH": "Enable push notifications" "REQUEST_PUSH": "Enable push notifications"

View file

@ -17,27 +17,23 @@
@click="() => onClickNotification(notificationItem)" @click="() => onClickNotification(notificationItem)"
> >
<td> <td>
<div class="notification--thumbnail"> <div class="">
<thumbnail <h5 class="notification--title">
:src="notificationItem.primary_actor.meta.sender.thumbnail" {{
size="36px" `#${
:username="notificationItem.primary_actor.meta.sender.name" notificationItem.primary_actor
:status=" ? notificationItem.primary_actor.id
notificationItem.primary_actor.meta.sender.availability_status : 'deleted'
" }`
/> }}
<div> </h5>
<h4 class="notification--name"> <span class="notification--message-title">
{{ `#${notificationItem.id}` }} {{ notificationItem.push_message_title }}
</h4> </span>
<p class="notification--title">
{{ notificationItem.push_message_title }}
</p>
</div>
</div> </div>
</td> </td>
<td> <td class="text-right">
<span class="label"> <span class="notification--type">
{{ {{
$t( $t(
`NOTIFICATIONS_PAGE.TYPE_LABEL.${notificationItem.notification_type}` `NOTIFICATIONS_PAGE.TYPE_LABEL.${notificationItem.notification_type}`
@ -45,8 +41,18 @@
}} }}
</span> </span>
</td> </td>
<td> <td class="thumbnail--column">
{{ dynamicTime(notificationItem.created_at) }} <thumbnail
v-if="notificationItem.primary_actor.meta.assignee"
:src="notificationItem.primary_actor.meta.assignee.thumbnail"
size="36px"
:username="notificationItem.primary_actor.meta.assignee.name"
/>
</td>
<td class="text-right timestamp--column">
<span class="notification--created-at">
{{ dynamicTime(notificationItem.created_at) }}
</span>
</td> </td>
<td> <td>
<div <div
@ -118,13 +124,8 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
@import '~dashboard/assets/scss/mixins'; @import '~dashboard/assets/scss/mixins';
.notification--name {
font-size: var(--font-size-small);
margin-bottom: 0;
}
.notification--title { .notification--title {
font-size: var(--font-size-mini); font-size: var(--font-size-small);
margin: 0; margin: 0;
} }
@ -132,7 +133,7 @@ export default {
@include scroll-on-hover; @include scroll-on-hover;
flex: 1 1; flex: 1 1;
height: 100%; height: 100%;
padding: var(--space-normal); padding: var(--space-large) var(--space-larger);
} }
.notifications-table { .notifications-table {
@ -153,14 +154,10 @@ export default {
padding-left: var(--space-medium); padding-left: var(--space-medium);
} }
} }
}
}
.notification--thumbnail {
display: flex;
align-items: center;
.user-thumbnail-box { &:last-child {
margin-right: var(--space-small); border-bottom: 0;
}
} }
} }
} }
@ -179,4 +176,25 @@ export default {
border-radius: 50%; border-radius: 50%;
background: var(--color-woot); background: var(--color-woot);
} }
.notification--created-at {
color: var(--s-700);
font-size: var(--font-size-mini);
}
.notification--type {
font-size: var(--font-size-mini);
}
.thumbnail--column {
width: 5.2rem;
}
.timestamp--column {
width: 12rem;
}
.notification--message-title {
color: var(--s-700);
}
</style> </style>

View file

@ -44,6 +44,23 @@
</label> </label>
</div> </div>
<div>
<input
v-model="selectedEmailFlags"
class="notification--checkbox"
type="checkbox"
value="email_conversation_mention"
@input="handleEmailInput"
/>
<label for="conversation_mention">
{{
$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.CONVERSATION_MENTION'
)
}}
</label>
</div>
<div> <div>
<input <input
v-model="selectedEmailFlags" v-model="selectedEmailFlags"
@ -123,6 +140,23 @@
</label> </label>
</div> </div>
<div>
<input
v-model="selectedPushFlags"
class="notification--checkbox"
type="checkbox"
value="push_conversation_mention"
@input="handlePushInput"
/>
<label for="conversation_mention">
{{
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.CONVERSATION_MENTION'
)
}}
</label>
</div>
<div> <div>
<input <input
v-model="selectedPushFlags" v-model="selectedPushFlags"

View file

@ -23,7 +23,7 @@ export const mutations = {
Vue.set($state.meta, 'unreadCount', unreadCount); Vue.set($state.meta, 'unreadCount', unreadCount);
}, },
[types.SET_NOTIFICATIONS_UNREAD_COUNT]: ($state, count) => { [types.SET_NOTIFICATIONS_UNREAD_COUNT]: ($state, count) => {
Vue.set($state.meta, 'unreadCount', count); Vue.set($state.meta, 'unreadCount', count < 0 ? 0 : count);
}, },
[types.SET_NOTIFICATIONS]: ($state, data) => { [types.SET_NOTIFICATIONS]: ($state, data) => {
data.forEach(notification => { data.forEach(notification => {

View file

@ -40,6 +40,12 @@ describe('#mutations', () => {
mutations[types.SET_NOTIFICATIONS_UNREAD_COUNT](state, 3); mutations[types.SET_NOTIFICATIONS_UNREAD_COUNT](state, 3);
expect(state.meta).toEqual({ unreadCount: 3 }); expect(state.meta).toEqual({ unreadCount: 3 });
}); });
it('set notifications unread count to 0 if invalid', () => {
const state = { meta: { unreadCount: 4 } };
mutations[types.SET_NOTIFICATIONS_UNREAD_COUNT](state, -1);
expect(state.meta).toEqual({ unreadCount: 0 });
});
}); });
describe('#SET_NOTIFICATIONS', () => { describe('#SET_NOTIFICATIONS', () => {

View file

@ -10,9 +10,11 @@ const TWITTER_HASH_REGEX = /(^|\s)#(\w+)/g;
const TWITTER_HASH_REPLACEMENT = const TWITTER_HASH_REPLACEMENT =
'$1<a href="https://twitter.com/hashtag/$2" target="_blank" rel="noreferrer nofollow noopener">#$2</a>'; '$1<a href="https://twitter.com/hashtag/$2" target="_blank" rel="noreferrer nofollow noopener">#$2</a>';
const USER_MENTIONS_REGEX = /mention:\/\/(user|team)\/(\d+)\/([\w\s]+)/gm;
class MessageFormatter { class MessageFormatter {
constructor(message, isATweet = false) { constructor(message, isATweet = false) {
this.message = DOMPurify.sanitize(escapeHtml(message) || ''); this.message = DOMPurify.sanitize(escapeHtml(message || ''));
this.isATweet = isATweet; this.isATweet = isATweet;
this.marked = marked; this.marked = marked;
@ -21,6 +23,10 @@ class MessageFormatter {
return `<strong>${text}</strong>`; return `<strong>${text}</strong>`;
}, },
link(url, title, text) { link(url, title, text) {
const mentionRegex = new RegExp(USER_MENTIONS_REGEX);
if (url.match(mentionRegex)) {
return `<span class="prosemirror-mention-node">${text}</span>`;
}
return `<a rel="noreferrer noopener nofollow" href="${url}" class="link" title="${title || return `<a rel="noreferrer noopener nofollow" href="${url}" class="link" title="${title ||
''}" target="_blank">${text}</a>`; ''}" target="_blank">${text}</a>`;
}, },

View file

@ -31,6 +31,8 @@ class NotificationListener < BaseListener
message, account = extract_message_and_account(event) message, account = extract_message_and_account(event)
conversation = message.conversation conversation = message.conversation
generate_notifications_for_mentions(message, account)
# only want to notify agents about customer messages # only want to notify agents about customer messages
return unless message.incoming? return unless message.incoming?
return unless conversation.assignee return unless conversation.assignee
@ -42,4 +44,28 @@ class NotificationListener < BaseListener
primary_actor: conversation primary_actor: conversation
).perform ).perform
end end
private
def get_valid_mentioned_ids(mentioned_ids, inbox)
valid_mentionable_ids = inbox.account.administrators.map(&:id) + inbox.members.map(&:id)
# Intersection of ids
mentioned_ids & valid_mentionable_ids.uniq.map(&:to_s)
end
def generate_notifications_for_mentions(message, account)
return unless message.private?
mentioned_ids = message.content.scan(%r{\(mention://(user|team)/(\d+)/([\w\s]+)\)}).map(&:second).uniq
return if mentioned_ids.blank?
get_valid_mentioned_ids(mentioned_ids, message.inbox).each do |user_id|
NotificationBuilder.new(
notification_type: 'conversation_mention',
user: User.find(user_id),
account: account,
primary_actor: message
).perform
end
end
end end

View file

@ -19,6 +19,16 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer
send_mail_with_liquid(to: @agent.email, subject: subject) and return send_mail_with_liquid(to: @agent.email, subject: subject) and return
end end
def conversation_mention(message, agent)
return unless smtp_config_set_or_development?
@agent = agent
@conversation = message.conversation
subject = "#{@agent.available_name}, You have been mentioned in 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
end
def assigned_conversation_new_message(conversation, agent) def assigned_conversation_new_message(conversation, agent)
return unless smtp_config_set_or_development? return unless smtp_config_set_or_development?
# Don't spam with email notifications if agent is online # Don't spam with email notifications if agent is online

View file

@ -32,7 +32,8 @@ class Notification < ApplicationRecord
NOTIFICATION_TYPES = { NOTIFICATION_TYPES = {
conversation_creation: 1, conversation_creation: 1,
conversation_assignment: 2, conversation_assignment: 2,
assigned_conversation_new_message: 3 assigned_conversation_new_message: 3,
conversation_mention: 4
}.freeze }.freeze
enum notification_type: NOTIFICATION_TYPES enum notification_type: NOTIFICATION_TYPES
@ -67,6 +68,10 @@ class Notification < ApplicationRecord
return "New message in your assigned conversation [ID -#{primary_actor.display_id}]." if notification_type == 'assigned_conversation_new_message' return "New message in your assigned conversation [ID -#{primary_actor.display_id}]." if notification_type == 'assigned_conversation_new_message'
if notification_type == 'conversation_mention'
return "You have been mentioned in conversation [ID -#{primary_actor.conversation.display_id}] by #{secondary_actor.name}"
end
'' ''
end end

View file

@ -10,9 +10,16 @@ json.data do
json.id notification.id json.id notification.id
json.notification_type notification.notification_type json.notification_type notification.notification_type
json.push_message_title notification.push_message_title json.push_message_title notification.push_message_title
json.primary_actor_type notification.primary_actor_type # TODO: front end assumes primary actor to be conversation. should fix in future
json.primary_actor_id notification.primary_actor_id if notification.notification_type == 'conversation_mention'
json.primary_actor notification.primary_actor&.push_event_data json.primary_actor_type 'Conversation'
json.primary_actor_id notification.primary_actor.conversation_id
json.primary_actor notification.primary_actor&.conversation&.push_event_data
else
json.primary_actor_type notification.primary_actor_type
json.primary_actor_id notification.primary_actor_id
json.primary_actor notification.primary_actor&.push_event_data
end
json.read_at notification.read_at json.read_at notification.read_at
json.secondary_actor notification.secondary_actor&.push_event_data json.secondary_actor notification.secondary_actor&.push_event_data
json.user notification.user&.push_event_data json.user notification.user&.push_event_data

View file

@ -0,0 +1,8 @@
<p>Hi {{user.available_name}}</p>
<p>Time to save the world. You have been mentioned in a conversation</p>
<p>
Click <a href="{{ action_url }}">here</a> to get cracking.
</p>

View file

@ -12,7 +12,7 @@
"start:dev": "foreman start -f ./Procfile.dev" "start:dev": "foreman start -f ./Procfile.dev"
}, },
"dependencies": { "dependencies": {
"@chatwoot/prosemirror-schema": "https://github.com/chatwoot/prosemirror-schema.git#main", "@chatwoot/prosemirror-schema": "https://github.com/chatwoot/prosemirror-schema.git#45e4efcc150e6674be02bc524061373b39cfed40",
"@rails/actioncable": "^6.0.0", "@rails/actioncable": "^6.0.0",
"@rails/webpacker": "^5.2.0", "@rails/webpacker": "^5.2.0",
"axios": "^0.21.1", "axios": "^0.21.1",

View file

@ -43,4 +43,37 @@ describe NotificationListener do
end end
end end
end end
describe 'message_created' do
let(:event_name) { :'message.created' }
context 'when message contains mention' do
it 'creates notifications for inbox member who was mentioned' do
notification_setting = agent_with_notification.notification_settings.find_by(account_id: account.id)
notification_setting.selected_email_flags = [:email_conversation_mention]
notification_setting.selected_push_flags = []
notification_setting.save!
builder = double
allow(NotificationBuilder).to receive(:new).and_return(builder)
allow(builder).to receive(:perform)
create(:inbox_member, user: agent_with_notification, inbox: inbox)
create(:inbox_member, user: agent_with_out_notification, inbox: inbox)
conversation.reload
message = build(:message, conversation: conversation, account: account,
content: "hi [#{agent_with_notification.name}](mention://user/#{agent_with_notification.id}/\
#{agent_with_notification.name})", private: true)
event = Events::Base.new(event_name, Time.zone.now, message: message)
listener.message_created(event)
expect(NotificationBuilder).to have_received(:new).with(notification_type: 'conversation_mention',
user: agent_with_notification,
account: account,
primary_actor: message)
end
end
end
end end

View file

@ -4,8 +4,9 @@ require 'rails_helper'
RSpec.describe AgentNotifications::ConversationNotificationsMailer, type: :mailer do RSpec.describe AgentNotifications::ConversationNotificationsMailer, type: :mailer do
let(:class_instance) { described_class.new } let(:class_instance) { described_class.new }
let(:agent) { create(:user, email: 'agent1@example.com') } let!(:account) { create(:account) }
let(:conversation) { create(:conversation, assignee: agent) } let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
let(:conversation) { create(:conversation, assignee: agent, account: account) }
before do before do
allow(described_class).to receive(:new).and_return(class_instance) allow(described_class).to receive(:new).and_return(class_instance)
@ -37,6 +38,19 @@ RSpec.describe AgentNotifications::ConversationNotificationsMailer, type: :maile
end end
end end
describe 'conversation_mention' do
let(:message) { create(:message, conversation: conversation, account: account) }
let(:mail) { described_class.conversation_mention(message, agent).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq("#{agent.available_name}, You have been mentioned in conversation [ID - #{conversation.display_id}]")
end
it 'renders the receiver email' do
expect(mail.to).to eq([agent.email])
end
end
describe 'assigned_conversation_new_message' do describe 'assigned_conversation_new_message' do
let(:mail) { described_class.assigned_conversation_new_message(conversation, agent).deliver_now } let(:mail) { described_class.assigned_conversation_new_message(conversation, agent).deliver_now }

View file

@ -857,9 +857,9 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@chatwoot/prosemirror-schema@https://github.com/chatwoot/prosemirror-schema.git#main": "@chatwoot/prosemirror-schema@https://github.com/chatwoot/prosemirror-schema.git#45e4efcc150e6674be02bc524061373b39cfed40":
version "1.0.0" version "1.0.0"
resolved "https://github.com/chatwoot/prosemirror-schema.git#0a5bb8130df9591faa9c997b1371c5b7c06af691" resolved "https://github.com/chatwoot/prosemirror-schema.git#45e4efcc150e6674be02bc524061373b39cfed40"
dependencies: dependencies:
prosemirror-commands "^1.1.4" prosemirror-commands "^1.1.4"
prosemirror-dropcursor "^1.3.2" prosemirror-dropcursor "^1.3.2"