mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-05 02:18:56 +00:00
* Add phone number matching helpers for tel: autolinks. Detect E.164, grouped national numbers, and tel: URIs while skipping bare digit runs that look like ticket IDs or dates. Co-authored-by: joram <joram@mattermost.com> * Autolink phone numbers in post markdown as tel: links. Wrap recognized numbers in markdown link nodes after parsing so tapping opens the device dialer. Skip code, existing links, and unsafe posts. Co-authored-by: joram <joram@mattermost.com> * Copy phone numbers from tel: markdown links on long-press. Treat tel: like mailto: in link and channel-header sheets, and cover opening tel: URLs through the existing openLink path. Co-authored-by: joram <joram@mattermost.com> * Reject truncated phone matches inside longer digit sequences. Skip past hyphens and dots when checking match boundaries so overlong numbers and numeric prefixes are not autolinked. Keep spaces and parentheses as boundaries so adjacent numbers still match. Also address CodeRabbit test nits: assert match counts, prefix autolinkPhoneNumbers cases with should, and assert rendered tel: links. Co-authored-by: joram <joram@mattermost.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
549 lines
16 KiB
TypeScript
549 lines
16 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {Node, type NodeType} from 'commonmark';
|
|
import urlParse from 'url-parse';
|
|
|
|
import {DeepLink} from '@constants';
|
|
import {parseDeepLink} from '@utils/deep_link';
|
|
import {escapeRegex} from '@utils/markdown';
|
|
import {findPhoneNumbers} from '@utils/phone_number';
|
|
import {safeDecodeURIComponent} from '@utils/url';
|
|
|
|
import type {HighlightWithoutNotificationKey, SearchPattern, UserMentionKey} from '@typings/global/markdown';
|
|
import type {DeepLinkChannel, DeepLinkPermalink} from '@typings/launch';
|
|
|
|
/* eslint-disable no-underscore-dangle */
|
|
|
|
const cjkPattern = /[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf\u3400-\u4dbf\uac00-\ud7a3]/;
|
|
|
|
// Combines adjacent text nodes into a single text node to make further transformation easier
|
|
export function combineTextNodes(ast: any) {
|
|
const walker = ast.walker();
|
|
|
|
let e;
|
|
while ((e = walker.next())) {
|
|
if (!e.entering) {
|
|
continue;
|
|
}
|
|
|
|
const node = e.node;
|
|
|
|
if (node.type !== 'text') {
|
|
continue;
|
|
}
|
|
|
|
while (node._next && node._next.type === 'text') {
|
|
const next = node._next;
|
|
|
|
node.literal += next.literal;
|
|
|
|
node._next = next._next;
|
|
if (node._next) {
|
|
node._next._prev = node;
|
|
}
|
|
|
|
if (node._parent._lastChild === next) {
|
|
node._parent._lastChild = node;
|
|
}
|
|
}
|
|
|
|
// Resume parsing after the current node since otherwise the walker would continue to parse any old text nodes
|
|
// that have been merged into this one
|
|
walker.resumeAt(node, false);
|
|
}
|
|
|
|
return ast;
|
|
}
|
|
|
|
// Add indices to the items of every list
|
|
export function addListItemIndices(ast: any) {
|
|
const walker = ast.walker();
|
|
|
|
let e;
|
|
while ((e = walker.next())) {
|
|
if (e.entering) {
|
|
const node = e.node;
|
|
|
|
if (node.type === 'list') {
|
|
let i = node.listStart == null ? 1 : node.listStart; // List indices match what would be displayed in the UI
|
|
|
|
for (let child = node.firstChild; child; child = child.next) {
|
|
child.index = i;
|
|
|
|
i += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return ast;
|
|
}
|
|
|
|
// Take all images that aren't inside of tables and move them to be children of the root document node.
|
|
// When this happens, their parent nodes are split into two, if necessary, with the version that follows
|
|
// the image having its "continue" field set to true to indicate that things like bullet points don't
|
|
// need to be rendered.
|
|
export function pullOutImages(ast: any) {
|
|
const walker = ast.walker();
|
|
|
|
let e;
|
|
while ((e = walker.next())) {
|
|
if (!e.entering) {
|
|
continue;
|
|
}
|
|
|
|
const node = e.node;
|
|
|
|
// Skip tables because we'll render images inside of those as links
|
|
if (node.type === 'table') {
|
|
walker.resumeAt(node, false);
|
|
continue;
|
|
}
|
|
|
|
if (node.type === 'image' && node.parent?.type !== 'document') {
|
|
pullOutImage(node);
|
|
}
|
|
}
|
|
|
|
return ast;
|
|
}
|
|
|
|
function pullOutImage(image: any) {
|
|
const parent = image.parent;
|
|
|
|
if (parent?.type === 'link') {
|
|
image.linkDestination = parent.destination;
|
|
}
|
|
}
|
|
|
|
export function highlightMentions(ast: Node, mentionKeys: UserMentionKey[]) {
|
|
const walker = ast.walker();
|
|
|
|
const patterns = mentionKeysToPatterns(mentionKeys);
|
|
|
|
let e;
|
|
while ((e = walker.next())) {
|
|
if (!e.entering) {
|
|
continue;
|
|
}
|
|
|
|
const node = e.node;
|
|
|
|
if (node.type === 'text' && node.literal) {
|
|
const {index, length} = getFirstMatch(node.literal, patterns);
|
|
|
|
if (index === -1) {
|
|
continue;
|
|
}
|
|
|
|
const mentionNode = highlightTextNode(node, index, index + length, 'mention_highlight');
|
|
|
|
// Resume processing on the next node after the mention node which may include any remaining text
|
|
// that was part of this one
|
|
walker.resumeAt(mentionNode, false);
|
|
} else if (node.type === 'at_mention') {
|
|
const matches = mentionKeys.some((mention) => {
|
|
const mentionName = '@' + node.mentionName;
|
|
const flags = mention.caseSensitive ? '' : 'i';
|
|
const pattern = new RegExp(`@${escapeRegex(mention.key.replace('@', ''))}\\.?\\b`, flags);
|
|
|
|
return pattern.test(mentionName);
|
|
});
|
|
|
|
if (!matches) {
|
|
continue;
|
|
}
|
|
|
|
const wrapper = new Node('mention_highlight');
|
|
wrapNode(wrapper, node);
|
|
|
|
// Skip processing the wrapper to prevent checking this node again as its child
|
|
walker.resumeAt(wrapper, false);
|
|
}
|
|
}
|
|
|
|
return ast;
|
|
}
|
|
|
|
export function mentionKeysToPatterns(mentionKeys: UserMentionKey[]) {
|
|
return mentionKeys.filter((mention) => mention.key.trim() !== '').map((mention) => {
|
|
const flags = mention.caseSensitive ? '' : 'i';
|
|
let pattern;
|
|
if (cjkPattern.test(mention.key)) {
|
|
pattern = new RegExp(`${escapeRegex(mention.key)}`, flags);
|
|
} else {
|
|
pattern = new RegExp(`\\b${escapeRegex(mention.key)}(?=_*\\b)`, flags);
|
|
}
|
|
|
|
return pattern;
|
|
});
|
|
}
|
|
|
|
export function highlightWithoutNotification(ast: Node, highlightKeys: HighlightWithoutNotificationKey[]) {
|
|
const walker = ast.walker();
|
|
|
|
const patterns = highlightKeysToPatterns(highlightKeys);
|
|
|
|
let e;
|
|
while ((e = walker.next())) {
|
|
if (!e.entering) {
|
|
continue;
|
|
}
|
|
|
|
const node = e.node;
|
|
if (node.type === 'text' && node.literal) {
|
|
const {index, length} = getFirstMatch(node.literal, patterns);
|
|
|
|
// If the text node doesn't match any of the patterns, skip the loop
|
|
if (index === -1) {
|
|
continue;
|
|
}
|
|
|
|
const matchNode = highlightTextNode(node, index, index + length, 'highlight_without_notification');
|
|
|
|
// Resume processing on the next node after the match node which may include any remaining text
|
|
// that was part of this one
|
|
walker.resumeAt(matchNode, false);
|
|
}
|
|
}
|
|
return ast;
|
|
}
|
|
|
|
export function highlightKeysToPatterns(highlightKeys: HighlightWithoutNotificationKey[]) {
|
|
if (highlightKeys.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
return highlightKeys.
|
|
filter((highlight) => highlight.key.trim() !== '').
|
|
sort((a, b) => b.key.length - a.key.length).
|
|
map(({key}) => {
|
|
if (cjkPattern.test(key)) {
|
|
// If the key contains Chinese, Japanese, Korean or Russian characters, don't mark word boundaries
|
|
return new RegExp(`${escapeRegex(key)}`, 'gi');
|
|
}
|
|
|
|
// If the key contains only English characters, mark word boundaries
|
|
return new RegExp(`(^|\\b)(${escapeRegex(key)})(?=_*\\b)`, 'gi');
|
|
});
|
|
}
|
|
|
|
export function highlightSearchPatterns(ast: Node, searchPatterns: SearchPattern[]) {
|
|
const walker = ast.walker();
|
|
|
|
let e;
|
|
while ((e = walker.next())) {
|
|
if (!e.entering) {
|
|
continue;
|
|
}
|
|
|
|
const node = e.node;
|
|
if ((node.type === 'text' || node.type === 'code') && node.literal) {
|
|
for (const patternPattern of searchPatterns) {
|
|
const {index, length} = getFirstMatch(node.literal, [patternPattern.pattern]);
|
|
|
|
// TODO we might need special handling here for if the search term is part of a hashtag
|
|
|
|
if (index === -1) {
|
|
continue;
|
|
}
|
|
|
|
const matchNode = highlightTextNode(node, index, index + length, 'search_highlight');
|
|
|
|
// Resume processing on the next node after the match node which may include any remaining text
|
|
// that was part of this one
|
|
walker.resumeAt(matchNode, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
return ast;
|
|
}
|
|
|
|
// Given a string and an array of regexes, returns the index and length of the first match.
|
|
export function getFirstMatch(str: string, patterns: RegExp[]) {
|
|
let firstMatchIndex = -1;
|
|
let firstMatchLength = -1;
|
|
|
|
for (const pattern of patterns) {
|
|
let matchResult;
|
|
if (pattern.global || pattern.sticky) {
|
|
// Since regex objects are stateful in global or sticky flags, we need to reset
|
|
const regex = new RegExp(pattern.source, pattern.flags);
|
|
matchResult = regex.exec(str);
|
|
} else {
|
|
matchResult = pattern.exec(str);
|
|
}
|
|
|
|
if (!matchResult || matchResult[0] === '') {
|
|
continue;
|
|
}
|
|
|
|
if (firstMatchIndex === -1 || matchResult.index < firstMatchIndex) {
|
|
firstMatchIndex = matchResult.index;
|
|
firstMatchLength = matchResult[0].length;
|
|
}
|
|
}
|
|
|
|
return {
|
|
index: firstMatchIndex,
|
|
length: firstMatchLength,
|
|
};
|
|
}
|
|
|
|
// Given a text node, start/end indices, and a highlight node type, splits it into up to three nodes:
|
|
// the text before the highlight (if any exists), the highlighted text, and the text after the highlight
|
|
// the end of the highlight (if any exists). Returns a node containing the highlighted text.
|
|
export function highlightTextNode(node: Node, start: number, end: number, type: NodeType) {
|
|
const literal = node.literal;
|
|
node.literal = literal!.substring(start, end);
|
|
|
|
// Start by wrapping the node and then re-insert any non-highlighted code around it
|
|
const highlighted = new Node(type);
|
|
wrapNode(highlighted, node);
|
|
|
|
if (start !== 0) {
|
|
const before = new Node('text');
|
|
before.literal = literal!.substring(0, start);
|
|
|
|
highlighted.insertBefore(before);
|
|
}
|
|
|
|
if (end !== literal!.length) {
|
|
const after = new Node('text');
|
|
after.literal = literal!.substring(end);
|
|
|
|
highlighted.insertAfter(after);
|
|
}
|
|
|
|
return highlighted;
|
|
}
|
|
|
|
const PHONE_AUTOLINK_SKIP_TYPES = new Set(['link', 'image']);
|
|
|
|
function hasSkippedPhoneAutolinkAncestor(node: Node) {
|
|
let current = node.parent;
|
|
while (current) {
|
|
if (PHONE_AUTOLINK_SKIP_TYPES.has(current.type)) {
|
|
return true;
|
|
}
|
|
current = current.parent;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export function autolinkPhoneNumbers(ast: Node) {
|
|
const walker = ast.walker();
|
|
|
|
let e;
|
|
while ((e = walker.next())) {
|
|
if (!e.entering) {
|
|
continue;
|
|
}
|
|
|
|
const node = e.node;
|
|
if (node.type !== 'text' || !node.literal) {
|
|
continue;
|
|
}
|
|
|
|
if (hasSkippedPhoneAutolinkAncestor(node)) {
|
|
continue;
|
|
}
|
|
|
|
const match = findPhoneNumbers(node.literal)[0];
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
|
|
const linkNode = highlightTextNode(node, match.index, match.index + match.length, 'link');
|
|
linkNode.destination = match.href;
|
|
linkNode.title = '';
|
|
|
|
walker.resumeAt(linkNode, false);
|
|
}
|
|
|
|
return ast;
|
|
}
|
|
|
|
// Wraps a given node in another node of the given type. The wrapper will take the place of
|
|
// the node in the AST relative to its parents and siblings, and it will have the node as
|
|
// its only child.
|
|
function wrapNode(wrapper: any, node: any) {
|
|
// Set parent and update parent's children if necessary
|
|
wrapper._parent = node._parent;
|
|
if (node._parent._firstChild === node) {
|
|
node._parent._firstChild = wrapper;
|
|
}
|
|
if (node._parent._lastChild === node) {
|
|
node._parent._lastChild = wrapper;
|
|
}
|
|
|
|
// Set siblings and update those if necessary
|
|
wrapper._prev = node._prev;
|
|
node._prev = null;
|
|
if (wrapper._prev) {
|
|
wrapper._prev._next = wrapper;
|
|
}
|
|
|
|
wrapper._next = node._next;
|
|
node._next = null;
|
|
if (wrapper._next) {
|
|
wrapper._next._prev = wrapper;
|
|
}
|
|
|
|
// Make node a child of wrapper
|
|
wrapper._firstChild = node;
|
|
wrapper._lastChild = node;
|
|
node._parent = wrapper;
|
|
}
|
|
|
|
export function parseTaskLists(ast: Node) {
|
|
const walker = ast.walker();
|
|
|
|
let e;
|
|
while ((e = walker.next())) {
|
|
if (!e.entering) {
|
|
continue;
|
|
}
|
|
|
|
const node = e.node;
|
|
|
|
if (node.type !== 'item') {
|
|
continue;
|
|
}
|
|
|
|
if (node.firstChild?.type === 'paragraph' && node.firstChild?.firstChild?.type === 'text') {
|
|
const paragraphNode = node.firstChild;
|
|
const textNode = node.firstChild.firstChild;
|
|
|
|
const literal = textNode.literal ?? '';
|
|
|
|
const match = (/^ {0,3}\[( |x)\]\s/).exec(literal);
|
|
if (match) {
|
|
const checkbox = new Node('checkbox');
|
|
checkbox.isChecked = match[1] === 'x';
|
|
|
|
paragraphNode.prependChild(checkbox);
|
|
|
|
textNode.literal = literal.substring(match[0].length);
|
|
}
|
|
}
|
|
}
|
|
|
|
return ast;
|
|
}
|
|
|
|
/**
|
|
* Parse a citation URL to extract entity type and identifier.
|
|
* Citation URLs must have view=citation as a query parameter.
|
|
* Uses parseDeepLink for URL structural parsing.
|
|
*
|
|
* URL patterns:
|
|
* - Post: /team/pl/postId?view=citation
|
|
* - Channel: /team/channels/channelName?view=citation
|
|
*
|
|
* @param url - The URL to parse
|
|
* @param serverUrl - Optional server URL to validate this is an internal link
|
|
* @returns Parsed citation info or null if not a valid citation URL
|
|
*/
|
|
export function parseCitationUrl(url: string, serverUrl?: string): {entityType: string; entityId: string; linkUrl: string} | null {
|
|
const decodedUrl = safeDecodeURIComponent(url);
|
|
const parsedUrl = urlParse(decodedUrl, true);
|
|
|
|
// Check if view=citation is a proper query parameter
|
|
if (parsedUrl.query.view !== 'citation') {
|
|
return null;
|
|
}
|
|
|
|
// If serverUrl is provided, validate this is an internal link
|
|
if (serverUrl) {
|
|
const parsedServerUrl = urlParse(serverUrl);
|
|
const normalizedServerHost = parsedServerUrl.hostname.toLowerCase();
|
|
const normalizedUrlHost = parsedUrl.hostname.toLowerCase();
|
|
|
|
if (normalizedUrlHost !== normalizedServerHost) {
|
|
return null;
|
|
}
|
|
|
|
if (parsedServerUrl.port && parsedUrl.port !== parsedServerUrl.port) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Use parseDeepLink to determine the entity type from the URL structure
|
|
const deepLink = parseDeepLink(decodedUrl);
|
|
|
|
switch (deepLink.type) {
|
|
case DeepLink.Permalink: {
|
|
const data = deepLink.data as DeepLinkPermalink;
|
|
return {
|
|
entityType: 'POST',
|
|
entityId: data.postId,
|
|
linkUrl: url,
|
|
};
|
|
}
|
|
case DeepLink.Channel: {
|
|
const data = deepLink.data as DeepLinkChannel;
|
|
return {
|
|
entityType: 'CHANNEL',
|
|
entityId: data.channelName,
|
|
linkUrl: url,
|
|
};
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process markdown links with ?view=citation query parameter and
|
|
* replace them with inline_entity_link nodes that will be rendered as clickable link icons.
|
|
*
|
|
* This handles the new citation format:
|
|
* - [text](http://server/team/pl/postId?view=citation) -> post citation
|
|
* - [text](http://server/team/channels/channelName?view=citation) -> channel citation
|
|
* - [text](http://server/team?view=citation) -> team citation
|
|
*
|
|
* @param ast - The AST to process
|
|
* @param serverUrl - Optional server URL to validate internal links only
|
|
*/
|
|
export function processInlineEntities(ast: Node, serverUrl?: string) {
|
|
const walker = ast.walker();
|
|
|
|
let e;
|
|
while ((e = walker.next())) {
|
|
if (!e.entering) {
|
|
continue;
|
|
}
|
|
|
|
const node: any = e.node;
|
|
|
|
// Look for link nodes with citation query parameter
|
|
if (node.type !== 'link' || !node.destination) {
|
|
continue;
|
|
}
|
|
|
|
const parsed = parseCitationUrl(node.destination, serverUrl);
|
|
if (!parsed) {
|
|
continue;
|
|
}
|
|
|
|
// Create the inline entity link node
|
|
const entityNode: any = new Node('inline_entity_link' as any);
|
|
entityNode.entityType = parsed.entityType;
|
|
entityNode.entityId = parsed.entityId;
|
|
entityNode.linkUrl = parsed.linkUrl;
|
|
|
|
// Replace the link node with the entity node
|
|
node.insertBefore(entityNode);
|
|
node.unlink();
|
|
|
|
// Resume at the entity node
|
|
walker.resumeAt(entityNode, false);
|
|
}
|
|
|
|
return ast;
|
|
}
|