diff --git a/crates/ruma-client-api/CHANGELOG.md b/crates/ruma-client-api/CHANGELOG.md index 66ca7643..8976ef07 100644 --- a/crates/ruma-client-api/CHANGELOG.md +++ b/crates/ruma-client-api/CHANGELOG.md @@ -4,6 +4,10 @@ Breaking changes: * Use `Raw` for `config::set_*_account_data::Request::data`. +Improvements: + +* Add support for the space summary API in `space::get_hierarchy` according to MSC2946. + # 0.13.0 Bug fixes: diff --git a/crates/ruma-client-api/src/lib.rs b/crates/ruma-client-api/src/lib.rs index 29347d3c..1ec0cc35 100644 --- a/crates/ruma-client-api/src/lib.rs +++ b/crates/ruma-client-api/src/lib.rs @@ -35,6 +35,7 @@ pub mod room; pub mod search; pub mod server; pub mod session; +pub mod space; pub mod state; pub mod sync; pub mod tag; diff --git a/crates/ruma-client-api/src/space.rs b/crates/ruma-client-api/src/space.rs new file mode 100644 index 00000000..1b1fec7b --- /dev/null +++ b/crates/ruma-client-api/src/space.rs @@ -0,0 +1,128 @@ +//! Endpoints for spaces. + +use js_int::UInt; +use ruma_common::{directory::PublicRoomJoinRule, room::RoomType}; +use ruma_events::space::child::HierarchySpaceChildStateEvent; +use ruma_identifiers::{MxcUri, RoomAliasId, RoomId, RoomName}; +use ruma_serde::Raw; +use serde::{Deserialize, Serialize}; + +pub mod get_hierarchy; + +/// A chunk of a space hierarchy response, describing one room. +/// +/// To create an instance of this type, first create a `SpaceHierarchyRoomsChunkInit` and convert it +/// via `SpaceHierarchyRoomsChunk::from` / `.into()`. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct SpaceHierarchyRoomsChunk { + /// The canonical alias of the room, if any. + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr( + feature = "compat", + serde(default, deserialize_with = "ruma_serde::empty_string_as_none") + )] + pub canonical_alias: Option>, + + /// The name of the room, if any. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option>, + + /// The number of members joined to the room. + pub num_joined_members: UInt, + + /// The ID of the room. + pub room_id: Box, + + /// The topic of the room, if any. + #[serde(skip_serializing_if = "Option::is_none")] + pub topic: Option, + + /// Whether the room may be viewed by guest users without joining. + pub world_readable: bool, + + /// Whether guest users may join the room and participate in it. + /// + /// If they can, they will be subject to ordinary power level rules like any other user. + pub guest_can_join: bool, + + /// The URL for the room's avatar, if one is set. + /// + /// If you activate the `compat` feature, this field being an empty string in JSON will result + /// in `None` here during deserialization. + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr( + feature = "compat", + serde(default, deserialize_with = "ruma_serde::empty_string_as_none") + )] + pub avatar_url: Option>, + + /// The join rule of the room. + #[serde(default, skip_serializing_if = "ruma_serde::is_default")] + pub join_rule: PublicRoomJoinRule, + + /// The type of room from `m.room.create`, if any. + pub room_type: Option, + + /// The stripped `m.space.child` events of the space-room. + /// + /// If the room is not a space-room, this should be empty. + pub children_state: Vec>, +} + +/// Initial set of mandatory fields of `SpaceHierarchyRoomsChunk`. +/// +/// This struct will not be updated even if additional fields are added to +/// `SpaceHierarchyRoomsChunk` in a new (non-breaking) release of the Matrix specification. +#[derive(Debug)] +#[allow(clippy::exhaustive_structs)] +pub struct SpaceHierarchyRoomsChunkInit { + /// The number of members joined to the room. + pub num_joined_members: UInt, + + /// The ID of the room. + pub room_id: Box, + + /// Whether the room may be viewed by guest users without joining. + pub world_readable: bool, + + /// Whether guest users may join the room and participate in it. + /// + /// If they can, they will be subject to ordinary power level rules like any other user. + pub guest_can_join: bool, + + /// The join rule of the room. + pub join_rule: PublicRoomJoinRule, + + /// The stripped `m.space.child` events of the space-room. + /// + /// If the room is not a space-room, this should be empty. + pub children_state: Vec>, +} + +impl From for SpaceHierarchyRoomsChunk { + fn from(init: SpaceHierarchyRoomsChunkInit) -> Self { + let SpaceHierarchyRoomsChunkInit { + num_joined_members, + room_id, + world_readable, + guest_can_join, + join_rule, + children_state, + } = init; + + Self { + canonical_alias: None, + name: None, + num_joined_members, + room_id, + topic: None, + world_readable, + guest_can_join, + avatar_url: None, + join_rule, + room_type: None, + children_state, + } + } +} diff --git a/crates/ruma-client-api/src/space/get_hierarchy.rs b/crates/ruma-client-api/src/space/get_hierarchy.rs new file mode 100644 index 00000000..cce491df --- /dev/null +++ b/crates/ruma-client-api/src/space/get_hierarchy.rs @@ -0,0 +1,85 @@ +//! `GET /_matrix/client/*/rooms/{roomId}/hierarchy` + +pub mod v1 { + //! `/v1/` ([spec]) + //! + //! [spec]: https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv1roomsroomidhierarchy + + use js_int::UInt; + use ruma_api::ruma_api; + use ruma_identifiers::RoomId; + + use crate::space::SpaceHierarchyRoomsChunk; + + ruma_api! { + metadata: { + description: "Paginates over the space tree in a depth-first manner to locate child rooms of a given space.", + method: GET, + name: "hierarchy", + unstable_path: "/_matrix/client/unstable/org.matrix.msc2946/rooms/:room_id/hierarchy", + stable_path: "/_matrix/client/v1/rooms/:room_id/hierarchy", + rate_limited: true, + authentication: AccessToken, + added: 1.2, + } + + request: { + /// The room ID of the space to get a hierarchy for. + #[ruma_api(path)] + pub room_id: &'a RoomId, + + /// A pagination token from a previous result. + /// + /// If specified, `max_depth` and `suggested_only` cannot be changed from the first request. + #[ruma_api(query)] + pub from: Option<&'a str>, + + /// The maximum number of rooms to include per response. + #[ruma_api(query)] + pub limit: Option, + + /// How far to go into the space. + /// + /// When reached, no further child rooms will be returned. + #[ruma_api(query)] + pub max_depth: Option, + + /// Whether or not the server should only consider suggested rooms. + /// + /// Suggested rooms are annotated in their `m.space.child` event contents. + /// + /// Defaults to `false`. + #[ruma_api(query)] + #[serde(default, skip_serializing_if = "ruma_serde::is_default")] + pub suggested_only: bool, + } + + #[derive(Default)] + response: { + /// A token to supply to from to keep paginating the responses. + /// + /// Not present when there are no further results. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_batch: Option, + + /// A paginated chunk of the space children. + pub chunk: Vec, + } + + error: crate::Error + } + + impl<'a> Request<'a> { + /// Creates a new `Request` with the given room ID. + pub fn new(room_id: &'a RoomId) -> Self { + Self { room_id, from: None, limit: None, max_depth: None, suggested_only: false } + } + } + + impl Response { + /// Creates an empty `Response`. + pub fn new() -> Self { + Default::default() + } + } +} diff --git a/crates/ruma-events/src/lib.rs b/crates/ruma-events/src/lib.rs index 71ce2d69..561feed7 100644 --- a/crates/ruma-events/src/lib.rs +++ b/crates/ruma-events/src/lib.rs @@ -357,6 +357,9 @@ pub enum EventKind { /// Presence event kind. Presence, + + /// Hierarchy space child kind. + HierarchySpaceChild, } /// `HasDeserializeFields` is used in the code generated by the `Event` derive diff --git a/crates/ruma-events/src/space/child.rs b/crates/ruma-events/src/space/child.rs index 22adbe8e..e4db50bf 100644 --- a/crates/ruma-events/src/space/child.rs +++ b/crates/ruma-events/src/space/child.rs @@ -2,8 +2,9 @@ //! //! [`m.space.child`]: https://spec.matrix.org/v1.2/client-server-api/#mspacechild -use ruma_identifiers::ServerName; -use ruma_macros::EventContent; +use ruma_common::MilliSecondsSinceUnixEpoch; +use ruma_identifiers::{ServerName, UserId}; +use ruma_macros::{Event, EventContent}; use serde::{Deserialize, Serialize}; /// The content of an `m.space.child` event. @@ -50,12 +51,33 @@ impl SpaceChildEventContent { } } +/// An `m.space.child` event represented as a Stripped State Event with an added `origin_server_ts` +/// key. +#[derive(Clone, Debug, Event)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct HierarchySpaceChildStateEvent { + /// The content of the space child event. + pub content: SpaceChildEventContent, + + /// The fully-qualified ID of the user who sent this event. + pub sender: Box, + + /// The room ID of the child. + pub state_key: String, + + /// Timestamp in milliseconds on originating homeserver when this event was sent. + pub origin_server_ts: MilliSecondsSinceUnixEpoch, +} + #[cfg(test)] mod tests { - use ruma_identifiers::server_name; - use serde_json::{json, to_value as to_json_value}; + use js_int::uint; + use matches::assert_matches; + use ruma_common::MilliSecondsSinceUnixEpoch; + use ruma_identifiers::{server_name, user_id}; + use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; - use super::SpaceChildEventContent; + use super::{HierarchySpaceChildStateEvent, SpaceChildEventContent}; #[test] fn space_child_serialization() { @@ -82,4 +104,63 @@ mod tests { assert_eq!(to_json_value(&content).unwrap(), json); } + + #[test] + fn hierarchy_space_child_serialization() { + let event = HierarchySpaceChildStateEvent { + content: SpaceChildEventContent { + via: Some(vec![server_name!("example.com").to_owned()]), + order: Some("uwu".to_owned()), + suggested: None, + }, + sender: user_id!("@example:localhost").to_owned(), + state_key: "!child:localhost".to_owned(), + origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(1_629_413_349)), + }; + + let json = json!({ + "content": { + "via": ["example.com"], + "order": "uwu", + }, + "sender": "@example:localhost", + "state_key": "!child:localhost", + "origin_server_ts": 1_629_413_349, + "type": "m.space.child", + }); + + assert_eq!(to_json_value(&event).unwrap(), json); + } + + #[test] + fn hierarchy_space_child_deserialization() { + let json = json!({ + "content": { + "via": [ + "example.org" + ] + }, + "origin_server_ts": 1_629_413_349, + "sender": "@alice:example.org", + "state_key": "!a:example.org", + "type": "m.space.child" + }); + + assert_matches!( + from_json_value::(json).unwrap(), + HierarchySpaceChildStateEvent { + content: SpaceChildEventContent { + via: Some(via), + order: None, + suggested: None, + }, + origin_server_ts, + sender, + state_key, + } if via[0] == "example.org" + && origin_server_ts.get() == uint!(1_629_413_349) + && sender == "@alice:example.org" + && state_key == "!a:example.org" + ); + } } diff --git a/crates/ruma-macros/src/events/event_content.rs b/crates/ruma-macros/src/events/event_content.rs index 5b178c93..b4789d47 100644 --- a/crates/ruma-macros/src/events/event_content.rs +++ b/crates/ruma-macros/src/events/event_content.rs @@ -394,7 +394,10 @@ fn generate_marker_trait_impl( EventKind::MessageLike => quote! { MessageLikeEventContent }, EventKind::State => quote! { StateEventContent }, EventKind::ToDevice => quote! { ToDeviceEventContent }, - EventKind::RoomRedaction | EventKind::Presence | EventKind::Decrypted => { + EventKind::RoomRedaction + | EventKind::Presence + | EventKind::Decrypted + | EventKind::HierarchySpaceChild => { return Err(syn::Error::new_spanned( ident, "valid event kinds are GlobalAccountData, RoomAccountData, \ @@ -454,7 +457,10 @@ fn generate_static_event_content_impl( EventKind::MessageLike => quote! { MessageLike { redacted: #redacted } }, EventKind::State => quote! { State { redacted: #redacted } }, EventKind::ToDevice => quote! { ToDevice }, - EventKind::RoomRedaction | EventKind::Presence | EventKind::Decrypted => { + EventKind::RoomRedaction + | EventKind::Presence + | EventKind::Decrypted + | EventKind::HierarchySpaceChild => { unreachable!("not a valid event content kind") } }; diff --git a/crates/ruma-macros/src/events/event_parse.rs b/crates/ruma-macros/src/events/event_parse.rs index 2c50cddd..e7c988f0 100644 --- a/crates/ruma-macros/src/events/event_parse.rs +++ b/crates/ruma-macros/src/events/event_parse.rs @@ -85,6 +85,7 @@ pub enum EventKind { ToDevice, RoomRedaction, Presence, + HierarchySpaceChild, Decrypted, } @@ -99,6 +100,7 @@ impl fmt::Display for EventKind { EventKind::ToDevice => write!(f, "ToDeviceEvent"), EventKind::RoomRedaction => write!(f, "RoomRedactionEvent"), EventKind::Presence => write!(f, "PresenceEvent"), + EventKind::HierarchySpaceChild => write!(f, "HierarchySpaceChildStateEvent"), EventKind::Decrypted => unreachable!(), } } @@ -204,6 +206,9 @@ pub fn to_kind_variation(ident: &Ident) -> Option<(EventKind, EventKindVariation "RedactedSyncStateEvent" => Some((EventKind::State, EventKindVariation::RedactedSync)), "ToDeviceEvent" => Some((EventKind::ToDevice, EventKindVariation::Full)), "PresenceEvent" => Some((EventKind::Presence, EventKindVariation::Full)), + "HierarchySpaceChildStateEvent" => { + Some((EventKind::HierarchySpaceChild, EventKindVariation::Stripped)) + } "RoomRedactionEvent" => Some((EventKind::RoomRedaction, EventKindVariation::Full)), "SyncRoomRedactionEvent" => Some((EventKind::RoomRedaction, EventKindVariation::Sync)), "RedactedRoomRedactionEvent" => { diff --git a/crates/ruma-macros/src/events/event_type.rs b/crates/ruma-macros/src/events/event_type.rs index fb5ccc5f..60a82ee2 100644 --- a/crates/ruma-macros/src/events/event_type.rs +++ b/crates/ruma-macros/src/events/event_type.rs @@ -31,7 +31,10 @@ pub fn expand_event_type_enum( room.push(&event.events); } EventKind::ToDevice => to_device.push(&event.events), - EventKind::RoomRedaction | EventKind::Presence | EventKind::Decrypted => {} + EventKind::RoomRedaction + | EventKind::Presence + | EventKind::Decrypted + | EventKind::HierarchySpaceChild => {} } } let presence = vec![EventEnumEntry {