Compare commits

...
175 changed files with 6152 additions and 10769 deletions
+3
View File
@@ -336,6 +336,9 @@ jobs:
restore-keys: |
rebar3-${{ runner.os }}-otp28-rebar3.27.0-
- name: Drop restored gateway build output
run: rm -rf fluxer_gateway/_build/default/lib/fluxer_gateway fluxer_gateway/_build/test/lib/fluxer_gateway
- name: Check formatting
run: |
"$FLUXER_CI_BIN" ci --step gateway_fmt
+126 -59
View File
@@ -10524,9 +10524,10 @@
},
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
"push_service_delivery": {"$ref": "#/components/schemas/PushServiceDeliveryConfigResponse"},
"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",
@@ -10954,9 +10955,10 @@
"sso",
"gateway_rollout",
"voice_noise_suppression",
"push_service_delivery",
"push_relay",
"domain_migration",
"altcha_captcha",
"profile_timezone",
"experiment_delivery",
"registration",
"self_hosted",
@@ -11091,10 +11093,7 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
},
"push_service_delivery": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/PushServiceDeliveryConfigUpdateRequest"}]
},
"push_relay": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PushRelayConfigUpdateRequest"}]},
"domain_migration": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
@@ -11103,6 +11102,10 @@
"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"}]
@@ -15196,6 +15199,30 @@
"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": {
@@ -15207,6 +15234,12 @@
"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",
@@ -15228,6 +15261,12 @@
"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",
@@ -15237,25 +15276,7 @@
"standalone_forwarding": {"type": "boolean"}
}
},
"PushServiceDeliveryConfigUpdateRequest": {
"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}$"}
},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"relay_consent_accepted": {"type": "boolean"}
}
},
"PushRelayConfigUpdateRequest": {"type": "object", "properties": {"relay_consent_accepted": {"type": "boolean"}}},
"VoiceNoiseSuppressionConfigUpdateRequest": {
"type": "object",
"properties": {
@@ -15274,6 +15295,12 @@
"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",
@@ -15321,6 +15348,51 @@
"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": {
@@ -15340,6 +15412,13 @@
"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,
@@ -15356,6 +15435,8 @@
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"anonymous_enabled",
"cost",
@@ -15382,6 +15463,13 @@
"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,
@@ -15397,37 +15485,17 @@
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"anonymous_rollout_basis_points",
"standalone_forwarding"
],
"additionalProperties": false
},
"PushServiceDeliveryConfigResponse": {
"PushRelayConfigResponse": {
"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": "push-service-delivery-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}$"}
},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"relay_consent_accepted": {"default": false, "type": "boolean"},
"relay_consent_accepted_at": {
"default": null,
@@ -15438,17 +15506,7 @@
},
"relay_consent_accepted_by": {"default": null, "nullable": true, "type": "string", "pattern": "^\\d{1,20}$"}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"excluded_user_ids",
"relay_consent_accepted",
"relay_consent_accepted_at",
"relay_consent_accepted_by"
],
"required": ["relay_consent_accepted", "relay_consent_accepted_at", "relay_consent_accepted_by"],
"additionalProperties": false
},
"VoiceNoiseSuppressionConfigResponse": {
@@ -15475,6 +15533,13 @@
"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,
@@ -15506,6 +15571,8 @@
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"guild_overrides",
"suppression_strength"
+93 -38
View File
@@ -23,12 +23,14 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
#[serde(default)]
pub push_service_delivery: PushServiceDeliveryConfigResponse,
pub push_relay: PushRelayConfigResponse,
#[serde(default)]
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub altcha_captcha: AltchaCaptchaConfigResponse,
#[serde(default)]
pub profile_timezone: ProfileTimezoneConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
}
@@ -453,11 +455,11 @@ impl VoiceE2eeScope {
}
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const PUSH_SERVICE_DELIVERY_DEFAULT_SALT: &str = "push-service-delivery-v1";
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";
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
impl NoiseSuppressionBackend {
@@ -501,6 +503,8 @@ pub struct VoiceNoiseSuppressionConfigResponse {
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>,
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
pub suppression_strength: u32,
@@ -517,6 +521,8 @@ impl Default for VoiceNoiseSuppressionConfigResponse {
rollout_basis_points: 0,
rollout_salt: "voice-ns-v1".to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
guild_overrides: Vec::new(),
suppression_strength: 80,
@@ -541,6 +547,10 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
#[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>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
@@ -548,48 +558,16 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
pub suppression_strength: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct PushServiceDeliveryConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub excluded_user_ids: Vec<String>,
pub struct PushRelayConfigResponse {
pub relay_consent_accepted: bool,
pub relay_consent_accepted_at: Option<String>,
pub relay_consent_accepted_by: Option<String>,
}
impl Default for PushServiceDeliveryConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PUSH_SERVICE_DELIVERY_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
relay_consent_accepted: false,
relay_consent_accepted_at: None,
relay_consent_accepted_by: None,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct PushServiceDeliveryConfigUpdateRequest {
#[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 excluded_user_ids: Option<Vec<String>>,
pub struct PushRelayConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub relay_consent_accepted: Option<bool>,
}
@@ -602,6 +580,8 @@ pub struct DomainMigrationConfigResponse {
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>,
pub anonymous_rollout_basis_points: u32,
pub standalone_forwarding: bool,
@@ -615,6 +595,8 @@ impl Default for DomainMigrationConfigResponse {
rollout_basis_points: 0,
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
anonymous_rollout_basis_points: 0,
standalone_forwarding: false,
@@ -633,6 +615,10 @@ pub struct DomainMigrationConfigUpdateRequest {
#[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>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anonymous_rollout_basis_points: Option<u32>,
@@ -648,6 +634,8 @@ pub struct AltchaCaptchaConfigResponse {
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>,
pub anonymous_enabled: bool,
pub cost: u32,
@@ -662,6 +650,8 @@ impl Default for AltchaCaptchaConfigResponse {
rollout_basis_points: 0,
rollout_salt: ALTCHA_CAPTCHA_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
anonymous_enabled: false,
cost: 5_000,
@@ -681,6 +671,10 @@ pub struct AltchaCaptchaConfigUpdateRequest {
#[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>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anonymous_enabled: Option<bool>,
@@ -690,6 +684,52 @@ 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 {
@@ -806,12 +846,14 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub push_service_delivery: Option<PushServiceDeliveryConfigUpdateRequest>,
pub push_relay: Option<PushRelayConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
#[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>,
}
@@ -1153,6 +1195,8 @@ 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 noise = serde_json::to_value(noise).expect("serializable noise config");
@@ -1160,6 +1204,8 @@ mod tests {
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_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
serde_json::from_value(noise.clone()).expect("generated noise config contract");
@@ -1169,6 +1215,9 @@ 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!(
@@ -1185,6 +1234,11 @@ 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"),
@@ -1194,6 +1248,7 @@ mod tests {
("VoiceNoiseSuppressionConfigResponse", noise),
("DomainMigrationConfigResponse", domain_migration),
("AltchaCaptchaConfigResponse", altcha_captcha),
("ProfileTimezoneConfigResponse", profile_timezone),
("ExperimentDeliveryConfigResponse", delivery),
] {
for (field, value) in value.as_object().expect("config object") {
+164 -53
View File
@@ -20,8 +20,8 @@ use crate::{
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
PremiumMode, PushServiceDeliveryConfigUpdateRequest, RegistrationMode,
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
PremiumMode, ProfileTimezoneConfigUpdateRequest, PushRelayConfigUpdateRequest,
RegistrationMode, SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
},
},
@@ -209,10 +209,10 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_push_service_delivery" => match build_push_service_delivery_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_push_relay" => {
let update = build_push_relay_update(&form);
instance_config_result(client.update_instance_config(&update).await)
}
"update_domain_migration" => match build_domain_migration_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
@@ -221,6 +221,10 @@ 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),
@@ -639,6 +643,12 @@ fn build_voice_noise_suppression_update(
form.first("voice_ns_included_user_ids").unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("voice_ns_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
"Excluded user IDs",
@@ -658,39 +668,13 @@ fn build_voice_noise_suppression_update(
})
}
fn build_push_service_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
push_service_delivery: Some(PushServiceDeliveryConfigUpdateRequest {
enabled: Some(form.bool_value("push_service_delivery_enabled")),
rollout_basis_points: parse_form_number(
form,
"push_service_delivery_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(
form,
"push_service_delivery_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("push_service_delivery_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("push_service_delivery_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
relay_consent_accepted: Some(
form.bool_value("push_service_delivery_relay_consent_accepted"),
),
fn build_push_relay_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
push_relay: Some(PushRelayConfigUpdateRequest {
relay_consent_accepted: Some(form.bool_value("push_relay_consent_accepted")),
}),
..Default::default()
})
}
}
fn build_domain_migration_update(
@@ -715,6 +699,12 @@ fn build_domain_migration_update(
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("domain_migration_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("domain_migration_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("domain_migration_excluded_user_ids")
.unwrap_or_default(),
@@ -752,6 +742,12 @@ fn build_altcha_captcha_update(
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("altcha_captcha_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_excluded_user_ids")
.unwrap_or_default(),
@@ -777,6 +773,44 @@ 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_ascii_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> {
@@ -1421,6 +1455,8 @@ mod tests {
"allow_user_override": false,
"enabled_backends": [],
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
"guild_overrides": [],
}})
@@ -1737,6 +1773,8 @@ mod tests {
serde_json::json!({"domain_migration": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
"standalone_forwarding": false,
}})
@@ -1784,26 +1822,19 @@ mod tests {
}
#[test]
fn build_push_service_delivery_update_reads_the_relay_consent_checkbox() {
let unchecked = MultiValueForm::parse(b"_csrf=token");
fn build_push_relay_update_reads_the_consent_checkbox() {
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
assert_eq!(
build_push_service_delivery_update(&unchecked)
.expect("valid form")
.push_service_delivery
.expect("push service delivery update")
.relay_consent_accepted,
Some(false)
serde_json::to_value(&unchecked).expect("serialize update"),
serde_json::json!({"push_relay": {"relay_consent_accepted": false}})
);
let checked =
MultiValueForm::parse(b"_csrf=token&push_service_delivery_relay_consent_accepted=true");
let checked = build_push_relay_update(&MultiValueForm::parse(
b"_csrf=token&push_relay_consent_accepted=true",
));
assert_eq!(
build_push_service_delivery_update(&checked)
.expect("valid form")
.push_service_delivery
.expect("push service delivery update")
.relay_consent_accepted,
Some(true)
serde_json::to_value(&checked).expect("serialize update"),
serde_json::json!({"push_relay": {"relay_consent_accepted": true}})
);
}
@@ -1841,6 +1872,8 @@ mod tests {
serde_json::json!({"altcha_captcha": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
"anonymous_enabled": false,
}})
@@ -1871,6 +1904,84 @@ 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 [
(
"voice_ns",
build_voice_noise_suppression_update
as fn(&MultiValueForm) -> Result<InstanceConfigUpdateRequest, String>,
),
("domain_migration", build_domain_migration_update),
("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(),
);
assert_eq!(
build(&form).expect_err("invalid guild id"),
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
"{prefix}"
);
}
}
#[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");
@@ -8,8 +8,8 @@ use crate::{
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
InstanceRegistrationResponse, LimitConfigResponse, NoiseSuppressionBackend,
PUSH_SERVICE_DELIVERY_DEFAULT_SALT, PendingRegistrationResponse,
PushServiceDeliveryConfigResponse, RegistrationUrlResponse, SsoConfigResponse,
PROFILE_TIMEZONE_DEFAULT_SALT, PendingRegistrationResponse, ProfileTimezoneConfigResponse,
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse,
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
},
config::AdminConfig,
@@ -138,6 +138,13 @@ pub fn instance_config_page(
(integrations_config_section(base, csrf_token, &instance_config.integrations))
},
))
(config_group(
"Push notifications",
"Consent for the relay that delivers official mobile app notifications.",
html! {
(push_relay_section(base, csrf_token, &instance_config.push_relay))
},
))
(config_group(
"Media & retention",
"Attachment expiry rules that can be changed without editing environment variables.",
@@ -151,9 +158,9 @@ pub fn instance_config_page(
html! {
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
(push_service_delivery_section(base, csrf_token, &instance_config.push_service_delivery))
(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))
@@ -1120,6 +1127,38 @@ fn voice_noise_suppression_section(
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"voice_ns_include_premium_users",
"true",
"Include premium users",
voice_noise_suppression.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(
"voice_ns_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&voice_noise_suppression.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
voice_noise_suppression.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(
"voice_ns_excluded_user_ids",
@@ -1182,138 +1221,69 @@ fn voice_noise_suppression_section(
)
}
fn push_service_delivery_section(
fn push_relay_section(
base: &str,
csrf_token: &str,
push_service_delivery: &PushServiceDeliveryConfigResponse,
push_relay: &PushRelayConfigResponse,
) -> Markup {
let status = if push_service_delivery.enabled {
("Live", BadgeVariant::Success)
let status = if push_relay.relay_consent_accepted {
("Accepted", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = push_service_delivery.included_user_ids.join("\n");
let excluded_user_ids = push_service_delivery.excluded_user_ids.join("\n");
let relay_consent_stamp = match (
push_service_delivery.relay_consent_accepted_at.as_deref(),
push_service_delivery.relay_consent_accepted_by.as_deref(),
) {
(Some(at), Some(by)) => Some(format!("Accepted {at} by user {by}")),
(Some(at), None) => Some(format!("Accepted {at}")),
_ => None,
("Not accepted", BadgeVariant::Default)
};
let accepted_at =
format_optional_admin_timestamp(push_relay.relay_consent_accepted_at.as_deref(), "Never");
let accepted_by = push_relay
.relay_consent_accepted_by
.as_deref()
.unwrap_or("Nobody");
section_card_with_description(
"Push Service Delivery",
"Routes push notification delivery for the selected accounts through the push service. \
Accounts the rollout does not select keep the current path.",
"Push Relay",
"Official mobile app notifications travel through Fluxer's relay to Apple and Google. \
The relay delivers them only after an operator accepts its privacy notice.",
html! {
form method="post" action={(base) "/instance-config?action=update_push_service_delivery"} {
form method="post" action={(base) "/instance-config?action=update_push_relay"} {
(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" }
h3 class="text-sm font-semibold text-neutral-900" { "Relay consent" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (push_service_delivery.config_version)
}
}
(checkbox(
"push_service_delivery_enabled",
"true",
"Hand push notifications to the push service",
push_service_delivery.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every notification keeps the \
current delivery path, so the rollout and targeting fields below have no \
effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Managed relay consent" }
(checkbox(
"push_service_delivery_relay_consent_accepted",
"push_relay_consent_accepted",
"true",
"Accept the push relay supplemental privacy notice",
push_service_delivery.relay_consent_accepted,
push_relay.relay_consent_accepted,
true,
))
p class="text-xs text-neutral-500" {
"Required only for the official mobile apps, whose notifications travel \
through Fluxer's relay to Apple and Google. Until this is accepted those \
notifications are dropped. Self-hosted UnifiedPush and ntfy endpoints \
never reach the relay and are unaffected. "
"Until this is accepted official mobile app notifications are dropped. \
Self-hosted UnifiedPush and ntfy endpoints never reach the relay and are \
unaffected. "
a href="https://fluxer.com/push-relay" target="_blank" rel="noreferrer"
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
"Read the notice"
}
}
@if let Some(stamp) = relay_consent_stamp {
p class="text-xs text-neutral-500" { (stamp) }
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"push_service_delivery_rollout_basis_points",
"Rollout (basis points)",
&push_service_delivery.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"push_service_delivery_rollout_salt",
"Rollout Salt",
&push_service_delivery.rollout_salt,
PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(form_field_group("Accepted at", "push_relay_consent_accepted_at", false, None, None,
html! {
input type="text" id="push_relay_consent_accepted_at"
value=(accepted_at)
disabled class=(FORM_INPUT_CLASS);
},
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"push_service_delivery_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
(form_field_group("Accepted by user ID", "push_relay_consent_accepted_by", false, None, None,
html! {
input type="text" id="push_relay_consent_accepted_by"
value=(accepted_by)
disabled class=(FORM_INPUT_CLASS);
},
))
(entry_count_hint(
push_service_delivery.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save. Blank entries and duplicate \
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"push_service_delivery_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
push_service_delivery.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. This is the per-user kill switch."
}
}
(form_actions(html! {
(submit_button("Save Push Service Delivery Configuration"))
(submit_button("Save Push Relay Settings"))
}))
}
}
@@ -1425,6 +1395,38 @@ fn domain_migration_section(
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"domain_migration_include_premium_users",
"true",
"Include premium users",
domain_migration.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(
"domain_migration_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&domain_migration.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
domain_migration.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(
"domain_migration_excluded_user_ids",
@@ -1546,6 +1548,38 @@ fn altcha_captcha_section(
regardless of the percentage above. Invalid entries prevent the save."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"altcha_captcha_include_premium_users",
"true",
"Include premium users",
altcha_captcha.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(
"altcha_captcha_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&altcha_captcha.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
altcha_captcha.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(
"altcha_captcha_excluded_user_ids",
@@ -1593,6 +1627,143 @@ 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,
@@ -2259,26 +2430,28 @@ mod tests {
}
#[test]
fn push_service_delivery_section_shows_the_relay_consent_toggle() {
let accepted = PushServiceDeliveryConfigResponse {
fn push_relay_section_shows_the_consent_toggle() {
let accepted = PushRelayConfigResponse {
relay_consent_accepted: true,
relay_consent_accepted_at: Some("2026-09-27T10:11:12.000Z".to_owned()),
relay_consent_accepted_by: Some("1130650140672000000".to_owned()),
..PushServiceDeliveryConfigResponse::default()
};
let markup = push_service_delivery_section("/admin", "csrf", &accepted).into_string();
assert!(markup.contains("name=\"push_service_delivery_relay_consent_accepted\""));
let markup = push_relay_section("/admin", "csrf", &accepted).into_string();
assert!(markup.contains("action=update_push_relay"));
assert!(markup.contains("name=\"push_relay_consent_accepted\""));
assert!(markup.contains("https://fluxer.com/push-relay"));
assert!(markup.contains("Accepted 2026-09-27T10:11:12.000Z by user 1130650140672000000"));
assert!(markup.contains("value=\"Sep 27, 2026, 10:11 AM UTC\""));
assert!(markup.contains("value=\"1130650140672000000\""));
assert!(!markup.contains("name=\"push_relay_consent_accepted_at\""));
assert!(!markup.contains("name=\"push_relay_consent_accepted_by\""));
assert!(!markup.to_lowercase().contains("rollout"));
let unaccepted = push_service_delivery_section(
"/admin",
"csrf",
&PushServiceDeliveryConfigResponse::default(),
)
.into_string();
assert!(unaccepted.contains("name=\"push_service_delivery_relay_consent_accepted\""));
assert!(!unaccepted.contains("Accepted "));
let unaccepted =
push_relay_section("/admin", "csrf", &PushRelayConfigResponse::default()).into_string();
assert!(unaccepted.contains("name=\"push_relay_consent_accepted\""));
assert!(unaccepted.contains("Not accepted"));
assert!(unaccepted.contains("value=\"Never\""));
assert!(unaccepted.contains("value=\"Nobody\""));
}
#[test]
+36 -39
View File
@@ -403,19 +403,15 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"included_guild_ids": ["1500000000000000005"],
"include_premium_users": true,
"suppression_strength": 80,
"future_presentation_knob": "verbose",
"future_knob": 7,
"future_object_knob": {"nested": true},
"future_list_knob": ["a", "b"]
},
"push_service_delivery": {
"enabled": true,
"config_version": 3,
"rollout_basis_points": 5000,
"rollout_salt": "push-service-delivery-v1",
"included_user_ids": ["1500000000000000002"],
"excluded_user_ids": [],
"push_relay": {
"relay_consent_accepted": true,
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
"relay_consent_accepted_by": "1130650140672000000"
@@ -427,6 +423,8 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"rollout_salt": "domain-migration-v1",
"included_user_ids": ["1500000000000000001"],
"excluded_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"future_migration_knob": 9,
"anonymous_rollout_basis_points": 100,
"standalone_forwarding": true
@@ -441,8 +439,21 @@ 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
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"registration": {
"mode": "open",
@@ -579,12 +590,18 @@ fn deserialize_instance_config_response_with_unknown_keys() {
assert_eq!(resp.domain_migration.included_user_ids.len(), 1);
assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100);
assert!(resp.domain_migration.standalone_forwarding);
assert!(resp.push_service_delivery.relay_consent_accepted);
assert!(resp.push_relay.relay_consent_accepted);
assert!(resp.altcha_captcha.enabled);
assert_eq!(resp.altcha_captcha.config_version, 3);
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!(resp.voice_noise_suppression.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));
@@ -618,15 +635,9 @@ fn deserialize_instance_config_response_with_unknown_keys() {
}
#[test]
fn deserialize_push_service_delivery_relay_consent() {
let accepted: types::PushServiceDeliveryConfigResponse = serde_json::from_str(
fn deserialize_push_relay_config() {
let accepted: types::PushRelayConfigResponse = serde_json::from_str(
r#"{
"enabled": true,
"config_version": 3,
"rollout_basis_points": 5000,
"rollout_salt": "push-service-delivery-v1",
"included_user_ids": [],
"excluded_user_ids": [],
"relay_consent_accepted": true,
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
"relay_consent_accepted_by": "1130650140672000000"
@@ -644,37 +655,23 @@ fn deserialize_push_service_delivery_relay_consent() {
Some("1130650140672000000")
);
let legacy: types::PushServiceDeliveryConfigResponse = serde_json::from_str(
r#"{
"enabled": true,
"config_version": 3,
"rollout_basis_points": 5000,
"rollout_salt": "push-service-delivery-v1",
"included_user_ids": [],
"excluded_user_ids": []
}"#,
)
.expect("a response written before relay consent must still deserialize");
let empty: types::PushRelayConfigResponse =
serde_json::from_str("{}").expect("an empty push relay config must deserialize");
assert!(!legacy.relay_consent_accepted);
assert!(legacy.relay_consent_accepted_at.is_none());
assert!(legacy.relay_consent_accepted_by.is_none());
assert!(!empty.relay_consent_accepted);
assert!(empty.relay_consent_accepted_at.is_none());
assert!(empty.relay_consent_accepted_by.is_none());
}
#[test]
fn serialize_push_service_delivery_update_omits_an_unset_relay_consent() {
let without = types::PushServiceDeliveryConfigUpdateRequest {
enabled: Some(true),
..Default::default()
};
fn serialize_push_relay_update_omits_an_unset_consent() {
assert_eq!(
serde_json::to_value(&without).unwrap(),
serde_json::json!({"enabled": true})
serde_json::to_value(types::PushRelayConfigUpdateRequest::default()).unwrap(),
serde_json::json!({})
);
let with = types::PushServiceDeliveryConfigUpdateRequest {
let with = types::PushRelayConfigUpdateRequest {
relay_consent_accepted: Some(true),
..Default::default()
};
assert_eq!(
serde_json::to_value(&with).unwrap(),
@@ -16,7 +16,7 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {
getGatewayRolloutConfigPublisher,
getInstanceConfigRepository,
getPushServiceDeliveryConfigPublisher,
getPushRelayConfigPublisher,
} from '@app/api/middleware/ServiceSingletons';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
@@ -37,11 +37,8 @@ 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 {
type PushServiceDeliveryConfig,
PushServiceDeliveryConfigSchema,
type PushServiceDeliveryConfigUpdateRequest,
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import {ProfileTimezoneConfigSchema} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
@@ -70,9 +67,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
ssoConfig,
gatewayRollout,
voiceNoiseSuppression,
pushServiceDelivery,
pushRelay,
domainMigration,
altchaCaptcha,
profileTimezone,
experimentDelivery,
registrationConfig,
registrationUrls,
@@ -81,9 +79,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getSsoConfig(),
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getPushServiceDeliveryConfig(),
instanceConfigRepository.getPushRelayConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
instanceConfigRepository.getProfileTimezoneConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
@@ -115,9 +114,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
},
gateway_rollout: gatewayRollout,
voice_noise_suppression: voiceNoiseSuppression,
push_service_delivery: pushServiceDelivery,
push_relay: pushRelay,
domain_migration: domainMigration,
altcha_captcha: altchaCaptcha,
profile_timezone: profileTimezone,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
@@ -203,10 +203,10 @@ async function grantSetupCompleterAdminACL(ctx: Context<HonoEnv>): Promise<boole
}
function relayConsentStamp(
current: PushServiceDeliveryConfig,
patch: Partial<PushServiceDeliveryConfigUpdateRequest>,
current: PushRelayConfig,
patch: PushRelayConfigUpdateRequest,
adminUserId: string,
): Partial<PushServiceDeliveryConfig> {
): Partial<PushRelayConfig> {
const accepted = patch.relay_consent_accepted;
if (accepted === undefined || accepted === current.relay_consent_accepted) {
return {};
@@ -295,19 +295,16 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
}
}
if (data.push_service_delivery) {
const patch = omitUndefinedFields(data.push_service_delivery);
if (data.push_relay) {
const patch = omitUndefinedFields(data.push_relay);
if (Object.keys(patch).length > 0) {
const adminUserId = ctx.get('adminUserId').toString();
const landed = await instanceConfigRepository.updatePushServiceDeliveryConfig((current) =>
PushServiceDeliveryConfigSchema.parse({
...current,
...patch,
...relayConsentStamp(current, patch, adminUserId),
config_version: current.config_version + 1,
}),
);
await getPushServiceDeliveryConfigPublisher().publish(landed);
const landed = await instanceConfigRepository.updatePushRelayConfig((current) => ({
...current,
...patch,
...relayConsentStamp(current, patch, adminUserId),
}));
await getPushRelayConfigPublisher().publish(landed);
}
}
if (data.domain_migration) {
@@ -334,6 +331,18 @@ 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) =>
@@ -4,7 +4,7 @@ import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
@@ -16,12 +16,12 @@ import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
type PushServiceDeliveryConfig,
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
type LegacyPushServiceDeliveryWire,
toLegacyPushServiceDeliveryWire,
} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const PUSH_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_config';
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
describe('instance config admin PATCH under concurrent writes', () => {
let harness: ApiTestHarness;
@@ -55,13 +55,13 @@ describe('instance config admin PATCH under concurrent writes', () => {
const patchConfig = (admin: TestAccount, body: Record<string, unknown>) =>
createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
const spyOnPushDeliveryPublishes = () =>
vi.spyOn(PushServiceDeliveryConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
const spyOnPushRelayPublishes = () =>
vi.spyOn(PushRelayConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
async function readStoredPushServiceDelivery(): Promise<PushServiceDeliveryConfig> {
const raw = await executor.readDirectly(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
if (raw === null) throw new Error('push service delivery config was never stored');
return JSON.parse(raw) as PushServiceDeliveryConfig;
async function readStoredPushRelay(): Promise<LegacyPushServiceDeliveryWire> {
const raw = await executor.readDirectly(PUSH_RELAY_CONFIG_KEY);
if (raw === null) throw new Error('push relay config was never stored');
return JSON.parse(raw) as LegacyPushServiceDeliveryWire;
}
async function listConfigUpdateAudits(): Promise<Array<AdminAuditLog>> {
@@ -84,37 +84,32 @@ describe('instance config admin PATCH under concurrent writes', () => {
});
it('answers with a conflict and neither writes, publishes nor audits once every attempt has lost the race', async () => {
const publish = spyOnPushDeliveryPublishes();
const publish = spyOnPushRelayPublishes();
const admin = await createAdmin();
await patchConfig(admin, {push_service_delivery: {enabled: true, rollout_basis_points: 1000}}).execute();
await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
publish.mockClear();
const auditsBefore = await listConfigUpdateAudits();
executor.watch(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
executor.watch(PUSH_RELAY_CONFIG_KEY);
const unaccepted = {
relay_consent_accepted: false,
relay_consent_accepted_at: null,
relay_consent_accepted_by: null,
};
let competingWrites = 0;
executor.competeBeforeEachWrite(async () => {
competingWrites++;
await executor.writeDirectly(
PUSH_SERVICE_DELIVERY_CONFIG_KEY,
JSON.stringify({
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
enabled: false,
rollout_basis_points: 1000,
config_version: 100 + competingWrites,
}),
PUSH_RELAY_CONFIG_KEY,
JSON.stringify(toLegacyPushServiceDeliveryWire(unaccepted, 100 + competingWrites)),
);
});
await patchConfig(admin, {push_service_delivery: {rollout_basis_points: 5000}})
await patchConfig(admin, {push_relay: {relay_consent_accepted: true}})
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.CONFLICT)
.execute();
expect(executor.events).not.toContain('write');
expect(await readStoredPushServiceDelivery()).toEqual({
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
enabled: false,
rollout_basis_points: 1000,
config_version: 100 + competingWrites,
});
expect(await readStoredPushRelay()).toEqual(toLegacyPushServiceDeliveryWire(unaccepted, 100 + competingWrites));
expect(publish).not.toHaveBeenCalled();
expect(await listConfigUpdateAudits()).toHaveLength(auditsBefore.length);
});
@@ -2,24 +2,54 @@
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {LegacyPushServiceDeliveryWire} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
const ACCEPTED_AT = '2026-09-20T08:00:00.000Z';
const ACCEPTED_BY = '1500000000000000007';
const PROD_ROW = {
enabled: true,
config_version: 41,
rollout_basis_points: 10000,
rollout_salt: 'push-service-delivery-v1',
included_user_ids: [],
excluded_user_ids: [],
relay_consent_accepted: true,
relay_consent_accepted_at: ACCEPTED_AT,
relay_consent_accepted_by: ACCEPTED_BY,
};
interface PushServiceDeliveryRpcResponse {
type: 'get_push_service_delivery_config';
data: {config: LegacyPushServiceDeliveryWire};
}
describe('push relay supplemental notice consent', () => {
let harness: ApiTestHarness;
let executor: InstanceConfigWriteRaceExecutor;
beforeAll(async () => {
harness = await createApiTestHarness();
executor = new InstanceConfigWriteRaceExecutor(new InMemoryCassandraQueryExecutor());
setCassandraQueryExecutorForTesting(executor);
});
beforeEach(async () => {
await harness.reset();
vi.spyOn(PushServiceDeliveryConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
vi.spyOn(PushRelayConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
});
afterEach(() => {
@@ -43,63 +73,88 @@ describe('push relay supplemental notice consent', () => {
const readConfig = (admin: TestAccount) =>
createBuilder<InstanceConfigResponse>(harness, admin.token).get('/admin/instance/config');
const readRpcConfig = async (): Promise<LegacyPushServiceDeliveryWire> => {
const response = await createBuilder<PushServiceDeliveryRpcResponse>(harness, '')
.post('/test/rpc-session-init')
.body({type: 'get_push_service_delivery_config'})
.expect(HTTP_STATUS.OK)
.execute();
expect(response.type).toBe('get_push_service_delivery_config');
return response.data.config;
};
async function storeRow(row: Record<string, unknown>): Promise<void> {
await executor.writeDirectly(PUSH_RELAY_CONFIG_KEY, JSON.stringify(row));
getInstanceConfigRepository().clearCacheForTesting();
}
async function readStoredRow(): Promise<unknown> {
const raw = await executor.readDirectly(PUSH_RELAY_CONFIG_KEY);
if (raw === null) throw new Error('push relay config was never stored');
return JSON.parse(raw);
}
it('reads back as unaccepted before an operator agrees', async () => {
const admin = await createAdmin();
const config = await readConfig(admin).execute();
expect(config.push_service_delivery).toMatchObject({
expect(config.push_relay).toEqual({
relay_consent_accepted: false,
relay_consent_accepted_at: null,
relay_consent_accepted_by: null,
});
});
it('keeps the consent of a stored push service delivery row', async () => {
const admin = await createAdmin();
await storeRow(PROD_ROW);
const config = await readConfig(admin).execute();
expect(config.push_relay).toEqual({
relay_consent_accepted: true,
relay_consent_accepted_at: ACCEPTED_AT,
relay_consent_accepted_by: ACCEPTED_BY,
});
});
it('reads a stored row without consent fields as unaccepted', async () => {
const admin = await createAdmin();
await storeRow({enabled: true, config_version: 3, rollout_basis_points: 10000});
const config = await readConfig(admin).execute();
expect(config.push_relay.relay_consent_accepted).toBe(false);
expect(await readRpcConfig()).toMatchObject({config_version: 3, relay_consent_accepted: false});
});
it('stamps the acting admin and the acceptance time when consent is given', async () => {
const admin = await createAdmin();
const updated = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
expect(updated.push_service_delivery.relay_consent_accepted).toBe(true);
expect(updated.push_service_delivery.relay_consent_accepted_by).toBe(admin.userId);
expect(Date.parse(updated.push_service_delivery.relay_consent_accepted_at ?? '')).not.toBeNaN();
});
it('keeps the first acceptance stamp when a later patch changes only the rollout', async () => {
const admin = await createAdmin();
const accepted = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
const rolledOut = await patchConfig(admin, {
push_service_delivery: {enabled: true, rollout_basis_points: 2500},
}).execute();
expect(rolledOut.push_service_delivery).toMatchObject({
enabled: true,
rollout_basis_points: 2500,
relay_consent_accepted: true,
relay_consent_accepted_at: accepted.push_service_delivery.relay_consent_accepted_at,
relay_consent_accepted_by: admin.userId,
});
expect(updated.push_relay.relay_consent_accepted).toBe(true);
expect(updated.push_relay.relay_consent_accepted_by).toBe(admin.userId);
expect(Date.parse(updated.push_relay.relay_consent_accepted_at ?? '')).not.toBeNaN();
});
it('keeps the stamp untouched when consent is re-sent unchanged', async () => {
const admin = await createAdmin();
const accepted = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
const accepted = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
const resent = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
const resent = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
expect(resent.push_service_delivery.relay_consent_accepted_at).toBe(
accepted.push_service_delivery.relay_consent_accepted_at,
);
expect(resent.push_relay).toEqual(accepted.push_relay);
});
it('clears the stamp when an operator withdraws consent', async () => {
const admin = await createAdmin();
await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
const withdrawn = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: false}}).execute();
const withdrawn = await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
expect(withdrawn.push_service_delivery).toMatchObject({
expect(withdrawn.push_relay).toEqual({
relay_consent_accepted: false,
relay_consent_accepted_at: null,
relay_consent_accepted_by: null,
@@ -110,23 +165,146 @@ describe('push relay supplemental notice consent', () => {
const admin = await createAdmin();
const updated = await patchConfig(admin, {
push_service_delivery: {
push_relay: {
relay_consent_accepted: true,
relay_consent_accepted_at: '2020-01-01T00:00:00.000Z',
relay_consent_accepted_by: '1500000000000000009',
},
}).execute();
expect(updated.push_service_delivery.relay_consent_accepted_at).not.toBe('2020-01-01T00:00:00.000Z');
expect(updated.push_service_delivery.relay_consent_accepted_by).toBe(admin.userId);
expect(updated.push_relay.relay_consent_accepted_at).not.toBe('2020-01-01T00:00:00.000Z');
expect(updated.push_relay.relay_consent_accepted_by).toBe(admin.userId);
});
it('publishes the consent to the delivery services', async () => {
it('writes the full legacy document and bumps the stored config version', async () => {
const admin = await createAdmin();
const publish = vi.mocked(PushServiceDeliveryConfigPublisher.prototype.publish);
await storeRow({
...PROD_ROW,
relay_consent_accepted: false,
relay_consent_accepted_at: null,
relay_consent_accepted_by: null,
});
await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
expect(publish).toHaveBeenCalledWith(expect.objectContaining({relay_consent_accepted: true}));
expect(await readStoredRow()).toEqual({
enabled: true,
config_version: 42,
rollout_basis_points: 10000,
rollout_salt: 'push-service-delivery-v1',
included_user_ids: [],
excluded_user_ids: [],
relay_consent_accepted: true,
relay_consent_accepted_at: updated.push_relay.relay_consent_accepted_at,
relay_consent_accepted_by: admin.userId,
});
await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
expect(await readStoredRow()).toMatchObject({
enabled: true,
config_version: 43,
rollout_basis_points: 10000,
relay_consent_accepted: false,
relay_consent_accepted_at: null,
relay_consent_accepted_by: null,
});
});
it('rewrites a partially enrolled stored row as full enrolment', async () => {
const admin = await createAdmin();
await storeRow({
...PROD_ROW,
enabled: false,
rollout_basis_points: 250,
rollout_salt: 'custom-salt',
included_user_ids: ['1500000000000000003'],
excluded_user_ids: ['1500000000000000004'],
});
await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
expect(await readStoredRow()).toMatchObject({
enabled: true,
config_version: 42,
rollout_basis_points: 10000,
rollout_salt: 'push-service-delivery-v1',
included_user_ids: [],
excluded_user_ids: [],
});
});
it('publishes the legacy delivery document with the consent', async () => {
const admin = await createAdmin();
const publish = vi.mocked(PushRelayConfigPublisher.prototype.publish);
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
expect(publish).toHaveBeenCalledTimes(1);
expect(publish).toHaveBeenCalledWith({
enabled: true,
config_version: 1,
rollout_basis_points: 10000,
rollout_salt: 'push-service-delivery-v1',
included_user_ids: [],
excluded_user_ids: [],
relay_consent_accepted: true,
relay_consent_accepted_at: updated.push_relay.relay_consent_accepted_at,
relay_consent_accepted_by: admin.userId,
});
});
it('does not write or publish for an empty push relay patch', async () => {
const admin = await createAdmin();
const publish = vi.mocked(PushRelayConfigPublisher.prototype.publish);
await patchConfig(admin, {push_relay: {}}).execute();
expect(publish).not.toHaveBeenCalled();
expect(await executor.readDirectly(PUSH_RELAY_CONFIG_KEY)).toBeNull();
});
it('ignores the retired push_service_delivery section', async () => {
const admin = await createAdmin();
const publish = vi.mocked(PushRelayConfigPublisher.prototype.publish);
const updated = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
expect(updated.push_relay.relay_consent_accepted).toBe(false);
expect(publish).not.toHaveBeenCalled();
});
it('answers the legacy delivery RPC with full enrolment and the stored consent', async () => {
await storeRow(PROD_ROW);
expect(await readRpcConfig()).toEqual(PROD_ROW);
});
it('answers the legacy delivery RPC with defaults before anything is stored', async () => {
expect(await readRpcConfig()).toEqual({
enabled: true,
config_version: 0,
rollout_basis_points: 10000,
rollout_salt: 'push-service-delivery-v1',
included_user_ids: [],
excluded_user_ids: [],
relay_consent_accepted: false,
relay_consent_accepted_at: null,
relay_consent_accepted_by: null,
});
});
it('answers the legacy delivery RPC with consent given through the admin API', async () => {
const admin = await createAdmin();
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
expect(await readRpcConfig()).toMatchObject({
enabled: true,
config_version: 1,
rollout_basis_points: 10000,
relay_consent_accepted: true,
relay_consent_accepted_at: updated.push_relay.relay_consent_accepted_at,
relay_consent_accepted_by: admin.userId,
});
});
});
@@ -43,7 +43,6 @@ async function revokeSessionTargets(
scope === 'all'
? users.deleteAllPushSubscriptions(userId)
: users.deletePushSubscriptionsForAuthSessions(userId, sessionIdHashes, {deleteUnboundSubscriptions: true}),
() => gateway.invalidatePushSubscriptions({userId}),
];
if (scope === 'selected' || targets.length > 0) {
steps.push(
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
import {LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
@@ -10,6 +11,7 @@ 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 {resolveVoiceNoiseSuppressionAssignment} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
@@ -30,20 +32,30 @@ export function ExperimentController(app: HonoApp) {
}),
async (ctx) => {
const instanceConfigRepository = ctx.get('instanceConfigRepository');
const [delivery, voiceConfig, domainMigrationConfig, altchaCaptchaConfig] = await Promise.all([
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
const [delivery, voiceConfig, domainMigrationConfig, altchaCaptchaConfig, profileTimezoneConfig] =
await Promise.all([
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
instanceConfigRepository.getProfileTimezoneConfig(),
]);
const user = ctx.get('user');
const userId = user.id.toString();
const targeting = await resolveExperimentTargeting(user, [
voiceConfig,
domainMigrationConfig,
altchaCaptchaConfig,
profileTimezoneConfig,
]);
const userId = ctx.get('user').id.toString();
const body: ExperimentAssignmentsResponse = {
poll_interval_seconds: delivery.poll_interval_seconds,
poll_jitter_percent: delivery.poll_jitter_percent,
assignments: {
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId),
altcha_captcha: resolveAltchaCaptchaAssignment(altchaCaptchaConfig, userId),
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId, targeting),
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')}"`;
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
import type {ExperimentTargeting} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
interface TargetableExperimentConfig {
readonly enabled: boolean;
readonly included_guild_ids: ReadonlyArray<string>;
}
const NO_GUILDS: ReadonlySet<string> = new Set();
export const ANONYMOUS_EXPERIMENT_TARGETING: ExperimentTargeting = {
memberGuildIds: NO_GUILDS,
premium: false,
};
export async function resolveExperimentTargeting(
user: User,
configs: ReadonlyArray<TargetableExperimentConfig>,
): Promise<ExperimentTargeting> {
const needsGuilds = configs.some((config) => config.enabled && config.included_guild_ids.length > 0);
const memberGuildIds = needsGuilds
? new Set((await getUserRepository().getUserGuildIds(user.id)).map((guildId) => guildId.toString()))
: NO_GUILDS;
return {memberGuildIds, premium: !user.isBot && user.isPremium()};
}
@@ -1,11 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
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 {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {grantPremium} from '@app/api/user/tests/UserTestUtils';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
@@ -14,6 +17,10 @@ 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_VOICE_NOISE_SUPPRESSION_CONFIG,
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
@@ -62,6 +69,7 @@ describe('GET /experiments', () => {
voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
altcha_captcha: INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
profile_timezone: INERT_PROFILE_TIMEZONE_ASSIGNMENT,
},
});
});
@@ -195,6 +203,159 @@ 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);
const outsider = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Experiment Guild');
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
await acceptInvite(harness, member.token, invite.code);
const repository = getInstanceConfigRepository();
await repository.setVoiceNoiseSuppressionConfig({
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
await repository.setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
await repository.setAltchaCaptchaConfig({
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
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.voice_noise_suppression).toMatchObject({user_targeted: true, source: 'user_rule'});
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.voice_noise_suppression).toMatchObject({user_targeted: false, source: null});
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 () => {
const subscriber = await createTestAccount(harness);
const visionary = await createTestAccount(harness);
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,
enabled: true,
include_premium_users: true,
});
for (const [account, expected] of [
[subscriber, true],
[visionary, true],
[free, false],
] as const) {
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(body.assignments.profile_timezone).toEqual({enabled: expected});
}
});
it('stores the guild ids and premium switch an admin sets for each experiment', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const guildIds = ['1500000000000000001', '1500000000000000002'];
const body = await createBuilder<
Record<
'voice_noise_suppression' | 'domain_migration' | 'altcha_captcha' | 'profile_timezone',
{included_guild_ids: Array<string>; include_premium_users: boolean}
>
>(harness, admin.token)
.patch('/admin/instance/config')
.body({
voice_noise_suppression: {included_guild_ids: guildIds, include_premium_users: true},
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.voice_noise_suppression.included_guild_ids).toEqual(guildIds);
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.voice_noise_suppression,
body.domain_migration,
body.altcha_captcha,
body.profile_timezone,
]) {
expect(section.include_premium_users).toBe(true);
}
});
it('serves the delivery cadence from the delivery config and not from the voice config', async () => {
const account = await createTestAccount(harness);
await getInstanceConfigRepository().setExperimentDeliveryConfig({
@@ -37,7 +37,6 @@ import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMe
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {ms} from 'itty-time';
const PUSH_BADGE_COUNT_BATCH_SIZE = 100;
const USER_PERMISSIONS_BATCH_SIZE = 100;
const GATEWAY_ERROR_TO_DOMAIN_ERROR: Record<string, () => Error> = {
@@ -67,18 +66,6 @@ interface DispatchPresenceParams {
data: unknown;
}
interface InvalidatePushBadgeCountParams {
userId: UserID;
}
interface InvalidatePushBadgeCountsParams {
userIds: Array<UserID>;
}
interface InvalidatePushSubscriptionsParams {
userId: UserID;
}
interface ClearPushChannelNotificationsParams {
userId: UserID;
channelId: ChannelID;
@@ -287,8 +274,6 @@ export class GatewayService {
private readonly MAX_BATCH_CONCURRENCY = 50;
private readonly PENDING_REQUEST_TIMEOUT_MS = ms('30 seconds');
private readonly AUTH_CONTEXT_FALLBACK_MS = ms('5 minutes');
private readonly BADGE_COUNTS_FALLBACK_MS = ms('5 minutes');
private badgeCountsUnsupportedUntil = 0;
constructor() {
this.rpcClient = GatewayRpcClient.getInstance();
@@ -704,48 +689,6 @@ export class GatewayService {
});
}
async invalidatePushBadgeCount({userId}: InvalidatePushBadgeCountParams): Promise<void> {
await this.call('push.invalidate_badge_count', {
user_id: userId.toString(),
});
}
async invalidatePushBadgeCounts({userIds}: InvalidatePushBadgeCountsParams): Promise<void> {
if (Date.now() < this.badgeCountsUnsupportedUntil) {
await this.invalidatePushBadgeCountsIndividually(userIds);
return;
}
const batches: Array<Array<UserID>> = [];
for (let index = 0; index < userIds.length; index += PUSH_BADGE_COUNT_BATCH_SIZE) {
batches.push(userIds.slice(index, index + PUSH_BADGE_COUNT_BATCH_SIZE));
}
try {
await Promise.all(
batches.map((batch) =>
this.call('push.invalidate_badge_counts', {user_ids: batch.map((userId) => userId.toString())}),
),
);
} catch (error) {
const transformedError = this.transformGatewayError(error);
if (!this.isAuthContextUnsupportedError(transformedError)) {
throw transformedError;
}
this.badgeCountsUnsupportedUntil = Date.now() + this.BADGE_COUNTS_FALLBACK_MS;
Logger.warn({error}, '[gateway-rpc] push.invalidate_badge_counts unavailable, falling back to per-user calls');
await this.invalidatePushBadgeCountsIndividually(userIds);
}
}
private async invalidatePushBadgeCountsIndividually(userIds: ReadonlyArray<UserID>): Promise<void> {
await Promise.all(userIds.map((userId) => this.invalidatePushBadgeCount({userId})));
}
async invalidatePushSubscriptions({userId}: InvalidatePushSubscriptionsParams): Promise<void> {
await this.call('push.invalidate_subscriptions', {
user_id: userId.toString(),
});
}
async clearPushChannelNotifications({
userId,
channelId,
@@ -292,12 +292,6 @@ export abstract class IGatewayService {
abstract dispatchPresence(params: {userId: UserID; event: GatewayDispatchEvent; data: unknown}): Promise<void>;
abstract invalidatePushBadgeCount(params: {userId: UserID}): Promise<void>;
abstract invalidatePushBadgeCounts(params: {userIds: Array<UserID>}): Promise<void>;
abstract invalidatePushSubscriptions(params: {userId: UserID}): Promise<void>;
abstract clearPushChannelNotifications(params: {
userId: UserID;
channelId: ChannelID;
@@ -41,9 +41,15 @@ import {
GatewayRolloutConfigSchema,
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {
type PushServiceDeliveryConfig,
PushServiceDeliveryConfigSchema,
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
type ProfileTimezoneConfig,
ProfileTimezoneConfigSchema,
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import {
type LegacyPushServiceDeliveryWire,
type PushRelayConfig,
PushRelayConfigSchema,
toLegacyPushServiceDeliveryWire,
} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {
type VoiceNoiseSuppressionConfig,
VoiceNoiseSuppressionConfigSchema,
@@ -70,9 +76,10 @@ import {z} from 'zod';
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
const PUSH_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_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';
@@ -379,9 +386,10 @@ type StoredConfigSection =
| 'app public'
| 'gateway rollout'
| 'voice noise suppression'
| 'push service delivery'
| 'push relay'
| 'domain migration'
| 'altcha captcha'
| 'profile timezone'
| 'experiment delivery'
| 'instance policy'
| 'integrations'
@@ -520,8 +528,25 @@ function parseStoredVoiceNoiseSuppressionConfig(raw: string | null): VoiceNoiseS
return parseStoredConfigOrDefault(VoiceNoiseSuppressionConfigSchema, raw, 'voice noise suppression');
}
function parseStoredPushServiceDeliveryConfig(raw: string | null): PushServiceDeliveryConfig {
return parseStoredConfigOrDefault(PushServiceDeliveryConfigSchema, raw, 'push service delivery');
const StoredPushRelayConfigSchema = PushRelayConfigSchema.extend({
config_version: z.number().int().min(0).default(0),
});
function parseStoredPushRelayConfig(raw: string | null): LegacyPushServiceDeliveryWire {
const {config_version, ...config} = salvageStoredConfig(
StoredPushRelayConfigSchema,
readStoredConfigValue(raw, 'push relay'),
'push relay',
);
return toLegacyPushServiceDeliveryWire(config, config_version);
}
function toPushRelayConfig(wire: LegacyPushServiceDeliveryWire): PushRelayConfig {
return {
relay_consent_accepted: wire.relay_consent_accepted,
relay_consent_accepted_at: wire.relay_consent_accepted_at,
relay_consent_accepted_by: wire.relay_consent_accepted_by,
};
}
function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationConfig {
@@ -532,6 +557,10 @@ 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');
}
@@ -1179,9 +1208,10 @@ export class InstanceConfigRepository {
parseStoredGatewayRolloutConfig(snapshot.get(GATEWAY_ROLLOUT_CONFIG_KEY) ?? null),
);
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
parseStoredPushServiceDeliveryConfig(snapshot.get(PUSH_SERVICE_DELIVERY_CONFIG_KEY) ?? null);
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);
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
checkStoredConfig('registration', () =>
@@ -1278,21 +1308,21 @@ export class InstanceConfigRepository {
);
}
async getPushServiceDeliveryConfig(): Promise<PushServiceDeliveryConfig> {
const raw = await this.getConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
return parseStoredPushServiceDeliveryConfig(raw);
async getLegacyPushServiceDeliveryWire(): Promise<LegacyPushServiceDeliveryWire> {
const raw = await this.getConfig(PUSH_RELAY_CONFIG_KEY);
return parseStoredPushRelayConfig(raw);
}
updatePushServiceDeliveryConfig(
update: (current: PushServiceDeliveryConfig) => PushServiceDeliveryConfig,
): Promise<PushServiceDeliveryConfig> {
return this.updateStoredConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY, (raw) =>
validateStoredConfig(
PushServiceDeliveryConfigSchema,
update(parseStoredPushServiceDeliveryConfig(raw)),
'push service delivery',
),
);
async getPushRelayConfig(): Promise<PushRelayConfig> {
return toPushRelayConfig(await this.getLegacyPushServiceDeliveryWire());
}
updatePushRelayConfig(update: (current: PushRelayConfig) => PushRelayConfig): Promise<LegacyPushServiceDeliveryWire> {
return this.updateStoredConfig(PUSH_RELAY_CONFIG_KEY, (raw) => {
const current = parseStoredPushRelayConfig(raw);
const next = validateStoredConfig(PushRelayConfigSchema, update(toPushRelayConfig(current)), 'push relay');
return toLegacyPushServiceDeliveryWire(next, current.config_version + 1);
});
}
async getDomainMigrationConfig(): Promise<DomainMigrationConfig> {
@@ -1333,6 +1363,27 @@ 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);
@@ -1,21 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {PushServiceDeliveryConfig} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import type {LegacyPushServiceDeliveryWire} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
const textEncoder = new TextEncoder();
export const PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT = 'config.push.delivery';
const PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT = 'config.push.delivery';
interface PushServiceDeliveryConfigNatsMessage {
type: 'push_service_delivery_config';
config: PushServiceDeliveryConfig;
config: LegacyPushServiceDeliveryWire;
}
export class PushServiceDeliveryConfigPublisher {
export class PushRelayConfigPublisher {
constructor(private readonly connectionManager: INatsConnectionManager) {}
async publish(config: PushServiceDeliveryConfig): Promise<void> {
async publish(config: LegacyPushServiceDeliveryWire): Promise<void> {
if (this.connectionManager.isClosed()) {
await this.connectionManager.connect();
}
@@ -2,6 +2,7 @@
import {createHmac} from 'node:crypto';
import {Config} from '@app/api/Config';
import {ANONYMOUS_EXPERIMENT_TARGETING, resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
import type {InstanceCaptchaEffectiveConfig} from '@app/api/instance/InstanceConfigRepository';
import {Logger} from '@app/api/Logger';
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
@@ -45,7 +46,8 @@ async function altchaChallengeData(altcha: AltchaProvider | null): Promise<Recor
async function resolveAltchaProvider(ctx: Context<HonoEnv>, user: User | undefined): Promise<AltchaProvider | null> {
const config = await ctx.get('instanceConfigRepository').getAltchaCaptchaConfig();
if (!altchaCaptchaAppliesTo(config, user ? user.id.toString() : null)) return null;
const targeting = user ? await resolveExperimentTargeting(user, [config]) : ANONYMOUS_EXPERIMENT_TARGETING;
if (!altchaCaptchaAppliesTo(config, user ? user.id.toString() : null, targeting)) return null;
return createAltchaProvider(config);
}
@@ -51,7 +51,7 @@ import {createUsersServiceClient} from '@app/api/infrastructure/UsersServiceClie
import {VirusScanService} from '@app/api/infrastructure/VirusScanService';
import {GatewayRolloutConfigPublisher} from '@app/api/instance/GatewayRolloutConfigPublisher';
import {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
import {InviteRepository} from '@app/api/invite/InviteRepository';
import {Logger} from '@app/api/Logger';
import {LimitConfigService} from '@app/api/limits/LimitConfigService';
@@ -157,13 +157,13 @@ export const getGatewayRolloutConfigPublisher = singleton(
),
);
export const getPushServiceDeliveryConfigPublisher = singleton(
export const getPushRelayConfigPublisher = singleton(
() =>
new PushServiceDeliveryConfigPublisher(
new PushRelayConfigPublisher(
new NatsConnectionManager({
url: Config.nats.coreUrl,
token: Config.nats.authToken || undefined,
name: 'fluxer-api-push-service-delivery-config',
name: 'fluxer-api-push-relay-config',
}),
),
);
+12 -11
View File
@@ -22404,14 +22404,8 @@
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
"description": "The user-selected accent color as an integer"
},
"timezone": {
"description": "The IANA timezone identifier saved by the user. Omitted unless the user has staff access.",
"type": ["string", "null"]
},
"timezone_privacy_flags": {
"description": "Bitfield controlling who can see the profile timezone. Omitted unless the user has staff access.",
"$ref": "#/components/schemas/ProfileFieldPrivacyFlags"
},
"timezone": {"description": "The IANA timezone identifier saved by the user", "type": ["string", "null"]},
"timezone_privacy_flags": {"$ref": "#/components/schemas/ProfileFieldPrivacyFlags"},
"banner": {"description": "The hash of the user profile banner image", "type": ["string", "null"]},
"banner_color": {
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
@@ -25121,11 +25115,11 @@
"anyOf": [{"$ref": "#/components/schemas/ColorType"}, {"type": "null"}]
},
"timezone": {
"description": "Staff-only IANA timezone identifier saved for profile local time. Ignored for non-staff users.",
"description": "IANA timezone identifier saved for profile local time. Ignored unless the profile_timezone experiment serves the user.",
"type": ["string", "null"]
},
"timezone_privacy_flags": {
"description": "Staff-only bitfield controlling who can see the profile timezone. Ignored for non-staff users.",
"description": "Bitfield controlling who can see the profile timezone. Ignored unless the profile_timezone experiment serves the user.",
"$ref": "#/components/schemas/ProfileFieldPrivacyFlags"
},
"premium_badge_hidden": {"type": "boolean", "description": "Whether to hide the premium badge"},
@@ -27954,7 +27948,8 @@
"properties": {
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"},
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"},
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaAssignmentResponse"}
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaAssignmentResponse"},
"profile_timezone": {"$ref": "#/components/schemas/ProfileTimezoneAssignmentResponse"}
},
"additionalProperties": false
}
@@ -31630,6 +31625,12 @@
"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"}},
@@ -15,53 +15,6 @@ import {ReadStateService} from '@app/api/read_state/ReadStateService';
import {BadGatewayError} from '@fluxer/errors/src/domains/core/BadGatewayError';
import {describe, expect, it, vi} from 'vitest';
describe('ReadStateService.bulkIncrementMentionCounts', () => {
it('invalidates badge counts for touched users in a single bulk call', async () => {
const channelId = createChannelID(2n);
const messageId = createMessageID(3n);
const touched: Array<{userId: UserID; channelId: ChannelID}> = [
{userId: createUserID(10n), channelId},
{userId: createUserID(11n), channelId},
{userId: createUserID(10n), channelId: createChannelID(4n)},
];
const repository = {
bulkIncrementMentionCounts: vi.fn().mockResolvedValue(touched),
} as unknown as IReadStateRepository;
const invalidatePushBadgeCounts = vi.fn().mockResolvedValue(undefined);
const invalidatePushBadgeCount = vi.fn().mockResolvedValue(undefined);
const gatewayService = {
invalidatePushBadgeCounts,
invalidatePushBadgeCount,
} as unknown as IGatewayService;
const service = new ReadStateService(repository, gatewayService);
await service.bulkIncrementMentionCounts([
{userId: createUserID(10n), channelId, messageId},
{userId: createUserID(11n), channelId, messageId},
{userId: createUserID(12n), channelId, messageId},
]);
expect(invalidatePushBadgeCount).not.toHaveBeenCalled();
expect(invalidatePushBadgeCounts).toHaveBeenCalledTimes(1);
expect(invalidatePushBadgeCounts).toHaveBeenCalledWith({userIds: [createUserID(10n), createUserID(11n)]});
});
it('skips the bulk call when no read state was touched', async () => {
const repository = {
bulkIncrementMentionCounts: vi.fn().mockResolvedValue([]),
} as unknown as IReadStateRepository;
const invalidatePushBadgeCounts = vi.fn().mockResolvedValue(undefined);
const gatewayService = {invalidatePushBadgeCounts} as unknown as IGatewayService;
const service = new ReadStateService(repository, gatewayService);
await service.bulkIncrementMentionCounts([
{userId: createUserID(10n), channelId: createChannelID(2n), messageId: createMessageID(3n)},
]);
expect(invalidatePushBadgeCounts).not.toHaveBeenCalled();
});
});
const USER_ID = createUserID(20n);
const CHANNEL_ID = createChannelID(21n);
const MESSAGE_ID = createMessageID(22n);
@@ -78,7 +31,7 @@ function makeReadState(channelId: ChannelID, messageId: MessageID, mentionCount
}
describe('ReadStateService gateway side effects after the write', () => {
it('returns the committed read state when the badge invalidation fails', async () => {
it('returns the committed read state when clearing push notifications fails', async () => {
const stored: Array<{channelId: ChannelID; messageId: MessageID}> = [];
const repository = {
upsertReadState: vi.fn(async (_userId: UserID, channelId: ChannelID, messageId: MessageID) => {
@@ -87,8 +40,7 @@ describe('ReadStateService gateway side effects after the write', () => {
}),
} as unknown as IReadStateRepository;
const gatewayService = {
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
clearPushChannelNotifications: vi.fn().mockRejectedValue(new BadGatewayError()),
dispatchPresence: vi.fn().mockResolvedValue(undefined),
} as unknown as IGatewayService;
const service = new ReadStateService(repository, gatewayService);
@@ -113,7 +65,6 @@ describe('ReadStateService gateway side effects after the write', () => {
),
} as unknown as IReadStateRepository;
const gatewayService = {
invalidatePushBadgeCount: vi.fn().mockResolvedValue(undefined),
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
dispatchPresence: vi.fn().mockRejectedValue(new BadGatewayError()),
} as unknown as IGatewayService;
@@ -138,7 +89,6 @@ describe('ReadStateService gateway side effects after the write', () => {
}),
} as unknown as IReadStateRepository;
const gatewayService = {
invalidatePushBadgeCount: vi.fn().mockResolvedValue(undefined),
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
dispatchPresence: vi.fn().mockRejectedValue(new BadGatewayError()),
} as unknown as IGatewayService;
@@ -156,42 +106,13 @@ describe('ReadStateService gateway side effects after the write', () => {
expect(stored).toEqual(['21', '23']);
});
it('deletes the read state when the badge invalidation fails', async () => {
const deleteReadState = vi.fn().mockResolvedValue(undefined);
const repository = {deleteReadState} as unknown as IReadStateRepository;
const gatewayService = {
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
} as unknown as IGatewayService;
const service = new ReadStateService(repository, gatewayService);
await expect(service.deleteReadState({userId: USER_ID, channelId: CHANNEL_ID})).resolves.toBeUndefined();
expect(deleteReadState).toHaveBeenCalledWith(USER_ID, CHANNEL_ID);
});
it('increments the mention count when the badge invalidation fails', async () => {
const incrementReadStateMentions = vi.fn().mockResolvedValue(makeReadState(CHANNEL_ID, MESSAGE_ID, 1));
const repository = {incrementReadStateMentions} as unknown as IReadStateRepository;
const gatewayService = {
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
} as unknown as IGatewayService;
const service = new ReadStateService(repository, gatewayService);
await expect(
service.incrementMentionCount({userId: USER_ID, channelId: CHANNEL_ID, messageId: MESSAGE_ID}),
).resolves.toBeUndefined();
expect(incrementReadStateMentions).toHaveBeenCalledTimes(1);
});
it('returns the bulk acknowledged states when the badge invalidation fails', async () => {
it('returns the bulk acknowledged states when clearing push notifications fails', async () => {
const updated = [makeReadState(CHANNEL_ID, MESSAGE_ID)];
const repository = {
bulkAckMessages: vi.fn().mockResolvedValue(updated),
} as unknown as IReadStateRepository;
const gatewayService = {
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
clearPushChannelNotifications: vi.fn().mockRejectedValue(new BadGatewayError()),
dispatchPresence: vi.fn().mockResolvedValue(undefined),
} as unknown as IGatewayService;
const service = new ReadStateService(repository, gatewayService);
@@ -34,7 +34,6 @@ export class ReadStateService {
undefined,
manual ?? false,
);
await this.invalidatePushBadgeCount(userId);
if (!silent) {
await this.clearPushChannelNotifications({userId, channelId, messageId});
}
@@ -115,7 +114,6 @@ export class ReadStateService {
try {
const updatedReadStates = await this.repository.bulkAckMessages(userId, readStates);
const readStatesByChannel = new Map(updatedReadStates.map((readState) => [readState.channelId, readState]));
await this.invalidatePushBadgeCount(userId);
await Promise.all(
readStates.map(({channelId, messageId}) =>
Promise.all([
@@ -145,7 +143,6 @@ export class ReadStateService {
async deleteReadState({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
await this.repository.deleteReadState(userId, channelId);
await this.invalidatePushBadgeCount(userId);
}
async incrementMentionCount({
@@ -157,11 +154,7 @@ export class ReadStateService {
channelId: ChannelID;
messageId: MessageID;
}): Promise<void> {
const readState = await this.repository.incrementReadStateMentions(userId, channelId, messageId, 1);
if (readState == null) {
return;
}
await this.invalidatePushBadgeCount(userId);
await this.repository.incrementReadStateMentions(userId, channelId, messageId, 1);
}
async bulkIncrementMentionCounts(
@@ -175,15 +168,7 @@ export class ReadStateService {
return;
}
try {
const appliedUpdates = await this.repository.bulkIncrementMentionCounts(updates);
const uniqueUserIds = Array.from(new Set(appliedUpdates.map((update) => update.userId)));
if (uniqueUserIds.length === 0) {
return;
}
await this.gatewayService.invalidatePushBadgeCounts({userIds: uniqueUserIds}).catch((error) => {
Logger.error({userCount: uniqueUserIds.length, error}, 'Failed to invalidate push badge counts');
return null;
});
await this.repository.bulkIncrementMentionCounts(updates);
} catch (error) {
Logger.error({error}, 'Bulk increment mention counts failed');
throw error;
@@ -196,13 +181,6 @@ export class ReadStateService {
await this.dispatchPinsAck({userId, channelId, timestamp});
}
private async invalidatePushBadgeCount(userId: UserID): Promise<void> {
await this.gatewayService.invalidatePushBadgeCount({userId}).catch((error) => {
Logger.error({userId: userId.toString(), error}, 'Failed to invalidate push badge count');
return null;
});
}
private async dispatchMessageAck(params: {
userId: UserID;
channelId: ChannelID;
+1 -9
View File
@@ -99,7 +99,6 @@ import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import {UnauthorizedError} from '@fluxer/errors/src/domains/core/UnauthorizedError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {pushServiceDeliveryEnrols} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {VoiceStateResponse} from '@fluxer/schema/src/domains/gateway/GatewaySchemas';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
@@ -433,13 +432,6 @@ export class RpcService {
}),
};
case 'send_apns_push': {
const deliveryConfig = await this.instanceConfigRepository.getPushServiceDeliveryConfig();
if (pushServiceDeliveryEnrols(deliveryConfig, request.user_id.toString())) {
Logger.warn(
{userId: request.user_id.toString(), configVersion: deliveryConfig.config_version},
'push service delivery path mismatch',
);
}
const result = await sendApnsPush({
userId: request.user_id.toString(),
subscriptionId: request.subscription_id,
@@ -646,7 +638,7 @@ export class RpcService {
};
}
case 'get_push_service_delivery_config': {
const config = await this.instanceConfigRepository.getPushServiceDeliveryConfig();
const config = await this.instanceConfigRepository.getLegacyPushServiceDeliveryWire();
return {
type: 'get_push_service_delivery_config',
data: {config},
@@ -795,12 +795,6 @@ export class NoopGatewayService extends IGatewayService {
async dispatchPresence(_params: {userId: UserID; event: GatewayDispatchEvent; data: unknown}): Promise<void> {}
async invalidatePushBadgeCount(_params: {userId: UserID}): Promise<void> {}
async invalidatePushBadgeCounts(_params: {userIds: Array<UserID>}): Promise<void> {}
async invalidatePushSubscriptions(_params: {userId: UserID}): Promise<void> {}
async clearPushChannelNotifications(_params: {
userId: UserID;
channelId: ChannelID;
+7 -2
View File
@@ -2,7 +2,9 @@
import {Config} from '@app/api/Config';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
import {getCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
import {accountPolicyContactHasCapability} from '@app/api/risk/AccountPolicyService';
import {getCachedDeferredPhoneGateEnabled} from '@app/api/risk/DeferredPhoneGateCache';
@@ -13,6 +15,7 @@ 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';
@@ -289,6 +292,8 @@ export function isBugHunterBotUser(user: Pick<User, 'flags' | 'isBot'>): boolean
return user.isBot && (user.flags & UserFlags.BUG_HUNTER) !== 0n;
}
export function canUseProfileTimezone(user: Pick<PremiumCheckable, 'flags'>): boolean {
return (user.flags & UserFlags.STAFF) !== 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;
}
+3 -8
View File
@@ -10,7 +10,7 @@ import type {User} from '@app/api/models/User';
import type {UserGuildSettings} from '@app/api/models/UserGuildSettings';
import type {UserSettings} from '@app/api/models/UserSettings';
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
import {canUseProfileTimezone, getRequiredActions} from '@app/api/user/UserHelpers';
import {getRequiredActions} from '@app/api/user/UserHelpers';
import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils';
import type {ChannelMessageNotifications} from '@fluxer/constants/src/NotificationConstants';
import {
@@ -121,7 +121,6 @@ export function mapUserToPrivateResponse(user: User): UserPrivateResponse {
const isStaff = (user.flags & UserFlags.STAFF) !== 0n;
const partialResponse = mapUserToPartialResponse(user);
const isActuallyPremium = user.isPremium();
const includeProfileTimezone = canUseProfileTimezone(user);
const traitSet = new Set<string>();
for (const trait of user.traits ?? []) {
if (trait && trait !== 'premium') {
@@ -147,12 +146,8 @@ export function mapUserToPrivateResponse(user: User): UserPrivateResponse {
bio: user.bio,
pronouns: user.pronouns,
accent_color: user.accentColor,
...(includeProfileTimezone
? {
timezone: user.timezone,
timezone_privacy_flags: user.timezonePrivacyFlags,
}
: {}),
timezone: user.timezone,
timezone_privacy_flags: user.timezonePrivacyFlags,
banner: stripBannerForUser(user),
banner_color: user.bannerColor,
mfa_enabled: authenticatorTypes.length > 0,
@@ -127,10 +127,10 @@ export class UserAccountLookupService {
: await this.getProfileFieldPrivacyContext(userId, targetId);
const timezoneVisible =
!restrictProfile &&
canUseProfileTimezone(user) &&
user.timezone != null &&
profileFieldPrivacyContext != null &&
this.canViewProfileField(user.timezonePrivacyFlags, profileFieldPrivacyContext);
this.canViewProfileField(user.timezonePrivacyFlags, profileFieldPrivacyContext) &&
(await canUseProfileTimezone(user));
const [mutualFriends, mutualGuilds, connections] = await Promise.all([
withMutualFriends && userId !== targetId ? this.getMutualFriends(userId, targetId) : undefined,
withMutualGuilds && userId !== targetId ? this.getMutualGuilds(userId, targetId) : undefined,
@@ -83,7 +83,8 @@ export class UserAccountProfileService {
if (data.accent_color !== undefined) {
await this.processAccentColorUpdate({user, accentColor: data.accent_color, updates});
}
const canUpdateProfileTimezone = canUseProfileTimezone(user);
const canUpdateProfileTimezone =
(data.timezone !== undefined || data.timezone_privacy_flags !== undefined) && (await canUseProfileTimezone(user));
if (canUpdateProfileTimezone && data.timezone !== undefined) {
const nextTimezone = this.processTimezoneUpdate({user, timezone: data.timezone, updates});
if (nextTimezone !== null && user.timezone === null && data.timezone_privacy_flags === undefined) {
@@ -85,11 +85,14 @@ function hasProfileCustomizationUpdate(data: UserUpdatePayload): boolean {
return EMAIL_VERIFICATION_REQUIRED_PROFILE_UPDATE_FIELDS.some((field) => data[field] !== undefined);
}
function stripUnauthorizedProfileTimezoneUpdate(
async function stripUnauthorizedProfileTimezoneUpdate(
user: User,
body: UserUpdateWithVerificationRequest,
): UserUpdateWithVerificationRequest {
if (canUseProfileTimezone(user)) {
): 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;
@@ -187,7 +190,7 @@ export class UserAccountRequestService {
const {ctx, body, authSession} = params;
let {user} = params;
const oldEmail = user.email;
const sanitizedBody = stripUnauthorizedProfileTimezoneUpdate(user, body);
const sanitizedBody = await stripUnauthorizedProfileTimezoneUpdate(user, body);
const {
mfa_method: _mfaMethod,
mfa_code: _mfaCode,
@@ -392,7 +392,6 @@ export class UserContentService {
provider_environment: null,
};
const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true);
await this.gatewayService.invalidatePushSubscriptions({userId});
return subscription;
}
@@ -468,7 +467,6 @@ export class UserContentService {
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
await this.userRepository.deletePushSubscription(userId, subscriptionId);
await this.gatewayService.invalidatePushSubscriptions({userId});
}
async rotatePushSubscription(params: {
@@ -504,7 +502,6 @@ export class UserContentService {
provider_environment: null,
};
const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true);
await this.gatewayService.invalidatePushSubscriptions({userId});
return subscription;
}
@@ -530,7 +527,6 @@ export class UserContentService {
provider_environment: providerEnvironment,
};
const subscription = await this.userRepository.createPushSubscription(data);
await this.gatewayService.invalidatePushSubscriptions({userId});
return subscription;
}
@@ -3,16 +3,16 @@
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 {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {
ProfileFieldPrivacyFlags,
type ProfilePrivacyLevel,
ProfilePrivacyLevels,
UserFlags,
} 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,12 +31,12 @@ async function updateProfileTimezone(
return createBuilder<UserPrivateResponse>(harness, token).patch('/users/@me').body(data).execute();
}
async function setUserFlags(harness: ApiTestHarness, userId: string, flags: bigint): Promise<void> {
await createBuilder(harness, '')
.patch(`/test/users/${userId}/flags`)
.body({flags: flags.toString()})
.expect(HTTP_STATUS.OK)
.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(
@@ -76,7 +76,7 @@ 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 setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
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 +86,7 @@ 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 setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {
timezone: TEST_TIMEZONE,
timezone_privacy_flags: 0,
@@ -97,7 +97,7 @@ describe('User Profile Timezone Visibility', () => {
});
it('hides timezone from the public profile when privacy flags are unset', async () => {
const targetAccount = await createTestAccount(harness);
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {
timezone: TEST_TIMEZONE,
timezone_privacy_flags: 0,
@@ -109,7 +109,7 @@ describe('User Profile Timezone Visibility', () => {
const targetAccount = await createTestAccount(harness);
const friendAccount = await createTestAccount(harness);
const guildMemberAccount = await createTestAccount(harness);
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {
timezone: TEST_TIMEZONE,
timezone_privacy_flags: ProfileFieldPrivacyFlags.FRIENDS,
@@ -125,7 +125,7 @@ describe('User Profile Timezone Visibility', () => {
const targetAccount = await createTestAccount(harness);
const friendAccount = await createTestAccount(harness);
const guildMemberAccount = await createTestAccount(harness);
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {
timezone: TEST_TIMEZONE,
timezone_privacy_flags: ProfileFieldPrivacyFlags.MUTUAL_GUILDS,
@@ -140,7 +140,7 @@ 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 setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
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,23 +148,47 @@ describe('User Profile Timezone Visibility', () => {
expect(profile.profile_limited).toBe(true);
expect(profile.timezone_offset).toBeNull();
});
it('ignores profile timezone updates from non-staff users', async () => {
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});
expect(updated).not.toHaveProperty('timezone');
expect(updated).not.toHaveProperty('timezone_privacy_flags');
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('hides stored profile timezone after the user no longer has the staff flag', async () => {
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);
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
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 setUserFlags(harness, targetAccount.userId, 0n);
const currentUser = await createBuilder<UserPrivateResponse>(harness, targetAccount.token)
.get('/users/@me')
.execute();
expect(currentUser).not.toHaveProperty('timezone');
expect(currentUser).not.toHaveProperty('timezone_privacy_flags');
await setProfileTimezoneUsers([]);
await createFriendship(harness, targetAccount, viewerAccount);
const profile = await getUserProfile(harness, viewerAccount.token, targetAccount.userId);
expect(profile.timezone_offset).toBeNull();
@@ -572,7 +572,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
setSingleCommunityEnabled(next.policy.single_community_enabled);
setDirectMessagesDisabled(next.policy.direct_messages_disabled);
setPremiumMode(next.policy.premium_mode);
setPushRelayConsentAccepted(next.push_service_delivery.relay_consent_accepted);
setPushRelayConsentAccepted(next.push_relay.relay_consent_accepted);
setServiceSelection({
gif: next.policy.services_resolved.gif_enabled,
youtube: next.policy.services_resolved.youtube_enabled,
@@ -771,8 +771,8 @@ export const SelfHostedSetupWizardGate = observer(() => {
const nextConfig = await updateInstanceConfig({
integrations: buildIntegrationsPatch(integrationDraft),
media: buildMediaPatch(mediaExpiryDraft),
push_service_delivery:
config.push_service_delivery.relay_consent_accepted === pushRelayConsentAccepted
push_relay:
config.push_relay.relay_consent_accepted === pushRelayConsentAccepted
? undefined
: {relay_consent_accepted: pushRelayConsentAccepted},
registration: {mode: registrationMode},
@@ -326,7 +326,7 @@ export const LexicalChannelTextareaContent = observer(
}, [value, segmentManagerRef]);
const handleEmojiSelect = useCallback(
(emoji: FlatEmoji, shiftKey?: boolean): boolean => {
const didInsert = insertComposerEmoji(handleRef.current, emoji);
const didInsert = insertComposerEmoji(handleRef.current, emoji, {reactionShorthand: true});
if (didInsert && !shiftKey) {
ExpressionPickerCommands.close();
PopoutCommands.close(`expression-picker-${channel.id}`);
@@ -31,7 +31,6 @@ export const DEFAULT_DEVELOPER_OPTIONS = {
selfHostedModeOverride: false,
forceShowVanityURLDisclaimer: false,
forceShowVoiceConnection: false,
showProfileTimezoneSettings: false,
premiumScenarioOverride: null,
premiumTypeOverride: null,
premiumLifetimeSequenceOverride: null,
@@ -132,15 +132,6 @@ const FORCE_SHOW_VOICE_CONNECTION_DESCRIPTOR = msg({
message: 'Force show voice connection',
comment: 'Developer option label for always showing the voice connection status bar.',
});
const SHOW_PROFILE_TIMEZONE_SETTINGS_DESCRIPTOR = msg({
message: 'Show profile time zone settings',
comment: 'Developer option label for exposing the staff-only profile timezone section in profile settings.',
});
const SHOW_PROFILE_TIMEZONE_SETTINGS_DESC_DESCRIPTOR = msg({
message: 'Expose the staff-only time zone section in profile settings.',
comment:
'Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.',
});
const NO_OP_IN_APP_REPORTS_DESCRIPTOR = msg({
message: 'No-op in-app reports',
comment:
@@ -255,11 +246,6 @@ export const getToggleGroups = (): Array<ToggleGroup> => [
label: FORCE_SHOW_VOICE_CONNECTION_DESCRIPTOR,
description: ALWAYS_DISPLAY_THE_VOICE_CONNECTION_STATUS_BAR_IN_DESCRIPTOR,
},
{
key: 'showProfileTimezoneSettings',
label: SHOW_PROFILE_TIMEZONE_SETTINGS_DESCRIPTOR,
description: SHOW_PROFILE_TIMEZONE_SETTINGS_DESC_DESCRIPTOR,
},
{
key: 'noOpInAppReports',
label: NO_OP_IN_APP_REPORTS_DESCRIPTOR,
@@ -46,7 +46,6 @@ export type DeveloperOptionsState = Readonly<{
selfHostedModeOverride: boolean;
forceShowVanityURLDisclaimer: boolean;
forceShowVoiceConnection: boolean;
showProfileTimezoneSettings: boolean;
premiumScenarioOverride: PremiumScenarioOverride | null;
premiumTypeOverride: number | null;
premiumLifetimeSequenceOverride: number | null;
@@ -129,7 +128,6 @@ class DeveloperOptions implements DeveloperOptionsState {
selfHostedModeOverride = false;
forceShowVanityURLDisclaimer = false;
forceShowVoiceConnection = false;
showProfileTimezoneSettings = false;
premiumScenarioOverride: PremiumScenarioOverride | null = null;
premiumTypeOverride: number | null = null;
premiumLifetimeSequenceOverride: number | null = null;
@@ -215,7 +213,6 @@ class DeveloperOptions implements DeveloperOptionsState {
'selfHostedModeOverride',
'forceShowVanityURLDisclaimer',
'forceShowVoiceConnection',
'showProfileTimezoneSettings',
'premiumScenarioOverride',
'premiumTypeOverride',
'premiumLifetimeSequenceOverride',
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "تعذّر إرسال رنين إلى هذا الشخص"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "تعذر تنفيذ هذا الأمر: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "تصدير بياناتي"
msgid "Export themes"
msgstr "تصدير السمات"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "عرض قسم المنطقة الزمنية المخصص للموظفين في إعدادات الملف الشخصي."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "لا يوجد أي ويب هوك"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "بلاغات داخل التطبيق بلا إرسال فعلي"
@@ -30549,11 +30544,6 @@ msgstr "عرض كلمة المرور"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "إظهار عناصر تحكم مستوى صوت المشاركين لكل جهاز في قوائم الصوت"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "إظهار إعدادات المنطقة الزمنية في الملف الشخصي"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "تخطي تحذير الروابط الخارجية لجميع النطاقات"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "تجاوز طلب الشبكة عند إرسال بلاغ داخل التطبيق. يستمر المسار إلى شاشة النجاح لتتمكن من تجربة الواجهة، لكن لا يُرسل أي شيء فعليًا إلى فريق السلامة."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Не успяхме да позвъним на този човек"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Командата не можа да бъде изпълнена: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Експортиране на моите данни"
msgid "Export themes"
msgstr "Експортиране на теми"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Показване на секцията за часова зона (само за служители) в настройките на профила."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Няма уебхукове"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Бездействие при сигнали в приложението"
@@ -30549,11 +30544,6 @@ msgstr "Покажи паролата"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Показване на плъзгачи за силата на звука на участниците за всяко устройство в гласовите менюта"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Показване на настройките за часова зона в профила"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Пропускане на предупреждението за външни линкове за всички домейни"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Пропускане на мрежовата заявка при изпращане на сигнал в приложението. Процесът все още стига до екрана за успех, така че можеш да тестваш интерфейса, но нищо не се изпраща до екипа за безопасност."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Tuto osobu se nepodařilo prozvonit"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Nepodařilo se spustit příkaz: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Exportovat moje data"
msgid "Export themes"
msgstr "Exportovat motivy"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Zobrazit sekci časových pásem určenou pouze pro zaměstnance v nastavení profilu."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Žádné webhooky"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Nahlášení v aplikaci bez odeslání"
@@ -30549,11 +30544,6 @@ msgstr "Zobrazit heslo"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Zobrazit posuvníky hlasitosti účastníků pro každé zařízení v nabídkách hlasu"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Zobrazit nastavení časového pásma v profilu"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Přeskočit upozornění na externí odkaz pro všechny domény"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Přeskočit síťový požadavek při odeslání nahlášení v aplikaci. Postup i tak přejde na obrazovku úspěšného odeslání, abyste mohli vyzkoušet uživatelské rozhraní, ale bezpečnostnímu týmu se nic neodešle."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Kunne ikke ringe til denne person"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Kunne ikke køre kommandoen: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Eksporter mine data"
msgid "Export themes"
msgstr "Eksporter temaer"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Vis tidszonesektionen i profilindstillingerne, som kun er tilgængelig for medarbejdere."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Ingen webhooks"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Simuler anmeldelser i appen uden at sende dem"
@@ -30549,11 +30544,6 @@ msgstr "Vis adgangskode"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Vis lydstyrkeskydere for deltagernes enkelte enheder i talemenuer"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Vis indstillinger for profilens tidszone"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Spring advarsel om eksternt link over for alle domæner"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Spring netværkskaldet over, når en anmeldelse indsendes i appen. Forløbet fortsætter til bekræftelsesskærmen, så du kan afprøve brugerfladen, men intet sendes til sikkerhedsteamet."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Diese Person konnte nicht angerufen werden"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Befehl konnte nicht ausgeführt werden: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Meine Daten exportieren"
msgid "Export themes"
msgstr "Designs exportieren"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Zeige den Abschnitt für Zeitzoneneinstellungen, der nur für Mitarbeiter sichtbar ist, in den Profiloptionen an."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Keine Webhooks"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "In-App-Meldungen ohne Funktion"
@@ -30549,11 +30544,6 @@ msgstr "Passwort anzeigen"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Lautstärkeregler für einzelne Geräte der Teilnehmer in Sprachchat-Menüs anzeigen"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Einstellungen für die Zeitzone im Profil anzeigen"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Warnung für externe Links für alle Domains überspringen"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Überspringt den Netzwerkaufruf beim Absenden einer In-App-Meldung. Der Ablauf führt weiterhin zur Erfolgsseite, damit du die Oberfläche ausprobieren kannst. Es wird jedoch nichts an das Sicherheitsteam gesendet."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Δεν ήταν δυνατή η κλήση αυτού του ατόμου"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Δεν ήταν δυνατή η εκτέλεση της εντολής: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Εξαγωγή των δεδομένων μου"
msgid "Export themes"
msgstr "Εξαγωγή θεμάτων"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Εμφανίζει στις ρυθμίσεις προφίλ την ενότητα ζώνης ώρας που είναι μόνο για το προσωπικό."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Δεν υπάρχουν webhooks"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Αναφορές εντός εφαρμογής χωρίς λειτουργία"
@@ -30549,11 +30544,6 @@ msgstr "Εμφάνιση κωδικού"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Εμφάνιση ρυθμιστικών έντασης ήχου συμμετεχόντων ανά συσκευή στα μενού φωνής"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Εμφάνιση ρυθμίσεων ζώνης ώρας προφίλ"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Παράλειψη προειδοποίησης εξωτερικού συνδέσμου για όλους τους τομείς"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Παράλειψη της κλήσης δικτύου κατά την υποβολή αναφοράς εντός εφαρμογής. Η ροή προχωρά στην οθόνη επιτυχίας, ώστε να δοκιμάσετε το UI, αλλά δεν αποστέλλεται τίποτα στην ομάδα ασφαλείας."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Couldn't ring this person"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Couldn't run that command: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Export my data"
msgid "Export themes"
msgstr "Export themes"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Expose the staff-only time zone section in profile settings."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "No webhooks"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "No-op in-app reports"
@@ -30549,11 +30544,6 @@ msgstr "Show password"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Show per-device participant volume sliders in voice menus"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Show profile time zone settings"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Skip the external link warning for all domains"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Couldn't ring this person"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Couldn't run that command: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Export my data"
msgid "Export themes"
msgstr "Export themes"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Expose the staff-only time zone section in profile settings."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "No webhooks"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "No-op in-app reports"
@@ -30549,11 +30544,6 @@ msgstr "Show password"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Show per-device participant volume sliders in voice menus"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Show profile time zone settings"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Skip the external link warning for all domains"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "No se pudo llamar a esta persona"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "No se pudo ejecutar el comando: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Exportar mis datos"
msgid "Export themes"
msgstr "Exportar temas"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Mostrar la sección de zona horaria reservada para el personal en la configuración del perfil."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "No hay webhooks"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Simular reportes en la aplicación sin enviarlos"
@@ -30549,11 +30544,6 @@ msgstr "Mostrar contraseña"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Mostrar controles deslizantes de volumen por participante y dispositivo en los menús de voz"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Mostrar la configuración de zona horaria del perfil"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Omitir la advertencia de enlace externo para todos los dominios"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Omite la llamada de red cuando se envía un reporte en la aplicación. El flujo avanza a la pantalla de éxito para que puedas probar la interfaz de usuario, pero no se envía nada al equipo de seguridad."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "No se ha podido llamar a esta persona"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "No se pudo ejecutar el comando: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Exportar mis datos"
msgid "Export themes"
msgstr "Exportar temas"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Mostrar la sección de zona horaria reservada para el personal en los ajustes del perfil."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Ningún webhook"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Simular denuncias en la aplicación sin enviarlas"
@@ -30549,11 +30544,6 @@ msgstr "Mostrar contraseña"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Mostrar controles deslizantes de volumen por participante y dispositivo en los menús de voz"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Mostrar los ajustes de zona horaria del perfil"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Omitir la advertencia de enlace externo para todos los dominios"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Omite la llamada de red cuando se envía una denuncia en la aplicación. El flujo avanza a la pantalla de éxito para que puedas probar la interfaz de usuario, pero no se envía nada al equipo de seguridad."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Henkilölle ei voitu soittaa"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Komentoa ei voitu suorittaa: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Vie tietoni"
msgid "Export themes"
msgstr "Vie teemat"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Näytä henkilökunnalle tarkoitettu aikavyöhykeosio profiiliasetuksissa."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Ei webhookeja"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Ei-toimivat sovelluksen sisäiset ilmoitukset"
@@ -30549,11 +30544,6 @@ msgstr "Näytä salasana"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Näytä laitekohtaiset osallistujien äänenvoimakkuuden liukusäätimet äänivalikoissa"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Näytä profiilin aikavyöhykeasetukset"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Ohita ulkoisen linkin varoitus kaikille verkkotunnuksille"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Ohita verkkopyyntö, kun sovelluksen sisäinen ilmoitus lähetetään. Prosessi etenee silti onnistumisnäyttöön, jotta voit kokeilla käyttöliittymää, mutta mitään ei todellisuudessa lähetetä turvallisuustiimille."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Impossible d'appeler cette personne"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Impossible d'exécuter cette commande : {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Exporter mes données"
msgid "Export themes"
msgstr "Exporter les thèmes"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Afficher la section du fuseau horaire dans les paramètres du profil, réservée au personnel."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Aucun webhook"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Désactiver l’envoi réel des signalements dans l’application"
@@ -30549,11 +30544,6 @@ msgstr "Afficher le mot de passe"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Afficher les curseurs de volume des participants par appareil dans les menus vocaux"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Afficher les paramètres de fuseau horaire du profil"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Ignorer l'avertissement de lien externe pour tous les domaines"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "N’effectue pas l’appel réseau lors de l’envoi d’un signalement depuis l’application. Le parcours continue jusqu’à l’écran de réussite pour permettre de vérifier l’interface, mais rien n’est envoyé à l’équipe de sécurité."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "לא ניתן להתקשר לאדם זה"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "לא ניתן היה להריץ את הפקודה: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "ייצוא הנתונים שלי"
msgid "Export themes"
msgstr "ייצוא ערכות נושא"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "הצג את סעיף אזור הזמן המיועד לצוות בלבד בהגדרות הפרופיל."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "אין webhooks"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "דיווחים פנימיים לא פעילים"
@@ -30549,11 +30544,6 @@ msgstr "הצג סיסמה"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "הצג פקדי עוצמת קול למשתתפים לפי מכשיר בתפריטי קול"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "הצג הגדרות אזור זמן בפרופיל"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "דלג על אזהרת הקישור החיצוני עבור כל הדומיינים"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "דלג על קריאת הרשת בעת שליחת דיווח מתוך האפליקציה. התהליך עדיין יתקדם למסך ההצלחה כדי שתוכל לבדוק את ממשק המשתמש, אך שום דבר לא יישלח בפועל לצוות הבטיחות."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "इस व्यक्ति को कॉल नहीं किया जा सका"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "वह कमांड नहीं चल सका: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "मेरा डेटा एक्सपोर्ट करें"
msgid "Export themes"
msgstr "थीम एक्सपोर्ट करें"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "प्रोफ़ाइल सेटिंग्स में केवल कर्मचारियों के लिए समय क्षेत्र अनुभाग दिखाएँ।"
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "कोई वेबहुक नहीं"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "इन-ऐप रिपोर्ट को नो-ऑप करें"
@@ -30549,11 +30544,6 @@ msgstr "पासवर्ड दिखाएं"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "वॉइस मेन्यू में प्रति-डिवाइस प्रतिभागी वॉल्यूम स्लाइडर दिखाएं"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "प्रोफ़ाइल की समय क्षेत्र सेटिंग्स दिखाएं"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "सभी डोमेन के लिए बाहरी लिंक चेतावनी को छोड़ें"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "इन-ऐप रिपोर्ट सबमिट होने पर नेटवर्क कॉल छोड़ दें। फ़्लो फिर भी सफलता स्क्रीन तक बढ़ता है ताकि आप UI आज़मा सकें, लेकिन सुरक्षा टीम को असल में कुछ नहीं भेजा जाता।"
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Nije moguće nazvati ovu osobu"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Nije moguće pokrenuti tu naredbu: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Izvezi moje podatke"
msgid "Export themes"
msgstr "Izvezi teme"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Prikaži odjeljak s vremenskom zonom rezerviran za osoblje u postavkama profila."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Nema webhookova"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Prijave u aplikaciji bez učinka"
@@ -30549,11 +30544,6 @@ msgstr "Prikaži lozinku"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Prikaži klizače glasnoće sudionika za svaki uređaj u glasovnim izbornicima"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Prikaži postavke vremenske zone profila"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Preskoči upozorenje o vanjskim poveznicama za sve domene"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Preskoči mrežni poziv kada se pošalje prijava u aplikaciji. Tijek i dalje dolazi do zaslona uspjeha kako biste mogli isprobati sučelje, ali sigurnosnom timu zapravo se ništa ne šalje."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Nem sikerült megcsörgetni ezt a felhasználót"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Nem sikerült futtatni a parancsot: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Adataim exportálása"
msgid "Export themes"
msgstr "Témák exportálása"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Mutassa a profilbeállításokban a csak a munkatársak számára elérhető időzóna-szekciót."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Nincsenek webhookok"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Alkalmazáson belüli bejelentések küldésének szimulálása"
@@ -30549,11 +30544,6 @@ msgstr "Jelszó megjelenítése"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Résztvevőnként és eszközönként külön hangerőcsúszkák megjelenítése a hangmenükben"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Profil időzóna-beállításainak megjelenítése"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Külső linkre figyelmeztetés kihagyása minden domainhez"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Az alkalmazáson belüli bejelentés beküldésekor kihagyja a hálózati kérést. A folyamat így is a sikert jelző képernyőre lép, hogy kipróbálhasd a felületet, de valójában semmit sem küld el a biztonsági csapatnak."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Tidak dapat memanggil orang ini"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Tidak dapat menjalankan perintah tersebut: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Ekspor data saya"
msgid "Export themes"
msgstr "Ekspor tema"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Tampilkan bagian zona waktu khusus staf di pengaturan profil."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Tidak ada webhook"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Laporan dalam aplikasi no-op"
@@ -30549,11 +30544,6 @@ msgstr "Tampilkan kata sandi"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Tampilkan penggeser volume peserta per perangkat di menu suara"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Tampilkan pengaturan zona waktu profil"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Lewati peringatan tautan eksternal untuk semua domain"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Lewati panggilan jaringan saat laporan dalam aplikasi dikirim. Alur tetap berlanjut ke layar sukses sehingga kamu dapat menguji UI, tetapi tidak ada yang benar-benar dikirim ke tim keamanan."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Impossibile chiamare questa persona"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Impossibile eseguire il comando: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Esporta i miei dati"
msgid "Export themes"
msgstr "Esporta temi"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Mostra la sezione del fuso orario riservata allo staff nelle impostazioni del profilo."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Nessun webhook"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Segnalazioni in-app senza effetto"
@@ -30549,11 +30544,6 @@ msgstr "Mostra password"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Mostra i controlli del volume dei partecipanti per dispositivo nei menu vocali"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Mostra le impostazioni del fuso orario del profilo"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Salta l'avviso per i link esterni per tutti i domini"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Salta la chiamata di rete quando viene inviata una segnalazione in-app. Il flusso avanza comunque alla schermata di successo, così puoi testare l'interfaccia utente, ma nulla viene effettivamente inviato al team di sicurezza."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "このユーザーを呼び出せませんでした"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "コマンドを実行できませんでした: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "データをエクスポート"
msgid "Export themes"
msgstr "テーマをエクスポート"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "プロフィール設定で、スタッフ専用のタイムゾーンセクションを表示します。"
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "ウェブフックなし"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "アプリ内報告を無効化"
@@ -30549,11 +30544,6 @@ msgstr "パスワードを表示"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "デバイスごとの参加者の音量スライダーを音声メニューに表示"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "プロフィールのタイムゾーン設定を表示"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "すべてのドメインで外部リンクの警告をスキップ"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "アプリ内報告が送信されてもネットワーク呼び出しをスキップします。フローは成功画面に進むためUIを試すことはできますが、実際には安全チームには何も送信されません。"
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "이 사람에게 전화를 걸 수 없습니다"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "명령을 실행할 수 없습니다: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "내 데이터 내보내기"
msgid "Export themes"
msgstr "테마 내보내기"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "프로필 설정에서 직원 전용 시간대 섹션을 표시합니다."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "웹훅 없음"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "앱 내 신고 무효화"
@@ -30549,11 +30544,6 @@ msgstr "비밀번호 표시"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "음성 메뉴에서 기기별 참여자 볼륨 슬라이더 표시"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "프로필 시간대 설정 표시"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "모든 도메인에서 외부 링크 경고 건너뛰기"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "앱 내 신고 제출 시 네트워크 호출을 건너뜁니다. UI를 테스트할 수 있도록 성공 화면으로 계속 진행되지만, 안전팀에는 아무것도 전송되지 않습니다."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Nepavyko paskambinti šiam asmeniui"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Nepavyko įvykdyti komandos: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Eksportuoti mano duomenis"
msgid "Export themes"
msgstr "Eksportuoti temas"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Rodyti tik personalui skirtą laiko juostos skiltį profilio nustatymuose."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Nėra webhookų"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Pranešimai programoje be poveikio"
@@ -30549,11 +30544,6 @@ msgstr "Rodyti slaptažodį"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Rodyti atskirų įrenginių dalyvių garsumo slankiklius balso meniu"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Rodyti profilio laiko juostos nustatymus"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Praleisti išorinių nuorodų įspėjimą visiems domenams"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Praleisti tinklo užklausą, kai programoje pateikiamas pranešimas. Eiga vis tiek pereina į sėkmės ekraną, kad galėtumėte išbandyti sąsają, bet saugos komandai iš tikrųjų nieko nesiunčiama."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Deze persoon kon niet worden gebeld"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Kon die opdracht niet uitvoeren: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Mijn gegevens exporteren"
msgid "Export themes"
msgstr "Thema's exporteren"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Toon het tijdzonegedeelte voor medewerkers in de profielinstellingen."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Geen webhooks"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "In-app-meldingen zonder verwerking"
@@ -30549,11 +30544,6 @@ msgstr "Wachtwoord tonen"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Volumeregelaars voor deelnemers per apparaat tonen in spraakmenu’s"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Instellingen voor tijdzone in profiel tonen"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Externe linkwaarschuwing overslaan voor alle domeinen"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Sla de netwerkaanroep over wanneer een melding in de app wordt ingediend. Het proces gaat wel door naar het bevestigingsscherm, zodat je de interface kunt uitproberen, maar er wordt niets naar het veiligheidsteam verstuurd."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Kunne ikke ringe denne personen"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Kunne ikke kjøre kommandoen: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Eksporter dataene mine"
msgid "Export themes"
msgstr "Eksporter temaer"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Vis seksjonen for tidssone i profilinnstillingene som bare er tilgjengelig for ansatte."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Ingen webhooker"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Simuler rapportering i appen uten å sende"
@@ -30549,11 +30544,6 @@ msgstr "Vis passord"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Vis skyveknapper for deltakervolum per enhet i talemenyer"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Vis innstillinger for tidssone i profil"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Hopp over advarsel om ekstern lenke for alle domener"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Hopp over nettverkskallet når en rapport i appen sendes inn. Flyten går fortsatt videre til bekreftelsesskjermen slik at du kan prøve brukergrensesnittet, men ingenting sendes til sikkerhetsteamet."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Nie udało się zadzwonić do tej osoby"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Nie udało się uruchomić polecenia: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Eksportuj moje dane"
msgid "Export themes"
msgstr "Eksportuj motywy"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Pokaż w ustawieniach profilu sekcję strefy czasowej dostępną tylko dla zespołu."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Brak webhooków"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Symuluj zgłoszenia w aplikacji bez ich wysyłania"
@@ -30549,11 +30544,6 @@ msgstr "Pokaż hasło"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Pokaż suwaki głośności uczestników dla poszczególnych urządzeń w menu głosowych"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Pokaż ustawienia strefy czasowej profilu"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Pomiń ostrzeżenie o linkach zewnętrznych dla wszystkich domen"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Pomiń żądanie sieciowe przy wysyłaniu zgłoszenia w aplikacji. Nadal pojawi się ekran powodzenia, aby umożliwić sprawdzenie interfejsu, ale nic nie zostanie wysłane do zespołu ds. bezpieczeństwa."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Não foi possível chamar esta pessoa"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Não foi possível executar o comando: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Exportar meus dados"
msgid "Export themes"
msgstr "Exportar temas"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Mostrar a seção de fuso horário restrita à equipe nas configurações do perfil."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Nenhum webhook"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Simular denúncias no aplicativo sem enviá-las"
@@ -30549,11 +30544,6 @@ msgstr "Mostrar senha"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Mostrar controles deslizantes de volume por participante por dispositivo nos menus de voz"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Mostrar configurações de fuso horário do perfil"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Pular o aviso de link externo para todos os domínios"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Não faz a chamada de rede ao enviar uma denúncia pelo aplicativo. O fluxo ainda avança até a tela de sucesso para permitir testar a interface, mas nada é enviado à equipe de segurança."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Nu s-a putut apela această persoană"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Nu s-a putut rula comanda: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Exportă-mi datele"
msgid "Export themes"
msgstr "Exportă teme"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Afișează în setările de profil secțiunea de fus orar disponibilă doar pentru personal."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Niciun webhook"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Rapoarte în aplicație fără efect"
@@ -30549,11 +30544,6 @@ msgstr "Afișează parola"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Afișează glisoare de volum per dispozitiv pentru participanți în meniurile vocale"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Afișează setările pentru fusul orar al profilului"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Omite avertismentul de link extern pentru toate domeniile"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Omite apelul de rețea la trimiterea unui raport din aplicație. Fluxul avansează în continuare la ecranul de succes, ca să poți testa interfața, dar nu se trimite nimic echipei de siguranță."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Не удалось позвонить этому пользователю"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Не удалось выполнить команду: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Экспорт моих данных"
msgid "Export themes"
msgstr "Экспорт тем"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Показывать раздел с настройками часового пояса, доступный только сотрудникам."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Вебхуков нет"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Жалобы в приложении без отправки"
@@ -30549,11 +30544,6 @@ msgstr "Показать пароль"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Показывать ползунки громкости участников для каждого устройства в голосовых меню"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Показать настройки часового пояса в профиле"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Пропускать предупреждение о внешних ссылках для всех доменов"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Пропускать сетевой вызов при отправке жалобы из приложения. Процесс всё равно переходит на экран успешной отправки, чтобы вы могли протестировать интерфейс, но команде безопасности ничего не отправляется."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Kunde inte ringa den här personen"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Kunde inte köra kommandot: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Exportera mina data"
msgid "Export themes"
msgstr "Exportera teman"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Visa tidszonsinställningarna för personal i profilinställningarna."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Inga webhooks"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Låt anmälningar i appen inte göra något"
@@ -30549,11 +30544,6 @@ msgstr "Visa lösenord"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Visa volymreglage för deltagare per enhet i röstmenyer"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Visa inställningar för profilens tidszon"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Hoppa över varningen för externa länkar för alla domäner"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Hoppa över nätverksanropet när en anmälan skickas i appen. Flödet går ändå vidare till bekräftelsen så att du kan prova gränssnittet, men inget skickas till säkerhetsteamet."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "ไม่สามารถโทรหาบุคคลนี้ได้"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "ไม่สามารถเรียกใช้คำสั่งนั้นได้: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "ส่งออกข้อมูลของฉัน"
msgid "Export themes"
msgstr "ส่งออกธีม"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "แสดงส่วนเขตเวลาสำหรับพนักงานเท่านั้นในการตั้งค่าโปรไฟล์"
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "ไม่มี webhook"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "รายงานในแอปแบบไม่ดำเนินการ"
@@ -30549,11 +30544,6 @@ msgstr "แสดงรหัสผ่าน"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "แสดงแถบเลื่อนระดับเสียงของผู้เข้าร่วมตามอุปกรณ์ในเมนูเสียง"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "แสดงการตั้งค่าเขตเวลาในโปรไฟล์"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "ข้ามคำเตือนลิงก์ภายนอกสำหรับทุกโดเมน"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "ข้ามการเรียกเครือข่ายเมื่อส่งรายงานในแอป โฟลว์จะยังคงดำเนินการต่อไปยังหน้าจอสำเร็จเพื่อให้คุณสามารถทดสอบ UI ได้ แต่จะไม่มีการส่งสิ่งใดไปยังทีมความปลอดภัยจริง"
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Bu kişi aranamadı"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Bu komut çalıştırılamadı: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Verilerimi dışa aktar"
msgid "Export themes"
msgstr "Temaları dışa aktar"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Profil ayarlarında yalnızca çalışanlara özel zaman dilimi bölümünü göster."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Webhook yok"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Uygulama içi raporları atla"
@@ -30549,11 +30544,6 @@ msgstr "Parolayı göster"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Ses menülerinde cihaza özel katılımcı ses seviyesi kaydırıcılarını göster"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Profil zaman dilimi ayarlarını göster"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Tüm alan adları için harici bağlantı uyarısını atla"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Uygulama içi bir rapor gönderildiğinde ağ çağrısını atla. Akış yine de başarı ekranına ilerler, böylece kullanıcı arayüzünü kullanabilirsiniz, ancak güvenlik ekibine hiçbir şey gönderilmez."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Не вдалося зателефонувати цій особі"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Не вдалося виконати команду: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Експортувати мої дані"
msgid "Export themes"
msgstr "Експортувати теми"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Показувати в налаштуваннях профілю розділ часового поясу, доступний лише для співробітників."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Вебхуків немає"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Скарги в застосунку без дії"
@@ -30549,11 +30544,6 @@ msgstr "Показати пароль"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Показувати повзунки гучності учасників для кожного пристрою в голосових меню"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Показувати налаштування часового поясу профілю"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Пропускати попередження про зовнішні посилання для всіх доменів"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Пропускати мережевий виклик під час надсилання скарги в застосунку. Процес усе одно переходить до екрана успішного виконання, щоб ви могли перевірити інтерфейс, але команді безпеки нічого не надсилається."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "Không thể gọi người này"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "Không thể chạy lệnh đó: {detail}"
@@ -14590,11 +14590,6 @@ msgstr "Xuất dữ liệu của tôi"
msgid "Export themes"
msgstr "Xuất chủ đề"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "Hiển thị phần cài đặt múi giờ dành riêng cho nhân viên trong cài đặt hồ sơ."
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "Không có webhook nào"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "Bỏ qua gửi báo cáo trong ứng dụng"
@@ -30549,11 +30544,6 @@ msgstr "Hiện mật khẩu"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "Hiển thị thanh trượt âm lượng người tham gia theo từng thiết bị trong menu thoại"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "Hiển thị cài đặt múi giờ hồ sơ"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "Bỏ qua cảnh báo liên kết ngoài cho tất cả các miền"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "Bỏ qua lệnh gọi mạng khi gửi báo cáo trong ứng dụng. Luồng vẫn chuyển sang màn hình thành công để bạn thử giao diện, nhưng không có gì thực sự được gửi đến đội ngũ an toàn."
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "无法呼叫此人"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "无法运行该命令:{detail}"
@@ -14590,11 +14590,6 @@ msgstr "导出我的数据"
msgid "Export themes"
msgstr "导出主题"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "在个人资料设置中显示仅供员工访问的时区部分。"
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "无 Webhook"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "空操作应用内举报"
@@ -30549,11 +30544,6 @@ msgstr "显示密码"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "在语音菜单中显示每个设备的参与者音量滑块"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "显示个人资料时区设置"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "跳过所有域名的外部链接警告"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "提交应用内举报时跳过网络请求。流程仍会进入成功界面,方便你测试 UI,但实际不会向安全团队发送任何内容。"
@@ -10084,7 +10084,7 @@ msgid "Couldn't ring this person"
msgstr "無法撥打給此人"
#. Error message in the use textarea submit hook. Preserve {detail}; it is inserted by code.
#: src/features/messaging/hooks/useTextareaSubmit.ts:48
#: src/features/messaging/hooks/useTextareaSubmit.ts:53
msgid "Couldn't run that command: {detail}"
msgstr "無法執行該指令:{detail}"
@@ -14590,11 +14590,6 @@ msgstr "匯出我的資料"
msgid "Export themes"
msgstr "匯出主題"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:139
msgid "Expose the staff-only time zone section in profile settings."
msgstr "在個人檔案設定中顯示僅限員工可見的時區區塊。"
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:343
@@ -22951,7 +22946,7 @@ msgid "No webhooks"
msgstr "沒有 Webhook"
#. Developer option label for short-circuiting the in-app reporting (IAR) flow. When enabled, submitting a report skips the network call.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:144
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "No-op in-app reports"
msgstr "停用應用程式內檢舉"
@@ -30549,11 +30544,6 @@ msgstr "顯示密碼"
msgid "Show per-device participant volume sliders in voice menus"
msgstr "在語音選單中顯示個別裝置的參與者音量滑桿"
#. Developer option label for exposing the staff-only profile timezone section in profile settings.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:135
msgid "Show profile time zone settings"
msgstr "顯示個人檔案時區設定"
#. Short label for an advanced message action bar preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:56
msgid "Show quick reactions"
@@ -30932,7 +30922,7 @@ msgid "Skip the external link warning for all domains"
msgstr "略過所有網域的外部連結警告"
#. Developer / debug surface — keep terse and technical. Tooltip / description for the no-op in-app reports toggle.
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:149
#: src/features/devtools/components/DeveloperOptionsToggleGroups.ts:140
msgid "Skip the network call when an in-app report is submitted. The flow still advances to the success screen so you can exercise the UI, but nothing is actually sent to the safety team."
msgstr "提交應用程式內檢舉時,略過網路呼叫。流程仍會進展到成功畫面,讓您可以操作使用者介面,但實際上不會傳送任何內容給安全團隊。"
@@ -15,8 +15,9 @@ import {
} from '@app/features/lexical/composer/nodes/ComposerStandardEmojiNode';
import {$isSyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode';
import {findTypedEmojiShortcode, type TypedEmojiMatch} from '@app/features/messaging/utils/markdown/TypedEmojiMatch';
import {isReactionShorthandText} from '@app/features/messaging/utils/ReactionShorthandUtils';
import type {ResolvedTypedEmoji} from '@app/features/messaging/utils/TypedEmojiShortcodeUtils';
import {type LexicalEditor, TextNode} from 'lexical';
import {$getRoot, type LexicalEditor, TextNode} from 'lexical';
export type ComposerEmojiResolver = (shortcodeName: string) => ResolvedTypedEmoji | null;
@@ -71,7 +72,7 @@ function isEscapedAt(text: string, index: number): boolean {
}
export function $convertEmojiShortcode(node: TextNode, resolve: ComposerEmojiResolver): void {
if ($isSyntaxMarkerNode(node) || node.hasFormat('code')) {
if ($isSyntaxMarkerNode(node) || node.hasFormat('code') || isReactionShorthandText($getRoot().getTextContent())) {
return;
}
const parent = node.getParent();
@@ -6,6 +6,10 @@ import * as EmojiImageUtils from '@app/features/expressions/utils/EmojiUtils';
import {getSkinTonedSurrogate} from '@app/features/expressions/utils/SkinToneUtils';
import type {ComposerHandle, ComposerSelectionRange} from '@app/features/lexical/composer/ComposerHandle';
import type {ComposerInsertPayload, ComposerInsertSpacing} from '@app/features/lexical/composer/composerOffsets';
import {
getReactionShortcodeName,
isReactionShorthandPrefix,
} from '@app/features/messaging/utils/ReactionShorthandUtils';
import {type MentionSegment, TextareaSegmentManager} from '@app/features/messaging/utils/TextareaSegmentManager';
export interface ComposerReplacementPlan {
@@ -24,6 +28,10 @@ export interface ComposerReplacementLimit {
onExceedMaxLength?: () => void;
}
export interface ComposerEmojiInsertOptions extends ComposerReplacementLimit {
reactionShorthand?: boolean;
}
interface ComposerPayloadSegment {
type: MentionSegment['type'];
id: string;
@@ -162,23 +170,27 @@ export function applyComposerReplacement(
export function insertComposerEmoji(
handle: ComposerHandle | null,
emoji: FlatEmoji,
limit: ComposerReplacementLimit = {},
options: ComposerEmojiInsertOptions = {},
): boolean {
if (handle == null) {
return false;
}
const display = handle.getDisplayValue();
const selection = normalizeSelection(display, handle.getSelection());
const charBefore = selection.start > 0 ? display[selection.start - 1] : '';
const charAfter = selection.end < display.length ? display[selection.end] : '';
const payload: ComposerInsertPayload =
options.reactionShorthand === true &&
isReactionShorthandPrefix(display.slice(0, selection.start), display.slice(selection.end))
? {kind: 'text', text: `:${getReactionShortcodeName(emoji)}:`}
: createComposerEmojiPayload(emoji);
return applyComposerReplacement(
handle,
selection,
createComposerEmojiPayload(emoji),
payload,
{
leading: charBefore !== '' && !/\s/.test(charBefore),
leading: false,
trailing: charAfter === '' || !/\s/.test(charAfter),
},
limit,
options,
);
}
@@ -588,7 +588,12 @@ const ComposerInner = ({
if (disabledRef.current) {
return false;
}
selectLastSelectionOrEnd();
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$setSelection(selection.clone());
} else {
selectLastSelectionOrEnd();
}
return false;
},
COMMAND_PRIORITY_LOW,
@@ -57,6 +57,7 @@ import type {GuildMember} from '@app/features/member/models/GuildMember';
import GuildMembers from '@app/features/member/state/GuildMembers';
import type {SearchContext} from '@app/features/member/state/MemberSearch';
import * as HighlightCommands from '@app/features/messaging/commands/HighlightCommands';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import * as ReactionCommands from '@app/features/messaging/commands/ReactionCommands';
import Messages from '@app/features/messaging/state/MessagingMessages';
import {
@@ -77,6 +78,10 @@ import {
} from '@app/features/messaging/utils/AutocompleteOptionBuilders';
import {isAutocompleteTriggerAllowed, type TriggerType} from '@app/features/messaging/utils/AutocompleteTriggerPolicy';
import {toReactionEmoji} from '@app/features/messaging/utils/MessageReactionUtils';
import {
getReactionShortcodeName,
getReactionShorthandTargetId,
} from '@app/features/messaging/utils/ReactionShorthandUtils';
import {
type AutocompleteTrigger,
detectAutocompleteTrigger,
@@ -704,14 +709,20 @@ export function useLexicalAutocomplete({
const caret = currentTextUpToCursor.length;
const matchStart = getComposerAutocompleteReplacementStart(currentTextUpToCursor, trigger.type, trigger.match);
if (trigger.type === 'emojiReaction' && isEmoji(option)) {
if (channel != null) {
const messages = Messages.getMessages(channel.id).toArray();
const mostRecent = messages[messages.length - 1];
if (mostRecent != null) {
ReactionCommands.addReaction(i18n, channel.id, mostRecent.id, toReactionEmoji(option.emoji));
}
const targetId = channel == null ? null : getReactionShorthandTargetId(channel.id);
if (channel != null && targetId !== null) {
ReactionCommands.addReaction(i18n, channel.id, targetId, toReactionEmoji(option.emoji));
MessageCommands.stopReply(channel.id);
handle.clear();
return;
}
handle.clear();
applyComposerReplacement(
handle,
{start: matchStart, end: caret},
{kind: 'text', text: `+:${getReactionShortcodeName(option.emoji)}:`},
{trailing: true},
{maxWireLength: maxActualLength, onExceedMaxLength},
);
return;
}
if (isCommand(option)) {
@@ -20,6 +20,7 @@ import GuildMembers from '@app/features/member/state/GuildMembers';
import MemberSidebar from '@app/features/member/state/MemberSidebar';
import * as DraftCommands from '@app/features/messaging/commands/DraftCommands';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import * as ReactionCommands from '@app/features/messaging/commands/ReactionCommands';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import type {MentionConfirmationInfo, MentionType} from '@app/features/messaging/state/MentionConfirmationStateMachine';
import Messages from '@app/features/messaging/state/MessagingMessages';
@@ -29,6 +30,10 @@ import {
isAttachmentOnlyMessage,
} from '@app/features/messaging/utils/MessageEditContentUtils';
import {canSubmitMessage, hasVisibleMessageContent} from '@app/features/messaging/utils/MessageRequestUtils';
import {
getReactionShorthandTargetId,
parseReactionShorthand,
} from '@app/features/messaging/utils/ReactionShorthandUtils';
import * as ReplaceCommandUtils from '@app/features/messaging/utils/ReplaceCommandUtils';
import {resolveTypedEmojiShortcodes} from '@app/features/messaging/utils/TypedEmojiShortcodeUtils';
import Permission from '@app/features/permissions/state/Permission';
@@ -458,6 +463,20 @@ export const useTextareaSubmit = ({
parsedCommand = lexicalCommand.command;
}
const replaceCommand = ReplaceCommandUtils.parseReplaceCommand(actualContent);
const reactionShorthand =
editingMessage === null && uploadAttachmentsLength === 0 && !hasPendingSticker
? parseReactionShorthand(actualContent, Channels.getChannel(channelId) ?? null, guildId, i18n)
: null;
const reactionTargetId = reactionShorthand === null ? null : getReactionShorthandTargetId(channelId);
if (reactionShorthand !== null && reactionTargetId !== null) {
ReactionCommands.addReaction(i18n, channelId, reactionTargetId, reactionShorthand);
setValue('');
clearSegments();
DraftCommands.deleteDraft(channelId);
TypingUtils.clear(channelId);
MessageCommands.stopReply(channelId);
return;
}
if (
shouldBlockSubmissionForSlowmode(
isSlowmodeActive,
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Channel} from '@app/features/channel/models/Channel';
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
import {getSkinTonedSurrogate} from '@app/features/expressions/utils/SkinToneUtils';
import UnicodeEmojis from '@app/features/expressions/utils/UnicodeEmojis';
import MessageReply from '@app/features/messaging/state/MessageReply';
import Messages from '@app/features/messaging/state/MessagingMessages';
import type {ReactionEmoji} from '@app/features/messaging/utils/MessageReactionUtils';
import {resolveTypedEmojiToken} from '@app/features/messaging/utils/TypedEmojiShortcodeUtils';
import type {I18n} from '@lingui/core';
const REACTION_SHORTHAND_PATTERN = /^\s*\+:([^\s:]+(?:::skin-tone-[1-5])?):\s*$/u;
const REACTION_SHORTHAND_PREFIX_PATTERN = /^\s*\+$/;
export function isReactionShorthandText(text: string): boolean {
return REACTION_SHORTHAND_PATTERN.test(text);
}
export function isReactionShorthandPrefix(textBefore: string, textAfter: string): boolean {
return REACTION_SHORTHAND_PREFIX_PATTERN.test(textBefore) && textAfter.trim() === '';
}
export function getReactionShortcodeName(emoji: FlatEmoji): string {
if (emoji.id) {
return emoji.name;
}
const name = UnicodeEmojis.nameForSurrogate(getSkinTonedSurrogate(emoji), false);
return name === '' ? emoji.name : name;
}
export function parseReactionShorthand(
content: string,
channel: Channel | null,
guildId: string | null,
i18n: I18n,
): ReactionEmoji | null {
const match = REACTION_SHORTHAND_PATTERN.exec(content);
if (match === null) {
return null;
}
const shortcodeName = match[1];
const unicodeEmoji = UnicodeEmojis.findEmojiByShortcodeName(shortcodeName);
if (unicodeEmoji !== null) {
return {name: unicodeEmoji.surrogates};
}
const resolved = resolveTypedEmojiToken(shortcodeName, channel, guildId, i18n);
if (resolved === null || resolved.kind !== 'custom') {
return null;
}
return {id: resolved.emojiId, name: shortcodeName.replace(/~\d+$/, ''), animated: resolved.animated};
}
export function getReactionShorthandTargetId(channelId: string): string | null {
const reply = MessageReply.getReplyingMessage(channelId);
if (reply !== null) {
return reply.messageId;
}
const messages = Messages.getMessages(channelId).toArray();
return messages[messages.length - 1]?.id ?? null;
}
@@ -8,7 +8,6 @@ import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstant
import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit';
import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
import {isLimitToggleEnabled} from '@app/features/app/utils/LimitUtils';
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
import Guilds from '@app/features/guild/state/Guilds';
@@ -54,6 +53,7 @@ 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 +411,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
);
const showPremiumFeatures = shouldShowPremiumFeatures();
const hasPremium = useMemo(() => showPremiumFeatures && (user?.isPremium() ?? false), [showPremiumFeatures, user]);
const hasProfileTimezoneAccess = (user?.isStaff() ?? false) && DeveloperOptions.showProfileTimezoneSettings;
const hasProfileTimezoneAccess = ProfileTimezoneRollout.enabled;
const hasPerGuildProfiles = useMemo(
() =>
isLimitToggleEnabled(
+8 -13
View File
@@ -203,12 +203,10 @@ export class User {
this.bannerColor = hasKey(user, 'banner_color') ? (user.banner_color ?? null) : undefined;
this.pronouns = hasKey(user, 'pronouns') ? (user.pronouns ?? null) : undefined;
this.accentColor = hasKey(user, 'accent_color') ? (user.accent_color ?? null) : undefined;
const hasProfileTimezoneAccess = this._isStaff ?? (this.flags & PublicUserFlags.STAFF) !== 0;
this.timezone = hasProfileTimezoneAccess && hasKey(user, 'timezone') ? (user.timezone ?? null) : undefined;
this.timezonePrivacyFlags =
hasProfileTimezoneAccess && hasKey(user, 'timezone_privacy_flags')
? (user.timezone_privacy_flags ?? ProfileFieldPrivacyFlags.EVERYONE)
: undefined;
this.timezone = hasKey(user, 'timezone') ? (user.timezone ?? null) : undefined;
this.timezonePrivacyFlags = hasKey(user, 'timezone_privacy_flags')
? (user.timezone_privacy_flags ?? ProfileFieldPrivacyFlags.EVERYONE)
: undefined;
this.mfaEnabled = hasKey(user, 'mfa_enabled') ? user.mfa_enabled : undefined;
this.hasVerifiedPhone = hasKey(user, 'has_verified_phone') ? user.has_verified_phone : undefined;
this.authenticatorTypes = hasKey(user, 'authenticator_types')
@@ -420,13 +418,10 @@ export class User {
if (pronouns !== undefined) result.pronouns = pronouns;
const accentColor = pickField(this.accentColor, u, 'accent_color', opts);
if (accentColor !== undefined) result.accent_color = accentColor;
const hasProfileTimezoneAccess = isStaff ?? (result.flags & PublicUserFlags.STAFF) !== 0;
if (hasProfileTimezoneAccess) {
const timezone = pickField(this.timezone, u, 'timezone', opts);
if (timezone !== undefined) result.timezone = timezone;
const timezonePrivacyFlags = pickField(this.timezonePrivacyFlags, u, 'timezone_privacy_flags', opts);
if (timezonePrivacyFlags !== undefined) result.timezone_privacy_flags = timezonePrivacyFlags;
}
const timezone = pickField(this.timezone, u, 'timezone', opts);
if (timezone !== undefined) result.timezone = timezone;
const timezonePrivacyFlags = pickField(this.timezonePrivacyFlags, u, 'timezone_privacy_flags', opts);
if (timezonePrivacyFlags !== undefined) result.timezone_privacy_flags = timezonePrivacyFlags;
const mfaEnabled = pickField(this.mfaEnabled, u, 'mfa_enabled', opts);
if (mfaEnabled !== undefined) result.mfa_enabled = mfaEnabled;
const hasVerifiedPhone = pickField(this.hasVerifiedPhone, u, 'has_verified_phone', opts);
@@ -0,0 +1,22 @@
// 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,6 +2,7 @@
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';
@@ -96,7 +97,7 @@ export function createMockProfile(
timezone_offset:
options?.previewTimezoneOffset !== undefined
? options.previewTimezoneOffset
: user.isStaff() && (user.timezonePrivacyFlags ?? ProfileFieldPrivacyFlags.EVERYONE) !== 0
: ProfileTimezoneRollout.enabled && (user.timezonePrivacyFlags ?? ProfileFieldPrivacyFlags.EVERYONE) !== 0
? getCurrentTimeZoneOffsetMinutes(user.timezone)
: null,
premium_type: visiblePremiumData.premiumType ?? undefined,
@@ -35,9 +35,10 @@ Missing settings use the defaults documented below. Invalid stored configuration
| sso | [SSO configuration](#sso-configuration-object) object | Single sign-on settings |
| gateway_rollout | [Gateway rollout configuration](#gateway-rollout-configuration-object) object | Gateway admission and dispatch tuning |
| voice_noise_suppression | [voice noise suppression configuration](#voice-noise-suppression-configuration-object) object | Client-side noise suppression rollout |
| push_service_delivery | [push service delivery configuration](#push-service-delivery-configuration-object) object | Push service delivery rollout |
| 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 |
@@ -113,33 +114,29 @@ The instance rollout of client-side noise suppression. [Experiments](/http-api/e
| 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 characters, default `voice-ns-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) |
| guild_overrides | array[[guild override](/http-api/experiments/#noise-suppression-guild-override-object) object] | Per-guild replacements, up to 200 entries (default empty) |
| suppression_strength | integer | Suppression strength (0-100, default 80) |
Every field is present on read. An absent document or missing field uses the defaults above.
`excluded_user_ids` is applied before `included_user_ids`, so the rollout never selects an account in both. A `default_backend` or `guild_overrides` entry naming a backend outside `enabled_backends` is dropped from what a client is served, and the stored value is kept as written.
`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. A `default_backend` or `guild_overrides` entry naming a backend outside `enabled_backends` is dropped from what a client is served, and the stored value is kept as written.
How often a client revalidates this rollout is not set here. It is set once for every experiment in the [experiment delivery configuration](#experiment-delivery-configuration-object) below.
## Push service delivery configuration object
## Push relay configuration object
The instance rollout of push service delivery.
The operator's consent to the push relay supplemental privacy notice.
### 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 `push-service-delivery-v1`) |
| included_user_ids | array[snowflake] | Accounts the rollout always selects, up to 1000 entries (default empty) |
| excluded_user_ids | array[snowflake] | Accounts the rollout never selects, up to 1000 entries (default empty) |
| relay_consent_accepted | boolean | Whether the operator accepted the push relay supplemental privacy notice (default false) |
| relay_consent_accepted_at | ?string | ISO 8601 timestamp of that acceptance, or null when the notice stands unaccepted (default null) |
| relay_consent_accepted_by | ?snowflake | Admin account that accepted the notice, or null when the notice stands unaccepted (default null) |
| relay_consent_accepted_by | ?snowflake | Account that accepted the notice, or null when the notice stands unaccepted (default null) |
Every field is present on read. An absent document or missing field uses the defaults above.
@@ -158,13 +155,15 @@ The instance rollout that moves the official web client from its legacy origin t
| 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 `domain-migration-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) |
| anonymous_rollout_basis_points | integer | Share of logged-out devices the rollout moves, in basis points (0-10000, default 0) |
| standalone_forwarding | boolean | Whether installed desktop web apps forward to the new origin once their data has moved (default false) |
Every field is present on read. An absent document or missing field uses the defaults above.
`excluded_user_ids` is applied before `included_user_ids`, so the rollout never selects an account in both.
`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 installed Chromium desktop web app moves its data like a browser tab, then stays on the legacy origin and offers to install the app from the new one. Set `standalone_forwarding` once the web app manifest lists the new origin in `scope_extensions` and the new origin serves the matching association file. From then on the installed app forwards like a browser tab. Installed mobile and Safari web apps never forward, whatever the value.
@@ -187,6 +186,8 @@ The instance rollout that replaces the configured captcha provider with an ALTCH
| 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 `altcha-captcha-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) |
| anonymous_enabled | boolean | Whether logged-out requests get ALTCHA (default false) |
| cost | integer | PBKDF2 iterations per solving attempt (1000-100000, default 5000) |
@@ -194,12 +195,35 @@ The instance rollout that replaces the configured captcha provider with an ALTCH
Every field is present on read. An absent document or missing field uses the defaults above.
`excluded_user_ids` is applied before `included_user_ids`, so the rollout never selects an account in both. `anonymous_enabled` has no effect on signed-in requests, and the account rules have no effect on logged-out ones.
`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. `anonymous_enabled` has no effect on signed-in requests, and the account rules have no effect on logged-out ones.
Each challenge hides its counter between half of `max_counter` and `max_counter`, and a client tries counters from 0 upward. Solve time grows with `cost` times `max_counter`. Fluxer spends one attempt at `cost` to issue each challenge.
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.
@@ -608,9 +632,10 @@ The body has one optional object for each section. Fluxer leaves an absent secti
| sso?<sup>1</sup> | object | Every [SSO configuration](#sso-configuration-object) field except `client_secret_set` and `redirect_uri`, plus `client_secret` |
| gateway_rollout? | object | Any subset of the [Gateway rollout configuration](#gateway-rollout-configuration-object) fields, each bound as documented there |
| voice_noise_suppression? | object | Any subset of the [noise suppression](#voice-noise-suppression-configuration-object) fields |
| push_service_delivery? | object | Any subset of the [push service delivery](#push-service-delivery-configuration-object) fields |
| 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 |
@@ -624,12 +649,14 @@ The body has one optional object for each section. Fluxer leaves an absent secti
`voice_noise_suppression` takes every [voice noise suppression configuration](#voice-noise-suppression-configuration-object) field except `config_version`, each bound as documented there. Fluxer raises `config_version` by one on each request that supplies at least one of them. A section that is absent, or present with no field set, writes nothing and leaves `config_version` alone.
`push_service_delivery` works the same way, over the [push service delivery configuration](#push-service-delivery-configuration-object) fields and its own `config_version`. It also takes `relay_consent_accepted`. Fluxer sets `relay_consent_accepted_at` and `relay_consent_accepted_by` itself on the request that changes that flag, and accepts neither from a request: turning the flag on stamps the current time and the acting Admin account, and turning it off clears both to null. A request that repeats the flag it already holds leaves the stamp alone.
`push_relay` takes only `relay_consent_accepted`. Fluxer sets `relay_consent_accepted_at` and `relay_consent_accepted_by` itself on the request that changes that flag, and accepts neither from a request. Turning the flag on stamps the current time and the acting account, and turning it off clears both to null. A request that repeats the flag it already holds leaves the stamp alone.
`domain_migration` works the same way, over the [domain migration configuration](#domain-migration-configuration-object) fields and its own `config_version`.
`domain_migration` works the same way as `voice_noise_suppression`, over the [domain migration configuration](#domain-migration-configuration-object) fields and its own `config_version`.
`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
@@ -666,12 +693,12 @@ Fluxer skips URL validation while the merged configuration leaves single sign-on
| 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`, `voice_noise_suppression`, `push_service_delivery`, `domain_migration`, `altcha_captcha`, `experiment_delivery`, `sso`, `registration`, `app_public` branding, legal, and registration fields, `integrations`, `media`, `policy`, and finally `app_public.setup`. A failure part way through leaves the earlier sections written.
The order is `gateway_rollout`, `voice_noise_suppression`, `push_relay`, `domain_migration`, `altcha_captcha`, `profile_timezone`, `experiment_delivery`, `sso`, `registration`, `app_public` branding, legal, and registration fields, `integrations`, `media`, `policy`, and finally `app_public.setup`. A failure part way through leaves the earlier sections written.
:::
### Side effects
Fluxer publishes a `gateway_rollout` change to the Gateway cluster. Premium mode changes affect the limits in force without replacing the saved limit configuration. On self-hosted deployments, `everyone` hides premium-filtered rules. Switching back to `mirror` restores them unless an Admin has replaced the limit configuration in the meantime. Enabling single community mode creates the community when none is designated, with the acting Admin as owner.
Fluxer publishes a `gateway_rollout` change to the Gateway cluster. A `push_relay` change reaches the push service without a restart. Premium mode changes affect the limits in force without replacing the saved limit configuration. On self-hosted deployments, `everyone` hides premium-filtered rules. Switching back to `mirror` restores them unless an Admin has replaced the limit configuration in the meantime. Enabling single community mode creates the community when none is designated, with the acting Admin as owner.
Initial setup completes on the first update that sets `app_public.setup.configured` to true from a session credential whose account holds neither `admin:authenticate` nor the wildcard. That update grants the account the wildcard Admin ACL and marks the deployment as bootstrapped.
@@ -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 `voice_noise_suppression`, whose placement protocol [Voice](/voice/) defines, `domain_migration` and `altcha_captcha`.
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 `voice_noise_suppression`, whose placement protocol [Voice](/voice/) defines, `domain_migration`, `altcha_captcha` and `profile_timezone`.
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.
@@ -35,6 +35,7 @@ One entry per experiment. The envelope reports this object even when it is empty
| voice_noise_suppression? | [noise suppression assignment](#noise-suppression-assignment-object) object | The caller's noise suppression assignment |
| 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.
@@ -84,7 +85,7 @@ The outcomes below set `user_targeted` to false, and they differ in what else th
2. The operator has excluded the caller. `enabled` is true, and every other field is as in the first outcome.
3. The caller was not drawn. `enabled` is true, and `enabled_backends`, `guild_overrides`, and `allow_user_override` all hold their configured values.
A caller is drawn either by the operator's allowlist, which sets `source` to `user_rule`, or by the sampled share of the account population, which sets `source` to `canary`. A caller that is drawn while `backend` is absent from `enabled_backends` is reported as not drawn, with `user_targeted` false and both `backend` and `source` null.
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. The sampled share sets `source` to `canary`, and every other rule sets it to `user_rule`. A caller that is drawn while `backend` is absent from `enabled_backends` is reported as not drawn, with `user_targeted` false and both `backend` and `source` null.
A client branches on `user_targeted` rather than on `enabled_backends`, because the third outcome keeps the array populated. A `guild_overrides` entry applies in its guild whether or not the caller was drawn.
@@ -113,7 +114,7 @@ One resolution of the instance web domain migration rollout against one account.
| --- | --- | --- |
| enabled | boolean | Whether the caller's web client moves to the new web origin |
A caller is drawn either by the operator's allowlist or by the sampled share of the account population. `enabled` is false in every other case, the rollout being off included.
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.
Only the official web client acts on this assignment, and only on its legacy origins. Every other client ignores it. The logged-out share and the instance-wide switch are published in the [instance discovery document](/http-api/instance/#domain-migration-object) instead, because a client that holds no credential cannot read this route.
@@ -127,10 +128,24 @@ One resolution of the instance ALTCHA captcha rollout against one account. This
| --- | --- | --- |
| enabled | boolean | Whether the caller's captcha challenges are ALTCHA proof-of-work challenges |
A caller is drawn either by the operator's allowlist or by the sampled share of the account population. `enabled` is false in every other case, the rollout being off included.
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 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> The pair is present only while the account has the staff flag, and it is absent for every other account
<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>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 target has the staff flag, 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 [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>6</sup> The field is present only on a restricted read
@@ -108,7 +108,7 @@ 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> The pair is staff-only. A request from an account without the [`STAFF`](/http-api/users/#public-user-flags) flag has both fields dropped before the update runs
<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>11</sup> An unrecognised identifier is rejected with `INVALID_TIMEZONE_IDENTIFIER`. Setting a first timezone without `timezone_privacy_flags` sets those flags to `EVERYONE`
@@ -811,17 +811,15 @@ For environment-based configuration, use `FLUXER_AUTH_BLUESKY_ENABLED`, `FLUXER_
| FLUXER_VAPID_PRIVATE_KEY | `CHANGE_ME` | The VAPID private key. Base64url of the 32-byte scalar, and the matching half of the pair |
| FLUXER_VAPID_EMAIL | unset | The VAPID contact address. Compose derives `admin@` followed by `FLUXER_DOMAIN` when it is unset |
The Gateway reads the same names. A malformed pair, or a private key that does not derive the public point, does not stop it. It records the fault in its log at startup and then drops every web push notification.
`FLUXER_GATEWAY_PUSH_ENABLED` is read by the Gateway alone, defaults to `true`, and skips that startup check on the VAPID pair when it is `false`.
`FLUXER_GATEWAY_PUSH_ENABLED` is read by the Gateway alone and defaults to `true`. When it is `false`, the Gateway hands no message or clear notification to `push`.
## Mobile push
`api`, `gateway`, and `push` read the APNs and FCM names. None appear in `.env.example` or in `docker-compose.yml`, so configuring mobile push means editing the Compose file. All are optional.
`api` and `push` read the APNs and FCM names. None appear in `.env.example` or in `docker-compose.yml`, so configuring mobile push means editing the Compose file. All are optional.
#### `FLUXER_PUSH_APNS_ENABLED`
Default `false`. The APNs switch. Must be set on `api`, `gateway`, and `push`.
Default `false`. The APNs switch. Must be set on `api` and `push`.
#### `FLUXER_PUSH_APNS_TEAM_ID`
@@ -849,7 +847,7 @@ Default `[]`. Per-app APNs configuration. JSON array. An entry with no `app_id`
#### `FLUXER_PUSH_FCM_ENABLED`
Default `false`. The FCM switch. Must be set on `api`, `gateway`, and `push`.
Default `false`. The FCM switch. Must be set on `api` and `push`.
#### `FLUXER_PUSH_FCM_PROJECT_ID`
@@ -907,6 +905,10 @@ No default. Replaces the APNs host in both environments. Set it only for a test
Default `https://fcm.googleapis.com`. The FCM host. Set it only for a proxy or a test double.
#### `FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED`
Default `false`. Accepts the push relay supplemental privacy notice for this `push` process. `true`, `1`, or `yes` accepts it, and `false`, `0`, or `no` leaves the decision to the [push relay configuration](/admin-api/instance/#push-relay-configuration-object). Any other value fails startup. When it is `true`, `push` sends through the Fluxer-run relay even while the stored consent is off. Compose does not forward it.
## Payments
Stripe billing, which the shipped stack keeps off. All are optional.
@@ -1711,7 +1713,7 @@ See [Bluesky connections](#bluesky-connections) for configuration.
#### `FLUXER_PUSH_APNS_` and `FLUXER_PUSH_FCM_`
Mobile push cannot be configured at all from the example. `api`, `gateway`, and `push` all read these names. An override has to reach all three.
Mobile push cannot be configured at all from the example. `api` and `push` both read these names. An override has to reach both.
#### `RUST_LOG`, `LOG_LEVEL` and `LOGGER_LEVEL`
+1 -3
View File
@@ -2,7 +2,6 @@
{erl_opts, [debug_info, nowarn_deprecated_function]}.
{deps, [
{cowboy, "2.19.0"},
{jose, "1.11.12"},
{ezstd, "1.2.4"},
{enats, "1.2.0"},
{opentelemetry_api, "1.5.0"},
@@ -23,7 +22,6 @@
{override, cowboy, [{deps, [cowlib, ranch]}]},
{override, enats, [{plugins, []}, {project_plugins, []}]},
{add, enats, [{erl_opts, [nowarn_deprecated_catch]}]},
{add, jose, [{erl_opts, [nowarn_deprecated_catch]}]},
{override, enats_msg, [{plugins, []}, {project_plugins, []}]},
{override, ezstd, [{plugins, []}, {project_plugins, []}]}
]}.
@@ -88,7 +86,7 @@
]},
{plt_apps, all_deps},
{plt_extra_apps, [
crypto, enats, inets, jose, public_key, ranch, ssl
crypto, enats, inets, public_key, ranch, ssl
]},
{warnings_file, "dialyzer.ignore-warnings"}
]}.
-3
View File
@@ -9,7 +9,6 @@
"eqwalizer_support"},
0},
{<<"ezstd">>,{pkg,<<"ezstd">>,<<"1.2.4">>},0},
{<<"jose">>,{pkg,<<"jose">>,<<"1.11.12">>},0},
{<<"opentelemetry_api">>,{pkg,<<"opentelemetry_api">>,<<"1.5.0">>},0},
{<<"ranch">>,{pkg,<<"ranch">>,<<"2.3.0">>},1}]}.
[
@@ -19,7 +18,6 @@
{<<"enats">>, <<"D7459C804013CAFA4AF880B18D446C48890D28D372D62AD66C76187E5779248D">>},
{<<"enats_msg">>, <<"50631124F37D88BE76A91A5B96A6565C5981EBF917CD819F0175CA658A966F43">>},
{<<"ezstd">>, <<"7AB3ED4BF5ED93E249C936F457A060CF99487F392A41B2B3FD0F93E2C792A7D6">>},
{<<"jose">>, <<"06E62B467B61D3726CBC19E9B5489F7549C37993DE846DFB3EE8259F9ED208B3">>},
{<<"opentelemetry_api">>, <<"1A676F3E3340CAB81C763E939A42E11A70C22863F645AA06AAFEFC689B5550CF">>},
{<<"ranch">>, <<"7DE7B041A9A6A5091A3AA5898D66C0564BE671D87DB4F9D63B1B5EE775B097DF">>}]},
{pkg_hash_ext,[
@@ -28,7 +26,6 @@
{<<"enats">>, <<"20DEB3CB1D3E960194DF8B136C40D2DB085B485BBA5E493B340AB5F9FD2BED22">>},
{<<"enats_msg">>, <<"C4F2139E5144FABC99AFE01B8B016AD9DA278CDDC60857AC5D5AFB0AD1283534">>},
{<<"ezstd">>, <<"C79A63C8F1706CA5402D4D97347A1934F41FD8DD055C7AF4CA92B9BCBEF6D1C0">>},
{<<"jose">>, <<"31E92B653E9210B696765CDD885437457DE1ADD2A9011D92F8CF63E4641BAB7B">>},
{<<"opentelemetry_api">>, <<"F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA">>},
{<<"ranch">>, <<"6168EC49409D982F7CFBD83DD083144F6CBE67CAA4036551D2F0A3AD67C9D023">>}]}
].
@@ -11,7 +11,6 @@
public_key,
ssl,
inets,
jose,
ranch,
cowboy,
ezstd,
@@ -8,26 +8,11 @@
-spec start(application:start_type(), term()) -> {ok, pid()} | {error, term()}.
start(_StartType, _StartArgs) ->
erlang:system_flag(fullsweep_after, 10),
init_jose(),
init_subsystems(),
{ok, Pid} = fluxer_gateway_sup:start_link(),
{ok, _} = start_cowboy(),
{ok, Pid}.
-spec init_jose() -> ok.
init_jose() ->
case code:ensure_loaded(jose_json_otp) of
{module, jose_json_otp} -> ok;
{error, EnsureErr} -> erlang:error({jose_json_otp_missing, EnsureErr})
end,
application:set_env(jose, json_module, jose_json_otp),
{ok, _} = application:ensure_all_started(jose),
_ = jose:json_module(jose_json_otp),
case jose:json_module() of
jose_json_otp -> ok;
Other -> erlang:error({jose_json_module_registration_failed, Other})
end.
-spec init_subsystems() -> ok.
init_subsystems() ->
_ = fluxer_gateway_env:load(),
@@ -19,7 +19,6 @@
-type gateway_role() :: websocket | sessions | presence | guilds | calls | push | all.
-define(MAX_CLUSTER_STATIC_PEERS, 256).
-define(DEFAULT_MANAGED_RELAY_HOSTS, <<"push.fluxer.com">>).
-spec load() -> config().
load() ->
@@ -33,8 +32,6 @@ env_config() ->
<<"public">> => env_public_config(),
<<"proxy">> => env_proxy_config(),
<<"services">> => env_services_config(),
<<"auth">> => env_auth_config(),
<<"integrations">> => env_integrations_config(),
<<"telemetry">> => env_telemetry_config()
}.
@@ -79,21 +76,9 @@ env_gateway_base_config() ->
<<"gateway_role">> => env_optional_binary("FLUXER_GATEWAY_ROLE"),
<<"rpc_auth_token">> => env_binary("FLUXER_GATEWAY_RPC_AUTH_TOKEN", <<>>),
<<"push_enabled">> => env_bool("FLUXER_GATEWAY_PUSH_ENABLED", true),
<<"push_clear_notifications_enabled">> => env_bool(
"FLUXER_GATEWAY_PUSH_CLEAR_NOTIFICATIONS_ENABLED", true
),
<<"push_enrolled_clear_notifications_enabled">> => env_bool(
"FLUXER_GATEWAY_PUSH_ENROLLED_CLEAR_NOTIFICATIONS_ENABLED", true
),
<<"push_endpoint_guard_enabled">> => env_bool(
"FLUXER_GATEWAY_PUSH_ENDPOINT_GUARD_ENABLED", true
),
<<"push_managed_relay_hosts">> => env_binary(
"FLUXER_GATEWAY_PUSH_MANAGED_RELAY_HOSTS", ?DEFAULT_MANAGED_RELAY_HOSTS
),
<<"push_relay_consent_accepted">> => env_bool(
"FLUXER_GATEWAY_PUSH_RELAY_CONSENT_ACCEPTED", false
),
<<"push_outbox_request_timeout_ms">> => env_int(
"FLUXER_GATEWAY_PUSH_OUTBOX_REQUEST_TIMEOUT_MS", 100000
),
@@ -146,56 +131,6 @@ env_nats_config() ->
<<"auth_token">> => env_string("FLUXER_NATS_AUTH_TOKEN", "")
}.
-spec env_auth_config() -> map().
env_auth_config() ->
#{
<<"vapid">> => #{
<<"email">> => env_binary("FLUXER_VAPID_EMAIL", <<>>),
<<"public_key">> => env_optional_binary("FLUXER_VAPID_PUBLIC_KEY"),
<<"private_key">> => env_optional_binary("FLUXER_VAPID_PRIVATE_KEY")
}
}.
-spec env_integrations_config() -> map().
env_integrations_config() ->
#{
<<"push">> => #{
<<"apns">> => env_apns_config(),
<<"fcm">> => env_fcm_config()
}
}.
-spec env_apns_config() -> map().
env_apns_config() ->
#{
<<"enabled">> => env_bool("FLUXER_PUSH_APNS_ENABLED", false),
<<"team_id">> => env_optional_binary("FLUXER_PUSH_APNS_TEAM_ID"),
<<"key_id">> => env_optional_binary("FLUXER_PUSH_APNS_KEY_ID"),
<<"private_key">> => env_optional_binary("FLUXER_PUSH_APNS_PRIVATE_KEY"),
<<"private_key_path">> => env_optional_binary("FLUXER_PUSH_APNS_PRIVATE_KEY_PATH"),
<<"default_environment">> => env_binary(
"FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT", <<"production">>
),
<<"apps">> => env_json_list("FLUXER_PUSH_APNS_APPS", [])
}.
-spec env_fcm_config() -> map().
env_fcm_config() ->
#{
<<"enabled">> => env_bool("FLUXER_PUSH_FCM_ENABLED", false),
<<"project_id">> => env_optional_binary("FLUXER_PUSH_FCM_PROJECT_ID"),
<<"client_email">> => env_optional_binary("FLUXER_PUSH_FCM_CLIENT_EMAIL"),
<<"private_key">> => env_optional_binary("FLUXER_PUSH_FCM_PRIVATE_KEY"),
<<"private_key_path">> => env_optional_binary("FLUXER_PUSH_FCM_PRIVATE_KEY_PATH"),
<<"service_account_json_path">> => env_optional_binary(
"FLUXER_PUSH_FCM_SERVICE_ACCOUNT_JSON_PATH"
),
<<"token_uri">> => env_binary(
"FLUXER_PUSH_FCM_TOKEN_URI", <<"https://oauth2.googleapis.com/token">>
),
<<"apps">> => env_json_list("FLUXER_PUSH_FCM_APPS", [])
}.
-spec env_telemetry_config() -> map().
env_telemetry_config() ->
#{
@@ -209,10 +144,6 @@ build_config(RawConfig) ->
Internal = get_map(RawConfig, [<<"internal">>]),
Nats = get_map(RawConfig, [<<"services">>, <<"nats">>]),
Telemetry = get_map(RawConfig, [<<"telemetry">>]),
Vapid = get_map(RawConfig, [<<"auth">>, <<"vapid">>]),
Push = get_map(RawConfig, [<<"integrations">>, <<"push">>]),
Apns = get_map(Push, [<<"apns">>]),
Fcm = get_map(Push, [<<"fcm">>]),
Proxy = get_map(RawConfig, [<<"proxy">>]),
Public = get_map(RawConfig, [<<"public">>]),
lists:foldl(fun maps:merge/2, #{}, [
@@ -221,9 +152,6 @@ build_config(RawConfig) ->
build_sharding_config(Service),
build_http_config(Service),
build_cluster_config(Service, Public),
build_vapid_config(Vapid),
build_apns_config(Apns),
build_fcm_config(Fcm),
build_misc_config(Service, Telemetry)
]).
@@ -252,35 +180,13 @@ build_push_config(Service, Public) ->
push_enabled => get_bool(Service, <<"push_enabled">>, true),
push_user_guild_settings_cache_mb =>
get_int(Service, <<"push_user_guild_settings_cache_mb">>, 1024),
push_subscriptions_cache_mb => get_int(
Service, <<"push_subscriptions_cache_mb">>, 1024
),
push_blocked_ids_cache_mb => get_int(Service, <<"push_blocked_ids_cache_mb">>, 1024),
push_badge_counts_cache_mb => get_int(Service, <<"push_badge_counts_cache_mb">>, 256),
push_badge_counts_cache_ttl_seconds =>
get_int(Service, <<"push_badge_counts_cache_ttl_seconds">>, 60),
static_cdn_endpoint => public_endpoint(
get_binary(Service, <<"static_cdn_endpoint">>, <<"http://localhost:8088">>), Public
),
push_dispatcher_max_inflight => get_int(
Service, <<"push_dispatcher_max_inflight">>, 16
),
push_dispatcher_max_queue => get_int(Service, <<"push_dispatcher_max_queue">>, 2048),
push_clear_notifications_enabled => get_bool(
Service, <<"push_clear_notifications_enabled">>, true
),
push_enrolled_clear_notifications_enabled => get_bool(
Service, <<"push_enrolled_clear_notifications_enabled">>, true
),
push_endpoint_guard_enabled => get_bool(
Service, <<"push_endpoint_guard_enabled">>, true
),
push_managed_relay_hosts => parse_host_list(
get_binary(Service, <<"push_managed_relay_hosts">>, ?DEFAULT_MANAGED_RELAY_HOSTS)
),
push_relay_consent_accepted => get_bool(
Service, <<"push_relay_consent_accepted">>, false
),
push_outbox_max_queue => get_int(Service, <<"push_outbox_max_queue">>, 10000),
push_outbox_max_inflight => get_int(Service, <<"push_outbox_max_inflight">>, 64),
push_outbox_request_timeout_ms => get_int(
@@ -309,14 +215,8 @@ build_sharding_config(Service) ->
-spec build_http_config(map()) -> config().
build_http_config(Service) ->
#{
gateway_http_push_connect_timeout_ms =>
get_int(Service, <<"gateway_http_push_connect_timeout_ms">>, 3000),
gateway_http_push_recv_timeout_ms =>
get_int(Service, <<"gateway_http_push_recv_timeout_ms">>, 5000),
gateway_http_rpc_max_concurrency =>
get_int(Service, <<"gateway_http_rpc_max_concurrency">>, 512),
gateway_http_push_max_concurrency =>
get_int(Service, <<"gateway_http_push_max_concurrency">>, 256),
gateway_http_failure_threshold => get_int(
Service, <<"gateway_http_failure_threshold">>, 6
),
@@ -347,44 +247,6 @@ build_cluster_config(Service, Public) ->
)
}.
-spec build_vapid_config(map()) -> config().
build_vapid_config(Vapid) ->
#{
vapid_email => get_binary(Vapid, <<"email">>, <<>>),
vapid_public_key => get_optional_binary(Vapid, <<"public_key">>),
vapid_private_key => get_optional_binary(Vapid, <<"private_key">>)
}.
-spec build_apns_config(map()) -> config().
build_apns_config(Apns) ->
#{
apns_enabled => get_bool(Apns, <<"enabled">>, false),
apns_team_id => get_optional_binary(Apns, <<"team_id">>),
apns_key_id => get_optional_binary(Apns, <<"key_id">>),
apns_private_key => get_optional_binary(Apns, <<"private_key">>),
apns_private_key_path => get_optional_binary(Apns, <<"private_key_path">>),
apns_default_environment => get_binary(
Apns, <<"default_environment">>, <<"production">>
),
apns_apps => get_list(Apns, <<"apps">>, [])
}.
-spec build_fcm_config(map()) -> config().
build_fcm_config(Fcm) ->
#{
fcm_enabled => get_bool(Fcm, <<"enabled">>, false),
fcm_project_id => get_optional_binary(Fcm, <<"project_id">>),
fcm_client_email => get_optional_binary(Fcm, <<"client_email">>),
fcm_private_key => get_optional_binary(Fcm, <<"private_key">>),
fcm_private_key_path => get_optional_binary(Fcm, <<"private_key_path">>),
fcm_service_account_json_path => get_optional_binary(
Fcm, <<"service_account_json_path">>
),
fcm_token_uri =>
get_binary(Fcm, <<"token_uri">>, <<"https://oauth2.googleapis.com/token">>),
fcm_apps => get_list(Fcm, <<"apps">>, [])
}.
-spec build_misc_config(map(), map()) -> config().
build_misc_config(Service, Telemetry) ->
#{
@@ -474,41 +336,6 @@ env_bool(Name, Default) ->
_ -> Default
end.
-spec env_json_list(string(), list()) -> list().
env_json_list(Name, Default) ->
parse_env_json_list(os:getenv(Name), Default).
-spec parse_env_json_list(false | string(), list()) -> list().
parse_env_json_list(false, Default) ->
Default;
parse_env_json_list("", Default) ->
Default;
parse_env_json_list(Value, Default) ->
parse_json_list(Value, Default).
-spec parse_json_list(string(), list()) -> list().
parse_json_list(Value, Default) ->
case unicode:characters_to_binary(Value) of
Encoded when is_binary(Encoded) -> decode_json_list(Encoded, Default);
_ -> Default
end.
-spec decode_json_list(binary(), list()) -> list().
decode_json_list(Value, Default) ->
try json:decode(Value) of
Decoded when is_list(Decoded) -> Decoded;
_ -> Default
catch
_:_ -> Default
end.
-spec get_list(map(), binary(), list()) -> list().
get_list(Map, Key, Default) when is_list(Default) ->
case get_value(Map, Key) of
V when is_list(V) -> V;
_ -> Default
end.
-spec get_int(map(), binary(), integer()) -> integer().
get_int(Map, Key, Default) when is_integer(Default) -> to_int(get_value(Map, Key), Default).
-spec get_optional_int(map(), binary()) -> integer() | undefined.
@@ -603,21 +430,6 @@ to_binary(Str, _) when is_list(Str) -> list_to_binary(config_char_list(Str));
to_binary(Atom, _) when is_atom(Atom) -> list_to_binary(atom_to_list(Atom));
to_binary(_, Default) -> Default.
-spec parse_host_list(binary()) -> [binary()].
parse_host_list(Bin) ->
[
lower_ascii(Host)
|| Host <- binary:split(Bin, [<<",">>, <<" ">>, <<"\t">>], [global, trim_all])
].
-spec lower_ascii(binary()) -> binary().
lower_ascii(Bin) ->
<<<<(lower_byte(Byte))>> || <<Byte>> <= Bin>>.
-spec lower_byte(byte()) -> byte().
lower_byte(Byte) when Byte >= $A, Byte =< $Z -> Byte + 32;
lower_byte(Byte) -> Byte.
-spec parse_node_list(binary() | undefined) -> [node()].
parse_node_list(undefined) ->
[];
@@ -38,8 +38,7 @@ common_children() ->
child_spec(gateway_nats_pool, gateway_nats_pool),
child_spec(gateway_event_pause, gateway_event_pause),
child_spec(gateway_concurrency, gateway_concurrency),
child_spec(gateway_rollout_config, gateway_rollout_config),
child_spec(push_delivery_config, push_delivery_config)
child_spec(gateway_rollout_config, gateway_rollout_config)
] ++ cluster_children() ++
[
child_spec(gateway_dispatch_relay, gateway_dispatch_relay),
@@ -99,7 +98,6 @@ role_specs(calls, Role) ->
[child_spec(call_manager, call_manager)] ++ calls_voice_state_counts_sync_children(Role);
role_specs(push, _Role) ->
[
child_spec(push_dispatcher, push_dispatcher),
child_spec(push_outbox, push_outbox),
child_spec(push, push)
].
@@ -7,7 +7,6 @@
-export([start_link/0, request/5, request/6]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-export([pick_sharded_profile/1, ensure_started/0, cleanup_max_age_ms/0]).
-export([push_max_concurrency/0]).
-define(SERVER, ?MODULE).
-define(CIRCUIT_TABLE, gateway_http_circuit_breaker).
@@ -16,11 +15,8 @@
-define(DEFAULT_RPC_CONNECT_TIMEOUT_MS, 5000).
-define(DEFAULT_RPC_RECV_TIMEOUT_MS, 30000).
-define(DEFAULT_PUSH_CONNECT_TIMEOUT_MS, 3000).
-define(DEFAULT_PUSH_RECV_TIMEOUT_MS, 5000).
-define(DEFAULT_RPC_MAX_CONCURRENCY, 512).
-define(DEFAULT_PUSH_MAX_CONCURRENCY, 256).
-define(DEFAULT_FAILURE_THRESHOLD, 500).
-define(DEFAULT_RECOVERY_TIMEOUT_MS, 5000).
@@ -28,9 +24,8 @@
-define(DEFAULT_CLEANUP_MAX_AGE_MS, 300000).
-define(RPC_PROFILE_SHARDS, 8).
-define(PUSH_PROFILE_SHARDS, 4).
-type workload() :: rpc | push.
-type workload() :: rpc.
-type method() :: get | post | put | patch | delete | head | options.
-type request_headers() :: [{binary() | string(), binary() | string()}].
-type request_options() :: #{
@@ -97,10 +92,7 @@ request(Workload, Method, Url, Headers, Body, Opts) when is_map(Opts) ->
-spec pick_sharded_profile(workload()) -> atom().
pick_sharded_profile(rpc) ->
Idx = erlang:phash2(self(), ?RPC_PROFILE_SHARDS),
sharded_profile_name(rpc, Idx);
pick_sharded_profile(push) ->
Idx = erlang:phash2(self(), ?PUSH_PROFILE_SHARDS),
sharded_profile_name(push, Idx).
sharded_profile_name(rpc, Idx).
-spec ensure_started() -> ok.
ensure_started() ->
@@ -113,10 +105,6 @@ ensure_started() ->
cleanup_max_age_ms() ->
get_int_or_default(gateway_http_cleanup_max_age_ms, ?DEFAULT_CLEANUP_MAX_AGE_MS).
-spec push_max_concurrency() -> pos_integer().
push_max_concurrency() ->
get_int_or_default(gateway_http_push_max_concurrency, ?DEFAULT_PUSH_MAX_CONCURRENCY).
-spec init([]) -> {ok, state()}.
init([]) ->
process_flag(trap_exit, true),
@@ -125,7 +113,6 @@ init([]) ->
ensure_table(?CIRCUIT_WINDOW_TABLE),
ensure_table(?INFLIGHT_TABLE),
ok = ensure_sharded_profiles(rpc, ?RPC_PROFILE_SHARDS),
ok = ensure_sharded_profiles(push, ?PUSH_PROFILE_SHARDS),
schedule_cleanup(),
{ok, #{}}.
@@ -315,9 +302,7 @@ ensure_sharded_profiles(Workload, ShardCount) ->
-spec sharded_profile_name(workload(), non_neg_integer()) -> atom().
sharded_profile_name(rpc, Idx) ->
list_to_atom("gateway_http_rpc_profile_" ++ integer_to_list(Idx));
sharded_profile_name(push, Idx) ->
list_to_atom("gateway_http_push_profile_" ++ integer_to_list(Idx)).
list_to_atom("gateway_http_rpc_profile_" ++ integer_to_list(Idx)).
-spec ensure_httpc_profile(atom(), workload()) -> ok.
ensure_httpc_profile(Profile, Workload) ->
@@ -339,9 +324,7 @@ workload_httpc_options(rpc) ->
{max_keep_alive_length, 128},
{max_pipeline_length, 0},
{keep_alive_timeout, 120000}
];
workload_httpc_options(push) ->
[{max_sessions, 512}, {max_keep_alive_length, 128}].
].
-spec merged_workload_options(workload(), request_options()) -> request_options().
merged_workload_options(Workload, Opts) ->
@@ -357,16 +340,6 @@ default_options(rpc) ->
gateway_http_rpc_max_concurrency,
?DEFAULT_RPC_MAX_CONCURRENCY,
<<"application/json">>
);
default_options(push) ->
default_options(
gateway_http_push_connect_timeout_ms,
?DEFAULT_PUSH_CONNECT_TIMEOUT_MS,
gateway_http_push_recv_timeout_ms,
?DEFAULT_PUSH_RECV_TIMEOUT_MS,
gateway_http_push_max_concurrency,
?DEFAULT_PUSH_MAX_CONCURRENCY,
<<"application/octet-stream">>
).
-spec default_options(atom(), integer(), atom(), integer(), atom(), integer(), binary()) ->
@@ -12,7 +12,7 @@
]).
-export_type([workload/0, method/0, request_headers/0, request_options/0, response/0]).
-type workload() :: rpc | push.
-type workload() :: rpc.
-type method() :: get | post | put | patch | delete | head | options.
-type request_headers() :: [{binary() | string(), binary() | string()}].
-type request_options() :: #{
@@ -187,18 +187,7 @@ retry_update_counter(Table, Key, Op) ->
-spec is_countable_circuit_failure({atom(), binary()}, response()) -> boolean().
is_countable_circuit_failure({rpc, _Host}, Result) ->
is_countable_failure_with_transport(Result);
is_countable_circuit_failure({_Workload, _Host}, Result) ->
is_countable_failure_without_transport(Result).
-spec is_countable_failure_without_transport(response()) -> boolean().
is_countable_failure_without_transport({error, nxdomain}) -> false;
is_countable_failure_without_transport({error, {failed_connect, _}}) -> false;
is_countable_failure_without_transport({error, timeout}) -> false;
is_countable_failure_without_transport({error, {timeout, _}}) -> false;
is_countable_failure_without_transport({error, _}) -> true;
is_countable_failure_without_transport({ok, StatusCode, _, _}) when StatusCode >= 500 -> true;
is_countable_failure_without_transport(_) -> false.
is_countable_failure_with_transport(Result).
-spec is_countable_failure_with_transport(response()) -> boolean().
is_countable_failure_with_transport({error, nxdomain}) -> true;
@@ -333,19 +322,6 @@ safe_delete(Table, Key) ->
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
circuit_ignores_transport_failures_for_non_rpc_workloads_test() ->
?assertEqual(false, is_countable_circuit_failure({push, <<"h">>}, {error, nxdomain})),
?assertEqual(
false, is_countable_circuit_failure({push, <<"h">>}, {error, {failed_connect, []}})
),
?assertEqual(false, is_countable_circuit_failure({push, <<"h">>}, {error, timeout})),
?assertEqual(
false, is_countable_circuit_failure({push, <<"h">>}, {error, {timeout, connect}})
),
?assertEqual(true, is_countable_circuit_failure({push, <<"h">>}, {error, closed})),
?assertEqual(true, is_countable_circuit_failure({push, <<"h">>}, {ok, 500, [], <<>>})),
?assertEqual(false, is_countable_circuit_failure({push, <<"h">>}, {ok, 200, [], <<>>})).
circuit_counts_transport_failures_for_rpc_test() ->
?assertEqual(true, is_countable_circuit_failure({rpc, <<"h">>}, {error, nxdomain})),
?assertEqual(
@@ -388,7 +388,6 @@ handle_msg(
-spec subject_owner(binary()) -> atom() | undefined.
subject_owner(<<"config.gateway.rollout">>) -> gateway_rollout_config;
subject_owner(<<"config.push.delivery">>) -> push_delivery_config;
subject_owner(_Subject) -> undefined.
-spec notify_resubscribed(binary()) -> ok.
@@ -36,15 +36,9 @@ handle_dispatch(#{<<"user_id">> := UserIdBin, <<"event">> := Event, <<"data">> :
-spec handle_unreachable_dispatch(atom(), integer(), map()) -> no_return().
handle_unreachable_dispatch(message_create, UserId, Data) ->
push_unreachable_dispatch(push_delivery_config:is_enrolled(UserId), UserId, Data);
handle_unreachable_dispatch(_EventAtom, _UserId, _Data) ->
gateway_rpc_error:raise(<<"presence_dispatch_error">>).
-spec push_unreachable_dispatch(boolean(), integer(), map()) -> no_return().
push_unreachable_dispatch(true, UserId, Data) ->
_ = handle_offline_dispatch(message_create, UserId, Data),
gateway_rpc_error:raise(<<"presence_dispatch_error">>);
push_unreachable_dispatch(false, _UserId, _Data) ->
handle_unreachable_dispatch(_EventAtom, _UserId, _Data) ->
gateway_rpc_error:raise(<<"presence_dispatch_error">>).
-spec dispatch_event_atom_or_error(term()) -> atom().
@@ -116,4 +116,31 @@ invalidate_badge_counts_rejects_invalid_snowflakes_test() ->
invalidate_badge_counts_accepts_an_empty_batch_test() ->
?assert(execute_method(<<"push.invalidate_badge_counts">>, #{<<"user_ids">> => []})).
invalidate_badge_counts_accepts_a_batch_test() ->
?assert(
execute_method(<<"push.invalidate_badge_counts">>, #{
<<"user_ids">> => [<<"1001">>, <<"1002">>]
})
).
invalidate_badge_count_accepts_a_user_test() ->
?assert(execute_method(<<"push.invalidate_badge_count">>, #{<<"user_id">> => <<"1001">>})).
invalidate_badge_count_rejects_an_invalid_snowflake_test() ->
?assertError(
{validation, _},
execute_method(<<"push.invalidate_badge_count">>, #{<<"user_id">> => <<"nope">>})
).
invalidate_subscriptions_accepts_a_user_test() ->
?assert(
execute_method(<<"push.invalidate_subscriptions">>, #{<<"user_id">> => <<"1001">>})
).
invalidate_subscriptions_rejects_an_invalid_snowflake_test() ->
?assertError(
{validation, _},
execute_method(<<"push.invalidate_subscriptions">>, #{<<"user_id">> => <<"nope">>})
).
-endif.
+49 -117
View File
@@ -44,8 +44,6 @@ render_metrics() ->
render_gateway_gauges(),
render_cluster_counters(),
render_process_counts(),
render_push_dispatcher_stats(),
render_push_delivery_gate_stats(),
render_push_outbox_stats(safe_apply_map(fun push_outbox:stats/0)),
render_vm_metrics()
].
@@ -254,97 +252,11 @@ count_registry_prefix(Prefix) ->
error:badarg -> 0
end.
-spec render_push_dispatcher_stats() -> iolist().
render_push_dispatcher_stats() ->
Stats = safe_apply_map(fun push_dispatcher:stats/0),
case map_size(Stats) of
0 ->
[];
_ ->
Queued = maps:get(queued, Stats, 0),
Inflight = maps:get(inflight, Stats, 0),
[
format_metric(
<<"fluxer_gateway_push_dispatcher_queued">>,
<<"gauge">>,
<<"Push dispatcher queued jobs">>,
integer_to_binary(Queued)
),
format_metric(
<<"fluxer_gateway_push_dispatcher_inflight">>,
<<"gauge">>,
<<"Push dispatcher in-flight jobs">>,
integer_to_binary(Inflight)
)
]
end.
-spec render_push_delivery_gate_stats() -> iolist().
render_push_delivery_gate_stats() ->
ConfigVersion = safe_apply_int(fun push_delivery_config:config_version/0),
[
format_metric(
<<"fluxer_gateway_push_delivery_config_version">>,
<<"gauge">>,
<<"Push delivery config version in effect">>,
integer_to_binary(ConfigVersion)
),
render_push_delivery_config_updates(
safe_apply_map(fun push_delivery_config:update_counts/0)
),
render_push_delivery_gate_counters(safe_apply_map(fun push:delivery_gate_counters/0))
].
-spec render_push_delivery_config_updates(map()) -> iolist().
render_push_delivery_config_updates(Counts) ->
format_labeled_series(
<<"fluxer_gateway_push_delivery_config_updates_total">>,
<<"counter">>,
<<"Push delivery config reads by outcome">>,
[
{<<"result=\"updated\"">>, gate_counter(updated, Counts)},
{<<"result=\"unchanged\"">>, gate_counter(unchanged, Counts)},
{<<"result=\"stale\"">>, gate_counter(stale, Counts)},
{<<"result=\"rejected\"">>, gate_counter(rejected, Counts)}
]
).
-spec render_push_delivery_gate_counters(map()) -> iolist().
render_push_delivery_gate_counters(Counters) when map_size(Counters) =:= 0 ->
[];
render_push_delivery_gate_counters(Counters) ->
[
format_metric(
<<"fluxer_gateway_push_delivery_service_users_total">>,
<<"counter">>,
<<"Recipients routed to the push service">>,
gate_counter(delivery_gate_service_users, Counters)
),
format_metric(
<<"fluxer_gateway_push_delivery_gateway_users_total">>,
<<"counter">>,
<<"Recipients kept on the gateway push path">>,
gate_counter(delivery_gate_gateway_users, Counters)
),
format_metric(
<<"fluxer_gateway_push_delivery_jobs_published_total">>,
<<"counter">>,
<<"Push jobs published to the push service">>,
gate_counter(delivery_gate_jobs_published, Counters)
),
format_metric(
<<"fluxer_gateway_push_delivery_publish_failed_total">>,
<<"counter">>,
<<"Push job publishes that fell back to the gateway path">>,
gate_counter(delivery_gate_publish_failed, Counters)
)
].
-spec render_push_outbox_stats(map()) -> iolist().
render_push_outbox_stats(Stats) when map_size(Stats) =:= 0 ->
[];
render_push_outbox_stats(Stats) ->
[render_push_outbox_queue_stats(Stats), render_push_outbox_hand_back_stats(Stats)].
[render_push_outbox_queue_stats(Stats), render_push_outbox_dropped(Stats)].
-spec render_push_outbox_queue_stats(map()) -> iolist().
render_push_outbox_queue_stats(Stats) ->
@@ -393,34 +305,25 @@ render_push_outbox_queue_stats(Stats) ->
)
].
-spec render_push_outbox_hand_back_stats(map()) -> iolist().
render_push_outbox_hand_back_stats(Stats) ->
[
format_metric(
<<"fluxer_gateway_push_outbox_fallbacks_total">>,
<<"counter">>,
<<"Push jobs handed back to the gateway push path">>,
gate_counter(fallbacks, Stats)
),
format_metric(
<<"fluxer_gateway_push_outbox_lost_total">>,
<<"counter">>,
<<"Push job hand-backs whose gateway push path send crashed">>,
gate_counter(lost, Stats)
),
format_metric(
<<"fluxer_gateway_push_outbox_fallback_backlog">>,
<<"gauge">>,
<<"Push job hand-backs waiting for a runner">>,
gate_counter(fallback_backlog, Stats)
),
format_metric(
<<"fluxer_gateway_push_outbox_fallback_runners">>,
<<"gauge">>,
<<"Push job hand-backs running on the gateway push path">>,
gate_counter(fallback_runners, Stats)
)
].
-spec render_push_outbox_dropped(map()) -> iolist().
render_push_outbox_dropped(Stats) ->
format_labeled_series(
<<"fluxer_gateway_push_outbox_dropped_total">>,
<<"counter">>,
<<"Push jobs dropped undelivered by kind and reason">>,
[
{push_outbox_dropped_label(Kind, Reason), integer_to_binary(Count)}
|| {{Kind, Reason}, Count} <- lists:sort(maps:to_list(maps:get(dropped, Stats, #{}))),
is_atom(Kind),
is_atom(Reason),
is_integer(Count)
]
).
-spec push_outbox_dropped_label(atom(), atom()) -> binary().
push_outbox_dropped_label(Kind, Reason) ->
<<"kind=\"", (atom_to_binary(Kind))/binary, "\",reason=\"", (atom_to_binary(Reason))/binary,
"\"">>.
-spec gate_counter(atom(), map()) -> binary().
gate_counter(Key, Counters) ->
@@ -519,3 +422,32 @@ format_labeled_series(Name, Type, Help, LabelValues) ->
|| {Label, Value} <- LabelValues
]
].
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
push_outbox_drops_render_one_series_per_kind_and_reason_test() ->
Rendered = iolist_to_binary(
render_push_outbox_dropped(#{
dropped => #{{message, expired} => 3, {clear, outbox_unavailable} => 1}
})
),
?assertNotEqual(
nomatch,
binary:match(
Rendered,
<<"fluxer_gateway_push_outbox_dropped_total{kind=\"message\",reason=\"expired\"} 3\n">>
)
),
?assertNotEqual(
nomatch,
binary:match(
Rendered,
<<"fluxer_gateway_push_outbox_dropped_total{kind=\"clear\",reason=\"outbox_unavailable\"} 1\n">>
)
).
push_outbox_without_drops_renders_no_dropped_series_test() ->
?assertEqual([], render_push_outbox_dropped(#{dropped => #{}})).
-endif.
+4
View File
@@ -8,6 +8,10 @@
-export([start_link/1, update_counts/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-ifdef(TEST).
-export([dispatch_event/3]).
-endif.
-define(HIBERNATE_TIMEOUT, 60000).
-define(VOICE_MEMBERS_TABLE_WARNED, {?MODULE, voice_members_table_unavailable}).
@@ -43,12 +43,12 @@ dispatch_member_list_event(channel_create, _EventData, _OldState, UpdatedState)
broadcast_all_updates(UpdatedState);
dispatch_member_list_event(channel_delete, _EventData, _OldState, UpdatedState) ->
broadcast_all_updates(UpdatedState);
dispatch_member_list_event(channel_update, EventData, OldState, UpdatedState) ->
broadcast_channel_update(EventData, OldState, UpdatedState);
dispatch_member_list_event(channel_update_bulk, EventData, OldState, UpdatedState) ->
dispatch_member_list_event(channel_update, EventData, _OldState, UpdatedState) ->
broadcast_channel_update(EventData, UpdatedState);
dispatch_member_list_event(channel_update_bulk, EventData, _OldState, UpdatedState) ->
Channels = maps:get(<<"channels">>, EventData, []),
lists:foldl(
fun(Channel, AccState) -> broadcast_channel_update(Channel, OldState, AccState) end,
fun(Channel, AccState) -> broadcast_channel_update(Channel, AccState) end,
UpdatedState,
Channels
);
@@ -73,25 +73,25 @@ broadcast_all_updates(UpdatedState) ->
{ok, NewState} = guild_member_list:broadcast_all_member_list_updates(UpdatedState),
NewState.
-spec broadcast_channel_update(event_data(), guild_state(), guild_state()) -> guild_state().
broadcast_channel_update(EventData, OldState, UpdatedState) ->
-spec broadcast_channel_update(event_data(), guild_state()) -> guild_state().
broadcast_channel_update(EventData, UpdatedState) ->
ChannelIdBin = maps:get(<<"id">>, EventData, undefined),
case guild_dispatch_decorate:parse_snowflake(<<"id">>, ChannelIdBin) of
undefined ->
UpdatedState;
ChannelId ->
broadcast_channel_update_for_id(ChannelId, OldState, UpdatedState)
broadcast_channel_update_for_id(ChannelId, UpdatedState)
end.
%% guild_state:post_update_channel/2 has already rebuilt this channel's engine
%% earlier in the same dispatch, through
%% earlier in the same dispatch when its permission inputs changed, through
%% guild_member_list_write:rebuild_channels_for_permission_change/2. Rebuilding
%% again here is a second full O(members) build over the same state, so when the
%% engine already exists and nothing in the window can have changed what a
%% rebuild would see, queue the sync and skip the rebuild.
-spec broadcast_channel_update_for_id(integer(), guild_state(), guild_state()) -> guild_state().
broadcast_channel_update_for_id(ChannelId, OldState, UpdatedState) ->
case rebuild_is_redundant(ChannelId, OldState, UpdatedState) of
-spec broadcast_channel_update_for_id(integer(), guild_state()) -> guild_state().
broadcast_channel_update_for_id(ChannelId, UpdatedState) ->
case rebuild_is_redundant(ChannelId, UpdatedState) of
true ->
sync_channel_list_without_rebuild(ChannelId, UpdatedState);
false ->
@@ -102,17 +102,15 @@ broadcast_channel_update_for_id(ChannelId, OldState, UpdatedState) ->
end.
%% The engine must already exist, because the earlier pass only rebuilds
%% ALREADY-LOADED engines while this pass would build a missing one. Virtual
%% channel access is compared across the whole dispatch, which is a conservative
%% superset of the window between the two rebuilds: guild_visibility can grant a
%% user virtual access to a channel they just lost permission for, and that does
%% change what a rebuild sees.
-spec rebuild_is_redundant(integer(), guild_state(), guild_state()) -> boolean().
rebuild_is_redundant(ChannelId, OldState, UpdatedState) ->
guild_member_list_channel_engine:ref(integer_to_binary(ChannelId), UpdatedState) =/=
undefined andalso
maps:get(virtual_channel_access, OldState, #{}) =:=
maps:get(virtual_channel_access, UpdatedState, #{}).
%% ALREADY-LOADED engines while this pass would build a missing one. guild_visibility
%% can grant a user virtual access to a channel they just lost permission for, and
%% that does change what a rebuild sees, so the engine must also still match the
%% permission inputs it was built from.
-spec rebuild_is_redundant(integer(), guild_state()) -> boolean().
rebuild_is_redundant(ChannelId, UpdatedState) ->
ListId = integer_to_binary(ChannelId),
guild_member_list_channel_engine:ref(ListId, UpdatedState) =/= undefined andalso
not guild_member_list_engine_inputs:is_stale(ListId, UpdatedState).
%% Mirrors guild_member_list_write:broadcast_list_by_id/4 with the rebuild
%% removed: a list nobody is subscribed to is left alone, otherwise the sync is

Some files were not shown because too many files have changed in this diff Show More