Better emoji support in linkify/previews
This commit is contained in:
parent
65ad608aa7
commit
773aa9af19
15 changed files with 337 additions and 260 deletions
14
js/modules/link_previews.d.ts
vendored
14
js/modules/link_previews.d.ts
vendored
|
@ -1,14 +0,0 @@
|
||||||
// Copyright 2019-2020 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
export function isLinkSafeToPreview(link: string): boolean;
|
|
||||||
|
|
||||||
export function findLinks(text: string, caretLocation?: number): Array<string>;
|
|
||||||
|
|
||||||
export function getDomain(href: string): string;
|
|
||||||
|
|
||||||
export function isGroupLink(href: string): boolean;
|
|
||||||
|
|
||||||
export function isLinkSneaky(link: string): boolean;
|
|
||||||
|
|
||||||
export function isStickerPack(href: string): boolean;
|
|
|
@ -1,163 +0,0 @@
|
||||||
// Copyright 2019-2021 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
const { isNumber, compact, isEmpty, range } = require('lodash');
|
|
||||||
const nodeUrl = require('url');
|
|
||||||
const LinkifyIt = require('linkify-it');
|
|
||||||
const { maybeParseUrl } = require('../../ts/util/url');
|
|
||||||
|
|
||||||
const linkify = LinkifyIt();
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
findLinks,
|
|
||||||
getDomain,
|
|
||||||
isGroupLink,
|
|
||||||
isLinkSafeToPreview,
|
|
||||||
isLinkSneaky,
|
|
||||||
isStickerPack,
|
|
||||||
};
|
|
||||||
|
|
||||||
function isLinkSafeToPreview(href) {
|
|
||||||
const url = maybeParseUrl(href);
|
|
||||||
return Boolean(url && url.protocol === 'https:' && !isLinkSneaky(href));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStickerPack(link) {
|
|
||||||
return (link || '').startsWith('https://signal.art/addstickers/');
|
|
||||||
}
|
|
||||||
|
|
||||||
function isGroupLink(link) {
|
|
||||||
return (link || '').startsWith('https://signal.group/');
|
|
||||||
}
|
|
||||||
|
|
||||||
function findLinks(text, caretLocation) {
|
|
||||||
const haveCaretLocation = isNumber(caretLocation);
|
|
||||||
const textLength = text ? text.length : 0;
|
|
||||||
|
|
||||||
const matches = linkify.match(text || '') || [];
|
|
||||||
return compact(
|
|
||||||
matches.map(match => {
|
|
||||||
if (!haveCaretLocation) {
|
|
||||||
return match.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (match.lastIndex === textLength && caretLocation === textLength) {
|
|
||||||
return match.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (match.index > caretLocation || match.lastIndex < caretLocation) {
|
|
||||||
return match.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDomain(href) {
|
|
||||||
const url = maybeParseUrl(href);
|
|
||||||
return url ? url.hostname : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// See <https://tools.ietf.org/html/rfc3986>.
|
|
||||||
const VALID_URI_CHARACTERS = new Set([
|
|
||||||
'%',
|
|
||||||
// "gen-delims"
|
|
||||||
':',
|
|
||||||
'/',
|
|
||||||
'?',
|
|
||||||
'#',
|
|
||||||
'[',
|
|
||||||
']',
|
|
||||||
'@',
|
|
||||||
// "sub-delims"
|
|
||||||
'!',
|
|
||||||
'$',
|
|
||||||
'&',
|
|
||||||
"'",
|
|
||||||
'(',
|
|
||||||
')',
|
|
||||||
'*',
|
|
||||||
'+',
|
|
||||||
',',
|
|
||||||
';',
|
|
||||||
'=',
|
|
||||||
// unreserved
|
|
||||||
...String.fromCharCode(...range(65, 91), ...range(97, 123)),
|
|
||||||
...range(10).map(String),
|
|
||||||
'-',
|
|
||||||
'.',
|
|
||||||
'_',
|
|
||||||
'~',
|
|
||||||
]);
|
|
||||||
const ASCII_PATTERN = new RegExp('[\\u0020-\\u007F]', 'g');
|
|
||||||
const MAX_HREF_LENGTH = 2 ** 12;
|
|
||||||
|
|
||||||
function isLinkSneaky(href) {
|
|
||||||
// This helps users avoid extremely long links (which could be hiding something
|
|
||||||
// sketchy) and also sidesteps the performance implications of extremely long hrefs.
|
|
||||||
if (href.length > MAX_HREF_LENGTH) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = maybeParseUrl(href);
|
|
||||||
|
|
||||||
// If we can't parse it, it's sneaky.
|
|
||||||
if (!url) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Any links which contain auth are considered sneaky
|
|
||||||
if (url.username || url.password) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the domain is falsy, something fishy is going on
|
|
||||||
if (!url.hostname) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// To quote [RFC 1034][0]: "the total number of octets that represent a
|
|
||||||
// domain name [...] is limited to 255." To be extra careful, we set a
|
|
||||||
// maximum of 2048. (This also uses the string's `.length` property,
|
|
||||||
// which isn't exactly the same thing as the number of octets.)
|
|
||||||
// [0]: https://tools.ietf.org/html/rfc1034
|
|
||||||
if (url.hostname.length > 2048) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Domains cannot contain encoded characters
|
|
||||||
if (url.hostname.includes('%')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// There must be at least 2 domain labels, and none of them can be empty.
|
|
||||||
const labels = url.hostname.split('.');
|
|
||||||
if (labels.length < 2 || labels.some(isEmpty)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is necesary because getDomain returns domains in punycode form.
|
|
||||||
const unicodeDomain = nodeUrl.domainToUnicode
|
|
||||||
? nodeUrl.domainToUnicode(url.hostname)
|
|
||||||
: url.hostname;
|
|
||||||
|
|
||||||
const withoutPeriods = unicodeDomain.replace(/\./g, '');
|
|
||||||
|
|
||||||
const hasASCII = ASCII_PATTERN.test(withoutPeriods);
|
|
||||||
const withoutASCII = withoutPeriods.replace(ASCII_PATTERN, '');
|
|
||||||
|
|
||||||
const isMixed = hasASCII && withoutASCII.length > 0;
|
|
||||||
if (isMixed) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We can't use `url.pathname` (and so on) because it automatically encodes strings.
|
|
||||||
// For example, it turns `/aquí` into `/aqu%C3%AD`.
|
|
||||||
const startOfPathAndHash = href.indexOf('/', url.protocol.length + 4);
|
|
||||||
const pathAndHash =
|
|
||||||
startOfPathAndHash === -1 ? '' : href.substr(startOfPathAndHash);
|
|
||||||
return [...pathAndHash].some(
|
|
||||||
character => !VALID_URI_CHARACTERS.has(character)
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -21,7 +21,6 @@ const Stickers = require('./stickers');
|
||||||
const Settings = require('./settings');
|
const Settings = require('./settings');
|
||||||
const RemoteConfig = require('../../ts/RemoteConfig');
|
const RemoteConfig = require('../../ts/RemoteConfig');
|
||||||
const Util = require('../../ts/util');
|
const Util = require('../../ts/util');
|
||||||
const LinkPreviews = require('./link_previews');
|
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
const {
|
const {
|
||||||
|
@ -445,7 +444,6 @@ exports.setup = (options = {}) => {
|
||||||
Groups,
|
Groups,
|
||||||
GroupChange,
|
GroupChange,
|
||||||
IndexedDB,
|
IndexedDB,
|
||||||
LinkPreviews,
|
|
||||||
Migrations,
|
Migrations,
|
||||||
Notifications,
|
Notifications,
|
||||||
OS,
|
OS,
|
||||||
|
|
|
@ -7,7 +7,7 @@ const {
|
||||||
findLinks,
|
findLinks,
|
||||||
isLinkSafeToPreview,
|
isLinkSafeToPreview,
|
||||||
isLinkSneaky,
|
isLinkSneaky,
|
||||||
} = require('../../js/modules/link_previews');
|
} = require('../../ts/types/LinkPreview');
|
||||||
|
|
||||||
describe('Link previews', () => {
|
describe('Link previews', () => {
|
||||||
describe('#isLinkSafeToPreview', () => {
|
describe('#isLinkSafeToPreview', () => {
|
||||||
|
@ -54,6 +54,15 @@ describe('Link previews', () => {
|
||||||
assert.deepEqual(expected, actual);
|
assert.deepEqual(expected, actual);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns all links after emojis without spaces in between', () => {
|
||||||
|
const text = '😎https://github.com/signalapp/Signal-Desktop😛';
|
||||||
|
|
||||||
|
const expected = ['https://github.com/signalapp/Signal-Desktop'];
|
||||||
|
|
||||||
|
const actual = findLinks(text);
|
||||||
|
assert.deepEqual(expected, actual);
|
||||||
|
});
|
||||||
|
|
||||||
it('includes all links if cursor is not in a link', () => {
|
it('includes all links if cursor is not in a link', () => {
|
||||||
const text =
|
const text =
|
||||||
'Check out this link: https://github.com/signalapp/Signal-Desktop\nAnd this one too: https://github.com/signalapp/Signal-Android';
|
'Check out this link: https://github.com/signalapp/Signal-Desktop\nAnd this one too: https://github.com/signalapp/Signal-Android';
|
||||||
|
|
|
@ -5,9 +5,9 @@ import React from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import emojiRegex from 'emoji-regex';
|
|
||||||
|
|
||||||
import { RenderTextCallbackType } from '../../types/Util';
|
import { RenderTextCallbackType } from '../../types/Util';
|
||||||
|
import { splitByEmoji } from '../../util/emoji';
|
||||||
|
import { missingCaseError } from '../../util/missingCaseError';
|
||||||
import { emojiToImage, SizeClassType } from '../emoji/lib';
|
import { emojiToImage, SizeClassType } from '../emoji/lib';
|
||||||
|
|
||||||
// Some of this logic taken from emoji-js/replacement
|
// Some of this logic taken from emoji-js/replacement
|
||||||
|
@ -19,23 +19,23 @@ function getImageTag({
|
||||||
sizeClass,
|
sizeClass,
|
||||||
key,
|
key,
|
||||||
}: {
|
}: {
|
||||||
match: RegExpExecArray;
|
match: string;
|
||||||
sizeClass?: SizeClassType;
|
sizeClass?: SizeClassType;
|
||||||
key: string | number;
|
key: string | number;
|
||||||
}) {
|
}): JSX.Element | string {
|
||||||
const img = emojiToImage(match[0]);
|
const img = emojiToImage(match);
|
||||||
|
|
||||||
if (!img) {
|
if (!img) {
|
||||||
return match[0];
|
return match;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
key={key}
|
key={key}
|
||||||
src={img}
|
src={img}
|
||||||
aria-label={match[0]}
|
aria-label={match}
|
||||||
className={classNames('emoji', sizeClass)}
|
className={classNames('emoji', sizeClass)}
|
||||||
title={match[0]}
|
title={match}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -53,15 +53,8 @@ export class Emojify extends React.Component<Props> {
|
||||||
renderNonEmoji: ({ text }) => text,
|
renderNonEmoji: ({ text }) => text,
|
||||||
};
|
};
|
||||||
|
|
||||||
public render():
|
public render(): null | Array<JSX.Element | string | null> {
|
||||||
| JSX.Element
|
|
||||||
| string
|
|
||||||
| null
|
|
||||||
| Array<JSX.Element | string | null> {
|
|
||||||
const { text, sizeClass, renderNonEmoji } = this.props;
|
const { text, sizeClass, renderNonEmoji } = this.props;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const results: Array<any> = [];
|
|
||||||
const regex = emojiRegex();
|
|
||||||
|
|
||||||
// We have to do this, because renderNonEmoji is not required in our Props object,
|
// We have to do this, because renderNonEmoji is not required in our Props object,
|
||||||
// but it is always provided via defaultProps.
|
// but it is always provided via defaultProps.
|
||||||
|
@ -69,33 +62,16 @@ export class Emojify extends React.Component<Props> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let match = regex.exec(text);
|
return splitByEmoji(text).map(({ type, value: match }, index) => {
|
||||||
let last = 0;
|
if (type === 'emoji') {
|
||||||
let count = 1;
|
return getImageTag({ match, sizeClass, key: index });
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
return renderNonEmoji({ text, key: 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
while (match) {
|
|
||||||
if (last < match.index) {
|
|
||||||
const textWithNoEmoji = text.slice(last, match.index);
|
|
||||||
count += 1;
|
|
||||||
results.push(renderNonEmoji({ text: textWithNoEmoji, key: count }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
count += 1;
|
if (type === 'text') {
|
||||||
results.push(getImageTag({ match, sizeClass, key: count }));
|
return renderNonEmoji({ text: match, key: index });
|
||||||
|
}
|
||||||
|
|
||||||
last = regex.lastIndex;
|
throw missingCaseError(type);
|
||||||
match = regex.exec(text);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (last < text.length) {
|
|
||||||
count += 1;
|
|
||||||
results.push(renderNonEmoji({ text: text.slice(last), key: count }));
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,6 +32,14 @@ story.add('Links with Text', () => {
|
||||||
return <Linkify {...props} />;
|
return <Linkify {...props} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
story.add('Links with Emoji without space', () => {
|
||||||
|
const props = createProps({
|
||||||
|
text: '👍https://www.signal.org😎',
|
||||||
|
});
|
||||||
|
|
||||||
|
return <Linkify {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
story.add('No Link', () => {
|
story.add('No Link', () => {
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
text: 'I am fond of cats',
|
text: 'I am fond of cats',
|
||||||
|
|
|
@ -6,7 +6,9 @@ import React from 'react';
|
||||||
import LinkifyIt from 'linkify-it';
|
import LinkifyIt from 'linkify-it';
|
||||||
|
|
||||||
import { RenderTextCallbackType } from '../../types/Util';
|
import { RenderTextCallbackType } from '../../types/Util';
|
||||||
import { isLinkSneaky } from '../../../js/modules/link_previews';
|
import { isLinkSneaky } from '../../types/LinkPreview';
|
||||||
|
import { splitByEmoji } from '../../util/emoji';
|
||||||
|
import { missingCaseError } from '../../util/missingCaseError';
|
||||||
|
|
||||||
const linkify = LinkifyIt()
|
const linkify = LinkifyIt()
|
||||||
// This is all of the TLDs in place in 2010, according to [Wikipedia][0]. Note that
|
// This is all of the TLDs in place in 2010, according to [Wikipedia][0]. Note that
|
||||||
|
@ -55,10 +57,6 @@ export class Linkify extends React.Component<Props> {
|
||||||
| null
|
| null
|
||||||
| Array<JSX.Element | string | null> {
|
| Array<JSX.Element | string | null> {
|
||||||
const { text, renderNonLink } = this.props;
|
const { text, renderNonLink } = this.props;
|
||||||
const matchData = linkify.match(text) || [];
|
|
||||||
const results: Array<JSX.Element | string> = [];
|
|
||||||
let last = 0;
|
|
||||||
let count = 1;
|
|
||||||
|
|
||||||
// We have to do this, because renderNonLink is not required in our Props object,
|
// We have to do this, because renderNonLink is not required in our Props object,
|
||||||
// but it is always provided via defaultProps.
|
// but it is always provided via defaultProps.
|
||||||
|
@ -66,19 +64,34 @@ export class Linkify extends React.Component<Props> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchData.length === 0) {
|
const chunkData: Array<{
|
||||||
return renderNonLink({ text, key: 0 });
|
chunk: string;
|
||||||
}
|
matchData: LinkifyIt.Match[];
|
||||||
|
}> = splitByEmoji(text).map(({ type, value: chunk }) => {
|
||||||
|
if (type === 'text') {
|
||||||
|
return { chunk, matchData: linkify.match(chunk) || [] };
|
||||||
|
}
|
||||||
|
|
||||||
matchData.forEach(
|
if (type === 'emoji') {
|
||||||
(match: {
|
return { chunk, matchData: [] };
|
||||||
index: number;
|
}
|
||||||
url: string;
|
|
||||||
lastIndex: number;
|
throw missingCaseError(type);
|
||||||
text: string;
|
});
|
||||||
}) => {
|
|
||||||
|
const results: Array<JSX.Element | string> = [];
|
||||||
|
let last = 0;
|
||||||
|
let count = 1;
|
||||||
|
|
||||||
|
chunkData.forEach(({ chunk, matchData }) => {
|
||||||
|
if (matchData.length === 0) {
|
||||||
|
results.push(renderNonLink({ text: chunk, key: 0 }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
matchData.forEach(match => {
|
||||||
if (last < match.index) {
|
if (last < match.index) {
|
||||||
const textWithNoLink = text.slice(last, match.index);
|
const textWithNoLink = chunk.slice(last, match.index);
|
||||||
count += 1;
|
count += 1;
|
||||||
results.push(renderNonLink({ text: textWithNoLink, key: count }));
|
results.push(renderNonLink({ text: textWithNoLink, key: count }));
|
||||||
}
|
}
|
||||||
|
@ -96,13 +109,13 @@ export class Linkify extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
|
|
||||||
last = match.lastIndex;
|
last = match.lastIndex;
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (last < text.length) {
|
if (last < chunk.length) {
|
||||||
count += 1;
|
count += 1;
|
||||||
results.push(renderNonLink({ text: text.slice(last), key: count }));
|
results.push(renderNonLink({ text: chunk.slice(last), key: count }));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,6 +77,7 @@ import { ReadReceipts } from '../messageModifiers/ReadReceipts';
|
||||||
import { ReadSyncs } from '../messageModifiers/ReadSyncs';
|
import { ReadSyncs } from '../messageModifiers/ReadSyncs';
|
||||||
import { ViewSyncs } from '../messageModifiers/ViewSyncs';
|
import { ViewSyncs } from '../messageModifiers/ViewSyncs';
|
||||||
import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
|
import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
|
||||||
|
import * as LinkPreview from '../types/LinkPreview';
|
||||||
|
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
/* eslint-disable more/no-then */
|
/* eslint-disable more/no-then */
|
||||||
|
@ -2644,13 +2645,13 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
try {
|
try {
|
||||||
const now = new Date().getTime();
|
const now = new Date().getTime();
|
||||||
|
|
||||||
const urls = window.Signal.LinkPreviews.findLinks(dataMessage.body);
|
const urls = LinkPreview.findLinks(dataMessage.body);
|
||||||
const incomingPreview = dataMessage.preview || [];
|
const incomingPreview = dataMessage.preview || [];
|
||||||
const preview = incomingPreview.filter(
|
const preview = incomingPreview.filter(
|
||||||
(item: typeof window.WhatIsThis) =>
|
(item: typeof window.WhatIsThis) =>
|
||||||
(item.image || item.title) &&
|
(item.image || item.title) &&
|
||||||
urls.includes(item.url) &&
|
urls.includes(item.url) &&
|
||||||
window.Signal.LinkPreviews.isLinkSafeToPreview(item.url)
|
LinkPreview.isLinkSafeToPreview(item.url)
|
||||||
);
|
);
|
||||||
if (preview.length < incomingPreview.length) {
|
if (preview.length < incomingPreview.length) {
|
||||||
window.log.info(
|
window.log.info(
|
||||||
|
|
|
@ -3,15 +3,21 @@
|
||||||
|
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
|
||||||
|
import { assert } from '../../util/assert';
|
||||||
|
import { getDomain } from '../../types/LinkPreview';
|
||||||
|
|
||||||
import { StateType } from '../reducer';
|
import { StateType } from '../reducer';
|
||||||
|
|
||||||
export const getLinkPreview = createSelector(
|
export const getLinkPreview = createSelector(
|
||||||
({ linkPreviews }: StateType) => linkPreviews.linkPreview,
|
({ linkPreviews }: StateType) => linkPreviews.linkPreview,
|
||||||
linkPreview => {
|
linkPreview => {
|
||||||
if (linkPreview) {
|
if (linkPreview) {
|
||||||
|
const domain = getDomain(linkPreview.url);
|
||||||
|
assert(domain !== undefined, "Domain of linkPreview can't be undefined");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...linkPreview,
|
...linkPreview,
|
||||||
domain: window.Signal.LinkPreviews.getDomain(linkPreview.url),
|
domain,
|
||||||
isLoaded: true,
|
isLoaded: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,7 @@ import {
|
||||||
import { PropsType as ProfileChangeNotificationPropsType } from '../../components/conversation/ProfileChangeNotification';
|
import { PropsType as ProfileChangeNotificationPropsType } from '../../components/conversation/ProfileChangeNotification';
|
||||||
import { QuotedAttachmentType } from '../../components/conversation/Quote';
|
import { QuotedAttachmentType } from '../../components/conversation/Quote';
|
||||||
|
|
||||||
import { getDomain, isStickerPack } from '../../../js/modules/link_previews';
|
import { getDomain, isStickerPack } from '../../types/LinkPreview';
|
||||||
|
|
||||||
import { ContactType, contactSelector } from '../../types/Contact';
|
import { ContactType, contactSelector } from '../../types/Contact';
|
||||||
import { BodyRangesType } from '../../types/Util';
|
import { BodyRangesType } from '../../types/Util';
|
||||||
|
|
53
ts/test-both/util/emoji_test.ts
Normal file
53
ts/test-both/util/emoji_test.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
import { assert } from 'chai';
|
||||||
|
|
||||||
|
import { replaceEmojiWithSpaces, splitByEmoji } from '../../util/emoji';
|
||||||
|
|
||||||
|
describe('emoji', () => {
|
||||||
|
describe('replaceEmojiWithSpaces', () => {
|
||||||
|
it('replaces emoji and pictograms with a single space', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
replaceEmojiWithSpaces('hello🌀🐀🔀😀world'),
|
||||||
|
'hello world'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves regular text as it is', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
replaceEmojiWithSpaces('Привет 嘿 հեյ העלא مرحبا '),
|
||||||
|
'Привет 嘿 հեյ העלא مرحبا '
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('splitByEmoji', () => {
|
||||||
|
it('replaces emoji and pictograms with a single space', () => {
|
||||||
|
assert.deepStrictEqual(splitByEmoji('hello😛world😎😛!'), [
|
||||||
|
{ type: 'text', value: 'hello' },
|
||||||
|
{ type: 'emoji', value: '😛' },
|
||||||
|
{ type: 'text', value: 'world' },
|
||||||
|
{ type: 'emoji', value: '😎' },
|
||||||
|
{ type: 'text', value: '' },
|
||||||
|
{ type: 'emoji', value: '😛' },
|
||||||
|
{ type: 'text', value: '!' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string after split at the end', () => {
|
||||||
|
assert.deepStrictEqual(splitByEmoji('hello😛'), [
|
||||||
|
{ type: 'text', value: 'hello' },
|
||||||
|
{ type: 'emoji', value: '😛' },
|
||||||
|
{ type: 'text', value: '' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string before the split at the start', () => {
|
||||||
|
assert.deepStrictEqual(splitByEmoji('😛hello'), [
|
||||||
|
{ type: 'text', value: '' },
|
||||||
|
{ type: 'emoji', value: '😛' },
|
||||||
|
{ type: 'text', value: 'hello' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,6 +1,13 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2019-2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { isNumber, compact, isEmpty, range } from 'lodash';
|
||||||
|
import nodeUrl from 'url';
|
||||||
|
import LinkifyIt from 'linkify-it';
|
||||||
|
|
||||||
|
import { maybeParseUrl } from '../util/url';
|
||||||
|
import { replaceEmojiWithSpaces } from '../util/emoji';
|
||||||
|
|
||||||
import { AttachmentType } from './Attachment';
|
import { AttachmentType } from './Attachment';
|
||||||
|
|
||||||
export type LinkPreviewImage = AttachmentType & {
|
export type LinkPreviewImage = AttachmentType & {
|
||||||
|
@ -18,3 +25,154 @@ export type LinkPreviewResult = {
|
||||||
export type LinkPreviewWithDomain = {
|
export type LinkPreviewWithDomain = {
|
||||||
domain: string;
|
domain: string;
|
||||||
} & LinkPreviewResult;
|
} & LinkPreviewResult;
|
||||||
|
|
||||||
|
const linkify = LinkifyIt();
|
||||||
|
|
||||||
|
export function isLinkSafeToPreview(href: string): boolean {
|
||||||
|
const url = maybeParseUrl(href);
|
||||||
|
return Boolean(url && url.protocol === 'https:' && !isLinkSneaky(href));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStickerPack(link = ''): boolean {
|
||||||
|
return link.startsWith('https://signal.art/addstickers/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGroupLink(link = ''): boolean {
|
||||||
|
return link.startsWith('https://signal.group/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findLinks(text: string, caretLocation?: number): Array<string> {
|
||||||
|
const haveCaretLocation = isNumber(caretLocation);
|
||||||
|
const textLength = text ? text.length : 0;
|
||||||
|
|
||||||
|
const matches = linkify.match(text ? replaceEmojiWithSpaces(text) : '') || [];
|
||||||
|
return compact(
|
||||||
|
matches.map(match => {
|
||||||
|
if (!haveCaretLocation) {
|
||||||
|
return match.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (caretLocation === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.lastIndex === textLength && caretLocation === textLength) {
|
||||||
|
return match.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.index > caretLocation || match.lastIndex < caretLocation) {
|
||||||
|
return match.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDomain(href: string): string | undefined {
|
||||||
|
const url = maybeParseUrl(href);
|
||||||
|
return url ? url.hostname : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// See <https://tools.ietf.org/html/rfc3986>.
|
||||||
|
const VALID_URI_CHARACTERS = new Set([
|
||||||
|
'%',
|
||||||
|
// "gen-delims"
|
||||||
|
':',
|
||||||
|
'/',
|
||||||
|
'?',
|
||||||
|
'#',
|
||||||
|
'[',
|
||||||
|
']',
|
||||||
|
'@',
|
||||||
|
// "sub-delims"
|
||||||
|
'!',
|
||||||
|
'$',
|
||||||
|
'&',
|
||||||
|
"'",
|
||||||
|
'(',
|
||||||
|
')',
|
||||||
|
'*',
|
||||||
|
'+',
|
||||||
|
',',
|
||||||
|
';',
|
||||||
|
'=',
|
||||||
|
// unreserved
|
||||||
|
...String.fromCharCode(...range(65, 91), ...range(97, 123)),
|
||||||
|
...range(10).map(String),
|
||||||
|
'-',
|
||||||
|
'.',
|
||||||
|
'_',
|
||||||
|
'~',
|
||||||
|
]);
|
||||||
|
const ASCII_PATTERN = new RegExp('[\\u0020-\\u007F]', 'g');
|
||||||
|
const MAX_HREF_LENGTH = 2 ** 12;
|
||||||
|
|
||||||
|
export function isLinkSneaky(href: string): boolean {
|
||||||
|
// This helps users avoid extremely long links (which could be hiding something
|
||||||
|
// sketchy) and also sidesteps the performance implications of extremely long hrefs.
|
||||||
|
if (href.length > MAX_HREF_LENGTH) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = maybeParseUrl(href);
|
||||||
|
|
||||||
|
// If we can't parse it, it's sneaky.
|
||||||
|
if (!url) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any links which contain auth are considered sneaky
|
||||||
|
if (url.username || url.password) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the domain is falsy, something fishy is going on
|
||||||
|
if (!url.hostname) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// To quote [RFC 1034][0]: "the total number of octets that represent a
|
||||||
|
// domain name [...] is limited to 255." To be extra careful, we set a
|
||||||
|
// maximum of 2048. (This also uses the string's `.length` property,
|
||||||
|
// which isn't exactly the same thing as the number of octets.)
|
||||||
|
// [0]: https://tools.ietf.org/html/rfc1034
|
||||||
|
if (url.hostname.length > 2048) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domains cannot contain encoded characters
|
||||||
|
if (url.hostname.includes('%')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// There must be at least 2 domain labels, and none of them can be empty.
|
||||||
|
const labels = url.hostname.split('.');
|
||||||
|
if (labels.length < 2 || labels.some(isEmpty)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is necesary because getDomain returns domains in punycode form.
|
||||||
|
const unicodeDomain = nodeUrl.domainToUnicode
|
||||||
|
? nodeUrl.domainToUnicode(url.hostname)
|
||||||
|
: url.hostname;
|
||||||
|
|
||||||
|
const withoutPeriods = unicodeDomain.replace(/\./g, '');
|
||||||
|
|
||||||
|
const hasASCII = ASCII_PATTERN.test(withoutPeriods);
|
||||||
|
const withoutASCII = withoutPeriods.replace(ASCII_PATTERN, '');
|
||||||
|
|
||||||
|
const isMixed = hasASCII && withoutASCII.length > 0;
|
||||||
|
if (isMixed) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can't use `url.pathname` (and so on) because it automatically encodes strings.
|
||||||
|
// For example, it turns `/aquí` into `/aqu%C3%AD`.
|
||||||
|
const startOfPathAndHash = href.indexOf('/', url.protocol.length + 4);
|
||||||
|
const pathAndHash =
|
||||||
|
startOfPathAndHash === -1 ? '' : href.substr(startOfPathAndHash);
|
||||||
|
return [...pathAndHash].some(
|
||||||
|
character => !VALID_URI_CHARACTERS.has(character)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
36
ts/util/emoji.ts
Normal file
36
ts/util/emoji.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
/* eslint-disable no-restricted-syntax */
|
||||||
|
|
||||||
|
import emojiRegex from 'emoji-regex/es2015/RGI_Emoji';
|
||||||
|
|
||||||
|
import { assert } from './assert';
|
||||||
|
|
||||||
|
const REGEXP = emojiRegex();
|
||||||
|
|
||||||
|
export function replaceEmojiWithSpaces(value: string): string {
|
||||||
|
return value.replace(REGEXP, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SplitElement = Readonly<{
|
||||||
|
type: 'emoji' | 'text';
|
||||||
|
value: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function splitByEmoji(value: string): ReadonlyArray<SplitElement> {
|
||||||
|
const emojis = value.matchAll(REGEXP);
|
||||||
|
|
||||||
|
const result: Array<SplitElement> = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
for (const match of emojis) {
|
||||||
|
result.push({ type: 'text', value: value.slice(lastIndex, match.index) });
|
||||||
|
result.push({ type: 'emoji', value: match[0] });
|
||||||
|
|
||||||
|
assert(match.index !== undefined, '`matchAll` should provide indices');
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({ type: 'text', value: value.slice(lastIndex) });
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
|
@ -47,6 +47,7 @@ import {
|
||||||
LinkPreviewResult,
|
LinkPreviewResult,
|
||||||
LinkPreviewWithDomain,
|
LinkPreviewWithDomain,
|
||||||
} from '../types/LinkPreview';
|
} from '../types/LinkPreview';
|
||||||
|
import * as LinkPreview from '../types/LinkPreview';
|
||||||
|
|
||||||
type AttachmentOptions = {
|
type AttachmentOptions = {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
@ -3982,7 +3983,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const links = window.Signal.LinkPreviews.findLinks(message, caretLocation);
|
const links = LinkPreview.findLinks(message, caretLocation);
|
||||||
const { currentlyMatchedLink } = this;
|
const { currentlyMatchedLink } = this;
|
||||||
if (links.includes(currentlyMatchedLink)) {
|
if (links.includes(currentlyMatchedLink)) {
|
||||||
return;
|
return;
|
||||||
|
@ -3993,7 +3994,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
|
|
||||||
const link = links.find(
|
const link = links.find(
|
||||||
item =>
|
item =>
|
||||||
window.Signal.LinkPreviews.isLinkSafeToPreview(item) &&
|
LinkPreview.isLinkSafeToPreview(item) &&
|
||||||
!this.excludedPreviewUrls.includes(item)
|
!this.excludedPreviewUrls.includes(item)
|
||||||
);
|
);
|
||||||
if (!link) {
|
if (!link) {
|
||||||
|
@ -4189,15 +4190,15 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
url: string,
|
url: string,
|
||||||
abortSignal: Readonly<AbortSignal>
|
abortSignal: Readonly<AbortSignal>
|
||||||
): Promise<null | LinkPreviewResult> {
|
): Promise<null | LinkPreviewResult> {
|
||||||
if (window.Signal.LinkPreviews.isStickerPack(url)) {
|
if (LinkPreview.isStickerPack(url)) {
|
||||||
return this.getStickerPackPreview(url, abortSignal);
|
return this.getStickerPackPreview(url, abortSignal);
|
||||||
}
|
}
|
||||||
if (window.Signal.LinkPreviews.isGroupLink(url)) {
|
if (LinkPreview.isGroupLink(url)) {
|
||||||
return this.getGroupPreview(url, abortSignal);
|
return this.getGroupPreview(url, abortSignal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is already checked elsewhere, but we want to be extra-careful.
|
// This is already checked elsewhere, but we want to be extra-careful.
|
||||||
if (!window.Signal.LinkPreviews.isLinkSafeToPreview(url)) {
|
if (!LinkPreview.isLinkSafeToPreview(url)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4211,10 +4212,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
const { title, imageHref, description, date } = linkPreviewMetadata;
|
const { title, imageHref, description, date } = linkPreviewMetadata;
|
||||||
|
|
||||||
let image;
|
let image;
|
||||||
if (
|
if (imageHref && LinkPreview.isLinkSafeToPreview(imageHref)) {
|
||||||
imageHref &&
|
|
||||||
window.Signal.LinkPreviews.isLinkSafeToPreview(imageHref)
|
|
||||||
) {
|
|
||||||
let objectUrl: void | string;
|
let objectUrl: void | string;
|
||||||
try {
|
try {
|
||||||
const fullSizeImage = await window.textsecure.messaging.fetchLinkPreviewImage(
|
const fullSizeImage = await window.textsecure.messaging.fetchLinkPreviewImage(
|
||||||
|
@ -4406,7 +4404,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
const [preview] = this.preview;
|
const [preview] = this.preview;
|
||||||
return {
|
return {
|
||||||
...preview,
|
...preview,
|
||||||
domain: window.Signal.LinkPreviews.getDomain(preview.url),
|
domain: LinkPreview.getDomain(preview.url),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
2
ts/window.d.ts
vendored
2
ts/window.d.ts
vendored
|
@ -10,7 +10,6 @@ import moment from 'moment';
|
||||||
import PQueue from 'p-queue/dist';
|
import PQueue from 'p-queue/dist';
|
||||||
import { Ref } from 'react';
|
import { Ref } from 'react';
|
||||||
import { imageToBlurHash } from './util/imageToBlurHash';
|
import { imageToBlurHash } from './util/imageToBlurHash';
|
||||||
import * as LinkPreviews from '../js/modules/link_previews.d';
|
|
||||||
import * as Util from './util';
|
import * as Util from './util';
|
||||||
import {
|
import {
|
||||||
ConversationModelCollectionType,
|
ConversationModelCollectionType,
|
||||||
|
@ -439,7 +438,6 @@ declare global {
|
||||||
VisualAttachment: any;
|
VisualAttachment: any;
|
||||||
};
|
};
|
||||||
Util: typeof Util;
|
Util: typeof Util;
|
||||||
LinkPreviews: typeof LinkPreviews;
|
|
||||||
GroupChange: {
|
GroupChange: {
|
||||||
renderChange: (change: unknown, things: unknown) => Array<string>;
|
renderChange: (change: unknown, things: unknown) => Array<string>;
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue