Skip to content

Commit 6b510a5

Browse files
authored
Automatically adjust history visibility when making a room private (#30713)
* Refactor StyledRadioButton to provide proper labels. * Automatically change history settings to members only if room is made private * Add tests * lint * lint further * Fix clickable buttons * Revert functional component-ing * text tweaks * update snapshots * Add unit test for history vis changes * lint * Update snapshots * Fix flakes * lint
1 parent 6d05bfc commit 6b510a5

File tree

8 files changed

+239
-25
lines changed

8 files changed

+239
-25
lines changed

playwright/e2e/settings/room-settings/room-security-tab.spec.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,16 @@ test.describe("Roles & Permissions room settings tab", () => {
1919
let settings: Locator;
2020

2121
test.beforeEach(async ({ user, app }) => {
22-
await app.client.createRoom({ name: roomName });
22+
await app.client.createRoom({
23+
name: roomName,
24+
power_level_content_override: {
25+
events: {
26+
// Set the join rules as lower than the history vis to test an edge case.
27+
["m.room.join_rules"]: 80,
28+
["m.room.history_visibility"]: 100,
29+
},
30+
},
31+
});
2332
await app.viewRoomByName(roomName);
2433
settings = await app.settings.openRoomSettings("Security & Privacy");
2534
});
@@ -45,4 +54,68 @@ test.describe("Roles & Permissions room settings tab", () => {
4554
await expect(settings).toMatchScreenshot("room-security-settings.png");
4655
},
4756
);
57+
58+
test(
59+
"should automatically adjust history visibility when a room is changed from public to private",
60+
{ tag: "@screenshot" },
61+
async ({ page, app, user, axe }) => {
62+
await page.setViewportSize({ width: 1024, height: 1400 });
63+
64+
const settingsGroupAccess = page.getByRole("group", { name: "Access" });
65+
const settingsGroupHistory = page.getByRole("group", { name: "Who can read history?" });
66+
67+
await settingsGroupAccess.getByText("Public").click();
68+
await settingsGroupHistory.getByText("Anyone").click();
69+
70+
// Test that we have the warning appear.
71+
axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view.
72+
await expect(axe).toHaveNoViolations();
73+
await expect(settings).toMatchScreenshot("room-security-settings-world-readable.png");
74+
75+
await settingsGroupAccess.getByText("Private (invite only)").click();
76+
// Element should have automatically set the room to "sharing" history visibility
77+
await expect(
78+
settingsGroupHistory.getByText("Members only (since the point in time of selecting this option)"),
79+
).toBeChecked();
80+
},
81+
);
82+
83+
test(
84+
"should disallow changing from public to private if the user cannot alter history",
85+
{ tag: "@screenshot" },
86+
async ({ page, app, user, bot }) => {
87+
await page.setViewportSize({ width: 1024, height: 1400 });
88+
89+
const settingsGroupAccess = page.getByRole("group", { name: "Access" });
90+
const settingsGroupHistory = page.getByRole("group", { name: "Who can read history?" });
91+
92+
await settingsGroupAccess.getByText("Public").click();
93+
await settingsGroupHistory.getByText("Anyone").click();
94+
95+
// De-op ourselves
96+
await app.settings.switchTab("Roles & Permissions");
97+
98+
// Wait for the permissions list to be visible
99+
await expect(settings.getByRole("heading", { name: "Permissions" })).toBeVisible();
100+
101+
const ourComboBox = settings.getByRole("combobox", { name: user.userId });
102+
await ourComboBox.selectOption("Custom level");
103+
const ourPl = settings.getByRole("spinbutton", { name: user.userId });
104+
await ourPl.fill("80");
105+
await ourPl.blur(); // Shows a warning on
106+
107+
// Accept the de-op
108+
await page.getByRole("button", { name: "Continue" }).click();
109+
await settings.getByRole("button", { name: "Apply", disabled: false }).click();
110+
111+
await app.settings.switchTab("Security & Privacy");
112+
113+
await settingsGroupAccess.getByText("Private (invite only)").click();
114+
// Element should have automatically set the room to "sharing" history visibility
115+
const errorDialog = page.getByRole("heading", { name: "Cannot make room private" });
116+
await expect(errorDialog).toBeVisible();
117+
await errorDialog.getByLabel("OK");
118+
await expect(settingsGroupHistory.getByText("Anyone")).toBeChecked();
119+
},
120+
);
48121
});
-8.11 KB
Loading
96.3 KB
Loading

src/components/views/settings/JoinRuleSettings.tsx

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ export interface JoinRuleSettingsProps {
3535
closeSettingsFn(): void;
3636
onError(error: unknown): void;
3737
beforeChange?(joinRule: JoinRule): Promise<boolean>; // if returns false then aborts the change
38-
aliasWarning?: ReactNode;
3938
disabledOptions?: Set<JoinRule>;
4039
hiddenOptions?: Set<JoinRule>;
4140
recommendedOption?: JoinRule;
@@ -44,7 +43,6 @@ export interface JoinRuleSettingsProps {
4443
const JoinRuleSettings: React.FC<JoinRuleSettingsProps> = ({
4544
room,
4645
promptUpgrade,
47-
aliasWarning,
4846
onError,
4947
beforeChange,
5048
closeSettingsFn,
@@ -209,12 +207,7 @@ const JoinRuleSettings: React.FC<JoinRuleSettingsProps> = ({
209207
{
210208
value: JoinRule.Public,
211209
label: withRecommendLabel(_t("common|public"), JoinRule.Public),
212-
description: (
213-
<>
214-
{_t("room_settings|security|join_rule_public_description")}
215-
{aliasWarning}
216-
</>
217-
),
210+
description: <>{_t("room_settings|security|join_rule_public_description")}</>,
218211
},
219212
];
220213

src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -251,19 +251,28 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
251251

252252
private renderJoinRule(): JSX.Element {
253253
const room = this.props.room;
254-
255-
let aliasWarning: JSX.Element | undefined;
256-
if (room.getJoinRule() === JoinRule.Public && !this.state.hasAliases) {
257-
aliasWarning = (
258-
<div className="mx_SecurityRoomSettingsTab_warning">
259-
<WarningIcon width={15} height={15} />
260-
<span>{_t("room_settings|security|public_without_alias_warning")}</span>
261-
</div>
262-
);
263-
}
264-
const description = _t("room_settings|security|join_rule_description", {
265-
roomName: room.name,
266-
});
254+
const isPublic = room.getJoinRule() === JoinRule.Public;
255+
const description = (
256+
<>
257+
<p>
258+
{_t("room_settings|security|join_rule_description", {
259+
roomName: room.name,
260+
})}
261+
</p>
262+
{isPublic && this.state.history === HistoryVisibility.WorldReadable && (
263+
<div className="mx_SecurityRoomSettingsTab_warning">
264+
<WarningIcon width={15} height={15} />
265+
<span>{_t("room_settings|security|join_rule_world_readable_description")}</span>
266+
</div>
267+
)}
268+
{isPublic && !this.state.hasAliases && (
269+
<div className="mx_SecurityRoomSettingsTab_warning">
270+
<WarningIcon width={15} height={15} />
271+
<span>{_t("room_settings|security|public_without_alias_warning")}</span>
272+
</div>
273+
)}
274+
</>
275+
);
267276

268277
let advanced: JSX.Element | undefined;
269278
if (room.getJoinRule() === JoinRule.Public) {
@@ -290,7 +299,6 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
290299
onError={this.onJoinRuleChangeError}
291300
closeSettingsFn={this.props.closeSettingsFn}
292301
promptUpgrade={true}
293-
aliasWarning={aliasWarning}
294302
/>
295303
{advanced}
296304
</SettingsFieldset>
@@ -342,6 +350,57 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
342350
if (!confirm) return false;
343351
}
344352

353+
// If the room is going from public to private AND the room is join readable, we want to encourage the user
354+
// to change the history visibility.
355+
const currentlyPublic = this.props.room.getJoinRule() === JoinRule.Public;
356+
if (this.state.history === HistoryVisibility.WorldReadable && currentlyPublic && joinRule !== JoinRule.Public) {
357+
const client = this.context;
358+
const canChangeHistory = this.props.room.currentState?.mayClientSendStateEvent(
359+
EventType.RoomHistoryVisibility,
360+
client,
361+
);
362+
363+
// If we can't change the history visibility, then don't allow the join rule transition. This is a unlikely occurance
364+
// and if this is the case, a room administator should step in.
365+
if (!canChangeHistory) {
366+
const dialog = Modal.createDialog(ErrorDialog, {
367+
title: _t(
368+
"room_settings|security|cannot_change_to_private_due_to_missing_history_visiblity_permissions|title",
369+
),
370+
description: (
371+
<p>
372+
{_t(
373+
"room_settings|security|cannot_change_to_private_due_to_missing_history_visiblity_permissions|description",
374+
)}
375+
</p>
376+
),
377+
});
378+
await dialog.finished;
379+
return false;
380+
}
381+
382+
// Adjust the history visibility first.
383+
try {
384+
await this.context.sendStateEvent(
385+
this.props.room.roomId,
386+
EventType.RoomHistoryVisibility,
387+
{
388+
history_visibility: HistoryVisibility.Shared,
389+
},
390+
"",
391+
);
392+
this.setState({ history: HistoryVisibility.Shared });
393+
} catch (ex) {
394+
logger.error("Failed to change history visibility", ex);
395+
Modal.createDialog(ErrorDialog, {
396+
title: _t("common|error"),
397+
description: _t("error|update_history_visibility"),
398+
});
399+
// If we fail to update the history visibility
400+
return false;
401+
}
402+
}
403+
345404
return true;
346405
};
347406

src/i18n/strings/en_EN.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1128,6 +1128,7 @@
11281128
"tls": "Can't connect to homeserver - please check your connectivity, ensure your <a>homeserver's SSL certificate</a> is trusted, and that a browser extension is not blocking requests.",
11291129
"unknown": "Unknown error",
11301130
"unknown_error_code": "unknown error code",
1131+
"update_history_visibility": "Failed to change history visibility",
11311132
"update_power_level": "Failed to change power level"
11321133
},
11331134
"error_app_open_in_another_tab": "Switch to the other tab to connect to %(brand)s. This tab can now be closed.",
@@ -2385,6 +2386,10 @@
23852386
"users_default": "Default role"
23862387
},
23872388
"security": {
2389+
"cannot_change_to_private_due_to_missing_history_visiblity_permissions": {
2390+
"description": "You do not have permissions to alter the history visibility of the room. This is dangerous as it could allow unjoined users to read messages.",
2391+
"title": "Cannot make room private"
2392+
},
23882393
"enable_encryption_confirm_description": "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>",
23892394
"enable_encryption_confirm_title": "Enable encryption?",
23902395
"enable_encryption_public_room_confirm_description_1": "<b>It's not recommended to add encryption to public rooms.</b> Anyone can find and join public rooms, so anyone can read messages in them. You'll get none of the benefits of encryption, and you won't be able to turn it off later. Encrypting messages in a public room will make receiving and sending messages slower.",
@@ -2402,7 +2407,7 @@
24022407
"history_visibility_joined": "Members only (since they joined)",
24032408
"history_visibility_legend": "Who can read history?",
24042409
"history_visibility_shared": "Members only (since the point in time of selecting this option)",
2405-
"history_visibility_warning": "Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.",
2410+
"history_visibility_warning": "The visibility of existing history will not be changed.",
24062411
"history_visibility_world_readable": "Anyone",
24072412
"join_rule_description": "Decide who can join %(roomName)s.",
24082413
"join_rule_invite": "Private (invite only)",
@@ -2445,6 +2450,7 @@
24452450
"other": "Updating spaces... (%(progress)s out of %(count)s)"
24462451
},
24472452
"join_rule_upgrade_upgrading_room": "Upgrading room",
2453+
"join_rule_world_readable_description": "Changing who can join the room will change the visibility of future messages too.",
24482454
"public_without_alias_warning": "To link to this room, please add an address.",
24492455
"publish_room": "Make this room visible in the public room directory.",
24502456
"publish_space": "Make this space visible in the public room directory.",

test/unit-tests/components/views/settings/tabs/room/SecurityRoomSettingsTab-test.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { fireEvent, render, screen, waitFor, within } from "jest-matrix-react";
1111
import { EventType, GuestAccess, HistoryVisibility, JoinRule, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
1212
import { logger } from "matrix-js-sdk/src/logger";
1313
import { mocked } from "jest-mock";
14+
import { type RoomPowerLevelsEventContent } from "matrix-js-sdk/src/types";
1415

1516
import SecurityRoomSettingsTab from "../../../../../../../src/components/views/settings/tabs/room/SecurityRoomSettingsTab";
1617
import MatrixClientContext from "../../../../../../../src/contexts/MatrixClientContext";
@@ -123,6 +124,88 @@ describe("<SecurityRoomSettingsTab />", () => {
123124
);
124125
});
125126

127+
it("handles changing room to private with world_readable history visiblity", async () => {
128+
const room = new Room(roomId, client, userId);
129+
setRoomStateEvents(room, JoinRule.Public, undefined, HistoryVisibility.WorldReadable);
130+
131+
getComponent(room);
132+
133+
fireEvent.click(screen.getByLabelText("Private (invite only)"));
134+
135+
await flushPromises();
136+
137+
expect(client.sendStateEvent).toHaveBeenCalledWith(
138+
room.roomId,
139+
EventType.RoomHistoryVisibility,
140+
{
141+
history_visibility: HistoryVisibility.Shared,
142+
},
143+
"",
144+
);
145+
expect(client.sendStateEvent).toHaveBeenCalledWith(
146+
room.roomId,
147+
EventType.RoomJoinRules,
148+
{
149+
join_rule: JoinRule.Invite,
150+
},
151+
"",
152+
);
153+
});
154+
155+
it("doesn't change room to private when user lacks permissions for history visibility", async () => {
156+
const room = new Room(roomId, client, userId);
157+
setRoomStateEvents(room, JoinRule.Public, undefined, HistoryVisibility.WorldReadable);
158+
room.currentState.setStateEvents([
159+
new MatrixEvent({
160+
type: EventType.RoomPowerLevels,
161+
content: {
162+
users: { [userId]: 50 },
163+
state_default: 50,
164+
events: {
165+
[EventType.RoomJoinRules]: 50,
166+
[EventType.RoomHistoryVisibility]: 100,
167+
},
168+
} as RoomPowerLevelsEventContent,
169+
sender: userId,
170+
state_key: "",
171+
room_id: room.roomId,
172+
}),
173+
]);
174+
175+
getComponent(room);
176+
fireEvent.click(screen.getByLabelText("Private (invite only)"));
177+
await flushPromises();
178+
// Ensure we don't make any changes
179+
expect(client.sendStateEvent).not.toHaveBeenCalled();
180+
});
181+
182+
it("doesn't change room to private when history visibility change fails", async () => {
183+
client.sendStateEvent.mockRejectedValue("Failed");
184+
const room = new Room(roomId, client, userId);
185+
setRoomStateEvents(room, JoinRule.Public, undefined, HistoryVisibility.WorldReadable);
186+
187+
getComponent(room);
188+
fireEvent.click(screen.getByLabelText("Private (invite only)"));
189+
await flushPromises();
190+
expect(client.sendStateEvent).toHaveBeenCalledWith(
191+
room.roomId,
192+
EventType.RoomHistoryVisibility,
193+
{
194+
history_visibility: HistoryVisibility.Shared,
195+
},
196+
"",
197+
);
198+
// Ensure we don't make any changes
199+
expect(client.sendStateEvent).not.toHaveBeenCalledWith(
200+
room.roomId,
201+
EventType.RoomJoinRules,
202+
{
203+
join_rule: JoinRule.Invite,
204+
},
205+
"",
206+
);
207+
});
208+
126209
it("handles error when updating join rule fails", async () => {
127210
const room = new Room(roomId, client, userId);
128211
client.sendStateEvent.mockRejectedValue("oups");

test/unit-tests/components/views/settings/tabs/room/__snapshots__/SecurityRoomSettingsTab-test.tsx.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ exports[`<SecurityRoomSettingsTab /> history visibility uses shared as default h
1515
<div
1616
class="mx_SettingsSubsection_text"
1717
>
18-
Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.
18+
The visibility of existing history will not be changed.
1919
</div>
2020
</div>
2121
<div

0 commit comments

Comments
 (0)