Skip to content

Commit c02db4e

Browse files
authored
Port over linkifyJS to shared-components. (#32731)
* Port over linkifyJS to shared-components. * Drop rubbish * update lock * quickfix test * drop group id * Modernize tests * Remove stories that aren't in use. * Complete working version * Add copyright * tidy up * update lock * Update snaps * update snap * undo change * remove unused * More test updates * fix typo * fix margin on preview * move margin block * snapupdate * prettier * cleanup a test mistake * Fixup sonar issues * Don't expose linkifyjs to applications, just provide helper functions. * Add story for documentation. * remove $ * Use a const * typo * cleanup var name * remove console line * Changes checkpoint * Convert to context * Revert unrelated change. * more cleanup * Add a test to cover ignoring incoming data elements * Make tests happy * Update tests for LinkedText * Underlines! * fix lock * remove unused linkify packages * import move * Remove mod to remove underline * undo * fix snap * another snapshot fix * Tidy up based on review. * fix story * Pass in args
1 parent d38eb4f commit c02db4e

44 files changed

Lines changed: 1508 additions & 839 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/web/package.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,6 @@
7878
"jsrsasign": "^11.0.0",
7979
"jszip": "^3.7.0",
8080
"katex": "^0.16.0",
81-
"linkify-html": "4.3.2",
82-
"linkify-react": "4.3.2",
83-
"linkify-string": "4.3.2",
84-
"linkifyjs": "4.3.2",
8581
"lodash": "npm:lodash-es@^4.17.21",
8682
"maplibre-gl": "^5.0.0",
8783
"matrix-encrypt-attachment": "^1.0.3",

apps/web/playwright/e2e/links/messages.spec.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@ test.describe("Message links", () => {
3838
const linkElement = page.locator(".mx_EventTile_last").getByRole("link", { name: "#aroom:example.org" });
3939
await expect(linkElement).toHaveAttribute("href", "https://matrix.to/#/#aroom:example.org");
4040
});
41-
test("should linkify text inside a URL preview", { tag: "@screenshot" }, async ({ page, user, app, room, axe }) => {
42-
axe.disableRules("color-contrast");
41+
test("should linkify text inside a URL preview", async ({ page, user, app, room }) => {
4342
await page.route(/.*\/_matrix\/(client\/v1\/media|media\/v3)\/preview_url.*/, (route, request) => {
4443
const requestedPage = new URL(request.url()).searchParams.get("url");
4544
expect(requestedPage).toEqual("https://example.org/");

apps/web/res/themes/light/css/_mods.pcss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* sidebar blurred avatar background */
2-
//
2+
33
/* if backdrop-filter is supported, */
44
/* set the user avatar (if any) as a background so */
55
/* it can be blurred by the tag panel and room list */

apps/web/src/HtmlUtils.tsx

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@ import { decode } from "html-entities";
1717
import { type IContent } from "matrix-js-sdk/src/matrix";
1818
import escapeHtml from "escape-html";
1919
import { getEmojiFromUnicode } from "@matrix-org/emojibase-bindings";
20+
import { PERMITTED_URL_SCHEMES, LINKIFIED_DATA_ATTRIBUTE } from "@element-hq/web-shared-components";
2021

2122
import SettingsStore from "./settings/SettingsStore";
2223
import { stripHTMLReply, stripPlainReply } from "./utils/Reply";
23-
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
24-
import { linkifyHtml, sanitizeHtmlParams, transformTags } from "./Linkify";
24+
import { sanitizeHtmlParams, transformTags, linkifyHtml } from "./Linkify";
2525
import { graphemeSegmenter } from "./utils/strings";
2626

27-
export { Linkify, linkifyAndSanitizeHtml } from "./Linkify";
27+
export { linkifyAndSanitizeHtml } from "./Linkify";
2828

2929
// Anything outside the basic multilingual plane will be a surrogate pair
3030
const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/;
@@ -323,13 +323,12 @@ function analyseEvent(content: IContent, highlights?: string[], opts: EventRende
323323
if (opts.linkify) {
324324
// Prevent mutating the source of sanitizeParams.
325325
sanitizeParams = { ...sanitizeParams };
326-
sanitizeParams.allowedClasses ??= {};
327-
if (typeof sanitizeParams.allowedClasses.a === "boolean") {
328-
// All classes are already allowed for "a"
329-
} else {
330-
sanitizeParams.allowedClasses.a ??= [];
331-
sanitizeParams.allowedClasses.a.push("linkified");
332-
}
326+
if (typeof sanitizeParams.allowedAttributes === "object") {
327+
const attribs = { ...sanitizeParams.allowedAttributes };
328+
// We allow data-linkified because TextualBody uses it to passthrough links.
329+
attribs["a"] = [...sanitizeParams.allowedAttributes["a"], `data-${LINKIFIED_DATA_ATTRIBUTE}`];
330+
sanitizeParams.allowedAttributes = attribs;
331+
} // else: No attibutes are are allowed for "a"
333332
}
334333

335334
try {
Lines changed: 199 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,35 @@
11
/*
2+
Copyright 2026 Element Creations Ltd.
23
Copyright 2024, 2025 New Vector Ltd.
34
Copyright 2024 The Matrix.org Foundation C.I.C.
45
56
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
67
Please see LICENSE files in the repository root for full details.
78
*/
89

9-
import React, { type ReactElement } from "react";
1010
import sanitizeHtml, { type IOptions } from "sanitize-html";
11-
import { merge } from "lodash";
12-
import _Linkify from "linkify-react";
11+
import {
12+
PERMITTED_URL_SCHEMES,
13+
linkifyString as _linkifyString,
14+
linkifyHtml as _linkifyHtml,
15+
LinkifyMatrixOpaqueIdType,
16+
generateLinkedTextOptions,
17+
type LinkEventListener,
18+
} from "@element-hq/web-shared-components";
19+
import { getHttpUriForMxc, User } from "matrix-js-sdk/src/matrix";
1320

14-
import { _linkifyString, _linkifyHtml, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix";
15-
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
21+
import { ELEMENT_URL_PATTERN } from "./linkify-matrix";
1622
import { mediaFromMxc } from "./customisations/Media";
17-
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
23+
import {
24+
parsePermalink,
25+
tryTransformEntityToPermalink,
26+
tryTransformPermalinkToLocalHref,
27+
} from "./utils/permalinks/Permalinks";
28+
import dis from "./dispatcher/dispatcher";
29+
import { Action } from "./dispatcher/actions";
30+
import { type ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload";
31+
import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
32+
import { MatrixClientPeg } from "./MatrixClientPeg";
1833

1934
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
2035
const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/;
@@ -29,7 +44,7 @@ export const transformTags: NonNullable<IOptions["transformTags"]> = {
2944
const transformed = tryTransformPermalinkToLocalHref(attribs.href); // only used to check if it is a link that can be handled locally
3045
if (
3146
transformed !== attribs.href || // it could be converted so handle locally symbols e.g. @user:server.tdl, matrix: and matrix.to
32-
attribs.href.match(ELEMENT_URL_PATTERN) // for https links to Element domains
47+
ELEMENT_URL_PATTERN.test(attribs.href) // for https links to Element domains
3348
) {
3449
delete attribs.target;
3550
}
@@ -193,43 +208,199 @@ export const sanitizeHtmlParams: IOptions = {
193208
nestingLimit: 50,
194209
};
195210

196-
/* Wrapper around linkify-react merging in our default linkify options */
197-
export function Linkify({ as, options, children }: React.ComponentProps<typeof _Linkify>): ReactElement {
198-
return (
199-
<_Linkify as={as} options={merge({}, linkifyMatrixOptions, options)}>
200-
{children}
201-
</_Linkify>
202-
);
211+
/**
212+
* Handler function when a UserID link is clicked.
213+
* @param event The click event
214+
* @param userId The linked UserID
215+
*/
216+
function onUserClick(event: MouseEvent, userId: string): void {
217+
event.preventDefault();
218+
dis.dispatch<ViewUserPayload>({
219+
action: Action.ViewUser,
220+
member: new User(userId),
221+
});
222+
}
223+
224+
/**
225+
* Handler function when a Room Alias link is clicked.
226+
* @param event The click event
227+
* @param roomAlias The linked room alias
228+
*/
229+
function onAliasClick(event: MouseEvent, roomAlias: string): void {
230+
event.preventDefault();
231+
dis.dispatch<ViewRoomPayload>({
232+
action: Action.ViewRoom,
233+
room_alias: roomAlias,
234+
metricsTrigger: "Timeline",
235+
metricsViaKeyboard: false,
236+
});
237+
}
238+
239+
/**
240+
* Generates a set of event handlers for a regular URL link.
241+
*
242+
* @param href The link location.
243+
* @returns Event listenenrs compatible with linkifyjs.
244+
*/
245+
function urlEventListeners(href: string): LinkEventListener {
246+
// intercept local permalinks to users and show them like userids (in userinfo of current room)
247+
try {
248+
const permalink = parsePermalink(href);
249+
if (permalink?.userId) {
250+
return {
251+
click: function (e: MouseEvent) {
252+
onUserClick(e, permalink.userId!);
253+
},
254+
};
255+
} else {
256+
// for events, rooms etc. (anything other than users)
257+
const localHref = tryTransformPermalinkToLocalHref(href);
258+
if (localHref !== href) {
259+
// it could be converted to a localHref -> therefore handle locally
260+
return {
261+
click: function (e: MouseEvent) {
262+
e.preventDefault();
263+
globalThis.location.hash = localHref;
264+
},
265+
};
266+
}
267+
}
268+
} catch {
269+
// OK fine, it's not actually a permalink
270+
}
271+
return {};
203272
}
204273

274+
/**
275+
* Generates a set of event handlers for a UserID link.
276+
*
277+
* @param href A link that contains a userId.
278+
* @returns Event listenenrs compatible with linkifyjs.
279+
*/
280+
export function userIdEventListeners(href: string): LinkEventListener {
281+
return {
282+
click: function (e: MouseEvent) {
283+
e.preventDefault();
284+
const userId = parsePermalink(href)?.userId ?? href;
285+
if (userId) onUserClick(e, userId);
286+
},
287+
};
288+
}
289+
290+
/**
291+
* Generates a set of event handlers for a UserID link.
292+
*
293+
* @param href A link that contains a room alias.
294+
* @returns Event listenenrs compatible with linkifyjs.
295+
*/
296+
export function roomAliasEventListeners(href: string): LinkEventListener {
297+
return {
298+
click: function (e: MouseEvent) {
299+
e.preventDefault();
300+
const alias = parsePermalink(href)?.roomIdOrAlias ?? href;
301+
if (alias) onAliasClick(e, alias);
302+
},
303+
};
304+
}
305+
306+
/**
307+
* Generates a `target` attribute for the anchor element
308+
* for the given `href` value.
309+
*
310+
* @param href A URL from a link.
311+
* @returns The resulting `target` value.
312+
*/
313+
function urlTargetTransformFunction(href: string): string {
314+
try {
315+
const transformed = tryTransformPermalinkToLocalHref(href);
316+
if (
317+
transformed !== href || // if it could be converted to handle locally for matrix symbols e.g. @user:server.tdl and matrix.to
318+
ELEMENT_URL_PATTERN.test(decodeURIComponent(href)) // for https links to Element domains
319+
) {
320+
return "";
321+
} else {
322+
return "_blank";
323+
}
324+
} catch {
325+
// malformed URI
326+
}
327+
return "";
328+
}
329+
330+
/**
331+
* Generates the result `href` value based on an incoming `href` value and a link type.
332+
*
333+
* @param href A URL from a link.
334+
* @param type The type of link beinh handled.
335+
* @returns The resulting `href` value.
336+
*/
337+
export function formatHref(href: string, type: LinkifyMatrixOpaqueIdType): string {
338+
switch (type) {
339+
case LinkifyMatrixOpaqueIdType.URL:
340+
if (href.startsWith("mxc://") && MatrixClientPeg.get()) {
341+
return getHttpUriForMxc(
342+
MatrixClientPeg.get()!.baseUrl,
343+
href,
344+
undefined,
345+
undefined,
346+
undefined,
347+
false,
348+
true,
349+
);
350+
}
351+
// fallthrough
352+
case LinkifyMatrixOpaqueIdType.RoomAlias:
353+
case LinkifyMatrixOpaqueIdType.UserId:
354+
default: {
355+
return tryTransformEntityToPermalink(MatrixClientPeg.safeGet(), href) ?? "";
356+
}
357+
}
358+
}
359+
360+
/**
361+
* The standard configuration for a LinkedTextContext.Provider
362+
* within Element Web.
363+
*/
364+
export const LinkedTextConfiguration = {
365+
userIdListener: userIdEventListeners,
366+
roomAliasListener: roomAliasEventListeners,
367+
urlListener: urlEventListeners,
368+
hrefTransformer: formatHref,
369+
urlTargetTransformer: urlTargetTransformFunction,
370+
};
371+
205372
/**
206373
* Linkifies the given string. This is a wrapper around 'linkifyjs/string'.
207374
*
208-
* @param {string} str string to linkify
209-
* @param {object} [options] Options for linkifyString. Default: linkifyMatrixOptions
210-
* @returns {string} Linkified string
375+
* @param str string to linkify
376+
* @param [options] Options for linkifyString.
377+
* @returns Linkified string
211378
*/
212-
export function linkifyString(str: string, options = linkifyMatrixOptions): string {
213-
return _linkifyString(str, options);
379+
export function linkifyString(value: string, options = generateLinkedTextOptions(LinkedTextConfiguration)): string {
380+
return _linkifyString(value, options);
214381
}
215382

216383
/**
217384
* Linkifies the given HTML-formatted string. This is a wrapper around 'linkifyjs/html'.
218385
*
219-
* @param {string} str HTML string to linkify
220-
* @param {object} [options] Options for linkifyHtml. Default: linkifyMatrixOptions
221-
* @returns {string} Linkified string
386+
* @param str HTML string to linkify
387+
* @param [options] Options for linkifyHtml.
388+
* @returns Linkified string
222389
*/
223-
export function linkifyHtml(str: string, options = linkifyMatrixOptions): string {
224-
return _linkifyHtml(str, options);
390+
export function linkifyHtml(value: string, options = generateLinkedTextOptions(LinkedTextConfiguration)): string {
391+
return _linkifyHtml(value, options);
225392
}
393+
226394
/**
227395
* Linkify the given string and sanitize the HTML afterwards.
228396
*
229-
* @param {string} dirtyHtml The HTML string to sanitize and linkify
230-
* @param {object} [options] Options for linkifyString. Default: linkifyMatrixOptions
231-
* @returns {string}
397+
* @param dirtyString The string to linkify, and then sanitize.
398+
* @param [options] Options for linkifyString. Default: linkifyMatrixOptions
399+
* @returns HTML string
232400
*/
233-
export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrixOptions): string {
401+
export function linkifyAndSanitizeHtml(
402+
dirtyHtml: string,
403+
options = generateLinkedTextOptions(LinkedTextConfiguration),
404+
): string {
234405
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
235406
}

apps/web/src/Markdown.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ import "./@types/commonmark"; // import better types than @types/commonmark
1111
import * as commonmark from "commonmark";
1212
import { escape } from "lodash";
1313
import { logger } from "matrix-js-sdk/src/logger";
14-
15-
import { linkify } from "./linkify-matrix";
14+
import { findLinksInString } from "@element-hq/web-shared-components";
1615

1716
const ALLOWED_HTML_TAGS = ["sub", "sup", "del", "s", "u", "br", "br/"];
1817

@@ -186,7 +185,7 @@ export default class Markdown {
186185
// We should not do this if previous node was not a textnode, as we can't combine it then.
187186
if ((node.type === "emph" || node.type === "strong") && previousNode?.type === "text") {
188187
if (event.entering) {
189-
const foundLinks = linkify.find(text);
188+
const foundLinks = findLinksInString(text);
190189
for (const { value } of foundLinks) {
191190
if (node?.firstChild?.literal) {
192191
/**
@@ -197,7 +196,7 @@ export default class Markdown {
197196
const nonEmphasizedText = `${format}${innerNodeLiteral(node)}${format}`;
198197
const f = getTextUntilEndOrLinebreak(node);
199198
const newText = value + nonEmphasizedText + f;
200-
const newLinks = linkify.find(newText);
199+
const newLinks = findLinksInString(newText);
201200
// Should always find only one link here, if it finds more it means that the algorithm is broken
202201
if (newLinks.length === 1) {
203202
const emphasisTextNode = new commonmark.Node("text");

0 commit comments

Comments
 (0)