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 StartCallDmVoice,
147 JoinExistingDmVoice,
150}
151
152#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
154#[derive(Debug, PartialEq, Serialize, Default, Clone)]
155#[serde(rename_all = "snake_case")]
156pub enum HeaderStyle {
157 #[default]
159 Standard,
160 AppBar,
162 None,
164}
165
166#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
168#[derive(Debug, PartialEq, Serialize, Clone, Default)]
169#[serde(rename_all = "snake_case")]
170pub enum NotificationType {
171 #[default]
173 Notification,
174 Ring,
176}
177
178#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
188#[derive(Debug, Default, Clone)]
189pub struct VirtualElementCallWidgetConfig {
190 pub intent: Option<Intent>,
194
195 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
197 pub skip_lobby: Option<bool>,
198
199 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
204 pub header: Option<HeaderStyle>,
205
206 #[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 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
218 pub preload: Option<bool>,
219
220 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
225 pub app_prompt: Option<bool>,
226
227 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
231 pub confine_to_room: Option<bool>,
232
233 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
235 pub hide_screensharing: Option<bool>,
236
237 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
240 pub controlled_audio_devices: Option<bool>,
241
242 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
245 pub send_notification_type: Option<NotificationType>,
246}
247
248#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
254#[derive(Debug, Default, Clone)]
255pub struct VirtualElementCallWidgetProperties {
256 pub element_call_url: String,
260
261 pub widget_id: String,
263
264 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
278 pub parent_url: Option<String>,
279
280 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
284 pub font_scale: Option<f64>,
285
286 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
288 pub font: Option<String>,
289
290 pub encryption: EncryptionSystem,
294
295 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
297 pub posthog_user_id: Option<String>,
298 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
301 pub posthog_api_host: Option<String>,
302 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
305 pub posthog_api_key: Option<String>,
306
307 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
310 pub rageshake_submit_url: Option<String>,
311
312 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
315 pub sentry_dsn: Option<String>,
316
317 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
320 pub sentry_environment: Option<String>,
321}
322
323impl WidgetSettings {
324 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 let query = query.replace("%24", "$");
388
389 raw_url.set_fragment(Some(&format!("?{query}")));
392
393 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 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 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 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 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 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 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 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 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 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 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 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}