diff --git a/src/components/views/dialogs/RoomSettingsDialog.tsx b/src/components/views/dialogs/RoomSettingsDialog.tsx index 8c5de0b579..c070292cfa 100644 --- a/src/components/views/dialogs/RoomSettingsDialog.tsx +++ b/src/components/views/dialogs/RoomSettingsDialog.tsx @@ -1,6 +1,8 @@ /* Copyright 2019 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2023 The Matrix.org Foundation C.I.C. + Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,7 +18,7 @@ limitations under the License. */ import React from "react"; -import { RoomEvent } from "matrix-js-sdk/src/models/room"; +import { RoomEvent, Room } from "matrix-js-sdk/src/models/room"; import TabbedView, { Tab } from "../../structures/TabbedView"; import { _t, _td } from "../../../languageHandler"; @@ -36,6 +38,7 @@ import { VoipRoomSettingsTab } from "../settings/tabs/room/VoipRoomSettingsTab"; import { ActionPayload } from "../../../dispatcher/payloads"; import { NonEmptyArray } from "../../../@types/common"; import { PollHistoryTab } from "../settings/tabs/room/PollHistoryTab"; +import ErrorBoundary from "../elements/ErrorBoundary"; export const ROOM_GENERAL_TAB = "ROOM_GENERAL_TAB"; export const ROOM_VOIP_TAB = "ROOM_VOIP_TAB"; @@ -53,15 +56,17 @@ interface IProps { } interface IState { - roomName: string; + room: Room; } -export default class RoomSettingsDialog extends React.Component { +class RoomSettingsDialog extends React.Component { private dispatcherRef: string; public constructor(props: IProps) { super(props); - this.state = { roomName: "" }; + + const room = this.getRoom(); + this.state = { room }; } public componentDidMount(): void { @@ -70,6 +75,13 @@ export default class RoomSettingsDialog extends React.Component this.onRoomName(); } + public componentDidUpdate(): void { + if (this.props.roomId !== this.state.room.roomId) { + const room = this.getRoom(); + this.setState({ room }); + } + } + public componentWillUnmount(): void { if (this.dispatcherRef) { dis.unregister(this.dispatcherRef); @@ -78,6 +90,21 @@ export default class RoomSettingsDialog extends React.Component MatrixClientPeg.get().removeListener(RoomEvent.Name, this.onRoomName); } + /** + * Get room from client + * @returns Room + * @throws when room is not found + */ + private getRoom(): Room { + const room = MatrixClientPeg.get().getRoom(this.props.roomId)!; + + // something is really wrong if we encounter this + if (!room) { + throw new Error(`Cannot find room ${this.props.roomId}`); + } + return room; + } + private onAction = (payload: ActionPayload): void => { // When view changes below us, close the room settings // whilst the modal is open this can only be triggered when someone hits Leave Room @@ -87,9 +114,8 @@ export default class RoomSettingsDialog extends React.Component }; private onRoomName = (): void => { - this.setState({ - roomName: MatrixClientPeg.get().getRoom(this.props.roomId)?.name ?? "", - }); + // rerender when the room name changes + this.forceUpdate(); }; private getTabs(): NonEmptyArray { @@ -100,7 +126,7 @@ export default class RoomSettingsDialog extends React.Component ROOM_GENERAL_TAB, _td("General"), "mx_RoomSettingsDialog_settingsIcon", - , + , "RoomSettingsGeneral", ), ); @@ -110,7 +136,7 @@ export default class RoomSettingsDialog extends React.Component ROOM_VOIP_TAB, _td("Voice & Video"), "mx_RoomSettingsDialog_voiceIcon", - , + , ), ); } @@ -119,12 +145,7 @@ export default class RoomSettingsDialog extends React.Component ROOM_SECURITY_TAB, _td("Security & Privacy"), "mx_RoomSettingsDialog_securityIcon", - ( - this.props.onFinished(true)} - /> - ), + this.props.onFinished(true)} />, "RoomSettingsSecurityPrivacy", ), ); @@ -133,7 +154,7 @@ export default class RoomSettingsDialog extends React.Component ROOM_ROLES_TAB, _td("Roles & Permissions"), "mx_RoomSettingsDialog_rolesIcon", - , + , "RoomSettingsRolesPermissions", ), ); @@ -144,7 +165,7 @@ export default class RoomSettingsDialog extends React.Component "mx_RoomSettingsDialog_notificationsIcon", ( this.props.onFinished(true)} /> ), @@ -158,7 +179,7 @@ export default class RoomSettingsDialog extends React.Component ROOM_BRIDGES_TAB, _td("Bridges"), "mx_RoomSettingsDialog_bridgesIcon", - , + , "RoomSettingsBridges", ), ); @@ -169,7 +190,7 @@ export default class RoomSettingsDialog extends React.Component ROOM_POLL_HISTORY_TAB, _td("Poll history"), "mx_RoomSettingsDialog_pollsIcon", - this.props.onFinished(true)} />, + this.props.onFinished(true)} />, ), ); @@ -181,7 +202,7 @@ export default class RoomSettingsDialog extends React.Component "mx_RoomSettingsDialog_warningIcon", ( this.props.onFinished(true)} /> ), @@ -194,7 +215,7 @@ export default class RoomSettingsDialog extends React.Component } public render(): React.ReactNode { - const roomName = this.state.roomName; + const roomName = this.state.room.name; return ( ); } } + +const WrappedRoomSettingsDialog: React.FC = (props) => ( + + + +); + +export default WrappedRoomSettingsDialog; diff --git a/src/components/views/dialogs/SpaceSettingsDialog.tsx b/src/components/views/dialogs/SpaceSettingsDialog.tsx index e67e6f21ff..8cf8e8418b 100644 --- a/src/components/views/dialogs/SpaceSettingsDialog.tsx +++ b/src/components/views/dialogs/SpaceSettingsDialog.tsx @@ -70,14 +70,14 @@ const SpaceSettingsDialog: React.FC = ({ matrixClient: cli, space, onFin SpaceSettingsTab.Roles, _td("Roles & Permissions"), "mx_RoomSettingsDialog_rolesIcon", - , + , ), SettingsStore.getValue(UIFeature.AdvancedSettings) ? new Tab( SpaceSettingsTab.Advanced, _td("Advanced"), "mx_RoomSettingsDialog_warningIcon", - , + , ) : null, ].filter(Boolean) as NonEmptyArray; diff --git a/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx index 48f1a43ea7..e8ce957c3b 100644 --- a/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx @@ -16,9 +16,9 @@ limitations under the License. import React from "react"; import { EventType } from "matrix-js-sdk/src/@types/event"; +import { Room } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../../../languageHandler"; -import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; import AccessibleButton, { ButtonEvent } from "../../../elements/AccessibleButton"; import RoomUpgradeDialog from "../../../dialogs/RoomUpgradeDialog"; import Modal from "../../../../../Modal"; @@ -29,7 +29,7 @@ import { ViewRoomPayload } from "../../../../../dispatcher/payloads/ViewRoomPayl import SettingsStore from "../../../../../settings/SettingsStore"; interface IProps { - roomId: string; + room: Room; closeSettingsFn(): void; } @@ -64,8 +64,8 @@ export default class AdvancedRoomSettingsTab extends React.Component { + const room = this.props.room; + room.getRecommendedVersion().then((v) => { const tombstone = room.currentState.getStateEvents(EventType.RoomTombstone, ""); const additionalStateChanges: Partial = {}; @@ -85,8 +85,7 @@ export default class AdvancedRoomSettingsTab extends React.Component { - const room = MatrixClientPeg.get().getRoom(this.props.roomId); - if (room) Modal.createDialog(RoomUpgradeDialog, { room }); + Modal.createDialog(RoomUpgradeDialog, { room: this.props.room }); }; private onOldRoomClicked = (e: ButtonEvent): void => { @@ -105,12 +104,11 @@ export default class AdvancedRoomSettingsTab extends React.Component{_t("This room is not accessible by remote Matrix servers")}; } @@ -143,9 +141,9 @@ export default class AdvancedRoomSettingsTab extends React.Component{_t("Advanced")}
- {room?.isSpaceRoom() ? _t("Space information") : _t("Room information")} + {room.isSpaceRoom() ? _t("Space information") : _t("Room information")}
{_t("Internal room ID")} - this.props.roomId}>{this.props.roomId} + this.props.room.roomId}> + {this.props.room.roomId} +
{unfederatableSection}
@@ -172,7 +172,7 @@ export default class AdvancedRoomSettingsTab extends React.Component{_t("Room version")}
{_t("Room version:")}  - {room?.getVersion()} + {room.getVersion()}
{oldRoomLink} {roomUpgradeButton} diff --git a/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx b/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx index 4eb676f411..8731cee0a6 100644 --- a/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx @@ -30,7 +30,7 @@ const BRIDGE_EVENT_TYPES = [ const BRIDGES_LINK = "https://matrix.org/bridges/"; interface IProps { - roomId: string; + room: Room; } export default class BridgeSettingsTab extends React.Component { @@ -51,9 +51,8 @@ export default class BridgeSettingsTab extends React.Component { public render(): React.ReactNode { // This settings tab will only be invoked if the following function returns more // than 0 events, so no validation is needed at this stage. - const bridgeEvents = BridgeSettingsTab.getBridgeStateEvents(this.props.roomId); - const client = MatrixClientPeg.get(); - const room = client.getRoom(this.props.roomId); + const bridgeEvents = BridgeSettingsTab.getBridgeStateEvents(this.props.room.roomId); + const room = this.props.room; let content: JSX.Element; if (bridgeEvents.length > 0) { diff --git a/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx index 9c47a7432e..a915aa42e3 100644 --- a/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx @@ -15,6 +15,7 @@ limitations under the License. */ import React, { ContextType } from "react"; +import { Room } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../../../languageHandler"; import RoomProfileSettings from "../../../room_settings/RoomProfileSettings"; @@ -28,7 +29,7 @@ import AliasSettings from "../../../room_settings/AliasSettings"; import PosthogTrackers from "../../../../../PosthogTrackers"; interface IProps { - roomId: string; + room: Room; } interface IState { @@ -50,7 +51,7 @@ export default class GeneralRoomSettingsTab extends React.Component { dis.dispatch({ action: "leave_room", - room_id: this.props.roomId, + room_id: this.props.room.roomId, }); PosthogTrackers.trackInteraction("WebRoomSettingsLeaveButton", ev); @@ -58,17 +59,18 @@ export default class GeneralRoomSettingsTab extends React.Component : null; + const urlPreviewSettings = SettingsStore.getValue(UIFeature.URLPreviews) ? ( + + ) : null; let leaveSection; - if (room?.getMyMembership() === "join") { + if (room.getMyMembership() === "join") { leaveSection = ( <> {_t("Leave room")} @@ -85,12 +87,12 @@ export default class GeneralRoomSettingsTab extends React.Component
{_t("General")}
- +
{_t("Room Addresses")}
) { super(props, context); - this.roomProps = EchoChamber.forRoom(context.getRoom(this.props.roomId)); + this.roomProps = EchoChamber.forRoom(context.getRoom(this.props.roomId)!); let currentSound = "default"; const soundData = Notifier.getSoundForRoom(this.props.roomId); diff --git a/src/components/views/settings/tabs/room/PollHistoryTab.tsx b/src/components/views/settings/tabs/room/PollHistoryTab.tsx index c1866d3b0d..8c162859b2 100644 --- a/src/components/views/settings/tabs/room/PollHistoryTab.tsx +++ b/src/components/views/settings/tabs/room/PollHistoryTab.tsx @@ -15,23 +15,20 @@ limitations under the License. */ import React, { useContext } from "react"; +import { Room } from "matrix-js-sdk/src/matrix"; import MatrixClientContext from "../../../../../contexts/MatrixClientContext"; import { PollHistory } from "../../../polls/pollHistory/PollHistory"; import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks"; interface IProps { - roomId: string; + room: Room; onFinished: () => void; } -export const PollHistoryTab: React.FC = ({ roomId, onFinished }) => { +export const PollHistoryTab: React.FC = ({ room, onFinished }) => { const matrixClient = useContext(MatrixClientContext); - const room = matrixClient.getRoom(roomId); - if (!room) { - return null; - } - const permalinkCreator = new RoomPermalinkCreator(room, roomId); + const permalinkCreator = new RoomPermalinkCreator(room, room.roomId); return (
diff --git a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx index 743cceff75..3f2c6d65fe 100644 --- a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx @@ -22,6 +22,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { throttle, get } from "lodash"; import { compare } from "matrix-js-sdk/src/utils"; import { IContent } from "matrix-js-sdk/src/models/event"; +import { Room } from "matrix-js-sdk/src/matrix"; import { _t, _td } from "../../../../../languageHandler"; import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; @@ -129,7 +130,7 @@ export class BannedUser extends React.Component { } interface IProps { - roomId: string; + room: Room; } export default class RolesRoomSettingsTab extends React.Component { @@ -145,7 +146,7 @@ export default class RolesRoomSettingsTab extends React.Component { } private onRoomStateUpdate = (state: RoomState): void => { - if (state.roomId !== this.props.roomId) return; + if (state.roomId !== this.props.room.roomId) return; this.onThisRoomMembership(); }; @@ -171,8 +172,8 @@ export default class RolesRoomSettingsTab extends React.Component { private onPowerLevelsChanged = (value: number, powerLevelKey: string): void => { const client = MatrixClientPeg.get(); - const room = client.getRoom(this.props.roomId); - const plEvent = room?.currentState.getStateEvents(EventType.RoomPowerLevels, ""); + const room = this.props.room; + const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); let plContent = plEvent?.getContent() ?? {}; // Clone the power levels just in case @@ -186,7 +187,7 @@ export default class RolesRoomSettingsTab extends React.Component { plContent["events"][powerLevelKey.slice(eventsLevelPrefix.length)] = value; } else { const keyPath = powerLevelKey.split("."); - let parentObj: IContent | undefined; + let parentObj: IContent = {}; let currentObj = plContent; for (const key of keyPath) { if (!currentObj[key]) { @@ -198,7 +199,7 @@ export default class RolesRoomSettingsTab extends React.Component { parentObj[keyPath[keyPath.length - 1]] = value; } - client.sendStateEvent(this.props.roomId, EventType.RoomPowerLevels, plContent).catch((e) => { + client.sendStateEvent(this.props.room.roomId, EventType.RoomPowerLevels, plContent).catch((e) => { logger.error(e); Modal.createDialog(ErrorDialog, { @@ -213,8 +214,8 @@ export default class RolesRoomSettingsTab extends React.Component { private onUserPowerLevelChanged = (value: number, powerLevelKey: string): void => { const client = MatrixClientPeg.get(); - const room = client.getRoom(this.props.roomId); - const plEvent = room?.currentState.getStateEvents(EventType.RoomPowerLevels, ""); + const room = this.props.room; + const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); let plContent = plEvent?.getContent() ?? {}; // Clone the power levels just in case @@ -224,7 +225,7 @@ export default class RolesRoomSettingsTab extends React.Component { if (!plContent["users"]) plContent["users"] = {}; plContent["users"][powerLevelKey] = value; - client.sendStateEvent(this.props.roomId, EventType.RoomPowerLevels, plContent).catch((e) => { + client.sendStateEvent(this.props.room.roomId, EventType.RoomPowerLevels, plContent).catch((e) => { logger.error(e); Modal.createDialog(ErrorDialog, { @@ -239,12 +240,12 @@ export default class RolesRoomSettingsTab extends React.Component { public render(): React.ReactNode { const client = MatrixClientPeg.get(); - const room = client.getRoom(this.props.roomId); - const isSpaceRoom = room?.isSpaceRoom(); + const room = this.props.room; + const isSpaceRoom = room.isSpaceRoom(); - const plEvent = room?.currentState.getStateEvents(EventType.RoomPowerLevels, ""); + const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); const plContent = plEvent ? plEvent.getContent() || {} : {}; - const canChangeLevels = room?.currentState.mayClientSendStateEvent(EventType.RoomPowerLevels, client); + const canChangeLevels = room.currentState.mayClientSendStateEvent(EventType.RoomPowerLevels, client); const plEventsToLabels: Record = { // These will be translated for us later. @@ -392,7 +393,7 @@ export default class RolesRoomSettingsTab extends React.Component { } } - const banned = room?.getMembersWithMembership("ban"); + const banned = room.getMembersWithMembership("ban"); let bannedUsersSection: JSX.Element | undefined; if (banned?.length) { const canBanUsers = currentUserLevel >= banLevel; @@ -401,16 +402,16 @@ export default class RolesRoomSettingsTab extends React.Component {
    {banned.map((member) => { const banEvent = member.events.member?.getContent(); - const sender = room?.getMember(member.events.member.getSender()); - let bannedBy = member.events.member?.getSender(); // start by falling back to mxid - if (sender) bannedBy = sender.name; + const bannedById = member.events.member?.getSender(); + const sender = bannedById ? room.getMember(bannedById) : undefined; + const bannedBy = sender?.name || bannedById; // fallback to mxid return ( ); })} @@ -443,7 +444,7 @@ export default class RolesRoomSettingsTab extends React.Component { .filter(Boolean); // hide the power level selector for enabling E2EE if it the room is already encrypted - if (client.isRoomEncrypted(this.props.roomId)) { + if (client.isRoomEncrypted(this.props.room.roomId)) { delete eventsLevels[EventType.RoomEncryption]; } @@ -481,9 +482,7 @@ export default class RolesRoomSettingsTab extends React.Component {
    {_t("Roles & Permissions")}
    {privilegedUsersSection} - {canChangeLevels && room !== null && ( - - )} + {canChangeLevels && } {mutedUsersSection} {bannedUsersSection} void; } @@ -61,7 +62,7 @@ export default class SecurityRoomSettingsTab extends React.Component) { super(props, context); - const state = context.getRoom(this.props.roomId)?.currentState; + const state = this.props.room.currentState; this.state = { guestAccess: this.pullContentPropertyFromEvent( @@ -75,7 +76,7 @@ export default class SecurityRoomSettingsTab extends React.Component => { - if (this.context.getRoom(this.props.roomId)?.getJoinRule() === JoinRule.Public) { + if (this.props.room.getJoinRule() === JoinRule.Public) { const dialog = Modal.createDialog(QuestionDialog, { title: _t("Are you sure you want to add encryption to this public room?"), description: ( @@ -172,7 +173,9 @@ export default class SecurityRoomSettingsTab extends React.Component { logger.error(e); this.setState({ encrypted: beforeEncrypted }); @@ -190,7 +193,7 @@ export default class SecurityRoomSettingsTab extends React.Component { - this.context.getRoom(this.props.roomId)?.setBlacklistUnverifiedDevices(checked); + this.props.room.setBlacklistUnverifiedDevices(checked); }; private async hasAliases(): Promise { const cli = this.context; - const response = await cli.getLocalAliases(this.props.roomId); + const response = await cli.getLocalAliases(this.props.room.roomId); const localAliases = response.aliases; return Array.isArray(localAliases) && localAliases.length !== 0; } private renderJoinRule(): JSX.Element { - const client = this.context; - const room = client.getRoom(this.props.roomId); + const room = this.props.room; let aliasWarning: JSX.Element | undefined; - if (room?.getJoinRule() === JoinRule.Public && !this.state.hasAliases) { + if (room.getJoinRule() === JoinRule.Public && !this.state.hasAliases) { aliasWarning = (
    @@ -260,7 +262,7 @@ export default class SecurityRoomSettingsTab extends React.Component ); } @@ -436,7 +438,7 @@ export default class SecurityRoomSettingsTab extends React.Component = ({ roomId }) => { - const room = useMemo(() => MatrixClientPeg.get().getRoom(roomId), [roomId]); - const isPublic = useMemo(() => room?.getJoinRule() === JoinRule.Public, [room]); +const ElementCallSwitch: React.FC = ({ room }) => { + const isPublic = useMemo(() => room.getJoinRule() === JoinRule.Public, [room]); const [content, events, maySend] = useRoomState( - room ?? undefined, + room, useCallback((state: RoomState) => { const content = state?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent(); return [ @@ -68,12 +68,12 @@ const ElementCallSwitch: React.FC = ({ roomId }) => { events[ElementCall.MEMBER_EVENT_TYPE.name] = adminLevel; } - MatrixClientPeg.get().sendStateEvent(roomId, EventType.RoomPowerLevels, { + MatrixClientPeg.get().sendStateEvent(room.roomId, EventType.RoomPowerLevels, { events: events, ...content, }); }, - [roomId, content, events, isPublic], + [room.roomId, content, events, isPublic], ); const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand; @@ -95,14 +95,14 @@ const ElementCallSwitch: React.FC = ({ roomId }) => { }; interface Props { - roomId: string; + room: Room; } -export const VoipRoomSettingsTab: React.FC = ({ roomId }) => { +export const VoipRoomSettingsTab: React.FC = ({ room }) => { return ( - + ); diff --git a/test/components/views/dialogs/RoomSettingsDialog-test.tsx b/test/components/views/dialogs/RoomSettingsDialog-test.tsx index 5a4356cb33..566358de79 100644 --- a/test/components/views/dialogs/RoomSettingsDialog-test.tsx +++ b/test/components/views/dialogs/RoomSettingsDialog-test.tsx @@ -38,24 +38,46 @@ describe("", () => { const roomId = "!room:server.org"; const room = new Room(roomId, mockClient, userId); + room.name = "Test Room"; + const room2 = new Room("!room2:server.org", mockClient, userId); + room2.name = "Another Room"; jest.spyOn(SettingsStore, "getValue"); beforeEach(() => { jest.clearAllMocks(); - mockClient.getRoom.mockReturnValue(room); + mockClient.getRoom.mockImplementation((roomId) => { + if (roomId === room.roomId) return room; + if (roomId === room2.roomId) return room2; + return null; + }); jest.spyOn(SettingsStore, "getValue").mockReset().mockReturnValue(false); }); - const getComponent = (onFinished = jest.fn()) => - render(, { + const getComponent = (onFinished = jest.fn(), propRoomId = roomId) => + render(, { wrapper: ({ children }) => ( {children} ), }); + it("catches errors when room is not found", () => { + getComponent(undefined, "!room-that-does-not-exist"); + expect(screen.getByText("Something went wrong!")).toBeInTheDocument(); + }); + + it("updates when roomId prop changes", () => { + const { rerender, getByText } = getComponent(undefined, roomId); + + expect(getByText(`Room Settings - ${room.name}`)).toBeInTheDocument(); + + rerender(); + + expect(getByText(`Room Settings - ${room2.name}`)).toBeInTheDocument(); + }); + describe("Settings tabs", () => { it("renders default tabs correctly", () => { const { container } = getComponent(); diff --git a/test/components/views/settings/tabs/room/AdvancedRoomSettingsTab-test.tsx b/test/components/views/settings/tabs/room/AdvancedRoomSettingsTab-test.tsx index d00b1a8f7e..bd026db367 100644 --- a/test/components/views/settings/tabs/room/AdvancedRoomSettingsTab-test.tsx +++ b/test/components/views/settings/tabs/room/AdvancedRoomSettingsTab-test.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React from "react"; -import { fireEvent, render, RenderResult } from "@testing-library/react"; +import { fireEvent, render, RenderResult, screen } from "@testing-library/react"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; import { mocked } from "jest-mock"; @@ -36,7 +36,7 @@ describe("AdvancedRoomSettingsTab", () => { let room: Room; const renderTab = (): RenderResult => { - return render(); + return render(); }; beforeEach(() => { @@ -69,6 +69,22 @@ describe("AdvancedRoomSettingsTab", () => { tab.getByText("custom_room_version_1"); }); + it("displays message when room cannot federate", () => { + const createEvent = new MatrixEvent({ + sender: "@a:b.com", + type: EventType.RoomCreate, + content: { "m.federate": false }, + room_id: room.roomId, + state_key: "", + }); + jest.spyOn(room.currentState, "getStateEvents").mockImplementation((type) => + type === EventType.RoomCreate ? createEvent : null, + ); + + renderTab(); + expect(screen.getByText("This room is not accessible by remote Matrix servers")).toBeInTheDocument(); + }); + function mockStateEvents(room: Room) { const createEvent = mkEvent({ event: true, @@ -143,5 +159,16 @@ describe("AdvancedRoomSettingsTab", () => { metricsViaKeyboard: false, }); }); + + it("handles when room is a space", async () => { + mockStateEvents(room); + jest.spyOn(room, "isSpaceRoom").mockReturnValue(true); + + mockStateEvents(room); + const tab = renderTab(); + const link = await tab.findByText("View older version of test room."); + expect(link).toBeInTheDocument(); + expect(screen.getByText("Space information")).toBeInTheDocument(); + }); }); }); diff --git a/test/components/views/settings/tabs/room/BridgeSettingsTab-test.tsx b/test/components/views/settings/tabs/room/BridgeSettingsTab-test.tsx new file mode 100644 index 0000000000..bf41be073a --- /dev/null +++ b/test/components/views/settings/tabs/room/BridgeSettingsTab-test.tsx @@ -0,0 +1,60 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { render } from "@testing-library/react"; +import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; + +import BridgeSettingsTab from "../../../../../../src/components/views/settings/tabs/room/BridgeSettingsTab"; +import { getMockClientWithEventEmitter } from "../../../../../test-utils"; + +describe("", () => { + const userId = "@alice:server.org"; + const client = getMockClientWithEventEmitter({ + getRoom: jest.fn(), + }); + const roomId = "!room:server.org"; + + const getComponent = (room: Room) => render(); + + it("renders when room is not bridging messages to any platform", () => { + const room = new Room(roomId, client, userId); + + const { container } = getComponent(room); + + expect(container).toMatchSnapshot(); + }); + + it("renders when room is bridging messages", () => { + const bridgeEvent = new MatrixEvent({ + type: "uk.half-shot.bridge", + content: { + channel: { id: "channel-test" }, + protocol: { id: "protocol-test" }, + bridgebot: "test", + }, + room_id: roomId, + state_key: "1", + }); + const room = new Room(roomId, client, userId); + room.currentState.setStateEvents([bridgeEvent]); + client.getRoom.mockReturnValue(room); + + const { container } = getComponent(room); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/settings/tabs/room/RolesRoomSettingsTab-test.tsx b/test/components/views/settings/tabs/room/RolesRoomSettingsTab-test.tsx index be95dbf56c..3b8ea44e77 100644 --- a/test/components/views/settings/tabs/room/RolesRoomSettingsTab-test.tsx +++ b/test/components/views/settings/tabs/room/RolesRoomSettingsTab-test.tsx @@ -15,12 +15,13 @@ limitations under the License. */ import React from "react"; -import { fireEvent, render, RenderResult } from "@testing-library/react"; +import { fireEvent, render, RenderResult, screen } from "@testing-library/react"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { EventType } from "matrix-js-sdk/src/@types/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; import { mocked } from "jest-mock"; +import { RoomMember } from "matrix-js-sdk/src/matrix"; import RolesRoomSettingsTab from "../../../../../../src/components/views/settings/tabs/room/RolesRoomSettingsTab"; import { mkStubRoom, stubClient } from "../../../../../test-utils"; @@ -30,12 +31,13 @@ import SettingsStore from "../../../../../../src/settings/SettingsStore"; import { ElementCall } from "../../../../../../src/models/Call"; describe("RolesRoomSettingsTab", () => { + const userId = "@alice:server.org"; const roomId = "!room:example.com"; let cli: MatrixClient; let room: Room; - const renderTab = (): RenderResult => { - return render(); + const renderTab = (propRoom: Room = room): RenderResult => { + return render(); }; const getVoiceBroadcastsSelect = (): HTMLElement => { @@ -183,4 +185,54 @@ describe("RolesRoomSettingsTab", () => { expect(getJoinCallSelectedOption(tab)).toBeFalsy(); }); }); + + describe("Banned users", () => { + it("should not render banned section when no banned users", () => { + const room = new Room(roomId, cli, userId); + renderTab(room); + + expect(screen.queryByText("Banned users")).not.toBeInTheDocument(); + }); + + it("renders banned users", () => { + const bannedMember = new RoomMember(roomId, "@bob:server.org"); + bannedMember.setMembershipEvent( + new MatrixEvent({ + type: EventType.RoomMember, + content: { + membership: "ban", + reason: "just testing", + }, + sender: userId, + }), + ); + const room = new Room(roomId, cli, userId); + jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bannedMember]); + renderTab(room); + + expect(screen.getByText("Banned users").parentElement).toMatchSnapshot(); + }); + + it("uses banners display name when available", () => { + const bannedMember = new RoomMember(roomId, "@bob:server.org"); + const senderMember = new RoomMember(roomId, "@alice:server.org"); + senderMember.name = "Alice"; + bannedMember.setMembershipEvent( + new MatrixEvent({ + type: EventType.RoomMember, + content: { + membership: "ban", + reason: "just testing", + }, + sender: userId, + }), + ); + const room = new Room(roomId, cli, userId); + jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bannedMember]); + jest.spyOn(room, "getMember").mockReturnValue(senderMember); + renderTab(room); + + expect(screen.getByTitle("Banned by Alice")).toBeInTheDocument(); + }); + }); }); diff --git a/test/components/views/settings/tabs/room/SecurityRoomSettingsTab-test.tsx b/test/components/views/settings/tabs/room/SecurityRoomSettingsTab-test.tsx new file mode 100644 index 0000000000..e2b4e49464 --- /dev/null +++ b/test/components/views/settings/tabs/room/SecurityRoomSettingsTab-test.tsx @@ -0,0 +1,402 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { EventType, GuestAccess, HistoryVisibility, JoinRule, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; + +import SecurityRoomSettingsTab from "../../../../../../src/components/views/settings/tabs/room/SecurityRoomSettingsTab"; +import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; +import SettingsStore from "../../../../../../src/settings/SettingsStore"; +import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../../test-utils"; +import { filterBoolean } from "../../../../../../src/utils/arrays"; + +describe("", () => { + const userId = "@alice:server.org"; + const client = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + getRoom: jest.fn(), + isRoomEncrypted: jest.fn(), + getLocalAliases: jest.fn().mockReturnValue([]), + sendStateEvent: jest.fn(), + }); + const roomId = "!room:server.org"; + + const getComponent = (room: Room, closeSettingsFn = jest.fn()) => + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + const setRoomStateEvents = ( + room: Room, + joinRule?: JoinRule, + guestAccess?: GuestAccess, + history?: HistoryVisibility, + ): void => { + const events = filterBoolean([ + new MatrixEvent({ + type: EventType.RoomCreate, + content: { version: "test" }, + sender: userId, + state_key: "", + room_id: room.roomId, + }), + guestAccess && + new MatrixEvent({ + type: EventType.RoomGuestAccess, + content: { guest_access: guestAccess }, + sender: userId, + state_key: "", + room_id: room.roomId, + }), + history && + new MatrixEvent({ + type: EventType.RoomHistoryVisibility, + content: { history_visibility: history }, + sender: userId, + state_key: "", + room_id: room.roomId, + }), + joinRule && + new MatrixEvent({ + type: EventType.RoomJoinRules, + content: { join_rule: joinRule }, + sender: userId, + state_key: "", + room_id: room.roomId, + }), + ]); + + room.currentState.setStateEvents(events); + }; + + beforeEach(() => { + client.sendStateEvent.mockReset().mockResolvedValue({ event_id: "test" }); + client.isRoomEncrypted.mockReturnValue(false); + jest.spyOn(SettingsStore, "getValue").mockRestore(); + }); + + describe("join rule", () => { + it("warns when trying to make an encrypted room public", async () => { + const room = new Room(roomId, client, userId); + client.isRoomEncrypted.mockReturnValue(true); + setRoomStateEvents(room, JoinRule.Invite); + + getComponent(room); + + fireEvent.click(screen.getByLabelText("Public")); + + const modal = await screen.findByRole("dialog"); + + expect(modal).toMatchSnapshot(); + + fireEvent.click(screen.getByText("Cancel")); + + // join rule not updated + expect(screen.getByLabelText("Private (invite only)").hasAttribute("checked")).toBeTruthy(); + }); + + it("updates join rule", async () => { + const room = new Room(roomId, client, userId); + setRoomStateEvents(room, JoinRule.Invite); + + getComponent(room); + + fireEvent.click(screen.getByLabelText("Public")); + + await flushPromises(); + + expect(client.sendStateEvent).toHaveBeenCalledWith( + room.roomId, + EventType.RoomJoinRules, + { + join_rule: JoinRule.Public, + }, + "", + ); + }); + + it("handles error when updating join rule fails", async () => { + const room = new Room(roomId, client, userId); + client.sendStateEvent.mockRejectedValue("oups"); + setRoomStateEvents(room, JoinRule.Invite); + + getComponent(room); + + fireEvent.click(screen.getByLabelText("Public")); + + await flushPromises(); + + const dialog = await screen.findByRole("dialog"); + + expect(dialog).toMatchSnapshot(); + + fireEvent.click(within(dialog).getByText("OK")); + }); + + it("displays advanced section toggle when join rule is public", () => { + const room = new Room(roomId, client, userId); + setRoomStateEvents(room, JoinRule.Public); + + getComponent(room); + + expect(screen.getByText("Show advanced")).toBeInTheDocument(); + }); + + it("does not display advanced section toggle when join rule is not public", () => { + const room = new Room(roomId, client, userId); + setRoomStateEvents(room, JoinRule.Invite); + + getComponent(room); + + expect(screen.queryByText("Show advanced")).not.toBeInTheDocument(); + }); + }); + + describe("guest access", () => { + it("uses forbidden by default when room has no guest access event", () => { + const room = new Room(roomId, client, userId); + setRoomStateEvents(room, JoinRule.Public); + + getComponent(room); + + fireEvent.click(screen.getByText("Show advanced")); + + expect(screen.getByLabelText("Enable guest access").getAttribute("aria-checked")).toBe("false"); + }); + + it("updates guest access on toggle", () => { + const room = new Room(roomId, client, userId); + setRoomStateEvents(room, JoinRule.Public); + getComponent(room); + fireEvent.click(screen.getByText("Show advanced")); + + fireEvent.click(screen.getByLabelText("Enable guest access")); + + // toggle set immediately + expect(screen.getByLabelText("Enable guest access").getAttribute("aria-checked")).toBe("true"); + + expect(client.sendStateEvent).toHaveBeenCalledWith( + room.roomId, + EventType.RoomGuestAccess, + { guest_access: GuestAccess.CanJoin }, + "", + ); + }); + + it("logs error and resets state when updating guest access fails", async () => { + client.sendStateEvent.mockRejectedValue("oups"); + jest.spyOn(logger, "error").mockImplementation(() => {}); + const room = new Room(roomId, client, userId); + setRoomStateEvents(room, JoinRule.Public, GuestAccess.CanJoin); + getComponent(room); + fireEvent.click(screen.getByText("Show advanced")); + + fireEvent.click(screen.getByLabelText("Enable guest access")); + + // toggle set immediately + expect(screen.getByLabelText("Enable guest access").getAttribute("aria-checked")).toBe("false"); + + await flushPromises(); + expect(client.sendStateEvent).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith("oups"); + + // toggle reset to old value + expect(screen.getByLabelText("Enable guest access").getAttribute("aria-checked")).toBe("true"); + }); + }); + + describe("history visibility", () => { + it("does not render section when RoomHistorySettings feature is disabled", () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); + const room = new Room(roomId, client, userId); + setRoomStateEvents(room); + + getComponent(room); + + expect(screen.queryByText("Who can read history")).not.toBeInTheDocument(); + }); + + it("uses shared as default history visibility when no state event found", () => { + const room = new Room(roomId, client, userId); + setRoomStateEvents(room); + + getComponent(room); + + expect(screen.getByText("Who can read history?").parentElement).toMatchSnapshot(); + expect(screen.getByDisplayValue(HistoryVisibility.Shared)).toBeChecked(); + }); + + it("does not render world readable option when room is encrypted", () => { + const room = new Room(roomId, client, userId); + client.isRoomEncrypted.mockReturnValue(true); + setRoomStateEvents(room); + + getComponent(room); + + expect(screen.queryByDisplayValue(HistoryVisibility.WorldReadable)).not.toBeInTheDocument(); + }); + + it("renders world readable option when room is encrypted and history is already set to world readable", () => { + const room = new Room(roomId, client, userId); + client.isRoomEncrypted.mockReturnValue(true); + setRoomStateEvents(room, undefined, undefined, HistoryVisibility.WorldReadable); + + getComponent(room); + + expect(screen.getByDisplayValue(HistoryVisibility.WorldReadable)).toBeInTheDocument(); + }); + + it("updates history visibility", () => { + const room = new Room(roomId, client, userId); + + getComponent(room); + + fireEvent.click(screen.getByDisplayValue(HistoryVisibility.Invited)); + + // toggle updated immediately + expect(screen.getByDisplayValue(HistoryVisibility.Invited)).toBeChecked(); + + expect(client.sendStateEvent).toHaveBeenCalledWith( + room.roomId, + EventType.RoomHistoryVisibility, + { + history_visibility: HistoryVisibility.Invited, + }, + "", + ); + }); + + it("handles error when updating history visibility", async () => { + const room = new Room(roomId, client, userId); + client.sendStateEvent.mockRejectedValue("oups"); + jest.spyOn(logger, "error").mockImplementation(() => {}); + + getComponent(room); + + fireEvent.click(screen.getByDisplayValue(HistoryVisibility.Invited)); + + // toggle updated immediately + expect(screen.getByDisplayValue(HistoryVisibility.Invited)).toBeChecked(); + + await flushPromises(); + + // reset to before updated value + expect(screen.getByDisplayValue(HistoryVisibility.Shared)).toBeChecked(); + expect(logger.error).toHaveBeenCalledWith("oups"); + }); + }); + + describe("encryption", () => { + it("displays encryption as enabled", () => { + const room = new Room(roomId, client, userId); + client.isRoomEncrypted.mockReturnValue(true); + setRoomStateEvents(room); + getComponent(room); + + expect(screen.getByLabelText("Encrypted")).toBeChecked(); + // can't disable encryption once enabled + expect(screen.getByLabelText("Encrypted").getAttribute("aria-disabled")).toEqual("true"); + }); + + it("asks users to confirm when setting room to encrypted", async () => { + const room = new Room(roomId, client, userId); + setRoomStateEvents(room); + getComponent(room); + + expect(screen.getByLabelText("Encrypted")).not.toBeChecked(); + + fireEvent.click(screen.getByLabelText("Encrypted")); + + const dialog = await screen.findByRole("dialog"); + + fireEvent.click(within(dialog).getByText("Cancel")); + + expect(client.sendStateEvent).not.toHaveBeenCalled(); + expect(screen.getByLabelText("Encrypted")).not.toBeChecked(); + }); + + it("enables encryption after confirmation", async () => { + const room = new Room(roomId, client, userId); + setRoomStateEvents(room); + getComponent(room); + + expect(screen.getByLabelText("Encrypted")).not.toBeChecked(); + + fireEvent.click(screen.getByLabelText("Encrypted")); + + const dialog = await screen.findByRole("dialog"); + + fireEvent.click(within(dialog).getByText("OK")); + + expect(client.sendStateEvent).toHaveBeenCalledWith(room.roomId, EventType.RoomEncryption, { + algorithm: "m.megolm.v1.aes-sha2", + }); + }); + + it("renders world readable option when room is encrypted and history is already set to world readable", () => { + const room = new Room(roomId, client, userId); + client.isRoomEncrypted.mockReturnValue(true); + setRoomStateEvents(room, undefined, undefined, HistoryVisibility.WorldReadable); + + getComponent(room); + + expect(screen.getByDisplayValue(HistoryVisibility.WorldReadable)).toBeInTheDocument(); + }); + + it("updates history visibility", () => { + const room = new Room(roomId, client, userId); + + getComponent(room); + + fireEvent.click(screen.getByDisplayValue(HistoryVisibility.Invited)); + + // toggle updated immediately + expect(screen.getByDisplayValue(HistoryVisibility.Invited)).toBeChecked(); + + expect(client.sendStateEvent).toHaveBeenCalledWith( + room.roomId, + EventType.RoomHistoryVisibility, + { + history_visibility: HistoryVisibility.Invited, + }, + "", + ); + }); + + it("handles error when updating history visibility", async () => { + const room = new Room(roomId, client, userId); + client.sendStateEvent.mockRejectedValue("oups"); + jest.spyOn(logger, "error").mockImplementation(() => {}); + + getComponent(room); + + fireEvent.click(screen.getByDisplayValue(HistoryVisibility.Invited)); + + // toggle updated immediately + expect(screen.getByDisplayValue(HistoryVisibility.Invited)).toBeChecked(); + + await flushPromises(); + + // reset to before updated value + expect(screen.getByDisplayValue(HistoryVisibility.Shared)).toBeChecked(); + expect(logger.error).toHaveBeenCalledWith("oups"); + }); + }); +}); diff --git a/test/components/views/settings/tabs/room/VoipRoomSettingsTab-test.tsx b/test/components/views/settings/tabs/room/VoipRoomSettingsTab-test.tsx index 88e49e0239..7e14a6f5dc 100644 --- a/test/components/views/settings/tabs/room/VoipRoomSettingsTab-test.tsx +++ b/test/components/views/settings/tabs/room/VoipRoomSettingsTab-test.tsx @@ -27,13 +27,13 @@ import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; import { VoipRoomSettingsTab } from "../../../../../../src/components/views/settings/tabs/room/VoipRoomSettingsTab"; import { ElementCall } from "../../../../../../src/models/Call"; -describe("RolesRoomSettingsTab", () => { +describe("VoipRoomSettingsTab", () => { const roomId = "!room:example.com"; let cli: MatrixClient; let room: Room; const renderTab = (): RenderResult => { - return render(); + return render(); }; beforeEach(() => { diff --git a/test/components/views/settings/tabs/room/__snapshots__/BridgeSettingsTab-test.tsx.snap b/test/components/views/settings/tabs/room/__snapshots__/BridgeSettingsTab-test.tsx.snap new file mode 100644 index 0000000000..c38e07d15f --- /dev/null +++ b/test/components/views/settings/tabs/room/__snapshots__/BridgeSettingsTab-test.tsx.snap @@ -0,0 +1,112 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders when room is bridging messages 1`] = ` +
    +
    +
    + Bridges +
    +
    +
    +

    + + This room is bridging messages to the following platforms. + + Learn more. + + +

    +
      +
    • +
      +
      +
      +
      +

      + protocol-test +

      +

      + + + Channel: + + channel-test + + + +

      + +
      +
    • +
    +
    +
    +
    +
    +`; + +exports[` renders when room is not bridging messages to any platform 1`] = ` +
    +
    +
    + Bridges +
    +
    +

    + + This room isn't bridging messages to any platforms. + + Learn more. + + +

    +
    +
    +
    +`; diff --git a/test/components/views/settings/tabs/room/__snapshots__/RolesRoomSettingsTab-test.tsx.snap b/test/components/views/settings/tabs/room/__snapshots__/RolesRoomSettingsTab-test.tsx.snap new file mode 100644 index 0000000000..d771a152d2 --- /dev/null +++ b/test/components/views/settings/tabs/room/__snapshots__/RolesRoomSettingsTab-test.tsx.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RolesRoomSettingsTab Banned users renders banned users 1`] = ` +
    + + Banned users + +
      +
    • + + + @bob:server.org + + + Reason: just testing + +
    • +
    +
    +`; diff --git a/test/components/views/settings/tabs/room/__snapshots__/SecurityRoomSettingsTab-test.tsx.snap b/test/components/views/settings/tabs/room/__snapshots__/SecurityRoomSettingsTab-test.tsx.snap new file mode 100644 index 0000000000..98a6d5a113 --- /dev/null +++ b/test/components/views/settings/tabs/room/__snapshots__/SecurityRoomSettingsTab-test.tsx.snap @@ -0,0 +1,227 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` history visibility uses shared as default history visibility when no state event found 1`] = ` +
    + + Who can read history? + +
    + Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged. +
    +
    +`; + +exports[` join rule handles error when updating join rule fails 1`] = ` +