use serde::Serialize;
use url::Url;
use super::{WidgetSettings, url_params};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ElementCallUrlParams {
user_id: String,
room_id: String,
widget_id: String,
display_name: String,
lang: String,
theme: String,
client_id: String,
device_id: String,
base_url: String,
parent_url: String,
skip_lobby: Option<bool>,
confine_to_room: Option<bool>,
app_prompt: Option<bool>,
header: Option<HeaderStyle>,
hide_header: Option<bool>,
preload: Option<bool>,
analytics_id: Option<String>,
posthog_user_id: Option<String>,
font_scale: Option<f64>,
font: Option<String>,
#[serde(rename = "perParticipantE2EE")]
per_participant_e2ee: Option<bool>,
password: Option<String>,
intent: Option<Intent>,
posthog_api_host: Option<String>,
posthog_api_key: Option<String>,
rageshake_submit_url: Option<String>,
sentry_dsn: Option<String>,
sentry_environment: Option<String>,
hide_screensharing: Option<bool>,
controlled_audio_devices: Option<bool>,
send_notification_type: Option<NotificationType>,
}
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
#[derive(Debug, PartialEq, Default, Clone)]
pub enum EncryptionSystem {
Unencrypted,
#[default]
PerParticipantKeys,
SharedSecret {
secret: String,
},
}
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
#[derive(Debug, PartialEq, Serialize, Default, Clone)]
#[serde(rename_all = "snake_case")]
pub enum Intent {
#[default]
StartCall,
JoinExisting,
JoinExistingDm,
StartCallDm,
}
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
#[derive(Debug, PartialEq, Serialize, Default, Clone)]
#[serde(rename_all = "snake_case")]
pub enum HeaderStyle {
#[default]
Standard,
AppBar,
None,
}
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
#[derive(Debug, PartialEq, Serialize, Clone, Default)]
#[serde(rename_all = "snake_case")]
pub enum NotificationType {
#[default]
Notification,
Ring,
}
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
#[derive(Debug, Default, Clone)]
pub struct VirtualElementCallWidgetConfig {
pub intent: Option<Intent>,
#[uniffi(default = None)]
pub skip_lobby: Option<bool>,
#[uniffi(default = None)]
pub header: Option<HeaderStyle>,
#[deprecated(note = "Use `header` instead", since = "0.12.1")]
#[uniffi(default = None)]
pub hide_header: Option<bool>,
#[uniffi(default = None)]
pub preload: Option<bool>,
#[uniffi(default = None)]
pub app_prompt: Option<bool>,
#[uniffi(default = None)]
pub confine_to_room: Option<bool>,
#[uniffi(default = None)]
pub hide_screensharing: Option<bool>,
#[uniffi(default = None)]
pub controlled_audio_devices: Option<bool>,
#[uniffi(default = None)]
pub send_notification_type: Option<NotificationType>,
}
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
#[derive(Debug, Default, Clone)]
pub struct VirtualElementCallWidgetProperties {
pub element_call_url: String,
pub widget_id: String,
#[uniffi(default = None)]
pub parent_url: Option<String>,
#[uniffi(default = None)]
pub font_scale: Option<f64>,
#[uniffi(default = None)]
pub font: Option<String>,
pub encryption: EncryptionSystem,
#[uniffi(default = None)]
pub posthog_user_id: Option<String>,
#[uniffi(default = None)]
pub posthog_api_host: Option<String>,
#[uniffi(default = None)]
pub posthog_api_key: Option<String>,
#[uniffi(default = None)]
pub rageshake_submit_url: Option<String>,
#[uniffi(default = None)]
pub sentry_dsn: Option<String>,
#[uniffi(default = None)]
pub sentry_environment: Option<String>,
}
impl WidgetSettings {
pub fn new_virtual_element_call_widget(
props: VirtualElementCallWidgetProperties,
config: VirtualElementCallWidgetConfig,
) -> Result<Self, url::ParseError> {
let mut raw_url: Url = Url::parse(&props.element_call_url)?;
#[allow(deprecated)]
let query_params = ElementCallUrlParams {
user_id: url_params::USER_ID.to_owned(),
room_id: url_params::ROOM_ID.to_owned(),
widget_id: url_params::WIDGET_ID.to_owned(),
display_name: url_params::DISPLAY_NAME.to_owned(),
lang: url_params::LANGUAGE.to_owned(),
theme: url_params::CLIENT_THEME.to_owned(),
client_id: url_params::CLIENT_ID.to_owned(),
device_id: url_params::DEVICE_ID.to_owned(),
base_url: url_params::HOMESERVER_URL.to_owned(),
parent_url: props.parent_url.unwrap_or(props.element_call_url.clone()),
confine_to_room: config.confine_to_room,
app_prompt: config.app_prompt,
header: config.header,
hide_header: config.hide_header,
preload: config.preload,
font_scale: props.font_scale,
font: props.font,
per_participant_e2ee: Some(props.encryption == EncryptionSystem::PerParticipantKeys),
password: match props.encryption {
EncryptionSystem::SharedSecret { secret } => Some(secret),
_ => None,
},
intent: config.intent,
skip_lobby: config.skip_lobby,
analytics_id: props.posthog_user_id.clone(),
posthog_user_id: props.posthog_user_id,
posthog_api_host: props.posthog_api_host,
posthog_api_key: props.posthog_api_key,
sentry_dsn: props.sentry_dsn,
sentry_environment: props.sentry_environment,
rageshake_submit_url: props.rageshake_submit_url,
hide_screensharing: config.hide_screensharing,
controlled_audio_devices: config.controlled_audio_devices,
send_notification_type: config.send_notification_type,
};
let query =
serde_html_form::to_string(query_params).map_err(|_| url::ParseError::Overflow)?;
let query = query.replace("%24", "$");
raw_url.set_fragment(Some(&format!("?{query}")));
Ok(Self { widget_id: props.widget_id, init_on_content_load: true, raw_url })
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use ruma::api::client::profile::get_profile;
use url::Url;
use crate::widget::{
ClientProperties, Intent, WidgetSettings,
settings::element_call::{HeaderStyle, VirtualElementCallWidgetConfig},
};
const WIDGET_ID: &str = "1/@#w23";
fn get_element_call_widget_settings(
encryption: Option<EncryptionSystem>,
posthog: bool,
rageshake: bool,
sentry: bool,
intent: Option<Intent>,
controlled_output: bool,
) -> WidgetSettings {
let props = VirtualElementCallWidgetProperties {
element_call_url: "https://call.element.io".to_owned(),
widget_id: WIDGET_ID.to_owned(),
posthog_user_id: posthog.then(|| "POSTHOG_USER_ID".to_owned()),
posthog_api_host: posthog.then(|| "posthog.element.io".to_owned()),
posthog_api_key: posthog.then(|| "POSTHOG_KEY".to_owned()),
rageshake_submit_url: rageshake.then(|| "https://rageshake.element.io".to_owned()),
sentry_dsn: sentry.then(|| "SENTRY_DSN".to_owned()),
sentry_environment: sentry.then(|| "SENTRY_ENV".to_owned()),
encryption: encryption.unwrap_or(EncryptionSystem::PerParticipantKeys),
..VirtualElementCallWidgetProperties::default()
};
let config = VirtualElementCallWidgetConfig {
controlled_audio_devices: Some(controlled_output),
preload: Some(true),
app_prompt: Some(true),
confine_to_room: Some(true),
hide_screensharing: Some(false),
header: Some(HeaderStyle::Standard),
intent,
..VirtualElementCallWidgetConfig::default()
};
WidgetSettings::new_virtual_element_call_widget(props, config)
.expect("could not parse virtual element call widget")
}
trait FragmentQuery {
fn fragment_query(&self) -> Option<&str>;
}
impl FragmentQuery for Url {
fn fragment_query(&self) -> Option<&str> {
Some(self.fragment()?.split_once('?')?.1)
}
}
type QuerySet = BTreeSet<(String, String)>;
use serde_html_form::from_str;
use super::{EncryptionSystem, VirtualElementCallWidgetProperties};
fn get_query_sets(url: &Url) -> Option<(QuerySet, QuerySet)> {
let fq = from_str::<QuerySet>(url.fragment_query().unwrap_or_default()).ok()?;
let q = from_str::<QuerySet>(url.query().unwrap_or_default()).ok()?;
Some((q, fq))
}
#[test]
fn test_new_virtual_element_call_widget_base_url() {
let widget_settings =
get_element_call_widget_settings(None, false, false, false, None, false);
assert_eq!(widget_settings.base_url().unwrap().as_str(), "https://call.element.io/");
}
#[test]
fn test_new_virtual_element_call_widget_raw_url() {
const CONVERTED_URL: &str = "
https://call.element.io#\
?userId=$matrix_user_id\
&roomId=$matrix_room_id\
&widgetId=$matrix_widget_id\
&displayName=$matrix_display_name\
&lang=$org.matrix.msc2873.client_language\
&theme=$org.matrix.msc2873.client_theme\
&clientId=$org.matrix.msc2873.client_id\
&deviceId=$org.matrix.msc2873.matrix_device_id\
&baseUrl=$org.matrix.msc4039.matrix_base_url\
&parentUrl=https%3A%2F%2Fcall.element.io\
&confineToRoom=true\
&appPrompt=true\
&header=standard\
&preload=true\
&perParticipantE2EE=true\
&hideScreensharing=false\
&controlledAudioDevices=false\
";
let mut generated_url =
get_element_call_widget_settings(None, false, false, false, None, false)
.raw_url()
.clone();
let mut expected_url = Url::parse(CONVERTED_URL).unwrap();
assert_eq!(get_query_sets(&generated_url).unwrap(), get_query_sets(&expected_url).unwrap());
generated_url.set_fragment(None);
generated_url.set_query(None);
expected_url.set_fragment(None);
expected_url.set_query(None);
assert_eq!(generated_url, expected_url);
}
#[test]
fn test_new_virtual_element_call_widget_id() {
assert_eq!(
get_element_call_widget_settings(None, false, false, false, None, false).widget_id(),
WIDGET_ID
);
}
fn build_url_from_widget_settings(settings: WidgetSettings) -> String {
let mut profile = get_profile::v3::Response::new();
profile.set("avatar_url".to_owned(), "some-url".into());
profile.set("displayname".to_owned(), "hello".into());
settings
._generate_webview_url(
profile,
"@test:user.org".try_into().unwrap(),
"!room_id:room.org".try_into().unwrap(),
"ABCDEFG".into(),
"https://client-matrix.server.org".try_into().unwrap(),
ClientProperties::new(
"io.my_matrix.client",
Some(language_tags::LanguageTag::parse("en-us").unwrap()),
Some("light".into()),
),
)
.unwrap()
.to_string()
}
#[test]
fn test_new_virtual_element_call_widget_webview_url() {
const CONVERTED_URL: &str = "
https://call.element.io#\
?parentUrl=https%3A%2F%2Fcall.element.io\
&widgetId=1/@#w23\
&userId=%40test%3Auser.org&deviceId=ABCDEFG\
&roomId=%21room_id%3Aroom.org\
&lang=en-US&theme=light\
&baseUrl=https%3A%2F%2Fclient-matrix.server.org%2F\
&header=standard\
&preload=true\
&confineToRoom=true\
&displayName=hello\
&appPrompt=true\
&clientId=io.my_matrix.client\
&perParticipantE2EE=true\
&hideScreensharing=false\
&controlledAudioDevices=false\
";
let mut generated_url = Url::parse(&build_url_from_widget_settings(
get_element_call_widget_settings(None, false, false, false, None, false),
))
.unwrap();
let mut expected_url = Url::parse(CONVERTED_URL).unwrap();
assert_eq!(get_query_sets(&generated_url).unwrap(), get_query_sets(&expected_url).unwrap());
generated_url.set_fragment(None);
generated_url.set_query(None);
expected_url.set_fragment(None);
expected_url.set_query(None);
assert_eq!(generated_url, expected_url);
}
#[test]
fn test_new_virtual_element_call_widget_webview_url_with_posthog_rageshake_sentry() {
const CONVERTED_URL: &str = "
https://call.element.io#\
?parentUrl=https%3A%2F%2Fcall.element.io\
&widgetId=1/@#w23\
&userId=%40test%3Auser.org&deviceId=ABCDEFG\
&roomId=%21room_id%3Aroom.org\
&lang=en-US&theme=light\
&baseUrl=https%3A%2F%2Fclient-matrix.server.org%2F\
&header=standard\
&preload=true\
&confineToRoom=true\
&displayName=hello\
&appPrompt=true\
&clientId=io.my_matrix.client\
&perParticipantE2EE=true\
&hideScreensharing=false\
&posthogApiHost=posthog.element.io\
&posthogApiKey=POSTHOG_KEY\
&analyticsId=POSTHOG_USER_ID\
&posthogUserId=POSTHOG_USER_ID\
&rageshakeSubmitUrl=https%3A%2F%2Frageshake.element.io\
&sentryDsn=SENTRY_DSN\
&sentryEnvironment=SENTRY_ENV\
&controlledAudioDevices=false\
";
let mut generated_url = Url::parse(&build_url_from_widget_settings(
get_element_call_widget_settings(None, true, true, true, None, false),
))
.unwrap();
let mut original_url = Url::parse(CONVERTED_URL).unwrap();
assert_eq!(get_query_sets(&generated_url).unwrap(), get_query_sets(&original_url).unwrap());
generated_url.set_fragment(None);
generated_url.set_query(None);
original_url.set_fragment(None);
original_url.set_query(None);
assert_eq!(generated_url, original_url);
}
#[test]
fn test_password_url_props_from_widget_settings() {
{
let url = build_url_from_widget_settings(get_element_call_widget_settings(
Some(EncryptionSystem::PerParticipantKeys),
false,
false,
false,
None,
false,
));
let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
let expected_elements = [("perParticipantE2EE".to_owned(), "true".to_owned())];
for e in expected_elements {
assert!(
query_set.contains(&e),
"The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
);
}
}
{
let url = build_url_from_widget_settings(get_element_call_widget_settings(
Some(EncryptionSystem::Unencrypted),
false,
false,
false,
None,
false,
));
let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
let expected_elements = ("perParticipantE2EE".to_owned(), "false".to_owned());
assert!(
query_set.contains(&expected_elements),
"The url query elements for an unencrypted call: \n{query_set:?}\nDid not contain: \n{expected_elements:?}"
);
}
{
let url = build_url_from_widget_settings(get_element_call_widget_settings(
Some(EncryptionSystem::SharedSecret { secret: "this_surely_is_save".to_owned() }),
false,
false,
false,
None,
false,
));
let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
let expected_elements = [("password".to_owned(), "this_surely_is_save".to_owned())];
for e in expected_elements {
assert!(
query_set.contains(&e),
"The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
);
}
}
}
#[test]
fn test_controlled_output_url_props_from_widget_settings() {
{
let url = build_url_from_widget_settings(get_element_call_widget_settings(
Some(EncryptionSystem::PerParticipantKeys),
false,
false,
false,
None,
true,
));
let controlled_audio_element = ("controlledAudioDevices".to_owned(), "true".to_owned());
let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
assert!(
query_set.contains(&controlled_audio_element),
"The query elements: \n{query_set:?}\nDid not contain: \n{controlled_audio_element:?}"
);
}
}
#[test]
fn test_intent_url_props_from_widget_settings() {
{
let url = build_url_from_widget_settings(get_element_call_widget_settings(
None, false, false, false, None, false,
));
let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
let expected_unset_elements = ["intent".to_owned(), "skipLobby".to_owned()];
for e in expected_unset_elements {
assert!(
!query_set.iter().any(|x| x.0 == e),
"The query elements: \n{query_set:?}\nShould not have contained: \n{e:?}"
);
}
}
{
let url = build_url_from_widget_settings(get_element_call_widget_settings(
None,
false,
false,
false,
Some(Intent::JoinExisting),
false,
));
let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
let expected_elements = ("intent".to_owned(), "join_existing".to_owned());
assert!(
query_set.contains(&expected_elements),
"The url query elements for an unencrypted call: \n{query_set:?}\nDid not contain: \n{expected_elements:?}"
);
let expected_unset_elements = ["skipLobby".to_owned()];
for e in expected_unset_elements {
assert!(
!query_set.iter().any(|x| x.0 == e),
"The query elements: \n{query_set:?}\nShould not have contained: \n{e:?}"
);
}
}
{
let url = build_url_from_widget_settings(get_element_call_widget_settings(
None,
false,
false,
false,
Some(Intent::StartCall),
false,
));
let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
let expected_elements = [("intent".to_owned(), "start_call".to_owned())];
for e in expected_elements {
assert!(
query_set.contains(&e),
"The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
);
}
}
{
let url = build_url_from_widget_settings(get_element_call_widget_settings(
None,
false,
false,
false,
Some(Intent::StartCallDm),
false,
));
let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
let expected_elements = [("intent".to_owned(), "start_call_dm".to_owned())];
for e in expected_elements {
assert!(
query_set.contains(&e),
"The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
);
}
}
{
let url = build_url_from_widget_settings(get_element_call_widget_settings(
None,
false,
false,
false,
Some(Intent::JoinExistingDm),
false,
));
let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
let expected_elements = [("intent".to_owned(), "join_existing_dm".to_owned())];
for e in expected_elements {
assert!(
query_set.contains(&e),
"The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
);
}
}
}
}