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
This commit is contained in:
Will Hunt
2026-03-12 15:54:01 +00:00
committed by GitHub
parent d38eb4fdb4
commit c02db4ebb8
44 changed files with 1508 additions and 839 deletions
-4
View File
@@ -78,10 +78,6 @@
"jsrsasign": "^11.0.0", "jsrsasign": "^11.0.0",
"jszip": "^3.7.0", "jszip": "^3.7.0",
"katex": "^0.16.0", "katex": "^0.16.0",
"linkify-html": "4.3.2",
"linkify-react": "4.3.2",
"linkify-string": "4.3.2",
"linkifyjs": "4.3.2",
"lodash": "npm:lodash-es@^4.17.21", "lodash": "npm:lodash-es@^4.17.21",
"maplibre-gl": "^5.0.0", "maplibre-gl": "^5.0.0",
"matrix-encrypt-attachment": "^1.0.3", "matrix-encrypt-attachment": "^1.0.3",
@@ -38,8 +38,7 @@ test.describe("Message links", () => {
const linkElement = page.locator(".mx_EventTile_last").getByRole("link", { name: "#aroom:example.org" }); const linkElement = page.locator(".mx_EventTile_last").getByRole("link", { name: "#aroom:example.org" });
await expect(linkElement).toHaveAttribute("href", "https://matrix.to/#/#aroom:example.org"); await expect(linkElement).toHaveAttribute("href", "https://matrix.to/#/#aroom:example.org");
}); });
test("should linkify text inside a URL preview", { tag: "@screenshot" }, async ({ page, user, app, room, axe }) => { test("should linkify text inside a URL preview", async ({ page, user, app, room }) => {
axe.disableRules("color-contrast");
await page.route(/.*\/_matrix\/(client\/v1\/media|media\/v3)\/preview_url.*/, (route, request) => { await page.route(/.*\/_matrix\/(client\/v1\/media|media\/v3)\/preview_url.*/, (route, request) => {
const requestedPage = new URL(request.url()).searchParams.get("url"); const requestedPage = new URL(request.url()).searchParams.get("url");
expect(requestedPage).toEqual("https://example.org/"); expect(requestedPage).toEqual("https://example.org/");
+1 -1
View File
@@ -1,5 +1,5 @@
/* sidebar blurred avatar background */ /* sidebar blurred avatar background */
//
/* if backdrop-filter is supported, */ /* if backdrop-filter is supported, */
/* set the user avatar (if any) as a background so */ /* set the user avatar (if any) as a background so */
/* it can be blurred by the tag panel and room list */ /* it can be blurred by the tag panel and room list */
+9 -10
View File
@@ -17,14 +17,14 @@ import { decode } from "html-entities";
import { type IContent } from "matrix-js-sdk/src/matrix"; import { type IContent } from "matrix-js-sdk/src/matrix";
import escapeHtml from "escape-html"; import escapeHtml from "escape-html";
import { getEmojiFromUnicode } from "@matrix-org/emojibase-bindings"; import { getEmojiFromUnicode } from "@matrix-org/emojibase-bindings";
import { PERMITTED_URL_SCHEMES, LINKIFIED_DATA_ATTRIBUTE } from "@element-hq/web-shared-components";
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import { stripHTMLReply, stripPlainReply } from "./utils/Reply"; import { stripHTMLReply, stripPlainReply } from "./utils/Reply";
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils"; import { sanitizeHtmlParams, transformTags, linkifyHtml } from "./Linkify";
import { linkifyHtml, sanitizeHtmlParams, transformTags } from "./Linkify";
import { graphemeSegmenter } from "./utils/strings"; import { graphemeSegmenter } from "./utils/strings";
export { Linkify, linkifyAndSanitizeHtml } from "./Linkify"; export { linkifyAndSanitizeHtml } from "./Linkify";
// Anything outside the basic multilingual plane will be a surrogate pair // Anything outside the basic multilingual plane will be a surrogate pair
const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/; const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/;
@@ -323,13 +323,12 @@ function analyseEvent(content: IContent, highlights?: string[], opts: EventRende
if (opts.linkify) { if (opts.linkify) {
// Prevent mutating the source of sanitizeParams. // Prevent mutating the source of sanitizeParams.
sanitizeParams = { ...sanitizeParams }; sanitizeParams = { ...sanitizeParams };
sanitizeParams.allowedClasses ??= {}; if (typeof sanitizeParams.allowedAttributes === "object") {
if (typeof sanitizeParams.allowedClasses.a === "boolean") { const attribs = { ...sanitizeParams.allowedAttributes };
// All classes are already allowed for "a" // We allow data-linkified because TextualBody uses it to passthrough links.
} else { attribs["a"] = [...sanitizeParams.allowedAttributes["a"], `data-${LINKIFIED_DATA_ATTRIBUTE}`];
sanitizeParams.allowedClasses.a ??= []; sanitizeParams.allowedAttributes = attribs;
sanitizeParams.allowedClasses.a.push("linkified"); } // else: No attibutes are are allowed for "a"
}
} }
try { try {
@@ -1,4 +1,5 @@
/* /*
Copyright 2026 Element Creations Ltd.
Copyright 2024, 2025 New Vector Ltd. Copyright 2024, 2025 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C. Copyright 2024 The Matrix.org Foundation C.I.C.
@@ -6,15 +7,29 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type ReactElement } from "react";
import sanitizeHtml, { type IOptions } from "sanitize-html"; import sanitizeHtml, { type IOptions } from "sanitize-html";
import { merge } from "lodash"; import {
import _Linkify from "linkify-react"; PERMITTED_URL_SCHEMES,
linkifyString as _linkifyString,
linkifyHtml as _linkifyHtml,
LinkifyMatrixOpaqueIdType,
generateLinkedTextOptions,
type LinkEventListener,
} from "@element-hq/web-shared-components";
import { getHttpUriForMxc, User } from "matrix-js-sdk/src/matrix";
import { _linkifyString, _linkifyHtml, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix"; import { ELEMENT_URL_PATTERN } from "./linkify-matrix";
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
import { mediaFromMxc } from "./customisations/Media"; import { mediaFromMxc } from "./customisations/Media";
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils"; import {
parsePermalink,
tryTransformEntityToPermalink,
tryTransformPermalinkToLocalHref,
} from "./utils/permalinks/Permalinks";
import dis from "./dispatcher/dispatcher";
import { Action } from "./dispatcher/actions";
import { type ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload";
import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
import { MatrixClientPeg } from "./MatrixClientPeg";
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/; const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/;
@@ -29,7 +44,7 @@ export const transformTags: NonNullable<IOptions["transformTags"]> = {
const transformed = tryTransformPermalinkToLocalHref(attribs.href); // only used to check if it is a link that can be handled locally const transformed = tryTransformPermalinkToLocalHref(attribs.href); // only used to check if it is a link that can be handled locally
if ( if (
transformed !== attribs.href || // it could be converted so handle locally symbols e.g. @user:server.tdl, matrix: and matrix.to transformed !== attribs.href || // it could be converted so handle locally symbols e.g. @user:server.tdl, matrix: and matrix.to
attribs.href.match(ELEMENT_URL_PATTERN) // for https links to Element domains ELEMENT_URL_PATTERN.test(attribs.href) // for https links to Element domains
) { ) {
delete attribs.target; delete attribs.target;
} }
@@ -193,43 +208,199 @@ export const sanitizeHtmlParams: IOptions = {
nestingLimit: 50, nestingLimit: 50,
}; };
/* Wrapper around linkify-react merging in our default linkify options */ /**
export function Linkify({ as, options, children }: React.ComponentProps<typeof _Linkify>): ReactElement { * Handler function when a UserID link is clicked.
return ( * @param event The click event
<_Linkify as={as} options={merge({}, linkifyMatrixOptions, options)}> * @param userId The linked UserID
{children} */
</_Linkify> function onUserClick(event: MouseEvent, userId: string): void {
); event.preventDefault();
dis.dispatch<ViewUserPayload>({
action: Action.ViewUser,
member: new User(userId),
});
} }
/**
* Handler function when a Room Alias link is clicked.
* @param event The click event
* @param roomAlias The linked room alias
*/
function onAliasClick(event: MouseEvent, roomAlias: string): void {
event.preventDefault();
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_alias: roomAlias,
metricsTrigger: "Timeline",
metricsViaKeyboard: false,
});
}
/**
* Generates a set of event handlers for a regular URL link.
*
* @param href The link location.
* @returns Event listenenrs compatible with linkifyjs.
*/
function urlEventListeners(href: string): LinkEventListener {
// intercept local permalinks to users and show them like userids (in userinfo of current room)
try {
const permalink = parsePermalink(href);
if (permalink?.userId) {
return {
click: function (e: MouseEvent) {
onUserClick(e, permalink.userId!);
},
};
} else {
// for events, rooms etc. (anything other than users)
const localHref = tryTransformPermalinkToLocalHref(href);
if (localHref !== href) {
// it could be converted to a localHref -> therefore handle locally
return {
click: function (e: MouseEvent) {
e.preventDefault();
globalThis.location.hash = localHref;
},
};
}
}
} catch {
// OK fine, it's not actually a permalink
}
return {};
}
/**
* Generates a set of event handlers for a UserID link.
*
* @param href A link that contains a userId.
* @returns Event listenenrs compatible with linkifyjs.
*/
export function userIdEventListeners(href: string): LinkEventListener {
return {
click: function (e: MouseEvent) {
e.preventDefault();
const userId = parsePermalink(href)?.userId ?? href;
if (userId) onUserClick(e, userId);
},
};
}
/**
* Generates a set of event handlers for a UserID link.
*
* @param href A link that contains a room alias.
* @returns Event listenenrs compatible with linkifyjs.
*/
export function roomAliasEventListeners(href: string): LinkEventListener {
return {
click: function (e: MouseEvent) {
e.preventDefault();
const alias = parsePermalink(href)?.roomIdOrAlias ?? href;
if (alias) onAliasClick(e, alias);
},
};
}
/**
* Generates a `target` attribute for the anchor element
* for the given `href` value.
*
* @param href A URL from a link.
* @returns The resulting `target` value.
*/
function urlTargetTransformFunction(href: string): string {
try {
const transformed = tryTransformPermalinkToLocalHref(href);
if (
transformed !== href || // if it could be converted to handle locally for matrix symbols e.g. @user:server.tdl and matrix.to
ELEMENT_URL_PATTERN.test(decodeURIComponent(href)) // for https links to Element domains
) {
return "";
} else {
return "_blank";
}
} catch {
// malformed URI
}
return "";
}
/**
* Generates the result `href` value based on an incoming `href` value and a link type.
*
* @param href A URL from a link.
* @param type The type of link beinh handled.
* @returns The resulting `href` value.
*/
export function formatHref(href: string, type: LinkifyMatrixOpaqueIdType): string {
switch (type) {
case LinkifyMatrixOpaqueIdType.URL:
if (href.startsWith("mxc://") && MatrixClientPeg.get()) {
return getHttpUriForMxc(
MatrixClientPeg.get()!.baseUrl,
href,
undefined,
undefined,
undefined,
false,
true,
);
}
// fallthrough
case LinkifyMatrixOpaqueIdType.RoomAlias:
case LinkifyMatrixOpaqueIdType.UserId:
default: {
return tryTransformEntityToPermalink(MatrixClientPeg.safeGet(), href) ?? "";
}
}
}
/**
* The standard configuration for a LinkedTextContext.Provider
* within Element Web.
*/
export const LinkedTextConfiguration = {
userIdListener: userIdEventListeners,
roomAliasListener: roomAliasEventListeners,
urlListener: urlEventListeners,
hrefTransformer: formatHref,
urlTargetTransformer: urlTargetTransformFunction,
};
/** /**
* Linkifies the given string. This is a wrapper around 'linkifyjs/string'. * Linkifies the given string. This is a wrapper around 'linkifyjs/string'.
* *
* @param {string} str string to linkify * @param str string to linkify
* @param {object} [options] Options for linkifyString. Default: linkifyMatrixOptions * @param [options] Options for linkifyString.
* @returns {string} Linkified string * @returns Linkified string
*/ */
export function linkifyString(str: string, options = linkifyMatrixOptions): string { export function linkifyString(value: string, options = generateLinkedTextOptions(LinkedTextConfiguration)): string {
return _linkifyString(str, options); return _linkifyString(value, options);
} }
/** /**
* Linkifies the given HTML-formatted string. This is a wrapper around 'linkifyjs/html'. * Linkifies the given HTML-formatted string. This is a wrapper around 'linkifyjs/html'.
* *
* @param {string} str HTML string to linkify * @param str HTML string to linkify
* @param {object} [options] Options for linkifyHtml. Default: linkifyMatrixOptions * @param [options] Options for linkifyHtml.
* @returns {string} Linkified string * @returns Linkified string
*/ */
export function linkifyHtml(str: string, options = linkifyMatrixOptions): string { export function linkifyHtml(value: string, options = generateLinkedTextOptions(LinkedTextConfiguration)): string {
return _linkifyHtml(str, options); return _linkifyHtml(value, options);
} }
/** /**
* Linkify the given string and sanitize the HTML afterwards. * Linkify the given string and sanitize the HTML afterwards.
* *
* @param {string} dirtyHtml The HTML string to sanitize and linkify * @param dirtyString The string to linkify, and then sanitize.
* @param {object} [options] Options for linkifyString. Default: linkifyMatrixOptions * @param [options] Options for linkifyString. Default: linkifyMatrixOptions
* @returns {string} * @returns HTML string
*/ */
export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrixOptions): string { export function linkifyAndSanitizeHtml(
dirtyHtml: string,
options = generateLinkedTextOptions(LinkedTextConfiguration),
): string {
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams); return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
} }
+3 -4
View File
@@ -11,8 +11,7 @@ import "./@types/commonmark"; // import better types than @types/commonmark
import * as commonmark from "commonmark"; import * as commonmark from "commonmark";
import { escape } from "lodash"; import { escape } from "lodash";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { findLinksInString } from "@element-hq/web-shared-components";
import { linkify } from "./linkify-matrix";
const ALLOWED_HTML_TAGS = ["sub", "sup", "del", "s", "u", "br", "br/"]; const ALLOWED_HTML_TAGS = ["sub", "sup", "del", "s", "u", "br", "br/"];
@@ -186,7 +185,7 @@ export default class Markdown {
// We should not do this if previous node was not a textnode, as we can't combine it then. // We should not do this if previous node was not a textnode, as we can't combine it then.
if ((node.type === "emph" || node.type === "strong") && previousNode?.type === "text") { if ((node.type === "emph" || node.type === "strong") && previousNode?.type === "text") {
if (event.entering) { if (event.entering) {
const foundLinks = linkify.find(text); const foundLinks = findLinksInString(text);
for (const { value } of foundLinks) { for (const { value } of foundLinks) {
if (node?.firstChild?.literal) { if (node?.firstChild?.literal) {
/** /**
@@ -197,7 +196,7 @@ export default class Markdown {
const nonEmphasizedText = `${format}${innerNodeLiteral(node)}${format}`; const nonEmphasizedText = `${format}${innerNodeLiteral(node)}${format}`;
const f = getTextUntilEndOrLinebreak(node); const f = getTextUntilEndOrLinebreak(node);
const newText = value + nonEmphasizedText + f; const newText = value + nonEmphasizedText + f;
const newLinks = linkify.find(newText); const newLinks = findLinksInString(newText);
// Should always find only one link here, if it finds more it means that the algorithm is broken // Should always find only one link here, if it finds more it means that the algorithm is broken
if (newLinks.length === 1) { if (newLinks.length === 1) {
const emphasisTextNode = new commonmark.Node("text"); const emphasisTextNode = new commonmark.Node("text");
@@ -28,7 +28,7 @@ import { TooltipProvider } from "@vector-im/compound-web";
// what-input helps improve keyboard accessibility // what-input helps improve keyboard accessibility
import "what-input"; import "what-input";
import sanitizeHtml from "sanitize-html"; import sanitizeHtml from "sanitize-html";
import { I18nContext } from "@element-hq/web-shared-components"; import { I18nContext, LinkedTextContext, LinkedText } from "@element-hq/web-shared-components";
import { LockSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { LockSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import PosthogTrackers from "../../PosthogTrackers"; import PosthogTrackers from "../../PosthogTrackers";
@@ -125,7 +125,7 @@ import { viewUserDeviceSettings } from "../../actions/handlers/viewUserDeviceSet
import GenericToast from "../views/toasts/GenericToast"; import GenericToast from "../views/toasts/GenericToast";
import RovingSpotlightDialog from "../views/dialogs/spotlight/SpotlightDialog"; import RovingSpotlightDialog from "../views/dialogs/spotlight/SpotlightDialog";
import { findDMForUser } from "../../utils/dm/findDMForUser"; import { findDMForUser } from "../../utils/dm/findDMForUser";
import { getHtmlText, Linkify } from "../../HtmlUtils"; import { getHtmlText } from "../../HtmlUtils";
import { NotificationLevel } from "../../stores/notifications/NotificationLevel"; import { NotificationLevel } from "../../stores/notifications/NotificationLevel";
import { type UserTab } from "../views/dialogs/UserTab"; import { type UserTab } from "../views/dialogs/UserTab";
import { shouldSkipSetupEncryption } from "../../utils/crypto/shouldSkipSetupEncryption"; import { shouldSkipSetupEncryption } from "../../utils/crypto/shouldSkipSetupEncryption";
@@ -139,7 +139,7 @@ import { setTheme } from "../../theme";
import { type OpenForwardDialogPayload } from "../../dispatcher/payloads/OpenForwardDialogPayload"; import { type OpenForwardDialogPayload } from "../../dispatcher/payloads/OpenForwardDialogPayload";
import { ShareFormat, type SharePayload } from "../../dispatcher/payloads/SharePayload"; import { ShareFormat, type SharePayload } from "../../dispatcher/payloads/SharePayload";
import Markdown from "../../Markdown"; import Markdown from "../../Markdown";
import { sanitizeHtmlParams } from "../../Linkify"; import { LinkedTextConfiguration, sanitizeHtmlParams } from "../../Linkify";
import { isOnlyAdmin } from "../../utils/membership"; import { isOnlyAdmin } from "../../utils/membership";
import { ModuleApi } from "../../modules/Api.ts"; import { ModuleApi } from "../../modules/Api.ts";
@@ -1458,7 +1458,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
key, key,
title: userNotice.title, title: userNotice.title,
props: { props: {
description: <Linkify>{userNotice.description}</Linkify>, description: <LinkedText>{userNotice.description}</LinkedText>,
primaryLabel: _t("action|ok"), primaryLabel: _t("action|ok"),
onPrimaryClick: () => { onPrimaryClick: () => {
ToastStore.sharedInstance().dismissToast(key); ToastStore.sharedInstance().dismissToast(key);
@@ -2291,7 +2291,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
<ErrorBoundary> <ErrorBoundary>
<I18nContext.Provider value={ModuleApi.instance.i18n}> <I18nContext.Provider value={ModuleApi.instance.i18n}>
<SDKContext.Provider value={this.stores}> <SDKContext.Provider value={this.stores}>
<LinkedTextContext.Provider value={LinkedTextConfiguration}>
<TooltipProvider>{view}</TooltipProvider> <TooltipProvider>{view}</TooltipProvider>
</LinkedTextContext.Provider>
</SDKContext.Provider> </SDKContext.Provider>
</I18nContext.Provider> </I18nContext.Provider>
</ErrorBoundary> </ErrorBoundary>
@@ -41,6 +41,7 @@ import { sortBy, uniqBy } from "lodash";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { KnownMembership, type SpaceChildEventContent } from "matrix-js-sdk/src/types"; import { KnownMembership, type SpaceChildEventContent } from "matrix-js-sdk/src/types";
import { ChevronDownIcon, CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { ChevronDownIcon, CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { LinkedText } from "@element-hq/web-shared-components";
import defaultDispatcher from "../../dispatcher/dispatcher"; import defaultDispatcher from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler"; import { _t } from "../../languageHandler";
@@ -55,7 +56,7 @@ import InfoTooltip from "../views/elements/InfoTooltip";
import TextWithTooltip from "../views/elements/TextWithTooltip"; import TextWithTooltip from "../views/elements/TextWithTooltip";
import { useStateToggle } from "../../hooks/useStateToggle"; import { useStateToggle } from "../../hooks/useStateToggle";
import { getChildOrder } from "../../stores/spaces/SpaceStore"; import { getChildOrder } from "../../stores/spaces/SpaceStore";
import { Linkify, topicToHtml } from "../../HtmlUtils"; import { topicToHtml } from "../../HtmlUtils";
import { useDispatcher } from "../../hooks/useDispatcher"; import { useDispatcher } from "../../hooks/useDispatcher";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import { type IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex"; import { type IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
@@ -233,20 +234,12 @@ const Tile: React.FC<ITileProps> = ({
let topicSection: ReactNode | undefined; let topicSection: ReactNode | undefined;
if (topic) { if (topic) {
topicSection = (
<Linkify
options={{
attributes: {
onClick(ev: MouseEvent) {
// prevent clicks on links from bubbling up to the room tile // prevent clicks on links from bubbling up to the room tile
ev.stopPropagation(); topicSection = (
}, <LinkedText onLinkClick={(ev) => ev.stopPropagation()}>
},
}}
>
{" · "} {" · "}
{topic} {topic}
</Linkify> </LinkedText>
); );
} }
@@ -11,6 +11,7 @@ Please see LICENSE files in the repository root for full details.
import React, { type JSX } from "react"; import React, { type JSX } from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { type AuthType } from "matrix-js-sdk/src/interactive-auth"; import { type AuthType } from "matrix-js-sdk/src/interactive-auth";
import { LinkedText } from "@element-hq/web-shared-components";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
@@ -21,7 +22,6 @@ import InteractiveAuth, {
} from "../../structures/InteractiveAuth"; } from "../../structures/InteractiveAuth";
import { type ContinueKind, SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents"; import { type ContinueKind, SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents";
import BaseDialog from "./BaseDialog"; import BaseDialog from "./BaseDialog";
import { Linkify } from "../../../Linkify";
type DialogAesthetics = Partial<{ type DialogAesthetics = Partial<{
[x in AuthType]: { [x in AuthType]: {
@@ -163,9 +163,9 @@ export default class InteractiveAuthDialog<T> extends React.Component<Interactiv
if (this.state.authError) { if (this.state.authError) {
content = ( content = (
<div id="mx_Dialog_content"> <div id="mx_Dialog_content">
<Linkify> <LinkedText role="alert">
<div role="alert">{this.state.authError.message || this.state.authError.toString()}</div> {this.state.authError.message || this.state.authError.toString()}
</Linkify> </LinkedText>
<br /> <br />
<AccessibleButton onClick={this.onDismissClick} className="mx_GeneralButton" autoFocus={true}> <AccessibleButton onClick={this.onDismissClick} className="mx_GeneralButton" autoFocus={true}>
{_t("action|dismiss")} {_t("action|dismiss")}
@@ -10,6 +10,7 @@ import React, { type JSX, useCallback, useContext, useState } from "react";
import { type Room, EventType } from "matrix-js-sdk/src/matrix"; import { type Room, EventType } from "matrix-js-sdk/src/matrix";
import classNames from "classnames"; import classNames from "classnames";
import { Tooltip } from "@vector-im/compound-web"; import { Tooltip } from "@vector-im/compound-web";
import { LinkedText } from "@element-hq/web-shared-components";
import { useTopic } from "../../../hooks/room/useTopic"; import { useTopic } from "../../../hooks/room/useTopic";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
@@ -20,7 +21,7 @@ import InfoDialog from "../dialogs/InfoDialog";
import { useDispatcher } from "../../../hooks/useDispatcher"; import { useDispatcher } from "../../../hooks/useDispatcher";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AccessibleButton from "./AccessibleButton"; import AccessibleButton from "./AccessibleButton";
import { Linkify, topicToHtml } from "../../../HtmlUtils"; import { topicToHtml } from "../../../HtmlUtils";
import { tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks"; import { tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
interface IProps extends React.HTMLProps<HTMLDivElement> { interface IProps extends React.HTMLProps<HTMLDivElement> {
@@ -74,19 +75,7 @@ export default function RoomTopic({ room, className, ...props }: IProps): JSX.El
title: room.name, title: room.name,
description: ( description: (
<div> <div>
<Linkify <LinkedText onLinkClick={() => modal.close()}>{body}</LinkedText>
options={{
attributes: {
onClick(e: React.MouseEvent<HTMLDivElement>) {
onClick(e);
modal.close();
},
},
}}
as="p"
>
{body}
</Linkify>
{canSetTopic && ( {canSetTopic && (
<AccessibleButton <AccessibleButton
kind="primary_outline" kind="primary_outline"
@@ -122,7 +111,7 @@ export default function RoomTopic({ room, className, ...props }: IProps): JSX.El
onFocus={onHover} onFocus={onHover}
aria-label={_t("room|read_topic")} aria-label={_t("room|read_topic")}
> >
<Linkify>{body}</Linkify> <LinkedText>{body}</LinkedText>
</div> </div>
</Tooltip> </Tooltip>
); );
@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import React, { type JSX, createRef, type SyntheticEvent, type MouseEvent } from "react"; import React, { type JSX, createRef, type SyntheticEvent, type MouseEvent } from "react";
import { MsgType } from "matrix-js-sdk/src/matrix"; import { MsgType } from "matrix-js-sdk/src/matrix";
import { EventContentBodyView } from "@element-hq/web-shared-components"; import { EventContentBodyView, LINKIFIED_DATA_ATTRIBUTE } from "@element-hq/web-shared-components";
import { EventContentBodyViewModel } from "../../../viewmodels/message-body/EventContentBodyViewModel"; import { EventContentBodyViewModel } from "../../../viewmodels/message-body/EventContentBodyViewModel";
import { formatDate } from "../../../DateUtils"; import { formatDate } from "../../../DateUtils";
@@ -26,7 +26,6 @@ import LinkPreviewGroup from "../rooms/LinkPreviewGroup";
import { type IBodyProps } from "./IBodyProps"; import { type IBodyProps } from "./IBodyProps";
import RoomContext from "../../../contexts/RoomContext"; import RoomContext from "../../../contexts/RoomContext";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import { options as linkifyOpts } from "../../../linkify-matrix";
import { getParentEventId } from "../../../utils/Reply"; import { getParentEventId } from "../../../utils/Reply";
import { EditWysiwygComposer } from "../rooms/wysiwyg_composer"; import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
import { type IEventTileOps } from "../rooms/EventTile"; import { type IEventTileOps } from "../rooms/EventTile";
@@ -250,7 +249,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
private onBodyLinkClick = (e: MouseEvent): void => { private onBodyLinkClick = (e: MouseEvent): void => {
let target: HTMLLinkElement | null = e.target as HTMLLinkElement; let target: HTMLLinkElement | null = e.target as HTMLLinkElement;
// links processed by linkifyjs have their own handler so don't handle those here // links processed by linkifyjs have their own handler so don't handle those here
if (target.classList.contains(linkifyOpts.className as string)) return; if (target.dataset[LINKIFIED_DATA_ATTRIBUTE]) return;
if (target.nodeName !== "A") { if (target.nodeName !== "A") {
// Jump to parent as the `<a>` may contain children, e.g. an anchor wrapping an inline code section // Jump to parent as the `<a>` may contain children, e.g. an anchor wrapping an inline code section
target = target.closest<HTMLLinkElement>("a"); target = target.closest<HTMLLinkElement>("a");
@@ -39,14 +39,14 @@ import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error"
import ErrorSolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid"; import ErrorSolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down"; import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
import { JoinRule, type Room } from "matrix-js-sdk/src/matrix"; import { JoinRule, type Room } from "matrix-js-sdk/src/matrix";
import { Box, Flex, HistoryVisibilityBadge } from "@element-hq/web-shared-components"; import { Box, Flex, HistoryVisibilityBadge, LinkedText } from "@element-hq/web-shared-components";
import BaseCard from "./BaseCard.tsx"; import BaseCard from "./BaseCard.tsx";
import { _t } from "../../../languageHandler.tsx"; import { _t } from "../../../languageHandler.tsx";
import RoomAvatar from "../avatars/RoomAvatar.tsx"; import RoomAvatar from "../avatars/RoomAvatar.tsx";
import { E2EStatus } from "../../../utils/ShieldUtils.ts"; import { E2EStatus } from "../../../utils/ShieldUtils.ts";
import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks.ts"; import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks.ts";
import { Linkify, topicToHtml } from "../../../HtmlUtils.tsx"; import { topicToHtml } from "../../../HtmlUtils.tsx";
import { useRoomSummaryCardViewModel } from "../../viewmodels/right_panel/RoomSummaryCardViewModel.tsx"; import { useRoomSummaryCardViewModel } from "../../viewmodels/right_panel/RoomSummaryCardViewModel.tsx";
import { useRoomTopicViewModel } from "../../viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx"; import { useRoomTopicViewModel } from "../../viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx";
import { useRoomName } from "../../../hooks/useRoomName.ts"; import { useRoomName } from "../../../hooks/useRoomName.ts";
@@ -89,7 +89,7 @@ const RoomTopic: React.FC<Pick<IProps, "room">> = ({ room }): JSX.Element | null
); );
} }
const content = vm.expanded ? <Linkify>{body}</Linkify> : body; const content = vm.expanded ? <LinkedText as="span">{body}</LinkedText> : body;
return ( return (
<Flex <Flex
@@ -12,6 +12,7 @@ import { type Room, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import EMOTICON_REGEX from "emojibase-regex/emoticon"; import EMOTICON_REGEX from "emojibase-regex/emoticon";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { EMOTICON_TO_EMOJI } from "@matrix-org/emojibase-bindings"; import { EMOTICON_TO_EMOJI } from "@matrix-org/emojibase-bindings";
import { isLinkable } from "@element-hq/web-shared-components";
import type EditorModel from "../../../editor/model"; import type EditorModel from "../../../editor/model";
import HistoryManager from "../../../editor/history"; import HistoryManager from "../../../editor/history";
@@ -40,7 +41,6 @@ import { type ICompletion } from "../../../autocomplete/Autocompleter";
import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { ALTERNATE_KEY_NAME, KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { ALTERNATE_KEY_NAME, KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { linkify } from "../../../linkify-matrix";
import { SdkContextClass } from "../../../contexts/SDKContext"; import { SdkContextClass } from "../../../contexts/SDKContext";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation"; import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation";
@@ -357,7 +357,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
const range = getRangeForSelection(this.editorRef.current, model, document.getSelection()!); const range = getRangeForSelection(this.editorRef.current, model, document.getSelection()!);
// If the user is pasting a link, and has a range selected which is not a link, wrap the range with the link // If the user is pasting a link, and has a range selected which is not a link, wrap the range with the link
if (plainText && range.length > 0 && linkify.test(plainText) && !linkify.test(range.text)) { if (plainText && range.length > 0 && isLinkable(plainText) && !isLinkable(range.text)) {
formatRangeAsLink(range, plainText); formatRangeAsLink(range, plainText);
} else { } else {
replaceRangeAndMoveCaret(range, parts); replaceRangeAndMoveCaret(range, parts);
@@ -9,8 +9,8 @@ Please see LICENSE files in the repository root for full details.
import React, { type JSX, type ComponentProps, createRef, type ReactNode } from "react"; import React, { type JSX, type ComponentProps, createRef, type ReactNode } from "react";
import { decode } from "html-entities"; import { decode } from "html-entities";
import { type MatrixEvent, type IPreviewUrlResponse } from "matrix-js-sdk/src/matrix"; import { type MatrixEvent, type IPreviewUrlResponse } from "matrix-js-sdk/src/matrix";
import { LinkedText } from "@element-hq/web-shared-components";
import { Linkify } from "../../../HtmlUtils";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import * as ImageUtils from "../../../ImageUtils"; import * as ImageUtils from "../../../ImageUtils";
import { mediaFromMxc } from "../../../customisations/Media"; import { mediaFromMxc } from "../../../customisations/Media";
@@ -128,7 +128,7 @@ export default class LinkPreviewWidget extends React.Component<IProps> {
)} )}
</div> </div>
<div className="mx_LinkPreviewWidget_description"> <div className="mx_LinkPreviewWidget_description">
<Linkify>{description}</Linkify> <LinkedText>{description}</LinkedText>
</div> </div>
</div> </div>
</div> </div>
@@ -6,11 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, useContext } from "react"; import React, { type JSX, useContext, useMemo } from "react";
import { EventType, type Room, type User, type MatrixClient } from "matrix-js-sdk/src/matrix"; import { EventType, type Room, type User, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types"; import { KnownMembership } from "matrix-js-sdk/src/types";
import { ErrorSolidIcon, UserAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { ErrorSolidIcon, UserAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { EventTileBubble } from "@element-hq/web-shared-components"; import { EventTileBubble, LinkedText } from "@element-hq/web-shared-components";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import DMRoomMap from "../../../utils/DMRoomMap"; import DMRoomMap from "../../../utils/DMRoomMap";
@@ -32,7 +32,7 @@ import { LocalRoom } from "../../../models/LocalRoom";
import { shouldEncryptRoomWithSingle3rdPartyInvite } from "../../../utils/room/shouldEncryptRoomWithSingle3rdPartyInvite"; import { shouldEncryptRoomWithSingle3rdPartyInvite } from "../../../utils/room/shouldEncryptRoomWithSingle3rdPartyInvite";
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
import { useTopic } from "../../../hooks/room/useTopic"; import { useTopic } from "../../../hooks/room/useTopic";
import { topicToHtml, Linkify } from "../../../HtmlUtils"; import { topicToHtml } from "../../../HtmlUtils";
function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean { function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean {
const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId); const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId);
@@ -56,14 +56,23 @@ const NewRoomIntro: React.FC = () => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const { room, roomId } = useScopedRoomContext("room", "roomId"); const { room, roomId } = useScopedRoomContext("room", "roomId");
const topic = useTopic(room); const topic = useTopic(room);
const isLocalRoom = room instanceof LocalRoom;
let dmPartner: string | undefined;
if (isLocalRoom) {
dmPartner = room?.targets[0]?.userId;
} else if (roomId) {
dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId);
}
const renderedTopic = useMemo(
() => (dmPartner ? undefined : <LinkedText as="span">{topicToHtml(topic?.text, topic?.html)}</LinkedText>),
[topic, dmPartner],
);
if (!room || !roomId) { if (!room || !roomId) {
throw new Error("Unable to create a NewRoomIntro without room and roomId"); throw new Error("Unable to create a NewRoomIntro without room and roomId");
} }
const isLocalRoom = room instanceof LocalRoom;
const dmPartner = isLocalRoom ? room.targets[0]?.userId : DMRoomMap.shared().getUserIdForRoomId(roomId);
let body: JSX.Element; let body: JSX.Element;
if (dmPartner) { if (dmPartner) {
const { shouldEncrypt: encryptedSingle3rdPartyInvite } = shouldEncryptRoomWithSingle3rdPartyInvite(room); const { shouldEncrypt: encryptedSingle3rdPartyInvite } = shouldEncryptRoomWithSingle3rdPartyInvite(room);
@@ -137,15 +146,11 @@ const NewRoomIntro: React.FC = () => {
{sub} {sub}
</AccessibleButton> </AccessibleButton>
), ),
topic: () => <Linkify>{topicToHtml(topic?.text, topic?.html)}</Linkify>, topic: renderedTopic,
}, },
); );
} else if (topic) { } else if (topic) {
topicText = _t( topicText = _t("room|intro|display_topic", {}, { topic: renderedTopic });
"room|intro|display_topic",
{},
{ topic: () => <Linkify>{topicToHtml(topic?.text, topic?.html)}</Linkify> },
);
} else if (canAddTopic) { } else if (canAddTopic) {
topicText = _t( topicText = _t(
"room|intro|no_topic", "room|intro|no_topic",
+4 -277
View File
@@ -1,4 +1,5 @@
/* /*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
@@ -7,291 +8,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import * as linkifyjs from "linkifyjs";
import { type EventListeners, type Opts, registerCustomProtocol, registerPlugin } from "linkifyjs";
import linkifyString from "linkify-string";
import linkifyHtml from "linkify-html";
import { getHttpUriForMxc, User } from "matrix-js-sdk/src/matrix";
import {
parsePermalink,
tryTransformEntityToPermalink,
tryTransformPermalinkToLocalHref,
} from "./utils/permalinks/Permalinks";
import dis from "./dispatcher/dispatcher";
import { Action } from "./dispatcher/actions";
import { type ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload";
import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
import { MatrixClientPeg } from "./MatrixClientPeg";
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
export enum Type {
URL = "url",
UserId = "userid",
RoomAlias = "roomalias",
}
function matrixOpaqueIdLinkifyParser({
scanner,
parser,
token,
name,
}: {
scanner: linkifyjs.ScannerInit;
parser: linkifyjs.ParserInit;
token: "#" | "+" | "@";
name: Type;
}): void {
const {
DOT,
// IPV4 necessity
NUM,
COLON,
SYM,
SLASH,
EQUALS,
HYPHEN,
UNDERSCORE,
} = scanner.tokens;
// Contains NUM, WORD, UWORD, EMOJI, TLD, UTLD, SCHEME, SLASH_SCHEME and LOCALHOST plus custom protocols (e.g. "matrix")
const { domain } = scanner.tokens.groups;
// Tokens we need that are not contained in the domain group
const additionalLocalpartTokens = [DOT, SYM, SLASH, EQUALS, UNDERSCORE, HYPHEN];
const additionalDomainpartTokens = [HYPHEN];
const matrixToken = linkifyjs.createTokenClass(name, { isLink: true });
const matrixTokenState = new linkifyjs.State(matrixToken) as any as linkifyjs.State<linkifyjs.MultiToken>; // linkify doesn't appear to type this correctly
const matrixTokenWithPort = linkifyjs.createTokenClass(name, { isLink: true });
const matrixTokenWithPortState = new linkifyjs.State(
matrixTokenWithPort,
) as any as linkifyjs.State<linkifyjs.MultiToken>; // linkify doesn't appear to type this correctly
const INITIAL_STATE = parser.start.tt(token);
// Localpart
const LOCALPART_STATE = new linkifyjs.State<linkifyjs.MultiToken>();
INITIAL_STATE.ta(domain, LOCALPART_STATE);
INITIAL_STATE.ta(additionalLocalpartTokens, LOCALPART_STATE);
LOCALPART_STATE.ta(domain, LOCALPART_STATE);
LOCALPART_STATE.ta(additionalLocalpartTokens, LOCALPART_STATE);
// Domainpart
const DOMAINPART_STATE_DOT = LOCALPART_STATE.tt(COLON);
DOMAINPART_STATE_DOT.ta(domain, matrixTokenState);
DOMAINPART_STATE_DOT.ta(additionalDomainpartTokens, matrixTokenState);
matrixTokenState.ta(domain, matrixTokenState);
matrixTokenState.ta(additionalDomainpartTokens, matrixTokenState);
matrixTokenState.tt(DOT, DOMAINPART_STATE_DOT);
// Port suffixes
matrixTokenState.tt(COLON).tt(NUM, matrixTokenWithPortState);
}
function onUserClick(event: MouseEvent, userId: string): void {
event.preventDefault();
dis.dispatch<ViewUserPayload>({
action: Action.ViewUser,
member: new User(userId),
});
}
function onAliasClick(event: MouseEvent, roomAlias: string): void {
event.preventDefault();
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_alias: roomAlias,
metricsTrigger: "Timeline",
metricsViaKeyboard: false,
});
}
const escapeRegExp = function (s: string): string { const escapeRegExp = function (s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}; };
// Recognise URLs from both our local and official Element deployments. // Recognise URLs from both our local and official Element deployments.
// Anyone else really should be using matrix.to. vector:// allowed to support Element Desktop relative links. // Anyone else really should be using matrix.to. vector:// allowed to support Element Desktop relative links.
export const ELEMENT_URL_PATTERN = export const ELEMENT_URL_PATTERN = new RegExp(
"^(?:vector://|https?://)?(?:" + "^(?:vector://|https?://)?(?:" +
escapeRegExp(window.location.host + window.location.pathname) + escapeRegExp(window.location.host + window.location.pathname) +
"|" + "|" +
"(?:www\\.)?(?:riot|vector)\\.im/(?:app|beta|staging|develop)/|" + "(?:www\\.)?(?:riot|vector)\\.im/(?:app|beta|staging|develop)/|" +
"(?:app|beta|staging|develop)\\.element\\.io/" + "(?:app|beta|staging|develop)\\.element\\.io/" +
")(#.*)"; ")(#.*)",
);
// Attach click handlers to links based on their type
function events(href: string, type: string): EventListeners {
switch (type as Type) {
case Type.URL: {
// intercept local permalinks to users and show them like userids (in userinfo of current room)
try {
const permalink = parsePermalink(href);
if (permalink?.userId) {
return {
click: function (e: MouseEvent) {
onUserClick(e, permalink.userId!);
},
};
} else {
// for events, rooms etc. (anything other than users)
const localHref = tryTransformPermalinkToLocalHref(href);
if (localHref !== href) {
// it could be converted to a localHref -> therefore handle locally
return {
click: function (e: MouseEvent) {
e.preventDefault();
window.location.hash = localHref;
},
};
}
}
} catch {
// OK fine, it's not actually a permalink
}
break;
}
case Type.UserId:
return {
click: function (e: MouseEvent) {
e.preventDefault();
const userId = parsePermalink(href)?.userId ?? href;
if (userId) onUserClick(e, userId);
},
};
case Type.RoomAlias:
return {
click: function (e: MouseEvent) {
e.preventDefault();
const alias = parsePermalink(href)?.roomIdOrAlias ?? href;
if (alias) onAliasClick(e, alias);
},
};
}
return {};
}
// linkify-react doesn't respect `events` and needs it mapping to React attributes
// so we need to manually add the click handler to the attributes
// https://linkify.js.org/docs/linkify-react.html#events
function attributes(href: string, type: string): Record<string, unknown> {
const attrs: Record<string, unknown> = {
rel: "noreferrer noopener",
};
const options = events(href, type);
if (options?.click) {
attrs.onClick = options.click;
}
return attrs;
}
export const options: Opts = {
events,
formatHref: function (href: string, type: Type | string): string {
switch (type) {
case "url":
if (href.startsWith("mxc://") && MatrixClientPeg.get()) {
return getHttpUriForMxc(
MatrixClientPeg.get()!.baseUrl,
href,
undefined,
undefined,
undefined,
false,
true,
);
}
// fallthrough
case Type.RoomAlias:
case Type.UserId:
default: {
return tryTransformEntityToPermalink(MatrixClientPeg.safeGet(), href) ?? "";
}
}
},
attributes,
ignoreTags: ["a", "pre", "code"],
className: "linkified",
target: function (href: string, type: Type | string): string {
if (type === Type.URL) {
try {
const transformed = tryTransformPermalinkToLocalHref(href);
if (
transformed !== href || // if it could be converted to handle locally for matrix symbols e.g. @user:server.tdl and matrix.to
decodeURIComponent(href).match(ELEMENT_URL_PATTERN) // for https links to Element domains
) {
return "";
} else {
return "_blank";
}
} catch {
// malformed URI
}
}
return "";
},
};
// Run the plugins
registerPlugin(Type.RoomAlias, ({ scanner, parser }) => {
const token = scanner.tokens.POUND as "#";
matrixOpaqueIdLinkifyParser({
scanner,
parser,
token,
name: Type.RoomAlias,
});
});
registerPlugin(Type.UserId, ({ scanner, parser }) => {
const token = scanner.tokens.AT as "@";
matrixOpaqueIdLinkifyParser({
scanner,
parser,
token,
name: Type.UserId,
});
});
// Linkify supports some common protocols but not others, register all permitted url schemes if unsupported
// https://github.com/Hypercontext/linkifyjs/blob/f4fad9df1870259622992bbfba38bfe3d0515609/packages/linkifyjs/src/scanner.js#L133-L141
// This also handles registering the `matrix:` protocol scheme
const linkifySupportedProtocols = ["file", "mailto", "http", "https", "ftp", "ftps"];
const optionalSlashProtocols = [
"bitcoin",
"geo",
"im",
"magnet",
"mailto",
"matrix",
"news",
"openpgp4fpr",
"sip",
"sms",
"smsto",
"tel",
"urn",
"xmpp",
];
PERMITTED_URL_SCHEMES.forEach((scheme) => {
if (!linkifySupportedProtocols.includes(scheme)) {
registerCustomProtocol(scheme, optionalSlashProtocols.includes(scheme));
}
});
registerCustomProtocol("mxc", false);
export const linkify = linkifyjs;
export const _linkifyString = linkifyString;
export const _linkifyHtml = linkifyHtml;
@@ -20,12 +20,13 @@ import {
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { KnownMembership, type RoomMemberEventContent } from "matrix-js-sdk/src/types"; import { KnownMembership, type RoomMemberEventContent } from "matrix-js-sdk/src/types";
import { LinkedText } from "@element-hq/web-shared-components";
import dis from "../dispatcher/dispatcher"; import dis from "../dispatcher/dispatcher";
import { _t, _td, UserFriendlyError } from "../languageHandler"; import { _t, _td, UserFriendlyError } from "../languageHandler";
import Modal from "../Modal"; import Modal from "../Modal";
import MultiInviter from "../utils/MultiInviter"; import MultiInviter from "../utils/MultiInviter";
import { Linkify, topicToHtml } from "../HtmlUtils"; import { topicToHtml } from "../HtmlUtils";
import QuestionDialog from "../components/views/dialogs/QuestionDialog"; import QuestionDialog from "../components/views/dialogs/QuestionDialog";
import WidgetUtils from "../utils/WidgetUtils"; import WidgetUtils from "../utils/WidgetUtils";
import { textToHtmlRainbow } from "../utils/colour"; import { textToHtmlRainbow } from "../utils/colour";
@@ -270,7 +271,7 @@ export const Commands = [
Modal.createDialog(InfoDialog, { Modal.createDialog(InfoDialog, {
title: room.name, title: room.name,
description: <Linkify>{body}</Linkify>, description: <LinkedText>{body}</LinkedText>,
hasCloseButton: true, hasCloseButton: true,
className: "markdown-body", className: "markdown-body",
}); });
+2 -2
View File
@@ -1,4 +1,5 @@
/* /*
* Copyright 2026 Element Creations Ltd.
* Copyright 2024 New Vector Ltd. * Copyright 2024 New Vector Ltd.
* Copyright 2023 The Matrix.org Foundation C.I.C. * Copyright 2023 The Matrix.org Foundation C.I.C.
* Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com> * Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
@@ -9,8 +10,7 @@
import { type IContent, type IEventRelation, type MatrixEvent, THREAD_RELATION_TYPE } from "matrix-js-sdk/src/matrix"; import { type IContent, type IEventRelation, type MatrixEvent, THREAD_RELATION_TYPE } from "matrix-js-sdk/src/matrix";
import sanitizeHtml from "sanitize-html"; import sanitizeHtml from "sanitize-html";
import { PERMITTED_URL_SCHEMES } from "@element-hq/web-shared-components";
import { PERMITTED_URL_SCHEMES } from "./UrlUtils";
export function getParentEventId(ev?: MatrixEvent): string | undefined { export function getParentEventId(ev?: MatrixEvent): string | undefined {
if (!ev || ev.isRedacted()) return; if (!ev || ev.isRedacted()) return;
-28
View File
@@ -49,31 +49,3 @@ export function parseUrl(u: string): URL {
} }
return new URL(u); return new URL(u);
} }
export const PERMITTED_URL_SCHEMES = [
"bitcoin",
"ftp",
"geo",
"http",
"https",
"im",
"irc",
"ircs",
"magnet",
"mailto",
"matrix",
"mms",
"news",
"nntp",
"openpgp4fpr",
"sip",
"sftp",
"sms",
"smsto",
"ssh",
"tel",
"urn",
"webcal",
"wtai",
"xmpp",
];
+22 -1
View File
@@ -101,7 +101,7 @@ describe("bodyToHtml", () => {
); );
expect(html).toMatchInlineSnapshot( expect(html).toMatchInlineSnapshot(
`"foo <a href="http://link.example/test/path" class="linkified" target="_blank" rel="noreferrer noopener">http://link.example/<span class="mx_EventTile_searchHighlight">test</span>/path</a> bar"`, `"foo <a href="http://link.example/test/path" target="_blank" rel="noreferrer noopener" data-linkified="true">http://link.example/<span class="mx_EventTile_searchHighlight">test</span>/path</a> bar"`,
); );
}); });
@@ -124,6 +124,27 @@ describe("bodyToHtml", () => {
); );
}); });
it("should ignore data-linkified in incoming links but should be applied to linkified links", () => {
getMockClientWithEventEmitter({});
const html = bodyToHtml(
{
body: "foo http://link.example/test/path bar",
msgtype: "m.text",
formatted_body:
'foo <a data-linkfied="true" href="http://link.example/test/path">http://link.example/test/path</a> bar with https://example.org',
format: "org.matrix.custom.html",
},
[],
{
linkify: true,
},
);
expect(html).toMatchInlineSnapshot(
`"foo <a href="http://link.example/test/path" target="_blank" rel="noreferrer noopener">http://link.example/test/path</a> bar with <a href="https://example.org" target="_blank" rel="noreferrer noopener" data-linkified="true">https://example.org</a>"`,
);
});
it("does not mistake characters in text presentation mode for emoji", () => { it("does not mistake characters in text presentation mode for emoji", () => {
const { asFragment } = render( const { asFragment } = render(
<span className="mx_EventTile_body translate" dir="auto"> <span className="mx_EventTile_body translate" dir="auto">
@@ -10,6 +10,7 @@ import React from "react";
import { Room } from "matrix-js-sdk/src/matrix"; import { Room } from "matrix-js-sdk/src/matrix";
import { fireEvent, render, screen, waitFor } from "jest-matrix-react"; import { fireEvent, render, screen, waitFor } from "jest-matrix-react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { LinkedTextContext } from "@element-hq/web-shared-components";
import { mkEvent, stubClient } from "../../../../test-utils"; import { mkEvent, stubClient } from "../../../../test-utils";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
@@ -52,7 +53,9 @@ describe("<RoomTopic/>", () => {
*/ */
const renderRoom = (topic: string) => { const renderRoom = (topic: string) => {
const room = createRoom(topic); const room = createRoom(topic);
render(<RoomTopic room={room} />); render(<RoomTopic room={room} />, {
wrapper: ({ children }) => <LinkedTextContext.Provider value={{}}>{children}</LinkedTextContext.Provider>,
});
}; };
/** /**
@@ -193,7 +193,7 @@ describe("<TextualBody />", () => {
const { container } = getComponent({ mxEvent: ev }); const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body"); const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot( expect(content.innerHTML).toMatchInlineSnapshot(
`"Chat with <a href="https://matrix.to/#/@user:example.com" class="linkified" rel="noreferrer noopener">@user:example.com</a>"`, `"Chat with <a href="https://matrix.to/#/@user:example.com" rel="noreferrer noopener" data-linkified="true">@user:example.com</a>"`,
); );
}); });
@@ -211,7 +211,7 @@ describe("<TextualBody />", () => {
const { container } = getComponent({ mxEvent: ev }); const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body"); const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot( expect(content.innerHTML).toMatchInlineSnapshot(
`"Visit <a href="https://matrix.to/#/#room:example.com" class="linkified" rel="noreferrer noopener">#room:example.com</a>"`, `"Visit <a href="https://matrix.to/#/#room:example.com" rel="noreferrer noopener" data-linkified="true">#room:example.com</a>"`,
); );
}); });
@@ -557,7 +557,7 @@ exports[`<TextualBody /> renders plain-text m.text correctly linkification get a
> >
Visit Visit
<a <a
class="linkified" data-linkified="true"
href="https://matrix.org/" href="https://matrix.org/"
rel="noreferrer noopener" rel="noreferrer noopener"
target="_blank" target="_blank"
@@ -11,6 +11,7 @@ import { render, fireEvent, screen } from "jest-matrix-react";
import { Room, type MatrixClient, JoinRule, MatrixEvent, HistoryVisibility } from "matrix-js-sdk/src/matrix"; import { Room, type MatrixClient, JoinRule, MatrixEvent, HistoryVisibility } from "matrix-js-sdk/src/matrix";
import { mocked, type MockedObject } from "jest-mock"; import { mocked, type MockedObject } from "jest-mock";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { LinkedTextContext } from "@element-hq/web-shared-components";
import RoomSummaryCardView from "../../../../../src/components/views/right_panel/RoomSummaryCardView"; import RoomSummaryCardView from "../../../../../src/components/views/right_panel/RoomSummaryCardView";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
@@ -44,7 +45,9 @@ describe("<RoomSummaryCard />", () => {
return render(<RoomSummaryCardView {...defaultProps} {...props} />, { return render(<RoomSummaryCardView {...defaultProps} {...props} />, {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<MatrixClientContext.Provider value={mockClient}>{children}</MatrixClientContext.Provider> <MatrixClientContext.Provider value={mockClient}>
<LinkedTextContext.Provider value={{}}>{children}</LinkedTextContext.Provider>
</MatrixClientContext.Provider>
), ),
}); });
}; };
@@ -140,12 +140,16 @@ exports[`<RoomSummaryCard /> has button to edit topic 1`] = `
> >
<p <p
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31" class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31"
>
<span
class="_container_15awj_8"
> >
<span <span
dir="auto" dir="auto"
> >
This is the room's topic. This is the room's topic.
</span> </span>
</span>
</p> </p>
<button <button
class="_icon-button_1215g_8 mx_RoomSummaryCard_topic_chevron" class="_icon-button_1215g_8 mx_RoomSummaryCard_topic_chevron"
@@ -1572,12 +1576,16 @@ exports[`<RoomSummaryCard /> renders the room topic in the summary 1`] = `
> >
<p <p
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31" class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31"
>
<span
class="_container_15awj_8"
> >
<span <span
dir="auto" dir="auto"
> >
This is the room's topic. This is the room's topic.
</span> </span>
</span>
</p> </p>
<button <button
class="_icon-button_1215g_8 mx_RoomSummaryCard_topic_chevron" class="_icon-button_1215g_8 mx_RoomSummaryCard_topic_chevron"
@@ -11,6 +11,7 @@ import React from "react";
import { render, screen } from "jest-matrix-react"; import { render, screen } from "jest-matrix-react";
import { EventTimeline, type MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { EventTimeline, type MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types"; import { KnownMembership } from "matrix-js-sdk/src/types";
import { LinkedTextContext } from "@element-hq/web-shared-components";
import { LocalRoom } from "../../../../../src/models/LocalRoom"; import { LocalRoom } from "../../../../../src/models/LocalRoom";
import { import {
@@ -32,7 +33,9 @@ const renderNewRoomIntro = (client: MatrixClient, room: Room | LocalRoom) => {
render( render(
<MatrixClientContext.Provider value={client}> <MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider {...({ room, roomId: room.roomId } as unknown as RoomContextType)}> <ScopedRoomContextProvider {...({ room, roomId: room.roomId } as unknown as RoomContextType)}>
<LinkedTextContext.Provider value={{}}>
<NewRoomIntro /> <NewRoomIntro />
</LinkedTextContext.Provider>
</ScopedRoomContextProvider> </ScopedRoomContextProvider>
</MatrixClientContext.Provider>, </MatrixClientContext.Provider>,
); );
@@ -6,12 +6,15 @@ exports[`NewRoomIntro topic should render a link in the topic 1`] = `
> >
<span> <span>
Topic: Topic:
<span
class="_container_15awj_8"
>
<span <span
dir="auto" dir="auto"
> >
This is a link: This is a link:
<a <a
class="linkified" data-linkified="true"
href="https://matrix.org/" href="https://matrix.org/"
rel="noreferrer noopener" rel="noreferrer noopener"
target="_blank" target="_blank"
@@ -20,5 +23,6 @@ exports[`NewRoomIntro topic should render a link in the topic 1`] = `
</a> </a>
</span> </span>
</span> </span>
</span>
</p> </p>
`; `;
+3 -387
View File
@@ -5,331 +5,16 @@ Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import { roomAliasEventListeners, userIdEventListeners } from "../../src/Linkify";
import { type EventListeners } from "linkifyjs";
import { linkify, Type, options } from "../../src/linkify-matrix";
import dispatcher from "../../src/dispatcher/dispatcher"; import dispatcher from "../../src/dispatcher/dispatcher";
import { Action } from "../../src/dispatcher/actions"; import { Action } from "../../src/dispatcher/actions";
describe("linkify-matrix", () => { describe("linkify-matrix", () => {
const linkTypesByInitialCharacter: Record<string, string> = {
"#": "roomalias",
"@": "userid",
};
/**
*
* @param testName Due to all the tests using the same logic underneath, it makes to generate it in a bit smarter way
* @param char
*/
function genTests(char: "#" | "@" | "+") {
const type = linkTypesByInitialCharacter[char];
it("should not parse " + char + "foo without domain", () => {
const test = char + "foo";
const found = linkify.find(test);
expect(found).toEqual([]);
});
describe("ip v4 tests", () => {
it("should properly parse IPs v4 as the domain name", () => {
const test = char + "potato:1.2.3.4";
const found = linkify.find(test);
expect(found).toEqual([
{
href: char + "potato:1.2.3.4",
type,
isLink: true,
start: 0,
end: test.length,
value: char + "potato:1.2.3.4",
},
]);
});
it("should properly parse IPs v4 with port as the domain name with attached", () => {
const test = char + "potato:1.2.3.4:1337";
const found = linkify.find(test);
expect(found).toEqual([
{
href: char + "potato:1.2.3.4:1337",
type,
isLink: true,
start: 0,
end: test.length,
value: char + "potato:1.2.3.4:1337",
},
]);
});
it("should properly parse IPs v4 as the domain name while ignoring missing port", () => {
const test = char + "potato:1.2.3.4:";
const found = linkify.find(test);
expect(found).toEqual([
{
href: char + "potato:1.2.3.4",
type,
isLink: true,
start: 0,
end: test.length - 1,
value: char + "potato:1.2.3.4",
},
]);
});
});
// Currently those tests are failing, as there's missing implementation.
describe.skip("ip v6 tests", () => {
it("should properly parse IPs v6 as the domain name", () => {
const test = char + "username:[1234:5678::abcd]";
const found = linkify.find(test);
expect(found).toEqual([
{
href: char + "username:[1234:5678::abcd]",
type,
isLink: true,
start: 0,
end: test.length,
value: char + "username:[1234:5678::abcd]",
},
]);
});
it("should properly parse IPs v6 with port as the domain name", () => {
const test = char + "username:[1234:5678::abcd]:1337";
const found = linkify.find(test);
expect(found).toEqual([
{
href: char + "username:[1234:5678::abcd]:1337",
type,
isLink: true,
start: 0,
end: test.length,
value: char + "username:[1234:5678::abcd]:1337",
},
]);
});
// eslint-disable-next-line max-len
it("should properly parse IPs v6 while ignoring dangling comma when without port name as the domain name", () => {
const test = char + "username:[1234:5678::abcd]:";
const found = linkify.find(test);
expect(found).toEqual([
{
href: char + "username:[1234:5678::abcd]:",
type,
isLink: true,
start: 0,
end: test.length - 1,
value: char + "username:[1234:5678::abcd]:",
},
]);
});
});
it("properly parses " + char + "_foonetic_xkcd:matrix.org", () => {
const test = "" + char + "_foonetic_xkcd:matrix.org";
const found = linkify.find(test);
expect(found).toEqual([
{
href: char + "_foonetic_xkcd:matrix.org",
type,
value: char + "_foonetic_xkcd:matrix.org",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("properly parses " + char + "localhost:foo.com", () => {
const test = char + "localhost:foo.com";
const found = linkify.find(test);
expect(found).toEqual([
{
href: char + "localhost:foo.com",
type,
value: char + "localhost:foo.com",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("properly parses " + char + "foo:localhost", () => {
const test = char + "foo:localhost";
const found = linkify.find(test);
expect(found).toEqual([
{
href: char + "foo:localhost",
type,
value: char + "foo:localhost",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("accept " + char + "foo:bar.com", () => {
const test = "" + char + "foo:bar.com";
const found = linkify.find(test);
expect(found).toEqual([
{
href: char + "foo:bar.com",
type,
value: char + "foo:bar.com",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("accept " + char + "foo:com (mostly for (TLD|DOMAIN)+ mixing)", () => {
const test = "" + char + "foo:com";
const found = linkify.find(test);
expect(found).toEqual([
{
href: char + "foo:com",
type,
value: char + "foo:com",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("accept repeated TLDs (e.g .org.uk)", () => {
const test = "" + char + "foo:bar.org.uk";
const found = linkify.find(test);
expect(found).toEqual([
{
href: char + "foo:bar.org.uk",
type,
value: char + "foo:bar.org.uk",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("accept hyphens in name " + char + "foo-bar:server.com", () => {
const test = "" + char + "foo-bar:server.com";
const found = linkify.find(test);
expect(found).toEqual([
{
href: char + "foo-bar:server.com",
type,
value: char + "foo-bar:server.com",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("ignores trailing `:`", () => {
const test = "" + char + "foo:bar.com:";
const found = linkify.find(test);
expect(found).toEqual([
{
type,
value: char + "foo:bar.com",
href: char + "foo:bar.com",
start: 0,
end: test.length - ":".length,
isLink: true,
},
]);
});
it("accept :NUM (port specifier)", () => {
const test = "" + char + "foo:bar.com:2225";
const found = linkify.find(test);
expect(found).toEqual([
{
href: char + "foo:bar.com:2225",
type,
value: char + "foo:bar.com:2225",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("ignores duplicate :NUM (double port specifier)", () => {
const test = "" + char + "foo:bar.com:2225:1234";
const found = linkify.find(test);
expect(found).toEqual([
{
href: char + "foo:bar.com:2225",
type,
value: char + "foo:bar.com:2225",
start: 0,
end: 17,
isLink: true,
},
]);
});
it("ignores all the trailing :", () => {
const test = "" + char + "foo:bar.com::::";
const found = linkify.find(test);
expect(found).toEqual([
{
href: char + "foo:bar.com",
type,
value: char + "foo:bar.com",
end: test.length - 4,
start: 0,
isLink: true,
},
]);
});
it("properly parses room alias with dots in name", () => {
const test = "" + char + "foo.asdf:bar.com::::";
const found = linkify.find(test);
expect(found).toEqual([
{
href: char + "foo.asdf:bar.com",
type,
value: char + "foo.asdf:bar.com",
start: 0,
end: test.length - ":".repeat(4).length,
isLink: true,
},
]);
});
it("does not parse room alias with too many separators", () => {
const test = "" + char + "foo:::bar.com";
const found = linkify.find(test);
expect(found).toEqual([
{
href: "http://bar.com",
type: "url",
value: "bar.com",
isLink: true,
start: 7,
end: test.length,
},
]);
});
it("properly parses room alias with hyphen in domain part", () => {
const test = "" + char + "foo:bar.com-baz.com";
const found = linkify.find(test);
expect(found).toEqual([
{
href: char + "foo:bar.com-baz.com",
type,
value: char + "foo:bar.com-baz.com",
end: 20,
start: 0,
isLink: true,
},
]);
});
}
describe("roomalias plugin", () => { describe("roomalias plugin", () => {
genTests("#");
it("should intercept clicks with a ViewRoom dispatch", () => { it("should intercept clicks with a ViewRoom dispatch", () => {
const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
const handlers = (options.events as (href: string, type: string) => EventListeners)( const handlers = roomAliasEventListeners("#room:server.com");
"#room:server.com",
"roomalias",
);
const event = new MouseEvent("mousedown"); const event = new MouseEvent("mousedown");
event.preventDefault = jest.fn(); event.preventDefault = jest.fn();
handlers!.click(event); handlers!.click(event);
@@ -344,31 +29,10 @@ describe("linkify-matrix", () => {
}); });
describe("userid plugin", () => { describe("userid plugin", () => {
genTests("@");
it("allows dots in localparts", () => {
const test = "@test.:matrix.org";
const found = linkify.find(test);
expect(found).toEqual([
{
href: test,
type: "userid",
value: test,
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("should intercept clicks with a ViewUser dispatch", () => { it("should intercept clicks with a ViewUser dispatch", () => {
const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
const handlers = (options.events as (href: string, type: string) => EventListeners)( const handlers = userIdEventListeners("@localpart:server.com");
"@localpart:server.com",
"userid",
);
const event = new MouseEvent("mousedown"); const event = new MouseEvent("mousedown");
event.preventDefault = jest.fn(); event.preventDefault = jest.fn();
@@ -384,52 +48,4 @@ describe("linkify-matrix", () => {
); );
}); });
}); });
describe("matrix uri", () => {
const acceptedMatrixUris = [
"matrix:u/foo_bar:server.uk",
"matrix:r/foo-bar:server.uk",
"matrix:roomid/somewhere:example.org?via=elsewhere.ca",
"matrix:r/somewhere:example.org",
"matrix:r/somewhere:example.org/e/event",
"matrix:roomid/somewhere:example.org/e/event?via=elsewhere.ca",
"matrix:u/alice:example.org?action=chat",
];
for (const matrixUri of acceptedMatrixUris) {
it("accepts " + matrixUri, () => {
const test = matrixUri;
const found = linkify.find(test);
expect(found).toEqual([
{
href: matrixUri,
type: Type.URL,
value: matrixUri,
end: matrixUri.length,
start: 0,
isLink: true,
},
]);
});
}
});
describe("matrix-prefixed domains", () => {
const acceptedDomains = ["matrix.org", "matrix.to", "matrix-help.org", "matrix123.org"];
for (const domain of acceptedDomains) {
it("accepts " + domain, () => {
const test = domain;
const found = linkify.find(test);
expect(found).toEqual([
{
href: `http://${domain}`,
type: Type.URL,
value: domain,
end: domain.length,
start: 0,
isLink: true,
},
]);
});
}
});
}); });
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

+4
View File
@@ -57,6 +57,10 @@
"classnames": "^2.5.1", "classnames": "^2.5.1",
"counterpart": "^0.18.6", "counterpart": "^0.18.6",
"html-react-parser": "^5.2.2", "html-react-parser": "^5.2.2",
"linkify-html": "4.3.2",
"linkify-react": "4.3.2",
"linkify-string": "4.3.2",
"linkifyjs": "4.3.2",
"lodash": "npm:lodash-es@^4.17.21", "lodash": "npm:lodash-es@^4.17.21",
"matrix-web-i18n": "catalog:", "matrix-web-i18n": "catalog:",
"react-merge-refs": "^3.0.2", "react-merge-refs": "^3.0.2",
+2
View File
@@ -42,6 +42,7 @@ export * from "./room-list/VirtualizedRoomListView";
export * from "./timeline/DateSeparatorView/"; export * from "./timeline/DateSeparatorView/";
export * from "./utils/Box"; export * from "./utils/Box";
export * from "./utils/Flex"; export * from "./utils/Flex";
export * from "./utils/LinkedText";
export * from "./right-panel/WidgetContextMenu"; export * from "./right-panel/WidgetContextMenu";
export * from "./utils/VirtualizedList"; export * from "./utils/VirtualizedList";
@@ -53,5 +54,6 @@ export * from "./utils/DateUtils";
export * from "./utils/numbers"; export * from "./utils/numbers";
export * from "./utils/FormattingUtils"; export * from "./utils/FormattingUtils";
export * from "./utils/I18nApi"; export * from "./utils/I18nApi";
export * from "./utils/linkify";
// MVVM // MVVM
export * from "./viewmodel"; export * from "./viewmodel";
@@ -0,0 +1,13 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.container {
a {
color: var(--cpd-color-text-link-external);
}
margin: 0;
}
@@ -0,0 +1,71 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type ComponentProps } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LinkedText } from "./LinkedText";
import { LinkedTextContext } from "./LinkedTextContext";
const meta = {
title: "Utils/LinkedText",
component: LinkedText,
decorators: [
(Story, { args }) => (
<LinkedTextContext.Provider
value={{
userIdListener: args.userIdListener,
roomAliasListener: args.roomAliasListener,
urlTargetTransformer: args.urlTargetTransformer,
hrefTransformer: args.hrefTransformer,
}}
>
<Story />
</LinkedTextContext.Provider>
),
],
args: {
children: "I love working on https://matrix.org.",
},
tags: ["autodocs"],
} satisfies Meta<ComponentProps<typeof LinkedText> & ComponentProps<typeof LinkedTextContext>["value"]>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithUserId: Story = {
args: {
children: "I love talking to @alice:example.org.",
userIdListener: fn(),
},
};
export const WithRoomAlias: Story = {
args: {
children: "I love talking in #general:example.org.",
roomAliasListener: fn(),
},
};
export const WithCustomUrlTarget: Story = {
args: {
urlTargetTransformer: () => "_fake_target",
},
tags: ["skip-test"],
};
export const WithCustomHref: Story = {
args: {
hrefTransformer: () => {
return "https://example.org";
},
},
tags: ["skip-test"],
};
@@ -0,0 +1,85 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { render } from "@test-utils";
import { describe, it, expect, vitest } from "vitest";
import React from "react";
import { composeStories } from "@storybook/react-vite";
import userEvent from "@testing-library/user-event";
import * as stories from "./LinkedText.stories.tsx";
import { LinkedText } from "./LinkedText.tsx";
import { LinkifyOptionalSlashProtocols, PERMITTED_URL_SCHEMES } from "../linkify";
import { LinkedTextContext } from "./LinkedTextContext.tsx";
const { Default, WithUserId, WithRoomAlias, WithCustomHref, WithCustomUrlTarget } = composeStories(stories);
describe("LinkedText", () => {
it.each(
PERMITTED_URL_SCHEMES.filter((protocol) => !LinkifyOptionalSlashProtocols.includes(protocol)).map(
(protocol) => `${protocol}://abcdef/`,
),
)("renders protocol with no optional slash '%s'", (path) => {
const { getByRole } = render(
<LinkedTextContext value={{}}>
<LinkedText>Check out this link {path}</LinkedText>
</LinkedTextContext>,
);
expect(getByRole("link")).toBeInTheDocument();
});
it.each(LinkifyOptionalSlashProtocols.map((protocol) => `${protocol}://abcdef`))(
"renders protocol with optional slash '%s'",
(path) => {
const { getByRole } = render(
<LinkedTextContext value={{}}>
<LinkedText>Check out this link {path}</LinkedText>
</LinkedTextContext>,
);
expect(getByRole("link")).toBeInTheDocument();
},
);
it("renders a standard link", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders a user ID", () => {
const { container } = render(<WithUserId />);
expect(container).toMatchSnapshot();
});
it("renders a room alias", () => {
const { container } = render(<WithRoomAlias />);
expect(container).toMatchSnapshot();
});
it("renders a custom target", () => {
const { container } = render(<WithCustomUrlTarget />);
expect(container).toMatchSnapshot();
});
it("renders a custom href", () => {
const { container } = render(<WithCustomHref />);
expect(container).toMatchSnapshot();
});
it("supports setting an onLinkClicked handler", async () => {
const fn = vitest.fn();
const { getAllByRole } = render(
<LinkedTextContext value={{}}>
<LinkedText onLinkClick={fn}>Check out this link https://google.com and example.org</LinkedText>
</LinkedTextContext>,
);
const links = getAllByRole("link");
expect(links).toHaveLength(2);
await userEvent.click(links[0]);
await userEvent.click(links[1]);
expect(fn).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,52 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { Link, Text } from "@vector-im/compound-web";
import React, { type ComponentProps } from "react";
import classNames from "classnames";
import Linkify from "linkify-react";
import styles from "./LinkedText.module.css";
import { generateLinkedTextOptions } from "../linkify";
import { useLinkedTextContext } from "./LinkedTextContext";
export type LinkedTextProps = ComponentProps<typeof Text> & {
/**
* Handler for when a link within the component is clicked. This will run
* *before* any LinkedTextContext handlers are run.
* @param ev The event raised by the click.
*/
onLinkClick?: (ev: MouseEvent) => void;
};
/**
* A component that renders URLs as clickable links inside some plain text.
*
* Requires a `<LinkedTextContext.Provider>`
*
* @example
* ```tsx
* <LinkedTextContext.Provider value={...}>
* <LinkedText>
* I love working on https://matrix.org
* </LinkedText>
* </LinkedTextContext.Provider>
* ```
*/
export function LinkedText({ children, className, onLinkClick, ...textProps }: LinkedTextProps): React.ReactNode {
const options = useLinkedTextContext();
const linkifyOptions = generateLinkedTextOptions({ ...options, onLinkClick });
return (
<Linkify
className={classNames(styles.container, className)}
as={Text}
options={{ ...linkifyOptions, render: Link }}
{...textProps}
>
{children}
</Linkify>
);
}
@@ -0,0 +1,50 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { createContext, useContext } from "react";
import type { LinkEventListener, LinkifyMatrixOpaqueIdType } from "../linkify";
export interface LinkedTextConfiguration {
/**
* Event handlers for URL links.
*/
urlListener?: (href: string) => LinkEventListener;
/**
* Event handlers for room alias links.
*/
roomAliasListener?: (href: string) => LinkEventListener;
/**
* Event handlers for user ID links.
*/
userIdListener?: (href: string) => LinkEventListener;
/**
* Function that can be used to transform the `target` attribute on links, depending on the `href`.
*/
urlTargetTransformer?: (href: string) => string;
/**
* Function that can be used to transform the `href` attribute on links, depending on the current href and target type.
*/
hrefTransformer?: (href: string, target: LinkifyMatrixOpaqueIdType) => string;
}
export const LinkedTextContext = createContext<LinkedTextConfiguration | null>(null);
LinkedTextContext.displayName = "LinkedTextContext";
/**
* A hook to get the linked text configuration from the context. Will throw if no LinkedTextContext is found.
* @throws If no LinkedTextContext context is found
* @returns The linked text configuration from the context
*/
export function useLinkedTextContext(): LinkedTextConfiguration {
const config = useContext(LinkedTextContext);
if (!config) {
throw new Error("useLinkedTextContextOpts must be used within an LinkedTextContext.Provider");
}
return config;
}
@@ -0,0 +1,96 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LinkedText > renders a custom href 1`] = `
<div>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
>
I love working on
<a
data-linkified="true"
href="https://example.org"
rel="noreferrer noopener"
target="_blank"
>
https://matrix.org
</a>
.
</p>
</div>
`;
exports[`LinkedText > renders a custom target 1`] = `
<div>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
>
I love working on
<a
data-linkified="true"
href="https://matrix.org"
rel="noreferrer noopener"
target="_fake_target"
>
https://matrix.org
</a>
.
</p>
</div>
`;
exports[`LinkedText > renders a room alias 1`] = `
<div>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
>
I love talking in
<a
data-linkified="true"
href="#general:example.org"
rel="noreferrer noopener"
target="_blank"
>
#general:example.org
</a>
.
</p>
</div>
`;
exports[`LinkedText > renders a standard link 1`] = `
<div>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
>
I love working on
<a
data-linkified="true"
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
https://matrix.org
</a>
.
</p>
</div>
`;
exports[`LinkedText > renders a user ID 1`] = `
<div>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
>
I love talking to
<a
data-linkified="true"
href="@alice:example.org"
rel="noreferrer noopener"
target="_blank"
>
@alice:example.org
</a>
.
</p>
</div>
`;
@@ -0,0 +1,9 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export { LinkedText, type LinkedTextProps } from "./LinkedText";
export { LinkedTextContext, useLinkedTextContext } from "./LinkedTextContext";
@@ -0,0 +1,54 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { Markdown } from "@storybook/addon-docs/blocks";
import type { Meta } from "@storybook/react-vite";
import LinkifyMatrixOpaqueIdType from "../../typedoc/enumerations/LinkifyMatrixOpaqueIdType.md?raw";
import findLinksInString from "../../typedoc/functions/findLinksInString.md?raw";
import isLinkable from "../../typedoc/functions/isLinkable.md?raw";
import linkifyHtml from "../../typedoc/functions/linkifyHtml.md?raw";
import linkifyString from "../../typedoc/functions/linkifyString.md?raw";
import generateLinkedTextOptions from "../../typedoc/functions/generateLinkedTextOptions.md?raw";
import LinkedTextOptions from "../../typedoc/interfaces/LinkedTextOptions.md?raw";
const meta = {
title: "utils/linkify",
parameters: {
docs: {
page: () => (
<>
<h1>Linkify utilities</h1>
<p>Supporting functions and types for parsing links from HTML/strings.</p>
<h2>LinkifyMatrixOpaqueIdType</h2>
<Markdown>{LinkifyMatrixOpaqueIdType}</Markdown>
<h2>findLinksInString</h2>
<Markdown>{findLinksInString}</Markdown>
<h2>isLinkable</h2>
<Markdown>{isLinkable}</Markdown>
<h2>linkifyHtml</h2>
<Markdown>{linkifyHtml}</Markdown>
<h2>linkifyString</h2>
<Markdown>{linkifyString}</Markdown>
<h2>generateLinkedTextOptions</h2>
<Markdown>{generateLinkedTextOptions}</Markdown>
<h3>LinkedTextOptions</h3>
<Markdown>{LinkedTextOptions}</Markdown>
</>
),
},
},
tags: ["autodocs", "skip-test"],
} satisfies Meta;
export default meta;
// Docs-only story - renders nothing but triggers autodocs
export const Docs = {
render: () => null,
};
@@ -0,0 +1,411 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { describe, it, expect } from "vitest";
import { findLinksInString, isLinkable, linkifyHtml, LinkifyMatrixOpaqueIdType } from "./linkify";
describe("linkify-matrix", () => {
const linkTypesByInitialCharacter: Record<string, string> = {
"#": "roomalias",
"@": "userid",
};
describe.each(Object.entries(linkTypesByInitialCharacter))("handles '%s' (%s)", (char, type) => {
it("should not parse " + char + "foo without domain", () => {
const test = char + "foo";
const found = findLinksInString(test);
expect(isLinkable(test)).toEqual(false);
expect(found).toEqual([]);
});
describe("ip v4 tests", () => {
it("should properly parse IPs v4 as the domain name", () => {
const test = char + "potato:1.2.3.4";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "potato:1.2.3.4",
type,
isLink: true,
start: 0,
end: test.length,
value: char + "potato:1.2.3.4",
},
]);
});
it("should properly parse IPs v4 with port as the domain name with attached", () => {
const test = char + "potato:1.2.3.4:1337";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "potato:1.2.3.4:1337",
type,
isLink: true,
start: 0,
end: test.length,
value: char + "potato:1.2.3.4:1337",
},
]);
});
it("should properly parse IPs v4 as the domain name while ignoring missing port", () => {
const test = char + "potato:1.2.3.4:";
expect(isLinkable(test)).toEqual(false);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "potato:1.2.3.4",
type,
isLink: true,
start: 0,
end: test.length - 1,
value: char + "potato:1.2.3.4",
},
]);
});
});
// Currently those tests are failing, as there's missing implementation.
describe.skip("ip v6 tests", () => {
it("should properly parse IPs v6 as the domain name", () => {
const test = char + "username:[1234:5678::abcd]";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "username:[1234:5678::abcd]",
type,
isLink: true,
start: 0,
end: test.length,
value: char + "username:[1234:5678::abcd]",
},
]);
});
it("should properly parse IPs v6 with port as the domain name", () => {
const test = char + "username:[1234:5678::abcd]:1337";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "username:[1234:5678::abcd]:1337",
type,
isLink: true,
start: 0,
end: test.length,
value: char + "username:[1234:5678::abcd]:1337",
},
]);
});
// eslint-disable-next-line max-len
it("should properly parse IPs v6 while ignoring dangling comma when without port name as the domain name", () => {
const test = char + "username:[1234:5678::abcd]:";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "username:[1234:5678::abcd]:",
type,
isLink: true,
start: 0,
end: test.length - 1,
value: char + "username:[1234:5678::abcd]:",
},
]);
});
});
it("properly parses " + char + "_foonetic_xkcd:matrix.org", () => {
const test = "" + char + "_foonetic_xkcd:matrix.org";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "_foonetic_xkcd:matrix.org",
type,
value: char + "_foonetic_xkcd:matrix.org",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("properly parses " + char + "localhost:foo.com", () => {
const test = char + "localhost:foo.com";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "localhost:foo.com",
type,
value: char + "localhost:foo.com",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("properly parses " + char + "foo:localhost", () => {
const test = char + "foo:localhost";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo:localhost",
type,
value: char + "foo:localhost",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("accept " + char + "foo:bar.com", () => {
const test = "" + char + "foo:bar.com";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo:bar.com",
type,
value: char + "foo:bar.com",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("accept " + char + "foo:com (mostly for (TLD|DOMAIN)+ mixing)", () => {
const test = "" + char + "foo:com";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo:com",
type,
value: char + "foo:com",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("accept repeated TLDs (e.g .org.uk)", () => {
const test = "" + char + "foo:bar.org.uk";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo:bar.org.uk",
type,
value: char + "foo:bar.org.uk",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("accept hyphens in name " + char + "foo-bar:server.com", () => {
const test = "" + char + "foo-bar:server.com";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo-bar:server.com",
type,
value: char + "foo-bar:server.com",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("ignores trailing `:`", () => {
const test = "" + char + "foo:bar.com:";
expect(isLinkable(test)).toEqual(false);
const found = findLinksInString(test);
expect(found).toEqual([
{
type,
value: char + "foo:bar.com",
href: char + "foo:bar.com",
start: 0,
end: test.length - ":".length,
isLink: true,
},
]);
});
it("accept :NUM (port specifier)", () => {
const test = "" + char + "foo:bar.com:2225";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo:bar.com:2225",
type,
value: char + "foo:bar.com:2225",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("ignores duplicate :NUM (double port specifier)", () => {
const test = "" + char + "foo:bar.com:2225:1234";
expect(isLinkable(test)).toEqual(false);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo:bar.com:2225",
type,
value: char + "foo:bar.com:2225",
start: 0,
end: 17,
isLink: true,
},
]);
});
it("ignores all the trailing :", () => {
const test = "" + char + "foo:bar.com::::";
expect(isLinkable(test)).toEqual(false);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo:bar.com",
type,
value: char + "foo:bar.com",
end: test.length - 4,
start: 0,
isLink: true,
},
]);
});
it("properly parses room alias with dots in name", () => {
const test = "" + char + "foo.asdf:bar.com::::";
expect(isLinkable(test)).toEqual(false);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo.asdf:bar.com",
type,
value: char + "foo.asdf:bar.com",
start: 0,
end: test.length - ":".repeat(4).length,
isLink: true,
},
]);
});
it("does not parse room alias with too many separators", () => {
const test = "" + char + "foo:::bar.com";
expect(isLinkable(test)).toEqual(false);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: "http://bar.com",
type: "url",
value: "bar.com",
isLink: true,
start: 7,
end: test.length,
},
]);
});
it("properly parses room alias with hyphen in domain part", () => {
const test = "" + char + "foo:bar.com-baz.com";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo:bar.com-baz.com",
type,
value: char + "foo:bar.com-baz.com",
end: 20,
start: 0,
isLink: true,
},
]);
});
});
describe("userid plugin", () => {
it("allows dots in localparts", () => {
const test = "@test.:matrix.org";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: test,
type: "userid",
value: test,
start: 0,
end: test.length,
isLink: true,
},
]);
});
});
describe("matrix uri", () => {
const acceptedMatrixUris = [
"matrix:u/foo_bar:server.uk",
"matrix:r/foo-bar:server.uk",
"matrix:roomid/somewhere:example.org?via=elsewhere.ca",
"matrix:r/somewhere:example.org",
"matrix:r/somewhere:example.org/e/event",
"matrix:roomid/somewhere:example.org/e/event?via=elsewhere.ca",
"matrix:u/alice:example.org?action=chat",
];
for (const matrixUri of acceptedMatrixUris) {
it("accepts " + matrixUri, () => {
expect(isLinkable(matrixUri)).toEqual(true);
const found = findLinksInString(matrixUri);
expect(found).toEqual([
{
href: matrixUri,
type: LinkifyMatrixOpaqueIdType.URL,
value: matrixUri,
end: matrixUri.length,
start: 0,
isLink: true,
},
]);
});
}
});
describe("matrix-prefixed domains", () => {
const acceptedDomains = ["matrix.org", "matrix.to", "matrix-help.org", "matrix123.org"];
for (const domain of acceptedDomains) {
it("accepts " + domain, () => {
expect(isLinkable(domain)).toEqual(true);
const found = findLinksInString(domain);
expect(found).toEqual([
{
href: `http://${domain}`,
type: LinkifyMatrixOpaqueIdType.URL,
value: domain,
end: domain.length,
start: 0,
isLink: true,
},
]);
});
}
});
describe("linkifyHtml", () => {
it("removes any existing data-linkified", () => {
expect(
linkifyHtml("<span data-linkfied><a data-linkfied href='evil://com'>evil.com</a></span>"),
).toMatchInlineSnapshot(
`"<span data-linkfied=""><a data-linkfied="" href="evil://com">evil.com</a></span>"`,
);
});
});
});
@@ -0,0 +1,312 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import * as linkifyjs from "linkifyjs";
import { default as linkifyString } from "linkify-string"; // Only exported by this file, but imported for jsdoc.
import { default as linkifyHtml } from "linkify-html"; // Only exported by this file, but imported for jsdoc.
/**
* This file describes common linkify configuration settings such as supported protocols.
* The instance of "linkifyjs" is the canonical instance that all dependant apps should use.
*
* Plugins should be configured inside this file exclusively so as to avoid contamination of
* the global state.
*/
/**
* List of supported protocols natively by linkify. Kept in sync with upstreanm.
* @see https://github.com/nfrasser/linkifyjs/blob/main/packages/linkifyjs/src/scanner.mjs#L171-L177
*/
export const LinkifySupportedProtocols = ["file", "mailto", "http", "https", "ftp", "ftps"];
/**
* Protocols that do not require a slash in the URL.
*/
export const LinkifyOptionalSlashProtocols = [
"bitcoin",
"geo",
"im",
"magnet",
"mailto",
"matrix",
"news",
"openpgp4fpr",
"sip",
"sms",
"smsto",
"tel",
"urn",
"xmpp",
];
/**
* URL schemes that are safe to be resolved by the app consuming the library.
*/
export const PERMITTED_URL_SCHEMES = [...LinkifySupportedProtocols, ...LinkifyOptionalSlashProtocols];
export enum LinkifyMatrixOpaqueIdType {
URL = "url",
UserId = "userid",
RoomAlias = "roomalias",
}
/**
* Plugin function for linkifyjs to find Matrix Room or User IDs.
*
* Should be used exclusively by a `registerPlugin` function call.
*/
function parseOpaqueIdsToMatrixIds({
scanner,
parser,
token,
name,
}: {
scanner: linkifyjs.ScannerInit;
parser: linkifyjs.ParserInit;
token: "#" | "@";
name: LinkifyMatrixOpaqueIdType;
}): void {
const {
DOT,
// IPV4 necessity
NUM,
COLON,
SYM,
SLASH,
EQUALS,
HYPHEN,
UNDERSCORE,
} = scanner.tokens;
// Contains NUM, WORD, UWORD, EMOJI, TLD, UTLD, SCHEME, SLASH_SCHEME and LOCALHOST plus custom protocols (e.g. "matrix")
const { domain } = scanner.tokens.groups;
// Tokens we need that are not contained in the domain group
const additionalLocalpartTokens = [DOT, SYM, SLASH, EQUALS, UNDERSCORE, HYPHEN];
const additionalDomainpartTokens = [HYPHEN];
const matrixToken = linkifyjs.createTokenClass(name, { isLink: true });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const matrixTokenState = new linkifyjs.State(matrixToken) as any as linkifyjs.State<linkifyjs.MultiToken>; // linkify doesn't appear to type this correctly
const matrixTokenWithPort = linkifyjs.createTokenClass(name, { isLink: true });
const matrixTokenWithPortState = new linkifyjs.State(
matrixTokenWithPort,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any as linkifyjs.State<linkifyjs.MultiToken>; // linkify doesn't appear to type this correctly
const initialState = parser.start.tt(token);
// Localpart
const localpartState = new linkifyjs.State<linkifyjs.MultiToken>();
initialState.ta(domain, localpartState);
initialState.ta(additionalLocalpartTokens, localpartState);
localpartState.ta(domain, localpartState);
localpartState.ta(additionalLocalpartTokens, localpartState);
// Domainpart
const domainStateDot = localpartState.tt(COLON);
domainStateDot.ta(domain, matrixTokenState);
domainStateDot.ta(additionalDomainpartTokens, matrixTokenState);
matrixTokenState.ta(domain, matrixTokenState);
matrixTokenState.ta(additionalDomainpartTokens, matrixTokenState);
matrixTokenState.tt(DOT, domainStateDot);
// Port suffixes
matrixTokenState.tt(COLON).tt(NUM, matrixTokenWithPortState);
}
export type LinkEventListener = linkifyjs.EventListeners;
export interface LinkedTextOptions {
/**
* Event handlers for URL links.
*/
urlListener?: (href: string) => LinkEventListener;
/**
* Event handlers for room alias links.
*/
roomAliasListener?: (href: string) => LinkEventListener;
/**
* Event handlers for user ID links.
*/
userIdListener?: (href: string) => LinkEventListener;
/**
* Function that can be used to transform the `target` attribute on links, depending on the `href`.
*/
urlTargetTransformer?: (href: string) => string;
/**
* Function that can be used to transform the `href` attribute on links, depending on the current href and target type.
*/
hrefTransformer?: (href: string, target: LinkifyMatrixOpaqueIdType) => string;
/**
* Function called before all listeners when a link is clicked.
*/
onLinkClick?: (ev: MouseEvent) => void;
}
/**
* Generates a linkifyjs options object that is reasonably paired down
* to just the essentials required for an Element client.
*
* @return A `linkifyjs` `Opts` object. Used by `linkifyString` and `linkifyHtml
* @see {@link linkifyHtml}
* @see {@link linkifyString}
*/
export function generateLinkedTextOptions({
urlListener,
roomAliasListener,
userIdListener,
urlTargetTransformer,
hrefTransformer,
onLinkClick,
}: LinkedTextOptions): linkifyjs.Opts {
const events = (href: string, type: string): LinkEventListener => {
switch (type as LinkifyMatrixOpaqueIdType) {
case LinkifyMatrixOpaqueIdType.URL: {
if (urlListener) {
return urlListener(href);
}
break;
}
case LinkifyMatrixOpaqueIdType.UserId:
if (userIdListener) {
return userIdListener(href);
}
break;
case LinkifyMatrixOpaqueIdType.RoomAlias:
if (roomAliasListener) {
return roomAliasListener(href);
}
break;
}
return {};
};
const attributes = (href: string, type: string): Record<string, unknown> => {
const attrs: Record<string, unknown> = {
[`data-${LINKIFIED_DATA_ATTRIBUTE}`]: "true",
};
// linkify-react doesn't respect `events` and needs it mapping to React attributes
// so we need to manually add the click handler to the attributes
// https://linkify.js.org/docs/linkify-react.html#events
const options = events(href, type);
if (options?.click) {
attrs.onClick = options.click;
}
if (onLinkClick) {
attrs.onClick = (ev: MouseEvent) => {
onLinkClick(ev);
options?.click?.(ev);
};
}
return attrs;
};
return {
rel: "noreferrer noopener",
ignoreTags: ["a", "pre", "code"],
defaultProtocol: "https",
events,
attributes,
target(href, type) {
if (type === LinkifyMatrixOpaqueIdType.URL && urlTargetTransformer) {
return urlTargetTransformer(href);
}
return "_blank";
},
...(hrefTransformer
? {
formatHref: (href, type) => hrefTransformer(href, type as LinkifyMatrixOpaqueIdType),
}
: undefined),
// By default, ignore Matrix ID types.
// Other applications may implement their own version of LinkifyComponent.
validate: (_value, type: string) =>
!!(type === LinkifyMatrixOpaqueIdType.UserId && userIdListener) ||
!!(type === LinkifyMatrixOpaqueIdType.RoomAlias && roomAliasListener) ||
type === LinkifyMatrixOpaqueIdType.URL,
} satisfies linkifyjs.Opts;
}
/**
* Finds all links in a given string.
*
* @param str A string that may contain one or more strings.
* @returns A set of all links in the string.
*/
export function findLinksInString(str: string): ReturnType<typeof linkifyjs.find> {
return linkifyjs.find(str);
}
/**
* Is the provided value something that would be converted to a clickable
* link.
*
* E.g. 'https://matrix.org', `matrix.org` or 'example@matrix.org'
*
* @param str A string value to be tested if the entire value is linkable.
* @returns Whether or not the `str` value is a link.
* @see `PERMITTED_URL_SCHEMES` for permitted links.
* @see {@link linkifyjs.test}
*/
export function isLinkable(str: string): boolean {
return linkifyjs.test(str);
}
/**
* `data-linkified` is applied to all links generated by the linkifaction functions and `<LinkedText>`.
*/
export const LINKIFIED_DATA_ATTRIBUTE = "linkified";
export { linkifyString, linkifyHtml };
// Linkifyjs MUST be configured globally as it has no ability to be instanced seperately
// so we ensure it's always configured the same way.
let linkifyJSConfigured = false;
function configureLinkifyJS(): void {
if (linkifyJSConfigured) {
return;
}
// Register plugins
linkifyjs.registerPlugin(LinkifyMatrixOpaqueIdType.RoomAlias, ({ scanner, parser }) => {
const token = scanner.tokens.POUND as "#";
parseOpaqueIdsToMatrixIds({
scanner,
parser,
token,
name: LinkifyMatrixOpaqueIdType.RoomAlias,
});
});
linkifyjs.registerPlugin(LinkifyMatrixOpaqueIdType.UserId, ({ scanner, parser }) => {
const token = scanner.tokens.AT as "@";
parseOpaqueIdsToMatrixIds({
scanner,
parser,
token,
name: LinkifyMatrixOpaqueIdType.UserId,
});
});
// 'mxc' is specialcased. They can be linked to
linkifyjs.registerCustomProtocol("mxc", false);
// Linkify supports some common protocols but not others, register all permitted url schemes if unsupported
// https://github.com/nfrasser/linkifyjs/blob/main/packages/linkifyjs/src/scanner.mjs#L171-L177
// This also handles registering the `matrix:` protocol scheme
PERMITTED_URL_SCHEMES.forEach((scheme) => {
if (!LinkifySupportedProtocols.includes(scheme)) {
linkifyjs.registerCustomProtocol(scheme, LinkifyOptionalSlashProtocols.includes(scheme));
}
});
linkifyJSConfigured = true;
}
configureLinkifyJS();
+12 -12
View File
@@ -267,18 +267,6 @@ importers:
katex: katex:
specifier: ^0.16.0 specifier: ^0.16.0
version: 0.16.33 version: 0.16.33
linkify-html:
specifier: 4.3.2
version: 4.3.2(patch_hash=1761c1eabe25d9fae83f74f27a20b3d24515840a4a8747bb04828df46bcfdea2)(linkifyjs@4.3.2)
linkify-react:
specifier: 4.3.2
version: 4.3.2(linkifyjs@4.3.2)(react@19.2.4)
linkify-string:
specifier: 4.3.2
version: 4.3.2(linkifyjs@4.3.2)
linkifyjs:
specifier: 4.3.2
version: 4.3.2
lodash: lodash:
specifier: npm:lodash-es@^4.17.21 specifier: npm:lodash-es@^4.17.21
version: lodash-es@4.17.23 version: lodash-es@4.17.23
@@ -778,6 +766,18 @@ importers:
html-react-parser: html-react-parser:
specifier: ^5.2.2 specifier: ^5.2.2
version: 5.2.17(@types/react@19.2.10)(react@19.2.4) version: 5.2.17(@types/react@19.2.10)(react@19.2.4)
linkify-html:
specifier: 4.3.2
version: 4.3.2(patch_hash=1761c1eabe25d9fae83f74f27a20b3d24515840a4a8747bb04828df46bcfdea2)(linkifyjs@4.3.2)
linkify-react:
specifier: 4.3.2
version: 4.3.2(linkifyjs@4.3.2)(react@19.2.4)
linkify-string:
specifier: 4.3.2
version: 4.3.2(linkifyjs@4.3.2)
linkifyjs:
specifier: 4.3.2
version: 4.3.2
lodash: lodash:
specifier: npm:lodash-es@^4.17.21 specifier: npm:lodash-es@^4.17.21
version: lodash-es@4.17.23 version: lodash-es@4.17.23