diff --git a/Cargo.lock b/Cargo.lock index 741420e7d..627e5512f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2985,6 +2985,7 @@ dependencies = [ "image", "imbl", "indexmap 2.2.2", + "js_int", "language-tags", "mas-oidc-client", "matrix-sdk-base", diff --git a/bindings/matrix-sdk-ffi/src/room.rs b/bindings/matrix-sdk-ffi/src/room.rs index fe1be0b4d..a80924a8a 100644 --- a/bindings/matrix-sdk-ffi/src/room.rs +++ b/bindings/matrix-sdk-ffi/src/room.rs @@ -1,14 +1,17 @@ use std::{convert::TryFrom, sync::Arc}; use anyhow::{Context, Result}; -use matrix_sdk::{room::Room as SdkRoom, RoomMemberships, RoomNotableTags, RoomState}; +use matrix_sdk::{ + room::{power_levels::RoomPowerLevelChanges, Room as SdkRoom}, + RoomMemberships, RoomNotableTags, RoomState, +}; use matrix_sdk_ui::timeline::RoomExt; use mime::Mime; use ruma::{ api::client::room::report_content, assign, events::room::{avatar::ImageInfo as RumaAvatarImageInfo, MediaSource}, - EventId, UserId, + EventId, Int, UserId, }; use tokio::sync::RwLock; use tracing::error; @@ -564,6 +567,35 @@ impl Room { self.mark_as_read().await } + + pub async fn build_power_level_changes_from_current( + &self, + ) -> Result { + let power_levels = self.inner.room_power_levels().await?; + Ok(power_levels.into()) + } + + pub async fn apply_power_level_changes( + &self, + changes: RoomPowerLevelChanges, + ) -> Result<(), ClientError> { + self.inner.apply_power_level_changes(changes).await?; + Ok(()) + } + + pub async fn update_power_level_for_user( + &self, + user_id: String, + power_level: i64, + ) -> Result<(), ClientError> { + let user_id = UserId::parse(&user_id)?; + let power_level = Int::new(power_level).context("Invalid power level")?; + self.inner + .update_power_levels(vec![(&user_id, power_level)]) + .await + .map_err(|e| ClientError::Generic { msg: e.to_string() })?; + Ok(()) + } } #[uniffi::export(callback_interface)] diff --git a/crates/matrix-sdk/Cargo.toml b/crates/matrix-sdk/Cargo.toml index 144696b2e..73b6f54d5 100644 --- a/crates/matrix-sdk/Cargo.toml +++ b/crates/matrix-sdk/Cargo.toml @@ -84,6 +84,7 @@ http = { workspace = true } hyper = { version = "0.14.20", features = ["http1", "http2", "server"], optional = true } imbl = { version = "2.0.0", features = ["serde"] } indexmap = "2.0.2" +js_int = "0.2.2" language-tags = { version = "0.3.2", optional = true } mas-oidc-client = { version = "0.7.0", optional = true } matrix-sdk-base = { workspace = true, features = ["uniffi"] } diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index 89d0bc3f2..b4a607bfd 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -81,6 +81,7 @@ use crate::{ event_handler::{EventHandler, EventHandlerDropGuard, EventHandlerHandle, SyncEvent}, media::{MediaFormat, MediaRequest}, notification_settings::{IsEncrypted, IsOneToOne, RoomNotificationMode}, + room::power_levels::{RoomPowerLevelChanges, RoomPowerLevelsExt}, sync::RoomUpdate, utils::{IntoRawMessageLikeEventContent, IntoRawStateEventContent}, BaseRoom, Client, Error, HttpError, HttpResult, Result, RoomState, TransmissionProgress, @@ -89,6 +90,7 @@ use crate::{ pub mod futures; mod member; mod messages; +pub mod power_levels; pub use self::{ member::{RoomMember, RoomMemberRole}, @@ -1720,7 +1722,7 @@ impl Room { &self, updates: Vec<(&UserId, Int)>, ) -> Result { - let mut power_levels = self.get_room_power_levels().await?; + let mut power_levels = self.room_power_levels().await?; for (user_id, new_level) in updates { if new_level == power_levels.users_default { @@ -1733,7 +1735,19 @@ impl Room { self.send_state_event(RoomPowerLevelsEventContent::from(power_levels)).await } - async fn get_room_power_levels(&self) -> Result { + /// Applies a set of power level changes to this room. + /// + /// Any values that are `None` in the given `RoomPowerLevelChanges` will + /// remain unchanged. + pub async fn apply_power_level_changes(&self, changes: RoomPowerLevelChanges) -> Result<()> { + let mut power_levels = self.room_power_levels().await?; + power_levels.apply(changes)?; + self.send_state_event(RoomPowerLevelsEventContent::from(power_levels)).await?; + Ok(()) + } + + /// Get the current power levels of this room. + pub async fn room_power_levels(&self) -> Result { Ok(self .get_state_event_static::() .await? @@ -2015,7 +2029,7 @@ impl Room { /// /// The call may fail if there is an error in getting the power levels. pub async fn can_user_redact_own(&self, user_id: &UserId) -> Result { - Ok(self.get_room_power_levels().await?.user_can_redact_own_event(user_id)) + Ok(self.room_power_levels().await?.user_can_redact_own_event(user_id)) } /// Returns true if the user with the given user_id is able to redact @@ -2023,7 +2037,7 @@ impl Room { /// /// The call may fail if there is an error in getting the power levels. pub async fn can_user_redact_other(&self, user_id: &UserId) -> Result { - Ok(self.get_room_power_levels().await?.user_can_redact_event_of_other(user_id)) + Ok(self.room_power_levels().await?.user_can_redact_event_of_other(user_id)) } /// Returns true if the user with the given user_id is able to ban in the @@ -2031,7 +2045,7 @@ impl Room { /// /// The call may fail if there is an error in getting the power levels. pub async fn can_user_ban(&self, user_id: &UserId) -> Result { - Ok(self.get_room_power_levels().await?.user_can_ban(user_id)) + Ok(self.room_power_levels().await?.user_can_ban(user_id)) } /// Returns true if the user with the given user_id is able to kick in the @@ -2039,7 +2053,7 @@ impl Room { /// /// The call may fail if there is an error in getting the power levels. pub async fn can_user_invite(&self, user_id: &UserId) -> Result { - Ok(self.get_room_power_levels().await?.user_can_invite(user_id)) + Ok(self.room_power_levels().await?.user_can_invite(user_id)) } /// Returns true if the user with the given user_id is able to kick in the @@ -2047,7 +2061,7 @@ impl Room { /// /// The call may fail if there is an error in getting the power levels. pub async fn can_user_kick(&self, user_id: &UserId) -> Result { - Ok(self.get_room_power_levels().await?.user_can_kick(user_id)) + Ok(self.room_power_levels().await?.user_can_kick(user_id)) } /// Returns true if the user with the given user_id is able to send a @@ -2059,7 +2073,7 @@ impl Room { user_id: &UserId, state_event: StateEventType, ) -> Result { - Ok(self.get_room_power_levels().await?.user_can_send_state(user_id, state_event)) + Ok(self.room_power_levels().await?.user_can_send_state(user_id, state_event)) } /// Returns true if the user with the given user_id is able to send a @@ -2071,7 +2085,7 @@ impl Room { user_id: &UserId, message: MessageLikeEventType, ) -> Result { - Ok(self.get_room_power_levels().await?.user_can_send_message(user_id, message)) + Ok(self.room_power_levels().await?.user_can_send_message(user_id, message)) } /// Returns true if the user with the given user_id is able to trigger a @@ -2079,7 +2093,7 @@ impl Room { /// /// The call may fail if there is an error in getting the power levels. pub async fn can_user_trigger_room_notification(&self, user_id: &UserId) -> Result { - Ok(self.get_room_power_levels().await?.user_can_trigger_room_notification(user_id)) + Ok(self.room_power_levels().await?.user_can_trigger_room_notification(user_id)) } /// Get a list of servers that should know this room. diff --git a/crates/matrix-sdk/src/room/power_levels.rs b/crates/matrix-sdk/src/room/power_levels.rs new file mode 100644 index 000000000..9d1986762 --- /dev/null +++ b/crates/matrix-sdk/src/room/power_levels.rs @@ -0,0 +1,305 @@ +//! Power level configuration types used in [the `room` module][super]. + +use ruma::events::{room::power_levels::RoomPowerLevels, StateEventType}; + +use crate::Result; + +/// A set of common power levels required for various operations within a room, +/// that can be applied as a single operation. When updating these +/// settings, any levels that are `None` will remain unchanged. +#[derive(Debug)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct RoomPowerLevelChanges { + // Actions + /// The level required to ban a user. + #[cfg_attr(feature = "uniffi", uniffi(default = None))] + pub ban: Option, + /// The level required to invite a user. + #[cfg_attr(feature = "uniffi", uniffi(default = None))] + pub invite: Option, + /// The level required to kick a user. + #[cfg_attr(feature = "uniffi", uniffi(default = None))] + pub kick: Option, + /// The level required to redact an event. + #[cfg_attr(feature = "uniffi", uniffi(default = None))] + pub redact: Option, + + // Events + /// The default level required to send message events. + #[cfg_attr(feature = "uniffi", uniffi(default = None))] + pub events_default: Option, + /// The default level required to send state events. + #[cfg_attr(feature = "uniffi", uniffi(default = None))] + pub state_default: Option, + /// The default power level for every user in the room. + #[cfg_attr(feature = "uniffi", uniffi(default = None))] + pub users_default: Option, + /// The level required to change the room's name. + #[cfg_attr(feature = "uniffi", uniffi(default = None))] + pub room_name: Option, + /// The level required to change the room's avatar. + #[cfg_attr(feature = "uniffi", uniffi(default = None))] + pub room_avatar: Option, + /// The level required to change the room's topic. + #[cfg_attr(feature = "uniffi", uniffi(default = None))] + pub room_topic: Option, +} + +impl RoomPowerLevelChanges { + /// Constructs an empty set of `RoomPowerLevelChanges`. + pub fn new() -> Self { + Self { + ban: None, + invite: None, + kick: None, + redact: None, + events_default: None, + state_default: None, + users_default: None, + room_name: None, + room_avatar: None, + room_topic: None, + } + } +} + +impl Default for RoomPowerLevelChanges { + fn default() -> Self { + Self::new() + } +} + +impl From for RoomPowerLevelChanges { + fn from(value: RoomPowerLevels) -> Self { + Self { + ban: Some(value.ban.into()), + invite: Some(value.invite.into()), + kick: Some(value.kick.into()), + redact: Some(value.redact.into()), + events_default: Some(value.events_default.into()), + state_default: Some(value.state_default.into()), + users_default: Some(value.users_default.into()), + room_name: value + .events + .get(&StateEventType::RoomName.into()) + .map(|v| (*v).into()) + .or(Some(value.state_default.into())), + room_avatar: value + .events + .get(&StateEventType::RoomAvatar.into()) + .map(|v| (*v).into()) + .or(Some(value.state_default.into())), + room_topic: value + .events + .get(&StateEventType::RoomTopic.into()) + .map(|v| (*v).into()) + .or(Some(value.state_default.into())), + } + } +} + +pub(crate) trait RoomPowerLevelsExt { + /// Applies the updated settings to the power levels. Any levels that are + /// `None` will remain unchanged. Unlike with members, we don't remove the + /// event if the new level matches the default as this could result in + /// unintended privileges when updating the default power level in + /// isolation of the others. + fn apply(&mut self, settings: RoomPowerLevelChanges) -> Result<()>; +} + +impl RoomPowerLevelsExt for RoomPowerLevels { + fn apply(&mut self, settings: RoomPowerLevelChanges) -> Result<()> { + if let Some(ban) = settings.ban { + self.ban = ban.try_into()?; + } + if let Some(invite) = settings.invite { + self.invite = invite.try_into()?; + } + if let Some(kick) = settings.kick { + self.kick = kick.try_into()?; + } + if let Some(redact) = settings.redact { + self.redact = redact.try_into()?; + } + if let Some(events_default) = settings.events_default { + self.events_default = events_default.try_into()?; + } + if let Some(state_default) = settings.state_default { + self.state_default = state_default.try_into()?; + } + if let Some(users_default) = settings.users_default { + self.users_default = users_default.try_into()?; + } + if let Some(room_name) = settings.room_name { + self.events.insert(StateEventType::RoomName.into(), room_name.try_into()?); + } + if let Some(room_avatar) = settings.room_avatar { + self.events.insert(StateEventType::RoomAvatar.into(), room_avatar.try_into()?); + } + if let Some(room_topic) = settings.room_topic { + self.events.insert(StateEventType::RoomTopic.into(), room_topic.try_into()?); + } + + Ok(()) + } +} + +impl From for crate::error::Error { + fn from(e: js_int::TryFromIntError) -> Self { + crate::error::Error::UnknownError(Box::new(e)) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use ruma::{ + events::room::power_levels::{RoomPowerLevels, RoomPowerLevelsEventContent}, + int, + power_levels::NotificationPowerLevels, + }; + + use super::*; + + #[test] + fn test_apply_actions() { + // Given a set of power levels and some settings that only change the + // actions. + let mut power_levels = default_power_levels(); + + let new_level = int!(100); + let settings = RoomPowerLevelChanges { + ban: Some(new_level.into()), + invite: Some(new_level.into()), + kick: Some(new_level.into()), + redact: Some(new_level.into()), + events_default: None, + state_default: None, + users_default: None, + room_name: None, + room_avatar: None, + room_topic: None, + }; + + // When applying the settings to the power levels. + let original_levels = power_levels.clone(); + power_levels.apply(settings).unwrap(); + + // Then the levels for the actions should be updated. + assert_eq!(power_levels.ban, new_level); + assert_eq!(power_levels.invite, new_level); + assert_eq!(power_levels.kick, new_level); + assert_eq!(power_levels.redact, new_level); + // And the rest should remain unchanged. + assert_eq!(power_levels.events_default, original_levels.events_default); + assert_eq!(power_levels.state_default, original_levels.state_default); + assert_eq!(power_levels.users_default, original_levels.users_default); + assert_eq!(power_levels.events, original_levels.events); + } + + #[test] + fn test_apply_room_settings() { + // Given a set of power levels and some settings that only change the specific + // state event levels. + let mut power_levels = default_power_levels(); + + let new_level = int!(100); + let settings = RoomPowerLevelChanges { + ban: None, + invite: None, + kick: None, + redact: None, + events_default: None, + state_default: None, + users_default: None, + room_name: Some(new_level.into()), + room_avatar: Some(new_level.into()), + room_topic: Some(new_level.into()), + }; + + // When applying the settings to the power levels. + let original_levels = power_levels.clone(); + power_levels.apply(settings).unwrap(); + + // Then levels for the necessary state events should be added. + assert_eq!( + power_levels.events, + BTreeMap::from_iter(vec![ + (StateEventType::RoomName.into(), new_level), + (StateEventType::RoomAvatar.into(), new_level), + (StateEventType::RoomTopic.into(), new_level), + ]) + ); + // And the rest should remain unchanged. + assert_eq!(power_levels.ban, original_levels.ban); + assert_eq!(power_levels.invite, original_levels.invite); + assert_eq!(power_levels.kick, original_levels.kick); + assert_eq!(power_levels.redact, original_levels.redact); + assert_eq!(power_levels.events_default, original_levels.events_default); + assert_eq!(power_levels.state_default, original_levels.state_default); + assert_eq!(power_levels.users_default, original_levels.users_default); + } + + #[test] + fn test_apply_state_event_to_default() { + // Given a set of power levels and some settings that change the room name level + // back to the default level. + let original_level = int!(100); + let mut power_levels = default_power_levels(); + power_levels.events = BTreeMap::from_iter(vec![ + (StateEventType::RoomName.into(), original_level), + (StateEventType::RoomAvatar.into(), original_level), + (StateEventType::RoomTopic.into(), original_level), + ]); + + let settings = RoomPowerLevelChanges { + ban: None, + invite: None, + kick: None, + redact: None, + events_default: None, + state_default: None, + users_default: None, + room_name: Some(power_levels.state_default.into()), + room_avatar: None, + room_topic: None, + }; + + // When applying the settings to the power levels. + let original_levels = power_levels.clone(); + power_levels.apply(settings).unwrap(); + + // Then the room name level should be updated (but not removed) without + // affecting any other state events. + assert_eq!( + power_levels.events, + BTreeMap::from_iter(vec![ + (StateEventType::RoomName.into(), power_levels.state_default), + (StateEventType::RoomAvatar.into(), original_level), + (StateEventType::RoomTopic.into(), original_level), + ]) + ); + // And the rest should remain unchanged. + assert_eq!(power_levels.ban, original_levels.ban); + assert_eq!(power_levels.invite, original_levels.invite); + assert_eq!(power_levels.kick, original_levels.kick); + assert_eq!(power_levels.redact, original_levels.redact); + assert_eq!(power_levels.events_default, original_levels.events_default); + assert_eq!(power_levels.state_default, original_levels.state_default); + assert_eq!(power_levels.users_default, original_levels.users_default); + } + + fn default_power_levels() -> RoomPowerLevels { + let mut content = RoomPowerLevelsEventContent::new(); + content.ban = int!(50); + content.invite = int!(50); + content.kick = int!(50); + content.redact = int!(50); + content.events_default = int!(0); + content.state_default = int!(50); + content.users_default = int!(0); + content.notifications = NotificationPowerLevels::default(); + content.into() + } +}