feat: Tag agents in a private note (#1688)
Co-authored-by: Sojan <sojan@pepalo.com>
This commit is contained in:
parent
b894b13e14
commit
b93388b330
20 changed files with 424 additions and 93 deletions
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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>
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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}` }}
|
|
||||||
</h4>
|
|
||||||
<p class="notification--title">
|
|
||||||
{{ notificationItem.push_message_title }}
|
{{ notificationItem.push_message_title }}
|
||||||
</p>
|
</span>
|
||||||
</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">
|
||||||
|
<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) }}
|
{{ 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>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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>`;
|
||||||
},
|
},
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
# TODO: front end assumes primary actor to be conversation. should fix in future
|
||||||
|
if notification.notification_type == 'conversation_mention'
|
||||||
|
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_type notification.primary_actor_type
|
||||||
json.primary_actor_id notification.primary_actor_id
|
json.primary_actor_id notification.primary_actor_id
|
||||||
json.primary_actor notification.primary_actor&.push_event_data
|
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
|
||||||
|
|
|
@ -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>
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue