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    /// Supported since Element Call v0.13.0.
49    header: HeaderStyle,
50    /// Deprecated since Element Call v0.13.0. Included for backwards
51    /// compatibility. Use header: "standard"|"none" instead.
52    hide_header: Option<bool>,
53    preload: bool,
54    /// Deprecated since Element Call v0.9.0. Included for backwards
55    /// compatibility. Set to the same as `posthog_user_id`.
56    analytics_id: Option<String>,
57    /// Supported since Element Call v0.9.0.
58    posthog_user_id: Option<String>,
59    font_scale: Option<f64>,
60    font: Option<String>,
61    #[serde(rename = "perParticipantE2EE")]
62    per_participant_e2ee: bool,
63    password: Option<String>,
64    /// Supported since Element Call v0.8.0.
65    intent: Option<Intent>,
66    /// Supported since Element Call v0.9.0. Only used by the embedded package.
67    posthog_api_host: Option<String>,
68    /// Supported since Element Call v0.9.0. Only used by the embedded package.
69    posthog_api_key: Option<String>,
70    /// Supported since Element Call v0.9.0. Only used by the embedded package.
71    rageshake_submit_url: Option<String>,
72    /// Supported since Element Call v0.9.0. Only used by the embedded package.
73    sentry_dsn: Option<String>,
74    /// Supported since Element Call v0.9.0. Only used by the embedded package.
75    sentry_environment: Option<String>,
76    hide_screensharing: bool,
77    controlled_media_devices: bool,
78    /// Supported since Element Call v0.14.0.
79    send_notification_type: Option<NotificationType>,
80}
81
82/// Defines if a call is encrypted and which encryption system should be used.
83///
84/// This controls the url parameters: `perParticipantE2EE`, `password`.
85#[derive(Debug, PartialEq, Default, uniffi::Enum, Clone)]
86pub enum EncryptionSystem {
87    /// Equivalent to the element call url parameter: `perParticipantE2EE=false`
88    /// and no password.
89    Unencrypted,
90    /// Equivalent to the element call url parameters:
91    /// `perParticipantE2EE=true`
92    #[default]
93    PerParticipantKeys,
94    /// Equivalent to the element call url parameters:
95    /// `password={secret}`
96    SharedSecret {
97        /// The secret/password which is used in the url.
98        secret: String,
99    },
100}
101
102/// Defines the intent of showing the call.
103///
104/// This controls whether to show or skip the lobby.
105#[derive(Debug, PartialEq, Serialize, Default, uniffi::Enum, Clone)]
106#[serde(rename_all = "snake_case")]
107pub enum Intent {
108    #[default]
109    /// The user wants to start a call.
110    StartCall,
111    /// The user wants to join an existing call.
112    JoinExisting,
113}
114
115/// Defines how (if) element-call renders a header.
116#[derive(Debug, PartialEq, Serialize, Default, uniffi::Enum, Clone)]
117#[serde(rename_all = "snake_case")]
118pub enum HeaderStyle {
119    /// The normal header with branding.
120    #[default]
121    Standard,
122    /// Render a header with a back button (useful on mobile platforms).
123    AppBar,
124    /// No Header (useful for webapps).
125    None,
126}
127
128/// Types of call notifications.
129#[derive(Debug, PartialEq, Serialize, uniffi::Enum, Clone)]
130#[serde(rename_all = "snake_case")]
131pub enum NotificationType {
132    /// The receiving client should display a visual notification.
133    Notification,
134    /// The receiving client should ring with an audible sound.
135    Ring,
136}
137
138/// Properties to create a new virtual Element Call widget.
139#[derive(Debug, Default, uniffi::Record, Clone)]
140pub struct VirtualElementCallWidgetOptions {
141    /// The url to the app.
142    ///
143    /// E.g. <https://call.element.io>, <https://call.element.dev>, <https://call.element.dev/room>
144    pub element_call_url: String,
145
146    /// The widget id.
147    pub widget_id: String,
148
149    /// The url that is used as the target for the PostMessages sent
150    /// by the widget (to the client).
151    ///
152    /// For a web app client this is the client url. In case of using other
153    /// platforms the client most likely is setup up to listen to
154    /// postmessages in the same webview the widget is hosted. In this case
155    /// the `parent_url` is set to the url of the webview with the widget. Be
156    /// aware that this means that the widget will receive its own postmessage
157    /// messages. The `matrix-widget-api` (js) ignores those so this works but
158    /// it might break custom implementations.
159    ///
160    /// Defaults to `element_call_url` for the non-iframe (dedicated webview)
161    /// usecase.
162    pub parent_url: Option<String>,
163
164    /// Whether the branding header of Element call should be shown or if a
165    /// mobile header navbar should be render.
166    ///
167    /// Default: [`HeaderStyle::Standard`]
168    pub header: Option<HeaderStyle>,
169
170    /// Whether the branding header of Element call should be hidden.
171    ///
172    /// Default: `true`
173    #[deprecated(note = "Use `header` instead", since = "0.12.1")]
174    pub hide_header: Option<bool>,
175
176    /// If set, the lobby will be skipped and the widget will join the
177    /// call on the `io.element.join` action.
178    ///
179    /// Default: `false`
180    pub preload: Option<bool>,
181
182    /// The font scale which will be used inside element call.
183    ///
184    /// Default: `1`
185    pub font_scale: Option<f64>,
186
187    /// Whether element call should prompt the user to open in the browser or
188    /// the app.
189    ///
190    /// Default: `false`
191    pub app_prompt: Option<bool>,
192
193    /// Make it not possible to get to the calls list in the webview.
194    ///
195    /// Default: `true`
196    pub confine_to_room: Option<bool>,
197
198    /// The font to use, to adapt to the system font.
199    pub font: Option<String>,
200
201    /// The encryption system to use.
202    ///
203    /// Use `EncryptionSystem::Unencrypted` to disable encryption.
204    pub encryption: EncryptionSystem,
205
206    /// The intent of showing the call.
207    /// If the user wants to start a call or join an existing one.
208    /// Controls if the lobby is skipped or not.
209    pub intent: Option<Intent>,
210
211    /// Do not show the screenshare button.
212    pub hide_screensharing: bool,
213
214    /// Can be used to pass a PostHog id to element call.
215    pub posthog_user_id: Option<String>,
216    /// The host of the posthog api.
217    /// This is only used by the embedded package of Element Call.
218    pub posthog_api_host: Option<String>,
219    /// The key for the posthog api.
220    /// This is only used by the embedded package of Element Call.
221    pub posthog_api_key: Option<String>,
222
223    /// The url to use for submitting rageshakes.
224    /// This is only used by the embedded package of Element Call.
225    pub rageshake_submit_url: Option<String>,
226
227    /// Sentry [DSN](https://docs.sentry.io/concepts/key-terms/dsn-explainer/)
228    /// This is only used by the embedded package of Element Call.
229    pub sentry_dsn: Option<String>,
230    /// Sentry [environment](https://docs.sentry.io/concepts/key-terms/key-terms/)
231    /// This is only used by the embedded package of Element Call.
232    pub sentry_environment: Option<String>,
233    //// - `true`: The webview should show the list of media devices it detects using
234    ////   `enumerateDevices`.
235    ///  - `false`: the webview shows a a list of devices injected by the
236    ///    client. (used on ios & android)
237    pub controlled_media_devices: bool,
238    /// Whether and what type of notification Element Call should send, when
239    /// starting a call.
240    pub send_notification_type: Option<NotificationType>,
241}
242
243impl WidgetSettings {
244    /// `WidgetSettings` are usually created from a state event.
245    /// (currently unimplemented)
246    ///
247    /// In some cases the client wants to create custom `WidgetSettings`
248    /// for specific rooms based on other conditions.
249    /// This function returns a `WidgetSettings` object which can be used
250    /// to setup a widget using `run_client_widget_api`
251    /// and to generate the correct url for the widget.
252    ///
253    /// # Arguments
254    ///
255    /// * `props` - A struct containing the configuration parameters for a
256    ///   element call widget.
257    pub fn new_virtual_element_call_widget(
258        props: VirtualElementCallWidgetOptions,
259    ) -> Result<Self, url::ParseError> {
260        let mut raw_url: Url = Url::parse(&props.element_call_url)?;
261
262        let skip_lobby = if props.intent.as_ref().is_some_and(|x| x == &Intent::StartCall) {
263            Some(true)
264        } else {
265            None
266        };
267        #[allow(deprecated)]
268        let query_params = ElementCallParams {
269            user_id: url_params::USER_ID.to_owned(),
270            room_id: url_params::ROOM_ID.to_owned(),
271            widget_id: url_params::WIDGET_ID.to_owned(),
272            display_name: url_params::DISPLAY_NAME.to_owned(),
273            lang: url_params::LANGUAGE.to_owned(),
274            theme: url_params::CLIENT_THEME.to_owned(),
275            client_id: url_params::CLIENT_ID.to_owned(),
276            device_id: url_params::DEVICE_ID.to_owned(),
277            base_url: url_params::HOMESERVER_URL.to_owned(),
278
279            parent_url: props.parent_url.unwrap_or(props.element_call_url.clone()),
280            confine_to_room: props.confine_to_room.unwrap_or(true),
281            app_prompt: props.app_prompt.unwrap_or_default(),
282            header: props.header.unwrap_or_default(),
283            hide_header: props.hide_header,
284            preload: props.preload.unwrap_or_default(),
285            font_scale: props.font_scale,
286            font: props.font,
287            per_participant_e2ee: props.encryption == EncryptionSystem::PerParticipantKeys,
288            password: match props.encryption {
289                EncryptionSystem::SharedSecret { secret } => Some(secret),
290                _ => None,
291            },
292            intent: props.intent,
293            skip_lobby,
294            analytics_id: props.posthog_user_id.clone(),
295            posthog_user_id: props.posthog_user_id,
296            posthog_api_host: props.posthog_api_host,
297            posthog_api_key: props.posthog_api_key,
298            sentry_dsn: props.sentry_dsn,
299            sentry_environment: props.sentry_environment,
300            rageshake_submit_url: props.rageshake_submit_url,
301            hide_screensharing: props.hide_screensharing,
302            controlled_media_devices: props.controlled_media_devices,
303            send_notification_type: props.send_notification_type,
304        };
305
306        let query =
307            serde_html_form::to_string(query_params).map_err(|_| url::ParseError::Overflow)?;
308
309        // Revert the encoding for the template parameters. So we can have a unified
310        // replace logic.
311        let query = query.replace("%24", "$");
312
313        // All the params will be set inside the fragment (to keep the traffic to the
314        // server minimal and most importantly don't send the passwords).
315        raw_url.set_fragment(Some(&format!("?{query}")));
316
317        // for EC we always want init on content load to be true.
318        Ok(Self { widget_id: props.widget_id, init_on_content_load: true, raw_url })
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use std::collections::BTreeSet;
325
326    use ruma::api::client::profile::get_profile;
327    use url::Url;
328
329    use crate::widget::{ClientProperties, Intent, WidgetSettings};
330
331    const WIDGET_ID: &str = "1/@#w23";
332
333    fn get_widget_settings(
334        encryption: Option<EncryptionSystem>,
335        posthog: bool,
336        rageshake: bool,
337        sentry: bool,
338        intent: Option<Intent>,
339        controlle_output: bool,
340    ) -> WidgetSettings {
341        let mut props = VirtualElementCallWidgetOptions {
342            element_call_url: "https://call.element.io".to_owned(),
343            widget_id: WIDGET_ID.to_owned(),
344            preload: Some(true),
345            app_prompt: Some(true),
346            confine_to_room: Some(true),
347            encryption: encryption.unwrap_or(EncryptionSystem::PerParticipantKeys),
348            intent,
349            controlled_media_devices: controlle_output,
350            ..VirtualElementCallWidgetOptions::default()
351        };
352
353        if posthog {
354            props.posthog_user_id = Some("POSTHOG_USER_ID".to_owned());
355            props.posthog_api_host = Some("posthog.element.io".to_owned());
356            props.posthog_api_key = Some("POSTHOG_KEY".to_owned());
357        }
358
359        if rageshake {
360            props.rageshake_submit_url = Some("https://rageshake.element.io".to_owned());
361        }
362
363        if sentry {
364            props.sentry_dsn = Some("SENTRY_DSN".to_owned());
365            props.sentry_environment = Some("SENTRY_ENV".to_owned());
366        }
367
368        WidgetSettings::new_virtual_element_call_widget(props)
369            .expect("could not parse virtual element call widget")
370    }
371
372    trait FragmentQuery {
373        fn fragment_query(&self) -> Option<&str>;
374    }
375
376    impl FragmentQuery for Url {
377        fn fragment_query(&self) -> Option<&str> {
378            Some(self.fragment()?.split_once('?')?.1)
379        }
380    }
381
382    // Convert query strings to BTreeSet so that we can compare the urls independent
383    // of the order of the params.
384    type QuerySet = BTreeSet<(String, String)>;
385
386    use serde_html_form::from_str;
387
388    use super::{EncryptionSystem, VirtualElementCallWidgetOptions};
389
390    fn get_query_sets(url: &Url) -> Option<(QuerySet, QuerySet)> {
391        let fq = from_str::<QuerySet>(url.fragment_query().unwrap_or_default()).ok()?;
392        let q = from_str::<QuerySet>(url.query().unwrap_or_default()).ok()?;
393        Some((q, fq))
394    }
395
396    #[test]
397    fn test_new_virtual_element_call_widget_base_url() {
398        let widget_settings = get_widget_settings(None, false, false, false, None, false);
399        assert_eq!(widget_settings.base_url().unwrap().as_str(), "https://call.element.io/");
400    }
401
402    #[test]
403    fn test_new_virtual_element_call_widget_raw_url() {
404        const CONVERTED_URL: &str = "
405            https://call.element.io#\
406                ?userId=$matrix_user_id\
407                &roomId=$matrix_room_id\
408                &widgetId=$matrix_widget_id\
409                &displayName=$matrix_display_name\
410                &lang=$org.matrix.msc2873.client_language\
411                &theme=$org.matrix.msc2873.client_theme\
412                &clientId=$org.matrix.msc2873.client_id\
413                &deviceId=$org.matrix.msc2873.matrix_device_id\
414                &baseUrl=$org.matrix.msc4039.matrix_base_url\
415                &parentUrl=https%3A%2F%2Fcall.element.io\
416                &confineToRoom=true\
417                &appPrompt=true\
418                &header=standard\
419                &preload=true\
420                &perParticipantE2EE=true\
421                &hideScreensharing=false\
422                &controlledMediaDevices=false\
423        ";
424
425        let mut url = get_widget_settings(None, false, false, false, None, false).raw_url().clone();
426        let mut gen = Url::parse(CONVERTED_URL).unwrap();
427        assert_eq!(get_query_sets(&url).unwrap(), get_query_sets(&gen).unwrap());
428        url.set_fragment(None);
429        url.set_query(None);
430        gen.set_fragment(None);
431        gen.set_query(None);
432        assert_eq!(url, gen);
433    }
434
435    #[test]
436    fn test_new_virtual_element_call_widget_id() {
437        assert_eq!(
438            get_widget_settings(None, false, false, false, None, false).widget_id(),
439            WIDGET_ID
440        );
441    }
442
443    fn build_url_from_widget_settings(settings: WidgetSettings) -> String {
444        let mut profile = get_profile::v3::Response::new();
445        profile.set("avatar_url", "some-url".into());
446        profile.set("displayname", "hello".into());
447
448        settings
449            ._generate_webview_url(
450                profile,
451                "@test:user.org".try_into().unwrap(),
452                "!room_id:room.org".try_into().unwrap(),
453                "ABCDEFG".into(),
454                "https://client-matrix.server.org".try_into().unwrap(),
455                ClientProperties::new(
456                    "io.my_matrix.client",
457                    Some(language_tags::LanguageTag::parse("en-us").unwrap()),
458                    Some("light".into()),
459                ),
460            )
461            .unwrap()
462            .to_string()
463    }
464
465    #[test]
466    fn test_new_virtual_element_call_widget_webview_url() {
467        const CONVERTED_URL: &str = "
468            https://call.element.io#\
469                ?parentUrl=https%3A%2F%2Fcall.element.io\
470                &widgetId=1/@#w23\
471                &userId=%40test%3Auser.org&deviceId=ABCDEFG\
472                &roomId=%21room_id%3Aroom.org\
473                &lang=en-US&theme=light\
474                &baseUrl=https%3A%2F%2Fclient-matrix.server.org%2F\
475                &header=standard\
476                &preload=true\
477                &confineToRoom=true\
478                &displayName=hello\
479                &appPrompt=true\
480                &clientId=io.my_matrix.client\
481                &perParticipantE2EE=true\
482                &hideScreensharing=false\
483                &controlledMediaDevices=false\
484        ";
485        let gen = build_url_from_widget_settings(get_widget_settings(
486            None, false, false, false, None, false,
487        ));
488
489        let mut url = Url::parse(&gen).unwrap();
490        let mut gen = Url::parse(CONVERTED_URL).unwrap();
491        assert_eq!(get_query_sets(&url).unwrap(), get_query_sets(&gen).unwrap());
492        url.set_fragment(None);
493        url.set_query(None);
494        gen.set_fragment(None);
495        gen.set_query(None);
496        assert_eq!(url, gen);
497    }
498
499    #[test]
500    fn test_new_virtual_element_call_widget_webview_url_with_posthog_rageshake_sentry() {
501        const CONVERTED_URL: &str = "
502            https://call.element.io#\
503                ?parentUrl=https%3A%2F%2Fcall.element.io\
504                &widgetId=1/@#w23\
505                &userId=%40test%3Auser.org&deviceId=ABCDEFG\
506                &roomId=%21room_id%3Aroom.org\
507                &lang=en-US&theme=light\
508                &baseUrl=https%3A%2F%2Fclient-matrix.server.org%2F\
509                &header=standard\
510                &preload=true\
511                &confineToRoom=true\
512                &displayName=hello\
513                &appPrompt=true\
514                &clientId=io.my_matrix.client\
515                &perParticipantE2EE=true\
516                &hideScreensharing=false\
517                &posthogApiHost=posthog.element.io\
518                &posthogApiKey=POSTHOG_KEY\
519                &analyticsId=POSTHOG_USER_ID\
520                &posthogUserId=POSTHOG_USER_ID\
521                &rageshakeSubmitUrl=https%3A%2F%2Frageshake.element.io\
522                &sentryDsn=SENTRY_DSN\
523                &sentryEnvironment=SENTRY_ENV\
524                &controlledMediaDevices=false\
525        ";
526        let gen = build_url_from_widget_settings(get_widget_settings(
527            None, true, true, true, None, false,
528        ));
529
530        let mut url = Url::parse(&gen).unwrap();
531        let mut gen = Url::parse(CONVERTED_URL).unwrap();
532        assert_eq!(get_query_sets(&url).unwrap(), get_query_sets(&gen).unwrap());
533        url.set_fragment(None);
534        url.set_query(None);
535        gen.set_fragment(None);
536        gen.set_query(None);
537        assert_eq!(url, gen);
538    }
539
540    #[test]
541    fn test_password_url_props_from_widget_settings() {
542        {
543            // PerParticipantKeys
544            let url = build_url_from_widget_settings(get_widget_settings(
545                Some(EncryptionSystem::PerParticipantKeys),
546                false,
547                false,
548                false,
549                None,
550                false,
551            ));
552            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
553            let expected_elements = [("perParticipantE2EE".to_owned(), "true".to_owned())];
554            for e in expected_elements {
555                assert!(
556                    query_set.contains(&e),
557                    "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
558                );
559            }
560        }
561        {
562            // Unencrypted
563            let url = build_url_from_widget_settings(get_widget_settings(
564                Some(EncryptionSystem::Unencrypted),
565                false,
566                false,
567                false,
568                None,
569                false,
570            ));
571            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
572            let expected_elements = ("perParticipantE2EE".to_owned(), "false".to_owned());
573            assert!(
574                query_set.contains(&expected_elements),
575                "The url query elements for an unencrypted call: \n{query_set:?}\nDid not contain: \n{expected_elements:?}"
576            );
577        }
578        {
579            // SharedSecret
580            let url = build_url_from_widget_settings(get_widget_settings(
581                Some(EncryptionSystem::SharedSecret { secret: "this_surely_is_save".to_owned() }),
582                false,
583                false,
584                false,
585                None,
586                false,
587            ));
588            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
589            let expected_elements = [("password".to_owned(), "this_surely_is_save".to_owned())];
590            for e in expected_elements {
591                assert!(
592                    query_set.contains(&e),
593                    "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
594                );
595            }
596        }
597    }
598
599    #[test]
600    fn test_controlled_output_url_props_from_widget_settings() {
601        {
602            // PerParticipantKeys
603            let url = build_url_from_widget_settings(get_widget_settings(
604                Some(EncryptionSystem::PerParticipantKeys),
605                false,
606                false,
607                false,
608                None,
609                true,
610            ));
611            let controlled_media_element = ("controlledMediaDevices".to_owned(), "true".to_owned());
612            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
613            assert!(
614                query_set.contains(&controlled_media_element),
615                "The query elements: \n{query_set:?}\nDid not contain: \n{controlled_media_element:?}"
616            );
617        }
618    }
619
620    #[test]
621    fn test_intent_url_props_from_widget_settings() {
622        {
623            // no intent
624            let url = build_url_from_widget_settings(get_widget_settings(
625                None, false, false, false, None, false,
626            ));
627            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
628
629            let expected_unset_elements = ["intent".to_owned(), "skipLobby".to_owned()];
630
631            for e in expected_unset_elements {
632                assert!(
633                    !query_set.iter().any(|x| x.0 == e),
634                    "The query elements: \n{query_set:?}\nShould not have contained: \n{e:?}"
635                );
636            }
637        }
638        {
639            // Intent::JoinExisting
640            let url = build_url_from_widget_settings(get_widget_settings(
641                None,
642                false,
643                false,
644                false,
645                Some(Intent::JoinExisting),
646                false,
647            ));
648            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
649            let expected_elements = ("intent".to_owned(), "join_existing".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            let expected_unset_elements = ["skipLobby".to_owned()];
656
657            for e in expected_unset_elements {
658                assert!(
659                    !query_set.iter().any(|x| x.0 == e),
660                    "The query elements: \n{query_set:?}\nShould not have contained: \n{e:?}"
661                );
662            }
663        }
664        {
665            // Intent::StartCall
666            let url = build_url_from_widget_settings(get_widget_settings(
667                None,
668                false,
669                false,
670                false,
671                Some(Intent::StartCall),
672                false,
673            ));
674            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
675
676            // skipLobby should be set for compatibility with versions < 0.8.0
677            let expected_elements = [
678                ("intent".to_owned(), "start_call".to_owned()),
679                ("skipLobby".to_owned(), "true".to_owned()),
680            ];
681            for e in expected_elements {
682                assert!(
683                    query_set.contains(&e),
684                    "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
685                );
686            }
687        }
688    }
689}