Skip to content

Commit fd74917

Browse files
authored
Allow creating knock rooms (matrix-org#11182)
Signed-off-by: Charly Nguyen <[email protected]>
1 parent 01bd80f commit fd74917

File tree

13 files changed

+197
-3
lines changed

13 files changed

+197
-3
lines changed

res/css/views/dialogs/_JoinRuleDropdown.pcss

+11
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ limitations under the License.
4444
mask-position: center;
4545
background-color: $secondary-content;
4646
}
47+
48+
&.mx_JoinRuleDropdown_knock::before {
49+
content: normal;
50+
}
4751
}
4852
}
4953

@@ -63,4 +67,11 @@ limitations under the License.
6367
mask-image: url("$(res)/img/element-icons/group-members.svg");
6468
mask-size: contain;
6569
}
70+
71+
.mx_JoinRuleDropdown_icon {
72+
color: $secondary-content;
73+
position: absolute;
74+
left: 6px;
75+
top: 8px;
76+
}
6677
}

res/img/element-icons/ask-to-join.svg

+1
Loading

src/TextForEvent.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,8 @@ function textForJoinRulesEvent(ev: MatrixEvent, client: MatrixClient, allowJSX:
286286
_t("%(senderDisplayName)s made the room invite only.", {
287287
senderDisplayName,
288288
});
289+
case JoinRule.Knock:
290+
return () => _t("%(senderDisplayName)s changed the join rule to ask to join.", { senderDisplayName });
289291
case JoinRule.Restricted:
290292
if (allowJSX) {
291293
return () => (

src/components/views/dialogs/CreateRoomDialog.tsx

+17-1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import JoinRuleDropdown from "../elements/JoinRuleDropdown";
3434
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
3535
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
3636
import { privateShouldBeEncrypted } from "../../../utils/rooms";
37+
import SettingsStore from "../../../settings/SettingsStore";
3738

3839
interface IProps {
3940
type?: RoomType;
@@ -59,13 +60,15 @@ interface IState {
5960
}
6061

6162
export default class CreateRoomDialog extends React.Component<IProps, IState> {
63+
private readonly askToJoinEnabled: boolean;
6264
private readonly supportsRestricted: boolean;
6365
private nameField = createRef<Field>();
6466
private aliasField = createRef<RoomAliasField>();
6567

6668
public constructor(props: IProps) {
6769
super(props);
6870

71+
this.askToJoinEnabled = SettingsStore.getValue("feature_ask_to_join");
6972
this.supportsRestricted = !!this.props.parentSpace;
7073

7174
let joinRule = JoinRule.Invite;
@@ -126,6 +129,10 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
126129
opts.joinRule = JoinRule.Restricted;
127130
}
128131

132+
if (this.state.joinRule === JoinRule.Knock) {
133+
opts.joinRule = JoinRule.Knock;
134+
}
135+
129136
return opts;
130137
}
131138

@@ -283,6 +290,14 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
283290
{_t("You can change this at any time from room settings.")}
284291
</p>
285292
);
293+
} else if (this.state.joinRule === JoinRule.Knock) {
294+
publicPrivateLabel = (
295+
<p>
296+
{_t(
297+
"Anyone can request to join, but admins or moderators need to grant access. You can change this later.",
298+
)}
299+
</p>
300+
);
286301
}
287302

288303
let e2eeSection: JSX.Element | undefined;
@@ -332,7 +347,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
332347
let title: string;
333348
if (isVideoRoom) {
334349
title = _t("Create a video room");
335-
} else if (this.props.parentSpace) {
350+
} else if (this.props.parentSpace || this.state.joinRule === JoinRule.Knock) {
336351
title = _t("Create a room");
337352
} else {
338353
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<IProps, IState> {
365380
<JoinRuleDropdown
366381
label={_t("Room visibility")}
367382
labelInvite={_t("Private room (invite only)")}
383+
labelKnock={this.askToJoinEnabled ? _t("Ask to join") : undefined}
368384
labelPublic={_t("Public room")}
369385
labelRestricted={this.supportsRestricted ? _t("Visible to space members") : undefined}
370386
value={this.state.joinRule}

src/components/views/elements/JoinRuleDropdown.tsx

+14
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ import { JoinRule } from "matrix-js-sdk/src/@types/partials";
1919

2020
import Dropdown from "./Dropdown";
2121
import { NonEmptyArray } from "../../../@types/common";
22+
import { Icon as AskToJoinIcon } from "../../../../res/img/element-icons/ask-to-join.svg";
2223

2324
interface IProps {
2425
value: JoinRule;
2526
label: string;
2627
width?: number;
2728
labelInvite: string;
29+
labelKnock?: string;
2830
labelPublic: string;
2931
labelRestricted?: string; // if omitted then this option will be hidden, e.g if unsupported
3032
onChange(value: JoinRule): void;
@@ -33,6 +35,7 @@ interface IProps {
3335
const JoinRuleDropdown: React.FC<IProps> = ({
3436
label,
3537
labelInvite,
38+
labelKnock,
3639
labelPublic,
3740
labelRestricted,
3841
value,
@@ -48,6 +51,17 @@ const JoinRuleDropdown: React.FC<IProps> = ({
4851
</div>,
4952
] as NonEmptyArray<ReactElement & { key: string }>;
5053

54+
if (labelKnock) {
55+
options.unshift(
56+
(
57+
<div key={JoinRule.Knock} className="mx_JoinRuleDropdown_knock">
58+
<AskToJoinIcon className="mx_Icon mx_Icon_16 mx_JoinRuleDropdown_icon" />
59+
{labelKnock}
60+
</div>
61+
) as ReactElement & { key: string },
62+
);
63+
}
64+
5165
if (labelRestricted) {
5266
options.unshift(
5367
(

src/createRoom.ts

+4
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,10 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro
222222
});
223223
}
224224

225+
if (opts.joinRule === JoinRule.Knock) {
226+
createOpts.room_version = PreferredRoomVersions.KnockRooms;
227+
}
228+
225229
if (opts.parentSpace) {
226230
createOpts.initial_state.push(makeSpaceParentEvent(opts.parentSpace, true));
227231
if (!opts.historyVisibility) {

src/i18n/strings/en_EN.json

+4
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,7 @@
527527
"%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s upgraded this room.",
528528
"%(senderDisplayName)s made the room public to whoever knows the link.": "%(senderDisplayName)s made the room public to whoever knows the link.",
529529
"%(senderDisplayName)s made the room invite only.": "%(senderDisplayName)s made the room invite only.",
530+
"%(senderDisplayName)s changed the join rule to ask to join.": "%(senderDisplayName)s changed the join rule to ask to join.",
530531
"%(senderDisplayName)s changed who can join this room. <a>View settings</a>.": "%(senderDisplayName)s changed who can join this room. <a>View settings</a>.",
531532
"%(senderDisplayName)s changed who can join this room.": "%(senderDisplayName)s changed who can join this room.",
532533
"%(senderDisplayName)s changed the join rule to %(rule)s": "%(senderDisplayName)s changed the join rule to %(rule)s",
@@ -1003,6 +1004,7 @@
10031004
"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",
10041005
"Hide notification dot (only display counters badges)": "Hide notification dot (only display counters badges)",
10051006
"Enable intentional mentions": "Enable intentional mentions",
1007+
"Enable ask to join": "Enable ask to join",
10061008
"Use a more compact 'Modern' layout": "Use a more compact 'Modern' layout",
10071009
"Show a placeholder for removed messages": "Show a placeholder for removed messages",
10081010
"Show join/leave messages (invites/removes/bans unaffected)": "Show join/leave messages (invites/removes/bans unaffected)",
@@ -2783,6 +2785,7 @@
27832785
"Anyone will be able to find and join this room, not just members of <SpaceName/>.": "Anyone will be able to find and join this room, not just members of <SpaceName/>.",
27842786
"Anyone will be able to find and join this room.": "Anyone will be able to find and join this room.",
27852787
"Only people invited will be able to find and join this room.": "Only people invited will be able to find and join this room.",
2788+
"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.",
27862789
"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.",
27872790
"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.",
27882791
"Your server requires encryption to be enabled in private rooms.": "Your server requires encryption to be enabled in private rooms.",
@@ -2796,6 +2799,7 @@
27962799
"Topic (optional)": "Topic (optional)",
27972800
"Room visibility": "Room visibility",
27982801
"Private room (invite only)": "Private room (invite only)",
2802+
"Ask to join": "Ask to join",
27992803
"Visible to space members": "Visible to space members",
28002804
"Block anyone not part of %(serverName)s from ever joining this room.": "Block anyone not part of %(serverName)s from ever joining this room.",
28012805
"Create video room": "Create video room",

src/settings/Settings.tsx

+7
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,13 @@ export const SETTINGS: { [setting: string]: ISetting } = {
556556
["org.matrix.msc3952_intentional_mentions"],
557557
]),
558558
},
559+
"feature_ask_to_join": {
560+
default: false,
561+
displayName: _td("Enable ask to join"),
562+
isFeature: true,
563+
labsGroup: LabGroup.Rooms,
564+
supportedLevels: LEVELS_FEATURE,
565+
},
559566
"useCompactLayout": {
560567
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
561568
displayName: _td("Use a more compact 'Modern' layout"),

src/utils/PreferredRoomVersions.ts

+5
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ limitations under the License.
2323
* Loosely follows https://spec.matrix.org/latest/rooms/#feature-matrix
2424
*/
2525
export class PreferredRoomVersions {
26+
/**
27+
* The room version to use when creating "knock" rooms.
28+
*/
29+
public static readonly KnockRooms = "7";
30+
2631
/**
2732
* The room version to use when creating "restricted" rooms.
2833
*/

test/PreferredRoomVersions-test.ts

+8
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ describe("doesRoomVersionSupport", () => {
3636
expect(doesRoomVersionSupport("3.1", "2.2")).toBe(true); // newer
3737
});
3838

39+
it("should detect knock rooms in v7 and above", () => {
40+
expect(doesRoomVersionSupport("6", PreferredRoomVersions.KnockRooms)).toBe(false);
41+
expect(doesRoomVersionSupport("7", PreferredRoomVersions.KnockRooms)).toBe(true);
42+
expect(doesRoomVersionSupport("8", PreferredRoomVersions.KnockRooms)).toBe(true);
43+
expect(doesRoomVersionSupport("9", PreferredRoomVersions.KnockRooms)).toBe(true);
44+
expect(doesRoomVersionSupport("10", PreferredRoomVersions.KnockRooms)).toBe(true);
45+
});
46+
3947
it("should detect restricted rooms in v9 and v10", () => {
4048
// Dev note: we consider it a feature that v8 rooms have to upgrade considering the bug in v8.
4149
// https://spec.matrix.org/v1.3/rooms/v8/#redactions

test/TextForEvent-test.ts

+60-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { EventType, MatrixClient, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix";
17+
import { EventType, JoinRule, MatrixClient, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix";
1818
import { render } from "@testing-library/react";
1919
import { ReactElement } from "react";
2020
import { Mocked, mocked } from "jest-mock";
@@ -512,4 +512,63 @@ describe("TextForEvent", () => {
512512
).toMatchInlineSnapshot(`"Andy changed their display name and profile picture"`);
513513
});
514514
});
515+
516+
describe("textForJoinRulesEvent()", () => {
517+
type TestCase = [string, { result: string }];
518+
const testCases: TestCase[] = [
519+
[JoinRule.Public, { result: "@a made the room public to whoever knows the link." }],
520+
[JoinRule.Invite, { result: "@a made the room invite only." }],
521+
[JoinRule.Knock, { result: "@a changed the join rule to ask to join." }],
522+
[JoinRule.Restricted, { result: "@a changed who can join this room." }],
523+
];
524+
525+
it.each(testCases)("returns correct message when room join rule changed to %s", (joinRule, { result }) => {
526+
expect(
527+
textForEvent(
528+
new MatrixEvent({
529+
type: "m.room.join_rules",
530+
sender: "@a",
531+
content: {
532+
join_rule: joinRule,
533+
},
534+
state_key: "",
535+
}),
536+
mockClient,
537+
),
538+
).toEqual(result);
539+
});
540+
541+
it(`returns correct JSX message when room join rule changed to ${JoinRule.Restricted}`, () => {
542+
expect(
543+
textForEvent(
544+
new MatrixEvent({
545+
type: "m.room.join_rules",
546+
sender: "@a",
547+
content: {
548+
join_rule: JoinRule.Restricted,
549+
},
550+
state_key: "",
551+
}),
552+
mockClient,
553+
true,
554+
),
555+
).toMatchSnapshot();
556+
});
557+
558+
it("returns correct default message", () => {
559+
expect(
560+
textForEvent(
561+
new MatrixEvent({
562+
type: "m.room.join_rules",
563+
sender: "@a",
564+
content: {
565+
join_rule: "a not implemented one",
566+
},
567+
state_key: "",
568+
}),
569+
mockClient,
570+
),
571+
).toEqual("@a changed the join rule to a not implemented one");
572+
});
573+
});
515574
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`TextForEvent textForJoinRulesEvent() returns correct JSX message when room join rule changed to restricted 1`] = `
4+
<span>
5+
<span>
6+
@a changed who can join this room.
7+
<AccessibleButton
8+
kind="link_inline"
9+
onClick={[Function]}
10+
role="button"
11+
tabIndex={0}
12+
>
13+
View settings
14+
</AccessibleButton>
15+
.
16+
</span>
17+
</span>
18+
`;

test/components/views/dialogs/CreateRoomDialog-test.tsx

+46-1
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ limitations under the License.
1616

1717
import React from "react";
1818
import { fireEvent, render, screen, within } from "@testing-library/react";
19-
import { MatrixError, Preset, Visibility } from "matrix-js-sdk/src/matrix";
19+
import { JoinRule, MatrixError, Preset, Visibility } from "matrix-js-sdk/src/matrix";
2020

2121
import CreateRoomDialog from "../../../../src/components/views/dialogs/CreateRoomDialog";
2222
import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../test-utils";
23+
import SettingsStore from "../../../../src/settings/SettingsStore";
2324

2425
describe("<CreateRoomDialog />", () => {
2526
const userId = "@alice:server.org";
@@ -208,6 +209,50 @@ describe("<CreateRoomDialog />", () => {
208209
});
209210
});
210211

212+
describe("for a knock room", () => {
213+
it("should not have the option to create a knock room", async () => {
214+
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
215+
getComponent();
216+
fireEvent.click(screen.getByLabelText("Room visibility"));
217+
218+
expect(screen.queryByRole("option", { name: "Ask to join" })).not.toBeInTheDocument();
219+
});
220+
221+
it("should create a knock room", async () => {
222+
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "feature_ask_to_join");
223+
const onFinished = jest.fn();
224+
getComponent({ onFinished });
225+
await flushPromises();
226+
227+
const roomName = "Test Room Name";
228+
fireEvent.change(screen.getByLabelText("Name"), { target: { value: roomName } });
229+
230+
fireEvent.click(screen.getByLabelText("Room visibility"));
231+
fireEvent.click(screen.getByRole("option", { name: "Ask to join" }));
232+
233+
fireEvent.click(screen.getByText("Create room"));
234+
await flushPromises();
235+
236+
expect(screen.getByText("Create a room")).toBeInTheDocument();
237+
238+
expect(
239+
screen.getByText(
240+
"Anyone can request to join, but admins or moderators need to grant access. You can change this later.",
241+
),
242+
).toBeInTheDocument();
243+
244+
expect(onFinished).toHaveBeenCalledWith(true, {
245+
createOpts: {
246+
name: roomName,
247+
},
248+
encryption: true,
249+
joinRule: JoinRule.Knock,
250+
parentSpace: undefined,
251+
roomType: undefined,
252+
});
253+
});
254+
});
255+
211256
describe("for a public room", () => {
212257
it("should set join rule to public defaultPublic is truthy", async () => {
213258
const onFinished = jest.fn();

0 commit comments

Comments
 (0)