From a253f845f5a4c007f8c490ed99b46981d964ea95 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 5 Mar 2026 12:05:05 +0000 Subject: [PATCH 01/48] Port over linkifyJS to shared-components. --- apps/web/src/HtmlUtils.tsx | 3 +- apps/web/src/Linkify.tsx | 24 ++- apps/web/src/Markdown.ts | 7 +- .../views/rooms/BasicMessageComposer.tsx | 4 +- apps/web/src/linkify-matrix.ts | 151 ++------------- packages/shared-components/package.json | 6 +- packages/shared-components/src/index.ts | 2 + .../utils/LinkedText/LinkedText.module.css | 5 + .../utils/LinkedText/LinkedText.stories.tsx | 37 ++++ .../src/utils/LinkedText/LinkedText.test.tsx | 31 +++ .../src/utils/LinkedText/LinkedText.tsx | 52 +++++ .../src/utils/LinkedText/index.ts | 8 + .../src/utils/humanize.stories.tsx | 5 +- .../shared-components/src/utils/linkify.ts | 183 ++++++++++++++++++ 14 files changed, 360 insertions(+), 158 deletions(-) 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/index.ts create mode 100644 packages/shared-components/src/utils/linkify.ts diff --git a/apps/web/src/HtmlUtils.tsx b/apps/web/src/HtmlUtils.tsx index 5112e3f7ce2..c81d7af6783 100644 --- a/apps/web/src/HtmlUtils.tsx +++ b/apps/web/src/HtmlUtils.tsx @@ -17,11 +17,12 @@ 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 { linkifyHtml } 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 } from "./Linkify"; import { graphemeSegmenter } from "./utils/strings"; export { Linkify, linkifyAndSanitizeHtml } from "./Linkify"; diff --git a/apps/web/src/Linkify.tsx b/apps/web/src/Linkify.tsx index f324acd9b81..0f7787f754a 100644 --- a/apps/web/src/Linkify.tsx +++ b/apps/web/src/Linkify.tsx @@ -9,12 +9,16 @@ 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 { _linkifyString, _linkifyHtml, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix"; +import { + PERMITTED_URL_SCHEMES, + LinkifyComponent, + linkifyString as _linkifyString, + linkifyHtml as _linkifyHtml, +} from "@element-hq/web-shared-components"; + +import { ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix"; import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks"; import { mediaFromMxc } from "./customisations/Media"; -import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils"; const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/; @@ -194,11 +198,11 @@ export const sanitizeHtmlParams: IOptions = { }; /* Wrapper around linkify-react merging in our default linkify options */ -export function Linkify({ as, options, children }: React.ComponentProps): ReactElement { +export function Linkify({ as, options, children }: React.ComponentProps): ReactElement { return ( - <_Linkify as={as} options={merge({}, linkifyMatrixOptions, options)}> + {children} - + ); } @@ -226,9 +230,9 @@ export function linkifyHtml(str: string, options = linkifyMatrixOptions): string /** * 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 { return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams); diff --git a/apps/web/src/Markdown.ts b/apps/web/src/Markdown.ts index 6f0e3e0c5e0..3e0b747c3f8 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 { linkifyjs } 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 = linkifyjs.find(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 = linkifyjs.find(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/views/rooms/BasicMessageComposer.tsx b/apps/web/src/components/views/rooms/BasicMessageComposer.tsx index 3e9d46cbf66..13074e9b77a 100644 --- a/apps/web/src/components/views/rooms/BasicMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/BasicMessageComposer.tsx @@ -12,6 +12,7 @@ import { type Room, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import EMOTICON_REGEX from "emojibase-regex/emoticon"; import { logger } from "matrix-js-sdk/src/logger"; import { EMOTICON_TO_EMOJI } from "@matrix-org/emojibase-bindings"; +import { linkifyjs } from "@element-hq/web-shared-components"; import type EditorModel from "../../../editor/model"; import HistoryManager from "../../../editor/history"; @@ -40,7 +41,6 @@ import { type ICompletion } from "../../../autocomplete/Autocompleter"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { ALTERNATE_KEY_NAME, KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { _t } from "../../../languageHandler"; -import { linkify } from "../../../linkify-matrix"; import { SdkContextClass } from "../../../contexts/SDKContext"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation"; @@ -357,7 +357,7 @@ export default class BasicMessageEditor extends React.Component 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 && linkifyjs.test(plainText) && !linkifyjs.test(range.text)) { formatRangeAsLink(range, plainText); } else { replaceRangeAndMoveCaret(range, parts); diff --git a/apps/web/src/linkify-matrix.ts b/apps/web/src/linkify-matrix.ts index b6ed8ee7fc2..a5a5578624a 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 @@ -6,11 +7,7 @@ Copyright 2015, 2016 OpenMarket 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 { type EventListeners, type Opts, registerCustomProtocol, registerPlugin } from "linkifyjs"; -import linkifyString from "linkify-string"; -import linkifyHtml from "linkify-html"; +import { type linkifyjs, LinkifyMatrixOpaqueIdType } from "@element-hq/web-shared-components"; import { getHttpUriForMxc, User } from "matrix-js-sdk/src/matrix"; import { @@ -23,72 +20,6 @@ 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(); @@ -123,9 +54,9 @@ export const ELEMENT_URL_PATTERN = ")(#.*)"; // Attach click handlers to links based on their type -function events(href: string, type: string): EventListeners { - switch (type as Type) { - case Type.URL: { +function events(href: string, type: string): linkifyjs.EventListeners { + switch (type as LinkifyMatrixOpaqueIdType) { + case LinkifyMatrixOpaqueIdType.URL: { // intercept local permalinks to users and show them like userids (in userinfo of current room) try { const permalink = parsePermalink(href); @@ -153,7 +84,7 @@ function events(href: string, type: string): EventListeners { } break; } - case Type.UserId: + case LinkifyMatrixOpaqueIdType.UserId: return { click: function (e: MouseEvent) { e.preventDefault(); @@ -161,7 +92,7 @@ function events(href: string, type: string): EventListeners { if (userId) onUserClick(e, userId); }, }; - case Type.RoomAlias: + case LinkifyMatrixOpaqueIdType.RoomAlias: return { click: function (e: MouseEvent) { e.preventDefault(); @@ -190,12 +121,12 @@ function attributes(href: string, type: string): Record { return attrs; } -export const options: Opts = { +export const options: linkifyjs.Opts = { events, - formatHref: function (href: string, type: Type | string): string { + formatHref: function (href: string, type: LinkifyMatrixOpaqueIdType | string): string { switch (type) { - case "url": + case LinkifyMatrixOpaqueIdType.URL: if (href.startsWith("mxc://") && MatrixClientPeg.get()) { return getHttpUriForMxc( MatrixClientPeg.get()!.baseUrl, @@ -208,8 +139,8 @@ export const options: Opts = { ); } // fallthrough - case Type.RoomAlias: - case Type.UserId: + case LinkifyMatrixOpaqueIdType.RoomAlias: + case LinkifyMatrixOpaqueIdType.UserId: default: { return tryTransformEntityToPermalink(MatrixClientPeg.safeGet(), href) ?? ""; } @@ -222,8 +153,8 @@ export const options: Opts = { className: "linkified", - target: function (href: string, type: Type | string): string { - if (type === Type.URL) { + target: function (href: string, type: LinkifyMatrixOpaqueIdType | string): string { + if (type === LinkifyMatrixOpaqueIdType.URL) { try { const transformed = tryTransformPermalinkToLocalHref(href); if ( @@ -241,57 +172,3 @@ export const options: Opts = { 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; diff --git a/packages/shared-components/package.json b/packages/shared-components/package.json index 71801835637..b39d2278fdd 100644 --- a/packages/shared-components/package.json +++ b/packages/shared-components/package.json @@ -60,7 +60,11 @@ "matrix-web-i18n": "catalog:", "react-merge-refs": "^3.0.2", "react-virtuoso": "^4.14.0", - "temporal-polyfill": "^0.3.0" + "temporal-polyfill": "^0.3.0", + "linkify-html": "4.3.2", + "linkify-react": "4.3.2", + "linkify-string": "4.3.2", + "linkifyjs": "4.3.2" }, "devDependencies": { "@element-hq/element-web-playwright-common-local": "workspace:*", diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 52ddcf115fe..5ee6c4d7f26 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -39,6 +39,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"; @@ -50,5 +51,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 00000000000..08dc19234fa --- /dev/null +++ b/packages/shared-components/src/utils/LinkedText/LinkedText.module.css @@ -0,0 +1,5 @@ +.container { + a { + color: var(--cpd-color-text-link-external); + } +} 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 00000000000..ec19aea7667 --- /dev/null +++ b/packages/shared-components/src/utils/LinkedText/LinkedText.stories.tsx @@ -0,0 +1,37 @@ +/* +Copyright 2026 New Vector 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 type { Meta, StoryFn } from "@storybook/react-vite"; +import { LinkedText } from "./LinkedText"; + +export default { + title: "Utils/LinkedText", + component: LinkedText, + args: { + children: "Test", + }, + tags: ["autodocs"], +} satisfies Meta; + +const Template: StoryFn = ({ children, ...args }) => {children}; + +export const Default = Template.bind({}); + +Default.args = { + children: "I love working on https://matrix.org.", +}; + +export const WithUserId = Template.bind({}); +WithUserId.args = { + children: "I love chatting to @foo:bar.org.", +}; + +export const WithAlias = Template.bind({}); +WithAlias.args = { + children: "I love chatting to #foo:bar.", +}; 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 00000000000..eb0c2616901 --- /dev/null +++ b/packages/shared-components/src/utils/LinkedText/LinkedText.test.tsx @@ -0,0 +1,31 @@ +/* + * 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 } from "vitest"; +import React from "react"; + +import { LinkedText } from "./LinkedText.tsx"; +import { LinkifyOptionalSlashProtocols, PERMITTED_URL_SCHEMES } from "./linkifyOptions.ts"; + +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(); + }, + ); +}); 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 00000000000..c099a86e5d1 --- /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 type { Opts } from "linkifyjs"; +import styles from "./LinkedText.module.css"; +import { LinkifyComponent, LinkifyMatrixOpaqueIdType } from "../linkify"; + +type Props = ComponentProps; + +const options: Opts = { + render: Link, + target: "_blank", + rel: "noreferrer noopener", + defaultProtocol: "https", + // By default, ignore Matrix ID types. + // Other applications may implement their own version of LinkifyComponent. + // In the future, shared-components may fully implement this logic. + validate: (_value, type: string) => + ![LinkifyMatrixOpaqueIdType.RoomAlias, LinkifyMatrixOpaqueIdType.UserId].includes( + type as LinkifyMatrixOpaqueIdType, + ), +}; +/** + * A component that renders URLs as clickable links inside some plain text. + * + * @example + * ```tsx + * + * I love working on https://matrix.org + * + * ``` + */ +export function LinkedText({ children, className, ...textProps }: Props): React.ReactNode { + return ( + + {children} + + ); +} 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 00000000000..e03a5fc3b2b --- /dev/null +++ b/packages/shared-components/src/utils/LinkedText/index.ts @@ -0,0 +1,8 @@ +/* + * 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 } from "./LinkedText"; diff --git a/packages/shared-components/src/utils/humanize.stories.tsx b/packages/shared-components/src/utils/humanize.stories.tsx index 6d5ba654409..a8772651bce 100644 --- a/packages/shared-components/src/utils/humanize.stories.tsx +++ b/packages/shared-components/src/utils/humanize.stories.tsx @@ -9,7 +9,6 @@ import React from "react"; import { Markdown } from "@storybook/addon-docs/blocks"; import type { Meta } from "@storybook/react-vite"; -import humanizeTimeDoc from "../../typedoc/functions/humanizeTime.md?raw"; const meta = { title: "utils/humanize", @@ -17,8 +16,8 @@ const meta = { docs: { page: () => ( <> -

humanize

- {humanizeTimeDoc} +

linkifyjs

+ **Here is some md** ), }, diff --git a/packages/shared-components/src/utils/linkify.ts b/packages/shared-components/src/utils/linkify.ts new file mode 100644 index 00000000000..2ca34e88ec8 --- /dev/null +++ b/packages/shared-components/src/utils/linkify.ts @@ -0,0 +1,183 @@ +/* + * 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"; + +/** + * 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 within the context of a Matrix client. + */ +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", +]; + +// 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)); + } +}); + +// MXC urls can be resolved, but are not permitted in other parts of the app. +linkifyjs.registerCustomProtocol("mxc", false); + +export enum LinkifyMatrixOpaqueIdType { + URL = "url", + UserId = "userid", + RoomAlias = "roomalias", +} + +/** + * Finds instances of room aliases and user IDs. + */ +function matrixOpaqueIdLinkifyParser({ + 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 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); +} + +// Register plugins +linkifyjs.registerPlugin(LinkifyMatrixOpaqueIdType.RoomAlias, ({ scanner, parser }) => { + const token = scanner.tokens.POUND as "#"; + matrixOpaqueIdLinkifyParser({ + scanner, + parser, + token, + name: LinkifyMatrixOpaqueIdType.RoomAlias, + }); +}); + +linkifyjs.registerPlugin(LinkifyMatrixOpaqueIdType.UserId, ({ scanner, parser }) => { + const token = scanner.tokens.AT as "@"; + matrixOpaqueIdLinkifyParser({ + scanner, + parser, + token, + name: LinkifyMatrixOpaqueIdType.UserId, + }); +}); + +// Export our instances of linkify to ensure there is a singular instance of it. +export { default as linkifyString } from "linkify-string"; +export { default as linkifyHtml } from "linkify-html"; +export { default as LinkifyComponent } from "linkify-react"; + +export { linkifyjs }; From a8efa1b0b57206b4aa45664dab9ba01d9e55baa1 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 5 Mar 2026 12:29:16 +0000 Subject: [PATCH 02/48] Drop rubbish --- packages/shared-components/src/utils/humanize.stories.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/shared-components/src/utils/humanize.stories.tsx b/packages/shared-components/src/utils/humanize.stories.tsx index a8772651bce..6d5ba654409 100644 --- a/packages/shared-components/src/utils/humanize.stories.tsx +++ b/packages/shared-components/src/utils/humanize.stories.tsx @@ -9,6 +9,7 @@ import React from "react"; import { Markdown } from "@storybook/addon-docs/blocks"; import type { Meta } from "@storybook/react-vite"; +import humanizeTimeDoc from "../../typedoc/functions/humanizeTime.md?raw"; const meta = { title: "utils/humanize", @@ -16,8 +17,8 @@ const meta = { docs: { page: () => ( <> -

linkifyjs

- **Here is some md** +

humanize

+ {humanizeTimeDoc} ), }, From db34d93b5a0b4e41afc06b9ca584a78222feb44d Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 5 Mar 2026 12:47:40 +0000 Subject: [PATCH 03/48] update lock --- pnpm-lock.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35dee08fb3a..3d8750e2369 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -775,6 +775,18 @@ importers: counterpart: specifier: ^0.18.6 version: 0.18.6 + 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 From 6a57306491c82dc887962f2781e74887f6361a8f Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 5 Mar 2026 12:49:37 +0000 Subject: [PATCH 04/48] quickfix test --- .../test/unit-tests/linkify-matrix-test.ts | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/apps/web/test/unit-tests/linkify-matrix-test.ts b/apps/web/test/unit-tests/linkify-matrix-test.ts index 26c2a809d0b..af9c9ca2218 100644 --- a/apps/web/test/unit-tests/linkify-matrix-test.ts +++ b/apps/web/test/unit-tests/linkify-matrix-test.ts @@ -6,9 +6,9 @@ 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 { type EventListeners } from "linkifyjs"; +import { linkifyjs, LinkifyMatrixOpaqueIdType } from "@element-hq/web-shared-components"; -import { linkify, Type, options } from "../../src/linkify-matrix"; +import { options } from "../../src/linkify-matrix"; import dispatcher from "../../src/dispatcher/dispatcher"; import { Action } from "../../src/dispatcher/actions"; @@ -27,13 +27,13 @@ describe("linkify-matrix", () => { const type = linkTypesByInitialCharacter[char]; it("should not parse " + char + "foo without domain", () => { const test = char + "foo"; - const found = linkify.find(test); + const found = linkifyjs.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); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: char + "potato:1.2.3.4", @@ -47,7 +47,7 @@ describe("linkify-matrix", () => { }); 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); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: char + "potato:1.2.3.4:1337", @@ -61,7 +61,7 @@ describe("linkify-matrix", () => { }); 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); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: char + "potato:1.2.3.4", @@ -78,7 +78,7 @@ describe("linkify-matrix", () => { 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); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: char + "username:[1234:5678::abcd]", @@ -93,7 +93,7 @@ describe("linkify-matrix", () => { 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); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: char + "username:[1234:5678::abcd]:1337", @@ -108,7 +108,7 @@ describe("linkify-matrix", () => { // 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); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: char + "username:[1234:5678::abcd]:", @@ -123,7 +123,7 @@ describe("linkify-matrix", () => { }); it("properly parses " + char + "_foonetic_xkcd:matrix.org", () => { const test = "" + char + "_foonetic_xkcd:matrix.org"; - const found = linkify.find(test); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: char + "_foonetic_xkcd:matrix.org", @@ -137,7 +137,7 @@ describe("linkify-matrix", () => { }); it("properly parses " + char + "localhost:foo.com", () => { const test = char + "localhost:foo.com"; - const found = linkify.find(test); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: char + "localhost:foo.com", @@ -151,7 +151,7 @@ describe("linkify-matrix", () => { }); it("properly parses " + char + "foo:localhost", () => { const test = char + "foo:localhost"; - const found = linkify.find(test); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: char + "foo:localhost", @@ -165,7 +165,7 @@ describe("linkify-matrix", () => { }); it("accept " + char + "foo:bar.com", () => { const test = "" + char + "foo:bar.com"; - const found = linkify.find(test); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: char + "foo:bar.com", @@ -179,7 +179,7 @@ describe("linkify-matrix", () => { }); it("accept " + char + "foo:com (mostly for (TLD|DOMAIN)+ mixing)", () => { const test = "" + char + "foo:com"; - const found = linkify.find(test); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: char + "foo:com", @@ -193,7 +193,7 @@ describe("linkify-matrix", () => { }); it("accept repeated TLDs (e.g .org.uk)", () => { const test = "" + char + "foo:bar.org.uk"; - const found = linkify.find(test); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: char + "foo:bar.org.uk", @@ -207,7 +207,7 @@ describe("linkify-matrix", () => { }); it("accept hyphens in name " + char + "foo-bar:server.com", () => { const test = "" + char + "foo-bar:server.com"; - const found = linkify.find(test); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: char + "foo-bar:server.com", @@ -221,7 +221,7 @@ describe("linkify-matrix", () => { }); it("ignores trailing `:`", () => { const test = "" + char + "foo:bar.com:"; - const found = linkify.find(test); + const found = linkifyjs.find(test); expect(found).toEqual([ { type, @@ -235,7 +235,7 @@ describe("linkify-matrix", () => { }); it("accept :NUM (port specifier)", () => { const test = "" + char + "foo:bar.com:2225"; - const found = linkify.find(test); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: char + "foo:bar.com:2225", @@ -249,7 +249,7 @@ describe("linkify-matrix", () => { }); it("ignores duplicate :NUM (double port specifier)", () => { const test = "" + char + "foo:bar.com:2225:1234"; - const found = linkify.find(test); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: char + "foo:bar.com:2225", @@ -263,7 +263,7 @@ describe("linkify-matrix", () => { }); it("ignores all the trailing :", () => { const test = "" + char + "foo:bar.com::::"; - const found = linkify.find(test); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: char + "foo:bar.com", @@ -277,7 +277,7 @@ describe("linkify-matrix", () => { }); it("properly parses room alias with dots in name", () => { const test = "" + char + "foo.asdf:bar.com::::"; - const found = linkify.find(test); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: char + "foo.asdf:bar.com", @@ -291,7 +291,7 @@ describe("linkify-matrix", () => { }); it("does not parse room alias with too many separators", () => { const test = "" + char + "foo:::bar.com"; - const found = linkify.find(test); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: "http://bar.com", @@ -305,7 +305,7 @@ describe("linkify-matrix", () => { }); it("properly parses room alias with hyphen in domain part", () => { const test = "" + char + "foo:bar.com-baz.com"; - const found = linkify.find(test); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: char + "foo:bar.com-baz.com", @@ -325,7 +325,7 @@ describe("linkify-matrix", () => { it("should intercept clicks with a ViewRoom dispatch", () => { const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); - const handlers = (options.events as (href: string, type: string) => EventListeners)( + const handlers = (options.events as (href: string, type: string) => linkifyjs.EventListeners)( "#room:server.com", "roomalias", ); @@ -348,7 +348,7 @@ describe("linkify-matrix", () => { it("allows dots in localparts", () => { const test = "@test.:matrix.org"; - const found = linkify.find(test); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: test, @@ -365,7 +365,7 @@ describe("linkify-matrix", () => { it("should intercept clicks with a ViewUser dispatch", () => { const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); - const handlers = (options.events as (href: string, type: string) => EventListeners)( + const handlers = (options.events as (href: string, type: string) => linkifyjs.EventListeners)( "@localpart:server.com", "userid", ); @@ -398,11 +398,11 @@ describe("linkify-matrix", () => { for (const matrixUri of acceptedMatrixUris) { it("accepts " + matrixUri, () => { const test = matrixUri; - const found = linkify.find(test); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: matrixUri, - type: Type.URL, + type: LinkifyMatrixOpaqueIdType.URL, value: matrixUri, end: matrixUri.length, start: 0, @@ -418,11 +418,11 @@ describe("linkify-matrix", () => { for (const domain of acceptedDomains) { it("accepts " + domain, () => { const test = domain; - const found = linkify.find(test); + const found = linkifyjs.find(test); expect(found).toEqual([ { href: `http://${domain}`, - type: Type.URL, + type: LinkifyMatrixOpaqueIdType.URL, value: domain, end: domain.length, start: 0, From 9a88517ebe7f24cfd26b9b29f9da272e522ab6d1 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 5 Mar 2026 12:50:13 +0000 Subject: [PATCH 05/48] drop group id --- apps/web/test/unit-tests/linkify-matrix-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/test/unit-tests/linkify-matrix-test.ts b/apps/web/test/unit-tests/linkify-matrix-test.ts index af9c9ca2218..75d6e7eb5f5 100644 --- a/apps/web/test/unit-tests/linkify-matrix-test.ts +++ b/apps/web/test/unit-tests/linkify-matrix-test.ts @@ -23,7 +23,7 @@ describe("linkify-matrix", () => { * @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: "#" | "@" | "+") { + function genTests(char: "#" | "@") { const type = linkTypesByInitialCharacter[char]; it("should not parse " + char + "foo without domain", () => { const test = char + "foo"; From 291aa4888223da6e79d39e056854e581169f9153 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 5 Mar 2026 13:00:43 +0000 Subject: [PATCH 06/48] Modernize tests --- .../test/unit-tests/linkify-matrix-test.ts | 378 +----------------- .../src/utils/linkify.test.ts | 378 ++++++++++++++++++ 2 files changed, 379 insertions(+), 377 deletions(-) create mode 100644 packages/shared-components/src/utils/linkify.test.ts diff --git a/apps/web/test/unit-tests/linkify-matrix-test.ts b/apps/web/test/unit-tests/linkify-matrix-test.ts index 75d6e7eb5f5..38e5c091a02 100644 --- a/apps/web/test/unit-tests/linkify-matrix-test.ts +++ b/apps/web/test/unit-tests/linkify-matrix-test.ts @@ -5,323 +5,13 @@ 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 { linkifyjs, LinkifyMatrixOpaqueIdType } from "@element-hq/web-shared-components"; - +import type { linkifyjs } from "@element-hq/web-shared-components"; import { options } from "../../src/linkify-matrix"; 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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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"); @@ -344,24 +34,6 @@ describe("linkify-matrix", () => { }); describe("userid plugin", () => { - genTests("@"); - - it("allows dots in localparts", () => { - const test = "@test.:matrix.org"; - const found = linkifyjs.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"); @@ -384,52 +56,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 = linkifyjs.find(test); - 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, () => { - const test = domain; - const found = linkifyjs.find(test); - expect(found).toEqual([ - { - href: `http://${domain}`, - type: LinkifyMatrixOpaqueIdType.URL, - value: domain, - end: domain.length, - start: 0, - isLink: true, - }, - ]); - }); - } - }); }); 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 00000000000..0bcc753a7d5 --- /dev/null +++ b/packages/shared-components/src/utils/linkify.test.ts @@ -0,0 +1,378 @@ +/* +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 { linkifyjs, LinkifyMatrixOpaqueIdType } from "@element-hq/web-shared-components"; + +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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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 = linkifyjs.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("userid plugin", () => { + it("allows dots in localparts", () => { + const test = "@test.:matrix.org"; + const found = linkifyjs.find(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, () => { + const test = matrixUri; + const found = linkifyjs.find(test); + 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, () => { + const test = domain; + const found = linkifyjs.find(test); + expect(found).toEqual([ + { + href: `http://${domain}`, + type: LinkifyMatrixOpaqueIdType.URL, + value: domain, + end: domain.length, + start: 0, + isLink: true, + }, + ]); + }); + } + }); +}); From bc362bdb710cd74cad6690ae5bb16c5aaede0afa Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 5 Mar 2026 13:44:14 +0000 Subject: [PATCH 07/48] Remove stories that aren't in use. --- .../src/utils/LinkedText/LinkedText.stories.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/shared-components/src/utils/LinkedText/LinkedText.stories.tsx b/packages/shared-components/src/utils/LinkedText/LinkedText.stories.tsx index ec19aea7667..95ee312a8f9 100644 --- a/packages/shared-components/src/utils/LinkedText/LinkedText.stories.tsx +++ b/packages/shared-components/src/utils/LinkedText/LinkedText.stories.tsx @@ -25,13 +25,3 @@ export const Default = Template.bind({}); Default.args = { children: "I love working on https://matrix.org.", }; - -export const WithUserId = Template.bind({}); -WithUserId.args = { - children: "I love chatting to @foo:bar.org.", -}; - -export const WithAlias = Template.bind({}); -WithAlias.args = { - children: "I love chatting to #foo:bar.", -}; From 378abbaaaaf94cac545087c46bff3c0cd1e3259e Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 5 Mar 2026 15:49:39 +0000 Subject: [PATCH 08/48] Complete working version --- apps/web/src/HtmlUtils.tsx | 5 +- apps/web/src/Linkify.tsx | 191 ++++++++++++++++-- .../src/components/structures/MatrixChat.tsx | 6 +- .../components/structures/SpaceHierarchy.tsx | 16 +- .../views/dialogs/InteractiveAuthDialog.tsx | 8 +- .../components/views/elements/RoomTopic.tsx | 19 +- .../components/views/messages/TextualBody.tsx | 3 +- .../views/right_panel/RoomSummaryCardView.tsx | 5 +- .../views/rooms/LinkPreviewWidget.tsx | 4 +- .../components/views/rooms/NewRoomIntro.tsx | 7 +- apps/web/src/linkify-matrix.ts | 151 -------------- apps/web/src/slash-commands/SlashCommands.tsx | 5 +- .../with-avatar-image-auto.png | Bin 7965 -> 24247 bytes .../LinkedText.stories.tsx/default-auto.png | Bin 0 -> 7267 bytes .../utils/LinkedText/LinkedText.stories.tsx | 41 +++- .../src/utils/LinkedText/LinkedText.test.tsx | 38 +++- .../src/utils/LinkedText/LinkedText.tsx | 56 ++--- .../__snapshots__/LinkedText.test.tsx.snap | 108 ++++++++++ .../src/utils/LinkedText/index.ts | 2 +- .../shared-components/src/utils/linkify.ts | 103 +++++++++- 20 files changed, 520 insertions(+), 248 deletions(-) create mode 100644 packages/shared-components/__vis__/linux/__baselines__/utils/LinkedText/LinkedText.stories.tsx/default-auto.png create mode 100644 packages/shared-components/src/utils/LinkedText/__snapshots__/LinkedText.test.tsx.snap diff --git a/apps/web/src/HtmlUtils.tsx b/apps/web/src/HtmlUtils.tsx index c81d7af6783..d76b580173c 100644 --- a/apps/web/src/HtmlUtils.tsx +++ b/apps/web/src/HtmlUtils.tsx @@ -17,15 +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 { linkifyHtml } 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 { 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])/; diff --git a/apps/web/src/Linkify.tsx b/apps/web/src/Linkify.tsx index 0f7787f754a..d2cc78beb2f 100644 --- a/apps/web/src/Linkify.tsx +++ b/apps/web/src/Linkify.tsx @@ -8,17 +8,30 @@ 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 { PERMITTED_URL_SCHEMES, - LinkifyComponent, + type LinkedTextProps, linkifyString as _linkifyString, linkifyHtml as _linkifyHtml, + LinkedText, + LinkifyMatrixOpaqueIdType, + generateLinkedTextOptions, + type linkifyjs, } from "@element-hq/web-shared-components"; +import { getHttpUriForMxc, User } from "matrix-js-sdk/src/matrix"; -import { 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 { + 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)\/(.+?)\/(.+?)(?:[?/]|$)/; @@ -197,36 +210,173 @@ 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 { +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, + }); +} + +function urlEventListeners(href: string, onClickAction?: () => void): linkifyjs.EventListeners { + // 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) { + onClickAction?.(); + 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(); + onClickAction?.(); + window.location.hash = localHref; + }, + }; + } + } + } catch { + // OK fine, it's not actually a permalink + } + return {}; +} + +function userIdEventListeners(href: string, onClickAction?: () => void): linkifyjs.EventListeners { + return { + click: function (e: MouseEvent) { + e.preventDefault(); + onClickAction?.(); + const userId = parsePermalink(href)?.userId ?? href; + if (userId) onUserClick(e, userId); + }, + }; +} + +function roomAliasEventListeners(href: string, onClickAction?: () => void): linkifyjs.EventListeners { + return { + click: function (e: MouseEvent) { + e.preventDefault(); + onClickAction?.(); + const alias = parsePermalink(href)?.roomIdOrAlias ?? href; + if (alias) onAliasClick(e, alias); + }, + }; +} + +function UrlTargetTransformFunction(href: string | 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 + decodeURIComponent(href).match(ELEMENT_URL_PATTERN) // for https links to Element domains + ) { + return ""; + } else { + return "_blank"; + } + } catch { + // malformed URI + } + return ""; +} + +export function formatHref(href: string, type: LinkifyMatrixOpaqueIdType): string { + console.log("formatHref", href, type); + 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: { + console.log("formatHref", { href, type }); + return tryTransformEntityToPermalink(MatrixClientPeg.safeGet(), href) ?? ""; + } + } +} +const DefaultLinkifyOptions = { + userIdListener: userIdEventListeners, + roomAliasListener: roomAliasEventListeners, + urlListener: urlEventListeners, + hrefTransformer: formatHref, + urlTargetTransformer: UrlTargetTransformFunction, +}; + +/** + * Wrapper around LinkedText providing Element Web specific hooks. + */ +export function ElementLinkedText({ + children, + onLinkClick, + ...props +}: LinkedTextProps & { onLinkClick?: () => void }): ReactElement { + // If the component requires an additional action on click, inject ith ere. + const options = onLinkClick + ? { + ...DefaultLinkifyOptions, + userIdListener: (href: string) => userIdEventListeners(href, onLinkClick), + roomAliasListener: (href: string) => roomAliasEventListeners(href, onLinkClick), + urlListener: (href: string) => urlEventListeners(href, onLinkClick), + } + : DefaultLinkifyOptions; + console.log("ElementLinkedText", children); return ( - + {children} - + ); } /** * 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(DefaultLinkifyOptions)): 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(DefaultLinkifyOptions)): string { + return _linkifyHtml(value, options); } + /** * Linkify the given string and sanitize the HTML afterwards. * @@ -234,6 +384,9 @@ export function linkifyHtml(str: string, options = linkifyMatrixOptions): string * @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(DefaultLinkifyOptions), +): string { return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams); } diff --git a/apps/web/src/components/structures/MatrixChat.tsx b/apps/web/src/components/structures/MatrixChat.tsx index 3ba87f50434..5a1ff61cb01 100644 --- a/apps/web/src/components/structures/MatrixChat.tsx +++ b/apps/web/src/components/structures/MatrixChat.tsx @@ -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 { ElementLinkedText, 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); diff --git a/apps/web/src/components/structures/SpaceHierarchy.tsx b/apps/web/src/components/structures/SpaceHierarchy.tsx index e7998682408..43c7d252ed8 100644 --- a/apps/web/src/components/structures/SpaceHierarchy.tsx +++ b/apps/web/src/components/structures/SpaceHierarchy.tsx @@ -55,7 +55,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"; @@ -73,6 +73,7 @@ import SettingsStore from "../../settings/SettingsStore"; import { filterBoolean } from "../../utils/arrays.ts"; import { type RoomViewStore } from "../../stores/RoomViewStore.tsx"; import RoomContext from "../../contexts/RoomContext.ts"; +import { ElementLinkedText } from "../../Linkify.tsx"; interface IProps { space: Room; @@ -234,19 +235,10 @@ const Tile: React.FC = ({ let topicSection: ReactNode | undefined; if (topic) { topicSection = ( - + {" · "} {topic} - + ); } diff --git a/apps/web/src/components/views/dialogs/InteractiveAuthDialog.tsx b/apps/web/src/components/views/dialogs/InteractiveAuthDialog.tsx index 1aa3e7fe684..244486473b4 100644 --- a/apps/web/src/components/views/dialogs/InteractiveAuthDialog.tsx +++ b/apps/web/src/components/views/dialogs/InteractiveAuthDialog.tsx @@ -21,7 +21,7 @@ import InteractiveAuth, { } from "../../structures/InteractiveAuth"; import { type ContinueKind, SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents"; import BaseDialog from "./BaseDialog"; -import { Linkify } from "../../../Linkify"; +import { ElementLinkedText } 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 34bf1c7cd35..b9df6f80288 100644 --- a/apps/web/src/components/views/elements/RoomTopic.tsx +++ b/apps/web/src/components/views/elements/RoomTopic.tsx @@ -20,8 +20,9 @@ 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"; +import { ElementLinkedText } from "../../../Linkify"; interface IProps extends React.HTMLProps { room: Room; @@ -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 34d6e9be943..49cb5ef2ced 100644 --- a/apps/web/src/components/views/messages/TextualBody.tsx +++ b/apps/web/src/components/views/messages/TextualBody.tsx @@ -25,7 +25,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"; @@ -186,7 +185,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.hasAttribute("data-linkified")) 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 460f7859d2f..0340fe98406 100644 --- a/apps/web/src/components/views/right_panel/RoomSummaryCardView.tsx +++ b/apps/web/src/components/views/right_panel/RoomSummaryCardView.tsx @@ -46,10 +46,11 @@ 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"; +import { ElementLinkedText } from "../../../Linkify.tsx"; interface IProps { room: Room; @@ -89,7 +90,7 @@ const RoomTopic: React.FC> = ({ room }): JSX.Element | null ); } - const content = vm.expanded ? {body} : body; + const content = vm.expanded ? {body} : body; return ( { )}
- {description} + {description}
diff --git a/apps/web/src/components/views/rooms/NewRoomIntro.tsx b/apps/web/src/components/views/rooms/NewRoomIntro.tsx index b486b5e0596..20cb76be0ed 100644 --- a/apps/web/src/components/views/rooms/NewRoomIntro.tsx +++ b/apps/web/src/components/views/rooms/NewRoomIntro.tsx @@ -32,7 +32,8 @@ 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"; +import { ElementLinkedText } from "../../../Linkify.tsx"; function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean { const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId); @@ -137,14 +138,14 @@ const NewRoomIntro: React.FC = () => { {sub}
), - topic: () => {topicToHtml(topic?.text, topic?.html)}, + topic: () => {topicToHtml(topic?.text, topic?.html)}, }, ); } else if (topic) { topicText = _t( "room|intro|display_topic", {}, - { topic: () => {topicToHtml(topic?.text, topic?.html)} }, + { topic: () => {topicToHtml(topic?.text, topic?.html)} }, ); } else if (canAddTopic) { topicText = _t( diff --git a/apps/web/src/linkify-matrix.ts b/apps/web/src/linkify-matrix.ts index a5a5578624a..01d292f6187 100644 --- a/apps/web/src/linkify-matrix.ts +++ b/apps/web/src/linkify-matrix.ts @@ -7,37 +7,6 @@ Copyright 2015, 2016 OpenMarket 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 { type linkifyjs, LinkifyMatrixOpaqueIdType } from "@element-hq/web-shared-components"; -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"; - -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, "\\$&"); @@ -52,123 +21,3 @@ export const ELEMENT_URL_PATTERN = "(?: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): linkifyjs.EventListeners { - switch (type as LinkifyMatrixOpaqueIdType) { - case LinkifyMatrixOpaqueIdType.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 LinkifyMatrixOpaqueIdType.UserId: - return { - click: function (e: MouseEvent) { - e.preventDefault(); - const userId = parsePermalink(href)?.userId ?? href; - if (userId) onUserClick(e, userId); - }, - }; - case LinkifyMatrixOpaqueIdType.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: linkifyjs.Opts = { - events, - - formatHref: function (href: string, type: LinkifyMatrixOpaqueIdType | string): 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) ?? ""; - } - } - }, - - attributes, - - ignoreTags: ["a", "pre", "code"], - - className: "linkified", - - target: function (href: string, type: LinkifyMatrixOpaqueIdType | string): string { - if (type === LinkifyMatrixOpaqueIdType.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 ""; - }, -}; diff --git a/apps/web/src/slash-commands/SlashCommands.tsx b/apps/web/src/slash-commands/SlashCommands.tsx index a566a5f093a..78081f08e3c 100644 --- a/apps/web/src/slash-commands/SlashCommands.tsx +++ b/apps/web/src/slash-commands/SlashCommands.tsx @@ -25,7 +25,7 @@ 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"; @@ -61,6 +61,7 @@ import { goto, join } from "./join"; import { manuallyVerifyDevice } from "../components/views/dialogs/ManualDeviceKeyVerificationDialog"; import upgraderoom from "./upgraderoom/upgraderoom"; import { emoticon } from "./emoticon"; +import { ElementLinkedText } from "../Linkify"; export { CommandCategories, Command }; @@ -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/packages/shared-components/__vis__/linux/__baselines__/composer/Banner/Banner.stories.tsx/with-avatar-image-auto.png b/packages/shared-components/__vis__/linux/__baselines__/composer/Banner/Banner.stories.tsx/with-avatar-image-auto.png index 618889203402841e88d0473c76db18c2b5cf3489..512b2264ed5f27dfcca3e3bfe0014984882551eb 100644 GIT binary patch literal 24247 zcmZvEcU;Zy`~Nvd(GbzpCJoZ?rYUvasE~${(0mJN7ww!nhmX}1DVoPfX=&-CZB#^q zwx&X*rM>HSz0U6Y_&xk{+~@VW?rS{nYu(rBuCbvW7l#lB3Wef2a`?~*6lzTw3Wcd- zTMH{zUR!ceC~nk|L;s%gMh|tYi+%2a)BZa;(mAZGrsk`NK7|dJP+{Y+-<$kZHu=tG zn^Vt^EN4ZvWRfnQ+7*5|_|Wm=o3kViH?r~jj!xlwH>l;8c=^o)_+K4zyEy7;`}^_Z zq@voGxW`G0{#{)Q`-&|6ryoBaEOnWFFV`;U&GrBPcWOzwuC$szMi=MOlrdwu=KuH4 z!-s3~w)PZX<`oPJ8@B`&k3z$ThQnA?#_=3^4+C^9W0gBO3Xd{>fW*o2;8JZ>)| zfojh9#eWQsWm!WbL(t%0EAtpCQ09>^c0s4g&?uh*%67f-F|dyZU#2tVabgJuZ^a zUBZ^jEe#_~VoTK75$r=sy+0XiE`BV^A0^!G={$S2D!|X$P;>ULD? zss-UV4WoZ6vLbHJXxO038%c%Y`CIt|d4)Ys-h}B|bp@WuIxj-HE?zH=8|yD(b!)Sj zsYu8u(#j|vn9lEwUl3~3_?ok{?MeZ#Fc-K#v} z$b<8@ok)SD?F;N#wef|O&ih=%+Z^BcIF?pNCYCzoH2%&SG;AyHG-^?v?&>>|^?5^E zMefU!=cf-|6@FXVaVZ1m--cFOYWnd*PoVO;&gb*QQth=e8$LY0%X7Y}`@^n9!@!W7 zo+QI~-Ul+%l|SDu3&z$7v}RPu@7u+{%ly7YNrJ$RL$|dVm;)HRDf@QuvykbvCcy-_n?B2s>EvhPOQ+4QUI^pnFaQFoTW$|+E(tlG)H&za=W+tk_` zxPSh1OzoZGY`OPd)kURa>OVA7hFxs$4GHka@lbumV#nd?XIiC$8HK$~W-bmxF$*6h-CBQ{?iCB0vKeWAGj>~h*{{5ADuy2g=AfaMmynb0q|Ca9lm1f%no}`53`r|X{F1?arnnG6dq*ovI&wMfO z+WO4pRb5k~-`OV@&vGEX0udb>!unhOOv~(|gKz4D?#ZNvGd*L88x3Q*{f4s~dV@;E z9!>o1-FNO&09Yawts^P*{DQLq@U6tvz9;rN2GK>JDxU^<5tIpIMk~JZl?P;DQtGt4$EMTB#tNpN>@@ z-1q!hTi4XcOvpmi1_7(_+ukedv?BcWYtN4v^_V4HD}3oDdW_+UK4JzqGp5^decPg1 za=v?ZUA%ob;jUrV^$x>LOX=}N-e2zvT59lqj*<`JYfWsc=>3tnyU0;Ca)bQcCsIK< z3p%Y%ZoAA1wntuUi+thK>U4SR>6vWr|9T@s&3yFx)|cPc(U&ZKUC>yd;ns;=!Sa7! z&=Z|^k`WP3!map)wAD|yDOxV2ycY%;`?N>z2@cj)8qT{eR_^E$9L!Xnb}3!*bnBfm zEw>t3Xi(1c8vJBpYQ=N4PwStI0WshHANjw(4_0`j-O$)+?Mm~b1Po`pe{*qo2`Z&D zV6y#q_qW2+-^B_?1WBE(orTtB-webnEOWix)^l-{8}na}y6{7#V`CDk=$hI$liD9c zRU_F$fBIZ~<2$~Uau!|FxnO!NN3Oi}#;<`uH_wEQ^!CUD?w)3maU}Nv*UxtboGM3g zU8NuV7qsO(TkJ@)Wu@1=&222)ewny^adQbuTK=`CXSD0@3$@Wj&eL(KN_7dW-R!CH zr19e5Exfcl9(k;Us3ZZG2(Y^&`Z zt(GdYhoUyvIn8u#N;9gzJ@C8g;5#&Z;Vg)ceo&R!rZBpIFRS;mmdM?v3f4(ksr& zk}odb=VW9vQqcCG=aG%kom0JGIjOmJ>40OOX959>y$O;QV_LI5ijJ|W{Tn;mkfg18 z#s0b6bi&+FpQPvnoZY+ct){-`FW(Y29X1+NpHHvU`A%98^V2!k_p!!(WS#cpSMMJ| zmYZ*j`hWO%X=cBGF)bDl^DzTmQwFVe6I)_? z|1sNfSFQhx1-`J3jkW|&@44|a;FNz1`Cpv15PNUu=h@vm6MpmO$LlU1vVZWMy(1Rt zzQgbBJDsyi=G}Irpr~D+ z>~Ti1u8Ub?N!p7F8?+Z6j=dL;E1BP#Dwg2a$(m)N^4p1IEtiC>44Y#ND+mO9eZ{*F6i)ULbqC38MwEg-T#oGIj#P_w!yJ#0@ znSOR^!NJdww*RRX$_s}+8T#Y;U8U&ht4;T!HoUr%`1$mOAL>Y2c2><$@uqTA#5=ut z$KgMuJps#~P1YqE@0khL>ydZyw{a}^B%Rf4>38Yv(ChGwdl?#+Bi5BJHlB4W%SqUx zh#Lrkv}ecI7hBE!p}tFlJu=!$2^C*?)=GcC2Kb?8KafPDZq~~85^rn>KDZHgyxntQ zI3&w-C2MbCbct+XcKr3`U9at6h9;w)eR^`lhFKK9IDWNbw$jFcZ3ex@G_J@~D+KsUt7j z%64gf$onm4yufXd#r9iPe{6mIYg}p!>Q$We#C#6*MzMs*_(R%q70pVvG+`U(mxR(x zx3`ILLW=o+pH>vAhN+DwtBp^&+zE@H9MfHUZs}Q~p7>SsqVde;+&$GoeJ{KSFT6Z6 zmYqK;Z|LuS@OIa;FIJh!iCgAl_NNkBRJoVdtl539V}4<+P}su4N$gOq|C+ieuGT0E zp3tGlk%gzF0q55nMsL1x1{gckhvMfry~m#)Nv%kT*l!uXg!!-WR;8GTcQ<-0<-OIWSNr~igj*gP`20+_ zXe%G<>zk21$p`wPSq#4S$j5)KL~MY z6cbAfbsxH|JxMs6FxAobazS-ja=!Crmr;yfaUw}ke5t9o=gsX!k82~GR>A9Xx??-X z#pD<5E_F`&p1}o*dbMbcl&&mSyvS;b_N`OH`S=ie6ameL) z?TpHZBg}j!mFbDg(YvlHI_Zz)4ZrO)iLuW*eS2Iz^$r^ z)N0w_-98{LzFb%O;kBt%llOv?Z_t7H;K4Ij#ipxDgFY``JF@sU_oPkbu8*Z-1}=Bq zO}vLEz6GE@_0RTBbi!7qFBMI^n5gIbH=NpokIwbijJ$#{oSjlMz?F)W(Nv$=AGdpd+!hm3-|Jku6gs=S(O+=D z&;9hu$NuJ$jbk^-0P8*; z<;&#CSna-*>lKxoSB5`Zg}DE?d?9Zbn>GF5ZtljRc-a4Mjjyk@B6W%c67Q!qa5rRP{-2eSk`fO?eHFVexxL|fVj=a0 z3&(x)YviZYaAPL(|D?<3wP~Hg-sQ24HmuMP9z8nIL!>m{~XAj+%jY;wj4dRoMZf{xBY6Z z(d~jwNFReR!tp2sML5CsTtZbIQsw#UVf&`jvxZgLKD_C(s%u#cvRdA0xU^g~nylUz zdsWz~&adgLZRMB8GZlWF3xwH$Eh}wmBZFPDr=NR;#QXeK>}j8<2}3HL-umRKinsh#fpR zUDwuEJm1A)6u)=(+H9YvpR{?hU2)^4CA?j8HD1D9w~A^@jKbFCbX^hg?eB7QeJ-Ol z#y&O{hTGoh+0!Q6)wvM(-cnSrc=@CE4^FE(wbA?9i(3unTV%E^JR58* z#tpZAeM_-*V(y2ZlSE6W`c++A^DMkN632`bMd$BFDVLdI1t+TPTwg~NG4rX}N}=S!CoI+Rx0`$`3C{ipW`S0;+DyKr@=aP*{2 zZShRC|0h>ZM|gH@#H)CJsQ2*k5Yi^AfF-W6h$KyQQ6U^oIG*HIXRmEJ-cx!PeCJ)? zWh-$Zy^>||?3It5A^}sT>-L5Uj-C@2ybv(6qCd#j*N%)8DIV!58&aUd`kvCCo&CLP>J&xHN2Q1#0ZC1Rd zGE%Y-pFdqNSpQ$D^T2`s=Gl5Ut!A6UJGVS8Sw9`&f|L9ld}DW$dFoob@qanqLP+W} zi05Cbj=vY05PP$MFeEgb{iHn0_3g9Wb)}k-ie{S+1_@mH*ZTy&D)^cIOOW6>-ui6o_U5R> ze(y!2`RmWDg2zw)o{XQEY-$UaoatJzbE|1Bbu79$3&C&{^7+}B!=}6Im%WOX5_(Q1 zES~XgVLRHFurOHBJ1NQ9`B|~}`PYWeic2<<&l86SrpKd}oXowd`BE#6wCNTuL0Nr= z6z4i5RCq$x$mM~-TbB!!>pEBBXZ!WETD<3LycSCjcV)-MAB)paE?Iv4>Vu5dbhxhQ zfZ45$>a%a`nuSQOD>Z-2+6Z-3ErxGg(O(?9-&LU<=lxEoGECE_BV6yp%F^_rcYw;u z%ay5NwSi~ifu|-uE;o*D^dGs}^JQhpu1GR<)U|$cHVPH?kjxR6bgril5#otJg0v5gr^w^#i9S|nID!Nu3?{qnR$95~%&Q{5T z$|C{O+DVm%xwPkh1XRjdjg41KPf8Y7_%EgIsefM~AAo1k& zAKurjrCQ7-g(IB#-lt3Lo%G+w+p+Cs;a4yhJekA!AVem0X|^z`bl11WACn!}fsW@Z za~-1>1*D@gG|q3=Hb~Ga&T`7BR&i*reUxR_>pxJ}Tls=(X=%xAXt&GV$(lA9&4C@z zGLT8*D6y%2STp)Ny|ney{QUg+<)SxP*2@RoIy_82jQ=RlvVPm1|0huUVw-qy`9aR} zU!VH4_TILc#?5>%GF-6yQm_(#Chvmfq@tC`#@Z6q-?edkJlV!Y+xB^Sk3IWJy72F= zxEa@DRns$y7J{l4K4(22di&%grn_D9)pwrn^O~P;TK~;KB)91S@855#D~8c~^PA#F z_74vZIxOb=J5Xgef4Xn1rf6ZLP)9??BD|wcecwh?FZ$~;QEd)=bF`hg1PMC7KSDwDoBUI+>$%! z9aIZG+1d*@9B%i;s-K5l`l<{6OpevKn@kV1vc7v=*=d&~Drr$Sht#YB7fqc4CVLY; zENK6I@_M7gpEqBjTr!Tk=@gS?E#bC8j)zZL|MoWg~9*v1^?>(X_Jxr~Hn$55JEdID3 z)Ki)*ce~l#@2t7saN<~>et^IV?hmQwv(=(|?3{KI6x3>RHOu8&+?HEyD_(ZZ>0Vp< zoDlZP|HlKjPop(qyt?(~&U7*1Lvjk%cVXiC&7iAiI~$wLg^_R_JDFvqJO9yg{!yoP z(T`-0isdTPQZdE3Hq*UYfwOr$p+Qch4(Z?3F{B!|-VP9wU-^+mU~>gICXOs81D ze6LGRYOxoDY_AVr{G2~$uxQ`6xRSs<)9&Zc?$#XQ*7{JOQgXt!a!S=|OKItB(NOm6 zFE^X%*&h9xust=i{)Yzsv$gvYAPjvUs>5- ztF2wgN&Pgir+h&4jZf{vKu4DD<0TWa!@`42Cs3V^6Y{-h*Dace{7qZ3fuI$9_r@l< z)9cIQygE0VTH(&N<7(x12fRJ#R$tv*@jk3wJfNiKKHPpd*!*7O^+9)!bh(({@Mg2& ze4Q_!rkzTrq_bRGi(0&Mc8ooZ64|V{f9T>^+KmlcQk@r=yXwZigvpEVcGSJjxi^VB zGT?Vx*V)VV&CutS=~ugP^A)MLmVT=mhK7&6ZuK2Z@5vRvrdTukX`>{r)nwlG0i=5P ziY!yBxjvIF(+I8TM-`e-wA&BqcQrqVPYTn5^xiULbaKm_Se*NpQ3VafZ(cpmN6*dA zxcH}!Ns)x3Q+HqH)V^;zU;Zy=k}kA13?{$Wif`UGoNJ&t-+!a^U1WLvbeBwCyKlYj z-#C)1^Lj4DU;~vHAz`O~q@;~vxUULShd{5kvb{X)Ll0k&P1F0Ci)~_oh2r^g(#M8_ zOvzv`|zQuGcgvYwEs(im}zh2QQ-on`YYvZkpU%&{BLKQ7RZCG*D?c3o zo736979tb;TAN9oZdcb=ervhdENbJ^m3?OKUhTEgGnIAY<+~#+9Vo#b? z#qdn~rsfWBi*{VIh1;OKYPpVp1vj8eXVQR z?{J;e&cf8h&S(p%E?nPmTXfDaD)v&BZNi!!+~|Jw4Qk!2S)YOp&VPrRWBK|VlvGHD z7#!!-vNi8f$q`3&Tz!k*j7&v|pSIL=i%(RGU0X+Qz4(%4(ej;@s9SE5Q2rZY=m3f^ z>Siw-;Z<$FVmrq%dZ1j@_J>znI_b`l2W#7+<)cusJ6qgt@9*hX#ckIbUv{ZHuD5&= zIwD)M+&p&#j2X`#9p&2%4V*T|5}lzpZI)wcNuok7{_iV$pKpv8S{S)%JbMs{)#@CA zffU8dCYI^ezxa@`&sjLi>gvfaF{POi4j&8qpFBx)8#W+SUOJoCekI4CO-ze~(me4g z(#%m@QEgUvOmTGa^tG9vUFD@KgDyR(R&7Og|OJ3 zc=Ap^rL85%*Ad2-2$Qu7n!k<|>da>+6<(^@IdS59#?o@@#&RxF=a#~uOeaSzn+JK7 z`fGlQ;p>T#q{+vfZ{L2}?K6(tcogU!mPL*8wQi&T;B zRTJ#)F$tH zYT7E77c_TFf47M`?BZkCmaX%9*%Vb1*MO%7(GGqkaY6yxF(HxQEisNFjEmny7thXX z71Fyejz7=HHAana`*0if8vQ)Bx*c=bGvg$x}ikWEFtw(nlXQ9li#AKr>=%Wk-uoOs0puEs; z98NyEvueFbP6G8RsvE| z=&KA3Pa2keVPVUal|Yf$`t~wu(s!%1Ats%Ztt`Dw{Q*NzG^T@3$;=GMn_V-cj9yS} zPjF_>-4}*x&mc}P8wyM0#Yk-|OH?|&or*e&M7&rmN&(ArPT}ocI(U!|O3rAscl)Ug zyQ1;F3^sxFU|>Bs(uSQ^4=cs=#@-#E8TQ|B-xw%CT+0xk8d$H6QAfrUiusiG(e}pb z1MB*ZhKRxmmh6vc#;r>M@+q+s6vfZ5eu0S)7@sgnRG+-PCKDMpZrSJ9PZ-y&Tzond zQIKbWo)kAXUcc_SiIguLrQ{FtpfLG*^{h6`Z-@-^jj{VcqJ6$KQg!>$dzkjEdjSfQ zCf2ia_us;+Gvvxu1ckX=W&u{*PnY%6E%kpEZ#qq0Xtyh z)~l@d6JXu<(ryB~*X8IQ;3(8bl*26Fzd zkx0g93Z4RQU_K7q*gh!-iWTaL@Ovms5jG%leo)>^MRhRVhD-`;3)|Mr3&l~@uB3(5 zL$H9Yg!NFK7&=_6ccGD7yp9Wv&KIuw&0sLoAMFQj=sti(>T*35iU?Oq(Re480vHSP zs{MY9$sF8gPL}0l06P}HRJN>AZ!nwtf+cDsFMn>8CW$f_R?f2hyOnmVdb$DeGbbK2 z9-#Axd=N$Ktn1KyiB-gi=?j7(Y#7&|tU?&$RJ%_V77hbp0~`*~wJMruOz$Sw_4Ex0 zHLxD1s^Xd-OjAz34ce zq@J%-PH##^SO8JTtGUgQsu#jTj6Z^KY%{1m<3Y?l28Xb-FSx3i10At+C3^5@dcrb5 zeTJnGDPcXAl^mFZ5@Ou?GpwAnlg zqBGbg)lgm+LzrjN5N&Qn&5Y|V<0zRJ*c0Pr7`!I!#Ro`vm}Wuaj-&EMfwG znL;o@0W@o|R#g;@IZ{SD?I8IKNInu16OJMjaGueo5yBdQzT-1vkciAQpoo11%a)&F z$WH!`taUX-D(preBGwZ_iJA@iW%d-1yTEeid<+%j$w7Dzva)MoIi7??Q&mFv!+B;a zY#a!ggTNC`dpgFGv+=JtNf>hj^d&-!*V1@H5$rk6@DY08^p_9ZJjzfvcXBj-iuWkM z^VB|G3L+?968^C9I!R$%ziK=cV|GN`{dcfYYh3l3tf_eb4Y^9-i#4T>hpwZg)>NK+ zrNNlNCH_2SyXoA(2VYS6%!#gc+LR>i>0Pj#kA5Lzyb+zsNMYb!ADut5-x!U?mHnu>^(P9R#4hVEqrr}?NITPMYY03*kEFhl{{e+kNUt@W6D#hcM zgCh!R+}KD3p~CzMh6k1st%r1jOb;HL+n3Yl`X*_w9wfm{pVH}+}^5I?( z_VyZA1!YR)VpuO%9^#xqJ^?*M%iSnv;vJ$EJDn+xtZg(^A=~gCLEMmP!wD(015?RP zv<9`j6?!UyY2?o2V7yQc{wb)LhY#@NiKb1j0+ka$<$@V6M0CP9M{+E3fDrwk0)dj_ zjte0FjW~{_;hX^4!l>%mkp&`}V^XBZfxR4NWrI4zY1!z-O~x{07A=AJwIE(<=EE&p zg6DvP0Z2yYueUfMN!bfj7|z;vvq&((Z>vJ1@0j3#QfUCjsILP_mCcmfx=$& zR0M)^lR{p7hAB{RCL+=|qw^g_PtrXnS)$zS_pYLxU#^v5*b~7hAryRq&t~Bus@Rk) zg#&z~K%Vk1xnt!FRsccSfS{iv$?3;1_7t%xTIfa;F8o{*^NZV9iY5}U0;J1F!$Dx# z0{lGP%tyheMm;qMgyR6s6*`b)VS3m+((olYF%Fa^(m;L)h~JG)q-2Z6P@+&UaRsuu zUw@(~ZU#ko%9c!?o;?aKI4a{J2Q7`Bw8XLkld{i%ctys`$22jCQJCLQSUd;fM}(6c z87L{E@|5TJEOzeXJ3u8|_Z*WeuVE8>i1h&E|C#*8?8-lym3cn|S0>kJ7{qkE@mPP1 ziSQ9r#yP$8+p9FL%Xj%7RG7xC5N6B*hkt>?J@VZ*iuq~1fGfM`C`CR*oQ8wsG+1Dj zzlF)-62_O{lz!8Hy2g(=_1egYS8U)6Yyr9je}$tu6Vu%EjQf=?yZV=CwvN&X*5 zd{^Zh3+$vJV@*qgu_hpKiu*SWvJ!bP6;$o-gw(<``EevAw;JbD5+bvj90^p?{m*ex z7{3>VcO$=e1!Vhm&i-GT_NQ5ckyE`BOlvZfqD(*Rxsu(GnC^s3#56Z6k|7OI8Ev~M zIRl7ybe?0*39C8OO1=T{#5M;vW_=2lVjIJF@u{8v4bs9B1x>fg1kw3!!Q#vhQ{eIU z*15~#6uv3$h3%9s{0!lk+=SH~B)g_Af>VGSl^d?G{gkw( z1UvF8jo{Ezn@`GZ!xAmfebg&e7PeF1&@(QW$s;YXbWSx|p>1pe#8cv4D{Bb$cxEEpsG23GYP6Z){baB

ZG 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 literal 7965 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 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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 @@ -22,6 +26,39 @@ const Template: StoryFn = ({ children, ...args }) => "_fake_target", +}; + +export const WithCustomHref = Template.bind({}); + +WithCustomHref.args = { + hrefTransformer: () => { + return "https://example.org"; + }, }; diff --git a/packages/shared-components/src/utils/LinkedText/LinkedText.test.tsx b/packages/shared-components/src/utils/LinkedText/LinkedText.test.tsx index eb0c2616901..ba57a8f892f 100644 --- a/packages/shared-components/src/utils/LinkedText/LinkedText.test.tsx +++ b/packages/shared-components/src/utils/LinkedText/LinkedText.test.tsx @@ -8,9 +8,14 @@ import { render } from "@test-utils"; import { describe, it, expect } from "vitest"; import React from "react"; +import { composeStories } from "@storybook/react-vite"; +import * as stories from "./LinkedText.stories.tsx"; import { LinkedText } from "./LinkedText.tsx"; -import { LinkifyOptionalSlashProtocols, PERMITTED_URL_SCHEMES } from "./linkifyOptions.ts"; +import { LinkifyOptionalSlashProtocols, PERMITTED_URL_SCHEMES } from "../linkify"; + +const { Default, Unclickable, WithUserId, WithRoomAlias, WithCustomHref, WithCustomUrlTarget } = + composeStories(stories); describe("LinkedText", () => { it.each( @@ -21,6 +26,7 @@ describe("LinkedText", () => { 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) => { @@ -28,4 +34,34 @@ describe("LinkedText", () => { expect(getByRole("link")).toBeInTheDocument(); }, ); + + it("renders a standard link", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders an unclickable 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(); + }); }); diff --git a/packages/shared-components/src/utils/LinkedText/LinkedText.tsx b/packages/shared-components/src/utils/LinkedText/LinkedText.tsx index c099a86e5d1..f1a5a8258cb 100644 --- a/packages/shared-components/src/utils/LinkedText/LinkedText.tsx +++ b/packages/shared-components/src/utils/LinkedText/LinkedText.tsx @@ -6,28 +6,16 @@ */ import { Link, Text } from "@vector-im/compound-web"; -import React, { type ComponentProps } from "react"; +import React, { useMemo, type ComponentProps } from "react"; import classNames from "classnames"; +import Linkify from "linkify-react"; import type { Opts } from "linkifyjs"; import styles from "./LinkedText.module.css"; -import { LinkifyComponent, LinkifyMatrixOpaqueIdType } from "../linkify"; +import { generateLinkedTextOptions, type LinkedTextOptions } from "../linkify"; -type Props = ComponentProps; +export type LinkedTextProps = ComponentProps & LinkedTextOptions; -const options: Opts = { - render: Link, - target: "_blank", - rel: "noreferrer noopener", - defaultProtocol: "https", - // By default, ignore Matrix ID types. - // Other applications may implement their own version of LinkifyComponent. - // In the future, shared-components may fully implement this logic. - validate: (_value, type: string) => - ![LinkifyMatrixOpaqueIdType.RoomAlias, LinkifyMatrixOpaqueIdType.UserId].includes( - type as LinkifyMatrixOpaqueIdType, - ), -}; /** * A component that renders URLs as clickable links inside some plain text. * @@ -38,15 +26,35 @@ const options: Opts = { * * ``` */ -export function LinkedText({ children, className, ...textProps }: Props): React.ReactNode { +export function LinkedText({ + children, + className, + userIdListener, + roomAliasListener, + urlListener, + urlTargetTransformer, + hrefTransformer, + canClick, + ...textProps +}: LinkedTextProps): React.ReactNode { + const options = useMemo( + () => ({ + render: Link, + ...generateLinkedTextOptions({ + canClick, + urlListener, + hrefTransformer, + urlTargetTransformer, + userIdListener, + roomAliasListener, + }), + }), + [canClick, urlListener, hrefTransformer, urlTargetTransformer, userIdListener, roomAliasListener], + ); + return ( - + {children} - + ); } 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 00000000000..f77e2b0f32f --- /dev/null +++ b/packages/shared-components/src/utils/LinkedText/__snapshots__/LinkedText.test.tsx.snap @@ -0,0 +1,108 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`LinkedText > renders a custom href 1`] = ` +

+`; + +exports[`LinkedText > renders a custom target 1`] = ` +
+

+ I love working on + + https://matrix.org + + . +

+
+`; + +exports[`LinkedText > renders a room alias 1`] = ` +
+

+ I love talking in + + #general:example.org + + . +

+
+`; + +exports[`LinkedText > renders a standard link 1`] = ` +
+

+ I love working on + + https://matrix.org + + . +

+
+`; + +exports[`LinkedText > renders a user ID 1`] = ` +
+

+ I love talking to + + @alice:example.org + + . +

+
+`; + +exports[`LinkedText > renders an unclickable link 1`] = ` +
+

+ I love working on + + https://matrix.org + + . +

+
+`; diff --git a/packages/shared-components/src/utils/LinkedText/index.ts b/packages/shared-components/src/utils/LinkedText/index.ts index e03a5fc3b2b..5581424c498 100644 --- a/packages/shared-components/src/utils/LinkedText/index.ts +++ b/packages/shared-components/src/utils/LinkedText/index.ts @@ -5,4 +5,4 @@ * Please see LICENSE files in the repository root for full details. */ -export { LinkedText } from "./LinkedText"; +export { LinkedText, type LinkedTextProps } from "./LinkedText"; diff --git a/packages/shared-components/src/utils/linkify.ts b/packages/shared-components/src/utils/linkify.ts index 2ca34e88ec8..fedc62711ce 100644 --- a/packages/shared-components/src/utils/linkify.ts +++ b/packages/shared-components/src/utils/linkify.ts @@ -175,9 +175,108 @@ linkifyjs.registerPlugin(LinkifyMatrixOpaqueIdType.UserId, ({ scanner, parser }) }); }); -// Export our instances of linkify to ensure there is a singular instance of it. +export interface LinkedTextOptions { + urlListener?: (href: string) => linkifyjs.EventListeners; + roomAliasListener?: (href: string) => linkifyjs.EventListeners; + userIdListener?: (href: string) => linkifyjs.EventListeners; + urlTargetTransformer?: (href: string) => string; + hrefTransformer?: (href: string, target: LinkifyMatrixOpaqueIdType) => string; + /** + * Disable this to force the + */ + canClick?: boolean; +} + +/** + * Generates a linkifyjs options object that is reasonably paired down + * to just the essentials required for an Element client. + * + * @param param0 + * @returns + */ +export function generateLinkedTextOptions({ + urlListener, + roomAliasListener, + userIdListener, + urlTargetTransformer, + hrefTransformer, + canClick, +}: LinkedTextOptions): linkifyjs.Opts { + const events = (href: string, type: string): linkifyjs.EventListeners => { + // Attach click handlers to links based on their type + 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 => { + // Sometimes components want to render links to prettify but not make them clicky. + if (canClick === false) { + return { + "href": undefined, + "data-linkfied": "true", + }; + } + + const attrs: Record = { + "data-linkfied": "true", + }; + + const options = events(href, type); + // 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 + if (options?.click) { + attrs.onClick = options.click; + } + + 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 && canClick !== false + ? { + 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; +} + export { default as linkifyString } from "linkify-string"; export { default as linkifyHtml } from "linkify-html"; -export { default as LinkifyComponent } from "linkify-react"; export { linkifyjs }; From 71984044e9690a52c47711aa283cb795940d36c9 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 5 Mar 2026 15:49:52 +0000 Subject: [PATCH 09/48] Add copyright --- apps/web/src/Linkify.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/Linkify.tsx b/apps/web/src/Linkify.tsx index d2cc78beb2f..be007a5cb46 100644 --- a/apps/web/src/Linkify.tsx +++ b/apps/web/src/Linkify.tsx @@ -1,4 +1,5 @@ /* +Copyright 2026 Element Creations Ltd. Copyright 2024, 2025 New Vector Ltd. Copyright 2024 The Matrix.org Foundation C.I.C. From 9cc4a6c9b62fb5614040c6b835e18bf9ab91a970 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 5 Mar 2026 15:59:42 +0000 Subject: [PATCH 10/48] tidy up --- apps/web/package.json | 4 ---- apps/web/src/Linkify.tsx | 8 ++++---- apps/web/test/unit-tests/linkify-matrix-test.ts | 13 +++---------- .../src/utils/LinkedText/LinkedText.stories.tsx | 2 +- .../shared-components/src/utils/linkify.test.ts | 3 ++- 5 files changed, 10 insertions(+), 20 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index f31b58ed11f..6860881367d 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/src/Linkify.tsx b/apps/web/src/Linkify.tsx index be007a5cb46..2e38c26a330 100644 --- a/apps/web/src/Linkify.tsx +++ b/apps/web/src/Linkify.tsx @@ -260,7 +260,7 @@ function urlEventListeners(href: string, onClickAction?: () => void): linkifyjs. return {}; } -function userIdEventListeners(href: string, onClickAction?: () => void): linkifyjs.EventListeners { +export function userIdEventListeners(href: string, onClickAction?: () => void): linkifyjs.EventListeners { return { click: function (e: MouseEvent) { e.preventDefault(); @@ -271,7 +271,7 @@ function userIdEventListeners(href: string, onClickAction?: () => void): linkify }; } -function roomAliasEventListeners(href: string, onClickAction?: () => void): linkifyjs.EventListeners { +export function roomAliasEventListeners(href: string, onClickAction?: () => void): linkifyjs.EventListeners { return { click: function (e: MouseEvent) { e.preventDefault(); @@ -282,7 +282,7 @@ function roomAliasEventListeners(href: string, onClickAction?: () => void): link }; } -function UrlTargetTransformFunction(href: string | string): string { +function urlTargetTransformFunction(href: string | string): string { try { const transformed = tryTransformPermalinkToLocalHref(href); if ( @@ -328,7 +328,7 @@ const DefaultLinkifyOptions = { roomAliasListener: roomAliasEventListeners, urlListener: urlEventListeners, hrefTransformer: formatHref, - urlTargetTransformer: UrlTargetTransformFunction, + urlTargetTransformer: urlTargetTransformFunction, }; /** diff --git a/apps/web/test/unit-tests/linkify-matrix-test.ts b/apps/web/test/unit-tests/linkify-matrix-test.ts index 38e5c091a02..8e1fd607544 100644 --- a/apps/web/test/unit-tests/linkify-matrix-test.ts +++ b/apps/web/test/unit-tests/linkify-matrix-test.ts @@ -6,7 +6,7 @@ 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 type { linkifyjs } from "@element-hq/web-shared-components"; -import { options } from "../../src/linkify-matrix"; +import { roomAliasEventListeners, userIdEventListeners } from "../../src/Linkify"; import dispatcher from "../../src/dispatcher/dispatcher"; import { Action } from "../../src/dispatcher/actions"; @@ -15,11 +15,7 @@ describe("linkify-matrix", () => { it("should intercept clicks with a ViewRoom dispatch", () => { const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); - const handlers = (options.events as (href: string, type: string) => linkifyjs.EventListeners)( - "#room:server.com", - "roomalias", - ); - + const handlers = roomAliasEventListeners("#room:server.com"); const event = new MouseEvent("mousedown"); event.preventDefault = jest.fn(); handlers!.click(event); @@ -37,10 +33,7 @@ describe("linkify-matrix", () => { it("should intercept clicks with a ViewUser dispatch", () => { const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); - const handlers = (options.events as (href: string, type: string) => linkifyjs.EventListeners)( - "@localpart:server.com", - "userid", - ); + const handlers = userIdEventListeners("@localpart:server.com"); const event = new MouseEvent("mousedown"); event.preventDefault = jest.fn(); diff --git a/packages/shared-components/src/utils/LinkedText/LinkedText.stories.tsx b/packages/shared-components/src/utils/LinkedText/LinkedText.stories.tsx index d7223738389..7ee618adf19 100644 --- a/packages/shared-components/src/utils/LinkedText/LinkedText.stories.tsx +++ b/packages/shared-components/src/utils/LinkedText/LinkedText.stories.tsx @@ -5,10 +5,10 @@ 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 from "react"; +import { fn } from "storybook/test"; import type { Meta, StoryFn } from "@storybook/react-vite"; import { LinkedText } from "./LinkedText"; -import { fn } from "storybook/test"; export default { title: "Utils/LinkedText", diff --git a/packages/shared-components/src/utils/linkify.test.ts b/packages/shared-components/src/utils/linkify.test.ts index 0bcc753a7d5..a073d8d0089 100644 --- a/packages/shared-components/src/utils/linkify.test.ts +++ b/packages/shared-components/src/utils/linkify.test.ts @@ -7,7 +7,8 @@ Please see LICENSE files in the repository root for full details. */ import { describe, it, expect } from "vitest"; -import { linkifyjs, LinkifyMatrixOpaqueIdType } from "@element-hq/web-shared-components"; + +import { linkifyjs, LinkifyMatrixOpaqueIdType } from "./linkify"; describe("linkify-matrix", () => { const linkTypesByInitialCharacter: Record = { From 0ef3801cc0c626164a97a50f323f37c4d709b67d Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 5 Mar 2026 16:03:35 +0000 Subject: [PATCH 11/48] update lock --- pnpm-lock.yaml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d8750e2369..a47db6304e1 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 From e8bd436dc0f47ec78ccb3c1e1138f76a227e0ea9 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 5 Mar 2026 16:04:27 +0000 Subject: [PATCH 12/48] Update snaps --- .../LinkedText.stories.tsx/unclickable-auto.png | Bin 0 -> 7215 bytes .../with-custom-href-auto.png | Bin 0 -> 7267 bytes .../with-custom-url-target-auto.png | Bin 0 -> 7267 bytes .../with-room-alias-auto.png | Bin 0 -> 7125 bytes .../LinkedText.stories.tsx/with-user-id-auto.png | Bin 0 -> 7669 bytes 5 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 packages/shared-components/__vis__/linux/__baselines__/utils/LinkedText/LinkedText.stories.tsx/unclickable-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/utils/LinkedText/LinkedText.stories.tsx/with-custom-href-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/utils/LinkedText/LinkedText.stories.tsx/with-custom-url-target-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 diff --git a/packages/shared-components/__vis__/linux/__baselines__/utils/LinkedText/LinkedText.stories.tsx/unclickable-auto.png b/packages/shared-components/__vis__/linux/__baselines__/utils/LinkedText/LinkedText.stories.tsx/unclickable-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..526b00a45f3c238ef015cd07051d35ce8de7199e GIT binary patch literal 7215 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 literal 0 HcmV?d00001 diff --git a/packages/shared-components/__vis__/linux/__baselines__/utils/LinkedText/LinkedText.stories.tsx/with-custom-href-auto.png b/packages/shared-components/__vis__/linux/__baselines__/utils/LinkedText/LinkedText.stories.tsx/with-custom-href-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..e62ad7d61a287ef65e043f68df6ffa77945cfd34 GIT binary patch literal 7267 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 Ud%CURO9pV#FYtKH(F?c!7ib1ohX4Qo 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 From 6488fdb84f631b7726031366e85c29766635a5ac Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 5 Mar 2026 16:07:08 +0000 Subject: [PATCH 13/48] update snap --- .../utils/LinkedText/__snapshots__/LinkedText.test.tsx.snap | 6 ++++++ 1 file changed, 6 insertions(+) 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 index f77e2b0f32f..486214b441a 100644 --- a/packages/shared-components/src/utils/LinkedText/__snapshots__/LinkedText.test.tsx.snap +++ b/packages/shared-components/src/utils/LinkedText/__snapshots__/LinkedText.test.tsx.snap @@ -7,6 +7,7 @@ exports[`LinkedText > renders a custom href 1`] = ` > I love working on renders a custom target 1`] = ` > I love working on renders a room alias 1`] = ` > I love talking in renders a standard link 1`] = ` > I love working on renders a user ID 1`] = ` > I love talking to renders an unclickable link 1`] = ` > I love working on From c0f3e37c33d00cd0b720d4d2b064aafa3c5de36f Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 5 Mar 2026 16:07:42 +0000 Subject: [PATCH 14/48] undo change --- .../with-avatar-image-auto.png | Bin 24247 -> 7965 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/shared-components/__vis__/linux/__baselines__/composer/Banner/Banner.stories.tsx/with-avatar-image-auto.png b/packages/shared-components/__vis__/linux/__baselines__/composer/Banner/Banner.stories.tsx/with-avatar-image-auto.png index 512b2264ed5f27dfcca3e3bfe0014984882551eb..618889203402841e88d0473c76db18c2b5cf3489 100644 GIT binary patch literal 7965 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 literal 24247 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

ZG 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 From c9cf1449904c187745531f82157efb7edcc7dc08 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 5 Mar 2026 16:11:45 +0000 Subject: [PATCH 15/48] remove unused --- apps/web/test/unit-tests/linkify-matrix-test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/test/unit-tests/linkify-matrix-test.ts b/apps/web/test/unit-tests/linkify-matrix-test.ts index 8e1fd607544..cf831e26b3d 100644 --- a/apps/web/test/unit-tests/linkify-matrix-test.ts +++ b/apps/web/test/unit-tests/linkify-matrix-test.ts @@ -5,7 +5,6 @@ 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 { linkifyjs } from "@element-hq/web-shared-components"; import { roomAliasEventListeners, userIdEventListeners } from "../../src/Linkify"; import dispatcher from "../../src/dispatcher/dispatcher"; import { Action } from "../../src/dispatcher/actions"; From c52d59d5072891b7e7d29e5c0260a95077f626bb Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 5 Mar 2026 17:34:46 +0000 Subject: [PATCH 16/48] More test updates --- apps/web/src/HtmlUtils.tsx | 13 +++++----- apps/web/src/Linkify.tsx | 2 -- apps/web/test/unit-tests/HtmlUtils-test.tsx | 4 +-- .../views/messages/TextualBody-test.tsx | 16 +++--------- .../__snapshots__/TextualBody-test.tsx.snap | 2 +- .../RoomSummaryCardView-test.tsx.snap | 24 +++++++++++------ .../__snapshots__/NewRoomIntro-test.tsx.snap | 26 +++++++++++-------- .../shared-components/src/utils/linkify.ts | 4 +-- 8 files changed, 45 insertions(+), 46 deletions(-) diff --git a/apps/web/src/HtmlUtils.tsx b/apps/web/src/HtmlUtils.tsx index d76b580173c..794350d7657 100644 --- a/apps/web/src/HtmlUtils.tsx +++ b/apps/web/src/HtmlUtils.tsx @@ -323,13 +323,11 @@ 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 }; + attribs["a"] = [...sanitizeParams.allowedAttributes["a"], "data-linkified"]; + sanitizeParams.allowedAttributes = attribs; + } // else: No attibutes are are allowed for "a" } try { @@ -393,6 +391,7 @@ function analyseEvent(content: IContent, highlights?: string[], opts: EventRende safeBody = phtml.body.innerHTML; } } else if (opts.linkify) { + console.log(sanitizeParams); // If we are linkifying plain text, pass the result through sanitizeHtml so that the highlighter configured in sanitizeParams.textFilter gets applied. safeBody = sanitizeHtml(linkifyHtml(escapeHtml(plainBody)), sanitizeParams); } else if (highlighter) { diff --git a/apps/web/src/Linkify.tsx b/apps/web/src/Linkify.tsx index 2e38c26a330..f38278288f7 100644 --- a/apps/web/src/Linkify.tsx +++ b/apps/web/src/Linkify.tsx @@ -300,7 +300,6 @@ function urlTargetTransformFunction(href: string | string): string { } export function formatHref(href: string, type: LinkifyMatrixOpaqueIdType): string { - console.log("formatHref", href, type); switch (type) { case LinkifyMatrixOpaqueIdType.URL: if (href.startsWith("mxc://") && MatrixClientPeg.get()) { @@ -318,7 +317,6 @@ export function formatHref(href: string, type: LinkifyMatrixOpaqueIdType): strin case LinkifyMatrixOpaqueIdType.RoomAlias: case LinkifyMatrixOpaqueIdType.UserId: default: { - console.log("formatHref", { href, type }); return tryTransformEntityToPermalink(MatrixClientPeg.safeGet(), href) ?? ""; } } diff --git a/apps/web/test/unit-tests/HtmlUtils-test.tsx b/apps/web/test/unit-tests/HtmlUtils-test.tsx index 690f2a713ab..183395b98bd 100644 --- a/apps/web/test/unit-tests/HtmlUtils-test.tsx +++ b/apps/web/test/unit-tests/HtmlUtils-test.tsx @@ -100,9 +100,7 @@ describe("bodyToHtml", () => { }, ); - expect(html).toMatchInlineSnapshot( - `"foo http://link.example/test/path bar"`, - ); + expect(html).toMatchInlineSnapshot(`"foo http://link.example/test/path bar"`); }); it("should hightlight parts of links in HTML message highlighting", () => { 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 3c9daa5d441..ee59aff6692 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 @@ -187,36 +187,28 @@ describe("", () => { const ev = mkRoomTextMessage("Chat with @user:example.com"); const { container } = getComponent({ mxEvent: ev }); const content = container.querySelector(".mx_EventTile_body"); - expect(content.innerHTML).toMatchInlineSnapshot( - `"Chat with @user:example.com"`, - ); + expect(content.innerHTML).toMatchInlineSnapshot(`"Chat with @user:example.com"`); }); it("should pillify an MXID permalink", () => { const ev = mkRoomTextMessage("Chat with https://matrix.to/#/@user:example.com"); const { container } = getComponent({ mxEvent: ev }); const content = container.querySelector(".mx_EventTile_body"); - expect(content.innerHTML).toMatchInlineSnapshot( - `"Chat with Member"`, - ); + expect(content.innerHTML).toMatchInlineSnapshot(`"Chat with Member"`); }); it("should not pillify room aliases", () => { const ev = mkRoomTextMessage("Visit #room:example.com"); const { container } = getComponent({ mxEvent: ev }); const content = container.querySelector(".mx_EventTile_body"); - expect(content.innerHTML).toMatchInlineSnapshot( - `"Visit #room:example.com"`, - ); + expect(content.innerHTML).toMatchInlineSnapshot(`"Visit #room:example.com"`); }); it("should pillify a room alias permalink", () => { const ev = mkRoomTextMessage("Visit https://matrix.to/#/#room:example.com"); const { container } = getComponent({ mxEvent: ev }); const content = container.querySelector(".mx_EventTile_body"); - expect(content.innerHTML).toMatchInlineSnapshot( - `"Visit #room:example.com"`, - ); + expect(content.innerHTML).toMatchInlineSnapshot(`"Visit #room:example.com"`); }); it("should pillify a permalink to a message in the same room with the label »Message from Member«", () => { 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 40d88d54bd1..3e071d8cbf7 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 has button to edit topic 1`] = `

- - This is the room's topic. - + + This is the room's topic. + +