diff --git a/docs/install.md b/docs/install.md index 5f9e6ddd2e..d80a844778 100644 --- a/docs/install.md +++ b/docs/install.md @@ -11,7 +11,7 @@ There are some exceptions like when using localhost, which is considered a [secu 1. Download the latest version from 1. Untar the tarball on your web server 1. Move (or symlink) the `element-x.x.x` directory to an appropriate name -1. Configure the correct caching headers in your webserver (see below) +1. Configure the correct caching headers in your webserver (see [README.md](../README.md#caching-requirements)) 1. Configure the app by copying `config.sample.json` to `config.json` and modifying it. See the [configuration docs](config.md) for details. 1. Enter the URL into your browser and log into Element! diff --git a/package.json b/package.json index 4b5456f4ec..a5be95ac59 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "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", diff --git a/playwright/pages/ElementAppPage.ts b/playwright/pages/ElementAppPage.ts index 82497317c3..d7689230e1 100644 --- a/playwright/pages/ElementAppPage.ts +++ b/playwright/pages/ElementAppPage.ts @@ -218,7 +218,7 @@ export class ElementAppPage { */ public async inviteUserToCurrentRoom(userId: string): Promise { await this.toggleRoomInfoPanel(); // TODO skip this if the room info panel is already open - await this.page.getByLabel("Right panel").getByRole("menuitem", { name: "Invite" }).click(); + await this.page.getByTestId("right-panel").getByRole("menuitem", { name: "Invite" }).click(); const input = this.page.getByRole("dialog").getByTestId("invite-dialog-input"); await input.fill(userId); diff --git a/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png b/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png index df1a991e9a..a4f6a476f6 100644 Binary files a/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png and b/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png differ diff --git a/playwright/testcontainers/mas.ts b/playwright/testcontainers/mas.ts index 51bd171f6f..8c609e7daa 100644 --- a/playwright/testcontainers/mas.ts +++ b/playwright/testcontainers/mas.ts @@ -10,7 +10,7 @@ import { type StartedPostgreSqlContainer, } from "@element-hq/element-web-playwright-common/lib/testcontainers"; -const TAG = "main@sha256:0a72a3ecb38e961e45062de91a9afa6d8f7319ddba460b8141b4e6a1bab45ea1"; +const TAG = "main@sha256:9038b286c20370b0c9a4aacfd540ad741d72ed3f5fda3fa556498d4ee5909d55"; /** * MatrixAuthenticationServiceContainer which freezes the docker digest to diff --git a/playwright/testcontainers/synapse.ts b/playwright/testcontainers/synapse.ts index 1e656110bb..32a4631377 100644 --- a/playwright/testcontainers/synapse.ts +++ b/playwright/testcontainers/synapse.ts @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers"; -const TAG = "develop@sha256:51e3ca5c351669569a945ad3cbfd11c803d19f7c32bdf6727ccffb3808a61a97"; +const TAG = "develop@sha256:eccb2cb1cbca46831b1851fe0ae130551be5b6cceeef42886bce6da19d46bd98"; /** * SynapseContainer which freezes the docker digest to stabilise tests, diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 1d17710dab..027a51ea72 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -22,7 +22,7 @@ import { getEmojiFromUnicode } from "@matrix-org/emojibase-bindings"; import SettingsStore from "./settings/SettingsStore"; import { stripHTMLReply, stripPlainReply } from "./utils/Reply"; import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils"; -import { sanitizeHtmlParams, transformTags } from "./Linkify"; +import { linkifyHtml, sanitizeHtmlParams, transformTags } from "./Linkify"; import { graphemeSegmenter } from "./utils/strings"; export { Linkify, linkifyAndSanitizeHtml } from "./Linkify"; @@ -298,6 +298,7 @@ export interface EventRenderOpts { * Should inline media be rendered? */ mediaIsVisible?: boolean; + linkify?: boolean; } function analyseEvent(content: IContent, highlights: Optional, opts: EventRenderOpts = {}): EventAnalysis { @@ -320,6 +321,18 @@ function analyseEvent(content: IContent, highlights: Optional, opts: E }; } + 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"); + } + } + try { const isFormattedBody = content.format === "org.matrix.custom.html" && typeof content.formatted_body === "string"; @@ -346,19 +359,26 @@ function analyseEvent(content: IContent, highlights: Optional, opts: E ? new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink) : null; + if (highlighter) { + // XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying + // to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which + // are interrupted by HTML tags (not that we did before) - e.g. foobar won't get highlighted + // by an attempt to search for 'foobar'. Then again, the search query probably wouldn't work either + // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure. + sanitizeParams.textFilter = function (safeText) { + return highlighter.applyHighlights(safeText, safeHighlights!).join(""); + }; + } + if (isFormattedBody) { - if (highlighter) { - // XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying - // to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which - // are interrupted by HTML tags (not that we did before) - e.g. foobar won't get highlighted - // by an attempt to search for 'foobar'. Then again, the search query probably wouldn't work either - // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure. - sanitizeParams.textFilter = function (safeText) { - return highlighter.applyHighlights(safeText, safeHighlights!).join(""); - }; + let unsafeBody = formattedBody!; + + if (opts.linkify) { + unsafeBody = linkifyHtml(unsafeBody); } - safeBody = sanitizeHtml(formattedBody!, sanitizeParams); + safeBody = sanitizeHtml(unsafeBody, sanitizeParams); + const phtml = new DOMParser().parseFromString(safeBody, "text/html"); const isPlainText = phtml.body.innerHTML === phtml.body.textContent; isHtmlMessage = !isPlainText; @@ -373,6 +393,9 @@ function analyseEvent(content: IContent, highlights: Optional, opts: E }); safeBody = phtml.body.innerHTML; } + } else if (opts.linkify) { + // If we are linkifying plain text, pass the result through sanitizeHtml so that the highlighter configured in sanitizeParams.textFilter gets applied. + safeBody = sanitizeHtml(linkifyHtml(escapeHtml(plainBody)), sanitizeParams); } else if (highlighter) { safeBody = highlighter.applyHighlights(escapeHtml(plainBody), safeHighlights!).join(""); } @@ -428,14 +451,15 @@ export function bodyToNode( }); let formattedBody = eventInfo.safeBody; - if (eventInfo.isFormattedBody && eventInfo.bodyHasEmoji && eventInfo.safeBody) { - // This has to be done after the emojiBody check as to not break big emoji on replies - formattedBody = formatEmojis(eventInfo.safeBody, true).join(""); - } - let emojiBodyElements: JSX.Element[] | undefined; - if (!eventInfo.safeBody && eventInfo.bodyHasEmoji) { - emojiBodyElements = formatEmojis(eventInfo.strippedBody, false) as JSX.Element[]; + + if (eventInfo.bodyHasEmoji) { + if (eventInfo.safeBody) { + // This has to be done after the emojiBody check as to not break big emoji on replies + formattedBody = formatEmojis(eventInfo.safeBody, true).join(""); + } else { + emojiBodyElements = formatEmojis(eventInfo.strippedBody, false) as JSX.Element[]; + } } return { strippedBody: eventInfo.strippedBody, formattedBody, emojiBodyElements, className }; @@ -458,7 +482,7 @@ export function bodyToHtml(content: IContent, highlights: Optional, op const eventInfo = analyseEvent(content, highlights, opts); let formattedBody = eventInfo.safeBody; - if (eventInfo.isFormattedBody && eventInfo.bodyHasEmoji && formattedBody) { + if (eventInfo.bodyHasEmoji && formattedBody) { // This has to be done after the emojiBody check above as to not break big emoji on replies formattedBody = formatEmojis(eventInfo.safeBody, true).join(""); } diff --git a/src/Linkify.tsx b/src/Linkify.tsx index 846bf8e82d..f324acd9b8 100644 --- a/src/Linkify.tsx +++ b/src/Linkify.tsx @@ -11,7 +11,7 @@ import sanitizeHtml, { type IOptions } from "sanitize-html"; import { merge } from "lodash"; import _Linkify from "linkify-react"; -import { _linkifyString, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix"; +import { _linkifyString, _linkifyHtml, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix"; import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks"; import { mediaFromMxc } from "./customisations/Media"; import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils"; @@ -213,6 +213,16 @@ export function linkifyString(str: string, options = linkifyMatrixOptions): stri return _linkifyString(str, 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 + */ +export function linkifyHtml(str: string, options = linkifyMatrixOptions): string { + return _linkifyHtml(str, options); +} /** * Linkify the given string and sanitize the HTML afterwards. * diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index e006b1d92a..24c1655455 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -34,7 +34,6 @@ import { Action } from "../../dispatcher/actions"; import { type XOR } from "../../@types/common"; import ExtensionsCard from "../views/right_panel/ExtensionsCard"; import MemberListView from "../views/rooms/MemberList/MemberListView"; -import { _t } from "../../languageHandler"; interface BaseProps { overwriteCard?: IRightPanelCard; // used to display a custom card and ignoring the RightPanelStore (used for UserView) @@ -65,7 +64,6 @@ interface IState { export default class RightPanel extends React.Component { public static contextType = MatrixClientContext; declare public context: React.ContextType; - private ref = React.createRef(); public constructor(props: Props) { super(props); @@ -84,7 +82,6 @@ export default class RightPanel extends React.Component { public componentDidMount(): void { this.context.on(RoomStateEvent.Members, this.onRoomStateMember); RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); - this.ref.current?.focus(); } public componentWillUnmount(): void { @@ -122,13 +119,7 @@ export default class RightPanel extends React.Component { }; private onRightPanelStoreUpdate = (): void => { - const oldPhase = this.state.phase; - const newState = RightPanel.getDerivedStateFromProps(this.props) as IState; - this.setState({ ...newState }); - - if (oldPhase !== newState.phase) { - this.ref.current?.focus(); - } + this.setState({ ...(RightPanel.getDerivedStateFromProps(this.props) as IState) }); }; private onClose = (): void => { @@ -289,14 +280,7 @@ export default class RightPanel extends React.Component { } return ( -