diff --git a/src/script/conversation/MessageRepository.ts b/src/script/conversation/MessageRepository.ts index 3fb9bbae5c2..b164bc6c5f1 100644 --- a/src/script/conversation/MessageRepository.ts +++ b/src/script/conversation/MessageRepository.ts @@ -54,6 +54,7 @@ import { } from 'Util/LinkPreviewSender'; import {Declension, joinNames, t} from 'Util/LocalizerUtil'; import {getLogger, Logger} from 'Util/Logger'; +import {isMarkdownText} from 'Util/MarkdownUtil'; import {areMentionsDifferent, isTextDifferent} from 'Util/messageComparator'; import {roundLogarithmic} from 'Util/NumberUtil'; import {matchQualifiedIds} from 'Util/QualifiedId'; @@ -1509,7 +1510,10 @@ export class MessageRepository { } const messageContentType = genericMessage.content; + let actionType; + let isRichText: boolean | undefined = undefined; + switch (messageContentType) { case 'asset': { const protoAsset = genericMessage.asset; @@ -1548,6 +1552,9 @@ export class MessageRepository { if (!length) { actionType = 'text'; } + if (protoText) { + isRichText = isMarkdownText(protoText.content); + } break; } @@ -1571,7 +1578,11 @@ export class MessageRepository { [Segmentation.CONVERSATION.TYPE]: trackingHelpers.getConversationType(conversationEntity), [Segmentation.CONVERSATION.SERVICES]: roundLogarithmic(services, 6), [Segmentation.MESSAGE.ACTION]: actionType, + ...(isRichText !== undefined && { + [Segmentation.IS_RICH_TEXT]: isRichText, + }), }; + const isTeamConversation = !!conversationEntity.teamId; if (isTeamConversation) { segmentations = { diff --git a/src/script/tracking/Segmentation.ts b/src/script/tracking/Segmentation.ts index 5bcc36987e4..bf42aeb6e2f 100644 --- a/src/script/tracking/Segmentation.ts +++ b/src/script/tracking/Segmentation.ts @@ -51,6 +51,7 @@ export const Segmentation = { IS_REPLY: 'message_is_reply', MENTION: 'message_mention', }, + IS_RICH_TEXT: 'is_rich_text', SCREEN_SHARE: { DIRECTION: 'screen_share_direction', DURATION: 'screen_share_duration', diff --git a/src/script/util/MarkdownUtil.test.ts b/src/script/util/MarkdownUtil.test.ts new file mode 100644 index 00000000000..dfd1c5f36f9 --- /dev/null +++ b/src/script/util/MarkdownUtil.test.ts @@ -0,0 +1,116 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {isMarkdownText} from './MarkdownUtil'; + +describe('MarkdownUtil', () => { + describe('isMarkdownText', () => { + it('returns false for empty text', () => { + expect(isMarkdownText('')).toBe(false); + }); + + it('returns true for headers', () => { + expect(isMarkdownText('# Header')).toBe(true); + expect(isMarkdownText('## Header')).toBe(true); + expect(isMarkdownText('###### Header')).toBe(true); + }); + + it('returns true for bold text', () => { + expect(isMarkdownText('**bold**')).toBe(true); + expect(isMarkdownText('__bold__')).toBe(true); + }); + + it('returns true for italic text', () => { + expect(isMarkdownText('*italic*')).toBe(true); + expect(isMarkdownText('_italic_')).toBe(true); + }); + + it('returns true for links', () => { + expect(isMarkdownText('[example](http://example.com)')).toBe(true); + }); + + it('returns true for images', () => { + expect(isMarkdownText('![alt text](image.jpg)')).toBe(true); + }); + + it('returns true for unordered lists', () => { + expect(isMarkdownText('- item')).toBe(true); + expect(isMarkdownText('* item')).toBe(true); + expect(isMarkdownText('+ item')).toBe(true); + }); + + it('returns true for ordered lists', () => { + expect(isMarkdownText('1. item')).toBe(true); + }); + + it('returns true for blockquotes', () => { + expect(isMarkdownText('> quote')).toBe(true); + }); + + it('returns true for code blocks', () => { + expect(isMarkdownText('```\ncode\n```')).toBe(true); + expect(isMarkdownText('`code`')).toBe(true); + }); + + it('returns true for horizontal rules', () => { + expect(isMarkdownText('---')).toBe(true); + expect(isMarkdownText('***')).toBe(true); + expect(isMarkdownText('___')).toBe(true); + }); + + it('returns true for tables', () => { + expect(isMarkdownText('| Header |')).toBe(true); + expect(isMarkdownText('|---|')).toBe(true); + }); + + it('returns true for strikethrough', () => { + expect(isMarkdownText('~~strikethrough~~')).toBe(true); + }); + + it('returns false for plain text', () => { + expect(isMarkdownText('plain text')).toBe(false); + }); + + it('returns true for a mix of Markdown features', () => { + expect(isMarkdownText('# Header with [link](http://example.com)')).toBe(true); + expect(isMarkdownText('**Bold and _italic_**')).toBe(true); + expect(isMarkdownText('- item with `inline code`')).toBe(true); + expect(isMarkdownText('> Quote with *italic*')).toBe(true); + }); + + it('returns true for multi-line Markdown', () => { + expect( + isMarkdownText(`\`\`\` + Line 1 + Line 2 + \`\`\``), + ).toBe(true); + expect( + isMarkdownText(`1. Item 1 + 2. Item 2`), + ).toBe(true); + }); + + it('handles escaped Markdown patterns correctly', () => { + expect(isMarkdownText('\\*not italic\\*')).toBe(false); + expect(isMarkdownText('Some \\`inline code\\` here')).toBe(false); + expect(isMarkdownText('\\> Not a blockquote')).toBe(false); + }); + }); +}); diff --git a/src/script/util/MarkdownUtil.ts b/src/script/util/MarkdownUtil.ts new file mode 100644 index 00000000000..1f5ec0e836e --- /dev/null +++ b/src/script/util/MarkdownUtil.ts @@ -0,0 +1,75 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export const isMarkdownText = (text: string): boolean => { + if (!text) { + return false; + } + + const markdownPatterns = [ + // Headers (e.g. # Header) + /^#{1,6}\s+/m, + + // Bold (e.g. **bold** or __bold__) + /\*\*[^*]+\*\*/, + /__[^_]+__/, + + // Italic (e.g. *italic* or _italic_) + /\*[^*]+\*/, + /_[^_]+_/, + + // Links (e.g. [text](http://example.com)) + /\[[^\]\r\n]{0,500}\]\([^()\r\n]{0,1000}\)/, + + // Images (e.g. ![alt](url)) + /!\[[^\]]*\]\([^)]*\)/, + + // Lists + /^[-*+]\s[^\n]*$/m, // Unordered (e.g. - item, * item) + /^\d+\.\s[^\n]*$/m, // Ordered (e.g. 1. item) + + // Blockquotes (e.g. > quote) + /^>\s+/m, + + // Code blocks (e.g. ``` code ``` or `inline code`) + /```[\s\S]*?```/, + /`[^`]+`/, + + // Horizontal rules (e.g. --- or *** or ___) + /^(?:[-*_]){3,}\s*$/m, + + // Tables (e.g. | Header | row | --- | :---: |) + /\|[^|]+\|/, + /^[-:|]+$/m, + + // Strikethrough (e.g., ~~text~~) + /~~[^~]+~~/, + ]; + + const invalidPatterns = [ + // Escaped markdown characters (\*not italic\*) + /\\([\\`*_{}[\]()#+\-.!>])/, + ]; + + if (invalidPatterns.some(pattern => pattern.test(text))) { + return false; + } + + return markdownPatterns.some(pattern => pattern.test(text)); +};