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