fix(app): gate reworked typing indicators behind an experiment (#2782)

This commit is contained in:
Hampus
2026-09-14 21:54:52 +02:00
committed by GitHub
parent 1861432a53
commit c212d315f4
91 changed files with 6137 additions and 1756 deletions
+54
View File
@@ -10571,6 +10571,7 @@
"blocked_message_groups": {"$ref": "#/components/schemas/BlockedMessageGroupsConfigResponse"},
"expression_info_card": {"$ref": "#/components/schemas/ExpressionInfoCardConfigResponse"},
"guild_header_collapse": {"$ref": "#/components/schemas/GuildHeaderCollapseConfigResponse"},
"typing_indicator_rework": {"$ref": "#/components/schemas/TypingIndicatorReworkConfigResponse"},
"registration": {
"type": "object",
"properties": {
@@ -10980,6 +10981,7 @@
"blocked_message_groups",
"expression_info_card",
"guild_header_collapse",
"typing_indicator_rework",
"registration",
"self_hosted",
"app_public",
@@ -11141,6 +11143,10 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/GuildHeaderCollapseConfigUpdateRequest"}]
},
"typing_indicator_rework": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/TypingIndicatorReworkConfigUpdateRequest"}]
},
"registration": {
"nullable": true,
"type": "object",
@@ -15207,6 +15213,24 @@
"enum": ["open", "approval", "closed"],
"type": "string"
},
"TypingIndicatorReworkConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
"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}$"}
}
}
},
"GuildHeaderCollapseConfigUpdateRequest": {
"type": "object",
"properties": {
@@ -15379,6 +15403,36 @@
"type": "string",
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"]
},
"TypingIndicatorReworkConfigResponse": {
"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": "typing-indicator-rework-v1", "type": "string", "minLength": 1, "maxLength": 64},
"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}$"}
}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"excluded_user_ids"
],
"additionalProperties": false
},
"GuildHeaderCollapseConfigResponse": {
"type": "object",
"properties": {
@@ -36,6 +36,8 @@ pub struct InstanceConfigResponse {
pub expression_info_card: ExpressionInfoCardConfigResponse,
#[serde(default)]
pub guild_header_collapse: GuildHeaderCollapseConfigResponse,
#[serde(default)]
pub typing_indicator_rework: TypingIndicatorReworkConfigResponse,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -777,6 +779,44 @@ pub struct GuildHeaderCollapseConfigUpdateRequest {
pub excluded_user_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct TypingIndicatorReworkConfigResponse {
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>,
}
impl Default for TypingIndicatorReworkConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: "typing-indicator-rework-v1".to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct TypingIndicatorReworkConfigUpdateRequest {
#[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>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ExperimentDeliveryConfigResponse {
@@ -906,6 +946,8 @@ pub struct InstanceConfigUpdateRequest {
pub expression_info_card: Option<ExpressionInfoCardConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_header_collapse: Option<GuildHeaderCollapseConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub typing_indicator_rework: Option<TypingIndicatorReworkConfigUpdateRequest>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -1253,6 +1295,8 @@ mod tests {
.expect("default expression info card config");
let collapse = serde_json::from_value::<GuildHeaderCollapseConfigResponse>(json!({}))
.expect("default guild header collapse config");
let typing = serde_json::from_value::<TypingIndicatorReworkConfigResponse>(json!({}))
.expect("default typing indicator rework config");
let noise = serde_json::to_value(noise).expect("serializable noise config");
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
let hover =
@@ -1267,6 +1311,8 @@ mod tests {
serde_json::to_value(expression).expect("serializable expression info card config");
let collapse =
serde_json::to_value(collapse).expect("serializable guild header collapse config");
let typing =
serde_json::to_value(typing).expect("serializable typing indicator rework config");
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
serde_json::from_value(noise.clone()).expect("generated noise config contract");
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
@@ -1289,6 +1335,9 @@ mod tests {
let generated_collapse: generated_types::GuildHeaderCollapseConfigResponse =
serde_json::from_value(collapse.clone())
.expect("generated guild header collapse config contract");
let generated_typing: generated_types::TypingIndicatorReworkConfigResponse =
serde_json::from_value(typing.clone())
.expect("generated typing indicator rework config contract");
assert_eq!(
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
noise
@@ -1328,6 +1377,11 @@ mod tests {
.expect("serializable generated guild header collapse config"),
collapse
);
assert_eq!(
serde_json::to_value(generated_typing)
.expect("serializable generated typing indicator rework config"),
typing
);
for (name, value) in [
("VoiceNoiseSuppressionConfigResponse", noise),
("ExperimentDeliveryConfigResponse", delivery),
@@ -1337,6 +1391,7 @@ mod tests {
("GuildActivityLogPresentationConfigResponse", activity_log),
("ExpressionInfoCardConfigResponse", expression),
("GuildHeaderCollapseConfigResponse", collapse),
("TypingIndicatorReworkConfigResponse", typing),
] {
for (field, value) in value.as_object().expect("config object") {
assert_eq!(
@@ -1440,6 +1495,46 @@ mod tests {
);
}
#[test]
fn typing_indicator_rework_update_preserves_empty_lists_and_omitted_fields() {
let update = TypingIndicatorReworkConfigUpdateRequest {
included_user_ids: Some(Vec::new()),
excluded_user_ids: Some(Vec::new()),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::TypingIndicatorReworkConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(TypingIndicatorReworkConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[test]
fn typing_indicator_rework_response_defaults_to_the_typing_indicator_rework_v1_salt() {
let config = TypingIndicatorReworkConfigResponse::default();
assert!(!config.enabled);
assert_eq!(config.config_version, 0);
assert_eq!(config.rollout_basis_points, 0);
assert_eq!(config.rollout_salt, "typing-indicator-rework-v1");
assert!(config.included_user_ids.is_empty());
assert!(config.excluded_user_ids.is_empty());
assert_eq!(
serde_json::from_value::<TypingIndicatorReworkConfigResponse>(json!({}))
.expect("default typing indicator rework config")
.rollout_salt,
"typing-indicator-rework-v1"
);
}
#[test]
fn message_hover_tracking_update_preserves_empty_lists_and_omitted_fields() {
let update = MessageHoverTrackingConfigUpdateRequest {
+112 -2
View File
@@ -22,8 +22,9 @@ use crate::{
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters,
MessageHoverTrackingConfigUpdateRequest, MessageKeyboardFocusConfigUpdateRequest,
NoiseSuppressionBackend, PremiumMode, RegistrationMode, SsoConfigUpdateRequest,
VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS, VoiceE2eeScope,
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
TypingIndicatorReworkConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES,
VOICE_NS_MAX_TARGETED_USERS, VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
VoiceNoiseSuppressionGuildOverride,
},
},
config::AdminConfig,
@@ -236,6 +237,10 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_typing_indicator_rework" => match build_typing_indicator_rework_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),
@@ -842,6 +847,38 @@ fn build_guild_header_collapse_update(
})
}
fn build_typing_indicator_rework_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
typing_indicator_rework: Some(TypingIndicatorReworkConfigUpdateRequest {
enabled: Some(form.bool_value("typing_indicator_rework_enabled")),
rollout_basis_points: parse_form_number(
form,
"typing_indicator_rework_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(
form,
"typing_indicator_rework_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("typing_indicator_rework_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("typing_indicator_rework_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_experiment_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
@@ -1836,6 +1873,79 @@ mod tests {
assert_eq!(update.excluded_user_ids, Some(Vec::new()));
}
#[test]
fn build_typing_indicator_rework_update_reads_the_whole_form() {
let form = MultiValueForm::parse(
b"typing_indicator_rework_enabled=true&typing_indicator_rework_rollout_basis_points=2500&typing_indicator_rework_rollout_salt=%20typing-indicator-rework-v2%20&typing_indicator_rework_included_user_ids=1500000000000000001%0A1500000000000000001&typing_indicator_rework_excluded_user_ids=1500000000000000002%2C%201500000000000000003",
);
let update = build_typing_indicator_rework_update(&form)
.expect("valid form")
.typing_indicator_rework
.expect("typing indicator rework update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(2_500));
assert_eq!(
update.rollout_salt,
Some("typing-indicator-rework-v2".to_owned())
);
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned()
])
);
}
#[test]
fn build_typing_indicator_rework_update_leaves_the_rollout_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_typing_indicator_rework_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"typing_indicator_rework": {
"enabled": false,
"included_user_ids": [],
"excluded_user_ids": [],
}})
);
}
#[test]
fn build_typing_indicator_rework_update_rejects_a_rollout_above_the_maximum() {
let form = MultiValueForm::parse(b"typing_indicator_rework_rollout_basis_points=10001");
assert_eq!(
build_typing_indicator_rework_update(&form).expect_err("invalid rollout"),
"Rollout basis points must be a whole number between 0 and 10000"
);
}
#[test]
fn build_typing_indicator_rework_update_reads_only_its_own_prefix() {
let form = MultiValueForm::parse(
b"expression_card_enabled=true&expression_card_rollout_basis_points=2500&expression_card_rollout_salt=expression-info-card-v2&expression_card_included_user_ids=1500000000000000001&typing_indicator_rework_rollout_basis_points=750&typing_indicator_rework_rollout_salt=typing-indicator-rework-v2&typing_indicator_rework_excluded_user_ids=1500000000000000009",
);
let update = build_typing_indicator_rework_update(&form)
.expect("valid form")
.typing_indicator_rework
.expect("typing indicator rework update");
assert_eq!(update.enabled, Some(false));
assert_eq!(update.rollout_basis_points, Some(750));
assert_eq!(
update.rollout_salt,
Some("typing-indicator-rework-v2".to_owned())
);
assert_eq!(update.included_user_ids, Some(Vec::new()));
assert_eq!(
update.excluded_user_ids,
Some(vec!["1500000000000000009".to_owned()])
);
}
#[test]
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
let form = MultiValueForm::parse(b"_csrf=token");
@@ -9,8 +9,9 @@ use crate::{
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
LimitConfigResponse, MessageHoverTrackingConfigResponse,
MessageKeyboardFocusConfigResponse, NoiseSuppressionBackend, PendingRegistrationResponse,
RegistrationUrlResponse, SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES,
VOICE_NS_MAX_TARGETED_USERS, VoiceNoiseSuppressionConfigResponse,
RegistrationUrlResponse, SsoConfigResponse, TypingIndicatorReworkConfigResponse,
VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
VoiceNoiseSuppressionConfigResponse,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -157,6 +158,7 @@ pub fn instance_config_page(
(guild_activity_log_presentation_section(base, csrf_token, &instance_config.guild_activity_log_presentation))
(expression_info_card_section(base, csrf_token, &instance_config.expression_info_card))
(guild_header_collapse_section(base, csrf_token, &instance_config.guild_header_collapse))
(typing_indicator_rework_section(base, csrf_token, &instance_config.typing_indicator_rework))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config))
@@ -1807,6 +1809,110 @@ fn guild_header_collapse_section(
)
}
fn typing_indicator_rework_section(
base: &str,
csrf_token: &str,
typing_indicator_rework: &TypingIndicatorReworkConfigResponse,
) -> Markup {
let status = if typing_indicator_rework.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = typing_indicator_rework.included_user_ids.join("\n");
let excluded_user_ids = typing_indicator_rework.excluded_user_ids.join("\n");
section_card_with_description(
"Typing Indicator Rework",
"Picks how a targeted client sends and shows typing indicators. A targeted client sends a \
typing signal 1.5 seconds after someone starts typing and then at most once every 8 \
seconds, names up to three typists, and announces those names to screen readers even \
where the visible row collapses them. While the master switch below is off every client \
keeps the typing behaviour it ships with, whatever the rest of these fields say.",
html! {
form method="post" action={(base) "/instance-config?action=update_typing_indicator_rework"} {
(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 " (typing_indicator_rework.config_version)
}
}
(checkbox(
"typing_indicator_rework_enabled",
"true",
"Serve typing indicator rework assignments to clients",
typing_indicator_rework.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every client is told the \
rollout is inert and keeps its current typing behaviour, so the rollout \
and targeting fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"typing_indicator_rework_rollout_basis_points",
"Rollout (basis points)",
&typing_indicator_rework.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(
"typing_indicator_rework_rollout_salt",
"Rollout Salt",
&typing_indicator_rework.rollout_salt,
"typing-indicator-rework-v1",
))
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(
"typing_indicator_rework_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
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(
"typing_indicator_rework_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage, so this is the per-user kill switch."
}
}
(form_actions(html! {
(submit_button("Save Typing Indicator Rework Configuration"))
}))
}
}
},
)
}
fn experiment_delivery_section(
base: &str,
csrf_token: &str,
@@ -2482,4 +2588,24 @@ mod tests {
assert!(markup.contains("guild_header_collapse_excluded_user_ids"));
assert!(!markup.contains("expression_card_"));
}
#[test]
fn typing_indicator_rework_section_posts_its_own_action_and_fields() {
let typing_indicator_rework = TypingIndicatorReworkConfigResponse {
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec!["1500000000000000002".to_owned()],
..TypingIndicatorReworkConfigResponse::default()
};
let markup = typing_indicator_rework_section("/admin", "csrf", &typing_indicator_rework)
.into_string();
assert!(markup.contains("/instance-config?action=update_typing_indicator_rework"));
assert!(markup.contains("Typing Indicator Rework"));
assert!(markup.contains("typing-indicator-rework-v1"));
assert!(markup.contains("typing_indicator_rework_enabled"));
assert!(markup.contains("typing_indicator_rework_rollout_basis_points"));
assert!(markup.contains("typing_indicator_rework_rollout_salt"));
assert!(markup.contains("typing_indicator_rework_included_user_ids"));
assert!(markup.contains("typing_indicator_rework_excluded_user_ids"));
assert!(!markup.contains("expression_card_"));
}
}
@@ -457,6 +457,14 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"included_user_ids": [],
"excluded_user_ids": []
},
"typing_indicator_rework": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "typing-indicator-rework-v1",
"included_user_ids": [],
"excluded_user_ids": []
},
"registration": {
"mode": "open",
"admin_registration_urls_enabled": false,
@@ -601,6 +609,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
assert!(!resp.message_keyboard_focus.enabled);
assert!(!resp.blocked_message_groups.enabled);
assert!(!resp.guild_header_collapse.enabled);
assert!(!resp.typing_indicator_rework.enabled);
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));
+9
View File
@@ -433,6 +433,7 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
"/instance-config?action=update_sso",
"/instance-config?action=update_voice_noise_suppression",
"/instance-config?action=update_guild_header_collapse",
"/instance-config?action=update_typing_indicator_rework",
"/instance-config?action=update_experiment_delivery",
][..],
),
@@ -1106,6 +1107,14 @@ fn instance_config() -> Value {
"included_user_ids": [],
"excluded_user_ids": []
},
"typing_indicator_rework": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "typing-indicator-rework-v1",
"included_user_ids": [],
"excluded_user_ids": []
},
"voice_noise_suppression": {
"enabled": false,
"config_version": 0,