1use serde::Serialize;
22use url::Url;
23
24use super::{WidgetSettings, url_params};
25
26#[derive(Serialize)]
54#[serde(rename_all = "camelCase")]
55struct ElementCallUrlParams {
56 user_id: String,
57 room_id: String,
58 widget_id: String,
59 display_name: String,
60 lang: String,
61 theme: String,
62 client_id: String,
63 device_id: String,
64 base_url: String,
65 parent_url: String,
67 skip_lobby: Option<bool>,
70 confine_to_room: Option<bool>,
71 app_prompt: Option<bool>,
72 header: Option<HeaderStyle>,
74 hide_header: Option<bool>,
77 preload: Option<bool>,
78 analytics_id: Option<String>,
81 posthog_user_id: Option<String>,
83 font_scale: Option<f64>,
84 font: Option<String>,
85 #[serde(rename = "perParticipantE2EE")]
86 per_participant_e2ee: Option<bool>,
87 password: Option<String>,
88 intent: Option<Intent>,
90 posthog_api_host: Option<String>,
92 posthog_api_key: Option<String>,
94 rageshake_submit_url: Option<String>,
96 sentry_dsn: Option<String>,
98 sentry_environment: Option<String>,
100 hide_screensharing: Option<bool>,
102 controlled_audio_devices: Option<bool>,
104 send_notification_type: Option<NotificationType>,
106}
107
108#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
112#[derive(Debug, PartialEq, Default, Clone)]
113pub enum EncryptionSystem {
114 Unencrypted,
117 #[default]
119 PerParticipantKeys,
120 SharedSecret {
122 secret: String,
124 },
125}
126
127#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
131#[derive(Debug, PartialEq, Serialize, Default, Clone)]
132#[serde(rename_all = "snake_case")]
133pub enum Intent {
134 #[default]
135 StartCall,
137 JoinExisting,
139 JoinExistingDm,
142 StartCallDm,
144 StartCallDmVoice,
146 JoinExistingDmVoice,
149}
150
151#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
153#[derive(Debug, PartialEq, Serialize, Default, Clone)]
154#[serde(rename_all = "snake_case")]
155pub enum HeaderStyle {
156 #[default]
158 Standard,
159 AppBar,
161 None,
163}
164
165#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
167#[derive(Debug, PartialEq, Serialize, Clone, Default)]
168#[serde(rename_all = "snake_case")]
169pub enum NotificationType {
170 #[default]
172 Notification,
173 Ring,
175}
176
177#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
188#[derive(Debug, Default, Clone)]
189pub struct VirtualElementCallWidgetConfig {
190 pub intent: Option<Intent>,
193
194 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
196 pub skip_lobby: Option<bool>,
197
198 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
203 pub header: Option<HeaderStyle>,
204
205 #[deprecated(note = "Use `header` instead", since = "0.12.1")]
209 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
210 pub hide_header: Option<bool>,
211
212 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
217 pub preload: Option<bool>,
218
219 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
224 pub app_prompt: Option<bool>,
225
226 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
230 pub confine_to_room: Option<bool>,
231
232 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
234 pub hide_screensharing: Option<bool>,
235
236 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
239 pub controlled_audio_devices: Option<bool>,
240
241 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
244 pub send_notification_type: Option<NotificationType>,
245}
246
247#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
253#[derive(Debug, Default, Clone)]
254pub struct VirtualElementCallWidgetProperties {
255 pub element_call_url: String,
259
260 pub widget_id: String,
262
263 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
277 pub parent_url: Option<String>,
278
279 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
283 pub font_scale: Option<f64>,
284
285 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
287 pub font: Option<String>,
288
289 pub encryption: EncryptionSystem,
293
294 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
296 pub posthog_user_id: Option<String>,
297 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
300 pub posthog_api_host: Option<String>,
301 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
304 pub posthog_api_key: Option<String>,
305
306 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
309 pub rageshake_submit_url: Option<String>,
310
311 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
314 pub sentry_dsn: Option<String>,
315
316 #[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(
337 props: VirtualElementCallWidgetProperties,
338 config: VirtualElementCallWidgetConfig,
339 ) -> Result<Self, url::ParseError> {
340 let mut raw_url: Url = Url::parse(&props.element_call_url)?;
341
342 #[allow(deprecated)]
343 let query_params = ElementCallUrlParams {
344 user_id: url_params::USER_ID.to_owned(),
345 room_id: url_params::ROOM_ID.to_owned(),
346 widget_id: url_params::WIDGET_ID.to_owned(),
347 display_name: url_params::DISPLAY_NAME.to_owned(),
348 lang: url_params::LANGUAGE.to_owned(),
349 theme: url_params::CLIENT_THEME.to_owned(),
350 client_id: url_params::CLIENT_ID.to_owned(),
351 device_id: url_params::DEVICE_ID.to_owned(),
352 base_url: url_params::HOMESERVER_URL.to_owned(),
353
354 parent_url: props.parent_url.unwrap_or(props.element_call_url.clone()),
355 confine_to_room: config.confine_to_room,
356 app_prompt: config.app_prompt,
357 header: config.header,
358 hide_header: config.hide_header,
359 preload: config.preload,
360 font_scale: props.font_scale,
361 font: props.font,
362 per_participant_e2ee: Some(props.encryption == EncryptionSystem::PerParticipantKeys),
363 password: match props.encryption {
364 EncryptionSystem::SharedSecret { secret } => Some(secret),
365 _ => None,
366 },
367 intent: config.intent,
368 skip_lobby: config.skip_lobby,
369 analytics_id: props.posthog_user_id.clone(),
370 posthog_user_id: props.posthog_user_id,
371 posthog_api_host: props.posthog_api_host,
372 posthog_api_key: props.posthog_api_key,
373 sentry_dsn: props.sentry_dsn,
374 sentry_environment: props.sentry_environment,
375 rageshake_submit_url: props.rageshake_submit_url,
376 hide_screensharing: config.hide_screensharing,
377 controlled_audio_devices: config.controlled_audio_devices,
378 send_notification_type: config.send_notification_type,
379 };
380
381 let query =
382 serde_html_form::to_string(query_params).map_err(|_| url::ParseError::Overflow)?;
383
384 let query = query.replace("%24", "$");
387
388 raw_url.set_fragment(Some(&format!("?{query}")));
391
392 Ok(Self { widget_id: props.widget_id, init_on_content_load: true, raw_url })
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use std::collections::BTreeSet;
400
401 use ruma::api::client::profile::get_profile;
402 use url::Url;
403
404 use crate::widget::{
405 ClientProperties, Intent, WidgetSettings,
406 settings::element_call::{HeaderStyle, VirtualElementCallWidgetConfig},
407 };
408
409 const WIDGET_ID: &str = "1/@#w23";
410
411 fn get_element_call_widget_settings(
412 encryption: Option<EncryptionSystem>,
413 posthog: bool,
414 rageshake: bool,
415 sentry: bool,
416 intent: Option<Intent>,
417 controlled_output: bool,
418 ) -> WidgetSettings {
419 let props = VirtualElementCallWidgetProperties {
420 element_call_url: "https://call.element.io".to_owned(),
421 widget_id: WIDGET_ID.to_owned(),
422 posthog_user_id: posthog.then(|| "POSTHOG_USER_ID".to_owned()),
423 posthog_api_host: posthog.then(|| "posthog.element.io".to_owned()),
424 posthog_api_key: posthog.then(|| "POSTHOG_KEY".to_owned()),
425 rageshake_submit_url: rageshake.then(|| "https://rageshake.element.io".to_owned()),
426 sentry_dsn: sentry.then(|| "SENTRY_DSN".to_owned()),
427 sentry_environment: sentry.then(|| "SENTRY_ENV".to_owned()),
428 encryption: encryption.unwrap_or(EncryptionSystem::PerParticipantKeys),
429 ..VirtualElementCallWidgetProperties::default()
430 };
431
432 let config = VirtualElementCallWidgetConfig {
433 controlled_audio_devices: Some(controlled_output),
434 preload: Some(true),
435 app_prompt: Some(true),
436 confine_to_room: Some(true),
437 hide_screensharing: Some(false),
438 header: Some(HeaderStyle::Standard),
439 intent,
440 ..VirtualElementCallWidgetConfig::default()
441 };
442
443 WidgetSettings::new_virtual_element_call_widget(props, config)
444 .expect("could not parse virtual element call widget")
445 }
446
447 trait FragmentQuery {
448 fn fragment_query(&self) -> Option<&str>;
449 }
450
451 impl FragmentQuery for Url {
452 fn fragment_query(&self) -> Option<&str> {
453 Some(self.fragment()?.split_once('?')?.1)
454 }
455 }
456
457 type QuerySet = BTreeSet<(String, String)>;
460
461 use serde_html_form::from_str;
462
463 use super::{EncryptionSystem, VirtualElementCallWidgetProperties};
464
465 fn get_query_sets(url: &Url) -> Option<(QuerySet, QuerySet)> {
466 let fq = from_str::<QuerySet>(url.fragment_query().unwrap_or_default()).ok()?;
467 let q = from_str::<QuerySet>(url.query().unwrap_or_default()).ok()?;
468 Some((q, fq))
469 }
470
471 #[test]
472 fn test_new_virtual_element_call_widget_base_url() {
473 let widget_settings =
474 get_element_call_widget_settings(None, false, false, false, None, false);
475 assert_eq!(widget_settings.base_url().unwrap().as_str(), "https://call.element.io/");
476 }
477
478 #[test]
479 fn test_new_virtual_element_call_widget_raw_url() {
480 const CONVERTED_URL: &str = "
481 https://call.element.io#\
482 ?userId=$matrix_user_id\
483 &roomId=$matrix_room_id\
484 &widgetId=$matrix_widget_id\
485 &displayName=$matrix_display_name\
486 &lang=$org.matrix.msc2873.client_language\
487 &theme=$org.matrix.msc2873.client_theme\
488 &clientId=$org.matrix.msc2873.client_id\
489 &deviceId=$org.matrix.msc2873.matrix_device_id\
490 &baseUrl=$org.matrix.msc4039.matrix_base_url\
491 &parentUrl=https%3A%2F%2Fcall.element.io\
492 &confineToRoom=true\
493 &appPrompt=true\
494 &header=standard\
495 &preload=true\
496 &perParticipantE2EE=true\
497 &hideScreensharing=false\
498 &controlledAudioDevices=false\
499 ";
500
501 let mut generated_url =
502 get_element_call_widget_settings(None, false, false, false, None, false)
503 .raw_url()
504 .clone();
505 let mut expected_url = Url::parse(CONVERTED_URL).unwrap();
506 assert_eq!(get_query_sets(&generated_url).unwrap(), get_query_sets(&expected_url).unwrap());
507 generated_url.set_fragment(None);
508 generated_url.set_query(None);
509 expected_url.set_fragment(None);
510 expected_url.set_query(None);
511 assert_eq!(generated_url, expected_url);
512 }
513
514 #[test]
515 fn test_new_virtual_element_call_widget_id() {
516 assert_eq!(
517 get_element_call_widget_settings(None, false, false, false, None, false).widget_id(),
518 WIDGET_ID
519 );
520 }
521
522 fn build_url_from_widget_settings(settings: WidgetSettings) -> String {
523 let mut profile = get_profile::v3::Response::new();
524 profile.set("avatar_url".to_owned(), "some-url".into());
525 profile.set("displayname".to_owned(), "hello".into());
526
527 settings
528 ._generate_webview_url(
529 profile,
530 "@test:user.org".try_into().unwrap(),
531 "!room_id:room.org".try_into().unwrap(),
532 "ABCDEFG".into(),
533 "https://client-matrix.server.org".try_into().unwrap(),
534 ClientProperties::new(
535 "io.my_matrix.client",
536 Some(language_tags::LanguageTag::parse("en-us").unwrap()),
537 Some("light".into()),
538 ),
539 )
540 .unwrap()
541 .to_string()
542 }
543
544 #[test]
545 fn test_new_virtual_element_call_widget_webview_url() {
546 const CONVERTED_URL: &str = "
547 https://call.element.io#\
548 ?parentUrl=https%3A%2F%2Fcall.element.io\
549 &widgetId=1/@#w23\
550 &userId=%40test%3Auser.org&deviceId=ABCDEFG\
551 &roomId=%21room_id%3Aroom.org\
552 &lang=en-US&theme=light\
553 &baseUrl=https%3A%2F%2Fclient-matrix.server.org%2F\
554 &header=standard\
555 &preload=true\
556 &confineToRoom=true\
557 &displayName=hello\
558 &appPrompt=true\
559 &clientId=io.my_matrix.client\
560 &perParticipantE2EE=true\
561 &hideScreensharing=false\
562 &controlledAudioDevices=false\
563 ";
564 let mut generated_url = Url::parse(&build_url_from_widget_settings(
565 get_element_call_widget_settings(None, false, false, false, None, false),
566 ))
567 .unwrap();
568 let mut expected_url = Url::parse(CONVERTED_URL).unwrap();
569 assert_eq!(get_query_sets(&generated_url).unwrap(), get_query_sets(&expected_url).unwrap());
570 generated_url.set_fragment(None);
571 generated_url.set_query(None);
572 expected_url.set_fragment(None);
573 expected_url.set_query(None);
574 assert_eq!(generated_url, expected_url);
575 }
576
577 #[test]
578 fn test_new_virtual_element_call_widget_webview_url_with_posthog_rageshake_sentry() {
579 const CONVERTED_URL: &str = "
580 https://call.element.io#\
581 ?parentUrl=https%3A%2F%2Fcall.element.io\
582 &widgetId=1/@#w23\
583 &userId=%40test%3Auser.org&deviceId=ABCDEFG\
584 &roomId=%21room_id%3Aroom.org\
585 &lang=en-US&theme=light\
586 &baseUrl=https%3A%2F%2Fclient-matrix.server.org%2F\
587 &header=standard\
588 &preload=true\
589 &confineToRoom=true\
590 &displayName=hello\
591 &appPrompt=true\
592 &clientId=io.my_matrix.client\
593 &perParticipantE2EE=true\
594 &hideScreensharing=false\
595 &posthogApiHost=posthog.element.io\
596 &posthogApiKey=POSTHOG_KEY\
597 &analyticsId=POSTHOG_USER_ID\
598 &posthogUserId=POSTHOG_USER_ID\
599 &rageshakeSubmitUrl=https%3A%2F%2Frageshake.element.io\
600 &sentryDsn=SENTRY_DSN\
601 &sentryEnvironment=SENTRY_ENV\
602 &controlledAudioDevices=false\
603 ";
604 let mut generated_url = Url::parse(&build_url_from_widget_settings(
605 get_element_call_widget_settings(None, true, true, true, None, false),
606 ))
607 .unwrap();
608 let mut original_url = Url::parse(CONVERTED_URL).unwrap();
609 assert_eq!(get_query_sets(&generated_url).unwrap(), get_query_sets(&original_url).unwrap());
610 generated_url.set_fragment(None);
611 generated_url.set_query(None);
612 original_url.set_fragment(None);
613 original_url.set_query(None);
614 assert_eq!(generated_url, original_url);
615 }
616
617 #[test]
618 fn test_password_url_props_from_widget_settings() {
619 {
620 let url = build_url_from_widget_settings(get_element_call_widget_settings(
622 Some(EncryptionSystem::PerParticipantKeys),
623 false,
624 false,
625 false,
626 None,
627 false,
628 ));
629 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
630 let expected_elements = [("perParticipantE2EE".to_owned(), "true".to_owned())];
631 for e in expected_elements {
632 assert!(
633 query_set.contains(&e),
634 "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
635 );
636 }
637 }
638 {
639 let url = build_url_from_widget_settings(get_element_call_widget_settings(
641 Some(EncryptionSystem::Unencrypted),
642 false,
643 false,
644 false,
645 None,
646 false,
647 ));
648 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
649 let expected_elements = ("perParticipantE2EE".to_owned(), "false".to_owned());
650 assert!(
651 query_set.contains(&expected_elements),
652 "The url query elements for an unencrypted call: \n{query_set:?}\nDid not contain: \n{expected_elements:?}"
653 );
654 }
655 {
656 let url = build_url_from_widget_settings(get_element_call_widget_settings(
658 Some(EncryptionSystem::SharedSecret { secret: "this_surely_is_save".to_owned() }),
659 false,
660 false,
661 false,
662 None,
663 false,
664 ));
665 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
666 let expected_elements = [("password".to_owned(), "this_surely_is_save".to_owned())];
667 for e in expected_elements {
668 assert!(
669 query_set.contains(&e),
670 "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
671 );
672 }
673 }
674 }
675
676 #[test]
677 fn test_controlled_output_url_props_from_widget_settings() {
678 {
679 let url = build_url_from_widget_settings(get_element_call_widget_settings(
681 Some(EncryptionSystem::PerParticipantKeys),
682 false,
683 false,
684 false,
685 None,
686 true,
687 ));
688 let controlled_audio_element = ("controlledAudioDevices".to_owned(), "true".to_owned());
689 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
690 assert!(
691 query_set.contains(&controlled_audio_element),
692 "The query elements: \n{query_set:?}\nDid not contain: \n{controlled_audio_element:?}"
693 );
694 }
695 }
696
697 #[test]
698 fn test_intent_url_props_from_widget_settings() {
699 {
700 let url = build_url_from_widget_settings(get_element_call_widget_settings(
702 None, false, false, false, None, false,
703 ));
704 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
705
706 let expected_unset_elements = ["intent".to_owned(), "skipLobby".to_owned()];
707
708 for e in expected_unset_elements {
709 assert!(
710 !query_set.iter().any(|x| x.0 == e),
711 "The query elements: \n{query_set:?}\nShould not have contained: \n{e:?}"
712 );
713 }
714 }
715 {
716 let url = build_url_from_widget_settings(get_element_call_widget_settings(
718 None,
719 false,
720 false,
721 false,
722 Some(Intent::JoinExisting),
723 false,
724 ));
725 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
726 let expected_elements = ("intent".to_owned(), "join_existing".to_owned());
727 assert!(
728 query_set.contains(&expected_elements),
729 "The url query elements for an unencrypted call: \n{query_set:?}\nDid not contain: \n{expected_elements:?}"
730 );
731
732 let expected_unset_elements = ["skipLobby".to_owned()];
733
734 for e in expected_unset_elements {
735 assert!(
736 !query_set.iter().any(|x| x.0 == e),
737 "The query elements: \n{query_set:?}\nShould not have contained: \n{e:?}"
738 );
739 }
740 }
741 {
742 let url = build_url_from_widget_settings(get_element_call_widget_settings(
744 None,
745 false,
746 false,
747 false,
748 Some(Intent::StartCall),
749 false,
750 ));
751 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
752
753 let expected_elements = [("intent".to_owned(), "start_call".to_owned())];
754 for e in expected_elements {
755 assert!(
756 query_set.contains(&e),
757 "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
758 );
759 }
760 }
761 {
762 let url = build_url_from_widget_settings(get_element_call_widget_settings(
764 None,
765 false,
766 false,
767 false,
768 Some(Intent::StartCallDm),
769 false,
770 ));
771 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
772
773 let expected_elements = [("intent".to_owned(), "start_call_dm".to_owned())];
774 for e in expected_elements {
775 assert!(
776 query_set.contains(&e),
777 "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
778 );
779 }
780 }
781 {
782 let url = build_url_from_widget_settings(get_element_call_widget_settings(
784 None,
785 false,
786 false,
787 false,
788 Some(Intent::JoinExistingDm),
789 false,
790 ));
791 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
792
793 let expected_elements = [("intent".to_owned(), "join_existing_dm".to_owned())];
794 for e in expected_elements {
795 assert!(
796 query_set.contains(&e),
797 "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
798 );
799 }
800 }
801 }
802
803 #[test]
804 fn test_call_intent_serialization() {
805 assert_eq!(serde_json::to_string(&Intent::StartCall).unwrap(), r#""start_call""#);
812 assert_eq!(serde_json::to_string(&Intent::JoinExisting).unwrap(), r#""join_existing""#);
813 assert_eq!(
814 serde_json::to_string(&Intent::JoinExistingDm).unwrap(),
815 r#""join_existing_dm""#
816 );
817 assert_eq!(serde_json::to_string(&Intent::StartCallDm).unwrap(), r#""start_call_dm""#);
818 assert_eq!(
819 serde_json::to_string(&Intent::StartCallDmVoice).unwrap(),
820 r#""start_call_dm_voice""#
821 );
822 assert_eq!(
823 serde_json::to_string(&Intent::JoinExistingDmVoice).unwrap(),
824 r#""join_existing_dm_voice""#
825 );
826 }
827}