Refactor matrix-linkify module (#7279)

Refactor the module to make it easier for upgrade and proper separation of code contexts
This commit is contained in:
Dariusz Niemczyk 2021-12-03 15:00:56 +01:00 committed by GitHub
parent 3b9e39ffca
commit 961fec9081
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 99 additions and 206 deletions

View file

@ -15,12 +15,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as linkifyjs from 'linkifyjs';
import linkifyElement from 'linkifyjs/element';
import linkifyString from 'linkifyjs/string';
import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
import { baseUrl } from "./utils/permalinks/SpecPermalinkConstructor";
import {
parsePermalink,
tryTransformEntityToPermalink,
tryTransformPermalinkToLocalHref,
} from "./utils/permalinks/Permalinks";
import dis from './dispatcher/dispatcher';
import { Action } from './dispatcher/actions';
import { ViewUserPayload } from './dispatcher/payloads/ViewUserPayload';
enum Type {
URL = "url",
@ -29,7 +36,20 @@ enum Type {
GroupId = "groupid"
}
function matrixLinkify(linkify): void {
// Linkifyjs types don't have parser, which really makes this harder.
const linkifyTokens = (linkifyjs as any).scanner.TOKENS;
enum MatrixLinkInitialToken {
POUND = linkifyTokens.POUND,
PLUS = linkifyTokens.PLUS,
AT = linkifyTokens.AT,
}
/**
* Token should be one of the type of linkify.parser.TOKENS[AT | PLUS | POUND]
* but due to typing issues it's just not a feasible solution.
* This problem kind of gets solved in linkify 3.0
*/
function parseFreeformMatrixLinks(linkify, token: MatrixLinkInitialToken, type: Type): void {
// Text tokens
const TT = linkify.scanner.TOKENS;
// Multi tokens
@ -37,152 +57,70 @@ function matrixLinkify(linkify): void {
const MultiToken = MT.Base;
const S_START = linkify.parser.start;
if (TT.UNDERSCORE === undefined) {
throw new Error("linkify-matrix requires linkifyjs 2.1.1: this version is too old.");
}
const ROOMALIAS = function(value) {
const TOKEN = function(value) {
MultiToken.call(this, value);
this.type = 'roomalias';
this.type = type;
this.isLink = true;
};
ROOMALIAS.prototype = new MultiToken();
TOKEN.prototype = new MultiToken();
const S_HASH = S_START.jump(TT.POUND);
const S_HASH_NAME = new linkify.parser.State();
const S_HASH_NAME_COLON = new linkify.parser.State();
const S_HASH_NAME_COLON_DOMAIN = new linkify.parser.State(ROOMALIAS);
const S_HASH_NAME_COLON_DOMAIN_DOT = new linkify.parser.State();
const S_ROOMALIAS = new linkify.parser.State(ROOMALIAS);
const S_ROOMALIAS_COLON = new linkify.parser.State();
const S_ROOMALIAS_COLON_NUM = new linkify.parser.State(ROOMALIAS);
const S_TOKEN = S_START.jump(token);
const S_TOKEN_NAME = new linkify.parser.State();
const S_TOKEN_NAME_COLON = new linkify.parser.State();
const S_TOKEN_NAME_COLON_DOMAIN = new linkify.parser.State(TOKEN);
const S_TOKEN_NAME_COLON_DOMAIN_DOT = new linkify.parser.State();
const S_MX_LINK = new linkify.parser.State(TOKEN);
const S_MX_LINK_COLON = new linkify.parser.State();
const S_MX_LINK_COLON_NUM = new linkify.parser.State(TOKEN);
const roomnameTokens = [
const allowedFreeformTokens = [
TT.DOT,
TT.PLUS,
TT.NUM,
TT.DOMAIN,
TT.TLD,
TT.UNDERSCORE,
TT.POUND,
token,
// because 'localhost' is tokenised to the localhost token,
// usernames @localhost:foo.com are otherwise not matched!
TT.LOCALHOST,
];
S_HASH.on(roomnameTokens, S_HASH_NAME);
S_HASH_NAME.on(roomnameTokens, S_HASH_NAME);
S_HASH_NAME.on(TT.DOMAIN, S_HASH_NAME);
S_TOKEN.on(allowedFreeformTokens, S_TOKEN_NAME);
S_TOKEN_NAME.on(allowedFreeformTokens, S_TOKEN_NAME);
S_TOKEN_NAME.on(TT.DOMAIN, S_TOKEN_NAME);
S_HASH_NAME.on(TT.COLON, S_HASH_NAME_COLON);
S_TOKEN_NAME.on(TT.COLON, S_TOKEN_NAME_COLON);
S_HASH_NAME_COLON.on(TT.DOMAIN, S_HASH_NAME_COLON_DOMAIN);
S_HASH_NAME_COLON.on(TT.LOCALHOST, S_ROOMALIAS); // accept #foo:localhost
S_HASH_NAME_COLON.on(TT.TLD, S_ROOMALIAS); // accept #foo:com (mostly for (TLD|DOMAIN)+ mixing)
S_HASH_NAME_COLON_DOMAIN.on(TT.DOT, S_HASH_NAME_COLON_DOMAIN_DOT);
S_HASH_NAME_COLON_DOMAIN_DOT.on(TT.DOMAIN, S_HASH_NAME_COLON_DOMAIN);
S_HASH_NAME_COLON_DOMAIN_DOT.on(TT.TLD, S_ROOMALIAS);
S_TOKEN_NAME_COLON.on(TT.DOMAIN, S_TOKEN_NAME_COLON_DOMAIN);
S_TOKEN_NAME_COLON.on(TT.LOCALHOST, S_MX_LINK); // accept #foo:localhost
S_TOKEN_NAME_COLON.on(TT.TLD, S_MX_LINK); // accept #foo:com (mostly for (TLD|DOMAIN)+ mixing)
S_TOKEN_NAME_COLON_DOMAIN.on(TT.DOT, S_TOKEN_NAME_COLON_DOMAIN_DOT);
S_TOKEN_NAME_COLON_DOMAIN_DOT.on(TT.DOMAIN, S_TOKEN_NAME_COLON_DOMAIN);
S_TOKEN_NAME_COLON_DOMAIN_DOT.on(TT.TLD, S_MX_LINK);
S_ROOMALIAS.on(TT.DOT, S_HASH_NAME_COLON_DOMAIN_DOT); // accept repeated TLDs (e.g .org.uk)
S_ROOMALIAS.on(TT.COLON, S_ROOMALIAS_COLON); // do not accept trailing `:`
S_ROOMALIAS_COLON.on(TT.NUM, S_ROOMALIAS_COLON_NUM); // but do accept :NUM (port specifier)
const USERID = function(value) {
MultiToken.call(this, value);
this.type = 'userid';
this.isLink = true;
};
USERID.prototype = new MultiToken();
const S_AT = S_START.jump(TT.AT);
const S_AT_NAME = new linkify.parser.State();
const S_AT_NAME_COLON = new linkify.parser.State();
const S_AT_NAME_COLON_DOMAIN = new linkify.parser.State(USERID);
const S_AT_NAME_COLON_DOMAIN_DOT = new linkify.parser.State();
const S_USERID = new linkify.parser.State(USERID);
const S_USERID_COLON = new linkify.parser.State();
const S_USERID_COLON_NUM = new linkify.parser.State(USERID);
const usernameTokens = [
TT.DOT,
TT.UNDERSCORE,
TT.PLUS,
TT.NUM,
TT.DOMAIN,
TT.TLD,
// as in roomnameTokens
TT.LOCALHOST,
];
S_AT.on(usernameTokens, S_AT_NAME);
S_AT_NAME.on(usernameTokens, S_AT_NAME);
S_AT_NAME.on(TT.DOMAIN, S_AT_NAME);
S_AT_NAME.on(TT.COLON, S_AT_NAME_COLON);
S_AT_NAME_COLON.on(TT.DOMAIN, S_AT_NAME_COLON_DOMAIN);
S_AT_NAME_COLON.on(TT.LOCALHOST, S_USERID); // accept @foo:localhost
S_AT_NAME_COLON.on(TT.TLD, S_USERID); // accept @foo:com (mostly for (TLD|DOMAIN)+ mixing)
S_AT_NAME_COLON_DOMAIN.on(TT.DOT, S_AT_NAME_COLON_DOMAIN_DOT);
S_AT_NAME_COLON_DOMAIN_DOT.on(TT.DOMAIN, S_AT_NAME_COLON_DOMAIN);
S_AT_NAME_COLON_DOMAIN_DOT.on(TT.TLD, S_USERID);
S_USERID.on(TT.DOT, S_AT_NAME_COLON_DOMAIN_DOT); // accept repeated TLDs (e.g .org.uk)
S_USERID.on(TT.COLON, S_USERID_COLON); // do not accept trailing `:`
S_USERID_COLON.on(TT.NUM, S_USERID_COLON_NUM); // but do accept :NUM (port specifier)
const GROUPID = function(value) {
MultiToken.call(this, value);
this.type = 'groupid';
this.isLink = true;
};
GROUPID.prototype = new MultiToken();
const S_PLUS = S_START.jump(TT.PLUS);
const S_PLUS_NAME = new linkify.parser.State();
const S_PLUS_NAME_COLON = new linkify.parser.State();
const S_PLUS_NAME_COLON_DOMAIN = new linkify.parser.State(GROUPID);
const S_PLUS_NAME_COLON_DOMAIN_DOT = new linkify.parser.State();
const S_GROUPID = new linkify.parser.State(GROUPID);
const S_GROUPID_COLON = new linkify.parser.State();
const S_GROUPID_COLON_NUM = new linkify.parser.State(GROUPID);
const groupIdTokens = [
TT.DOT,
TT.UNDERSCORE,
TT.PLUS,
TT.NUM,
TT.DOMAIN,
TT.TLD,
// as in roomnameTokens
TT.LOCALHOST,
];
S_PLUS.on(groupIdTokens, S_PLUS_NAME);
S_PLUS_NAME.on(groupIdTokens, S_PLUS_NAME);
S_PLUS_NAME.on(TT.DOMAIN, S_PLUS_NAME);
S_PLUS_NAME.on(TT.COLON, S_PLUS_NAME_COLON);
S_PLUS_NAME_COLON.on(TT.DOMAIN, S_PLUS_NAME_COLON_DOMAIN);
S_PLUS_NAME_COLON.on(TT.LOCALHOST, S_GROUPID); // accept +foo:localhost
S_PLUS_NAME_COLON.on(TT.TLD, S_GROUPID); // accept +foo:com (mostly for (TLD|DOMAIN)+ mixing)
S_PLUS_NAME_COLON_DOMAIN.on(TT.DOT, S_PLUS_NAME_COLON_DOMAIN_DOT);
S_PLUS_NAME_COLON_DOMAIN_DOT.on(TT.DOMAIN, S_PLUS_NAME_COLON_DOMAIN);
S_PLUS_NAME_COLON_DOMAIN_DOT.on(TT.TLD, S_GROUPID);
S_GROUPID.on(TT.DOT, S_PLUS_NAME_COLON_DOMAIN_DOT); // accept repeated TLDs (e.g .org.uk)
S_GROUPID.on(TT.COLON, S_GROUPID_COLON); // do not accept trailing `:`
S_GROUPID_COLON.on(TT.NUM, S_GROUPID_COLON_NUM); // but do accept :NUM (port specifier)
S_MX_LINK.on(TT.DOT, S_TOKEN_NAME_COLON_DOMAIN_DOT); // accept repeated TLDs (e.g .org.uk)
S_MX_LINK.on(TT.COLON, S_MX_LINK_COLON); // do not accept trailing `:`
S_MX_LINK_COLON.on(TT.NUM, S_MX_LINK_COLON_NUM); // but do accept :NUM (port specifier)
}
// stubs, overwritten in MatrixChat's componentDidMount
matrixLinkify.onUserClick = function(e: MouseEvent, userId: string) { e.preventDefault(); };
matrixLinkify.onAliasClick = function(e: MouseEvent, roomAlias: string) { e.preventDefault(); };
matrixLinkify.onGroupClick = function(e: MouseEvent, groupId: string) { e.preventDefault(); };
function onUserClick(event: MouseEvent, userId: string) {
const member = new RoomMember(null, userId);
if (!member) { return; }
dis.dispatch<ViewUserPayload>({
action: Action.ViewUser,
member: member,
});
}
function onAliasClick(event: MouseEvent, roomAlias: string) {
event.preventDefault();
dis.dispatch({ action: 'view_room', room_alias: roomAlias });
}
function onGroupClick(event: MouseEvent, groupId: string) {
event.preventDefault();
dis.dispatch({ action: 'view_group', group_id: groupId });
}
const escapeRegExp = function(string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@ -190,19 +128,19 @@ const escapeRegExp = function(string): string {
// Recognise URLs from both our local and official Element deployments.
// Anyone else really should be using matrix.to.
matrixLinkify.ELEMENT_URL_PATTERN =
export const ELEMENT_URL_PATTERN =
"^(?:https?://)?(?:" +
escapeRegExp(window.location.host + window.location.pathname) + "|" +
"(?:www\\.)?(?:riot|vector)\\.im/(?:app|beta|staging|develop)/|" +
"(?:app|beta|staging|develop)\\.element\\.io/" +
")(#.*)";
matrixLinkify.MATRIXTO_URL_PATTERN = "^(?:https?://)?(?:www\\.)?matrix\\.to/#/(([#@!+]).*)";
matrixLinkify.MATRIXTO_MD_LINK_PATTERN =
export const MATRIXTO_URL_PATTERN = "^(?:https?://)?(?:www\\.)?matrix\\.to/#/(([#@!+]).*)";
export const MATRIXTO_MD_LINK_PATTERN =
'\\[([^\\]]*)\\]\\((?:https?://)?(?:www\\.)?matrix\\.to/#/([#@!+][^\\)]*)\\)';
matrixLinkify.MATRIXTO_BASE_URL= baseUrl;
export const MATRIXTO_BASE_URL= baseUrl;
matrixLinkify.options = {
export const options = {
events: function(href: string, type: Type | string): Partial<GlobalEventHandlers> {
switch (type) {
case Type.URL: {
@ -213,7 +151,7 @@ matrixLinkify.options = {
return {
// @ts-ignore see https://linkify.js.org/docs/options.html
click: function(e) {
matrixLinkify.onUserClick(e, permalink.userId);
onUserClick(e, permalink.userId);
},
};
}
@ -226,21 +164,21 @@ matrixLinkify.options = {
return {
// @ts-ignore see https://linkify.js.org/docs/options.html
click: function(e) {
matrixLinkify.onUserClick(e, href);
onUserClick(e, href);
},
};
case Type.RoomAlias:
return {
// @ts-ignore see https://linkify.js.org/docs/options.html
click: function(e) {
matrixLinkify.onAliasClick(e, href);
onAliasClick(e, href);
},
};
case Type.GroupId:
return {
// @ts-ignore see https://linkify.js.org/docs/options.html
click: function(e) {
matrixLinkify.onGroupClick(e, href);
onGroupClick(e, href);
},
};
}
@ -265,7 +203,7 @@ matrixLinkify.options = {
if (type === Type.URL) {
try {
const transformed = tryTransformPermalinkToLocalHref(href);
if (transformed !== href || decodeURIComponent(href).match(matrixLinkify.ELEMENT_URL_PATTERN)) {
if (transformed !== href || decodeURIComponent(href).match(ELEMENT_URL_PATTERN)) {
return null;
} else {
return '_blank';
@ -278,4 +216,14 @@ matrixLinkify.options = {
},
};
export default matrixLinkify;
// Run the plugins
// Linkify room aliases
parseFreeformMatrixLinks(linkifyjs, MatrixLinkInitialToken.POUND, Type.RoomAlias);
// Linkify group IDs
parseFreeformMatrixLinks(linkifyjs, MatrixLinkInitialToken.PLUS, Type.GroupId);
// Linkify user IDs
parseFreeformMatrixLinks(linkifyjs, MatrixLinkInitialToken.AT, Type.UserId);
export const linkify = linkifyjs;
export const _linkifyElement = linkifyElement;
export const _linkifyString = linkifyString;