Merge remote-tracking branch 'upstream/main' into conduwuit-changes
This commit is contained in:
		
						commit
						9a5bfad849
					
				| @ -137,6 +137,13 @@ use super::MyType; | ||||
| 
 | ||||
| ### Commit Messages | ||||
| 
 | ||||
| The commit message should start with the _area_ that is affected by the change. | ||||
| An area is usually the name of the affected crate without the `ruma-` prefix, | ||||
| except for the ruma-common crate, where the area is usually the name of the | ||||
| top-level module, like `api` or `identifiers`. For example, the description of | ||||
| a commit that affects the ruma-events crate should look like | ||||
| "events: Add new event". | ||||
| 
 | ||||
| Write commit messages using the imperative mood, as if completing the sentence: | ||||
| "If applied, this commit will \_\_\_." For example, use "Fix some bug" instead | ||||
| of "Fixed some bug" or "Add a feature" instead of "Added a feature". | ||||
|  | ||||
| @ -33,7 +33,7 @@ pub mod v1 { | ||||
| 
 | ||||
|         /// One or more custom fields to help identify the third party location.
 | ||||
|         // The specification is incorrect for this parameter. See [matrix-spec#560](https://github.com/matrix-org/matrix-spec/issues/560).
 | ||||
|         #[ruma_api(query_map)] | ||||
|         #[ruma_api(query_all)] | ||||
|         pub fields: BTreeMap<String, String>, | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -34,7 +34,7 @@ pub mod v1 { | ||||
| 
 | ||||
|         /// One or more custom fields that are passed to the AS to help identify the user.
 | ||||
|         // The specification is incorrect for this parameter. See [matrix-spec#560](https://github.com/matrix-org/matrix-spec/issues/560).
 | ||||
|         #[ruma_api(query_map)] | ||||
|         #[ruma_api(query_all)] | ||||
|         pub fields: BTreeMap<String, String>, | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -6,6 +6,7 @@ Breaking changes: | ||||
|   as before. | ||||
| - Change type of `client_secret` field in `ThirdpartyIdCredentials` | ||||
|   from `Box<ClientSecret>` to `OwnedClientSecret` | ||||
| - Make `id_server` and `id_access_token` in `ThirdpartyIdCredentials` optional | ||||
| 
 | ||||
| Improvements: | ||||
| 
 | ||||
| @ -13,15 +14,23 @@ Improvements: | ||||
| - Heroes in `sync::sync_events::v4`: `SyncRequestList` and `RoomSubscription` | ||||
|   both have a new `include_heroes` field. `SlidingSyncRoom` has a new `heroes` | ||||
|   field, with a new type `SlidingSyncRoomHero`. | ||||
| - Add unstable support for authenticated media endpoints, according to MSC3916. | ||||
| 
 | ||||
| Bug fixes: | ||||
| 
 | ||||
| - Rename `avatar` to `avatar_url` when (De)serializing | ||||
| - Add support for authenticated media endpoints, according to MSC3916 / Matrix | ||||
|   1.11. | ||||
|   - They replace the newly deprecated `media::get_*` endpoints. | ||||
| - Stabilize support for animated thumbnails, according to Matrix 1.11 | ||||
| - Add support for terms of service at registration, according to MSC1692 / | ||||
|   Matrix 1.11 | ||||
| - Add unstable support for [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) | ||||
|   to send `Future` events and update `Future` events with `future_tokens`. | ||||
|   (`Future` events are scheduled messages that can be controlled | ||||
|   with `future_tokens` to send on demand or restart the timeout) | ||||
| 
 | ||||
| Bug fixes: | ||||
| 
 | ||||
| - Rename `avatar` to `avatar_url` when (De)serializing `SlidingSyncRoomHero` | ||||
| - `user_id` of `SlidingSyncRoomHero` is now mandatory | ||||
| - Make authentication with access token optional for the `change_password` and | ||||
|   `deactivate` endpoints. | ||||
| 
 | ||||
| # 0.18.0 | ||||
| 
 | ||||
|  | ||||
| @ -40,7 +40,6 @@ unstable-exhaustive-types = ["ruma-common/unstable-exhaustive-types"] | ||||
| unstable-msc2666 = [] | ||||
| unstable-msc2448 = [] | ||||
| unstable-msc2654 = [] | ||||
| unstable-msc2705 = [] | ||||
| unstable-msc2965 = [] | ||||
| unstable-msc2967 = [] | ||||
| unstable-msc3266 = [] | ||||
| @ -48,10 +47,10 @@ unstable-msc3488 = [] | ||||
| unstable-msc3575 = [] | ||||
| unstable-msc3814 = [] | ||||
| unstable-msc3843 = [] | ||||
| unstable-msc3916 = [] | ||||
| unstable-msc3983 = [] | ||||
| unstable-msc4108 = [] | ||||
| unstable-msc4121 = [] | ||||
| unstable-msc4140 = [] | ||||
| 
 | ||||
| [dependencies] | ||||
| as_variant = { workspace = true } | ||||
|  | ||||
| @ -17,7 +17,7 @@ pub mod v3 { | ||||
|     const METADATA: Metadata = metadata! { | ||||
|         method: POST, | ||||
|         rate_limited: true, | ||||
|         authentication: AccessToken, | ||||
|         authentication: AccessTokenOptional, | ||||
|         history: { | ||||
|             1.0 => "/_matrix/client/r0/account/password", | ||||
|             1.1 => "/_matrix/client/v3/account/password", | ||||
|  | ||||
| @ -20,7 +20,7 @@ pub mod v3 { | ||||
|     const METADATA: Metadata = metadata! { | ||||
|         method: POST, | ||||
|         rate_limited: true, | ||||
|         authentication: AccessToken, | ||||
|         authentication: AccessTokenOptional, | ||||
|         history: { | ||||
|             1.0 => "/_matrix/client/r0/account/deactivate", | ||||
|             1.1 => "/_matrix/client/v3/account/deactivate", | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| //! Authenticated endpoints for the media repository, according to [MSC3916].
 | ||||
| //! Authenticated endpoints for the [content repository].
 | ||||
| //!
 | ||||
| //! [MSC3916]: https://github.com/matrix-org/matrix-spec-proposals/pull/3916
 | ||||
| //! [content repository]: https://spec.matrix.org/latest/client-server-api/#content-repository
 | ||||
| 
 | ||||
| pub mod get_content; | ||||
| pub mod get_content_as_filename; | ||||
|  | ||||
| @ -2,10 +2,10 @@ | ||||
| //!
 | ||||
| //! Retrieve content from the media store.
 | ||||
| 
 | ||||
| pub mod unstable { | ||||
|     //! `/unstable/org.matrix.msc3916/` ([MSC])
 | ||||
| pub mod v1 { | ||||
|     //! `/v1/` ([spec])
 | ||||
|     //!
 | ||||
|     //! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/3916
 | ||||
|     //! [spec]: https://spec.matrix.org/latest/client-server-api/#get_matrixclientv1mediadownloadservernamemediaid
 | ||||
| 
 | ||||
|     use std::time::Duration; | ||||
| 
 | ||||
| @ -17,10 +17,11 @@ pub mod unstable { | ||||
| 
 | ||||
|     const METADATA: Metadata = metadata! { | ||||
|         method: GET, | ||||
|         rate_limited: false, | ||||
|         rate_limited: true, | ||||
|         authentication: AccessToken, | ||||
|         history: { | ||||
|             unstable => "/_matrix/client/unstable/org.matrix.msc3916/media/download/:server_name/:media_id", | ||||
|             1.11 => "/_matrix/client/v1/media/download/:server_name/:media_id", | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|  | ||||
| @ -2,10 +2,10 @@ | ||||
| //!
 | ||||
| //! Retrieve content from the media store, specifying a filename to return.
 | ||||
| 
 | ||||
| pub mod unstable { | ||||
|     //! `/unstable/org.matrix.msc3916/` ([MSC])
 | ||||
| pub mod v1 { | ||||
|     //! `/v1/` ([spec])
 | ||||
|     //!
 | ||||
|     //! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/3916
 | ||||
|     //! [spec]: https://spec.matrix.org/latest/client-server-api/#get_matrixclientv1mediadownloadservernamemediaidfilename
 | ||||
| 
 | ||||
|     use std::time::Duration; | ||||
| 
 | ||||
| @ -17,10 +17,11 @@ pub mod unstable { | ||||
| 
 | ||||
|     const METADATA: Metadata = metadata! { | ||||
|         method: GET, | ||||
|         rate_limited: false, | ||||
|         rate_limited: true, | ||||
|         authentication: AccessToken, | ||||
|         history: { | ||||
|             unstable => "/_matrix/client/unstable/org.matrix.msc3916/media/download/:server_name/:media_id/:filename", | ||||
|             1.11 => "/_matrix/client/v1/media/download/:server_name/:media_id/:filename", | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|  | ||||
| @ -2,10 +2,10 @@ | ||||
| //!
 | ||||
| //! Get a thumbnail of content from the media store.
 | ||||
| 
 | ||||
| pub mod unstable { | ||||
|     //! `/unstable/org.matrix.msc3916/` ([MSC])
 | ||||
| pub mod v1 { | ||||
|     //! `/v1/` ([spec])
 | ||||
|     //!
 | ||||
|     //! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/3916
 | ||||
|     //! [spec]: https://spec.matrix.org/latest/client-server-api/#get_matrixclientv1mediathumbnailservernamemediaid
 | ||||
| 
 | ||||
|     use std::time::Duration; | ||||
| 
 | ||||
| @ -24,6 +24,7 @@ pub mod unstable { | ||||
|         authentication: AccessToken, | ||||
|         history: { | ||||
|             unstable => "/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail/:server_name/:media_id", | ||||
|             1.11 => "/_matrix/client/v1/media/thumbnail/:server_name/:media_id", | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
| @ -69,18 +70,12 @@ pub mod unstable { | ||||
| 
 | ||||
|         /// Whether the server should return an animated thumbnail.
 | ||||
|         ///
 | ||||
|         /// When `true`, the server should return an animated thumbnail if possible and supported.
 | ||||
|         /// Otherwise it must not return an animated thumbnail.
 | ||||
|         ///
 | ||||
|         /// Defaults to `false`.
 | ||||
|         #[cfg(feature = "unstable-msc2705")] | ||||
|         /// When `Some(true)`, the server should return an animated thumbnail if possible and
 | ||||
|         /// supported. When `Some(false)`, the server must not return an animated
 | ||||
|         /// thumbnail. When `None`, the server should not return an animated thumbnail.
 | ||||
|         #[ruma_api(query)] | ||||
|         #[serde(
 | ||||
|             rename = "org.matrix.msc2705.animated", | ||||
|             default, | ||||
|             skip_serializing_if = "ruma_common::serde::is_default" | ||||
|         )] | ||||
|         pub animated: bool, | ||||
|         #[serde(skip_serializing_if = "Option::is_none")] | ||||
|         pub animated: Option<bool>, | ||||
|     } | ||||
| 
 | ||||
|     /// Response type for the `get_content_thumbnail` endpoint.
 | ||||
| @ -111,8 +106,7 @@ pub mod unstable { | ||||
|                 width, | ||||
|                 height, | ||||
|                 timeout_ms: crate::media::default_download_timeout(), | ||||
|                 #[cfg(feature = "unstable-msc2705")] | ||||
|                 animated: false, | ||||
|                 animated: None, | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|  | ||||
| @ -2,10 +2,10 @@ | ||||
| //!
 | ||||
| //! Gets the config for the media repository.
 | ||||
| 
 | ||||
| pub mod unstable { | ||||
|     //! `/unstable/org.matrix.msc3916/` ([MSC])
 | ||||
| pub mod v1 { | ||||
|     //! `/v1/` ([spec])
 | ||||
|     //!
 | ||||
|     //! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/3916
 | ||||
|     //! [spec]: https://spec.matrix.org/latest/client-server-api/#get_matrixclientv1mediaconfig
 | ||||
| 
 | ||||
|     use js_int::UInt; | ||||
|     use ruma_common::{ | ||||
| @ -19,6 +19,7 @@ pub mod unstable { | ||||
|         authentication: AccessToken, | ||||
|         history: { | ||||
|             unstable => "/_matrix/client/unstable/org.matrix.msc3916/media/config", | ||||
|             1.11 => "/_matrix/client/v1/media/config", | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|  | ||||
| @ -2,10 +2,10 @@ | ||||
| //!
 | ||||
| //! Get a preview for a URL.
 | ||||
| 
 | ||||
| pub mod unstable { | ||||
|     //! `/unstable/org.matrix.msc3916/` ([MSC])
 | ||||
| pub mod v1 { | ||||
|     //! `/v1/` ([spec])
 | ||||
|     //!
 | ||||
|     //! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/3916
 | ||||
|     //! [spec]: https://spec.matrix.org/latest/client-server-api/#get_matrixclientv1mediapreview_url
 | ||||
| 
 | ||||
|     use ruma_common::{ | ||||
|         api::{request, response, Metadata}, | ||||
| @ -20,6 +20,7 @@ pub mod unstable { | ||||
|         authentication: AccessToken, | ||||
|         history: { | ||||
|             unstable => "/_matrix/client/unstable/org.matrix.msc3916/media/preview_url", | ||||
|             1.11 => "/_matrix/client/v1/media/preview_url", | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										38
									
								
								crates/ruma-client-api/src/future.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								crates/ruma-client-api/src/future.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,38 @@ | ||||
| //! Endpoints for sending and receiving futures
 | ||||
| 
 | ||||
| pub mod send_future_message_event; | ||||
| pub mod send_future_state_event; | ||||
| pub mod update_future; | ||||
| 
 | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use web_time::Duration; | ||||
| 
 | ||||
| /// The query parameters for a future request.
 | ||||
| /// It can contain the possible timeout and the future_group_id combinations.
 | ||||
| #[derive(Clone, Debug, Serialize, Deserialize)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| #[serde(untagged)] | ||||
| pub enum FutureParameters { | ||||
|     /// Only sending the timeout creates a timeout future with a new (server generated)
 | ||||
|     /// group id. The optional group id is used to create a secondary timeout.  
 | ||||
|     /// In a future group with two timeouts only one of them will ever be sent.
 | ||||
|     Timeout { | ||||
|         /// The timeout duration for this Future.
 | ||||
|         #[serde(with = "ruma_common::serde::duration::ms")] | ||||
|         #[serde(rename = "future_timeout")] | ||||
|         timeout: Duration, | ||||
|         /// The associated group for this Future.
 | ||||
|         #[serde(skip_serializing_if = "Option::is_none")] | ||||
|         #[serde(rename = "future_group_id")] | ||||
|         group_id: Option<String>, | ||||
|     }, | ||||
| 
 | ||||
|     /// Adds an additional action to a future without a timeout but requires a future group_id.
 | ||||
|     /// A possible matrix event that this future group can resolve to. It can be sent by using the
 | ||||
|     /// send_token as an alternative to the timeout future of an already existing group.
 | ||||
|     Action { | ||||
|         /// The associated group for this Future.
 | ||||
|         #[serde(rename = "future_group_id")] | ||||
|         group_id: String, | ||||
|     }, | ||||
| } | ||||
							
								
								
									
										182
									
								
								crates/ruma-client-api/src/future/send_future_message_event.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										182
									
								
								crates/ruma-client-api/src/future/send_future_message_event.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,182 @@ | ||||
| //! `PUT /_matrix/client/*/rooms/{roomId}/send_future/{eventType}/{txnId}`
 | ||||
| //!
 | ||||
| //! Send a future (a scheduled message) to a room. [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140)
 | ||||
| 
 | ||||
| pub mod unstable { | ||||
|     //! `msc4140` ([MSC])
 | ||||
|     //!
 | ||||
|     //! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/4140
 | ||||
| 
 | ||||
|     use ruma_common::{ | ||||
|         api::{request, response, Metadata}, | ||||
|         metadata, | ||||
|         serde::Raw, | ||||
|         OwnedRoomId, OwnedTransactionId, | ||||
|     }; | ||||
|     use ruma_events::{AnyMessageLikeEventContent, MessageLikeEventContent, MessageLikeEventType}; | ||||
|     use serde_json::value::to_raw_value as to_raw_json_value; | ||||
| 
 | ||||
|     use crate::future::FutureParameters; | ||||
| 
 | ||||
|     const METADATA: Metadata = metadata! { | ||||
|         method: PUT, | ||||
|         rate_limited: false, | ||||
|         authentication: AccessToken, | ||||
|         history: { | ||||
|             unstable => "/_matrix/client/unstable/org.matrix.msc4140/rooms/:room_id/send_future/:event_type/:txn_id", | ||||
|         } | ||||
|     }; | ||||
|     /// Request type for the [`send_future_message_event`](crate::future::send_future_message_event)
 | ||||
|     /// endpoint.
 | ||||
|     #[request(error = crate::Error)] | ||||
|     pub struct Request { | ||||
|         /// The room to send the event to.
 | ||||
|         #[ruma_api(path)] | ||||
|         pub room_id: OwnedRoomId, | ||||
| 
 | ||||
|         /// The type of event to send.
 | ||||
|         #[ruma_api(path)] | ||||
|         pub event_type: MessageLikeEventType, | ||||
| 
 | ||||
|         /// The transaction ID for this event.
 | ||||
|         ///
 | ||||
|         /// Clients should generate a unique ID across requests within the
 | ||||
|         /// same session. A session is identified by an access token, and
 | ||||
|         /// persists when the [access token is refreshed].
 | ||||
|         ///
 | ||||
|         /// It will be used by the server to ensure idempotency of requests.
 | ||||
|         ///
 | ||||
|         /// [access token is refreshed]: https://spec.matrix.org/latest/client-server-api/#refreshing-access-tokens
 | ||||
|         #[ruma_api(path)] | ||||
|         pub txn_id: OwnedTransactionId, | ||||
| 
 | ||||
|         /// Additional parameters to describe sending a future.
 | ||||
|         ///
 | ||||
|         /// Only three combinations for `future_timeout` and `future_group_id` are possible.
 | ||||
|         /// The enum [`FutureParameters`] enforces this.
 | ||||
|         #[ruma_api(query_all)] | ||||
|         pub future_parameters: FutureParameters, | ||||
| 
 | ||||
|         /// The event content to send.
 | ||||
|         #[ruma_api(body)] | ||||
|         pub body: Raw<AnyMessageLikeEventContent>, | ||||
|     } | ||||
| 
 | ||||
|     /// Response type for the
 | ||||
|     /// [`send_future_message_event`](crate::future::send_future_message_event) endpoint.
 | ||||
|     #[response(error = crate::Error)] | ||||
|     pub struct Response { | ||||
|         /// A token to send/insert the future into the DAG.
 | ||||
|         pub send_token: String, | ||||
|         /// A token to cancel this future. It will never be send if this is called.
 | ||||
|         pub cancel_token: String, | ||||
|         /// The `future_group_id` generated for this future. Used to connect multiple futures
 | ||||
|         /// only one of the connected futures will be sent and inserted into the DAG.
 | ||||
|         pub future_group_id: String, | ||||
|         /// A token used to refresh the timer of the future. This allows
 | ||||
|         /// to implement heartbeat like capabilities. An event is only sent once
 | ||||
|         /// a refresh in the timeout interval is missed.
 | ||||
|         ///
 | ||||
|         /// If the future does not have a timeout this will be `None`.
 | ||||
|         pub refresh_token: Option<String>, | ||||
|     } | ||||
| 
 | ||||
|     impl Request { | ||||
|         /// Creates a new `Request` with the given room id, transaction id future_parameters and
 | ||||
|         /// event content.
 | ||||
|         ///
 | ||||
|         /// # Errors
 | ||||
|         ///
 | ||||
|         /// Since `Request` stores the request body in serialized form, this function can fail if
 | ||||
|         /// `T`s [`::serde::Serialize`] implementation can fail.
 | ||||
|         pub fn new<T>( | ||||
|             room_id: OwnedRoomId, | ||||
|             txn_id: OwnedTransactionId, | ||||
|             future_parameters: FutureParameters, | ||||
|             content: &T, | ||||
|         ) -> serde_json::Result<Self> | ||||
|         where | ||||
|             T: MessageLikeEventContent, | ||||
|         { | ||||
|             Ok(Self { | ||||
|                 room_id, | ||||
|                 txn_id, | ||||
|                 event_type: content.event_type(), | ||||
|                 future_parameters, | ||||
|                 body: Raw::from_json(to_raw_json_value(content)?), | ||||
|             }) | ||||
|         } | ||||
| 
 | ||||
|         /// Creates a new `Request` with the given room id, transaction id, event type,
 | ||||
|         /// future parameters and raw event content.
 | ||||
|         pub fn new_raw( | ||||
|             room_id: OwnedRoomId, | ||||
|             txn_id: OwnedTransactionId, | ||||
|             event_type: MessageLikeEventType, | ||||
|             future_parameters: FutureParameters, | ||||
|             body: Raw<AnyMessageLikeEventContent>, | ||||
|         ) -> Self { | ||||
|             Self { room_id, event_type, txn_id, future_parameters, body } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     impl Response { | ||||
|         /// Creates a new `Response` with the tokens required to control the future using the
 | ||||
|         /// [`crate::future::update_future::unstable::Request`] request.
 | ||||
|         pub fn new( | ||||
|             send_token: String, | ||||
|             cancel_token: String, | ||||
|             future_group_id: String, | ||||
|             refresh_token: Option<String>, | ||||
|         ) -> Self { | ||||
|             Self { send_token, cancel_token, future_group_id, refresh_token } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     #[cfg(all(test, feature = "client"))] | ||||
|     mod tests { | ||||
|         use ruma_common::{ | ||||
|             api::{MatrixVersion, OutgoingRequest, SendAccessToken}, | ||||
|             owned_room_id, | ||||
|         }; | ||||
|         use ruma_events::room::message::RoomMessageEventContent; | ||||
|         use serde_json::{json, Value as JsonValue}; | ||||
|         use web_time::Duration; | ||||
| 
 | ||||
|         use super::Request; | ||||
|         use crate::future::send_future_message_event::unstable::FutureParameters; | ||||
| 
 | ||||
|         #[test] | ||||
|         fn serialize_message_future_request() { | ||||
|             let room_id = owned_room_id!("!roomid:example.org"); | ||||
| 
 | ||||
|             let req = Request::new( | ||||
|                 room_id, | ||||
|                 "1234".into(), | ||||
|                 FutureParameters::Timeout { | ||||
|                     timeout: Duration::from_millis(103), | ||||
|                     group_id: Some("testId".to_owned()), | ||||
|                 }, | ||||
|                 &RoomMessageEventContent::text_plain("test"), | ||||
|             ) | ||||
|             .unwrap(); | ||||
|             let request: http::Request<Vec<u8>> = req | ||||
|                 .try_into_http_request( | ||||
|                     "https://homeserver.tld", | ||||
|                     SendAccessToken::IfRequired("auth_tok"), | ||||
|                     &[MatrixVersion::V1_1], | ||||
|                 ) | ||||
|                 .unwrap(); | ||||
|             let (parts, body) = request.into_parts(); | ||||
|             assert_eq!( | ||||
|                 "https://homeserver.tld/_matrix/client/unstable/org.matrix.msc4140/rooms/!roomid:example.org/send_future/m.room.message/1234?future_timeout=103&future_group_id=testId", | ||||
|                 parts.uri.to_string() | ||||
|             ); | ||||
|             assert_eq!("PUT", parts.method.to_string()); | ||||
|             assert_eq!( | ||||
|                 json!({"msgtype":"m.text","body":"test"}), | ||||
|                 serde_json::from_str::<JsonValue>(std::str::from_utf8(&body).unwrap()).unwrap() | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										175
									
								
								crates/ruma-client-api/src/future/send_future_state_event.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										175
									
								
								crates/ruma-client-api/src/future/send_future_state_event.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,175 @@ | ||||
| //! `PUT /_matrix/client/*/rooms/{roomId}/state_future/{eventType}/{txnId}`
 | ||||
| //!
 | ||||
| //! Send a future state (a scheduled state event) to a room. [MSC](https://github.com/matrix-org/matrix-spec-proposals/pull/4140)
 | ||||
| 
 | ||||
| pub mod unstable { | ||||
|     //! `msc4140` ([MSC])
 | ||||
|     //!
 | ||||
|     //! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/4140
 | ||||
| 
 | ||||
|     use ruma_common::{ | ||||
|         api::{request, response, Metadata}, | ||||
|         metadata, | ||||
|         serde::Raw, | ||||
|         OwnedRoomId, | ||||
|     }; | ||||
|     use ruma_events::{AnyStateEventContent, StateEventContent, StateEventType}; | ||||
|     use serde_json::value::to_raw_value as to_raw_json_value; | ||||
| 
 | ||||
|     use crate::future::FutureParameters; | ||||
| 
 | ||||
|     const METADATA: Metadata = metadata! { | ||||
|         method: PUT, | ||||
|         rate_limited: false, | ||||
|         authentication: AccessToken, | ||||
|         history: { | ||||
|             unstable => "/_matrix/client/unstable/org.matrix.msc4140/rooms/:room_id/state_future/:event_type/:state_key", | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     /// Request type for the [`send_future_state_event`](crate::future::send_future_state_event)
 | ||||
|     /// endpoint.
 | ||||
|     #[request(error = crate::Error)] | ||||
|     pub struct Request { | ||||
|         /// The room to send the event to.
 | ||||
|         #[ruma_api(path)] | ||||
|         pub room_id: OwnedRoomId, | ||||
| 
 | ||||
|         /// The type of event to send.
 | ||||
|         #[ruma_api(path)] | ||||
|         pub event_type: StateEventType, | ||||
| 
 | ||||
|         /// The state_key for the state to send.
 | ||||
|         #[ruma_api(path)] | ||||
|         pub state_key: String, | ||||
| 
 | ||||
|         /// Additional parameters to describe sending a future.
 | ||||
|         ///
 | ||||
|         /// Only three combinations for `future_timeout` and `future_group_id` are possible.
 | ||||
|         /// The enum [`FutureParameters`] enforces this.
 | ||||
|         #[ruma_api(query_all)] | ||||
|         pub future_parameters: FutureParameters, | ||||
| 
 | ||||
|         /// The event content to send.
 | ||||
|         #[ruma_api(body)] | ||||
|         pub body: Raw<AnyStateEventContent>, | ||||
|     } | ||||
| 
 | ||||
|     /// Response type for the [`send_future_state_event`](crate::future::send_future_state_event)
 | ||||
|     /// endpoint.
 | ||||
|     #[response(error = crate::Error)] | ||||
|     pub struct Response { | ||||
|         /// A token to send/insert the future into the DAG.
 | ||||
|         pub send_token: String, | ||||
|         /// A token to cancel this future. It will never be send if this is called.
 | ||||
|         pub cancel_token: String, | ||||
|         /// The `future_group_id` generated for this future. Used to connect multiple futures
 | ||||
|         /// only one of the connected futures will be sent and inserted into the DAG.
 | ||||
|         pub future_group_id: String, | ||||
|         /// A token used to refresh the timer of the future. This allows
 | ||||
|         /// to implement heardbeat like capabilities. An event is only send once
 | ||||
|         /// a refresh in the timeout interval is missed.
 | ||||
|         ///
 | ||||
|         /// If the future does not have a timeout this will be `None`.
 | ||||
|         pub refresh_token: Option<String>, | ||||
|     } | ||||
| 
 | ||||
|     impl Request { | ||||
|         /// Creates a new `Request` with the given room id, state_key future_parameters and
 | ||||
|         /// event content.
 | ||||
|         ///
 | ||||
|         /// # Errors
 | ||||
|         ///
 | ||||
|         /// Since `Request` stores the request body in serialized form, this function can fail if
 | ||||
|         /// `T`s [`::serde::Serialize`] implementation can fail.
 | ||||
|         pub fn new<T>( | ||||
|             room_id: OwnedRoomId, | ||||
|             state_key: String, | ||||
|             future_parameters: FutureParameters, | ||||
|             content: &T, | ||||
|         ) -> serde_json::Result<Self> | ||||
|         where | ||||
|             T: StateEventContent, | ||||
|         { | ||||
|             Ok(Self { | ||||
|                 room_id, | ||||
|                 state_key, | ||||
|                 event_type: content.event_type(), | ||||
|                 future_parameters, | ||||
|                 body: Raw::from_json(to_raw_json_value(content)?), | ||||
|             }) | ||||
|         } | ||||
| 
 | ||||
|         /// Creates a new `Request` with the given room id, event type, state key,
 | ||||
|         /// future parameters and raw event content.
 | ||||
|         pub fn new_raw( | ||||
|             room_id: OwnedRoomId, | ||||
|             state_key: String, | ||||
|             event_type: StateEventType, | ||||
|             future_parameters: FutureParameters, | ||||
|             body: Raw<AnyStateEventContent>, | ||||
|         ) -> Self { | ||||
|             Self { room_id, event_type, state_key, body, future_parameters } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     impl Response { | ||||
|         /// Creates a new `Response` with the tokens required to control the future using the
 | ||||
|         /// [`crate::future::update_future::unstable::Request`] request.
 | ||||
|         pub fn new( | ||||
|             send_token: String, | ||||
|             cancel_token: String, | ||||
|             future_group_id: String, | ||||
|             refresh_token: Option<String>, | ||||
|         ) -> Self { | ||||
|             Self { send_token, cancel_token, future_group_id, refresh_token } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     #[cfg(all(test, feature = "client"))] | ||||
|     mod tests { | ||||
|         use ruma_common::{ | ||||
|             api::{MatrixVersion, OutgoingRequest, SendAccessToken}, | ||||
|             owned_room_id, | ||||
|         }; | ||||
|         use ruma_events::room::topic::RoomTopicEventContent; | ||||
|         use serde_json::{json, Value as JsonValue}; | ||||
|         use web_time::Duration; | ||||
| 
 | ||||
|         use super::Request; | ||||
|         use crate::future::FutureParameters; | ||||
| 
 | ||||
|         #[test] | ||||
|         fn serialize_state_future_request() { | ||||
|             let room_id = owned_room_id!("!roomid:example.org"); | ||||
| 
 | ||||
|             let req = Request::new( | ||||
|                 room_id, | ||||
|                 "@userAsStateKey:example.org".to_owned(), | ||||
|                 FutureParameters::Timeout { | ||||
|                     timeout: Duration::from_millis(1_234_321), | ||||
|                     group_id: Some("abs1abs1abs1abs1".to_owned()), | ||||
|                 }, | ||||
|                 &RoomTopicEventContent::new("my_topic".to_owned()), | ||||
|             ) | ||||
|             .unwrap(); | ||||
|             let request: http::Request<Vec<u8>> = req | ||||
|                 .try_into_http_request( | ||||
|                     "https://homeserver.tld", | ||||
|                     SendAccessToken::IfRequired("auth_tok"), | ||||
|                     &[MatrixVersion::V1_1], | ||||
|                 ) | ||||
|                 .unwrap(); | ||||
|             let (parts, body) = request.into_parts(); | ||||
|             assert_eq!( | ||||
|                 "https://homeserver.tld/_matrix/client/unstable/org.matrix.msc4140/rooms/!roomid:example.org/state_future/m.room.topic/@userAsStateKey:example.org?future_timeout=1234321&future_group_id=abs1abs1abs1abs1", | ||||
|                 parts.uri.to_string() | ||||
|             ); | ||||
|             assert_eq!("PUT", parts.method.to_string()); | ||||
|             assert_eq!( | ||||
|                 json!({"topic": "my_topic"}), | ||||
|                 serde_json::from_str::<JsonValue>(std::str::from_utf8(&body).unwrap()).unwrap() | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										49
									
								
								crates/ruma-client-api/src/future/update_future.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								crates/ruma-client-api/src/future/update_future.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,49 @@ | ||||
| //! `POST /_matrix/client/*/futures/{token}`
 | ||||
| //!
 | ||||
| //! Send a future token to update/cancel/send the associated future event.
 | ||||
| 
 | ||||
| pub mod unstable { | ||||
|     //! `msc3814` ([MSC])
 | ||||
|     //!
 | ||||
|     //! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/4140
 | ||||
| 
 | ||||
|     use ruma_common::{ | ||||
|         api::{request, response, Metadata}, | ||||
|         metadata, | ||||
|     }; | ||||
| 
 | ||||
|     const METADATA: Metadata = metadata! { | ||||
|         method: POST, | ||||
|         rate_limited: true, | ||||
|         authentication: None, | ||||
|         history: { | ||||
|             unstable => "/_matrix/client/unstable/org.matrix.msc4140/future/:token", | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     /// Request type for the [`update_future`](crate::future::update_future) endpoint.
 | ||||
|     #[request(error = crate::Error)] | ||||
|     pub struct Request { | ||||
|         /// The token.
 | ||||
|         #[ruma_api(path)] | ||||
|         pub token: String, | ||||
|     } | ||||
| 
 | ||||
|     impl Request { | ||||
|         /// Creates a new `Request` to update a future. This is an unauthenticated request and only
 | ||||
|         /// requires the future token.
 | ||||
|         pub fn new(token: String) -> serde_json::Result<Self> { | ||||
|             Ok(Self { token }) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /// Response type for the [`update_future`](crate::future::update_future) endpoint.
 | ||||
|     #[response(error = crate::Error)] | ||||
|     pub struct Response {} | ||||
|     impl Response { | ||||
|         /// Creates a new response for the [`update_future`](crate::future::update_future) endpoint.
 | ||||
|         pub fn new() -> Self { | ||||
|             Response {} | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -12,7 +12,6 @@ | ||||
| pub mod account; | ||||
| pub mod alias; | ||||
| pub mod appservice; | ||||
| #[cfg(feature = "unstable-msc3916")] | ||||
| pub mod authenticated_media; | ||||
| pub mod backup; | ||||
| pub mod config; | ||||
| @ -24,6 +23,8 @@ pub mod directory; | ||||
| pub mod discovery; | ||||
| pub mod error; | ||||
| pub mod filter; | ||||
| #[cfg(feature = "unstable-msc4140")] | ||||
| pub mod future; | ||||
| pub mod http_headers; | ||||
| pub mod keys; | ||||
| pub mod knock; | ||||
|  | ||||
| @ -24,11 +24,16 @@ pub mod v3 { | ||||
|         history: { | ||||
|             1.0 => "/_matrix/media/r0/download/:server_name/:media_id", | ||||
|             1.1 => "/_matrix/media/v3/download/:server_name/:media_id", | ||||
|             1.11 => deprecated, | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     /// Request type for the `get_media_content` endpoint.
 | ||||
|     #[request(error = crate::Error)] | ||||
|     #[deprecated = "\ | ||||
|         Since Matrix 1.11, clients should use `authenticated_media::get_content::v1::Request` \ | ||||
|         instead if the homeserver supports it.\ | ||||
|     "]
 | ||||
|     pub struct Request { | ||||
|         /// The server name from the mxc:// URI (the authoritory component).
 | ||||
|         #[ruma_api(path)] | ||||
| @ -106,6 +111,7 @@ pub mod v3 { | ||||
|         pub cache_control: Option<String>, | ||||
|     } | ||||
| 
 | ||||
|     #[allow(deprecated)] | ||||
|     impl Request { | ||||
|         /// Creates a new `Request` with the given media ID and server name.
 | ||||
|         pub fn new(media_id: String, server_name: OwnedServerName) -> Self { | ||||
|  | ||||
| @ -24,11 +24,16 @@ pub mod v3 { | ||||
|         history: { | ||||
|             1.0 => "/_matrix/media/r0/download/:server_name/:media_id/:filename", | ||||
|             1.1 => "/_matrix/media/v3/download/:server_name/:media_id/:filename", | ||||
|             1.11 => deprecated, | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     /// Request type for the `get_media_content_as_filename` endpoint.
 | ||||
|     #[request(error = crate::Error)] | ||||
|     #[deprecated = "\ | ||||
|         Since Matrix 1.11, clients should use `authenticated_media::get_content_as_filename::v1::Request` \ | ||||
|         instead if the homeserver supports it.\ | ||||
|     "]
 | ||||
|     pub struct Request { | ||||
|         /// The server name from the mxc:// URI (the authoritory component).
 | ||||
|         #[ruma_api(path)] | ||||
| @ -110,6 +115,7 @@ pub mod v3 { | ||||
|         pub cache_control: Option<String>, | ||||
|     } | ||||
| 
 | ||||
|     #[allow(deprecated)] | ||||
|     impl Request { | ||||
|         /// Creates a new `Request` with the given media ID, server name and filename.
 | ||||
|         pub fn new(media_id: String, server_name: OwnedServerName, filename: String) -> Self { | ||||
|  | ||||
| @ -27,11 +27,16 @@ pub mod v3 { | ||||
|         history: { | ||||
|             1.0 => "/_matrix/media/r0/thumbnail/:server_name/:media_id", | ||||
|             1.1 => "/_matrix/media/v3/thumbnail/:server_name/:media_id", | ||||
|             1.11 => deprecated, | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     /// Request type for the `get_content_thumbnail` endpoint.
 | ||||
|     #[request(error = crate::Error)] | ||||
|     #[deprecated = "\ | ||||
|         Since Matrix 1.11, clients should use `authenticated_media::get_content_thumbnail::v1::Request` \ | ||||
|         instead if the homeserver supports it.\ | ||||
|     "]
 | ||||
|     pub struct Request { | ||||
|         /// The server name from the mxc:// URI (the authoritory component).
 | ||||
|         #[ruma_api(path)] | ||||
| @ -90,18 +95,12 @@ pub mod v3 { | ||||
| 
 | ||||
|         /// Whether the server should return an animated thumbnail.
 | ||||
|         ///
 | ||||
|         /// When `true`, the server should return an animated thumbnail if possible and supported.
 | ||||
|         /// Otherwise it must not return an animated thumbnail.
 | ||||
|         ///
 | ||||
|         /// Defaults to `false`.
 | ||||
|         #[cfg(feature = "unstable-msc2705")] | ||||
|         /// When `Some(true)`, the server should return an animated thumbnail if possible and
 | ||||
|         /// supported. When `Some(false)`, the server must not return an animated
 | ||||
|         /// thumbnail. When `None`, the server should not return an animated thumbnail.
 | ||||
|         #[ruma_api(query)] | ||||
|         #[serde(
 | ||||
|             rename = "org.matrix.msc2705.animated", | ||||
|             default, | ||||
|             skip_serializing_if = "ruma_common::serde::is_default" | ||||
|         )] | ||||
|         pub animated: bool, | ||||
|         #[serde(skip_serializing_if = "Option::is_none")] | ||||
|         pub animated: Option<bool>, | ||||
|     } | ||||
| 
 | ||||
|     /// Response type for the `get_content_thumbnail` endpoint.
 | ||||
| @ -141,6 +140,7 @@ pub mod v3 { | ||||
|         pub content_disposition: Option<String>, | ||||
|     } | ||||
| 
 | ||||
|     #[allow(deprecated)] | ||||
|     impl Request { | ||||
|         /// Creates a new `Request` with the given media ID, server name, desired thumbnail width
 | ||||
|         /// and desired thumbnail height.
 | ||||
| @ -159,8 +159,7 @@ pub mod v3 { | ||||
|                 allow_remote: true, | ||||
|                 timeout_ms: crate::media::default_download_timeout(), | ||||
|                 allow_redirect: false, | ||||
|                 #[cfg(feature = "unstable-msc2705")] | ||||
|                 animated: false, | ||||
|                 animated: None, | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|  | ||||
| @ -20,12 +20,17 @@ pub mod v3 { | ||||
|         history: { | ||||
|             1.0 => "/_matrix/media/r0/config", | ||||
|             1.1 => "/_matrix/media/v3/config", | ||||
|             1.11 => deprecated, | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     /// Request type for the `get_media_config` endpoint.
 | ||||
|     #[request(error = crate::Error)] | ||||
|     #[derive(Default)] | ||||
|     #[deprecated = "\ | ||||
|         Since Matrix 1.11, clients should use `authenticated_media::get_media_config::v1::Request` \ | ||||
|         instead if the homeserver supports it.\ | ||||
|     "]
 | ||||
|     pub struct Request {} | ||||
| 
 | ||||
|     /// Response type for the `get_media_config` endpoint.
 | ||||
| @ -36,6 +41,7 @@ pub mod v3 { | ||||
|         pub upload_size: UInt, | ||||
|     } | ||||
| 
 | ||||
|     #[allow(deprecated)] | ||||
|     impl Request { | ||||
|         /// Creates an empty `Request`.
 | ||||
|         pub fn new() -> Self { | ||||
|  | ||||
| @ -21,11 +21,16 @@ pub mod v3 { | ||||
|         history: { | ||||
|             1.0 => "/_matrix/media/r0/preview_url", | ||||
|             1.1 => "/_matrix/media/v3/preview_url", | ||||
|             1.11 => deprecated, | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     /// Request type for the `get_media_preview` endpoint.
 | ||||
|     #[request(error = crate::Error)] | ||||
|     #[deprecated = "\ | ||||
|       Since Matrix 1.11, clients should use `authenticated_media::get_media_preview::v1::Request` \ | ||||
|       instead if the homeserver supports it.\ | ||||
|     "]
 | ||||
|     pub struct Request { | ||||
|         /// URL to get a preview of.
 | ||||
|         #[ruma_api(query)] | ||||
| @ -49,6 +54,7 @@ pub mod v3 { | ||||
|         pub data: Option<Box<RawJsonValue>>, | ||||
|     } | ||||
| 
 | ||||
|     #[allow(deprecated)] | ||||
|     impl Request { | ||||
|         /// Creates a new `Request` with the given url.
 | ||||
|         pub fn new(url: String) -> Self { | ||||
|  | ||||
| @ -9,8 +9,8 @@ pub mod v3 { | ||||
|     //! [by their Matrix identifier][spec-mxid], and one to invite a user
 | ||||
|     //! [by their third party identifier][spec-3pid].
 | ||||
|     //!
 | ||||
|     //! [spec-mxid]: https://spec.matrix.org/v1.10/client-server-api/#post_matrixclientv3roomsroomidinvite
 | ||||
|     //! [spec-3pid]: https://spec.matrix.org/v1.10/client-server-api/#post_matrixclientv3roomsroomidinvite-1
 | ||||
|     //! [spec-mxid]: https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3roomsroomidinvite
 | ||||
|     //! [spec-3pid]: https://spec.matrix.org/latest/client-server-api/#thirdparty_post_matrixclientv3roomsroomidinvite
 | ||||
| 
 | ||||
|     use ruma_common::{ | ||||
|         api::{request, response, Metadata}, | ||||
|  | ||||
| @ -34,7 +34,7 @@ pub mod v3 { | ||||
| 
 | ||||
|         /// One or more custom fields to help identify the third party location.
 | ||||
|         // The specification is incorrect for this parameter. See [matrix-spec#560](https://github.com/matrix-org/matrix-spec/issues/560).
 | ||||
|         #[ruma_api(query_map)] | ||||
|         #[ruma_api(query_all)] | ||||
|         pub fields: BTreeMap<String, String>, | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -34,7 +34,7 @@ pub mod v3 { | ||||
| 
 | ||||
|         /// One or more custom fields that are passed to the AS to help identify the user.
 | ||||
|         // The specification is incorrect for this parameter. See [matrix-spec#560](https://github.com/matrix-org/matrix-spec/issues/560).
 | ||||
|         #[ruma_api(query_map)] | ||||
|         #[ruma_api(query_all)] | ||||
|         pub fields: BTreeMap<String, String>, | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -53,6 +53,11 @@ pub enum AuthData { | ||||
|     /// Fallback acknowledgement.
 | ||||
|     FallbackAcknowledgement(FallbackAcknowledgement), | ||||
| 
 | ||||
|     /// Terms of service (`m.login.terms`).
 | ||||
|     ///
 | ||||
|     /// This type is only valid during account registration.
 | ||||
|     Terms(Terms), | ||||
| 
 | ||||
|     #[doc(hidden)] | ||||
|     _Custom(CustomAuthData), | ||||
| } | ||||
| @ -90,6 +95,7 @@ impl AuthData { | ||||
|             "m.login.msisdn" => Self::Msisdn(deserialize_variant(session, data)?), | ||||
|             "m.login.dummy" => Self::Dummy(deserialize_variant(session, data)?), | ||||
|             "m.registration_token" => Self::RegistrationToken(deserialize_variant(session, data)?), | ||||
|             "m.login.terms" => Self::Terms(deserialize_variant(session, data)?), | ||||
|             _ => { | ||||
|                 Self::_Custom(CustomAuthData { auth_type: auth_type.into(), session, extra: data }) | ||||
|             } | ||||
| @ -111,6 +117,7 @@ impl AuthData { | ||||
|             Self::Dummy(_) => Some(AuthType::Dummy), | ||||
|             Self::RegistrationToken(_) => Some(AuthType::RegistrationToken), | ||||
|             Self::FallbackAcknowledgement(_) => None, | ||||
|             Self::Terms(_) => Some(AuthType::Terms), | ||||
|             Self::_Custom(c) => Some(AuthType::_Custom(PrivOwnedStr(c.auth_type.as_str().into()))), | ||||
|         } | ||||
|     } | ||||
| @ -125,6 +132,7 @@ impl AuthData { | ||||
|             Self::Dummy(x) => x.session.as_deref(), | ||||
|             Self::RegistrationToken(x) => x.session.as_deref(), | ||||
|             Self::FallbackAcknowledgement(x) => Some(&x.session), | ||||
|             Self::Terms(x) => x.session.as_deref(), | ||||
|             Self::_Custom(x) => x.session.as_deref(), | ||||
|         } | ||||
|     } | ||||
| @ -165,8 +173,10 @@ impl AuthData { | ||||
|             Self::RegistrationToken(x) => { | ||||
|                 Cow::Owned(serialize(RegistrationToken { token: x.token.clone(), session: None })) | ||||
|             } | ||||
|             // Dummy and fallback acknowledgement have no associated data
 | ||||
|             Self::Dummy(_) | Self::FallbackAcknowledgement(_) => Cow::Owned(JsonObject::default()), | ||||
|             // Dummy, fallback acknowledgement, and terms of service have no associated data
 | ||||
|             Self::Dummy(_) | Self::FallbackAcknowledgement(_) | Self::Terms(_) => { | ||||
|                 Cow::Owned(JsonObject::default()) | ||||
|             } | ||||
|             Self::_Custom(c) => Cow::Borrowed(&c.extra), | ||||
|         } | ||||
|     } | ||||
| @ -183,6 +193,7 @@ impl fmt::Debug for AuthData { | ||||
|             Self::Dummy(inner) => inner.fmt(f), | ||||
|             Self::RegistrationToken(inner) => inner.fmt(f), | ||||
|             Self::FallbackAcknowledgement(inner) => inner.fmt(f), | ||||
|             Self::Terms(inner) => inner.fmt(f), | ||||
|             Self::_Custom(inner) => inner.fmt(f), | ||||
|         } | ||||
|     } | ||||
| @ -214,6 +225,7 @@ impl<'de> Deserialize<'de> for AuthData { | ||||
|             Some("m.login.registration_token") => { | ||||
|                 from_raw_json_value(&json).map(Self::RegistrationToken) | ||||
|             } | ||||
|             Some("m.login.terms") => from_raw_json_value(&json).map(Self::Terms), | ||||
|             None => from_raw_json_value(&json).map(Self::FallbackAcknowledgement), | ||||
|             Some(_) => from_raw_json_value(&json).map(Self::_Custom), | ||||
|         } | ||||
| @ -253,6 +265,12 @@ pub enum AuthType { | ||||
|     #[ruma_enum(rename = "m.login.registration_token")] | ||||
|     RegistrationToken, | ||||
| 
 | ||||
|     /// Terms of service (`m.login.terms`).
 | ||||
|     ///
 | ||||
|     /// This type is only valid during account registration.
 | ||||
|     #[ruma_enum(rename = "m.login.terms")] | ||||
|     Terms, | ||||
| 
 | ||||
|     #[doc(hidden)] | ||||
|     _Custom(PrivOwnedStr), | ||||
| } | ||||
| @ -434,6 +452,28 @@ impl FallbackAcknowledgement { | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// Data for terms of service flow.
 | ||||
| ///
 | ||||
| /// This type is only valid during account registration.
 | ||||
| ///
 | ||||
| /// See [the spec] for how to use this.
 | ||||
| ///
 | ||||
| /// [the spec]: https://spec.matrix.org/latest/client-server-api/#terms-of-service-at-registration
 | ||||
| #[derive(Clone, Debug, Default, Deserialize, Serialize)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| #[serde(tag = "type", rename = "m.login.terms")] | ||||
| pub struct Terms { | ||||
|     /// The value of the session key given by the homeserver, if any.
 | ||||
|     pub session: Option<String>, | ||||
| } | ||||
| 
 | ||||
| impl Terms { | ||||
|     /// Creates an empty `Terms`.
 | ||||
|     pub fn new() -> Self { | ||||
|         Self::default() | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[doc(hidden)] | ||||
| #[derive(Clone, Deserialize, Serialize)] | ||||
| #[non_exhaustive] | ||||
| @ -551,29 +591,25 @@ pub struct IncomingCustomThirdPartyId { | ||||
| #[derive(Clone, Deserialize, Serialize)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| pub struct ThirdpartyIdCredentials { | ||||
|     /// Identity server session ID.
 | ||||
|     /// Identity server (or homeserver) session ID.
 | ||||
|     pub sid: OwnedSessionId, | ||||
| 
 | ||||
|     /// Identity server client secret.
 | ||||
|     /// Identity server (or homeserver) client secret.
 | ||||
|     pub client_secret: OwnedClientSecret, | ||||
| 
 | ||||
|     /// Identity server URL.
 | ||||
|     pub id_server: String, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub id_server: Option<String>, | ||||
| 
 | ||||
|     /// Identity server access token.
 | ||||
|     pub id_access_token: String, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub id_access_token: Option<String>, | ||||
| } | ||||
| 
 | ||||
| impl ThirdpartyIdCredentials { | ||||
|     /// Creates a new `ThirdpartyIdCredentials` with the given session ID, client secret, identity
 | ||||
|     /// server address and access token.
 | ||||
|     pub fn new( | ||||
|         sid: OwnedSessionId, | ||||
|         client_secret: OwnedClientSecret, | ||||
|         id_server: String, | ||||
|         id_access_token: String, | ||||
|     ) -> Self { | ||||
|         Self { sid, client_secret, id_server, id_access_token } | ||||
|     /// Creates a new `ThirdpartyIdCredentials` with the given session ID and client secret.
 | ||||
|     pub fn new(sid: OwnedSessionId, client_secret: OwnedClientSecret) -> Self { | ||||
|         Self { sid, client_secret, id_server: None, id_access_token: None } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -1,9 +1,30 @@ | ||||
| # [unreleased] | ||||
| 
 | ||||
| Bug fixes: | ||||
| 
 | ||||
| - The `instance_id` field was removed from `ProtocolInstanceInit` and is now an | ||||
|   `Option<String>` for `ProtocolInstance`. It made the `unstable-unspecified` | ||||
|   feature non-additive. | ||||
| 
 | ||||
| Breaking changes: | ||||
| 
 | ||||
| - Rename the `query_map` attribute of the `request` macro to `query_all`, and | ||||
|   remove the required bound to implement `IntoIterator<Item = (String, String)>`. | ||||
|   This allows to use a struct or enum as well as a map to represent the list of | ||||
|   query parameters. Note that the (de)serialization of the type used must work | ||||
|   with `serde_html_form`. | ||||
| 
 | ||||
| Improvements: | ||||
| 
 | ||||
| - Add the `InvalidHeaderValue` variant to the `DeserializationError` struct, for | ||||
|   cases where we receive a HTTP header with an unexpected value. | ||||
| - Implement `Eq`/`Hash`/`PartialEq` for `ThirdPartyIdentifier`, to check whether  | ||||
|   a `ThirdPartyIdentifier` has already been added by another user. | ||||
| - Add `MatrixVersion::V1_11` | ||||
| - Clarify in the docs of `AuthScheme` that sending an access token via a query | ||||
|   parameter is deprecated, according to MSC4126 / Matrix 1.11. | ||||
| - Constructing a Matrix URI for an event with a room alias is deprecated, | ||||
|   according to MSC4132 / Matrix 1.11 | ||||
| 
 | ||||
| # 0.13.0 | ||||
| 
 | ||||
|  | ||||
| @ -122,9 +122,11 @@ macro_rules! metadata { | ||||
| ///   they are declared must match the order in which they occur in the request path.
 | ||||
| /// * `#[ruma_api(query)]`: Fields with this attribute will be inserting into the URL's query
 | ||||
| ///   string.
 | ||||
| /// * `#[ruma_api(query_map)]`: Instead of individual query fields, one query_map field, of any
 | ||||
| ///   type that implements `IntoIterator<Item = (String, String)>` (e.g. `HashMap<String,
 | ||||
| ///   String>`, can be used for cases where an endpoint supports arbitrary query parameters.
 | ||||
| /// * `#[ruma_api(query_all)]`: Instead of individual query fields, one query_all field, of any
 | ||||
| ///   type that can be (de)serialized by [serde_html_form], can be used for cases where
 | ||||
| ///   multiple endpoints should share a query fields type, the query fields are better
 | ||||
| ///   expressed as an `enum` rather than a `struct`, or the endpoint supports arbitrary query
 | ||||
| ///   parameters.
 | ||||
| /// * No attribute: Fields without an attribute are part of the body. They can use `#[serde]`
 | ||||
| ///   attributes to customize (de)serialization.
 | ||||
| /// * `#[ruma_api(body)]`: Use this if multiple endpoints should share a request body type, or
 | ||||
| @ -209,11 +211,13 @@ macro_rules! metadata { | ||||
| ///     # pub struct Response {}
 | ||||
| /// }
 | ||||
| /// ```
 | ||||
| ///
 | ||||
| /// [serde_html_form]: https://crates.io/crates/serde_html_form
 | ||||
| pub use ruma_macros::request; | ||||
| /// Generates [`OutgoingResponse`] and [`IncomingResponse`] implementations.
 | ||||
| ///
 | ||||
| /// The `OutgoingRequest` impl is feature-gated behind `cfg(feature = "client")`.
 | ||||
| /// The `IncomingRequest` impl is feature-gated behind `cfg(feature = "server")`.
 | ||||
| /// The `OutgoingResponse` impl is feature-gated behind `cfg(feature = "server")`.
 | ||||
| /// The `IncomingResponse` impl is feature-gated behind `cfg(feature = "client")`.
 | ||||
| ///
 | ||||
| /// The generated code expects a `METADATA` constant of type [`Metadata`] to be in scope.
 | ||||
| ///
 | ||||
| @ -223,7 +227,7 @@ pub use ruma_macros::request; | ||||
| ///
 | ||||
| /// ## Attributes
 | ||||
| ///
 | ||||
| /// To declare which part of the request a field belongs to:
 | ||||
| /// To declare which part of the response a field belongs to:
 | ||||
| ///
 | ||||
| /// * `#[ruma_api(header = HEADER_NAME)]`: Fields with this attribute will be treated as HTTP
 | ||||
| ///   headers on the response. The value must implement `Display`. Generally this is a
 | ||||
| @ -497,19 +501,19 @@ pub enum AuthScheme { | ||||
|     /// Authentication is performed by including an access token in the `Authentication` http
 | ||||
|     /// header, or an `access_token` query parameter.
 | ||||
|     ///
 | ||||
|     /// It is recommended to use the header over the query parameter.
 | ||||
|     /// Using the query parameter is deprecated since Matrix 1.11.
 | ||||
|     AccessToken, | ||||
| 
 | ||||
|     /// Authentication is optional, and it is performed by including an access token in the
 | ||||
|     /// `Authentication` http header, or an `access_token` query parameter.
 | ||||
|     ///
 | ||||
|     /// It is recommended to use the header over the query parameter.
 | ||||
|     /// Using the query parameter is deprecated since Matrix 1.11.
 | ||||
|     AccessTokenOptional, | ||||
| 
 | ||||
|     /// Authentication is only performed for appservices, by including an access token in the
 | ||||
|     /// `Authentication` http header, or an `access_token` query parameter.
 | ||||
|     ///
 | ||||
|     /// It is recommended to use the header over the query parameter.
 | ||||
|     /// Using the query parameter is deprecated since Matrix 1.11.
 | ||||
|     AppserviceToken, | ||||
| 
 | ||||
|     /// Authentication is performed by including X-Matrix signatures in the request headers,
 | ||||
|  | ||||
| @ -541,6 +541,11 @@ pub enum MatrixVersion { | ||||
|     ///
 | ||||
|     /// See <https://spec.matrix.org/v1.10/>.
 | ||||
|     V1_10, | ||||
| 
 | ||||
|     /// Version 1.11 of the Matrix specification, released in Q2 2024.
 | ||||
|     ///
 | ||||
|     /// See <https://spec.matrix.org/v1.11/>.
 | ||||
|     V1_11, | ||||
| } | ||||
| 
 | ||||
| impl TryFrom<&str> for MatrixVersion { | ||||
| @ -564,6 +569,7 @@ impl TryFrom<&str> for MatrixVersion { | ||||
|             "v1.8" => V1_8, | ||||
|             "v1.9" => V1_9, | ||||
|             "v1.10" => V1_10, | ||||
|             "v1.11" => V1_11, | ||||
|             _ => return Err(UnknownVersionError), | ||||
|         }) | ||||
|     } | ||||
| @ -613,6 +619,7 @@ impl MatrixVersion { | ||||
|             MatrixVersion::V1_8 => (1, 8), | ||||
|             MatrixVersion::V1_9 => (1, 9), | ||||
|             MatrixVersion::V1_10 => (1, 10), | ||||
|             MatrixVersion::V1_11 => (1, 11), | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| @ -630,6 +637,7 @@ impl MatrixVersion { | ||||
|             (1, 8) => Ok(MatrixVersion::V1_8), | ||||
|             (1, 9) => Ok(MatrixVersion::V1_9), | ||||
|             (1, 10) => Ok(MatrixVersion::V1_10), | ||||
|             (1, 11) => Ok(MatrixVersion::V1_11), | ||||
|             _ => Err(UnknownVersionError), | ||||
|         } | ||||
|     } | ||||
| @ -724,7 +732,9 @@ impl MatrixVersion { | ||||
|             // <https://spec.matrix.org/v1.9/rooms/#complete-list-of-room-versions>
 | ||||
|             | MatrixVersion::V1_9 | ||||
|             // <https://spec.matrix.org/v1.10/rooms/#complete-list-of-room-versions>
 | ||||
|             | MatrixVersion::V1_10 => RoomVersionId::V10, | ||||
|             | MatrixVersion::V1_10 | ||||
|             // <https://spec.matrix.org/v1.11/rooms/#complete-list-of-room-versions>
 | ||||
|             | MatrixVersion::V1_11  => RoomVersionId::V10, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -32,6 +32,9 @@ pub enum MatrixId { | ||||
|     User(OwnedUserId), | ||||
| 
 | ||||
|     /// An event ID.
 | ||||
|     ///
 | ||||
|     /// Constructing this variant from an `OwnedRoomAliasId` is deprecated, because room aliases
 | ||||
|     /// are mutable, so the URI might break after a while.
 | ||||
|     Event(OwnedRoomOrAliasId, OwnedEventId), | ||||
| } | ||||
| 
 | ||||
| @ -572,12 +575,11 @@ mod tests { | ||||
|                 .to_string(), | ||||
|             "https://matrix.to/#/!ruma:notareal.hs?via=notareal.hs" | ||||
|         ); | ||||
|         assert_eq!( | ||||
|             room_alias_id!("#ruma:notareal.hs") | ||||
|         #[allow(deprecated)] | ||||
|         let uri = room_alias_id!("#ruma:notareal.hs") | ||||
|             .matrix_to_event_uri(event_id!("$event:notareal.hs")) | ||||
|                 .to_string(), | ||||
|             "https://matrix.to/#/%23ruma:notareal.hs/$event:notareal.hs" | ||||
|         ); | ||||
|             .to_string(); | ||||
|         assert_eq!(uri, "https://matrix.to/#/%23ruma:notareal.hs/$event:notareal.hs"); | ||||
|         assert_eq!( | ||||
|             room_id!("!ruma:notareal.hs") | ||||
|                 .matrix_to_event_uri(event_id!("$event:notareal.hs")) | ||||
| @ -869,12 +871,11 @@ mod tests { | ||||
|                 .to_string(), | ||||
|             "matrix:roomid/ruma:notareal.hs?via=notareal.hs&via=anotherunreal.hs&action=join" | ||||
|         ); | ||||
|         assert_eq!( | ||||
|             room_alias_id!("#ruma:notareal.hs") | ||||
|         #[allow(deprecated)] | ||||
|         let uri = room_alias_id!("#ruma:notareal.hs") | ||||
|             .matrix_event_uri(event_id!("$event:notareal.hs")) | ||||
|                 .to_string(), | ||||
|             "matrix:r/ruma:notareal.hs/e/event:notareal.hs" | ||||
|         ); | ||||
|             .to_string(); | ||||
|         assert_eq!(uri, "matrix:r/ruma:notareal.hs/e/event:notareal.hs"); | ||||
|         assert_eq!( | ||||
|             room_id!("!ruma:notareal.hs") | ||||
|                 .matrix_event_uri(event_id!("$event:notareal.hs")) | ||||
|  | ||||
| @ -37,6 +37,9 @@ impl RoomAliasId { | ||||
|     } | ||||
| 
 | ||||
|     /// Create a `matrix.to` URI for an event scoped under this room alias ID.
 | ||||
|     ///
 | ||||
|     /// This is deprecated because room aliases are mutable, so the URI might break after a while.
 | ||||
|     #[deprecated = "Use `RoomId::matrix_to_event_uri` instead."] | ||||
|     pub fn matrix_to_event_uri(&self, ev_id: impl Into<OwnedEventId>) -> MatrixToUri { | ||||
|         MatrixToUri::new((self.to_owned(), ev_id.into()).into(), Vec::new()) | ||||
|     } | ||||
| @ -49,6 +52,9 @@ impl RoomAliasId { | ||||
|     } | ||||
| 
 | ||||
|     /// Create a `matrix:` URI for an event scoped under this room alias ID.
 | ||||
|     ///
 | ||||
|     /// This is deprecated because room aliases are mutable, so the URI might break after a while.
 | ||||
|     #[deprecated = "Use `RoomId::matrix_event_uri` instead."] | ||||
|     pub fn matrix_event_uri(&self, ev_id: impl Into<OwnedEventId>) -> MatrixUri { | ||||
|         MatrixUri::new((self.to_owned(), ev_id.into()).into(), Vec::new(), None) | ||||
|     } | ||||
|  | ||||
| @ -602,7 +602,7 @@ mod tests { | ||||
|         assert!(!"m".matches_word("[[:alpha:]]?")); | ||||
|         assert!("[[:alpha:]]!".matches_word("[[:alpha:]]?")); | ||||
| 
 | ||||
|         // From the spec: <https://spec.matrix.org/v1.10/client-server-api/#conditions-1>
 | ||||
|         // From the spec: <https://spec.matrix.org/v1.11/client-server-api/#conditions-1>
 | ||||
|         assert!("An example event.".matches_word("ex*ple")); | ||||
|         assert!("exple".matches_word("ex*ple")); | ||||
|         assert!("An exciting triple-whammy".matches_word("ex*ple")); | ||||
| @ -651,7 +651,7 @@ mod tests { | ||||
|         assert!("".matches_pattern("*", false)); | ||||
|         assert!(!"foo".matches_pattern("", false)); | ||||
| 
 | ||||
|         // From the spec: <https://spec.matrix.org/v1.10/client-server-api/#conditions-1>
 | ||||
|         // From the spec: <https://spec.matrix.org/v1.11/client-server-api/#conditions-1>
 | ||||
|         assert!("Lunch plans".matches_pattern("lunc?*", false)); | ||||
|         assert!("LUNCH".matches_pattern("lunc?*", false)); | ||||
|         assert!(!" lunch".matches_pattern("lunc?*", false)); | ||||
|  | ||||
| @ -2,7 +2,10 @@ | ||||
| //!
 | ||||
| //! [thirdparty]: https://spec.matrix.org/latest/client-server-api/#third-party-networks
 | ||||
| 
 | ||||
| use std::collections::BTreeMap; | ||||
| use std::{ | ||||
|     collections::BTreeMap, | ||||
|     hash::{Hash, Hasher}, | ||||
| }; | ||||
| 
 | ||||
| use serde::{Deserialize, Serialize}; | ||||
| 
 | ||||
| @ -91,7 +94,8 @@ pub struct ProtocolInstance { | ||||
|     ///
 | ||||
|     /// See [matrix-spec#833](https://github.com/matrix-org/matrix-spec/issues/833).
 | ||||
|     #[cfg(feature = "unstable-unspecified")] | ||||
|     pub instance_id: String, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub instance_id: Option<String>, | ||||
| } | ||||
| 
 | ||||
| /// Initial set of fields of `Protocol`.
 | ||||
| @ -109,30 +113,18 @@ pub struct ProtocolInstanceInit { | ||||
| 
 | ||||
|     /// A unique identifier across all instances.
 | ||||
|     pub network_id: String, | ||||
| 
 | ||||
|     /// A unique identifier across all instances.
 | ||||
|     ///
 | ||||
|     /// See [matrix-spec#833](https://github.com/matrix-org/matrix-spec/issues/833).
 | ||||
|     #[cfg(feature = "unstable-unspecified")] | ||||
|     pub instance_id: String, | ||||
| } | ||||
| 
 | ||||
| impl From<ProtocolInstanceInit> for ProtocolInstance { | ||||
|     fn from(init: ProtocolInstanceInit) -> Self { | ||||
|         let ProtocolInstanceInit { | ||||
|             desc, | ||||
|             fields, | ||||
|             network_id, | ||||
|             #[cfg(feature = "unstable-unspecified")] | ||||
|             instance_id, | ||||
|         } = init; | ||||
|         let ProtocolInstanceInit { desc, fields, network_id } = init; | ||||
|         Self { | ||||
|             desc, | ||||
|             icon: None, | ||||
|             fields, | ||||
|             network_id, | ||||
|             #[cfg(feature = "unstable-unspecified")] | ||||
|             instance_id, | ||||
|             instance_id: None, | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -240,7 +232,6 @@ pub enum Medium { | ||||
| /// this type using `ThirdPartyIdentifier::Init` / `.into()`.
 | ||||
| #[derive(Clone, Debug, Deserialize, Serialize)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| #[cfg_attr(test, derive(PartialEq))] | ||||
| pub struct ThirdPartyIdentifier { | ||||
|     /// The third party identifier address.
 | ||||
|     pub address: String, | ||||
| @ -255,6 +246,20 @@ pub struct ThirdPartyIdentifier { | ||||
|     pub added_at: MilliSecondsSinceUnixEpoch, | ||||
| } | ||||
| 
 | ||||
| impl Eq for ThirdPartyIdentifier {} | ||||
| 
 | ||||
| impl Hash for ThirdPartyIdentifier { | ||||
|     fn hash<H: Hasher>(&self, hasher: &mut H) { | ||||
|         (self.medium.as_str(), &self.address).hash(hasher); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl PartialEq for ThirdPartyIdentifier { | ||||
|     fn eq(&self, other: &ThirdPartyIdentifier) -> bool { | ||||
|         self.address == other.address && self.medium == other.medium | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// Initial set of fields of `ThirdPartyIdentifier`.
 | ||||
| ///
 | ||||
| /// This struct will not be updated even if additional fields are added to `ThirdPartyIdentifier`
 | ||||
|  | ||||
| @ -133,7 +133,41 @@ pub mod raw_body_endpoint { | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| pub mod query_map_endpoint { | ||||
| pub mod query_all_enum_endpoint { | ||||
|     use ruma_common::{ | ||||
|         api::{request, response, Metadata}, | ||||
|         metadata, | ||||
|     }; | ||||
| 
 | ||||
|     #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] | ||||
|     #[serde(untagged)] | ||||
|     pub enum MyCustomQueryEnum { | ||||
|         VariantA { field_a: String }, | ||||
|         VariantB { field_b: String }, | ||||
|     } | ||||
| 
 | ||||
|     const METADATA: Metadata = metadata! { | ||||
|         method: GET, | ||||
|         rate_limited: false, | ||||
|         authentication: None, | ||||
|         history: { | ||||
|             unstable => "/_matrix/some/query/map/endpoint", | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     /// Request type for the `query_all_enum_endpoint` endpoint.
 | ||||
|     #[request] | ||||
|     pub struct Request { | ||||
|         #[ruma_api(query_all)] | ||||
|         pub query: MyCustomQueryEnum, | ||||
|     } | ||||
| 
 | ||||
|     /// Response type for the `query_all_enum_endpoint` endpoint.
 | ||||
|     #[response] | ||||
|     pub struct Response {} | ||||
| } | ||||
| 
 | ||||
| pub mod query_all_vec_endpoint { | ||||
|     use ruma_common::{ | ||||
|         api::{request, response, Metadata}, | ||||
|         metadata, | ||||
| @ -148,14 +182,14 @@ pub mod query_map_endpoint { | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     /// Request type for the `newtype_body_endpoint` endpoint.
 | ||||
|     /// Request type for the `query_all_vec_endpoint` endpoint.
 | ||||
|     #[request] | ||||
|     pub struct Request { | ||||
|         #[ruma_api(query_map)] | ||||
|         #[ruma_api(query_all)] | ||||
|         pub fields: Vec<(String, String)>, | ||||
|     } | ||||
| 
 | ||||
|     /// Response type for the `newtype_body_endpoint` endpoint.
 | ||||
|     /// Response type for the `query_all_vec_endpoint` endpoint.
 | ||||
|     #[response] | ||||
|     pub struct Response {} | ||||
| } | ||||
|  | ||||
| @ -1,8 +1,21 @@ | ||||
| # [unreleased] | ||||
| 
 | ||||
| Bug fixes: | ||||
| 
 | ||||
| - Fix deserialization of `AnyGlobalAccountDataEvent` for variants with a type | ||||
|   fragment. | ||||
| - Fix serialization of `room::message::Relation` and `room::encrypted::Relation` | ||||
|   which could cause duplicate `rel_type` keys.  | ||||
| - `Restricted` no longer fails to deserialize when the `allow` field is missing | ||||
| 
 | ||||
| Improvements: | ||||
| 
 | ||||
| - Add support for encrypted stickers as sent by several bridges under the flag `compat-encrypted-stickers` | ||||
| - Add unstable support for MSC3489 `m.beacon` & `m.beacon_info` events | ||||
|   (unstable types `org.matrix.msc3489.beacon` & `org.matrix.msc3489.beacon_info`) | ||||
| - Stabilize support for muting in VoIP calls, according to Matrix 1.11 | ||||
| - All the root `Any*EventContent` types now have a `EventContentFromType` implementations | ||||
|   automatically derived by the `event_enum!` macro. | ||||
| 
 | ||||
| Breaking changes: | ||||
| 
 | ||||
|  | ||||
| @ -16,7 +16,7 @@ all-features = true | ||||
| [features] | ||||
| canonical-json = ["ruma-common/canonical-json"] | ||||
| html = ["dep:ruma-html"] | ||||
| markdown = ["pulldown-cmark"] | ||||
| markdown = ["dep:pulldown-cmark"] | ||||
| unstable-exhaustive-types = [] | ||||
| unstable-msc1767 = [] | ||||
| unstable-msc2448 = [] | ||||
| @ -29,10 +29,10 @@ unstable-msc3245 = ["unstable-msc3246"] | ||||
| # https://github.com/matrix-org/matrix-spec-proposals/blob/83f6c5b469c1d78f714e335dcaa25354b255ffa5/proposals/3245-voice-messages.md | ||||
| unstable-msc3245-v1-compat = [] | ||||
| unstable-msc3246 = ["unstable-msc3927"] | ||||
| unstable-msc3291 = [] | ||||
| unstable-msc3381 = ["unstable-msc1767"] | ||||
| unstable-msc3401 = [] | ||||
| unstable-msc3488 = ["unstable-msc1767"] | ||||
| unstable-msc3489 = ["unstable-msc3488"] | ||||
| unstable-msc3551 = ["unstable-msc3956"] | ||||
| unstable-msc3552 = ["unstable-msc3551"] | ||||
| unstable-msc3553 = ["unstable-msc3552"] | ||||
| @ -41,7 +41,7 @@ unstable-msc3927 = ["unstable-msc3551"] | ||||
| unstable-msc3954 = ["unstable-msc1767"] | ||||
| unstable-msc3955 = ["unstable-msc1767"] | ||||
| unstable-msc3956 = ["unstable-msc1767"] | ||||
| unstable-msc4075 = [] | ||||
| unstable-msc4075 = ["unstable-msc3401"] | ||||
| unstable-pdu = [] | ||||
| 
 | ||||
| # Allow some mandatory fields to be missing, defaulting them to an empty string | ||||
| @ -65,7 +65,7 @@ indexmap = { version = "2.0.0", features = ["serde"] } | ||||
| js_int = { workspace = true, features = ["serde"] } | ||||
| js_option = "0.1.0" | ||||
| percent-encoding = "2.1.0" | ||||
| pulldown-cmark = { version = "0.10.3", optional = true, default-features = false, features = ["html"] } | ||||
| pulldown-cmark = { version = "0.11.0", optional = true, default-features = false, features = ["html"] } | ||||
| regex = { version = "1.5.6", default-features = false, features = ["std", "perf"] } | ||||
| ruma-common = { workspace = true } | ||||
| ruma-html = { workspace = true, optional = true } | ||||
|  | ||||
							
								
								
									
										43
									
								
								crates/ruma-events/src/beacon.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								crates/ruma-events/src/beacon.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,43 @@ | ||||
| //! Types for the `org.matrix.msc3489.beacon` event, the unstable version of
 | ||||
| //! `m.beacon` ([MSC3489]).
 | ||||
| //!
 | ||||
| //! [MSC3489]: https://github.com/matrix-org/matrix-spec-proposals/pull/3489
 | ||||
| 
 | ||||
| use ruma_common::{MilliSecondsSinceUnixEpoch, OwnedEventId}; | ||||
| use ruma_events::{location::LocationContent, relation::Reference}; | ||||
| use ruma_macros::EventContent; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| 
 | ||||
| /// The content of a beacon.
 | ||||
| #[derive(Clone, Debug, Serialize, Deserialize, EventContent)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| #[ruma_event(type = "org.matrix.msc3672.beacon", alias = "m.beacon", kind = MessageLike)] | ||||
| pub struct BeaconEventContent { | ||||
|     /// The beacon_info event id this relates to.
 | ||||
|     #[serde(rename = "m.relates_to")] | ||||
|     pub relates_to: Reference, | ||||
| 
 | ||||
|     /// The location of the beacon.
 | ||||
|     #[serde(rename = "org.matrix.msc3488.location")] | ||||
|     pub location: LocationContent, | ||||
| 
 | ||||
|     /// The timestamp of the event.
 | ||||
|     #[serde(rename = "org.matrix.msc3488.ts")] | ||||
|     pub ts: MilliSecondsSinceUnixEpoch, | ||||
| } | ||||
| 
 | ||||
| impl BeaconEventContent { | ||||
|     /// Creates a new `BeaconEventContent` with the given beacon_info event id, geo uri and
 | ||||
|     /// optional ts. If ts is None, the current time will be used.
 | ||||
|     pub fn new( | ||||
|         beacon_info_event_id: OwnedEventId, | ||||
|         geo_uri: String, | ||||
|         ts: Option<MilliSecondsSinceUnixEpoch>, | ||||
|     ) -> Self { | ||||
|         Self { | ||||
|             relates_to: Reference::new(beacon_info_event_id), | ||||
|             location: LocationContent::new(geo_uri), | ||||
|             ts: ts.unwrap_or_else(MilliSecondsSinceUnixEpoch::now), | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										83
									
								
								crates/ruma-events/src/beacon_info.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								crates/ruma-events/src/beacon_info.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,83 @@ | ||||
| //! Types for the `org.matrix.msc3489.beacon_info` state event, the unstable version of
 | ||||
| //! `m.beacon_info` ([MSC3489]).
 | ||||
| //!
 | ||||
| //! [MSC3489]: https://github.com/matrix-org/matrix-spec-proposals/pull/3489
 | ||||
| 
 | ||||
| use std::time::{Duration, SystemTime}; | ||||
| 
 | ||||
| use ruma_common::{MilliSecondsSinceUnixEpoch, OwnedUserId}; | ||||
| use ruma_macros::EventContent; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| 
 | ||||
| use crate::location::AssetContent; | ||||
| 
 | ||||
| /// The content of a beacon_info state.
 | ||||
| #[derive(Clone, Debug, Deserialize, Serialize, EventContent)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| #[ruma_event(
 | ||||
|     type = "org.matrix.msc3672.beacon_info", alias = "m.beacon_info", kind = State, state_key_type = OwnedUserId | ||||
| )] | ||||
| pub struct BeaconInfoEventContent { | ||||
|     /// The description of the location.
 | ||||
|     ///
 | ||||
|     /// It should be used to label the location on a map.
 | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub description: Option<String>, | ||||
| 
 | ||||
|     /// Whether the user starts sharing their location.
 | ||||
|     pub live: bool, | ||||
| 
 | ||||
|     /// The time when location sharing started.
 | ||||
|     #[serde(rename = "org.matrix.msc3488.ts")] | ||||
|     pub ts: MilliSecondsSinceUnixEpoch, | ||||
| 
 | ||||
|     /// The duration that the location sharing will be live.
 | ||||
|     ///
 | ||||
|     /// Meaning that the location will stop being shared at `ts + timeout`.
 | ||||
|     #[serde(default, with = "ruma_common::serde::duration::ms")] | ||||
|     pub timeout: Duration, | ||||
| 
 | ||||
|     /// The asset that this message refers to.
 | ||||
|     #[serde(default, rename = "org.matrix.msc3488.asset")] | ||||
|     pub asset: AssetContent, | ||||
| } | ||||
| 
 | ||||
| impl BeaconInfoEventContent { | ||||
|     /// Creates a new `BeaconInfoEventContent` with the given description, live, timeout and
 | ||||
|     /// optional ts. If ts is None, the current time will be used.
 | ||||
|     pub fn new( | ||||
|         description: Option<String>, | ||||
|         timeout: Duration, | ||||
|         live: bool, | ||||
|         ts: Option<MilliSecondsSinceUnixEpoch>, | ||||
|     ) -> Self { | ||||
|         Self { | ||||
|             description, | ||||
|             live, | ||||
|             ts: ts.unwrap_or_else(MilliSecondsSinceUnixEpoch::now), | ||||
|             timeout, | ||||
|             asset: Default::default(), | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /// Starts the beacon_info being live.
 | ||||
|     pub fn start(&mut self) { | ||||
|         self.live = true; | ||||
|     } | ||||
| 
 | ||||
|     /// Stops the beacon_info from being live.
 | ||||
|     pub fn stop(&mut self) { | ||||
|         self.live = false; | ||||
|     } | ||||
| 
 | ||||
|     /// Start time plus its timeout, it returns `false`, indicating that the beacon is not live.
 | ||||
|     /// Otherwise, it returns `true`.
 | ||||
|     pub fn is_live(&self) -> bool { | ||||
|         self.live | ||||
|             && self | ||||
|                 .ts | ||||
|                 .to_system_time() | ||||
|                 .and_then(|t| t.checked_add(self.timeout)) | ||||
|                 .is_some_and(|t| t > SystemTime::now()) | ||||
|     } | ||||
| } | ||||
| @ -12,7 +12,6 @@ pub mod negotiate; | ||||
| #[cfg(feature = "unstable-msc4075")] | ||||
| pub mod notify; | ||||
| pub mod reject; | ||||
| #[cfg(feature = "unstable-msc3291")] | ||||
| pub mod sdp_stream_metadata_changed; | ||||
| pub mod select_answer; | ||||
| 
 | ||||
| @ -59,14 +58,12 @@ pub struct StreamMetadata { | ||||
|     /// Whether the audio track of the stream is muted.
 | ||||
|     ///
 | ||||
|     /// Defaults to `false`.
 | ||||
|     #[cfg(feature = "unstable-msc3291")] | ||||
|     #[serde(default, skip_serializing_if = "ruma_common::serde::is_default")] | ||||
|     pub audio_muted: bool, | ||||
| 
 | ||||
|     /// Whether the video track of the stream is muted.
 | ||||
|     ///
 | ||||
|     /// Defaults to `false`.
 | ||||
|     #[cfg(feature = "unstable-msc3291")] | ||||
|     #[serde(default, skip_serializing_if = "ruma_common::serde::is_default")] | ||||
|     pub video_muted: bool, | ||||
| } | ||||
| @ -74,13 +71,7 @@ pub struct StreamMetadata { | ||||
| impl StreamMetadata { | ||||
|     /// Creates a new `StreamMetadata` with the given purpose.
 | ||||
|     pub fn new(purpose: StreamPurpose) -> Self { | ||||
|         Self { | ||||
|             purpose, | ||||
|             #[cfg(feature = "unstable-msc3291")] | ||||
|             audio_muted: false, | ||||
|             #[cfg(feature = "unstable-msc3291")] | ||||
|             video_muted: false, | ||||
|         } | ||||
|         Self { purpose, audio_muted: false, video_muted: false } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -1,65 +1,118 @@ | ||||
| //! Types for matrixRTC state events ([MSC3401]).
 | ||||
| //! Types for MatrixRTC state events ([MSC3401]).
 | ||||
| //!
 | ||||
| //! This implements a newer/updated version of MSC3401.
 | ||||
| //!
 | ||||
| //! [MSC3401]: https://github.com/matrix-org/matrix-spec-proposals/pull/3401
 | ||||
| 
 | ||||
| use std::time::Duration; | ||||
| mod focus; | ||||
| mod member_data; | ||||
| 
 | ||||
| use as_variant::as_variant; | ||||
| use ruma_common::{serde::StringEnum, MilliSecondsSinceUnixEpoch, OwnedUserId}; | ||||
| use ruma_macros::EventContent; | ||||
| pub use focus::*; | ||||
| pub use member_data::*; | ||||
| use ruma_common::{MilliSecondsSinceUnixEpoch, OwnedUserId}; | ||||
| use ruma_macros::{EventContent, StringEnum}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use tracing::warn; | ||||
| 
 | ||||
| use crate::PrivOwnedStr; | ||||
| use crate::{ | ||||
|     PossiblyRedactedStateEventContent, PrivOwnedStr, RedactContent, RedactedStateEventContent, | ||||
|     StateEventType, | ||||
| }; | ||||
| 
 | ||||
| /// The member state event for a matrixRTC session.
 | ||||
| /// The member state event for a MatrixRTC session.
 | ||||
| ///
 | ||||
| /// This is the object containing all the data related to a matrix users participation in a
 | ||||
| /// matrixRTC session. It consists of memberships / sessions.
 | ||||
| #[derive(Clone, Debug, Serialize, Deserialize, EventContent)] | ||||
| #[cfg_attr(test, derive(PartialEq))] | ||||
| /// This is the object containing all the data related to a Matrix users participation in a
 | ||||
| /// MatrixRTC session.
 | ||||
| ///
 | ||||
| /// This is a unit struct with the enum [`CallMemberEventContent`] because a Ruma state event cannot
 | ||||
| /// be an enum and we need this to be an untagged enum for parsing purposes. (see
 | ||||
| /// [`CallMemberEventContent`])
 | ||||
| ///
 | ||||
| /// This struct also exposes allows to call the methods from [`CallMemberEventContent`].
 | ||||
| #[derive(Clone, Debug, Serialize, Deserialize, EventContent, PartialEq)] | ||||
| #[ruma_event(type = "org.matrix.msc3401.call.member", kind = State, state_key_type = OwnedUserId, custom_redacted, custom_possibly_redacted)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| #[ruma_event(type = "org.matrix.msc3401.call.member", kind = State, state_key_type = OwnedUserId)] | ||||
| pub struct CallMemberEventContent { | ||||
|     /// A list of all the memberships that user currently has in this room.
 | ||||
|     ///
 | ||||
|     /// There can be multiple ones in cases the user participates with multiple devices or there
 | ||||
|     /// are multiple RTC applications running.
 | ||||
|     ///
 | ||||
|     /// e.g. a call and a spacial experience.
 | ||||
|     ///
 | ||||
|     /// Important: This includes expired memberships.
 | ||||
|     /// To retrieve a list including only valid memberships,
 | ||||
|     /// see [`active_memberships`](CallMemberEventContent::active_memberships).
 | ||||
|     pub memberships: Vec<Membership>, | ||||
| #[serde(untagged)] | ||||
| pub enum CallMemberEventContent { | ||||
|     /// The legacy format for m.call.member events. (An array of memberships. The devices of one
 | ||||
|     /// user.)
 | ||||
|     LegacyContent(LegacyMembershipContent), | ||||
|     /// Normal membership events. One event per membership. Multiple state keys will
 | ||||
|     /// be used to describe multiple devices for one user.
 | ||||
|     SessionContent(SessionMembershipData), | ||||
|     /// An empty content means this user has been in a rtc session but is not anymore.
 | ||||
|     Empty(EmptyMembershipData), | ||||
| } | ||||
| 
 | ||||
| impl CallMemberEventContent { | ||||
|     /// Creates a new `CallMemberEventContent`.
 | ||||
|     pub fn new(memberships: Vec<Membership>) -> Self { | ||||
|         Self { memberships } | ||||
|     /// Creates a new [`CallMemberEventContent`] with [`LegacyMembershipData`].
 | ||||
|     pub fn new_legacy(memberships: Vec<LegacyMembershipData>) -> Self { | ||||
|         Self::LegacyContent(LegacyMembershipContent { | ||||
|             memberships, //: memberships.into_iter().map(MembershipData::Legacy).collect(),
 | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     /// Creates a new [`CallMemberEventContent`] with [`SessionMembershipData`].
 | ||||
|     pub fn new( | ||||
|         application: Application, | ||||
|         device_id: String, | ||||
|         focus_active: ActiveFocus, | ||||
|         foci_preferred: Vec<Focus>, | ||||
|         created_ts: Option<MilliSecondsSinceUnixEpoch>, | ||||
|     ) -> Self { | ||||
|         Self::SessionContent(SessionMembershipData { | ||||
|             application, | ||||
|             device_id, | ||||
|             focus_active, | ||||
|             foci_preferred, | ||||
|             created_ts, | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     /// Creates a new Empty [`CallMemberEventContent`] representing a left membership.
 | ||||
|     pub fn new_empty(leave_reason: Option<LeaveReason>) -> Self { | ||||
|         Self::Empty(EmptyMembershipData { leave_reason }) | ||||
|     } | ||||
|     /// All non expired memberships in this member event.
 | ||||
|     ///
 | ||||
|     /// In most cases you want tu use this method instead of the public memberships field.
 | ||||
|     /// In most cases you want to use this method instead of the public memberships field.
 | ||||
|     /// The memberships field will also include expired events.
 | ||||
|     ///
 | ||||
|     /// This copies all the memberships and converts them
 | ||||
|     /// # Arguments
 | ||||
|     ///
 | ||||
|     /// * `origin_server_ts` - optionally the `origin_server_ts` can be passed as a fallback in case
 | ||||
|     ///   the Membership does not contain `created_ts`. (`origin_server_ts` will be ignored if
 | ||||
|     ///   `created_ts` is `Some`)
 | ||||
|     /// * `origin_server_ts` - optionally the `origin_server_ts` can be passed as a fallback in the
 | ||||
|     ///   Membership does not contain [`LegacyMembershipData::created_ts`]. (`origin_server_ts` will
 | ||||
|     ///   be ignored if [`LegacyMembershipData::created_ts`] is `Some`)
 | ||||
|     pub fn active_memberships( | ||||
|         &self, | ||||
|         origin_server_ts: Option<MilliSecondsSinceUnixEpoch>, | ||||
|     ) -> Vec<&Membership> { | ||||
|         self.memberships.iter().filter(|m| !m.is_expired(origin_server_ts)).collect() | ||||
|     ) -> Vec<MembershipData<'_>> { | ||||
|         match self { | ||||
|             CallMemberEventContent::LegacyContent(content) => { | ||||
|                 content.active_memberships(origin_server_ts) | ||||
|             } | ||||
|             CallMemberEventContent::SessionContent(content) => { | ||||
|                 [content].map(MembershipData::Session).to_vec() | ||||
|             } | ||||
|             CallMemberEventContent::Empty(_) => Vec::new(), | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /// Set the `created_ts` of each [Membership] in this event.
 | ||||
|     /// All the memberships for this event. Can only contain multiple elements in the case of legacy
 | ||||
|     /// `m.call.member` state events.
 | ||||
|     pub fn memberships(&self) -> Vec<MembershipData<'_>> { | ||||
|         match self { | ||||
|             CallMemberEventContent::LegacyContent(content) => { | ||||
|                 content.memberships.iter().map(MembershipData::Legacy).collect() | ||||
|             } | ||||
|             CallMemberEventContent::SessionContent(content) => { | ||||
|                 [content].map(MembershipData::Session).to_vec() | ||||
|             } | ||||
|             CallMemberEventContent::Empty(_) => Vec::new(), | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /// Set the `created_ts` of each [`MembershipData::Legacy`] in this event.
 | ||||
|     ///
 | ||||
|     /// Each call member event contains the `origin_server_ts` and `content.create_ts`.
 | ||||
|     /// `content.create_ts` is undefined for the initial event of a session (because the
 | ||||
| @ -68,257 +121,130 @@ impl CallMemberEventContent { | ||||
|     /// (This allows to use `MinimalStateEvents` and still be able to determine if a membership is
 | ||||
|     /// expired)
 | ||||
|     pub fn set_created_ts_if_none(&mut self, origin_server_ts: MilliSecondsSinceUnixEpoch) { | ||||
|         self.memberships.iter_mut().for_each(|m| { | ||||
|         match self { | ||||
|             CallMemberEventContent::LegacyContent(content) => { | ||||
|                 content.memberships.iter_mut().for_each(|m: &mut LegacyMembershipData| { | ||||
|                     m.created_ts.get_or_insert(origin_server_ts); | ||||
|                 }); | ||||
|             } | ||||
|             CallMemberEventContent::SessionContent(m) => { | ||||
|                 m.created_ts.get_or_insert(origin_server_ts); | ||||
|             } | ||||
|             _ => (), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// A membership describes one of the sessions this user currently partakes.
 | ||||
| ///
 | ||||
| /// The application defines the type of the session.
 | ||||
| #[derive(Clone, Debug, Serialize, Deserialize)] | ||||
| #[cfg_attr(test, derive(PartialEq))] | ||||
| /// This describes the CallMember event if the user is not part of the current session.
 | ||||
| #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| pub struct Membership { | ||||
|     /// The type of the matrixRTC session the membership belongs to.
 | ||||
|     ///
 | ||||
|     /// e.g. call, spacial, document...
 | ||||
|     #[serde(flatten)] | ||||
|     pub application: Application, | ||||
| 
 | ||||
|     /// The device id of this membership.
 | ||||
|     ///
 | ||||
|     /// The same user can join with their phone/computer.
 | ||||
|     pub device_id: String, | ||||
| 
 | ||||
|     /// The duration in milliseconds relative to the time this membership joined
 | ||||
|     /// during which the membership is valid.
 | ||||
|     ///
 | ||||
|     /// The time a member has joined is defined as:
 | ||||
|     /// `MIN(content.created_ts, event.origin_server_ts)`
 | ||||
|     #[serde(with = "ruma_common::serde::duration::ms")] | ||||
|     pub expires: Duration, | ||||
| 
 | ||||
|     /// Stores a copy of the `origin_server_ts` of the initial session event.
 | ||||
|     ///
 | ||||
|     /// If the membership is updated this field will be used to track to
 | ||||
|     /// original `origin_server_ts`.
 | ||||
| pub struct EmptyMembershipData { | ||||
|     /// An empty call member state event can optionally contain a leave reason.
 | ||||
|     /// If it is `None` the user has left the call ordinarily. (Intentional hangup)  
 | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub created_ts: Option<MilliSecondsSinceUnixEpoch>, | ||||
| 
 | ||||
|     /// A list of the foci in use for this membership.
 | ||||
|     pub foci_active: Vec<Focus>, | ||||
| 
 | ||||
|     /// The id of the membership.
 | ||||
|     ///
 | ||||
|     /// This is required to guarantee uniqueness of the event.
 | ||||
|     /// Sending the same state event twice to synapse makes the HS drop the second one and return
 | ||||
|     /// 200.
 | ||||
|     #[serde(rename = "membershipID")] | ||||
|     pub membership_id: String, | ||||
|     pub leave_reason: Option<LeaveReason>, | ||||
| } | ||||
| 
 | ||||
| impl Membership { | ||||
|     /// The application of the membership is "m.call" and the scope is "m.room".
 | ||||
|     pub fn is_room_call(&self) -> bool { | ||||
|         as_variant!(&self.application, Application::Call) | ||||
|             .is_some_and(|call| call.scope == CallScope::Room) | ||||
|     } | ||||
| 
 | ||||
|     /// The application of the membership is "m.call".
 | ||||
|     pub fn is_call(&self) -> bool { | ||||
|         as_variant!(&self.application, Application::Call).is_some() | ||||
|     } | ||||
| 
 | ||||
|     /// Checks if the event is expired.
 | ||||
|     ///
 | ||||
|     /// Defaults to using `created_ts` of the `Membership`.
 | ||||
|     /// If no `origin_server_ts` is provided and the event does not contain `created_ts`
 | ||||
|     /// the event will be considered as not expired.
 | ||||
|     /// In this case, a warning will be logged.
 | ||||
|     ///
 | ||||
|     /// # Arguments
 | ||||
|     ///
 | ||||
|     /// * `origin_server_ts` - a fallback if `created_ts` is not present
 | ||||
|     pub fn is_expired(&self, origin_server_ts: Option<MilliSecondsSinceUnixEpoch>) -> bool { | ||||
|         let ev_created_ts = self.created_ts.or(origin_server_ts); | ||||
| 
 | ||||
|         if let Some(ev_created_ts) = ev_created_ts { | ||||
|             let now = MilliSecondsSinceUnixEpoch::now().to_system_time(); | ||||
|             let expire_ts = ev_created_ts.to_system_time().map(|t| t + self.expires); | ||||
|             now > expire_ts | ||||
|         } else { | ||||
|             // This should not be reached since we only allow events that have copied over
 | ||||
|             // the origin server ts. `set_created_ts_if_none`
 | ||||
|             warn!("Encountered a Call Member state event where the origin_ts (or origin_server_ts) could not be found.\ | ||||
|             It is treated as a non expired event but this might be wrong.");
 | ||||
|             false | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// Initial set of fields of [`Membership`].
 | ||||
| #[derive(Debug)] | ||||
| #[allow(clippy::exhaustive_structs)] | ||||
| pub struct MembershipInit { | ||||
|     /// The type of the matrixRTC session the membership belongs to.
 | ||||
|     ///
 | ||||
|     /// e.g. call, spacial, document...
 | ||||
|     pub application: Application, | ||||
| 
 | ||||
|     /// The device id of this membership.
 | ||||
|     ///
 | ||||
|     /// The same user can join with their phone/computer.
 | ||||
|     pub device_id: String, | ||||
| 
 | ||||
|     /// The duration in milliseconds relative to the time this membership joined
 | ||||
|     /// during which the membership is valid.
 | ||||
|     ///
 | ||||
|     /// The time a member has joined is defined as:
 | ||||
|     /// `MIN(content.created_ts, event.origin_server_ts)`
 | ||||
|     pub expires: Duration, | ||||
| 
 | ||||
|     /// A list of the focuses (foci) in use for this membership.
 | ||||
|     pub foci_active: Vec<Focus>, | ||||
| 
 | ||||
|     /// The id of the membership.
 | ||||
|     ///
 | ||||
|     /// This is required to guarantee uniqueness of the event.
 | ||||
|     /// Sending the same state event twice to synapse makes the HS drop the second one and return
 | ||||
|     /// 200.
 | ||||
|     pub membership_id: String, | ||||
| } | ||||
| 
 | ||||
| impl From<MembershipInit> for Membership { | ||||
|     fn from(init: MembershipInit) -> Self { | ||||
|         let MembershipInit { application, device_id, expires, foci_active, membership_id } = init; | ||||
|         Self { application, device_id, expires, created_ts: None, foci_active, membership_id } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// Description of the SFU/Focus a membership can be connected to.
 | ||||
| ///
 | ||||
| /// A focus can be any server powering the matrixRTC session (SFU,
 | ||||
| /// MCU). It serves as a node to redistribute RTC streams.
 | ||||
| #[derive(Clone, Debug, Serialize, Deserialize)] | ||||
| #[cfg_attr(test, derive(PartialEq))] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| #[serde(tag = "type", rename_all = "snake_case")] | ||||
| pub enum Focus { | ||||
|     /// Livekit is one possible type of SFU/Focus that can be used for a matrixRTC session.
 | ||||
|     Livekit(LivekitFocus), | ||||
| } | ||||
| 
 | ||||
| /// The fields to describe livekit as an `active_foci`.
 | ||||
| #[derive(Clone, Debug, Serialize, Deserialize)] | ||||
| #[cfg_attr(test, derive(PartialEq))] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| pub struct LivekitFocus { | ||||
|     /// The alias where the livekit sessions can be reached.
 | ||||
|     #[serde(rename = "livekit_alias")] | ||||
|     pub alias: String, | ||||
| 
 | ||||
|     /// The url of the jwt server for the livekit instance.
 | ||||
|     #[serde(rename = "livekit_service_url")] | ||||
|     pub service_url: String, | ||||
| } | ||||
| 
 | ||||
| impl LivekitFocus { | ||||
|     /// Initialize a [`LivekitFocus`].
 | ||||
|     ///
 | ||||
|     /// # Arguments
 | ||||
|     ///
 | ||||
|     /// * `alias` - The alias where the livekit sessions can be reached.
 | ||||
|     /// * `service_url` - The url of the jwt server for the livekit instance.
 | ||||
|     pub fn new(alias: String, service_url: String) -> Self { | ||||
|         Self { alias, service_url } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// The type of the matrixRTC session.
 | ||||
| ///
 | ||||
| /// This is not the application/client used by the user but the
 | ||||
| /// type of matrixRTC session e.g. calling (`m.call`), third-room, whiteboard could be
 | ||||
| /// possible applications.
 | ||||
| #[derive(Clone, Debug, Serialize, Deserialize)] | ||||
| #[cfg_attr(test, derive(PartialEq))] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| #[serde(tag = "application")] | ||||
| pub enum Application { | ||||
|     #[serde(rename = "m.call")] | ||||
|     /// A VoIP call.
 | ||||
|     Call(CallApplicationContent), | ||||
| } | ||||
| 
 | ||||
| /// Call specific parameters membership parameters.
 | ||||
| #[derive(Clone, Debug, Serialize, Deserialize)] | ||||
| #[cfg_attr(test, derive(PartialEq))] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| pub struct CallApplicationContent { | ||||
|     /// An identifier for calls.
 | ||||
|     ///
 | ||||
|     /// All members using the same `call_id` will end up in the same call.
 | ||||
|     ///
 | ||||
|     /// Does not need to be a uuid.
 | ||||
|     ///
 | ||||
|     /// `""` is used for room scoped calls.
 | ||||
|     pub call_id: String, | ||||
| 
 | ||||
|     /// Who owns/joins/controls (can modify) the call.
 | ||||
|     pub scope: CallScope, | ||||
| } | ||||
| 
 | ||||
| impl CallApplicationContent { | ||||
|     /// Initialize a [`CallApplicationContent`].
 | ||||
|     ///
 | ||||
|     /// # Arguments
 | ||||
|     ///
 | ||||
|     /// * `call_id` - An identifier for calls. All members using the same `call_id` will end up in
 | ||||
|     ///   the same call. Does not need to be a uuid. `""` is used for room scoped calls.
 | ||||
|     /// * `scope` - Who owns/joins/controls (can modify) the call.
 | ||||
|     pub fn new(call_id: String, scope: CallScope) -> Self { | ||||
|         Self { call_id, scope } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// The call scope defines different call ownership models.
 | ||||
| #[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))] | ||||
| /// This is the optional value for an empty membership event content:
 | ||||
| /// [`CallMemberEventContent::Empty`]. It is used when the user disconnected and a Future ([MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140))
 | ||||
| /// was used to update the membership after the client was not reachable anymore.  
 | ||||
| #[derive(Clone, PartialEq, StringEnum)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| #[ruma_enum(rename_all = "m.snake_case")] | ||||
| pub enum CallScope { | ||||
|     /// A call which every user of a room can join and create.
 | ||||
|     ///
 | ||||
|     /// There is no particular name associated with it.
 | ||||
|     ///
 | ||||
|     /// There can only be one per room.
 | ||||
|     Room, | ||||
| 
 | ||||
|     /// A user call is owned by a user.
 | ||||
|     ///
 | ||||
|     /// Each user can create one there can be multiple per room. They are started and ended by the
 | ||||
|     /// owning user.
 | ||||
|     User, | ||||
| 
 | ||||
| pub enum LeaveReason { | ||||
|     /// The user left the call by losing network connection or closing  
 | ||||
|     /// the client before it was able to send the leave event.
 | ||||
|     LostConnection, | ||||
|     #[doc(hidden)] | ||||
|     _Custom(PrivOwnedStr), | ||||
| } | ||||
| 
 | ||||
| impl RedactContent for CallMemberEventContent { | ||||
|     type Redacted = RedactedCallMemberEventContent; | ||||
| 
 | ||||
|     fn redact(self, _version: &ruma_common::RoomVersionId) -> Self::Redacted { | ||||
|         RedactedCallMemberEventContent {} | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// The PossiblyRedacted version of [`CallMemberEventContent`].
 | ||||
| ///
 | ||||
| /// Since [`CallMemberEventContent`] has the [`CallMemberEventContent::Empty`] state it already is
 | ||||
| /// compatible with the redacted version of the state event content.
 | ||||
| pub type PossiblyRedactedCallMemberEventContent = CallMemberEventContent; | ||||
| 
 | ||||
| impl PossiblyRedactedStateEventContent for PossiblyRedactedCallMemberEventContent { | ||||
|     type StateKey = OwnedUserId; | ||||
| } | ||||
| 
 | ||||
| /// The Redacted version of [`CallMemberEventContent`].
 | ||||
| #[derive(Clone, Debug, Deserialize, Serialize)] | ||||
| #[allow(clippy::exhaustive_structs)] | ||||
| pub struct RedactedCallMemberEventContent {} | ||||
| 
 | ||||
| impl ruma_events::content::EventContent for RedactedCallMemberEventContent { | ||||
|     type EventType = StateEventType; | ||||
|     fn event_type(&self) -> Self::EventType { | ||||
|         StateEventType::CallMember | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl RedactedStateEventContent for RedactedCallMemberEventContent { | ||||
|     type StateKey = OwnedUserId; | ||||
| } | ||||
| 
 | ||||
| /// Legacy content with an array of memberships. See also: [`CallMemberEventContent`]
 | ||||
| #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| pub struct LegacyMembershipContent { | ||||
|     /// A list of all the memberships that user currently has in this room.
 | ||||
|     ///
 | ||||
|     /// There can be multiple ones in case the user participates with multiple devices or there
 | ||||
|     /// are multiple RTC applications running.
 | ||||
|     ///
 | ||||
|     /// e.g. a call and a spacial experience.
 | ||||
|     ///
 | ||||
|     /// Important: This includes expired memberships.
 | ||||
|     /// To retrieve a list including only valid memberships,
 | ||||
|     /// see [`active_memberships`](CallMemberEventContent::active_memberships).
 | ||||
|     memberships: Vec<LegacyMembershipData>, | ||||
| } | ||||
| 
 | ||||
| impl LegacyMembershipContent { | ||||
|     fn active_memberships( | ||||
|         &self, | ||||
|         origin_server_ts: Option<MilliSecondsSinceUnixEpoch>, | ||||
|     ) -> Vec<MembershipData<'_>> { | ||||
|         self.memberships | ||||
|             .iter() | ||||
|             .filter(|m| !m.is_expired(origin_server_ts)) | ||||
|             .map(MembershipData::Legacy) | ||||
|             .collect() | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use std::time::Duration; | ||||
| 
 | ||||
|     use ruma_common::MilliSecondsSinceUnixEpoch as TS; | ||||
|     use serde_json::json; | ||||
|     use assert_matches2::assert_matches; | ||||
|     use ruma_common::{MilliSecondsSinceUnixEpoch as TS, OwnedEventId, OwnedRoomId, OwnedUserId}; | ||||
|     use serde_json::{from_value as from_json_value, json}; | ||||
| 
 | ||||
|     use super::{ | ||||
|         Application, CallApplicationContent, CallMemberEventContent, CallScope, Focus, | ||||
|         LivekitFocus, Membership, | ||||
|         focus::{ActiveFocus, ActiveLivekitFocus, Focus, LivekitFocus}, | ||||
|         member_data::{ | ||||
|             Application, CallApplicationContent, CallScope, LegacyMembershipData, MembershipData, | ||||
|         }, | ||||
|         CallMemberEventContent, | ||||
|     }; | ||||
|     use crate::{ | ||||
|         call::member::{EmptyMembershipData, FocusSelection, SessionMembershipData}, | ||||
|         AnyStateEvent, StateEvent, | ||||
|     }; | ||||
| 
 | ||||
|     fn create_call_member_event_content() -> CallMemberEventContent { | ||||
|         CallMemberEventContent::new(vec![Membership { | ||||
|     fn create_call_member_legacy_event_content() -> CallMemberEventContent { | ||||
|         CallMemberEventContent::new_legacy(vec![LegacyMembershipData { | ||||
|             application: Application::Call(CallApplicationContent { | ||||
|                 call_id: "123456".to_owned(), | ||||
|                 scope: CallScope::Room, | ||||
| @ -334,8 +260,60 @@ mod tests { | ||||
|         }]) | ||||
|     } | ||||
| 
 | ||||
|     fn create_call_member_event_content() -> CallMemberEventContent { | ||||
|         CallMemberEventContent::new( | ||||
|             Application::Call(CallApplicationContent { | ||||
|                 call_id: "123456".to_owned(), | ||||
|                 scope: CallScope::Room, | ||||
|             }), | ||||
|             "ABCDE".to_owned(), | ||||
|             ActiveFocus::Livekit(ActiveLivekitFocus { | ||||
|                 focus_select: FocusSelection::OldestMembership, | ||||
|             }), | ||||
|             vec![Focus::Livekit(LivekitFocus { | ||||
|                 alias: "1".to_owned(), | ||||
|                 service_url: "https://livekit.com".to_owned(), | ||||
|             })], | ||||
|             None, | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn serialize_call_member_event_content() { | ||||
|         let call_member_event = &json!({ | ||||
|             "application": "m.call", | ||||
|             "call_id": "123456", | ||||
|             "scope": "m.room", | ||||
|             "device_id": "ABCDE", | ||||
|             "foci_preferred": [ | ||||
|                 { | ||||
|                     "livekit_alias": "1", | ||||
|                     "livekit_service_url": "https://livekit.com", | ||||
|                     "type": "livekit" | ||||
|                 } | ||||
|             ], | ||||
|             "focus_active":{ | ||||
|                 "type":"livekit", | ||||
|                 "focus_select":"oldest_membership" | ||||
|             } | ||||
|         }); | ||||
|         assert_eq!( | ||||
|             call_member_event, | ||||
|             &serde_json::to_value(create_call_member_event_content()).unwrap() | ||||
|         ); | ||||
| 
 | ||||
|         let empty_call_member_event = &json!({}); | ||||
|         assert_eq!( | ||||
|             empty_call_member_event, | ||||
|             &serde_json::to_value(CallMemberEventContent::Empty(EmptyMembershipData { | ||||
|                 leave_reason: None | ||||
|             })) | ||||
|             .unwrap() | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn serialize_legacy_call_member_event_content() { | ||||
|         let call_member_event = &json!({ | ||||
|             "memberships": [ | ||||
|                 { | ||||
| @ -358,14 +336,62 @@ mod tests { | ||||
| 
 | ||||
|         assert_eq!( | ||||
|             call_member_event, | ||||
|             &serde_json::to_value(create_call_member_event_content()).unwrap() | ||||
|             &serde_json::to_value(create_call_member_legacy_event_content()).unwrap() | ||||
|         ); | ||||
|     } | ||||
|     #[test] | ||||
|     fn deserialize_call_member_event_content() { | ||||
|         let call_member_ev = CallMemberEventContent::new( | ||||
|             Application::Call(CallApplicationContent { | ||||
|                 call_id: "123456".to_owned(), | ||||
|                 scope: CallScope::Room, | ||||
|             }), | ||||
|             "THIS_DEVICE".to_owned(), | ||||
|             ActiveFocus::Livekit(ActiveLivekitFocus { | ||||
|                 focus_select: FocusSelection::OldestMembership, | ||||
|             }), | ||||
|             vec![Focus::Livekit(LivekitFocus { | ||||
|                 alias: "room1".to_owned(), | ||||
|                 service_url: "https://livekit1.com".to_owned(), | ||||
|             })], | ||||
|             None, | ||||
|         ); | ||||
| 
 | ||||
|         let call_member_ev_json = json!({ | ||||
|             "application": "m.call", | ||||
|             "call_id": "123456", | ||||
|             "scope": "m.room", | ||||
|             "device_id": "THIS_DEVICE", | ||||
|             "focus_active":{ | ||||
|                 "type": "livekit", | ||||
|                 "focus_select": "oldest_membership" | ||||
|             }, | ||||
|             "foci_preferred": [ | ||||
|                 { | ||||
|                     "livekit_alias": "room1", | ||||
|                     "livekit_service_url": "https://livekit1.com", | ||||
|                     "type": "livekit" | ||||
|                 } | ||||
|             ], | ||||
|         }); | ||||
| 
 | ||||
|         let ev_content: CallMemberEventContent = | ||||
|             serde_json::from_value(call_member_ev_json).unwrap(); | ||||
|         assert_eq!( | ||||
|             serde_json::to_string(&ev_content).unwrap(), | ||||
|             serde_json::to_string(&call_member_ev).unwrap() | ||||
|         ); | ||||
|         let empty = CallMemberEventContent::Empty(EmptyMembershipData { leave_reason: None }); | ||||
|         assert_eq!( | ||||
|             serde_json::to_string(&json!({})).unwrap(), | ||||
|             serde_json::to_string(&empty).unwrap() | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn deserialize_call_member_event_content() { | ||||
|         let call_member_ev: CallMemberEventContent = CallMemberEventContent::new(vec![ | ||||
|             Membership { | ||||
|     fn deserialize_legacy_call_member_event_content() { | ||||
|         let call_member_ev = CallMemberEventContent::new_legacy(vec![ | ||||
|             LegacyMembershipData { | ||||
|                 application: Application::Call(CallApplicationContent { | ||||
|                     call_id: "123456".to_owned(), | ||||
|                     scope: CallScope::Room, | ||||
| @ -379,7 +405,7 @@ mod tests { | ||||
|                 membership_id: "0".to_owned(), | ||||
|                 created_ts: None, | ||||
|             }, | ||||
|             Membership { | ||||
|             LegacyMembershipData { | ||||
|                 application: Application::Call(CallApplicationContent { | ||||
|                     call_id: "".to_owned(), | ||||
|                     scope: CallScope::Room, | ||||
| @ -432,7 +458,85 @@ mod tests { | ||||
| 
 | ||||
|         let ev_content: CallMemberEventContent = | ||||
|             serde_json::from_value(call_member_ev_json).unwrap(); | ||||
|         assert_eq!(ev_content, call_member_ev); | ||||
|         assert_eq!( | ||||
|             serde_json::to_string(&ev_content).unwrap(), | ||||
|             serde_json::to_string(&call_member_ev).unwrap() | ||||
|         ); | ||||
|     } | ||||
|     #[test] | ||||
|     fn deserialize_member_event() { | ||||
|         let ev = json!({ | ||||
|             "content":{ | ||||
|                 "application": "m.call", | ||||
|                 "call_id": "", | ||||
|                 "scope": "m.room", | ||||
|                 "device_id": "THIS_DEVICE", | ||||
|                 "focus_active":{ | ||||
|                     "type": "livekit", | ||||
|                     "focus_select": "oldest_membership" | ||||
|                 }, | ||||
|                 "foci_preferred": [ | ||||
|                     { | ||||
|                         "livekit_alias": "room1", | ||||
|                         "livekit_service_url": "https://livekit1.com", | ||||
|                         "type": "livekit" | ||||
|                     } | ||||
|                 ], | ||||
|             }, | ||||
|             "type": "m.call.member", | ||||
|             "origin_server_ts": 111, | ||||
|             "event_id": "$3qfxjGYSu4sL25FtR0ep6vePOc", | ||||
|             "room_id": "!1234:example.org", | ||||
|             "sender": "@user:example.org", | ||||
|             "state_key":"@user:example.org", | ||||
|             "unsigned":{ | ||||
|                 "age":10, | ||||
|                 "prev_content": {}, | ||||
|                 "prev_sender":"@user:example.org", | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         assert_matches!( | ||||
|             from_json_value(ev), | ||||
|             Ok(AnyStateEvent::CallMember(StateEvent::Original(member_event))) | ||||
|         ); | ||||
| 
 | ||||
|         let event_id = OwnedEventId::try_from("$3qfxjGYSu4sL25FtR0ep6vePOc").unwrap(); | ||||
|         let sender = OwnedUserId::try_from("@user:example.org").unwrap(); | ||||
|         let room_id = OwnedRoomId::try_from("!1234:example.org").unwrap(); | ||||
|         assert_eq!(member_event.state_key, sender); | ||||
|         assert_eq!(member_event.event_id, event_id); | ||||
|         assert_eq!(member_event.sender, sender); | ||||
|         assert_eq!(member_event.room_id, room_id); | ||||
|         assert_eq!(member_event.origin_server_ts, TS(js_int::UInt::new(111).unwrap())); | ||||
|         assert_eq!( | ||||
|             member_event.content, | ||||
|             CallMemberEventContent::SessionContent(SessionMembershipData { | ||||
|                 application: Application::Call(CallApplicationContent { | ||||
|                     call_id: "".to_owned(), | ||||
|                     scope: CallScope::Room | ||||
|                 }), | ||||
|                 device_id: "THIS_DEVICE".to_owned(), | ||||
|                 foci_preferred: [Focus::Livekit(LivekitFocus { | ||||
|                     alias: "room1".to_owned(), | ||||
|                     service_url: "https://livekit1.com".to_owned() | ||||
|                 })] | ||||
|                 .to_vec(), | ||||
|                 focus_active: ActiveFocus::Livekit(ActiveLivekitFocus { | ||||
|                     focus_select: FocusSelection::OldestMembership | ||||
|                 }), | ||||
|                 created_ts: None | ||||
|             }) | ||||
|         ); | ||||
| 
 | ||||
|         assert_eq!(js_int::Int::new(10), member_event.unsigned.age); | ||||
|         assert_eq!( | ||||
|             CallMemberEventContent::Empty(EmptyMembershipData { leave_reason: None }), | ||||
|             member_event.unsigned.prev_content.unwrap() | ||||
|         ); | ||||
| 
 | ||||
|         // assert_eq!(, StateUnsigned { age: 10, transaction_id: None, prev_content:
 | ||||
|         // CallMemberEventContent::Empty { leave_reason: None }, relations: None })
 | ||||
|     } | ||||
| 
 | ||||
|     fn timestamps() -> (TS, TS, TS) { | ||||
| @ -449,44 +553,59 @@ mod tests { | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn membership_do_expire() { | ||||
|         let content = create_call_member_event_content(); | ||||
|     fn memberships_do_expire() { | ||||
|         let content_legacy = create_call_member_legacy_event_content(); | ||||
|         let (now, one_second_ago, two_hours_ago) = timestamps(); | ||||
| 
 | ||||
|         assert_eq!( | ||||
|             content.active_memberships(Some(one_second_ago)), | ||||
|             content.memberships.iter().collect::<Vec<&Membership>>() | ||||
|             content_legacy.active_memberships(Some(one_second_ago)), | ||||
|             content_legacy.memberships() | ||||
|         ); | ||||
|         assert_eq!(content_legacy.active_memberships(Some(now)), content_legacy.memberships()); | ||||
|         assert_eq!( | ||||
|             content.active_memberships(Some(now)), | ||||
|             content.memberships.iter().collect::<Vec<&Membership>>() | ||||
|             content_legacy.active_memberships(Some(two_hours_ago)), | ||||
|             (vec![] as Vec<MembershipData<'_>>) | ||||
|         ); | ||||
|         // session do never expire
 | ||||
|         let content_session = create_call_member_event_content(); | ||||
|         assert_eq!( | ||||
|             content_session.active_memberships(Some(one_second_ago)), | ||||
|             content_session.memberships() | ||||
|         ); | ||||
|         assert_eq!(content_session.active_memberships(Some(now)), content_session.memberships()); | ||||
|         assert_eq!( | ||||
|             content_session.active_memberships(Some(two_hours_ago)), | ||||
|             content_session.memberships() | ||||
|         ); | ||||
|         assert_eq!(content.active_memberships(Some(two_hours_ago)), vec![] as Vec<&Membership>); | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn set_created_ts() { | ||||
|         let mut content_now = create_call_member_event_content(); | ||||
|         let mut content_two_hours_ago = create_call_member_event_content(); | ||||
|         let mut content_one_second_ago = create_call_member_event_content(); | ||||
|         let mut content_now = create_call_member_legacy_event_content(); | ||||
|         let mut content_two_hours_ago = create_call_member_legacy_event_content(); | ||||
|         let mut content_one_second_ago = create_call_member_legacy_event_content(); | ||||
|         let (now, one_second_ago, two_hours_ago) = timestamps(); | ||||
| 
 | ||||
|         content_now.set_created_ts_if_none(now); | ||||
|         content_one_second_ago.set_created_ts_if_none(one_second_ago); | ||||
|         content_two_hours_ago.set_created_ts_if_none(two_hours_ago); | ||||
|         assert_eq!( | ||||
|             content_now.active_memberships(None), | ||||
|             content_now.memberships.iter().collect::<Vec<&Membership>>() | ||||
|         ); | ||||
|         assert_eq!(content_now.active_memberships(None), content_now.memberships()); | ||||
| 
 | ||||
|         assert_eq!(content_two_hours_ago.active_memberships(None), vec![] as Vec<&Membership>); | ||||
|         assert_eq!( | ||||
|             content_two_hours_ago.active_memberships(None), | ||||
|             vec![] as Vec<MembershipData<'_>> | ||||
|         ); | ||||
|         assert_eq!( | ||||
|             content_one_second_ago.active_memberships(None), | ||||
|             content_one_second_ago.memberships.iter().collect::<Vec<&Membership>>() | ||||
|             content_one_second_ago.memberships() | ||||
|         ); | ||||
| 
 | ||||
|         // created_ts should not be overwritten.
 | ||||
|         content_two_hours_ago.set_created_ts_if_none(one_second_ago); | ||||
|         // There still should be no active membership.
 | ||||
|         assert_eq!(content_two_hours_ago.active_memberships(None), vec![] as Vec<&Membership>); | ||||
|         assert_eq!( | ||||
|             content_two_hours_ago.active_memberships(None), | ||||
|             vec![] as Vec<MembershipData<'_>> | ||||
|         ); | ||||
|     } | ||||
| } | ||||
|  | ||||
							
								
								
									
										88
									
								
								crates/ruma-events/src/call/member/focus.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								crates/ruma-events/src/call/member/focus.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,88 @@ | ||||
| //! Types for MatrixRTC Focus/SFU configurations.
 | ||||
| 
 | ||||
| use ruma_macros::StringEnum; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| 
 | ||||
| use crate::PrivOwnedStr; | ||||
| 
 | ||||
| /// Description of the SFU/Focus a membership can be connected to.
 | ||||
| ///
 | ||||
| /// A focus can be any server powering the MatrixRTC session (SFU,
 | ||||
| /// MCU). It serves as a node to redistribute RTC streams.
 | ||||
| #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| #[serde(tag = "type", rename_all = "snake_case")] | ||||
| pub enum Focus { | ||||
|     /// LiveKit is one possible type of SFU/Focus that can be used for a MatrixRTC session.
 | ||||
|     Livekit(LivekitFocus), | ||||
| } | ||||
| 
 | ||||
| /// The struct to describe LiveKit as a `preferred_foci`.
 | ||||
| #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| pub struct LivekitFocus { | ||||
|     /// The alias where the LiveKit sessions can be reached.
 | ||||
|     #[serde(rename = "livekit_alias")] | ||||
|     pub alias: String, | ||||
| 
 | ||||
|     /// The URL of the JWT service for the LiveKit instance.
 | ||||
|     #[serde(rename = "livekit_service_url")] | ||||
|     pub service_url: String, | ||||
| } | ||||
| 
 | ||||
| impl LivekitFocus { | ||||
|     /// Initialize a [`LivekitFocus`].
 | ||||
|     ///
 | ||||
|     /// # Arguments
 | ||||
|     ///
 | ||||
|     /// * `alias` - The alias with which the LiveKit sessions can be reached.
 | ||||
|     /// * `service_url` - The url of the JWT server for the LiveKit instance.
 | ||||
|     pub fn new(alias: String, service_url: String) -> Self { | ||||
|         Self { alias, service_url } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// Data to define the actively used Focus.
 | ||||
| ///
 | ||||
| /// A focus can be any server powering the MatrixRTC session (SFU,
 | ||||
| /// MCU). It serves as a node to redistribute RTC streams.
 | ||||
| #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| #[serde(tag = "type", rename_all = "snake_case")] | ||||
| pub enum ActiveFocus { | ||||
|     /// LiveKit is one possible type of SFU/Focus that can be used for a MatrixRTC session.
 | ||||
|     Livekit(ActiveLivekitFocus), | ||||
| } | ||||
| 
 | ||||
| /// The fields to describe the `active_foci`.
 | ||||
| #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| pub struct ActiveLivekitFocus { | ||||
|     /// The selection method used to select the LiveKit focus for the rtc session.
 | ||||
|     pub focus_select: FocusSelection, | ||||
| } | ||||
| 
 | ||||
| impl ActiveLivekitFocus { | ||||
|     /// Initialize a [`ActiveLivekitFocus`].
 | ||||
|     ///
 | ||||
|     /// # Arguments
 | ||||
|     ///
 | ||||
|     /// * `focus_select` - The selection method used to select the LiveKit focus for the rtc
 | ||||
|     ///   session.
 | ||||
|     pub fn new() -> Self { | ||||
|         Self { focus_select: FocusSelection::OldestMembership } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// How to select the active focus for LiveKit
 | ||||
| #[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))] | ||||
| #[derive(Clone, PartialEq, StringEnum)] | ||||
| #[ruma_enum(rename_all = "snake_case")] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| pub enum FocusSelection { | ||||
|     /// Select the active focus by using the oldest membership and the oldest focus.
 | ||||
|     OldestMembership, | ||||
| 
 | ||||
|     #[doc(hidden)] | ||||
|     _Custom(PrivOwnedStr), | ||||
| } | ||||
							
								
								
									
										319
									
								
								crates/ruma-events/src/call/member/member_data.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										319
									
								
								crates/ruma-events/src/call/member/member_data.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,319 @@ | ||||
| //! Types for MatrixRTC `m.call.member` state event content data ([MSC3401])
 | ||||
| //!
 | ||||
| //! [MSC3401]: https://github.com/matrix-org/matrix-spec-proposals/pull/3401
 | ||||
| 
 | ||||
| use std::time::Duration; | ||||
| 
 | ||||
| use as_variant::as_variant; | ||||
| use ruma_common::MilliSecondsSinceUnixEpoch; | ||||
| use ruma_macros::StringEnum; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use tracing::warn; | ||||
| 
 | ||||
| use super::focus::{ActiveFocus, ActiveLivekitFocus, Focus}; | ||||
| use crate::PrivOwnedStr; | ||||
| 
 | ||||
| /// The data object that contains the information for one membership.
 | ||||
| ///
 | ||||
| /// It can be a legacy or a normal MatrixRTC Session membership.
 | ||||
| ///
 | ||||
| /// The legacy format contains time information to compute if it is expired or not.
 | ||||
| /// SessionMembershipData does not have the concept of timestamp based expiration anymore.
 | ||||
| /// The state event will reliably be set to empty when the user disconnects.
 | ||||
| #[derive(Clone, Debug)] | ||||
| #[cfg_attr(test, derive(PartialEq))] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| pub enum MembershipData<'a> { | ||||
|     /// The legacy format (using an array of memberships for each device -> one event per user)
 | ||||
|     Legacy(&'a LegacyMembershipData), | ||||
|     /// One event per device. `SessionMembershipData` contains all the information required to
 | ||||
|     /// represent the current membership state of one device.
 | ||||
|     Session(&'a SessionMembershipData), | ||||
| } | ||||
| 
 | ||||
| impl<'a> MembershipData<'a> { | ||||
|     /// The application this RTC membership participates in (the session type, can be `m.call`...)
 | ||||
|     pub fn application(&self) -> &Application { | ||||
|         match self { | ||||
|             MembershipData::Legacy(data) => &data.application, | ||||
|             MembershipData::Session(data) => &data.application, | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /// The device id of this membership.
 | ||||
|     pub fn device_id(&self) -> &String { | ||||
|         match self { | ||||
|             MembershipData::Legacy(data) => &data.device_id, | ||||
|             MembershipData::Session(data) => &data.device_id, | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /// The active focus is a FocusType specific object that describes how this user
 | ||||
|     /// is currently connected.
 | ||||
|     ///
 | ||||
|     /// It can use the foci_preferred list to choose one of the available (preferred)
 | ||||
|     /// foci or specific information on how to connect to this user.
 | ||||
|     ///
 | ||||
|     /// Every user needs to converge to use the same focus_active type.
 | ||||
|     pub fn focus_active(&self) -> &ActiveFocus { | ||||
|         match self { | ||||
|             MembershipData::Legacy(_) => &ActiveFocus::Livekit(ActiveLivekitFocus { | ||||
|                 focus_select: super::focus::FocusSelection::OldestMembership, | ||||
|             }), | ||||
|             MembershipData::Session(data) => &data.focus_active, | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /// The list of available/preferred options this user provides to connect to the call.
 | ||||
|     pub fn foci_preferred(&self) -> &Vec<Focus> { | ||||
|         match self { | ||||
|             MembershipData::Legacy(data) => &data.foci_active, | ||||
|             MembershipData::Session(data) => &data.foci_preferred, | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /// The application of the membership is "m.call" and the scope is "m.room".
 | ||||
|     pub fn is_room_call(&self) -> bool { | ||||
|         as_variant!(self.application(), Application::Call) | ||||
|             .is_some_and(|call| call.scope == CallScope::Room) | ||||
|     } | ||||
| 
 | ||||
|     /// The application of the membership is "m.call".
 | ||||
|     pub fn is_call(&self) -> bool { | ||||
|         as_variant!(self.application(), Application::Call).is_some() | ||||
|     } | ||||
| 
 | ||||
|     /// Checks if the event is expired. This is only relevant for LegacyMembershipData
 | ||||
|     /// returns `false` if its SessionMembershipData
 | ||||
|     pub fn is_expired(&self, origin_server_ts: Option<MilliSecondsSinceUnixEpoch>) -> bool { | ||||
|         match self { | ||||
|             MembershipData::Legacy(data) => data.is_expired(origin_server_ts), | ||||
|             MembershipData::Session(_) => false, | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /// Gets the created_ts of the event.
 | ||||
|     ///
 | ||||
|     /// This is the `origin_server_ts` for session data.
 | ||||
|     /// For legacy events this can either be the origin server ts or a copy from the
 | ||||
|     /// `origin_server_ts` since we expect legacy events to get updated (when a new device
 | ||||
|     /// joins/leaves).
 | ||||
|     pub fn created_ts(&self) -> Option<MilliSecondsSinceUnixEpoch> { | ||||
|         match self { | ||||
|             MembershipData::Legacy(data) => data.created_ts, | ||||
|             MembershipData::Session(data) => data.created_ts, | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// A membership describes one of the sessions this user currently partakes.
 | ||||
| ///
 | ||||
| /// The application defines the type of the session.
 | ||||
| #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| pub struct LegacyMembershipData { | ||||
|     /// The type of the MatrixRTC session the membership belongs to.
 | ||||
|     ///
 | ||||
|     /// e.g. call, spacial, document...
 | ||||
|     #[serde(flatten)] | ||||
|     pub application: Application, | ||||
| 
 | ||||
|     /// The device id of this membership.
 | ||||
|     ///
 | ||||
|     /// The same user can join with their phone/computer.
 | ||||
|     pub device_id: String, | ||||
| 
 | ||||
|     /// The duration in milliseconds relative to the time this membership joined
 | ||||
|     /// during which the membership is valid.
 | ||||
|     ///
 | ||||
|     /// The time a member has joined is defined as:
 | ||||
|     /// `MIN(content.created_ts, event.origin_server_ts)`
 | ||||
|     #[serde(with = "ruma_common::serde::duration::ms")] | ||||
|     pub expires: Duration, | ||||
| 
 | ||||
|     /// Stores a copy of the `origin_server_ts` of the initial session event.
 | ||||
|     ///
 | ||||
|     /// If the membership is updated this field will be used to track to
 | ||||
|     /// original `origin_server_ts`.
 | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub created_ts: Option<MilliSecondsSinceUnixEpoch>, | ||||
| 
 | ||||
|     /// A list of the foci in use for this membership.
 | ||||
|     pub foci_active: Vec<Focus>, | ||||
| 
 | ||||
|     /// The id of the membership.
 | ||||
|     ///
 | ||||
|     /// This is required to guarantee uniqueness of the event.
 | ||||
|     /// Sending the same state event twice to synapse makes the HS drop the second one and return
 | ||||
|     /// 200.
 | ||||
|     #[serde(rename = "membershipID")] | ||||
|     pub membership_id: String, | ||||
| } | ||||
| 
 | ||||
| impl LegacyMembershipData { | ||||
|     /// Checks if the event is expired.
 | ||||
|     ///
 | ||||
|     /// Defaults to using `created_ts` of the [`LegacyMembershipData`].
 | ||||
|     /// If no `origin_server_ts` is provided and the event does not contain `created_ts`
 | ||||
|     /// the event will be considered as not expired.
 | ||||
|     /// In this case, a warning will be logged.
 | ||||
|     ///
 | ||||
|     /// # Arguments
 | ||||
|     ///
 | ||||
|     /// * `origin_server_ts` - a fallback if [`LegacyMembershipData::created_ts`] is not present
 | ||||
|     pub fn is_expired(&self, origin_server_ts: Option<MilliSecondsSinceUnixEpoch>) -> bool { | ||||
|         let ev_created_ts = self.created_ts.or(origin_server_ts); | ||||
| 
 | ||||
|         if let Some(ev_created_ts) = ev_created_ts { | ||||
|             let now = MilliSecondsSinceUnixEpoch::now().to_system_time(); | ||||
|             let expire_ts = ev_created_ts.to_system_time().map(|t| t + self.expires); | ||||
|             now > expire_ts | ||||
|         } else { | ||||
|             // This should not be reached since we only allow events that have copied over
 | ||||
|             // the origin server ts. `set_created_ts_if_none`
 | ||||
|             warn!("Encountered a Call Member state event where the origin_ts (or origin_server_ts) could not be found.\ | ||||
|             It is treated as a non expired event but this might be wrong.");
 | ||||
|             false | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// Initial set of fields of [`LegacyMembershipData`].
 | ||||
| #[derive(Debug)] | ||||
| #[allow(clippy::exhaustive_structs)] | ||||
| pub struct LegacyMembershipDataInit { | ||||
|     /// The type of the MatrixRTC session the membership belongs to.
 | ||||
|     ///
 | ||||
|     /// e.g. call, spacial, document...
 | ||||
|     pub application: Application, | ||||
| 
 | ||||
|     /// The device id of this membership.
 | ||||
|     ///
 | ||||
|     /// The same user can join with their phone/computer.
 | ||||
|     pub device_id: String, | ||||
| 
 | ||||
|     /// The duration in milliseconds relative to the time this membership joined
 | ||||
|     /// during which the membership is valid.
 | ||||
|     ///
 | ||||
|     /// The time a member has joined is defined as:
 | ||||
|     /// `MIN(content.created_ts, event.origin_server_ts)`
 | ||||
|     pub expires: Duration, | ||||
| 
 | ||||
|     /// A list of the focuses (foci) in use for this membership.
 | ||||
|     pub foci_active: Vec<Focus>, | ||||
| 
 | ||||
|     /// The id of the membership.
 | ||||
|     ///
 | ||||
|     /// This is required to guarantee uniqueness of the event.
 | ||||
|     /// Sending the same state event twice to synapse makes the HS drop the second one and return
 | ||||
|     /// 200.
 | ||||
|     pub membership_id: String, | ||||
| } | ||||
| 
 | ||||
| impl From<LegacyMembershipDataInit> for LegacyMembershipData { | ||||
|     fn from(init: LegacyMembershipDataInit) -> Self { | ||||
|         let LegacyMembershipDataInit { | ||||
|             application, | ||||
|             device_id, | ||||
|             expires, | ||||
|             foci_active, | ||||
|             membership_id, | ||||
|         } = init; | ||||
|         Self { application, device_id, expires, created_ts: None, foci_active, membership_id } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// Stores all the information for a MatrixRTC membership. (one for each device)
 | ||||
| #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| pub struct SessionMembershipData { | ||||
|     /// The type of the MatrixRTC session the membership belongs to.
 | ||||
|     ///
 | ||||
|     /// e.g. call, spacial, document...
 | ||||
|     #[serde(flatten)] | ||||
|     pub application: Application, | ||||
| 
 | ||||
|     /// The device id of this membership.
 | ||||
|     ///
 | ||||
|     /// The same user can join with their phone/computer.
 | ||||
|     pub device_id: String, | ||||
| 
 | ||||
|     /// A list of the foci that this membership proposes to use.
 | ||||
|     pub foci_preferred: Vec<Focus>, | ||||
| 
 | ||||
|     /// Data required to determine the currently used focus by this member.
 | ||||
|     pub focus_active: ActiveFocus, | ||||
| 
 | ||||
|     /// Stores a copy of the `origin_server_ts` of the initial session event.
 | ||||
|     ///
 | ||||
|     /// This is not part of the serialized event and computed after serialization.
 | ||||
|     #[serde(skip)] | ||||
|     pub created_ts: Option<MilliSecondsSinceUnixEpoch>, | ||||
| } | ||||
| 
 | ||||
| /// The type of the MatrixRTC session.
 | ||||
| ///
 | ||||
| /// This is not the application/client used by the user but the
 | ||||
| /// type of MatrixRTC session e.g. calling (`m.call`), third-room, whiteboard could be
 | ||||
| /// possible applications.
 | ||||
| #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| #[serde(tag = "application")] | ||||
| pub enum Application { | ||||
|     /// The rtc application (session type) for VoIP call.
 | ||||
|     #[serde(rename = "m.call")] | ||||
|     Call(CallApplicationContent), | ||||
| } | ||||
| 
 | ||||
| /// Call specific parameters of a `m.call.member` event.
 | ||||
| #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| pub struct CallApplicationContent { | ||||
|     /// An identifier for calls.
 | ||||
|     ///
 | ||||
|     /// All members using the same `call_id` will end up in the same call.
 | ||||
|     ///
 | ||||
|     /// Does not need to be a uuid.
 | ||||
|     ///
 | ||||
|     /// `""` is used for room scoped calls.
 | ||||
|     pub call_id: String, | ||||
| 
 | ||||
|     /// Who owns/joins/controls (can modify) the call.
 | ||||
|     pub scope: CallScope, | ||||
| } | ||||
| 
 | ||||
| impl CallApplicationContent { | ||||
|     /// Initialize a [`CallApplicationContent`].
 | ||||
|     ///
 | ||||
|     /// # Arguments
 | ||||
|     ///
 | ||||
|     /// * `call_id` - An identifier for calls. All members using the same `call_id` will end up in
 | ||||
|     ///   the same call. Does not need to be a uuid. `""` is used for room scoped calls.
 | ||||
|     /// * `scope` - Who owns/joins/controls (can modify) the call.
 | ||||
|     pub fn new(call_id: String, scope: CallScope) -> Self { | ||||
|         Self { call_id, scope } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// The call scope defines different call ownership models.
 | ||||
| #[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))] | ||||
| #[derive(Clone, PartialEq, StringEnum)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| #[ruma_enum(rename_all = "m.snake_case")] | ||||
| pub enum CallScope { | ||||
|     /// A call which every user of a room can join and create.
 | ||||
|     ///
 | ||||
|     /// There is no particular name associated with it.
 | ||||
|     ///
 | ||||
|     /// There can only be one per room.
 | ||||
|     Room, | ||||
| 
 | ||||
|     /// A user call is owned by a user.
 | ||||
|     ///
 | ||||
|     /// Each user can create one there can be multiple per room. They are started and ended by the
 | ||||
|     /// owning user.
 | ||||
|     User, | ||||
| 
 | ||||
|     #[doc(hidden)] | ||||
|     _Custom(PrivOwnedStr), | ||||
| } | ||||
| @ -1,4 +1,4 @@ | ||||
| //! Type for the matrixRTC notify event ([MSC4075]).
 | ||||
| //! Type for the MatrixRTC notify event ([MSC4075]).
 | ||||
| //!
 | ||||
| //! [MSC4075]: https://github.com/matrix-org/matrix-spec-proposals/pull/4075
 | ||||
| 
 | ||||
| @ -75,3 +75,99 @@ impl From<Application> for ApplicationType { | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; | ||||
| 
 | ||||
|     use crate::{ | ||||
|         call::notify::{ApplicationType, CallNotifyEventContent, NotifyType}, | ||||
|         Mentions, | ||||
|     }; | ||||
| 
 | ||||
|     #[test] | ||||
|     fn notify_event_serialization() { | ||||
|         use ruma_common::owned_user_id; | ||||
| 
 | ||||
|         let content_user_mention = CallNotifyEventContent::new( | ||||
|             "abcdef".into(), | ||||
|             ApplicationType::Call, | ||||
|             NotifyType::Ring, | ||||
|             Mentions::with_user_ids(vec![ | ||||
|                 owned_user_id!("@user:example.com"), | ||||
|                 owned_user_id!("@user2:example.com"), | ||||
|             ]), | ||||
|         ); | ||||
| 
 | ||||
|         let content_room_mention = CallNotifyEventContent::new( | ||||
|             "abcdef".into(), | ||||
|             ApplicationType::Call, | ||||
|             NotifyType::Ring, | ||||
|             Mentions::with_room_mention(), | ||||
|         ); | ||||
| 
 | ||||
|         assert_eq!( | ||||
|             to_json_value(&content_user_mention).unwrap(), | ||||
|             json!({ | ||||
|                 "call_id": "abcdef", | ||||
|                 "application": "m.call", | ||||
|                 "m.mentions": { | ||||
|                     "user_ids": ["@user2:example.com","@user:example.com"], | ||||
|                 }, | ||||
|                 "notify_type": "ring", | ||||
|             }) | ||||
|         ); | ||||
|         assert_eq!( | ||||
|             to_json_value(&content_room_mention).unwrap(), | ||||
|             json!({ | ||||
|                 "call_id": "abcdef", | ||||
|                 "application": "m.call", | ||||
|                 "m.mentions": { "room": true }, | ||||
|                 "notify_type": "ring", | ||||
|             }) | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn notify_event_deserialization() { | ||||
|         use std::collections::BTreeSet; | ||||
| 
 | ||||
|         use assert_matches2::assert_matches; | ||||
|         use ruma_common::owned_user_id; | ||||
| 
 | ||||
|         use crate::{AnyMessageLikeEvent, MessageLikeEvent}; | ||||
| 
 | ||||
|         let json_data = json!({ | ||||
|             "content": { | ||||
|                 "call_id": "abcdef", | ||||
|                 "application": "m.call", | ||||
|                 "m.mentions": { | ||||
|                     "room": false, | ||||
|                     "user_ids": ["@user:example.com", "@user2:example.com"], | ||||
|                 }, | ||||
|                 "notify_type": "ring", | ||||
|             }, | ||||
|             "event_id": "$event:notareal.hs", | ||||
|             "origin_server_ts": 134_829_848, | ||||
|             "room_id": "!roomid:notareal.hs", | ||||
|             "sender": "@user:notareal.hs", | ||||
|             "type": "m.call.notify", | ||||
|         }); | ||||
| 
 | ||||
|         let event = from_json_value::<AnyMessageLikeEvent>(json_data).unwrap(); | ||||
|         assert_matches!( | ||||
|             event, | ||||
|             AnyMessageLikeEvent::CallNotify(MessageLikeEvent::Original(message_event)) | ||||
|         ); | ||||
|         let content = message_event.content; | ||||
|         assert_eq!(content.call_id, "abcdef"); | ||||
|         assert!(!content.mentions.room); | ||||
|         assert_eq!( | ||||
|             content.mentions.user_ids, | ||||
|             BTreeSet::from([ | ||||
|                 owned_user_id!("@user:example.com"), | ||||
|                 owned_user_id!("@user2:example.com") | ||||
|             ]) | ||||
|         ); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| //! Types for the [`m.call.sdp_stream_metadata_changed`] event.
 | ||||
| //!
 | ||||
| //! [`m.call.sdp_stream_metadata_changed`]: https://github.com/matrix-org/matrix-spec-proposals/pull/3291
 | ||||
| //! [`m.call.sdp_stream_metadata_changed`]: https://spec.matrix.org/latest/client-server-api/#mcallsdp_stream_metadata_changed
 | ||||
| 
 | ||||
| use std::collections::BTreeMap; | ||||
| 
 | ||||
| @ -15,7 +15,7 @@ use super::StreamMetadata; | ||||
| /// This event is sent by any party when a stream metadata changes but no negotiation is required.
 | ||||
| #[derive(Clone, Debug, Deserialize, Serialize, EventContent)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| #[ruma_event(type = "org.matrix.call.sdp_stream_metadata_changed", kind = MessageLike)] | ||||
| #[ruma_event(type = "m.call.sdp_stream_metadata_changed", alias = "org.matrix.call.sdp_stream_metadata_changed", kind = MessageLike)] | ||||
| pub struct CallSdpStreamMetadataChangedEventContent { | ||||
|     /// A unique identifier for the call.
 | ||||
|     pub call_id: OwnedVoipId, | ||||
|  | ||||
| @ -95,7 +95,6 @@ pub trait ToDeviceEventContent: EventContent<EventType = ToDeviceEventType> {} | ||||
| /// Event content that can be deserialized with its event type.
 | ||||
| pub trait EventContentFromType: EventContent { | ||||
|     /// Constructs this event content from the given event type and JSON.
 | ||||
|     #[doc(hidden)] | ||||
|     fn from_parts(event_type: &str, content: &RawJsonValue) -> serde_json::Result<Self>; | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -45,9 +45,8 @@ event_enum! { | ||||
|         "m.call.candidates" => super::call::candidates, | ||||
|         "m.call.negotiate" => super::call::negotiate, | ||||
|         "m.call.reject" => super::call::reject, | ||||
|         #[cfg(feature = "unstable-msc3291")] | ||||
|         #[ruma_enum(alias = "m.call.sdp_stream_metadata_changed")] | ||||
|         "org.matrix.call.sdp_stream_metadata_changed" => super::call::sdp_stream_metadata_changed, | ||||
|         #[ruma_enum(alias = "org.matrix.call.sdp_stream_metadata_changed")] | ||||
|         "m.call.sdp_stream_metadata_changed" => super::call::sdp_stream_metadata_changed, | ||||
|         "m.call.select_answer" => super::call::select_answer, | ||||
|         #[cfg(feature = "unstable-msc3954")] | ||||
|         #[ruma_enum(alias = "m.emote")] | ||||
| @ -88,6 +87,9 @@ event_enum! { | ||||
|         #[cfg(feature = "unstable-msc3381")] | ||||
|         #[ruma_enum(ident = UnstablePollEnd)] | ||||
|         "org.matrix.msc3381.poll.end" => super::poll::unstable_end, | ||||
|         #[cfg(feature = "unstable-msc3489")] | ||||
|         #[ruma_enum(alias = "m.beacon")] | ||||
|         "org.matrix.msc3672.beacon" => super::beacon, | ||||
|         "m.reaction" => super::reaction, | ||||
|         "m.room.encrypted" => super::room::encrypted, | ||||
|         "m.room.message" => super::room::message, | ||||
| @ -127,6 +129,9 @@ event_enum! { | ||||
|         "m.room.topic" => super::room::topic, | ||||
|         "m.space.child" => super::space::child, | ||||
|         "m.space.parent" => super::space::parent, | ||||
|         #[cfg(feature = "unstable-msc3489")] | ||||
|         #[ruma_enum(alias = "m.beacon_info")] | ||||
|         "org.matrix.msc3672.beacon_info" => super::beacon_info, | ||||
|         #[cfg(feature = "unstable-msc3401")] | ||||
|         #[ruma_enum(alias = "m.call.member")] | ||||
|         "org.matrix.msc3401.call.member" => super::call::member, | ||||
| @ -309,6 +314,8 @@ impl AnyMessageLikeEventContent { | ||||
|     /// This is a helper function intended for encryption. There should not be a reason to access
 | ||||
|     /// `m.relates_to` without first destructuring an `AnyMessageLikeEventContent` otherwise.
 | ||||
|     pub fn relation(&self) -> Option<encrypted::Relation> { | ||||
|         #[cfg(feature = "unstable-msc3489")] | ||||
|         use super::beacon::BeaconEventContent; | ||||
|         use super::key::verification::{ | ||||
|             accept::KeyVerificationAcceptEventContent, cancel::KeyVerificationCancelEventContent, | ||||
|             done::KeyVerificationDoneEventContent, key::KeyVerificationKeyEventContent, | ||||
| @ -361,13 +368,16 @@ impl AnyMessageLikeEventContent { | ||||
|             | Self::UnstablePollEnd(UnstablePollEndEventContent { relates_to, .. }) => { | ||||
|                 Some(encrypted::Relation::Reference(relates_to.clone())) | ||||
|             } | ||||
|             #[cfg(feature = "unstable-msc3489")] | ||||
|             Self::Beacon(BeaconEventContent { relates_to, .. }) => { | ||||
|                 Some(encrypted::Relation::Reference(relates_to.clone())) | ||||
|             } | ||||
|             #[cfg(feature = "unstable-msc3381")] | ||||
|             Self::PollStart(_) | Self::UnstablePollStart(_) => None, | ||||
|             #[cfg(feature = "unstable-msc4075")] | ||||
|             Self::CallNotify(_) => None, | ||||
|             #[cfg(feature = "unstable-msc3291")] | ||||
|             Self::CallSdpStreamMetadataChanged(_) => None, | ||||
|             Self::CallNegotiate(_) | ||||
|             Self::CallSdpStreamMetadataChanged(_) | ||||
|             | Self::CallNegotiate(_) | ||||
|             | Self::CallReject(_) | ||||
|             | Self::CallSelectAnswer(_) | ||||
|             | Self::CallAnswer(_) | ||||
|  | ||||
| @ -165,7 +165,10 @@ mod tests { | ||||
|     use std::collections::BTreeMap; | ||||
| 
 | ||||
|     use assert_matches2::assert_matches; | ||||
|     use ruma_common::{event_id, serde::Base64}; | ||||
|     use ruma_common::{ | ||||
|         event_id, | ||||
|         serde::{Base64, Raw}, | ||||
|     }; | ||||
|     use serde_json::{ | ||||
|         from_value as from_json_value, json, to_value as to_json_value, Value as JsonValue, | ||||
|     }; | ||||
| @ -348,4 +351,26 @@ mod tests { | ||||
|         assert_eq!(sas.message_authentication_code, MessageAuthenticationCode::HkdfHmacSha256V2); | ||||
|         assert_eq!(sas.short_authentication_string, vec![ShortAuthenticationString::Decimal]); | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn in_room_serialization_roundtrip() { | ||||
|         let event_id = event_id!("$1598361704261elfgc:localhost"); | ||||
| 
 | ||||
|         let content = KeyVerificationAcceptEventContent { | ||||
|             relates_to: Reference { event_id: event_id.to_owned() }, | ||||
|             method: AcceptMethod::SasV1(SasV1Content { | ||||
|                 hash: HashAlgorithm::Sha256, | ||||
|                 key_agreement_protocol: KeyAgreementProtocol::Curve25519, | ||||
|                 message_authentication_code: MessageAuthenticationCode::HkdfHmacSha256V2, | ||||
|                 short_authentication_string: vec![ShortAuthenticationString::Decimal], | ||||
|                 commitment: Base64::new(b"hello".to_vec()), | ||||
|             }), | ||||
|         }; | ||||
| 
 | ||||
|         let json_content = Raw::new(&content).unwrap(); | ||||
|         let deser_content = json_content.deserialize().unwrap(); | ||||
| 
 | ||||
|         assert_matches!(deser_content.method, AcceptMethod::SasV1(_)); | ||||
|         assert_eq!(deser_content.relates_to.event_id, event_id); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -139,6 +139,10 @@ pub mod macros { | ||||
| 
 | ||||
| #[cfg(feature = "unstable-msc3927")] | ||||
| pub mod audio; | ||||
| #[cfg(feature = "unstable-msc3489")] | ||||
| pub mod beacon; | ||||
| #[cfg(feature = "unstable-msc3489")] | ||||
| pub mod beacon_info; | ||||
| pub mod call; | ||||
| pub mod direct; | ||||
| pub mod dummy; | ||||
|  | ||||
| @ -37,7 +37,7 @@ impl From<Annotation> for ReactionEventContent { | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use assert_matches2::assert_matches; | ||||
|     use ruma_common::owned_event_id; | ||||
|     use ruma_common::{owned_event_id, serde::Raw}; | ||||
|     use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; | ||||
| 
 | ||||
|     use super::ReactionEventContent; | ||||
| @ -79,4 +79,18 @@ mod tests { | ||||
|             }) | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn serialization_roundtrip() { | ||||
|         let content = ReactionEventContent::new(Annotation::new( | ||||
|             owned_event_id!("$my_reaction"), | ||||
|             "🏠".to_owned(), | ||||
|         )); | ||||
| 
 | ||||
|         let json_content = Raw::new(&content).unwrap(); | ||||
|         let deser_content = json_content.deserialize().unwrap(); | ||||
| 
 | ||||
|         assert_eq!(deser_content.relates_to.event_id, content.relates_to.event_id); | ||||
|         assert_eq!(deser_content.relates_to.key, content.relates_to.key); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -165,6 +165,7 @@ impl<C> From<message::Relation<C>> for Relation { | ||||
| /// [replaces another event]: https://spec.matrix.org/latest/client-server-api/#event-replacements
 | ||||
| #[derive(Clone, Debug, Deserialize, Serialize)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| #[serde(tag = "rel_type", rename = "m.replace")] | ||||
| pub struct Replacement { | ||||
|     /// The ID of the event being replaced.
 | ||||
|     pub event_id: OwnedEventId, | ||||
|  | ||||
| @ -74,27 +74,11 @@ impl Serialize for Relation { | ||||
|                 st.serialize_field("m.in_reply_to", in_reply_to)?; | ||||
|                 st.end() | ||||
|             } | ||||
|             Relation::Replacement(data) => { | ||||
|                 RelationSerHelper { rel_type: "m.replace", data }.serialize(serializer) | ||||
|             } | ||||
|             Relation::Reference(data) => { | ||||
|                 RelationSerHelper { rel_type: "m.reference", data }.serialize(serializer) | ||||
|             } | ||||
|             Relation::Annotation(data) => { | ||||
|                 RelationSerHelper { rel_type: "m.annotation", data }.serialize(serializer) | ||||
|             } | ||||
|             Relation::Thread(data) => { | ||||
|                 RelationSerHelper { rel_type: "m.thread", data }.serialize(serializer) | ||||
|             } | ||||
|             Relation::Replacement(data) => data.serialize(serializer), | ||||
|             Relation::Reference(data) => data.serialize(serializer), | ||||
|             Relation::Annotation(data) => data.serialize(serializer), | ||||
|             Relation::Thread(data) => data.serialize(serializer), | ||||
|             Relation::_Custom(c) => c.serialize(serializer), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Serialize)] | ||||
| struct RelationSerHelper<'a, T> { | ||||
|     rel_type: &'a str, | ||||
| 
 | ||||
|     #[serde(flatten)] | ||||
|     data: &'a T, | ||||
| } | ||||
|  | ||||
| @ -168,6 +168,7 @@ impl From<JoinRule> for SpaceRoomJoinRule { | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| pub struct Restricted { | ||||
|     /// Allow rules which describe conditions that allow joining a room.
 | ||||
|     #[serde(default)] | ||||
|     pub allow: Vec<AllowRule>, | ||||
| } | ||||
| 
 | ||||
| @ -324,6 +325,16 @@ mod tests { | ||||
|         assert_eq!(serde_json::to_string(&allow_rule).unwrap(), json); | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn restricted_room_no_allow_field() { | ||||
|         let json = r#"{"join_rule":"restricted"}"#; | ||||
|         let join_rules: RoomJoinRulesEventContent = serde_json::from_str(json).unwrap(); | ||||
|         assert_matches!( | ||||
|             join_rules, | ||||
|             RoomJoinRulesEventContent { join_rule: JoinRule::Restricted(_) } | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn join_rule_to_space_room_join_rule() { | ||||
|         assert_eq!(SpaceRoomJoinRule::Invite, JoinRule::Invite.into()); | ||||
|  | ||||
| @ -157,7 +157,7 @@ pub(super) enum RelationSerHelper { | ||||
|     Replacement(ReplacementJsonRepr), | ||||
| 
 | ||||
|     /// An event that belongs to a thread, with stable names.
 | ||||
|     #[serde(rename = "m.thread")] | ||||
|     #[serde(untagged)] | ||||
|     Thread(Thread), | ||||
| 
 | ||||
|     /// An unknown relation type.
 | ||||
|  | ||||
| @ -57,7 +57,7 @@ fn is_default_bits(val: &UInt) -> bool { | ||||
| ///
 | ||||
| /// The only algorithm currently specified is `m.secret_storage.v1.aes-hmac-sha2`, so this
 | ||||
| /// essentially represents `AesHmacSha2KeyDescription` in the
 | ||||
| /// [spec](https://spec.matrix.org/latest/client-server-api/#msecret_storagev1aes-hmac-sha2).
 | ||||
| /// [spec](https://spec.matrix.org/v1.11/client-server-api/#msecret_storagev1aes-hmac-sha2).
 | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| #[derive(Clone, Debug, Serialize, EventContent)] | ||||
| #[ruma_event(type = "m.secret_storage.key.*", kind = GlobalAccountData)] | ||||
| @ -137,7 +137,7 @@ impl SecretStorageEncryptionAlgorithm { | ||||
| /// The key properties for the `m.secret_storage.v1.aes-hmac-sha2` algorithm.
 | ||||
| ///
 | ||||
| /// Corresponds to the AES-specific properties of `AesHmacSha2KeyDescription` in the
 | ||||
| /// [spec](https://spec.matrix.org/latest/client-server-api/#msecret_storagev1aes-hmac-sha2).
 | ||||
| /// [spec](https://spec.matrix.org/v1.11/client-server-api/#msecret_storagev1aes-hmac-sha2).
 | ||||
| #[derive(Debug, Clone, Deserialize, Serialize)] | ||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||
| pub struct SecretStorageV1AesHmacSha2Properties { | ||||
| @ -182,7 +182,7 @@ mod tests { | ||||
|         PassPhrase, SecretStorageEncryptionAlgorithm, SecretStorageKeyEventContent, | ||||
|         SecretStorageV1AesHmacSha2Properties, | ||||
|     }; | ||||
|     use crate::{EventContentFromType, GlobalAccountDataEvent}; | ||||
|     use crate::{AnyGlobalAccountDataEvent, EventContentFromType, GlobalAccountDataEvent}; | ||||
| 
 | ||||
|     #[test] | ||||
|     fn key_description_serialization() { | ||||
| @ -326,7 +326,7 @@ mod tests { | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn event_serialization() { | ||||
|     fn event_content_serialization() { | ||||
|         let mut content = SecretStorageKeyEventContent::new( | ||||
|             "my_key_id".into(), | ||||
|             SecretStorageEncryptionAlgorithm::V1AesHmacSha2(SecretStorageV1AesHmacSha2Properties { | ||||
| @ -346,6 +346,31 @@ mod tests { | ||||
|         assert_eq!(to_json_value(&content).unwrap(), json); | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn event_serialization() { | ||||
|         let mut content = SecretStorageKeyEventContent::new( | ||||
|             "my_key_id".into(), | ||||
|             SecretStorageEncryptionAlgorithm::V1AesHmacSha2(SecretStorageV1AesHmacSha2Properties { | ||||
|                 iv: Some(Base64::parse("YWJjZGVmZ2hpamtsbW5vcA").unwrap()), | ||||
|                 mac: Some(Base64::parse("aWRvbnRrbm93d2hhdGFtYWNsb29rc2xpa2U").unwrap()), | ||||
|             }), | ||||
|         ); | ||||
|         content.name = Some("my_key".to_owned()); | ||||
|         let event = GlobalAccountDataEvent { content }; | ||||
| 
 | ||||
|         let json = json!({ | ||||
|             "type": "m.secret_storage.key.my_key_id", | ||||
|             "content": { | ||||
|                 "name": "my_key", | ||||
|                 "algorithm": "m.secret_storage.v1.aes-hmac-sha2", | ||||
|                 "iv": "YWJjZGVmZ2hpamtsbW5vcA", | ||||
|                 "mac": "aWRvbnRrbm93d2hhdGFtYWNsb29rc2xpa2U" | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         assert_eq!(to_json_value(&event).unwrap(), json); | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn event_deserialization() { | ||||
|         let json = json!({ | ||||
| @ -358,8 +383,8 @@ mod tests { | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         let ev = | ||||
|             from_json_value::<GlobalAccountDataEvent<SecretStorageKeyEventContent>>(json).unwrap(); | ||||
|         let any_ev = from_json_value::<AnyGlobalAccountDataEvent>(json).unwrap(); | ||||
|         assert_matches!(any_ev, AnyGlobalAccountDataEvent::SecretStorageKey(ev)); | ||||
|         assert_eq!(ev.content.key_id, "my_key_id"); | ||||
|         assert_eq!(ev.content.name.unwrap(), "my_key"); | ||||
|         assert_matches!(ev.content.passphrase, None); | ||||
|  | ||||
							
								
								
									
										81
									
								
								crates/ruma-events/tests/it/beacon.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								crates/ruma-events/tests/it/beacon.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,81 @@ | ||||
| #![cfg(feature = "unstable-msc3489")] | ||||
| 
 | ||||
| use assert_matches2::assert_matches; | ||||
| use js_int::uint; | ||||
| use ruma_common::{ | ||||
|     owned_event_id, room_id, serde::CanBeEmpty, user_id, MilliSecondsSinceUnixEpoch, | ||||
| }; | ||||
| use ruma_events::{ | ||||
|     beacon::BeaconEventContent, relation::Reference, AnyMessageLikeEvent, MessageLikeEvent, | ||||
| }; | ||||
| use serde_json::{ | ||||
|     from_value as from_json_value, json, to_value as to_json_value, Value as JsonValue, | ||||
| }; | ||||
| 
 | ||||
| fn get_beacon_event_content() -> BeaconEventContent { | ||||
|     BeaconEventContent::new( | ||||
|         owned_event_id!("$beacon_info_event_id:example.com"), | ||||
|         "geo:51.5008,0.1247;u=35".to_owned(), | ||||
|         Some(MilliSecondsSinceUnixEpoch(uint!(1_636_829_458))), | ||||
|     ) | ||||
| } | ||||
| 
 | ||||
| fn get_beacon_event_content_json() -> JsonValue { | ||||
|     json!({ | ||||
|         "m.relates_to": { | ||||
|             "rel_type": "m.reference", | ||||
|             "event_id": "$beacon_info_event_id:example.com" | ||||
|         }, | ||||
|         "org.matrix.msc3488.location": { | ||||
|             "uri": "geo:51.5008,0.1247;u=35", | ||||
|         }, | ||||
|         "org.matrix.msc3488.ts": 1_636_829_458 | ||||
|     }) | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn beacon_event_content_serialization() { | ||||
|     let event_content = get_beacon_event_content(); | ||||
| 
 | ||||
|     assert_eq!(to_json_value(&event_content).unwrap(), get_beacon_event_content_json()); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn beacon_event_content_deserialization() { | ||||
|     let json_data = get_beacon_event_content_json(); | ||||
| 
 | ||||
|     let event_content: BeaconEventContent = | ||||
|         from_json_value::<BeaconEventContent>(json_data).unwrap(); | ||||
| 
 | ||||
|     assert_eq!( | ||||
|         event_content.relates_to.event_id, | ||||
|         owned_event_id!("$beacon_info_event_id:example.com") | ||||
|     ); | ||||
|     assert_eq!(event_content.location.uri, "geo:51.5008,0.1247;u=35"); | ||||
|     assert_eq!(event_content.ts, MilliSecondsSinceUnixEpoch(uint!(1_636_829_458))); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn message_event_deserialization() { | ||||
|     let json_data = json!({ | ||||
|         "content": get_beacon_event_content_json(), | ||||
|         "event_id": "$beacon_event_id:example.com", | ||||
|         "origin_server_ts": 1_636_829_458, | ||||
|         "room_id": "!roomid:example.com", | ||||
|         "type": "org.matrix.msc3672.beacon", | ||||
|         "sender": "@example:example.com" | ||||
|     }); | ||||
| 
 | ||||
|     let event = from_json_value::<AnyMessageLikeEvent>(json_data).unwrap(); | ||||
| 
 | ||||
|     assert_matches!(event, AnyMessageLikeEvent::Beacon(MessageLikeEvent::Original(ev))); | ||||
|     assert_eq!(ev.content.location.uri, "geo:51.5008,0.1247;u=35"); | ||||
|     assert_eq!(ev.content.ts, MilliSecondsSinceUnixEpoch(uint!(1_636_829_458))); | ||||
|     assert_matches!(ev.content.relates_to, Reference { event_id, .. }); | ||||
|     assert_eq!(event_id, owned_event_id!("$beacon_info_event_id:example.com")); | ||||
| 
 | ||||
|     assert_eq!(ev.sender, user_id!("@example:example.com")); | ||||
|     assert_eq!(ev.room_id, room_id!("!roomid:example.com")); | ||||
|     assert_eq!(ev.origin_server_ts, MilliSecondsSinceUnixEpoch(uint!(1_636_829_458))); | ||||
|     assert!(ev.unsigned.is_empty()); | ||||
| } | ||||
							
								
								
									
										161
									
								
								crates/ruma-events/tests/it/beacon_info.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										161
									
								
								crates/ruma-events/tests/it/beacon_info.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,161 @@ | ||||
| #![cfg(feature = "unstable-msc3489")] | ||||
| 
 | ||||
| use std::time::Duration; | ||||
| 
 | ||||
| use assert_matches2::assert_matches; | ||||
| use js_int::uint; | ||||
| use ruma_common::{event_id, room_id, serde::CanBeEmpty, user_id, MilliSecondsSinceUnixEpoch}; | ||||
| use ruma_events::{ | ||||
|     beacon_info::BeaconInfoEventContent, location::AssetType, AnyStateEvent, StateEvent, | ||||
| }; | ||||
| use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; | ||||
| 
 | ||||
| fn get_beacon_info_event_content( | ||||
|     duration: Option<Duration>, | ||||
|     ts: Option<MilliSecondsSinceUnixEpoch>, | ||||
| ) -> BeaconInfoEventContent { | ||||
|     let description = Some("Kylie's live location".to_owned()); | ||||
|     let duration_or = duration.unwrap_or(Duration::from_secs(60)); | ||||
|     let ts_or = Some(ts.unwrap_or(MilliSecondsSinceUnixEpoch::now())); | ||||
| 
 | ||||
|     BeaconInfoEventContent::new(description, duration_or, true, ts_or) | ||||
| } | ||||
| 
 | ||||
| fn get_beacon_info_json() -> serde_json::Value { | ||||
|     json!({ | ||||
|         "org.matrix.msc3488.ts": 1_636_829_458, | ||||
|         "org.matrix.msc3488.asset": { | ||||
|             "type": "m.self" | ||||
|         }, | ||||
|         "timeout": 60_000, | ||||
|         "description": "Kylie's live location", | ||||
|         "live": true | ||||
|     }) | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn beacon_info_is_live() { | ||||
|     let event_content = get_beacon_info_event_content(None, None); | ||||
| 
 | ||||
|     assert!(event_content.is_live()); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn beacon_info_is_not_live() { | ||||
|     let duration = Some(Duration::from_nanos(1)); | ||||
|     let event_content = get_beacon_info_event_content(duration, None); | ||||
| 
 | ||||
|     assert!(!event_content.is_live()); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn beacon_info_stop_event() { | ||||
|     let ts = Some(MilliSecondsSinceUnixEpoch(1_636_829_458_u64.try_into().unwrap())); | ||||
| 
 | ||||
|     let mut event_content = get_beacon_info_event_content(None, ts); | ||||
| 
 | ||||
|     event_content.stop(); | ||||
| 
 | ||||
|     assert_eq!( | ||||
|         to_json_value(&event_content).unwrap(), | ||||
|         json!({ | ||||
|             "org.matrix.msc3488.ts": 1_636_829_458, | ||||
|             "org.matrix.msc3488.asset": { | ||||
|                 "type": "m.self" | ||||
|             }, | ||||
|             "timeout": 60_000, | ||||
|             "description": "Kylie's live location", | ||||
|             "live": false | ||||
|         }) | ||||
|     ); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn beacon_info_start_event() { | ||||
|     let ts = Some(MilliSecondsSinceUnixEpoch(1_636_829_458_u64.try_into().unwrap())); | ||||
| 
 | ||||
|     let mut event_content = BeaconInfoEventContent::new( | ||||
|         Some("Kylie's live location".to_owned()), | ||||
|         Duration::from_secs(60), | ||||
|         false, | ||||
|         ts, | ||||
|     ); | ||||
| 
 | ||||
|     event_content.start(); | ||||
| 
 | ||||
|     assert_eq!( | ||||
|         to_json_value(&event_content).unwrap(), | ||||
|         json!({ | ||||
|             "org.matrix.msc3488.ts": 1_636_829_458, | ||||
|             "org.matrix.msc3488.asset": { | ||||
|                 "type": "m.self" | ||||
|             }, | ||||
|             "timeout": 60_000, | ||||
|             "description": "Kylie's live location", | ||||
|             "live": true | ||||
|         }) | ||||
|     ); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn beacon_info_start_event_content_serialization() { | ||||
|     let ts = Some(MilliSecondsSinceUnixEpoch(1_636_829_458_u64.try_into().unwrap())); | ||||
| 
 | ||||
|     let event_content = get_beacon_info_event_content(None, ts); | ||||
| 
 | ||||
|     assert_eq!( | ||||
|         to_json_value(&event_content).unwrap(), | ||||
|         json!({ | ||||
|             "org.matrix.msc3488.ts": 1_636_829_458, | ||||
|             "org.matrix.msc3488.asset": { | ||||
|                 "type": "m.self" | ||||
|             }, | ||||
|             "timeout": 60_000, | ||||
|             "description": "Kylie's live location", | ||||
|             "live": true | ||||
|         }) | ||||
|     ); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn beacon_info_start_event_content_deserialization() { | ||||
|     let json_data = get_beacon_info_json(); | ||||
| 
 | ||||
|     let event_content: BeaconInfoEventContent = serde_json::from_value(json_data).unwrap(); | ||||
| 
 | ||||
|     assert_eq!(event_content.description, Some("Kylie's live location".to_owned())); | ||||
|     assert!(event_content.live); | ||||
|     assert_eq!(event_content.ts, MilliSecondsSinceUnixEpoch(uint!(1_636_829_458))); | ||||
|     assert_eq!(event_content.timeout, Duration::from_secs(60)); | ||||
|     assert_eq!(event_content.asset.type_, AssetType::Self_); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn state_event_deserialization() { | ||||
|     let json_data = json!({ | ||||
|         "content": get_beacon_info_json(), | ||||
|         "event_id": "$beacon_event_id:example.com", | ||||
|         "origin_server_ts": 1_636_829_458, | ||||
|         "room_id": "!roomid:example.com", | ||||
|         "type": "org.matrix.msc3672.beacon_info", | ||||
|         "sender": "@example:example.com", | ||||
|         "state_key": "@example:example.com" | ||||
|     }); | ||||
| 
 | ||||
|     let event = from_json_value::<AnyStateEvent>(json_data).unwrap(); | ||||
| 
 | ||||
|     assert_matches!(event, AnyStateEvent::BeaconInfo(StateEvent::Original(ev))); | ||||
| 
 | ||||
|     assert_eq!(ev.content.description, Some("Kylie's live location".to_owned())); | ||||
|     assert_eq!(ev.content.ts, MilliSecondsSinceUnixEpoch(uint!(1_636_829_458))); | ||||
|     assert_eq!(ev.content.timeout, Duration::from_secs(60)); | ||||
|     assert_eq!(ev.content.asset.type_, AssetType::Self_); | ||||
|     assert!(ev.content.live); | ||||
| 
 | ||||
|     assert_eq!(ev.event_id, event_id!("$beacon_event_id:example.com")); | ||||
|     assert_eq!(ev.origin_server_ts, MilliSecondsSinceUnixEpoch(uint!(1_636_829_458))); | ||||
|     assert_eq!(ev.room_id, room_id!("!roomid:example.com")); | ||||
|     assert_eq!(ev.sender, user_id!("@example:example.com")); | ||||
|     assert_eq!(ev.state_key, "@example:example.com"); | ||||
|     assert!(ev.unsigned.is_empty()); | ||||
| } | ||||
| @ -1,6 +1,3 @@ | ||||
| #[cfg(feature = "unstable-msc4075")] | ||||
| use std::collections::BTreeSet; | ||||
| 
 | ||||
| use assert_matches2::assert_matches; | ||||
| #[cfg(feature = "unstable-msc2747")] | ||||
| use assign::assign; | ||||
| @ -8,11 +5,6 @@ use js_int::uint; | ||||
| use ruma_common::{room_id, serde::CanBeEmpty, MilliSecondsSinceUnixEpoch, VoipVersionId}; | ||||
| #[cfg(feature = "unstable-msc2747")] | ||||
| use ruma_events::call::CallCapabilities; | ||||
| #[cfg(feature = "unstable-msc4075")] | ||||
| use ruma_events::{ | ||||
|     call::notify::{ApplicationType, CallNotifyEventContent, NotifyType}, | ||||
|     Mentions, | ||||
| }; | ||||
| use ruma_events::{ | ||||
|     call::{ | ||||
|         answer::CallAnswerEventContent, | ||||
| @ -616,83 +608,3 @@ fn select_v1_answer_event_deserialization() { | ||||
|     assert_eq!(content.selected_party_id, "6336"); | ||||
|     assert_eq!(content.version, VoipVersionId::V1); | ||||
| } | ||||
| 
 | ||||
| #[cfg(feature = "unstable-msc4075")] | ||||
| #[test] | ||||
| fn notify_event_serialization() { | ||||
|     use ruma_common::owned_user_id; | ||||
| 
 | ||||
|     let content_user_mention = CallNotifyEventContent::new( | ||||
|         "abcdef".into(), | ||||
|         ApplicationType::Call, | ||||
|         NotifyType::Ring, | ||||
|         Mentions::with_user_ids(vec![ | ||||
|             owned_user_id!("@user:example.com"), | ||||
|             owned_user_id!("@user2:example.com"), | ||||
|         ]), | ||||
|     ); | ||||
| 
 | ||||
|     let content_room_mention = CallNotifyEventContent::new( | ||||
|         "abcdef".into(), | ||||
|         ApplicationType::Call, | ||||
|         NotifyType::Ring, | ||||
|         Mentions::with_room_mention(), | ||||
|     ); | ||||
| 
 | ||||
|     assert_eq!( | ||||
|         to_json_value(&content_user_mention).unwrap(), | ||||
|         json!({ | ||||
|             "call_id": "abcdef", | ||||
|             "application": "m.call", | ||||
|             "m.mentions": { | ||||
|                 "user_ids": ["@user2:example.com","@user:example.com"], | ||||
|             }, | ||||
|             "notify_type": "ring", | ||||
|         }) | ||||
|     ); | ||||
|     assert_eq!( | ||||
|         to_json_value(&content_room_mention).unwrap(), | ||||
|         json!({ | ||||
|             "call_id": "abcdef", | ||||
|             "application": "m.call", | ||||
|             "m.mentions": { "room": true }, | ||||
|             "notify_type": "ring", | ||||
|         }) | ||||
|     ); | ||||
| } | ||||
| 
 | ||||
| #[cfg(feature = "unstable-msc4075")] | ||||
| #[test] | ||||
| fn notify_event_deserialization() { | ||||
|     use ruma_common::owned_user_id; | ||||
| 
 | ||||
|     let json_data = json!({ | ||||
|         "content": { | ||||
|             "call_id": "abcdef", | ||||
|             "application": "m.call", | ||||
|             "m.mentions": { | ||||
|                 "room": false, | ||||
|                 "user_ids": ["@user:example.com", "@user2:example.com"], | ||||
|             }, | ||||
|             "notify_type": "ring", | ||||
|         }, | ||||
|         "event_id": "$event:notareal.hs", | ||||
|         "origin_server_ts": 134_829_848, | ||||
|         "room_id": "!roomid:notareal.hs", | ||||
|         "sender": "@user:notareal.hs", | ||||
|         "type": "m.call.notify", | ||||
|     }); | ||||
| 
 | ||||
|     let event = from_json_value::<AnyMessageLikeEvent>(json_data).unwrap(); | ||||
|     assert_matches!( | ||||
|         event, | ||||
|         AnyMessageLikeEvent::CallNotify(MessageLikeEvent::Original(message_event)) | ||||
|     ); | ||||
|     let content = message_event.content; | ||||
|     assert_eq!(content.call_id, "abcdef"); | ||||
|     assert!(!content.mentions.room); | ||||
|     assert_eq!( | ||||
|         content.mentions.user_ids, | ||||
|         BTreeSet::from([owned_user_id!("@user:example.com"), owned_user_id!("@user2:example.com")]) | ||||
|     ); | ||||
| } | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| use assert_matches2::assert_matches; | ||||
| use ruma_common::{owned_device_id, owned_event_id}; | ||||
| use ruma_common::{owned_device_id, owned_event_id, serde::Raw}; | ||||
| use ruma_events::{ | ||||
|     relation::{CustomRelation, InReplyTo, Reference, Thread}, | ||||
|     relation::{Annotation, CustomRelation, InReplyTo, Reference, Thread}, | ||||
|     room::encrypted::{ | ||||
|         EncryptedEventScheme, MegolmV1AesSha2ContentInit, Relation, Replacement, | ||||
|         RoomEncryptedEventContent, | ||||
| @ -82,6 +82,17 @@ fn content_no_relation_deserialization() { | ||||
|     assert_matches!(content.relates_to, None); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn content_no_relation_serialization_roundtrip() { | ||||
|     let content = RoomEncryptedEventContent::new(encrypted_scheme(), None); | ||||
| 
 | ||||
|     let json_content = Raw::new(&content).unwrap(); | ||||
|     let deser_content = json_content.deserialize().unwrap(); | ||||
| 
 | ||||
|     assert_matches!(deser_content.scheme, EncryptedEventScheme::MegolmV1AesSha2(_)); | ||||
|     assert_matches!(deser_content.relates_to, None); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn content_reply_serialization() { | ||||
|     let content = RoomEncryptedEventContent::new( | ||||
| @ -149,6 +160,22 @@ fn content_reply_deserialization() { | ||||
|     assert_eq!(in_reply_to.event_id, "$replied_to_event"); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn content_reply_serialization_roundtrip() { | ||||
|     let reply = InReplyTo::new(owned_event_id!("$replied_to_event")); | ||||
|     let content = RoomEncryptedEventContent::new( | ||||
|         encrypted_scheme(), | ||||
|         Some(Relation::Reply { in_reply_to: reply.clone() }), | ||||
|     ); | ||||
| 
 | ||||
|     let json_content = Raw::new(&content).unwrap(); | ||||
|     let deser_content = json_content.deserialize().unwrap(); | ||||
| 
 | ||||
|     assert_matches!(deser_content.scheme, EncryptedEventScheme::MegolmV1AesSha2(_)); | ||||
|     assert_matches!(deser_content.relates_to, Some(Relation::Reply { in_reply_to: deser_reply })); | ||||
|     assert_eq!(deser_reply.event_id, reply.event_id); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn content_replacement_serialization() { | ||||
|     let content = RoomEncryptedEventContent::new( | ||||
| @ -214,6 +241,22 @@ fn content_replacement_deserialization() { | ||||
|     assert_eq!(replacement.event_id, "$replaced_event"); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn content_replacement_serialization_roundtrip() { | ||||
|     let replacement = Replacement::new(owned_event_id!("$replaced_event")); | ||||
|     let content = RoomEncryptedEventContent::new( | ||||
|         encrypted_scheme(), | ||||
|         Some(Relation::Replacement(replacement.clone())), | ||||
|     ); | ||||
| 
 | ||||
|     let json_content = Raw::new(&content).unwrap(); | ||||
|     let deser_content = json_content.deserialize().unwrap(); | ||||
| 
 | ||||
|     assert_matches!(deser_content.scheme, EncryptedEventScheme::MegolmV1AesSha2(_)); | ||||
|     assert_matches!(deser_content.relates_to, Some(Relation::Replacement(deser_replacement))); | ||||
|     assert_eq!(deser_replacement.event_id, replacement.event_id); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn content_reference_serialization() { | ||||
|     let content = RoomEncryptedEventContent::new( | ||||
| @ -279,6 +322,22 @@ fn content_reference_deserialization() { | ||||
|     assert_eq!(reference.event_id, "$referenced_event"); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn content_reference_serialization_roundtrip() { | ||||
|     let reference = Reference::new(owned_event_id!("$referenced_event")); | ||||
|     let content = RoomEncryptedEventContent::new( | ||||
|         encrypted_scheme(), | ||||
|         Some(Relation::Reference(reference.clone())), | ||||
|     ); | ||||
| 
 | ||||
|     let json_content = Raw::new(&content).unwrap(); | ||||
|     let deser_content = json_content.deserialize().unwrap(); | ||||
| 
 | ||||
|     assert_matches!(deser_content.scheme, EncryptedEventScheme::MegolmV1AesSha2(_)); | ||||
|     assert_matches!(deser_content.relates_to, Some(Relation::Reference(deser_reference))); | ||||
|     assert_eq!(deser_reference.event_id, reference.event_id); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn content_thread_serialization() { | ||||
|     let content = RoomEncryptedEventContent::new( | ||||
| @ -357,9 +416,23 @@ fn content_thread_deserialization() { | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn content_annotation_serialization() { | ||||
|     use ruma_events::relation::Annotation; | ||||
| fn content_thread_serialization_roundtrip() { | ||||
|     let thread = Thread::plain(owned_event_id!("$thread_root"), owned_event_id!("$prev_event")); | ||||
|     let content = | ||||
|         RoomEncryptedEventContent::new(encrypted_scheme(), Some(Relation::Thread(thread.clone()))); | ||||
| 
 | ||||
|     let json_content = Raw::new(&content).unwrap(); | ||||
|     let deser_content = json_content.deserialize().unwrap(); | ||||
| 
 | ||||
|     assert_matches!(deser_content.scheme, EncryptedEventScheme::MegolmV1AesSha2(_)); | ||||
|     assert_matches!(deser_content.relates_to, Some(Relation::Thread(deser_thread))); | ||||
|     assert_eq!(deser_thread.event_id, thread.event_id); | ||||
|     assert_eq!(deser_thread.in_reply_to.unwrap().event_id, thread.in_reply_to.unwrap().event_id); | ||||
|     assert_eq!(deser_thread.is_falling_back, thread.is_falling_back); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn content_annotation_serialization() { | ||||
|     let content = RoomEncryptedEventContent::new( | ||||
|         encrypted_scheme(), | ||||
|         Some(Relation::Annotation(Annotation::new( | ||||
| @ -429,6 +502,23 @@ fn content_annotation_deserialization() { | ||||
|     assert_eq!(annotation.key, "some_key"); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn content_annotation_serialization_roundtrip() { | ||||
|     let annotation = Annotation::new(owned_event_id!("$annotated_event"), "some_key".to_owned()); | ||||
|     let content = RoomEncryptedEventContent::new( | ||||
|         encrypted_scheme(), | ||||
|         Some(Relation::Annotation(annotation.clone())), | ||||
|     ); | ||||
| 
 | ||||
|     let json_content = Raw::new(&content).unwrap(); | ||||
|     let deser_content = json_content.deserialize().unwrap(); | ||||
| 
 | ||||
|     assert_matches!(deser_content.scheme, EncryptedEventScheme::MegolmV1AesSha2(_)); | ||||
|     assert_matches!(deser_content.relates_to, Some(Relation::Annotation(deser_annotation))); | ||||
|     assert_eq!(deser_annotation.event_id, annotation.event_id); | ||||
|     assert_eq!(deser_annotation.key, annotation.key); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn custom_relation_deserialization() { | ||||
|     let relation_json = json!({ | ||||
| @ -502,3 +592,31 @@ fn custom_relation_serialization() { | ||||
|         }) | ||||
|     ); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn custom_serialization_roundtrip() { | ||||
|     let rel_type = "io.ruma.unknown"; | ||||
|     let event_id = "$related_event"; | ||||
|     let key = "value"; | ||||
|     let json_relation = json!({ | ||||
|         "rel_type": rel_type, | ||||
|         "event_id": event_id, | ||||
|         "key": key, | ||||
|     }); | ||||
|     let relation = from_json_value::<CustomRelation>(json_relation).unwrap(); | ||||
| 
 | ||||
|     let content = | ||||
|         RoomEncryptedEventContent::new(encrypted_scheme(), Some(Relation::_Custom(relation))); | ||||
| 
 | ||||
|     let json_content = Raw::new(&content).unwrap(); | ||||
|     let deser_content = json_content.deserialize().unwrap(); | ||||
| 
 | ||||
|     assert_matches!(content.scheme, EncryptedEventScheme::MegolmV1AesSha2(_)); | ||||
|     let deser_relates_to = deser_content.relates_to.unwrap(); | ||||
|     assert_matches!(&deser_relates_to, Relation::_Custom(_)); | ||||
|     assert_eq!(deser_relates_to.rel_type().unwrap().as_str(), rel_type); | ||||
|     let deser_relation = deser_relates_to.data(); | ||||
|     assert_eq!(deser_relation.get("rel_type").unwrap().as_str().unwrap(), rel_type); | ||||
|     assert_eq!(deser_relation.get("event_id").unwrap().as_str().unwrap(), event_id); | ||||
|     assert_eq!(deser_relation.get("key").unwrap().as_str().unwrap(), key); | ||||
| } | ||||
|  | ||||
| @ -1,8 +1,15 @@ | ||||
| use assert_matches2::assert_matches; | ||||
| use js_int::uint; | ||||
| use ruma_common::{serde::CanBeEmpty, MilliSecondsSinceUnixEpoch, VoipVersionId}; | ||||
| use ruma_events::{AnyMessageLikeEvent, MessageLikeEvent}; | ||||
| use serde_json::{from_value as from_json_value, json}; | ||||
| use ruma_common::{ | ||||
|     serde::{CanBeEmpty, Raw}, | ||||
|     MilliSecondsSinceUnixEpoch, VoipVersionId, | ||||
| }; | ||||
| use ruma_events::{ | ||||
|     secret_storage::key::{SecretStorageEncryptionAlgorithm, SecretStorageV1AesHmacSha2Properties}, | ||||
|     AnyGlobalAccountDataEventContent, AnyMessageLikeEvent, AnyMessageLikeEventContent, | ||||
|     MessageLikeEvent, RawExt as _, | ||||
| }; | ||||
| use serde_json::{from_value as from_json_value, json, value::to_raw_value as to_raw_json_value}; | ||||
| 
 | ||||
| #[test] | ||||
| fn ui() { | ||||
| @ -46,3 +53,53 @@ fn deserialize_message_event() { | ||||
|     assert_eq!(content.call_id, "foofoo"); | ||||
|     assert_eq!(content.version, VoipVersionId::V0); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn text_msgtype_plain_text_deserialization_as_any() { | ||||
|     let serialized = json!({ | ||||
|         "body": "Hello world!", | ||||
|         "msgtype": "m.text" | ||||
|     }); | ||||
| 
 | ||||
|     let raw_event: Raw<AnyMessageLikeEventContent> = | ||||
|         Raw::from_json_string(serialized.to_string()).unwrap(); | ||||
| 
 | ||||
|     let event = raw_event.deserialize_with_type("m.room.message".into()).unwrap(); | ||||
| 
 | ||||
|     assert_matches!(event, AnyMessageLikeEventContent::RoomMessage(content)); | ||||
|     assert_eq!(content.body(), "Hello world!"); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn secret_storage_key_deserialization_as_any() { | ||||
|     let serialized = to_raw_json_value(&json!({ | ||||
|         "name": "my_key", | ||||
|         "algorithm": "m.secret_storage.v1.aes-hmac-sha2", | ||||
|         "iv": "YWJjZGVmZ2hpamtsbW5vcA", | ||||
|         "mac": "aWRvbnRrbm93d2hhdGFtYWNsb29rc2xpa2U" | ||||
|     })) | ||||
|     .unwrap(); | ||||
| 
 | ||||
|     let raw_event: Raw<AnyGlobalAccountDataEventContent> = | ||||
|         Raw::from_json_string(serialized.to_string()).unwrap(); | ||||
| 
 | ||||
|     let event = raw_event.deserialize_with_type("m.secret_storage.key.test".into()).unwrap(); | ||||
| 
 | ||||
|     assert_matches!(event, AnyGlobalAccountDataEventContent::SecretStorageKey(content)); | ||||
| 
 | ||||
|     assert_eq!(content.name.unwrap(), "my_key"); | ||||
|     assert_eq!(content.key_id, "test"); | ||||
|     assert_matches!(content.passphrase, None); | ||||
| 
 | ||||
|     assert_matches!( | ||||
|         content.algorithm, | ||||
|         SecretStorageEncryptionAlgorithm::V1AesHmacSha2(SecretStorageV1AesHmacSha2Properties { | ||||
|             iv: Some(iv), | ||||
|             mac: Some(mac), | ||||
|             .. | ||||
|         }) | ||||
|     ); | ||||
| 
 | ||||
|     assert_eq!(iv.encode(), "YWJjZGVmZ2hpamtsbW5vcA"); | ||||
|     assert_eq!(mac.encode(), "aWRvbnRrbm93d2hhdGFtYWNsb29rc2xpa2U"); | ||||
| } | ||||
|  | ||||
| @ -1,4 +1,6 @@ | ||||
| mod audio; | ||||
| mod beacon; | ||||
| mod beacon_info; | ||||
| mod call; | ||||
| mod encrypted; | ||||
| mod enums; | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| use assert_matches2::assert_matches; | ||||
| use assign::assign; | ||||
| use ruma_common::owned_event_id; | ||||
| use ruma_common::{owned_event_id, serde::Raw}; | ||||
| use ruma_events::{ | ||||
|     relation::{CustomRelation, InReplyTo, Replacement, Thread}, | ||||
|     room::message::{MessageType, Relation, RoomMessageEventContent}, | ||||
| @ -52,6 +52,22 @@ fn reply_serialize() { | ||||
|     ); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn reply_serialization_roundtrip() { | ||||
|     let body = "This is a reply"; | ||||
|     let mut content = RoomMessageEventContent::text_plain(body); | ||||
|     let reply = InReplyTo::new(owned_event_id!("$1598361704261elfgc")); | ||||
|     content.relates_to = Some(Relation::Reply { in_reply_to: reply.clone() }); | ||||
| 
 | ||||
|     let json_content = Raw::new(&content).unwrap(); | ||||
|     let deser_content = json_content.deserialize().unwrap(); | ||||
| 
 | ||||
|     assert_matches!(deser_content.msgtype, MessageType::Text(deser_msg)); | ||||
|     assert_eq!(deser_msg.body, body); | ||||
|     assert_matches!(content.relates_to.unwrap(), Relation::Reply { in_reply_to: deser_reply }); | ||||
|     assert_eq!(deser_reply.event_id, reply.event_id); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn replacement_serialize() { | ||||
|     let content = assign!( | ||||
| @ -111,6 +127,28 @@ fn replacement_deserialize() { | ||||
|     assert_eq!(text.body, "Hello! My name is bar"); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn replacement_serialization_roundtrip() { | ||||
|     let body = "<text msg>"; | ||||
|     let mut content = RoomMessageEventContent::text_plain(body); | ||||
|     let new_body = "This is the new content."; | ||||
|     let replacement = Replacement::new( | ||||
|         owned_event_id!("$1598361704261elfgc"), | ||||
|         RoomMessageEventContent::text_plain(new_body).into(), | ||||
|     ); | ||||
|     content.relates_to = Some(Relation::Replacement(replacement.clone())); | ||||
| 
 | ||||
|     let json_content = Raw::new(&content).unwrap(); | ||||
|     let deser_content = json_content.deserialize().unwrap(); | ||||
| 
 | ||||
|     assert_matches!(deser_content.msgtype, MessageType::Text(deser_msg)); | ||||
|     assert_eq!(deser_msg.body, body); | ||||
|     assert_matches!(content.relates_to.unwrap(), Relation::Replacement(deser_replacement)); | ||||
|     assert_eq!(deser_replacement.event_id, replacement.event_id); | ||||
|     assert_matches!(deser_replacement.new_content.msgtype, MessageType::Text(deser_new_msg)); | ||||
|     assert_eq!(deser_new_msg.body, new_body); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn thread_plain_serialize() { | ||||
|     let content = assign!( | ||||
| @ -250,6 +288,25 @@ fn thread_unstable_deserialize() { | ||||
|     assert!(!thread.is_falling_back); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn thread_serialization_roundtrip() { | ||||
|     let body = "<text msg>"; | ||||
|     let mut content = RoomMessageEventContent::text_plain(body); | ||||
|     let thread = | ||||
|         Thread::plain(owned_event_id!("$1598361704261elfgc"), owned_event_id!("$latesteventid")); | ||||
|     content.relates_to = Some(Relation::Thread(thread.clone())); | ||||
| 
 | ||||
|     let json_content = Raw::new(&content).unwrap(); | ||||
|     let deser_content = json_content.deserialize().unwrap(); | ||||
| 
 | ||||
|     assert_matches!(deser_content.msgtype, MessageType::Text(deser_msg)); | ||||
|     assert_eq!(deser_msg.body, body); | ||||
|     assert_matches!(content.relates_to.unwrap(), Relation::Thread(deser_thread)); | ||||
|     assert_eq!(deser_thread.event_id, thread.event_id); | ||||
|     assert_eq!(deser_thread.in_reply_to.unwrap().event_id, thread.in_reply_to.unwrap().event_id); | ||||
|     assert_eq!(deser_thread.is_falling_back, thread.is_falling_back); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn custom_deserialize() { | ||||
|     let relation_json = json!({ | ||||
| @ -300,3 +357,33 @@ fn custom_serialize() { | ||||
|         }) | ||||
|     ); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn custom_serialization_roundtrip() { | ||||
|     let rel_type = "io.ruma.unknown"; | ||||
|     let event_id = "$related_event"; | ||||
|     let key = "value"; | ||||
|     let json_relation = json!({ | ||||
|         "rel_type": rel_type, | ||||
|         "event_id": event_id, | ||||
|         "key": key, | ||||
|     }); | ||||
|     let relation = from_json_value::<CustomRelation>(json_relation).unwrap(); | ||||
| 
 | ||||
|     let body = "<text msg>"; | ||||
|     let mut content = RoomMessageEventContent::text_plain(body); | ||||
|     content.relates_to = Some(Relation::_Custom(relation)); | ||||
| 
 | ||||
|     let json_content = Raw::new(&content).unwrap(); | ||||
|     let deser_content = json_content.deserialize().unwrap(); | ||||
| 
 | ||||
|     assert_matches!(deser_content.msgtype, MessageType::Text(deser_msg)); | ||||
|     assert_eq!(deser_msg.body, body); | ||||
|     let deser_relates_to = deser_content.relates_to.unwrap(); | ||||
|     assert_matches!(&deser_relates_to, Relation::_Custom(_)); | ||||
|     assert_eq!(deser_relates_to.rel_type().unwrap().as_str(), rel_type); | ||||
|     let deser_relation = deser_relates_to.data(); | ||||
|     assert_eq!(deser_relation.get("rel_type").unwrap().as_str().unwrap(), rel_type); | ||||
|     assert_eq!(deser_relation.get("event_id").unwrap().as_str().unwrap(), event_id); | ||||
|     assert_eq!(deser_relation.get("key").unwrap().as_str().unwrap(), key); | ||||
| } | ||||
|  | ||||
| @ -19,7 +19,7 @@ use ruma_events::{ | ||||
|         }, | ||||
|         EncryptedFileInit, JsonWebKeyInit, MediaSource, | ||||
|     }, | ||||
|     AnySyncTimelineEvent, Mentions, MessageLikeUnsigned, | ||||
|     AnySyncTimelineEvent, EventContent, Mentions, MessageLikeUnsigned, RawExt, | ||||
| }; | ||||
| use serde_json::{ | ||||
|     from_value as from_json_value, json, to_value as to_json_value, Value as JsonValue, | ||||
| @ -484,6 +484,49 @@ fn reply_thread_fallback() { | ||||
|     assert!(thread_info.is_falling_back); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn reply_thread_serialization_roundtrip() { | ||||
|     let thread_root = OriginalRoomMessageEvent { | ||||
|         content: RoomMessageEventContent::text_plain("Thread root"), | ||||
|         event_id: owned_event_id!("$thread_root"), | ||||
|         origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(10_000)), | ||||
|         room_id: owned_room_id!("!testroomid:example.org"), | ||||
|         sender: owned_user_id!("@user:example.org"), | ||||
|         unsigned: MessageLikeUnsigned::default(), | ||||
|     }; | ||||
|     let threaded_message = OriginalRoomMessageEvent { | ||||
|         content: RoomMessageEventContent::text_plain("Threaded message").make_for_thread( | ||||
|             &thread_root, | ||||
|             ReplyWithinThread::No, | ||||
|             AddMentions::No, | ||||
|         ), | ||||
|         event_id: owned_event_id!("$threaded_message"), | ||||
|         origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(10_000)), | ||||
|         room_id: owned_room_id!("!testroomid:example.org"), | ||||
|         sender: owned_user_id!("@user:example.org"), | ||||
|         unsigned: MessageLikeUnsigned::default(), | ||||
|     }; | ||||
| 
 | ||||
|     let reply_as_thread_fallback = RoomMessageEventContent::text_plain( | ||||
|         "Reply from a thread client", | ||||
|     ) | ||||
|     .make_reply_to(&threaded_message, ForwardThread::Yes, AddMentions::No); | ||||
| 
 | ||||
|     let as_raw = Raw::new(&reply_as_thread_fallback).unwrap(); | ||||
| 
 | ||||
|     let reply_as_thread_fallback = | ||||
|         as_raw.deserialize_with_type(reply_as_thread_fallback.event_type()).unwrap(); | ||||
| 
 | ||||
|     let relation = reply_as_thread_fallback.relates_to.unwrap(); | ||||
|     assert_matches!(relation, Relation::Thread(thread_info)); | ||||
|     assert_eq!( | ||||
|         thread_info.in_reply_to.map(|in_reply_to| in_reply_to.event_id), | ||||
|         Some(threaded_message.event_id) | ||||
|     ); | ||||
|     assert_eq!(thread_info.event_id, thread_root.event_id); | ||||
|     assert!(thread_info.is_falling_back); | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn reply_add_mentions() { | ||||
|     let user = owned_user_id!("@user:example.org"); | ||||
|  | ||||
| @ -33,7 +33,7 @@ pub mod v1 { | ||||
|         pub query_type: String, | ||||
| 
 | ||||
|         /// The query parameters.
 | ||||
|         #[ruma_api(query_map)] | ||||
|         #[ruma_api(query_all)] | ||||
|         pub params: BTreeMap<String, String>, | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -1,5 +1,13 @@ | ||||
| # [unreleased] | ||||
| 
 | ||||
| Breaking Changes: | ||||
| 
 | ||||
| - `MatrixElement::Div` is now a newtype variant. | ||||
| 
 | ||||
| Improvements: | ||||
| 
 | ||||
| - Add support for mathematical messages, according to MSC2191 / Matrix 1.11 | ||||
| 
 | ||||
| # 0.2.0 | ||||
| 
 | ||||
| Breaking Changes: | ||||
|  | ||||
| @ -152,7 +152,7 @@ pub enum MatrixElement { | ||||
|     /// [`<div>`], a content division element.
 | ||||
|     ///
 | ||||
|     /// [`<div>`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/div
 | ||||
|     Div, | ||||
|     Div(DivData), | ||||
| 
 | ||||
|     /// [`<table>`], a table element.
 | ||||
|     ///
 | ||||
| @ -268,7 +268,10 @@ impl MatrixElement { | ||||
|             } | ||||
|             b"hr" => (Self::Hr, attrs.clone()), | ||||
|             b"br" => (Self::Br, attrs.clone()), | ||||
|             b"div" => (Self::Div, attrs.clone()), | ||||
|             b"div" => { | ||||
|                 let (data, attrs) = DivData::parse(attrs); | ||||
|                 (Self::Div(data), attrs) | ||||
|             } | ||||
|             b"table" => (Self::Table, attrs.clone()), | ||||
|             b"thead" => (Self::Thead, attrs.clone()), | ||||
|             b"tbody" => (Self::Tbody, attrs.clone()), | ||||
| @ -599,12 +602,23 @@ pub struct SpanData { | ||||
|     ///
 | ||||
|     /// [spoiler message]: https://spec.matrix.org/latest/client-server-api/#spoiler-messages
 | ||||
|     pub spoiler: Option<StrTendril>, | ||||
| 
 | ||||
|     /// `data-mx-maths`, an inline Matrix [mathematical message].
 | ||||
|     ///
 | ||||
|     /// The value is the mathematical notation in [LaTeX] format.
 | ||||
|     ///
 | ||||
|     /// If this attribute is present, the content of the span is the fallback representation of the
 | ||||
|     /// mathematical notation.
 | ||||
|     ///
 | ||||
|     /// [mathematical message]: https://spec.matrix.org/latest/client-server-api/#mathematical-messages
 | ||||
|     /// [LaTeX]: https://www.latex-project.org/
 | ||||
|     pub maths: Option<StrTendril>, | ||||
| } | ||||
| 
 | ||||
| impl SpanData { | ||||
|     /// Construct an empty `SpanData`.
 | ||||
|     fn new() -> Self { | ||||
|         Self { bg_color: None, color: None, spoiler: None } | ||||
|         Self { bg_color: None, color: None, spoiler: None, maths: None } | ||||
|     } | ||||
| 
 | ||||
|     /// Parse the given attributes to construct a new `SpanData`.
 | ||||
| @ -629,6 +643,9 @@ impl SpanData { | ||||
|                 b"data-mx-spoiler" => { | ||||
|                     data.spoiler = Some(attr.value.clone()); | ||||
|                 } | ||||
|                 b"data-mx-maths" => { | ||||
|                     data.maths = Some(attr.value.clone()); | ||||
|                 } | ||||
|                 _ => { | ||||
|                     remaining_attrs.insert(attr.clone()); | ||||
|                 } | ||||
| @ -722,3 +739,53 @@ impl ImageData { | ||||
|         (data, remaining_attrs) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// The supported data of a `<div>` HTML element.
 | ||||
| #[derive(Debug, Clone)] | ||||
| #[non_exhaustive] | ||||
| pub struct DivData { | ||||
|     /// `data-mx-maths`, a Matrix [mathematical message] block.
 | ||||
|     ///
 | ||||
|     /// The value is the mathematical notation in [LaTeX] format.
 | ||||
|     ///
 | ||||
|     /// If this attribute is present, the content of the div is the fallback representation of the
 | ||||
|     /// mathematical notation.
 | ||||
|     ///
 | ||||
|     /// [mathematical message]: https://spec.matrix.org/latest/client-server-api/#mathematical-messages
 | ||||
|     /// [LaTeX]: https://www.latex-project.org/
 | ||||
|     pub maths: Option<StrTendril>, | ||||
| } | ||||
| 
 | ||||
| impl DivData { | ||||
|     /// Construct an empty `DivData`.
 | ||||
|     fn new() -> Self { | ||||
|         Self { maths: None } | ||||
|     } | ||||
| 
 | ||||
|     /// Parse the given attributes to construct a new `SpanData`.
 | ||||
|     ///
 | ||||
|     /// Returns a tuple containing the constructed data and the remaining unsupported attributes.
 | ||||
|     #[allow(clippy::mutable_key_type)] | ||||
|     fn parse(attrs: &BTreeSet<Attribute>) -> (Self, BTreeSet<Attribute>) { | ||||
|         let mut data = Self::new(); | ||||
|         let mut remaining_attrs = BTreeSet::new(); | ||||
| 
 | ||||
|         for attr in attrs { | ||||
|             if attr.name.ns != ns!() { | ||||
|                 remaining_attrs.insert(attr.clone()); | ||||
|                 continue; | ||||
|             } | ||||
| 
 | ||||
|             match attr.name.local.as_bytes() { | ||||
|                 b"data-mx-maths" => { | ||||
|                     data.maths = Some(attr.value.clone()); | ||||
|                 } | ||||
|                 _ => { | ||||
|                     remaining_attrs.insert(attr.clone()); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         (data, remaining_attrs) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -28,14 +28,16 @@ static ALLOWED_ATTRIBUTES_STRICT: Map<&str, &Set<&str>> = phf_map! { | ||||
|     "img" => &ALLOWED_ATTRIBUTES_IMG_STRICT, | ||||
|     "ol" => &ALLOWED_ATTRIBUTES_OL_STRICT, | ||||
|     "code" => &ALLOWED_ATTRIBUTES_CODE_STRICT, | ||||
|     "div" => &ALLOWED_ATTRIBUTES_DIV_STRICT, | ||||
| }; | ||||
| static ALLOWED_ATTRIBUTES_SPAN_STRICT: Set<&str> = | ||||
|     phf_set! { "data-mx-bg-color", "data-mx-color", "data-mx-spoiler" }; | ||||
|     phf_set! { "data-mx-bg-color", "data-mx-color", "data-mx-spoiler", "data-mx-maths" }; | ||||
| static ALLOWED_ATTRIBUTES_A_STRICT: Set<&str> = phf_set! { "name", "target", "href" }; | ||||
| static ALLOWED_ATTRIBUTES_IMG_STRICT: Set<&str> = | ||||
|     phf_set! { "width", "height", "alt", "title", "src" }; | ||||
| static ALLOWED_ATTRIBUTES_OL_STRICT: Set<&str> = phf_set! { "start" }; | ||||
| static ALLOWED_ATTRIBUTES_CODE_STRICT: Set<&str> = phf_set! { "class" }; | ||||
| static ALLOWED_ATTRIBUTES_DIV_STRICT: Set<&str> = phf_set! { "data-mx-maths" }; | ||||
| 
 | ||||
| /// Attributes that were previously allowed on HTML elements according to the Matrix specification,
 | ||||
| /// with their replacement.
 | ||||
|  | ||||
| @ -26,7 +26,8 @@ fn elements() { | ||||
|     // `<div>` element.
 | ||||
|     let div_node = html_children.next().unwrap(); | ||||
|     let div_element = div_node.as_element().unwrap().to_matrix(); | ||||
|     assert_matches!(div_element.element, MatrixElement::Div); | ||||
|     assert_matches!(div_element.element, MatrixElement::Div(div)); | ||||
|     assert_eq!(div.maths, None); | ||||
|     // The `class` attribute is not supported.
 | ||||
|     assert_eq!(div_element.attrs.len(), 1); | ||||
| 
 | ||||
|  | ||||
| @ -5,10 +5,8 @@ | ||||
| Bug fixes: | ||||
| 
 | ||||
| - Allow underscores (`_`) when validating MXC URIs. | ||||
|   - They have always been allowed in [the spec][mxc validation spec] | ||||
|     in order to support URL-safe base64-encoded media IDs. | ||||
| 
 | ||||
| [mxc validation spec]: https://spec.matrix.org/v1.9/client-server-api/#security-considerations-5 | ||||
|   - They have always been allowed in the spec in order to support URL-safe | ||||
|     base64-encoded media IDs. | ||||
| 
 | ||||
| Improvements: | ||||
| 
 | ||||
|  | ||||
| @ -77,7 +77,7 @@ pub enum MxcUriError { | ||||
|     /// Media identifier malformed due to invalid characters detected.
 | ||||
|     ///
 | ||||
|     /// Valid characters are (in regex notation) `[A-Za-z0-9_-]+`.
 | ||||
|     /// See [here](https://spec.matrix.org/v1.10/client-server-api/#security-considerations-5) for more details.
 | ||||
|     /// See [here](https://spec.matrix.org/v1.11/client-server-api/#security-considerations-5) for more details.
 | ||||
|     #[error("Media Identifier malformed, invalid characters")] | ||||
|     MediaIdMalformed, | ||||
| 
 | ||||
|  | ||||
| @ -17,7 +17,7 @@ pub fn validate(uri: &str) -> Result<NonZeroU8, MxcUriError> { | ||||
| 
 | ||||
|     let server_name = &uri[..index]; | ||||
|     let media_id = &uri[index + 1..]; | ||||
|     // See: https://spec.matrix.org/v1.10/client-server-api/#security-considerations-5
 | ||||
|     // See: https://spec.matrix.org/v1.11/client-server-api/#security-considerations-5
 | ||||
|     let media_id_is_valid = media_id | ||||
|         .bytes() | ||||
|         .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'-' | b'_' )); | ||||
|  | ||||
| @ -10,7 +10,7 @@ mod kw { | ||||
|     syn::custom_keyword!(raw_body); | ||||
|     syn::custom_keyword!(path); | ||||
|     syn::custom_keyword!(query); | ||||
|     syn::custom_keyword!(query_map); | ||||
|     syn::custom_keyword!(query_all); | ||||
|     syn::custom_keyword!(header); | ||||
|     syn::custom_keyword!(error); | ||||
|     syn::custom_keyword!(manual_body_serde); | ||||
| @ -22,7 +22,7 @@ pub enum RequestMeta { | ||||
|     RawBody, | ||||
|     Path, | ||||
|     Query, | ||||
|     QueryMap, | ||||
|     QueryAll, | ||||
|     Header(Ident), | ||||
| } | ||||
| 
 | ||||
| @ -41,9 +41,9 @@ impl Parse for RequestMeta { | ||||
|         } else if lookahead.peek(kw::query) { | ||||
|             let _: kw::query = input.parse()?; | ||||
|             Ok(Self::Query) | ||||
|         } else if lookahead.peek(kw::query_map) { | ||||
|             let _: kw::query_map = input.parse()?; | ||||
|             Ok(Self::QueryMap) | ||||
|         } else if lookahead.peek(kw::query_all) { | ||||
|             let _: kw::query_all = input.parse()?; | ||||
|             Ok(Self::QueryAll) | ||||
|         } else if lookahead.peek(kw::header) { | ||||
|             let _: kw::header = input.parse()?; | ||||
|             let _: Token![=] = input.parse()?; | ||||
|  | ||||
| @ -137,8 +137,8 @@ impl Request { | ||||
|         self.fields.iter().find_map(RequestField::as_raw_body_field) | ||||
|     } | ||||
| 
 | ||||
|     fn query_map_field(&self) -> Option<&Field> { | ||||
|         self.fields.iter().find_map(RequestField::as_query_map_field) | ||||
|     fn query_all_field(&self) -> Option<&Field> { | ||||
|         self.fields.iter().find_map(RequestField::as_query_all_field) | ||||
|     } | ||||
| 
 | ||||
|     fn expand_all(&self, ruma_common: &TokenStream) -> TokenStream { | ||||
| @ -161,7 +161,7 @@ impl Request { | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         let request_query_def = if let Some(f) = self.query_map_field() { | ||||
|         let request_query_def = if let Some(f) = self.query_all_field() { | ||||
|             let field = Field { ident: None, colon_token: None, ..f.clone() }; | ||||
|             let field = PrivateField(&field); | ||||
|             Some(quote! { (#field); }) | ||||
| @ -220,15 +220,15 @@ impl Request { | ||||
|             } | ||||
|         }; | ||||
| 
 | ||||
|         let query_map_fields = | ||||
|             self.fields.iter().filter(|f| matches!(&f.kind, RequestFieldKind::QueryMap)); | ||||
|         let has_query_map_field = match query_map_fields.count() { | ||||
|         let query_all_fields = | ||||
|             self.fields.iter().filter(|f| matches!(&f.kind, RequestFieldKind::QueryAll)); | ||||
|         let has_query_all_field = match query_all_fields.count() { | ||||
|             0 => false, | ||||
|             1 => true, | ||||
|             _ => { | ||||
|                 return Err(syn::Error::new_spanned( | ||||
|                     &self.ident, | ||||
|                     "Can't have more than one query_map field", | ||||
|                     "Can't have more than one query_all field", | ||||
|                 )) | ||||
|             } | ||||
|         }; | ||||
| @ -244,10 +244,10 @@ impl Request { | ||||
|             )); | ||||
|         } | ||||
| 
 | ||||
|         if has_query_map_field && has_query_fields { | ||||
|         if has_query_all_field && has_query_fields { | ||||
|             return Err(syn::Error::new_spanned( | ||||
|                 &self.ident, | ||||
|                 "Can't have both a query map field and regular query fields", | ||||
|                 "Can't have both a query_all field and regular query fields", | ||||
|             )); | ||||
|         } | ||||
| 
 | ||||
| @ -307,8 +307,8 @@ pub(super) enum RequestFieldKind { | ||||
|     /// Data that appears in the query string.
 | ||||
|     Query, | ||||
| 
 | ||||
|     /// Data that appears in the query string as dynamic key-value pairs.
 | ||||
|     QueryMap, | ||||
|     /// Data that represents all the query string as a single type.
 | ||||
|     QueryAll, | ||||
| } | ||||
| 
 | ||||
| impl RequestField { | ||||
| @ -319,7 +319,7 @@ impl RequestField { | ||||
|             Some(RequestMeta::RawBody) => RequestFieldKind::RawBody, | ||||
|             Some(RequestMeta::Path) => RequestFieldKind::Path, | ||||
|             Some(RequestMeta::Query) => RequestFieldKind::Query, | ||||
|             Some(RequestMeta::QueryMap) => RequestFieldKind::QueryMap, | ||||
|             Some(RequestMeta::QueryAll) => RequestFieldKind::QueryAll, | ||||
|             Some(RequestMeta::Header(header)) => RequestFieldKind::Header(header), | ||||
|             None => RequestFieldKind::Body, | ||||
|         }; | ||||
| @ -359,10 +359,10 @@ impl RequestField { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /// Return the contained field if this request field is a query map kind.
 | ||||
|     pub fn as_query_map_field(&self) -> Option<&Field> { | ||||
|     /// Return the contained field if this request field is a query all kind.
 | ||||
|     pub fn as_query_all_field(&self) -> Option<&Field> { | ||||
|         match &self.kind { | ||||
|             RequestFieldKind::QueryMap => Some(&self.inner), | ||||
|             RequestFieldKind::QueryAll => Some(&self.inner), | ||||
|             _ => None, | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @ -31,7 +31,7 @@ impl Request { | ||||
|             (TokenStream::new(), TokenStream::new()) | ||||
|         }; | ||||
| 
 | ||||
|         let (parse_query, query_vars) = if let Some(field) = self.query_map_field() { | ||||
|         let (parse_query, query_vars) = if let Some(field) = self.query_all_field() { | ||||
|             let cfg_attrs = | ||||
|                 field.attrs.iter().filter(|a| a.path().is_ident("cfg")).collect::<Vec<_>>(); | ||||
|             let field_name = field.ident.as_ref().expect("expected field to have an identifier"); | ||||
|  | ||||
| @ -15,29 +15,11 @@ impl Request { | ||||
|         let path_fields = | ||||
|             self.path_fields().map(|f| f.ident.as_ref().expect("path fields have a name")); | ||||
| 
 | ||||
|         let request_query_string = if let Some(field) = self.query_map_field() { | ||||
|         let request_query_string = if let Some(field) = self.query_all_field() { | ||||
|             let field_name = field.ident.as_ref().expect("expected field to have identifier"); | ||||
| 
 | ||||
|             quote! {{ | ||||
|                 // This function exists so that the compiler will throw an error when the type of
 | ||||
|                 // the field with the query_map attribute doesn't implement
 | ||||
|                 // `IntoIterator<Item = (String, String)>`.
 | ||||
|                 //
 | ||||
|                 // This is necessary because the `serde_html_form::to_string` call will result in a
 | ||||
|                 // runtime error when the type cannot be encoded as a list key-value pairs
 | ||||
|                 // (?key1=value1&key2=value2).
 | ||||
|                 //
 | ||||
|                 // By asserting that it implements the iterator trait, we can ensure that it won't
 | ||||
|                 // fail.
 | ||||
|                 fn assert_trait_impl<T>(_: &T) | ||||
|                 where | ||||
|                     T: ::std::iter::IntoIterator< | ||||
|                         Item = (::std::string::String, ::std::string::String), | ||||
|                     >, | ||||
|                 {} | ||||
| 
 | ||||
|                 let request_query = RequestQuery(self.#field_name); | ||||
|                 assert_trait_impl(&request_query.0); | ||||
| 
 | ||||
|                 &#serde_html_form::to_string(request_query)? | ||||
|             }} | ||||
|  | ||||
| @ -170,7 +170,17 @@ fn expand_deserialize_impl( | ||||
|             }; | ||||
|             let self_variant = variant.ctor(quote! { Self }); | ||||
|             let content = event.to_event_path(kind, var); | ||||
|             let ev_types = event.aliases.iter().chain([&event.ev_type]); | ||||
|             let ev_types = event.aliases.iter().chain([&event.ev_type]).map(|ev_type| { | ||||
|                 if event.has_type_fragment() { | ||||
|                     let ev_type = ev_type.value(); | ||||
|                     let prefix = ev_type | ||||
|                         .strip_suffix('*') | ||||
|                         .expect("event type with type fragment must end with *"); | ||||
|                     quote! { t if t.starts_with(#prefix) } | ||||
|                 } else { | ||||
|                     quote! { #ev_type } | ||||
|                 } | ||||
|             }); | ||||
| 
 | ||||
|             Ok(quote! { | ||||
|                 #variant_attrs #(#ev_types)|* => { | ||||
| @ -328,6 +338,55 @@ fn expand_content_enum( | ||||
|     let serialize_custom_event_error_path = | ||||
|         quote! { #ruma_events::serialize_custom_event_error }.to_string(); | ||||
| 
 | ||||
|     // Generate an `EventContentFromType` implementation.
 | ||||
|     let serde_json = quote! { #ruma_events::exports::serde_json }; | ||||
|     let event_type_match_arms: TokenStream = events | ||||
|         .iter() | ||||
|         .map(|event| { | ||||
|             let variant = event.to_variant()?; | ||||
|             let variant_attrs = { | ||||
|                 let attrs = &variant.attrs; | ||||
|                 quote! { #(#attrs)* } | ||||
|             }; | ||||
|             let self_variant = variant.ctor(quote! { Self }); | ||||
| 
 | ||||
|             let ev_types = event.aliases.iter().chain([&event.ev_type]).map(|ev_type| { | ||||
|                 if event.has_type_fragment() { | ||||
|                     let ev_type = ev_type.value(); | ||||
|                     let prefix = ev_type | ||||
|                         .strip_suffix('*') | ||||
|                         .expect("event type with type fragment must end with *"); | ||||
|                     quote! { t if t.starts_with(#prefix) } | ||||
|                 } else { | ||||
|                     quote! { #ev_type } | ||||
|                 } | ||||
|             }); | ||||
| 
 | ||||
|             let deserialize_content = if event.has_type_fragment() { | ||||
|                 // If the event has a type fragment, then it implements EventContentFromType itself;
 | ||||
|                 // see `generate_event_content_impl` which does that. In this case, forward to its
 | ||||
|                 // implementation.
 | ||||
|                 let content_type = event.to_event_content_path(kind, None); | ||||
|                 quote! { | ||||
|                     #content_type::from_parts(event_type, json)? | ||||
|                 } | ||||
|             } else { | ||||
|                 // The event doesn't have a type fragment, so it *should* implement Deserialize:
 | ||||
|                 // use that here.
 | ||||
|                 quote! { | ||||
|                     #serde_json::from_str(json.get())? | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             Ok(quote! { | ||||
|                 #variant_attrs #(#ev_types)|* => { | ||||
|                     let content = #deserialize_content; | ||||
|                     Ok(#self_variant(content)) | ||||
|                 }, | ||||
|             }) | ||||
|         }) | ||||
|         .collect::<syn::Result<_>>()?; | ||||
| 
 | ||||
|     Ok(quote! { | ||||
|         #( #attrs )* | ||||
|         #[derive(Clone, Debug, #serde::Serialize)] | ||||
| @ -358,6 +417,23 @@ fn expand_content_enum( | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         #[automatically_derived] | ||||
|         impl #ruma_events::EventContentFromType for #ident { | ||||
|             fn from_parts(event_type: &str, json: &#serde_json::value::RawValue) -> serde_json::Result<Self> { | ||||
|                 match event_type { | ||||
|                     #event_type_match_arms | ||||
| 
 | ||||
|                     _ => { | ||||
|                         Ok(Self::_Custom { | ||||
|                             event_type: crate::PrivOwnedStr( | ||||
|                                 ::std::convert::From::from(event_type.to_owned()) | ||||
|                             ) | ||||
|                         }) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         #[automatically_derived] | ||||
|         impl #ruma_events::#sub_trait_name for #ident { | ||||
|             #state_event_content_impl | ||||
|  | ||||
| @ -4,6 +4,19 @@ Breaking changes: | ||||
| 
 | ||||
| - The `XMatrix::new` method now takes `OwnedServerName` instead of `Option<OwnedServerName>` | ||||
|   for the destination, since servers must always set the destination. | ||||
| - The `sig` field in `XMatrix` has been changed from `String` to `Base64` to more accurately | ||||
|   mirror its allowed values in the type system. | ||||
| 
 | ||||
| Bug fixes: | ||||
| 
 | ||||
| - When encoding to a header value, `XMatrix` fields are now quoted and escaped correctly. | ||||
| - Use http-auth crate to parse `XMatrix`. Allows to parse the Authorization HTTP | ||||
|   header with full compatibility with RFC 7235 | ||||
| 
 | ||||
| Improvements: | ||||
| 
 | ||||
| - Implement `Display`, `FromStr` and conversion to/from `http::HeaderValue` for | ||||
|   `XMatrix` | ||||
| 
 | ||||
| # 0.3.0 | ||||
| 
 | ||||
|  | ||||
| @ -16,9 +16,11 @@ all-features = true | ||||
| 
 | ||||
| [dependencies] | ||||
| headers = "0.4.0" | ||||
| http = { workspace = true } | ||||
| http-auth = { version = "0.1.9", default-features = false } | ||||
| ruma-common = { workspace = true } | ||||
| thiserror = { workspace = true } | ||||
| tracing = { workspace = true } | ||||
| yap = "0.12.0" | ||||
| 
 | ||||
| [dev-dependencies] | ||||
| tracing-subscriber = "0.3.16" | ||||
|  | ||||
| @ -1,30 +1,38 @@ | ||||
| //! Common types for implementing federation authorization.
 | ||||
| 
 | ||||
| use headers::{authorization::Credentials, HeaderValue}; | ||||
| use ruma_common::{OwnedServerName, OwnedServerSigningKeyId}; | ||||
| use std::{borrow::Cow, fmt, str::FromStr}; | ||||
| 
 | ||||
| use headers::authorization::Credentials; | ||||
| use http::HeaderValue; | ||||
| use http_auth::ChallengeParser; | ||||
| use ruma_common::{ | ||||
|     serde::{Base64, Base64DecodeError}, | ||||
|     IdParseError, OwnedServerName, OwnedServerSigningKeyId, | ||||
| }; | ||||
| use thiserror::Error; | ||||
| use tracing::debug; | ||||
| use yap::{IntoTokens, TokenLocation, Tokens}; | ||||
| 
 | ||||
| /// Typed representation of an `Authorization` header of scheme `X-Matrix`, as defined in the
 | ||||
| /// [Matrix Server-Server API][spec]. Includes an implementation of
 | ||||
| /// [`headers::authorization::Credentials`] for automatically handling the encoding and decoding
 | ||||
| /// when using a web framework that supports typed headers.
 | ||||
| /// [Matrix Server-Server API][spec].
 | ||||
| ///
 | ||||
| /// [spec]: https://spec.matrix.org/latest/server-server-api/#request-authentication
 | ||||
| #[derive(Clone)] | ||||
| #[non_exhaustive] | ||||
| pub struct XMatrix { | ||||
|     /// The server name of the sending server.
 | ||||
|     pub origin: OwnedServerName, | ||||
|     /// The server name of the receiving sender. For compatibility with older servers, recipients
 | ||||
|     /// should accept requests without this parameter, but MUST always send it. If this property is
 | ||||
|     /// included, but the value does not match the receiving server's name, the receiving server
 | ||||
|     /// must deny the request with an HTTP status code 401 Unauthorized.
 | ||||
|     /// The server name of the receiving sender.
 | ||||
|     ///
 | ||||
|     /// For compatibility with older servers, recipients should accept requests without this
 | ||||
|     /// parameter, but MUST always send it. If this property is included, but the value does
 | ||||
|     /// not match the receiving server's name, the receiving server must deny the request with
 | ||||
|     /// an HTTP status code 401 Unauthorized.
 | ||||
|     pub destination: Option<OwnedServerName>, | ||||
|     /// The ID - including the algorithm name - of the sending server's key that was used to sign
 | ||||
|     /// the request.
 | ||||
|     pub key: OwnedServerSigningKeyId, | ||||
|     /// The signature of the JSON.
 | ||||
|     pub sig: String, | ||||
|     pub sig: Base64, | ||||
| } | ||||
| 
 | ||||
| impl XMatrix { | ||||
| @ -33,227 +41,214 @@ impl XMatrix { | ||||
|         origin: OwnedServerName, | ||||
|         destination: OwnedServerName, | ||||
|         key: OwnedServerSigningKeyId, | ||||
|         sig: String, | ||||
|         sig: Base64, | ||||
|     ) -> Self { | ||||
|         Self { origin, destination: Some(destination), key, sig } | ||||
|     } | ||||
| 
 | ||||
|     /// Parse an X-Matrix Authorization header from the given string.
 | ||||
|     pub fn parse(s: impl AsRef<str>) -> Result<Self, XMatrixParseError> { | ||||
|         let parser = ChallengeParser::new(s.as_ref()); | ||||
|         let mut xmatrix = None; | ||||
| 
 | ||||
|         for challenge in parser { | ||||
|             let challenge = challenge?; | ||||
| 
 | ||||
|             if challenge.scheme.eq_ignore_ascii_case(XMatrix::SCHEME) { | ||||
|                 xmatrix = Some(challenge); | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
| fn parse_token<'a>(tokens: &mut impl Tokens<Item = &'a u8>) -> Option<Vec<u8>> { | ||||
|     tokens.optional(|t| { | ||||
|         let token: Vec<u8> = t.take_while(|c| is_tchar(**c)).as_iter().copied().collect(); | ||||
|         if !token.is_empty() { | ||||
|             Some(token) | ||||
|         } else { | ||||
|             debug!("Returning early because of empty token at {}", t.location().offset()); | ||||
|             None | ||||
|         } | ||||
|     }) | ||||
| } | ||||
|         let Some(xmatrix) = xmatrix else { | ||||
|             return Err(XMatrixParseError::NotFound); | ||||
|         }; | ||||
| 
 | ||||
| fn parse_token_with_colons<'a>(tokens: &mut impl Tokens<Item = &'a u8>) -> Option<Vec<u8>> { | ||||
|     tokens.optional(|t| { | ||||
|         let token: Vec<u8> = | ||||
|             t.take_while(|c| is_tchar(**c) || **c == b':').as_iter().copied().collect(); | ||||
|         if !token.is_empty() { | ||||
|             Some(token) | ||||
|         } else { | ||||
|             debug!("Returning early because of empty token at {}", t.location().offset()); | ||||
|             None | ||||
|         } | ||||
|     }) | ||||
| } | ||||
| 
 | ||||
| fn parse_quoted<'a>(tokens: &mut impl Tokens<Item = &'a u8>) -> Option<Vec<u8>> { | ||||
|     tokens.optional(|t| { | ||||
|         if !(t.token(&b'"')) { | ||||
|             return None; | ||||
|         } | ||||
|         let mut buffer = Vec::new(); | ||||
|         loop { | ||||
|             match t.next()? { | ||||
|                 // quoted pair
 | ||||
|                 b'\\' => { | ||||
|                     let escaped = t.next().filter(|c| { | ||||
|                         if is_quoted_pair(**c) { | ||||
|                             true | ||||
|                         } else { | ||||
|                             debug!( | ||||
|                                 "Encountered an illegal character {} at location {}", | ||||
|                                 **c as char, | ||||
|                                 t.location().offset() | ||||
|                             ); | ||||
|                             false | ||||
|                         } | ||||
|                     })?; | ||||
|                     buffer.push(*escaped); | ||||
|                 } | ||||
|                 // end of quote
 | ||||
|                 b'"' => break, | ||||
|                 // regular character
 | ||||
|                 c if is_qdtext(*c) => buffer.push(*c), | ||||
|                 // Invalid character
 | ||||
|                 c => { | ||||
|                     debug!( | ||||
|                         "Encountered an illegal character {} at location {}", | ||||
|                         *c as char, | ||||
|                         t.location().offset() | ||||
|                     ); | ||||
|                     return None; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         Some(buffer) | ||||
|     }) | ||||
| } | ||||
| 
 | ||||
| fn parse_xmatrix_field<'a>(tokens: &mut impl Tokens<Item = &'a u8>) -> Option<(String, Vec<u8>)> { | ||||
|     tokens.optional(|t| { | ||||
|         let name = parse_token(t).and_then(|name| { | ||||
|             let name = std::str::from_utf8(&name).ok()?.to_ascii_lowercase(); | ||||
|             match name.as_str() { | ||||
|                 "origin" | "destination" | "key" | "sig" => Some(name), | ||||
|                 name => { | ||||
|                     debug!( | ||||
|                         "Encountered an invalid field name {} at location {}", | ||||
|                         name, | ||||
|                         t.location().offset() | ||||
|                     ); | ||||
|                     None | ||||
|                 } | ||||
|             } | ||||
|         })?; | ||||
| 
 | ||||
|         if !t.token(&b'=') { | ||||
|             return None; | ||||
|         } | ||||
| 
 | ||||
|         let value = parse_quoted(t).or_else(|| parse_token_with_colons(t))?; | ||||
| 
 | ||||
|         Some((name, value)) | ||||
|     }) | ||||
| } | ||||
| 
 | ||||
| fn parse_xmatrix<'a>(tokens: &mut impl Tokens<Item = &'a u8>) -> Option<XMatrix> { | ||||
|     tokens.optional(|t| { | ||||
|         if !t.tokens(b"X-Matrix ") { | ||||
|             debug!("Failed to parse X-Matrix credentials, didn't start with 'X-Matrix '"); | ||||
|             return None; | ||||
|         } | ||||
|         let mut origin = None; | ||||
|         let mut destination = None; | ||||
|         let mut key = None; | ||||
|         let mut sig = None; | ||||
| 
 | ||||
|         for (name, value) in t.sep_by(|t| parse_xmatrix_field(t), |t| t.token(&b',')).as_iter() { | ||||
|             match name.as_str() { | ||||
|                 "origin" => { | ||||
|         for (name, value) in xmatrix.params { | ||||
|             if name.eq_ignore_ascii_case("origin") { | ||||
|                 if origin.is_some() { | ||||
|                         debug!("Field origin duplicated in X-Matrix Authorization header"); | ||||
|                     return Err(XMatrixParseError::DuplicateParameter("origin".to_owned())); | ||||
|                 } else { | ||||
|                     origin = Some(OwnedServerName::try_from(value.to_unescaped())?); | ||||
|                 } | ||||
|                     origin = Some(std::str::from_utf8(&value).ok()?.try_into().ok()?); | ||||
|                 } | ||||
|                 "destination" => { | ||||
|             } else if name.eq_ignore_ascii_case("destination") { | ||||
|                 if destination.is_some() { | ||||
|                         debug!("Field destination duplicated in X-Matrix Authorization header"); | ||||
|                     return Err(XMatrixParseError::DuplicateParameter("destination".to_owned())); | ||||
|                 } else { | ||||
|                     destination = Some(OwnedServerName::try_from(value.to_unescaped())?); | ||||
|                 } | ||||
|                     destination = Some(std::str::from_utf8(&value).ok()?.try_into().ok()?); | ||||
|                 } | ||||
|                 "key" => { | ||||
|             } else if name.eq_ignore_ascii_case("key") { | ||||
|                 if key.is_some() { | ||||
|                         debug!("Field key duplicated in X-Matrix Authorization header"); | ||||
|                     return Err(XMatrixParseError::DuplicateParameter("key".to_owned())); | ||||
|                 } else { | ||||
|                     key = Some(OwnedServerSigningKeyId::try_from(value.to_unescaped())?); | ||||
|                 } | ||||
|                     key = Some(std::str::from_utf8(&value).ok()?.try_into().ok()?); | ||||
|                 } | ||||
|                 "sig" => { | ||||
|             } else if name.eq_ignore_ascii_case("sig") { | ||||
|                 if sig.is_some() { | ||||
|                         debug!("Field sig duplicated in X-Matrix Authorization header"); | ||||
|                     } | ||||
|                     sig = Some(std::str::from_utf8(&value).ok()?.to_owned()); | ||||
|                 } | ||||
|                 name => { | ||||
|                     debug!("Unknown field {} found in X-Matrix Authorization header", name); | ||||
|                     return Err(XMatrixParseError::DuplicateParameter("sig".to_owned())); | ||||
|                 } else { | ||||
|                     sig = Some(Base64::parse(value.to_unescaped())?); | ||||
|                 } | ||||
|             } else { | ||||
|                 debug!("Unknown parameter {name} in X-Matrix Authorization header"); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         Some(XMatrix { origin: origin?, destination, key: key?, sig: sig? }) | ||||
|         Ok(Self { | ||||
|             origin: origin | ||||
|                 .ok_or_else(|| XMatrixParseError::MissingParameter("origin".to_owned()))?, | ||||
|             destination, | ||||
|             key: key.ok_or_else(|| XMatrixParseError::MissingParameter("key".to_owned()))?, | ||||
|             sig: sig.ok_or_else(|| XMatrixParseError::MissingParameter("sig".to_owned()))?, | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
| fn is_alpha(c: u8) -> bool { | ||||
|     (0x41..=0x5A).contains(&c) || (0x61..=0x7A).contains(&c) | ||||
| } | ||||
| 
 | ||||
| fn is_digit(c: u8) -> bool { | ||||
|     (0x30..=0x39).contains(&c) | ||||
| impl fmt::Debug for XMatrix { | ||||
|     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||||
|         f.debug_struct("XMatrix") | ||||
|             .field("origin", &self.origin) | ||||
|             .field("destination", &self.destination) | ||||
|             .field("key", &self.key) | ||||
|             .finish_non_exhaustive() | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| fn is_tchar(c: u8) -> bool { | ||||
|     const TOKEN_CHARS: [u8; 15] = | ||||
|         [b'!', b'#', b'$', b'%', b'&', b'\'', b'*', b'+', b'-', b'.', b'^', b'_', b'`', b'|', b'~']; | ||||
|     is_alpha(c) || is_digit(c) || TOKEN_CHARS.contains(&c) | ||||
| /// Whether the given char is a [token char].
 | ||||
| ///
 | ||||
| /// [token char]: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.2
 | ||||
| fn is_tchar(c: char) -> bool { | ||||
|     const TOKEN_CHARS: [char; 15] = | ||||
|         ['!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~']; | ||||
|     c.is_ascii_alphanumeric() || TOKEN_CHARS.contains(&c) | ||||
| } | ||||
| 
 | ||||
| fn is_qdtext(c: u8) -> bool { | ||||
|     c == b'\t' | ||||
|         || c == b' ' | ||||
|         || c == 0x21 | ||||
|         || (0x23..=0x5B).contains(&c) | ||||
|         || (0x5D..=0x7E).contains(&c) | ||||
|         || is_obs_text(c) | ||||
| /// If the field value does not contain only token chars, convert it to a [quoted string].
 | ||||
| ///
 | ||||
| /// [quoted string]: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.4
 | ||||
| fn escape_field_value(value: &str) -> Cow<'_, str> { | ||||
|     if !value.is_empty() && value.chars().all(is_tchar) { | ||||
|         return Cow::Borrowed(value); | ||||
|     } | ||||
| 
 | ||||
| fn is_obs_text(c: u8) -> bool { | ||||
|     c >= 0x80 // The spec does contain an upper limit of 0xFF here, but that's enforced by the type
 | ||||
|     let value = value.replace('\\', r#"\\"#).replace('"', r#"\""#); | ||||
|     Cow::Owned(format!("\"{value}\"")) | ||||
| } | ||||
| 
 | ||||
| fn is_vchar(c: u8) -> bool { | ||||
|     (0x21..=0x7E).contains(&c) | ||||
| impl fmt::Display for XMatrix { | ||||
|     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||||
|         let Self { origin, destination, key, sig } = self; | ||||
| 
 | ||||
|         let origin = escape_field_value(origin.as_str()); | ||||
|         let key = escape_field_value(key.as_str()); | ||||
|         let sig = sig.encode(); | ||||
|         let sig = escape_field_value(&sig); | ||||
| 
 | ||||
|         write!(f, r#"{} "#, Self::SCHEME)?; | ||||
| 
 | ||||
|         if let Some(destination) = destination { | ||||
|             let destination = escape_field_value(destination.as_str()); | ||||
|             write!(f, r#"destination={destination},"#)?; | ||||
|         } | ||||
| 
 | ||||
| fn is_quoted_pair(c: u8) -> bool { | ||||
|     c == b'\t' || c == b' ' || is_vchar(c) || is_obs_text(c) | ||||
|         write!(f, "key={key},origin={origin},sig={sig}") | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl FromStr for XMatrix { | ||||
|     type Err = XMatrixParseError; | ||||
| 
 | ||||
|     fn from_str(s: &str) -> Result<Self, Self::Err> { | ||||
|         Self::parse(s) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl TryFrom<&HeaderValue> for XMatrix { | ||||
|     type Error = XMatrixParseError; | ||||
| 
 | ||||
|     fn try_from(value: &HeaderValue) -> Result<Self, Self::Error> { | ||||
|         Self::parse(value.to_str()?) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl From<&XMatrix> for HeaderValue { | ||||
|     fn from(value: &XMatrix) -> Self { | ||||
|         value.to_string().try_into().expect("header format is static") | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl Credentials for XMatrix { | ||||
|     const SCHEME: &'static str = "X-Matrix"; | ||||
| 
 | ||||
|     fn decode(value: &HeaderValue) -> Option<Self> { | ||||
|         let value: Vec<u8> = value.as_bytes().to_vec(); | ||||
|         parse_xmatrix(&mut value.into_tokens()) | ||||
|         value.try_into().ok() | ||||
|     } | ||||
| 
 | ||||
|     fn encode(&self) -> HeaderValue { | ||||
|         if let Some(destination) = &self.destination { | ||||
|             format!( | ||||
|                 "X-Matrix origin=\"{}\",destination=\"{destination}\",key=\"{}\",sig=\"{}\"", | ||||
|                 self.origin, self.key, self.sig | ||||
|             ) | ||||
|         } else { | ||||
|             format!("X-Matrix origin=\"{}\",key=\"{}\",sig=\"{}\"", self.origin, self.key, self.sig) | ||||
|         self.into() | ||||
|     } | ||||
|         .try_into() | ||||
|         .expect("header format is static") | ||||
| } | ||||
| 
 | ||||
| /// An error when trying to parse an X-Matrix Authorization header.
 | ||||
| #[derive(Debug, Error)] | ||||
| #[non_exhaustive] | ||||
| pub enum XMatrixParseError { | ||||
|     /// The `HeaderValue` could not be converted to a `str`.
 | ||||
|     #[error(transparent)] | ||||
|     ToStr(#[from] http::header::ToStrError), | ||||
| 
 | ||||
|     /// The string could not be parsed as a valid Authorization string.
 | ||||
|     #[error("{0}")] | ||||
|     ParseStr(String), | ||||
| 
 | ||||
|     /// The credentials with the X-Matrix scheme were not found.
 | ||||
|     #[error("X-Matrix credentials not found")] | ||||
|     NotFound, | ||||
| 
 | ||||
|     /// The parameter value could not be parsed as a Matrix ID.
 | ||||
|     #[error(transparent)] | ||||
|     ParseId(#[from] IdParseError), | ||||
| 
 | ||||
|     /// The parameter value could not be parsed as base64.
 | ||||
|     #[error(transparent)] | ||||
|     ParseBase64(#[from] Base64DecodeError), | ||||
| 
 | ||||
|     /// The parameter with the given name was not found.
 | ||||
|     #[error("missing parameter '{0}'")] | ||||
|     MissingParameter(String), | ||||
| 
 | ||||
|     /// The parameter with the given name was found more than once.
 | ||||
|     #[error("duplicate parameter '{0}'")] | ||||
|     DuplicateParameter(String), | ||||
| } | ||||
| 
 | ||||
| impl<'a> From<http_auth::parser::Error<'a>> for XMatrixParseError { | ||||
|     fn from(value: http_auth::parser::Error<'a>) -> Self { | ||||
|         Self::ParseStr(value.to_string()) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use headers::{authorization::Credentials, HeaderValue}; | ||||
|     use ruma_common::OwnedServerName; | ||||
|     use ruma_common::{serde::Base64, OwnedServerName}; | ||||
| 
 | ||||
|     use super::XMatrix; | ||||
| 
 | ||||
|     #[test] | ||||
|     fn xmatrix_auth_pre_1_3() { | ||||
|         let header = HeaderValue::from_static( | ||||
|             "X-Matrix origin=\"origin.hs.example.com\",key=\"ed25519:key1\",sig=\"ABCDEF...\"", | ||||
|             "X-Matrix origin=\"origin.hs.example.com\",key=\"ed25519:key1\",sig=\"dGVzdA==\"", | ||||
|         ); | ||||
|         let origin = "origin.hs.example.com".try_into().unwrap(); | ||||
|         let key = "ed25519:key1".try_into().unwrap(); | ||||
|         let sig = "ABCDEF...".to_owned(); | ||||
|         let credentials: XMatrix = Credentials::decode(&header).unwrap(); | ||||
|         let sig = Base64::new(b"test".to_vec()); | ||||
|         let credentials = XMatrix::try_from(&header).unwrap(); | ||||
|         assert_eq!(credentials.origin, origin); | ||||
|         assert_eq!(credentials.destination, None); | ||||
|         assert_eq!(credentials.key, key); | ||||
| @ -261,17 +256,20 @@ mod tests { | ||||
| 
 | ||||
|         let credentials = XMatrix { origin, destination: None, key, sig }; | ||||
| 
 | ||||
|         assert_eq!(credentials.encode(), header); | ||||
|         assert_eq!( | ||||
|             credentials.encode(), | ||||
|             "X-Matrix key=\"ed25519:key1\",origin=origin.hs.example.com,sig=dGVzdA" | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn xmatrix_auth_1_3() { | ||||
|         let header = HeaderValue::from_static("X-Matrix origin=\"origin.hs.example.com\",destination=\"destination.hs.example.com\",key=\"ed25519:key1\",sig=\"ABCDEF...\""); | ||||
|         let header = HeaderValue::from_static("X-Matrix origin=\"origin.hs.example.com\",destination=\"destination.hs.example.com\",key=\"ed25519:key1\",sig=\"dGVzdA==\""); | ||||
|         let origin: OwnedServerName = "origin.hs.example.com".try_into().unwrap(); | ||||
|         let destination: OwnedServerName = "destination.hs.example.com".try_into().unwrap(); | ||||
|         let key = "ed25519:key1".try_into().unwrap(); | ||||
|         let sig = "ABCDEF...".to_owned(); | ||||
|         let credentials: XMatrix = Credentials::decode(&header).unwrap(); | ||||
|         let sig = Base64::new(b"test".to_vec()); | ||||
|         let credentials = XMatrix::try_from(&header).unwrap(); | ||||
|         assert_eq!(credentials.origin, origin); | ||||
|         assert_eq!(credentials.destination, Some(destination.clone())); | ||||
|         assert_eq!(credentials.key, key); | ||||
| @ -279,6 +277,41 @@ mod tests { | ||||
| 
 | ||||
|         let credentials = XMatrix::new(origin, destination, key, sig); | ||||
| 
 | ||||
|         assert_eq!(credentials.encode(), header); | ||||
|         assert_eq!(credentials.encode(), "X-Matrix destination=destination.hs.example.com,key=\"ed25519:key1\",origin=origin.hs.example.com,sig=dGVzdA"); | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn xmatrix_quoting() { | ||||
|         let header = HeaderValue::from_static( | ||||
|             r#"X-Matrix origin="example.com:1234",key="abc\"def\\:ghi",sig=dGVzdA,"#, | ||||
|         ); | ||||
| 
 | ||||
|         let origin: OwnedServerName = "example.com:1234".try_into().unwrap(); | ||||
|         let key = r#"abc"def\:ghi"#.try_into().unwrap(); | ||||
|         let sig = Base64::new(b"test".to_vec()); | ||||
|         let credentials = XMatrix::try_from(&header).unwrap(); | ||||
|         assert_eq!(credentials.origin, origin); | ||||
|         assert_eq!(credentials.destination, None); | ||||
|         assert_eq!(credentials.key, key); | ||||
|         assert_eq!(credentials.sig, sig); | ||||
| 
 | ||||
|         let credentials = XMatrix { origin, destination: None, key, sig }; | ||||
| 
 | ||||
|         assert_eq!( | ||||
|             credentials.encode(), | ||||
|             r#"X-Matrix key="abc\"def\\:ghi",origin="example.com:1234",sig=dGVzdA"# | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn xmatrix_auth_1_3_with_extra_spaces() { | ||||
|         let header = HeaderValue::from_static("X-Matrix origin=\"origin.hs.example.com\"  ,     destination=\"destination.hs.example.com\",key=\"ed25519:key1\", sig=\"dGVzdA\""); | ||||
|         let credentials = XMatrix::try_from(&header).unwrap(); | ||||
|         let sig = Base64::new(b"test".to_vec()); | ||||
| 
 | ||||
|         assert_eq!(credentials.origin, "origin.hs.example.com"); | ||||
|         assert_eq!(credentials.destination.unwrap(), "destination.hs.example.com"); | ||||
|         assert_eq!(credentials.key, "ed25519:key1"); | ||||
|         assert_eq!(credentials.sig, sig); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -226,7 +226,6 @@ unstable-msc2448 = [ | ||||
| ] | ||||
| unstable-msc2654 = ["ruma-client-api?/unstable-msc2654"] | ||||
| unstable-msc2666 = ["ruma-client-api?/unstable-msc2666"] | ||||
| unstable-msc2705 = ["ruma-client-api?/unstable-msc2705"] | ||||
| unstable-msc2747 = ["ruma-events?/unstable-msc2747"] | ||||
| unstable-msc2867 = ["ruma-events?/unstable-msc2867"] | ||||
| unstable-msc2870 = ["ruma-common/unstable-msc2870"] | ||||
| @ -242,10 +241,10 @@ unstable-msc3245 = ["ruma-events?/unstable-msc3245"] | ||||
| unstable-msc3245-v1-compat = ["ruma-events?/unstable-msc3245-v1-compat"] | ||||
| unstable-msc3246 = ["ruma-events?/unstable-msc3246"] | ||||
| unstable-msc3266 = ["ruma-client-api?/unstable-msc3266"] | ||||
| unstable-msc3291 = ["ruma-events?/unstable-msc3291"] | ||||
| unstable-msc3381 = ["ruma-events?/unstable-msc3381"] | ||||
| unstable-msc3401 = ["ruma-events?/unstable-msc3401"] | ||||
| unstable-msc3488 = ["ruma-client-api?/unstable-msc3488", "ruma-events?/unstable-msc3488"] | ||||
| unstable-msc3489 = ["ruma-events?/unstable-msc3489"] | ||||
| unstable-msc3551 = ["ruma-events?/unstable-msc3551"] | ||||
| unstable-msc3552 = ["ruma-events?/unstable-msc3552"] | ||||
| unstable-msc3553 = ["ruma-events?/unstable-msc3553"] | ||||
| @ -255,7 +254,6 @@ unstable-msc3618 = ["ruma-federation-api?/unstable-msc3618"] | ||||
| unstable-msc3723 = ["ruma-federation-api?/unstable-msc3723"] | ||||
| unstable-msc3814 = ["ruma-client-api?/unstable-msc3814"] | ||||
| unstable-msc3843 = ["ruma-client-api?/unstable-msc3843", "ruma-federation-api?/unstable-msc3843"] | ||||
| unstable-msc3916 = ["ruma-client-api?/unstable-msc3916"] | ||||
| unstable-msc3927 = ["ruma-events?/unstable-msc3927"] | ||||
| unstable-msc3930 = ["ruma-common/unstable-msc3930"] | ||||
| unstable-msc3931 = ["ruma-common/unstable-msc3931"] | ||||
| @ -268,6 +266,7 @@ unstable-msc4075 = ["ruma-events?/unstable-msc4075"] | ||||
| unstable-msc4108 = ["ruma-client-api?/unstable-msc4108"] | ||||
| unstable-msc4121 = ["ruma-client-api?/unstable-msc4121"] | ||||
| unstable-msc4125 = ["ruma-federation-api?/unstable-msc4125"] | ||||
| unstable-msc4140 = ["ruma-client-api?/unstable-msc4140"] | ||||
| unstable-pdu = ["ruma-events?/unstable-pdu"] | ||||
| unstable-unspecified = [ | ||||
|     "ruma-common/unstable-unspecified", | ||||
| @ -285,7 +284,6 @@ __ci = [ | ||||
|     "unstable-msc2448", | ||||
|     "unstable-msc2654", | ||||
|     "unstable-msc2666", | ||||
|     "unstable-msc2705", | ||||
|     "unstable-msc2747", | ||||
|     "unstable-msc2867", | ||||
|     "unstable-msc2870", | ||||
| @ -297,10 +295,10 @@ __ci = [ | ||||
|     "unstable-msc3245-v1-compat", | ||||
|     "unstable-msc3246", | ||||
|     "unstable-msc3266", | ||||
|     "unstable-msc3291", | ||||
|     "unstable-msc3381", | ||||
|     "unstable-msc3401", | ||||
|     "unstable-msc3488", | ||||
|     "unstable-msc3489", | ||||
|     "unstable-msc3551", | ||||
|     "unstable-msc3552", | ||||
|     "unstable-msc3553", | ||||
| @ -310,7 +308,6 @@ __ci = [ | ||||
|     "unstable-msc3723", | ||||
|     "unstable-msc3814", | ||||
|     "unstable-msc3843", | ||||
|     "unstable-msc3916", | ||||
|     "unstable-msc3927", | ||||
|     "unstable-msc3930", | ||||
|     "unstable-msc3931", | ||||
| @ -323,6 +320,7 @@ __ci = [ | ||||
|     "unstable-msc4108", | ||||
|     "unstable-msc4121", | ||||
|     "unstable-msc4125", | ||||
|     "unstable-msc4140" | ||||
| ] | ||||
| 
 | ||||
| [dependencies] | ||||
|  | ||||
| @ -17,7 +17,7 @@ const OLD_URL_WHITELIST: &[&str] = | ||||
| 
 | ||||
| /// Authorized versions in URLs pointing to the new specs.
 | ||||
| const NEW_VERSION_WHITELIST: &[&str] = &[ | ||||
|     "v1.1", "v1.2", "v1.3", "v1.4", "v1.5", "v1.6", "v1.7", "v1.8", "v1.9", "v1.10", | ||||
|     "v1.1", "v1.2", "v1.3", "v1.4", "v1.5", "v1.6", "v1.7", "v1.8", "v1.9", "v1.10", "v1.11", | ||||
|     "latest", | ||||
|     // This should only be enabled if a legitimate use case is found.
 | ||||
|     // "unstable",
 | ||||
| @ -220,43 +220,67 @@ fn get_page_ids(url: &str) -> Result<HashMap<String, HasDuplicates>> { | ||||
|             continue; | ||||
|         }; | ||||
| 
 | ||||
|         // For the URLs using the "latest" version, log the actual version we got.
 | ||||
|         if url[URL_PREFIX.len()..].starts_with("latest/") { | ||||
|             // Let's use the `meta` element with the `og:url` property, it contains the original
 | ||||
|             // relative URL of the page.
 | ||||
|             if tag.name.0 == b"meta" | ||||
|                 && tag | ||||
|                     .attributes | ||||
|                     .get(b"property".as_slice()) | ||||
|                     .is_some_and(|value| value.0 == b"og:url") | ||||
|             { | ||||
|                 match tag.attributes.get(b"content".as_slice()) { | ||||
|                     Some(value) => { | ||||
|                         println!( | ||||
|                             "Original URL for latest spec page: {}", | ||||
|                             String::from_utf8_lossy(value) | ||||
|                         ); | ||||
|                     } | ||||
|                     None => println!( | ||||
|                         "Could not get original URL for latest spec page: /{}", | ||||
|                         &url[URL_PREFIX.len()..] | ||||
|                     ), | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         let Some(id) = | ||||
|             tag.attributes.get(b"id".as_slice()).and_then(|s| String::from_utf8(s.0.clone()).ok()) | ||||
|         else { | ||||
|             continue; | ||||
|         }; | ||||
| 
 | ||||
|         let (id, has_duplicates) = uniquify_heading_id(id, &mut ids); | ||||
|         let has_duplicates = heading_id_has_duplicates(&id, &mut ids); | ||||
| 
 | ||||
|         ids.insert(id, has_duplicates); | ||||
|     } | ||||
| 
 | ||||
|     Ok(ids) | ||||
| } | ||||
| 
 | ||||
| /// Make sure the ID is unique in the page, if not make it unique.
 | ||||
| /// Check whether the given heading ID has duplicates in the given map.
 | ||||
| ///
 | ||||
| /// This is necessary because Matrix spec pages do that in JavaScript, so IDs
 | ||||
| /// are not unique in the source.
 | ||||
| ///
 | ||||
| /// This is a reimplementation of the algorithm used for the spec.
 | ||||
| ///
 | ||||
| /// See <https://github.com/matrix-org/matrix-spec/blob/6b02e393082570db2d0a651ddb79a365bc4a0f8d/static/js/toc.js#L25-L37>.
 | ||||
| fn uniquify_heading_id( | ||||
|     mut id: String, | ||||
| /// This check is necessary because duplicates IDs have a number depending on their occurrence in a
 | ||||
| /// HTML page. If a duplicate ID is added, moved or removed from the spec, its number might change
 | ||||
| /// from one version to the next.
 | ||||
| fn heading_id_has_duplicates( | ||||
|     id: &str, | ||||
|     unique_ids: &mut HashMap<String, HasDuplicates>, | ||||
| ) -> (String, HasDuplicates) { | ||||
|     let base_id = id.clone(); | ||||
|     let mut counter: u16 = 0; | ||||
|     let mut has_duplicates = HasDuplicates::No; | ||||
| ) -> HasDuplicates { | ||||
|     // IDs that should be duplicates end with `-{number}`.
 | ||||
|     let Some((start, _end)) = | ||||
|         id.rsplit_once('-').filter(|(_start, end)| end.chars().all(|c| c.is_ascii_digit())) | ||||
|     else { | ||||
|         return HasDuplicates::No; | ||||
|     }; | ||||
| 
 | ||||
|     while let Some(other_id_has_dup) = unique_ids.get_mut(&id) { | ||||
|         has_duplicates = HasDuplicates::Yes; | ||||
|     // Update the first duplicate ID, because it doesn't end with a number.
 | ||||
|     if let Some(other_id_has_dup) = unique_ids.get_mut(start) { | ||||
|         *other_id_has_dup = HasDuplicates::Yes; | ||||
|         counter += 1; | ||||
|         id = format!("{base_id}-{counter}"); | ||||
|     } | ||||
| 
 | ||||
|     (id, has_duplicates) | ||||
|     HasDuplicates::Yes | ||||
| } | ||||
| 
 | ||||
| fn print_link_err(error: &str, link: &SpecLink) { | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user