matrix_sdk/widget/settings/
element_call.rs

1// Copyright 2023 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// This module contains ALL the Element Call related code (minus the FFI
16// bindings for this file). Hence all other files in the rust sdk contain code
17// that is relevant for all widgets. This makes it simple to rip out Element
18// Call related pieces.
19// TODO: The goal is to have not any Element Call specific code
20// in the rust sdk. Find a better solution for this.
21
22use serde::Serialize;
23use url::Url;
24
25use super::{url_params, WidgetSettings};
26
27#[derive(Serialize)]
28#[serde(rename_all = "camelCase")]
29/// Parameters for the Element Call widget.
30/// These are documented at https://github.com/element-hq/element-call/blob/livekit/docs/url-params.md
31struct ElementCallParams {
32    user_id: String,
33    room_id: String,
34    widget_id: String,
35    display_name: String,
36    lang: String,
37    theme: String,
38    client_id: String,
39    device_id: String,
40    base_url: String,
41    // Non template parameters
42    parent_url: String,
43    /// Deprecated since Element Call v0.8.0. Included for backwards
44    /// compatibility. Set to `true` if intent is `Intent::StartCall`.
45    skip_lobby: Option<bool>,
46    confine_to_room: bool,
47    app_prompt: bool,
48    hide_header: bool,
49    preload: bool,
50    /// Deprecated since Element Call v0.9.0. Included for backwards
51    /// compatibility. Set to the same as `posthog_user_id`.
52    analytics_id: Option<String>,
53    /// Supported since Element Call v0.9.0.
54    posthog_user_id: Option<String>,
55    font_scale: Option<f64>,
56    font: Option<String>,
57    #[serde(rename = "perParticipantE2EE")]
58    per_participant_e2ee: bool,
59    password: Option<String>,
60    /// Supported since Element Call v0.8.0.
61    intent: Option<Intent>,
62    /// Supported since Element Call v0.9.0. Only used by the embedded package.
63    posthog_api_host: Option<String>,
64    /// Supported since Element Call v0.9.0. Only used by the embedded package.
65    posthog_api_key: Option<String>,
66    /// Supported since Element Call v0.9.0. Only used by the embedded package.
67    rageshake_submit_url: Option<String>,
68    /// Supported since Element Call v0.9.0. Only used by the embedded package.
69    sentry_dsn: Option<String>,
70    /// Supported since Element Call v0.9.0. Only used by the embedded package.
71    sentry_environment: Option<String>,
72    hide_screensharing: bool,
73}
74
75/// Defines if a call is encrypted and which encryption system should be used.
76///
77/// This controls the url parameters: `perParticipantE2EE`, `password`.
78#[derive(Debug, PartialEq, Default)]
79pub enum EncryptionSystem {
80    /// Equivalent to the element call url parameter: `perParticipantE2EE=false`
81    /// and no password.
82    Unencrypted,
83    /// Equivalent to the element call url parameters:
84    /// `perParticipantE2EE=true`
85    #[default]
86    PerParticipantKeys,
87    /// Equivalent to the element call url parameters:
88    /// `password={secret}`
89    SharedSecret {
90        /// The secret/password which is used in the url.
91        secret: String,
92    },
93}
94
95/// Defines the intent of showing the call.
96///
97/// This controls whether to show or skip the lobby.
98#[derive(Debug, PartialEq, Serialize, Default)]
99#[serde(rename_all = "snake_case")]
100pub enum Intent {
101    #[default]
102    /// The user wants to start a call.
103    StartCall,
104    /// The user wants to join an existing call.
105    JoinExisting,
106}
107
108/// Properties to create a new virtual Element Call widget.
109#[derive(Debug, Default)]
110pub struct VirtualElementCallWidgetOptions {
111    /// The url to the app.
112    ///
113    /// E.g. <https://call.element.io>, <https://call.element.dev>, <https://call.element.dev/room>
114    pub element_call_url: String,
115
116    /// The widget id.
117    pub widget_id: String,
118
119    /// The url that is used as the target for the PostMessages sent
120    /// by the widget (to the client).
121    ///
122    /// For a web app client this is the client url. In case of using other
123    /// platforms the client most likely is setup up to listen to
124    /// postmessages in the same webview the widget is hosted. In this case
125    /// the `parent_url` is set to the url of the webview with the widget. Be
126    /// aware that this means that the widget will receive its own postmessage
127    /// messages. The `matrix-widget-api` (js) ignores those so this works but
128    /// it might break custom implementations.
129    ///
130    /// Defaults to `element_call_url` for the non-iframe (dedicated webview)
131    /// usecase.
132    pub parent_url: Option<String>,
133
134    /// Whether the branding header of Element call should be hidden.
135    ///
136    /// Default: `true`
137    pub hide_header: Option<bool>,
138
139    /// If set, the lobby will be skipped and the widget will join the
140    /// call on the `io.element.join` action.
141    ///
142    /// Default: `false`
143    pub preload: Option<bool>,
144
145    /// The font scale which will be used inside element call.
146    ///
147    /// Default: `1`
148    pub font_scale: Option<f64>,
149
150    /// Whether element call should prompt the user to open in the browser or
151    /// the app.
152    ///
153    /// Default: `false`
154    pub app_prompt: Option<bool>,
155
156    /// Make it not possible to get to the calls list in the webview.
157    ///
158    /// Default: `true`
159    pub confine_to_room: Option<bool>,
160
161    /// The font to use, to adapt to the system font.
162    pub font: Option<String>,
163
164    /// The encryption system to use.
165    ///
166    /// Use `EncryptionSystem::Unencrypted` to disable encryption.
167    pub encryption: EncryptionSystem,
168
169    /// The intent of showing the call.
170    /// If the user wants to start a call or join an existing one.
171    /// Controls if the lobby is skipped or not.
172    pub intent: Option<Intent>,
173
174    /// Do not show the screenshare button.
175    pub hide_screensharing: bool,
176
177    /// Can be used to pass a PostHog id to element call.
178    pub posthog_user_id: Option<String>,
179    /// The host of the posthog api.
180    /// This is only used by the embedded package of Element Call.
181    pub posthog_api_host: Option<String>,
182    /// The key for the posthog api.
183    /// This is only used by the embedded package of Element Call.
184    pub posthog_api_key: Option<String>,
185
186    /// The url to use for submitting rageshakes.
187    /// This is only used by the embedded package of Element Call.
188    pub rageshake_submit_url: Option<String>,
189
190    /// Sentry [DSN](https://docs.sentry.io/concepts/key-terms/dsn-explainer/)
191    /// This is only used by the embedded package of Element Call.
192    pub sentry_dsn: Option<String>,
193    /// Sentry [environment](https://docs.sentry.io/concepts/key-terms/key-terms/)
194    /// This is only used by the embedded package of Element Call.
195    pub sentry_environment: Option<String>,
196}
197
198impl WidgetSettings {
199    /// `WidgetSettings` are usually created from a state event.
200    /// (currently unimplemented)
201    ///
202    /// In some cases the client wants to create custom `WidgetSettings`
203    /// for specific rooms based on other conditions.
204    /// This function returns a `WidgetSettings` object which can be used
205    /// to setup a widget using `run_client_widget_api`
206    /// and to generate the correct url for the widget.
207    ///
208    /// # Arguments
209    ///
210    /// * `props` - A struct containing the configuration parameters for a
211    ///   element call widget.
212    pub fn new_virtual_element_call_widget(
213        props: VirtualElementCallWidgetOptions,
214    ) -> Result<Self, url::ParseError> {
215        let mut raw_url: Url = Url::parse(&props.element_call_url)?;
216
217        let skip_lobby = if props.intent.as_ref().is_some_and(|x| x == &Intent::StartCall) {
218            Some(true)
219        } else {
220            None
221        };
222
223        let query_params = ElementCallParams {
224            user_id: url_params::USER_ID.to_owned(),
225            room_id: url_params::ROOM_ID.to_owned(),
226            widget_id: url_params::WIDGET_ID.to_owned(),
227            display_name: url_params::DISPLAY_NAME.to_owned(),
228            lang: url_params::LANGUAGE.to_owned(),
229            theme: url_params::CLIENT_THEME.to_owned(),
230            client_id: url_params::CLIENT_ID.to_owned(),
231            device_id: url_params::DEVICE_ID.to_owned(),
232            base_url: url_params::HOMESERVER_URL.to_owned(),
233
234            parent_url: props.parent_url.unwrap_or(props.element_call_url.clone()),
235            confine_to_room: props.confine_to_room.unwrap_or(true),
236            app_prompt: props.app_prompt.unwrap_or(false),
237            hide_header: props.hide_header.unwrap_or(true),
238            preload: props.preload.unwrap_or(false),
239            font_scale: props.font_scale,
240            font: props.font,
241            per_participant_e2ee: props.encryption == EncryptionSystem::PerParticipantKeys,
242            password: match props.encryption {
243                EncryptionSystem::SharedSecret { secret } => Some(secret),
244                _ => None,
245            },
246            intent: props.intent,
247            skip_lobby,
248            analytics_id: props.posthog_user_id.clone(),
249            posthog_user_id: props.posthog_user_id,
250            posthog_api_host: props.posthog_api_host,
251            posthog_api_key: props.posthog_api_key,
252            sentry_dsn: props.sentry_dsn,
253            sentry_environment: props.sentry_environment,
254            rageshake_submit_url: props.rageshake_submit_url,
255            hide_screensharing: props.hide_screensharing,
256        };
257
258        let query =
259            serde_html_form::to_string(query_params).map_err(|_| url::ParseError::Overflow)?;
260
261        // Revert the encoding for the template parameters. So we can have a unified
262        // replace logic.
263        let query = query.replace("%24", "$");
264
265        // All the params will be set inside the fragment (to keep the traffic to the
266        // server minimal and most importantly don't send the passwords).
267        raw_url.set_fragment(Some(&format!("?{}", query)));
268
269        // for EC we always want init on content load to be true.
270        Ok(Self { widget_id: props.widget_id, init_on_content_load: true, raw_url })
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use std::collections::BTreeSet;
277
278    use ruma::api::client::profile::get_profile;
279    use url::Url;
280
281    use crate::widget::{ClientProperties, Intent, WidgetSettings};
282
283    const WIDGET_ID: &str = "1/@#w23";
284
285    fn get_widget_settings(
286        encryption: Option<EncryptionSystem>,
287        posthog: bool,
288        rageshake: bool,
289        sentry: bool,
290        intent: Option<Intent>,
291    ) -> WidgetSettings {
292        let mut props = VirtualElementCallWidgetOptions {
293            element_call_url: "https://call.element.io".to_owned(),
294            widget_id: WIDGET_ID.to_owned(),
295            hide_header: Some(true),
296            preload: Some(true),
297            app_prompt: Some(true),
298            confine_to_room: Some(true),
299            encryption: encryption.unwrap_or(EncryptionSystem::PerParticipantKeys),
300            intent,
301            ..VirtualElementCallWidgetOptions::default()
302        };
303
304        if posthog {
305            props.posthog_user_id = Some("POSTHOG_USER_ID".to_owned());
306            props.posthog_api_host = Some("posthog.element.io".to_owned());
307            props.posthog_api_key = Some("POSTHOG_KEY".to_owned());
308        }
309
310        if rageshake {
311            props.rageshake_submit_url = Some("https://rageshake.element.io".to_owned());
312        }
313
314        if sentry {
315            props.sentry_dsn = Some("SENTRY_DSN".to_owned());
316            props.sentry_environment = Some("SENTRY_ENV".to_owned());
317        }
318
319        WidgetSettings::new_virtual_element_call_widget(props)
320            .expect("could not parse virtual element call widget")
321    }
322
323    trait FragmentQuery {
324        fn fragment_query(&self) -> Option<&str>;
325    }
326
327    impl FragmentQuery for Url {
328        fn fragment_query(&self) -> Option<&str> {
329            Some(self.fragment()?.split_once('?')?.1)
330        }
331    }
332
333    // Convert query strings to BTreeSet so that we can compare the urls independent
334    // of the order of the params.
335    type QuerySet = BTreeSet<(String, String)>;
336
337    use serde_html_form::from_str;
338
339    use super::{EncryptionSystem, VirtualElementCallWidgetOptions};
340
341    fn get_query_sets(url: &Url) -> Option<(QuerySet, QuerySet)> {
342        let fq = from_str::<QuerySet>(url.fragment_query().unwrap_or_default()).ok()?;
343        let q = from_str::<QuerySet>(url.query().unwrap_or_default()).ok()?;
344        Some((q, fq))
345    }
346
347    #[test]
348    fn new_virtual_element_call_widget_base_url() {
349        let widget_settings = get_widget_settings(None, false, false, false, None);
350        assert_eq!(widget_settings.base_url().unwrap().as_str(), "https://call.element.io/");
351    }
352
353    #[test]
354    fn new_virtual_element_call_widget_raw_url() {
355        const CONVERTED_URL: &str = "
356            https://call.element.io#\
357                ?userId=$matrix_user_id\
358                &roomId=$matrix_room_id\
359                &widgetId=$matrix_widget_id\
360                &displayName=$matrix_display_name\
361                &lang=$org.matrix.msc2873.client_language\
362                &theme=$org.matrix.msc2873.client_theme\
363                &clientId=$org.matrix.msc2873.client_id\
364                &deviceId=$org.matrix.msc2873.matrix_device_id\
365                &baseUrl=$org.matrix.msc4039.matrix_base_url\
366                &parentUrl=https%3A%2F%2Fcall.element.io\
367                &confineToRoom=true\
368                &appPrompt=true\
369                &hideHeader=true\
370                &preload=true\
371                &perParticipantE2EE=true\
372                &hideScreensharing=false\
373        ";
374
375        let mut url = get_widget_settings(None, false, false, false, None).raw_url().clone();
376        let mut gen = Url::parse(CONVERTED_URL).unwrap();
377        assert_eq!(get_query_sets(&url).unwrap(), get_query_sets(&gen).unwrap());
378        url.set_fragment(None);
379        url.set_query(None);
380        gen.set_fragment(None);
381        gen.set_query(None);
382        assert_eq!(url, gen);
383    }
384
385    #[test]
386    fn new_virtual_element_call_widget_id() {
387        assert_eq!(get_widget_settings(None, false, false, false, None).widget_id(), WIDGET_ID);
388    }
389
390    fn build_url_from_widget_settings(settings: WidgetSettings) -> String {
391        settings
392            ._generate_webview_url(
393                get_profile::v3::Response::new(Some("some-url".into()), Some("hello".into())),
394                "@test:user.org".try_into().unwrap(),
395                "!room_id:room.org".try_into().unwrap(),
396                "ABCDEFG".into(),
397                "https://client-matrix.server.org".try_into().unwrap(),
398                ClientProperties::new(
399                    "io.my_matrix.client",
400                    Some(language_tags::LanguageTag::parse("en-us").unwrap()),
401                    Some("light".into()),
402                ),
403            )
404            .unwrap()
405            .to_string()
406    }
407
408    #[test]
409    fn new_virtual_element_call_widget_webview_url() {
410        const CONVERTED_URL: &str = "
411            https://call.element.io#\
412                ?parentUrl=https%3A%2F%2Fcall.element.io\
413                &widgetId=1/@#w23\
414                &userId=%40test%3Auser.org&deviceId=ABCDEFG\
415                &roomId=%21room_id%3Aroom.org\
416                &lang=en-US&theme=light\
417                &baseUrl=https%3A%2F%2Fclient-matrix.server.org%2F\
418                &hideHeader=true\
419                &preload=true\
420                &confineToRoom=true\
421                &displayName=hello\
422                &appPrompt=true\
423                &clientId=io.my_matrix.client\
424                &perParticipantE2EE=true\
425                &hideScreensharing=false\
426        ";
427        let gen =
428            build_url_from_widget_settings(get_widget_settings(None, false, false, false, None));
429
430        let mut url = Url::parse(&gen).unwrap();
431        let mut gen = Url::parse(CONVERTED_URL).unwrap();
432        assert_eq!(get_query_sets(&url).unwrap(), get_query_sets(&gen).unwrap());
433        url.set_fragment(None);
434        url.set_query(None);
435        gen.set_fragment(None);
436        gen.set_query(None);
437        assert_eq!(url, gen);
438    }
439
440    #[test]
441    fn new_virtual_element_call_widget_webview_url_with_posthog_rageshake_sentry() {
442        const CONVERTED_URL: &str = "
443            https://call.element.io#\
444                ?parentUrl=https%3A%2F%2Fcall.element.io\
445                &widgetId=1/@#w23\
446                &userId=%40test%3Auser.org&deviceId=ABCDEFG\
447                &roomId=%21room_id%3Aroom.org\
448                &lang=en-US&theme=light\
449                &baseUrl=https%3A%2F%2Fclient-matrix.server.org%2F\
450                &hideHeader=true\
451                &preload=true\
452                &confineToRoom=true\
453                &displayName=hello\
454                &appPrompt=true\
455                &clientId=io.my_matrix.client\
456                &perParticipantE2EE=true\
457                &hideScreensharing=false\
458                &posthogApiHost=posthog.element.io\
459                &posthogApiKey=POSTHOG_KEY\
460                &analyticsId=POSTHOG_USER_ID\
461                &posthogUserId=POSTHOG_USER_ID\
462                &rageshakeSubmitUrl=https%3A%2F%2Frageshake.element.io\
463                &sentryDsn=SENTRY_DSN\
464                &sentryEnvironment=SENTRY_ENV\
465        ";
466        let gen = build_url_from_widget_settings(get_widget_settings(None, true, true, true, None));
467
468        let mut url = Url::parse(&gen).unwrap();
469        let mut gen = Url::parse(CONVERTED_URL).unwrap();
470        assert_eq!(get_query_sets(&url).unwrap(), get_query_sets(&gen).unwrap());
471        url.set_fragment(None);
472        url.set_query(None);
473        gen.set_fragment(None);
474        gen.set_query(None);
475        assert_eq!(url, gen);
476    }
477
478    #[test]
479    fn password_url_props_from_widget_settings() {
480        {
481            // PerParticipantKeys
482            let url = build_url_from_widget_settings(get_widget_settings(
483                Some(EncryptionSystem::PerParticipantKeys),
484                false,
485                false,
486                false,
487                None,
488            ));
489            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
490            let expected_elements = [("perParticipantE2EE".to_owned(), "true".to_owned())];
491            for e in expected_elements {
492                assert!(
493                    query_set.contains(&e),
494                    "The query elements: \n{:?}\nDid not contain: \n{:?}",
495                    query_set,
496                    e
497                );
498            }
499        }
500        {
501            // Unencrypted
502            let url = build_url_from_widget_settings(get_widget_settings(
503                Some(EncryptionSystem::Unencrypted),
504                false,
505                false,
506                false,
507                None,
508            ));
509            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
510            let expected_elements = ("perParticipantE2EE".to_owned(), "false".to_owned());
511            assert!(
512                query_set.contains(&expected_elements),
513                "The url query elements for an unencrypted call: \n{:?}\nDid not contain: \n{:?}",
514                query_set,
515                expected_elements
516            );
517        }
518        {
519            // SharedSecret
520            let url = build_url_from_widget_settings(get_widget_settings(
521                Some(EncryptionSystem::SharedSecret { secret: "this_surely_is_save".to_owned() }),
522                false,
523                false,
524                false,
525                None,
526            ));
527            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
528            let expected_elements = [("password".to_owned(), "this_surely_is_save".to_owned())];
529            for e in expected_elements {
530                assert!(
531                    query_set.contains(&e),
532                    "The query elements: \n{:?}\nDid not contain: \n{:?}",
533                    query_set,
534                    e
535                );
536            }
537        }
538    }
539
540    #[test]
541    fn intent_url_props_from_widget_settings() {
542        {
543            // no intent
544            let url = build_url_from_widget_settings(get_widget_settings(
545                None, false, false, false, None,
546            ));
547            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
548
549            let expected_unset_elements = ["intent".to_owned(), "skipLobby".to_owned()];
550
551            for e in expected_unset_elements {
552                assert!(
553                    !query_set.iter().any(|x| x.0 == e),
554                    "The query elements: \n{:?}\nShould not have contained: \n{:?}",
555                    query_set,
556                    e
557                );
558            }
559        }
560        {
561            // Intent::JoinExisting
562            let url = build_url_from_widget_settings(get_widget_settings(
563                None,
564                false,
565                false,
566                false,
567                Some(Intent::JoinExisting),
568            ));
569            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
570            let expected_elements = ("intent".to_owned(), "join_existing".to_owned());
571            assert!(
572                query_set.contains(&expected_elements),
573                "The url query elements for an unencrypted call: \n{:?}\nDid not contain: \n{:?}",
574                query_set,
575                expected_elements
576            );
577
578            let expected_unset_elements = ["skipLobby".to_owned()];
579
580            for e in expected_unset_elements {
581                assert!(
582                    !query_set.iter().any(|x| x.0 == e),
583                    "The query elements: \n{:?}\nShould not have contained: \n{:?}",
584                    query_set,
585                    e
586                );
587            }
588        }
589        {
590            // Intent::StartCall
591            let url = build_url_from_widget_settings(get_widget_settings(
592                None,
593                false,
594                false,
595                false,
596                Some(Intent::StartCall),
597            ));
598            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
599
600            // skipLobby should be set for compatibility with versions < 0.8.0
601            let expected_elements = [
602                ("intent".to_owned(), "start_call".to_owned()),
603                ("skipLobby".to_owned(), "true".to_owned()),
604            ];
605            for e in expected_elements {
606                assert!(
607                    query_set.contains(&e),
608                    "The query elements: \n{:?}\nDid not contain: \n{:?}",
609                    query_set,
610                    e
611                );
612            }
613        }
614    }
615}