mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +00:00 
			
		
		
		
	 a6cc3617c0
			
		
	
	a6cc3617c0
	
	
	
		
			
			This PR allows agents to mention entire teams in private messages using `@team_name` syntax. When a team is mentioned, all team members with inbox access are automatically notified. The scheme changes can be found [here](https://github.com/chatwoot/prosemirror-schema/pull/34). --------- Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Sojan Jose <sojan@pepalo.com>
		
			
				
	
	
		
			381 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			381 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| import {
 | |
|   messageSchema,
 | |
|   MessageMarkdownTransformer,
 | |
|   MessageMarkdownSerializer,
 | |
| } from '@chatwoot/prosemirror-schema';
 | |
| import { replaceVariablesInMessage } from '@chatwoot/utils';
 | |
| import * as Sentry from '@sentry/vue';
 | |
| 
 | |
| /**
 | |
|  * The delimiter used to separate the signature from the rest of the body.
 | |
|  * @type {string}
 | |
|  */
 | |
| export const SIGNATURE_DELIMITER = '--';
 | |
| 
 | |
| /**
 | |
|  * Parse and Serialize the markdown text to remove any extra spaces or new lines
 | |
|  */
 | |
| export function cleanSignature(signature) {
 | |
|   try {
 | |
|     // remove any horizontal rule tokens
 | |
|     signature = signature
 | |
|       .replace(/^( *\* *){3,} *$/gm, '')
 | |
|       .replace(/^( *- *){3,} *$/gm, '')
 | |
|       .replace(/^( *_ *){3,} *$/gm, '');
 | |
| 
 | |
|     const nodes = new MessageMarkdownTransformer(messageSchema).parse(
 | |
|       signature
 | |
|     );
 | |
|     return MessageMarkdownSerializer.serialize(nodes);
 | |
|   } catch (e) {
 | |
|     // eslint-disable-next-line no-console
 | |
|     console.warn(e);
 | |
|     Sentry.captureException(e);
 | |
|     // The parser can break on some cases
 | |
|     // for example, Token type `hr` not supported by Markdown parser
 | |
|     return signature;
 | |
|   }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Adds the signature delimiter to the beginning of the signature.
 | |
|  *
 | |
|  * @param {string} signature - The signature to add the delimiter to.
 | |
|  * @returns {string} - The signature with the delimiter added.
 | |
|  */
 | |
| function appendDelimiter(signature) {
 | |
|   return `${SIGNATURE_DELIMITER}\n\n${cleanSignature(signature)}`;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Check if there's an unedited signature at the end of the body
 | |
|  * If there is, return the index of the signature, If there isn't, return -1
 | |
|  *
 | |
|  * @param {string} body - The body to search for the signature.
 | |
|  * @param {string} signature - The signature to search for.
 | |
|  * @returns {number} - The index of the last occurrence of the signature in the body, or -1 if not found.
 | |
|  */
 | |
| export function findSignatureInBody(body, signature) {
 | |
|   const trimmedBody = body.trimEnd();
 | |
|   const cleanedSignature = cleanSignature(signature);
 | |
| 
 | |
|   // check if body ends with signature
 | |
|   if (trimmedBody.endsWith(cleanedSignature)) {
 | |
|     return body.lastIndexOf(cleanedSignature);
 | |
|   }
 | |
| 
 | |
|   return -1;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Appends the signature to the body, separated by the signature delimiter.
 | |
|  *
 | |
|  * @param {string} body - The body to append the signature to.
 | |
|  * @param {string} signature - The signature to append.
 | |
|  * @returns {string} - The body with the signature appended.
 | |
|  */
 | |
| export function appendSignature(body, signature) {
 | |
|   const cleanedSignature = cleanSignature(signature);
 | |
|   // if signature is already present, return body
 | |
|   if (findSignatureInBody(body, cleanedSignature) > -1) {
 | |
|     return body;
 | |
|   }
 | |
| 
 | |
|   return `${body.trimEnd()}\n\n${appendDelimiter(cleanedSignature)}`;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Removes the signature from the body, along with the signature delimiter.
 | |
|  *
 | |
|  * @param {string} body - The body to remove the signature from.
 | |
|  * @param {string} signature - The signature to remove.
 | |
|  * @returns {string} - The body with the signature removed.
 | |
|  */
 | |
| export function removeSignature(body, signature) {
 | |
|   // this will find the index of the signature if it exists
 | |
|   // Regardless of extra spaces or new lines after the signature, the index will be the same if present
 | |
|   const cleanedSignature = cleanSignature(signature);
 | |
|   const signatureIndex = findSignatureInBody(body, cleanedSignature);
 | |
| 
 | |
|   // no need to trim the ends here, because it will simply be removed in the next method
 | |
|   let newBody = body;
 | |
| 
 | |
|   // if signature is present, remove it and trim it
 | |
|   // trimming will ensure any spaces or new lines before the signature are removed
 | |
|   // This means we will have the delimiter at the end
 | |
|   if (signatureIndex > -1) {
 | |
|     newBody = newBody.substring(0, signatureIndex).trimEnd();
 | |
|   }
 | |
| 
 | |
|   // let's find the delimiter and remove it
 | |
|   const delimiterIndex = newBody.lastIndexOf(SIGNATURE_DELIMITER);
 | |
|   if (
 | |
|     delimiterIndex !== -1 &&
 | |
|     delimiterIndex === newBody.length - SIGNATURE_DELIMITER.length // this will ensure the delimiter is at the end
 | |
|   ) {
 | |
|     // if the delimiter is at the end, remove it
 | |
|     newBody = newBody.substring(0, delimiterIndex);
 | |
|   }
 | |
| 
 | |
|   // return the value
 | |
|   return newBody;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Replaces the old signature with the new signature.
 | |
|  * If the old signature is not present, it will append the new signature.
 | |
|  *
 | |
|  * @param {string} body - The body to replace the signature in.
 | |
|  * @param {string} oldSignature - The signature to replace.
 | |
|  * @param {string} newSignature - The signature to replace the old signature with.
 | |
|  * @returns {string} - The body with the old signature replaced with the new signature.
 | |
|  *
 | |
|  */
 | |
| export function replaceSignature(body, oldSignature, newSignature) {
 | |
|   const withoutSignature = removeSignature(body, oldSignature);
 | |
|   return appendSignature(withoutSignature, newSignature);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
 | |
|  * Links will be converted to text, and not removed.
 | |
|  *
 | |
|  * @param {string} markdown - markdown text to be extracted
 | |
|  * @returns
 | |
|  */
 | |
| export function extractTextFromMarkdown(markdown) {
 | |
|   return markdown
 | |
|     .replace(/```[\s\S]*?```/g, '') // Remove code blocks
 | |
|     .replace(/`.*?`/g, '') // Remove inline code
 | |
|     .replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
 | |
|     .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
 | |
|     .replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
 | |
|     .split('\n')
 | |
|     .map(line => line.trim())
 | |
|     .filter(Boolean)
 | |
|     .join('\n') // Trim each line & remove any lines only having spaces
 | |
|     .replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
 | |
|     .trim(); // Trim any extra space
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Scrolls the editor view into current cursor position
 | |
|  *
 | |
|  * @param {EditorView} view - The Prosemirror EditorView
 | |
|  *
 | |
|  */
 | |
| export const scrollCursorIntoView = view => {
 | |
|   // Get the current selection's head position (where the cursor is).
 | |
|   const pos = view.state.selection.head;
 | |
| 
 | |
|   // Get the corresponding DOM node for that position.
 | |
|   const domAtPos = view.domAtPos(pos);
 | |
|   const node = domAtPos.node;
 | |
| 
 | |
|   // Scroll the node into view.
 | |
|   if (node && node.scrollIntoView) {
 | |
|     node.scrollIntoView({ behavior: 'smooth', block: 'center' });
 | |
|   }
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * Returns a transaction that inserts a node into editor at the given position
 | |
|  * Has an optional param 'content' to check if the
 | |
|  *
 | |
|  * @param {Node} node - The prosemirror node that needs to be inserted into the editor
 | |
|  * @param {number} from - Position in the editor where the node needs to be inserted
 | |
|  * @param {number} to - Position in the editor where the node needs to be replaced
 | |
|  *
 | |
|  */
 | |
| export function insertAtCursor(editorView, node, from, to) {
 | |
|   if (!editorView) {
 | |
|     return undefined;
 | |
|   }
 | |
| 
 | |
|   // This is a workaround to prevent inserting content into new line rather than on the exiting line
 | |
|   // If the node is of type 'doc' and has only one child which is a paragraph,
 | |
|   // then extract its inline content to be inserted as inline.
 | |
|   const isWrappedInParagraph =
 | |
|     node.type.name === 'doc' &&
 | |
|     node.childCount === 1 &&
 | |
|     node.firstChild.type.name === 'paragraph';
 | |
| 
 | |
|   if (isWrappedInParagraph) {
 | |
|     node = node.firstChild.content;
 | |
|   }
 | |
| 
 | |
|   let tr;
 | |
|   if (to) {
 | |
|     tr = editorView.state.tr.replaceWith(from, to, node).insertText(` `);
 | |
|   } else {
 | |
|     tr = editorView.state.tr.insert(from, node);
 | |
|   }
 | |
|   const state = editorView.state.apply(tr);
 | |
|   editorView.updateState(state);
 | |
|   editorView.focus();
 | |
| 
 | |
|   return state;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Determines the appropriate node and position to insert an image in the editor.
 | |
|  *
 | |
|  * Based on the current editor state and the provided image URL, this function finds out the correct node (either
 | |
|  * a standalone image node or an image wrapped in a paragraph) and its respective position in the editor.
 | |
|  *
 | |
|  * 1. If the current node is a paragraph and doesn't contain an image or text, the image is inserted directly into it.
 | |
|  * 2. If the current node isn't a paragraph or it's a paragraph containing text, the image will be wrapped
 | |
|  *    in a new paragraph and then inserted.
 | |
|  * 3. If the current node is a paragraph containing an image, the new image will be inserted directly into it.
 | |
|  *
 | |
|  * @param {Object} editorState - The current state of the editor. It provides necessary details like selection, schema, etc.
 | |
|  * @param {string} fileUrl - The URL of the image to be inserted into the editor.
 | |
|  * @returns {Object|null} An object containing details about the node to be inserted and its position. It returns null if no image node can be created.
 | |
|  * @property {Node} node - The ProseMirror node to be inserted (either an image node or a paragraph containing the image).
 | |
|  * @property {number} pos - The position where the new node should be inserted in the editor.
 | |
|  */
 | |
| 
 | |
| export const findNodeToInsertImage = (editorState, fileUrl) => {
 | |
|   const { selection, schema } = editorState;
 | |
|   const { nodes } = schema;
 | |
|   const currentNode = selection.$from.node();
 | |
|   const {
 | |
|     type: { name: typeName },
 | |
|     content: { size, content },
 | |
|   } = currentNode;
 | |
| 
 | |
|   const imageNode = nodes.image.create({ src: fileUrl });
 | |
| 
 | |
|   if (!imageNode) return null;
 | |
| 
 | |
|   const isInParagraph = typeName === 'paragraph';
 | |
|   const needsNewLine =
 | |
|     !content.some(n => n.type.name === 'image') && size !== 0 ? 1 : 0;
 | |
| 
 | |
|   return {
 | |
|     node: isInParagraph ? imageNode : nodes.paragraph.create({}, imageNode),
 | |
|     pos: selection.from + needsNewLine,
 | |
|   };
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * Set URL with query and size.
 | |
|  *
 | |
|  * @param {Object} selectedImageNode - The current selected node.
 | |
|  * @param {Object} size - The size to set.
 | |
|  * @param {Object} editorView - The editor view.
 | |
|  */
 | |
| export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
 | |
|   if (selectedImageNode) {
 | |
|     // Create and apply the transaction
 | |
|     const tr = editorView.state.tr.setNodeMarkup(
 | |
|       editorView.state.selection.from,
 | |
|       null,
 | |
|       {
 | |
|         src: selectedImageNode.src,
 | |
|         height: size.height,
 | |
|       }
 | |
|     );
 | |
| 
 | |
|     if (tr.docChanged) {
 | |
|       editorView.dispatch(tr);
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * 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': {
 | |
|       const mentionType = content.type || 'user';
 | |
|       const displayName = content.displayName || content.name;
 | |
| 
 | |
|       const mentionNode = state.schema.nodes.mention.create({
 | |
|         userId: content.id,
 | |
|         userFullName: displayName,
 | |
|         mentionType,
 | |
|       });
 | |
| 
 | |
|       return mentionNode;
 | |
|     }
 | |
|     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 };
 | |
| };
 |