feat: Adds support for selecting emojis using the keyboard (#10055)

This commit is contained in:
Sivin Varghese
2024-09-04 11:32:54 +05:30
committed by GitHub
parent 3a0e68030a
commit a3732c8f51
10 changed files with 388 additions and 89 deletions

View File

@@ -17,6 +17,8 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
import TagAgents from '../conversation/TagAgents.vue'; import TagAgents from '../conversation/TagAgents.vue';
import CannedResponse from '../conversation/CannedResponse.vue'; import CannedResponse from '../conversation/CannedResponse.vue';
import VariableList from '../conversation/VariableList.vue'; import VariableList from '../conversation/VariableList.vue';
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
import { import {
appendSignature, appendSignature,
removeSignature, removeSignature,
@@ -24,6 +26,7 @@ import {
scrollCursorIntoView, scrollCursorIntoView,
findNodeToInsertImage, findNodeToInsertImage,
setURLWithQueryAndSize, setURLWithQueryAndSize,
getContentNode,
} from 'dashboard/helper/editorHelper'; } from 'dashboard/helper/editorHelper';
const TYPING_INDICATOR_IDLE_TIME = 4000; const TYPING_INDICATOR_IDLE_TIME = 4000;
@@ -35,10 +38,8 @@ import {
} from 'shared/helpers/KeyboardHelpers'; } from 'shared/helpers/KeyboardHelpers';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import {
replaceVariablesInMessage, import { createTypingIndicator } from '@chatwoot/utils';
createTypingIndicator,
} from '@chatwoot/utils';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events'; import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper'; import { uploadFile } from 'dashboard/helper/uploadHelper';
@@ -71,7 +72,12 @@ const createState = (
export default { export default {
name: 'WootMessageEditor', name: 'WootMessageEditor',
components: { TagAgents, CannedResponse, VariableList }, components: {
TagAgents,
CannedResponse,
VariableList,
KeyboardEmojiSelector,
},
mixins: [keyboardEventListenerMixins], mixins: [keyboardEventListenerMixins],
props: { props: {
value: { type: String, default: '' }, value: { type: String, default: '' },
@@ -119,9 +125,11 @@ export default {
showUserMentions: false, showUserMentions: false,
showCannedMenu: false, showCannedMenu: false,
showVariables: false, showVariables: false,
showEmojiMenu: false,
mentionSearchKey: '', mentionSearchKey: '',
cannedSearchTerm: '', cannedSearchTerm: '',
variableSearchTerm: '', variableSearchTerm: '',
emojiSearchTerm: '',
editorView: null, editorView: null,
range: null, range: null,
state: undefined, state: undefined,
@@ -169,7 +177,7 @@ export default {
this.editorView = args.view; this.editorView = args.view;
this.range = args.range; this.range = args.range;
this.mentionSearchKey = args.text.replace('@', ''); this.mentionSearchKey = args.text;
return false; return false;
}, },
@@ -198,7 +206,7 @@ export default {
this.editorView = args.view; this.editorView = args.view;
this.range = args.range; this.range = args.range;
this.cannedSearchTerm = args.text.replace('/', ''); this.cannedSearchTerm = args.text;
return false; return false;
}, },
onExit: () => { onExit: () => {
@@ -226,7 +234,7 @@ export default {
this.editorView = args.view; this.editorView = args.view;
this.range = args.range; this.range = args.range;
this.variableSearchTerm = args.text.replace('{{', ''); this.variableSearchTerm = args.text;
return false; return false;
}, },
onExit: () => { onExit: () => {
@@ -238,6 +246,31 @@ export default {
return event.keyCode === 13 && this.showVariables; return event.keyCode === 13 && this.showVariables;
}, },
}), }),
suggestionsPlugin({
matcher: triggerCharacters(':', 1), // Trigger after ':' and at least 1 characters
suggestionClass: '',
onEnter: args => {
this.showEmojiMenu = true;
this.emojiSearchTerm = args.text || '';
this.range = args.range;
this.editorView = args.view;
return false;
},
onChange: args => {
this.editorView = args.view;
this.range = args.range;
this.emojiSearchTerm = args.text;
return false;
},
onExit: () => {
this.emojiSearchTerm = '';
this.showEmojiMenu = false;
return false;
},
onKeyDown: ({ event }) => {
return event.keyCode === 13 && this.showEmojiMenu;
},
}),
]; ];
}, },
sendWithSignature() { sendWithSignature() {
@@ -267,6 +300,8 @@ export default {
}, },
editorId() { editorId() {
this.showCannedMenu = false; this.showCannedMenu = false;
this.showEmojiMenu = false;
this.showVariables = false;
this.cannedSearchTerm = ''; this.cannedSearchTerm = '';
this.reloadState(this.value); this.reloadState(this.value);
}, },
@@ -517,57 +552,36 @@ export default {
this.editorView.dispatch(tr.setSelection(selection)); this.editorView.dispatch(tr.setSelection(selection));
this.editorView.focus(); this.editorView.focus();
}, },
insertMentionNode(mentionItem) { /**
* Inserts special content (mention, canned response, variable, emoji) into the editor.
* @param {string} type - The type of special content to insert. Possible values: 'mention', 'canned_response', 'variable', 'emoji'.
* @param {Object|string} content - The content to insert, depending on the type.
*/
insertSpecialContent(type, content) {
if (!this.editorView) { if (!this.editorView) {
return null; return;
}
const node = this.editorView.state.schema.nodes.mention.create({
userId: mentionItem.id,
userFullName: mentionItem.name,
});
this.insertNodeIntoEditor(node, this.range.from, this.range.to);
this.$track(CONVERSATION_EVENTS.USED_MENTIONS);
return false;
},
insertCannedResponse(cannedItem) {
const updatedMessage = replaceVariablesInMessage({
message: cannedItem,
variables: this.variables,
});
if (!this.editorView) {
return null;
} }
let node = new MessageMarkdownTransformer(messageSchema).parse( let { node, from, to } = getContentNode(
updatedMessage this.editorView,
type,
content,
this.range,
this.variables
); );
const from = if (!node) return;
node.textContent === updatedMessage
? this.range.from
: this.range.from - 1;
this.insertNodeIntoEditor(node, from, this.range.to);
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
return false;
},
insertVariable(variable) {
if (!this.editorView) {
return null;
}
const content = `{{${variable}}}`;
let node = this.editorView.state.schema.text(content);
const { from, to } = this.range;
this.insertNodeIntoEditor(node, from, to); this.insertNodeIntoEditor(node, from, to);
this.showVariables = false;
this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE); const event_map = {
return false; mention: CONVERSATION_EVENTS.USED_MENTIONS,
cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE,
variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE,
emoji: CONVERSATION_EVENTS.INSERTED_AN_EMOJI,
};
this.$track(event_map[type]);
}, },
openFileBrowser() { openFileBrowser() {
this.$refs.imageUpload.click(); this.$refs.imageUpload.click();
@@ -687,17 +701,22 @@ export default {
<TagAgents <TagAgents
v-if="showUserMentions && isPrivate" v-if="showUserMentions && isPrivate"
:search-key="mentionSearchKey" :search-key="mentionSearchKey"
@click="insertMentionNode" @click="content => insertSpecialContent('mention', content)"
/> />
<CannedResponse <CannedResponse
v-if="shouldShowCannedResponses" v-if="shouldShowCannedResponses"
:search-key="cannedSearchTerm" :search-key="cannedSearchTerm"
@click="insertCannedResponse" @click="content => insertSpecialContent('cannedResponse', content)"
/> />
<VariableList <VariableList
v-if="shouldShowVariables" v-if="shouldShowVariables"
:search-key="variableSearchTerm" :search-key="variableSearchTerm"
@click="insertVariable" @click="content => insertSpecialContent('variable', content)"
/>
<KeyboardEmojiSelector
v-if="showEmojiMenu"
:search-key="emojiSearchTerm"
@click="emoji => insertSpecialContent('emoji', emoji)"
/> />
<input <input
ref="imageUpload" ref="imageUpload"

View File

@@ -0,0 +1,68 @@
<script setup>
import { shallowRef, computed, onMounted } from 'vue';
import emojis from 'shared/components/emoji/emojisGroup.json';
import MentionBox from '../mentions/MentionBox.vue';
const props = defineProps({
searchKey: {
type: String,
default: '',
},
});
const emit = defineEmits(['click']);
const allEmojis = shallowRef([]);
const items = computed(() => {
if (!props.searchKey) return [];
const searchTerm = props.searchKey.toLowerCase();
return allEmojis.value.filter(emoji =>
emoji.searchString.includes(searchTerm)
);
});
function loadEmojis() {
allEmojis.value = emojis.flatMap(group =>
group.emojis.map(emoji => ({
...emoji,
searchString: `${emoji.slug} ${emoji.name}`.toLowerCase(),
}))
);
}
function handleMentionClick(item = {}) {
emit('click', item.emoji);
}
onMounted(() => {
loadEmojis();
});
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<MentionBox
v-if="items.length"
type="emoji"
:items="items"
@mentionSelect="handleMentionClick"
>
<template #default="{ item, selected }">
<span
class="max-w-full inline-flex items-center gap-0.5 min-w-0 mb-0 text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 truncate"
>
{{ item.emoji }}
<p
class="relative mb-0 truncate bottom-px"
:class="{
'text-woot-500 dark:text-woot-500': selected,
'font-normal': !selected,
}"
>
:{{ item.slug }}
</p>
</span>
</template>
</MentionBox>
</template>

View File

@@ -41,14 +41,11 @@ export default {
}; };
</script> </script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template> <template>
<MentionBox <MentionBox
v-if="items.length" v-if="items.length"
:items="items" :items="items"
@mentionSelect="handleMentionClick" @mentionSelect="handleMentionClick"
> />
<template slot-scope="{ item }">
<strong>{{ item.label }}</strong> - {{ item.description }}
</template>
</MentionBox>
</template> </template>

View File

@@ -56,20 +56,14 @@ export default {
}; };
</script> </script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template> <template>
<MentionBox <MentionBox
v-if="items.length" v-if="items.length"
type="variable" type="variable"
:items="items" :items="items"
@mentionSelect="handleVariableClick" @mentionSelect="handleVariableClick"
> />
<template slot-scope="{ item }">
<span class="text-capitalize variable--list-label">
{{ item.description }}
</span>
({{ item.label }})
</template>
</MentionBox>
</template> </template>
<style scoped> <style scoped>

View File

@@ -90,22 +90,24 @@ const variableKey = (item = {}) => {
}" }"
@click="onListItemSelection(index)" @click="onListItemSelection(index)"
> >
<p <slot :item="item" :index="index" :selected="index === selectedIndex">
class="max-w-full min-w-0 mb-0 overflow-hidden text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap" <p
:class="{ class="max-w-full min-w-0 mb-0 overflow-hidden text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
'text-woot-500 dark:text-woot-500': index === selectedIndex, :class="{
}" 'text-woot-500 dark:text-woot-500': index === selectedIndex,
> }"
{{ item.description }} >
</p> {{ item.description }}
<p </p>
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-slate-500 dark:text-slate-300 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap" <p
:class="{ class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-slate-500 dark:text-slate-300 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
'text-woot-500 dark:text-woot-500': index === selectedIndex, :class="{
}" 'text-woot-500 dark:text-woot-500': index === selectedIndex,
> }"
{{ variableKey(item) }} >
</p> {{ variableKey(item) }}
</p>
</slot>
</button> </button>
</woot-dropdown-item> </woot-dropdown-item>
</ul> </ul>

View File

@@ -5,6 +5,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
INSERTED_A_CANNED_RESPONSE: 'Inserted a canned response', INSERTED_A_CANNED_RESPONSE: 'Inserted a canned response',
TRANSLATE_A_MESSAGE: 'Translated a message', TRANSLATE_A_MESSAGE: 'Translated a message',
INSERTED_A_VARIABLE: 'Inserted a variable', INSERTED_A_VARIABLE: 'Inserted a variable',
INSERTED_AN_EMOJI: 'Inserted an emoji',
USED_MENTIONS: 'Used mentions', USED_MENTIONS: 'Used mentions',
SEARCH_CONVERSATION: 'Searched conversations', SEARCH_CONVERSATION: 'Searched conversations',
APPLY_FILTER: 'Applied filters in the conversation list', APPLY_FILTER: 'Applied filters in the conversation list',

View File

@@ -3,6 +3,8 @@ import {
MessageMarkdownTransformer, MessageMarkdownTransformer,
MessageMarkdownSerializer, MessageMarkdownSerializer,
} from '@chatwoot/prosemirror-schema'; } from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
/** /**
@@ -281,3 +283,92 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
} }
} }
} }
/**
* Content Node Creation Helper Functions for
* - mention
* - canned response
* - variable
* - emoji
*/
/**
* Centralized node creation function that handles the creation of different types of nodes based on the specified type.
* @param {Object} editorView - The editor view instance.
* @param {string} nodeType - The type of node to create ('mention', 'cannedResponse', 'variable', 'emoji').
* @param {Object|string} content - The content needed to create the node, which varies based on node type.
* @returns {Object|null} - The created ProseMirror node or null if the type is not supported.
*/
const createNode = (editorView, nodeType, content) => {
const { state } = editorView;
switch (nodeType) {
case 'mention':
return state.schema.nodes.mention.create({
userId: content.id,
userFullName: content.name,
});
case 'cannedResponse':
return new MessageMarkdownTransformer(messageSchema).parse(content);
case 'variable':
return state.schema.text(`{{${content}}}`);
case 'emoji':
return state.schema.text(content);
default:
return null;
}
};
/**
* Object mapping types to their respective node creation functions.
*/
const nodeCreators = {
mention: (editorView, content, from, to) => ({
node: createNode(editorView, 'mention', content),
from,
to,
}),
cannedResponse: (editorView, content, from, to, variables) => {
const updatedMessage = replaceVariablesInMessage({
message: content,
variables,
});
const node = createNode(editorView, 'cannedResponse', updatedMessage);
return {
node,
from: node.textContent === updatedMessage ? from : from - 1,
to,
};
},
variable: (editorView, content, from, to) => ({
node: createNode(editorView, 'variable', content),
from,
to,
}),
emoji: (editorView, content, from, to) => ({
node: createNode(editorView, 'emoji', content),
from,
to,
}),
};
/**
* Retrieves a content node based on the specified type and content, using a functional approach to select the appropriate node creation function.
* @param {Object} editorView - The editor view instance.
* @param {string} type - The type of content node to create ('mention', 'cannedResponse', 'variable', 'emoji').
* @param {string|Object} content - The content to be transformed into a node.
* @param {Object} range - An object containing 'from' and 'to' properties indicating the range in the document where the node should be placed.
* @param {Object} variables - Optional. Variables to replace in the content, used for 'cannedResponse' type.
* @returns {Object} - An object containing the created node and the updated 'from' and 'to' positions.
*/
export const getContentNode = (
editorView,
type,
content,
{ from, to },
variables
) => {
const creator = nodeCreators[type];
return creator
? creator(editorView, content, from, to, variables)
: { node: null, from, to };
};

View File

@@ -0,0 +1,127 @@
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
import { getContentNode } from '../editorHelper';
import {
MessageMarkdownTransformer,
messageSchema,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
vi.mock('@chatwoot/prosemirror-schema', () => ({
MessageMarkdownTransformer: vi.fn(),
messageSchema: {},
}));
vi.mock('@chatwoot/utils', () => ({
replaceVariablesInMessage: vi.fn(),
}));
describe('getContentNode', () => {
let editorView;
beforeEach(() => {
editorView = {
state: {
schema: {
nodes: {
mention: {
create: vi.fn(),
},
},
text: vi.fn(),
},
},
};
});
describe('getMentionNode', () => {
it('should create a mention node', () => {
const content = { id: 1, name: 'John Doe' };
const from = 0;
const to = 10;
getContentNode(editorView, 'mention', content, {
from,
to,
});
expect(editorView.state.schema.nodes.mention.create).toHaveBeenCalledWith(
{
userId: content.id,
userFullName: content.name,
}
);
});
});
describe('getCannedResponseNode', () => {
it('should create a canned response node', () => {
const content = 'Hello {{name}}';
const variables = { name: 'John' };
const from = 0;
const to = 10;
const updatedMessage = 'Hello John';
replaceVariablesInMessage.mockReturnValue(updatedMessage);
MessageMarkdownTransformer.mockImplementation(() => ({
parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
}));
const { node } = getContentNode(
editorView,
'cannedResponse',
content,
{ from, to },
variables
);
expect(replaceVariablesInMessage).toHaveBeenCalledWith({
message: content,
variables,
});
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
expect(node.textContent).toBe(updatedMessage);
});
});
describe('getVariableNode', () => {
it('should create a variable node', () => {
const content = 'name';
const from = 0;
const to = 10;
getContentNode(editorView, 'variable', content, {
from,
to,
});
expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}');
});
});
describe('getEmojiNode', () => {
it('should create an emoji node', () => {
const content = '😊';
const from = 0;
const to = 2;
getContentNode(editorView, 'emoji', content, {
from,
to,
});
expect(editorView.state.schema.text).toHaveBeenCalledWith('😊');
});
});
describe('getContentNode', () => {
it('should return null for invalid type', () => {
const content = 'invalid';
const from = 0;
const to = 10;
const { node } = getContentNode(editorView, 'invalid', content, {
from,
to,
});
expect(node).toBeNull();
});
});
});

View File

@@ -31,7 +31,7 @@
"dependencies": { "dependencies": {
"@braid/vue-formulate": "^2.5.2", "@braid/vue-formulate": "^2.5.2",
"@chatwoot/ninja-keys": "1.2.3", "@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.0.11", "@chatwoot/prosemirror-schema": "1.0.13",
"@chatwoot/utils": "^0.0.25", "@chatwoot/utils": "^0.0.25",
"@hcaptcha/vue-hcaptcha": "^0.3.2", "@hcaptcha/vue-hcaptcha": "^0.3.2",
"@june-so/analytics-next": "^2.0.0", "@june-so/analytics-next": "^2.0.0",

View File

@@ -2914,10 +2914,10 @@
hotkeys-js "3.8.7" hotkeys-js "3.8.7"
lit "2.2.6" lit "2.2.6"
"@chatwoot/prosemirror-schema@1.0.11": "@chatwoot/prosemirror-schema@1.0.13":
version "1.0.11" version "1.0.13"
resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.11.tgz#b66201be8b09cd4c6370a60607cc5d4f9d924bdb" resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.13.tgz#1b7cd82e16bd66d8fcd96bc3415b56e7e40841a0"
integrity sha512-OAUa1CuHtetEPh/ZhRkMLVQHY2/eesCRb2IRdVzjh0z+4spp3fG826y8FbOEsa7m4hisNirnGF/mDH+NMmYetw== integrity sha512-Ki7H2kUxkbDup+A8useTRQb2F45BxdTe1C9VsX4oSRh3WHqWriedEMmpnZy1SCzRm0zEFCw57RA4XppgZVkfrg==
dependencies: dependencies:
markdown-it-sup "^1.0.0" markdown-it-sup "^1.0.0"
prosemirror-commands "^1.1.4" prosemirror-commands "^1.1.4"