Signal-Desktop/ts/components/conversation/Linkify.tsx

408 lines
6.3 KiB
TypeScript

// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import LinkifyIt from 'linkify-it';
import type { RenderTextCallbackType } from '../../types/Util';
import { isLinkSneaky, shouldLinkifyMessage } from '../../types/LinkPreview';
import { splitByEmoji } from '../../util/emoji';
import { missingCaseError } from '../../util/missingCaseError';
const linkify = LinkifyIt()
// This is all TLDs in place in 2010, according to [IANA's root zone database][0]
// except for those domains marked as [a test domain][1].
//
// Note that this only applies to "fuzzy" matches (`example.com`), not matches with
// protocols (`https://example.com`). See [this GitHub comment][2] for more.
//
// [0]: https://www.iana.org/domains/root/db
// [1]: https://www.iana.org/domains/reserved
// [2]: https://github.com/signalapp/Signal-Desktop/issues/4538#issuecomment-748368590
.tlds([
'ac',
'ad',
'ae',
'aero',
'af',
'ag',
'ai',
'al',
'am',
'an',
'ao',
'aq',
'ar',
'arpa',
'as',
'asia',
'at',
'au',
'aw',
'ax',
'az',
'ba',
'bb',
'bd',
'be',
'bf',
'bg',
'bh',
'bi',
'biz',
'bj',
'bl',
'bm',
'bn',
'bo',
'bq',
'br',
'bs',
'bt',
'bv',
'bw',
'by',
'bz',
'ca',
'cat',
'cc',
'cd',
'cf',
'cg',
'ch',
'ci',
'ck',
'cl',
'cm',
'cn',
'co',
'com',
'coop',
'cr',
'cu',
'cv',
'cw',
'cx',
'cy',
'cz',
'de',
'dj',
'dk',
'dm',
'do',
'dz',
'ec',
'edu',
'ee',
'eg',
'er',
'es',
'et',
'eu',
'fi',
'fj',
'fk',
'fm',
'fo',
'fr',
'ga',
'gb',
'gd',
'ge',
'gf',
'gg',
'gh',
'gi',
'gl',
'gm',
'gn',
'gov',
'gp',
'gq',
'gr',
'gs',
'gt',
'gu',
'gw',
'gy',
'hk',
'hm',
'hn',
'hr',
'ht',
'hu',
'id',
'ie',
'il',
'im',
'in',
'info',
'int',
'io',
'iq',
'ir',
'is',
'it',
'je',
'jm',
'jo',
'jobs',
'jp',
'ke',
'kg',
'kh',
'ki',
'km',
'kn',
'kp',
'kr',
'kw',
'ky',
'kz',
'la',
'lb',
'lc',
'li',
'lk',
'lr',
'ls',
'lt',
'lu',
'lv',
'ly',
'ma',
'mc',
'md',
'me',
'mf',
'mg',
'mh',
'mil',
'mk',
'ml',
'mm',
'mn',
'mo',
'mobi',
'mp',
'mq',
'mr',
'ms',
'mt',
'mu',
'museum',
'mv',
'mw',
'mx',
'my',
'mz',
'na',
'name',
'nc',
'ne',
'net',
'nf',
'ng',
'ni',
'nl',
'no',
'np',
'nr',
'nu',
'nz',
'om',
'org',
'pa',
'pe',
'pf',
'pg',
'ph',
'pk',
'pl',
'pm',
'pn',
'pr',
'pro',
'ps',
'pt',
'pw',
'py',
'qa',
're',
'ro',
'rs',
'ru',
'rw',
'sa',
'sb',
'sc',
'sd',
'se',
'sg',
'sh',
'si',
'sj',
'sk',
'sl',
'sm',
'sn',
'so',
'sr',
'st',
'su',
'sv',
'sx',
'sy',
'sz',
'tc',
'td',
'tel',
'tf',
'tg',
'th',
'tj',
'tk',
'tl',
'tm',
'tn',
'to',
'tp',
'tr',
'travel',
'tt',
'tv',
'tw',
'tz',
'ua',
'ug',
'uk',
'um',
'us',
'uy',
'uz',
'va',
'vc',
've',
'vg',
'vi',
'vn',
'vu',
'wf',
'ws',
'中国',
'中國',
'ලංකා',
'香港',
'台湾',
'台灣',
'امارات',
'الاردن',
'السعودية',
'ไทย',
'рф',
'تونس',
'مصر',
'قطر',
'இலங்கை',
'فلسطين',
'ye',
'yt',
'za',
'zm',
'zw',
]);
export type Props = {
text: string;
/** Allows you to customize how non-links are rendered. Simplest is just a <span>. */
renderNonLink?: RenderTextCallbackType;
};
const SUPPORTED_PROTOCOLS = /^(http|https):/i;
export class Linkify extends React.Component<Props> {
public static defaultProps: Partial<Props> = {
renderNonLink: ({ text }) => text,
};
public override render():
| JSX.Element
| string
| null
| Array<JSX.Element | string | null> {
const { text, renderNonLink } = this.props;
if (!shouldLinkifyMessage(text)) {
return text;
}
// We have to do this, because renderNonLink is not required in our Props object,
// but it is always provided via defaultProps.
if (!renderNonLink) {
return null;
}
const chunkData: Array<{
chunk: string;
matchData: ReadonlyArray<LinkifyIt.Match>;
}> = splitByEmoji(text).map(({ type, value: chunk }) => {
if (type === 'text') {
return { chunk, matchData: linkify.match(chunk) || [] };
}
if (type === 'emoji') {
return { chunk, matchData: [] };
}
throw missingCaseError(type);
});
const results: Array<JSX.Element | string> = [];
let count = 1;
chunkData.forEach(({ chunk, matchData }) => {
if (matchData.length === 0) {
count += 1;
results.push(renderNonLink({ text: chunk, key: count }));
return;
}
let chunkLastIndex = 0;
matchData.forEach(match => {
if (chunkLastIndex < match.index) {
const textWithNoLink = chunk.slice(chunkLastIndex, match.index);
count += 1;
results.push(renderNonLink({ text: textWithNoLink, key: count }));
}
const { url, text: originalText } = match;
count += 1;
if (SUPPORTED_PROTOCOLS.test(url) && !isLinkSneaky(url)) {
results.push(
<a key={count} href={url}>
{originalText}
</a>
);
} else {
results.push(renderNonLink({ text: originalText, key: count }));
}
chunkLastIndex = match.lastIndex;
});
if (chunkLastIndex < chunk.length) {
count += 1;
results.push(
renderNonLink({
text: chunk.slice(chunkLastIndex),
key: count,
})
);
}
});
return results;
}
}