From 2cd4ac3e46855dc0b3d5af24e93d9f90706c01ef Mon Sep 17 00:00:00 2001 From: Amir Ghezelbash Date: Mon, 27 Mar 2023 18:41:49 +0330 Subject: [PATCH] refactor: Fix typescript errors in mention rendering (#14869) (#14920) * refactor: Fix typescript errors in mention rendering (refactor/SQSERVICES-1944) * update test case titles * update tests --- .../TextMessageRenderer.tsx | 20 ++- src/script/util/messageRenderer.ts | 128 +++++++++--------- test/unit_tests/util/messageRendererSpec.js | 34 +++++ 3 files changed, 113 insertions(+), 69 deletions(-) diff --git a/src/script/components/MessagesList/Message/ContentMessage/asset/TextMessageRenderer/TextMessageRenderer.tsx b/src/script/components/MessagesList/Message/ContentMessage/asset/TextMessageRenderer/TextMessageRenderer.tsx index 7fd448cf89f..842e40a8133 100644 --- a/src/script/components/MessagesList/Message/ContentMessage/asset/TextMessageRenderer/TextMessageRenderer.tsx +++ b/src/script/components/MessagesList/Message/ContentMessage/asset/TextMessageRenderer/TextMessageRenderer.tsx @@ -107,17 +107,23 @@ const TextMessage: FC = ({ if (!target) { return; } - const isEmail = target.closest('[data-email-link]'); - const isMarkdownLink = target.closest('[data-md-link]'); - const isMention = target.closest('.message-mention'); + const emailElement = target.closest('[data-email-link]'); + const markdownLinkElement = target.closest('[data-md-link]'); + const mentionElement = target.closest('.message-mention'); - if (isEmail || isMarkdownLink) { - const href = (event.target as HTMLAnchorElement).href; + if (markdownLinkElement) { + const href = (markdownLinkElement as HTMLAnchorElement).href; const markdownLinkDetails = { href: href, }; - forwardEvent(event.nativeEvent, isEmail ? 'email' : 'markdownLink', markdownLinkDetails); - } else if (isMention) { + forwardEvent(event.nativeEvent, 'markdownLink', markdownLinkDetails); + } else if (emailElement) { + const href = (emailElement as HTMLAnchorElement).href; + const markdownLinkDetails = { + href: href, + }; + forwardEvent(event.nativeEvent, 'email', markdownLinkDetails); + } else if (mentionElement) { const mentionMsgDetails = { userId: target.dataset.userId, userDomain: target.dataset.userDomain, diff --git a/src/script/util/messageRenderer.ts b/src/script/util/messageRenderer.ts index 131b1fefa55..adce2b83a3f 100644 --- a/src/script/util/messageRenderer.ts +++ b/src/script/util/messageRenderer.ts @@ -20,7 +20,7 @@ import {QualifiedId} from '@wireapp/api-client/lib/user'; import hljs from 'highlight.js'; import MarkdownIt from 'markdown-it'; -import type Token from 'markdown-it/lib/token'; +import {escapeHtml} from 'markdown-it/lib/common/utils'; import {escape} from 'underscore'; import {replaceInRange} from './StringUtil'; @@ -46,13 +46,7 @@ const markdownit = new MarkdownIt('zero', { linkify: true, }).enable(['autolink', 'backticks', 'code', 'emphasis', 'escape', 'fence', 'heading', 'link', 'linkify', 'newline']); -const originalFenceRule = markdownit.renderer.rules.fence; - -markdownit.renderer.rules.fence = (tokens, idx, options, env, self) => { - const highlighted = originalFenceRule(tokens, idx, options, env, self); - tokens[idx].map[1] += 1; - return highlighted.replace(/\n$/, ''); -}; +const originalFenceRule = markdownit.renderer.rules.fence!; markdownit.renderer.rules.heading_open = () => '
'; markdownit.renderer.rules.heading_close = () => '
'; @@ -60,34 +54,36 @@ markdownit.renderer.rules.heading_close = () => ''; markdownit.renderer.rules.softbreak = () => '
'; markdownit.renderer.rules.hardbreak = () => '
'; markdownit.renderer.rules.paragraph_open = (tokens, idx) => { - const [position] = tokens[idx].map; + const [position] = tokens[idx].map || [0, 0]; + const previousWithMap = tokens .slice(0, idx) .reverse() .find(({map}) => map?.length); - const previousPosition = previousWithMap ? previousWithMap.map[1] - 1 : 0; + const previousPosition = previousWithMap ? (previousWithMap.map || [0, 0])[1] - 1 : 0; const count = position - previousPosition; return '
'.repeat(Math.max(count, 0)); }; markdownit.renderer.rules.paragraph_close = () => ''; +const renderMention = (mentionData: MentionText) => { + const elementClasses = mentionData.isSelfMentioned ? ' self-mention' : ''; + let elementAttributes = mentionData.isSelfMentioned + ? ' data-uie-name="label-self-mention" role="button"' + : ` data-uie-name="label-other-mention" data-user-id="${escape(mentionData.userId)}" role="button"`; + if (!mentionData.isSelfMentioned && mentionData.domain) { + elementAttributes += ` data-user-domain="${escape(mentionData.domain)}"`; + } + + const mentionText = mentionData.text.replace(/^@/, ''); + const content = `@${escape(mentionText)}`; + return `
${content}
`; +}; + markdownit.normalizeLinkText = text => text; export const renderMessage = (message: string, selfId: QualifiedId | null, mentionEntities: MentionEntity[] = []) => { const createMentionHash = (mention: MentionEntity) => `@@${window.btoa(JSON.stringify(mention)).replace(/=/g, '')}`; - const renderMention = (mentionData: MentionText) => { - const elementClasses = mentionData.isSelfMentioned ? ' self-mention' : ''; - let elementAttributes = mentionData.isSelfMentioned - ? ' data-uie-name="label-self-mention" role="button"' - : ` data-uie-name="label-other-mention" data-user-id="${escape(mentionData.userId)}" role="button"`; - if (!mentionData.isSelfMentioned && mentionData.domain) { - elementAttributes += ` data-user-domain="${escape(mentionData.domain)}"`; - } - - const mentionText = mentionData.text.replace(/^@/, ''); - const content = `@${escape(mentionText)}`; - return `
${content}
`; - }; const mentionTexts: Record = {}; @@ -107,6 +103,38 @@ export const renderMessage = (message: string, selfId: QualifiedId | null, menti return replaceInRange(strippedText, mentionKey, mention.startIndex, mention.startIndex + mention.length); }, message); + const removeMentionsHashes = (hashedText: string): string => { + return Object.entries(mentionTexts).reduce( + (text, [mentionHash, mention]) => text.replace(mentionHash, () => mention.text), + hashedText, + ); + }; + + const renderMentions = (inputText: string): string => { + const replacedText = Object.keys(mentionTexts).reduce((text, mentionHash) => { + const mentionMarkup = renderMention(mentionTexts[mentionHash]); + return text.replace(mentionHash, () => mentionMarkup); + }, inputText); + return replacedText; + }; + + markdownit.renderer.rules.text = (tokens, idx) => { + const escapedText = escapeHtml(tokens[idx].content); + + return renderMentions(escapedText); + }; + + markdownit.renderer.rules.fence = (tokens, idx, options, env, self) => { + const token = tokens[idx]; + token.info = removeMentionsHashes(token.info); + const highlighted = originalFenceRule(tokens, idx, options, env, self); + (tokens[idx].map ?? [0, 0])[1] += 1; + + const replacedText = renderMentions(highlighted); + + return replacedText.replace(/\n$/, ''); + }; + markdownit.set({ highlight: function (code, lang): string { const containsMentions = mentionEntities.some(mention => { @@ -118,30 +146,26 @@ export const renderMessage = (message: string, selfId: QualifiedId | null, menti // highlighting will be wrong anyway because this is not valid code return escape(code); } - return hljs.highlightAuto(code, lang && [lang]).value; + return hljs.highlightAuto(code, lang ? [lang] : undefined).value; }, }); markdownit.renderer.rules.link_open = (tokens, idx, options, env, self) => { - const cleanString = (hashedString: string) => - escape( - Object.entries(mentionTexts).reduce( - (text, [mentionHash, mention]) => text.replace(mentionHash, () => mention.text), - hashedString, - ), - ); + const cleanString = (hashedString: string) => escape(removeMentionsHashes(hashedString)); const link = tokens[idx]; - const href = link.attrGet('href'); - const isEmail = href.startsWith('mailto:'); - const isWireDeepLink = href.toLowerCase().startsWith('wire://'); + const href = removeMentionsHashes(link.attrGet('href') ?? ''); + const isEmail = href?.startsWith('mailto:'); + const isWireDeepLink = href?.toLowerCase().startsWith('wire://'); const nextToken = tokens[idx + 1]; const text = nextToken?.type === 'text' ? nextToken.content : ''; if (!href || !text.trim()) { nextToken.content = ''; const closeToken = tokens.slice(idx).find(token => token.type === 'link_close'); - closeToken.type = 'text'; - closeToken.content = ''; + if (closeToken) { + closeToken.type = 'text'; + closeToken.content = ''; + } return `[${cleanString(text)}](${cleanString(href)})`; } if (isEmail) { @@ -150,14 +174,11 @@ export const renderMessage = (message: string, selfId: QualifiedId | null, menti link.attrPush(['target', '_blank']); link.attrPush(['rel', 'nofollow noopener noreferrer']); } + link.attrSet('href', href); if (!isWireDeepLink && !['autolink', 'linkify'].includes(link.markup)) { const title = link.attrGet('title'); if (title) { - link.attrSet('title', title); - } - link.attrSet('href', href); - if (nextToken?.type === 'text') { - nextToken.content = text; + link.attrSet('title', removeMentionsHashes(title)); } link.attrPush(['data-md-link', 'true']); link.attrPush(['data-uie-name', 'markdown-link']); @@ -170,30 +191,13 @@ export const renderMessage = (message: string, selfId: QualifiedId | null, menti } return self.renderToken(tokens, idx, options); }; - const originalTokens = markdownit.parse(mentionlessText, {}); - const modifiedLinksTokens = markdownit.parse(mentionlessText, {}); - const fixCodeTokens = (modifiedTokens: Token[], originalTokens: Token[]) => - modifiedTokens.map((modifiedToken, index) => { - const originalToken = originalTokens[index]; - if (modifiedToken.tag === 'code') { - return originalToken; - } - if (modifiedToken.children) { - modifiedToken.children = fixCodeTokens(modifiedToken.children, originalToken.children); - } - return modifiedToken; - }); - const fixedTokens = fixCodeTokens(modifiedLinksTokens, originalTokens); - mentionlessText = markdownit.renderer.render(fixedTokens, (markdownit as MarkdownItWithOptions).options, {}); + + const tokens = markdownit.parse(mentionlessText, {}); + mentionlessText = markdownit.renderer.render(tokens, (markdownit as MarkdownItWithOptions).options, {}); // Remove
and \n if it is the last thing in a message mentionlessText = mentionlessText.replace(/(
|\n)*$/, ''); - const parsedText = Object.keys(mentionTexts).reduce((text, mentionHash) => { - const mentionMarkup = renderMention(mentionTexts[mentionHash]); - - return text.replace(mentionHash, () => mentionMarkup); - }, mentionlessText); - return parsedText; + return mentionlessText; }; export const getRenderedTextContent = (text: string): string => { diff --git a/test/unit_tests/util/messageRendererSpec.js b/test/unit_tests/util/messageRendererSpec.js index 47ee3a3f433..8221298c1f8 100644 --- a/test/unit_tests/util/messageRendererSpec.js +++ b/test/unit_tests/util/messageRendererSpec.js @@ -304,6 +304,40 @@ describe('renderMessage', () => { testCase: 'displays mention inside code block', text: 'salut\n```\n@you\n```', }, + { + expected: 'salut
', + mentions: [{length: 4, startIndex: 10, userId: 'pain-id'}], + testCase: 'mention does not affect code language', + text: 'salut\n\n```@you\n```', + }, + { + expected: + 'text', + mentions: [{length: 4, startIndex: 25, userId: 'pain-id'}], + testCase: 'mention does not affect url', + text: '[text](https://wire.com/#@you)', + }, + { + expected: + 'text', + mentions: [{length: 4, startIndex: 27, userId: 'pain-id'}], + testCase: 'mention in title is displayed correctly', + text: '[text](https://wire.com/# "@you")', + }, + { + expected: + '
@you
', + mentions: [{length: 4, startIndex: 1, userId: 'pain-id'}], + testCase: 'mention works correctly as part of a link text', + text: '[@you](https://wire.com)', + }, + { + expected: + 'http://wire.com#
@you
', + mentions: [{length: 4, startIndex: 16, userId: 'pain-id'}], + testCase: "mentions don't affect auto-detected links", + text: 'http://wire.com#@you', + }, ]; tests.forEach(({expected, mentions, testCase, text}) => {