diff --git a/apps/web/package.json b/apps/web/package.json index 100aff42f0..2a0434b168 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -78,10 +78,6 @@ "jsrsasign": "^11.0.0", "jszip": "^3.7.0", "katex": "^0.16.0", - "linkify-html": "4.3.2", - "linkify-react": "4.3.2", - "linkify-string": "4.3.2", - "linkifyjs": "4.3.2", "lodash": "npm:lodash-es@^4.17.21", "maplibre-gl": "^5.0.0", "matrix-encrypt-attachment": "^1.0.3", diff --git a/apps/web/playwright/e2e/links/messages.spec.ts b/apps/web/playwright/e2e/links/messages.spec.ts index 579a070722..1f86e1bc09 100644 --- a/apps/web/playwright/e2e/links/messages.spec.ts +++ b/apps/web/playwright/e2e/links/messages.spec.ts @@ -38,8 +38,7 @@ test.describe("Message links", () => { const linkElement = page.locator(".mx_EventTile_last").getByRole("link", { name: "#aroom:example.org" }); await expect(linkElement).toHaveAttribute("href", "https://matrix.to/#/#aroom:example.org"); }); - test("should linkify text inside a URL preview", { tag: "@screenshot" }, async ({ page, user, app, room, axe }) => { - axe.disableRules("color-contrast"); + test("should linkify text inside a URL preview", async ({ page, user, app, room }) => { await page.route(/.*\/_matrix\/(client\/v1\/media|media\/v3)\/preview_url.*/, (route, request) => { const requestedPage = new URL(request.url()).searchParams.get("url"); expect(requestedPage).toEqual("https://example.org/"); diff --git a/apps/web/res/themes/light/css/_mods.pcss b/apps/web/res/themes/light/css/_mods.pcss index 2764c8762f..4541b4afde 100644 --- a/apps/web/res/themes/light/css/_mods.pcss +++ b/apps/web/res/themes/light/css/_mods.pcss @@ -1,5 +1,5 @@ /* sidebar blurred avatar background */ -// + /* if backdrop-filter is supported, */ /* set the user avatar (if any) as a background so */ /* it can be blurred by the tag panel and room list */ diff --git a/apps/web/src/HtmlUtils.tsx b/apps/web/src/HtmlUtils.tsx index 5112e3f7ce..3fefbba418 100644 --- a/apps/web/src/HtmlUtils.tsx +++ b/apps/web/src/HtmlUtils.tsx @@ -17,14 +17,14 @@ import { decode } from "html-entities"; import { type IContent } from "matrix-js-sdk/src/matrix"; import escapeHtml from "escape-html"; import { getEmojiFromUnicode } from "@matrix-org/emojibase-bindings"; +import { PERMITTED_URL_SCHEMES, LINKIFIED_DATA_ATTRIBUTE } from "@element-hq/web-shared-components"; import SettingsStore from "./settings/SettingsStore"; import { stripHTMLReply, stripPlainReply } from "./utils/Reply"; -import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils"; -import { linkifyHtml, sanitizeHtmlParams, transformTags } from "./Linkify"; +import { sanitizeHtmlParams, transformTags, linkifyHtml } from "./Linkify"; import { graphemeSegmenter } from "./utils/strings"; -export { Linkify, linkifyAndSanitizeHtml } from "./Linkify"; +export { linkifyAndSanitizeHtml } from "./Linkify"; // Anything outside the basic multilingual plane will be a surrogate pair const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/; @@ -323,13 +323,12 @@ function analyseEvent(content: IContent, highlights?: string[], opts: EventRende if (opts.linkify) { // Prevent mutating the source of sanitizeParams. sanitizeParams = { ...sanitizeParams }; - sanitizeParams.allowedClasses ??= {}; - if (typeof sanitizeParams.allowedClasses.a === "boolean") { - // All classes are already allowed for "a" - } else { - sanitizeParams.allowedClasses.a ??= []; - sanitizeParams.allowedClasses.a.push("linkified"); - } + if (typeof sanitizeParams.allowedAttributes === "object") { + const attribs = { ...sanitizeParams.allowedAttributes }; + // We allow data-linkified because TextualBody uses it to passthrough links. + attribs["a"] = [...sanitizeParams.allowedAttributes["a"], `data-${LINKIFIED_DATA_ATTRIBUTE}`]; + sanitizeParams.allowedAttributes = attribs; + } // else: No attibutes are are allowed for "a" } try { diff --git a/apps/web/src/Linkify.tsx b/apps/web/src/Linkify.ts similarity index 52% rename from apps/web/src/Linkify.tsx rename to apps/web/src/Linkify.ts index f324acd9b8..fdb51a86d6 100644 --- a/apps/web/src/Linkify.tsx +++ b/apps/web/src/Linkify.ts @@ -1,4 +1,5 @@ /* +Copyright 2026 Element Creations Ltd. Copyright 2024, 2025 New Vector Ltd. Copyright 2024 The Matrix.org Foundation C.I.C. @@ -6,15 +7,29 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type ReactElement } from "react"; import sanitizeHtml, { type IOptions } from "sanitize-html"; -import { merge } from "lodash"; -import _Linkify from "linkify-react"; +import { + PERMITTED_URL_SCHEMES, + linkifyString as _linkifyString, + linkifyHtml as _linkifyHtml, + LinkifyMatrixOpaqueIdType, + generateLinkedTextOptions, + type LinkEventListener, +} from "@element-hq/web-shared-components"; +import { getHttpUriForMxc, User } from "matrix-js-sdk/src/matrix"; -import { _linkifyString, _linkifyHtml, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix"; -import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks"; +import { ELEMENT_URL_PATTERN } from "./linkify-matrix"; import { mediaFromMxc } from "./customisations/Media"; -import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils"; +import { + parsePermalink, + tryTransformEntityToPermalink, + tryTransformPermalinkToLocalHref, +} from "./utils/permalinks/Permalinks"; +import dis from "./dispatcher/dispatcher"; +import { Action } from "./dispatcher/actions"; +import { type ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload"; +import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; +import { MatrixClientPeg } from "./MatrixClientPeg"; const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/; @@ -29,7 +44,7 @@ export const transformTags: NonNullable = { const transformed = tryTransformPermalinkToLocalHref(attribs.href); // only used to check if it is a link that can be handled locally if ( transformed !== attribs.href || // it could be converted so handle locally symbols e.g. @user:server.tdl, matrix: and matrix.to - attribs.href.match(ELEMENT_URL_PATTERN) // for https links to Element domains + ELEMENT_URL_PATTERN.test(attribs.href) // for https links to Element domains ) { delete attribs.target; } @@ -193,43 +208,199 @@ export const sanitizeHtmlParams: IOptions = { nestingLimit: 50, }; -/* Wrapper around linkify-react merging in our default linkify options */ -export function Linkify({ as, options, children }: React.ComponentProps): ReactElement { - return ( - <_Linkify as={as} options={merge({}, linkifyMatrixOptions, options)}> - {children} - - ); +/** + * Handler function when a UserID link is clicked. + * @param event The click event + * @param userId The linked UserID + */ +function onUserClick(event: MouseEvent, userId: string): void { + event.preventDefault(); + dis.dispatch({ + action: Action.ViewUser, + member: new User(userId), + }); } +/** + * Handler function when a Room Alias link is clicked. + * @param event The click event + * @param roomAlias The linked room alias + */ +function onAliasClick(event: MouseEvent, roomAlias: string): void { + event.preventDefault(); + dis.dispatch({ + action: Action.ViewRoom, + room_alias: roomAlias, + metricsTrigger: "Timeline", + metricsViaKeyboard: false, + }); +} + +/** + * Generates a set of event handlers for a regular URL link. + * + * @param href The link location. + * @returns Event listenenrs compatible with linkifyjs. + */ +function urlEventListeners(href: string): LinkEventListener { + // intercept local permalinks to users and show them like userids (in userinfo of current room) + try { + const permalink = parsePermalink(href); + if (permalink?.userId) { + return { + click: function (e: MouseEvent) { + onUserClick(e, permalink.userId!); + }, + }; + } else { + // for events, rooms etc. (anything other than users) + const localHref = tryTransformPermalinkToLocalHref(href); + if (localHref !== href) { + // it could be converted to a localHref -> therefore handle locally + return { + click: function (e: MouseEvent) { + e.preventDefault(); + globalThis.location.hash = localHref; + }, + }; + } + } + } catch { + // OK fine, it's not actually a permalink + } + return {}; +} + +/** + * Generates a set of event handlers for a UserID link. + * + * @param href A link that contains a userId. + * @returns Event listenenrs compatible with linkifyjs. + */ +export function userIdEventListeners(href: string): LinkEventListener { + return { + click: function (e: MouseEvent) { + e.preventDefault(); + const userId = parsePermalink(href)?.userId ?? href; + if (userId) onUserClick(e, userId); + }, + }; +} + +/** + * Generates a set of event handlers for a UserID link. + * + * @param href A link that contains a room alias. + * @returns Event listenenrs compatible with linkifyjs. + */ +export function roomAliasEventListeners(href: string): LinkEventListener { + return { + click: function (e: MouseEvent) { + e.preventDefault(); + const alias = parsePermalink(href)?.roomIdOrAlias ?? href; + if (alias) onAliasClick(e, alias); + }, + }; +} + +/** + * Generates a `target` attribute for the anchor element + * for the given `href` value. + * + * @param href A URL from a link. + * @returns The resulting `target` value. + */ +function urlTargetTransformFunction(href: string): string { + try { + const transformed = tryTransformPermalinkToLocalHref(href); + if ( + transformed !== href || // if it could be converted to handle locally for matrix symbols e.g. @user:server.tdl and matrix.to + ELEMENT_URL_PATTERN.test(decodeURIComponent(href)) // for https links to Element domains + ) { + return ""; + } else { + return "_blank"; + } + } catch { + // malformed URI + } + return ""; +} + +/** + * Generates the result `href` value based on an incoming `href` value and a link type. + * + * @param href A URL from a link. + * @param type The type of link beinh handled. + * @returns The resulting `href` value. + */ +export function formatHref(href: string, type: LinkifyMatrixOpaqueIdType): string { + switch (type) { + case LinkifyMatrixOpaqueIdType.URL: + if (href.startsWith("mxc://") && MatrixClientPeg.get()) { + return getHttpUriForMxc( + MatrixClientPeg.get()!.baseUrl, + href, + undefined, + undefined, + undefined, + false, + true, + ); + } + // fallthrough + case LinkifyMatrixOpaqueIdType.RoomAlias: + case LinkifyMatrixOpaqueIdType.UserId: + default: { + return tryTransformEntityToPermalink(MatrixClientPeg.safeGet(), href) ?? ""; + } + } +} + +/** + * The standard configuration for a LinkedTextContext.Provider + * within Element Web. + */ +export const LinkedTextConfiguration = { + userIdListener: userIdEventListeners, + roomAliasListener: roomAliasEventListeners, + urlListener: urlEventListeners, + hrefTransformer: formatHref, + urlTargetTransformer: urlTargetTransformFunction, +}; + /** * Linkifies the given string. This is a wrapper around 'linkifyjs/string'. * - * @param {string} str string to linkify - * @param {object} [options] Options for linkifyString. Default: linkifyMatrixOptions - * @returns {string} Linkified string + * @param str string to linkify + * @param [options] Options for linkifyString. + * @returns Linkified string */ -export function linkifyString(str: string, options = linkifyMatrixOptions): string { - return _linkifyString(str, options); +export function linkifyString(value: string, options = generateLinkedTextOptions(LinkedTextConfiguration)): string { + return _linkifyString(value, options); } /** * Linkifies the given HTML-formatted string. This is a wrapper around 'linkifyjs/html'. * - * @param {string} str HTML string to linkify - * @param {object} [options] Options for linkifyHtml. Default: linkifyMatrixOptions - * @returns {string} Linkified string + * @param str HTML string to linkify + * @param [options] Options for linkifyHtml. + * @returns Linkified string */ -export function linkifyHtml(str: string, options = linkifyMatrixOptions): string { - return _linkifyHtml(str, options); +export function linkifyHtml(value: string, options = generateLinkedTextOptions(LinkedTextConfiguration)): string { + return _linkifyHtml(value, options); } + /** * Linkify the given string and sanitize the HTML afterwards. * - * @param {string} dirtyHtml The HTML string to sanitize and linkify - * @param {object} [options] Options for linkifyString. Default: linkifyMatrixOptions - * @returns {string} + * @param dirtyString The string to linkify, and then sanitize. + * @param [options] Options for linkifyString. Default: linkifyMatrixOptions + * @returns HTML string */ -export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrixOptions): string { +export function linkifyAndSanitizeHtml( + dirtyHtml: string, + options = generateLinkedTextOptions(LinkedTextConfiguration), +): string { return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams); } diff --git a/apps/web/src/Markdown.ts b/apps/web/src/Markdown.ts index 6f0e3e0c5e..3bc38c8f7d 100644 --- a/apps/web/src/Markdown.ts +++ b/apps/web/src/Markdown.ts @@ -11,8 +11,7 @@ import "./@types/commonmark"; // import better types than @types/commonmark import * as commonmark from "commonmark"; import { escape } from "lodash"; import { logger } from "matrix-js-sdk/src/logger"; - -import { linkify } from "./linkify-matrix"; +import { findLinksInString } from "@element-hq/web-shared-components"; const ALLOWED_HTML_TAGS = ["sub", "sup", "del", "s", "u", "br", "br/"]; @@ -186,7 +185,7 @@ export default class Markdown { // We should not do this if previous node was not a textnode, as we can't combine it then. if ((node.type === "emph" || node.type === "strong") && previousNode?.type === "text") { if (event.entering) { - const foundLinks = linkify.find(text); + const foundLinks = findLinksInString(text); for (const { value } of foundLinks) { if (node?.firstChild?.literal) { /** @@ -197,7 +196,7 @@ export default class Markdown { const nonEmphasizedText = `${format}${innerNodeLiteral(node)}${format}`; const f = getTextUntilEndOrLinebreak(node); const newText = value + nonEmphasizedText + f; - const newLinks = linkify.find(newText); + const newLinks = findLinksInString(newText); // Should always find only one link here, if it finds more it means that the algorithm is broken if (newLinks.length === 1) { const emphasisTextNode = new commonmark.Node("text"); diff --git a/apps/web/src/components/structures/MatrixChat.tsx b/apps/web/src/components/structures/MatrixChat.tsx index 3ba87f5043..da23b01bef 100644 --- a/apps/web/src/components/structures/MatrixChat.tsx +++ b/apps/web/src/components/structures/MatrixChat.tsx @@ -28,7 +28,7 @@ import { TooltipProvider } from "@vector-im/compound-web"; // what-input helps improve keyboard accessibility import "what-input"; import sanitizeHtml from "sanitize-html"; -import { I18nContext } from "@element-hq/web-shared-components"; +import { I18nContext, LinkedTextContext, LinkedText } from "@element-hq/web-shared-components"; import { LockSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import PosthogTrackers from "../../PosthogTrackers"; @@ -125,7 +125,7 @@ import { viewUserDeviceSettings } from "../../actions/handlers/viewUserDeviceSet import GenericToast from "../views/toasts/GenericToast"; import RovingSpotlightDialog from "../views/dialogs/spotlight/SpotlightDialog"; import { findDMForUser } from "../../utils/dm/findDMForUser"; -import { getHtmlText, Linkify } from "../../HtmlUtils"; +import { getHtmlText } from "../../HtmlUtils"; import { NotificationLevel } from "../../stores/notifications/NotificationLevel"; import { type UserTab } from "../views/dialogs/UserTab"; import { shouldSkipSetupEncryption } from "../../utils/crypto/shouldSkipSetupEncryption"; @@ -139,7 +139,7 @@ import { setTheme } from "../../theme"; import { type OpenForwardDialogPayload } from "../../dispatcher/payloads/OpenForwardDialogPayload"; import { ShareFormat, type SharePayload } from "../../dispatcher/payloads/SharePayload"; import Markdown from "../../Markdown"; -import { sanitizeHtmlParams } from "../../Linkify"; +import { LinkedTextConfiguration, sanitizeHtmlParams } from "../../Linkify"; import { isOnlyAdmin } from "../../utils/membership"; import { ModuleApi } from "../../modules/Api.ts"; @@ -1458,7 +1458,7 @@ export default class MatrixChat extends React.PureComponent { key, title: userNotice.title, props: { - description: {userNotice.description}, + description: {userNotice.description}, primaryLabel: _t("action|ok"), onPrimaryClick: () => { ToastStore.sharedInstance().dismissToast(key); @@ -2291,7 +2291,9 @@ export default class MatrixChat extends React.PureComponent { - {view} + + {view} + diff --git a/apps/web/src/components/structures/SpaceHierarchy.tsx b/apps/web/src/components/structures/SpaceHierarchy.tsx index e799868240..779ac9f5c2 100644 --- a/apps/web/src/components/structures/SpaceHierarchy.tsx +++ b/apps/web/src/components/structures/SpaceHierarchy.tsx @@ -41,6 +41,7 @@ import { sortBy, uniqBy } from "lodash"; import { logger } from "matrix-js-sdk/src/logger"; import { KnownMembership, type SpaceChildEventContent } from "matrix-js-sdk/src/types"; import { ChevronDownIcon, CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { LinkedText } from "@element-hq/web-shared-components"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; @@ -55,7 +56,7 @@ import InfoTooltip from "../views/elements/InfoTooltip"; import TextWithTooltip from "../views/elements/TextWithTooltip"; import { useStateToggle } from "../../hooks/useStateToggle"; import { getChildOrder } from "../../stores/spaces/SpaceStore"; -import { Linkify, topicToHtml } from "../../HtmlUtils"; +import { topicToHtml } from "../../HtmlUtils"; import { useDispatcher } from "../../hooks/useDispatcher"; import { Action } from "../../dispatcher/actions"; import { type IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex"; @@ -233,20 +234,12 @@ const Tile: React.FC = ({ let topicSection: ReactNode | undefined; if (topic) { + // prevent clicks on links from bubbling up to the room tile topicSection = ( - + ev.stopPropagation()}> {" · "} {topic} - + ); } diff --git a/apps/web/src/components/views/dialogs/InteractiveAuthDialog.tsx b/apps/web/src/components/views/dialogs/InteractiveAuthDialog.tsx index 1aa3e7fe68..71c16b47d4 100644 --- a/apps/web/src/components/views/dialogs/InteractiveAuthDialog.tsx +++ b/apps/web/src/components/views/dialogs/InteractiveAuthDialog.tsx @@ -11,6 +11,7 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX } from "react"; import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { type AuthType } from "matrix-js-sdk/src/interactive-auth"; +import { LinkedText } from "@element-hq/web-shared-components"; import { _t } from "../../../languageHandler"; import AccessibleButton from "../elements/AccessibleButton"; @@ -21,7 +22,6 @@ import InteractiveAuth, { } from "../../structures/InteractiveAuth"; import { type ContinueKind, SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents"; import BaseDialog from "./BaseDialog"; -import { Linkify } from "../../../Linkify"; type DialogAesthetics = Partial<{ [x in AuthType]: { @@ -163,9 +163,9 @@ export default class InteractiveAuthDialog extends React.Component - -
{this.state.authError.message || this.state.authError.toString()}
-
+ + {this.state.authError.message || this.state.authError.toString()} +
{_t("action|dismiss")} diff --git a/apps/web/src/components/views/elements/RoomTopic.tsx b/apps/web/src/components/views/elements/RoomTopic.tsx index 34bf1c7cd3..e539c9c7d3 100644 --- a/apps/web/src/components/views/elements/RoomTopic.tsx +++ b/apps/web/src/components/views/elements/RoomTopic.tsx @@ -10,6 +10,7 @@ import React, { type JSX, useCallback, useContext, useState } from "react"; import { type Room, EventType } from "matrix-js-sdk/src/matrix"; import classNames from "classnames"; import { Tooltip } from "@vector-im/compound-web"; +import { LinkedText } from "@element-hq/web-shared-components"; import { useTopic } from "../../../hooks/room/useTopic"; import { _t } from "../../../languageHandler"; @@ -20,7 +21,7 @@ import InfoDialog from "../dialogs/InfoDialog"; import { useDispatcher } from "../../../hooks/useDispatcher"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import AccessibleButton from "./AccessibleButton"; -import { Linkify, topicToHtml } from "../../../HtmlUtils"; +import { topicToHtml } from "../../../HtmlUtils"; import { tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks"; interface IProps extends React.HTMLProps { @@ -74,19 +75,7 @@ export default function RoomTopic({ room, className, ...props }: IProps): JSX.El title: room.name, description: (
- ) { - onClick(e); - modal.close(); - }, - }, - }} - as="p" - > - {body} - + modal.close()}>{body} {canSetTopic && ( - {body} + {body}
); diff --git a/apps/web/src/components/views/messages/TextualBody.tsx b/apps/web/src/components/views/messages/TextualBody.tsx index 5e65dc3271..5c84fa2d9e 100644 --- a/apps/web/src/components/views/messages/TextualBody.tsx +++ b/apps/web/src/components/views/messages/TextualBody.tsx @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX, createRef, type SyntheticEvent, type MouseEvent } from "react"; import { MsgType } from "matrix-js-sdk/src/matrix"; -import { EventContentBodyView } from "@element-hq/web-shared-components"; +import { EventContentBodyView, LINKIFIED_DATA_ATTRIBUTE } from "@element-hq/web-shared-components"; import { EventContentBodyViewModel } from "../../../viewmodels/message-body/EventContentBodyViewModel"; import { formatDate } from "../../../DateUtils"; @@ -26,7 +26,6 @@ import LinkPreviewGroup from "../rooms/LinkPreviewGroup"; import { type IBodyProps } from "./IBodyProps"; import RoomContext from "../../../contexts/RoomContext"; import AccessibleButton from "../elements/AccessibleButton"; -import { options as linkifyOpts } from "../../../linkify-matrix"; import { getParentEventId } from "../../../utils/Reply"; import { EditWysiwygComposer } from "../rooms/wysiwyg_composer"; import { type IEventTileOps } from "../rooms/EventTile"; @@ -250,7 +249,7 @@ export default class TextualBody extends React.Component { private onBodyLinkClick = (e: MouseEvent): void => { let target: HTMLLinkElement | null = e.target as HTMLLinkElement; // links processed by linkifyjs have their own handler so don't handle those here - if (target.classList.contains(linkifyOpts.className as string)) return; + if (target.dataset[LINKIFIED_DATA_ATTRIBUTE]) return; if (target.nodeName !== "A") { // Jump to parent as the `` may contain children, e.g. an anchor wrapping an inline code section target = target.closest("a"); diff --git a/apps/web/src/components/views/right_panel/RoomSummaryCardView.tsx b/apps/web/src/components/views/right_panel/RoomSummaryCardView.tsx index 460f7859d2..4514ee231e 100644 --- a/apps/web/src/components/views/right_panel/RoomSummaryCardView.tsx +++ b/apps/web/src/components/views/right_panel/RoomSummaryCardView.tsx @@ -39,14 +39,14 @@ import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error" import ErrorSolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid"; import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down"; import { JoinRule, type Room } from "matrix-js-sdk/src/matrix"; -import { Box, Flex, HistoryVisibilityBadge } from "@element-hq/web-shared-components"; +import { Box, Flex, HistoryVisibilityBadge, LinkedText } from "@element-hq/web-shared-components"; import BaseCard from "./BaseCard.tsx"; import { _t } from "../../../languageHandler.tsx"; import RoomAvatar from "../avatars/RoomAvatar.tsx"; import { E2EStatus } from "../../../utils/ShieldUtils.ts"; import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks.ts"; -import { Linkify, topicToHtml } from "../../../HtmlUtils.tsx"; +import { topicToHtml } from "../../../HtmlUtils.tsx"; import { useRoomSummaryCardViewModel } from "../../viewmodels/right_panel/RoomSummaryCardViewModel.tsx"; import { useRoomTopicViewModel } from "../../viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx"; import { useRoomName } from "../../../hooks/useRoomName.ts"; @@ -89,7 +89,7 @@ const RoomTopic: React.FC> = ({ room }): JSX.Element | null ); } - const content = vm.expanded ? {body} : body; + const content = vm.expanded ? {body} : body; return ( const range = getRangeForSelection(this.editorRef.current, model, document.getSelection()!); // If the user is pasting a link, and has a range selected which is not a link, wrap the range with the link - if (plainText && range.length > 0 && linkify.test(plainText) && !linkify.test(range.text)) { + if (plainText && range.length > 0 && isLinkable(plainText) && !isLinkable(range.text)) { formatRangeAsLink(range, plainText); } else { replaceRangeAndMoveCaret(range, parts); diff --git a/apps/web/src/components/views/rooms/LinkPreviewWidget.tsx b/apps/web/src/components/views/rooms/LinkPreviewWidget.tsx index 0bcbaa940a..5e0b42f650 100644 --- a/apps/web/src/components/views/rooms/LinkPreviewWidget.tsx +++ b/apps/web/src/components/views/rooms/LinkPreviewWidget.tsx @@ -9,8 +9,8 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX, type ComponentProps, createRef, type ReactNode } from "react"; import { decode } from "html-entities"; import { type MatrixEvent, type IPreviewUrlResponse } from "matrix-js-sdk/src/matrix"; +import { LinkedText } from "@element-hq/web-shared-components"; -import { Linkify } from "../../../HtmlUtils"; import Modal from "../../../Modal"; import * as ImageUtils from "../../../ImageUtils"; import { mediaFromMxc } from "../../../customisations/Media"; @@ -128,7 +128,7 @@ export default class LinkPreviewWidget extends React.Component { )}
- {description} + {description}
diff --git a/apps/web/src/components/views/rooms/NewRoomIntro.tsx b/apps/web/src/components/views/rooms/NewRoomIntro.tsx index b486b5e059..1dec30785d 100644 --- a/apps/web/src/components/views/rooms/NewRoomIntro.tsx +++ b/apps/web/src/components/views/rooms/NewRoomIntro.tsx @@ -6,11 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, useContext } from "react"; +import React, { type JSX, useContext, useMemo } from "react"; import { EventType, type Room, type User, type MatrixClient } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { ErrorSolidIcon, UserAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { EventTileBubble } from "@element-hq/web-shared-components"; +import { EventTileBubble, LinkedText } from "@element-hq/web-shared-components"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import DMRoomMap from "../../../utils/DMRoomMap"; @@ -32,7 +32,7 @@ import { LocalRoom } from "../../../models/LocalRoom"; import { shouldEncryptRoomWithSingle3rdPartyInvite } from "../../../utils/room/shouldEncryptRoomWithSingle3rdPartyInvite"; import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; import { useTopic } from "../../../hooks/room/useTopic"; -import { topicToHtml, Linkify } from "../../../HtmlUtils"; +import { topicToHtml } from "../../../HtmlUtils"; function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean { const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId); @@ -56,14 +56,23 @@ const NewRoomIntro: React.FC = () => { const cli = useContext(MatrixClientContext); const { room, roomId } = useScopedRoomContext("room", "roomId"); const topic = useTopic(room); + const isLocalRoom = room instanceof LocalRoom; + let dmPartner: string | undefined; + if (isLocalRoom) { + dmPartner = room?.targets[0]?.userId; + } else if (roomId) { + dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId); + } + + const renderedTopic = useMemo( + () => (dmPartner ? undefined : {topicToHtml(topic?.text, topic?.html)}), + [topic, dmPartner], + ); if (!room || !roomId) { throw new Error("Unable to create a NewRoomIntro without room and roomId"); } - const isLocalRoom = room instanceof LocalRoom; - const dmPartner = isLocalRoom ? room.targets[0]?.userId : DMRoomMap.shared().getUserIdForRoomId(roomId); - let body: JSX.Element; if (dmPartner) { const { shouldEncrypt: encryptedSingle3rdPartyInvite } = shouldEncryptRoomWithSingle3rdPartyInvite(room); @@ -137,15 +146,11 @@ const NewRoomIntro: React.FC = () => { {sub}
), - topic: () => {topicToHtml(topic?.text, topic?.html)}, + topic: renderedTopic, }, ); } else if (topic) { - topicText = _t( - "room|intro|display_topic", - {}, - { topic: () => {topicToHtml(topic?.text, topic?.html)} }, - ); + topicText = _t("room|intro|display_topic", {}, { topic: renderedTopic }); } else if (canAddTopic) { topicText = _t( "room|intro|no_topic", diff --git a/apps/web/src/linkify-matrix.ts b/apps/web/src/linkify-matrix.ts index b6ed8ee7fc..54b7b25962 100644 --- a/apps/web/src/linkify-matrix.ts +++ b/apps/web/src/linkify-matrix.ts @@ -1,4 +1,5 @@ /* +Copyright 2026 Element Creations Ltd. Copyright 2024 New Vector Ltd. Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2015, 2016 OpenMarket Ltd @@ -7,291 +8,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import * as linkifyjs from "linkifyjs"; -import { type EventListeners, type Opts, registerCustomProtocol, registerPlugin } from "linkifyjs"; -import linkifyString from "linkify-string"; -import linkifyHtml from "linkify-html"; -import { getHttpUriForMxc, User } from "matrix-js-sdk/src/matrix"; - -import { - parsePermalink, - tryTransformEntityToPermalink, - tryTransformPermalinkToLocalHref, -} from "./utils/permalinks/Permalinks"; -import dis from "./dispatcher/dispatcher"; -import { Action } from "./dispatcher/actions"; -import { type ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload"; -import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; -import { MatrixClientPeg } from "./MatrixClientPeg"; -import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils"; - -export enum Type { - URL = "url", - UserId = "userid", - RoomAlias = "roomalias", -} - -function matrixOpaqueIdLinkifyParser({ - scanner, - parser, - token, - name, -}: { - scanner: linkifyjs.ScannerInit; - parser: linkifyjs.ParserInit; - token: "#" | "+" | "@"; - name: Type; -}): void { - const { - DOT, - // IPV4 necessity - NUM, - COLON, - SYM, - SLASH, - EQUALS, - HYPHEN, - UNDERSCORE, - } = scanner.tokens; - - // Contains NUM, WORD, UWORD, EMOJI, TLD, UTLD, SCHEME, SLASH_SCHEME and LOCALHOST plus custom protocols (e.g. "matrix") - const { domain } = scanner.tokens.groups; - - // Tokens we need that are not contained in the domain group - const additionalLocalpartTokens = [DOT, SYM, SLASH, EQUALS, UNDERSCORE, HYPHEN]; - const additionalDomainpartTokens = [HYPHEN]; - - const matrixToken = linkifyjs.createTokenClass(name, { isLink: true }); - const matrixTokenState = new linkifyjs.State(matrixToken) as any as linkifyjs.State; // linkify doesn't appear to type this correctly - - const matrixTokenWithPort = linkifyjs.createTokenClass(name, { isLink: true }); - const matrixTokenWithPortState = new linkifyjs.State( - matrixTokenWithPort, - ) as any as linkifyjs.State; // linkify doesn't appear to type this correctly - - const INITIAL_STATE = parser.start.tt(token); - - // Localpart - const LOCALPART_STATE = new linkifyjs.State(); - INITIAL_STATE.ta(domain, LOCALPART_STATE); - INITIAL_STATE.ta(additionalLocalpartTokens, LOCALPART_STATE); - LOCALPART_STATE.ta(domain, LOCALPART_STATE); - LOCALPART_STATE.ta(additionalLocalpartTokens, LOCALPART_STATE); - - // Domainpart - const DOMAINPART_STATE_DOT = LOCALPART_STATE.tt(COLON); - DOMAINPART_STATE_DOT.ta(domain, matrixTokenState); - DOMAINPART_STATE_DOT.ta(additionalDomainpartTokens, matrixTokenState); - matrixTokenState.ta(domain, matrixTokenState); - matrixTokenState.ta(additionalDomainpartTokens, matrixTokenState); - matrixTokenState.tt(DOT, DOMAINPART_STATE_DOT); - - // Port suffixes - matrixTokenState.tt(COLON).tt(NUM, matrixTokenWithPortState); -} - -function onUserClick(event: MouseEvent, userId: string): void { - event.preventDefault(); - dis.dispatch({ - action: Action.ViewUser, - member: new User(userId), - }); -} - -function onAliasClick(event: MouseEvent, roomAlias: string): void { - event.preventDefault(); - dis.dispatch({ - action: Action.ViewRoom, - room_alias: roomAlias, - metricsTrigger: "Timeline", - metricsViaKeyboard: false, - }); -} - const escapeRegExp = function (s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }; // Recognise URLs from both our local and official Element deployments. // Anyone else really should be using matrix.to. vector:// allowed to support Element Desktop relative links. -export const ELEMENT_URL_PATTERN = +export const ELEMENT_URL_PATTERN = new RegExp( "^(?:vector://|https?://)?(?:" + - escapeRegExp(window.location.host + window.location.pathname) + - "|" + - "(?:www\\.)?(?:riot|vector)\\.im/(?:app|beta|staging|develop)/|" + - "(?:app|beta|staging|develop)\\.element\\.io/" + - ")(#.*)"; - -// Attach click handlers to links based on their type -function events(href: string, type: string): EventListeners { - switch (type as Type) { - case Type.URL: { - // intercept local permalinks to users and show them like userids (in userinfo of current room) - try { - const permalink = parsePermalink(href); - if (permalink?.userId) { - return { - click: function (e: MouseEvent) { - onUserClick(e, permalink.userId!); - }, - }; - } else { - // for events, rooms etc. (anything other than users) - const localHref = tryTransformPermalinkToLocalHref(href); - if (localHref !== href) { - // it could be converted to a localHref -> therefore handle locally - return { - click: function (e: MouseEvent) { - e.preventDefault(); - window.location.hash = localHref; - }, - }; - } - } - } catch { - // OK fine, it's not actually a permalink - } - break; - } - case Type.UserId: - return { - click: function (e: MouseEvent) { - e.preventDefault(); - const userId = parsePermalink(href)?.userId ?? href; - if (userId) onUserClick(e, userId); - }, - }; - case Type.RoomAlias: - return { - click: function (e: MouseEvent) { - e.preventDefault(); - const alias = parsePermalink(href)?.roomIdOrAlias ?? href; - if (alias) onAliasClick(e, alias); - }, - }; - } - - return {}; -} - -// linkify-react doesn't respect `events` and needs it mapping to React attributes -// so we need to manually add the click handler to the attributes -// https://linkify.js.org/docs/linkify-react.html#events -function attributes(href: string, type: string): Record { - const attrs: Record = { - rel: "noreferrer noopener", - }; - - const options = events(href, type); - if (options?.click) { - attrs.onClick = options.click; - } - - return attrs; -} - -export const options: Opts = { - events, - - formatHref: function (href: string, type: Type | string): string { - switch (type) { - case "url": - if (href.startsWith("mxc://") && MatrixClientPeg.get()) { - return getHttpUriForMxc( - MatrixClientPeg.get()!.baseUrl, - href, - undefined, - undefined, - undefined, - false, - true, - ); - } - // fallthrough - case Type.RoomAlias: - case Type.UserId: - default: { - return tryTransformEntityToPermalink(MatrixClientPeg.safeGet(), href) ?? ""; - } - } - }, - - attributes, - - ignoreTags: ["a", "pre", "code"], - - className: "linkified", - - target: function (href: string, type: Type | string): string { - if (type === Type.URL) { - try { - const transformed = tryTransformPermalinkToLocalHref(href); - if ( - transformed !== href || // if it could be converted to handle locally for matrix symbols e.g. @user:server.tdl and matrix.to - decodeURIComponent(href).match(ELEMENT_URL_PATTERN) // for https links to Element domains - ) { - return ""; - } else { - return "_blank"; - } - } catch { - // malformed URI - } - } - return ""; - }, -}; - -// Run the plugins -registerPlugin(Type.RoomAlias, ({ scanner, parser }) => { - const token = scanner.tokens.POUND as "#"; - matrixOpaqueIdLinkifyParser({ - scanner, - parser, - token, - name: Type.RoomAlias, - }); -}); - -registerPlugin(Type.UserId, ({ scanner, parser }) => { - const token = scanner.tokens.AT as "@"; - matrixOpaqueIdLinkifyParser({ - scanner, - parser, - token, - name: Type.UserId, - }); -}); - -// Linkify supports some common protocols but not others, register all permitted url schemes if unsupported -// https://github.com/Hypercontext/linkifyjs/blob/f4fad9df1870259622992bbfba38bfe3d0515609/packages/linkifyjs/src/scanner.js#L133-L141 -// This also handles registering the `matrix:` protocol scheme -const linkifySupportedProtocols = ["file", "mailto", "http", "https", "ftp", "ftps"]; -const optionalSlashProtocols = [ - "bitcoin", - "geo", - "im", - "magnet", - "mailto", - "matrix", - "news", - "openpgp4fpr", - "sip", - "sms", - "smsto", - "tel", - "urn", - "xmpp", -]; - -PERMITTED_URL_SCHEMES.forEach((scheme) => { - if (!linkifySupportedProtocols.includes(scheme)) { - registerCustomProtocol(scheme, optionalSlashProtocols.includes(scheme)); - } -}); - -registerCustomProtocol("mxc", false); - -export const linkify = linkifyjs; -export const _linkifyString = linkifyString; -export const _linkifyHtml = linkifyHtml; + escapeRegExp(window.location.host + window.location.pathname) + + "|" + + "(?:www\\.)?(?:riot|vector)\\.im/(?:app|beta|staging|develop)/|" + + "(?:app|beta|staging|develop)\\.element\\.io/" + + ")(#.*)", +); diff --git a/apps/web/src/slash-commands/SlashCommands.tsx b/apps/web/src/slash-commands/SlashCommands.tsx index a566a5f093..f3e1d1ed85 100644 --- a/apps/web/src/slash-commands/SlashCommands.tsx +++ b/apps/web/src/slash-commands/SlashCommands.tsx @@ -20,12 +20,13 @@ import { } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { KnownMembership, type RoomMemberEventContent } from "matrix-js-sdk/src/types"; +import { LinkedText } from "@element-hq/web-shared-components"; import dis from "../dispatcher/dispatcher"; import { _t, _td, UserFriendlyError } from "../languageHandler"; import Modal from "../Modal"; import MultiInviter from "../utils/MultiInviter"; -import { Linkify, topicToHtml } from "../HtmlUtils"; +import { topicToHtml } from "../HtmlUtils"; import QuestionDialog from "../components/views/dialogs/QuestionDialog"; import WidgetUtils from "../utils/WidgetUtils"; import { textToHtmlRainbow } from "../utils/colour"; @@ -270,7 +271,7 @@ export const Commands = [ Modal.createDialog(InfoDialog, { title: room.name, - description: {body}, + description: {body}, hasCloseButton: true, className: "markdown-body", }); diff --git a/apps/web/src/utils/Reply.ts b/apps/web/src/utils/Reply.ts index e5909ffcc0..3b595e8693 100644 --- a/apps/web/src/utils/Reply.ts +++ b/apps/web/src/utils/Reply.ts @@ -1,4 +1,5 @@ /* + * Copyright 2026 Element Creations Ltd. * Copyright 2024 New Vector Ltd. * Copyright 2023 The Matrix.org Foundation C.I.C. * Copyright 2021 Šimon Brandner @@ -9,8 +10,7 @@ import { type IContent, type IEventRelation, type MatrixEvent, THREAD_RELATION_TYPE } from "matrix-js-sdk/src/matrix"; import sanitizeHtml from "sanitize-html"; - -import { PERMITTED_URL_SCHEMES } from "./UrlUtils"; +import { PERMITTED_URL_SCHEMES } from "@element-hq/web-shared-components"; export function getParentEventId(ev?: MatrixEvent): string | undefined { if (!ev || ev.isRedacted()) return; diff --git a/apps/web/src/utils/UrlUtils.ts b/apps/web/src/utils/UrlUtils.ts index d256e655c2..fcb720414e 100644 --- a/apps/web/src/utils/UrlUtils.ts +++ b/apps/web/src/utils/UrlUtils.ts @@ -49,31 +49,3 @@ export function parseUrl(u: string): URL { } return new URL(u); } - -export const PERMITTED_URL_SCHEMES = [ - "bitcoin", - "ftp", - "geo", - "http", - "https", - "im", - "irc", - "ircs", - "magnet", - "mailto", - "matrix", - "mms", - "news", - "nntp", - "openpgp4fpr", - "sip", - "sftp", - "sms", - "smsto", - "ssh", - "tel", - "urn", - "webcal", - "wtai", - "xmpp", -]; diff --git a/apps/web/test/unit-tests/HtmlUtils-test.tsx b/apps/web/test/unit-tests/HtmlUtils-test.tsx index 690f2a713a..e56d7203cc 100644 --- a/apps/web/test/unit-tests/HtmlUtils-test.tsx +++ b/apps/web/test/unit-tests/HtmlUtils-test.tsx @@ -101,7 +101,7 @@ describe("bodyToHtml", () => { ); expect(html).toMatchInlineSnapshot( - `"foo http://link.example/test/path bar"`, + `"foo http://link.example/test/path bar"`, ); }); @@ -124,6 +124,27 @@ describe("bodyToHtml", () => { ); }); + it("should ignore data-linkified in incoming links but should be applied to linkified links", () => { + getMockClientWithEventEmitter({}); + const html = bodyToHtml( + { + body: "foo http://link.example/test/path bar", + msgtype: "m.text", + formatted_body: + 'foo http://link.example/test/path bar with https://example.org', + format: "org.matrix.custom.html", + }, + [], + { + linkify: true, + }, + ); + + expect(html).toMatchInlineSnapshot( + `"foo http://link.example/test/path bar with https://example.org"`, + ); + }); + it("does not mistake characters in text presentation mode for emoji", () => { const { asFragment } = render( diff --git a/apps/web/test/unit-tests/components/views/elements/RoomTopic-test.tsx b/apps/web/test/unit-tests/components/views/elements/RoomTopic-test.tsx index 47bc39fdd7..e53af36b71 100644 --- a/apps/web/test/unit-tests/components/views/elements/RoomTopic-test.tsx +++ b/apps/web/test/unit-tests/components/views/elements/RoomTopic-test.tsx @@ -10,6 +10,7 @@ import React from "react"; import { Room } from "matrix-js-sdk/src/matrix"; import { fireEvent, render, screen, waitFor } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; +import { LinkedTextContext } from "@element-hq/web-shared-components"; import { mkEvent, stubClient } from "../../../../test-utils"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; @@ -52,7 +53,9 @@ describe("", () => { */ const renderRoom = (topic: string) => { const room = createRoom(topic); - render(); + render(, { + wrapper: ({ children }) => {children}, + }); }; /** diff --git a/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx b/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx index 924473a692..7060c91418 100644 --- a/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx @@ -193,7 +193,7 @@ describe("", () => { const { container } = getComponent({ mxEvent: ev }); const content = container.querySelector(".mx_EventTile_body"); expect(content.innerHTML).toMatchInlineSnapshot( - `"Chat with @user:example.com"`, + `"Chat with @user:example.com"`, ); }); @@ -211,7 +211,7 @@ describe("", () => { const { container } = getComponent({ mxEvent: ev }); const content = container.querySelector(".mx_EventTile_body"); expect(content.innerHTML).toMatchInlineSnapshot( - `"Visit #room:example.com"`, + `"Visit #room:example.com"`, ); }); diff --git a/apps/web/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap b/apps/web/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap index 40d88d54bd..3e071d8cbf 100644 --- a/apps/web/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap @@ -557,7 +557,7 @@ exports[` renders plain-text m.text correctly linkification get a > Visit ", () => { return render(, { wrapper: ({ children }) => ( - {children} + + {children} + ), }); }; diff --git a/apps/web/test/unit-tests/components/views/right_panel/__snapshots__/RoomSummaryCardView-test.tsx.snap b/apps/web/test/unit-tests/components/views/right_panel/__snapshots__/RoomSummaryCardView-test.tsx.snap index 23da533e9f..cbd83caff9 100644 --- a/apps/web/test/unit-tests/components/views/right_panel/__snapshots__/RoomSummaryCardView-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/right_panel/__snapshots__/RoomSummaryCardView-test.tsx.snap @@ -142,9 +142,13 @@ exports[` has button to edit topic 1`] = ` class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31" > - This is the room's topic. + + This is the room's topic. +