Skip to content
Merged
Show file tree
Hide file tree
Changes from 47 commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
a253f84
Port over linkifyJS to shared-components.
Half-Shot Mar 5, 2026
a8efa1b
Drop rubbish
Half-Shot Mar 5, 2026
db34d93
update lock
Half-Shot Mar 5, 2026
6a57306
quickfix test
Half-Shot Mar 5, 2026
9a88517
drop group id
Half-Shot Mar 5, 2026
291aa48
Modernize tests
Half-Shot Mar 5, 2026
bc362bd
Remove stories that aren't in use.
Half-Shot Mar 5, 2026
378abba
Complete working version
Half-Shot Mar 5, 2026
7198404
Add copyright
Half-Shot Mar 5, 2026
9cc4a6c
tidy up
Half-Shot Mar 5, 2026
0ef3801
update lock
Half-Shot Mar 5, 2026
e8bd436
Update snaps
Half-Shot Mar 5, 2026
6488fdb
update snap
Half-Shot Mar 5, 2026
c0f3e37
undo change
Half-Shot Mar 5, 2026
c9cf144
remove unused
Half-Shot Mar 5, 2026
c52d59d
More test updates
Half-Shot Mar 5, 2026
ba405f5
fix typo
Half-Shot Mar 5, 2026
878baef
fix margin on preview
Half-Shot Mar 5, 2026
861730c
move margin block
Half-Shot Mar 5, 2026
79ea08a
snapupdate
Half-Shot Mar 5, 2026
f019cf3
prettier
Half-Shot Mar 5, 2026
d7a42d4
cleanup a test mistake
Half-Shot Mar 6, 2026
57e2d80
Fixup sonar issues
Half-Shot Mar 6, 2026
609da31
Don't expose linkifyjs to applications, just provide helper functions.
Half-Shot Mar 6, 2026
db98064
Add story for documentation.
Half-Shot Mar 6, 2026
6068490
remove $
Half-Shot Mar 6, 2026
cd36c02
Use a const
Half-Shot Mar 6, 2026
9549c7c
typo
Half-Shot Mar 6, 2026
4263b28
cleanup var name
Half-Shot Mar 6, 2026
4ca3c93
Merge remote-tracking branch 'origin/develop' into hs/partial-linkify…
Half-Shot Mar 9, 2026
548b376
remove console line
Half-Shot Mar 9, 2026
a83e18a
Changes checkpoint
Half-Shot Mar 9, 2026
1707c33
Convert to context
Half-Shot Mar 9, 2026
e2aa463
Revert unrelated change.
Half-Shot Mar 10, 2026
51b8c6f
more cleanup
Half-Shot Mar 10, 2026
d9dd9cc
Add a test to cover ignoring incoming data elements
Half-Shot Mar 10, 2026
bceed65
Make tests happy
Half-Shot Mar 10, 2026
813b295
Update tests for LinkedText
Half-Shot Mar 10, 2026
c55a595
Underlines!
Half-Shot Mar 10, 2026
d1211f8
fix lock
Half-Shot Mar 10, 2026
9bd6339
remove unused linkify packages
Half-Shot Mar 10, 2026
b048b00
import move
Half-Shot Mar 10, 2026
34f0c35
Merge remote-tracking branch 'origin/develop' into hs/partial-linkify…
Half-Shot Mar 10, 2026
73404ad
Remove mod to remove underline
Half-Shot Mar 10, 2026
d92f671
undo
Half-Shot Mar 10, 2026
e50b126
fix snap
Half-Shot Mar 10, 2026
6dc68b7
another snapshot fix
Half-Shot Mar 10, 2026
eba6cf0
Tidy up based on review.
Half-Shot Mar 11, 2026
d606872
fix story
Half-Shot Mar 11, 2026
a9afb3d
Pass in args
Half-Shot Mar 12, 2026
5913893
Merge remote-tracking branch 'origin/develop' into hs/partial-linkify…
Half-Shot Mar 12, 2026
4e6ef15
Merge branch 'develop' into hs/partial-linkify-move
Half-Shot Mar 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 1 addition & 2 deletions apps/web/playwright/e2e/links/messages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/");
Expand Down
2 changes: 1 addition & 1 deletion apps/web/res/themes/light/css/_mods.pcss
Original file line number Diff line number Diff line change
@@ -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 */
Expand Down
19 changes: 9 additions & 10 deletions apps/web/src/HtmlUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])/;
Expand Down Expand Up @@ -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 {
Expand Down
181 changes: 153 additions & 28 deletions apps/web/src/Linkify.tsx → apps/web/src/Linkify.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2024 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 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)\/(.+?)\/(.+?)(?:[?/]|$)/;
Expand All @@ -29,7 +44,7 @@
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
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drive-by sonarcloud happiness change (it seems to prefer RegEx.test over String.Match)

) {
delete attribs.target;
}
Expand Down Expand Up @@ -78,7 +93,7 @@
return { tagName, attribs };
},
"code": function (tagName: string, attribs: sanitizeHtml.Attributes) {
if (typeof attribs.class !== "undefined") {

Check warning on line 96 in apps/web/src/Linkify.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Compare with `undefined` directly instead of using `typeof`.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZzXbzQX1Cd4SiunGwYX&open=AZzXbzQX1Cd4SiunGwYX&pullRequest=32731
// Filter out all classes other than ones starting with language- for syntax highlighting.
const classes = attribs.class.split(/\s/).filter(function (cl) {
return cl.startsWith("language-") && !cl.startsWith("language-_");
Expand Down Expand Up @@ -193,43 +208,153 @@
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>
);
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,
});
}

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 {};
}

export function userIdEventListeners(href: string): LinkEventListener {
return {
click: function (e: MouseEvent) {
e.preventDefault();
const userId = parsePermalink(href)?.userId ?? href;
if (userId) onUserClick(e, userId);
},
};
}

export function roomAliasEventListeners(href: string): LinkEventListener {
return {
click: function (e: MouseEvent) {
e.preventDefault();
const alias = parsePermalink(href)?.roomIdOrAlias ?? href;
if (alias) onAliasClick(e, alias);
},
};
}

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 "";
}

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) ?? "";
}
}
}

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);
}
7 changes: 3 additions & 4 deletions apps/web/src/Markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/"];

Expand Down Expand Up @@ -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) {
/**
Expand All @@ -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");
Expand Down
12 changes: 7 additions & 5 deletions apps/web/src/components/structures/MatrixChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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>
Expand Down
Loading
Loading