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 hide_header: bool,
49 preload: bool,
50 analytics_id: Option<String>,
53 posthog_user_id: Option<String>,
55 font_scale: Option<f64>,
56 font: Option<String>,
57 #[serde(rename = "perParticipantE2EE")]
58 per_participant_e2ee: bool,
59 password: Option<String>,
60 intent: Option<Intent>,
62 posthog_api_host: Option<String>,
64 posthog_api_key: Option<String>,
66 rageshake_submit_url: Option<String>,
68 sentry_dsn: Option<String>,
70 sentry_environment: Option<String>,
72 hide_screensharing: bool,
73}
74
75#[derive(Debug, PartialEq, Default)]
79pub enum EncryptionSystem {
80 Unencrypted,
83 #[default]
86 PerParticipantKeys,
87 SharedSecret {
90 secret: String,
92 },
93}
94
95#[derive(Debug, PartialEq, Serialize, Default)]
99#[serde(rename_all = "snake_case")]
100pub enum Intent {
101 #[default]
102 StartCall,
104 JoinExisting,
106}
107
108#[derive(Debug, Default)]
110pub struct VirtualElementCallWidgetOptions {
111 pub element_call_url: String,
115
116 pub widget_id: String,
118
119 pub parent_url: Option<String>,
133
134 pub hide_header: Option<bool>,
138
139 pub preload: Option<bool>,
144
145 pub font_scale: Option<f64>,
149
150 pub app_prompt: Option<bool>,
155
156 pub confine_to_room: Option<bool>,
160
161 pub font: Option<String>,
163
164 pub encryption: EncryptionSystem,
168
169 pub intent: Option<Intent>,
173
174 pub hide_screensharing: bool,
176
177 pub posthog_user_id: Option<String>,
179 pub posthog_api_host: Option<String>,
182 pub posthog_api_key: Option<String>,
185
186 pub rageshake_submit_url: Option<String>,
189
190 pub sentry_dsn: Option<String>,
193 pub sentry_environment: Option<String>,
196}
197
198impl WidgetSettings {
199 pub fn new_virtual_element_call_widget(
213 props: VirtualElementCallWidgetOptions,
214 ) -> Result<Self, url::ParseError> {
215 let mut raw_url: Url = Url::parse(&props.element_call_url)?;
216
217 let skip_lobby = if props.intent.as_ref().is_some_and(|x| x == &Intent::StartCall) {
218 Some(true)
219 } else {
220 None
221 };
222
223 let query_params = ElementCallParams {
224 user_id: url_params::USER_ID.to_owned(),
225 room_id: url_params::ROOM_ID.to_owned(),
226 widget_id: url_params::WIDGET_ID.to_owned(),
227 display_name: url_params::DISPLAY_NAME.to_owned(),
228 lang: url_params::LANGUAGE.to_owned(),
229 theme: url_params::CLIENT_THEME.to_owned(),
230 client_id: url_params::CLIENT_ID.to_owned(),
231 device_id: url_params::DEVICE_ID.to_owned(),
232 base_url: url_params::HOMESERVER_URL.to_owned(),
233
234 parent_url: props.parent_url.unwrap_or(props.element_call_url.clone()),
235 confine_to_room: props.confine_to_room.unwrap_or(true),
236 app_prompt: props.app_prompt.unwrap_or(false),
237 hide_header: props.hide_header.unwrap_or(true),
238 preload: props.preload.unwrap_or(false),
239 font_scale: props.font_scale,
240 font: props.font,
241 per_participant_e2ee: props.encryption == EncryptionSystem::PerParticipantKeys,
242 password: match props.encryption {
243 EncryptionSystem::SharedSecret { secret } => Some(secret),
244 _ => None,
245 },
246 intent: props.intent,
247 skip_lobby,
248 analytics_id: props.posthog_user_id.clone(),
249 posthog_user_id: props.posthog_user_id,
250 posthog_api_host: props.posthog_api_host,
251 posthog_api_key: props.posthog_api_key,
252 sentry_dsn: props.sentry_dsn,
253 sentry_environment: props.sentry_environment,
254 rageshake_submit_url: props.rageshake_submit_url,
255 hide_screensharing: props.hide_screensharing,
256 };
257
258 let query =
259 serde_html_form::to_string(query_params).map_err(|_| url::ParseError::Overflow)?;
260
261 let query = query.replace("%24", "$");
264
265 raw_url.set_fragment(Some(&format!("?{}", query)));
268
269 Ok(Self { widget_id: props.widget_id, init_on_content_load: true, raw_url })
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use std::collections::BTreeSet;
277
278 use ruma::api::client::profile::get_profile;
279 use url::Url;
280
281 use crate::widget::{ClientProperties, Intent, WidgetSettings};
282
283 const WIDGET_ID: &str = "1/@#w23";
284
285 fn get_widget_settings(
286 encryption: Option<EncryptionSystem>,
287 posthog: bool,
288 rageshake: bool,
289 sentry: bool,
290 intent: Option<Intent>,
291 ) -> WidgetSettings {
292 let mut props = VirtualElementCallWidgetOptions {
293 element_call_url: "https://call.element.io".to_owned(),
294 widget_id: WIDGET_ID.to_owned(),
295 hide_header: Some(true),
296 preload: Some(true),
297 app_prompt: Some(true),
298 confine_to_room: Some(true),
299 encryption: encryption.unwrap_or(EncryptionSystem::PerParticipantKeys),
300 intent,
301 ..VirtualElementCallWidgetOptions::default()
302 };
303
304 if posthog {
305 props.posthog_user_id = Some("POSTHOG_USER_ID".to_owned());
306 props.posthog_api_host = Some("posthog.element.io".to_owned());
307 props.posthog_api_key = Some("POSTHOG_KEY".to_owned());
308 }
309
310 if rageshake {
311 props.rageshake_submit_url = Some("https://rageshake.element.io".to_owned());
312 }
313
314 if sentry {
315 props.sentry_dsn = Some("SENTRY_DSN".to_owned());
316 props.sentry_environment = Some("SENTRY_ENV".to_owned());
317 }
318
319 WidgetSettings::new_virtual_element_call_widget(props)
320 .expect("could not parse virtual element call widget")
321 }
322
323 trait FragmentQuery {
324 fn fragment_query(&self) -> Option<&str>;
325 }
326
327 impl FragmentQuery for Url {
328 fn fragment_query(&self) -> Option<&str> {
329 Some(self.fragment()?.split_once('?')?.1)
330 }
331 }
332
333 type QuerySet = BTreeSet<(String, String)>;
336
337 use serde_html_form::from_str;
338
339 use super::{EncryptionSystem, VirtualElementCallWidgetOptions};
340
341 fn get_query_sets(url: &Url) -> Option<(QuerySet, QuerySet)> {
342 let fq = from_str::<QuerySet>(url.fragment_query().unwrap_or_default()).ok()?;
343 let q = from_str::<QuerySet>(url.query().unwrap_or_default()).ok()?;
344 Some((q, fq))
345 }
346
347 #[test]
348 fn new_virtual_element_call_widget_base_url() {
349 let widget_settings = get_widget_settings(None, false, false, false, None);
350 assert_eq!(widget_settings.base_url().unwrap().as_str(), "https://call.element.io/");
351 }
352
353 #[test]
354 fn new_virtual_element_call_widget_raw_url() {
355 const CONVERTED_URL: &str = "
356 https://call.element.io#\
357 ?userId=$matrix_user_id\
358 &roomId=$matrix_room_id\
359 &widgetId=$matrix_widget_id\
360 &displayName=$matrix_display_name\
361 &lang=$org.matrix.msc2873.client_language\
362 &theme=$org.matrix.msc2873.client_theme\
363 &clientId=$org.matrix.msc2873.client_id\
364 &deviceId=$org.matrix.msc2873.matrix_device_id\
365 &baseUrl=$org.matrix.msc4039.matrix_base_url\
366 &parentUrl=https%3A%2F%2Fcall.element.io\
367 &confineToRoom=true\
368 &appPrompt=true\
369 &hideHeader=true\
370 &preload=true\
371 &perParticipantE2EE=true\
372 &hideScreensharing=false\
373 ";
374
375 let mut url = get_widget_settings(None, false, false, false, None).raw_url().clone();
376 let mut gen = Url::parse(CONVERTED_URL).unwrap();
377 assert_eq!(get_query_sets(&url).unwrap(), get_query_sets(&gen).unwrap());
378 url.set_fragment(None);
379 url.set_query(None);
380 gen.set_fragment(None);
381 gen.set_query(None);
382 assert_eq!(url, gen);
383 }
384
385 #[test]
386 fn new_virtual_element_call_widget_id() {
387 assert_eq!(get_widget_settings(None, false, false, false, None).widget_id(), WIDGET_ID);
388 }
389
390 fn build_url_from_widget_settings(settings: WidgetSettings) -> String {
391 settings
392 ._generate_webview_url(
393 get_profile::v3::Response::new(Some("some-url".into()), Some("hello".into())),
394 "@test:user.org".try_into().unwrap(),
395 "!room_id:room.org".try_into().unwrap(),
396 "ABCDEFG".into(),
397 "https://client-matrix.server.org".try_into().unwrap(),
398 ClientProperties::new(
399 "io.my_matrix.client",
400 Some(language_tags::LanguageTag::parse("en-us").unwrap()),
401 Some("light".into()),
402 ),
403 )
404 .unwrap()
405 .to_string()
406 }
407
408 #[test]
409 fn new_virtual_element_call_widget_webview_url() {
410 const CONVERTED_URL: &str = "
411 https://call.element.io#\
412 ?parentUrl=https%3A%2F%2Fcall.element.io\
413 &widgetId=1/@#w23\
414 &userId=%40test%3Auser.org&deviceId=ABCDEFG\
415 &roomId=%21room_id%3Aroom.org\
416 &lang=en-US&theme=light\
417 &baseUrl=https%3A%2F%2Fclient-matrix.server.org%2F\
418 &hideHeader=true\
419 &preload=true\
420 &confineToRoom=true\
421 &displayName=hello\
422 &appPrompt=true\
423 &clientId=io.my_matrix.client\
424 &perParticipantE2EE=true\
425 &hideScreensharing=false\
426 ";
427 let gen =
428 build_url_from_widget_settings(get_widget_settings(None, false, false, false, None));
429
430 let mut url = Url::parse(&gen).unwrap();
431 let mut gen = Url::parse(CONVERTED_URL).unwrap();
432 assert_eq!(get_query_sets(&url).unwrap(), get_query_sets(&gen).unwrap());
433 url.set_fragment(None);
434 url.set_query(None);
435 gen.set_fragment(None);
436 gen.set_query(None);
437 assert_eq!(url, gen);
438 }
439
440 #[test]
441 fn new_virtual_element_call_widget_webview_url_with_posthog_rageshake_sentry() {
442 const CONVERTED_URL: &str = "
443 https://call.element.io#\
444 ?parentUrl=https%3A%2F%2Fcall.element.io\
445 &widgetId=1/@#w23\
446 &userId=%40test%3Auser.org&deviceId=ABCDEFG\
447 &roomId=%21room_id%3Aroom.org\
448 &lang=en-US&theme=light\
449 &baseUrl=https%3A%2F%2Fclient-matrix.server.org%2F\
450 &hideHeader=true\
451 &preload=true\
452 &confineToRoom=true\
453 &displayName=hello\
454 &appPrompt=true\
455 &clientId=io.my_matrix.client\
456 &perParticipantE2EE=true\
457 &hideScreensharing=false\
458 &posthogApiHost=posthog.element.io\
459 &posthogApiKey=POSTHOG_KEY\
460 &analyticsId=POSTHOG_USER_ID\
461 &posthogUserId=POSTHOG_USER_ID\
462 &rageshakeSubmitUrl=https%3A%2F%2Frageshake.element.io\
463 &sentryDsn=SENTRY_DSN\
464 &sentryEnvironment=SENTRY_ENV\
465 ";
466 let gen = build_url_from_widget_settings(get_widget_settings(None, true, true, true, None));
467
468 let mut url = Url::parse(&gen).unwrap();
469 let mut gen = Url::parse(CONVERTED_URL).unwrap();
470 assert_eq!(get_query_sets(&url).unwrap(), get_query_sets(&gen).unwrap());
471 url.set_fragment(None);
472 url.set_query(None);
473 gen.set_fragment(None);
474 gen.set_query(None);
475 assert_eq!(url, gen);
476 }
477
478 #[test]
479 fn password_url_props_from_widget_settings() {
480 {
481 let url = build_url_from_widget_settings(get_widget_settings(
483 Some(EncryptionSystem::PerParticipantKeys),
484 false,
485 false,
486 false,
487 None,
488 ));
489 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
490 let expected_elements = [("perParticipantE2EE".to_owned(), "true".to_owned())];
491 for e in expected_elements {
492 assert!(
493 query_set.contains(&e),
494 "The query elements: \n{:?}\nDid not contain: \n{:?}",
495 query_set,
496 e
497 );
498 }
499 }
500 {
501 let url = build_url_from_widget_settings(get_widget_settings(
503 Some(EncryptionSystem::Unencrypted),
504 false,
505 false,
506 false,
507 None,
508 ));
509 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
510 let expected_elements = ("perParticipantE2EE".to_owned(), "false".to_owned());
511 assert!(
512 query_set.contains(&expected_elements),
513 "The url query elements for an unencrypted call: \n{:?}\nDid not contain: \n{:?}",
514 query_set,
515 expected_elements
516 );
517 }
518 {
519 let url = build_url_from_widget_settings(get_widget_settings(
521 Some(EncryptionSystem::SharedSecret { secret: "this_surely_is_save".to_owned() }),
522 false,
523 false,
524 false,
525 None,
526 ));
527 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
528 let expected_elements = [("password".to_owned(), "this_surely_is_save".to_owned())];
529 for e in expected_elements {
530 assert!(
531 query_set.contains(&e),
532 "The query elements: \n{:?}\nDid not contain: \n{:?}",
533 query_set,
534 e
535 );
536 }
537 }
538 }
539
540 #[test]
541 fn intent_url_props_from_widget_settings() {
542 {
543 let url = build_url_from_widget_settings(get_widget_settings(
545 None, false, false, false, None,
546 ));
547 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
548
549 let expected_unset_elements = ["intent".to_owned(), "skipLobby".to_owned()];
550
551 for e in expected_unset_elements {
552 assert!(
553 !query_set.iter().any(|x| x.0 == e),
554 "The query elements: \n{:?}\nShould not have contained: \n{:?}",
555 query_set,
556 e
557 );
558 }
559 }
560 {
561 let url = build_url_from_widget_settings(get_widget_settings(
563 None,
564 false,
565 false,
566 false,
567 Some(Intent::JoinExisting),
568 ));
569 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
570 let expected_elements = ("intent".to_owned(), "join_existing".to_owned());
571 assert!(
572 query_set.contains(&expected_elements),
573 "The url query elements for an unencrypted call: \n{:?}\nDid not contain: \n{:?}",
574 query_set,
575 expected_elements
576 );
577
578 let expected_unset_elements = ["skipLobby".to_owned()];
579
580 for e in expected_unset_elements {
581 assert!(
582 !query_set.iter().any(|x| x.0 == e),
583 "The query elements: \n{:?}\nShould not have contained: \n{:?}",
584 query_set,
585 e
586 );
587 }
588 }
589 {
590 let url = build_url_from_widget_settings(get_widget_settings(
592 None,
593 false,
594 false,
595 false,
596 Some(Intent::StartCall),
597 ));
598 let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
599
600 let expected_elements = [
602 ("intent".to_owned(), "start_call".to_owned()),
603 ("skipLobby".to_owned(), "true".to_owned()),
604 ];
605 for e in expected_elements {
606 assert!(
607 query_set.contains(&e),
608 "The query elements: \n{:?}\nDid not contain: \n{:?}",
609 query_set,
610 e
611 );
612 }
613 }
614 }
615}