Add UIFeature to hide public space and room creation (#30708)

* Add settings to hide public room & space creation.

* Add space changes.

* Add room changes.

* lint

* Add playwright tests

* don't specialcase 1 join rule

* Ensure mocks get cleared

* Fixup test

* Add SpaceCreateMenu component unit-tests

* Fixup create room test asserts

* fix import

(cherry picked from commit 6d05bfc4c520575a0f47957fc9128f8cf5729f9d)
This commit is contained in:
Will Hunt 2025-09-08 14:53:13 +01:00 committed by Half-Shot
parent 4f702b70aa
commit eb86c0e1fa
13 changed files with 282 additions and 22 deletions

View File

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

View File

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

View File

@ -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<Locator> {
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();
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -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<IProps, IState> {
private readonly askToJoinEnabled: boolean;
private readonly allowCreatingPublicRooms: boolean;
private readonly supportsRestricted: boolean;
private nameField = createRef<Field>();
private aliasField = createRef<RoomAliasField>();
@ -91,10 +93,12 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
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<IProps, IState> {
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<IProps, IState> {
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
}

View File

@ -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<IProps> = ({
<div key={JoinRule.Invite} className="mx_JoinRuleDropdown_invite">
{labelInvite}
</div>,
<div key={JoinRule.Public} className="mx_JoinRuleDropdown_public">
{labelPublic}
</div>,
] as NonEmptyArray<ReactElement & { key: string }>;
if (labelPublic) {
options.push(
(
<div key={JoinRule.Public} className="mx_JoinRuleDropdown_public">
{labelPublic}
</div>
) as ReactElement & { key: string },
);
}
if (labelKnock) {
options.unshift(
(
@ -72,6 +79,7 @@ const JoinRuleDropdown: React.FC<IProps> = ({
menuWidth={width}
value={value}
label={label}
disabled={options.length === 1}
>
{options}
</Dropdown>

View File

@ -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<Visibility | null>(null);
const settingAllowPublicSpaces = useSettingValue(UIFeature.AllowCreatingPublicSpaces);
const [visibility, setVisibility] = useState<Visibility | null>(
settingAllowPublicSpaces === false ? Visibility.Private : null,
);
const [busy, setBusy] = useState<boolean>(false);
const [name, setName] = useState("");
@ -303,16 +308,20 @@ const SpaceCreateMenu: React.FC<{
} else {
body = (
<React.Fragment>
<AccessibleButton
className="mx_SpaceCreateMenu_back"
onClick={() => setVisibility(null)}
title={_t("action|go_back")}
/>
{settingAllowPublicSpaces && (
<AccessibleButton
className="mx_SpaceCreateMenu_back"
onClick={() => setVisibility(null)}
title={_t("action|go_back")}
/>
)}
<h2>
{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")}
</h2>
<p>
{_t("create_space|add_details_prompt")} {_t("create_space|add_details_prompt_2")}

View File

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

View File

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

View File

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

View File

@ -16,25 +16,30 @@ import SettingsStore from "../../../../../src/settings/SettingsStore";
describe("<CreateRoomDialog />", () => {
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<typeof getMockClientWithEventEmitter>;
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(<CreateRoomDialog onFinished={jest.fn()} {...props} />);
it("should default to private room", async () => {

View File

@ -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("<SpaceCreateMenu />", () => {
let client: MockedObject<MatrixClient>;
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(
<SpaceCreateMenu onFinished={jest.fn()} />,
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(
<SpaceCreateMenu onFinished={onFinished} />,
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(
<SpaceCreateMenu onFinished={onFinished} />,
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",
});
});
});

View File

@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SpaceCreateMenu /> should render 1`] = `<DocumentFragment />`;