mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(profile): ship profile timezone to everyone (#3034)
This commit is contained in:
@@ -10630,7 +10630,6 @@
|
||||
"push_relay": {"$ref": "#/components/schemas/PushRelayConfigResponse"},
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
|
||||
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaConfigResponse"},
|
||||
"profile_timezone": {"$ref": "#/components/schemas/ProfileTimezoneConfigResponse"},
|
||||
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
|
||||
"registration": {
|
||||
"type": "object",
|
||||
@@ -11054,7 +11053,6 @@
|
||||
"push_relay",
|
||||
"domain_migration",
|
||||
"altcha_captcha",
|
||||
"profile_timezone",
|
||||
"experiment_delivery",
|
||||
"registration",
|
||||
"self_hosted",
|
||||
@@ -11195,10 +11193,6 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/AltchaCaptchaConfigUpdateRequest"}]
|
||||
},
|
||||
"profile_timezone": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ProfileTimezoneConfigUpdateRequest"}]
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
|
||||
@@ -15281,30 +15275,6 @@
|
||||
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
|
||||
}
|
||||
},
|
||||
"ProfileTimezoneConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"AltchaCaptchaConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15461,51 +15431,6 @@
|
||||
"required": ["poll_interval_seconds", "poll_jitter_percent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProfileTimezoneConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {
|
||||
"default": "profile-timezone-v1",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[\\x20-\\x7e]+$"
|
||||
},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"default": false, "type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"included_guild_ids",
|
||||
"include_premium_users",
|
||||
"excluded_user_ids"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AltchaCaptchaConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -27,8 +27,6 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub altcha_captcha: AltchaCaptchaConfigResponse,
|
||||
#[serde(default)]
|
||||
pub profile_timezone: ProfileTimezoneConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
#[serde(default)]
|
||||
pub billing: InstanceBillingResponse,
|
||||
@@ -445,7 +443,6 @@ pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
|
||||
pub const ALTCHA_CAPTCHA_DEFAULT_SALT: &str = "altcha-captcha-v1";
|
||||
pub const ALTCHA_CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=100_000;
|
||||
pub const ALTCHA_CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=1_000_000;
|
||||
pub const PROFILE_TIMEZONE_DEFAULT_SALT: &str = "profile-timezone-v1";
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
@@ -573,52 +570,6 @@ pub struct AltchaCaptchaConfigUpdateRequest {
|
||||
pub max_counter: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ProfileTimezoneConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub included_guild_ids: Vec<String>,
|
||||
pub include_premium_users: bool,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for ProfileTimezoneConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: PROFILE_TIMEZONE_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ProfileTimezoneConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_guild_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_premium_users: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ExperimentDeliveryConfigResponse {
|
||||
@@ -739,8 +690,6 @@ pub struct InstanceConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub altcha_captcha: Option<AltchaCaptchaConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile_timezone: Option<ProfileTimezoneConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub billing: Option<InstanceBillingUpdateRequest>,
|
||||
@@ -1057,16 +1006,12 @@ mod tests {
|
||||
.expect("default domain migration config");
|
||||
let altcha_captcha = serde_json::from_value::<AltchaCaptchaConfigResponse>(json!({}))
|
||||
.expect("default altcha captcha config");
|
||||
let profile_timezone = serde_json::from_value::<ProfileTimezoneConfigResponse>(json!({}))
|
||||
.expect("default profile timezone config");
|
||||
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
|
||||
.expect("default delivery config");
|
||||
let domain_migration =
|
||||
serde_json::to_value(domain_migration).expect("serializable domain migration config");
|
||||
let altcha_captcha =
|
||||
serde_json::to_value(altcha_captcha).expect("serializable altcha captcha config");
|
||||
let profile_timezone =
|
||||
serde_json::to_value(profile_timezone).expect("serializable profile timezone config");
|
||||
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
|
||||
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
|
||||
serde_json::from_value(domain_migration.clone())
|
||||
@@ -1074,9 +1019,6 @@ mod tests {
|
||||
let generated_altcha_captcha: generated_types::AltchaCaptchaConfigResponse =
|
||||
serde_json::from_value(altcha_captcha.clone())
|
||||
.expect("generated altcha captcha config contract");
|
||||
let generated_profile_timezone: generated_types::ProfileTimezoneConfigResponse =
|
||||
serde_json::from_value(profile_timezone.clone())
|
||||
.expect("generated profile timezone config contract");
|
||||
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
|
||||
assert_eq!(
|
||||
@@ -1089,11 +1031,6 @@ mod tests {
|
||||
.expect("serializable generated altcha captcha config"),
|
||||
altcha_captcha
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_profile_timezone)
|
||||
.expect("serializable generated profile timezone config"),
|
||||
profile_timezone
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_delivery)
|
||||
.expect("serializable generated delivery config"),
|
||||
@@ -1102,7 +1039,6 @@ mod tests {
|
||||
for (name, value) in [
|
||||
("DomainMigrationConfigResponse", domain_migration),
|
||||
("AltchaCaptchaConfigResponse", altcha_captcha),
|
||||
("ProfileTimezoneConfigResponse", profile_timezone),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
] {
|
||||
for (field, value) in value.as_object().expect("config object") {
|
||||
|
||||
@@ -19,8 +19,8 @@ use crate::{
|
||||
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
||||
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
||||
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
|
||||
LimitRuleFilters, PremiumMode, ProfileTimezoneConfigUpdateRequest,
|
||||
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope,
|
||||
LimitRuleFilters, PremiumMode, PushRelayConfigUpdateRequest, RegistrationMode,
|
||||
SsoConfigUpdateRequest, VoiceE2eeScope,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -225,10 +225,6 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_profile_timezone" => match build_profile_timezone_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
@@ -664,41 +660,6 @@ fn build_altcha_captcha_update(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_profile_timezone_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
profile_timezone: Some(ProfileTimezoneConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("profile_timezone_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"profile_timezone_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "profile_timezone_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("profile_timezone_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("profile_timezone_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("profile_timezone_include_premium_users")),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("profile_timezone_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
@@ -1341,7 +1302,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_domain_migration_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true",
|
||||
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true&domain_migration_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&domain_migration_include_premium_users=true",
|
||||
);
|
||||
let update = build_domain_migration_update(&form)
|
||||
.expect("valid form")
|
||||
@@ -1366,6 +1327,14 @@ mod tests {
|
||||
);
|
||||
assert_eq!(update.anonymous_rollout_basis_points, Some(100));
|
||||
assert_eq!(update.standalone_forwarding, Some(true));
|
||||
assert_eq!(update.include_premium_users, Some(true));
|
||||
assert_eq!(
|
||||
update.included_guild_ids,
|
||||
Some(vec![
|
||||
"1500000000000000005".to_owned(),
|
||||
"1500000000000000006".to_owned()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1512,52 +1481,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_profile_timezone_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"profile_timezone_enabled=true&profile_timezone_rollout_basis_points=%20500%20&profile_timezone_rollout_salt=%20profile-timezone-v2%20&profile_timezone_included_user_ids=1500000000000000001&profile_timezone_excluded_user_ids=1500000000000000002&profile_timezone_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&profile_timezone_include_premium_users=true",
|
||||
);
|
||||
let update = build_profile_timezone_update(&form)
|
||||
.expect("valid form")
|
||||
.profile_timezone
|
||||
.expect("profile timezone update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(500));
|
||||
assert_eq!(update.rollout_salt, Some("profile-timezone-v2".to_owned()));
|
||||
assert_eq!(update.include_premium_users, Some(true));
|
||||
assert_eq!(
|
||||
update.included_guild_ids,
|
||||
Some(vec![
|
||||
"1500000000000000005".to_owned(),
|
||||
"1500000000000000006".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec!["1500000000000000002".to_owned()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_profile_timezone_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_profile_timezone_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"profile_timezone": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"excluded_user_ids": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_experiment_update_rejects_an_invalid_included_guild_id() {
|
||||
for (prefix, build) in [
|
||||
@@ -1567,7 +1490,6 @@ mod tests {
|
||||
as fn(&MultiValueForm) -> Result<InstanceConfigUpdateRequest, String>,
|
||||
),
|
||||
("altcha_captcha", build_altcha_captcha_update),
|
||||
("profile_timezone", build_profile_timezone_update),
|
||||
] {
|
||||
let form = MultiValueForm::parse(
|
||||
format!("{prefix}_included_guild_ids=1500000000000000005%0Anot-a-guild").as_bytes(),
|
||||
@@ -1580,15 +1502,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_profile_timezone_update_rejects_a_rollout_above_everybody() {
|
||||
let form = MultiValueForm::parse(b"profile_timezone_rollout_basis_points=10001");
|
||||
assert_eq!(
|
||||
build_profile_timezone_update(&form).expect_err("invalid field"),
|
||||
"Rollout basis points must be a whole number between 0 and 10000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
|
||||
@@ -7,9 +7,8 @@ use crate::{
|
||||
DomainMigrationConfigResponse, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, PROFILE_TIMEZONE_DEFAULT_SALT,
|
||||
PendingRegistrationResponse, ProfileTimezoneConfigResponse, PushRelayConfigResponse,
|
||||
RegistrationUrlResponse, SsoConfigResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
|
||||
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -176,7 +175,6 @@ pub fn instance_config_page(
|
||||
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
|
||||
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
|
||||
(altcha_captcha_section(base, csrf_token, &instance_config.altcha_captcha))
|
||||
(profile_timezone_section(base, csrf_token, &instance_config.profile_timezone))
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
@@ -1376,143 +1374,6 @@ fn altcha_captcha_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn profile_timezone_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
profile_timezone: &ProfileTimezoneConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if profile_timezone.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = profile_timezone.included_user_ids.join("\n");
|
||||
let excluded_user_ids = profile_timezone.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Profile Timezone",
|
||||
"Lets the selected users save a time zone in profile settings and show their local time \
|
||||
on their profile. Users outside the rollout cannot change it, and a saved time zone \
|
||||
stays hidden from everyone while its owner is outside the rollout.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_profile_timezone"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (profile_timezone.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"profile_timezone_enabled",
|
||||
"true",
|
||||
"Serve profile timezone to the selected users",
|
||||
profile_timezone.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state and the kill switch. With this unchecked nobody \
|
||||
sees the setting and every saved time zone is hidden."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"profile_timezone_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&profile_timezone.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into profile timezone, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"profile_timezone_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&profile_timezone.rollout_salt,
|
||||
PROFILE_TIMEZONE_DEFAULT_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"profile_timezone_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
profile_timezone.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users get profile \
|
||||
timezone regardless of the percentage above. Invalid entries prevent the save."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(checkbox(
|
||||
"profile_timezone_include_premium_users",
|
||||
"true",
|
||||
"Include premium users",
|
||||
profile_timezone.include_premium_users,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Includes every account with active premium perks, regardless of the \
|
||||
percentage above. The never-on list still wins."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"profile_timezone_included_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1500000000000000005\n1500000000000000006",
|
||||
&profile_timezone.included_guild_ids.join("\n"),
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
profile_timezone.included_guild_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format, with guild IDs. Every member of a listed guild is \
|
||||
included regardless of the percentage above, unless the user is \
|
||||
in the never-on list."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"profile_timezone_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
profile_timezone.excluded_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the percentage."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Profile Timezone Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn experiment_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
|
||||
@@ -432,20 +432,9 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"anonymous_enabled": true,
|
||||
"cost": 5000,
|
||||
"max_counter": 10000,
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"future_altcha_knob": "argon2id"
|
||||
},
|
||||
"profile_timezone": {
|
||||
"enabled": true,
|
||||
"config_version": 2,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "profile-timezone-v1",
|
||||
"included_user_ids": ["1500000000000000001"],
|
||||
"excluded_user_ids": [],
|
||||
"included_guild_ids": ["1500000000000000005"],
|
||||
"include_premium_users": true,
|
||||
"future_profile_timezone_knob": true
|
||||
"future_altcha_knob": "argon2id"
|
||||
},
|
||||
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
|
||||
"registration": {
|
||||
@@ -611,11 +600,8 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
assert!(resp.altcha_captcha.anonymous_enabled);
|
||||
assert_eq!(resp.altcha_captcha.excluded_user_ids.len(), 1);
|
||||
assert_eq!(resp.altcha_captcha.max_counter, 10000);
|
||||
assert!(resp.profile_timezone.enabled);
|
||||
assert_eq!(resp.profile_timezone.config_version, 2);
|
||||
assert_eq!(resp.profile_timezone.included_user_ids.len(), 1);
|
||||
assert_eq!(resp.profile_timezone.included_guild_ids.len(), 1);
|
||||
assert!(resp.profile_timezone.include_premium_users);
|
||||
assert_eq!(resp.altcha_captcha.included_guild_ids.len(), 1);
|
||||
assert!(resp.altcha_captcha.include_premium_users);
|
||||
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
|
||||
assert!(resp.policy.single_community_guild_id.is_none());
|
||||
assert_eq!(resp.policy.services.gif_enabled, Some(true));
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
import {AltchaCaptchaConfigSchema} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {ProfileTimezoneConfigSchema} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
@@ -69,7 +68,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
pushRelay,
|
||||
domainMigration,
|
||||
altchaCaptcha,
|
||||
profileTimezone,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
@@ -80,7 +78,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getPushRelayConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getAltchaCaptchaConfig(),
|
||||
instanceConfigRepository.getProfileTimezoneConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
@@ -115,7 +112,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
push_relay: pushRelay,
|
||||
domain_migration: domainMigration,
|
||||
altcha_captcha: altchaCaptcha,
|
||||
profile_timezone: profileTimezone,
|
||||
experiment_delivery: experimentDelivery,
|
||||
registration: {
|
||||
...registrationConfig,
|
||||
@@ -404,18 +400,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.profile_timezone) {
|
||||
const patch = omitUndefinedFields(data.profile_timezone);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.updateProfileTimezoneConfig((current) =>
|
||||
ProfileTimezoneConfigSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
const patch = data.experiment_delivery;
|
||||
await instanceConfigRepository.updateExperimentDeliveryConfig((current) =>
|
||||
|
||||
@@ -11,7 +11,6 @@ import {entityTagMatches} from '@app/api/utils/EntityTag';
|
||||
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
|
||||
import {resolveAltchaCaptchaAssignment} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {resolveDomainMigrationAssignment} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {resolveProfileTimezoneAssignment} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
|
||||
export function ExperimentController(app: HonoApp) {
|
||||
@@ -31,26 +30,20 @@ export function ExperimentController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [delivery, domainMigrationConfig, altchaCaptchaConfig, profileTimezoneConfig] = await Promise.all([
|
||||
const [delivery, domainMigrationConfig, altchaCaptchaConfig] = await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getAltchaCaptchaConfig(),
|
||||
instanceConfigRepository.getProfileTimezoneConfig(),
|
||||
]);
|
||||
const user = ctx.get('user');
|
||||
const userId = user.id.toString();
|
||||
const targeting = await resolveExperimentTargeting(user, [
|
||||
domainMigrationConfig,
|
||||
altchaCaptchaConfig,
|
||||
profileTimezoneConfig,
|
||||
]);
|
||||
const targeting = await resolveExperimentTargeting(user, [domainMigrationConfig, altchaCaptchaConfig]);
|
||||
const body: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: delivery.poll_interval_seconds,
|
||||
poll_jitter_percent: delivery.poll_jitter_percent,
|
||||
assignments: {
|
||||
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId, targeting),
|
||||
altcha_captcha: resolveAltchaCaptchaAssignment(altchaCaptchaConfig, userId, targeting),
|
||||
profile_timezone: resolveProfileTimezoneAssignment(profileTimezoneConfig, userId, targeting),
|
||||
},
|
||||
};
|
||||
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
|
||||
|
||||
@@ -17,10 +17,6 @@ import {
|
||||
DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {
|
||||
DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
INERT_PROFILE_TIMEZONE_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import {
|
||||
DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
|
||||
DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
@@ -63,7 +59,6 @@ describe('GET /experiments', () => {
|
||||
assignments: {
|
||||
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
altcha_captcha: INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
|
||||
profile_timezone: INERT_PROFILE_TIMEZONE_ASSIGNMENT,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -170,56 +165,6 @@ describe('GET /experiments', () => {
|
||||
expect(body.assignments.altcha_captcha).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
it('resolves the profile timezone caller through the allowlist and the exclusion list', async () => {
|
||||
const targeted = await createTestAccount(harness);
|
||||
const excluded = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
included_user_ids: [targeted.userId],
|
||||
excluded_user_ids: [excluded.userId],
|
||||
});
|
||||
|
||||
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(targetedBody.assignments.profile_timezone).toEqual({enabled: true});
|
||||
|
||||
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(excludedBody.assignments.profile_timezone).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('bumps the profile timezone config version on every admin update without the client sending one', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const afterFirst = await createBuilder<{profile_timezone: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({profile_timezone: {enabled: true, included_user_ids: [admin.userId]}})
|
||||
.execute();
|
||||
expect(afterFirst.profile_timezone).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterSecond = await createBuilder<{
|
||||
profile_timezone: {config_version: number; rollout_basis_points: number};
|
||||
}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({profile_timezone: {rollout_basis_points: 2500}})
|
||||
.execute();
|
||||
expect(afterSecond.profile_timezone).toMatchObject({config_version: 2, rollout_basis_points: 2500});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.profile_timezone).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
it('enrols members of an included guild in every experiment and leaves everyone else out', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
@@ -239,25 +184,18 @@ describe('GET /experiments', () => {
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
await repository.setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
|
||||
const memberBody = await createBuilder<ExperimentAssignmentsResponse>(harness, member.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(memberBody.assignments.domain_migration).toEqual({enabled: true});
|
||||
expect(memberBody.assignments.altcha_captcha).toEqual({enabled: true});
|
||||
expect(memberBody.assignments.profile_timezone).toEqual({enabled: true});
|
||||
|
||||
const outsiderBody = await createBuilder<ExperimentAssignmentsResponse>(harness, outsider.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(outsiderBody.assignments.domain_migration).toEqual({enabled: false});
|
||||
expect(outsiderBody.assignments.altcha_captcha).toEqual({enabled: false});
|
||||
expect(outsiderBody.assignments.profile_timezone).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('enrols premium users, subscription and lifetime alike, when the switch is on', async () => {
|
||||
@@ -266,8 +204,8 @@ describe('GET /experiments', () => {
|
||||
const free = await createTestAccount(harness);
|
||||
await grantPremium(harness, subscriber.userId, UserPremiumTypes.SUBSCRIPTION);
|
||||
await grantPremium(harness, visionary.userId, UserPremiumTypes.LIFETIME);
|
||||
await getInstanceConfigRepository().setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
await getInstanceConfigRepository().setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
include_premium_users: true,
|
||||
});
|
||||
@@ -277,7 +215,7 @@ describe('GET /experiments', () => {
|
||||
[free, false],
|
||||
] as const) {
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.profile_timezone).toEqual({enabled: expected});
|
||||
expect(body.assignments.domain_migration).toEqual({enabled: expected});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -289,22 +227,17 @@ describe('GET /experiments', () => {
|
||||
]);
|
||||
const guildIds = ['1500000000000000001', '1500000000000000002'];
|
||||
const body = await createBuilder<
|
||||
Record<
|
||||
'domain_migration' | 'altcha_captcha' | 'profile_timezone',
|
||||
{included_guild_ids: Array<string>; include_premium_users: boolean}
|
||||
>
|
||||
Record<'domain_migration' | 'altcha_captcha', {included_guild_ids: Array<string>; include_premium_users: boolean}>
|
||||
>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({
|
||||
domain_migration: {included_guild_ids: guildIds, include_premium_users: true},
|
||||
altcha_captcha: {included_guild_ids: guildIds, include_premium_users: true},
|
||||
profile_timezone: {included_guild_ids: guildIds, include_premium_users: true},
|
||||
})
|
||||
.execute();
|
||||
expect(body.domain_migration.included_guild_ids).toEqual(guildIds);
|
||||
expect(body.altcha_captcha.included_guild_ids).toEqual(guildIds);
|
||||
expect(body.profile_timezone.included_guild_ids).toEqual(guildIds);
|
||||
for (const section of [body.domain_migration, body.altcha_captcha, body.profile_timezone]) {
|
||||
for (const section of [body.domain_migration, body.altcha_captcha]) {
|
||||
expect(section.include_premium_users).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -50,10 +50,6 @@ import {
|
||||
type StoredBillingConfig,
|
||||
StoredBillingConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/InstanceBillingSchemas';
|
||||
import {
|
||||
type ProfileTimezoneConfig,
|
||||
ProfileTimezoneConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import {
|
||||
type LegacyPushServiceDeliveryWire,
|
||||
type PushRelayConfig,
|
||||
@@ -84,7 +80,6 @@ const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
|
||||
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
|
||||
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
|
||||
const ALTCHA_CAPTCHA_CONFIG_KEY = 'altcha_captcha_config';
|
||||
const PROFILE_TIMEZONE_CONFIG_KEY = 'profile_timezone_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const REGISTRATION_CONFIG_KEY = 'registration_config';
|
||||
const REGISTRATION_URLS_KEY = 'registration_urls';
|
||||
@@ -441,7 +436,6 @@ type StoredConfigSection =
|
||||
| 'push relay'
|
||||
| 'domain migration'
|
||||
| 'altcha captcha'
|
||||
| 'profile timezone'
|
||||
| 'experiment delivery'
|
||||
| 'instance policy'
|
||||
| 'integrations'
|
||||
@@ -606,10 +600,6 @@ function parseStoredAltchaCaptchaConfig(raw: string | null): AltchaCaptchaConfig
|
||||
return parseStoredConfigOrDefault(AltchaCaptchaConfigSchema, raw, 'altcha captcha');
|
||||
}
|
||||
|
||||
function parseStoredProfileTimezoneConfig(raw: string | null): ProfileTimezoneConfig {
|
||||
return parseStoredConfigOrDefault(ProfileTimezoneConfigSchema, raw, 'profile timezone');
|
||||
}
|
||||
|
||||
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
|
||||
return parseStoredConfigOrDefault(ExperimentDeliveryConfigSchema, raw, 'experiment delivery');
|
||||
}
|
||||
@@ -1281,7 +1271,6 @@ export class InstanceConfigRepository {
|
||||
parseStoredPushRelayConfig(snapshot.get(PUSH_RELAY_CONFIG_KEY) ?? null);
|
||||
parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null);
|
||||
parseStoredAltchaCaptchaConfig(snapshot.get(ALTCHA_CAPTCHA_CONFIG_KEY) ?? null);
|
||||
parseStoredProfileTimezoneConfig(snapshot.get(PROFILE_TIMEZONE_CONFIG_KEY) ?? null);
|
||||
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
|
||||
parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
|
||||
checkStoredConfig('registration', () =>
|
||||
@@ -1412,27 +1401,6 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async getProfileTimezoneConfig(): Promise<ProfileTimezoneConfig> {
|
||||
const raw = await this.getConfig(PROFILE_TIMEZONE_CONFIG_KEY);
|
||||
return parseStoredProfileTimezoneConfig(raw);
|
||||
}
|
||||
|
||||
async setProfileTimezoneConfig(config: ProfileTimezoneConfig): Promise<void> {
|
||||
await this.updateProfileTimezoneConfig(() => config);
|
||||
}
|
||||
|
||||
updateProfileTimezoneConfig(
|
||||
update: (current: ProfileTimezoneConfig) => ProfileTimezoneConfig,
|
||||
): Promise<ProfileTimezoneConfig> {
|
||||
return this.updateStoredConfig(PROFILE_TIMEZONE_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(
|
||||
ProfileTimezoneConfigSchema,
|
||||
update(parseStoredProfileTimezoneConfig(raw)),
|
||||
'profile timezone',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
|
||||
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
|
||||
return parseStoredExperimentDeliveryConfig(raw);
|
||||
|
||||
@@ -25109,13 +25109,10 @@
|
||||
"anyOf": [{"$ref": "#/components/schemas/ColorType"}, {"type": "null"}]
|
||||
},
|
||||
"timezone": {
|
||||
"description": "IANA timezone identifier saved for profile local time. Ignored unless the profile_timezone experiment serves the user.",
|
||||
"description": "IANA timezone identifier saved for profile local time",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"timezone_privacy_flags": {
|
||||
"description": "Bitfield controlling who can see the profile timezone. Ignored unless the profile_timezone experiment serves the user.",
|
||||
"$ref": "#/components/schemas/ProfileFieldPrivacyFlags"
|
||||
},
|
||||
"timezone_privacy_flags": {"$ref": "#/components/schemas/ProfileFieldPrivacyFlags"},
|
||||
"premium_badge_hidden": {"type": "boolean", "description": "Whether to hide the premium badge"},
|
||||
"premium_badge_masked": {"type": "boolean", "description": "Whether to mask the premium badge"},
|
||||
"premium_badge_timestamp_hidden": {
|
||||
@@ -27944,8 +27941,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"},
|
||||
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaAssignmentResponse"},
|
||||
"profile_timezone": {"$ref": "#/components/schemas/ProfileTimezoneAssignmentResponse"}
|
||||
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaAssignmentResponse"}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -31644,12 +31640,6 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]},
|
||||
"ProfileTimezoneAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AltchaCaptchaAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}},
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
|
||||
import {sharedListHas} from '@app/api/infrastructure/activity/SharedLists';
|
||||
import {getCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {extractEmailDomain} from '@app/api/utils/EmailDomainUtils';
|
||||
import {
|
||||
@@ -15,7 +13,6 @@ import {
|
||||
SuspiciousActivityFlags,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {resolveProfileTimezoneAssignment} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import type {RequiredAction} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
@@ -315,9 +312,3 @@ export function isProfileSubstringExempt(user: Pick<PremiumCheckable, 'flags'>):
|
||||
export function isBugHunterBotUser(user: Pick<User, 'flags' | 'isBot'>): boolean {
|
||||
return user.isBot && (user.flags & UserFlags.BUG_HUNTER) !== 0n;
|
||||
}
|
||||
|
||||
export async function canUseProfileTimezone(user: User): Promise<boolean> {
|
||||
const config = await getInstanceConfigRepository().getProfileTimezoneConfig();
|
||||
const targeting = await resolveExperimentTargeting(user, [config]);
|
||||
return resolveProfileTimezoneAssignment(config, user.id.toString(), targeting).enabled;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {IUserAccountRepository} from '@app/api/user/repositories/IUserAccou
|
||||
import type {IUserChannelRepository} from '@app/api/user/repositories/IUserChannelRepository';
|
||||
import type {IUserRelationshipRepository} from '@app/api/user/repositories/IUserRelationshipRepository';
|
||||
import type {IUserSettingsRepository} from '@app/api/user/repositories/IUserSettingsRepository';
|
||||
import {canUseProfileTimezone} from '@app/api/user/UserHelpers';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {
|
||||
PremiumFlags,
|
||||
@@ -129,8 +128,7 @@ export class UserAccountLookupService {
|
||||
!restrictProfile &&
|
||||
user.timezone != null &&
|
||||
profileFieldPrivacyContext != null &&
|
||||
this.canViewProfileField(user.timezonePrivacyFlags, profileFieldPrivacyContext) &&
|
||||
(await canUseProfileTimezone(user));
|
||||
this.canViewProfileField(user.timezonePrivacyFlags, profileFieldPrivacyContext);
|
||||
const [mutualFriends, mutualGuilds, connections] = await Promise.all([
|
||||
withMutualFriends && userId !== targetId ? this.getMutualFriends(userId, targetId) : undefined,
|
||||
withMutualGuilds && userId !== targetId ? this.getMutualGuilds(userId, targetId) : undefined,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder'
|
||||
import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {IUserAccountRepository} from '@app/api/user/repositories/IUserAccountRepository';
|
||||
import {canUseProfileTimezone, isProfileSubstringExempt} from '@app/api/user/UserHelpers';
|
||||
import {isProfileSubstringExempt} from '@app/api/user/UserHelpers';
|
||||
import {deriveDominantAvatarColor} from '@app/api/utils/AvatarColorUtils';
|
||||
import * as EmojiUtils from '@app/api/utils/EmojiUtils';
|
||||
import {MAX_BIO_LENGTH} from '@fluxer/constants/src/LimitConstants';
|
||||
@@ -83,15 +83,13 @@ export class UserAccountProfileService {
|
||||
if (data.accent_color !== undefined) {
|
||||
await this.processAccentColorUpdate({user, accentColor: data.accent_color, updates});
|
||||
}
|
||||
const canUpdateProfileTimezone =
|
||||
(data.timezone !== undefined || data.timezone_privacy_flags !== undefined) && (await canUseProfileTimezone(user));
|
||||
if (canUpdateProfileTimezone && data.timezone !== undefined) {
|
||||
if (data.timezone !== undefined) {
|
||||
const nextTimezone = this.processTimezoneUpdate({user, timezone: data.timezone, updates});
|
||||
if (nextTimezone !== null && user.timezone === null && data.timezone_privacy_flags === undefined) {
|
||||
updates.timezone_privacy_flags = ProfileFieldPrivacyFlags.EVERYONE;
|
||||
}
|
||||
}
|
||||
if (canUpdateProfileTimezone && data.timezone_privacy_flags !== undefined) {
|
||||
if (data.timezone_privacy_flags !== undefined) {
|
||||
this.processTimezonePrivacyFlagsUpdate({
|
||||
user,
|
||||
privacyFlags: data.timezone_privacy_flags,
|
||||
|
||||
@@ -19,7 +19,6 @@ import type {UserAccountService} from '@app/api/user/services/UserAccountService
|
||||
import type {UserChannelService} from '@app/api/user/services/UserChannelService';
|
||||
import {mapUserToPartialResponseWithCache} from '@app/api/user/UserCacheHelpers';
|
||||
import {
|
||||
canUseProfileTimezone,
|
||||
createPremiumClearPatch,
|
||||
getEffectiveSuspiciousFlags,
|
||||
shouldStripExpiredPremium,
|
||||
@@ -69,20 +68,6 @@ function hasProfileCustomizationUpdate(data: UserUpdatePayload): boolean {
|
||||
return EMAIL_VERIFICATION_REQUIRED_PROFILE_UPDATE_FIELDS.some((field) => data[field] !== undefined);
|
||||
}
|
||||
|
||||
async function stripUnauthorizedProfileTimezoneUpdate(
|
||||
user: User,
|
||||
body: UserUpdateWithVerificationRequest,
|
||||
): Promise<UserUpdateWithVerificationRequest> {
|
||||
if (body.timezone === undefined && body.timezone_privacy_flags === undefined) {
|
||||
return body;
|
||||
}
|
||||
if (await canUseProfileTimezone(user)) {
|
||||
return body;
|
||||
}
|
||||
const {timezone: _timezone, timezone_privacy_flags: _timezonePrivacyFlags, ...rest} = body;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function hasDefinedUserUpdatePayload(data: UserUpdatePayload): boolean {
|
||||
return Object.values(data).some((value) => value !== undefined);
|
||||
}
|
||||
@@ -158,7 +143,6 @@ export class UserAccountRequestService {
|
||||
const {ctx, body, authSession} = params;
|
||||
const {user} = params;
|
||||
const oldEmail = user.email;
|
||||
const sanitizedBody = await stripUnauthorizedProfileTimezoneUpdate(user, body);
|
||||
const {
|
||||
mfa_method: _mfaMethod,
|
||||
mfa_code: _mfaCode,
|
||||
@@ -166,13 +150,13 @@ export class UserAccountRequestService {
|
||||
webauthn_challenge: _webauthnChallenge,
|
||||
email_token: emailToken,
|
||||
...userUpdateDataRest
|
||||
} = sanitizedBody;
|
||||
} = body;
|
||||
let userUpdateData: UserUpdatePayload = userUpdateDataRest;
|
||||
const emailTokenProvided = emailToken !== undefined;
|
||||
if (!emailTokenProvided && !hasDefinedUserUpdatePayload(userUpdateData)) {
|
||||
return mapUserToPrivateResponse(user);
|
||||
}
|
||||
this.enforceSuspiciousSelfUpdateAllowance(user, sanitizedBody);
|
||||
this.enforceSuspiciousSelfUpdateAllowance(user, body);
|
||||
if (userUpdateData.email !== undefined) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_MUST_BE_CHANGED_VIA_TOKEN);
|
||||
}
|
||||
@@ -198,7 +182,7 @@ export class UserAccountRequestService {
|
||||
const needsVerification = this.requiresSensitiveUserVerification(user, userUpdateData, emailTokenProvided);
|
||||
let sudoResult: SudoVerificationResult | null = null;
|
||||
if (needsVerification) {
|
||||
sudoResult = await requireSudoMode(ctx, user, sanitizedBody);
|
||||
sudoResult = await requireSudoMode(ctx, user, body);
|
||||
}
|
||||
if (emailTokenProvided && emailToken) {
|
||||
emailFromToken = await this.emailChangeService.getTokenEmail(user.id, emailToken);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createFriendship} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
ProfilePrivacyLevels,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {getCurrentTimeZoneOffsetMinutes} from '@fluxer/date_utils/src/TimeZoneUtils';
|
||||
import {DEFAULT_PROFILE_TIMEZONE_CONFIG} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import type {UserPrivateResponse, UserProfileFullResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
@@ -31,14 +29,6 @@ async function updateProfileTimezone(
|
||||
return createBuilder<UserPrivateResponse>(harness, token).patch('/users/@me').body(data).execute();
|
||||
}
|
||||
|
||||
async function setProfileTimezoneUsers(userIds: Array<string>): Promise<void> {
|
||||
await getInstanceConfigRepository().setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
included_user_ids: userIds,
|
||||
});
|
||||
}
|
||||
|
||||
async function updateProfilePrivacy(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
@@ -76,7 +66,6 @@ describe('User Profile Timezone Visibility', () => {
|
||||
it('defaults timezone visibility to everyone when a timezone is set', async () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
const viewerAccount = await createTestAccount(harness);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
|
||||
expect(updated.timezone).toBe(TEST_TIMEZONE);
|
||||
expect(updated.timezone_privacy_flags).toBe(ProfileFieldPrivacyFlags.EVERYONE);
|
||||
@@ -86,7 +75,6 @@ describe('User Profile Timezone Visibility', () => {
|
||||
});
|
||||
it('restores default timezone visibility when a timezone is set again without explicit flags', async () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
await updateProfileTimezone(harness, targetAccount.token, {
|
||||
timezone: TEST_TIMEZONE,
|
||||
timezone_privacy_flags: 0,
|
||||
@@ -97,7 +85,6 @@ describe('User Profile Timezone Visibility', () => {
|
||||
});
|
||||
it('hides timezone from the public profile when privacy flags are unset', async () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
await updateProfileTimezone(harness, targetAccount.token, {
|
||||
timezone: TEST_TIMEZONE,
|
||||
timezone_privacy_flags: 0,
|
||||
@@ -109,7 +96,6 @@ describe('User Profile Timezone Visibility', () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
const friendAccount = await createTestAccount(harness);
|
||||
const guildMemberAccount = await createTestAccount(harness);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
await updateProfileTimezone(harness, targetAccount.token, {
|
||||
timezone: TEST_TIMEZONE,
|
||||
timezone_privacy_flags: ProfileFieldPrivacyFlags.FRIENDS,
|
||||
@@ -125,7 +111,6 @@ describe('User Profile Timezone Visibility', () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
const friendAccount = await createTestAccount(harness);
|
||||
const guildMemberAccount = await createTestAccount(harness);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
await updateProfileTimezone(harness, targetAccount.token, {
|
||||
timezone: TEST_TIMEZONE,
|
||||
timezone_privacy_flags: ProfileFieldPrivacyFlags.MUTUAL_GUILDS,
|
||||
@@ -140,7 +125,6 @@ describe('User Profile Timezone Visibility', () => {
|
||||
it('hides timezone when full profile privacy restricts the viewer', async () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
const guildMemberAccount = await createTestAccount(harness);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
|
||||
await updateProfilePrivacy(harness, targetAccount.token, ProfilePrivacyLevels.FRIENDS_ONLY);
|
||||
await createSharedGuild(harness, targetAccount.token, guildMemberAccount.token);
|
||||
@@ -148,49 +132,4 @@ describe('User Profile Timezone Visibility', () => {
|
||||
expect(profile.profile_limited).toBe(true);
|
||||
expect(profile.timezone_offset).toBeNull();
|
||||
});
|
||||
it('ignores profile timezone updates from users outside the experiment', async () => {
|
||||
const targetAccount = await createTestAccount(harness, {skipEmailVerification: true});
|
||||
const updated = await updateProfileTimezone(harness, targetAccount.token, {
|
||||
timezone: TEST_TIMEZONE,
|
||||
timezone_privacy_flags: ProfileFieldPrivacyFlags.FRIENDS,
|
||||
});
|
||||
expect(updated.timezone).toBeNull();
|
||||
expect(updated.timezone_privacy_flags).toBe(ProfileFieldPrivacyFlags.EVERYONE);
|
||||
});
|
||||
it('ignores profile timezone updates from users excluded from a full rollout', async () => {
|
||||
const targetAccount = await createTestAccount(harness, {skipEmailVerification: true});
|
||||
await getInstanceConfigRepository().setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
excluded_user_ids: [targetAccount.userId],
|
||||
});
|
||||
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
|
||||
expect(updated.timezone).toBeNull();
|
||||
});
|
||||
it('lets members of an included guild set and show a timezone', async () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
const viewerAccount = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, targetAccount.token, 'Timezone Rollout Guild');
|
||||
await getInstanceConfigRepository().setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
|
||||
expect(updated.timezone).toBe(TEST_TIMEZONE);
|
||||
await createFriendship(harness, targetAccount, viewerAccount);
|
||||
const profile = await getUserProfile(harness, viewerAccount.token, targetAccount.userId);
|
||||
expect(profile.timezone_offset).toBe(TEST_TIMEZONE_OFFSET);
|
||||
});
|
||||
it('hides stored profile timezone after the user leaves the experiment', async () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
const viewerAccount = await createTestAccount(harness);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
|
||||
await setProfileTimezoneUsers([]);
|
||||
await createFriendship(harness, targetAccount, viewerAccount);
|
||||
const profile = await getUserProfile(harness, viewerAccount.token, targetAccount.userId);
|
||||
expect(profile.timezone_offset).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {RestResponse} from '@app/features/platform/types/TransportTypes';
|
||||
import type {ProfileTimezoneAssignmentResponse} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import type {DomainMigrationAssignmentResponse} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {
|
||||
type ExperimentAssignmentsResponse,
|
||||
INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
|
||||
readProfileTimezoneAssignment,
|
||||
readDomainMigrationAssignment,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
@@ -26,14 +26,14 @@ vi.mock('@app/features/platform/transport/RestTransport', () => ({
|
||||
const {http} = await import('@app/features/platform/transport/RestTransport');
|
||||
const {ExperimentAssignments} = await import('@app/features/experiment/state/ExperimentAssignments');
|
||||
|
||||
const CANARY_ASSIGNMENT: ProfileTimezoneAssignmentResponse = {
|
||||
const CANARY_ASSIGNMENT: DomainMigrationAssignmentResponse = {
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const CANARY_ENVELOPE: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: 300,
|
||||
poll_jitter_percent: 15,
|
||||
assignments: {profile_timezone: CANARY_ASSIGNMENT},
|
||||
assignments: {domain_migration: CANARY_ASSIGNMENT},
|
||||
};
|
||||
|
||||
let visibility: DocumentVisibilityState = 'visible';
|
||||
@@ -68,8 +68,8 @@ function subscribe(listener: () => void): () => void {
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
function timezoneEnabled(): boolean {
|
||||
return readProfileTimezoneAssignment(ExperimentAssignments.response).enabled;
|
||||
function migrationEnabled(): boolean {
|
||||
return readDomainMigrationAssignment(ExperimentAssignments.response).enabled;
|
||||
}
|
||||
|
||||
function deferredReply(): {resolve: (response: RestResponse<unknown>) => void} {
|
||||
@@ -111,7 +111,7 @@ describe('ExperimentAssignments cold start', () => {
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
expect(ExperimentAssignments.response).toBe(INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE);
|
||||
expect(ExperimentAssignments.response.assignments.profile_timezone).toBeUndefined();
|
||||
expect(ExperimentAssignments.response.assignments.domain_migration).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the inert envelope while unauthenticated and retries later', async () => {
|
||||
@@ -129,7 +129,7 @@ describe('ExperimentAssignments response handling', () => {
|
||||
it('adopts a valid envelope', async () => {
|
||||
await adopt(CANARY_ENVELOPE);
|
||||
expect(ExperimentAssignments.response).toEqual(CANARY_ENVELOPE);
|
||||
expect(readProfileTimezoneAssignment(ExperimentAssignments.response)).toEqual(CANARY_ASSIGNMENT);
|
||||
expect(readDomainMigrationAssignment(ExperimentAssignments.response)).toEqual(CANARY_ASSIGNMENT);
|
||||
});
|
||||
|
||||
it('requests the shared experiment endpoint', async () => {
|
||||
@@ -142,7 +142,7 @@ describe('ExperimentAssignments response handling', () => {
|
||||
vi.mocked(http.get).mockResolvedValue(
|
||||
reply(200, {
|
||||
...CANARY_ENVELOPE,
|
||||
assignments: {profile_timezone: {enabled: 'yes'}},
|
||||
assignments: {domain_migration: {enabled: 'yes'}},
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(400_000);
|
||||
@@ -157,9 +157,9 @@ describe('ExperimentAssignments response handling', () => {
|
||||
expect(ExperimentAssignments.response).toEqual(CANARY_ENVELOPE);
|
||||
});
|
||||
|
||||
it('accepts an envelope that carries no profile timezone assignment', async () => {
|
||||
it('accepts an envelope that carries no domain migration assignment', async () => {
|
||||
await adopt({poll_interval_seconds: 600, poll_jitter_percent: 0, assignments: {}});
|
||||
expect(ExperimentAssignments.response.assignments.profile_timezone).toBeUndefined();
|
||||
expect(ExperimentAssignments.response.assignments.domain_migration).toBeUndefined();
|
||||
expect(lastScheduledDelayMs()).toBe(600_000);
|
||||
});
|
||||
|
||||
@@ -416,14 +416,14 @@ describe('ExperimentAssignments lifecycle', () => {
|
||||
describe('ExperimentAssignments subscribe', () => {
|
||||
it('fires once per envelope change and stops after unsubscribe', async () => {
|
||||
const seen: Array<boolean> = [];
|
||||
const unsubscribe = subscribe(() => seen.push(timezoneEnabled()));
|
||||
const unsubscribe = subscribe(() => seen.push(migrationEnabled()));
|
||||
await adopt(CANARY_ENVELOPE);
|
||||
expect(seen).toEqual([true]);
|
||||
vi.mocked(http.get).mockResolvedValue(reply(304, undefined));
|
||||
await vi.advanceTimersByTimeAsync(400_000);
|
||||
expect(seen).toEqual([true]);
|
||||
vi.mocked(http.get).mockResolvedValue(
|
||||
reply(200, {...CANARY_ENVELOPE, assignments: {profile_timezone: {enabled: false}}}, {etag: 'W/"v8"'}),
|
||||
reply(200, {...CANARY_ENVELOPE, assignments: {domain_migration: {enabled: false}}}, {etag: 'W/"v8"'}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(400_000);
|
||||
expect(seen).toEqual([true, false]);
|
||||
|
||||
@@ -53,7 +53,6 @@ import {ProfileTypeSelector} from '@app/features/user/components/modals/tabs/my_
|
||||
import {TimezoneProfileSettings} from '@app/features/user/components/modals/tabs/my_profile_tab/TimezoneProfileSettings';
|
||||
import {ProfilePreview} from '@app/features/user/components/profile/ProfilePreview';
|
||||
import type {Profile} from '@app/features/user/models/Profile';
|
||||
import ProfileTimezoneRollout from '@app/features/user/state/ProfileTimezoneRollout';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
|
||||
import {setMeaningfulFormValue} from '@app/lib/forms/MeaningfulFormValue';
|
||||
@@ -411,7 +410,6 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
|
||||
);
|
||||
const showPremiumFeatures = shouldShowPremiumFeatures();
|
||||
const hasPremium = useMemo(() => showPremiumFeatures && (user?.isPremium() ?? false), [showPremiumFeatures, user]);
|
||||
const hasProfileTimezoneAccess = ProfileTimezoneRollout.enabled;
|
||||
const hasPerGuildProfiles = useMemo(
|
||||
() =>
|
||||
isLimitToggleEnabled(
|
||||
@@ -537,10 +535,8 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
|
||||
};
|
||||
assignProfileAssetUploadPatch(updateData, 'avatar', avatarAsset);
|
||||
assignProfileAssetUploadPatch(updateData, 'banner', bannerAsset);
|
||||
if (hasProfileTimezoneAccess) {
|
||||
updateData.timezone = data.timezone;
|
||||
updateData.timezone_privacy_flags = data.timezone_privacy_flags;
|
||||
}
|
||||
updateData.timezone = data.timezone;
|
||||
updateData.timezone_privacy_flags = data.timezone_privacy_flags;
|
||||
if (data.premium_badge_hidden !== undefined) {
|
||||
updateData.premium_badge_hidden = data.premium_badge_hidden;
|
||||
}
|
||||
@@ -591,7 +587,6 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
|
||||
commitProfileFormValues,
|
||||
isPerGuildProfile,
|
||||
isProfileCustomizationLocked,
|
||||
hasProfileTimezoneAccess,
|
||||
selectedGuildId,
|
||||
user,
|
||||
activeProfileData,
|
||||
@@ -701,12 +696,10 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
|
||||
!bannerAsset.hasCleared &&
|
||||
(bannerAsset.hasAsset || (!bannerAsset.isDirty && Boolean(profileRemoteValues?.banner.hasCustomAsset)));
|
||||
const profileFallbackDisplayName = NicknameUtils.getDisplayName(user);
|
||||
const watchedTimezone = hasProfileTimezoneAccess ? (form.watch('timezone') ?? null) : null;
|
||||
const watchedTimezonePrivacyFlags = hasProfileTimezoneAccess
|
||||
? (form.watch('timezone_privacy_flags') ?? ProfileFieldPrivacyFlags.EVERYONE)
|
||||
: ProfileFieldPrivacyFlags.EVERYONE;
|
||||
const watchedTimezone = form.watch('timezone') ?? null;
|
||||
const watchedTimezonePrivacyFlags = form.watch('timezone_privacy_flags') ?? ProfileFieldPrivacyFlags.EVERYONE;
|
||||
const previewTimezoneOffset =
|
||||
hasProfileTimezoneAccess && !isPerGuildProfile && watchedTimezone !== null && watchedTimezonePrivacyFlags !== 0
|
||||
!isPerGuildProfile && watchedTimezone !== null && watchedTimezonePrivacyFlags !== 0
|
||||
? getCurrentTimeZoneOffsetMinutes(watchedTimezone)
|
||||
: null;
|
||||
return (
|
||||
@@ -756,7 +749,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
|
||||
>
|
||||
{isPerGuildProfile
|
||||
? i18n._(VERIFY_YOUR_EMAIL_BEFORE_CHANGING_YOUR_COMMUNITY_NICKNAME_DESCRIPTOR)
|
||||
: hasProfileTimezoneAccess && showPremiumFeatures
|
||||
: showPremiumFeatures
|
||||
? i18n._(VERIFY_YOUR_EMAIL_BEFORE_CHANGING_YOUR_USERNAME_DISPLAY_DESCRIPTOR, {
|
||||
premiumProductName: PREMIUM_PRODUCT_NAME,
|
||||
})
|
||||
@@ -832,7 +825,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
|
||||
disabled={isPronounsDisabled}
|
||||
/>
|
||||
</div>
|
||||
{!isPerGuildProfile && hasProfileTimezoneAccess && (
|
||||
{!isPerGuildProfile && (
|
||||
<TimezoneProfileSettings
|
||||
timezone={watchedTimezone}
|
||||
timezonePrivacyFlags={watchedTimezonePrivacyFlags}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {readProfileTimezoneAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
|
||||
const logger = new Logger('ProfileTimezoneRollout');
|
||||
|
||||
class ProfileTimezoneRolloutSelector {
|
||||
get enabled(): boolean {
|
||||
try {
|
||||
return readProfileTimezoneAssignment(ExperimentAssignments.response).enabled;
|
||||
} catch (err) {
|
||||
logger.warn('Failed to resolve profile timezone assignment:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ProfileTimezoneRollout = new ProfileTimezoneRolloutSelector();
|
||||
|
||||
export default ProfileTimezoneRollout;
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {Profile} from '@app/features/user/models/Profile';
|
||||
import type {User} from '@app/features/user/models/User';
|
||||
import ProfileTimezoneRollout from '@app/features/user/state/ProfileTimezoneRollout';
|
||||
import {ProfileFieldPrivacyFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {getCurrentTimeZoneOffsetMinutes} from '@fluxer/date_utils/src/TimeZoneUtils';
|
||||
|
||||
@@ -97,7 +96,7 @@ export function createMockProfile(
|
||||
timezone_offset:
|
||||
options?.previewTimezoneOffset !== undefined
|
||||
? options.previewTimezoneOffset
|
||||
: ProfileTimezoneRollout.enabled && (user.timezonePrivacyFlags ?? ProfileFieldPrivacyFlags.EVERYONE) !== 0
|
||||
: (user.timezonePrivacyFlags ?? ProfileFieldPrivacyFlags.EVERYONE) !== 0
|
||||
? getCurrentTimeZoneOffsetMinutes(user.timezone)
|
||||
: null,
|
||||
premium_type: visiblePremiumData.premiumType ?? undefined,
|
||||
|
||||
@@ -37,7 +37,6 @@ Missing settings use the defaults documented below. Invalid stored configuration
|
||||
| push_relay | [push relay configuration](#push-relay-configuration-object) object | Operator consent to the Fluxer-run push relay |
|
||||
| domain_migration | [domain migration configuration](#domain-migration-configuration-object) object | Web domain migration rollout |
|
||||
| altcha_captcha | [ALTCHA captcha configuration](#altcha-captcha-configuration-object) object | ALTCHA proof-of-work captcha rollout |
|
||||
| profile_timezone | [profile timezone configuration](#profile-timezone-configuration-object) object | Profile timezone rollout |
|
||||
| experiment_delivery | [experiment delivery configuration](#experiment-delivery-configuration-object) object | Cadence every client polls the experiments route on |
|
||||
| registration | [registration configuration](#registration-configuration-object) object | Registration policy, issued URLs, and pending registrations |
|
||||
| self_hosted | boolean | Whether the deployment runs in self-hosted mode |
|
||||
@@ -173,29 +172,6 @@ Each challenge hides its counter between half of `max_counter` and `max_counter`
|
||||
|
||||
The rollout only changes which provider answers a captcha that is already required. While the instance captcha provider is `none`, it has no effect.
|
||||
|
||||
## Profile timezone configuration object
|
||||
|
||||
The instance rollout that lets an account set a profile timezone and show its local time on its profile. [Experiments](/http-api/experiments/#profile-timezone-assignment-object) defines what a client resolves from it.
|
||||
|
||||
### Structure
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| enabled | boolean | Whether the rollout runs at all (default false) |
|
||||
| config_version | integer | Revision counter, raised by Fluxer and never accepted from a request |
|
||||
| rollout_basis_points | integer | Share of accounts the rollout selects, in basis points (0-10000, default 0) |
|
||||
| rollout_salt | string | Salt of the sampling hash (1-64 printable ASCII characters, default `profile-timezone-v1`) |
|
||||
| included_user_ids | array[snowflake] | Accounts the rollout always selects, up to 1000 entries (default empty) |
|
||||
| included_guild_ids | array[snowflake] | Guilds whose members the rollout always selects, up to 1000 entries (default empty) |
|
||||
| include_premium_users | boolean | Whether the rollout always selects accounts with active premium (default false) |
|
||||
| excluded_user_ids | array[snowflake] | Accounts the rollout never selects, up to 1000 entries (default empty) |
|
||||
|
||||
Every field is present on read. An absent document or missing field uses the defaults above.
|
||||
|
||||
`excluded_user_ids` wins over every other rule. Otherwise the rollout selects an account in `included_user_ids`, a member of a guild in `included_guild_ids`, or a premium account while `include_premium_users` is true, whatever `rollout_basis_points` says.
|
||||
|
||||
An account that leaves the rollout keeps its stored timezone, but Fluxer stops showing it on the profile and refuses changes to it until the account is selected again.
|
||||
|
||||
## Experiment delivery configuration object
|
||||
|
||||
How often a client polls [Get experiment assignments](/http-api/experiments/#get-experiment-assignments), and how widely those polls are spread. The setting is instance-wide and applies to every experiment at once, so adding an experiment adds no second cadence to tune.
|
||||
@@ -647,7 +623,6 @@ The body has one optional object for each section. Fluxer leaves an absent secti
|
||||
| push_relay? | object | `relay_consent_accepted` from the [push relay configuration](#push-relay-configuration-object) |
|
||||
| domain_migration? | object | Any subset of the [domain migration](#domain-migration-configuration-object) fields |
|
||||
| altcha_captcha? | object | Any subset of the [ALTCHA captcha](#altcha-captcha-configuration-object) fields |
|
||||
| profile_timezone? | object | Any subset of the [profile timezone](#profile-timezone-configuration-object) fields |
|
||||
| experiment_delivery? | object | Any subset of the [experiment delivery](#experiment-delivery-configuration-object) fields |
|
||||
| registration? | object | `mode` and `admin_registration_urls_enabled` |
|
||||
| app_public?<sup>2</sup> | object | `branding`, `setup`, `legal`, and `registration` sub-objects, each merged field by field |
|
||||
@@ -666,8 +641,6 @@ The body has one optional object for each section. Fluxer leaves an absent secti
|
||||
|
||||
`altcha_captcha` works the same way, over the [ALTCHA captcha configuration](#altcha-captcha-configuration-object) fields and its own `config_version`.
|
||||
|
||||
`profile_timezone` works the same way, over the [profile timezone configuration](#profile-timezone-configuration-object) fields and its own `config_version`.
|
||||
|
||||
`experiment_delivery` takes both [experiment delivery configuration](#experiment-delivery-configuration-object) fields, each bound as documented there. It is a section of its own, so a write to it changes no `config_version` and changes no assignment, only the cadence on which clients ask for one.
|
||||
|
||||
<sup>3</sup> A secret such as `klipy_api_key`, `api_key`, `hcaptcha_secret_key`, `turnstile_secret_key`, or the SMTP `password` is written when supplied and left alone when absent. `integrations.bluesky.keys` is the only way to write the Bluesky signing keys counted as `bluesky.key_count`. It takes up to 8 entries of `kid` (1-255 characters) and nullable `private_key` (up to 10000 characters), and replaces the stored key set outright
|
||||
@@ -705,7 +678,7 @@ On a self-hosted deployment, billing and the `everyone` premium mode exclude eac
|
||||
| 400 | [error response](/admin-api/#error-response) | A policy transition is refused, returned as `INSTANCE_POLICY_TRANSITION_NOT_ALLOWED` |
|
||||
|
||||
:::caution[Sections are applied one after another]
|
||||
The order is `gateway_rollout`, `push_relay`, `domain_migration`, `altcha_captcha`, `profile_timezone`, `experiment_delivery`, `sso`, `registration`, `app_public` branding, legal, and registration fields, `integrations`, `media`, `policy`, `billing`, and finally `app_public.setup`. A failure part way through leaves the earlier sections written.
|
||||
The order is `gateway_rollout`, `push_relay`, `domain_migration`, `altcha_captcha`, `experiment_delivery`, `sso`, `registration`, `app_public` branding, legal, and registration fields, `integrations`, `media`, `policy`, `billing`, and finally `app_public.setup`. A failure part way through leaves the earlier sections written.
|
||||
:::
|
||||
|
||||
### Side effects
|
||||
|
||||
@@ -6,7 +6,7 @@ description: The experiment assignments envelope, the revalidation and polling c
|
||||
|
||||
import RouteHeader from '@/components/RouteHeader.astro';
|
||||
|
||||
An experiment is an instance-wide rollout that an operator configures. For each account, Fluxer works out from that configuration whether the account is in the rollout and which settings the account receives. The single route on this page resolves every experiment the server defines and returns them in one envelope, together with the polling cadence they share. This server defines `domain_migration`, `altcha_captcha` and `profile_timezone`.
|
||||
An experiment is an instance-wide rollout that an operator configures. For each account, Fluxer works out from that configuration whether the account is in the rollout and which settings the account receives. The single route on this page resolves every experiment the server defines and returns them in one envelope, together with the polling cadence they share. This server defines `domain_migration` and `altcha_captcha`.
|
||||
|
||||
Every assignment is advice. A client that ignores one behaves as it does with the rollout off, and no route and no Gateway event reports what a client actually ran.
|
||||
|
||||
@@ -34,7 +34,6 @@ One entry per experiment. The envelope reports this object even when it is empty
|
||||
| --- | --- | --- |
|
||||
| domain_migration? | [domain migration assignment](#domain-migration-assignment-object) object | The caller's web domain migration assignment |
|
||||
| altcha_captcha? | [ALTCHA captcha assignment](#altcha-captcha-assignment-object) object | The caller's captcha provider assignment |
|
||||
| profile_timezone? | [profile timezone assignment](#profile-timezone-assignment-object) object | The caller's profile timezone assignment |
|
||||
|
||||
Ignore unknown experiments and treat a missing experiment as off.
|
||||
|
||||
@@ -66,20 +65,6 @@ A caller is drawn by the operator's account or guild allowlist, by having premiu
|
||||
|
||||
The assignment is informational. The server applies the same resolution to every request that needs a captcha and names the provider in the [captcha error](/topics/captcha/#altcha-proof-of-work), so a client needs no copy of this value to answer a challenge.
|
||||
|
||||
## Profile timezone assignment object
|
||||
|
||||
One resolution of the instance profile timezone rollout against one account. This server version writes the key on every response.
|
||||
|
||||
### Structure
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| enabled | boolean | Whether the caller can set a profile timezone and show it on their profile |
|
||||
|
||||
A caller is drawn by the operator's account or guild allowlist, by having premium when the operator includes premium users, or by the sampled share of the account population. `enabled` is false in every other case, the rollout being off included.
|
||||
|
||||
The server applies the same resolution on its own. [Modify current user](/http-api/users/current-user/#modify-current-user) drops `timezone` and `timezone_privacy_flags` for an account outside the rollout, and a [user profile](/http-api/users/#get-user-profile) reports `timezone_offset` as null for a target outside it.
|
||||
|
||||
## Get experiment assignments
|
||||
|
||||
<RouteHeader method="GET" path="/v1/experiments" bot />
|
||||
|
||||
@@ -161,7 +161,7 @@ The private representation of the current account, returned by [Get current user
|
||||
|
||||
<sup>6</sup> Always null
|
||||
|
||||
<sup>7</sup> This server version writes the pair for every account. Whether the account can change it is decided by the [profile timezone assignment](/http-api/experiments/#profile-timezone-assignment-object)
|
||||
<sup>7</sup> This server version writes the pair for every account
|
||||
|
||||
<sup>8</sup> The banner hash is not returned at all while the account lacks the animated banner entitlement, which every profile banner requires
|
||||
|
||||
@@ -565,7 +565,7 @@ The profile read returned by [Get user profile](#get-user-profile) for one targe
|
||||
|
||||
<sup>4</sup> The array is empty when the profile is restricted. It otherwise holds only verified connections whose [profile field privacy flags](#profile-field-privacy-flags) admit the caller
|
||||
|
||||
<sup>5</sup> The value is null unless the profile is unrestricted, the [profile timezone assignment](/http-api/experiments/#profile-timezone-assignment-object) of the target is enabled, the target has stored a timezone, and its [profile field privacy flags](#profile-field-privacy-flags) admit the caller
|
||||
<sup>5</sup> The value is null unless the profile is unrestricted, the target has stored a timezone, and its [profile field privacy flags](#profile-field-privacy-flags) admit the caller
|
||||
|
||||
<sup>6</sup> The field is present only on a restricted read
|
||||
|
||||
|
||||
@@ -74,17 +74,17 @@ Modifies the current account and returns the resulting [user](/http-api/users/#u
|
||||
| bio?<sup>8</sup> | ?string | Biography (1-320 characters), or null to clear |
|
||||
| pronouns?<sup>9</sup> | ?string | Pronouns (1-40 characters), or null to clear |
|
||||
| accent_color? | ?integer | Packed 24-bit RGB colour (0-16777215), or null to clear |
|
||||
| timezone?<sup>10</sup> <sup>11</sup> | ?string | Supported IANA timezone identifier (1-128 characters), or null to clear |
|
||||
| timezone_privacy_flags?<sup>10</sup> | integer | [Profile field privacy flags](/http-api/users/#profile-field-privacy-flags) applied to the profile timezone |
|
||||
| timezone?<sup>10</sup> | ?string | Supported IANA timezone identifier (1-128 characters), or null to clear |
|
||||
| timezone_privacy_flags? | integer | [Profile field privacy flags](/http-api/users/#profile-field-privacy-flags) applied to the profile timezone |
|
||||
| premium_badge_hidden? | boolean | Whether to hide the premium badge from the public profile |
|
||||
| premium_badge_masked? | boolean | Whether to present a lifetime badge as an ordinary subscription badge |
|
||||
| premium_badge_timestamp_hidden? | boolean | Whether to hide the premium activation time |
|
||||
| premium_badge_sequence_hidden? | boolean | Whether to hide the lifetime premium sequence |
|
||||
| premium_enabled_override?<sup>12</sup> | boolean | Whether a staff override grants premium entitlements |
|
||||
| has_dismissed_premium_onboarding?<sup>13</sup> | boolean | Whether the premium onboarding flow has been dismissed |
|
||||
| has_unread_gift_inventory?<sup>14</sup> | boolean | Whether the gift inventory still holds unread items |
|
||||
| premium_enabled_override?<sup>11</sup> | boolean | Whether a staff override grants premium entitlements |
|
||||
| has_dismissed_premium_onboarding?<sup>12</sup> | boolean | Whether the premium onboarding flow has been dismissed |
|
||||
| has_unread_gift_inventory?<sup>13</sup> | boolean | Whether the gift inventory still holds unread items |
|
||||
| mention_flags? | integer | [Reply mention preference](/http-api/users/#reply-mention-preferences), one of `0`, `1`, or `2` |
|
||||
| email_token?<sup>15</sup> | string | Email token (1-256 characters) issued by [verify new email](/http-api/users/email-and-password/#verify-new-email) |
|
||||
| email_token?<sup>14</sup> | string | Email token (1-256 characters) issued by [verify new email](/http-api/users/email-and-password/#verify-new-email) |
|
||||
| mfa_method? | string | [Sudo verification](/http-api/users/mfa/#sudo-mode) method, either `totp` or `webauthn` |
|
||||
| mfa_code? | string | [Sudo verification](/http-api/users/mfa/#sudo-mode) authenticator or backup code (1-32 characters) |
|
||||
| webauthn_response? | [WebAuthn assertion](/http-api/users/mfa/#webauthn-assertion-object) object | [Sudo verification](/http-api/users/mfa/#sudo-mode) WebAuthn assertion |
|
||||
@@ -108,17 +108,15 @@ Modifies the current account and returns the resulting [user](/http-api/users/#u
|
||||
|
||||
<sup>9</sup> The value is matched against the instance profile substring blocklist like `username`, `global_name`, and `bio`
|
||||
|
||||
<sup>10</sup> A request from an account whose [profile timezone assignment](/http-api/experiments/#profile-timezone-assignment-object) is not enabled has both fields dropped before the update runs
|
||||
<sup>10</sup> An unrecognised identifier is rejected with `INVALID_TIMEZONE_IDENTIFIER`. Setting a first timezone without `timezone_privacy_flags` sets those flags to `EVERYONE`
|
||||
|
||||
<sup>11</sup> An unrecognised identifier is rejected with `INVALID_TIMEZONE_IDENTIFIER`. Setting a first timezone without `timezone_privacy_flags` sets those flags to `EVERYONE`
|
||||
<sup>11</sup> The field requires the [`STAFF`](/http-api/users/#public-user-flags) flag, and a caller without it is rejected with 403 [`MISSING_ACCESS`](/http-api/errors/)
|
||||
|
||||
<sup>12</sup> The field requires the [`STAFF`](/http-api/users/#public-user-flags) flag, and a caller without it is rejected with 403 [`MISSING_ACCESS`](/http-api/errors/)
|
||||
<sup>12</sup> Only `true` has an effect, recording the current time as the dismissal moment. `false` is accepted and does nothing
|
||||
|
||||
<sup>13</sup> Only `true` has an effect, recording the current time as the dismissal moment. `false` is accepted and does nothing
|
||||
<sup>13</sup> Only `false` has an effect, advancing the gift inventory read cursor to the current sequence. `true` is accepted and does nothing
|
||||
|
||||
<sup>14</sup> Only `false` has an effect, advancing the gift inventory read cursor to the current sequence. `true` is accepted and does nothing
|
||||
|
||||
<sup>15</sup> Supplying the field applies the verified address the token stands for, and the token is deleted once the update lands
|
||||
<sup>14</sup> Supplying the field applies the verified address the token stands for, and the token is deleted once the update lands
|
||||
|
||||
An omitted field leaves its stored value untouched, and an explicit `null` clears a nullable field. `has_dismissed_premium_onboarding` acts only on `true` and `has_unread_gift_inventory` only on `false`, so neither can be reversed through this route.
|
||||
|
||||
|
||||
@@ -27,10 +27,6 @@ import {
|
||||
InstanceBillingResponse,
|
||||
InstanceBillingUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/InstanceBillingSchemas';
|
||||
import {
|
||||
ProfileTimezoneConfigResponse,
|
||||
ProfileTimezoneConfigUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import {PushRelayConfigResponse, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {
|
||||
ExperimentDeliveryConfigResponse,
|
||||
@@ -656,7 +652,6 @@ export const InstanceConfigResponse = z.object({
|
||||
push_relay: PushRelayConfigResponse,
|
||||
domain_migration: DomainMigrationConfigResponse,
|
||||
altcha_captcha: AltchaCaptchaConfigResponse,
|
||||
profile_timezone: ProfileTimezoneConfigResponse,
|
||||
experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
registration: InstanceRegistrationResponse,
|
||||
self_hosted: z.boolean(),
|
||||
@@ -689,7 +684,6 @@ export const InstanceConfigUpdateRequest = z.object({
|
||||
push_relay: PushRelayConfigUpdateRequest.nullish(),
|
||||
domain_migration: DomainMigrationConfigUpdateRequest.nullish(),
|
||||
altcha_captcha: AltchaCaptchaConfigUpdateRequest.nullish(),
|
||||
profile_timezone: ProfileTimezoneConfigUpdateRequest.nullish(),
|
||||
experiment_delivery: ExperimentDeliveryConfigUpdateRequest.nullish(),
|
||||
registration: z
|
||||
.object({
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
type ProfileTimezoneConfig,
|
||||
ProfileTimezoneConfigSchema,
|
||||
ProfileTimezoneConfigUpdateRequest,
|
||||
resolveProfileTimezoneAssignment,
|
||||
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import {type ExperimentTargeting, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
const NO_TARGETING: ExperimentTargeting = {memberGuildIds: new Set(), premium: false};
|
||||
|
||||
const TARGETED_USER_ID = '1000000000000000001';
|
||||
|
||||
function createConfig(overrides: Partial<ProfileTimezoneConfig> = {}): ProfileTimezoneConfig {
|
||||
return {...DEFAULT_PROFILE_TIMEZONE_CONFIG, included_user_ids: [], excluded_user_ids: [], ...overrides};
|
||||
}
|
||||
|
||||
function syntheticUserIds(count: number): Array<string> {
|
||||
return Array.from({length: count}, (_, index) => (1400000000000000000n + BigInt(index)).toString());
|
||||
}
|
||||
|
||||
describe('profile timezone configuration', () => {
|
||||
test('defaults to disabled', () => {
|
||||
expect(ProfileTimezoneConfigSchema.parse({})).toEqual({
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: 'profile-timezone-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
included_guild_ids: [],
|
||||
include_premium_users: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('strips config_version from admin updates', () => {
|
||||
expect(ProfileTimezoneConfigUpdateRequest.safeParse({config_version: 3}).data).toEqual({});
|
||||
expect(ProfileTimezoneConfigUpdateRequest.safeParse({rollout_basis_points: 10001}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProfileTimezoneAssignment', () => {
|
||||
test('serves nobody while disabled, even included users', () => {
|
||||
const config = createConfig({rollout_basis_points: 10000, included_user_ids: [TARGETED_USER_ID]});
|
||||
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
test('applies exclusions before inclusions', () => {
|
||||
const config = createConfig({
|
||||
enabled: true,
|
||||
included_user_ids: [TARGETED_USER_ID],
|
||||
excluded_user_ids: [TARGETED_USER_ID],
|
||||
});
|
||||
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
test('serves included users at zero rollout', () => {
|
||||
const config = createConfig({enabled: true, included_user_ids: [TARGETED_USER_ID]});
|
||||
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
test('buckets the rollout by salt and user id', () => {
|
||||
const config = createConfig({enabled: true, rollout_basis_points: 2500});
|
||||
for (const userId of syntheticUserIds(200)) {
|
||||
expect(resolveProfileTimezoneAssignment(config, userId, NO_TARGETING).enabled).toBe(
|
||||
experimentBucket(userId, config.rollout_salt) < 2500,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProfileTimezoneAssignment guild targeting', () => {
|
||||
const INCLUDED_GUILD_ID = '3000000000000000001';
|
||||
const MEMBER_GUILDS: ExperimentTargeting = {
|
||||
memberGuildIds: new Set(['3000000000000000009', INCLUDED_GUILD_ID]),
|
||||
premium: false,
|
||||
};
|
||||
|
||||
test('serves members of an included guild at zero rollout', () => {
|
||||
const config = createConfig({enabled: true, included_guild_ids: [INCLUDED_GUILD_ID]});
|
||||
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: true});
|
||||
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
test('keeps user exclusions ahead of guild membership', () => {
|
||||
const config = createConfig({
|
||||
enabled: true,
|
||||
included_guild_ids: [INCLUDED_GUILD_ID],
|
||||
excluded_user_ids: [TARGETED_USER_ID],
|
||||
});
|
||||
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
test('serves no guild members while disabled', () => {
|
||||
const config = createConfig({included_guild_ids: [INCLUDED_GUILD_ID]});
|
||||
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: false});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProfileTimezoneAssignment premium targeting', () => {
|
||||
const PREMIUM: ExperimentTargeting = {memberGuildIds: new Set(), premium: true};
|
||||
|
||||
test('serves premium users only when the switch is on', () => {
|
||||
const on = createConfig({enabled: true, include_premium_users: true});
|
||||
expect(resolveProfileTimezoneAssignment(on, TARGETED_USER_ID, PREMIUM)).toEqual({enabled: true});
|
||||
expect(resolveProfileTimezoneAssignment(on, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
|
||||
expect(resolveProfileTimezoneAssignment(createConfig({enabled: true}), TARGETED_USER_ID, PREMIUM)).toEqual({
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps user exclusions ahead of the premium switch', () => {
|
||||
const config = createConfig({
|
||||
enabled: true,
|
||||
include_premium_users: true,
|
||||
excluded_user_ids: [TARGETED_USER_ID],
|
||||
});
|
||||
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, PREMIUM)).toEqual({enabled: false});
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
EXPERIMENT_BUCKET_RESOLUTION,
|
||||
type ExperimentTargeting,
|
||||
experimentAudienceIncludes,
|
||||
experimentBucket,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
import {z} from 'zod';
|
||||
|
||||
const PROFILE_TIMEZONE_ROLLOUT_BASIS_POINTS_MAX = EXPERIMENT_BUCKET_RESOLUTION;
|
||||
const PROFILE_TIMEZONE_MAX_TARGETED_USERS = 1000;
|
||||
const DEFAULT_PROFILE_TIMEZONE_SALT = 'profile-timezone-v1';
|
||||
|
||||
const PROFILE_TIMEZONE_SALT_PATTERN = /^[\x20-\x7e]+$/u;
|
||||
|
||||
const ProfileTimezoneTargetIdSchema = z.string().regex(/^\d{1,20}$/u);
|
||||
const ProfileTimezoneTargetedUserIdsSchema = z
|
||||
.array(ProfileTimezoneTargetIdSchema)
|
||||
.max(PROFILE_TIMEZONE_MAX_TARGETED_USERS);
|
||||
|
||||
const profileTimezoneConfigFields = {
|
||||
enabled: z.boolean(),
|
||||
config_version: z.number().int().min(0),
|
||||
rollout_basis_points: z.number().int().min(0).max(PROFILE_TIMEZONE_ROLLOUT_BASIS_POINTS_MAX),
|
||||
rollout_salt: z.string().trim().min(1).max(64).regex(PROFILE_TIMEZONE_SALT_PATTERN),
|
||||
included_user_ids: ProfileTimezoneTargetedUserIdsSchema,
|
||||
included_guild_ids: ProfileTimezoneTargetedUserIdsSchema,
|
||||
include_premium_users: z.boolean(),
|
||||
excluded_user_ids: ProfileTimezoneTargetedUserIdsSchema,
|
||||
};
|
||||
|
||||
export const ProfileTimezoneConfigSchema = z.object({
|
||||
enabled: profileTimezoneConfigFields.enabled.default(false),
|
||||
config_version: profileTimezoneConfigFields.config_version.default(0),
|
||||
rollout_basis_points: profileTimezoneConfigFields.rollout_basis_points.default(0),
|
||||
rollout_salt: profileTimezoneConfigFields.rollout_salt.default(DEFAULT_PROFILE_TIMEZONE_SALT),
|
||||
included_user_ids: profileTimezoneConfigFields.included_user_ids.default([]),
|
||||
included_guild_ids: profileTimezoneConfigFields.included_guild_ids.default([]),
|
||||
include_premium_users: profileTimezoneConfigFields.include_premium_users.default(false),
|
||||
excluded_user_ids: profileTimezoneConfigFields.excluded_user_ids.default([]),
|
||||
});
|
||||
|
||||
export type ProfileTimezoneConfig = z.infer<typeof ProfileTimezoneConfigSchema>;
|
||||
|
||||
export const DEFAULT_PROFILE_TIMEZONE_CONFIG: ProfileTimezoneConfig = ProfileTimezoneConfigSchema.parse({});
|
||||
|
||||
export const ProfileTimezoneConfigUpdateRequest = z
|
||||
.object(profileTimezoneConfigFields)
|
||||
.omit({config_version: true})
|
||||
.partial();
|
||||
|
||||
export type ProfileTimezoneConfigUpdateRequest = z.infer<typeof ProfileTimezoneConfigUpdateRequest>;
|
||||
|
||||
export const ProfileTimezoneConfigResponse = ProfileTimezoneConfigSchema;
|
||||
|
||||
export type ProfileTimezoneConfigResponse = z.infer<typeof ProfileTimezoneConfigResponse>;
|
||||
|
||||
export const ProfileTimezoneAssignmentResponse = z.object({
|
||||
enabled: profileTimezoneConfigFields.enabled,
|
||||
});
|
||||
|
||||
export type ProfileTimezoneAssignmentResponse = z.infer<typeof ProfileTimezoneAssignmentResponse>;
|
||||
|
||||
export const INERT_PROFILE_TIMEZONE_ASSIGNMENT: ProfileTimezoneAssignmentResponse = {
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
export function resolveProfileTimezoneAssignment(
|
||||
config: ProfileTimezoneConfig,
|
||||
userId: string,
|
||||
targeting: ExperimentTargeting,
|
||||
): ProfileTimezoneAssignmentResponse {
|
||||
if (!config.enabled) return {...INERT_PROFILE_TIMEZONE_ASSIGNMENT};
|
||||
if (config.excluded_user_ids.includes(userId)) return {...INERT_PROFILE_TIMEZONE_ASSIGNMENT};
|
||||
if (config.included_user_ids.includes(userId)) return {enabled: true};
|
||||
if (experimentAudienceIncludes(config, targeting)) return {enabled: true};
|
||||
return {enabled: experimentBucket(userId, config.rollout_salt) < config.rollout_basis_points};
|
||||
}
|
||||
@@ -5,10 +5,6 @@ import {
|
||||
DomainMigrationAssignmentResponse,
|
||||
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {
|
||||
INERT_PROFILE_TIMEZONE_ASSIGNMENT,
|
||||
ProfileTimezoneAssignmentResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import {z} from 'zod';
|
||||
|
||||
export const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS = 60;
|
||||
@@ -48,7 +44,6 @@ export type ExperimentDeliveryConfigResponse = z.infer<typeof ExperimentDelivery
|
||||
const ExperimentAssignmentsSchema = z.object({
|
||||
domain_migration: DomainMigrationAssignmentResponse.optional(),
|
||||
altcha_captcha: AltchaCaptchaAssignmentResponse.optional(),
|
||||
profile_timezone: ProfileTimezoneAssignmentResponse.optional(),
|
||||
});
|
||||
|
||||
export const ExperimentAssignmentsResponse = z.object({
|
||||
@@ -70,9 +65,3 @@ export function readDomainMigrationAssignment(
|
||||
): DomainMigrationAssignmentResponse {
|
||||
return response.assignments.domain_migration ?? INERT_DOMAIN_MIGRATION_ASSIGNMENT;
|
||||
}
|
||||
|
||||
export function readProfileTimezoneAssignment(
|
||||
response: ExperimentAssignmentsResponse,
|
||||
): ProfileTimezoneAssignmentResponse {
|
||||
return response.assignments.profile_timezone ?? INERT_PROFILE_TIMEZONE_ASSIGNMENT;
|
||||
}
|
||||
|
||||
@@ -75,19 +75,13 @@ export const UserUpdateRequest = z
|
||||
bio: createStringType(1, 320).nullish().describe('User biography text (max 320 characters)'),
|
||||
pronouns: createStringType(1, 40).nullish().describe('User pronouns (max 40 characters)'),
|
||||
accent_color: ColorType.nullish().describe('Profile accent color as integer'),
|
||||
timezone: createStringType(1, 128)
|
||||
.nullish()
|
||||
.describe(
|
||||
'IANA timezone identifier saved for profile local time. Ignored unless the profile_timezone experiment serves the user.',
|
||||
),
|
||||
timezone: createStringType(1, 128).nullish().describe('IANA timezone identifier saved for profile local time'),
|
||||
timezone_privacy_flags: createBitflagInt32Type(
|
||||
ProfileFieldPrivacyFlags,
|
||||
ProfileFieldPrivacyFlagsDescriptions,
|
||||
'Bitfield controlling who can see the profile timezone',
|
||||
'ProfileFieldPrivacyFlags',
|
||||
).describe(
|
||||
'Bitfield controlling who can see the profile timezone. Ignored unless the profile_timezone experiment serves the user.',
|
||||
),
|
||||
).describe('Bitfield controlling who can see the profile timezone'),
|
||||
premium_badge_hidden: z.boolean().describe('Whether to hide the premium badge'),
|
||||
premium_badge_masked: z.boolean().describe('Whether to mask the premium badge'),
|
||||
premium_badge_timestamp_hidden: z.boolean().describe('Whether to hide premium badge timestamp'),
|
||||
|
||||
Reference in New Issue
Block a user