Merge branch 'develop' into dbkr/module_experiments

This commit is contained in:
David Baker 2025-10-21 11:04:34 +01:00 committed by GitHub
commit c31d4fea8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 2465 additions and 1026 deletions

View File

@ -11,7 +11,7 @@ There are some exceptions like when using localhost, which is considered a [secu
1. Download the latest version from <https://github.com/element-hq/element-web/releases>
1. Untar the tarball on your web server
1. Move (or symlink) the `element-x.x.x` directory to an appropriate name
1. Configure the correct caching headers in your webserver (see below)
1. Configure the correct caching headers in your webserver (see [README.md](../README.md#caching-requirements))
1. Configure the app by copying `config.sample.json` to `config.json` and
modifying it. See the [configuration docs](config.md) for details.
1. Enter the URL into your browser and log into Element!

View File

@ -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",

View File

@ -218,7 +218,7 @@ export class ElementAppPage {
*/
public async inviteUserToCurrentRoom(userId: string): Promise<void> {
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);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -10,7 +10,7 @@ import {
type StartedPostgreSqlContainer,
} from "@element-hq/element-web-playwright-common/lib/testcontainers";
const TAG = "main@sha256:0a72a3ecb38e961e45062de91a9afa6d8f7319ddba460b8141b4e6a1bab45ea1";
const TAG = "main@sha256:9038b286c20370b0c9a4aacfd540ad741d72ed3f5fda3fa556498d4ee5909d55";
/**
* MatrixAuthenticationServiceContainer which freezes the docker digest to

View File

@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
const TAG = "develop@sha256:51e3ca5c351669569a945ad3cbfd11c803d19f7c32bdf6727ccffb3808a61a97";
const TAG = "develop@sha256:eccb2cb1cbca46831b1851fe0ae130551be5b6cceeef42886bce6da19d46bd98";
/**
* SynapseContainer which freezes the docker digest to stabilise tests,

View File

@ -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<string[]>, opts: EventRenderOpts = {}): EventAnalysis {
@ -320,6 +321,18 @@ function analyseEvent(content: IContent, highlights: Optional<string[]>, 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<string[]>, 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. foo<span/>bar 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. foo<span/>bar 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<string[]>, 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<string[]>, 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("");
}

View File

@ -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.
*

View File

@ -34,7 +34,6 @@ import { Action } from "../../dispatcher/actions";
import { type XOR } from "../../@types/common";
import ExtensionsCard from "../views/right_panel/ExtensionsCard";
import MemberListView from "../views/rooms/MemberList/MemberListView";
import { _t } from "../../languageHandler";
interface BaseProps {
overwriteCard?: IRightPanelCard; // used to display a custom card and ignoring the RightPanelStore (used for UserView)
@ -65,7 +64,6 @@ interface IState {
export default class RightPanel extends React.Component<Props, IState> {
public static contextType = MatrixClientContext;
declare public context: React.ContextType<typeof MatrixClientContext>;
private ref = React.createRef<HTMLDivElement>();
public constructor(props: Props) {
super(props);
@ -84,7 +82,6 @@ export default class RightPanel extends React.Component<Props, IState> {
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<Props, IState> {
};
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<Props, IState> {
}
return (
<aside
aria-label={_t("right_panel|title")}
ref={this.ref}
className="mx_RightPanel"
id="mx_RightPanel"
data-testid="right-panel"
tabIndex={-1}
>
<aside className="mx_RightPanel" id="mx_RightPanel" data-testid="right-panel">
{card}
</aside>
);

View File

@ -32,7 +32,6 @@ import {
RoomType,
GuestAccess,
HistoryVisibility,
type HierarchyRelation,
type HierarchyRoom,
JoinRule,
} from "matrix-js-sdk/src/matrix";
@ -71,6 +70,7 @@ import { getTopic } from "../../hooks/room/useTopic";
import { SdkContextClass } from "../../contexts/SDKContext";
import { getDisplayAliasForAliasSet } from "../../Rooms";
import SettingsStore from "../../settings/SettingsStore";
import { filterBoolean } from "../../utils/arrays.ts";
interface IProps {
space: Room;
@ -504,68 +504,67 @@ export const HierarchyLevel: React.FC<IHierarchyLevelProps> = ({
const space = cli.getRoom(root.room_id);
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getSafeUserId());
const sortedChildren = sortBy(root.children_state, (ev) => {
return getChildOrder(ev.content.order, ev.origin_server_ts, ev.state_key);
});
const [subspaces, childRooms] = sortedChildren.reduce(
(result, ev: HierarchyRelation) => {
const room = hierarchy.roomMap.get(ev.state_key);
if (room && roomSet.has(room)) {
result[room.room_type === RoomType.Space ? 0 : 1].push(toLocalRoom(cli, room, hierarchy));
}
return result;
},
[[] as HierarchyRoom[], [] as HierarchyRoom[]],
const sortedChildren = filterBoolean(
sortBy(root.children_state, (ev) => {
return getChildOrder(ev.content.order, ev.origin_server_ts, ev.state_key);
}).map((ev) => {
const hierarchyRoom = hierarchy.roomMap.get(ev.state_key);
if (!hierarchyRoom || !roomSet.has(hierarchyRoom)) return null;
// Find the most up-to-date info for this room, if it has been upgraded and we know about it.
return toLocalRoom(cli, hierarchyRoom, hierarchy);
}),
);
const newParents = new Set(parents).add(root.room_id);
return (
<React.Fragment>
{uniqBy(childRooms, "room_id").map((room) => (
<Tile
key={room.room_id}
room={room}
suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
onViewRoomClick={() => onViewRoomClick(room.room_id, room.room_type as RoomType)}
onJoinRoomClick={() => onJoinRoomClick(room.room_id, newParents)}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
/>
))}
{subspaces
.filter((room) => !newParents.has(room.room_id))
.map((space) => (
<Tile
key={space.room_id}
room={space}
numChildRooms={
space.children_state.filter((ev) => {
const room = hierarchy.roomMap.get(ev.state_key);
return room && roomSet.has(room) && !room.room_type;
}).length
}
suggested={hierarchy.isSuggested(root.room_id, space.room_id)}
selected={selectedMap?.get(root.room_id)?.has(space.room_id)}
onViewRoomClick={() => onViewRoomClick(space.room_id, RoomType.Space)}
onJoinRoomClick={() => onJoinRoomClick(space.room_id, newParents)}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined}
>
<HierarchyLevel
root={space}
roomSet={roomSet}
hierarchy={hierarchy}
parents={newParents}
selectedMap={selectedMap}
onViewRoomClick={onViewRoomClick}
onJoinRoomClick={onJoinRoomClick}
onToggleClick={onToggleClick}
{uniqBy(sortedChildren, "room_id").map((room) => {
if (room.room_type !== RoomType.Space) {
return (
<Tile
key={room.room_id}
room={room}
suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
onViewRoomClick={() => onViewRoomClick(room.room_id, room.room_type as RoomType)}
onJoinRoomClick={() => onJoinRoomClick(room.room_id, newParents)}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
/>
</Tile>
))}
);
} else {
if (newParents.has(room.room_id)) return null; // prevent cycles
return (
<Tile
key={room.room_id}
room={room}
numChildRooms={
room.children_state.filter((ev) => {
const child = hierarchy.roomMap.get(ev.state_key);
return child && roomSet.has(child) && !child.room_type;
}).length
}
suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
onViewRoomClick={() => onViewRoomClick(room.room_id, RoomType.Space)}
onJoinRoomClick={() => onJoinRoomClick(room.room_id, newParents)}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
>
<HierarchyLevel
root={room}
roomSet={roomSet}
hierarchy={hierarchy}
parents={newParents}
selectedMap={selectedMap}
onViewRoomClick={onViewRoomClick}
onJoinRoomClick={onJoinRoomClick}
onToggleClick={onToggleClick}
/>
</Tile>
);
}
})}
</React.Fragment>
);
};

View File

@ -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<void>;
// Method called when the DM button is clicked, will open a DM with the selected member
onOpenDmForUser: (member: Member) => Promise<void>;
}
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<ViewRoomPayload>({
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<ComposerInsertPayload>({
action: Action.ComposerInsert,
userId: member.userId,
timelineRenderingType: TimelineRenderingType.Room,
});
};
const onInviteUserButton = async (ev: Event): Promise<void> => {
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<void> => {
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,
};
};

View File

@ -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<IRoomPermissions>({
// 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<IPowerLevelsContent>(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: <div>{_t("user_info|deactivate_confirm_description")}</div>,
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,
};
};

View File

@ -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: <div>{_t("user_info|ignore_confirm_description")}</div>,
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,
};
};

View File

@ -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(
</As>
);
if (!linkify) return body;
return <Linkify options={linkifyOptions}>{body}</Linkify>;
return body;
},
);

View File

@ -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<void> {
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 (
<MenuItem
role="button"
onSelect={async (ev) => {
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<ViewRoomPayload>({
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 = (
<MenuItem
role="button"
onSelect={async (ev) => {
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<ComposerInsertPayload>({
action: Action.ComposerInsert,
userId: member.userId,
timelineRenderingType: TimelineRenderingType.Room,
});
};
insertPillButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
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<void> => {
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 = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onInviteUserButton(ev);
}}
label={_t("action|invite")}
Icon={InviteIcon}
/>
);
}
}
const shareUserButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onShareUserClick();
}}
label={_t("user_info|share_button")}
Icon={ShareIcon}
/>
);
const directMessageButton =
isMe || !shouldShowComponent(UIComponent.CreateRooms) ? null : <MessageButton member={member} />;
return (
<Container>
{children}
{directMessageButton}
{inviteUserButton}
{readReceiptButton}
{shareUserButton}
{insertPillButton}
</Container>
);
};
export const warnSelfDemote = async (isSpace: boolean): Promise<boolean> => {
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<IPowerLevelsContent>(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: <div>{_t("user_info|ignore_confirm_description")}</div>,
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 (
<MenuItem
role="button"
onSelect={async (ev) => {
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<IRoomPermissions>({
// 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: <div>{_t("user_info|deactivate_confirm_description")}</div>,
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 = (
<MenuItem
role="button"
onSelect={async (ev) => {
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 = (
<PowerLevelSection user={member as RoomMember} room={room} roomPermissions={roomPermissions} />
);
}
adminToolsContainer = (
<UserInfoAdminToolsContainer
powerLevels={powerLevels}
member={member as RoomMember}
room={room}
isUpdating={pendingUpdateCount > 0}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
>
{synapseDeactivateButton}
</UserInfoAdminToolsContainer>
);
} else if (synapseDeactivateButton) {
adminToolsContainer = <Container>{synapseDeactivateButton}</Container>;
}
if (pendingUpdateCount > 0) {
spinner = <Spinner />;
}
const isMe = member.userId === cli.getUserId();
return (
<React.Fragment>
<UserOptionsSection
canInvite={roomPermissions.canInvite}
member={member as RoomMember}
isSpace={room?.isSpaceRoom()}
>
{memberDetails}
</UserOptionsSection>
{adminToolsContainer}
{!isMe && (
<Container>
<IgnoreToggleButton member={member} />
</Container>
)}
{spinner}
</React.Fragment>
);
};
export type Member = User | RoomMember;
interface IProps {
@ -700,7 +221,7 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
let content: JSX.Element | undefined;
switch (phase) {
case RightPanelPhases.MemberInfo:
content = <BasicUserInfo room={room as Room} member={member as User} />;
content = <UserInfoBasicView room={room as Room} member={member as User} />;
break;
case RightPanelPhases.EncryptionPanel:
classes.push("mx_UserInfo_smallAvatar");

View File

@ -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<void>;
}): JSX.Element => {
const [busy, setBusy] = useState(false);
return (
<MenuItem
role="button"
onSelect={async (ev) => {
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 = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
vm.onReadReceiptButton();
}}
label={_t("user_info|jump_to_rr_button")}
disabled={vm.readReceiptButtonDisabled}
Icon={CheckIcon}
/>
);
if (vm.showInsertPillButton) {
insertPillButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
vm.onInsertPillButton();
}}
label={_t("action|mention")}
Icon={MentionIcon}
/>
);
}
if (vm.showInviteButton && shouldShowComponent(UIComponent.InviteUsers)) {
inviteUserButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
vm.onInviteUserButton(ev);
}}
label={_t("action|invite")}
Icon={InviteIcon}
/>
);
}
}
const shareUserButton = (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
vm.onShareUserClick();
}}
label={_t("user_info|share_button")}
Icon={ShareIcon}
/>
);
const directMessageButton =
vm.isMe || !shouldShowComponent(UIComponent.CreateRooms) ? null : (
<MessageButton member={member} openDMForUser={vm.onOpenDmForUser} />
);
return (
<Container>
{children}
{directMessageButton}
{inviteUserButton}
{readReceiptButton}
{shareUserButton}
{insertPillButton}
</Container>
);
};

View File

@ -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 = (
<MenuItem
role="button"
onSelect={async (ev) => {
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 = (
<PowerLevelSection user={member as RoomMember} room={room} roomPermissions={vm.roomPermissions} />
);
}
adminToolsContainer = (
<UserInfoAdminToolsContainer
powerLevels={vm.powerLevels}
member={member as RoomMember}
room={room}
isUpdating={vm.pendingUpdateCount > 0}
startUpdating={vm.startUpdating}
stopUpdating={vm.stopUpdating}
>
{synapseDeactivateButton}
</UserInfoAdminToolsContainer>
);
} else if (synapseDeactivateButton) {
adminToolsContainer = <Container>{synapseDeactivateButton}</Container>;
}
if (vm.pendingUpdateCount > 0) {
spinner = <Spinner />;
}
return (
<React.Fragment>
<UserInfoBasicOptionsView room={room} member={member}>
{memberDetails}
</UserInfoBasicOptionsView>
{adminToolsContainer}
{!vm.isMe && (
<Container>
<IgnoreToggleButton member={member} />
</Container>
)}
{spinner}
</React.Fragment>
);
};

View File

@ -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 (
<MenuItem
role="button"
onSelect={async (ev) => vm.ignoreButtonClick(ev)}
label={vm.isIgnored ? _t("user_info|unignore_button") : _t("user_info|ignore_button")}
kind="critical"
Icon={BlockIcon}
/>
);
};

View File

@ -1917,7 +1917,6 @@
"thread_list": {
"context_menu_label": "Thread options"
},
"title": "Right panel",
"video_room_chat": {
"title": "Chat"
}

View File

@ -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",

View File

@ -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;

View File

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

View File

@ -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 <Spoiler reason={reason}>{domToReact(span.children as DOMNode[])}</Spoiler>;
return (
<Spoiler reason={reason}>{domToReact(span.children as DOMNode[], { replace: params.replace })}</Spoiler>
);
}
},
};

View File

@ -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<Parameters, "replace">) => 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;
};

View File

@ -31,6 +31,7 @@ import { ReactionEventPreview } from "./previews/ReactionEventPreview";
import { UPDATE_EVENT } from "../AsyncStore";
import { type IPreview } from "./previews/IPreview";
import shouldHideEvent from "../../shouldHideEvent";
import SettingsStore from "../../settings/SettingsStore";
// Emitted event for when a room's preview has changed. First argument will the room for which
// the change happened.
@ -178,11 +179,14 @@ export class MessagePreviewStore extends AsyncStoreWithClient<EmptyObject> {
private async generatePreview(room: Room, tagId?: TagID): Promise<void> {
const events = [...room.getLiveTimeline().getEvents(), ...room.getPendingEvents()];
// add last reply from each thread
room.getThreads().forEach((thread: Thread): void => {
const lastReply = thread.lastReply();
if (lastReply) events.push(lastReply);
});
const isNewRoomListEnabled = SettingsStore.getValue("feature_new_room_list");
if (!isNewRoomListEnabled) {
// add last reply from each thread
room.getThreads().forEach((thread: Thread): void => {
const lastReply = thread.lastReply();
if (lastReply) events.push(lastReply);
});
}
// sort events from oldest to newest
events.sort((a: MatrixEvent, b: MatrixEvent) => {

View File

@ -86,6 +86,44 @@ describe("bodyToHtml", () => {
expect(html).toMatchInlineSnapshot(`"<span class="mx_EventTile_searchHighlight">test</span> foo &lt;b&gt;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 <a href="http://link.example/test/path" class="linkified" target="_blank" rel="noreferrer noopener">http://link.example/<span class="mx_EventTile_searchHighlight">test</span>/path</a> bar"`,
);
});
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 <a href="http://link.example/test/path">http://link.example/test/path</a> bar',
format: "org.matrix.custom.html",
},
["test"],
{
linkify: true,
},
);
expect(html).toMatchInlineSnapshot(
`"foo <a href="http://link.example/test/path" target="_blank" rel="noreferrer noopener">http://link.example/<span class="mx_EventTile_searchHighlight">test</span>/path</a> bar"`,
);
});
it("does not mistake characters in text presentation mode for emoji", () => {
const { asFragment } = render(
<span className="mx_EventTile_body translate" dir="auto">

View File

@ -169,11 +169,14 @@ describe("SpaceHierarchy", () => {
const room2 = mkStubRoom("room-id-3", "Room 2", client);
const space1 = mkStubRoom("space-id-4", "Space 2", client);
const room3 = mkStubRoom("room-id-5", "Room 3", client);
const space2 = mkStubRoom("space-id-6", "Space 3", client);
mocked(client.getRooms).mockReturnValue([root]);
mocked(client.getRoom).mockImplementation(
(roomId) => client.getRooms().find((room) => room.roomId === roomId) ?? null,
);
[room1, room2, space1, room3].forEach((r) => mocked(r.getMyMembership).mockReturnValue(KnownMembership.Leave));
[room1, room2, space1, room3, space2].forEach((r) =>
mocked(r.getMyMembership).mockReturnValue(KnownMembership.Leave),
);
const hierarchyRoot: HierarchyRoom = {
room_id: root.roomId,
@ -324,5 +327,36 @@ describe("SpaceHierarchy", () => {
undefined,
);
});
it("should not render cycles", async () => {
const hierarchySpace2: HierarchyRoom = {
room_id: space2.roomId,
name: "Space with cycle",
num_joined_members: 1,
room_type: "m.space",
children_state: [
{
state_key: root.roomId,
content: { order: "1" },
origin_server_ts: 111,
type: "m.space.child",
sender: "@other:server",
},
],
world_readable: true,
guest_can_join: true,
};
mocked(client.getRoomHierarchy).mockResolvedValue({
rooms: [hierarchyRoot, hierarchyRoom1, hierarchyRoom2, hierarchySpace1, hierarchySpace2],
});
const { getAllByText, queryByText, asFragment } = render(getComponent());
// Wait for spinners to go away
await waitForElementToBeRemoved(screen.getAllByRole("progressbar"));
expect(getAllByText("Nested space")).toHaveLength(1);
expect(queryByText("Space 1")).not.toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
});
});

View File

@ -1958,11 +1958,9 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
style="position: relative; user-select: auto; width: 420px; height: 100%; max-width: 50%; min-width: 320px; box-sizing: border-box; flex-shrink: 0;"
>
<aside
aria-label="Right panel"
class="mx_RightPanel"
data-testid="right-panel"
id="mx_RightPanel"
tabindex="-1"
>
<div
class="mx_BaseCard mx_ThreadPanel mx_TimelineCard"

View File

@ -254,12 +254,13 @@ exports[`SpaceHierarchy <SpaceHierarchy /> renders 1`] = `
</div>
</li>
<li
aria-expanded="true"
aria-labelledby="_r_g_"
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile mx_SpaceHierarchy_subspace"
role="button"
tabindex="-1"
>
@ -271,13 +272,13 @@ exports[`SpaceHierarchy <SpaceHierarchy /> renders 1`] = `
>
<span
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="6"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
K
N
</span>
</div>
<div
@ -286,24 +287,24 @@ exports[`SpaceHierarchy <SpaceHierarchy /> renders 1`] = `
<span
id="_r_g_"
>
Knock room
Nested space
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
3 members
1 member · 1 room
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="-1"
>
View
Join
</div>
<form
class="_root_19upo_16"
@ -349,11 +350,477 @@ exports[`SpaceHierarchy <SpaceHierarchy /> renders 1`] = `
</div>
</form>
</div>
<div
class="mx_SpaceHierarchy_subspace_toggle mx_SpaceHierarchy_subspace_toggle_shown"
/>
</div>
<div
class="mx_SpaceHierarchy_subspace_children"
role="group"
/>
</li>
<li
aria-labelledby="_r_k_"
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
role="button"
tabindex="-1"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_avatar"
>
<span
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
N
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
<span
id="_r_k_"
>
Nested room
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
3 members
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="-1"
>
Join
</div>
<span
aria-labelledby="_r_l_"
tabindex="0"
>
<form
class="_root_19upo_16"
>
<div
class="_inline-field_19upo_32"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_1hel1_10"
>
<input
aria-labelledby="_r_k_"
class="_input_1hel1_18"
disabled=""
id="checkbox_RD7nyrA2oh"
role="presentation"
tabindex="-1"
type="checkbox"
/>
<div
class="_ui_1hel1_19"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
/>
</div>
</form>
</span>
</div>
</div>
</li>
<li
aria-labelledby="_r_t_"
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
role="button"
tabindex="-1"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_avatar"
>
<span
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="6"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
K
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
<span
id="_r_t_"
>
Knock room
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
3 members
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
role="button"
tabindex="-1"
>
View
</div>
<form
class="_root_19upo_16"
>
<div
class="_inline-field_19upo_32"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_1hel1_10"
>
<input
aria-labelledby="_r_t_"
class="_input_1hel1_18"
id="checkbox_jWVJIPauy1"
role="presentation"
tabindex="-1"
type="checkbox"
/>
<div
class="_ui_1hel1_19"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
/>
</div>
</form>
</div>
</div>
</li>
</ul>
</DocumentFragment>
`;
exports[`SpaceHierarchy <SpaceHierarchy /> should not render cycles 1`] = `
<DocumentFragment>
<div
class="mx_SearchBox mx_textinput"
>
<input
autocomplete="off"
class="mx_textinput_icon mx_textinput_search mx_SpaceHierarchy_search mx_textinput_icon mx_textinput_search"
data-testid="searchbox-input"
placeholder="Search names and descriptions"
type="text"
value=""
/>
<div
class="mx_AccessibleButton mx_SearchBox_closeButton"
role="button"
tabindex="-1"
/>
</div>
<div
class="mx_SpaceHierarchy_listHeader"
>
<h4
class="mx_SpaceHierarchy_listHeader_header"
>
Rooms and spaces
</h4>
<div
class="mx_SpaceHierarchy_listHeader_buttons"
>
<div
aria-disabled="true"
aria-label="Remove"
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_outline mx_AccessibleButton_disabled"
disabled=""
role="button"
tabindex="0"
>
Remove
</div>
<div
aria-disabled="true"
aria-label="Mark as not suggested"
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline mx_AccessibleButton_disabled"
disabled=""
role="button"
tabindex="0"
>
Mark as not suggested
</div>
</div>
</div>
<ul
aria-label="Space"
class="mx_SpaceHierarchy_list"
role="tree"
>
<li
aria-labelledby="_r_3i_"
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
role="button"
tabindex="0"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_avatar"
>
<span
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="5"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
U
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
<span
id="_r_3i_"
>
Unnamed Room
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
2 members
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Join
</div>
<form
class="_root_19upo_16"
>
<div
class="_inline-field_19upo_32"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_1hel1_10"
>
<input
aria-labelledby="_r_3i_"
class="_input_1hel1_18"
id="checkbox_EetmBG4yVC"
role="presentation"
tabindex="-1"
type="checkbox"
/>
<div
class="_ui_1hel1_19"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
/>
</div>
</form>
</div>
</div>
</li>
<li
aria-labelledby="_r_3m_"
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
role="button"
tabindex="-1"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_avatar"
>
<span
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="6"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
U
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
<span
id="_r_3m_"
>
Unnamed Room
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
3 members
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="-1"
>
Join
</div>
<form
class="_root_19upo_16"
>
<div
class="_inline-field_19upo_32"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_1hel1_10"
>
<input
aria-labelledby="_r_3m_"
class="_input_1hel1_18"
id="checkbox_eEefiPqpMR"
role="presentation"
tabindex="-1"
type="checkbox"
/>
<div
class="_ui_1hel1_19"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
/>
</div>
</form>
</div>
</div>
</li>
<li
aria-expanded="true"
aria-labelledby="_r_k_"
aria-labelledby="_r_3q_"
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
@ -383,7 +850,7 @@ exports[`SpaceHierarchy <SpaceHierarchy /> renders 1`] = `
class="mx_SpaceHierarchy_roomTile_name"
>
<span
id="_r_k_"
id="_r_3q_"
>
Nested space
</span>
@ -391,7 +858,7 @@ exports[`SpaceHierarchy <SpaceHierarchy /> renders 1`] = `
<div
class="mx_SpaceHierarchy_roomTile_info"
>
1 member · 1 room
1 member · 0 rooms
</div>
</div>
<div
@ -417,9 +884,9 @@ exports[`SpaceHierarchy <SpaceHierarchy /> renders 1`] = `
class="_container_1hel1_10"
>
<input
aria-labelledby="_r_k_"
aria-labelledby="_r_3q_"
class="_input_1hel1_18"
id="checkbox_RD7nyrA2oh"
id="checkbox_MwbPDmfGtm"
role="presentation"
tabindex="-1"
type="checkbox"
@ -457,110 +924,6 @@ exports[`SpaceHierarchy <SpaceHierarchy /> renders 1`] = `
role="group"
/>
</li>
<li
aria-labelledby="_r_o_"
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
role="button"
tabindex="-1"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_avatar"
>
<span
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
N
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
<span
id="_r_o_"
>
Nested room
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
3 members
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="-1"
>
Join
</div>
<span
aria-labelledby="_r_p_"
tabindex="0"
>
<form
class="_root_19upo_16"
>
<div
class="_inline-field_19upo_32"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_1hel1_10"
>
<input
aria-labelledby="_r_o_"
class="_input_1hel1_18"
disabled=""
id="checkbox_jWVJIPauy1"
role="presentation"
tabindex="-1"
type="checkbox"
/>
<div
class="_ui_1hel1_19"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
/>
</div>
</form>
</span>
</div>
</div>
</li>
</ul>
</DocumentFragment>
`;

View File

@ -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("<UserOptionsSection />", () => {
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",
});
});
});
});

View File

@ -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: (
<div>
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?
</div>
),
title: "Deactivate user?",
});
});
expect(mockClient.deactivateSynapseUser).toHaveBeenCalledWith(defaultMember.userId);
});
});

View File

@ -3,11 +3,9 @@
exports[`AppTile destroys non-persisted right panel widget on room change 1`] = `
<DocumentFragment>
<aside
aria-label="Right panel"
class="mx_RightPanel"
data-testid="right-panel"
id="mx_RightPanel"
tabindex="-1"
>
<div
class="mx_BaseCard mx_WidgetCard"

View File

@ -188,7 +188,7 @@ describe("<TextualBody />", () => {
const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot(
`"Chat with <a href="https://matrix.to/#/@user:example.com" rel="noreferrer noopener" class="linkified">@user:example.com</a>"`,
`"Chat with <a href="https://matrix.to/#/@user:example.com" class="linkified" rel="noreferrer noopener">@user:example.com</a>"`,
);
});
@ -206,7 +206,7 @@ describe("<TextualBody />", () => {
const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot(
`"Visit <a href="https://matrix.to/#/#room:example.com" rel="noreferrer noopener" class="linkified">#room:example.com</a>"`,
`"Visit <a href="https://matrix.to/#/#room:example.com" class="linkified" rel="noreferrer noopener">#room:example.com</a>"`,
);
});

View File

@ -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("<UserInfo />", () => {
});
});
describe("<UserOptionsSection />", () => {
const member = new RoomMember(defaultRoomId, defaultUserId);
const defaultProps = { member, canInvite: false, isSpace: false };
const renderComponent = (props = {}) => {
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<UserOptionsSection {...defaultProps} {...props} />, {
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<string>();
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 = [

View File

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

View File

@ -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("<UserInfoBasic />", () => {
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(
<MatrixClientContext.Provider value={matrixClient}>
<UserInfoBasicView {...props} />
</MatrixClientContext.Provider>,
);
};
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();
});
});

View File

@ -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("<UserOptionsSection />", () => {
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(<UserInfoBasicOptionsView {...defaultProps} />);
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(<UserInfoBasicOptionsView {...propsWithMe} />);
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(<UserInfoBasicOptionsView {...defaultProps} />);
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(<UserInfoBasicOptionsView {...defaultProps} />);
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(<UserInfoBasicOptionsView {...propsWithMe} />);
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(<UserInfoBasicOptionsView {...propsWithMe} />);
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(<UserInfoBasicOptionsView {...defaultProps} />);
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(<UserInfoBasicOptionsView {...defaultProps} />);
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(<UserInfoBasicOptionsView {...defaultProps} />);
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(<UserInfoBasicOptionsView {...defaultProps} />);
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(<UserInfoBasicOptionsView {...defaultProps} />);
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(<UserInfoBasicOptionsView {...propsWithMe} />);
const dmButton = screen.queryByRole("button", { name: "Send message" });
expect(dmButton).not.toBeInTheDocument();
});
});

View File

@ -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("<UserInfoHeaderVerificationView />", () => {
const defaultRoomId = "!fkfk";

View File

@ -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: {

View File

@ -0,0 +1,315 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<UserInfoBasic /> should display the defaut values 1`] = `
<div>
<div
class="mx_UserInfo_container"
>
<div
class="mx_UserInfo_profileField"
>
<div
class="mx_PowerSelector"
>
<div
class="mx_Field mx_Field_select"
>
<select
data-testid="power-level-select-element"
id="mx_Field_1"
label="Power level"
placeholder="Power level"
type="text"
>
<option
data-testid="power-level-option-0"
value="0"
>
Default
</option>
<option
data-testid="power-level-option-SELECT_VALUE_CUSTOM"
value="SELECT_VALUE_CUSTOM"
>
Custom level
</option>
</select>
<label
for="mx_Field_1"
>
Power level
</label>
</div>
</div>
</div>
<button
class="_item_dyt4i_8 _interactive_dyt4i_26"
data-kind="primary"
role="button"
>
<svg
aria-hidden="true"
class="_icon_dyt4i_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m1.5 21.25 1.45-4.95a10.2 10.2 0 0 1-.712-2.1A10.2 10.2 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22q-1.125 0-2.2-.238a10.2 10.2 0 0 1-2.1-.712L2.75 22.5a.94.94 0 0 1-1-.25.94.94 0 0 1-.25-1m2.45-1.2 3.2-.95a1 1 0 0 1 .275-.062q.15-.013.275-.013.225 0 .438.038.212.036.412.137a7.4 7.4 0 0 0 1.675.6Q11.1 20 12 20q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12q0 .9.2 1.775t.6 1.675q.176.325.188.688t-.088.712z"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_dyt4i_34"
>
Send message
</span>
<svg
aria-hidden="true"
class="_nav-hint_dyt4i_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
<button
class="_item_dyt4i_8 _interactive_dyt4i_26"
data-kind="primary"
role="button"
>
<svg
aria-hidden="true"
class="_icon_dyt4i_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 12q-1.65 0-2.825-1.175T6 8t1.175-2.825T10 4t2.825 1.175T14 8t-1.175 2.825T10 12m-8 6v-.8q0-.85.438-1.562.437-.713 1.162-1.088a14.8 14.8 0 0 1 3.15-1.163A13.8 13.8 0 0 1 10 13q1.65 0 3.25.387 1.6.388 3.15 1.163.724.375 1.163 1.087Q18 16.35 18 17.2v.8q0 .824-.587 1.413A1.93 1.93 0 0 1 16 20H4q-.824 0-1.412-.587A1.93 1.93 0 0 1 2 18m2 0h12v-.8a.97.97 0 0 0-.5-.85q-1.35-.675-2.725-1.012a11.6 11.6 0 0 0-5.55 0Q5.85 15.675 4.5 16.35a.97.97 0 0 0-.5.85zm6-8q.825 0 1.412-.588Q12 8.826 12 8q0-.824-.588-1.412A1.93 1.93 0 0 0 10 6q-.825 0-1.412.588A1.93 1.93 0 0 0 8 8q0 .825.588 1.412Q9.175 10 10 10m7 1h2v2q0 .424.288.713.287.287.712.287.424 0 .712-.287A.97.97 0 0 0 21 13v-2h2q.424 0 .712-.287A.97.97 0 0 0 24 10a.97.97 0 0 0-.288-.713A.97.97 0 0 0 23 9h-2V7a.97.97 0 0 0-.288-.713A.97.97 0 0 0 20 6a.97.97 0 0 0-.712.287A.97.97 0 0 0 19 7v2h-2a.97.97 0 0 0-.712.287A.97.97 0 0 0 16 10q0 .424.288.713.287.287.712.287"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_dyt4i_34"
>
Invite
</span>
<svg
aria-hidden="true"
class="_nav-hint_dyt4i_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
<button
class="_item_dyt4i_8 _interactive_dyt4i_26 _disabled_dyt4i_118"
data-kind="primary"
disabled=""
role="button"
>
<svg
aria-hidden="true"
class="_icon_dyt4i_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_dyt4i_34"
>
Jump to read receipt
</span>
<svg
aria-hidden="true"
class="_nav-hint_dyt4i_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
<button
class="_item_dyt4i_8 _interactive_dyt4i_26"
data-kind="primary"
role="button"
>
<svg
aria-hidden="true"
class="_icon_dyt4i_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 16a.97.97 0 0 1-.713-.287A.97.97 0 0 1 11 15V7.85L9.125 9.725q-.3.3-.7.3T7.7 9.7a.93.93 0 0 1-.288-.713A.98.98 0 0 1 7.7 8.3l3.6-3.6q.15-.15.325-.213.175-.062.375-.062t.375.062a.9.9 0 0 1 .325.213l3.6 3.6q.3.3.287.712a.98.98 0 0 1-.287.688q-.3.3-.713.313a.93.93 0 0 1-.712-.288L13 7.85V15q0 .424-.287.713A.97.97 0 0 1 12 16m-6 4q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 18v-2q0-.424.287-.713A.97.97 0 0 1 5 15q.424 0 .713.287Q6 15.576 6 16v2h12v-2q0-.424.288-.713A.97.97 0 0 1 19 15q.424 0 .712.287.288.288.288.713v2q0 .824-.587 1.413A1.93 1.93 0 0 1 18 20z"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_dyt4i_34"
>
Share profile
</span>
<svg
aria-hidden="true"
class="_nav-hint_dyt4i_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
<button
class="_item_dyt4i_8 _interactive_dyt4i_26"
data-kind="primary"
role="button"
>
<svg
aria-hidden="true"
class="_icon_dyt4i_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 4a8 8 0 1 0 0 16 1 1 0 1 1 0 2C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10v1.5a3.5 3.5 0 0 1-6.396 1.966A5 5 0 1 1 17 12v1.5a1.5 1.5 0 0 0 3 0V12a8 8 0 0 0-8-8m3 8a3 3 0 1 0-6 0 3 3 0 0 0 6 0"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_dyt4i_34"
>
Mention
</span>
<svg
aria-hidden="true"
class="_nav-hint_dyt4i_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
</div>
<div
class="mx_UserInfo_container"
>
<button
class="_item_dyt4i_8 _interactive_dyt4i_26"
data-kind="critical"
role="button"
>
<svg
aria-hidden="true"
class="_icon_dyt4i_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 21q-.824 0-1.412-.587A1.93 1.93 0 0 1 5 19V6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 5q0-.424.287-.713A.97.97 0 0 1 5 4h4q0-.424.287-.712A.97.97 0 0 1 10 3h4q.424 0 .713.288Q15 3.575 15 4h4q.424 0 .712.287Q20 4.576 20 5t-.288.713A.97.97 0 0 1 19 6v13q0 .824-.587 1.413A1.93 1.93 0 0 1 17 21zM7 6v13h10V6zm2 10q0 .424.287.712Q9.576 17 10 17t.713-.288A.97.97 0 0 0 11 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 10 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 9 9zm4 0q0 .424.287.712.288.288.713.288.424 0 .713-.288A.97.97 0 0 0 15 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 14 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 13 9z"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_dyt4i_34"
>
Deactivate user
</span>
<svg
aria-hidden="true"
class="_nav-hint_dyt4i_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
</div>
<div
class="mx_UserInfo_container"
>
<button
class="_item_dyt4i_8 _interactive_dyt4i_26"
data-kind="critical"
role="button"
>
<svg
aria-hidden="true"
class="_icon_dyt4i_50"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 22a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22m0-2q3.35 0 5.675-2.325T20 12q0-1.35-.437-2.6A8 8 0 0 0 18.3 7.1L7.1 18.3q1.05.825 2.3 1.262T12 20m-6.3-3.1L16.9 5.7a8 8 0 0 0-2.3-1.263A7.8 7.8 0 0 0 12 4Q8.65 4 6.325 6.325T4 12q0 1.35.438 2.6A8 8 0 0 0 5.7 16.9"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _label_dyt4i_34"
>
Ignore
</span>
<svg
aria-hidden="true"
class="_nav-hint_dyt4i_59"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</button>
</div>
</div>
`;

View File

@ -214,6 +214,9 @@ module.exports = (env, argv) => {
// Define a variable so the i18n stuff can load
"$webapp": path.resolve(__dirname, "webapp"),
// Make shared-components imports resolve to EW counterpart
"counterpart": path.resolve(__dirname, "node_modules/counterpart"),
},
fallback: {
// Mock out the NodeFS module: The opus decoder imports this wrongly.

View File

@ -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"