Skip to main content

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. TODO: The goal is to have not any Element Call specific
19// code in the rust sdk. Find a better solution for this.
20
21use serde::Serialize;
22use url::Url;
23
24use super::{WidgetSettings, url_params};
25
26/// Serialization struct for URL parameters for the Element Call widget. These
27/// are documented at
28/// https://github.com/element-hq/element-call/blob/livekit/docs/url-params.md
29///
30/// The ElementCallParams are used to be translated into url query parameters.
31/// For all optional fields, the None case implies, that it will not be part of
32/// the url parameters.
33///
34/// # Example
35///
36/// ```no_compile
37/// # use matrix_sdk::widget::settings::element_call::ElementCallUrlParams;
38/// ElementCallUrlParams {
39///     // Required parameters:
40///     user_id: "@1234",
41///     room_id: "$1234",
42///     // Optional configuration:
43///     hide_screensharing: Some(true),
44///     ..Default::default()
45/// }
46/// ```
47///
48/// will become: `my.url? ...requires_parameters... &hide_screensharing=true`
49/// The reason it might be desirable to not list those configurations in the
50/// URLs parameters is that the `intent` implies defaults for all configuration
51/// values in the widget itself. Setting the URL parameter specifically will
52/// overwrite those defaults.
53#[derive(Serialize)]
54#[serde(rename_all = "camelCase")]
55struct ElementCallUrlParams {
56    user_id: String,
57    room_id: String,
58    widget_id: String,
59    display_name: String,
60    lang: String,
61    theme: String,
62    client_id: String,
63    device_id: String,
64    base_url: String,
65    // Non template parameters
66    parent_url: String,
67    /// Deprecated since Element Call v0.8.0. Included for backwards
68    /// compatibility. Set to `true` if intent is `Intent::StartCall`.
69    skip_lobby: Option<bool>,
70    confine_to_room: Option<bool>,
71    app_prompt: Option<bool>,
72    /// Supported since Element Call v0.13.0.
73    header: Option<HeaderStyle>,
74    /// Deprecated since Element Call v0.13.0. Included for backwards
75    /// compatibility. Use header: "standard"|"none" instead.
76    hide_header: Option<bool>,
77    preload: Option<bool>,
78    /// Deprecated since Element Call v0.9.0. Included for backwards
79    /// compatibility. Set to the same as `posthog_user_id`.
80    analytics_id: Option<String>,
81    /// Supported since Element Call v0.9.0.
82    posthog_user_id: Option<String>,
83    font_scale: Option<f64>,
84    font: Option<String>,
85    #[serde(rename = "perParticipantE2EE")]
86    per_participant_e2ee: Option<bool>,
87    password: Option<String>,
88    /// Supported since Element Call v0.8.0.
89    intent: Option<Intent>,
90    /// Supported since Element Call v0.9.0. Only used by the embedded package.
91    posthog_api_host: Option<String>,
92    /// Supported since Element Call v0.9.0. Only used by the embedded package.
93    posthog_api_key: Option<String>,
94    /// Supported since Element Call v0.9.0. Only used by the embedded package.
95    rageshake_submit_url: Option<String>,
96    /// Supported since Element Call v0.9.0. Only used by the embedded package.
97    sentry_dsn: Option<String>,
98    /// Supported since Element Call v0.9.0. Only used by the embedded package.
99    sentry_environment: Option<String>,
100    /// Supported since Element Call v0.9.0.
101    hide_screensharing: Option<bool>,
102    /// Supported since Element Call v0.13.0.
103    controlled_audio_devices: Option<bool>,
104    /// Supported since Element Call v0.14.0.
105    send_notification_type: Option<NotificationType>,
106}
107
108/// Defines if a call is encrypted and which encryption system should be used.
109///
110/// This controls the url parameters: `perParticipantE2EE`, `password`.
111#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
112#[derive(Debug, PartialEq, Default, Clone)]
113pub enum EncryptionSystem {
114    /// Equivalent to the element call url parameter: `perParticipantE2EE=false`
115    /// and no password.
116    Unencrypted,
117    /// Equivalent to the element call url parameters: `perParticipantE2EE=true`
118    #[default]
119    PerParticipantKeys,
120    /// Equivalent to the element call url parameters: `password={secret}`
121    SharedSecret {
122        /// The secret/password which is used in the url.
123        secret: String,
124    },
125}
126
127/// Defines the intent of showing the call.
128///
129/// This controls whether to show or skip the lobby.
130#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
131#[derive(Debug, PartialEq, Serialize, Default, Clone)]
132#[serde(rename_all = "snake_case")]
133pub enum Intent {
134    #[default]
135    /// The user wants to start a call.
136    StartCall,
137    /// The user wants to join an existing call.
138    JoinExisting,
139    /// The user wants to join an existing call that is a "Direct Message" (DM)
140    /// room.
141    JoinExistingDm,
142    /// The user wants to start a call in a "Direct Message" (DM) room.
143    StartCallDm,
144    /// The user wants to start a voice call in a "Direct Message" (DM) room.
145    StartCallDmVoice,
146    /// The user wants to join an existing voice call that is a "Direct Message"
147    /// (DM) room.
148    JoinExistingDmVoice,
149}
150
151/// Defines how (if) element-call renders a header.
152#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
153#[derive(Debug, PartialEq, Serialize, Default, Clone)]
154#[serde(rename_all = "snake_case")]
155pub enum HeaderStyle {
156    /// The normal header with branding.
157    #[default]
158    Standard,
159    /// Render a header with a back button (useful on mobile platforms).
160    AppBar,
161    /// No Header (useful for webapps).
162    None,
163}
164
165/// Types of call notifications.
166#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
167#[derive(Debug, PartialEq, Serialize, Clone, Default)]
168#[serde(rename_all = "snake_case")]
169pub enum NotificationType {
170    /// The receiving client should display a visual notification.
171    #[default]
172    Notification,
173    /// The receiving client should ring with an audible sound.
174    Ring,
175}
176
177/// Configuration parameters, to create a new virtual Element Call widget.
178///
179/// If `intent` is provided the appropriate default values for all other
180/// parameters will be used by element call. In most cases its enough to only
181/// set the intent. Use the other properties only if you want to deviate from
182/// the `intent` defaults.
183///
184/// Set
185/// [`docs/url-params.md`](https://github.com/element-hq/element-call/blob/livekit/docs/url-params.md)
186/// to find out more about the parameters and their defaults.
187#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
188#[derive(Debug, Default, Clone)]
189pub struct VirtualElementCallWidgetConfig {
190    /// The intent of showing the call. If the user wants to start a call or
191    /// join an existing one. Controls if the lobby is skipped or not.
192    pub intent: Option<Intent>,
193
194    /// Skip the lobby when joining a call.
195    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
196    pub skip_lobby: Option<bool>,
197
198    /// Whether the branding header of Element call should be shown or if a
199    /// mobile header navbar should be render.
200    ///
201    /// Default: [`HeaderStyle::Standard`]
202    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
203    pub header: Option<HeaderStyle>,
204
205    /// Whether the branding header of Element call should be hidden.
206    ///
207    /// Default: `true`
208    #[deprecated(note = "Use `header` instead", since = "0.12.1")]
209    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
210    pub hide_header: Option<bool>,
211
212    /// If set, the lobby will be skipped and the widget will join the call on
213    /// the `io.element.join` action.
214    ///
215    /// Default: `false`
216    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
217    pub preload: Option<bool>,
218
219    /// Whether element call should prompt the user to open in the browser or
220    /// the app.
221    ///
222    /// Default: `false`
223    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
224    pub app_prompt: Option<bool>,
225
226    /// Make it not possible to get to the calls list in the webview.
227    ///
228    /// Default: `true`
229    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
230    pub confine_to_room: Option<bool>,
231
232    /// Do not show the screenshare button.
233    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
234    pub hide_screensharing: Option<bool>,
235
236    /// Make the audio devices be controlled by the os instead of the
237    /// element-call webview.
238    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
239    pub controlled_audio_devices: Option<bool>,
240
241    /// Whether and what type of notification Element Call should send, when
242    /// starting a call.
243    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
244    pub send_notification_type: Option<NotificationType>,
245}
246
247/// Properties to create a new virtual Element Call widget.
248///
249/// All these are required to start the widget in the first place. This is
250/// different from the `VirtualElementCallWidgetConfiguration` which configures
251/// the widgets behavior.
252#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
253#[derive(Debug, Default, Clone)]
254pub struct VirtualElementCallWidgetProperties {
255    /// The url to the app.
256    ///
257    /// E.g. <https://call.element.io>, <https://call.element.dev>, <https://call.element.dev/room>
258    pub element_call_url: String,
259
260    /// The widget id.
261    pub widget_id: String,
262
263    /// The url that is used as the target for the PostMessages sent by the
264    /// widget (to the client).
265    ///
266    /// For a web app client this is the client url. In case of using other
267    /// platforms the client most likely is setup up to listen to postmessages
268    /// in the same webview the widget is hosted. In this case the `parent_url`
269    /// is set to the url of the webview with the widget. Be aware that this
270    /// means that the widget will receive its own postmessage messages. The
271    /// `matrix-widget-api` (js) ignores those so this works but it might break
272    /// custom implementations.
273    ///
274    /// Defaults to `element_call_url` for the non-iframe (dedicated webview)
275    /// usecase.
276    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
277    pub parent_url: Option<String>,
278
279    /// The font scale which will be used inside element call.
280    ///
281    /// Default: `1`
282    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
283    pub font_scale: Option<f64>,
284
285    /// The font to use, to adapt to the system font.
286    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
287    pub font: Option<String>,
288
289    /// The encryption system to use.
290    ///
291    /// Use `EncryptionSystem::Unencrypted` to disable encryption.
292    pub encryption: EncryptionSystem,
293
294    /// Can be used to pass a PostHog id to element call.
295    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
296    pub posthog_user_id: Option<String>,
297    /// The host of the posthog api. This is only used by the embedded package
298    /// of Element Call.
299    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
300    pub posthog_api_host: Option<String>,
301    /// The key for the posthog api. This is only used by the embedded package
302    /// of Element Call.
303    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
304    pub posthog_api_key: Option<String>,
305
306    /// The url to use for submitting rageshakes. This is only used by the
307    /// embedded package of Element Call.
308    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
309    pub rageshake_submit_url: Option<String>,
310
311    /// Sentry [DSN](https://docs.sentry.io/concepts/key-terms/dsn-explainer/)
312    /// This is only used by the embedded package of Element Call.
313    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
314    pub sentry_dsn: Option<String>,
315
316    /// Sentry
317    /// [environment](https://docs.sentry.io/concepts/key-terms/key-terms/) This
318    /// is only used by the embedded package of Element Call.
319    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
320    pub sentry_environment: Option<String>,
321}
322
323impl WidgetSettings {
324    /// `WidgetSettings` are usually created from a state event. (currently
325    /// unimplemented)
326    ///
327    /// In some cases the client wants to create custom `WidgetSettings` for
328    /// specific rooms based on other conditions. This function returns a
329    /// `WidgetSettings` object which can be used to setup a widget using
330    /// `run_client_widget_api` and to generate the correct url for the widget.
331    ///
332    /// # Arguments
333    ///
334    /// - `props` - A struct containing the configuration parameters for a
335    ///   element call widget.
336    pub fn new_virtual_element_call_widget(
337        props: VirtualElementCallWidgetProperties,
338        config: VirtualElementCallWidgetConfig,
339    ) -> Result<Self, url::ParseError> {
340        let mut raw_url: Url = Url::parse(&props.element_call_url)?;
341
342        #[allow(deprecated)]
343        let query_params = ElementCallUrlParams {
344            user_id: url_params::USER_ID.to_owned(),
345            room_id: url_params::ROOM_ID.to_owned(),
346            widget_id: url_params::WIDGET_ID.to_owned(),
347            display_name: url_params::DISPLAY_NAME.to_owned(),
348            lang: url_params::LANGUAGE.to_owned(),
349            theme: url_params::CLIENT_THEME.to_owned(),
350            client_id: url_params::CLIENT_ID.to_owned(),
351            device_id: url_params::DEVICE_ID.to_owned(),
352            base_url: url_params::HOMESERVER_URL.to_owned(),
353
354            parent_url: props.parent_url.unwrap_or(props.element_call_url.clone()),
355            confine_to_room: config.confine_to_room,
356            app_prompt: config.app_prompt,
357            header: config.header,
358            hide_header: config.hide_header,
359            preload: config.preload,
360            font_scale: props.font_scale,
361            font: props.font,
362            per_participant_e2ee: Some(props.encryption == EncryptionSystem::PerParticipantKeys),
363            password: match props.encryption {
364                EncryptionSystem::SharedSecret { secret } => Some(secret),
365                _ => None,
366            },
367            intent: config.intent,
368            skip_lobby: config.skip_lobby,
369            analytics_id: props.posthog_user_id.clone(),
370            posthog_user_id: props.posthog_user_id,
371            posthog_api_host: props.posthog_api_host,
372            posthog_api_key: props.posthog_api_key,
373            sentry_dsn: props.sentry_dsn,
374            sentry_environment: props.sentry_environment,
375            rageshake_submit_url: props.rageshake_submit_url,
376            hide_screensharing: config.hide_screensharing,
377            controlled_audio_devices: config.controlled_audio_devices,
378            send_notification_type: config.send_notification_type,
379        };
380
381        let query =
382            serde_html_form::to_string(query_params).map_err(|_| url::ParseError::Overflow)?;
383
384        // Revert the encoding for the template parameters. So we can have a
385        // unified replace logic.
386        let query = query.replace("%24", "$");
387
388        // All the params will be set inside the fragment (to keep the traffic
389        // to the server minimal and most importantly don't send the passwords).
390        raw_url.set_fragment(Some(&format!("?{query}")));
391
392        // for EC we always want init on content load to be true.
393        Ok(Self { widget_id: props.widget_id, init_on_content_load: true, raw_url })
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use std::collections::BTreeSet;
400
401    use ruma::api::client::profile::get_profile;
402    use url::Url;
403
404    use crate::widget::{
405        ClientProperties, Intent, WidgetSettings,
406        settings::element_call::{HeaderStyle, VirtualElementCallWidgetConfig},
407    };
408
409    const WIDGET_ID: &str = "1/@#w23";
410
411    fn get_element_call_widget_settings(
412        encryption: Option<EncryptionSystem>,
413        posthog: bool,
414        rageshake: bool,
415        sentry: bool,
416        intent: Option<Intent>,
417        controlled_output: bool,
418    ) -> WidgetSettings {
419        let props = VirtualElementCallWidgetProperties {
420            element_call_url: "https://call.element.io".to_owned(),
421            widget_id: WIDGET_ID.to_owned(),
422            posthog_user_id: posthog.then(|| "POSTHOG_USER_ID".to_owned()),
423            posthog_api_host: posthog.then(|| "posthog.element.io".to_owned()),
424            posthog_api_key: posthog.then(|| "POSTHOG_KEY".to_owned()),
425            rageshake_submit_url: rageshake.then(|| "https://rageshake.element.io".to_owned()),
426            sentry_dsn: sentry.then(|| "SENTRY_DSN".to_owned()),
427            sentry_environment: sentry.then(|| "SENTRY_ENV".to_owned()),
428            encryption: encryption.unwrap_or(EncryptionSystem::PerParticipantKeys),
429            ..VirtualElementCallWidgetProperties::default()
430        };
431
432        let config = VirtualElementCallWidgetConfig {
433            controlled_audio_devices: Some(controlled_output),
434            preload: Some(true),
435            app_prompt: Some(true),
436            confine_to_room: Some(true),
437            hide_screensharing: Some(false),
438            header: Some(HeaderStyle::Standard),
439            intent,
440            ..VirtualElementCallWidgetConfig::default()
441        };
442
443        WidgetSettings::new_virtual_element_call_widget(props, config)
444            .expect("could not parse virtual element call widget")
445    }
446
447    trait FragmentQuery {
448        fn fragment_query(&self) -> Option<&str>;
449    }
450
451    impl FragmentQuery for Url {
452        fn fragment_query(&self) -> Option<&str> {
453            Some(self.fragment()?.split_once('?')?.1)
454        }
455    }
456
457    // Convert query strings to BTreeSet so that we can compare the urls
458    // independent of the order of the params.
459    type QuerySet = BTreeSet<(String, String)>;
460
461    use serde_html_form::from_str;
462
463    use super::{EncryptionSystem, VirtualElementCallWidgetProperties};
464
465    fn get_query_sets(url: &Url) -> Option<(QuerySet, QuerySet)> {
466        let fq = from_str::<QuerySet>(url.fragment_query().unwrap_or_default()).ok()?;
467        let q = from_str::<QuerySet>(url.query().unwrap_or_default()).ok()?;
468        Some((q, fq))
469    }
470
471    #[test]
472    fn test_new_virtual_element_call_widget_base_url() {
473        let widget_settings =
474            get_element_call_widget_settings(None, false, false, false, None, false);
475        assert_eq!(widget_settings.base_url().unwrap().as_str(), "https://call.element.io/");
476    }
477
478    #[test]
479    fn test_new_virtual_element_call_widget_raw_url() {
480        const CONVERTED_URL: &str = "
481            https://call.element.io#\
482                ?userId=$matrix_user_id\
483                &roomId=$matrix_room_id\
484                &widgetId=$matrix_widget_id\
485                &displayName=$matrix_display_name\
486                &lang=$org.matrix.msc2873.client_language\
487                &theme=$org.matrix.msc2873.client_theme\
488                &clientId=$org.matrix.msc2873.client_id\
489                &deviceId=$org.matrix.msc2873.matrix_device_id\
490                &baseUrl=$org.matrix.msc4039.matrix_base_url\
491                &parentUrl=https%3A%2F%2Fcall.element.io\
492                &confineToRoom=true\
493                &appPrompt=true\
494                &header=standard\
495                &preload=true\
496                &perParticipantE2EE=true\
497                &hideScreensharing=false\
498                &controlledAudioDevices=false\
499        ";
500
501        let mut generated_url =
502            get_element_call_widget_settings(None, false, false, false, None, false)
503                .raw_url()
504                .clone();
505        let mut expected_url = Url::parse(CONVERTED_URL).unwrap();
506        assert_eq!(get_query_sets(&generated_url).unwrap(), get_query_sets(&expected_url).unwrap());
507        generated_url.set_fragment(None);
508        generated_url.set_query(None);
509        expected_url.set_fragment(None);
510        expected_url.set_query(None);
511        assert_eq!(generated_url, expected_url);
512    }
513
514    #[test]
515    fn test_new_virtual_element_call_widget_id() {
516        assert_eq!(
517            get_element_call_widget_settings(None, false, false, false, None, false).widget_id(),
518            WIDGET_ID
519        );
520    }
521
522    fn build_url_from_widget_settings(settings: WidgetSettings) -> String {
523        let mut profile = get_profile::v3::Response::new();
524        profile.set("avatar_url".to_owned(), "some-url".into());
525        profile.set("displayname".to_owned(), "hello".into());
526
527        settings
528            ._generate_webview_url(
529                profile,
530                "@test:user.org".try_into().unwrap(),
531                "!room_id:room.org".try_into().unwrap(),
532                "ABCDEFG".into(),
533                "https://client-matrix.server.org".try_into().unwrap(),
534                ClientProperties::new(
535                    "io.my_matrix.client",
536                    Some(language_tags::LanguageTag::parse("en-us").unwrap()),
537                    Some("light".into()),
538                ),
539            )
540            .unwrap()
541            .to_string()
542    }
543
544    #[test]
545    fn test_new_virtual_element_call_widget_webview_url() {
546        const CONVERTED_URL: &str = "
547            https://call.element.io#\
548                ?parentUrl=https%3A%2F%2Fcall.element.io\
549                &widgetId=1/@#w23\
550                &userId=%40test%3Auser.org&deviceId=ABCDEFG\
551                &roomId=%21room_id%3Aroom.org\
552                &lang=en-US&theme=light\
553                &baseUrl=https%3A%2F%2Fclient-matrix.server.org%2F\
554                &header=standard\
555                &preload=true\
556                &confineToRoom=true\
557                &displayName=hello\
558                &appPrompt=true\
559                &clientId=io.my_matrix.client\
560                &perParticipantE2EE=true\
561                &hideScreensharing=false\
562                &controlledAudioDevices=false\
563        ";
564        let mut generated_url = Url::parse(&build_url_from_widget_settings(
565            get_element_call_widget_settings(None, false, false, false, None, false),
566        ))
567        .unwrap();
568        let mut expected_url = Url::parse(CONVERTED_URL).unwrap();
569        assert_eq!(get_query_sets(&generated_url).unwrap(), get_query_sets(&expected_url).unwrap());
570        generated_url.set_fragment(None);
571        generated_url.set_query(None);
572        expected_url.set_fragment(None);
573        expected_url.set_query(None);
574        assert_eq!(generated_url, expected_url);
575    }
576
577    #[test]
578    fn test_new_virtual_element_call_widget_webview_url_with_posthog_rageshake_sentry() {
579        const CONVERTED_URL: &str = "
580            https://call.element.io#\
581                ?parentUrl=https%3A%2F%2Fcall.element.io\
582                &widgetId=1/@#w23\
583                &userId=%40test%3Auser.org&deviceId=ABCDEFG\
584                &roomId=%21room_id%3Aroom.org\
585                &lang=en-US&theme=light\
586                &baseUrl=https%3A%2F%2Fclient-matrix.server.org%2F\
587                &header=standard\
588                &preload=true\
589                &confineToRoom=true\
590                &displayName=hello\
591                &appPrompt=true\
592                &clientId=io.my_matrix.client\
593                &perParticipantE2EE=true\
594                &hideScreensharing=false\
595                &posthogApiHost=posthog.element.io\
596                &posthogApiKey=POSTHOG_KEY\
597                &analyticsId=POSTHOG_USER_ID\
598                &posthogUserId=POSTHOG_USER_ID\
599                &rageshakeSubmitUrl=https%3A%2F%2Frageshake.element.io\
600                &sentryDsn=SENTRY_DSN\
601                &sentryEnvironment=SENTRY_ENV\
602                &controlledAudioDevices=false\
603        ";
604        let mut generated_url = Url::parse(&build_url_from_widget_settings(
605            get_element_call_widget_settings(None, true, true, true, None, false),
606        ))
607        .unwrap();
608        let mut original_url = Url::parse(CONVERTED_URL).unwrap();
609        assert_eq!(get_query_sets(&generated_url).unwrap(), get_query_sets(&original_url).unwrap());
610        generated_url.set_fragment(None);
611        generated_url.set_query(None);
612        original_url.set_fragment(None);
613        original_url.set_query(None);
614        assert_eq!(generated_url, original_url);
615    }
616
617    #[test]
618    fn test_password_url_props_from_widget_settings() {
619        {
620            // PerParticipantKeys
621            let url = build_url_from_widget_settings(get_element_call_widget_settings(
622                Some(EncryptionSystem::PerParticipantKeys),
623                false,
624                false,
625                false,
626                None,
627                false,
628            ));
629            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
630            let expected_elements = [("perParticipantE2EE".to_owned(), "true".to_owned())];
631            for e in expected_elements {
632                assert!(
633                    query_set.contains(&e),
634                    "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
635                );
636            }
637        }
638        {
639            // Unencrypted
640            let url = build_url_from_widget_settings(get_element_call_widget_settings(
641                Some(EncryptionSystem::Unencrypted),
642                false,
643                false,
644                false,
645                None,
646                false,
647            ));
648            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
649            let expected_elements = ("perParticipantE2EE".to_owned(), "false".to_owned());
650            assert!(
651                query_set.contains(&expected_elements),
652                "The url query elements for an unencrypted call: \n{query_set:?}\nDid not contain: \n{expected_elements:?}"
653            );
654        }
655        {
656            // SharedSecret
657            let url = build_url_from_widget_settings(get_element_call_widget_settings(
658                Some(EncryptionSystem::SharedSecret { secret: "this_surely_is_save".to_owned() }),
659                false,
660                false,
661                false,
662                None,
663                false,
664            ));
665            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
666            let expected_elements = [("password".to_owned(), "this_surely_is_save".to_owned())];
667            for e in expected_elements {
668                assert!(
669                    query_set.contains(&e),
670                    "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
671                );
672            }
673        }
674    }
675
676    #[test]
677    fn test_controlled_output_url_props_from_widget_settings() {
678        {
679            // PerParticipantKeys
680            let url = build_url_from_widget_settings(get_element_call_widget_settings(
681                Some(EncryptionSystem::PerParticipantKeys),
682                false,
683                false,
684                false,
685                None,
686                true,
687            ));
688            let controlled_audio_element = ("controlledAudioDevices".to_owned(), "true".to_owned());
689            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
690            assert!(
691                query_set.contains(&controlled_audio_element),
692                "The query elements: \n{query_set:?}\nDid not contain: \n{controlled_audio_element:?}"
693            );
694        }
695    }
696
697    #[test]
698    fn test_intent_url_props_from_widget_settings() {
699        {
700            // no intent
701            let url = build_url_from_widget_settings(get_element_call_widget_settings(
702                None, false, false, false, None, false,
703            ));
704            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
705
706            let expected_unset_elements = ["intent".to_owned(), "skipLobby".to_owned()];
707
708            for e in expected_unset_elements {
709                assert!(
710                    !query_set.iter().any(|x| x.0 == e),
711                    "The query elements: \n{query_set:?}\nShould not have contained: \n{e:?}"
712                );
713            }
714        }
715        {
716            // Intent::JoinExisting
717            let url = build_url_from_widget_settings(get_element_call_widget_settings(
718                None,
719                false,
720                false,
721                false,
722                Some(Intent::JoinExisting),
723                false,
724            ));
725            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
726            let expected_elements = ("intent".to_owned(), "join_existing".to_owned());
727            assert!(
728                query_set.contains(&expected_elements),
729                "The url query elements for an unencrypted call: \n{query_set:?}\nDid not contain: \n{expected_elements:?}"
730            );
731
732            let expected_unset_elements = ["skipLobby".to_owned()];
733
734            for e in expected_unset_elements {
735                assert!(
736                    !query_set.iter().any(|x| x.0 == e),
737                    "The query elements: \n{query_set:?}\nShould not have contained: \n{e:?}"
738                );
739            }
740        }
741        {
742            // Intent::StartCall
743            let url = build_url_from_widget_settings(get_element_call_widget_settings(
744                None,
745                false,
746                false,
747                false,
748                Some(Intent::StartCall),
749                false,
750            ));
751            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
752
753            let expected_elements = [("intent".to_owned(), "start_call".to_owned())];
754            for e in expected_elements {
755                assert!(
756                    query_set.contains(&e),
757                    "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
758                );
759            }
760        }
761        {
762            // Intent::StartCallDm
763            let url = build_url_from_widget_settings(get_element_call_widget_settings(
764                None,
765                false,
766                false,
767                false,
768                Some(Intent::StartCallDm),
769                false,
770            ));
771            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
772
773            let expected_elements = [("intent".to_owned(), "start_call_dm".to_owned())];
774            for e in expected_elements {
775                assert!(
776                    query_set.contains(&e),
777                    "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
778                );
779            }
780        }
781        {
782            // Intent::JoinExistingDm
783            let url = build_url_from_widget_settings(get_element_call_widget_settings(
784                None,
785                false,
786                false,
787                false,
788                Some(Intent::JoinExistingDm),
789                false,
790            ));
791            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
792
793            let expected_elements = [("intent".to_owned(), "join_existing_dm".to_owned())];
794            for e in expected_elements {
795                assert!(
796                    query_set.contains(&e),
797                    "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
798                );
799            }
800        }
801    }
802
803    #[test]
804    fn test_call_intent_serialization() {
805        // The call intent serialized value must match the expected enum names
806        // as defined in the Element-Call repo:
807        // https://github.com/element-hq/element-call/blob/de8fdcfa694659a29f2c7a4401dd09cfec846a96/src/UrlParams.ts#L32
808        // The enum uses serde rename `snake_case` to serialize the values, but
809        // it makes it invisible that it is important, so ensure that the values
810        // are correct.
811        assert_eq!(serde_json::to_string(&Intent::StartCall).unwrap(), r#""start_call""#);
812        assert_eq!(serde_json::to_string(&Intent::JoinExisting).unwrap(), r#""join_existing""#);
813        assert_eq!(
814            serde_json::to_string(&Intent::JoinExistingDm).unwrap(),
815            r#""join_existing_dm""#
816        );
817        assert_eq!(serde_json::to_string(&Intent::StartCallDm).unwrap(), r#""start_call_dm""#);
818        assert_eq!(
819            serde_json::to_string(&Intent::StartCallDmVoice).unwrap(),
820            r#""start_call_dm_voice""#
821        );
822        assert_eq!(
823            serde_json::to_string(&Intent::JoinExistingDmVoice).unwrap(),
824            r#""join_existing_dm_voice""#
825        );
826    }
827}