From c02db4ebb87aced56cd3db0c1a02811dc769494d Mon Sep 17 00:00:00 2001
From: Will Hunt <2072976+Half-Shot@users.noreply.github.com>
Date: Thu, 12 Mar 2026 15:54:01 +0000
Subject: [PATCH] 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
---
apps/web/package.json | 4 -
.../web/playwright/e2e/links/messages.spec.ts | 3 +-
apps/web/res/themes/light/css/_mods.pcss | 2 +-
apps/web/src/HtmlUtils.tsx | 19 +-
apps/web/src/{Linkify.tsx => Linkify.ts} | 227 ++++++++--
apps/web/src/Markdown.ts | 7 +-
.../src/components/structures/MatrixChat.tsx | 12 +-
.../components/structures/SpaceHierarchy.tsx | 17 +-
.../views/dialogs/InteractiveAuthDialog.tsx | 8 +-
.../components/views/elements/RoomTopic.tsx | 19 +-
.../components/views/messages/TextualBody.tsx | 5 +-
.../views/right_panel/RoomSummaryCardView.tsx | 6 +-
.../views/rooms/BasicMessageComposer.tsx | 4 +-
.../views/rooms/LinkPreviewWidget.tsx | 4 +-
.../components/views/rooms/NewRoomIntro.tsx | 29 +-
apps/web/src/linkify-matrix.ts | 289 +-----------
apps/web/src/slash-commands/SlashCommands.tsx | 5 +-
apps/web/src/utils/Reply.ts | 4 +-
apps/web/src/utils/UrlUtils.ts | 28 --
apps/web/test/unit-tests/HtmlUtils-test.tsx | 23 +-
.../views/elements/RoomTopic-test.tsx | 5 +-
.../views/messages/TextualBody-test.tsx | 4 +-
.../__snapshots__/TextualBody-test.tsx.snap | 2 +-
.../right_panel/RoomSummaryCardView-test.tsx | 5 +-
.../RoomSummaryCardView-test.tsx.snap | 16 +-
.../views/rooms/NewRoomIntro-test.tsx | 5 +-
.../__snapshots__/NewRoomIntro-test.tsx.snap | 22 +-
.../test/unit-tests/linkify-matrix-test.ts | 390 +----------------
.../LinkedText.stories.tsx/default-auto.png | Bin 0 -> 7267 bytes
.../with-room-alias-auto.png | Bin 0 -> 21074 bytes
.../with-user-id-auto.png | Bin 0 -> 7669 bytes
packages/shared-components/package.json | 4 +
packages/shared-components/src/index.ts | 2 +
.../utils/LinkedText/LinkedText.module.css | 13 +
.../utils/LinkedText/LinkedText.stories.tsx | 71 +++
.../src/utils/LinkedText/LinkedText.test.tsx | 85 ++++
.../src/utils/LinkedText/LinkedText.tsx | 52 +++
.../utils/LinkedText/LinkedTextContext.tsx | 50 +++
.../__snapshots__/LinkedText.test.tsx.snap | 96 ++++
.../src/utils/LinkedText/index.ts | 9 +
.../src/utils/linkify.stories.tsx | 54 +++
.../src/utils/linkify.test.ts | 411 ++++++++++++++++++
.../shared-components/src/utils/linkify.ts | 312 +++++++++++++
pnpm-lock.yaml | 24 +-
44 files changed, 1508 insertions(+), 839 deletions(-)
rename apps/web/src/{Linkify.tsx => Linkify.ts} (52%)
create mode 100644 packages/shared-components/__vis__/linux/__baselines__/utils/LinkedText/LinkedText.stories.tsx/default-auto.png
create mode 100644 packages/shared-components/__vis__/linux/__baselines__/utils/LinkedText/LinkedText.stories.tsx/with-room-alias-auto.png
create mode 100644 packages/shared-components/__vis__/linux/__baselines__/utils/LinkedText/LinkedText.stories.tsx/with-user-id-auto.png
create mode 100644 packages/shared-components/src/utils/LinkedText/LinkedText.module.css
create mode 100644 packages/shared-components/src/utils/LinkedText/LinkedText.stories.tsx
create mode 100644 packages/shared-components/src/utils/LinkedText/LinkedText.test.tsx
create mode 100644 packages/shared-components/src/utils/LinkedText/LinkedText.tsx
create mode 100644 packages/shared-components/src/utils/LinkedText/LinkedTextContext.tsx
create mode 100644 packages/shared-components/src/utils/LinkedText/__snapshots__/LinkedText.test.tsx.snap
create mode 100644 packages/shared-components/src/utils/LinkedText/index.ts
create mode 100644 packages/shared-components/src/utils/linkify.stories.tsx
create mode 100644 packages/shared-components/src/utils/linkify.test.ts
create mode 100644 packages/shared-components/src/utils/linkify.ts
diff --git a/apps/web/package.json b/apps/web/package.json
index 100aff42f0..2a0434b168 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -78,10 +78,6 @@
"jsrsasign": "^11.0.0",
"jszip": "^3.7.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",
"maplibre-gl": "^5.0.0",
"matrix-encrypt-attachment": "^1.0.3",
diff --git a/apps/web/playwright/e2e/links/messages.spec.ts b/apps/web/playwright/e2e/links/messages.spec.ts
index 579a070722..1f86e1bc09 100644
--- a/apps/web/playwright/e2e/links/messages.spec.ts
+++ b/apps/web/playwright/e2e/links/messages.spec.ts
@@ -38,8 +38,7 @@ test.describe("Message links", () => {
const linkElement = page.locator(".mx_EventTile_last").getByRole("link", { name: "#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 }) => {
- axe.disableRules("color-contrast");
+ test("should linkify text inside a URL preview", async ({ page, user, app, room }) => {
await page.route(/.*\/_matrix\/(client\/v1\/media|media\/v3)\/preview_url.*/, (route, request) => {
const requestedPage = new URL(request.url()).searchParams.get("url");
expect(requestedPage).toEqual("https://example.org/");
diff --git a/apps/web/res/themes/light/css/_mods.pcss b/apps/web/res/themes/light/css/_mods.pcss
index 2764c8762f..4541b4afde 100644
--- a/apps/web/res/themes/light/css/_mods.pcss
+++ b/apps/web/res/themes/light/css/_mods.pcss
@@ -1,5 +1,5 @@
/* sidebar blurred avatar background */
-//
+
/* if backdrop-filter is supported, */
/* set the user avatar (if any) as a background so */
/* it can be blurred by the tag panel and room list */
diff --git a/apps/web/src/HtmlUtils.tsx b/apps/web/src/HtmlUtils.tsx
index 5112e3f7ce..3fefbba418 100644
--- a/apps/web/src/HtmlUtils.tsx
+++ b/apps/web/src/HtmlUtils.tsx
@@ -17,14 +17,14 @@ import { decode } from "html-entities";
import { type IContent } from "matrix-js-sdk/src/matrix";
import escapeHtml from "escape-html";
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 { stripHTMLReply, stripPlainReply } from "./utils/Reply";
-import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
-import { linkifyHtml, sanitizeHtmlParams, transformTags } from "./Linkify";
+import { sanitizeHtmlParams, transformTags, linkifyHtml } from "./Linkify";
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
const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/;
@@ -323,13 +323,12 @@ function analyseEvent(content: IContent, highlights?: string[], opts: EventRende
if (opts.linkify) {
// Prevent mutating the source of sanitizeParams.
sanitizeParams = { ...sanitizeParams };
- sanitizeParams.allowedClasses ??= {};
- if (typeof sanitizeParams.allowedClasses.a === "boolean") {
- // All classes are already allowed for "a"
- } else {
- sanitizeParams.allowedClasses.a ??= [];
- sanitizeParams.allowedClasses.a.push("linkified");
- }
+ if (typeof sanitizeParams.allowedAttributes === "object") {
+ const attribs = { ...sanitizeParams.allowedAttributes };
+ // We allow data-linkified because TextualBody uses it to passthrough links.
+ attribs["a"] = [...sanitizeParams.allowedAttributes["a"], `data-${LINKIFIED_DATA_ATTRIBUTE}`];
+ sanitizeParams.allowedAttributes = attribs;
+ } // else: No attibutes are are allowed for "a"
}
try {
diff --git a/apps/web/src/Linkify.tsx b/apps/web/src/Linkify.ts
similarity index 52%
rename from apps/web/src/Linkify.tsx
rename to apps/web/src/Linkify.ts
index f324acd9b8..fdb51a86d6 100644
--- a/apps/web/src/Linkify.tsx
+++ b/apps/web/src/Linkify.ts
@@ -1,4 +1,5 @@
/*
+Copyright 2026 Element Creations Ltd.
Copyright 2024, 2025 New Vector Ltd.
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.
*/
-import React, { type ReactElement } from "react";
import sanitizeHtml, { type IOptions } from "sanitize-html";
-import { merge } from "lodash";
-import _Linkify from "linkify-react";
+import {
+ 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 { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
+import { ELEMENT_URL_PATTERN } from "./linkify-matrix";
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 MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/;
@@ -29,7 +44,7 @@ export const transformTags: NonNullable = {
const transformed = tryTransformPermalinkToLocalHref(attribs.href); // only used to check if it is a link that can be handled locally
if (
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;
}
@@ -193,43 +208,199 @@ export const sanitizeHtmlParams: IOptions = {
nestingLimit: 50,
};
-/* Wrapper around linkify-react merging in our default linkify options */
-export function Linkify({ as, options, children }: React.ComponentProps): ReactElement {
- return (
- <_Linkify as={as} options={merge({}, linkifyMatrixOptions, options)}>
- {children}
-
- );
+/**
+ * Handler function when a UserID link is clicked.
+ * @param event The click event
+ * @param userId The linked UserID
+ */
+function onUserClick(event: MouseEvent, userId: string): void {
+ event.preventDefault();
+ dis.dispatch({
+ 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({
+ 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'.
*
- * @param {string} str string to linkify
- * @param {object} [options] Options for linkifyString. Default: linkifyMatrixOptions
- * @returns {string} Linkified string
+ * @param str string to linkify
+ * @param [options] Options for linkifyString.
+ * @returns Linkified string
*/
-export function linkifyString(str: string, options = linkifyMatrixOptions): string {
- return _linkifyString(str, options);
+export function linkifyString(value: string, options = generateLinkedTextOptions(LinkedTextConfiguration)): string {
+ return _linkifyString(value, options);
}
/**
* Linkifies the given HTML-formatted string. This is a wrapper around 'linkifyjs/html'.
*
- * @param {string} str HTML string to linkify
- * @param {object} [options] Options for linkifyHtml. Default: linkifyMatrixOptions
- * @returns {string} Linkified string
+ * @param str HTML string to linkify
+ * @param [options] Options for linkifyHtml.
+ * @returns Linkified string
*/
-export function linkifyHtml(str: string, options = linkifyMatrixOptions): string {
- return _linkifyHtml(str, options);
+export function linkifyHtml(value: string, options = generateLinkedTextOptions(LinkedTextConfiguration)): string {
+ return _linkifyHtml(value, options);
}
+
/**
* Linkify the given string and sanitize the HTML afterwards.
*
- * @param {string} dirtyHtml The HTML string to sanitize and linkify
- * @param {object} [options] Options for linkifyString. Default: linkifyMatrixOptions
- * @returns {string}
+ * @param dirtyString The string to linkify, and then sanitize.
+ * @param [options] Options for linkifyString. Default: linkifyMatrixOptions
+ * @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);
}
diff --git a/apps/web/src/Markdown.ts b/apps/web/src/Markdown.ts
index 6f0e3e0c5e..3bc38c8f7d 100644
--- a/apps/web/src/Markdown.ts
+++ b/apps/web/src/Markdown.ts
@@ -11,8 +11,7 @@ import "./@types/commonmark"; // import better types than @types/commonmark
import * as commonmark from "commonmark";
import { escape } from "lodash";
import { logger } from "matrix-js-sdk/src/logger";
-
-import { linkify } from "./linkify-matrix";
+import { findLinksInString } from "@element-hq/web-shared-components";
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.
if ((node.type === "emph" || node.type === "strong") && previousNode?.type === "text") {
if (event.entering) {
- const foundLinks = linkify.find(text);
+ const foundLinks = findLinksInString(text);
for (const { value } of foundLinks) {
if (node?.firstChild?.literal) {
/**
@@ -197,7 +196,7 @@ export default class Markdown {
const nonEmphasizedText = `${format}${innerNodeLiteral(node)}${format}`;
const f = getTextUntilEndOrLinebreak(node);
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
if (newLinks.length === 1) {
const emphasisTextNode = new commonmark.Node("text");
diff --git a/apps/web/src/components/structures/MatrixChat.tsx b/apps/web/src/components/structures/MatrixChat.tsx
index 3ba87f5043..da23b01bef 100644
--- a/apps/web/src/components/structures/MatrixChat.tsx
+++ b/apps/web/src/components/structures/MatrixChat.tsx
@@ -28,7 +28,7 @@ import { TooltipProvider } from "@vector-im/compound-web";
// what-input helps improve keyboard accessibility
import "what-input";
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 PosthogTrackers from "../../PosthogTrackers";
@@ -125,7 +125,7 @@ import { viewUserDeviceSettings } from "../../actions/handlers/viewUserDeviceSet
import GenericToast from "../views/toasts/GenericToast";
import RovingSpotlightDialog from "../views/dialogs/spotlight/SpotlightDialog";
import { findDMForUser } from "../../utils/dm/findDMForUser";
-import { getHtmlText, Linkify } from "../../HtmlUtils";
+import { getHtmlText } from "../../HtmlUtils";
import { NotificationLevel } from "../../stores/notifications/NotificationLevel";
import { type UserTab } from "../views/dialogs/UserTab";
import { shouldSkipSetupEncryption } from "../../utils/crypto/shouldSkipSetupEncryption";
@@ -139,7 +139,7 @@ import { setTheme } from "../../theme";
import { type OpenForwardDialogPayload } from "../../dispatcher/payloads/OpenForwardDialogPayload";
import { ShareFormat, type SharePayload } from "../../dispatcher/payloads/SharePayload";
import Markdown from "../../Markdown";
-import { sanitizeHtmlParams } from "../../Linkify";
+import { LinkedTextConfiguration, sanitizeHtmlParams } from "../../Linkify";
import { isOnlyAdmin } from "../../utils/membership";
import { ModuleApi } from "../../modules/Api.ts";
@@ -1458,7 +1458,7 @@ export default class MatrixChat extends React.PureComponent {
key,
title: userNotice.title,
props: {
- description: {userNotice.description} ,
+ description: {userNotice.description} ,
primaryLabel: _t("action|ok"),
onPrimaryClick: () => {
ToastStore.sharedInstance().dismissToast(key);
@@ -2291,7 +2291,9 @@ export default class MatrixChat extends React.PureComponent {
- {view}
+
+ {view}
+
diff --git a/apps/web/src/components/structures/SpaceHierarchy.tsx b/apps/web/src/components/structures/SpaceHierarchy.tsx
index e799868240..779ac9f5c2 100644
--- a/apps/web/src/components/structures/SpaceHierarchy.tsx
+++ b/apps/web/src/components/structures/SpaceHierarchy.tsx
@@ -41,6 +41,7 @@ import { sortBy, uniqBy } from "lodash";
import { logger } from "matrix-js-sdk/src/logger";
import { KnownMembership, type SpaceChildEventContent } from "matrix-js-sdk/src/types";
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 { _t } from "../../languageHandler";
@@ -55,7 +56,7 @@ import InfoTooltip from "../views/elements/InfoTooltip";
import TextWithTooltip from "../views/elements/TextWithTooltip";
import { useStateToggle } from "../../hooks/useStateToggle";
import { getChildOrder } from "../../stores/spaces/SpaceStore";
-import { Linkify, topicToHtml } from "../../HtmlUtils";
+import { topicToHtml } from "../../HtmlUtils";
import { useDispatcher } from "../../hooks/useDispatcher";
import { Action } from "../../dispatcher/actions";
import { type IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
@@ -233,20 +234,12 @@ const Tile: React.FC = ({
let topicSection: ReactNode | undefined;
if (topic) {
+ // prevent clicks on links from bubbling up to the room tile
topicSection = (
-
+ ev.stopPropagation()}>
{" · "}
{topic}
-
+
);
}
diff --git a/apps/web/src/components/views/dialogs/InteractiveAuthDialog.tsx b/apps/web/src/components/views/dialogs/InteractiveAuthDialog.tsx
index 1aa3e7fe68..71c16b47d4 100644
--- a/apps/web/src/components/views/dialogs/InteractiveAuthDialog.tsx
+++ b/apps/web/src/components/views/dialogs/InteractiveAuthDialog.tsx
@@ -11,6 +11,7 @@ Please see LICENSE files in the repository root for full details.
import React, { type JSX } from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { type AuthType } from "matrix-js-sdk/src/interactive-auth";
+import { LinkedText } from "@element-hq/web-shared-components";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
@@ -21,7 +22,6 @@ import InteractiveAuth, {
} from "../../structures/InteractiveAuth";
import { type ContinueKind, SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents";
import BaseDialog from "./BaseDialog";
-import { Linkify } from "../../../Linkify";
type DialogAesthetics = Partial<{
[x in AuthType]: {
@@ -163,9 +163,9 @@ export default class InteractiveAuthDialog extends React.Component
-
- {this.state.authError.message || this.state.authError.toString()}
-
+
+ {this.state.authError.message || this.state.authError.toString()}
+
{_t("action|dismiss")}
diff --git a/apps/web/src/components/views/elements/RoomTopic.tsx b/apps/web/src/components/views/elements/RoomTopic.tsx
index 34bf1c7cd3..e539c9c7d3 100644
--- a/apps/web/src/components/views/elements/RoomTopic.tsx
+++ b/apps/web/src/components/views/elements/RoomTopic.tsx
@@ -10,6 +10,7 @@ import React, { type JSX, useCallback, useContext, useState } from "react";
import { type Room, EventType } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { Tooltip } from "@vector-im/compound-web";
+import { LinkedText } from "@element-hq/web-shared-components";
import { useTopic } from "../../../hooks/room/useTopic";
import { _t } from "../../../languageHandler";
@@ -20,7 +21,7 @@ import InfoDialog from "../dialogs/InfoDialog";
import { useDispatcher } from "../../../hooks/useDispatcher";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AccessibleButton from "./AccessibleButton";
-import { Linkify, topicToHtml } from "../../../HtmlUtils";
+import { topicToHtml } from "../../../HtmlUtils";
import { tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
interface IProps extends React.HTMLProps {
@@ -74,19 +75,7 @@ export default function RoomTopic({ room, className, ...props }: IProps): JSX.El
title: room.name,
description: (
-
) {
- onClick(e);
- modal.close();
- },
- },
- }}
- as="p"
- >
- {body}
-
+
modal.close()}>{body}
{canSetTopic && (
- {body}
+ {body}
);
diff --git a/apps/web/src/components/views/messages/TextualBody.tsx b/apps/web/src/components/views/messages/TextualBody.tsx
index 5e65dc3271..5c84fa2d9e 100644
--- a/apps/web/src/components/views/messages/TextualBody.tsx
+++ b/apps/web/src/components/views/messages/TextualBody.tsx
@@ -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 { 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 { formatDate } from "../../../DateUtils";
@@ -26,7 +26,6 @@ import LinkPreviewGroup from "../rooms/LinkPreviewGroup";
import { type IBodyProps } from "./IBodyProps";
import RoomContext from "../../../contexts/RoomContext";
import AccessibleButton from "../elements/AccessibleButton";
-import { options as linkifyOpts } from "../../../linkify-matrix";
import { getParentEventId } from "../../../utils/Reply";
import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
import { type IEventTileOps } from "../rooms/EventTile";
@@ -250,7 +249,7 @@ export default class TextualBody extends React.Component {
private onBodyLinkClick = (e: MouseEvent): void => {
let target: HTMLLinkElement | null = e.target as HTMLLinkElement;
// 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") {
// Jump to parent as the `` may contain children, e.g. an anchor wrapping an inline code section
target = target.closest("a");
diff --git a/apps/web/src/components/views/right_panel/RoomSummaryCardView.tsx b/apps/web/src/components/views/right_panel/RoomSummaryCardView.tsx
index 460f7859d2..4514ee231e 100644
--- a/apps/web/src/components/views/right_panel/RoomSummaryCardView.tsx
+++ b/apps/web/src/components/views/right_panel/RoomSummaryCardView.tsx
@@ -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 ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
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 { _t } from "../../../languageHandler.tsx";
import RoomAvatar from "../avatars/RoomAvatar.tsx";
import { E2EStatus } from "../../../utils/ShieldUtils.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 { useRoomTopicViewModel } from "../../viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx";
import { useRoomName } from "../../../hooks/useRoomName.ts";
@@ -89,7 +89,7 @@ const RoomTopic: React.FC> = ({ room }): JSX.Element | null
);
}
- const content = vm.expanded ? {body} : body;
+ const content = vm.expanded ? {body} : body;
return (
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 (plainText && range.length > 0 && linkify.test(plainText) && !linkify.test(range.text)) {
+ if (plainText && range.length > 0 && isLinkable(plainText) && !isLinkable(range.text)) {
formatRangeAsLink(range, plainText);
} else {
replaceRangeAndMoveCaret(range, parts);
diff --git a/apps/web/src/components/views/rooms/LinkPreviewWidget.tsx b/apps/web/src/components/views/rooms/LinkPreviewWidget.tsx
index 0bcbaa940a..5e0b42f650 100644
--- a/apps/web/src/components/views/rooms/LinkPreviewWidget.tsx
+++ b/apps/web/src/components/views/rooms/LinkPreviewWidget.tsx
@@ -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 { decode } from "html-entities";
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 * as ImageUtils from "../../../ImageUtils";
import { mediaFromMxc } from "../../../customisations/Media";
@@ -128,7 +128,7 @@ export default class LinkPreviewWidget extends React.Component {
)}
- {description}
+ {description}
diff --git a/apps/web/src/components/views/rooms/NewRoomIntro.tsx b/apps/web/src/components/views/rooms/NewRoomIntro.tsx
index b486b5e059..1dec30785d 100644
--- a/apps/web/src/components/views/rooms/NewRoomIntro.tsx
+++ b/apps/web/src/components/views/rooms/NewRoomIntro.tsx
@@ -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.
*/
-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 { KnownMembership } from "matrix-js-sdk/src/types";
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 DMRoomMap from "../../../utils/DMRoomMap";
@@ -32,7 +32,7 @@ import { LocalRoom } from "../../../models/LocalRoom";
import { shouldEncryptRoomWithSingle3rdPartyInvite } from "../../../utils/room/shouldEncryptRoomWithSingle3rdPartyInvite";
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
import { useTopic } from "../../../hooks/room/useTopic";
-import { topicToHtml, Linkify } from "../../../HtmlUtils";
+import { topicToHtml } from "../../../HtmlUtils";
function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean {
const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId);
@@ -56,14 +56,23 @@ const NewRoomIntro: React.FC = () => {
const cli = useContext(MatrixClientContext);
const { room, roomId } = useScopedRoomContext("room", "roomId");
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 : {topicToHtml(topic?.text, topic?.html)} ),
+ [topic, dmPartner],
+ );
if (!room || !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;
if (dmPartner) {
const { shouldEncrypt: encryptedSingle3rdPartyInvite } = shouldEncryptRoomWithSingle3rdPartyInvite(room);
@@ -137,15 +146,11 @@ const NewRoomIntro: React.FC = () => {
{sub}
),
- topic: () => {topicToHtml(topic?.text, topic?.html)} ,
+ topic: renderedTopic,
},
);
} else if (topic) {
- topicText = _t(
- "room|intro|display_topic",
- {},
- { topic: () => {topicToHtml(topic?.text, topic?.html)} },
- );
+ topicText = _t("room|intro|display_topic", {}, { topic: renderedTopic });
} else if (canAddTopic) {
topicText = _t(
"room|intro|no_topic",
diff --git a/apps/web/src/linkify-matrix.ts b/apps/web/src/linkify-matrix.ts
index b6ed8ee7fc..54b7b25962 100644
--- a/apps/web/src/linkify-matrix.ts
+++ b/apps/web/src/linkify-matrix.ts
@@ -1,4 +1,5 @@
/*
+Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
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.
*/
-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; // 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; // linkify doesn't appear to type this correctly
-
- const INITIAL_STATE = parser.start.tt(token);
-
- // Localpart
- const LOCALPART_STATE = new linkifyjs.State();
- 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({
- action: Action.ViewUser,
- member: new User(userId),
- });
-}
-
-function onAliasClick(event: MouseEvent, roomAlias: string): void {
- event.preventDefault();
- dis.dispatch({
- action: Action.ViewRoom,
- room_alias: roomAlias,
- metricsTrigger: "Timeline",
- metricsViaKeyboard: false,
- });
-}
-
const escapeRegExp = function (s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
};
// 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.
-export const ELEMENT_URL_PATTERN =
+export const ELEMENT_URL_PATTERN = new RegExp(
"^(?:vector://|https?://)?(?:" +
- escapeRegExp(window.location.host + window.location.pathname) +
- "|" +
- "(?:www\\.)?(?:riot|vector)\\.im/(?:app|beta|staging|develop)/|" +
- "(?: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 {
- const attrs: Record = {
- 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;
+ escapeRegExp(window.location.host + window.location.pathname) +
+ "|" +
+ "(?:www\\.)?(?:riot|vector)\\.im/(?:app|beta|staging|develop)/|" +
+ "(?:app|beta|staging|develop)\\.element\\.io/" +
+ ")(#.*)",
+);
diff --git a/apps/web/src/slash-commands/SlashCommands.tsx b/apps/web/src/slash-commands/SlashCommands.tsx
index a566a5f093..f3e1d1ed85 100644
--- a/apps/web/src/slash-commands/SlashCommands.tsx
+++ b/apps/web/src/slash-commands/SlashCommands.tsx
@@ -20,12 +20,13 @@ import {
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { KnownMembership, type RoomMemberEventContent } from "matrix-js-sdk/src/types";
+import { LinkedText } from "@element-hq/web-shared-components";
import dis from "../dispatcher/dispatcher";
import { _t, _td, UserFriendlyError } from "../languageHandler";
import Modal from "../Modal";
import MultiInviter from "../utils/MultiInviter";
-import { Linkify, topicToHtml } from "../HtmlUtils";
+import { topicToHtml } from "../HtmlUtils";
import QuestionDialog from "../components/views/dialogs/QuestionDialog";
import WidgetUtils from "../utils/WidgetUtils";
import { textToHtmlRainbow } from "../utils/colour";
@@ -270,7 +271,7 @@ export const Commands = [
Modal.createDialog(InfoDialog, {
title: room.name,
- description: {body} ,
+ description: {body} ,
hasCloseButton: true,
className: "markdown-body",
});
diff --git a/apps/web/src/utils/Reply.ts b/apps/web/src/utils/Reply.ts
index e5909ffcc0..3b595e8693 100644
--- a/apps/web/src/utils/Reply.ts
+++ b/apps/web/src/utils/Reply.ts
@@ -1,4 +1,5 @@
/*
+ * Copyright 2026 Element Creations Ltd.
* Copyright 2024 New Vector Ltd.
* Copyright 2023 The Matrix.org Foundation C.I.C.
* Copyright 2021 Šimon Brandner
@@ -9,8 +10,7 @@
import { type IContent, type IEventRelation, type MatrixEvent, THREAD_RELATION_TYPE } from "matrix-js-sdk/src/matrix";
import sanitizeHtml from "sanitize-html";
-
-import { PERMITTED_URL_SCHEMES } from "./UrlUtils";
+import { PERMITTED_URL_SCHEMES } from "@element-hq/web-shared-components";
export function getParentEventId(ev?: MatrixEvent): string | undefined {
if (!ev || ev.isRedacted()) return;
diff --git a/apps/web/src/utils/UrlUtils.ts b/apps/web/src/utils/UrlUtils.ts
index d256e655c2..fcb720414e 100644
--- a/apps/web/src/utils/UrlUtils.ts
+++ b/apps/web/src/utils/UrlUtils.ts
@@ -49,31 +49,3 @@ export function parseUrl(u: string): URL {
}
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",
-];
diff --git a/apps/web/test/unit-tests/HtmlUtils-test.tsx b/apps/web/test/unit-tests/HtmlUtils-test.tsx
index 690f2a713a..e56d7203cc 100644
--- a/apps/web/test/unit-tests/HtmlUtils-test.tsx
+++ b/apps/web/test/unit-tests/HtmlUtils-test.tsx
@@ -101,7 +101,7 @@ describe("bodyToHtml", () => {
);
expect(html).toMatchInlineSnapshot(
- `"foo http://link.example/test /path bar"`,
+ `"foo http://link.example/test /path 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 http://link.example/test/path bar with https://example.org',
+ format: "org.matrix.custom.html",
+ },
+ [],
+ {
+ linkify: true,
+ },
+ );
+
+ expect(html).toMatchInlineSnapshot(
+ `"foo http://link.example/test/path bar with https://example.org "`,
+ );
+ });
+
it("does not mistake characters in text presentation mode for emoji", () => {
const { asFragment } = render(
diff --git a/apps/web/test/unit-tests/components/views/elements/RoomTopic-test.tsx b/apps/web/test/unit-tests/components/views/elements/RoomTopic-test.tsx
index 47bc39fdd7..e53af36b71 100644
--- a/apps/web/test/unit-tests/components/views/elements/RoomTopic-test.tsx
+++ b/apps/web/test/unit-tests/components/views/elements/RoomTopic-test.tsx
@@ -10,6 +10,7 @@ import React from "react";
import { Room } from "matrix-js-sdk/src/matrix";
import { fireEvent, render, screen, waitFor } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
+import { LinkedTextContext } from "@element-hq/web-shared-components";
import { mkEvent, stubClient } from "../../../../test-utils";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
@@ -52,7 +53,9 @@ describe(" ", () => {
*/
const renderRoom = (topic: string) => {
const room = createRoom(topic);
- render( );
+ render( , {
+ wrapper: ({ children }) => {children} ,
+ });
};
/**
diff --git a/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx b/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx
index 924473a692..7060c91418 100644
--- a/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx
+++ b/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx
@@ -193,7 +193,7 @@ describe(" ", () => {
const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot(
- `"Chat with @user:example.com "`,
+ `"Chat with @user:example.com "`,
);
});
@@ -211,7 +211,7 @@ describe(" ", () => {
const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot(
- `"Visit #room:example.com "`,
+ `"Visit #room:example.com "`,
);
});
diff --git a/apps/web/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap b/apps/web/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap
index 40d88d54bd..3e071d8cbf 100644
--- a/apps/web/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap
+++ b/apps/web/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap
@@ -557,7 +557,7 @@ exports[` renders plain-text m.text correctly linkification get a
>
Visit
", () => {
return render( , {
wrapper: ({ children }) => (
- {children}
+
+ {children}
+
),
});
};
diff --git a/apps/web/test/unit-tests/components/views/right_panel/__snapshots__/RoomSummaryCardView-test.tsx.snap b/apps/web/test/unit-tests/components/views/right_panel/__snapshots__/RoomSummaryCardView-test.tsx.snap
index 23da533e9f..cbd83caff9 100644
--- a/apps/web/test/unit-tests/components/views/right_panel/__snapshots__/RoomSummaryCardView-test.tsx.snap
+++ b/apps/web/test/unit-tests/components/views/right_panel/__snapshots__/RoomSummaryCardView-test.tsx.snap
@@ -142,9 +142,13 @@ exports[` has button to edit topic 1`] = `
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31"
>
- This is the room's topic.
+
+ This is the room's topic.
+
renders the room topic in the summary 1`] = `
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31"
>
- This is the room's topic.
+
+ This is the room's topic.
+
{
render(
-
+
+
+
,
);
diff --git a/apps/web/test/unit-tests/components/views/rooms/__snapshots__/NewRoomIntro-test.tsx.snap b/apps/web/test/unit-tests/components/views/rooms/__snapshots__/NewRoomIntro-test.tsx.snap
index e8401c135c..f0c6c5586c 100644
--- a/apps/web/test/unit-tests/components/views/rooms/__snapshots__/NewRoomIntro-test.tsx.snap
+++ b/apps/web/test/unit-tests/components/views/rooms/__snapshots__/NewRoomIntro-test.tsx.snap
@@ -7,17 +7,21 @@ exports[`NewRoomIntro topic should render a link in the topic 1`] = `
Topic:
- This is a link:
-
- https://matrix.org/
-
+ This is a link:
+
+ https://matrix.org/
+
+
diff --git a/apps/web/test/unit-tests/linkify-matrix-test.ts b/apps/web/test/unit-tests/linkify-matrix-test.ts
index 26c2a809d0..cf831e26b3 100644
--- a/apps/web/test/unit-tests/linkify-matrix-test.ts
+++ b/apps/web/test/unit-tests/linkify-matrix-test.ts
@@ -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
Please see LICENSE files in the repository root for full details.
*/
-
-import { type EventListeners } from "linkifyjs";
-
-import { linkify, Type, options } from "../../src/linkify-matrix";
+import { roomAliasEventListeners, userIdEventListeners } from "../../src/Linkify";
import dispatcher from "../../src/dispatcher/dispatcher";
import { Action } from "../../src/dispatcher/actions";
describe("linkify-matrix", () => {
- const linkTypesByInitialCharacter: Record = {
- "#": "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", () => {
- genTests("#");
-
it("should intercept clicks with a ViewRoom dispatch", () => {
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
- const handlers = (options.events as (href: string, type: string) => EventListeners)(
- "#room:server.com",
- "roomalias",
- );
-
+ const handlers = roomAliasEventListeners("#room:server.com");
const event = new MouseEvent("mousedown");
event.preventDefault = jest.fn();
handlers!.click(event);
@@ -344,31 +29,10 @@ describe("linkify-matrix", () => {
});
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", () => {
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
- const handlers = (options.events as (href: string, type: string) => EventListeners)(
- "@localpart:server.com",
- "userid",
- );
+ const handlers = userIdEventListeners("@localpart:server.com");
const event = new MouseEvent("mousedown");
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,
- },
- ]);
- });
- }
- });
});
diff --git a/packages/shared-components/__vis__/linux/__baselines__/utils/LinkedText/LinkedText.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/utils/LinkedText/LinkedText.stories.tsx/default-auto.png
new file mode 100644
index 0000000000000000000000000000000000000000..e62ad7d61a287ef65e043f68df6ffa77945cfd34
GIT binary patch
literal 7267
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
literal 0
HcmV?d00001
diff --git a/packages/shared-components/__vis__/linux/__baselines__/utils/LinkedText/LinkedText.stories.tsx/with-user-id-auto.png b/packages/shared-components/__vis__/linux/__baselines__/utils/LinkedText/LinkedText.stories.tsx/with-user-id-auto.png
new file mode 100644
index 0000000000000000000000000000000000000000..ac06b200620d2cfb59e8993c9d57a2516430c38a
GIT binary patch
literal 7669
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
literal 0
HcmV?d00001
diff --git a/packages/shared-components/package.json b/packages/shared-components/package.json
index a0f86a235a..ca73c27072 100644
--- a/packages/shared-components/package.json
+++ b/packages/shared-components/package.json
@@ -57,6 +57,10 @@
"classnames": "^2.5.1",
"counterpart": "^0.18.6",
"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",
"matrix-web-i18n": "catalog:",
"react-merge-refs": "^3.0.2",
diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts
index 15d5cc39f8..b29287fdd8 100644
--- a/packages/shared-components/src/index.ts
+++ b/packages/shared-components/src/index.ts
@@ -42,6 +42,7 @@ export * from "./room-list/VirtualizedRoomListView";
export * from "./timeline/DateSeparatorView/";
export * from "./utils/Box";
export * from "./utils/Flex";
+export * from "./utils/LinkedText";
export * from "./right-panel/WidgetContextMenu";
export * from "./utils/VirtualizedList";
@@ -53,5 +54,6 @@ export * from "./utils/DateUtils";
export * from "./utils/numbers";
export * from "./utils/FormattingUtils";
export * from "./utils/I18nApi";
+export * from "./utils/linkify";
// MVVM
export * from "./viewmodel";
diff --git a/packages/shared-components/src/utils/LinkedText/LinkedText.module.css b/packages/shared-components/src/utils/LinkedText/LinkedText.module.css
new file mode 100644
index 0000000000..0c9c1ef4f8
--- /dev/null
+++ b/packages/shared-components/src/utils/LinkedText/LinkedText.module.css
@@ -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;
+}
diff --git a/packages/shared-components/src/utils/LinkedText/LinkedText.stories.tsx b/packages/shared-components/src/utils/LinkedText/LinkedText.stories.tsx
new file mode 100644
index 0000000000..e5c1b5bc5b
--- /dev/null
+++ b/packages/shared-components/src/utils/LinkedText/LinkedText.stories.tsx
@@ -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 }) => (
+
+
+
+ ),
+ ],
+ args: {
+ children: "I love working on https://matrix.org.",
+ },
+ tags: ["autodocs"],
+} satisfies Meta & ComponentProps["value"]>;
+
+export default meta;
+type Story = StoryObj;
+
+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"],
+};
diff --git a/packages/shared-components/src/utils/LinkedText/LinkedText.test.tsx b/packages/shared-components/src/utils/LinkedText/LinkedText.test.tsx
new file mode 100644
index 0000000000..850bcb0c15
--- /dev/null
+++ b/packages/shared-components/src/utils/LinkedText/LinkedText.test.tsx
@@ -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(
+
+ Check out this link {path}
+ ,
+ );
+ expect(getByRole("link")).toBeInTheDocument();
+ });
+
+ it.each(LinkifyOptionalSlashProtocols.map((protocol) => `${protocol}://abcdef`))(
+ "renders protocol with optional slash '%s'",
+ (path) => {
+ const { getByRole } = render(
+
+ Check out this link {path}
+ ,
+ );
+ expect(getByRole("link")).toBeInTheDocument();
+ },
+ );
+
+ it("renders a standard link", () => {
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders a user ID", () => {
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders a room alias", () => {
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders a custom target", () => {
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders a custom href", () => {
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+
+ it("supports setting an onLinkClicked handler", async () => {
+ const fn = vitest.fn();
+ const { getAllByRole } = render(
+
+ Check out this link https://google.com and example.org
+ ,
+ );
+ const links = getAllByRole("link");
+ expect(links).toHaveLength(2);
+ await userEvent.click(links[0]);
+ await userEvent.click(links[1]);
+ expect(fn).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/shared-components/src/utils/LinkedText/LinkedText.tsx b/packages/shared-components/src/utils/LinkedText/LinkedText.tsx
new file mode 100644
index 0000000000..5290ce0935
--- /dev/null
+++ b/packages/shared-components/src/utils/LinkedText/LinkedText.tsx
@@ -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 & {
+ /**
+ * 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 ``
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ * I love working on https://matrix.org
+ *
+ *
+ * ```
+ */
+export function LinkedText({ children, className, onLinkClick, ...textProps }: LinkedTextProps): React.ReactNode {
+ const options = useLinkedTextContext();
+ const linkifyOptions = generateLinkedTextOptions({ ...options, onLinkClick });
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/shared-components/src/utils/LinkedText/LinkedTextContext.tsx b/packages/shared-components/src/utils/LinkedText/LinkedTextContext.tsx
new file mode 100644
index 0000000000..00d190b17e
--- /dev/null
+++ b/packages/shared-components/src/utils/LinkedText/LinkedTextContext.tsx
@@ -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(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;
+}
diff --git a/packages/shared-components/src/utils/LinkedText/__snapshots__/LinkedText.test.tsx.snap b/packages/shared-components/src/utils/LinkedText/__snapshots__/LinkedText.test.tsx.snap
new file mode 100644
index 0000000000..cf847b30f1
--- /dev/null
+++ b/packages/shared-components/src/utils/LinkedText/__snapshots__/LinkedText.test.tsx.snap
@@ -0,0 +1,96 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`LinkedText > renders a custom href 1`] = `
+
+`;
+
+exports[`LinkedText > renders a custom target 1`] = `
+
+`;
+
+exports[`LinkedText > renders a room alias 1`] = `
+
+`;
+
+exports[`LinkedText > renders a standard link 1`] = `
+
+`;
+
+exports[`LinkedText > renders a user ID 1`] = `
+
+`;
diff --git a/packages/shared-components/src/utils/LinkedText/index.ts b/packages/shared-components/src/utils/LinkedText/index.ts
new file mode 100644
index 0000000000..ba7276e871
--- /dev/null
+++ b/packages/shared-components/src/utils/LinkedText/index.ts
@@ -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";
diff --git a/packages/shared-components/src/utils/linkify.stories.tsx b/packages/shared-components/src/utils/linkify.stories.tsx
new file mode 100644
index 0000000000..7a20615e5b
--- /dev/null
+++ b/packages/shared-components/src/utils/linkify.stories.tsx
@@ -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: () => (
+ <>
+ Linkify utilities
+ Supporting functions and types for parsing links from HTML/strings.
+ LinkifyMatrixOpaqueIdType
+ {LinkifyMatrixOpaqueIdType}
+ findLinksInString
+ {findLinksInString}
+ isLinkable
+ {isLinkable}
+ linkifyHtml
+ {linkifyHtml}
+ linkifyString
+ {linkifyString}
+ generateLinkedTextOptions
+ {generateLinkedTextOptions}
+ LinkedTextOptions
+ {LinkedTextOptions}
+ >
+ ),
+ },
+ },
+ tags: ["autodocs", "skip-test"],
+} satisfies Meta;
+
+export default meta;
+
+// Docs-only story - renders nothing but triggers autodocs
+export const Docs = {
+ render: () => null,
+};
diff --git a/packages/shared-components/src/utils/linkify.test.ts b/packages/shared-components/src/utils/linkify.test.ts
new file mode 100644
index 0000000000..b2fd9903d3
--- /dev/null
+++ b/packages/shared-components/src/utils/linkify.test.ts
@@ -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 = {
+ "#": "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("evil.com "),
+ ).toMatchInlineSnapshot(
+ `"evil.com "`,
+ );
+ });
+ });
+});
diff --git a/packages/shared-components/src/utils/linkify.ts b/packages/shared-components/src/utils/linkify.ts
new file mode 100644
index 0000000000..7ff737c2cb
--- /dev/null
+++ b/packages/shared-components/src/utils/linkify.ts
@@ -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; // 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; // linkify doesn't appear to type this correctly
+
+ const initialState = parser.start.tt(token);
+
+ // Localpart
+ const localpartState = new linkifyjs.State();
+ 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 => {
+ const attrs: Record = {
+ [`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 {
+ 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 ``.
+ */
+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();
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4860547dff..cf355623f1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -267,18 +267,6 @@ importers:
katex:
specifier: ^0.16.0
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:
specifier: npm:lodash-es@^4.17.21
version: lodash-es@4.17.23
@@ -778,6 +766,18 @@ importers:
html-react-parser:
specifier: ^5.2.2
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:
specifier: npm:lodash-es@^4.17.21
version: lodash-es@4.17.23