diff --git a/app-modules/in-app-communication/resources/js/TipTap/Extentions/Mention.js b/app-modules/in-app-communication/resources/js/TipTap/Extentions/Mention.js new file mode 100644 index 0000000000..a796c79e27 --- /dev/null +++ b/app-modules/in-app-communication/resources/js/TipTap/Extentions/Mention.js @@ -0,0 +1,305 @@ +/* + + + Copyright © 2022-2023, Canyon GBS LLC. All rights reserved. + + Advising App™ is licensed under the Elastic License 2.0. For more details, + see https://github.com/canyongbs/advisingapp/blob/main/LICENSE. + + Notice: + + - You may not provide the software to third parties as a hosted or managed + service, where the service provides users with access to any substantial set of + the features or functionality of the software. + - You may not move, change, disable, or circumvent the license key functionality + in the software, and you may not remove or obscure any functionality in the + software that is protected by the license key. + - You may not alter, remove, or obscure any licensing, copyright, or other notices + of the licensor in the software. Any use of the licensor’s trademarks is subject + to applicable law. + - Canyon GBS LLC respects the intellectual property rights of others and expects the + same in return. Canyon GBS™ and Advising App™ are registered trademarks of + Canyon GBS LLC, and we are committed to enforcing and protecting our trademarks + vigorously. + - The software solution, including services, infrastructure, and code, is offered as a + Software as a Service (SaaS) by Canyon GBS LLC. + - Use of this software implies agreement to the license terms and conditions as stated + in the Elastic License 2.0. + + For more information or inquiries please visit our website at + https://www.canyongbs.com or contact us via email at legal@canyongbs.com. + + +*/ +import { mergeAttributes, Node } from '@tiptap/core'; +import { PluginKey } from '@tiptap/pm/state'; +import Suggestion from '@tiptap/suggestion'; +import tippy from 'tippy.js'; + +export const MentionPluginKey = new PluginKey('mention'); + +export const Mention = Node.create({ + name: 'mention', + + group: 'inline', + + inline: true, + + selectable: false, + + atom: true, + + addAttributes() { + return { + id: { + default: null, + parseHTML: (element) => element.getAttribute('data-id'), + renderHTML: (attributes) => { + if (!attributes.id) { + return {}; + } + + return { + 'data-id': attributes.id, + }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + user: `span[data-type='${this.name}']`, + }, + ]; + }, + + renderHTML({ node, HTMLAttributes }) { + return [ + 'span', + mergeAttributes({ 'data-type': this.name }, HTMLAttributes), + `@${this.options.users[node.attrs.id] ?? node.attrs.id}`, + ]; + }, + + renderText({ node }) { + return `@${this.options.users[node.attrs.id] ?? node.attrs.id}`; + }, + + addKeyboardShortcuts() { + return { + Backspace: () => + this.editor.commands.command(({ tr, state }) => { + let isMention = false; + const { selection } = state; + const { empty, anchor } = selection; + + if (!empty) { + return false; + } + + state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => { + if (node.type.name === this.name) { + isMention = true; + tr.insertText('@', pos, pos + node.nodeSize); + + return false; + } + }); + + return isMention; + }), + }; + }, + + addCommands() { + return { + insertMention: + (attributes) => + ({ chain }) => { + const currentChain = chain(); + + if (![null, undefined].includes(attributes.coordinates?.pos)) { + currentChain.insertContentAt( + { from: attributes.coordinates.pos, to: attributes.coordinates.pos }, + [ + { type: this.name, attrs: { id: attributes.user } }, + { type: 'text', text: ' ' }, + ], + ); + + return currentChain; + } + }, + }; + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + char: '@', + items: ({ query }) => + Object.fromEntries( + Object.entries(this.options.users) + .filter(([id, name]) => name.toLowerCase().startsWith(query.toLowerCase())) + .slice(0, 5), + ), + pluginKey: MentionPluginKey, + command: ({ editor, range, props }) => { + const nodeAfter = editor.view.state.selection.$to.nodeAfter; + const overrideSpace = nodeAfter?.text?.startsWith(' '); + + if (overrideSpace) { + range.to += 1; + } + + editor + .chain() + .focus() + .insertContentAt(range, [ + { + type: this.name, + attrs: props, + }, + { + type: 'text', + text: ' ', + }, + ]) + .run(); + + window.getSelection()?.collapseToEnd(); + }, + allow: ({ state, range }) => { + const $from = state.doc.resolve(range.from); + const type = state.schema.nodes[this.name]; + const allow = !!$from.parent.type.contentMatch.matchType(type); + + return allow; + }, + render: () => { + let component; + let popup; + + return { + onStart: (props) => { + if (!props.clientRect) { + return; + } + + const html = ` +
+ +
+ `; + + component = document.createElement('div'); + component.innerHTML = html; + component.addEventListener('mentions-select', (event) => { + props.command({ id: event.detail.item }); + }); + + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component, + allowHTML: true, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }); + }, + + onUpdate(props) { + if (!props.items.length) { + popup[0].hide(); + + return; + } + + popup[0].show(); + + component.dispatchEvent(new CustomEvent('mentions-update-items', { detail: props.items })); + }, + + onKeyDown(props) { + component.dispatchEvent(new CustomEvent('mentions-key-down', { detail: props.event })); + }, + + onExit() { + popup[0].destroy(); + }, + }; + }, + }), + ]; + }, +}); diff --git a/app-modules/in-app-communication/resources/js/userToUserChat.js b/app-modules/in-app-communication/resources/js/userToUserChat.js index c6eae94bbe..d42be2b458 100644 --- a/app-modules/in-app-communication/resources/js/userToUserChat.js +++ b/app-modules/in-app-communication/resources/js/userToUserChat.js @@ -35,6 +35,7 @@ document.addEventListener('alpine:init', () => { global = globalThis; const { generateHTML } = require('@tiptap/html'); const { Editor } = require('@tiptap/core'); + const { Mention } = require('./TipTap/Extentions/Mention'); const { Placeholder } = require('@tiptap/extension-placeholder'); const { StarterKit } = require('@tiptap/starter-kit'); const { Underline } = require('@tiptap/extension-underline'); @@ -46,7 +47,7 @@ document.addEventListener('alpine:init', () => { let conversationsClient = null; - Alpine.data('userToUserChat', (selectedConversation) => ({ + Alpine.data('userToUserChat', ({ selectedConversation, users }) => ({ loading: true, loadingMessage: 'Loading chat…', error: false, @@ -285,11 +286,17 @@ document.addEventListener('alpine:init', () => { .catch((error) => console.error('Error handler failed to handle error: ', error)); }, generateHTML: (content) => { - return generateHTML(content, [StarterKit, Underline]); + return generateHTML(content, [ + Mention.configure({ + users, + }), + StarterKit, + Underline, + ]); }, })); - Alpine.data('chatEditor', () => { + Alpine.data('chatEditor', ({ currentUser, users }) => { let editor; return { @@ -299,9 +306,14 @@ document.addEventListener('alpine:init', () => { init() { const _this = this; + delete users[currentUser]; + editor = new Editor({ element: this.$refs.element, extensions: [ + Mention.configure({ + users, + }), StarterKit, Underline, Placeholder.configure({ diff --git a/app-modules/in-app-communication/resources/views/filament/pages/user-chat.blade.php b/app-modules/in-app-communication/resources/views/filament/pages/user-chat.blade.php index a5cd2897b4..56562b0238 100644 --- a/app-modules/in-app-communication/resources/views/filament/pages/user-chat.blade.php +++ b/app-modules/in-app-communication/resources/views/filament/pages/user-chat.blade.php @@ -152,7 +152,7 @@ class="flex flex-col gap-y-1 rounded-xl border border-gray-950/5 bg-white p-2 sh @if ($conversation)