mattermost-mobile-upstream/app/utils/phone_number.ts
Joram Wilander 6ec7bef334
MM-70688 Autolink phone numbers in posts as tel: links (#10143)
* 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>
2026-09-14 14:32:17 -04:00

93 lines
2.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type PhoneNumberMatch = {
index: number;
length: number;
raw: string;
href: string;
}
// tel:/E.164 (+ and 7–15 digits) or NANP grouped numbers (separators required).
const PHONE_NUMBER_PATTERN = /(?:tel:(?:\+[1-9](?:[\s.\-()]*\d){6,14}|[+]?(?:[\s.\-()]*\d){7,15})|\+[1-9](?:[\s.\-()]*\d){6,14}|(?:\+?1[\s.\-]?)?(?:\(\d{3}\)[\s.\-]?|\d{3}[\s.\-])\d{3}[\s.\-]\d{4})/i;
const MIN_PHONE_DIGITS = 7;
const MAX_PHONE_DIGITS = 15;
// Hyphens and dots continue a digit run (`+123456789012345-6`). Spaces and
// parentheses are left as boundaries so a following number still matches.
const PHONE_CONTINUATION = /[.\-]/;
export function isTelHref(href: string): boolean {
return href.toLowerCase().startsWith('tel:');
}
export function toTelHref(raw: string): string {
const withoutScheme = raw.replace(/^tel:/i, '');
const hasPlus = withoutScheme.trimStart().startsWith('+');
const digits = withoutScheme.replace(/\D/g, '');
return `tel:${hasPlus ? '+' : ''}${digits}`;
}
function isDigit(char: string) {
return char >= '0' && char <= '9';
}
function isValidPhoneBoundary(text: string, index: number, direction: -1 | 1) {
const start = direction === -1 ? index - 1 : index;
if (start < 0 || start >= text.length) {
return true;
}
if ((/[0-9A-Za-z]/).test(text.charAt(start))) {
return false;
}
let i = start;
while (i >= 0 && i < text.length && PHONE_CONTINUATION.test(text.charAt(i))) {
i += direction;
}
if (i < 0 || i >= text.length) {
return true;
}
return !isDigit(text.charAt(i));
}
function isValidDigitCount(href: string): boolean {
const digits = href.replace(/^tel:\+?/, '');
return digits.length >= MIN_PHONE_DIGITS && digits.length <= MAX_PHONE_DIGITS;
}
export function findPhoneNumbers(text: string): PhoneNumberMatch[] {
if (!text) {
return [];
}
const matches: PhoneNumberMatch[] = [];
const regex = new RegExp(PHONE_NUMBER_PATTERN.source, 'gi');
let result = regex.exec(text);
while (result) {
const raw = result[0];
const index = result.index;
if (isValidPhoneBoundary(text, index, -1) && isValidPhoneBoundary(text, index + raw.length, 1)) {
const href = toTelHref(raw);
if (isValidDigitCount(href)) {
matches.push({
index,
length: raw.length,
raw,
href,
});
}
}
result = regex.exec(text);
}
return matches;
}