From 34a193dfb8fc4d18cde8a1b27e0a81505183018f Mon Sep 17 00:00:00 2001 From: Dan Harrin Date: Thu, 1 Feb 2024 21:07:06 +0000 Subject: [PATCH 1/4] [ADVAPP-279]: Add mention capabilities for Realtime Chat --- .../resources/js/TipTap/Extentions/Mention.js | 270 ++++++++++++++++++ .../resources/js/userToUserChat.js | 21 +- .../views/filament/pages/user-chat.blade.php | 10 +- .../src/Filament/Pages/UserChat.php | 7 + package-lock.json | 15 + package.json | 1 + 6 files changed, 317 insertions(+), 7 deletions(-) create mode 100644 app-modules/in-app-communication/resources/js/TipTap/Extentions/Mention.js 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..a9fb7e3098 --- /dev/null +++ b/app-modules/in-app-communication/resources/js/TipTap/Extentions/Mention.js @@ -0,0 +1,270 @@ +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..25263e9697 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,20 @@ 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 +309,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)