mirror of
https://github.com/vector-im/element-web.git
synced 2026-03-25 23:32:38 +01:00
Port over linkifyJS to shared-components. (#32731)
* Port over linkifyJS to shared-components. * Drop rubbish * update lock * quickfix test * drop group id * Modernize tests * Remove stories that aren't in use. * Complete working version * Add copyright * tidy up * update lock * Update snaps * update snap * undo change * remove unused * More test updates * fix typo * fix margin on preview * move margin block * snapupdate * prettier * cleanup a test mistake * Fixup sonar issues * Don't expose linkifyjs to applications, just provide helper functions. * Add story for documentation. * remove $ * Use a const * typo * cleanup var name * remove console line * Changes checkpoint * Convert to context * Revert unrelated change. * more cleanup * Add a test to cover ignoring incoming data elements * Make tests happy * Update tests for LinkedText * Underlines! * fix lock * remove unused linkify packages * import move * Remove mod to remove underline * undo * fix snap * another snapshot fix * Tidy up based on review. * fix story * Pass in args
This commit is contained in:
parent
d38eb4fdb4
commit
c02db4ebb8
@ -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",
|
||||
|
||||
@ -38,8 +38,7 @@ test.describe("Message links", () => {
|
||||
const linkElement = page.locator(".mx_EventTile_last").getByRole("link", { name: "#aroom:example.org" });
|
||||
await expect(linkElement).toHaveAttribute("href", "https://matrix.to/#/#aroom:example.org");
|
||||
});
|
||||
test("should linkify text inside a URL preview", { tag: "@screenshot" }, async ({ page, user, app, room, axe }) => {
|
||||
axe.disableRules("color-contrast");
|
||||
test("should linkify text inside a URL preview", async ({ page, user, app, room }) => {
|
||||
await page.route(/.*\/_matrix\/(client\/v1\/media|media\/v3)\/preview_url.*/, (route, request) => {
|
||||
const requestedPage = new URL(request.url()).searchParams.get("url");
|
||||
expect(requestedPage).toEqual("https://example.org/");
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/* sidebar blurred avatar background */
|
||||
//
|
||||
|
||||
/* if backdrop-filter is supported, */
|
||||
/* set the user avatar (if any) as a background so */
|
||||
/* it can be blurred by the tag panel and room list */
|
||||
|
||||
@ -17,14 +17,14 @@ import { decode } from "html-entities";
|
||||
import { type IContent } from "matrix-js-sdk/src/matrix";
|
||||
import escapeHtml from "escape-html";
|
||||
import { getEmojiFromUnicode } from "@matrix-org/emojibase-bindings";
|
||||
import { PERMITTED_URL_SCHEMES, LINKIFIED_DATA_ATTRIBUTE } from "@element-hq/web-shared-components";
|
||||
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { stripHTMLReply, stripPlainReply } from "./utils/Reply";
|
||||
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
|
||||
import { linkifyHtml, sanitizeHtmlParams, transformTags } from "./Linkify";
|
||||
import { sanitizeHtmlParams, transformTags, linkifyHtml } from "./Linkify";
|
||||
import { graphemeSegmenter } from "./utils/strings";
|
||||
|
||||
export { Linkify, linkifyAndSanitizeHtml } from "./Linkify";
|
||||
export { linkifyAndSanitizeHtml } from "./Linkify";
|
||||
|
||||
// Anything outside the basic multilingual plane will be a surrogate pair
|
||||
const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/;
|
||||
@ -323,13 +323,12 @@ function analyseEvent(content: IContent, highlights?: string[], opts: EventRende
|
||||
if (opts.linkify) {
|
||||
// Prevent mutating the source of sanitizeParams.
|
||||
sanitizeParams = { ...sanitizeParams };
|
||||
sanitizeParams.allowedClasses ??= {};
|
||||
if (typeof sanitizeParams.allowedClasses.a === "boolean") {
|
||||
// All classes are already allowed for "a"
|
||||
} else {
|
||||
sanitizeParams.allowedClasses.a ??= [];
|
||||
sanitizeParams.allowedClasses.a.push("linkified");
|
||||
}
|
||||
if (typeof sanitizeParams.allowedAttributes === "object") {
|
||||
const attribs = { ...sanitizeParams.allowedAttributes };
|
||||
// We allow data-linkified because TextualBody uses it to passthrough links.
|
||||
attribs["a"] = [...sanitizeParams.allowedAttributes["a"], `data-${LINKIFIED_DATA_ATTRIBUTE}`];
|
||||
sanitizeParams.allowedAttributes = attribs;
|
||||
} // else: No attibutes are are allowed for "a"
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2024, 2025 New Vector Ltd.
|
||||
Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
|
||||
@ -6,15 +7,29 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type ReactElement } from "react";
|
||||
import sanitizeHtml, { type IOptions } from "sanitize-html";
|
||||
import { merge } from "lodash";
|
||||
import _Linkify from "linkify-react";
|
||||
import {
|
||||
PERMITTED_URL_SCHEMES,
|
||||
linkifyString as _linkifyString,
|
||||
linkifyHtml as _linkifyHtml,
|
||||
LinkifyMatrixOpaqueIdType,
|
||||
generateLinkedTextOptions,
|
||||
type LinkEventListener,
|
||||
} from "@element-hq/web-shared-components";
|
||||
import { getHttpUriForMxc, User } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _linkifyString, _linkifyHtml, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix";
|
||||
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
|
||||
import { ELEMENT_URL_PATTERN } from "./linkify-matrix";
|
||||
import { mediaFromMxc } from "./customisations/Media";
|
||||
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
|
||||
import {
|
||||
parsePermalink,
|
||||
tryTransformEntityToPermalink,
|
||||
tryTransformPermalinkToLocalHref,
|
||||
} from "./utils/permalinks/Permalinks";
|
||||
import dis from "./dispatcher/dispatcher";
|
||||
import { Action } from "./dispatcher/actions";
|
||||
import { type ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload";
|
||||
import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
|
||||
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
|
||||
const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/;
|
||||
@ -29,7 +44,7 @@ export const transformTags: NonNullable<IOptions["transformTags"]> = {
|
||||
const transformed = tryTransformPermalinkToLocalHref(attribs.href); // only used to check if it is a link that can be handled locally
|
||||
if (
|
||||
transformed !== attribs.href || // it could be converted so handle locally symbols e.g. @user:server.tdl, matrix: and matrix.to
|
||||
attribs.href.match(ELEMENT_URL_PATTERN) // for https links to Element domains
|
||||
ELEMENT_URL_PATTERN.test(attribs.href) // for https links to Element domains
|
||||
) {
|
||||
delete attribs.target;
|
||||
}
|
||||
@ -193,43 +208,199 @@ export const sanitizeHtmlParams: IOptions = {
|
||||
nestingLimit: 50,
|
||||
};
|
||||
|
||||
/* Wrapper around linkify-react merging in our default linkify options */
|
||||
export function Linkify({ as, options, children }: React.ComponentProps<typeof _Linkify>): ReactElement {
|
||||
return (
|
||||
<_Linkify as={as} options={merge({}, linkifyMatrixOptions, options)}>
|
||||
{children}
|
||||
</_Linkify>
|
||||
);
|
||||
/**
|
||||
* Handler function when a UserID link is clicked.
|
||||
* @param event The click event
|
||||
* @param userId The linked UserID
|
||||
*/
|
||||
function onUserClick(event: MouseEvent, userId: string): void {
|
||||
event.preventDefault();
|
||||
dis.dispatch<ViewUserPayload>({
|
||||
action: Action.ViewUser,
|
||||
member: new User(userId),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler function when a Room Alias link is clicked.
|
||||
* @param event The click event
|
||||
* @param roomAlias The linked room alias
|
||||
*/
|
||||
function onAliasClick(event: MouseEvent, roomAlias: string): void {
|
||||
event.preventDefault();
|
||||
dis.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_alias: roomAlias,
|
||||
metricsTrigger: "Timeline",
|
||||
metricsViaKeyboard: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a set of event handlers for a regular URL link.
|
||||
*
|
||||
* @param href The link location.
|
||||
* @returns Event listenenrs compatible with linkifyjs.
|
||||
*/
|
||||
function urlEventListeners(href: string): LinkEventListener {
|
||||
// intercept local permalinks to users and show them like userids (in userinfo of current room)
|
||||
try {
|
||||
const permalink = parsePermalink(href);
|
||||
if (permalink?.userId) {
|
||||
return {
|
||||
click: function (e: MouseEvent) {
|
||||
onUserClick(e, permalink.userId!);
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// for events, rooms etc. (anything other than users)
|
||||
const localHref = tryTransformPermalinkToLocalHref(href);
|
||||
if (localHref !== href) {
|
||||
// it could be converted to a localHref -> therefore handle locally
|
||||
return {
|
||||
click: function (e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
globalThis.location.hash = localHref;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// OK fine, it's not actually a permalink
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a set of event handlers for a UserID link.
|
||||
*
|
||||
* @param href A link that contains a userId.
|
||||
* @returns Event listenenrs compatible with linkifyjs.
|
||||
*/
|
||||
export function userIdEventListeners(href: string): LinkEventListener {
|
||||
return {
|
||||
click: function (e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
const userId = parsePermalink(href)?.userId ?? href;
|
||||
if (userId) onUserClick(e, userId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a set of event handlers for a UserID link.
|
||||
*
|
||||
* @param href A link that contains a room alias.
|
||||
* @returns Event listenenrs compatible with linkifyjs.
|
||||
*/
|
||||
export function roomAliasEventListeners(href: string): LinkEventListener {
|
||||
return {
|
||||
click: function (e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
const alias = parsePermalink(href)?.roomIdOrAlias ?? href;
|
||||
if (alias) onAliasClick(e, alias);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a `target` attribute for the anchor element
|
||||
* for the given `href` value.
|
||||
*
|
||||
* @param href A URL from a link.
|
||||
* @returns The resulting `target` value.
|
||||
*/
|
||||
function urlTargetTransformFunction(href: string): string {
|
||||
try {
|
||||
const transformed = tryTransformPermalinkToLocalHref(href);
|
||||
if (
|
||||
transformed !== href || // if it could be converted to handle locally for matrix symbols e.g. @user:server.tdl and matrix.to
|
||||
ELEMENT_URL_PATTERN.test(decodeURIComponent(href)) // for https links to Element domains
|
||||
) {
|
||||
return "";
|
||||
} else {
|
||||
return "_blank";
|
||||
}
|
||||
} catch {
|
||||
// malformed URI
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the result `href` value based on an incoming `href` value and a link type.
|
||||
*
|
||||
* @param href A URL from a link.
|
||||
* @param type The type of link beinh handled.
|
||||
* @returns The resulting `href` value.
|
||||
*/
|
||||
export function formatHref(href: string, type: LinkifyMatrixOpaqueIdType): string {
|
||||
switch (type) {
|
||||
case LinkifyMatrixOpaqueIdType.URL:
|
||||
if (href.startsWith("mxc://") && MatrixClientPeg.get()) {
|
||||
return getHttpUriForMxc(
|
||||
MatrixClientPeg.get()!.baseUrl,
|
||||
href,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
// fallthrough
|
||||
case LinkifyMatrixOpaqueIdType.RoomAlias:
|
||||
case LinkifyMatrixOpaqueIdType.UserId:
|
||||
default: {
|
||||
return tryTransformEntityToPermalink(MatrixClientPeg.safeGet(), href) ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard configuration for a LinkedTextContext.Provider
|
||||
* within Element Web.
|
||||
*/
|
||||
export const LinkedTextConfiguration = {
|
||||
userIdListener: userIdEventListeners,
|
||||
roomAliasListener: roomAliasEventListeners,
|
||||
urlListener: urlEventListeners,
|
||||
hrefTransformer: formatHref,
|
||||
urlTargetTransformer: urlTargetTransformFunction,
|
||||
};
|
||||
|
||||
/**
|
||||
* Linkifies the given string. This is a wrapper around 'linkifyjs/string'.
|
||||
*
|
||||
* @param {string} str string to linkify
|
||||
* @param {object} [options] Options for linkifyString. Default: linkifyMatrixOptions
|
||||
* @returns {string} Linkified string
|
||||
* @param str string to linkify
|
||||
* @param [options] Options for linkifyString.
|
||||
* @returns Linkified string
|
||||
*/
|
||||
export function linkifyString(str: string, options = linkifyMatrixOptions): string {
|
||||
return _linkifyString(str, options);
|
||||
export function linkifyString(value: string, options = generateLinkedTextOptions(LinkedTextConfiguration)): string {
|
||||
return _linkifyString(value, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Linkifies the given HTML-formatted string. This is a wrapper around 'linkifyjs/html'.
|
||||
*
|
||||
* @param {string} str HTML string to linkify
|
||||
* @param {object} [options] Options for linkifyHtml. Default: linkifyMatrixOptions
|
||||
* @returns {string} Linkified string
|
||||
* @param str HTML string to linkify
|
||||
* @param [options] Options for linkifyHtml.
|
||||
* @returns Linkified string
|
||||
*/
|
||||
export function linkifyHtml(str: string, options = linkifyMatrixOptions): string {
|
||||
return _linkifyHtml(str, options);
|
||||
export function linkifyHtml(value: string, options = generateLinkedTextOptions(LinkedTextConfiguration)): string {
|
||||
return _linkifyHtml(value, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Linkify the given string and sanitize the HTML afterwards.
|
||||
*
|
||||
* @param {string} dirtyHtml The HTML string to sanitize and linkify
|
||||
* @param {object} [options] Options for linkifyString. Default: linkifyMatrixOptions
|
||||
* @returns {string}
|
||||
* @param dirtyString The string to linkify, and then sanitize.
|
||||
* @param [options] Options for linkifyString. Default: linkifyMatrixOptions
|
||||
* @returns HTML string
|
||||
*/
|
||||
export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrixOptions): string {
|
||||
export function linkifyAndSanitizeHtml(
|
||||
dirtyHtml: string,
|
||||
options = generateLinkedTextOptions(LinkedTextConfiguration),
|
||||
): string {
|
||||
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
|
||||
}
|
||||
@ -11,8 +11,7 @@ import "./@types/commonmark"; // import better types than @types/commonmark
|
||||
import * as commonmark from "commonmark";
|
||||
import { escape } from "lodash";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { linkify } from "./linkify-matrix";
|
||||
import { findLinksInString } from "@element-hq/web-shared-components";
|
||||
|
||||
const ALLOWED_HTML_TAGS = ["sub", "sup", "del", "s", "u", "br", "br/"];
|
||||
|
||||
@ -186,7 +185,7 @@ export default class Markdown {
|
||||
// We should not do this if previous node was not a textnode, as we can't combine it then.
|
||||
if ((node.type === "emph" || node.type === "strong") && previousNode?.type === "text") {
|
||||
if (event.entering) {
|
||||
const foundLinks = linkify.find(text);
|
||||
const foundLinks = findLinksInString(text);
|
||||
for (const { value } of foundLinks) {
|
||||
if (node?.firstChild?.literal) {
|
||||
/**
|
||||
@ -197,7 +196,7 @@ export default class Markdown {
|
||||
const nonEmphasizedText = `${format}${innerNodeLiteral(node)}${format}`;
|
||||
const f = getTextUntilEndOrLinebreak(node);
|
||||
const newText = value + nonEmphasizedText + f;
|
||||
const newLinks = linkify.find(newText);
|
||||
const newLinks = findLinksInString(newText);
|
||||
// Should always find only one link here, if it finds more it means that the algorithm is broken
|
||||
if (newLinks.length === 1) {
|
||||
const emphasisTextNode = new commonmark.Node("text");
|
||||
|
||||
@ -28,7 +28,7 @@ import { TooltipProvider } from "@vector-im/compound-web";
|
||||
// what-input helps improve keyboard accessibility
|
||||
import "what-input";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import { I18nContext } from "@element-hq/web-shared-components";
|
||||
import { I18nContext, LinkedTextContext, LinkedText } from "@element-hq/web-shared-components";
|
||||
import { LockSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import PosthogTrackers from "../../PosthogTrackers";
|
||||
@ -125,7 +125,7 @@ import { viewUserDeviceSettings } from "../../actions/handlers/viewUserDeviceSet
|
||||
import GenericToast from "../views/toasts/GenericToast";
|
||||
import RovingSpotlightDialog from "../views/dialogs/spotlight/SpotlightDialog";
|
||||
import { findDMForUser } from "../../utils/dm/findDMForUser";
|
||||
import { getHtmlText, Linkify } from "../../HtmlUtils";
|
||||
import { getHtmlText } from "../../HtmlUtils";
|
||||
import { NotificationLevel } from "../../stores/notifications/NotificationLevel";
|
||||
import { type UserTab } from "../views/dialogs/UserTab";
|
||||
import { shouldSkipSetupEncryption } from "../../utils/crypto/shouldSkipSetupEncryption";
|
||||
@ -139,7 +139,7 @@ import { setTheme } from "../../theme";
|
||||
import { type OpenForwardDialogPayload } from "../../dispatcher/payloads/OpenForwardDialogPayload";
|
||||
import { ShareFormat, type SharePayload } from "../../dispatcher/payloads/SharePayload";
|
||||
import Markdown from "../../Markdown";
|
||||
import { sanitizeHtmlParams } from "../../Linkify";
|
||||
import { LinkedTextConfiguration, sanitizeHtmlParams } from "../../Linkify";
|
||||
import { isOnlyAdmin } from "../../utils/membership";
|
||||
import { ModuleApi } from "../../modules/Api.ts";
|
||||
|
||||
@ -1458,7 +1458,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
key,
|
||||
title: userNotice.title,
|
||||
props: {
|
||||
description: <Linkify>{userNotice.description}</Linkify>,
|
||||
description: <LinkedText>{userNotice.description}</LinkedText>,
|
||||
primaryLabel: _t("action|ok"),
|
||||
onPrimaryClick: () => {
|
||||
ToastStore.sharedInstance().dismissToast(key);
|
||||
@ -2291,7 +2291,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
<ErrorBoundary>
|
||||
<I18nContext.Provider value={ModuleApi.instance.i18n}>
|
||||
<SDKContext.Provider value={this.stores}>
|
||||
<TooltipProvider>{view}</TooltipProvider>
|
||||
<LinkedTextContext.Provider value={LinkedTextConfiguration}>
|
||||
<TooltipProvider>{view}</TooltipProvider>
|
||||
</LinkedTextContext.Provider>
|
||||
</SDKContext.Provider>
|
||||
</I18nContext.Provider>
|
||||
</ErrorBoundary>
|
||||
|
||||
@ -41,6 +41,7 @@ import { sortBy, uniqBy } from "lodash";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { KnownMembership, type SpaceChildEventContent } from "matrix-js-sdk/src/types";
|
||||
import { ChevronDownIcon, CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { LinkedText } from "@element-hq/web-shared-components";
|
||||
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
@ -55,7 +56,7 @@ import InfoTooltip from "../views/elements/InfoTooltip";
|
||||
import TextWithTooltip from "../views/elements/TextWithTooltip";
|
||||
import { useStateToggle } from "../../hooks/useStateToggle";
|
||||
import { getChildOrder } from "../../stores/spaces/SpaceStore";
|
||||
import { Linkify, topicToHtml } from "../../HtmlUtils";
|
||||
import { topicToHtml } from "../../HtmlUtils";
|
||||
import { useDispatcher } from "../../hooks/useDispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { type IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
|
||||
@ -233,20 +234,12 @@ const Tile: React.FC<ITileProps> = ({
|
||||
|
||||
let topicSection: ReactNode | undefined;
|
||||
if (topic) {
|
||||
// prevent clicks on links from bubbling up to the room tile
|
||||
topicSection = (
|
||||
<Linkify
|
||||
options={{
|
||||
attributes: {
|
||||
onClick(ev: MouseEvent) {
|
||||
// prevent clicks on links from bubbling up to the room tile
|
||||
ev.stopPropagation();
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<LinkedText onLinkClick={(ev) => ev.stopPropagation()}>
|
||||
{" · "}
|
||||
{topic}
|
||||
</Linkify>
|
||||
</LinkedText>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
import React, { type JSX } from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { type AuthType } from "matrix-js-sdk/src/interactive-auth";
|
||||
import { LinkedText } from "@element-hq/web-shared-components";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
@ -21,7 +22,6 @@ import InteractiveAuth, {
|
||||
} from "../../structures/InteractiveAuth";
|
||||
import { type ContinueKind, SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import { Linkify } from "../../../Linkify";
|
||||
|
||||
type DialogAesthetics = Partial<{
|
||||
[x in AuthType]: {
|
||||
@ -163,9 +163,9 @@ export default class InteractiveAuthDialog<T> extends React.Component<Interactiv
|
||||
if (this.state.authError) {
|
||||
content = (
|
||||
<div id="mx_Dialog_content">
|
||||
<Linkify>
|
||||
<div role="alert">{this.state.authError.message || this.state.authError.toString()}</div>
|
||||
</Linkify>
|
||||
<LinkedText role="alert">
|
||||
{this.state.authError.message || this.state.authError.toString()}
|
||||
</LinkedText>
|
||||
<br />
|
||||
<AccessibleButton onClick={this.onDismissClick} className="mx_GeneralButton" autoFocus={true}>
|
||||
{_t("action|dismiss")}
|
||||
|
||||
@ -10,6 +10,7 @@ import React, { type JSX, useCallback, useContext, useState } from "react";
|
||||
import { type Room, EventType } from "matrix-js-sdk/src/matrix";
|
||||
import classNames from "classnames";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import { LinkedText } from "@element-hq/web-shared-components";
|
||||
|
||||
import { useTopic } from "../../../hooks/room/useTopic";
|
||||
import { _t } from "../../../languageHandler";
|
||||
@ -20,7 +21,7 @@ import InfoDialog from "../dialogs/InfoDialog";
|
||||
import { useDispatcher } from "../../../hooks/useDispatcher";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import { Linkify, topicToHtml } from "../../../HtmlUtils";
|
||||
import { topicToHtml } from "../../../HtmlUtils";
|
||||
import { tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
|
||||
|
||||
interface IProps extends React.HTMLProps<HTMLDivElement> {
|
||||
@ -74,19 +75,7 @@ export default function RoomTopic({ room, className, ...props }: IProps): JSX.El
|
||||
title: room.name,
|
||||
description: (
|
||||
<div>
|
||||
<Linkify
|
||||
options={{
|
||||
attributes: {
|
||||
onClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||
onClick(e);
|
||||
modal.close();
|
||||
},
|
||||
},
|
||||
}}
|
||||
as="p"
|
||||
>
|
||||
{body}
|
||||
</Linkify>
|
||||
<LinkedText onLinkClick={() => modal.close()}>{body}</LinkedText>
|
||||
{canSetTopic && (
|
||||
<AccessibleButton
|
||||
kind="primary_outline"
|
||||
@ -122,7 +111,7 @@ export default function RoomTopic({ room, className, ...props }: IProps): JSX.El
|
||||
onFocus={onHover}
|
||||
aria-label={_t("room|read_topic")}
|
||||
>
|
||||
<Linkify>{body}</Linkify>
|
||||
<LinkedText>{body}</LinkedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import React, { type JSX, createRef, type SyntheticEvent, type MouseEvent } from "react";
|
||||
import { MsgType } from "matrix-js-sdk/src/matrix";
|
||||
import { EventContentBodyView } from "@element-hq/web-shared-components";
|
||||
import { EventContentBodyView, LINKIFIED_DATA_ATTRIBUTE } from "@element-hq/web-shared-components";
|
||||
|
||||
import { EventContentBodyViewModel } from "../../../viewmodels/message-body/EventContentBodyViewModel";
|
||||
import { formatDate } from "../../../DateUtils";
|
||||
@ -26,7 +26,6 @@ import LinkPreviewGroup from "../rooms/LinkPreviewGroup";
|
||||
import { type IBodyProps } from "./IBodyProps";
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { options as linkifyOpts } from "../../../linkify-matrix";
|
||||
import { getParentEventId } from "../../../utils/Reply";
|
||||
import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
|
||||
import { type IEventTileOps } from "../rooms/EventTile";
|
||||
@ -250,7 +249,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
private onBodyLinkClick = (e: MouseEvent): void => {
|
||||
let target: HTMLLinkElement | null = e.target as HTMLLinkElement;
|
||||
// links processed by linkifyjs have their own handler so don't handle those here
|
||||
if (target.classList.contains(linkifyOpts.className as string)) return;
|
||||
if (target.dataset[LINKIFIED_DATA_ATTRIBUTE]) return;
|
||||
if (target.nodeName !== "A") {
|
||||
// Jump to parent as the `<a>` may contain children, e.g. an anchor wrapping an inline code section
|
||||
target = target.closest<HTMLLinkElement>("a");
|
||||
|
||||
@ -39,14 +39,14 @@ import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error"
|
||||
import ErrorSolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
|
||||
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
|
||||
import { JoinRule, type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { Box, Flex, HistoryVisibilityBadge } from "@element-hq/web-shared-components";
|
||||
import { Box, Flex, HistoryVisibilityBadge, LinkedText } from "@element-hq/web-shared-components";
|
||||
|
||||
import BaseCard from "./BaseCard.tsx";
|
||||
import { _t } from "../../../languageHandler.tsx";
|
||||
import RoomAvatar from "../avatars/RoomAvatar.tsx";
|
||||
import { E2EStatus } from "../../../utils/ShieldUtils.ts";
|
||||
import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks.ts";
|
||||
import { Linkify, topicToHtml } from "../../../HtmlUtils.tsx";
|
||||
import { topicToHtml } from "../../../HtmlUtils.tsx";
|
||||
import { useRoomSummaryCardViewModel } from "../../viewmodels/right_panel/RoomSummaryCardViewModel.tsx";
|
||||
import { useRoomTopicViewModel } from "../../viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx";
|
||||
import { useRoomName } from "../../../hooks/useRoomName.ts";
|
||||
@ -89,7 +89,7 @@ const RoomTopic: React.FC<Pick<IProps, "room">> = ({ room }): JSX.Element | null
|
||||
);
|
||||
}
|
||||
|
||||
const content = vm.expanded ? <Linkify>{body}</Linkify> : body;
|
||||
const content = vm.expanded ? <LinkedText as="span">{body}</LinkedText> : body;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
|
||||
@ -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 { isLinkable } 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<IProps, IState>
|
||||
const range = getRangeForSelection(this.editorRef.current, model, document.getSelection()!);
|
||||
|
||||
// If the user is pasting a link, and has a range selected which is not a link, wrap the range with the link
|
||||
if (plainText && range.length > 0 && linkify.test(plainText) && !linkify.test(range.text)) {
|
||||
if (plainText && range.length > 0 && isLinkable(plainText) && !isLinkable(range.text)) {
|
||||
formatRangeAsLink(range, plainText);
|
||||
} else {
|
||||
replaceRangeAndMoveCaret(range, parts);
|
||||
|
||||
@ -9,8 +9,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
import React, { type JSX, type ComponentProps, createRef, type ReactNode } from "react";
|
||||
import { decode } from "html-entities";
|
||||
import { type MatrixEvent, type IPreviewUrlResponse } from "matrix-js-sdk/src/matrix";
|
||||
import { LinkedText } from "@element-hq/web-shared-components";
|
||||
|
||||
import { Linkify } from "../../../HtmlUtils";
|
||||
import Modal from "../../../Modal";
|
||||
import * as ImageUtils from "../../../ImageUtils";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
@ -128,7 +128,7 @@ export default class LinkPreviewWidget extends React.Component<IProps> {
|
||||
)}
|
||||
</div>
|
||||
<div className="mx_LinkPreviewWidget_description">
|
||||
<Linkify>{description}</Linkify>
|
||||
<LinkedText>{description}</LinkedText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -6,11 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, useContext } from "react";
|
||||
import React, { type JSX, useContext, useMemo } from "react";
|
||||
import { EventType, type Room, type User, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { ErrorSolidIcon, UserAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { EventTileBubble } from "@element-hq/web-shared-components";
|
||||
import { EventTileBubble, LinkedText } from "@element-hq/web-shared-components";
|
||||
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
@ -32,7 +32,7 @@ import { LocalRoom } from "../../../models/LocalRoom";
|
||||
import { shouldEncryptRoomWithSingle3rdPartyInvite } from "../../../utils/room/shouldEncryptRoomWithSingle3rdPartyInvite";
|
||||
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
|
||||
import { useTopic } from "../../../hooks/room/useTopic";
|
||||
import { topicToHtml, Linkify } from "../../../HtmlUtils";
|
||||
import { topicToHtml } from "../../../HtmlUtils";
|
||||
|
||||
function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean {
|
||||
const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId);
|
||||
@ -56,14 +56,23 @@ const NewRoomIntro: React.FC = () => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const { room, roomId } = useScopedRoomContext("room", "roomId");
|
||||
const topic = useTopic(room);
|
||||
const isLocalRoom = room instanceof LocalRoom;
|
||||
|
||||
let dmPartner: string | undefined;
|
||||
if (isLocalRoom) {
|
||||
dmPartner = room?.targets[0]?.userId;
|
||||
} else if (roomId) {
|
||||
dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId);
|
||||
}
|
||||
|
||||
const renderedTopic = useMemo(
|
||||
() => (dmPartner ? undefined : <LinkedText as="span">{topicToHtml(topic?.text, topic?.html)}</LinkedText>),
|
||||
[topic, dmPartner],
|
||||
);
|
||||
if (!room || !roomId) {
|
||||
throw new Error("Unable to create a NewRoomIntro without room and roomId");
|
||||
}
|
||||
|
||||
const isLocalRoom = room instanceof LocalRoom;
|
||||
const dmPartner = isLocalRoom ? room.targets[0]?.userId : DMRoomMap.shared().getUserIdForRoomId(roomId);
|
||||
|
||||
let body: JSX.Element;
|
||||
if (dmPartner) {
|
||||
const { shouldEncrypt: encryptedSingle3rdPartyInvite } = shouldEncryptRoomWithSingle3rdPartyInvite(room);
|
||||
@ -137,15 +146,11 @@ const NewRoomIntro: React.FC = () => {
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
topic: () => <Linkify>{topicToHtml(topic?.text, topic?.html)}</Linkify>,
|
||||
topic: renderedTopic,
|
||||
},
|
||||
);
|
||||
} else if (topic) {
|
||||
topicText = _t(
|
||||
"room|intro|display_topic",
|
||||
{},
|
||||
{ topic: () => <Linkify>{topicToHtml(topic?.text, topic?.html)}</Linkify> },
|
||||
);
|
||||
topicText = _t("room|intro|display_topic", {}, { topic: renderedTopic });
|
||||
} else if (canAddTopic) {
|
||||
topicText = _t(
|
||||
"room|intro|no_topic",
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
@ -7,291 +8,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import * as linkifyjs from "linkifyjs";
|
||||
import { type EventListeners, type Opts, registerCustomProtocol, registerPlugin } from "linkifyjs";
|
||||
import linkifyString from "linkify-string";
|
||||
import linkifyHtml from "linkify-html";
|
||||
import { getHttpUriForMxc, User } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import {
|
||||
parsePermalink,
|
||||
tryTransformEntityToPermalink,
|
||||
tryTransformPermalinkToLocalHref,
|
||||
} from "./utils/permalinks/Permalinks";
|
||||
import dis from "./dispatcher/dispatcher";
|
||||
import { Action } from "./dispatcher/actions";
|
||||
import { type ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload";
|
||||
import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
|
||||
|
||||
export enum Type {
|
||||
URL = "url",
|
||||
UserId = "userid",
|
||||
RoomAlias = "roomalias",
|
||||
}
|
||||
|
||||
function matrixOpaqueIdLinkifyParser({
|
||||
scanner,
|
||||
parser,
|
||||
token,
|
||||
name,
|
||||
}: {
|
||||
scanner: linkifyjs.ScannerInit;
|
||||
parser: linkifyjs.ParserInit;
|
||||
token: "#" | "+" | "@";
|
||||
name: Type;
|
||||
}): void {
|
||||
const {
|
||||
DOT,
|
||||
// IPV4 necessity
|
||||
NUM,
|
||||
COLON,
|
||||
SYM,
|
||||
SLASH,
|
||||
EQUALS,
|
||||
HYPHEN,
|
||||
UNDERSCORE,
|
||||
} = scanner.tokens;
|
||||
|
||||
// Contains NUM, WORD, UWORD, EMOJI, TLD, UTLD, SCHEME, SLASH_SCHEME and LOCALHOST plus custom protocols (e.g. "matrix")
|
||||
const { domain } = scanner.tokens.groups;
|
||||
|
||||
// Tokens we need that are not contained in the domain group
|
||||
const additionalLocalpartTokens = [DOT, SYM, SLASH, EQUALS, UNDERSCORE, HYPHEN];
|
||||
const additionalDomainpartTokens = [HYPHEN];
|
||||
|
||||
const matrixToken = linkifyjs.createTokenClass(name, { isLink: true });
|
||||
const matrixTokenState = new linkifyjs.State(matrixToken) as any as linkifyjs.State<linkifyjs.MultiToken>; // linkify doesn't appear to type this correctly
|
||||
|
||||
const matrixTokenWithPort = linkifyjs.createTokenClass(name, { isLink: true });
|
||||
const matrixTokenWithPortState = new linkifyjs.State(
|
||||
matrixTokenWithPort,
|
||||
) as any as linkifyjs.State<linkifyjs.MultiToken>; // linkify doesn't appear to type this correctly
|
||||
|
||||
const INITIAL_STATE = parser.start.tt(token);
|
||||
|
||||
// Localpart
|
||||
const LOCALPART_STATE = new linkifyjs.State<linkifyjs.MultiToken>();
|
||||
INITIAL_STATE.ta(domain, LOCALPART_STATE);
|
||||
INITIAL_STATE.ta(additionalLocalpartTokens, LOCALPART_STATE);
|
||||
LOCALPART_STATE.ta(domain, LOCALPART_STATE);
|
||||
LOCALPART_STATE.ta(additionalLocalpartTokens, LOCALPART_STATE);
|
||||
|
||||
// Domainpart
|
||||
const DOMAINPART_STATE_DOT = LOCALPART_STATE.tt(COLON);
|
||||
DOMAINPART_STATE_DOT.ta(domain, matrixTokenState);
|
||||
DOMAINPART_STATE_DOT.ta(additionalDomainpartTokens, matrixTokenState);
|
||||
matrixTokenState.ta(domain, matrixTokenState);
|
||||
matrixTokenState.ta(additionalDomainpartTokens, matrixTokenState);
|
||||
matrixTokenState.tt(DOT, DOMAINPART_STATE_DOT);
|
||||
|
||||
// Port suffixes
|
||||
matrixTokenState.tt(COLON).tt(NUM, matrixTokenWithPortState);
|
||||
}
|
||||
|
||||
function onUserClick(event: MouseEvent, userId: string): void {
|
||||
event.preventDefault();
|
||||
dis.dispatch<ViewUserPayload>({
|
||||
action: Action.ViewUser,
|
||||
member: new User(userId),
|
||||
});
|
||||
}
|
||||
|
||||
function onAliasClick(event: MouseEvent, roomAlias: string): void {
|
||||
event.preventDefault();
|
||||
dis.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_alias: roomAlias,
|
||||
metricsTrigger: "Timeline",
|
||||
metricsViaKeyboard: false,
|
||||
});
|
||||
}
|
||||
|
||||
const escapeRegExp = function (s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
};
|
||||
|
||||
// Recognise URLs from both our local and official Element deployments.
|
||||
// Anyone else really should be using matrix.to. vector:// allowed to support Element Desktop relative links.
|
||||
export const ELEMENT_URL_PATTERN =
|
||||
export const ELEMENT_URL_PATTERN = new RegExp(
|
||||
"^(?:vector://|https?://)?(?:" +
|
||||
escapeRegExp(window.location.host + window.location.pathname) +
|
||||
"|" +
|
||||
"(?:www\\.)?(?:riot|vector)\\.im/(?:app|beta|staging|develop)/|" +
|
||||
"(?:app|beta|staging|develop)\\.element\\.io/" +
|
||||
")(#.*)";
|
||||
|
||||
// Attach click handlers to links based on their type
|
||||
function events(href: string, type: string): EventListeners {
|
||||
switch (type as Type) {
|
||||
case Type.URL: {
|
||||
// intercept local permalinks to users and show them like userids (in userinfo of current room)
|
||||
try {
|
||||
const permalink = parsePermalink(href);
|
||||
if (permalink?.userId) {
|
||||
return {
|
||||
click: function (e: MouseEvent) {
|
||||
onUserClick(e, permalink.userId!);
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// for events, rooms etc. (anything other than users)
|
||||
const localHref = tryTransformPermalinkToLocalHref(href);
|
||||
if (localHref !== href) {
|
||||
// it could be converted to a localHref -> therefore handle locally
|
||||
return {
|
||||
click: function (e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
window.location.hash = localHref;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// OK fine, it's not actually a permalink
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Type.UserId:
|
||||
return {
|
||||
click: function (e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
const userId = parsePermalink(href)?.userId ?? href;
|
||||
if (userId) onUserClick(e, userId);
|
||||
},
|
||||
};
|
||||
case Type.RoomAlias:
|
||||
return {
|
||||
click: function (e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
const alias = parsePermalink(href)?.roomIdOrAlias ?? href;
|
||||
if (alias) onAliasClick(e, alias);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
// linkify-react doesn't respect `events` and needs it mapping to React attributes
|
||||
// so we need to manually add the click handler to the attributes
|
||||
// https://linkify.js.org/docs/linkify-react.html#events
|
||||
function attributes(href: string, type: string): Record<string, unknown> {
|
||||
const attrs: Record<string, unknown> = {
|
||||
rel: "noreferrer noopener",
|
||||
};
|
||||
|
||||
const options = events(href, type);
|
||||
if (options?.click) {
|
||||
attrs.onClick = options.click;
|
||||
}
|
||||
|
||||
return attrs;
|
||||
}
|
||||
|
||||
export const options: Opts = {
|
||||
events,
|
||||
|
||||
formatHref: function (href: string, type: Type | string): string {
|
||||
switch (type) {
|
||||
case "url":
|
||||
if (href.startsWith("mxc://") && MatrixClientPeg.get()) {
|
||||
return getHttpUriForMxc(
|
||||
MatrixClientPeg.get()!.baseUrl,
|
||||
href,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
// fallthrough
|
||||
case Type.RoomAlias:
|
||||
case Type.UserId:
|
||||
default: {
|
||||
return tryTransformEntityToPermalink(MatrixClientPeg.safeGet(), href) ?? "";
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
attributes,
|
||||
|
||||
ignoreTags: ["a", "pre", "code"],
|
||||
|
||||
className: "linkified",
|
||||
|
||||
target: function (href: string, type: Type | string): string {
|
||||
if (type === Type.URL) {
|
||||
try {
|
||||
const transformed = tryTransformPermalinkToLocalHref(href);
|
||||
if (
|
||||
transformed !== href || // if it could be converted to handle locally for matrix symbols e.g. @user:server.tdl and matrix.to
|
||||
decodeURIComponent(href).match(ELEMENT_URL_PATTERN) // for https links to Element domains
|
||||
) {
|
||||
return "";
|
||||
} else {
|
||||
return "_blank";
|
||||
}
|
||||
} catch {
|
||||
// malformed URI
|
||||
}
|
||||
}
|
||||
return "";
|
||||
},
|
||||
};
|
||||
|
||||
// Run the plugins
|
||||
registerPlugin(Type.RoomAlias, ({ scanner, parser }) => {
|
||||
const token = scanner.tokens.POUND as "#";
|
||||
matrixOpaqueIdLinkifyParser({
|
||||
scanner,
|
||||
parser,
|
||||
token,
|
||||
name: Type.RoomAlias,
|
||||
});
|
||||
});
|
||||
|
||||
registerPlugin(Type.UserId, ({ scanner, parser }) => {
|
||||
const token = scanner.tokens.AT as "@";
|
||||
matrixOpaqueIdLinkifyParser({
|
||||
scanner,
|
||||
parser,
|
||||
token,
|
||||
name: Type.UserId,
|
||||
});
|
||||
});
|
||||
|
||||
// Linkify supports some common protocols but not others, register all permitted url schemes if unsupported
|
||||
// https://github.com/Hypercontext/linkifyjs/blob/f4fad9df1870259622992bbfba38bfe3d0515609/packages/linkifyjs/src/scanner.js#L133-L141
|
||||
// This also handles registering the `matrix:` protocol scheme
|
||||
const linkifySupportedProtocols = ["file", "mailto", "http", "https", "ftp", "ftps"];
|
||||
const optionalSlashProtocols = [
|
||||
"bitcoin",
|
||||
"geo",
|
||||
"im",
|
||||
"magnet",
|
||||
"mailto",
|
||||
"matrix",
|
||||
"news",
|
||||
"openpgp4fpr",
|
||||
"sip",
|
||||
"sms",
|
||||
"smsto",
|
||||
"tel",
|
||||
"urn",
|
||||
"xmpp",
|
||||
];
|
||||
|
||||
PERMITTED_URL_SCHEMES.forEach((scheme) => {
|
||||
if (!linkifySupportedProtocols.includes(scheme)) {
|
||||
registerCustomProtocol(scheme, optionalSlashProtocols.includes(scheme));
|
||||
}
|
||||
});
|
||||
|
||||
registerCustomProtocol("mxc", false);
|
||||
|
||||
export const linkify = linkifyjs;
|
||||
export const _linkifyString = linkifyString;
|
||||
export const _linkifyHtml = linkifyHtml;
|
||||
escapeRegExp(window.location.host + window.location.pathname) +
|
||||
"|" +
|
||||
"(?:www\\.)?(?:riot|vector)\\.im/(?:app|beta|staging|develop)/|" +
|
||||
"(?:app|beta|staging|develop)\\.element\\.io/" +
|
||||
")(#.*)",
|
||||
);
|
||||
|
||||
@ -20,12 +20,13 @@ import {
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { KnownMembership, type RoomMemberEventContent } from "matrix-js-sdk/src/types";
|
||||
import { LinkedText } from "@element-hq/web-shared-components";
|
||||
|
||||
import dis from "../dispatcher/dispatcher";
|
||||
import { _t, _td, UserFriendlyError } from "../languageHandler";
|
||||
import Modal from "../Modal";
|
||||
import MultiInviter from "../utils/MultiInviter";
|
||||
import { Linkify, topicToHtml } from "../HtmlUtils";
|
||||
import { topicToHtml } from "../HtmlUtils";
|
||||
import QuestionDialog from "../components/views/dialogs/QuestionDialog";
|
||||
import WidgetUtils from "../utils/WidgetUtils";
|
||||
import { textToHtmlRainbow } from "../utils/colour";
|
||||
@ -270,7 +271,7 @@ export const Commands = [
|
||||
|
||||
Modal.createDialog(InfoDialog, {
|
||||
title: room.name,
|
||||
description: <Linkify>{body}</Linkify>,
|
||||
description: <LinkedText>{body}</LinkedText>,
|
||||
hasCloseButton: true,
|
||||
className: "markdown-body",
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
* Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
* Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
@ -9,8 +10,7 @@
|
||||
|
||||
import { type IContent, type IEventRelation, type MatrixEvent, THREAD_RELATION_TYPE } from "matrix-js-sdk/src/matrix";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
import { PERMITTED_URL_SCHEMES } from "./UrlUtils";
|
||||
import { PERMITTED_URL_SCHEMES } from "@element-hq/web-shared-components";
|
||||
|
||||
export function getParentEventId(ev?: MatrixEvent): string | undefined {
|
||||
if (!ev || ev.isRedacted()) return;
|
||||
|
||||
@ -49,31 +49,3 @@ export function parseUrl(u: string): URL {
|
||||
}
|
||||
return new URL(u);
|
||||
}
|
||||
|
||||
export const PERMITTED_URL_SCHEMES = [
|
||||
"bitcoin",
|
||||
"ftp",
|
||||
"geo",
|
||||
"http",
|
||||
"https",
|
||||
"im",
|
||||
"irc",
|
||||
"ircs",
|
||||
"magnet",
|
||||
"mailto",
|
||||
"matrix",
|
||||
"mms",
|
||||
"news",
|
||||
"nntp",
|
||||
"openpgp4fpr",
|
||||
"sip",
|
||||
"sftp",
|
||||
"sms",
|
||||
"smsto",
|
||||
"ssh",
|
||||
"tel",
|
||||
"urn",
|
||||
"webcal",
|
||||
"wtai",
|
||||
"xmpp",
|
||||
];
|
||||
|
||||
@ -101,7 +101,7 @@ describe("bodyToHtml", () => {
|
||||
);
|
||||
|
||||
expect(html).toMatchInlineSnapshot(
|
||||
`"foo <a href="http://link.example/test/path" class="linkified" target="_blank" rel="noreferrer noopener">http://link.example/<span class="mx_EventTile_searchHighlight">test</span>/path</a> bar"`,
|
||||
`"foo <a href="http://link.example/test/path" target="_blank" rel="noreferrer noopener" data-linkified="true">http://link.example/<span class="mx_EventTile_searchHighlight">test</span>/path</a> bar"`,
|
||||
);
|
||||
});
|
||||
|
||||
@ -124,6 +124,27 @@ describe("bodyToHtml", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should ignore data-linkified in incoming links but should be applied to linkified links", () => {
|
||||
getMockClientWithEventEmitter({});
|
||||
const html = bodyToHtml(
|
||||
{
|
||||
body: "foo http://link.example/test/path bar",
|
||||
msgtype: "m.text",
|
||||
formatted_body:
|
||||
'foo <a data-linkfied="true" href="http://link.example/test/path">http://link.example/test/path</a> bar with https://example.org',
|
||||
format: "org.matrix.custom.html",
|
||||
},
|
||||
[],
|
||||
{
|
||||
linkify: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toMatchInlineSnapshot(
|
||||
`"foo <a href="http://link.example/test/path" target="_blank" rel="noreferrer noopener">http://link.example/test/path</a> bar with <a href="https://example.org" target="_blank" rel="noreferrer noopener" data-linkified="true">https://example.org</a>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not mistake characters in text presentation mode for emoji", () => {
|
||||
const { asFragment } = render(
|
||||
<span className="mx_EventTile_body translate" dir="auto">
|
||||
|
||||
@ -10,6 +10,7 @@ import React from "react";
|
||||
import { Room } from "matrix-js-sdk/src/matrix";
|
||||
import { fireEvent, render, screen, waitFor } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LinkedTextContext } from "@element-hq/web-shared-components";
|
||||
|
||||
import { mkEvent, stubClient } from "../../../../test-utils";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
@ -52,7 +53,9 @@ describe("<RoomTopic/>", () => {
|
||||
*/
|
||||
const renderRoom = (topic: string) => {
|
||||
const room = createRoom(topic);
|
||||
render(<RoomTopic room={room} />);
|
||||
render(<RoomTopic room={room} />, {
|
||||
wrapper: ({ children }) => <LinkedTextContext.Provider value={{}}>{children}</LinkedTextContext.Provider>,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -193,7 +193,7 @@ describe("<TextualBody />", () => {
|
||||
const { container } = getComponent({ mxEvent: ev });
|
||||
const content = container.querySelector(".mx_EventTile_body");
|
||||
expect(content.innerHTML).toMatchInlineSnapshot(
|
||||
`"Chat with <a href="https://matrix.to/#/@user:example.com" class="linkified" rel="noreferrer noopener">@user:example.com</a>"`,
|
||||
`"Chat with <a href="https://matrix.to/#/@user:example.com" rel="noreferrer noopener" data-linkified="true">@user:example.com</a>"`,
|
||||
);
|
||||
});
|
||||
|
||||
@ -211,7 +211,7 @@ describe("<TextualBody />", () => {
|
||||
const { container } = getComponent({ mxEvent: ev });
|
||||
const content = container.querySelector(".mx_EventTile_body");
|
||||
expect(content.innerHTML).toMatchInlineSnapshot(
|
||||
`"Visit <a href="https://matrix.to/#/#room:example.com" class="linkified" rel="noreferrer noopener">#room:example.com</a>"`,
|
||||
`"Visit <a href="https://matrix.to/#/#room:example.com" rel="noreferrer noopener" data-linkified="true">#room:example.com</a>"`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -557,7 +557,7 @@ exports[`<TextualBody /> renders plain-text m.text correctly linkification get a
|
||||
>
|
||||
Visit
|
||||
<a
|
||||
class="linkified"
|
||||
data-linkified="true"
|
||||
href="https://matrix.org/"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
|
||||
@ -11,6 +11,7 @@ import { render, fireEvent, screen } from "jest-matrix-react";
|
||||
import { Room, type MatrixClient, JoinRule, MatrixEvent, HistoryVisibility } from "matrix-js-sdk/src/matrix";
|
||||
import { mocked, type MockedObject } from "jest-mock";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LinkedTextContext } from "@element-hq/web-shared-components";
|
||||
|
||||
import RoomSummaryCardView from "../../../../../src/components/views/right_panel/RoomSummaryCardView";
|
||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||
@ -44,7 +45,9 @@ describe("<RoomSummaryCard />", () => {
|
||||
|
||||
return render(<RoomSummaryCardView {...defaultProps} {...props} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<MatrixClientContext.Provider value={mockClient}>{children}</MatrixClientContext.Provider>
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<LinkedTextContext.Provider value={{}}>{children}</LinkedTextContext.Provider>
|
||||
</MatrixClientContext.Provider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@ -142,9 +142,13 @@ exports[`<RoomSummaryCard /> has button to edit topic 1`] = `
|
||||
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31"
|
||||
>
|
||||
<span
|
||||
dir="auto"
|
||||
class="_container_15awj_8"
|
||||
>
|
||||
This is the room's topic.
|
||||
<span
|
||||
dir="auto"
|
||||
>
|
||||
This is the room's topic.
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
<button
|
||||
@ -1574,9 +1578,13 @@ exports[`<RoomSummaryCard /> renders the room topic in the summary 1`] = `
|
||||
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31"
|
||||
>
|
||||
<span
|
||||
dir="auto"
|
||||
class="_container_15awj_8"
|
||||
>
|
||||
This is the room's topic.
|
||||
<span
|
||||
dir="auto"
|
||||
>
|
||||
This is the room's topic.
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
<button
|
||||
|
||||
@ -11,6 +11,7 @@ import React from "react";
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import { EventTimeline, type MatrixClient, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { LinkedTextContext } from "@element-hq/web-shared-components";
|
||||
|
||||
import { LocalRoom } from "../../../../../src/models/LocalRoom";
|
||||
import {
|
||||
@ -32,7 +33,9 @@ const renderNewRoomIntro = (client: MatrixClient, room: Room | LocalRoom) => {
|
||||
render(
|
||||
<MatrixClientContext.Provider value={client}>
|
||||
<ScopedRoomContextProvider {...({ room, roomId: room.roomId } as unknown as RoomContextType)}>
|
||||
<NewRoomIntro />
|
||||
<LinkedTextContext.Provider value={{}}>
|
||||
<NewRoomIntro />
|
||||
</LinkedTextContext.Provider>
|
||||
</ScopedRoomContextProvider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
|
||||
@ -7,17 +7,21 @@ exports[`NewRoomIntro topic should render a link in the topic 1`] = `
|
||||
<span>
|
||||
Topic:
|
||||
<span
|
||||
dir="auto"
|
||||
class="_container_15awj_8"
|
||||
>
|
||||
This is a link:
|
||||
<a
|
||||
class="linkified"
|
||||
href="https://matrix.org/"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
<span
|
||||
dir="auto"
|
||||
>
|
||||
https://matrix.org/
|
||||
</a>
|
||||
This is a link:
|
||||
<a
|
||||
data-linkified="true"
|
||||
href="https://matrix.org/"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
https://matrix.org/
|
||||
</a>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@ -5,331 +5,16 @@ Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type EventListeners } from "linkifyjs";
|
||||
|
||||
import { linkify, Type, options } from "../../src/linkify-matrix";
|
||||
import { roomAliasEventListeners, userIdEventListeners } from "../../src/Linkify";
|
||||
import dispatcher from "../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../src/dispatcher/actions";
|
||||
|
||||
describe("linkify-matrix", () => {
|
||||
const linkTypesByInitialCharacter: Record<string, string> = {
|
||||
"#": "roomalias",
|
||||
"@": "userid",
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param testName Due to all the tests using the same logic underneath, it makes to generate it in a bit smarter way
|
||||
* @param char
|
||||
*/
|
||||
function genTests(char: "#" | "@" | "+") {
|
||||
const type = linkTypesByInitialCharacter[char];
|
||||
it("should not parse " + char + "foo without domain", () => {
|
||||
const test = char + "foo";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([]);
|
||||
});
|
||||
describe("ip v4 tests", () => {
|
||||
it("should properly parse IPs v4 as the domain name", () => {
|
||||
const test = char + "potato:1.2.3.4";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "potato:1.2.3.4",
|
||||
type,
|
||||
isLink: true,
|
||||
start: 0,
|
||||
end: test.length,
|
||||
value: char + "potato:1.2.3.4",
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("should properly parse IPs v4 with port as the domain name with attached", () => {
|
||||
const test = char + "potato:1.2.3.4:1337";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "potato:1.2.3.4:1337",
|
||||
type,
|
||||
isLink: true,
|
||||
start: 0,
|
||||
end: test.length,
|
||||
value: char + "potato:1.2.3.4:1337",
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("should properly parse IPs v4 as the domain name while ignoring missing port", () => {
|
||||
const test = char + "potato:1.2.3.4:";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "potato:1.2.3.4",
|
||||
type,
|
||||
isLink: true,
|
||||
start: 0,
|
||||
end: test.length - 1,
|
||||
value: char + "potato:1.2.3.4",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
// Currently those tests are failing, as there's missing implementation.
|
||||
describe.skip("ip v6 tests", () => {
|
||||
it("should properly parse IPs v6 as the domain name", () => {
|
||||
const test = char + "username:[1234:5678::abcd]";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "username:[1234:5678::abcd]",
|
||||
type,
|
||||
isLink: true,
|
||||
start: 0,
|
||||
end: test.length,
|
||||
value: char + "username:[1234:5678::abcd]",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should properly parse IPs v6 with port as the domain name", () => {
|
||||
const test = char + "username:[1234:5678::abcd]:1337";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "username:[1234:5678::abcd]:1337",
|
||||
type,
|
||||
isLink: true,
|
||||
start: 0,
|
||||
end: test.length,
|
||||
value: char + "username:[1234:5678::abcd]:1337",
|
||||
},
|
||||
]);
|
||||
});
|
||||
// eslint-disable-next-line max-len
|
||||
it("should properly parse IPs v6 while ignoring dangling comma when without port name as the domain name", () => {
|
||||
const test = char + "username:[1234:5678::abcd]:";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "username:[1234:5678::abcd]:",
|
||||
type,
|
||||
isLink: true,
|
||||
start: 0,
|
||||
end: test.length - 1,
|
||||
value: char + "username:[1234:5678::abcd]:",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
it("properly parses " + char + "_foonetic_xkcd:matrix.org", () => {
|
||||
const test = "" + char + "_foonetic_xkcd:matrix.org";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "_foonetic_xkcd:matrix.org",
|
||||
type,
|
||||
value: char + "_foonetic_xkcd:matrix.org",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("properly parses " + char + "localhost:foo.com", () => {
|
||||
const test = char + "localhost:foo.com";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "localhost:foo.com",
|
||||
type,
|
||||
value: char + "localhost:foo.com",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("properly parses " + char + "foo:localhost", () => {
|
||||
const test = char + "foo:localhost";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:localhost",
|
||||
type,
|
||||
value: char + "foo:localhost",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("accept " + char + "foo:bar.com", () => {
|
||||
const test = "" + char + "foo:bar.com";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:bar.com",
|
||||
type,
|
||||
value: char + "foo:bar.com",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("accept " + char + "foo:com (mostly for (TLD|DOMAIN)+ mixing)", () => {
|
||||
const test = "" + char + "foo:com";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:com",
|
||||
type,
|
||||
value: char + "foo:com",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("accept repeated TLDs (e.g .org.uk)", () => {
|
||||
const test = "" + char + "foo:bar.org.uk";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:bar.org.uk",
|
||||
type,
|
||||
value: char + "foo:bar.org.uk",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("accept hyphens in name " + char + "foo-bar:server.com", () => {
|
||||
const test = "" + char + "foo-bar:server.com";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo-bar:server.com",
|
||||
type,
|
||||
value: char + "foo-bar:server.com",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("ignores trailing `:`", () => {
|
||||
const test = "" + char + "foo:bar.com:";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
type,
|
||||
value: char + "foo:bar.com",
|
||||
href: char + "foo:bar.com",
|
||||
start: 0,
|
||||
end: test.length - ":".length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("accept :NUM (port specifier)", () => {
|
||||
const test = "" + char + "foo:bar.com:2225";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:bar.com:2225",
|
||||
type,
|
||||
value: char + "foo:bar.com:2225",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("ignores duplicate :NUM (double port specifier)", () => {
|
||||
const test = "" + char + "foo:bar.com:2225:1234";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:bar.com:2225",
|
||||
type,
|
||||
value: char + "foo:bar.com:2225",
|
||||
start: 0,
|
||||
end: 17,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("ignores all the trailing :", () => {
|
||||
const test = "" + char + "foo:bar.com::::";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:bar.com",
|
||||
type,
|
||||
value: char + "foo:bar.com",
|
||||
end: test.length - 4,
|
||||
start: 0,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("properly parses room alias with dots in name", () => {
|
||||
const test = "" + char + "foo.asdf:bar.com::::";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo.asdf:bar.com",
|
||||
type,
|
||||
value: char + "foo.asdf:bar.com",
|
||||
start: 0,
|
||||
end: test.length - ":".repeat(4).length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("does not parse room alias with too many separators", () => {
|
||||
const test = "" + char + "foo:::bar.com";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: "http://bar.com",
|
||||
type: "url",
|
||||
value: "bar.com",
|
||||
isLink: true,
|
||||
start: 7,
|
||||
end: test.length,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("properly parses room alias with hyphen in domain part", () => {
|
||||
const test = "" + char + "foo:bar.com-baz.com";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:bar.com-baz.com",
|
||||
type,
|
||||
value: char + "foo:bar.com-baz.com",
|
||||
end: 20,
|
||||
start: 0,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
describe("roomalias plugin", () => {
|
||||
genTests("#");
|
||||
|
||||
it("should intercept clicks with a ViewRoom dispatch", () => {
|
||||
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
|
||||
const handlers = (options.events as (href: string, type: string) => EventListeners)(
|
||||
"#room:server.com",
|
||||
"roomalias",
|
||||
);
|
||||
|
||||
const handlers = roomAliasEventListeners("#room:server.com");
|
||||
const event = new MouseEvent("mousedown");
|
||||
event.preventDefault = jest.fn();
|
||||
handlers!.click(event);
|
||||
@ -344,31 +29,10 @@ describe("linkify-matrix", () => {
|
||||
});
|
||||
|
||||
describe("userid plugin", () => {
|
||||
genTests("@");
|
||||
|
||||
it("allows dots in localparts", () => {
|
||||
const test = "@test.:matrix.org";
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: test,
|
||||
type: "userid",
|
||||
value: test,
|
||||
start: 0,
|
||||
end: test.length,
|
||||
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should intercept clicks with a ViewUser dispatch", () => {
|
||||
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
|
||||
const handlers = (options.events as (href: string, type: string) => EventListeners)(
|
||||
"@localpart:server.com",
|
||||
"userid",
|
||||
);
|
||||
const handlers = userIdEventListeners("@localpart:server.com");
|
||||
|
||||
const event = new MouseEvent("mousedown");
|
||||
event.preventDefault = jest.fn();
|
||||
@ -384,52 +48,4 @@ describe("linkify-matrix", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matrix uri", () => {
|
||||
const acceptedMatrixUris = [
|
||||
"matrix:u/foo_bar:server.uk",
|
||||
"matrix:r/foo-bar:server.uk",
|
||||
"matrix:roomid/somewhere:example.org?via=elsewhere.ca",
|
||||
"matrix:r/somewhere:example.org",
|
||||
"matrix:r/somewhere:example.org/e/event",
|
||||
"matrix:roomid/somewhere:example.org/e/event?via=elsewhere.ca",
|
||||
"matrix:u/alice:example.org?action=chat",
|
||||
];
|
||||
for (const matrixUri of acceptedMatrixUris) {
|
||||
it("accepts " + matrixUri, () => {
|
||||
const test = matrixUri;
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: matrixUri,
|
||||
type: Type.URL,
|
||||
value: matrixUri,
|
||||
end: matrixUri.length,
|
||||
start: 0,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("matrix-prefixed domains", () => {
|
||||
const acceptedDomains = ["matrix.org", "matrix.to", "matrix-help.org", "matrix123.org"];
|
||||
for (const domain of acceptedDomains) {
|
||||
it("accepts " + domain, () => {
|
||||
const test = domain;
|
||||
const found = linkify.find(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: `http://${domain}`,
|
||||
type: Type.URL,
|
||||
value: domain,
|
||||
end: domain.length,
|
||||
start: 0,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
@ -57,6 +57,10 @@
|
||||
"classnames": "^2.5.1",
|
||||
"counterpart": "^0.18.6",
|
||||
"html-react-parser": "^5.2.2",
|
||||
"linkify-html": "4.3.2",
|
||||
"linkify-react": "4.3.2",
|
||||
"linkify-string": "4.3.2",
|
||||
"linkifyjs": "4.3.2",
|
||||
"lodash": "npm:lodash-es@^4.17.21",
|
||||
"matrix-web-i18n": "catalog:",
|
||||
"react-merge-refs": "^3.0.2",
|
||||
|
||||
@ -42,6 +42,7 @@ export * from "./room-list/VirtualizedRoomListView";
|
||||
export * from "./timeline/DateSeparatorView/";
|
||||
export * from "./utils/Box";
|
||||
export * from "./utils/Flex";
|
||||
export * from "./utils/LinkedText";
|
||||
export * from "./right-panel/WidgetContextMenu";
|
||||
export * from "./utils/VirtualizedList";
|
||||
|
||||
@ -53,5 +54,6 @@ export * from "./utils/DateUtils";
|
||||
export * from "./utils/numbers";
|
||||
export * from "./utils/FormattingUtils";
|
||||
export * from "./utils/I18nApi";
|
||||
export * from "./utils/linkify";
|
||||
// MVVM
|
||||
export * from "./viewmodel";
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.container {
|
||||
a {
|
||||
color: var(--cpd-color-text-link-external);
|
||||
}
|
||||
margin: 0;
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type ComponentProps } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LinkedText } from "./LinkedText";
|
||||
import { LinkedTextContext } from "./LinkedTextContext";
|
||||
|
||||
const meta = {
|
||||
title: "Utils/LinkedText",
|
||||
component: LinkedText,
|
||||
decorators: [
|
||||
(Story, { args }) => (
|
||||
<LinkedTextContext.Provider
|
||||
value={{
|
||||
userIdListener: args.userIdListener,
|
||||
roomAliasListener: args.roomAliasListener,
|
||||
urlTargetTransformer: args.urlTargetTransformer,
|
||||
hrefTransformer: args.hrefTransformer,
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</LinkedTextContext.Provider>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
children: "I love working on https://matrix.org.",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<ComponentProps<typeof LinkedText> & ComponentProps<typeof LinkedTextContext>["value"]>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const WithUserId: Story = {
|
||||
args: {
|
||||
children: "I love talking to @alice:example.org.",
|
||||
userIdListener: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRoomAlias: Story = {
|
||||
args: {
|
||||
children: "I love talking in #general:example.org.",
|
||||
roomAliasListener: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomUrlTarget: Story = {
|
||||
args: {
|
||||
urlTargetTransformer: () => "_fake_target",
|
||||
},
|
||||
tags: ["skip-test"],
|
||||
};
|
||||
|
||||
export const WithCustomHref: Story = {
|
||||
args: {
|
||||
hrefTransformer: () => {
|
||||
return "https://example.org";
|
||||
},
|
||||
},
|
||||
tags: ["skip-test"],
|
||||
};
|
||||
@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render } from "@test-utils";
|
||||
import { describe, it, expect, vitest } from "vitest";
|
||||
import React from "react";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import * as stories from "./LinkedText.stories.tsx";
|
||||
import { LinkedText } from "./LinkedText.tsx";
|
||||
import { LinkifyOptionalSlashProtocols, PERMITTED_URL_SCHEMES } from "../linkify";
|
||||
import { LinkedTextContext } from "./LinkedTextContext.tsx";
|
||||
|
||||
const { Default, WithUserId, WithRoomAlias, WithCustomHref, WithCustomUrlTarget } = composeStories(stories);
|
||||
|
||||
describe("LinkedText", () => {
|
||||
it.each(
|
||||
PERMITTED_URL_SCHEMES.filter((protocol) => !LinkifyOptionalSlashProtocols.includes(protocol)).map(
|
||||
(protocol) => `${protocol}://abcdef/`,
|
||||
),
|
||||
)("renders protocol with no optional slash '%s'", (path) => {
|
||||
const { getByRole } = render(
|
||||
<LinkedTextContext value={{}}>
|
||||
<LinkedText>Check out this link {path}</LinkedText>
|
||||
</LinkedTextContext>,
|
||||
);
|
||||
expect(getByRole("link")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(LinkifyOptionalSlashProtocols.map((protocol) => `${protocol}://abcdef`))(
|
||||
"renders protocol with optional slash '%s'",
|
||||
(path) => {
|
||||
const { getByRole } = render(
|
||||
<LinkedTextContext value={{}}>
|
||||
<LinkedText>Check out this link {path}</LinkedText>
|
||||
</LinkedTextContext>,
|
||||
);
|
||||
expect(getByRole("link")).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("renders a standard link", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders a user ID", () => {
|
||||
const { container } = render(<WithUserId />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders a room alias", () => {
|
||||
const { container } = render(<WithRoomAlias />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders a custom target", () => {
|
||||
const { container } = render(<WithCustomUrlTarget />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders a custom href", () => {
|
||||
const { container } = render(<WithCustomHref />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("supports setting an onLinkClicked handler", async () => {
|
||||
const fn = vitest.fn();
|
||||
const { getAllByRole } = render(
|
||||
<LinkedTextContext value={{}}>
|
||||
<LinkedText onLinkClick={fn}>Check out this link https://google.com and example.org</LinkedText>
|
||||
</LinkedTextContext>,
|
||||
);
|
||||
const links = getAllByRole("link");
|
||||
expect(links).toHaveLength(2);
|
||||
await userEvent.click(links[0]);
|
||||
await userEvent.click(links[1]);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Link, Text } from "@vector-im/compound-web";
|
||||
import React, { type ComponentProps } from "react";
|
||||
import classNames from "classnames";
|
||||
import Linkify from "linkify-react";
|
||||
|
||||
import styles from "./LinkedText.module.css";
|
||||
import { generateLinkedTextOptions } from "../linkify";
|
||||
import { useLinkedTextContext } from "./LinkedTextContext";
|
||||
|
||||
export type LinkedTextProps = ComponentProps<typeof Text> & {
|
||||
/**
|
||||
* Handler for when a link within the component is clicked. This will run
|
||||
* *before* any LinkedTextContext handlers are run.
|
||||
* @param ev The event raised by the click.
|
||||
*/
|
||||
onLinkClick?: (ev: MouseEvent) => void;
|
||||
};
|
||||
/**
|
||||
* A component that renders URLs as clickable links inside some plain text.
|
||||
*
|
||||
* Requires a `<LinkedTextContext.Provider>`
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LinkedTextContext.Provider value={...}>
|
||||
* <LinkedText>
|
||||
* I love working on https://matrix.org
|
||||
* </LinkedText>
|
||||
* </LinkedTextContext.Provider>
|
||||
* ```
|
||||
*/
|
||||
export function LinkedText({ children, className, onLinkClick, ...textProps }: LinkedTextProps): React.ReactNode {
|
||||
const options = useLinkedTextContext();
|
||||
const linkifyOptions = generateLinkedTextOptions({ ...options, onLinkClick });
|
||||
return (
|
||||
<Linkify
|
||||
className={classNames(styles.container, className)}
|
||||
as={Text}
|
||||
options={{ ...linkifyOptions, render: Link }}
|
||||
{...textProps}
|
||||
>
|
||||
{children}
|
||||
</Linkify>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { LinkEventListener, LinkifyMatrixOpaqueIdType } from "../linkify";
|
||||
|
||||
export interface LinkedTextConfiguration {
|
||||
/**
|
||||
* Event handlers for URL links.
|
||||
*/
|
||||
urlListener?: (href: string) => LinkEventListener;
|
||||
/**
|
||||
* Event handlers for room alias links.
|
||||
*/
|
||||
roomAliasListener?: (href: string) => LinkEventListener;
|
||||
/**
|
||||
* Event handlers for user ID links.
|
||||
*/
|
||||
userIdListener?: (href: string) => LinkEventListener;
|
||||
/**
|
||||
* Function that can be used to transform the `target` attribute on links, depending on the `href`.
|
||||
*/
|
||||
urlTargetTransformer?: (href: string) => string;
|
||||
/**
|
||||
* Function that can be used to transform the `href` attribute on links, depending on the current href and target type.
|
||||
*/
|
||||
hrefTransformer?: (href: string, target: LinkifyMatrixOpaqueIdType) => string;
|
||||
}
|
||||
|
||||
export const LinkedTextContext = createContext<LinkedTextConfiguration | null>(null);
|
||||
LinkedTextContext.displayName = "LinkedTextContext";
|
||||
|
||||
/**
|
||||
* A hook to get the linked text configuration from the context. Will throw if no LinkedTextContext is found.
|
||||
* @throws If no LinkedTextContext context is found
|
||||
* @returns The linked text configuration from the context
|
||||
*/
|
||||
export function useLinkedTextContext(): LinkedTextConfiguration {
|
||||
const config = useContext(LinkedTextContext);
|
||||
|
||||
if (!config) {
|
||||
throw new Error("useLinkedTextContextOpts must be used within an LinkedTextContext.Provider");
|
||||
}
|
||||
return config;
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`LinkedText > renders a custom href 1`] = `
|
||||
<div>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
|
||||
>
|
||||
I love working on
|
||||
<a
|
||||
data-linkified="true"
|
||||
href="https://example.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
https://matrix.org
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`LinkedText > renders a custom target 1`] = `
|
||||
<div>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
|
||||
>
|
||||
I love working on
|
||||
<a
|
||||
data-linkified="true"
|
||||
href="https://matrix.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_fake_target"
|
||||
>
|
||||
https://matrix.org
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`LinkedText > renders a room alias 1`] = `
|
||||
<div>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
|
||||
>
|
||||
I love talking in
|
||||
<a
|
||||
data-linkified="true"
|
||||
href="#general:example.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
#general:example.org
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`LinkedText > renders a standard link 1`] = `
|
||||
<div>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
|
||||
>
|
||||
I love working on
|
||||
<a
|
||||
data-linkified="true"
|
||||
href="https://matrix.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
https://matrix.org
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`LinkedText > renders a user ID 1`] = `
|
||||
<div>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
|
||||
>
|
||||
I love talking to
|
||||
<a
|
||||
data-linkified="true"
|
||||
href="@alice:example.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
@alice:example.org
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
9
packages/shared-components/src/utils/LinkedText/index.ts
Normal file
9
packages/shared-components/src/utils/LinkedText/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { LinkedText, type LinkedTextProps } from "./LinkedText";
|
||||
export { LinkedTextContext, useLinkedTextContext } from "./LinkedTextContext";
|
||||
54
packages/shared-components/src/utils/linkify.stories.tsx
Normal file
54
packages/shared-components/src/utils/linkify.stories.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Markdown } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import type { Meta } from "@storybook/react-vite";
|
||||
import LinkifyMatrixOpaqueIdType from "../../typedoc/enumerations/LinkifyMatrixOpaqueIdType.md?raw";
|
||||
import findLinksInString from "../../typedoc/functions/findLinksInString.md?raw";
|
||||
import isLinkable from "../../typedoc/functions/isLinkable.md?raw";
|
||||
import linkifyHtml from "../../typedoc/functions/linkifyHtml.md?raw";
|
||||
import linkifyString from "../../typedoc/functions/linkifyString.md?raw";
|
||||
import generateLinkedTextOptions from "../../typedoc/functions/generateLinkedTextOptions.md?raw";
|
||||
import LinkedTextOptions from "../../typedoc/interfaces/LinkedTextOptions.md?raw";
|
||||
|
||||
const meta = {
|
||||
title: "utils/linkify",
|
||||
parameters: {
|
||||
docs: {
|
||||
page: () => (
|
||||
<>
|
||||
<h1>Linkify utilities</h1>
|
||||
<p>Supporting functions and types for parsing links from HTML/strings.</p>
|
||||
<h2>LinkifyMatrixOpaqueIdType</h2>
|
||||
<Markdown>{LinkifyMatrixOpaqueIdType}</Markdown>
|
||||
<h2>findLinksInString</h2>
|
||||
<Markdown>{findLinksInString}</Markdown>
|
||||
<h2>isLinkable</h2>
|
||||
<Markdown>{isLinkable}</Markdown>
|
||||
<h2>linkifyHtml</h2>
|
||||
<Markdown>{linkifyHtml}</Markdown>
|
||||
<h2>linkifyString</h2>
|
||||
<Markdown>{linkifyString}</Markdown>
|
||||
<h2>generateLinkedTextOptions</h2>
|
||||
<Markdown>{generateLinkedTextOptions}</Markdown>
|
||||
<h3>LinkedTextOptions</h3>
|
||||
<Markdown>{LinkedTextOptions}</Markdown>
|
||||
</>
|
||||
),
|
||||
},
|
||||
},
|
||||
tags: ["autodocs", "skip-test"],
|
||||
} satisfies Meta;
|
||||
|
||||
export default meta;
|
||||
|
||||
// Docs-only story - renders nothing but triggers autodocs
|
||||
export const Docs = {
|
||||
render: () => null,
|
||||
};
|
||||
411
packages/shared-components/src/utils/linkify.test.ts
Normal file
411
packages/shared-components/src/utils/linkify.test.ts
Normal file
@ -0,0 +1,411 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { findLinksInString, isLinkable, linkifyHtml, LinkifyMatrixOpaqueIdType } from "./linkify";
|
||||
|
||||
describe("linkify-matrix", () => {
|
||||
const linkTypesByInitialCharacter: Record<string, string> = {
|
||||
"#": "roomalias",
|
||||
"@": "userid",
|
||||
};
|
||||
|
||||
describe.each(Object.entries(linkTypesByInitialCharacter))("handles '%s' (%s)", (char, type) => {
|
||||
it("should not parse " + char + "foo without domain", () => {
|
||||
const test = char + "foo";
|
||||
const found = findLinksInString(test);
|
||||
expect(isLinkable(test)).toEqual(false);
|
||||
expect(found).toEqual([]);
|
||||
});
|
||||
describe("ip v4 tests", () => {
|
||||
it("should properly parse IPs v4 as the domain name", () => {
|
||||
const test = char + "potato:1.2.3.4";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "potato:1.2.3.4",
|
||||
type,
|
||||
isLink: true,
|
||||
start: 0,
|
||||
end: test.length,
|
||||
value: char + "potato:1.2.3.4",
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("should properly parse IPs v4 with port as the domain name with attached", () => {
|
||||
const test = char + "potato:1.2.3.4:1337";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "potato:1.2.3.4:1337",
|
||||
type,
|
||||
isLink: true,
|
||||
start: 0,
|
||||
end: test.length,
|
||||
value: char + "potato:1.2.3.4:1337",
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("should properly parse IPs v4 as the domain name while ignoring missing port", () => {
|
||||
const test = char + "potato:1.2.3.4:";
|
||||
expect(isLinkable(test)).toEqual(false);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "potato:1.2.3.4",
|
||||
type,
|
||||
isLink: true,
|
||||
start: 0,
|
||||
end: test.length - 1,
|
||||
value: char + "potato:1.2.3.4",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
// Currently those tests are failing, as there's missing implementation.
|
||||
describe.skip("ip v6 tests", () => {
|
||||
it("should properly parse IPs v6 as the domain name", () => {
|
||||
const test = char + "username:[1234:5678::abcd]";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "username:[1234:5678::abcd]",
|
||||
type,
|
||||
isLink: true,
|
||||
start: 0,
|
||||
end: test.length,
|
||||
value: char + "username:[1234:5678::abcd]",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should properly parse IPs v6 with port as the domain name", () => {
|
||||
const test = char + "username:[1234:5678::abcd]:1337";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "username:[1234:5678::abcd]:1337",
|
||||
type,
|
||||
isLink: true,
|
||||
start: 0,
|
||||
end: test.length,
|
||||
value: char + "username:[1234:5678::abcd]:1337",
|
||||
},
|
||||
]);
|
||||
});
|
||||
// eslint-disable-next-line max-len
|
||||
it("should properly parse IPs v6 while ignoring dangling comma when without port name as the domain name", () => {
|
||||
const test = char + "username:[1234:5678::abcd]:";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "username:[1234:5678::abcd]:",
|
||||
type,
|
||||
isLink: true,
|
||||
start: 0,
|
||||
end: test.length - 1,
|
||||
value: char + "username:[1234:5678::abcd]:",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
it("properly parses " + char + "_foonetic_xkcd:matrix.org", () => {
|
||||
const test = "" + char + "_foonetic_xkcd:matrix.org";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "_foonetic_xkcd:matrix.org",
|
||||
type,
|
||||
value: char + "_foonetic_xkcd:matrix.org",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("properly parses " + char + "localhost:foo.com", () => {
|
||||
const test = char + "localhost:foo.com";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "localhost:foo.com",
|
||||
type,
|
||||
value: char + "localhost:foo.com",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("properly parses " + char + "foo:localhost", () => {
|
||||
const test = char + "foo:localhost";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:localhost",
|
||||
type,
|
||||
value: char + "foo:localhost",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("accept " + char + "foo:bar.com", () => {
|
||||
const test = "" + char + "foo:bar.com";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:bar.com",
|
||||
type,
|
||||
value: char + "foo:bar.com",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("accept " + char + "foo:com (mostly for (TLD|DOMAIN)+ mixing)", () => {
|
||||
const test = "" + char + "foo:com";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:com",
|
||||
type,
|
||||
value: char + "foo:com",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("accept repeated TLDs (e.g .org.uk)", () => {
|
||||
const test = "" + char + "foo:bar.org.uk";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:bar.org.uk",
|
||||
type,
|
||||
value: char + "foo:bar.org.uk",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("accept hyphens in name " + char + "foo-bar:server.com", () => {
|
||||
const test = "" + char + "foo-bar:server.com";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo-bar:server.com",
|
||||
type,
|
||||
value: char + "foo-bar:server.com",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("ignores trailing `:`", () => {
|
||||
const test = "" + char + "foo:bar.com:";
|
||||
expect(isLinkable(test)).toEqual(false);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
type,
|
||||
value: char + "foo:bar.com",
|
||||
href: char + "foo:bar.com",
|
||||
start: 0,
|
||||
end: test.length - ":".length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("accept :NUM (port specifier)", () => {
|
||||
const test = "" + char + "foo:bar.com:2225";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:bar.com:2225",
|
||||
type,
|
||||
value: char + "foo:bar.com:2225",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("ignores duplicate :NUM (double port specifier)", () => {
|
||||
const test = "" + char + "foo:bar.com:2225:1234";
|
||||
expect(isLinkable(test)).toEqual(false);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:bar.com:2225",
|
||||
type,
|
||||
value: char + "foo:bar.com:2225",
|
||||
start: 0,
|
||||
end: 17,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("ignores all the trailing :", () => {
|
||||
const test = "" + char + "foo:bar.com::::";
|
||||
expect(isLinkable(test)).toEqual(false);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:bar.com",
|
||||
type,
|
||||
value: char + "foo:bar.com",
|
||||
end: test.length - 4,
|
||||
start: 0,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("properly parses room alias with dots in name", () => {
|
||||
const test = "" + char + "foo.asdf:bar.com::::";
|
||||
expect(isLinkable(test)).toEqual(false);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo.asdf:bar.com",
|
||||
type,
|
||||
value: char + "foo.asdf:bar.com",
|
||||
start: 0,
|
||||
end: test.length - ":".repeat(4).length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("does not parse room alias with too many separators", () => {
|
||||
const test = "" + char + "foo:::bar.com";
|
||||
expect(isLinkable(test)).toEqual(false);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: "http://bar.com",
|
||||
type: "url",
|
||||
value: "bar.com",
|
||||
isLink: true,
|
||||
start: 7,
|
||||
end: test.length,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("properly parses room alias with hyphen in domain part", () => {
|
||||
const test = "" + char + "foo:bar.com-baz.com";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:bar.com-baz.com",
|
||||
type,
|
||||
value: char + "foo:bar.com-baz.com",
|
||||
end: 20,
|
||||
start: 0,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("userid plugin", () => {
|
||||
it("allows dots in localparts", () => {
|
||||
const test = "@test.:matrix.org";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: test,
|
||||
type: "userid",
|
||||
value: test,
|
||||
start: 0,
|
||||
end: test.length,
|
||||
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matrix uri", () => {
|
||||
const acceptedMatrixUris = [
|
||||
"matrix:u/foo_bar:server.uk",
|
||||
"matrix:r/foo-bar:server.uk",
|
||||
"matrix:roomid/somewhere:example.org?via=elsewhere.ca",
|
||||
"matrix:r/somewhere:example.org",
|
||||
"matrix:r/somewhere:example.org/e/event",
|
||||
"matrix:roomid/somewhere:example.org/e/event?via=elsewhere.ca",
|
||||
"matrix:u/alice:example.org?action=chat",
|
||||
];
|
||||
for (const matrixUri of acceptedMatrixUris) {
|
||||
it("accepts " + matrixUri, () => {
|
||||
expect(isLinkable(matrixUri)).toEqual(true);
|
||||
const found = findLinksInString(matrixUri);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: matrixUri,
|
||||
type: LinkifyMatrixOpaqueIdType.URL,
|
||||
value: matrixUri,
|
||||
end: matrixUri.length,
|
||||
start: 0,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("matrix-prefixed domains", () => {
|
||||
const acceptedDomains = ["matrix.org", "matrix.to", "matrix-help.org", "matrix123.org"];
|
||||
for (const domain of acceptedDomains) {
|
||||
it("accepts " + domain, () => {
|
||||
expect(isLinkable(domain)).toEqual(true);
|
||||
const found = findLinksInString(domain);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: `http://${domain}`,
|
||||
type: LinkifyMatrixOpaqueIdType.URL,
|
||||
value: domain,
|
||||
end: domain.length,
|
||||
start: 0,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
describe("linkifyHtml", () => {
|
||||
it("removes any existing data-linkified", () => {
|
||||
expect(
|
||||
linkifyHtml("<span data-linkfied><a data-linkfied href='evil://com'>evil.com</a></span>"),
|
||||
).toMatchInlineSnapshot(
|
||||
`"<span data-linkfied=""><a data-linkfied="" href="evil://com">evil.com</a></span>"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
312
packages/shared-components/src/utils/linkify.ts
Normal file
312
packages/shared-components/src/utils/linkify.ts
Normal file
@ -0,0 +1,312 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import * as linkifyjs from "linkifyjs";
|
||||
import { default as linkifyString } from "linkify-string"; // Only exported by this file, but imported for jsdoc.
|
||||
import { default as linkifyHtml } from "linkify-html"; // Only exported by this file, but imported for jsdoc.
|
||||
|
||||
/**
|
||||
* This file describes common linkify configuration settings such as supported protocols.
|
||||
* The instance of "linkifyjs" is the canonical instance that all dependant apps should use.
|
||||
*
|
||||
* Plugins should be configured inside this file exclusively so as to avoid contamination of
|
||||
* the global state.
|
||||
*/
|
||||
|
||||
/**
|
||||
* List of supported protocols natively by linkify. Kept in sync with upstreanm.
|
||||
* @see https://github.com/nfrasser/linkifyjs/blob/main/packages/linkifyjs/src/scanner.mjs#L171-L177
|
||||
*/
|
||||
export const LinkifySupportedProtocols = ["file", "mailto", "http", "https", "ftp", "ftps"];
|
||||
|
||||
/**
|
||||
* Protocols that do not require a slash in the URL.
|
||||
*/
|
||||
export const LinkifyOptionalSlashProtocols = [
|
||||
"bitcoin",
|
||||
"geo",
|
||||
"im",
|
||||
"magnet",
|
||||
"mailto",
|
||||
"matrix",
|
||||
"news",
|
||||
"openpgp4fpr",
|
||||
"sip",
|
||||
"sms",
|
||||
"smsto",
|
||||
"tel",
|
||||
"urn",
|
||||
"xmpp",
|
||||
];
|
||||
|
||||
/**
|
||||
* URL schemes that are safe to be resolved by the app consuming the library.
|
||||
*/
|
||||
export const PERMITTED_URL_SCHEMES = [...LinkifySupportedProtocols, ...LinkifyOptionalSlashProtocols];
|
||||
|
||||
export enum LinkifyMatrixOpaqueIdType {
|
||||
URL = "url",
|
||||
UserId = "userid",
|
||||
RoomAlias = "roomalias",
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin function for linkifyjs to find Matrix Room or User IDs.
|
||||
*
|
||||
* Should be used exclusively by a `registerPlugin` function call.
|
||||
*/
|
||||
function parseOpaqueIdsToMatrixIds({
|
||||
scanner,
|
||||
parser,
|
||||
token,
|
||||
name,
|
||||
}: {
|
||||
scanner: linkifyjs.ScannerInit;
|
||||
parser: linkifyjs.ParserInit;
|
||||
token: "#" | "@";
|
||||
name: LinkifyMatrixOpaqueIdType;
|
||||
}): void {
|
||||
const {
|
||||
DOT,
|
||||
// IPV4 necessity
|
||||
NUM,
|
||||
COLON,
|
||||
SYM,
|
||||
SLASH,
|
||||
EQUALS,
|
||||
HYPHEN,
|
||||
UNDERSCORE,
|
||||
} = scanner.tokens;
|
||||
|
||||
// Contains NUM, WORD, UWORD, EMOJI, TLD, UTLD, SCHEME, SLASH_SCHEME and LOCALHOST plus custom protocols (e.g. "matrix")
|
||||
const { domain } = scanner.tokens.groups;
|
||||
|
||||
// Tokens we need that are not contained in the domain group
|
||||
const additionalLocalpartTokens = [DOT, SYM, SLASH, EQUALS, UNDERSCORE, HYPHEN];
|
||||
const additionalDomainpartTokens = [HYPHEN];
|
||||
|
||||
const matrixToken = linkifyjs.createTokenClass(name, { isLink: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const matrixTokenState = new linkifyjs.State(matrixToken) as any as linkifyjs.State<linkifyjs.MultiToken>; // linkify doesn't appear to type this correctly
|
||||
|
||||
const matrixTokenWithPort = linkifyjs.createTokenClass(name, { isLink: true });
|
||||
const matrixTokenWithPortState = new linkifyjs.State(
|
||||
matrixTokenWithPort,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any as linkifyjs.State<linkifyjs.MultiToken>; // linkify doesn't appear to type this correctly
|
||||
|
||||
const initialState = parser.start.tt(token);
|
||||
|
||||
// Localpart
|
||||
const localpartState = new linkifyjs.State<linkifyjs.MultiToken>();
|
||||
initialState.ta(domain, localpartState);
|
||||
initialState.ta(additionalLocalpartTokens, localpartState);
|
||||
localpartState.ta(domain, localpartState);
|
||||
localpartState.ta(additionalLocalpartTokens, localpartState);
|
||||
|
||||
// Domainpart
|
||||
const domainStateDot = localpartState.tt(COLON);
|
||||
domainStateDot.ta(domain, matrixTokenState);
|
||||
domainStateDot.ta(additionalDomainpartTokens, matrixTokenState);
|
||||
matrixTokenState.ta(domain, matrixTokenState);
|
||||
matrixTokenState.ta(additionalDomainpartTokens, matrixTokenState);
|
||||
matrixTokenState.tt(DOT, domainStateDot);
|
||||
|
||||
// Port suffixes
|
||||
matrixTokenState.tt(COLON).tt(NUM, matrixTokenWithPortState);
|
||||
}
|
||||
|
||||
export type LinkEventListener = linkifyjs.EventListeners;
|
||||
|
||||
export interface LinkedTextOptions {
|
||||
/**
|
||||
* Event handlers for URL links.
|
||||
*/
|
||||
urlListener?: (href: string) => LinkEventListener;
|
||||
/**
|
||||
* Event handlers for room alias links.
|
||||
*/
|
||||
roomAliasListener?: (href: string) => LinkEventListener;
|
||||
/**
|
||||
* Event handlers for user ID links.
|
||||
*/
|
||||
userIdListener?: (href: string) => LinkEventListener;
|
||||
/**
|
||||
* Function that can be used to transform the `target` attribute on links, depending on the `href`.
|
||||
*/
|
||||
urlTargetTransformer?: (href: string) => string;
|
||||
/**
|
||||
* Function that can be used to transform the `href` attribute on links, depending on the current href and target type.
|
||||
*/
|
||||
hrefTransformer?: (href: string, target: LinkifyMatrixOpaqueIdType) => string;
|
||||
/**
|
||||
* Function called before all listeners when a link is clicked.
|
||||
*/
|
||||
onLinkClick?: (ev: MouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a linkifyjs options object that is reasonably paired down
|
||||
* to just the essentials required for an Element client.
|
||||
*
|
||||
* @return A `linkifyjs` `Opts` object. Used by `linkifyString` and `linkifyHtml
|
||||
* @see {@link linkifyHtml}
|
||||
* @see {@link linkifyString}
|
||||
*/
|
||||
export function generateLinkedTextOptions({
|
||||
urlListener,
|
||||
roomAliasListener,
|
||||
userIdListener,
|
||||
urlTargetTransformer,
|
||||
hrefTransformer,
|
||||
onLinkClick,
|
||||
}: LinkedTextOptions): linkifyjs.Opts {
|
||||
const events = (href: string, type: string): LinkEventListener => {
|
||||
switch (type as LinkifyMatrixOpaqueIdType) {
|
||||
case LinkifyMatrixOpaqueIdType.URL: {
|
||||
if (urlListener) {
|
||||
return urlListener(href);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case LinkifyMatrixOpaqueIdType.UserId:
|
||||
if (userIdListener) {
|
||||
return userIdListener(href);
|
||||
}
|
||||
break;
|
||||
case LinkifyMatrixOpaqueIdType.RoomAlias:
|
||||
if (roomAliasListener) {
|
||||
return roomAliasListener(href);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const attributes = (href: string, type: string): Record<string, unknown> => {
|
||||
const attrs: Record<string, unknown> = {
|
||||
[`data-${LINKIFIED_DATA_ATTRIBUTE}`]: "true",
|
||||
};
|
||||
// linkify-react doesn't respect `events` and needs it mapping to React attributes
|
||||
// so we need to manually add the click handler to the attributes
|
||||
// https://linkify.js.org/docs/linkify-react.html#events
|
||||
const options = events(href, type);
|
||||
if (options?.click) {
|
||||
attrs.onClick = options.click;
|
||||
}
|
||||
if (onLinkClick) {
|
||||
attrs.onClick = (ev: MouseEvent) => {
|
||||
onLinkClick(ev);
|
||||
options?.click?.(ev);
|
||||
};
|
||||
}
|
||||
|
||||
return attrs;
|
||||
};
|
||||
|
||||
return {
|
||||
rel: "noreferrer noopener",
|
||||
ignoreTags: ["a", "pre", "code"],
|
||||
defaultProtocol: "https",
|
||||
events,
|
||||
attributes,
|
||||
target(href, type) {
|
||||
if (type === LinkifyMatrixOpaqueIdType.URL && urlTargetTransformer) {
|
||||
return urlTargetTransformer(href);
|
||||
}
|
||||
return "_blank";
|
||||
},
|
||||
...(hrefTransformer
|
||||
? {
|
||||
formatHref: (href, type) => hrefTransformer(href, type as LinkifyMatrixOpaqueIdType),
|
||||
}
|
||||
: undefined),
|
||||
// By default, ignore Matrix ID types.
|
||||
// Other applications may implement their own version of LinkifyComponent.
|
||||
validate: (_value, type: string) =>
|
||||
!!(type === LinkifyMatrixOpaqueIdType.UserId && userIdListener) ||
|
||||
!!(type === LinkifyMatrixOpaqueIdType.RoomAlias && roomAliasListener) ||
|
||||
type === LinkifyMatrixOpaqueIdType.URL,
|
||||
} satisfies linkifyjs.Opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all links in a given string.
|
||||
*
|
||||
* @param str A string that may contain one or more strings.
|
||||
* @returns A set of all links in the string.
|
||||
*/
|
||||
export function findLinksInString(str: string): ReturnType<typeof linkifyjs.find> {
|
||||
return linkifyjs.find(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the provided value something that would be converted to a clickable
|
||||
* link.
|
||||
*
|
||||
* E.g. 'https://matrix.org', `matrix.org` or 'example@matrix.org'
|
||||
*
|
||||
* @param str A string value to be tested if the entire value is linkable.
|
||||
* @returns Whether or not the `str` value is a link.
|
||||
* @see `PERMITTED_URL_SCHEMES` for permitted links.
|
||||
* @see {@link linkifyjs.test}
|
||||
*/
|
||||
export function isLinkable(str: string): boolean {
|
||||
return linkifyjs.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* `data-linkified` is applied to all links generated by the linkifaction functions and `<LinkedText>`.
|
||||
*/
|
||||
export const LINKIFIED_DATA_ATTRIBUTE = "linkified";
|
||||
|
||||
export { linkifyString, linkifyHtml };
|
||||
|
||||
// Linkifyjs MUST be configured globally as it has no ability to be instanced seperately
|
||||
// so we ensure it's always configured the same way.
|
||||
let linkifyJSConfigured = false;
|
||||
function configureLinkifyJS(): void {
|
||||
if (linkifyJSConfigured) {
|
||||
return;
|
||||
}
|
||||
// Register plugins
|
||||
linkifyjs.registerPlugin(LinkifyMatrixOpaqueIdType.RoomAlias, ({ scanner, parser }) => {
|
||||
const token = scanner.tokens.POUND as "#";
|
||||
parseOpaqueIdsToMatrixIds({
|
||||
scanner,
|
||||
parser,
|
||||
token,
|
||||
name: LinkifyMatrixOpaqueIdType.RoomAlias,
|
||||
});
|
||||
});
|
||||
|
||||
linkifyjs.registerPlugin(LinkifyMatrixOpaqueIdType.UserId, ({ scanner, parser }) => {
|
||||
const token = scanner.tokens.AT as "@";
|
||||
parseOpaqueIdsToMatrixIds({
|
||||
scanner,
|
||||
parser,
|
||||
token,
|
||||
name: LinkifyMatrixOpaqueIdType.UserId,
|
||||
});
|
||||
});
|
||||
|
||||
// 'mxc' is specialcased. They can be linked to
|
||||
linkifyjs.registerCustomProtocol("mxc", false);
|
||||
|
||||
// Linkify supports some common protocols but not others, register all permitted url schemes if unsupported
|
||||
// https://github.com/nfrasser/linkifyjs/blob/main/packages/linkifyjs/src/scanner.mjs#L171-L177
|
||||
// This also handles registering the `matrix:` protocol scheme
|
||||
PERMITTED_URL_SCHEMES.forEach((scheme) => {
|
||||
if (!LinkifySupportedProtocols.includes(scheme)) {
|
||||
linkifyjs.registerCustomProtocol(scheme, LinkifyOptionalSlashProtocols.includes(scheme));
|
||||
}
|
||||
});
|
||||
linkifyJSConfigured = true;
|
||||
}
|
||||
|
||||
configureLinkifyJS();
|
||||
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
@ -267,18 +267,6 @@ importers:
|
||||
katex:
|
||||
specifier: ^0.16.0
|
||||
version: 0.16.33
|
||||
linkify-html:
|
||||
specifier: 4.3.2
|
||||
version: 4.3.2(patch_hash=1761c1eabe25d9fae83f74f27a20b3d24515840a4a8747bb04828df46bcfdea2)(linkifyjs@4.3.2)
|
||||
linkify-react:
|
||||
specifier: 4.3.2
|
||||
version: 4.3.2(linkifyjs@4.3.2)(react@19.2.4)
|
||||
linkify-string:
|
||||
specifier: 4.3.2
|
||||
version: 4.3.2(linkifyjs@4.3.2)
|
||||
linkifyjs:
|
||||
specifier: 4.3.2
|
||||
version: 4.3.2
|
||||
lodash:
|
||||
specifier: npm:lodash-es@^4.17.21
|
||||
version: lodash-es@4.17.23
|
||||
@ -778,6 +766,18 @@ importers:
|
||||
html-react-parser:
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.17(@types/react@19.2.10)(react@19.2.4)
|
||||
linkify-html:
|
||||
specifier: 4.3.2
|
||||
version: 4.3.2(patch_hash=1761c1eabe25d9fae83f74f27a20b3d24515840a4a8747bb04828df46bcfdea2)(linkifyjs@4.3.2)
|
||||
linkify-react:
|
||||
specifier: 4.3.2
|
||||
version: 4.3.2(linkifyjs@4.3.2)(react@19.2.4)
|
||||
linkify-string:
|
||||
specifier: 4.3.2
|
||||
version: 4.3.2(linkifyjs@4.3.2)
|
||||
linkifyjs:
|
||||
specifier: 4.3.2
|
||||
version: 4.3.2
|
||||
lodash:
|
||||
specifier: npm:lodash-es@^4.17.21
|
||||
version: lodash-es@4.17.23
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user