From e33894bed4ee4252fe297b84d895a15759de95d6 Mon Sep 17 00:00:00 2001 From: ElementRobot Date: Fri, 17 Oct 2025 07:22:49 +0100 Subject: [PATCH 01/10] [create-pull-request] automated change (#31039) Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com> --- playwright/testcontainers/mas.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright/testcontainers/mas.ts b/playwright/testcontainers/mas.ts index 51bd171f6f..39ae0c4f1b 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:a1a5cf8820660c7bd160e6244791105b832b2c2cfda55bd38569304c0dffc1ba"; /** * MatrixAuthenticationServiceContainer which freezes the docker digest to From 9d973c88f9db5f73b3d773e068203c125ac17a15 Mon Sep 17 00:00:00 2001 From: ElementRobot Date: Fri, 17 Oct 2025 07:25:57 +0100 Subject: [PATCH 02/10] [create-pull-request] automated change (#31040) Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com> --- src/i18n/strings/et.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/i18n/strings/et.json b/src/i18n/strings/et.json index df8b95d274..6274b5dd19 100644 --- a/src/i18n/strings/et.json +++ b/src/i18n/strings/et.json @@ -2891,6 +2891,7 @@ "room_list_heading": "Jututubade loend", "show_avatars_pills": "Näita tunnuspilte kasutajate, jututubade ja sündmuste mainimistes", "show_polls_button": "Näita küsitluste nuppu", + "startup_window_behaviour_label": "Käivitamine ja akna käitumine", "surround_text": "Erimärkide sisestamisel märgista valitud tekst", "time_heading": "Aegade kuvamine", "user_timezone": "Seadista ajavöönd" @@ -3065,6 +3066,9 @@ "title": "Külgpaan" }, "start_automatically": { + "disabled": "Ei", + "enabled": "Jah", + "label": "Oma arvutisse logimisel ava %(brand)s", "minimised": "Minimeeritud" }, "tac_only_notifications": "Näita teavitusi vaid jutulõngade ülevaates", From cf51b256cefd8c6ccde938fa9cec63cf9e69b977 Mon Sep 17 00:00:00 2001 From: Bojidar Marinov Date: Mon, 20 Oct 2025 09:10:13 +0300 Subject: [PATCH 03/10] Fix highlights in messages (or search results) breaking links (#30264) * Fix highlights in messages (or search results) breaking links Fixes #17011 and fixes #29807, by running the linkifier that turns text into links before the highlighter that adds highlights to text. * Fix jest test * Fix tests related to emojis and pills-inside-spoilers * Remove dead code * Address review comments around sanitizeParams * Address review comment about linkify-matrix * Fix code style * Refactor if statement per review --------- Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- package.json | 1 + src/HtmlUtils.tsx | 62 +++++++++++++------ src/Linkify.tsx | 12 +++- .../views/messages/EventContentBody.tsx | 15 +---- src/linkify-matrix.ts | 2 + src/renderer/index.ts | 8 +-- src/renderer/spoiler.tsx | 6 +- src/renderer/utils.tsx | 54 ++++++---------- test/unit-tests/HtmlUtils-test.tsx | 38 ++++++++++++ .../views/messages/TextualBody-test.tsx | 4 +- yarn.lock | 5 ++ 11 files changed, 128 insertions(+), 79 deletions(-) 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/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/views/messages/EventContentBody.tsx b/src/components/views/messages/EventContentBody.tsx index 04c37461ec..50d95642f8 100644 --- a/src/components/views/messages/EventContentBody.tsx +++ b/src/components/views/messages/EventContentBody.tsx @@ -11,7 +11,6 @@ import parse from "html-react-parser"; import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; import { bodyToNode } from "../../../HtmlUtils.tsx"; -import { Linkify } from "../../../Linkify.tsx"; import PlatformPeg from "../../../PlatformPeg.ts"; import { applyReplacerOnString, @@ -23,7 +22,6 @@ import { ambiguousLinkTooltipRenderer, codeBlockRenderer, spoilerRenderer, - replacerToRenderFunction, } from "../../../renderer"; import MatrixClientContext from "../../../contexts/MatrixClientContext.tsx"; import { useSettingValue } from "../../../hooks/useSettings.ts"; @@ -154,12 +152,6 @@ const EventContentBody = memo( const [mediaIsVisible] = useMediaVisible(mxEvent); const replacer = useReplacer(content, mxEvent, options); - const linkifyOptions = useMemo( - () => ({ - render: replacerToRenderFunction(replacer), - }), - [replacer], - ); const isEmote = content.msgtype === MsgType.Emote; @@ -170,8 +162,9 @@ const EventContentBody = memo( // Part of Replies fallback support stripReplyFallback: stripReply, mediaIsVisible, + linkify, }), - [content, mediaIsVisible, enableBigEmoji, highlights, isEmote, stripReply], + [content, mediaIsVisible, enableBigEmoji, highlights, isEmote, stripReply, linkify], ); if (as === "div") includeDir = true; // force dir="auto" on divs @@ -189,9 +182,7 @@ const EventContentBody = memo( ); - if (!linkify) return body; - - return {body}; + return body; }, ); diff --git a/src/linkify-matrix.ts b/src/linkify-matrix.ts index 94e796a178..b6ed8ee7fc 100644 --- a/src/linkify-matrix.ts +++ b/src/linkify-matrix.ts @@ -10,6 +10,7 @@ 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 { @@ -293,3 +294,4 @@ registerCustomProtocol("mxc", false); export const linkify = linkifyjs; export const _linkifyString = linkifyString; +export const _linkifyHtml = linkifyHtml; diff --git a/src/renderer/index.ts b/src/renderer/index.ts index eaed7b71c3..3de73c8bbe 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -9,10 +9,4 @@ export { ambiguousLinkTooltipRenderer } from "./link-tooltip"; export { keywordPillRenderer, mentionPillRenderer } from "./pill"; export { spoilerRenderer } from "./spoiler"; export { codeBlockRenderer } from "./code-block"; -export { - applyReplacerOnString, - replacerToRenderFunction, - combineRenderers, - type RendererMap, - type Replacer, -} from "./utils"; +export { applyReplacerOnString, combineRenderers, type RendererMap, type Replacer } from "./utils"; diff --git a/src/renderer/spoiler.tsx b/src/renderer/spoiler.tsx index ee7f45f48e..7b71295c08 100644 --- a/src/renderer/spoiler.tsx +++ b/src/renderer/spoiler.tsx @@ -15,10 +15,12 @@ import Spoiler from "../components/views/elements/Spoiler.tsx"; * Replaces spans with `data-mx-spoiler` with a Spoiler component. */ export const spoilerRenderer: RendererMap = { - span: (span) => { + span: (span, params) => { const reason = span.attribs["data-mx-spoiler"]; if (typeof reason === "string") { - return {domToReact(span.children as DOMNode[])}; + return ( + {domToReact(span.children as DOMNode[], { replace: params.replace })} + ); } }, }; diff --git a/src/renderer/utils.tsx b/src/renderer/utils.tsx index 0cf37b9a8c..3109e9b447 100644 --- a/src/renderer/utils.tsx +++ b/src/renderer/utils.tsx @@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX } from "react"; import { type DOMNode, Element, type HTMLReactParserOptions, Text } from "html-react-parser"; import { type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix"; -import { type Opts } from "linkifyjs"; /** * The type of a parent node of an element, normally exported by domhandler but that is not a direct dependency of ours @@ -65,29 +64,9 @@ export function applyReplacerOnString( }); } -/** - * Converts a Replacer function to a render function for linkify-react - * So that we can use the same replacer functions for both - * @param replacer The replacer function to convert - */ -export function replacerToRenderFunction(replacer: Replacer): Opts["render"] { - if (!replacer) return; - return ({ tagName, attributes, content }) => { - const domNode = new Element(tagName, attributes, [new Text(content)], "tag" as Element["type"]); - const result = replacer(domNode, 0); - if (result) return result; - - // This is cribbed from the default render function in linkify-react - if (attributes.class) { - attributes.className = attributes.class; - delete attributes.class; - } - return React.createElement(tagName, attributes, content); - }; -} - interface Parameters { isHtml: boolean; + replace: Replacer; // Required for keywordPillRenderer keywordRegexpPattern?: RegExp; // Required for mentionPillRenderer @@ -114,7 +93,7 @@ export type RendererMap = Partial< } >; -type PreparedRenderer = (parameters: Parameters) => Replacer; +type PreparedRenderer = (parameters: Omit) => Replacer; /** * Combines multiple renderers into a single Replacer function @@ -122,19 +101,22 @@ type PreparedRenderer = (parameters: Parameters) => Replacer; */ export const combineRenderers = (...renderers: RendererMap[]): PreparedRenderer => - (parameters) => - (node, index) => { - if (node.type === "text") { - for (const replacer of renderers) { - const result = replacer[Node.TEXT_NODE]?.(node, parameters, index); - if (result) return result; + (parameters) => { + const replace: Replacer = (node, index) => { + if (node.type === "text") { + for (const replacer of renderers) { + const result = replacer[Node.TEXT_NODE]?.(node, parametersWithReplace, index); + if (result) return result; + } } - } - if (node instanceof Element) { - const tagName = node.tagName.toLowerCase() as keyof HTMLElementTagNameMap; - for (const replacer of renderers) { - const result = replacer[tagName]?.(node, parameters, index); - if (result) return result; + if (node instanceof Element) { + const tagName = node.tagName.toLowerCase() as keyof HTMLElementTagNameMap; + for (const replacer of renderers) { + const result = replacer[tagName]?.(node, parametersWithReplace, index); + if (result) return result; + } } - } + }; + const parametersWithReplace: Parameters = { ...parameters, replace }; + return replace; }; diff --git a/test/unit-tests/HtmlUtils-test.tsx b/test/unit-tests/HtmlUtils-test.tsx index 97c8da6013..690f2a713a 100644 --- a/test/unit-tests/HtmlUtils-test.tsx +++ b/test/unit-tests/HtmlUtils-test.tsx @@ -86,6 +86,44 @@ describe("bodyToHtml", () => { expect(html).toMatchInlineSnapshot(`"test foo <b>bar"`); }); + it("should linkify and hightlight parts of links in plaintext message highlighting", () => { + getMockClientWithEventEmitter({}); + + const html = bodyToHtml( + { + body: "foo http://link.example/test/path bar", + msgtype: "m.text", + }, + ["test"], + { + linkify: true, + }, + ); + + expect(html).toMatchInlineSnapshot( + `"foo http://link.example/test/path bar"`, + ); + }); + + it("should hightlight parts of links in HTML message highlighting", () => { + const html = bodyToHtml( + { + body: "foo http://link.example/test/path bar", + msgtype: "m.text", + formatted_body: 'foo http://link.example/test/path bar', + format: "org.matrix.custom.html", + }, + ["test"], + { + linkify: true, + }, + ); + + expect(html).toMatchInlineSnapshot( + `"foo http://link.example/test/path bar"`, + ); + }); + it("does not mistake characters in text presentation mode for emoji", () => { const { asFragment } = render( diff --git a/test/unit-tests/components/views/messages/TextualBody-test.tsx b/test/unit-tests/components/views/messages/TextualBody-test.tsx index cb3d755f8b..78a1261116 100644 --- a/test/unit-tests/components/views/messages/TextualBody-test.tsx +++ b/test/unit-tests/components/views/messages/TextualBody-test.tsx @@ -188,7 +188,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"`, ); }); @@ -206,7 +206,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/yarn.lock b/yarn.lock index 11f7d27fdb..89c270291d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9296,6 +9296,11 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== +linkify-html@4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/linkify-html/-/linkify-html-4.3.2.tgz#ef84b39828c66170221af1a49a042c7993bd4543" + integrity sha512-RozNgrfSFrNQlprJSZIN7lF+ZVPj5Pz8POQcu1PYGAUhL9tKtvtWcOXOmlXjuGGEWHtC6gt6Q2U4+VUq9ELmng== + linkify-it@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-4.0.1.tgz#01f1d5e508190d06669982ba31a7d9f56a5751ec" From e6e6f87d01c9d51931c27ca573bc151070ad1195 Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 20 Oct 2025 08:13:20 +0200 Subject: [PATCH 04/10] MVVM userinfo basic component (#30305) * feat: mvvm userinfo basic component * test: mvvm userinfobasic component * chore: apply review. rename views, add comment and move some codes * chore(review): move openDM method into viewmodel --- .../UserInfoBasicOptionsViewModel.tsx | 156 ++++++ .../user_info/UserInfoBasicViewModel.tsx | 197 +++++++ .../UserInfoIgnoreButtonViewModel.tsx | 85 +++ src/components/views/right_panel/UserInfo.tsx | 489 +----------------- .../user_info/UserInfoBasicOptionsView.tsx | 127 +++++ .../user_info/UserInfoBasicView.tsx | 93 ++++ .../user_info/UserInfoIgnoreButtonView.tsx | 30 ++ .../UserInfoBasicOptionsViewModel-test.tsx | 220 ++++++++ .../user_info/UserInfoBasicViewModel-test.tsx | 149 ++++++ .../views/right_panel/UserInfo-test.tsx | 222 +------- .../UserInfoAdminToolsContainer-test.tsx | 63 ++- .../user_info/UserInfoBasic-test.tsx | 112 ++++ .../UserInfoBasicOptionsView-test.tsx | 208 ++++++++ .../UserInfoHeaderVerificationView-test.tsx | 8 +- .../UserInfoHeaderView-test.tsx | 12 +- .../__snapshots__/UserInfoBasic-test.tsx.snap | 315 +++++++++++ ...erInfoHeaderVerificationView-test.tsx.snap | 0 .../UserInfoHeaderView-test.tsx.snap | 0 18 files changed, 1745 insertions(+), 741 deletions(-) create mode 100644 src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel.tsx create mode 100644 src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel.tsx create mode 100644 src/components/viewmodels/right_panel/user_info/UserInfoIgnoreButtonViewModel.tsx create mode 100644 src/components/views/right_panel/user_info/UserInfoBasicOptionsView.tsx create mode 100644 src/components/views/right_panel/user_info/UserInfoBasicView.tsx create mode 100644 src/components/views/right_panel/user_info/UserInfoIgnoreButtonView.tsx create mode 100644 test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel-test.tsx create mode 100644 test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel-test.tsx rename test/unit-tests/components/views/right_panel/{ => user_info}/UserInfoAdminToolsContainer-test.tsx (80%) create mode 100644 test/unit-tests/components/views/right_panel/user_info/UserInfoBasic-test.tsx create mode 100644 test/unit-tests/components/views/right_panel/user_info/UserInfoBasicOptionsView-test.tsx rename test/unit-tests/components/views/right_panel/{ => user_info}/UserInfoHeaderVerificationView-test.tsx (91%) rename test/unit-tests/components/views/right_panel/{ => user_info}/UserInfoHeaderView-test.tsx (91%) create mode 100644 test/unit-tests/components/views/right_panel/user_info/__snapshots__/UserInfoBasic-test.tsx.snap rename test/unit-tests/components/views/right_panel/{ => user_info}/__snapshots__/UserInfoHeaderVerificationView-test.tsx.snap (100%) rename test/unit-tests/components/views/right_panel/{ => user_info}/__snapshots__/UserInfoHeaderView-test.tsx.snap (100%) diff --git a/src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel.tsx b/src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel.tsx new file mode 100644 index 0000000000..6af49cbe6a --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel.tsx @@ -0,0 +1,156 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { useContext } from "react"; +import { RoomMember, User, type Room, KnownMembership } from "matrix-js-sdk/src/matrix"; + +import Modal from "../../../../Modal"; +import ErrorDialog from "../../../views/dialogs/ErrorDialog"; +import { _t, UserFriendlyError } from "../../../../languageHandler"; +import MatrixClientContext from "../../../../contexts/MatrixClientContext"; +import dis from "../../../../dispatcher/dispatcher"; +import PosthogTrackers from "../../../../PosthogTrackers"; +import { ShareDialog } from "../../../views/dialogs/ShareDialog"; +import { type ComposerInsertPayload } from "../../../../dispatcher/payloads/ComposerInsertPayload"; +import { Action } from "../../../../dispatcher/actions"; +import { SdkContextClass } from "../../../../contexts/SDKContext"; +import { TimelineRenderingType } from "../../../../contexts/RoomContext"; +import MultiInviter from "../../../../utils/MultiInviter"; +import { type ViewRoomPayload } from "../../../../dispatcher/payloads/ViewRoomPayload"; +import { useRoomPermissions } from "./UserInfoBasicViewModel"; +import { DirectoryMember, startDmOnFirstMessage } from "../../../../utils/direct-messages"; +import { type Member } from "../../../views/right_panel/UserInfo"; + +export interface UserInfoBasicOptionsState { + // boolean to know if selected user is current user + isMe: boolean; + // boolean to display/hide invite button + showInviteButton: boolean; + // boolean to display/hide insert pill button + showInsertPillButton: boolean | ""; + // boolean to display/hide read receipt button + readReceiptButtonDisabled: boolean; + // Method called when a insert pill button is clicked + onInsertPillButton: () => void; + // Method called when a read receipt button is clicked, will add a pill in the input message field + onReadReceiptButton: () => void; + // Method called when a share user button is clicked, will display modal with profile to share + onShareUserClick: () => void; + // Method called when a invite button is clicked, will display modal to invite user + onInviteUserButton: (evt: Event) => Promise; + // Method called when the DM button is clicked, will open a DM with the selected member + onOpenDmForUser: (member: Member) => Promise; +} + +export const useUserInfoBasicOptionsViewModel = (room: Room, member: User | RoomMember): UserInfoBasicOptionsState => { + const cli = useContext(MatrixClientContext); + + // selected member is current user + const isMe = member.userId === cli.getUserId(); + + // Those permissions are updated when a change is done on the room current state and the selected user + const roomPermissions = useRoomPermissions(cli, room, member as RoomMember); + + const isSpace = room?.isSpaceRoom(); + + // read receipt button stay disable for a room space or if all events where read (null) + const readReceiptButtonDisabled = isSpace || !room?.getEventReadUpTo(member.userId); + + // always show exempt when room is a space + const showInsertPillButton = member instanceof RoomMember && member.roomId && !isSpace; + + // show invite button only if current user has the permission to invite and the selected user membership is LEAVE + const showInviteButton = + member instanceof RoomMember && + roomPermissions.canInvite && + (member?.membership ?? KnownMembership.Leave) === KnownMembership.Leave; + + const onReadReceiptButton = function (): void { + const room = member instanceof RoomMember ? cli.getRoom(member.roomId) : null; + if (!room || readReceiptButtonDisabled) return; + + dis.dispatch({ + action: Action.ViewRoom, + highlighted: true, + // this could return null, the default prevents a type error + event_id: room.getEventReadUpTo(member.userId) || undefined, + room_id: room.roomId, + metricsTrigger: undefined, // room doesn't change + }); + }; + + const onInsertPillButton = function (): void { + dis.dispatch({ + action: Action.ComposerInsert, + userId: member.userId, + timelineRenderingType: TimelineRenderingType.Room, + }); + }; + + const onInviteUserButton = async (ev: Event): Promise => { + try { + const roomId = + member instanceof RoomMember && member.roomId + ? member.roomId + : SdkContextClass.instance.roomViewStore.getRoomId(); + + // We use a MultiInviter to re-use the invite logic, even though we're only inviting one user. + const inviter = new MultiInviter(cli, roomId || ""); + await inviter.invite([member.userId]).then(() => { + if (inviter.getCompletionState(member.userId) !== "invited") { + const errorStringFromInviterUtility = inviter.getErrorText(member.userId); + if (errorStringFromInviterUtility) { + throw new Error(errorStringFromInviterUtility); + } else { + throw new UserFriendlyError("slash_command|invite_failed", { + user: member.userId, + roomId, + cause: undefined, + }); + } + } + }); + } catch (err) { + const description = err instanceof Error ? err.message : _t("invite|failed_generic"); + + Modal.createDialog(ErrorDialog, { + title: _t("invite|failed_title"), + description, + }); + } + + PosthogTrackers.trackInteraction("WebRightPanelRoomUserInfoInviteButton", ev); + }; + + const onShareUserClick = (): void => { + Modal.createDialog(ShareDialog, { + target: member, + }); + }; + + const onOpenDmForUser = async (user: Member): Promise => { + const avatarUrl = user instanceof User ? user.avatarUrl : user.getMxcAvatarUrl(); + const startDmUser = new DirectoryMember({ + user_id: user.userId, + display_name: user.rawDisplayName, + avatar_url: avatarUrl, + }); + await startDmOnFirstMessage(cli, [startDmUser]); + }; + + return { + isMe, + showInviteButton, + showInsertPillButton, + readReceiptButtonDisabled, + onReadReceiptButton, + onInsertPillButton, + onInviteUserButton, + onShareUserClick, + onOpenDmForUser, + }; +}; diff --git a/src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel.tsx b/src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel.tsx new file mode 100644 index 0000000000..0773ca3cb7 --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel.tsx @@ -0,0 +1,197 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { useCallback, useEffect, useState } from "react"; +import { + EventType, + type RoomMember, + type IPowerLevelsContent, + type Room, + RoomStateEvent, + type MatrixClient, + type User, + type MatrixEvent, +} from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { useAsyncMemo } from "../../../../hooks/useAsyncMemo"; +import { useTypedEventEmitter } from "../../../../hooks/useEventEmitter"; +import Modal from "../../../../Modal"; +import ErrorDialog from "../../../views/dialogs/ErrorDialog"; +import { _t } from "../../../../languageHandler"; +import { type IRoomPermissions } from "../../../views/right_panel/UserInfo"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import QuestionDialog from "../../../views/dialogs/QuestionDialog"; +import DMRoomMap from "../../../../utils/DMRoomMap"; + +export interface UserInfoBasicState { + // current room powerlevels + powerLevels: IPowerLevelsContent; + // getting user permissions in this room + roomPermissions: IRoomPermissions; + // numbers of operation in progress > 0 + pendingUpdateCount: number; + // true if user is me + isMe: boolean; + // true if room is a DM for the user + isRoomDMForMember: boolean; + // Boolean to hide or show the deactivate button + showDeactivateButton: boolean; + // Method called when a deactivate user action is triggered + onSynapseDeactivate: () => void; + startUpdating: () => void; + stopUpdating: () => void; +} + +export const getPowerLevels = (room: Room): IPowerLevelsContent => + room?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() || {}; + +export const useRoomPermissions = (cli: MatrixClient, room: Room, user: RoomMember): IRoomPermissions => { + const [roomPermissions, setRoomPermissions] = useState({ + // modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL + modifyLevelMax: -1, + canEdit: false, + canInvite: false, + }); + + const updateRoomPermissions = useCallback(() => { + const powerLevels = room?.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent(); + if (!powerLevels) return; + + const me = room.getMember(cli.getUserId() || ""); + if (!me) return; + + const them = user; + const isMe = me.userId === them.userId; + const canAffectUser = them.powerLevel < me.powerLevel || isMe; + + let modifyLevelMax = -1; + if (canAffectUser) { + const editPowerLevel = powerLevels.events?.[EventType.RoomPowerLevels] ?? powerLevels.state_default ?? 50; + if (me.powerLevel >= editPowerLevel) { + modifyLevelMax = me.powerLevel; + } + } + + setRoomPermissions({ + canInvite: me.powerLevel >= (powerLevels.invite ?? 0), + canEdit: modifyLevelMax >= 0, + modifyLevelMax, + }); + }, [cli, user, room]); + + useTypedEventEmitter(cli, RoomStateEvent.Update, updateRoomPermissions); + useEffect(() => { + updateRoomPermissions(); + return () => { + setRoomPermissions({ + modifyLevelMax: -1, + canEdit: false, + canInvite: false, + }); + }; + }, [updateRoomPermissions]); + + return roomPermissions; +}; + +const useIsSynapseAdmin = (cli?: MatrixClient): boolean => { + return useAsyncMemo(async () => (cli ? cli.isSynapseAdministrator().catch(() => false) : false), [cli], false); +}; + +export const useRoomPowerLevels = (cli: MatrixClient, room: Room): IPowerLevelsContent => { + const [powerLevels, setPowerLevels] = useState(getPowerLevels(room)); + + const update = useCallback( + (ev?: MatrixEvent) => { + if (!room) return; + if (ev && ev.getType() !== EventType.RoomPowerLevels) return; + setPowerLevels(getPowerLevels(room)); + }, + [room], + ); + + useTypedEventEmitter(cli, RoomStateEvent.Events, update); + useEffect(() => { + update(); + return () => { + setPowerLevels({}); + }; + }, [update]); + return powerLevels; +}; + +export const useUserInfoBasicViewModel = (room: Room, member: User | RoomMember): UserInfoBasicState => { + const cli = useMatrixClientContext(); + + const powerLevels = useRoomPowerLevels(cli, room); + // Load whether or not we are a Synapse Admin + const isSynapseAdmin = useIsSynapseAdmin(cli); + + // Count of how many operations are currently in progress, if > 0 then show a Spinner + const [pendingUpdateCount, setPendingUpdateCount] = useState(0); + + const roomPermissions = useRoomPermissions(cli, room, member as RoomMember); + + // selected member is current user + const isMe = member.userId === cli.getUserId(); + + // is needed to hide the Roles section for DMs as it doesn't make sense there + const isRoomDMForMember = !!DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId); + + // used to check if user can deactivate another member + const isMemberSameDomain = member.userId.endsWith(`:${cli.getDomain()}`); + + // We don't need a perfect check here, just something to pass as "probably not our homeserver". If + // someone does figure out how to bypass this check the worst that happens is an error. + const showDeactivateButton = isSynapseAdmin && isMemberSameDomain; + + const startUpdating = useCallback(() => { + setPendingUpdateCount(pendingUpdateCount + 1); + }, [pendingUpdateCount]); + + const stopUpdating = useCallback(() => { + setPendingUpdateCount(pendingUpdateCount - 1); + }, [pendingUpdateCount]); + + const onSynapseDeactivate = useCallback(async () => { + const { finished } = Modal.createDialog(QuestionDialog, { + title: _t("user_info|deactivate_confirm_title"), + description:
{_t("user_info|deactivate_confirm_description")}
, + button: _t("user_info|deactivate_confirm_action"), + danger: true, + }); + + const [accepted] = await finished; + if (!accepted) return; + try { + await cli.deactivateSynapseUser(member.userId); + } catch (err) { + logger.error("Failed to deactivate user"); + logger.error(err); + + const description = err instanceof Error ? err.message : _t("invite|failed_generic"); + + Modal.createDialog(ErrorDialog, { + title: _t("user_info|error_deactivate"), + description, + }); + } + }, [cli, member.userId]); + + return { + showDeactivateButton, + powerLevels, + roomPermissions, + pendingUpdateCount, + isMe, + isRoomDMForMember, + onSynapseDeactivate, + startUpdating, + stopUpdating, + }; +}; diff --git a/src/components/viewmodels/right_panel/user_info/UserInfoIgnoreButtonViewModel.tsx b/src/components/viewmodels/right_panel/user_info/UserInfoIgnoreButtonViewModel.tsx new file mode 100644 index 0000000000..3c243feaec --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/UserInfoIgnoreButtonViewModel.tsx @@ -0,0 +1,85 @@ +/* +Copyright 2025 New Vector Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import React, { useContext, useEffect, useState, useCallback } from "react"; +import { type RoomMember, User, ClientEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import MatrixClientContext from "../../../../contexts/MatrixClientContext"; +import { _t } from "../../../../languageHandler"; +import Modal from "../../../../Modal"; +import QuestionDialog from "../../../views/dialogs/QuestionDialog"; +import { useTypedEventEmitter } from "../../../../hooks/useEventEmitter"; + +export interface UserInfoPowerLevelState { + /** + * Weither the member is ignored by current user or not + */ + isIgnored: boolean; + /** + * Trigger the method to ignore or unignore a user + * @param ev - The click event + */ + ignoreButtonClick: (ev: Event) => void; +} + +export const useUserInfoIgnoreButtonViewModel = (member: User | RoomMember): UserInfoPowerLevelState => { + const cli = useContext(MatrixClientContext); + + const unignore = useCallback(() => { + const ignoredUsers = cli.getIgnoredUsers(); + const index = ignoredUsers.indexOf(member.userId); + if (index !== -1) ignoredUsers.splice(index, 1); + cli.setIgnoredUsers(ignoredUsers); + }, [cli, member]); + + const ignore = useCallback(async () => { + const name = (member instanceof User ? member.displayName : member.name) || member.userId; + const { finished } = Modal.createDialog(QuestionDialog, { + title: _t("user_info|ignore_confirm_title", { user: name }), + description:
{_t("user_info|ignore_confirm_description")}
, + button: _t("action|ignore"), + }); + const [confirmed] = await finished; + + if (confirmed) { + const ignoredUsers = cli.getIgnoredUsers(); + ignoredUsers.push(member.userId); + cli.setIgnoredUsers(ignoredUsers); + } + }, [cli, member]); + + // Check whether the user is ignored + const [isIgnored, setIsIgnored] = useState(cli.isUserIgnored(member.userId)); + // Recheck if the user or client changes + useEffect(() => { + setIsIgnored(cli.isUserIgnored(member.userId)); + }, [cli, member.userId]); + + // Recheck also if we receive new accountData m.ignored_user_list + const accountDataHandler = useCallback( + (ev: MatrixEvent) => { + if (ev.getType() === "m.ignored_user_list") { + setIsIgnored(cli.isUserIgnored(member.userId)); + } + }, + [cli, member.userId], + ); + useTypedEventEmitter(cli, ClientEvent.AccountData, accountDataHandler); + + const ignoreButtonClick = (ev: Event): void => { + ev.preventDefault(); + if (isIgnored) { + unignore(); + } else { + ignore(); + } + }; + + return { + ignoreButtonClick, + isIgnored, + }; +}; diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 4556063303..086777d141 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -9,62 +9,25 @@ 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, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import React, { type JSX, type ReactNode, useContext, useEffect, useMemo, useState } from "react"; import classNames from "classnames"; -import { - ClientEvent, - type MatrixClient, - RoomMember, - type Room, - RoomStateEvent, - type MatrixEvent, - User, - type Device, - EventType, -} from "matrix-js-sdk/src/matrix"; -import { KnownMembership } from "matrix-js-sdk/src/types"; +import { type MatrixClient, type RoomMember, type Room, type User, type Device } from "matrix-js-sdk/src/matrix"; import { type UserVerificationStatus, type VerificationRequest, CryptoEvent } from "matrix-js-sdk/src/crypto-api"; -import { logger } from "matrix-js-sdk/src/logger"; -import { MenuItem } from "@vector-im/compound-web"; -import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat"; -import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"; -import ShareIcon from "@vector-im/compound-design-tokens/assets/web/icons/share"; -import MentionIcon from "@vector-im/compound-design-tokens/assets/web/icons/mention"; -import InviteIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add"; -import BlockIcon from "@vector-im/compound-design-tokens/assets/web/icons/block"; -import DeleteIcon from "@vector-im/compound-design-tokens/assets/web/icons/delete"; -import dis from "../../../dispatcher/dispatcher"; import Modal from "../../../Modal"; -import { _t, UserFriendlyError } from "../../../languageHandler"; -import DMRoomMap from "../../../utils/DMRoomMap"; +import { _t } from "../../../languageHandler"; import { type ButtonEvent } from "../elements/AccessibleButton"; -import MultiInviter from "../../../utils/MultiInviter"; -import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; import EncryptionPanel from "./EncryptionPanel"; -import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; -import { Action } from "../../../dispatcher/actions"; import { useIsEncrypted } from "../../../hooks/useIsEncrypted"; import BaseCard from "./BaseCard"; -import Spinner from "../elements/Spinner"; -import { ShareDialog } from "../dialogs/ShareDialog"; -import ErrorDialog from "../dialogs/ErrorDialog"; import QuestionDialog from "../dialogs/QuestionDialog"; -import { type ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; -import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; -import { UIComponent } from "../../../settings/UIFeature"; -import { TimelineRenderingType } from "../../../contexts/RoomContext"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import { type IRightPanelCardState } from "../../../stores/right-panel/RightPanelStoreIPanelState"; import PosthogTrackers from "../../../PosthogTrackers"; -import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import { DirectoryMember, startDmOnFirstMessage } from "../../../utils/direct-messages"; -import { SdkContextClass } from "../../../contexts/SDKContext"; -import { UserInfoAdminToolsContainer } from "./user_info/UserInfoAdminToolsContainer"; -import { PowerLevelSection } from "./user_info/UserInfoPowerLevels"; import { UserInfoHeaderView } from "./user_info/UserInfoHeaderView"; +import { UserInfoBasicView } from "./user_info/UserInfoBasicView"; export interface IDevice extends Device { ambiguous?: boolean; @@ -87,190 +50,6 @@ export const disambiguateDevices = (devices: IDevice[]): void => { } }; -/** - * Converts the member to a DirectoryMember and starts a DM with them. - */ -async function openDmForUser(matrixClient: MatrixClient, user: Member): Promise { - const avatarUrl = user instanceof User ? user.avatarUrl : user.getMxcAvatarUrl(); - const startDmUser = new DirectoryMember({ - user_id: user.userId, - display_name: user.rawDisplayName, - avatar_url: avatarUrl, - }); - await startDmOnFirstMessage(matrixClient, [startDmUser]); -} - -const MessageButton = ({ member }: { member: Member }): JSX.Element => { - const cli = useContext(MatrixClientContext); - const [busy, setBusy] = useState(false); - - return ( - { - ev.preventDefault(); - if (busy) return; - setBusy(true); - await openDmForUser(cli, member); - setBusy(false); - }} - disabled={busy} - label={_t("user_info|send_message")} - Icon={ChatIcon} - /> - ); -}; - -export const UserOptionsSection: React.FC<{ - member: Member; - canInvite: boolean; - isSpace?: boolean; - children?: ReactNode; -}> = ({ member, canInvite, isSpace, children }) => { - const cli = useContext(MatrixClientContext); - - let insertPillButton: JSX.Element | undefined; - let inviteUserButton: JSX.Element | undefined; - let readReceiptButton: JSX.Element | undefined; - - const isMe = member.userId === cli.getUserId(); - const onShareUserClick = (): void => { - Modal.createDialog(ShareDialog, { - target: member, - }); - }; - - // Only allow the user to ignore the user if its not ourselves - // same goes for jumping to read receipt - if (!isMe) { - const onReadReceiptButton = function (room: Room): void { - dis.dispatch({ - action: Action.ViewRoom, - highlighted: true, - // this could return null, the default prevents a type error - event_id: room.getEventReadUpTo(member.userId) || undefined, - room_id: room.roomId, - metricsTrigger: undefined, // room doesn't change - }); - }; - - const room = member instanceof RoomMember ? cli.getRoom(member.roomId) : null; - const readReceiptButtonDisabled = isSpace || !room?.getEventReadUpTo(member.userId); - readReceiptButton = ( - { - ev.preventDefault(); - if (room && !readReceiptButtonDisabled) { - onReadReceiptButton(room); - } - }} - label={_t("user_info|jump_to_rr_button")} - disabled={readReceiptButtonDisabled} - Icon={CheckIcon} - /> - ); - - if (member instanceof RoomMember && member.roomId && !isSpace) { - const onInsertPillButton = function (): void { - dis.dispatch({ - action: Action.ComposerInsert, - userId: member.userId, - timelineRenderingType: TimelineRenderingType.Room, - }); - }; - - insertPillButton = ( - { - ev.preventDefault(); - onInsertPillButton(); - }} - label={_t("action|mention")} - Icon={MentionIcon} - /> - ); - } - - if ( - member instanceof RoomMember && - canInvite && - (member?.membership ?? KnownMembership.Leave) === KnownMembership.Leave && - shouldShowComponent(UIComponent.InviteUsers) - ) { - const roomId = member && member.roomId ? member.roomId : SdkContextClass.instance.roomViewStore.getRoomId(); - const onInviteUserButton = async (ev: Event): Promise => { - try { - // We use a MultiInviter to re-use the invite logic, even though we're only inviting one user. - const inviter = new MultiInviter(cli, roomId || ""); - await inviter.invite([member.userId]).then(() => { - if (inviter.getCompletionState(member.userId) !== "invited") { - const errorStringFromInviterUtility = inviter.getErrorText(member.userId); - if (errorStringFromInviterUtility) { - throw new Error(errorStringFromInviterUtility); - } else { - throw new UserFriendlyError("slash_command|invite_failed", { - user: member.userId, - roomId, - cause: undefined, - }); - } - } - }); - } catch (err) { - const description = err instanceof Error ? err.message : _t("invite|failed_generic"); - - Modal.createDialog(ErrorDialog, { - title: _t("invite|failed_title"), - description, - }); - } - - PosthogTrackers.trackInteraction("WebRightPanelRoomUserInfoInviteButton", ev); - }; - - inviteUserButton = ( - { - ev.preventDefault(); - onInviteUserButton(ev); - }} - label={_t("action|invite")} - Icon={InviteIcon} - /> - ); - } - } - - const shareUserButton = ( - { - ev.preventDefault(); - onShareUserClick(); - }} - label={_t("user_info|share_button")} - Icon={ShareIcon} - /> - ); - - const directMessageButton = - isMe || !shouldShowComponent(UIComponent.CreateRooms) ? null : ; - - return ( - - {children} - {directMessageButton} - {inviteUserButton} - {readReceiptButton} - {shareUserButton} - {insertPillButton} - - ); -}; - export const warnSelfDemote = async (isSpace: boolean): Promise => { const { finished } = Modal.createDialog(QuestionDialog, { title: _t("user_info|demote_self_confirm_title"), @@ -325,152 +104,12 @@ export const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsConte return member.powerLevel < levelToSend; }; -export const getPowerLevels = (room: Room): IPowerLevelsContent => - room?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() || {}; - -export const useRoomPowerLevels = (cli: MatrixClient, room: Room): IPowerLevelsContent => { - const [powerLevels, setPowerLevels] = useState(getPowerLevels(room)); - - const update = useCallback( - (ev?: MatrixEvent) => { - if (!room) return; - if (ev && ev.getType() !== EventType.RoomPowerLevels) return; - setPowerLevels(getPowerLevels(room)); - }, - [room], - ); - - useTypedEventEmitter(cli, RoomStateEvent.Events, update); - useEffect(() => { - update(); - return () => { - setPowerLevels({}); - }; - }, [update]); - return powerLevels; -}; - -const IgnoreToggleButton: React.FC<{ - member: User | RoomMember; -}> = ({ member }) => { - const cli = useContext(MatrixClientContext); - const unignore = useCallback(() => { - const ignoredUsers = cli.getIgnoredUsers(); - const index = ignoredUsers.indexOf(member.userId); - if (index !== -1) ignoredUsers.splice(index, 1); - cli.setIgnoredUsers(ignoredUsers); - }, [cli, member]); - - const ignore = useCallback(async () => { - const name = (member instanceof User ? member.displayName : member.name) || member.userId; - const { finished } = Modal.createDialog(QuestionDialog, { - title: _t("user_info|ignore_confirm_title", { user: name }), - description:
{_t("user_info|ignore_confirm_description")}
, - button: _t("action|ignore"), - }); - const [confirmed] = await finished; - - if (confirmed) { - const ignoredUsers = cli.getIgnoredUsers(); - ignoredUsers.push(member.userId); - cli.setIgnoredUsers(ignoredUsers); - } - }, [cli, member]); - - // Check whether the user is ignored - const [isIgnored, setIsIgnored] = useState(cli.isUserIgnored(member.userId)); - // Recheck if the user or client changes - useEffect(() => { - setIsIgnored(cli.isUserIgnored(member.userId)); - }, [cli, member.userId]); - // Recheck also if we receive new accountData m.ignored_user_list - const accountDataHandler = useCallback( - (ev: MatrixEvent) => { - if (ev.getType() === "m.ignored_user_list") { - setIsIgnored(cli.isUserIgnored(member.userId)); - } - }, - [cli, member.userId], - ); - useTypedEventEmitter(cli, ClientEvent.AccountData, accountDataHandler); - - return ( - { - ev.preventDefault(); - if (isIgnored) { - unignore(); - } else { - ignore(); - } - }} - label={isIgnored ? _t("user_info|unignore_button") : _t("user_info|ignore_button")} - kind="critical" - Icon={BlockIcon} - /> - ); -}; - -const useIsSynapseAdmin = (cli?: MatrixClient): boolean => { - return useAsyncMemo(async () => (cli ? cli.isSynapseAdministrator().catch(() => false) : false), [cli], false); -}; - export interface IRoomPermissions { modifyLevelMax: number; canEdit: boolean; canInvite: boolean; } -function useRoomPermissions(cli: MatrixClient, room: Room, user: RoomMember): IRoomPermissions { - const [roomPermissions, setRoomPermissions] = useState({ - // modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL - modifyLevelMax: -1, - canEdit: false, - canInvite: false, - }); - - const updateRoomPermissions = useCallback(() => { - const powerLevels = room?.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent(); - if (!powerLevels) return; - - const me = room.getMember(cli.getUserId() || ""); - if (!me) return; - - const them = user; - const isMe = me.userId === them.userId; - const canAffectUser = them.powerLevel < me.powerLevel || isMe; - - let modifyLevelMax = -1; - if (canAffectUser) { - const editPowerLevel = powerLevels.events?.[EventType.RoomPowerLevels] ?? powerLevels.state_default ?? 50; - if (me.powerLevel >= editPowerLevel) { - modifyLevelMax = me.powerLevel; - } - } - - setRoomPermissions({ - canInvite: me.powerLevel >= (powerLevels.invite ?? 0), - canEdit: modifyLevelMax >= 0, - modifyLevelMax, - }); - }, [cli, user, room]); - - useTypedEventEmitter(cli, RoomStateEvent.Update, updateRoomPermissions); - useEffect(() => { - updateRoomPermissions(); - return () => { - setRoomPermissions({ - modifyLevelMax: -1, - canEdit: false, - canInvite: false, - }); - }; - }, [updateRoomPermissions]); - - return roomPermissions; -} - async function getUserDeviceInfo( userId: string, cli: MatrixClient, @@ -547,124 +186,6 @@ export const useDevices = (userId: string): IDevice[] | undefined | null => { return devices; }; -const BasicUserInfo: React.FC<{ - room: Room; - member: User | RoomMember; -}> = ({ room, member }) => { - const cli = useContext(MatrixClientContext); - - const powerLevels = useRoomPowerLevels(cli, room); - // Load whether or not we are a Synapse Admin - const isSynapseAdmin = useIsSynapseAdmin(cli); - - // Count of how many operations are currently in progress, if > 0 then show a Spinner - const [pendingUpdateCount, setPendingUpdateCount] = useState(0); - const startUpdating = useCallback(() => { - setPendingUpdateCount(pendingUpdateCount + 1); - }, [pendingUpdateCount]); - const stopUpdating = useCallback(() => { - setPendingUpdateCount(pendingUpdateCount - 1); - }, [pendingUpdateCount]); - - const roomPermissions = useRoomPermissions(cli, room, member as RoomMember); - - const onSynapseDeactivate = useCallback(async () => { - const { finished } = Modal.createDialog(QuestionDialog, { - title: _t("user_info|deactivate_confirm_title"), - description:
{_t("user_info|deactivate_confirm_description")}
, - button: _t("user_info|deactivate_confirm_action"), - danger: true, - }); - - const [accepted] = await finished; - if (!accepted) return; - try { - await cli.deactivateSynapseUser(member.userId); - } catch (err) { - logger.error("Failed to deactivate user"); - logger.error(err); - - const description = err instanceof Error ? err.message : _t("invite|failed_generic"); - - Modal.createDialog(ErrorDialog, { - title: _t("user_info|error_deactivate"), - description, - }); - } - }, [cli, member.userId]); - - let synapseDeactivateButton; - let spinner; - - // We don't need a perfect check here, just something to pass as "probably not our homeserver". If - // someone does figure out how to bypass this check the worst that happens is an error. - if (isSynapseAdmin && member.userId.endsWith(`:${cli.getDomain()}`)) { - synapseDeactivateButton = ( - { - ev.preventDefault(); - onSynapseDeactivate(); - }} - label={_t("user_info|deactivate_confirm_action")} - kind="critical" - Icon={DeleteIcon} - /> - ); - } - - let memberDetails; - let adminToolsContainer; - if (room && (member as RoomMember).roomId) { - // hide the Roles section for DMs as it doesn't make sense there - if (!DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId)) { - memberDetails = ( - - ); - } - - adminToolsContainer = ( - 0} - startUpdating={startUpdating} - stopUpdating={stopUpdating} - > - {synapseDeactivateButton} - - ); - } else if (synapseDeactivateButton) { - adminToolsContainer = {synapseDeactivateButton}; - } - - if (pendingUpdateCount > 0) { - spinner = ; - } - - const isMe = member.userId === cli.getUserId(); - - return ( - - - {memberDetails} - - {adminToolsContainer} - {!isMe && ( - - - - )} - {spinner} - - ); -}; - export type Member = User | RoomMember; interface IProps { @@ -700,7 +221,7 @@ const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPha let content: JSX.Element | undefined; switch (phase) { case RightPanelPhases.MemberInfo: - content = ; + content = ; break; case RightPanelPhases.EncryptionPanel: classes.push("mx_UserInfo_smallAvatar"); diff --git a/src/components/views/right_panel/user_info/UserInfoBasicOptionsView.tsx b/src/components/views/right_panel/user_info/UserInfoBasicOptionsView.tsx new file mode 100644 index 0000000000..b14bd87278 --- /dev/null +++ b/src/components/views/right_panel/user_info/UserInfoBasicOptionsView.tsx @@ -0,0 +1,127 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type RoomMember, type User, type Room } from "matrix-js-sdk/src/matrix"; +import React, { type JSX, type ReactNode, useState } from "react"; +import { MenuItem } from "@vector-im/compound-web"; +import { ChatIcon, CheckIcon, MentionIcon, ShareIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import InviteIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add"; + +import { _t } from "../../../../languageHandler"; +import { useUserInfoBasicOptionsViewModel } from "../../../viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel"; +import { Container, type Member } from "../UserInfo"; +import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents"; +import { UIComponent } from "../../../../settings/UIFeature"; + +const MessageButton = ({ + member, + openDMForUser, +}: { + member: Member; + openDMForUser: (user: Member) => Promise; +}): JSX.Element => { + const [busy, setBusy] = useState(false); + + return ( + { + ev.preventDefault(); + if (busy) return; + setBusy(true); + await openDMForUser(member); + setBusy(false); + }} + disabled={busy} + label={_t("user_info|send_message")} + Icon={ChatIcon} + /> + ); +}; + +export const UserInfoBasicOptionsView: React.FC<{ + member: User | RoomMember; + room: Room; + children?: ReactNode; +}> = ({ room, member, children }) => { + const vm = useUserInfoBasicOptionsViewModel(room, member); + + let insertPillButton: JSX.Element | undefined; + let inviteUserButton: JSX.Element | undefined; + let readReceiptButton: JSX.Element | undefined; + + if (!vm.isMe) { + readReceiptButton = ( + { + ev.preventDefault(); + vm.onReadReceiptButton(); + }} + label={_t("user_info|jump_to_rr_button")} + disabled={vm.readReceiptButtonDisabled} + Icon={CheckIcon} + /> + ); + + if (vm.showInsertPillButton) { + insertPillButton = ( + { + ev.preventDefault(); + vm.onInsertPillButton(); + }} + label={_t("action|mention")} + Icon={MentionIcon} + /> + ); + } + + if (vm.showInviteButton && shouldShowComponent(UIComponent.InviteUsers)) { + inviteUserButton = ( + { + ev.preventDefault(); + vm.onInviteUserButton(ev); + }} + label={_t("action|invite")} + Icon={InviteIcon} + /> + ); + } + } + + const shareUserButton = ( + { + ev.preventDefault(); + vm.onShareUserClick(); + }} + label={_t("user_info|share_button")} + Icon={ShareIcon} + /> + ); + + const directMessageButton = + vm.isMe || !shouldShowComponent(UIComponent.CreateRooms) ? null : ( + + ); + + return ( + + {children} + {directMessageButton} + {inviteUserButton} + {readReceiptButton} + {shareUserButton} + {insertPillButton} + + ); +}; diff --git a/src/components/views/right_panel/user_info/UserInfoBasicView.tsx b/src/components/views/right_panel/user_info/UserInfoBasicView.tsx new file mode 100644 index 0000000000..5a2c9158ff --- /dev/null +++ b/src/components/views/right_panel/user_info/UserInfoBasicView.tsx @@ -0,0 +1,93 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { type RoomMember, type User, type Room } from "matrix-js-sdk/src/matrix"; +import { MenuItem } from "@vector-im/compound-web"; +import { DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { _t } from "../../../../languageHandler"; +import { useUserInfoBasicViewModel } from "../../../viewmodels/right_panel/user_info/UserInfoBasicViewModel"; +import { PowerLevelSection } from "./UserInfoPowerLevels"; +import { Container } from "../UserInfo"; +import { IgnoreToggleButton } from "./UserInfoIgnoreButtonView"; +import Spinner from "../../elements/Spinner"; +import { UserInfoAdminToolsContainer } from "./UserInfoAdminToolsContainer"; +import { UserInfoBasicOptionsView } from "./UserInfoBasicOptionsView"; + +/** + * There are two types of components that can be displayed in the right panel concerning userinfo + * Basic info or Encryption Panel + */ +export const UserInfoBasicView: React.FC<{ + room: Room; + member: User | RoomMember; +}> = ({ room, member }) => { + const vm = useUserInfoBasicViewModel(room, member); + let synapseDeactivateButton; + let spinner; + let memberDetails; + let adminToolsContainer; + + if (vm.showDeactivateButton) { + synapseDeactivateButton = ( + { + ev.preventDefault(); + vm.onSynapseDeactivate(); + }} + label={_t("user_info|deactivate_confirm_action")} + kind="critical" + Icon={DeleteIcon} + /> + ); + } + + if (room && (member as RoomMember).roomId) { + // hide the Roles section for DMs as it doesn't make sense there + if (!vm.isRoomDMForMember) { + memberDetails = ( + + ); + } + + adminToolsContainer = ( + 0} + startUpdating={vm.startUpdating} + stopUpdating={vm.stopUpdating} + > + {synapseDeactivateButton} + + ); + } else if (synapseDeactivateButton) { + adminToolsContainer = {synapseDeactivateButton}; + } + + if (vm.pendingUpdateCount > 0) { + spinner = ; + } + + return ( + + + {memberDetails} + + {adminToolsContainer} + {!vm.isMe && ( + + + + )} + {spinner} + + ); +}; diff --git a/src/components/views/right_panel/user_info/UserInfoIgnoreButtonView.tsx b/src/components/views/right_panel/user_info/UserInfoIgnoreButtonView.tsx new file mode 100644 index 0000000000..671b2ce4a7 --- /dev/null +++ b/src/components/views/right_panel/user_info/UserInfoIgnoreButtonView.tsx @@ -0,0 +1,30 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type RoomMember, type User } from "matrix-js-sdk/src/matrix"; +import React from "react"; +import { MenuItem } from "@vector-im/compound-web"; +import { BlockIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { _t } from "../../../../languageHandler"; +import { useUserInfoIgnoreButtonViewModel } from "../../../viewmodels/right_panel/user_info/UserInfoIgnoreButtonViewModel"; + +export const IgnoreToggleButton: React.FC<{ + member: User | RoomMember; +}> = ({ member }) => { + const vm = useUserInfoIgnoreButtonViewModel(member); + + return ( + vm.ignoreButtonClick(ev)} + label={vm.isIgnored ? _t("user_info|unignore_button") : _t("user_info|ignore_button")} + kind="critical" + Icon={BlockIcon} + /> + ); +}; diff --git a/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel-test.tsx b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel-test.tsx new file mode 100644 index 0000000000..fba632199e --- /dev/null +++ b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel-test.tsx @@ -0,0 +1,220 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { + EventType, + KnownMembership, + type MatrixClient, + MatrixEvent, + type Room, + RoomMember, + type User, +} from "matrix-js-sdk/src/matrix"; +import { renderHook, waitFor } from "jest-matrix-react"; + +import { Action } from "../../../../../../src/dispatcher/actions"; +import Modal from "../../../../../../src/Modal"; +import MultiInviter from "../../../../../../src/utils/MultiInviter"; +import { createTestClient, mkRoom, withClientContextRenderOptions } from "../../../../../test-utils"; +import dis from "../../../../../../src/dispatcher/dispatcher"; +import { useUserInfoBasicOptionsViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel"; +import DMRoomMap from "../../../../../../src/utils/DMRoomMap"; +import ErrorDialog from "../../../../../../src/components/views/dialogs/ErrorDialog"; + +jest.mock("../../../../../../src/dispatcher/dispatcher"); + +describe("", () => { + const defaultRoomId = "!fkfk"; + const defaultUserId = "@user:example.com"; + const meUserId = "@me:example.com"; + + const defaultMember = new RoomMember(defaultRoomId, defaultUserId); + let defaultProps: { room: Room; member: User | RoomMember }; + let mockClient: MatrixClient; + let room: Room; + + beforeEach(() => { + mockClient = createTestClient(); + room = mkRoom(mockClient, defaultRoomId); + defaultProps = { + member: defaultMember, + room, + }; + DMRoomMap.makeShared(mockClient); + }); + + const renderUserInfoBasicOptionsViewModelHook = ( + props: { + member: User | RoomMember; + room: Room; + } = defaultProps, + ) => { + return renderHook( + () => useUserInfoBasicOptionsViewModel(props.room, props.member), + withClientContextRenderOptions(mockClient), + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Mock the current user account id. Which is different to the defaultMember which is the selected one + // When we want to mock the current user, needs to override this value + jest.spyOn(mockClient, "getUserId").mockReturnValue(meUserId); + jest.spyOn(mockClient, "getRoom").mockReturnValue(room); + }); + + it("should showInviteButton if current user can invite and selected user membership is LEAVE", () => { + // cant use mkRoomMember because instanceof check will failed in this case + const member: RoomMember = new RoomMember(defaultMember.userId, defaultMember.roomId); + const me: RoomMember = new RoomMember(meUserId, defaultMember.roomId); + + console.log("member instanceof RoomMember", member instanceof RoomMember); + + member.powerLevel = 1; + member.membership = KnownMembership.Leave; + me.powerLevel = 50; + me.membership = KnownMembership.Join; + const powerLevelEvents = new MatrixEvent({ + type: EventType.RoomPowerLevels, + content: { + invite: 50, + state_default: 0, + }, + }); + jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents); + // used to get the current me user + jest.spyOn(room, "getMember").mockReturnValue(me); + const { result } = renderUserInfoBasicOptionsViewModelHook({ ...defaultProps, member }); + + expect(result.current.showInviteButton).toBeTruthy(); + }); + + it("should not showInviteButton if current cannot invite", () => { + const member: RoomMember = new RoomMember(defaultMember.userId, defaultMember.roomId); + const me: RoomMember = new RoomMember(meUserId, defaultMember.roomId); + member.powerLevel = 50; + member.membership = KnownMembership.Leave; + me.powerLevel = 0; + me.membership = KnownMembership.Join; + const powerLevelEvents = new MatrixEvent({ + type: EventType.RoomPowerLevels, + content: { + invite: 50, + state_default: 0, + }, + }); + jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents); + // used to get the current me user + jest.spyOn(room, "getMember").mockReturnValue(me); + const { result } = renderUserInfoBasicOptionsViewModelHook({ ...defaultProps, member }); + + expect(result.current.showInviteButton).toBeFalsy(); + }); + + it("should not showInviteButton if selected user membership is not LEAVE", () => { + const member: RoomMember = new RoomMember(defaultMember.userId, defaultMember.roomId); + const me: RoomMember = new RoomMember(meUserId, defaultMember.roomId); + member.powerLevel = 50; + member.membership = KnownMembership.Join; + me.powerLevel = 50; + me.membership = KnownMembership.Join; + const powerLevelEvents = new MatrixEvent({ + type: EventType.RoomPowerLevels, + content: { + invite: 50, + state_default: 0, + }, + }); + jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents); + jest.spyOn(room, "getMember").mockReturnValue(me); + const { result } = renderUserInfoBasicOptionsViewModelHook({ ...defaultProps, member }); + + expect(result.current.showInviteButton).toBeFalsy(); + }); + + it("should showInsertPillButton if room is not a space", () => { + jest.spyOn(room, "isSpaceRoom").mockReturnValue(false); + const { result } = renderUserInfoBasicOptionsViewModelHook(); + expect(result.current.showInsertPillButton).toBeTruthy(); + }); + + it("should not showInsertPillButton if room is a space", () => { + jest.spyOn(room, "isSpaceRoom").mockReturnValue(true); + const { result } = renderUserInfoBasicOptionsViewModelHook(); + expect(result.current.showInsertPillButton).toBeFalsy(); + }); + + it("should readReceiptButtonDisabled be true if all messages where read", () => { + jest.spyOn(room, "getEventReadUpTo").mockReturnValue(null); + const { result } = renderUserInfoBasicOptionsViewModelHook(); + expect(result.current.readReceiptButtonDisabled).toBeTruthy(); + }); + + it("should readReceiptButtonDisabled be false if some messages are available", () => { + jest.spyOn(room, "getEventReadUpTo").mockReturnValue("aneventId"); + const { result } = renderUserInfoBasicOptionsViewModelHook(); + expect(result.current.readReceiptButtonDisabled).toBeFalsy(); + }); + + it("should readReceiptButtonDisabled be true if room is a space", () => { + jest.spyOn(room, "getEventReadUpTo").mockReturnValue("aneventId"); + jest.spyOn(room, "isSpaceRoom").mockReturnValue(true); + const { result } = renderUserInfoBasicOptionsViewModelHook(); + expect(result.current.readReceiptButtonDisabled).toBeTruthy(); + }); + + it("firing onReadReceiptButton calls dispatch with correct event_id", () => { + const eventId = "aneventId"; + jest.spyOn(room, "getEventReadUpTo").mockReturnValue(eventId); + jest.spyOn(room, "isSpaceRoom").mockReturnValue(false); + const { result } = renderUserInfoBasicOptionsViewModelHook(); + + result.current.onReadReceiptButton(); + + expect(dis.dispatch).toHaveBeenCalledWith({ + action: "view_room", + event_id: eventId, + highlighted: true, + metricsTrigger: undefined, + room_id: defaultRoomId, + }); + }); + + it("calling onInsertPillButton should calls dispatch", () => { + const { result } = renderUserInfoBasicOptionsViewModelHook(); + + result.current.onInsertPillButton(); + + expect(dis.dispatch).toHaveBeenCalledWith({ + action: Action.ComposerInsert, + userId: defaultMember.userId, + timelineRenderingType: "Room", + }); + }); + + it("calling onInviteUserButton will call MultiInviter.invite", async () => { + // to save mocking, we will reject the call to .invite + const mockErrorMessage = new Error("test error message"); + const spy = jest.spyOn(MultiInviter.prototype, "invite"); + spy.mockRejectedValue(mockErrorMessage); + jest.spyOn(Modal, "createDialog"); + + const { result } = renderUserInfoBasicOptionsViewModelHook(); + result.current.onInviteUserButton(new Event("click")); + + // check that we have called .invite + expect(spy).toHaveBeenCalledWith([defaultMember.userId]); + + await waitFor(() => { + // check that the test error message is displayed + expect(Modal.createDialog).toHaveBeenCalledWith(ErrorDialog, { + description: "test error message", + title: "Failed to invite", + }); + }); + }); +}); diff --git a/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel-test.tsx b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel-test.tsx new file mode 100644 index 0000000000..17d6500278 --- /dev/null +++ b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel-test.tsx @@ -0,0 +1,149 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ +import React from "react"; +import { EventType, type MatrixClient, MatrixEvent, type Room, RoomMember, type User } from "matrix-js-sdk/src/matrix"; +import { renderHook, waitFor } from "jest-matrix-react"; + +import { createTestClient, mkRoom, withClientContextRenderOptions } from "../../../../../test-utils"; +import { useUserInfoBasicViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel"; +import DMRoomMap from "../../../../../../src/utils/DMRoomMap"; +import Modal from "../../../../../../src/Modal"; +import QuestionDialog from "../../../../../../src/components/views/dialogs/QuestionDialog"; + +jest.mock("../../../../../../src/customisations/UserIdentifier", () => { + return { + getDisplayUserIdentifier: jest.fn().mockReturnValue("customUserIdentifier"), + }; +}); + +describe("useUserInfoHeaderViewModel", () => { + const defaultRoomId = "!fkfk"; + const defaultUserId = "@user:example.com"; + + const defaultMember = new RoomMember(defaultRoomId, defaultUserId); + let mockClient: MatrixClient; + + let defaultProps: { + member: User | RoomMember; + room: Room; + }; + + let room: Room; + + beforeEach(() => { + mockClient = createTestClient(); + mockClient.isSynapseAdministrator = jest.fn().mockResolvedValue(true); + mockClient.deactivateSynapseUser = jest.fn().mockResolvedValue({ + id_server_unbind_result: "success", + }); + + room = mkRoom(mockClient, defaultRoomId); + defaultProps = { + member: defaultMember, + room, + }; + DMRoomMap.makeShared(mockClient); + jest.spyOn(mockClient, "getRoom").mockReturnValue(room); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const renderUserInfoBasicViewModelHook = ( + props: { + member: User | RoomMember; + room: Room; + } = defaultProps, + ) => { + return renderHook( + () => useUserInfoBasicViewModel(props.room, props.member), + withClientContextRenderOptions(mockClient), + ); + }; + + it("should set showDeactivateButton value to true", async () => { + jest.spyOn(mockClient, "getDomain").mockReturnValue("example.com"); + const { result } = renderUserInfoBasicViewModelHook(); + // checking the synpase admin is an async operation, that is why we wait for it + await waitFor(() => { + expect(result.current.showDeactivateButton).toBe(true); + }); + }); + + it("should set showDeactivateButton value to false because domain is not the same", async () => { + jest.spyOn(mockClient, "getDomain").mockReturnValue("toto.com"); + const { result } = renderUserInfoBasicViewModelHook(); + + await waitFor(() => { + expect(result.current.showDeactivateButton).toBe(false); + }); + }); + + it("should give powerlevels values", () => { + const powerLevelEvents = new MatrixEvent({ + type: EventType.RoomPowerLevels, + content: { + invite: 1, + state_default: 1, + }, + }); + jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents); + const { result } = renderUserInfoBasicViewModelHook(); + expect(result.current.powerLevels).toStrictEqual({ + invite: 1, + state_default: 1, + }); + }); + + it("should set isRoomDMForMember to true if found in dmroommap", () => { + jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue("id"); + const { result } = renderUserInfoBasicViewModelHook(); + expect(result.current.isRoomDMForMember).toBeTruthy(); + }); + + it("should set isRoomDMForMember to false if not found in dmroommap", () => { + jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(undefined); + const { result } = renderUserInfoBasicViewModelHook(); + expect(result.current.isRoomDMForMember).toBeFalsy(); + }); + + it("should display modal and call deactivateSynapseUser when calling onSynapaseDeactivate", async () => { + const powerLevelEvents = new MatrixEvent({ + type: EventType.RoomPowerLevels, + content: { + invite: 1, + state_default: 1, + }, + }); + jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(powerLevelEvents); + jest.spyOn(Modal, "createDialog").mockReturnValue({ + finished: Promise.resolve([true, true, false]), + close: jest.fn(), + }); + + const { result } = renderUserInfoBasicViewModelHook(); + + await waitFor(() => result.current.onSynapseDeactivate()); + + await waitFor(() => { + expect(Modal.createDialog).toHaveBeenLastCalledWith(QuestionDialog, { + button: "Deactivate user", + danger: true, + description: ( +
+ Deactivating this user will log them out and prevent them from logging back in. Additionally, + they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want + to deactivate this user? +
+ ), + title: "Deactivate user?", + }); + }); + expect(mockClient.deactivateSynapseUser).toHaveBeenCalledWith(defaultMember.userId); + }); +}); diff --git a/test/unit-tests/components/views/right_panel/UserInfo-test.tsx b/test/unit-tests/components/views/right_panel/UserInfo-test.tsx index 1b5efc2868..47dc1826b5 100644 --- a/test/unit-tests/components/views/right_panel/UserInfo-test.tsx +++ b/test/unit-tests/components/views/right_panel/UserInfo-test.tsx @@ -28,24 +28,16 @@ import { type CryptoApi, } from "matrix-js-sdk/src/crypto-api"; -import UserInfo, { - disambiguateDevices, - getPowerLevels, - UserOptionsSection, -} from "../../../../../src/components/views/right_panel/UserInfo"; -import dis from "../../../../../src/dispatcher/dispatcher"; +import UserInfo, { disambiguateDevices } from "../../../../../src/components/views/right_panel/UserInfo"; +import { getPowerLevels } from "../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel"; import { RightPanelPhases } from "../../../../../src/stores/right-panel/RightPanelStorePhases"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import MultiInviter from "../../../../../src/utils/MultiInviter"; import Modal from "../../../../../src/Modal"; -import { DirectoryMember, startDmOnFirstMessage } from "../../../../../src/utils/direct-messages"; import { clearAllModals, flushPromises } from "../../../../test-utils"; import ErrorDialog from "../../../../../src/components/views/dialogs/ErrorDialog"; import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents"; import { UIComponent } from "../../../../../src/settings/UIFeature"; -import { Action } from "../../../../../src/dispatcher/actions"; -import { ShareDialog } from "../../../../../src/components/views/dialogs/ShareDialog"; jest.mock("../../../../../src/utils/direct-messages", () => ({ ...jest.requireActual("../../../../../src/utils/direct-messages"), @@ -449,216 +441,6 @@ describe("", () => { }); }); -describe("", () => { - const member = new RoomMember(defaultRoomId, defaultUserId); - const defaultProps = { member, canInvite: false, isSpace: false }; - - const renderComponent = (props = {}) => { - const Wrapper = (wrapperProps = {}) => { - return ; - }; - - return render(, { - wrapper: Wrapper, - }); - }; - - const inviteSpy = jest.spyOn(MultiInviter.prototype, "invite"); - - beforeEach(() => { - inviteSpy.mockReset(); - mockClient.setIgnoredUsers.mockClear(); - }); - - afterEach(async () => { - await clearAllModals(); - }); - - afterAll(() => { - inviteSpy.mockRestore(); - }); - - it("always shows share user button and clicking it should produce a ShareDialog", async () => { - const spy = jest.spyOn(Modal, "createDialog"); - - renderComponent(); - await userEvent.click(screen.getByRole("button", { name: "Share profile" })); - - expect(spy).toHaveBeenCalledWith(ShareDialog, { target: defaultProps.member }); - }); - - it("does not show ignore or direct message buttons when member userId matches client userId", () => { - mockClient.getSafeUserId.mockReturnValueOnce(member.userId); - mockClient.getUserId.mockReturnValueOnce(member.userId); - renderComponent(); - - expect(screen.queryByRole("button", { name: /ignore/i })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /message/i })).not.toBeInTheDocument(); - }); - - it("shows direct message and mention buttons when member userId does not match client userId", () => { - // call to client.getUserId returns undefined, which will not match member.userId - renderComponent(); - - expect(screen.getByRole("button", { name: "Send message" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Mention" })).toBeInTheDocument(); - }); - - it("mention button fires ComposerInsert Action", async () => { - renderComponent(); - - const button = screen.getByRole("button", { name: "Mention" }); - await userEvent.click(button); - expect(dis.dispatch).toHaveBeenCalledWith({ - action: Action.ComposerInsert, - timelineRenderingType: "Room", - userId: "@user:example.com", - }); - }); - - it("when call to client.getRoom is null, shows disabled read receipt button", () => { - mockClient.getRoom.mockReturnValueOnce(null); - renderComponent(); - - expect(screen.queryByRole("button", { name: "Jump to read receipt" })).toBeDisabled(); - }); - - it("when call to client.getRoom is non-null and room.getEventReadUpTo is null, shows disabled read receipt button", () => { - mockRoom.getEventReadUpTo.mockReturnValueOnce(null); - mockClient.getRoom.mockReturnValueOnce(mockRoom); - renderComponent(); - - expect(screen.queryByRole("button", { name: "Jump to read receipt" })).toBeDisabled(); - }); - - it("when calls to client.getRoom and room.getEventReadUpTo are non-null, shows read receipt button", () => { - mockRoom.getEventReadUpTo.mockReturnValueOnce("1234"); - mockClient.getRoom.mockReturnValueOnce(mockRoom); - renderComponent(); - - expect(screen.getByRole("button", { name: "Jump to read receipt" })).toBeInTheDocument(); - }); - - it("clicking the read receipt button calls dispatch with correct event_id", async () => { - const mockEventId = "1234"; - mockRoom.getEventReadUpTo.mockReturnValue(mockEventId); - mockClient.getRoom.mockReturnValue(mockRoom); - renderComponent(); - - const readReceiptButton = screen.getByRole("button", { name: "Jump to read receipt" }); - - expect(readReceiptButton).toBeInTheDocument(); - await userEvent.click(readReceiptButton); - expect(dis.dispatch).toHaveBeenCalledWith({ - action: "view_room", - event_id: mockEventId, - highlighted: true, - metricsTrigger: undefined, - room_id: "!fkfk", - }); - - mockRoom.getEventReadUpTo.mockReset(); - mockClient.getRoom.mockReset(); - }); - - it("firing the read receipt event handler with a null event_id calls dispatch with undefined not null", async () => { - const mockEventId = "1234"; - // the first call is the check to see if we should render the button, second call is - // when the button is clicked - mockRoom.getEventReadUpTo.mockReturnValueOnce(mockEventId).mockReturnValueOnce(null); - mockClient.getRoom.mockReturnValue(mockRoom); - renderComponent(); - - const readReceiptButton = screen.getByRole("button", { name: "Jump to read receipt" }); - - expect(readReceiptButton).toBeInTheDocument(); - await userEvent.click(readReceiptButton); - expect(dis.dispatch).toHaveBeenCalledWith({ - action: "view_room", - event_id: undefined, - highlighted: true, - metricsTrigger: undefined, - room_id: "!fkfk", - }); - - mockClient.getRoom.mockReset(); - }); - - it("does not show the invite button when canInvite is false", () => { - renderComponent(); - expect(screen.queryByRole("button", { name: /invite/i })).not.toBeInTheDocument(); - }); - - it("shows the invite button when canInvite is true", () => { - renderComponent({ canInvite: true }); - expect(screen.getByRole("button", { name: /invite/i })).toBeInTheDocument(); - }); - - it("clicking the invite button will call MultiInviter.invite", async () => { - // to save mocking, we will reject the call to .invite - const mockErrorMessage = new Error("test error message"); - inviteSpy.mockRejectedValue(mockErrorMessage); - - // render the component and click the button - renderComponent({ canInvite: true }); - const inviteButton = screen.getByRole("button", { name: /invite/i }); - expect(inviteButton).toBeInTheDocument(); - await userEvent.click(inviteButton); - - // check that we have called .invite - expect(inviteSpy).toHaveBeenCalledWith([member.userId]); - - // check that the test error message is displayed - await expect(screen.findByText(mockErrorMessage.message)).resolves.toBeInTheDocument(); - }); - - it("if calling .invite throws something strange, show default error message", async () => { - inviteSpy.mockRejectedValue({ this: "could be anything" }); - - // render the component and click the button - renderComponent({ canInvite: true }); - const inviteButton = screen.getByRole("button", { name: /invite/i }); - expect(inviteButton).toBeInTheDocument(); - await userEvent.click(inviteButton); - - // check that the default test error message is displayed - await expect(screen.findByText(/operation failed/i)).resolves.toBeInTheDocument(); - }); - - it.each([ - ["for a RoomMember", member, member.getMxcAvatarUrl()], - ["for a User", defaultUser, defaultUser.avatarUrl], - ])( - "clicking »message« %s should start a DM", - async (test: string, member: RoomMember | User, expectedAvatarUrl: string | undefined) => { - const deferred = Promise.withResolvers(); - mocked(startDmOnFirstMessage).mockReturnValue(deferred.promise); - - renderComponent({ member }); - await userEvent.click(screen.getByRole("button", { name: "Send message" })); - - // Checking the attribute, because the button is a DIV and toBeDisabled() does not work. - expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled(); - - expect(startDmOnFirstMessage).toHaveBeenCalledWith(mockClient, [ - new DirectoryMember({ - user_id: member.userId, - display_name: member.rawDisplayName, - avatar_url: expectedAvatarUrl, - }), - ]); - - await act(async () => { - deferred.resolve("!dm:example.com"); - await flushPromises(); - }); - - // Checking the attribute, because the button is a DIV and toBeDisabled() does not work. - expect(screen.getByRole("button", { name: "Send message" })).not.toBeDisabled(); - }, - ); -}); - describe("disambiguateDevices", () => { it("does not add ambiguous key to unique names", () => { const initialDevices = [ diff --git a/test/unit-tests/components/views/right_panel/UserInfoAdminToolsContainer-test.tsx b/test/unit-tests/components/views/right_panel/user_info/UserInfoAdminToolsContainer-test.tsx similarity index 80% rename from test/unit-tests/components/views/right_panel/UserInfoAdminToolsContainer-test.tsx rename to test/unit-tests/components/views/right_panel/user_info/UserInfoAdminToolsContainer-test.tsx index 30a4f78842..524c76a515 100644 --- a/test/unit-tests/components/views/right_panel/UserInfoAdminToolsContainer-test.tsx +++ b/test/unit-tests/components/views/right_panel/user_info/UserInfoAdminToolsContainer-test.tsx @@ -10,16 +10,16 @@ import { render, screen, fireEvent } from "jest-matrix-react"; import { type Room, type RoomMember } from "matrix-js-sdk/src/matrix"; import { mocked } from "jest-mock"; -import { UserInfoAdminToolsContainer } from "../../../../../src/components/views/right_panel/user_info/UserInfoAdminToolsContainer"; -import { useUserInfoAdminToolsContainerViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel"; -import { useRoomKickButtonViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel"; -import { useBanButtonViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel"; -import { useMuteButtonViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel"; -import { useRedactMessagesButtonViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel"; -import { stubClient } from "../../../../test-utils"; -import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; +import { UserInfoAdminToolsContainer } from "../../../../../../src/components/views/right_panel/user_info/UserInfoAdminToolsContainer"; +import { useUserInfoAdminToolsContainerViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel"; +import { useRoomKickButtonViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel"; +import { useBanButtonViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel"; +import { useMuteButtonViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel"; +import { useRedactMessagesButtonViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel"; +import { stubClient } from "../../../../../test-utils"; +import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; -jest.mock("../../../../../src/utils/DMRoomMap", () => { +jest.mock("../../../../../../src/utils/DMRoomMap", () => { const mock = { getUserIdForRoomId: jest.fn(), getDMRoomsForUserId: jest.fn(), @@ -32,7 +32,7 @@ jest.mock("../../../../../src/utils/DMRoomMap", () => { }); jest.mock( - "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel", + "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel", () => ({ useUserInfoAdminToolsContainerViewModel: jest.fn().mockReturnValue({ isCurrentUserInTheRoom: true, @@ -44,34 +44,43 @@ jest.mock( }), ); -jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel", () => ({ - useRoomKickButtonViewModel: jest.fn().mockReturnValue({ - canUserBeKicked: true, - kickLabel: "Kick", - onKickClick: jest.fn(), +jest.mock( + "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel", + () => ({ + useRoomKickButtonViewModel: jest.fn().mockReturnValue({ + canUserBeKicked: true, + kickLabel: "Kick", + onKickClick: jest.fn(), + }), }), -})); +); -jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel", () => ({ +jest.mock("../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel", () => ({ useBanButtonViewModel: jest.fn().mockReturnValue({ banLabel: "Ban", onBanOrUnbanClick: jest.fn(), }), })); -jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel", () => ({ - useMuteButtonViewModel: jest.fn().mockReturnValue({ - isMemberInTheRoom: true, - muteLabel: "Mute", - onMuteButtonClick: jest.fn(), +jest.mock( + "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel", + () => ({ + useMuteButtonViewModel: jest.fn().mockReturnValue({ + isMemberInTheRoom: true, + muteLabel: "Mute", + onMuteButtonClick: jest.fn(), + }), }), -})); +); -jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel", () => ({ - useRedactMessagesButtonViewModel: jest.fn().mockReturnValue({ - onRedactAllMessagesClick: jest.fn(), +jest.mock( + "../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel", + () => ({ + useRedactMessagesButtonViewModel: jest.fn().mockReturnValue({ + onRedactAllMessagesClick: jest.fn(), + }), }), -})); +); const defaultRoomId = "!fkfk"; diff --git a/test/unit-tests/components/views/right_panel/user_info/UserInfoBasic-test.tsx b/test/unit-tests/components/views/right_panel/user_info/UserInfoBasic-test.tsx new file mode 100644 index 0000000000..3fb67c6876 --- /dev/null +++ b/test/unit-tests/components/views/right_panel/user_info/UserInfoBasic-test.tsx @@ -0,0 +1,112 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { mocked } from "jest-mock"; +import { type MatrixClient, type Room, RoomMember, type User } from "matrix-js-sdk/src/matrix"; +import { logRoles, render, screen } from "jest-matrix-react"; + +import { createTestClient, mkStubRoom } from "../../../../../test-utils"; +import { + type UserInfoBasicState, + useUserInfoBasicViewModel, +} from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel"; +import { UserInfoBasicView } from "../../../../../../src/components/views/right_panel/user_info/UserInfoBasicView"; +import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; + +const defaultRoomPermissions = { + canEdit: true, + canInvite: true, + modifyLevelMax: -1, +}; +jest.mock("../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel", () => ({ + useUserInfoBasicViewModel: jest.fn(), + useRoomPermissions: () => defaultRoomPermissions, +})); + +describe("", () => { + const defaultValue: UserInfoBasicState = { + powerLevels: {}, + roomPermissions: defaultRoomPermissions, + pendingUpdateCount: 0, + isMe: false, + isRoomDMForMember: false, + showDeactivateButton: true, + onSynapseDeactivate: jest.fn(), + startUpdating: jest.fn(), + stopUpdating: jest.fn(), + }; + + const defaultRoomId = "!fkfk"; + const defaultUserId = "@user:example.com"; + + const defaultMember = new RoomMember(defaultRoomId, defaultUserId); + let defaultRoom: Room; + + let defaultProps: { member: User | RoomMember; room: Room }; + let matrixClient: MatrixClient; + + const renderComponent = (props = defaultProps) => { + return render( + + + , + ); + }; + beforeEach(() => { + matrixClient = createTestClient(); + defaultRoom = mkStubRoom(defaultRoomId, defaultRoomId, matrixClient); + defaultProps = { + member: defaultMember, + room: defaultRoom, + }; + }); + + it("should display the defaut values", () => { + mocked(useUserInfoBasicViewModel).mockReturnValue(defaultValue); + const { container } = renderComponent(); + logRoles(container); + expect(container).toMatchSnapshot(); + }); + + it("should not show ignore button if user is me", () => { + const state: UserInfoBasicState = { ...defaultValue, isMe: true }; + mocked(useUserInfoBasicViewModel).mockReturnValue(state); + renderComponent(); + + const ignoreButton = screen.queryByRole("button", { name: "Ignore" }); + expect(ignoreButton).not.toBeInTheDocument(); + }); + + it("should not show deactivate button", () => { + const state: UserInfoBasicState = { ...defaultValue, showDeactivateButton: false }; + mocked(useUserInfoBasicViewModel).mockReturnValue(state); + renderComponent(); + + const deactivateButton = screen.queryByRole("button", { name: "Deactivate user" }); + expect(deactivateButton).not.toBeInTheDocument(); + }); + + it("should not show powerlevels selector for dm", () => { + const state: UserInfoBasicState = { ...defaultValue, isRoomDMForMember: true }; + mocked(useUserInfoBasicViewModel).mockReturnValue(state); + const { container } = renderComponent(); + + logRoles(container); + const powserlevel = screen.queryByRole("option", { name: "Default" }); + expect(powserlevel).not.toBeInTheDocument(); + }); + + it("should show spinner if pending update is > 0", () => { + const state: UserInfoBasicState = { ...defaultValue, pendingUpdateCount: 2 }; + mocked(useUserInfoBasicViewModel).mockReturnValue(state); + renderComponent(); + + const spinner = screen.getByTestId("spinner"); + expect(spinner).toBeInTheDocument(); + }); +}); diff --git a/test/unit-tests/components/views/right_panel/user_info/UserInfoBasicOptionsView-test.tsx b/test/unit-tests/components/views/right_panel/user_info/UserInfoBasicOptionsView-test.tsx new file mode 100644 index 0000000000..3047541e65 --- /dev/null +++ b/test/unit-tests/components/views/right_panel/user_info/UserInfoBasicOptionsView-test.tsx @@ -0,0 +1,208 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { mocked } from "jest-mock"; +import { type Room, RoomMember, type User } from "matrix-js-sdk/src/matrix"; +import { fireEvent, render, screen } from "jest-matrix-react"; + +import { mkStubRoom, stubClient } from "../../../../../test-utils"; +import { + useUserInfoBasicOptionsViewModel, + type UserInfoBasicOptionsState, +} from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel"; +import { UserInfoBasicOptionsView } from "../../../../../../src/components/views/right_panel/user_info/UserInfoBasicOptionsView"; +import { UIComponent } from "../../../../../../src/settings/UIFeature"; +import { shouldShowComponent } from "../../../../../../src/customisations/helpers/UIComponents"; +import { type Member } from "../../../../../../src/components/views/right_panel/UserInfo"; + +jest.mock("../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel", () => ({ + useUserInfoBasicOptionsViewModel: jest.fn(), +})); + +jest.mock("../../../../../../src/customisations/helpers/UIComponents", () => { + const original = jest.requireActual("../../../../../../src/customisations/helpers/UIComponents"); + return { + shouldShowComponent: jest.fn().mockImplementation(original.shouldShowComponent), + }; +}); + +describe("", () => { + const defaultValue: UserInfoBasicOptionsState = { + isMe: false, + showInviteButton: false, + showInsertPillButton: false, + readReceiptButtonDisabled: false, + onInsertPillButton: () => jest.fn(), + onReadReceiptButton: () => jest.fn(), + onShareUserClick: () => jest.fn(), + onInviteUserButton: (evt: Event) => Promise.resolve(), + onOpenDmForUser: (member: Member) => Promise.resolve(), + }; + + const defaultRoomId = "!fkfk"; + const defaultUserId = "@user:example.com"; + + const defaultMember = new RoomMember(defaultRoomId, defaultUserId); + let defaultRoom: Room; + + let defaultProps: { member: User | RoomMember; room: Room }; + + beforeEach(() => { + const matrixClient = stubClient(); + defaultRoom = mkStubRoom(defaultRoomId, defaultRoomId, matrixClient); + defaultProps = { + member: defaultMember, + room: defaultRoom, + }; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should always display sharedButton when user is not me", () => { + // User is not me by default + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({ ...defaultValue }); + render(); + const sharedButton = screen.getByRole("button", { name: "Share profile" }); + expect(sharedButton).toBeInTheDocument(); + }); + + it("should always display sharedButton when user is me", () => { + const propsWithMe = { ...defaultProps }; + const onShareUserClick = jest.fn(); + const state = { ...defaultValue, isMe: true, onShareUserClick }; + + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue(state); + render(); + + const sharedButton2 = screen.getByRole("button", { name: "Share profile" }); + expect(sharedButton2).toBeInTheDocument(); + + // clicking on the share profile button + fireEvent.click(sharedButton2); + + expect(onShareUserClick).toHaveBeenCalled(); + }); + + it("should show insert pill button when user is not me and showinsertpill is true", () => { + const onInsertPillButton = jest.fn(); + const state = { ...defaultValue, showInsertPillButton: true, onInsertPillButton }; + // User is not me and showInsertpill is true + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue(state); + render(); + + const insertPillButton = screen.getByRole("button", { name: "Mention" }); + expect(insertPillButton).toBeInTheDocument(); + + // clicking on the insert pill button + fireEvent.click(insertPillButton); + + expect(onInsertPillButton).toHaveBeenCalled(); + }); + + it("should not show insert pill button when user is not me and showinsertpill is false", () => { + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({ ...defaultValue, showInsertPillButton: false }); + render(); + const insertPillButton = screen.queryByRole("button", { name: "Mention" }); + expect(insertPillButton).not.toBeInTheDocument(); + }); + + it("should not show insert pill button when user is me", () => { + // User is me, should not see the insert button even when show insertpill is true + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({ + ...defaultValue, + showInsertPillButton: true, + isMe: true, + }); + const propsWithMe = { ...defaultProps }; + render(); + const insertPillButton = screen.queryByRole("button", { name: "Mention" }); + expect(insertPillButton).not.toBeInTheDocument(); + }); + + it("should not show readreceiptbutton when user is me", () => { + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({ + ...defaultValue, + readReceiptButtonDisabled: true, + isMe: true, + }); + const propsWithMe = { ...defaultProps }; + render(); + + const readReceiptButton = screen.queryByRole("button", { name: "Jump to read receipt" }); + expect(readReceiptButton).not.toBeInTheDocument(); + }); + + it("should show disable readreceiptbutton when readReceiptButtonDisabled is true", () => { + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({ ...defaultValue, readReceiptButtonDisabled: true }); + render(); + + const readReceiptButton = screen.getByRole("button", { name: "Jump to read receipt" }); + expect(readReceiptButton).toBeDisabled(); + }); + + it("should not show disable readreceiptbutton when readReceiptButtonDisabled is false", () => { + const onReadReceiptButton = jest.fn(); + const state = { ...defaultValue, readReceiptButtonDisabled: false, onReadReceiptButton }; + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue(state); + render(); + + const readReceiptButton = screen.getByRole("button", { name: "Jump to read receipt" }); + expect(readReceiptButton).not.toBeDisabled(); + + // clicking on the read receipt button + fireEvent.click(readReceiptButton); + + expect(onReadReceiptButton).toHaveBeenCalled(); + }); + + it("should show not show invite button if shouldShowComponent is false", () => { + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({ ...defaultValue, showInviteButton: true }); + mocked(shouldShowComponent).mockReturnValue(false); + render(); + + const inviteButton = screen.queryByRole("button", { name: "Invite" }); + expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.InviteUsers); + expect(inviteButton).not.toBeInTheDocument(); + }); + + it("should show show invite button if shouldShowComponent is true", () => { + const onInviteUserButton = jest.fn(); + const state = { ...defaultValue, showInviteButton: true, onInviteUserButton }; + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue(state); + mocked(shouldShowComponent).mockReturnValue(true); + render(); + + const inviteButton = screen.getByRole("button", { name: "Invite" }); + expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.InviteUsers); + expect(inviteButton).toBeInTheDocument(); + + // clicking on the invite button + fireEvent.click(inviteButton); + expect(onInviteUserButton).toHaveBeenCalled(); + }); + + it("should show directMessageButton when user is not me", () => { + // User is not me, direct message button should display + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue(defaultValue); + mocked(shouldShowComponent).mockReturnValue(true); + render(); + const dmButton = screen.getByRole("button", { name: "Send message" }); + expect(dmButton).toBeInTheDocument(); + }); + + it("should not show directMessageButton when user is me", () => { + mocked(useUserInfoBasicOptionsViewModel).mockReturnValue({ ...defaultValue, isMe: true }); + mocked(shouldShowComponent).mockReturnValue(true); + const propsWithMe = { ...defaultProps }; + render(); + const dmButton = screen.queryByRole("button", { name: "Send message" }); + expect(dmButton).not.toBeInTheDocument(); + }); +}); diff --git a/test/unit-tests/components/views/right_panel/UserInfoHeaderVerificationView-test.tsx b/test/unit-tests/components/views/right_panel/user_info/UserInfoHeaderVerificationView-test.tsx similarity index 91% rename from test/unit-tests/components/views/right_panel/UserInfoHeaderVerificationView-test.tsx rename to test/unit-tests/components/views/right_panel/user_info/UserInfoHeaderVerificationView-test.tsx index 65db069d05..7c54d167c3 100644 --- a/test/unit-tests/components/views/right_panel/UserInfoHeaderVerificationView-test.tsx +++ b/test/unit-tests/components/views/right_panel/user_info/UserInfoHeaderVerificationView-test.tsx @@ -12,10 +12,10 @@ import { Device, RoomMember } from "matrix-js-sdk/src/matrix"; import { render, waitFor, screen } from "jest-matrix-react"; import React from "react"; -import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; -import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import { UserInfoHeaderVerificationView } from "../../../../../src/components/views/right_panel/user_info/UserInfoHeaderVerificationView"; -import { createTestClient } from "../../../../test-utils"; +import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; +import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; +import { UserInfoHeaderVerificationView } from "../../../../../../src/components/views/right_panel/user_info/UserInfoHeaderVerificationView"; +import { createTestClient } from "../../../../../test-utils"; describe("", () => { const defaultRoomId = "!fkfk"; diff --git a/test/unit-tests/components/views/right_panel/UserInfoHeaderView-test.tsx b/test/unit-tests/components/views/right_panel/user_info/UserInfoHeaderView-test.tsx similarity index 91% rename from test/unit-tests/components/views/right_panel/UserInfoHeaderView-test.tsx rename to test/unit-tests/components/views/right_panel/user_info/UserInfoHeaderView-test.tsx index 04f59f16f6..31da372b2a 100644 --- a/test/unit-tests/components/views/right_panel/UserInfoHeaderView-test.tsx +++ b/test/unit-tests/components/views/right_panel/user_info/UserInfoHeaderView-test.tsx @@ -12,14 +12,14 @@ import { Device, RoomMember } from "matrix-js-sdk/src/matrix"; import { fireEvent, render, screen } from "jest-matrix-react"; import React from "react"; -import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; -import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import { UserInfoHeaderView } from "../../../../../src/components/views/right_panel/user_info/UserInfoHeaderView"; -import { createTestClient } from "../../../../test-utils"; -import { useUserfoHeaderViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel"; +import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; +import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; +import { UserInfoHeaderView } from "../../../../../../src/components/views/right_panel/user_info/UserInfoHeaderView"; +import { createTestClient } from "../../../../../test-utils"; +import { useUserfoHeaderViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel"; // Mock the viewmodel hooks -jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel", () => ({ +jest.mock("../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel", () => ({ useUserfoHeaderViewModel: jest.fn().mockReturnValue({ onMemberAvatarClick: jest.fn(), precenseInfo: { diff --git a/test/unit-tests/components/views/right_panel/user_info/__snapshots__/UserInfoBasic-test.tsx.snap b/test/unit-tests/components/views/right_panel/user_info/__snapshots__/UserInfoBasic-test.tsx.snap new file mode 100644 index 0000000000..70bb7465a3 --- /dev/null +++ b/test/unit-tests/components/views/right_panel/user_info/__snapshots__/UserInfoBasic-test.tsx.snap @@ -0,0 +1,315 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should display the defaut values 1`] = ` +
+
+
+
+
+ + +
+
+
+ + + + + +
+
+ +
+
+ +
+
+`; diff --git a/test/unit-tests/components/views/right_panel/__snapshots__/UserInfoHeaderVerificationView-test.tsx.snap b/test/unit-tests/components/views/right_panel/user_info/__snapshots__/UserInfoHeaderVerificationView-test.tsx.snap similarity index 100% rename from test/unit-tests/components/views/right_panel/__snapshots__/UserInfoHeaderVerificationView-test.tsx.snap rename to test/unit-tests/components/views/right_panel/user_info/__snapshots__/UserInfoHeaderVerificationView-test.tsx.snap diff --git a/test/unit-tests/components/views/right_panel/__snapshots__/UserInfoHeaderView-test.tsx.snap b/test/unit-tests/components/views/right_panel/user_info/__snapshots__/UserInfoHeaderView-test.tsx.snap similarity index 100% rename from test/unit-tests/components/views/right_panel/__snapshots__/UserInfoHeaderView-test.tsx.snap rename to test/unit-tests/components/views/right_panel/user_info/__snapshots__/UserInfoHeaderView-test.tsx.snap From d0a8879971333a4ed00b6440adc2e4302aa9a2fa Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Mon, 20 Oct 2025 14:08:45 +0200 Subject: [PATCH 05/10] Revert "A11y: move focus to right panel when opened" (#30999) * Revert "A11y: move focus to right panel when opened (#30553)" This reverts commit 0c498a66b1ccc0c6b198af804ec8704f25b226cf. * test(e2e): update test --- playwright/pages/ElementAppPage.ts | 2 +- .../user-view.spec.ts/user-info-linux.png | Bin 19403 -> 20061 bytes src/components/structures/RightPanel.tsx | 20 ++---------------- src/i18n/strings/en_EN.json | 1 - .../__snapshots__/RoomView-test.tsx.snap | 2 -- .../__snapshots__/AppTile-test.tsx.snap | 2 -- 6 files changed, 3 insertions(+), 24 deletions(-) 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 df1a991e9a4456eafb257dbe094190953efc6eb8..a4f6a476f66bd1e29082312aeef7064840399969 100644 GIT binary patch literal 20061 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 dP}`cB;r_KMu9A??z&e|O!PC{xWt~$(69B~ { 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 ( -