diff --git a/docs/config.md b/docs/config.md index 2bc36e206f..0c5a2b2dca 100644 --- a/docs/config.md +++ b/docs/config.md @@ -585,6 +585,8 @@ Currently, the following UI feature flags are supported: - `UIFeature.BulkUnverifiedSessionsReminder` - Display popup reminders to verify or remove unverified sessions. Defaults to true. - `UIFeature.locationSharing` - Whether or not location sharing menus will be shown. +- `UIFeature.allowCreatingPublicRooms` - Whether or not public rooms can be created. +- `UIFeature.allowCreatingPublicSpaces` - Whether or not public spaces can be created. ## Undocumented / developer options diff --git a/playwright/e2e/room/create-room.spec.ts b/playwright/e2e/room/create-room.spec.ts new file mode 100644 index 0000000000..9c3aecd9ca --- /dev/null +++ b/playwright/e2e/room/create-room.spec.ts @@ -0,0 +1,47 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2022, 2023 The Matrix.org Foundation C.I.C. + +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 { SettingLevel } from "../../../src/settings/SettingLevel"; +import { UIFeature } from "../../../src/settings/UIFeature"; +import { test, expect } from "../../element-web-test"; + +const name = "Test room"; +const topic = "A decently explanatory topic for a test room."; + +test.describe("Create Room", () => { + test.use({ displayName: "Jim" }); + + test.describe("Should hide public room option if not allowed", () => { + test.use({ + config: { + setting_defaults: { + [UIFeature.AllowCreatingPublicRooms]: false, + }, + }, + }); + + test("should disallow creating public rooms", { tag: "@screenshot" }, async ({ page, user, app, axe }) => { + const dialog = await app.openCreateRoomDialog(); + // Fill name & topic + await dialog.getByRole("textbox", { name: "Name" }).fill(name); + await dialog.getByRole("textbox", { name: "Topic" }).fill(topic); + + axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view. + await expect(axe).toHaveNoViolations(); + // Snapshot it + await expect(dialog).toMatchScreenshot("create-room-no-public.png"); + + // Submit + await dialog.getByRole("button", { name: "Create room" }).click(); + + await expect(page).toHaveURL(new RegExp(`/#/room/!.+`)); + const header = page.locator(".mx_RoomHeader"); + await expect(header).toContainText(name); + }); + }); +}); diff --git a/playwright/e2e/spaces/spaces.spec.ts b/playwright/e2e/spaces/spaces.spec.ts index b3f7ecb0c9..eb29939615 100644 --- a/playwright/e2e/spaces/spaces.spec.ts +++ b/playwright/e2e/spaces/spaces.spec.ts @@ -11,6 +11,7 @@ import { test, expect } from "../../element-web-test"; import type { Preset, ICreateRoomOpts } from "matrix-js-sdk/src/matrix"; import { type ElementAppPage } from "../../pages/ElementAppPage"; import { isDendrite } from "../../plugins/homeserver/dendrite"; +import { UIFeature } from "../../../src/settings/UIFeature"; async function openSpaceCreateMenu(page: Page): Promise { await page.getByRole("button", { name: "Create a space" }).click(); @@ -376,4 +377,53 @@ test.describe("Spaces", () => { await app.viewSpaceByName("Root Space"); await expect(page.locator(".mx_SpaceRoomView")).toMatchScreenshot("space-room-view.png"); }); + + test.describe("Should hide public spaces option if not allowed", () => { + test.use({ + config: { + setting_defaults: { + [UIFeature.AllowCreatingPublicSpaces]: false, + }, + }, + }); + + test("should disallow creating public rooms", { tag: "@screenshot" }, async ({ page, user, app }) => { + const menu = await openSpaceCreateMenu(page); + await menu + .locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]') + .setInputFiles("playwright/sample-files/riot.png"); + await menu.getByRole("textbox", { name: "Name" }).fill("This is a private space"); + await expect(menu.getByRole("textbox", { name: "Address" })).not.toBeVisible(); + await menu + .getByRole("textbox", { name: "Description" }) + .fill("This is a private space because we can't make public ones"); + await menu.getByRole("button", { name: "Create" }).click(); + + await page.getByRole("button", { name: "Me and my teammates" }).click(); + + // Create the default General & Random rooms, as well as a custom "Projects" room + await expect(page.getByPlaceholder("General")).toBeVisible(); + await expect(page.getByPlaceholder("Random")).toBeVisible(); + await page.getByPlaceholder("Support").fill("Projects"); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Skip for now" }).click(); + + // Assert rooms exist in the room list + const roomList = page.getByRole("tree", { name: "Rooms" }); + await expect(roomList.getByRole("treeitem", { name: "General", exact: true })).toBeVisible(); + await expect(roomList.getByRole("treeitem", { name: "Random", exact: true })).toBeVisible(); + await expect(roomList.getByRole("treeitem", { name: "Projects", exact: true })).toBeVisible(); + + // Assert rooms exist in the space explorer + await expect( + page.locator(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", { hasText: "General" }), + ).toBeVisible(); + await expect( + page.locator(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", { hasText: "Random" }), + ).toBeVisible(); + await expect( + page.locator(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", { hasText: "Projects" }), + ).toBeVisible(); + }); + }); }); diff --git a/playwright/snapshots/room/create-room.spec.ts/create-room-no-public-linux.png b/playwright/snapshots/room/create-room.spec.ts/create-room-no-public-linux.png new file mode 100644 index 0000000000..84494f46bf Binary files /dev/null and b/playwright/snapshots/room/create-room.spec.ts/create-room-no-public-linux.png differ diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx index 3ff42cde22..2ec1b91237 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -26,6 +26,7 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { privateShouldBeEncrypted } from "../../../utils/rooms"; import SettingsStore from "../../../settings/SettingsStore"; import LabelledCheckbox from "../elements/LabelledCheckbox"; +import { UIFeature } from "../../../settings/UIFeature"; interface IProps { type?: RoomType; @@ -83,6 +84,7 @@ interface IState { export default class CreateRoomDialog extends React.Component { private readonly askToJoinEnabled: boolean; + private readonly allowCreatingPublicRooms: boolean; private readonly supportsRestricted: boolean; private nameField = createRef(); private aliasField = createRef(); @@ -91,10 +93,12 @@ export default class CreateRoomDialog extends React.Component { super(props); this.askToJoinEnabled = SettingsStore.getValue("feature_ask_to_join"); + this.allowCreatingPublicRooms = SettingsStore.getValue(UIFeature.AllowCreatingPublicRooms); this.supportsRestricted = !!this.props.parentSpace; + const defaultPublic = this.allowCreatingPublicRooms && this.props.defaultPublic; let joinRule = JoinRule.Invite; - if (this.props.defaultPublic) { + if (defaultPublic) { joinRule = JoinRule.Public; } else if (this.supportsRestricted) { joinRule = JoinRule.Restricted; @@ -102,7 +106,7 @@ export default class CreateRoomDialog extends React.Component { const cli = MatrixClientPeg.safeGet(); this.state = { - isPublicKnockRoom: this.props.defaultPublic || false, + isPublicKnockRoom: defaultPublic || false, isEncrypted: this.props.defaultEncrypted ?? privateShouldBeEncrypted(cli), joinRule, name: this.props.defaultName || "", @@ -415,7 +419,7 @@ export default class CreateRoomDialog extends React.Component { labelKnock={ this.askToJoinEnabled ? _t("room_settings|security|join_rule_knock") : undefined } - labelPublic={_t("common|public_room")} + labelPublic={this.allowCreatingPublicRooms ? _t("common|public_room") : undefined} labelRestricted={ this.supportsRestricted ? _t("create_room|join_rule_restricted") : undefined } diff --git a/src/components/views/elements/JoinRuleDropdown.tsx b/src/components/views/elements/JoinRuleDropdown.tsx index cded54ffd6..860b87fa00 100644 --- a/src/components/views/elements/JoinRuleDropdown.tsx +++ b/src/components/views/elements/JoinRuleDropdown.tsx @@ -19,7 +19,7 @@ interface IProps { width?: number; labelInvite: string; labelKnock?: string; - labelPublic: string; + labelPublic?: string; labelRestricted?: string; // if omitted then this option will be hidden, e.g if unsupported onChange(value: JoinRule): void; } @@ -38,11 +38,18 @@ const JoinRuleDropdown: React.FC = ({
{labelInvite}
, -
- {labelPublic} -
, ] as NonEmptyArray; + if (labelPublic) { + options.push( + ( +
+ {labelPublic} +
+ ) as ReactElement & { key: string }, + ); + } + if (labelKnock) { options.unshift( ( @@ -72,6 +79,7 @@ const JoinRuleDropdown: React.FC = ({ menuWidth={width} value={value} label={label} + disabled={options.length === 1} > {options} diff --git a/src/components/views/spaces/SpaceCreateMenu.tsx b/src/components/views/spaces/SpaceCreateMenu.tsx index fd5b0cb1c0..6b881e33a8 100644 --- a/src/components/views/spaces/SpaceCreateMenu.tsx +++ b/src/components/views/spaces/SpaceCreateMenu.tsx @@ -45,6 +45,8 @@ import defaultDispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import { Filter } from "../dialogs/spotlight/Filter"; import { type OpenSpotlightPayload } from "../../../dispatcher/payloads/OpenSpotlightPayload.ts"; +import { useSettingValue } from "../../../hooks/useSettings.ts"; +import { UIFeature } from "../../../settings/UIFeature.ts"; export const createSpace = async ( client: MatrixClient, @@ -212,7 +214,10 @@ const SpaceCreateMenu: React.FC<{ onFinished(): void; }> = ({ onFinished }) => { const cli = useMatrixClientContext(); - const [visibility, setVisibility] = useState(null); + const settingAllowPublicSpaces = useSettingValue(UIFeature.AllowCreatingPublicSpaces); + const [visibility, setVisibility] = useState( + settingAllowPublicSpaces === false ? Visibility.Private : null, + ); const [busy, setBusy] = useState(false); const [name, setName] = useState(""); @@ -303,16 +308,20 @@ const SpaceCreateMenu: React.FC<{ } else { body = ( - setVisibility(null)} - title={_t("action|go_back")} - /> + {settingAllowPublicSpaces && ( + setVisibility(null)} + title={_t("action|go_back")} + /> + )}

{visibility === Visibility.Public ? _t("create_space|public_heading") - : _t("create_space|private_heading")} + : settingAllowPublicSpaces + ? _t("create_space|private_heading") + : _t("create_space|private_only_heading")}

{_t("create_space|add_details_prompt")} {_t("create_space|add_details_prompt_2")} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index dc2a17cc62..ae71ba09aa 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -718,6 +718,7 @@ "personal_space_description": "A private space to organise your rooms", "private_description": "Invite only, best for yourself or teams", "private_heading": "Your private space", + "private_only_heading": "Your space", "private_personal_description": "Make sure the right people have access to %(name)s", "private_personal_heading": "Who are you working with?", "private_space": "Me and my teammates", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index f0f52bbbf8..0d272a46c5 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -1425,6 +1425,14 @@ export const SETTINGS: Settings = { supportedLevels: LEVELS_UI_FEATURE, default: true, }, + [UIFeature.AllowCreatingPublicSpaces]: { + supportedLevels: LEVELS_UI_FEATURE, + default: true, + }, + [UIFeature.AllowCreatingPublicRooms]: { + supportedLevels: LEVELS_UI_FEATURE, + default: true, + }, // Electron-specific settings, they are stored by Electron and set/read over an IPC. // We store them over there are they are necessary to know before the renderer process launches. diff --git a/src/settings/UIFeature.ts b/src/settings/UIFeature.ts index 4aef725d4a..12b0c7c089 100644 --- a/src/settings/UIFeature.ts +++ b/src/settings/UIFeature.ts @@ -25,6 +25,8 @@ export const enum UIFeature { RoomHistorySettings = "UIFeature.roomHistorySettings", TimelineEnableRelativeDates = "UIFeature.timelineEnableRelativeDates", BulkUnverifiedSessionsReminder = "UIFeature.BulkUnverifiedSessionsReminder", + AllowCreatingPublicRooms = "UIFeature.allowCreatingPublicRooms", + AllowCreatingPublicSpaces = "UIFeature.allowCreatingPublicSpaces", } export enum UIComponent { diff --git a/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx b/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx index ad3dcd58e9..ffc575908b 100644 --- a/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx @@ -16,25 +16,30 @@ import SettingsStore from "../../../../../src/settings/SettingsStore"; describe("", () => { const userId = "@alice:server.org"; - const mockClient = getMockClientWithEventEmitter({ - ...mockClientMethodsUser(userId), - getDomain: jest.fn().mockReturnValue("server.org"), - getClientWellKnown: jest.fn(), - doesServerForceEncryptionForPreset: jest.fn(), - // make every alias available - getRoomIdForAlias: jest.fn().mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" })), - }); const getE2eeEnableToggleInputElement = () => screen.getByLabelText("Enable end-to-end encryption"); // labelled toggle switch doesn't set the disabled attribute, only aria-disabled const getE2eeEnableToggleIsDisabled = () => getE2eeEnableToggleInputElement().getAttribute("aria-disabled") === "true"; + let mockClient: ReturnType; beforeEach(() => { + mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + getDomain: jest.fn().mockReturnValue("server.org"), + getClientWellKnown: jest.fn(), + doesServerForceEncryptionForPreset: jest.fn(), + // make every alias available + getRoomIdForAlias: jest.fn().mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" })), + }); mockClient.doesServerForceEncryptionForPreset.mockResolvedValue(false); mockClient.getClientWellKnown.mockReturnValue({}); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + const getComponent = (props = {}) => render(); it("should default to private room", async () => { diff --git a/test/unit-tests/components/views/spaces/SpaceCreateMenu-test.tsx b/test/unit-tests/components/views/spaces/SpaceCreateMenu-test.tsx new file mode 100644 index 0000000000..ad8c3ad86e --- /dev/null +++ b/test/unit-tests/components/views/spaces/SpaceCreateMenu-test.tsx @@ -0,0 +1,121 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2022 The Matrix.org Foundation C.I.C. + +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 { render, cleanup } from "jest-matrix-react"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; +import userEvent from "@testing-library/user-event"; +import { MatrixError } from "matrix-js-sdk/src/matrix"; +import { type MockedObject } from "jest-mock"; + +import SpaceCreateMenu from "../../../../../src/components/views/spaces/SpaceCreateMenu"; +import { + getMockClientWithEventEmitter, + mockClientMethodsRooms, + mockClientMethodsServer, + mockClientMethodsUser, + withClientContextRenderOptions, +} from "../../../../test-utils"; +import { UIFeature } from "../../../../../src/settings/UIFeature"; +import SettingsStore from "../../../../../src/settings/SettingsStore"; + +describe("", () => { + let client: MockedObject; + + beforeEach(() => { + client = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(), + ...mockClientMethodsServer(), + ...mockClientMethodsRooms(), + createRoom: jest.fn(), + getRoomIdForAlias: jest.fn().mockImplementation(async () => { + throw new MatrixError({ errcode: "M_NOT_FOUND", error: "Test says no alias found" }, 404); + }), + }); + }); + + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + }); + + it("should render", async () => { + const { asFragment } = render( + , + withClientContextRenderOptions(client), + ); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should be able to create a public space", async () => { + const onFinished = jest.fn(); + client.createRoom.mockResolvedValue({ room_id: "!room:id" }); + const { getByText, getByLabelText } = render( + , + withClientContextRenderOptions(client), + ); + await userEvent.click(getByText("Public")); + await userEvent.type(getByLabelText("Name"), "My Name"); + await userEvent.type(getByLabelText("Address"), "foobar"); + await userEvent.type(getByLabelText("Description"), "A description"); + await userEvent.click(getByText("Create")); + expect(onFinished).toHaveBeenCalledTimes(1); + expect(client.createRoom).toHaveBeenCalledWith({ + creation_content: { type: "m.space" }, + initial_state: [ + { content: { guest_access: "can_join" }, state_key: "", type: "m.room.guest_access" }, + { content: { history_visibility: "world_readable" }, type: "m.room.history_visibility" }, + ], + name: "My Name", + power_level_content_override: { + events_default: 100, + invite: 0, + }, + preset: "public_chat", + room_alias_name: "my-namefoobar", + topic: "A description", + visibility: "private", + }); + }); + + it("should be prompted to automatically create a private space when configured", async () => { + const realGetValue = SettingsStore.getValue; + jest.spyOn(SettingsStore, "getValue").mockImplementation((name, roomId) => { + if (name === UIFeature.AllowCreatingPublicSpaces) { + return false; + } + return realGetValue(name, roomId); + }); + const onFinished = jest.fn(); + client.createRoom.mockResolvedValue({ room_id: "!room:id" }); + const { getByText, getByLabelText } = render( + , + withClientContextRenderOptions(client), + ); + await userEvent.type(getByLabelText("Name"), "My Name"); + await userEvent.type(getByLabelText("Description"), "A description"); + await userEvent.click(getByText("Create")); + expect(onFinished).toHaveBeenCalledTimes(1); + expect(client.createRoom).toHaveBeenCalledWith({ + creation_content: { type: "m.space" }, + initial_state: [ + { content: { guest_access: "can_join" }, state_key: "", type: "m.room.guest_access" }, + { content: { history_visibility: "invited" }, type: "m.room.history_visibility" }, + ], + name: "My Name", + power_level_content_override: { + events_default: 100, + invite: 50, + }, + room_alias_name: undefined, + preset: "private_chat", + topic: "A description", + visibility: "private", + }); + }); +}); diff --git a/test/unit-tests/components/views/spaces/__snapshots__/SpaceCreateMenu-test.tsx.snap b/test/unit-tests/components/views/spaces/__snapshots__/SpaceCreateMenu-test.tsx.snap new file mode 100644 index 0000000000..f8e1b4e6c9 --- /dev/null +++ b/test/unit-tests/components/views/spaces/__snapshots__/SpaceCreateMenu-test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render 1`] = ``;