1use serde::Serialize;
23use url::Url;
24
25use super::{url_params, WidgetSettings};
26
27#[derive(Serialize)]
28#[serde(rename_all = "camelCase")]
29struct 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 parent_url: String,
43 skip_lobby: Option<bool>,
46 confine_to_room: bool,
47 app_prompt: bool,
48 header: HeaderStyle,
50 hide_header: Option<bool>,
53 preload: bool,
54 analytics_id: Option<String>,
57 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 intent: Option<Intent>,
66 posthog_api_host: Option<String>,
68 posthog_api_key: Option<String>,
70 rageshake_submit_url: Option<String>,
72 sentry_dsn: Option<String>,
74 sentry_environment: Option<String>,
76 hide_screensharing: bool,
77 controlled_media_devices: bool,
78 send_notification_type: Option<NotificationType>,
80}
81
82#[derive(Debug, PartialEq, Default, uniffi::Enum, Clone)]
86pub enum EncryptionSystem {
87 Unencrypted,
90 #[default]
93 PerParticipantKeys,
94 SharedSecret {
97 secret: String,
99 },
100}
101
102#[derive(Debug, PartialEq, Serialize, Default, uniffi::Enum, Clone)]
106#[serde(rename_all = "snake_case")]
107pub enum Intent {
108 #[default]
109 StartCall,
111 JoinExisting,
113}
114
115#[derive(Debug, PartialEq, Serialize, Default, uniffi::Enum, Clone)]
117#[serde(rename_all = "snake_case")]
118pub enum HeaderStyle {
119 #[default]
121 Standard,
122 AppBar,
124 None,
126}
127
128#[derive(Debug, PartialEq, Serialize, uniffi::Enum, Clone)]
130#[serde(rename_all = "snake_case")]
131pub enum NotificationType {
132 Notification,
134 Ring,
136}
137
138#[derive(Debug, Default, uniffi::Record, Clone)]
140pub struct VirtualElementCallWidgetOptions {
141 pub element_call_url: String,
145
146 pub widget_id: String,
148
149 pub parent_url: Option<String>,
163
164 pub header: Option<HeaderStyle>,
169
170 #[deprecated(note = "Use `header` instead", since = "0.12.1")]
174 pub hide_header: Option<bool>,
175
176 pub preload: Option<bool>,
181
182 pub font_scale: Option<f64>,
186
187 pub app_prompt: Option<bool>,
192
193 pub confine_to_room: Option<bool>,
197
198 pub font: Option<String>,
200
201 pub encryption: EncryptionSystem,
205
206 pub intent: Option<Intent>,
210
211 pub hide_screensharing: bool,
213
214 pub posthog_user_id: Option<String>,
216 pub posthog_api_host: Option<String>,
219 pub posthog_api_key: Option<String>,
222
223 pub rageshake_submit_url: Option<String>,
226
227 pub sentry_dsn: Option<String>,
230 pub sentry_environment: Option<String>,
233 pub controlled_media_devices: bool,
238 pub send_notification_type: Option<NotificationType>,
241}
242
243impl WidgetSettings {
244 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 let query = query.replace("%24", "$");
312
313 raw_url.set_fragment(Some(&format!("?{query}")));
316
317 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 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 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 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 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 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 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 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 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 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}