Repository: ruma/ruma
Pull request #2076: Client API: Add RTC Foci to the well-known discovery.
Author: login: pixlwave
Created: 2025-05-28T16:53:52Z | Merged: 2025-05-29T16:22:55Z

Ah, I was going to target this against #2075 but it's a fork so I can't. Please ignore the first commit until that PR is merged.

This PR adds discovery of the MatrixRTC foci using the client well-known file. The spec is here: https://github.com/matrix-org/matrix-spec-proposals/blob/toger5/matrixRTC/proposals/4143-matrix-rtc.md#discovery-of-foci-using-well-knownmatrixclient


Diff:
diff --git a/crates/ruma-client-api/CHANGELOG.md b/crates/ruma-client-api/CHANGELOG.md
index bc2a56c8e8..e1caac501c 100644
--- a/crates/ruma-client-api/CHANGELOG.md
+++ b/crates/ruma-client-api/CHANGELOG.md
@@ -23,6 +23,8 @@ Improvements:
   parameter. The former was removed in Matrix 1.14.
 - The `authentication` field of `discovery::discover_homeserver::Response` has
   been removed in favour of `discovery::get_authorization_server_metadata`.
+- The `discovery::discover_homeserver::Response` well-known now includes the
+  `rtc_foci` as defined in MSC4143.
 
 # 0.20.2
 
diff --git a/crates/ruma-client-api/Cargo.toml b/crates/ruma-client-api/Cargo.toml
index cc82d89014..9da1902401 100644
--- a/crates/ruma-client-api/Cargo.toml
+++ b/crates/ruma-client-api/Cargo.toml
@@ -50,6 +50,7 @@ unstable-msc3983 = []
 unstable-msc4108 = []
 unstable-msc4121 = []
 unstable-msc4140 = []
+unstable-msc4143 = []
 unstable-msc4186 = []
 
 [dependencies]
diff --git a/crates/ruma-client-api/src/discovery/discover_homeserver.rs b/crates/ruma-client-api/src/discovery/discover_homeserver.rs
index 7c641f3838..a9c080272f 100644
--- a/crates/ruma-client-api/src/discovery/discover_homeserver.rs
+++ b/crates/ruma-client-api/src/discovery/discover_homeserver.rs
@@ -4,11 +4,20 @@
 //!
 //! Get discovery information about the domain.
 
+#[cfg(feature = "unstable-msc4143")]
+use std::borrow::Cow;
+
+#[cfg(feature = "unstable-msc4143")]
+use ruma_common::serde::JsonObject;
 use ruma_common::{
     api::{request, response, Metadata},
     metadata,
 };
+#[cfg(feature = "unstable-msc4143")]
+use serde::de::DeserializeOwned;
 use serde::{Deserialize, Serialize};
+#[cfg(feature = "unstable-msc4143")]
+use serde_json::Value as JsonValue;
 
 const METADATA: Metadata = metadata! {
     method: GET,
@@ -43,6 +52,16 @@ pub struct Response {
         skip_serializing_if = "Option::is_none"
     )]
     pub tile_server: Option<TileServerInfo>,
+
+    /// A list of the available MatrixRTC foci, ordered by priority.
+    #[cfg(feature = "unstable-msc4143")]
+    #[serde(
+        rename = "org.matrix.msc4143.rtc_foci",
+        alias = "m.rtc_foci",
+        default,
+        skip_serializing_if = "Vec::is_empty"
+    )]
+    pub rtc_foci: Vec<RtcFocusInfo>,
 }
 
 impl Request {
@@ -60,6 +79,8 @@ impl Response {
             identity_server: None,
             #[cfg(feature = "unstable-msc3488")]
             tile_server: None,
+            #[cfg(feature = "unstable-msc4143")]
+            rtc_foci: Default::default(),
         }
     }
 }
@@ -112,3 +133,185 @@ impl TileServerInfo {
         Self { map_style_url }
     }
 }
+
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit(LiveKitRtcFocusInfo),
+
+    /// A custom RTC focus.
+    #[doc(hidden)]
+    #[serde(untagged)]
+    _Custom(CustomRtcFocusInfo),
+}
+
+#[cfg(feature = "unstable-msc4143")]
+impl RtcFocusInfo {
+    /// A constructor to create a custom RTC focus.
+    ///
+    /// Prefer to use the public variants of `RtcFocusInfo` where possible; this constructor is
+    /// meant to be used for unsupported focus types only and does not allow setting arbitrary data
+    /// for supported ones.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the `focus_type` is known and serialization of `data` to the
+    /// corresponding `RtcFocusInfo` variant fails.
+    pub fn new(focus_type: &str, data: JsonObject) -> serde_json::Result<Self> {
+        fn deserialize_variant<T: DeserializeOwned>(obj: JsonObject) -> serde_json::Result<T> {
+            serde_json::from_value(JsonValue::Object(obj))
+        }
+
+        Ok(match focus_type {
+            "livekit" => Self::LiveKit(deserialize_variant(data)?),
+            _ => Self::_Custom(CustomRtcFocusInfo { focus_type: focus_type.to_owned(), data }),
+        })
+    }
+
+    /// Creates a new `RtcFocusInfo::LiveKit`.
+    pub fn livekit(service_url: String) -> Self {
+        Self::LiveKit(LiveKitRtcFocusInfo { service_url })
+    }
+
+    /// Returns a reference to the focus type of this RTC focus.
+    pub fn focus_type(&self) -> &str {
+        match self {
+            Self::LiveKit(_) => "livekit",
+            Self::_Custom(custom) => &custom.focus_type,
+        }
+    }
+
+    /// Returns the associated data.
+    ///
+    /// The returned JSON object won't contain the `focus_type` field, please use
+    /// [`.focus_type()`][Self::focus_type] to access that.
+    ///
+    /// Prefer to use the public variants of `RtcFocusInfo` where possible; this method is meant to
+    /// be used for custom focus types only.
+    pub fn data(&self) -> Cow<'_, JsonObject> {
+        fn serialize<T: Serialize>(object: &T) -> JsonObject {
+            match serde_json::to_value(object).expect("rtc focus type serialization to succeed") {
+                JsonValue::Object(object) => object,
+                _ => panic!("all rtc focus types must serialize to objects"),
+            }
+        }
+
+        match self {
+            Self::LiveKit(info) => Cow::Owned(serialize(info)),
+            Self::_Custom(info) => Cow::Borrowed(&info.data),
+        }
+    }
+}
+
+/// Information about a LiveKit RTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub struct LiveKitRtcFocusInfo {
+    /// The URL for the LiveKit service.
+    #[serde(rename = "livekit_service_url")]
+    pub service_url: String,
+}
+
+/// Information about a custom RTC focus type.
+#[doc(hidden)]
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct CustomRtcFocusInfo {
+    /// The type of RTC focus.
+    #[serde(rename = "type")]
+    focus_type: String,
+
+    /// Remaining RTC focus data.
+    #[serde(flatten)]
+    data: JsonObject,
+}
+
+#[cfg(test)]
+mod tests {
+    #[cfg(feature = "unstable-msc4143")]
+    use assert_matches2::assert_matches;
+    #[cfg(feature = "unstable-msc4143")]
+    use serde_json::{from_value as from_json_value, json, to_value as to_json_value};
+
+    #[cfg(feature = "unstable-msc4143")]
+    use super::RtcFocusInfo;
+
+    #[test]
+    #[cfg(feature = "unstable-msc4143")]
+    fn test_livekit_rtc_focus_deserialization() {
+        // Given the JSON for a LiveKit RTC focus.
+        let json = json!({
+            "type": "livekit",
+            "livekit_service_url": "https://livekit.example.com"
+        });
+
+        // When deserializing it into an RtcFocusInfo.
+        let focus: RtcFocusInfo = from_json_value(json).unwrap();
+
+        // Then it should be recognized as a LiveKit focus with the correct service URL.
+        assert_matches!(focus, RtcFocusInfo::LiveKit(info));
+        assert_eq!(info.service_url, "https://livekit.example.com");
+    }
+
+    #[test]
+    #[cfg(feature = "unstable-msc4143")]
+    fn test_livekit_rtc_focus_serialization() {
+        // Given a LiveKit RTC focus info.
+        let focus = RtcFocusInfo::livekit("https://livekit.example.com".to_owned());
+
+        // When serializing it to JSON.
+        let json = to_json_value(&focus).unwrap();
+
+        // Then it should match the expected JSON structure.
+        assert_eq!(
+            json,
+            json!({
+                "type": "livekit",
+                "livekit_service_url": "https://livekit.example.com"
+            })
+        );
+    }
+
+    #[test]
+    #[cfg(feature = "unstable-msc4143")]
+    fn test_custom_rtc_focus_serialization() {
+        // Given the JSON for a custom RTC focus type with additional fields.
+        let json = json!({
+            "type": "some-focus-type",
+            "additional-type-specific-field": "https://my_focus.domain",
+            "another-additional-type-specific-field": ["with", "Array", "type"]
+        });
+
+        // When deserializing it into an RtcFocusInfo.
+        let focus: RtcFocusInfo = from_json_value(json.clone()).unwrap();
+
+        // Then it should be recognized as a custom focus type, with all the additional fields
+        // included.
+        assert_eq!(focus.focus_type(), "some-focus-type");
+
+        let data = &focus.data();
+        assert_eq!(data["additional-type-specific-field"], "https://my_focus.domain");
+
+        let array_values: Vec<&str> = data["another-additional-type-specific-field"]
+            .as_array()
+            .unwrap()
+            .iter()
+            .map(|v| v.as_str().unwrap())
+            .collect();
+        assert_eq!(array_values, vec!["with", "Array", "type"]);
+
+        assert!(!data.contains_key("type"));
+
+        // When serializing it back to JSON.
+        let serialized = to_json_value(&focus).unwrap();
+
+        // Then it should match the original JSON.
+        assert_eq!(serialized, json);
+    }
+}
diff --git a/crates/ruma/Cargo.toml b/crates/ruma/Cargo.toml
index 91aba72049..172594cd15 100644
--- a/crates/ruma/Cargo.toml
+++ b/crates/ruma/Cargo.toml
@@ -183,6 +183,7 @@ 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-msc4143 = ["ruma-client-api?/unstable-msc4143"]
 unstable-msc4171 = ["ruma-events?/unstable-msc4171"]
 unstable-msc4186 = ["ruma-client-api?/unstable-msc4186"]
 unstable-msc4203 = ["ruma-appservice-api?/unstable-msc4203"]
@@ -237,6 +238,7 @@ __unstable-mscs = [
     "unstable-msc4121",
     "unstable-msc4125",
     "unstable-msc4140",
+    "unstable-msc4143",
     "unstable-msc4171",
     "unstable-msc4186",
     "unstable-msc4203",


Reviews:
user: login: zecakeh
body: Thanks! Could you add deserialization tests of the livekit variant and a custom type, and serialization test of the livekit variant? Can you also add an entry in the changelog for this?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T08:21:36Z
user: login: jplatte
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-29T08:27:29Z
user: login: zecakeh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T08:34:06Z
user: login: jplatte
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-29T08:46:58Z
user: login: pixlwave
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T10:14:33Z
user: login: pixlwave
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T10:15:06Z
user: login: pixlwave
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T10:15:24Z
user: login: pixlwave
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T10:50:06Z
user: login: pixlwave
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T10:50:35Z
user: login: pixlwave
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T10:52:07Z
user: login: zecakeh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T13:18:21Z
user: login: pixlwave
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T14:01:35Z
user: login: pixlwave
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T14:02:18Z
user: login: pixlwave
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T14:03:22Z
user: login: pixlwave
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T14:03:39Z
user: login: zecakeh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T14:32:50Z
user: login: zecakeh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T14:33:37Z
user: login: pixlwave
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T15:10:53Z
user: login: zecakeh
body: Thanks!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T16:22:38Z

Review comments:
diff_hunk: @@ -44,14 +44,14 @@ pub struct Response {
     )]
     pub tile_server: Option<TileServerInfo>,
 
-    /// Information about the authentication server to connect to when using OpenID Connect.
-    #[cfg(feature = "unstable-msc2965")]
+    /// A list of the available MatrixRTC foci, ordered by priority.
+    #[cfg(feature = "unstable-msc4143")]
     #[serde(
-        rename = "org.matrix.msc2965.authentication",
-        alias = "m.authentication",
+        rename = "org.matrix.msc4143.rtc_foci",
+        alias = "m.rtc_foci",
         skip_serializing_if = "Option::is_none"
     )]
-    pub authentication: Option<AuthenticationServerInfo>,
+    pub rtc_foci: Option<Vec<RtcFocusInfo>>,
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: zecakeh
body: It seems that there is no difference between an empty list and a missing list, so we can remove the `Option` and use `#[serde(default, skip_serializing_if = "Vec::is_empty")]`.
created_at: 2025-05-29T08:05:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -124,24 +124,28 @@ impl TileServerInfo {
     }
 }
 
-/// Information about a discovered authentication server.
-#[cfg(feature = "unstable-msc2965")]
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
 #[derive(Clone, Debug, Deserialize, Hash, Serialize)]
 #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
-pub struct AuthenticationServerInfo {
-    /// The OIDC Provider that is trusted by the homeserver.
-    pub issuer: String,
-
-    /// The URL where the user is able to access the account management
-    /// capabilities of the OIDC Provider.
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub account: Option<String>,
-}
-
-#[cfg(feature = "unstable-msc2965")]
-impl AuthenticationServerInfo {
-    /// Creates an `AuthenticationServerInfo` with the given `issuer` and an optional `account`.
-    pub fn new(issuer: String, account: Option<String>) -> Self {
-        Self { issuer, account }
-    }
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit {
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: zecakeh
body: We prefer to use "tuple" variants with a struct inside, rather than "struct" variants.
created_at: 2025-05-29T08:10:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -124,24 +124,28 @@ impl TileServerInfo {
     }
 }
 
-/// Information about a discovered authentication server.
-#[cfg(feature = "unstable-msc2965")]
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
 #[derive(Clone, Debug, Deserialize, Hash, Serialize)]
 #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
-pub struct AuthenticationServerInfo {
-    /// The OIDC Provider that is trusted by the homeserver.
-    pub issuer: String,
-
-    /// The URL where the user is able to access the account management
-    /// capabilities of the OIDC Provider.
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub account: Option<String>,
-}
-
-#[cfg(feature = "unstable-msc2965")]
-impl AuthenticationServerInfo {
-    /// Creates an `AuthenticationServerInfo` with the given `issuer` and an optional `account`.
-    pub fn new(issuer: String, account: Option<String>) -> Self {
-        Self { issuer, account }
-    }
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit {
+        /// The service URL for the LiveKit service.
+        #[serde(rename = "livekit_service_url")]
+        service_url: String,
+    },
+
+    /// A generic RTC focus with any unrecognized type.
+    #[serde(untagged)]
+    Unknown {
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: zecakeh
body: We also don't expose unknown variants. We usually call them `_Custom`, hide them in the docs, and add accessors on the type to access custom data.

See [the corresponding variant on `MessageType`](https://github.com/ruma/ruma/blob/a0105c84ca20232630bbbf2a0405d9fa34f21dd0/crates/ruma-events/src/room/message.rs#L446) for example.
created_at: 2025-05-29T08:13:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -124,24 +124,28 @@ impl TileServerInfo {
     }
 }
 
-/// Information about a discovered authentication server.
-#[cfg(feature = "unstable-msc2965")]
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
 #[derive(Clone, Debug, Deserialize, Hash, Serialize)]
 #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
-pub struct AuthenticationServerInfo {
-    /// The OIDC Provider that is trusted by the homeserver.
-    pub issuer: String,
-
-    /// The URL where the user is able to access the account management
-    /// capabilities of the OIDC Provider.
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub account: Option<String>,
-}
-
-#[cfg(feature = "unstable-msc2965")]
-impl AuthenticationServerInfo {
-    /// Creates an `AuthenticationServerInfo` with the given `issuer` and an optional `account`.
-    pub fn new(issuer: String, account: Option<String>) -> Self {
-        Self { issuer, account }
-    }
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit {
+        /// The service URL for the LiveKit service.
+        #[serde(rename = "livekit_service_url")]
+        service_url: String,
+    },
+
+    /// A generic RTC focus with any unrecognized type.
+    #[serde(untagged)]
+    Unknown {
+        /// The type of the RTC focus.
+        r#type: String,
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: zecakeh
body: We don't usually use raw identifiers, we use a different name and `#[serde(rename = "type")]`.
created_at: 2025-05-29T08:14:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -183,6 +183,7 @@ 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-msc4143 = ["ruma-client-api?/unstable-msc4143"]
path: crates/ruma/Cargo.toml
user: login: zecakeh
body: You also need to add this feature to the `__unstable-mscs` list below.
created_at: 2025-05-29T08:15:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3,4 +3,5 @@ target
 Cargo.lock
 .DS_Store
 .idea
+.nova
path: .gitignore
user: login: jplatte
body: What's this? 
created_at: 2025-05-29T08:27:24Z
author_association: MEMBER
diff_hunk: @@ -3,4 +3,5 @@ target
 Cargo.lock
 .DS_Store
 .idea
+.nova
path: .gitignore
user: login: zecakeh
body: Looks like [an IDE](https://nova.app/).

IMO, this should rather be in the global gitignore on the system, but in that case we should have no IDE folder in this list at all.
created_at: 2025-05-29T08:34:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3,4 +3,5 @@ target
 Cargo.lock
 .DS_Store
 .idea
+.nova
path: .gitignore
user: login: jplatte
body: Right. I guess it's fine.
created_at: 2025-05-29T08:46:58Z
author_association: MEMBER
diff_hunk: @@ -44,14 +44,14 @@ pub struct Response {
     )]
     pub tile_server: Option<TileServerInfo>,
 
-    /// Information about the authentication server to connect to when using OpenID Connect.
-    #[cfg(feature = "unstable-msc2965")]
+    /// A list of the available MatrixRTC foci, ordered by priority.
+    #[cfg(feature = "unstable-msc4143")]
     #[serde(
-        rename = "org.matrix.msc2965.authentication",
-        alias = "m.authentication",
+        rename = "org.matrix.msc4143.rtc_foci",
+        alias = "m.rtc_foci",
         skip_serializing_if = "Option::is_none"
     )]
-    pub authentication: Option<AuthenticationServerInfo>,
+    pub rtc_foci: Option<Vec<RtcFocusInfo>>,
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: pixlwave
body: Ah yeah, I wondered if that would be preferable. Done in 87f74d9eabb34d41df3fa2b7e71ebb3527d5c2c4
created_at: 2025-05-29T10:14:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -124,24 +124,28 @@ impl TileServerInfo {
     }
 }
 
-/// Information about a discovered authentication server.
-#[cfg(feature = "unstable-msc2965")]
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
 #[derive(Clone, Debug, Deserialize, Hash, Serialize)]
 #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
-pub struct AuthenticationServerInfo {
-    /// The OIDC Provider that is trusted by the homeserver.
-    pub issuer: String,
-
-    /// The URL where the user is able to access the account management
-    /// capabilities of the OIDC Provider.
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub account: Option<String>,
-}
-
-#[cfg(feature = "unstable-msc2965")]
-impl AuthenticationServerInfo {
-    /// Creates an `AuthenticationServerInfo` with the given `issuer` and an optional `account`.
-    pub fn new(issuer: String, account: Option<String>) -> Self {
-        Self { issuer, account }
-    }
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit {
+        /// The service URL for the LiveKit service.
+        #[serde(rename = "livekit_service_url")]
+        service_url: String,
+    },
+
+    /// A generic RTC focus with any unrecognized type.
+    #[serde(untagged)]
+    Unknown {
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: pixlwave
body: I think I've done this correctly, please shout if I've gone astray 😄
created_at: 2025-05-29T10:15:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -183,6 +183,7 @@ 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-msc4143 = ["ruma-client-api?/unstable-msc4143"]
path: crates/ruma/Cargo.toml
user: login: pixlwave
body: Ah, my bad. Added 👍
created_at: 2025-05-29T10:15:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,3 +131,163 @@ impl TileServerInfo {
         Self { map_style_url }
     }
 }
+
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit(LiveKitRtcFocusInfo),
+
+    /// A custom RTC focus.
+    #[doc(hidden)]
+    #[serde(untagged)]
+    _Custom(CustomRtcFocusInfo),
+}
+
+#[cfg(feature = "unstable-msc4143")]
+impl RtcFocusInfo {
+    /// A constructor to create a LiveKit RTC focus.
+    pub fn live_kit(service_url: String) -> Self {
+        Self::LiveKit(LiveKitRtcFocusInfo { service_url })
+    }
+
+    /// A constructor to create a custom RTC focus.
+    pub fn custom(focus_type: String, data: JsonObject) -> Self {
+        Self::_Custom(CustomRtcFocusInfo { focus_type, data })
+    }
+
+    /// Returns a reference to the focus type of this RTC focus.
+    pub fn focus_type(&self) -> &str {
+        match self {
+            Self::LiveKit(_) => "livekit",
+            Self::_Custom(custom) => &custom.focus_type,
+        }
+    }
+
+    /// Returns the associated data.
+    ///
+    /// The returned JSON object won't contain the `focus_type` field, please use
+    /// [`.focus_type()`][Self::focus_type] to access that.
+    ///
+    /// Prefer to use the public variants of `RtcFocusInfo` where possible; this method is meant to
+    /// be used for custom focus types only.
+    pub fn data(&self) -> Cow<'_, JsonObject> {
+        fn serialize<T: Serialize>(object: &T) -> JsonObject {
+            match serde_json::to_value(object).expect("rtc focus type serialization to succeed") {
+                JsonValue::Object(object) => object,
+                _ => panic!("all rtc focus types must serialize to objects"),
+            }
+        }
+
+        match self {
+            Self::LiveKit(info) => Cow::Owned(serialize(info)),
+            Self::_Custom(info) => Cow::Borrowed(&info.data),
+        }
+    }
+}
+
+/// Information about a LiveKit RTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub struct LiveKitRtcFocusInfo {
+    /// The service URL for the LiveKit service.
+    #[serde(rename = "livekit_service_url")]
+    pub service_url: String,
+}
+
+/// Information about a custom RTC focus type.
+#[doc(hidden)]
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: pixlwave
body: I had to remove `Hash` from this derive because of the `JsonObject` type, I hope that is ok?
created_at: 2025-05-29T10:50:06Z
author_association: CONTRIBUTOR
path: crates/ruma-client-api/CHANGELOG.md
user: login: pixlwave
body: Oh god, what has format on save done here 😭
created_at: 2025-05-29T10:50:35Z
author_association: CONTRIBUTOR
path: crates/ruma-client-api/CHANGELOG.md
user: login: pixlwave
body: Fixed. (Sorry, that was another force push).
created_at: 2025-05-29T10:52:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,3 +131,163 @@ impl TileServerInfo {
         Self { map_style_url }
     }
 }
+
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit(LiveKitRtcFocusInfo),
+
+    /// A custom RTC focus.
+    #[doc(hidden)]
+    #[serde(untagged)]
+    _Custom(CustomRtcFocusInfo),
+}
+
+#[cfg(feature = "unstable-msc4143")]
+impl RtcFocusInfo {
+    /// A constructor to create a LiveKit RTC focus.
+    pub fn live_kit(service_url: String) -> Self {
+        Self::LiveKit(LiveKitRtcFocusInfo { service_url })
+    }
+
+    /// A constructor to create a custom RTC focus.
+    pub fn custom(focus_type: String, data: JsonObject) -> Self {
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: zecakeh
body: This should actually be named `new`, and construct the proper variant in case `focus_type` is `livekit`. See also the implementation of `MessageType::new()`.
created_at: 2025-05-29T13:05:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,3 +131,163 @@ impl TileServerInfo {
         Self { map_style_url }
     }
 }
+
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit(LiveKitRtcFocusInfo),
+
+    /// A custom RTC focus.
+    #[doc(hidden)]
+    #[serde(untagged)]
+    _Custom(CustomRtcFocusInfo),
+}
+
+#[cfg(feature = "unstable-msc4143")]
+impl RtcFocusInfo {
+    /// A constructor to create a LiveKit RTC focus.
+    pub fn live_kit(service_url: String) -> Self {
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: zecakeh
body: I think that for consistency with the `type` we should just name it `livekit`.
created_at: 2025-05-29T13:06:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,3 +131,163 @@ impl TileServerInfo {
         Self { map_style_url }
     }
 }
+
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit(LiveKitRtcFocusInfo),
+
+    /// A custom RTC focus.
+    #[doc(hidden)]
+    #[serde(untagged)]
+    _Custom(CustomRtcFocusInfo),
+}
+
+#[cfg(feature = "unstable-msc4143")]
+impl RtcFocusInfo {
+    /// A constructor to create a LiveKit RTC focus.
+    pub fn live_kit(service_url: String) -> Self {
+        Self::LiveKit(LiveKitRtcFocusInfo { service_url })
+    }
+
+    /// A constructor to create a custom RTC focus.
+    pub fn custom(focus_type: String, data: JsonObject) -> Self {
+        Self::_Custom(CustomRtcFocusInfo { focus_type, data })
+    }
+
+    /// Returns a reference to the focus type of this RTC focus.
+    pub fn focus_type(&self) -> &str {
+        match self {
+            Self::LiveKit(_) => "livekit",
+            Self::_Custom(custom) => &custom.focus_type,
+        }
+    }
+
+    /// Returns the associated data.
+    ///
+    /// The returned JSON object won't contain the `focus_type` field, please use
+    /// [`.focus_type()`][Self::focus_type] to access that.
+    ///
+    /// Prefer to use the public variants of `RtcFocusInfo` where possible; this method is meant to
+    /// be used for custom focus types only.
+    pub fn data(&self) -> Cow<'_, JsonObject> {
+        fn serialize<T: Serialize>(object: &T) -> JsonObject {
+            match serde_json::to_value(object).expect("rtc focus type serialization to succeed") {
+                JsonValue::Object(object) => object,
+                _ => panic!("all rtc focus types must serialize to objects"),
+            }
+        }
+
+        match self {
+            Self::LiveKit(info) => Cow::Owned(serialize(info)),
+            Self::_Custom(info) => Cow::Borrowed(&info.data),
+        }
+    }
+}
+
+/// Information about a LiveKit RTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub struct LiveKitRtcFocusInfo {
+    /// The service URL for the LiveKit service.
+    #[serde(rename = "livekit_service_url")]
+    pub service_url: String,
+}
+
+/// Information about a custom RTC focus type.
+#[doc(hidden)]
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: zecakeh
body: It's probably better, it doesn't seem very useful to implement `Hash` for this.
created_at: 2025-05-29T13:07:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,3 +131,163 @@ impl TileServerInfo {
         Self { map_style_url }
     }
 }
+
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit(LiveKitRtcFocusInfo),
+
+    /// A custom RTC focus.
+    #[doc(hidden)]
+    #[serde(untagged)]
+    _Custom(CustomRtcFocusInfo),
+}
+
+#[cfg(feature = "unstable-msc4143")]
+impl RtcFocusInfo {
+    /// A constructor to create a LiveKit RTC focus.
+    pub fn live_kit(service_url: String) -> Self {
+        Self::LiveKit(LiveKitRtcFocusInfo { service_url })
+    }
+
+    /// A constructor to create a custom RTC focus.
+    pub fn custom(focus_type: String, data: JsonObject) -> Self {
+        Self::_Custom(CustomRtcFocusInfo { focus_type, data })
+    }
+
+    /// Returns a reference to the focus type of this RTC focus.
+    pub fn focus_type(&self) -> &str {
+        match self {
+            Self::LiveKit(_) => "livekit",
+            Self::_Custom(custom) => &custom.focus_type,
+        }
+    }
+
+    /// Returns the associated data.
+    ///
+    /// The returned JSON object won't contain the `focus_type` field, please use
+    /// [`.focus_type()`][Self::focus_type] to access that.
+    ///
+    /// Prefer to use the public variants of `RtcFocusInfo` where possible; this method is meant to
+    /// be used for custom focus types only.
+    pub fn data(&self) -> Cow<'_, JsonObject> {
+        fn serialize<T: Serialize>(object: &T) -> JsonObject {
+            match serde_json::to_value(object).expect("rtc focus type serialization to succeed") {
+                JsonValue::Object(object) => object,
+                _ => panic!("all rtc focus types must serialize to objects"),
+            }
+        }
+
+        match self {
+            Self::LiveKit(info) => Cow::Owned(serialize(info)),
+            Self::_Custom(info) => Cow::Borrowed(&info.data),
+        }
+    }
+}
+
+/// Information about a LiveKit RTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub struct LiveKitRtcFocusInfo {
+    /// The service URL for the LiveKit service.
+    #[serde(rename = "livekit_service_url")]
+    pub service_url: String,
+}
+
+/// Information about a custom RTC focus type.
+#[doc(hidden)]
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub struct CustomRtcFocusInfo {
+    /// The type of RTC focus.
+    #[serde(rename = "type")]
+    pub focus_type: String,
+
+    /// Remaining RTC focus data.
+    #[serde(flatten)]
+    pub data: JsonObject,
+}
+
+#[cfg(test)]
+mod tests {
+    #[cfg(feature = "unstable-msc4143")]
+    mod rtc_foci {
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: zecakeh
body: We don't really need this extra module, you can just put everything directly in `tests`, with `#[cfg(all(test, feature = "unstable-msc4143"))]`
created_at: 2025-05-29T13:09:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,3 +131,163 @@ impl TileServerInfo {
         Self { map_style_url }
     }
 }
+
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit(LiveKitRtcFocusInfo),
+
+    /// A custom RTC focus.
+    #[doc(hidden)]
+    #[serde(untagged)]
+    _Custom(CustomRtcFocusInfo),
+}
+
+#[cfg(feature = "unstable-msc4143")]
+impl RtcFocusInfo {
+    /// A constructor to create a LiveKit RTC focus.
+    pub fn live_kit(service_url: String) -> Self {
+        Self::LiveKit(LiveKitRtcFocusInfo { service_url })
+    }
+
+    /// A constructor to create a custom RTC focus.
+    pub fn custom(focus_type: String, data: JsonObject) -> Self {
+        Self::_Custom(CustomRtcFocusInfo { focus_type, data })
+    }
+
+    /// Returns a reference to the focus type of this RTC focus.
+    pub fn focus_type(&self) -> &str {
+        match self {
+            Self::LiveKit(_) => "livekit",
+            Self::_Custom(custom) => &custom.focus_type,
+        }
+    }
+
+    /// Returns the associated data.
+    ///
+    /// The returned JSON object won't contain the `focus_type` field, please use
+    /// [`.focus_type()`][Self::focus_type] to access that.
+    ///
+    /// Prefer to use the public variants of `RtcFocusInfo` where possible; this method is meant to
+    /// be used for custom focus types only.
+    pub fn data(&self) -> Cow<'_, JsonObject> {
+        fn serialize<T: Serialize>(object: &T) -> JsonObject {
+            match serde_json::to_value(object).expect("rtc focus type serialization to succeed") {
+                JsonValue::Object(object) => object,
+                _ => panic!("all rtc focus types must serialize to objects"),
+            }
+        }
+
+        match self {
+            Self::LiveKit(info) => Cow::Owned(serialize(info)),
+            Self::_Custom(info) => Cow::Borrowed(&info.data),
+        }
+    }
+}
+
+/// Information about a LiveKit RTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub struct LiveKitRtcFocusInfo {
+    /// The service URL for the LiveKit service.
+    #[serde(rename = "livekit_service_url")]
+    pub service_url: String,
+}
+
+/// Information about a custom RTC focus type.
+#[doc(hidden)]
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub struct CustomRtcFocusInfo {
+    /// The type of RTC focus.
+    #[serde(rename = "type")]
+    pub focus_type: String,
+
+    /// Remaining RTC focus data.
+    #[serde(flatten)]
+    pub data: JsonObject,
+}
+
+#[cfg(test)]
+mod tests {
+    #[cfg(feature = "unstable-msc4143")]
+    mod rtc_foci {
+        use serde_json::{from_value as from_json_value, json, to_value as to_json_value};
+
+        use super::super::RtcFocusInfo;
+
+        #[test]
+        fn test_live_kit_rtc_focus_deserialization() {
+            let json = json!({
+                "type": "livekit",
+                "livekit_service_url": "https://livekit.example.com"
+            });
+
+            let focus: RtcFocusInfo = from_json_value(json).unwrap();
+
+            match focus {
+                RtcFocusInfo::LiveKit(info) => {
+                    assert_eq!(info.service_url, "https://livekit.example.com");
+                }
+                RtcFocusInfo::_Custom(_) => panic!("Expected LiveKit focus"),
+            }
+        }
+
+        #[test]
+        fn test_live_kit_rtc_focus_serialization() {
+            let focus = RtcFocusInfo::live_kit("https://livekit.example.com".to_owned());
+
+            let json = to_json_value(&focus).unwrap();
+
+            assert_eq!(
+                json,
+                json!({
+                    "type": "livekit",
+                    "livekit_service_url": "https://livekit.example.com"
+                })
+            );
+        }
+
+        #[test]
+        fn test_custom_rtc_focus_serialization() {
+            let json = json!({
+                "type": "some-focus-type",
+                "additional-type-specific-field": "https://my_focus.domain",
+                "another-additional-type-specific-field": ["with", "Array", "type"]
+            });
+
+            let focus: RtcFocusInfo = from_json_value(json.clone()).unwrap();
+
+            match &focus {
+                RtcFocusInfo::LiveKit(_) => panic!("Expected Custom focus"),
+                RtcFocusInfo::_Custom(info) => {
+                    assert_eq!(info.focus_type, "some-focus-type");
+
+                    let data = &info.data;
+                    assert_eq!(data["additional-type-specific-field"], "https://my_focus.domain");
+
+                    let array_values: Vec<&str> = data["another-additional-type-specific-field"]
+                        .as_array()
+                        .unwrap()
+                        .iter()
+                        .map(|v| v.as_str().unwrap())
+                        .collect();
+                    assert_eq!(array_values, vec!["with", "Array", "type"]);
+
+                    assert!(!data.contains_key("type"));
+                }
+            }
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: zecakeh
body: Here It think that it makes more sense to test the accessors (`focus_type()`, `data()`) instead of matching on the variant, to match what a user of the library should do.
created_at: 2025-05-29T13:14:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,3 +131,163 @@ impl TileServerInfo {
         Self { map_style_url }
     }
 }
+
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit(LiveKitRtcFocusInfo),
+
+    /// A custom RTC focus.
+    #[doc(hidden)]
+    #[serde(untagged)]
+    _Custom(CustomRtcFocusInfo),
+}
+
+#[cfg(feature = "unstable-msc4143")]
+impl RtcFocusInfo {
+    /// A constructor to create a LiveKit RTC focus.
+    pub fn live_kit(service_url: String) -> Self {
+        Self::LiveKit(LiveKitRtcFocusInfo { service_url })
+    }
+
+    /// A constructor to create a custom RTC focus.
+    pub fn custom(focus_type: String, data: JsonObject) -> Self {
+        Self::_Custom(CustomRtcFocusInfo { focus_type, data })
+    }
+
+    /// Returns a reference to the focus type of this RTC focus.
+    pub fn focus_type(&self) -> &str {
+        match self {
+            Self::LiveKit(_) => "livekit",
+            Self::_Custom(custom) => &custom.focus_type,
+        }
+    }
+
+    /// Returns the associated data.
+    ///
+    /// The returned JSON object won't contain the `focus_type` field, please use
+    /// [`.focus_type()`][Self::focus_type] to access that.
+    ///
+    /// Prefer to use the public variants of `RtcFocusInfo` where possible; this method is meant to
+    /// be used for custom focus types only.
+    pub fn data(&self) -> Cow<'_, JsonObject> {
+        fn serialize<T: Serialize>(object: &T) -> JsonObject {
+            match serde_json::to_value(object).expect("rtc focus type serialization to succeed") {
+                JsonValue::Object(object) => object,
+                _ => panic!("all rtc focus types must serialize to objects"),
+            }
+        }
+
+        match self {
+            Self::LiveKit(info) => Cow::Owned(serialize(info)),
+            Self::_Custom(info) => Cow::Borrowed(&info.data),
+        }
+    }
+}
+
+/// Information about a LiveKit RTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub struct LiveKitRtcFocusInfo {
+    /// The service URL for the LiveKit service.
+    #[serde(rename = "livekit_service_url")]
+    pub service_url: String,
+}
+
+/// Information about a custom RTC focus type.
+#[doc(hidden)]
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub struct CustomRtcFocusInfo {
+    /// The type of RTC focus.
+    #[serde(rename = "type")]
+    pub focus_type: String,
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: zecakeh
body: The fields of this type should not be public, and the struct doesn't need to be non-exhaustive.
created_at: 2025-05-29T13:16:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,3 +131,163 @@ impl TileServerInfo {
         Self { map_style_url }
     }
 }
+
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit(LiveKitRtcFocusInfo),
+
+    /// A custom RTC focus.
+    #[doc(hidden)]
+    #[serde(untagged)]
+    _Custom(CustomRtcFocusInfo),
+}
+
+#[cfg(feature = "unstable-msc4143")]
+impl RtcFocusInfo {
+    /// A constructor to create a LiveKit RTC focus.
+    pub fn live_kit(service_url: String) -> Self {
+        Self::LiveKit(LiveKitRtcFocusInfo { service_url })
+    }
+
+    /// A constructor to create a custom RTC focus.
+    pub fn custom(focus_type: String, data: JsonObject) -> Self {
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: pixlwave
body: Ah I see, thanks 🙏
created_at: 2025-05-29T14:01:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,3 +131,163 @@ impl TileServerInfo {
         Self { map_style_url }
     }
 }
+
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit(LiveKitRtcFocusInfo),
+
+    /// A custom RTC focus.
+    #[doc(hidden)]
+    #[serde(untagged)]
+    _Custom(CustomRtcFocusInfo),
+}
+
+#[cfg(feature = "unstable-msc4143")]
+impl RtcFocusInfo {
+    /// A constructor to create a LiveKit RTC focus.
+    pub fn live_kit(service_url: String) -> Self {
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: pixlwave
body: Ok, updated. I only went with `live_kit` as `livekit` was highlighted as a typo 🙃
created_at: 2025-05-29T14:02:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,3 +131,163 @@ impl TileServerInfo {
         Self { map_style_url }
     }
 }
+
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit(LiveKitRtcFocusInfo),
+
+    /// A custom RTC focus.
+    #[doc(hidden)]
+    #[serde(untagged)]
+    _Custom(CustomRtcFocusInfo),
+}
+
+#[cfg(feature = "unstable-msc4143")]
+impl RtcFocusInfo {
+    /// A constructor to create a LiveKit RTC focus.
+    pub fn live_kit(service_url: String) -> Self {
+        Self::LiveKit(LiveKitRtcFocusInfo { service_url })
+    }
+
+    /// A constructor to create a custom RTC focus.
+    pub fn custom(focus_type: String, data: JsonObject) -> Self {
+        Self::_Custom(CustomRtcFocusInfo { focus_type, data })
+    }
+
+    /// Returns a reference to the focus type of this RTC focus.
+    pub fn focus_type(&self) -> &str {
+        match self {
+            Self::LiveKit(_) => "livekit",
+            Self::_Custom(custom) => &custom.focus_type,
+        }
+    }
+
+    /// Returns the associated data.
+    ///
+    /// The returned JSON object won't contain the `focus_type` field, please use
+    /// [`.focus_type()`][Self::focus_type] to access that.
+    ///
+    /// Prefer to use the public variants of `RtcFocusInfo` where possible; this method is meant to
+    /// be used for custom focus types only.
+    pub fn data(&self) -> Cow<'_, JsonObject> {
+        fn serialize<T: Serialize>(object: &T) -> JsonObject {
+            match serde_json::to_value(object).expect("rtc focus type serialization to succeed") {
+                JsonValue::Object(object) => object,
+                _ => panic!("all rtc focus types must serialize to objects"),
+            }
+        }
+
+        match self {
+            Self::LiveKit(info) => Cow::Owned(serialize(info)),
+            Self::_Custom(info) => Cow::Borrowed(&info.data),
+        }
+    }
+}
+
+/// Information about a LiveKit RTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub struct LiveKitRtcFocusInfo {
+    /// The service URL for the LiveKit service.
+    #[serde(rename = "livekit_service_url")]
+    pub service_url: String,
+}
+
+/// Information about a custom RTC focus type.
+#[doc(hidden)]
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub struct CustomRtcFocusInfo {
+    /// The type of RTC focus.
+    #[serde(rename = "type")]
+    pub focus_type: String,
+
+    /// Remaining RTC focus data.
+    #[serde(flatten)]
+    pub data: JsonObject,
+}
+
+#[cfg(test)]
+mod tests {
+    #[cfg(feature = "unstable-msc4143")]
+    mod rtc_foci {
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: pixlwave
body: Ok updated, it seemed redundant to have to enable that feature everywhere to me :)
created_at: 2025-05-29T14:03:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,3 +131,163 @@ impl TileServerInfo {
         Self { map_style_url }
     }
 }
+
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit(LiveKitRtcFocusInfo),
+
+    /// A custom RTC focus.
+    #[doc(hidden)]
+    #[serde(untagged)]
+    _Custom(CustomRtcFocusInfo),
+}
+
+#[cfg(feature = "unstable-msc4143")]
+impl RtcFocusInfo {
+    /// A constructor to create a LiveKit RTC focus.
+    pub fn live_kit(service_url: String) -> Self {
+        Self::LiveKit(LiveKitRtcFocusInfo { service_url })
+    }
+
+    /// A constructor to create a custom RTC focus.
+    pub fn custom(focus_type: String, data: JsonObject) -> Self {
+        Self::_Custom(CustomRtcFocusInfo { focus_type, data })
+    }
+
+    /// Returns a reference to the focus type of this RTC focus.
+    pub fn focus_type(&self) -> &str {
+        match self {
+            Self::LiveKit(_) => "livekit",
+            Self::_Custom(custom) => &custom.focus_type,
+        }
+    }
+
+    /// Returns the associated data.
+    ///
+    /// The returned JSON object won't contain the `focus_type` field, please use
+    /// [`.focus_type()`][Self::focus_type] to access that.
+    ///
+    /// Prefer to use the public variants of `RtcFocusInfo` where possible; this method is meant to
+    /// be used for custom focus types only.
+    pub fn data(&self) -> Cow<'_, JsonObject> {
+        fn serialize<T: Serialize>(object: &T) -> JsonObject {
+            match serde_json::to_value(object).expect("rtc focus type serialization to succeed") {
+                JsonValue::Object(object) => object,
+                _ => panic!("all rtc focus types must serialize to objects"),
+            }
+        }
+
+        match self {
+            Self::LiveKit(info) => Cow::Owned(serialize(info)),
+            Self::_Custom(info) => Cow::Borrowed(&info.data),
+        }
+    }
+}
+
+/// Information about a LiveKit RTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub struct LiveKitRtcFocusInfo {
+    /// The service URL for the LiveKit service.
+    #[serde(rename = "livekit_service_url")]
+    pub service_url: String,
+}
+
+/// Information about a custom RTC focus type.
+#[doc(hidden)]
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub struct CustomRtcFocusInfo {
+    /// The type of RTC focus.
+    #[serde(rename = "type")]
+    pub focus_type: String,
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: pixlwave
body: I think I've done this right now.
created_at: 2025-05-29T14:03:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,3 +133,187 @@ impl TileServerInfo {
         Self { map_style_url }
     }
 }
+
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit(LiveKitRtcFocusInfo),
+
+    /// A custom RTC focus.
+    #[doc(hidden)]
+    #[serde(untagged)]
+    _Custom(CustomRtcFocusInfo),
+}
+
+#[cfg(feature = "unstable-msc4143")]
+impl RtcFocusInfo {
+    /// A constructor to create a custom RTC focus.
+    ///
+    /// Prefer to use the public variants of `RtcFocusInfo` where possible; this constructor is
+    /// meant be used for unsupported focus types only and does not allow setting arbitrary data
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: zecakeh
body: ```suggestion
    /// meant to be used for unsupported focus types only and does not allow setting arbitrary data
```
created_at: 2025-05-29T14:27:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,3 +133,187 @@ impl TileServerInfo {
         Self { map_style_url }
     }
 }
+
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit(LiveKitRtcFocusInfo),
+
+    /// A custom RTC focus.
+    #[doc(hidden)]
+    #[serde(untagged)]
+    _Custom(CustomRtcFocusInfo),
+}
+
+#[cfg(feature = "unstable-msc4143")]
+impl RtcFocusInfo {
+    /// A constructor to create a custom RTC focus.
+    ///
+    /// Prefer to use the public variants of `RtcFocusInfo` where possible; this constructor is
+    /// meant be used for unsupported focus types only and does not allow setting arbitrary data
+    /// for supported ones.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the `focus_type` is known and serialization of `data` to the
+    /// corresponding `RtcFocusInfo` variant fails.
+    pub fn new(focus_type: &str, data: JsonObject) -> serde_json::Result<Self> {
+        fn deserialize_variant<T: DeserializeOwned>(obj: JsonObject) -> serde_json::Result<T> {
+            serde_json::from_value(JsonValue::Object(obj))
+        }
+
+        Ok(match focus_type {
+            "livekit" => Self::LiveKit(deserialize_variant(data)?),
+            _ => Self::_Custom(CustomRtcFocusInfo { focus_type: focus_type.to_owned(), data }),
+        })
+    }
+
+    /// Creates a new `RtcFocusInfo`.
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: zecakeh
body: ```suggestion
    /// Creates a new `RtcFocusInfo::LiveKit`.
```
created_at: 2025-05-29T14:27:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,3 +133,187 @@ impl TileServerInfo {
         Self { map_style_url }
     }
 }
+
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit(LiveKitRtcFocusInfo),
+
+    /// A custom RTC focus.
+    #[doc(hidden)]
+    #[serde(untagged)]
+    _Custom(CustomRtcFocusInfo),
+}
+
+#[cfg(feature = "unstable-msc4143")]
+impl RtcFocusInfo {
+    /// A constructor to create a custom RTC focus.
+    ///
+    /// Prefer to use the public variants of `RtcFocusInfo` where possible; this constructor is
+    /// meant be used for unsupported focus types only and does not allow setting arbitrary data
+    /// for supported ones.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the `focus_type` is known and serialization of `data` to the
+    /// corresponding `RtcFocusInfo` variant fails.
+    pub fn new(focus_type: &str, data: JsonObject) -> serde_json::Result<Self> {
+        fn deserialize_variant<T: DeserializeOwned>(obj: JsonObject) -> serde_json::Result<T> {
+            serde_json::from_value(JsonValue::Object(obj))
+        }
+
+        Ok(match focus_type {
+            "livekit" => Self::LiveKit(deserialize_variant(data)?),
+            _ => Self::_Custom(CustomRtcFocusInfo { focus_type: focus_type.to_owned(), data }),
+        })
+    }
+
+    /// Creates a new `RtcFocusInfo`.
+    pub fn livekit(service_url: String) -> Self {
+        Self::LiveKit(LiveKitRtcFocusInfo { service_url })
+    }
+
+    /// Returns a reference to the focus type of this RTC focus.
+    pub fn focus_type(&self) -> &str {
+        match self {
+            Self::LiveKit(_) => "livekit",
+            Self::_Custom(custom) => &custom.focus_type,
+        }
+    }
+
+    /// Returns the associated data.
+    ///
+    /// The returned JSON object won't contain the `focus_type` field, please use
+    /// [`.focus_type()`][Self::focus_type] to access that.
+    ///
+    /// Prefer to use the public variants of `RtcFocusInfo` where possible; this method is meant to
+    /// be used for custom focus types only.
+    pub fn data(&self) -> Cow<'_, JsonObject> {
+        fn serialize<T: Serialize>(object: &T) -> JsonObject {
+            match serde_json::to_value(object).expect("rtc focus type serialization to succeed") {
+                JsonValue::Object(object) => object,
+                _ => panic!("all rtc focus types must serialize to objects"),
+            }
+        }
+
+        match self {
+            Self::LiveKit(info) => Cow::Owned(serialize(info)),
+            Self::_Custom(info) => Cow::Borrowed(&info.data),
+        }
+    }
+}
+
+/// Information about a LiveKit RTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub struct LiveKitRtcFocusInfo {
+    /// The service URL for the LiveKit service.
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: zecakeh
body: It seems a bit redundant, maybe:

```suggestion
    /// The URL for the LiveKit service.
```
created_at: 2025-05-29T14:29:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,3 +133,187 @@ impl TileServerInfo {
         Self { map_style_url }
     }
 }
+
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit(LiveKitRtcFocusInfo),
+
+    /// A custom RTC focus.
+    #[doc(hidden)]
+    #[serde(untagged)]
+    _Custom(CustomRtcFocusInfo),
+}
+
+#[cfg(feature = "unstable-msc4143")]
+impl RtcFocusInfo {
+    /// A constructor to create a custom RTC focus.
+    ///
+    /// Prefer to use the public variants of `RtcFocusInfo` where possible; this constructor is
+    /// meant be used for unsupported focus types only and does not allow setting arbitrary data
+    /// for supported ones.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the `focus_type` is known and serialization of `data` to the
+    /// corresponding `RtcFocusInfo` variant fails.
+    pub fn new(focus_type: &str, data: JsonObject) -> serde_json::Result<Self> {
+        fn deserialize_variant<T: DeserializeOwned>(obj: JsonObject) -> serde_json::Result<T> {
+            serde_json::from_value(JsonValue::Object(obj))
+        }
+
+        Ok(match focus_type {
+            "livekit" => Self::LiveKit(deserialize_variant(data)?),
+            _ => Self::_Custom(CustomRtcFocusInfo { focus_type: focus_type.to_owned(), data }),
+        })
+    }
+
+    /// Creates a new `RtcFocusInfo`.
+    pub fn livekit(service_url: String) -> Self {
+        Self::LiveKit(LiveKitRtcFocusInfo { service_url })
+    }
+
+    /// Returns a reference to the focus type of this RTC focus.
+    pub fn focus_type(&self) -> &str {
+        match self {
+            Self::LiveKit(_) => "livekit",
+            Self::_Custom(custom) => &custom.focus_type,
+        }
+    }
+
+    /// Returns the associated data.
+    ///
+    /// The returned JSON object won't contain the `focus_type` field, please use
+    /// [`.focus_type()`][Self::focus_type] to access that.
+    ///
+    /// Prefer to use the public variants of `RtcFocusInfo` where possible; this method is meant to
+    /// be used for custom focus types only.
+    pub fn data(&self) -> Cow<'_, JsonObject> {
+        fn serialize<T: Serialize>(object: &T) -> JsonObject {
+            match serde_json::to_value(object).expect("rtc focus type serialization to succeed") {
+                JsonValue::Object(object) => object,
+                _ => panic!("all rtc focus types must serialize to objects"),
+            }
+        }
+
+        match self {
+            Self::LiveKit(info) => Cow::Owned(serialize(info)),
+            Self::_Custom(info) => Cow::Borrowed(&info.data),
+        }
+    }
+}
+
+/// Information about a LiveKit RTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub struct LiveKitRtcFocusInfo {
+    /// The service URL for the LiveKit service.
+    #[serde(rename = "livekit_service_url")]
+    pub service_url: String,
+}
+
+/// Information about a custom RTC focus type.
+#[doc(hidden)]
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct CustomRtcFocusInfo {
+    /// The type of RTC focus.
+    #[serde(rename = "type")]
+    focus_type: String,
+
+    /// Remaining RTC focus data.
+    #[serde(flatten)]
+    data: JsonObject,
+}
+
+#[cfg(test)]
+mod tests {
+    #[cfg(feature = "unstable-msc4143")]
+    use serde_json::{from_value as from_json_value, json, to_value as to_json_value};
+
+    #[cfg(feature = "unstable-msc4143")]
+    use super::RtcFocusInfo;
+
+    #[test]
+    #[cfg(feature = "unstable-msc4143")]
+    fn test_livekit_rtc_focus_deserialization() {
+        // Given the JSON for a LiveKit RTC focus.
+        let json = json!({
+            "type": "livekit",
+            "livekit_service_url": "https://livekit.example.com"
+        });
+
+        // When deserializing it into an RtcFocusInfo.
+        let focus: RtcFocusInfo = from_json_value(json).unwrap();
+
+        // Then it should be recognized as a LiveKit focus with the correct service URL.
+        match focus {
+            RtcFocusInfo::LiveKit(info) => {
+                assert_eq!(info.service_url, "https://livekit.example.com");
+            }
+            RtcFocusInfo::_Custom(_) => panic!("Expected LiveKit focus"),
+        }
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: zecakeh
body: Sorry I didn't catch that before, here we would use `assert_matches2::assert_matches`:

```suggestion
        assert_matches!(focus, RtcFocusInfo::LiveKit(info));
        assert_eq!(info.service_url, "https://livekit.example.com");
```
created_at: 2025-05-29T14:31:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,3 +131,163 @@ impl TileServerInfo {
         Self { map_style_url }
     }
 }
+
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit(LiveKitRtcFocusInfo),
+
+    /// A custom RTC focus.
+    #[doc(hidden)]
+    #[serde(untagged)]
+    _Custom(CustomRtcFocusInfo),
+}
+
+#[cfg(feature = "unstable-msc4143")]
+impl RtcFocusInfo {
+    /// A constructor to create a LiveKit RTC focus.
+    pub fn live_kit(service_url: String) -> Self {
+        Self::LiveKit(LiveKitRtcFocusInfo { service_url })
+    }
+
+    /// A constructor to create a custom RTC focus.
+    pub fn custom(focus_type: String, data: JsonObject) -> Self {
+        Self::_Custom(CustomRtcFocusInfo { focus_type, data })
+    }
+
+    /// Returns a reference to the focus type of this RTC focus.
+    pub fn focus_type(&self) -> &str {
+        match self {
+            Self::LiveKit(_) => "livekit",
+            Self::_Custom(custom) => &custom.focus_type,
+        }
+    }
+
+    /// Returns the associated data.
+    ///
+    /// The returned JSON object won't contain the `focus_type` field, please use
+    /// [`.focus_type()`][Self::focus_type] to access that.
+    ///
+    /// Prefer to use the public variants of `RtcFocusInfo` where possible; this method is meant to
+    /// be used for custom focus types only.
+    pub fn data(&self) -> Cow<'_, JsonObject> {
+        fn serialize<T: Serialize>(object: &T) -> JsonObject {
+            match serde_json::to_value(object).expect("rtc focus type serialization to succeed") {
+                JsonValue::Object(object) => object,
+                _ => panic!("all rtc focus types must serialize to objects"),
+            }
+        }
+
+        match self {
+            Self::LiveKit(info) => Cow::Owned(serialize(info)),
+            Self::_Custom(info) => Cow::Borrowed(&info.data),
+        }
+    }
+}
+
+/// Information about a LiveKit RTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub struct LiveKitRtcFocusInfo {
+    /// The service URL for the LiveKit service.
+    #[serde(rename = "livekit_service_url")]
+    pub service_url: String,
+}
+
+/// Information about a custom RTC focus type.
+#[doc(hidden)]
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub struct CustomRtcFocusInfo {
+    /// The type of RTC focus.
+    #[serde(rename = "type")]
+    pub focus_type: String,
+
+    /// Remaining RTC focus data.
+    #[serde(flatten)]
+    pub data: JsonObject,
+}
+
+#[cfg(test)]
+mod tests {
+    #[cfg(feature = "unstable-msc4143")]
+    mod rtc_foci {
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: zecakeh
body: You don't need to, you just need to set it on `mod tests`.
created_at: 2025-05-29T14:33:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,3 +131,163 @@ impl TileServerInfo {
         Self { map_style_url }
     }
 }
+
+/// Information about a specific MatrixRTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+#[serde(tag = "type")]
+pub enum RtcFocusInfo {
+    /// A LiveKit RTC focus.
+    #[serde(rename = "livekit")]
+    LiveKit(LiveKitRtcFocusInfo),
+
+    /// A custom RTC focus.
+    #[doc(hidden)]
+    #[serde(untagged)]
+    _Custom(CustomRtcFocusInfo),
+}
+
+#[cfg(feature = "unstable-msc4143")]
+impl RtcFocusInfo {
+    /// A constructor to create a LiveKit RTC focus.
+    pub fn live_kit(service_url: String) -> Self {
+        Self::LiveKit(LiveKitRtcFocusInfo { service_url })
+    }
+
+    /// A constructor to create a custom RTC focus.
+    pub fn custom(focus_type: String, data: JsonObject) -> Self {
+        Self::_Custom(CustomRtcFocusInfo { focus_type, data })
+    }
+
+    /// Returns a reference to the focus type of this RTC focus.
+    pub fn focus_type(&self) -> &str {
+        match self {
+            Self::LiveKit(_) => "livekit",
+            Self::_Custom(custom) => &custom.focus_type,
+        }
+    }
+
+    /// Returns the associated data.
+    ///
+    /// The returned JSON object won't contain the `focus_type` field, please use
+    /// [`.focus_type()`][Self::focus_type] to access that.
+    ///
+    /// Prefer to use the public variants of `RtcFocusInfo` where possible; this method is meant to
+    /// be used for custom focus types only.
+    pub fn data(&self) -> Cow<'_, JsonObject> {
+        fn serialize<T: Serialize>(object: &T) -> JsonObject {
+            match serde_json::to_value(object).expect("rtc focus type serialization to succeed") {
+                JsonValue::Object(object) => object,
+                _ => panic!("all rtc focus types must serialize to objects"),
+            }
+        }
+
+        match self {
+            Self::LiveKit(info) => Cow::Owned(serialize(info)),
+            Self::_Custom(info) => Cow::Borrowed(&info.data),
+        }
+    }
+}
+
+/// Information about a LiveKit RTC focus.
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub struct LiveKitRtcFocusInfo {
+    /// The service URL for the LiveKit service.
+    #[serde(rename = "livekit_service_url")]
+    pub service_url: String,
+}
+
+/// Information about a custom RTC focus type.
+#[doc(hidden)]
+#[cfg(feature = "unstable-msc4143")]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub struct CustomRtcFocusInfo {
+    /// The type of RTC focus.
+    #[serde(rename = "type")]
+    pub focus_type: String,
+
+    /// Remaining RTC focus data.
+    #[serde(flatten)]
+    pub data: JsonObject,
+}
+
+#[cfg(test)]
+mod tests {
+    #[cfg(feature = "unstable-msc4143")]
+    mod rtc_foci {
path: crates/ruma-client-api/src/discovery/discover_homeserver.rs
user: login: pixlwave
body: But that seemed mean for the next person who comes along and adds some tests to this file 🙂
created_at: 2025-05-29T15:10:53Z
author_association: CONTRIBUTOR

PR comments:
user: login: pixlwave
created_at: 2025-05-29T10:47:40Z
body: Finally, CI is happy, sorry for all of the push noise.
author_association: CONTRIBUTOR
user: login: pixlwave
created_at: 2025-05-29T14:04:02Z
body: I've squashed the 2 commits, keeping them separate didn't seem to provide much value.
author_association: CONTRIBUTOR