From fd749172e1011d6deee6a434876e38e45081d2d1 Mon Sep 17 00:00:00 2001 From: Charly Nguyen <1422657+charlynguyen@users.noreply.github.com> Date: Mon, 10 Jul 2023 10:01:03 +0200 Subject: [PATCH] Allow creating knock rooms (#11182) Signed-off-by: Charly Nguyen --- res/css/views/dialogs/_JoinRuleDropdown.pcss | 11 ++++ res/img/element-icons/ask-to-join.svg | 1 + src/TextForEvent.tsx | 2 + .../views/dialogs/CreateRoomDialog.tsx | 18 +++++- .../views/elements/JoinRuleDropdown.tsx | 14 +++++ src/createRoom.ts | 4 ++ src/i18n/strings/en_EN.json | 4 ++ src/settings/Settings.tsx | 7 +++ src/utils/PreferredRoomVersions.ts | 5 ++ test/PreferredRoomVersions-test.ts | 8 +++ test/TextForEvent-test.ts | 61 ++++++++++++++++++- test/__snapshots__/TextForEvent-test.ts.snap | 18 ++++++ .../views/dialogs/CreateRoomDialog-test.tsx | 47 +++++++++++++- 13 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 res/img/element-icons/ask-to-join.svg create mode 100644 test/__snapshots__/TextForEvent-test.ts.snap diff --git a/res/css/views/dialogs/_JoinRuleDropdown.pcss b/res/css/views/dialogs/_JoinRuleDropdown.pcss index 5b1b8b7a18..6df937816e 100644 --- a/res/css/views/dialogs/_JoinRuleDropdown.pcss +++ b/res/css/views/dialogs/_JoinRuleDropdown.pcss @@ -44,6 +44,10 @@ limitations under the License. mask-position: center; background-color: $secondary-content; } + + &.mx_JoinRuleDropdown_knock::before { + content: normal; + } } } @@ -63,4 +67,11 @@ limitations under the License. mask-image: url("$(res)/img/element-icons/group-members.svg"); mask-size: contain; } + + .mx_JoinRuleDropdown_icon { + color: $secondary-content; + position: absolute; + left: 6px; + top: 8px; + } } diff --git a/res/img/element-icons/ask-to-join.svg b/res/img/element-icons/ask-to-join.svg new file mode 100644 index 0000000000..e8d447cfff --- /dev/null +++ b/res/img/element-icons/ask-to-join.svg @@ -0,0 +1 @@ + diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index 746436a9e3..0018ca2ff8 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -286,6 +286,8 @@ function textForJoinRulesEvent(ev: MatrixEvent, client: MatrixClient, allowJSX: _t("%(senderDisplayName)s made the room invite only.", { senderDisplayName, }); + case JoinRule.Knock: + return () => _t("%(senderDisplayName)s changed the join rule to ask to join.", { senderDisplayName }); case JoinRule.Restricted: if (allowJSX) { return () => ( diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx index 50482af550..d629103d28 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -34,6 +34,7 @@ import JoinRuleDropdown from "../elements/JoinRuleDropdown"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { privateShouldBeEncrypted } from "../../../utils/rooms"; +import SettingsStore from "../../../settings/SettingsStore"; interface IProps { type?: RoomType; @@ -59,6 +60,7 @@ interface IState { } export default class CreateRoomDialog extends React.Component { + private readonly askToJoinEnabled: boolean; private readonly supportsRestricted: boolean; private nameField = createRef(); private aliasField = createRef(); @@ -66,6 +68,7 @@ export default class CreateRoomDialog extends React.Component { public constructor(props: IProps) { super(props); + this.askToJoinEnabled = SettingsStore.getValue("feature_ask_to_join"); this.supportsRestricted = !!this.props.parentSpace; let joinRule = JoinRule.Invite; @@ -126,6 +129,10 @@ export default class CreateRoomDialog extends React.Component { opts.joinRule = JoinRule.Restricted; } + if (this.state.joinRule === JoinRule.Knock) { + opts.joinRule = JoinRule.Knock; + } + return opts; } @@ -283,6 +290,14 @@ export default class CreateRoomDialog extends React.Component { {_t("You can change this at any time from room settings.")}

); + } else if (this.state.joinRule === JoinRule.Knock) { + publicPrivateLabel = ( +

+ {_t( + "Anyone can request to join, but admins or moderators need to grant access. You can change this later.", + )} +

+ ); } let e2eeSection: JSX.Element | undefined; @@ -332,7 +347,7 @@ export default class CreateRoomDialog extends React.Component { let title: string; if (isVideoRoom) { title = _t("Create a video room"); - } else if (this.props.parentSpace) { + } else if (this.props.parentSpace || this.state.joinRule === JoinRule.Knock) { title = _t("Create a room"); } else { title = this.state.joinRule === JoinRule.Public ? _t("Create a public room") : _t("Create a private room"); @@ -365,6 +380,7 @@ export default class CreateRoomDialog extends React.Component { = ({ label, labelInvite, + labelKnock, labelPublic, labelRestricted, value, @@ -48,6 +51,17 @@ const JoinRuleDropdown: React.FC = ({ , ] as NonEmptyArray; + if (labelKnock) { + options.unshift( + ( +
+ + {labelKnock} +
+ ) as ReactElement & { key: string }, + ); + } + if (labelRestricted) { options.unshift( ( diff --git a/src/createRoom.ts b/src/createRoom.ts index b0888d7d00..555bb98efc 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -222,6 +222,10 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro }); } + if (opts.joinRule === JoinRule.Knock) { + createOpts.room_version = PreferredRoomVersions.KnockRooms; + } + if (opts.parentSpace) { createOpts.initial_state.push(makeSpaceParentEvent(opts.parentSpace, true)); if (!opts.historyVisibility) { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 87c2dc3329..b545df9892 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -527,6 +527,7 @@ "%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s upgraded this room.", "%(senderDisplayName)s made the room public to whoever knows the link.": "%(senderDisplayName)s made the room public to whoever knows the link.", "%(senderDisplayName)s made the room invite only.": "%(senderDisplayName)s made the room invite only.", + "%(senderDisplayName)s changed the join rule to ask to join.": "%(senderDisplayName)s changed the join rule to ask to join.", "%(senderDisplayName)s changed who can join this room. View settings.": "%(senderDisplayName)s changed who can join this room. View settings.", "%(senderDisplayName)s changed who can join this room.": "%(senderDisplayName)s changed who can join this room.", "%(senderDisplayName)s changed the join rule to %(rule)s": "%(senderDisplayName)s changed the join rule to %(rule)s", @@ -1003,6 +1004,7 @@ "Insert a trailing colon after user mentions at the start of a message": "Insert a trailing colon after user mentions at the start of a message", "Hide notification dot (only display counters badges)": "Hide notification dot (only display counters badges)", "Enable intentional mentions": "Enable intentional mentions", + "Enable ask to join": "Enable ask to join", "Use a more compact 'Modern' layout": "Use a more compact 'Modern' layout", "Show a placeholder for removed messages": "Show a placeholder for removed messages", "Show join/leave messages (invites/removes/bans unaffected)": "Show join/leave messages (invites/removes/bans unaffected)", @@ -2783,6 +2785,7 @@ "Anyone will be able to find and join this room, not just members of .": "Anyone will be able to find and join this room, not just members of .", "Anyone will be able to find and join this room.": "Anyone will be able to find and join this room.", "Only people invited will be able to find and join this room.": "Only people invited will be able to find and join this room.", + "Anyone can request to join, but admins or moderators need to grant access. You can change this later.": "Anyone can request to join, but admins or moderators need to grant access. You can change this later.", "You can't disable this later. The room will be encrypted but the embedded call will not.": "You can't disable this later. The room will be encrypted but the embedded call will not.", "You can't disable this later. Bridges & most bots won't work yet.": "You can't disable this later. Bridges & most bots won't work yet.", "Your server requires encryption to be enabled in private rooms.": "Your server requires encryption to be enabled in private rooms.", @@ -2796,6 +2799,7 @@ "Topic (optional)": "Topic (optional)", "Room visibility": "Room visibility", "Private room (invite only)": "Private room (invite only)", + "Ask to join": "Ask to join", "Visible to space members": "Visible to space members", "Block anyone not part of %(serverName)s from ever joining this room.": "Block anyone not part of %(serverName)s from ever joining this room.", "Create video room": "Create video room", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 532f1a3a27..96594e90c9 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -556,6 +556,13 @@ export const SETTINGS: { [setting: string]: ISetting } = { ["org.matrix.msc3952_intentional_mentions"], ]), }, + "feature_ask_to_join": { + default: false, + displayName: _td("Enable ask to join"), + isFeature: true, + labsGroup: LabGroup.Rooms, + supportedLevels: LEVELS_FEATURE, + }, "useCompactLayout": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, displayName: _td("Use a more compact 'Modern' layout"), diff --git a/src/utils/PreferredRoomVersions.ts b/src/utils/PreferredRoomVersions.ts index b4944d4a00..9d1e10417b 100644 --- a/src/utils/PreferredRoomVersions.ts +++ b/src/utils/PreferredRoomVersions.ts @@ -23,6 +23,11 @@ limitations under the License. * Loosely follows https://spec.matrix.org/latest/rooms/#feature-matrix */ export class PreferredRoomVersions { + /** + * The room version to use when creating "knock" rooms. + */ + public static readonly KnockRooms = "7"; + /** * The room version to use when creating "restricted" rooms. */ diff --git a/test/PreferredRoomVersions-test.ts b/test/PreferredRoomVersions-test.ts index caaf3fe5ae..33dc30e8b8 100644 --- a/test/PreferredRoomVersions-test.ts +++ b/test/PreferredRoomVersions-test.ts @@ -36,6 +36,14 @@ describe("doesRoomVersionSupport", () => { expect(doesRoomVersionSupport("3.1", "2.2")).toBe(true); // newer }); + it("should detect knock rooms in v7 and above", () => { + expect(doesRoomVersionSupport("6", PreferredRoomVersions.KnockRooms)).toBe(false); + expect(doesRoomVersionSupport("7", PreferredRoomVersions.KnockRooms)).toBe(true); + expect(doesRoomVersionSupport("8", PreferredRoomVersions.KnockRooms)).toBe(true); + expect(doesRoomVersionSupport("9", PreferredRoomVersions.KnockRooms)).toBe(true); + expect(doesRoomVersionSupport("10", PreferredRoomVersions.KnockRooms)).toBe(true); + }); + it("should detect restricted rooms in v9 and v10", () => { // Dev note: we consider it a feature that v8 rooms have to upgrade considering the bug in v8. // https://spec.matrix.org/v1.3/rooms/v8/#redactions diff --git a/test/TextForEvent-test.ts b/test/TextForEvent-test.ts index 26a1f43de4..f86b780869 100644 --- a/test/TextForEvent-test.ts +++ b/test/TextForEvent-test.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventType, MatrixClient, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix"; +import { EventType, JoinRule, MatrixClient, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix"; import { render } from "@testing-library/react"; import { ReactElement } from "react"; import { Mocked, mocked } from "jest-mock"; @@ -512,4 +512,63 @@ describe("TextForEvent", () => { ).toMatchInlineSnapshot(`"Andy changed their display name and profile picture"`); }); }); + + describe("textForJoinRulesEvent()", () => { + type TestCase = [string, { result: string }]; + const testCases: TestCase[] = [ + [JoinRule.Public, { result: "@a made the room public to whoever knows the link." }], + [JoinRule.Invite, { result: "@a made the room invite only." }], + [JoinRule.Knock, { result: "@a changed the join rule to ask to join." }], + [JoinRule.Restricted, { result: "@a changed who can join this room." }], + ]; + + it.each(testCases)("returns correct message when room join rule changed to %s", (joinRule, { result }) => { + expect( + textForEvent( + new MatrixEvent({ + type: "m.room.join_rules", + sender: "@a", + content: { + join_rule: joinRule, + }, + state_key: "", + }), + mockClient, + ), + ).toEqual(result); + }); + + it(`returns correct JSX message when room join rule changed to ${JoinRule.Restricted}`, () => { + expect( + textForEvent( + new MatrixEvent({ + type: "m.room.join_rules", + sender: "@a", + content: { + join_rule: JoinRule.Restricted, + }, + state_key: "", + }), + mockClient, + true, + ), + ).toMatchSnapshot(); + }); + + it("returns correct default message", () => { + expect( + textForEvent( + new MatrixEvent({ + type: "m.room.join_rules", + sender: "@a", + content: { + join_rule: "a not implemented one", + }, + state_key: "", + }), + mockClient, + ), + ).toEqual("@a changed the join rule to a not implemented one"); + }); + }); }); diff --git a/test/__snapshots__/TextForEvent-test.ts.snap b/test/__snapshots__/TextForEvent-test.ts.snap new file mode 100644 index 0000000000..1ab32ffd04 --- /dev/null +++ b/test/__snapshots__/TextForEvent-test.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TextForEvent textForJoinRulesEvent() returns correct JSX message when room join rule changed to restricted 1`] = ` + + + @a changed who can join this room. + + View settings + + . + + +`; diff --git a/test/components/views/dialogs/CreateRoomDialog-test.tsx b/test/components/views/dialogs/CreateRoomDialog-test.tsx index f675efd023..b319c28865 100644 --- a/test/components/views/dialogs/CreateRoomDialog-test.tsx +++ b/test/components/views/dialogs/CreateRoomDialog-test.tsx @@ -16,10 +16,11 @@ limitations under the License. import React from "react"; import { fireEvent, render, screen, within } from "@testing-library/react"; -import { MatrixError, Preset, Visibility } from "matrix-js-sdk/src/matrix"; +import { JoinRule, MatrixError, Preset, Visibility } from "matrix-js-sdk/src/matrix"; import CreateRoomDialog from "../../../../src/components/views/dialogs/CreateRoomDialog"; import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../test-utils"; +import SettingsStore from "../../../../src/settings/SettingsStore"; describe("", () => { const userId = "@alice:server.org"; @@ -208,6 +209,50 @@ describe("", () => { }); }); + describe("for a knock room", () => { + it("should not have the option to create a knock room", async () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); + getComponent(); + fireEvent.click(screen.getByLabelText("Room visibility")); + + expect(screen.queryByRole("option", { name: "Ask to join" })).not.toBeInTheDocument(); + }); + + it("should create a knock room", async () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "feature_ask_to_join"); + const onFinished = jest.fn(); + getComponent({ onFinished }); + await flushPromises(); + + const roomName = "Test Room Name"; + fireEvent.change(screen.getByLabelText("Name"), { target: { value: roomName } }); + + fireEvent.click(screen.getByLabelText("Room visibility")); + fireEvent.click(screen.getByRole("option", { name: "Ask to join" })); + + fireEvent.click(screen.getByText("Create room")); + await flushPromises(); + + expect(screen.getByText("Create a room")).toBeInTheDocument(); + + expect( + screen.getByText( + "Anyone can request to join, but admins or moderators need to grant access. You can change this later.", + ), + ).toBeInTheDocument(); + + expect(onFinished).toHaveBeenCalledWith(true, { + createOpts: { + name: roomName, + }, + encryption: true, + joinRule: JoinRule.Knock, + parentSpace: undefined, + roomType: undefined, + }); + }); + }); + describe("for a public room", () => { it("should set join rule to public defaultPublic is truthy", async () => { const onFinished = jest.fn();