/* Copyright 2024 New Vector Ltd. Copyright 2019-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 from "react"; import { EventType, RoomMember, RoomState, RoomStateEvent, Room, IContent } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { throttle, get } from "lodash"; import { KnownMembership, RoomPowerLevelsEventContent } from "matrix-js-sdk/src/types"; import { _t, _td, TranslationKey } from "../../../../../languageHandler"; import AccessibleButton from "../../../elements/AccessibleButton"; import Modal from "../../../../../Modal"; import ErrorDialog from "../../../dialogs/ErrorDialog"; import PowerSelector from "../../../elements/PowerSelector"; import SettingsFieldset from "../../SettingsFieldset"; import SettingsStore from "../../../../../settings/SettingsStore"; import { VoiceBroadcastInfoEventType } from "../../../../../voice-broadcast"; import { ElementCall } from "../../../../../models/Call"; import SdkConfig, { DEFAULTS } from "../../../../../SdkConfig"; import { AddPrivilegedUsers } from "../../AddPrivilegedUsers"; import SettingsTab from "../SettingsTab"; import { SettingsSection } from "../../shared/SettingsSection"; import MatrixClientContext from "../../../../../contexts/MatrixClientContext"; import { PowerLevelSelector } from "../../PowerLevelSelector"; interface IEventShowOpts { isState?: boolean; hideForSpace?: boolean; hideForRoom?: boolean; } interface IPowerLevelDescriptor { desc: string; defaultValue: number; hideForSpace?: boolean; } const plEventsToShow: Record = { // If an event is listed here, it will be shown in the PL settings. Defaults will be calculated. [EventType.RoomAvatar]: { isState: true }, [EventType.RoomName]: { isState: true }, [EventType.RoomCanonicalAlias]: { isState: true }, [EventType.SpaceChild]: { isState: true, hideForRoom: true }, [EventType.RoomHistoryVisibility]: { isState: true, hideForSpace: true }, [EventType.RoomPowerLevels]: { isState: true }, [EventType.RoomTopic]: { isState: true }, [EventType.RoomTombstone]: { isState: true, hideForSpace: true }, [EventType.RoomEncryption]: { isState: true, hideForSpace: true }, [EventType.RoomServerAcl]: { isState: true, hideForSpace: true }, [EventType.RoomPinnedEvents]: { isState: true, hideForSpace: true }, [EventType.Reaction]: { isState: false, hideForSpace: true }, [EventType.RoomRedaction]: { isState: false, hideForSpace: true }, // MSC3401: Native Group VoIP signaling [ElementCall.CALL_EVENT_TYPE.name]: { isState: true, hideForSpace: true }, [ElementCall.MEMBER_EVENT_TYPE.name]: { isState: true, hideForSpace: true }, // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) "im.vector.modular.widgets": { isState: true, hideForSpace: true }, [VoiceBroadcastInfoEventType]: { isState: true, hideForSpace: true }, }; // parse a string as an integer; if the input is undefined, or cannot be parsed // as an integer, return a default. function parseIntWithDefault(val: string, def: number): number { const res = parseInt(val); return isNaN(res) ? def : res; } interface IBannedUserProps { canUnban?: boolean; member: RoomMember; by: string; reason?: string; } export class BannedUser extends React.Component { public static contextType = MatrixClientContext; public declare context: React.ContextType; private onUnbanClick = (): void => { this.context.unban(this.props.member.roomId, this.props.member.userId).catch((err) => { logger.error("Failed to unban: " + err); Modal.createDialog(ErrorDialog, { title: _t("common|error"), description: _t("room_settings|permissions|error_unbanning"), }); }); }; public render(): React.ReactNode { let unbanButton; if (this.props.canUnban) { unbanButton = ( {_t("action|unban")} ); } const userId = this.props.member.name === this.props.member.userId ? null : this.props.member.userId; return (
  • {unbanButton} {this.props.member.name} {userId} {this.props.reason ? " " + _t("room_settings|permissions|ban_reason") + ": " + this.props.reason : ""}
  • ); } } interface IProps { room: Room; } interface RolesRoomSettingsTabState { isRoomEncrypted: boolean; } export default class RolesRoomSettingsTab extends React.Component { public static contextType = MatrixClientContext; public declare context: React.ContextType; public constructor(props: IProps) { super(props); this.state = { isRoomEncrypted: false, }; } public async componentDidMount(): Promise { this.context.on(RoomStateEvent.Update, this.onRoomStateUpdate); this.setState({ isRoomEncrypted: (await this.context.getCrypto()?.isEncryptionEnabledInRoom(this.props.room.roomId)) || false, }); } public componentWillUnmount(): void { const client = this.context; if (client) { client.removeListener(RoomStateEvent.Update, this.onRoomStateUpdate); } } private onRoomStateUpdate = (state: RoomState): void => { if (state.roomId !== this.props.room.roomId) return; this.onThisRoomMembership(); }; private onThisRoomMembership = throttle( () => { this.forceUpdate(); }, 200, { leading: true, trailing: true }, ); private populateDefaultPlEvents( eventsSection: Record, stateLevel: number, eventsLevel: number, ): void { for (const desiredEvent of Object.keys(plEventsToShow)) { if (!(desiredEvent in eventsSection)) { eventsSection[desiredEvent] = plEventsToShow[desiredEvent].isState ? stateLevel : eventsLevel; } } } private onPowerLevelsChanged = async (value: number, powerLevelKey: string): Promise => { const client = this.context; const room = this.props.room; const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); let plContent = plEvent?.getContent() ?? {}; // Clone the power levels just in case plContent = Object.assign({}, plContent); const eventsLevelPrefix = "event_levels_"; if (powerLevelKey.startsWith(eventsLevelPrefix)) { // deep copy "events" object, Object.assign itself won't deep copy plContent["events"] = Object.assign({}, plContent["events"] || {}); plContent["events"][powerLevelKey.slice(eventsLevelPrefix.length)] = value; } else { const keyPath = powerLevelKey.split("."); let parentObj: IContent = {}; let currentObj: IContent = plContent; for (const key of keyPath) { if (!currentObj[key]) { currentObj[key] = {}; } parentObj = currentObj; currentObj = currentObj[key]; } parentObj[keyPath[keyPath.length - 1]] = value; } try { await client.sendStateEvent(this.props.room.roomId, EventType.RoomPowerLevels, plContent); } catch (e) { logger.error(e); Modal.createDialog(ErrorDialog, { title: _t("room_settings|permissions|error_changing_pl_reqs_title"), description: _t("room_settings|permissions|error_changing_pl_reqs_description"), }); // Rethrow so that the PowerSelector can roll back throw e; } }; private onUserPowerLevelChanged = async (value: number, powerLevelKey: string): Promise => { const client = this.context; const room = this.props.room; const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); let plContent = plEvent?.getContent() ?? {}; // Clone the power levels just in case plContent = Object.assign({}, plContent); // powerLevelKey should be a user ID if (!plContent["users"]) plContent["users"] = {}; plContent["users"][powerLevelKey] = value; try { await client.sendStateEvent(this.props.room.roomId, EventType.RoomPowerLevels, plContent); } catch (e) { logger.error(e); Modal.createDialog(ErrorDialog, { title: _t("room_settings|permissions|error_changing_pl_title"), description: _t("room_settings|permissions|error_changing_pl_description"), }); } }; public render(): React.ReactNode { const client = this.context; const room = this.props.room; const isSpaceRoom = room.isSpaceRoom(); const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); const plContent = plEvent ? plEvent.getContent() || {} : {}; const canChangeLevels = room.currentState.mayClientSendStateEvent(EventType.RoomPowerLevels, client); const plEventsToLabels: Record = { // These will be translated for us later. [EventType.RoomAvatar]: isSpaceRoom ? _td("room_settings|permissions|m.room.avatar_space") : _td("room_settings|permissions|m.room.avatar"), [EventType.RoomName]: isSpaceRoom ? _td("room_settings|permissions|m.room.name_space") : _td("room_settings|permissions|m.room.name"), [EventType.RoomCanonicalAlias]: isSpaceRoom ? _td("room_settings|permissions|m.room.canonical_alias_space") : _td("room_settings|permissions|m.room.canonical_alias"), [EventType.SpaceChild]: _td("room_settings|permissions|m.space.child"), [EventType.RoomHistoryVisibility]: _td("room_settings|permissions|m.room.history_visibility"), [EventType.RoomPowerLevels]: _td("room_settings|permissions|m.room.power_levels"), [EventType.RoomTopic]: isSpaceRoom ? _td("room_settings|permissions|m.room.topic_space") : _td("room_settings|permissions|m.room.topic"), [EventType.RoomTombstone]: _td("room_settings|permissions|m.room.tombstone"), [EventType.RoomEncryption]: _td("room_settings|permissions|m.room.encryption"), [EventType.RoomServerAcl]: _td("room_settings|permissions|m.room.server_acl"), [EventType.Reaction]: _td("room_settings|permissions|m.reaction"), [EventType.RoomRedaction]: _td("room_settings|permissions|m.room.redaction"), [EventType.RoomPinnedEvents]: _td("room_settings|permissions|m.room.pinned_events"), // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) "im.vector.modular.widgets": isSpaceRoom ? null : _td("room_settings|permissions|m.widget"), [VoiceBroadcastInfoEventType]: _td("room_settings|permissions|io.element.voice_broadcast_info"), }; // MSC3401: Native Group VoIP signaling if (SettingsStore.getValue("feature_group_calls")) { plEventsToLabels[ElementCall.CALL_EVENT_TYPE.name] = _td("room_settings|permissions|m.call"); plEventsToLabels[ElementCall.MEMBER_EVENT_TYPE.name] = _td("room_settings|permissions|m.call.member"); } const powerLevelDescriptors: Record = { "users_default": { desc: _t("room_settings|permissions|users_default"), defaultValue: 0, }, "events_default": { desc: _t("room_settings|permissions|events_default"), defaultValue: 0, hideForSpace: true, }, "invite": { desc: _t("room_settings|permissions|invite"), defaultValue: 0, }, "state_default": { desc: _t("room_settings|permissions|state_default"), defaultValue: 50, }, "kick": { desc: _t("room_settings|permissions|kick"), defaultValue: 50, }, "ban": { desc: _t("room_settings|permissions|ban"), defaultValue: 50, }, "redact": { desc: _t("room_settings|permissions|redact"), defaultValue: 50, hideForSpace: true, }, "notifications.room": { desc: _t("room_settings|permissions|notifications.room"), defaultValue: 50, hideForSpace: true, }, }; const eventsLevels = plContent.events || {}; const userLevels = plContent.users || {}; const banLevel = parseIntWithDefault(plContent.ban, powerLevelDescriptors.ban.defaultValue); const defaultUserLevel = parseIntWithDefault( plContent.users_default, powerLevelDescriptors.users_default.defaultValue, ); let currentUserLevel = userLevels[client.getUserId()!]; if (currentUserLevel === undefined) { currentUserLevel = defaultUserLevel; } this.populateDefaultPlEvents( eventsLevels, parseIntWithDefault(plContent.state_default, powerLevelDescriptors.state_default.defaultValue), parseIntWithDefault(plContent.events_default, powerLevelDescriptors.events_default.defaultValue), ); let privilegedUsersSection =
    {_t("room_settings|permissions|no_privileged_users")}
    ; let mutedUsersSection; if (Object.keys(userLevels).length) { privilegedUsersSection = ( userLevels[user] > defaultUserLevel} >
    {_t("room_settings|permissions|no_privileged_users")}
    ); mutedUsersSection = ( userLevels[user] < defaultUserLevel} /> ); } const banned = room.getMembersWithMembership(KnownMembership.Ban); let bannedUsersSection: JSX.Element | undefined; if (banned?.length) { const canBanUsers = currentUserLevel >= banLevel; bannedUsersSection = (
      {banned.map((member) => { const banEvent = member.events.member?.getContent(); const bannedById = member.events.member?.getSender(); const sender = bannedById ? room.getMember(bannedById) : undefined; const bannedBy = sender?.name || bannedById; // fallback to mxid return ( ); })}
    ); } const powerSelectors = Object.keys(powerLevelDescriptors) .map((key, index) => { const descriptor = powerLevelDescriptors[key]; if (isSpaceRoom && descriptor.hideForSpace) { return null; } const value = parseIntWithDefault(get(plContent, key), descriptor.defaultValue); return (
    ); }) .filter(Boolean); // hide the power level selector for enabling E2EE if it the room is already encrypted if (this.state.isRoomEncrypted) { delete eventsLevels[EventType.RoomEncryption]; } const eventPowerSelectors = Object.keys(eventsLevels) .map((eventType, i) => { if (isSpaceRoom && plEventsToShow[eventType]?.hideForSpace) { return null; } else if (!isSpaceRoom && plEventsToShow[eventType]?.hideForRoom) { return null; } const translationKeyForEvent = plEventsToLabels[eventType]; let label: string; if (translationKeyForEvent) { const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand; label = _t(translationKeyForEvent, { brand }); } else { label = _t("room_settings|permissions|send_event_type", { eventType }); } return (
    ); }) .filter(Boolean); return ( {privilegedUsersSection} {canChangeLevels && } {mutedUsersSection} {bannedUsersSection} {powerSelectors} {eventPowerSelectors} ); } }