1use serde::Serialize;
23use url::Url;
24
25use super::{WidgetSettings, url_params};
26
27#[derive(Serialize)]
28#[serde(rename_all = "camelCase")]
29struct 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 parent_url: String,
66 skip_lobby: Option<bool>,
69 confine_to_room: Option<bool>,
70 app_prompt: Option<bool>,
71 header: Option<HeaderStyle>,
73 hide_header: Option<bool>,
76 preload: Option<bool>,
77 analytics_id: Option<String>,
80 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 intent: Option<Intent>,
89 posthog_api_host: Option<String>,
91 posthog_api_key: Option<String>,
93 rageshake_submit_url: Option<String>,
95 sentry_dsn: Option<String>,
97 sentry_environment: Option<String>,
99 hide_screensharing: Option<bool>,
101 controlled_audio_devices: Option<bool>,
103 send_notification_type: Option<NotificationType>,
105}
106
107#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
111#[derive(Debug, PartialEq, Default, Clone)]
112pub enum EncryptionSystem {
113 Unencrypted,
116 #[default]
119 PerParticipantKeys,
120 SharedSecret {
123 secret: String,
125 },
126}
127
128#[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 StartCall,
138 JoinExisting,
140 JoinExistingDm,
143 StartCallDm,
145}
146
147#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
149#[derive(Debug, PartialEq, Serialize, Default, Clone)]
150#[serde(rename_all = "snake_case")]
151pub enum HeaderStyle {
152 #[default]
154 Standard,
155 AppBar,
157 None,
159}
160
161#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
163#[derive(Debug, PartialEq, Serialize, Clone, Default)]
164#[serde(rename_all = "snake_case")]
165pub enum NotificationType {
166 #[default]
168 Notification,
169 Ring,
171}
172
173#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
183#[derive(Debug, Default, Clone)]
184pub struct VirtualElementCallWidgetConfig {
185 pub intent: Option<Intent>,
189
190 #[uniffi(default = None)]
192 pub skip_lobby: Option<bool>,
193
194 #[uniffi(default = None)]
199 pub header: Option<HeaderStyle>,
200
201 #[deprecated(note = "Use `header` instead", since = "0.12.1")]
205 #[uniffi(default = None)]
206 pub hide_header: Option<bool>,
207
208 #[uniffi(default = None)]
213 pub preload: Option<bool>,
214
215 #[uniffi(default = None)]
220 pub app_prompt: Option<bool>,
221
222 #[uniffi(default = None)]
226 pub confine_to_room: Option<bool>,
227
228 #[uniffi(default = None)]
230 pub hide_screensharing: Option<bool>,
231
232 #[uniffi(default = None)]
235 pub controlled_audio_devices: Option<bool>,
236
237 #[uniffi(default = None)]
240 pub send_notification_type: Option<NotificationType>,
241}
242
243#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
249#[derive(Debug, Default, Clone)]
250pub struct VirtualElementCallWidgetProperties {
251 pub element_call_url: String,
255
256 pub widget_id: String,
258
259 #[uniffi(default = None)]
273 pub parent_url: Option<String>,
274
275 #[uniffi(default = None)]
279 pub font_scale: Option<f64>,
280
281 #[uniffi(default = None)]
283 pub font: Option<String>,
284
285 pub encryption: EncryptionSystem,
289
290 #[uniffi(default = None)]
292 pub posthog_user_id: Option<String>,
293 #[uniffi(default = None)]
296 pub posthog_api_host: Option<String>,
297 #[uniffi(default = None)]
300 pub posthog_api_key: Option<String>,
301
302 #[uniffi(default = None)]
305 pub rageshake_submit_url: Option<String>,
306
307 #[uniffi(default = None)]
310 pub sentry_dsn: Option<String>,
311
312 #[uniffi(default = None)]
315 pub sentry_environment: Option<String>,
316}
317
318impl WidgetSettings {
319 pub fn new_virtual_element_call_widget(
333 props: VirtualElementCallWidgetProperties,
334 config: VirtualElementCallWidgetConfig,
335 ) -> Result<Self, url::ParseError> {
336 let mut raw_url: Url = Url::parse(&props.element_call_url)?;
337
338 #[allow(deprecated)]
339 let query_params = ElementCallUrlParams {
340 user_id: url_params::USER_ID.to_owned(),
341 room_id: url_params::ROOM_ID.to_owned(),
342 widget_id: url_params::WIDGET_ID.to_owned(),
343 display_name: url_params::DISPLAY_NAME.to_owned(),
344 lang: url_params::LANGUAGE.to_owned(),
345 theme: url_params::CLIENT_THEME.to_owned(),
346 client_id: url_params::CLIENT_ID.to_owned(),
347 device_id: url_params::DEVICE_ID.to_owned(),
348 base_url: url_params::HOMESERVER_URL.to_owned(),
349
350 parent_url: props.parent_url.unwrap_or(props.element_call_url.clone()),
351 confine_to_room: config.confine_to_room,
352 app_prompt: config.app_prompt,
353 header: config.header,
354 hide_header: config.hide_header,
355 preload: config.preload,
356 font_scale: props.font_scale,
357 font: props.font,
358 per_participant_e2ee: Some(props.encryption == EncryptionSystem::PerParticipantKeys),
359 password: match props.encryption {
360 EncryptionSystem::SharedSecret { secret } => Some(secret),
361 _ => None,
362 },
363 intent: config.intent,
364 skip_lobby: config.skip_lobby,
365 analytics_id: props.posthog_user_id.clone(),
366 posthog_user_id: props.posthog_user_id,
367 posthog_api_host: props.posthog_api_host,
368 posthog_api_key: props.posthog_api_key,
369 sentry_dsn: props.sentry_dsn,
370 sentry_environment: props.sentry_environment,
371 rageshake_submit_url: props.rageshake_submit_url,
372 hide_screensharing: config.hide_screensharing,
373 controlled_audio_devices: config.controlled_audio_devices,
374 send_notification_type: config.send_notification_type,
375 };
376
377 let query =
378 serde_html_form::to_string(query_params).map_err(|_| url::ParseError::Overflow)?;
379
380 let query = query.replace("%24", "$");
383
384 raw_url.set_fragment(Some(&format!("?{query}")));
387
388 Ok(Self { widget_id: props.widget_id, init_on_content_load: true, raw_url })
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use std::collections::BTreeSet;
396
397 use ruma::api::client::profile::get_profile;
398 use url::Url;
399
400 use crate::widget::{
401 ClientProperties, Intent, WidgetSettings,
402 settings::element_call::{HeaderStyle, VirtualElementCallWidgetConfig},
403 };
404
405 const WIDGET_ID: &str = "1/@#w23";
406
407 fn get_element_call_widget_settings(
408 encryption: Option<EncryptionSystem>,
409 posthog: bool,
410 rageshake: bool,
411 sentry: bool,
412 intent: Option<Intent>,
413 controlled_output: bool,
414 ) -> WidgetSettings {
415 let props = VirtualElementCallWidgetProperties {
416 element_call_url: "https://call.element.io".to_owned(),
417 widget_id: WIDGET_ID.to_owned(),
418 posthog_user_id: posthog.then(|| "POSTHOG_USER_ID".to_owned()),
419 posthog_api_host: posthog.then(|| "posthog.element.io".to_owned()),
420 posthog_api_key: posthog.then(|| "POSTHOG_KEY".to_owned()),
421 rageshake_submit_url: rageshake.then(|| "https://rageshake.element.io".to_owned()),
422 sentry_dsn: sentry.then(|| "SENTRY_DSN".to_owned()),
423 sentry_environment: sentry.then(|| "SENTRY_ENV".to_owned()),
424 encryption: encryption.unwrap_or(EncryptionSystem::PerParticipantKeys),
425 ..VirtualElementCallWidgetProperties::default()
426 };
427
428 let config = VirtualElementCallWidgetConfig {
429 controlled_audio_devices: Some(controlled_output),
430 preload: Some(true),
431 app_prompt: Some(true),
432 confine_to_room: Some(true),
433 hide_screensharing: Some(false),
434 header: Some(HeaderStyle::Standard),
435 intent,
436 ..VirtualElementCallWidgetConfig::default()
437 };
438
439 WidgetSettings::new_virtual_element_call_widget(props, config)
440 .expect("could not parse virtual element call widget")
441 }
442
443 trait FragmentQuery {
444 fn fragment_query(&self) -> Option<&str>;
445 }
446
447 impl FragmentQuery for Url {
448 fn fragment_query(&self) -> Option<&str> {
449 Some(self.fragment()?.split_once('?')?.1)
450 }
451 }
452
453 type QuerySet = BTreeSet<(String, String)>;
456
457 use serde_html_form::from_str;
458
459 use super::{EncryptionSystem, VirtualElementCallWidgetProperties};
460
461 fn get_query_sets(url: &Url) -> Option<(QuerySet, QuerySet)> {
462 let fq = from_str::<QuerySet>(url.fragment_query().unwrap_or_default()).ok()?;
463 let q = from_str::<QuerySet>(url.query().unwrap_or_default()).ok()?;
464 Some((q, fq))
465 }
466
467 #[test]
468 fn test_new_virtual_element_call_widget_base_url() {
469 let widget_settings =
470 get_element_call_widget_settings(None, false, false, false, None, false);
471 assert_eq!(widget_settings.base_url().unwrap().as_str(), "https://call.element.io/");
472 }
473
474 #[test]
475 fn test_new_virtual_element_call_widget_raw_url() {
476 const CONVERTED_URL: &str = "
477 https://call.element.io#\
478 ?userId=$matrix_user_id\
479 &roomId=$matrix_room_id\
480 &widgetId=$matrix_widget_id\
481 &displayName=$matrix_display_name\
482 &lang=$org.matrix.msc2873.client_language\
483 &theme=$org.matrix.msc2873.client_theme\
484 &clientId=$org.matrix.msc2873.client_id\
485 &deviceId=$org.matrix.msc2873.matrix_device_id\
486 &baseUrl=$org.matrix.msc4039.matrix_base_url\
487 &parentUrl=https%3A%2F%2Fcall.element.io\
488 &confineToRoom=true\
489 &appPrompt=true\
490 &header=standard\
491 &preload=true\
492 &perParticipantE2EE=true\
493 &hideScreensharing=false\
494 &controlledAudioDevices=false\
495 ";
496
497 let mut generated_url =
498 get_element_call_widget_settings(None, false, false, false, None, false)
499 .raw_url()
500 .clone();
501 let mut expected_url = Url::parse(CONVERTED_URL).unwrap();
502 assert_eq!(get_query_sets(&generated_url).unwrap(), get_query_sets(&expected_url).unwrap());
503 generated_url.set_fragment(None);
504 generated_url.set_query(None);
505 expected_url.set_fragment(None);
506 expected_url.set_query(None);
507 assert_eq!(generated_url, expected_url);
508 }
509
510 #[test]
511 fn test_new_virtual_element_call_widget_id() {
512 assert_eq!(
513 get_element_call_widget_settings(None, false, false, false, None, false).widget_id(),
514 WIDGET_ID
515 );
516 }
517
518 fn build_url_from_widget_settings(settings: WidgetSettings) -> String {
519 let mut profile = get_profile::v3::Response::new();
520 profile.set("avatar_url", "some-url".into());
521 profile.set("displayname", "hello".into());
522
523 settings
524 ._generate_webview_url(
525 profile,
526 "@test:user.org".try_into().unwrap(),
527 "!room_id:room.org".try_into().unwrap(),
528 "ABCDEFG".into(),
529 "https://client-matrix.server.org".try_into().unwrap(),
530 ClientProperties::new(
531 "io.my_matrix.client",
532 Some(language_tags::LanguageTag::parse("en-us").unwrap()),
533 Some("light".into()),
534 ),
535 )
536 .unwrap()
537 .to_string()
538 }
539
540 #[test]
541 fn test_new_virtual_element_call_widget_webview_url() {
542 const CONVERTED_URL: &str = "
543 https://call.element.io#\
544 ?parentUrl=https%3A%2F%2Fcall.element.io\
545 &widgetId=1/@#w23\
546 &userId=%40test%3Auser.org&deviceId=ABCDEFG\
547 &roomId=%21room_id%3Aroom.org\
548 &lang=en-US&theme=light\
549 &baseUrl=https%3A%2F%2Fclient-matrix.server.org%2F\
550 &header=standard\
551 &preload=true\
552 &confineToRoom=true\
553 &displayName=hello\
554 &appPrompt=true\
555 &clientId=io.my_matrix.client\
556 &perParticipantE2EE=true\
557 &hideScreensharing=false\
558 &controlledAudioDevices=false\
559 ";
560 let mut generated_url = Url::parse(&build_url_from_widget_settings(
561 get_element_call_widget_settings(None, false, false, false, None, false),
562 ))
563 .unwrap();
564 let mut expected_url = Url::parse(CONVERTED_URL).unwrap();
565 assert_eq!(get_query_sets(&generated_url).unwrap(), get_query_sets(&expected_url).unwrap());
566 generated_url.set_fragment(None);
567 generated_url.set_query(None);
568 expected_url.set_fragment(None);
569 expected_url.set_query(None);
570 assert_eq!(generated_url, expected_url);
571 }
572
573 #[test]
574 fn test_new_virtual_element_call_widget_webview_url_with_posthog_rageshake_sentry() {
575 const CONVERTED_URL: &str = "
576 https://call.element.io#\
577 ?parentUrl=https%3A%2F%2Fcall.element.io\
578 &widgetId=1/@#w23\
579 &userId=%40test%3Auser.org&deviceId=ABCDEFG\
580 &roomId=%21room_id%3Aroom.org\
581 &lang=en-US&theme=light\
582 &baseUrl=https%3A%2F%2Fclient-matrix.server.org%2F\
583 &header=standard\
584 &preload=true\
585 &confineToRoom=true\
586 &displayName=hello\
587 &appPrompt=true\
588 &clientId=io.my_matrix.client\
589 &perParticipantE2EE=true\
590 &hideScreensharing=false\
591 &posthogApiHost=posthog.element.io\
592 &posthogApiKey=POSTHOG_KEY\
593 &analyticsId=POSTHOG_USER_ID\
594 &posthogUserId=POSTHOG_USER_ID\
595 &rageshakeSubmitUrl=https%3A%2F%2Frageshake.element.io\
596 &sentryDsn=SENTRY_DSN\
597 &sentryEnvironment=SENTRY_ENV\
598 &controlledAudioDevices=false\
599 ";
600 let mut generated_url = Url::parse(&build_url_from_widget_settings(
601 get_element_call_widget_settings(None, true, true, true, None, false),
602 ))
603 .unwrap();
604 let mut original_url = Url::parse(CONVERTED_URL).unwrap();
605 assert_eq!(get_query_sets(&generated_url).unwrap(), get_query_sets(&original_url).unwrap());
606 generated_url.set_fragment(None);
607 generated_url.set_query(None);
608 original_url.set_fragment(None);
609 original_url.set_query(None);
610 assert_eq!(generated_url, original_url);
611 }
612
613 #[test]
614 fn test_password_url_props_from_widget_settings() {
615 {
616 let url = build_url_from_widget_settings(get_element_call_widget_settings(
618 Some(EncryptionSystem::PerParticipantKeys),
619 false,
620 false,
621 false,
622 None,
623 false,
624 ));
625 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
626 let expected_elements = [("perParticipantE2EE".to_owned(), "true".to_owned())];
627 for e in expected_elements {
628 assert!(
629 query_set.contains(&e),
630 "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
631 );
632 }
633 }
634 {
635 let url = build_url_from_widget_settings(get_element_call_widget_settings(
637 Some(EncryptionSystem::Unencrypted),
638 false,
639 false,
640 false,
641 None,
642 false,
643 ));
644 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
645 let expected_elements = ("perParticipantE2EE".to_owned(), "false".to_owned());
646 assert!(
647 query_set.contains(&expected_elements),
648 "The url query elements for an unencrypted call: \n{query_set:?}\nDid not contain: \n{expected_elements:?}"
649 );
650 }
651 {
652 let url = build_url_from_widget_settings(get_element_call_widget_settings(
654 Some(EncryptionSystem::SharedSecret { secret: "this_surely_is_save".to_owned() }),
655 false,
656 false,
657 false,
658 None,
659 false,
660 ));
661 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
662 let expected_elements = [("password".to_owned(), "this_surely_is_save".to_owned())];
663 for e in expected_elements {
664 assert!(
665 query_set.contains(&e),
666 "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
667 );
668 }
669 }
670 }
671
672 #[test]
673 fn test_controlled_output_url_props_from_widget_settings() {
674 {
675 let url = build_url_from_widget_settings(get_element_call_widget_settings(
677 Some(EncryptionSystem::PerParticipantKeys),
678 false,
679 false,
680 false,
681 None,
682 true,
683 ));
684 let controlled_audio_element = ("controlledAudioDevices".to_owned(), "true".to_owned());
685 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
686 assert!(
687 query_set.contains(&controlled_audio_element),
688 "The query elements: \n{query_set:?}\nDid not contain: \n{controlled_audio_element:?}"
689 );
690 }
691 }
692
693 #[test]
694 fn test_intent_url_props_from_widget_settings() {
695 {
696 let url = build_url_from_widget_settings(get_element_call_widget_settings(
698 None, false, false, false, None, false,
699 ));
700 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
701
702 let expected_unset_elements = ["intent".to_owned(), "skipLobby".to_owned()];
703
704 for e in expected_unset_elements {
705 assert!(
706 !query_set.iter().any(|x| x.0 == e),
707 "The query elements: \n{query_set:?}\nShould not have contained: \n{e:?}"
708 );
709 }
710 }
711 {
712 let url = build_url_from_widget_settings(get_element_call_widget_settings(
714 None,
715 false,
716 false,
717 false,
718 Some(Intent::JoinExisting),
719 false,
720 ));
721 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
722 let expected_elements = ("intent".to_owned(), "join_existing".to_owned());
723 assert!(
724 query_set.contains(&expected_elements),
725 "The url query elements for an unencrypted call: \n{query_set:?}\nDid not contain: \n{expected_elements:?}"
726 );
727
728 let expected_unset_elements = ["skipLobby".to_owned()];
729
730 for e in expected_unset_elements {
731 assert!(
732 !query_set.iter().any(|x| x.0 == e),
733 "The query elements: \n{query_set:?}\nShould not have contained: \n{e:?}"
734 );
735 }
736 }
737 {
738 let url = build_url_from_widget_settings(get_element_call_widget_settings(
740 None,
741 false,
742 false,
743 false,
744 Some(Intent::StartCall),
745 false,
746 ));
747 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
748
749 let expected_elements = [("intent".to_owned(), "start_call".to_owned())];
750 for e in expected_elements {
751 assert!(
752 query_set.contains(&e),
753 "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
754 );
755 }
756 }
757 {
758 let url = build_url_from_widget_settings(get_element_call_widget_settings(
760 None,
761 false,
762 false,
763 false,
764 Some(Intent::StartCallDm),
765 false,
766 ));
767 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
768
769 let expected_elements = [("intent".to_owned(), "start_call_dm".to_owned())];
770 for e in expected_elements {
771 assert!(
772 query_set.contains(&e),
773 "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
774 );
775 }
776 }
777 {
778 let url = build_url_from_widget_settings(get_element_call_widget_settings(
780 None,
781 false,
782 false,
783 false,
784 Some(Intent::JoinExistingDm),
785 false,
786 ));
787 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
788
789 let expected_elements = [("intent".to_owned(), "join_existing_dm".to_owned())];
790 for e in expected_elements {
791 assert!(
792 query_set.contains(&e),
793 "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
794 );
795 }
796 }
797 }
798}