/* Copyright 2024 New Vector Ltd. Copyright 2021 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import React, { ReactNode, useEffect, useState } from "react"; import { JoinRule, RestrictedAllowType, Room, EventType, Visibility } from "matrix-js-sdk/src/matrix"; import { RoomJoinRulesEventContent } from "matrix-js-sdk/src/types"; import StyledRadioGroup, { IDefinition } from "../elements/StyledRadioGroup"; import { _t } from "../../../languageHandler"; import AccessibleButton from "../elements/AccessibleButton"; import RoomAvatar from "../avatars/RoomAvatar"; import SpaceStore from "../../../stores/spaces/SpaceStore"; import Modal from "../../../Modal"; import ManageRestrictedJoinRuleDialog from "../dialogs/ManageRestrictedJoinRuleDialog"; import RoomUpgradeWarningDialog, { IFinishedOpts } from "../dialogs/RoomUpgradeWarningDialog"; import { upgradeRoom } from "../../../utils/RoomUpgrade"; import { arrayHasDiff } from "../../../utils/arrays"; import { useLocalEcho } from "../../../hooks/useLocalEcho"; import dis from "../../../dispatcher/dispatcher"; import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog"; import { Action } from "../../../dispatcher/actions"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { doesRoomVersionSupport, PreferredRoomVersions } from "../../../utils/PreferredRoomVersions"; import SettingsStore from "../../../settings/SettingsStore"; import LabelledCheckbox from "../elements/LabelledCheckbox"; export interface JoinRuleSettingsProps { room: Room; promptUpgrade?: boolean; closeSettingsFn(): void; onError(error: unknown): void; beforeChange?(joinRule: JoinRule): Promise; // if returns false then aborts the change aliasWarning?: ReactNode; disabledOptions?: Set; hiddenOptions?: Set; recommendedOption?: JoinRule; } const JoinRuleSettings: React.FC = ({ room, promptUpgrade, aliasWarning, onError, beforeChange, closeSettingsFn, disabledOptions, hiddenOptions, recommendedOption, }) => { const cli = room.client; const askToJoinEnabled = SettingsStore.getValue("feature_ask_to_join"); const roomSupportsKnock = doesRoomVersionSupport(room.getVersion(), PreferredRoomVersions.KnockRooms); const preferredKnockVersion = !roomSupportsKnock && promptUpgrade ? PreferredRoomVersions.KnockRooms : undefined; const roomSupportsRestricted = doesRoomVersionSupport(room.getVersion(), PreferredRoomVersions.RestrictedRooms); const preferredRestrictionVersion = !roomSupportsRestricted && promptUpgrade ? PreferredRoomVersions.RestrictedRooms : undefined; const disabled = !room.currentState.mayClientSendStateEvent(EventType.RoomJoinRules, cli); const [content, setContent] = useLocalEcho( () => room.currentState.getStateEvents(EventType.RoomJoinRules, "")?.getContent(), (content) => cli.sendStateEvent(room.roomId, EventType.RoomJoinRules, content, ""), onError, ); const { join_rule: joinRule = JoinRule.Invite } = content || {}; const restrictedAllowRoomIds = joinRule === JoinRule.Restricted ? content?.allow?.filter((o) => o.type === RestrictedAllowType.RoomMembership).map((o) => o.room_id) : undefined; const [isPublicKnockRoom, setIsPublicKnockRoom] = useState(false); useEffect(() => { if (joinRule === JoinRule.Knock) { cli.getRoomDirectoryVisibility(room.roomId) .then(({ visibility }) => setIsPublicKnockRoom(visibility === Visibility.Public)) .catch(onError); } }, [cli, joinRule, onError, room.roomId]); const onIsPublicKnockRoomChange = (checked: boolean): void => { cli.setRoomDirectoryVisibility(room.roomId, checked ? Visibility.Public : Visibility.Private) .then(() => setIsPublicKnockRoom(checked)) .catch(onError); }; const editRestrictedRoomIds = async (): Promise => { let selected = restrictedAllowRoomIds; if (!selected?.length && SpaceStore.instance.activeSpaceRoom) { selected = [SpaceStore.instance.activeSpaceRoom.roomId]; } const { finished } = Modal.createDialog( ManageRestrictedJoinRuleDialog, { room, selected, }, "mx_ManageRestrictedJoinRuleDialog_wrapper", ); const [roomIds] = await finished; return roomIds; }; const upgradeRequiredDialog = (targetVersion: string, description?: ReactNode): void => { Modal.createDialog(RoomUpgradeWarningDialog, { roomId: room.roomId, targetVersion, description, doUpgrade: async ( opts: IFinishedOpts, fn: (progressText: string, progress: number, total: number) => void, ): Promise => { const roomId = await upgradeRoom(room, targetVersion, opts.invite, true, true, true, (progress) => { const total = 2 + progress.updateSpacesTotal + progress.inviteUsersTotal; if (!progress.roomUpgraded) { fn(_t("room_settings|security|join_rule_upgrade_upgrading_room"), 0, total); } else if (!progress.roomSynced) { fn(_t("room_settings|security|join_rule_upgrade_awaiting_room"), 1, total); } else if ( progress.inviteUsersProgress !== undefined && progress.inviteUsersProgress < progress.inviteUsersTotal ) { fn( _t("room_settings|security|join_rule_upgrade_sending_invites", { progress: progress.inviteUsersProgress, count: progress.inviteUsersTotal, }), 2 + progress.inviteUsersProgress, total, ); } else if ( progress.updateSpacesProgress !== undefined && progress.updateSpacesProgress < progress.updateSpacesTotal ) { fn( _t("room_settings|security|join_rule_upgrade_updating_spaces", { progress: progress.updateSpacesProgress, count: progress.updateSpacesTotal, }), 2 + (progress.inviteUsersProgress ?? 0) + progress.updateSpacesProgress, total, ); } }); closeSettingsFn?.(); // switch to the new room in the background dis.dispatch({ action: Action.ViewRoom, room_id: roomId, metricsTrigger: undefined, // other }); // open new settings on this tab dis.dispatch({ action: "open_room_settings", initial_tab_id: RoomSettingsTab.Security, }); }, }); }; const upgradeRequiredPill = ( {_t("room_settings|security|join_rule_upgrade_required")} ); const withRecommendLabel = (label: string, rule: JoinRule): React.ReactNode => rule === recommendedOption ? ( <> {label} ({_t("common|recommended")}) ) : ( label ); const definitions: IDefinition[] = [ { value: JoinRule.Invite, label: withRecommendLabel(_t("room_settings|security|join_rule_invite"), JoinRule.Invite), description: _t("room_settings|security|join_rule_invite_description"), checked: joinRule === JoinRule.Invite || (joinRule === JoinRule.Restricted && !restrictedAllowRoomIds?.length), }, { value: JoinRule.Public, label: withRecommendLabel(_t("common|public"), JoinRule.Public), description: ( <> {_t("room_settings|security|join_rule_public_description")} {aliasWarning} ), }, ]; if (roomSupportsRestricted || preferredRestrictionVersion || joinRule === JoinRule.Restricted) { let description; if (joinRule === JoinRule.Restricted && restrictedAllowRoomIds?.length) { // only show the first 4 spaces we know about, so that the UI doesn't grow out of proportion there are lots. const shownSpaces = restrictedAllowRoomIds .map((roomId) => cli.getRoom(roomId)) .filter((room) => room?.isSpaceRoom()) .slice(0, 4) as Room[]; let moreText; if (shownSpaces.length < restrictedAllowRoomIds.length) { if (shownSpaces.length > 0) { moreText = _t("room_settings|security|join_rule_restricted_n_more", { count: restrictedAllowRoomIds.length - shownSpaces.length, }); } else { moreText = _t("room_settings|security|join_rule_restricted_summary", { count: restrictedAllowRoomIds.length, }); } } const onRestrictedRoomIdsChange = (newAllowRoomIds: string[]): void => { if (!arrayHasDiff(restrictedAllowRoomIds || [], newAllowRoomIds)) return; if (!newAllowRoomIds.length) { setContent({ join_rule: JoinRule.Invite, }); return; } setContent({ join_rule: JoinRule.Restricted, allow: newAllowRoomIds.map((roomId) => ({ type: RestrictedAllowType.RoomMembership, room_id: roomId, })), }); }; const onEditRestrictedClick = async (): Promise => { const restrictedAllowRoomIds = await editRestrictedRoomIds(); if (!Array.isArray(restrictedAllowRoomIds)) return; if (restrictedAllowRoomIds.length > 0) { onRestrictedRoomIdsChange(restrictedAllowRoomIds); } else { onChange(JoinRule.Invite); } }; description = (
{_t( "room_settings|security|join_rule_restricted_description", {}, { a: (sub) => ( {sub} ), }, )}

{_t("room_settings|security|join_rule_restricted_description_spaces")}

{shownSpaces.map((room) => { return ( {room.name} ); })} {moreText && {moreText}}
); } else if (SpaceStore.instance.activeSpaceRoom) { description = _t( "room_settings|security|join_rule_restricted_description_active_space", {}, { spaceName: () => {SpaceStore.instance.activeSpaceRoom!.name}, }, ); } else { description = _t("room_settings|security|join_rule_restricted_description_prompt"); } definitions.splice(1, 0, { value: JoinRule.Restricted, label: ( <> {withRecommendLabel(_t("room_settings|security|join_rule_restricted"), JoinRule.Restricted)} {preferredRestrictionVersion && upgradeRequiredPill} ), description, // if there are 0 allowed spaces then render it as invite only instead checked: joinRule === JoinRule.Restricted && !!restrictedAllowRoomIds?.length, }); } if (askToJoinEnabled && (roomSupportsKnock || preferredKnockVersion)) { definitions.splice(Math.max(0, definitions.length - 1), 0, { value: JoinRule.Knock, label: ( <> {withRecommendLabel(_t("room_settings|security|join_rule_knock"), JoinRule.Knock)} {preferredKnockVersion && upgradeRequiredPill} ), description: ( <> {_t("room_settings|security|join_rule_knock_description")} ), }); } const onChange = async (joinRule: JoinRule): Promise => { const beforeJoinRule = content?.join_rule; let restrictedAllowRoomIds: string[] | undefined; if (joinRule === JoinRule.Restricted) { if (beforeJoinRule === JoinRule.Restricted || roomSupportsRestricted) { // Have the user pick which spaces to allow joins from restrictedAllowRoomIds = await editRestrictedRoomIds(); if (!Array.isArray(restrictedAllowRoomIds)) return; } else if (preferredRestrictionVersion) { // Block this action on a room upgrade otherwise it'd make their room unjoinable const targetVersion = preferredRestrictionVersion; let warning: JSX.Element | undefined; const userId = cli.getUserId()!; const unableToUpdateSomeParents = Array.from(SpaceStore.instance.getKnownParents(room.roomId)).some( (roomId) => !cli.getRoom(roomId)?.currentState.maySendStateEvent(EventType.SpaceChild, userId), ); if (unableToUpdateSomeParents) { warning = {_t("room_settings|security|join_rule_restricted_upgrade_warning")}; } upgradeRequiredDialog( targetVersion, <> {_t("room_settings|security|join_rule_restricted_upgrade_description")} {warning} , ); return; } // when setting to 0 allowed rooms/spaces set to invite only instead as per the note if (!restrictedAllowRoomIds?.length) { joinRule = JoinRule.Invite; } } else if (joinRule === JoinRule.Knock) { if (preferredKnockVersion) { upgradeRequiredDialog(preferredKnockVersion); return; } } if (beforeJoinRule === joinRule && !restrictedAllowRoomIds) return; if (beforeChange && !(await beforeChange(joinRule))) return; const newContent: RoomJoinRulesEventContent = { join_rule: joinRule, }; // pre-set the accepted spaces with the currently viewed one as per the microcopy if (joinRule === JoinRule.Restricted) { newContent.allow = restrictedAllowRoomIds?.map((roomId) => ({ type: RestrictedAllowType.RoomMembership, room_id: roomId, })); } setContent(newContent); }; return ( (disabledOptions?.has(d.value) ? { ...d, disabled: true } : d)) .filter((d) => !hiddenOptions?.has(d.value))} disabled={disabled} className="mx_JoinRuleSettings_radioButton" /> ); }; export default JoinRuleSettings;