feat(admin): one threads toggle, hide hosted-only rollouts (#3257)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-07 21:32:45 +02:00
1 parent 1997850d55
commit 002502a7fa
3 files changed
+94 -359

No files matched your search

+36 -141
View File
@@ -229,10 +229,11 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await), Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message), Err(message) => FlashData::error(message),
}, },
"update_channel_threads" => match build_channel_threads_update(&form) { "update_channel_threads" => instance_config_result(
Ok(update) => instance_config_result(client.update_instance_config(&update).await), client
Err(message) => FlashData::error(message), .update_instance_config(&build_channel_threads_update(&form))
}, .await,
),
"update_experiment_delivery" => match build_experiment_delivery_update(&form) { "update_experiment_delivery" => match build_experiment_delivery_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await), Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message), Err(message) => FlashData::error(message),
@@ -676,51 +677,26 @@ fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateReq
}) })
} }
fn build_channel_threads_update( fn build_channel_threads_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
form: &MultiValueForm, let channel_threads = if form.bool_value("channel_threads_everyone") {
) -> Result<InstanceConfigUpdateRequest, String> { ChannelThreadsConfigUpdateRequest {
Ok(InstanceConfigUpdateRequest { enabled: Some(true),
channel_threads: Some(ChannelThreadsConfigUpdateRequest { guild_basis_points: Some(EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX),
enabled: Some(form.bool_value("channel_threads_enabled")), user_basis_points: Some(EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX),
guild_basis_points: parse_form_number( disabled_guild_ids: Some(Vec::new()),
form, excluded_user_ids: Some(Vec::new()),
"channel_threads_guild_basis_points", ..Default::default()
"Guild rollout basis points", }
0, } else {
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX, ChannelThreadsConfigUpdateRequest {
)?, enabled: Some(false),
guild_salt: parse_experiment_rollout_salt(form, "channel_threads_guild_salt")?, ..Default::default()
enabled_guild_ids: Some(parse_experiment_user_ids( }
form.first("channel_threads_enabled_guild_ids") };
.unwrap_or_default(), InstanceConfigUpdateRequest {
"Enabled guild IDs", channel_threads: Some(channel_threads),
)?),
disabled_guild_ids: Some(parse_experiment_user_ids(
form.first("channel_threads_disabled_guild_ids")
.unwrap_or_default(),
"Disabled guild IDs",
)?),
user_basis_points: parse_form_number(
form,
"channel_threads_user_basis_points",
"User rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
user_salt: parse_experiment_rollout_salt(form, "channel_threads_user_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("channel_threads_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("channel_threads_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default() ..Default::default()
}) }
} }
fn build_experiment_delivery_update( fn build_experiment_delivery_update(
@@ -1489,108 +1465,27 @@ mod tests {
} }
#[test] #[test]
fn build_channel_threads_update_reads_both_rollout_dimensions() { fn build_channel_threads_update_turns_threads_on_for_everyone() {
let form = MultiValueForm::parse( let form = MultiValueForm::parse(b"_csrf=token&channel_threads_everyone=true");
b"channel_threads_enabled=true&channel_threads_guild_basis_points=%2010%20&channel_threads_guild_salt=%20channel-threads-guild-v2%20&channel_threads_enabled_guild_ids=1600000000000000001%0A1600000000000000002%0A1600000000000000001&channel_threads_disabled_guild_ids=1600000000000000003&channel_threads_user_basis_points=10000&channel_threads_user_salt=channel-threads-user-v2&channel_threads_included_user_ids=1500000000000000001&channel_threads_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
);
let update = build_channel_threads_update(&form)
.expect("valid form")
.channel_threads
.expect("channel threads update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.guild_basis_points, Some(10));
assert_eq!( assert_eq!(
update.guild_salt, serde_json::to_value(build_channel_threads_update(&form)).expect("serializable update"),
Some("channel-threads-guild-v2".to_owned())
);
assert_eq!(
update.enabled_guild_ids,
Some(vec![
"1600000000000000001".to_owned(),
"1600000000000000002".to_owned()
])
);
assert_eq!(
update.disabled_guild_ids,
Some(vec!["1600000000000000003".to_owned()])
);
assert_eq!(update.user_basis_points, Some(10000));
assert_eq!(update.user_salt, Some("channel-threads-user-v2".to_owned()));
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec![
"1500000000000000003".to_owned(),
"1500000000000000004".to_owned()
])
);
}
#[test]
fn build_channel_threads_update_leaves_the_experiment_off_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_channel_threads_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"channel_threads": { serde_json::json!({"channel_threads": {
"enabled": false, "enabled": true,
"enabled_guild_ids": [], "guild_basis_points": 10000,
"user_basis_points": 10000,
"disabled_guild_ids": [], "disabled_guild_ids": [],
"included_user_ids": [],
"excluded_user_ids": [], "excluded_user_ids": [],
}}) }})
); );
} }
#[test] #[test]
fn build_channel_threads_update_rejects_invalid_targeting() { fn build_channel_threads_update_only_turns_threads_off_when_unchecked() {
let too_many_guilds = (0..=EXPERIMENT_MAX_TARGETED_USERS) let form = MultiValueForm::parse(b"_csrf=token");
.map(|index| index.to_string()) assert_eq!(
.collect::<Vec<_>>() serde_json::to_value(build_channel_threads_update(&form)).expect("serializable update"),
.join("%2C"); serde_json::json!({"channel_threads": {"enabled": false}})
for (form, message) in [ );
(
"channel_threads_guild_basis_points=10001".to_owned(),
"Guild rollout basis points must be a whole number between 0 and 10000",
),
(
"channel_threads_user_basis_points=-1".to_owned(),
"User rollout basis points must be a whole number between 0 and 10000",
),
(
"channel_threads_guild_salt=%20%20".to_owned(),
"Rollout salt must be between 1 and 64 characters",
),
(
"channel_threads_user_salt=caf%C3%A9".to_owned(),
"Rollout salt must use printable ASCII",
),
(
"channel_threads_enabled_guild_ids=123%2Cinvalid".to_owned(),
"Enabled guild IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"channel_threads_disabled_guild_ids=123456789012345678901".to_owned(),
"Disabled guild IDs entry 1 must contain 1 to 20 decimal digits",
),
(
"channel_threads_excluded_user_ids=abc".to_owned(),
"Excluded user IDs entry 1 must contain 1 to 20 decimal digits",
),
(
format!("channel_threads_enabled_guild_ids={too_many_guilds}"),
"Enabled guild IDs must contain at most 1000 unique IDs",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_channel_threads_update(&form).expect_err("invalid targeting"),
message
);
}
} }
#[test] #[test]
@@ -3,9 +3,8 @@
use crate::{ use crate::{
api::types::{ api::types::{
AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse, AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse,
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CHANNEL_THREADS_DEFAULT_GUILD_SALT, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse,
CHANNEL_THREADS_DEFAULT_USER_SALT, CaptchaConfigResponse, ChannelThreadsConfigResponse, ChannelThreadsConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse, EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse, GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse, InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
@@ -17,7 +16,6 @@ use crate::{
middleware::auth::AuthContext, middleware::auth::AuthContext,
templates::{ templates::{
components::{ components::{
alert::alert_warning,
badge::{BadgeVariant, badge}, badge::{BadgeVariant, badge},
form::{ form::{
FORM_INPUT_CLASS, checkbox, csrf_input, danger_button, form_actions, FORM_INPUT_CLASS, checkbox, csrf_input, danger_button, form_actions,
@@ -192,9 +190,13 @@ pub fn instance_config_page(
"Gateway rollout behavior and the limit rules applied to users and guilds.", "Gateway rollout behavior and the limit rules applied to users and guilds.",
html! { html! {
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout)) (gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
(domain_migration_section(base, csrf_token, &instance_config.domain_migration)) @if !instance_config.self_hosted {
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
}
(channel_threads_section(base, csrf_token, &instance_config.channel_threads)) (channel_threads_section(base, csrf_token, &instance_config.channel_threads))
(plutonium_page_section(base, csrf_token, &instance_config.plutonium_page)) @if !instance_config.self_hosted {
(plutonium_page_section(base, csrf_token, &instance_config.plutonium_page))
}
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery)) (experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config { @if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config)) (limit_config_section(base, limit_config))
@@ -1487,182 +1489,41 @@ fn captcha_section(base: &str, csrf_token: &str, captcha: &CaptchaConfigResponse
) )
} }
fn channel_threads_available_to_everyone(channel_threads: &ChannelThreadsConfigResponse) -> bool {
channel_threads.enabled
&& channel_threads.guild_basis_points >= 10_000
&& channel_threads.user_basis_points >= 10_000
&& channel_threads.disabled_guild_ids.is_empty()
&& channel_threads.excluded_user_ids.is_empty()
}
fn channel_threads_section( fn channel_threads_section(
base: &str, base: &str,
csrf_token: &str, csrf_token: &str,
channel_threads: &ChannelThreadsConfigResponse, channel_threads: &ChannelThreadsConfigResponse,
) -> Markup { ) -> Markup {
let status = if channel_threads.enabled { let everyone = channel_threads_available_to_everyone(channel_threads);
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let enabled_guild_ids = channel_threads.enabled_guild_ids.join("\n");
let disabled_guild_ids = channel_threads.disabled_guild_ids.join("\n");
let included_user_ids = channel_threads.included_user_ids.join("\n");
let excluded_user_ids = channel_threads.excluded_user_ids.join("\n");
section_card_with_description( section_card_with_description(
"Channel threads", "Threads and forums",
"Threads, forum channels and media channels. A guild gets the feature only when the guild \ "Threads, forum channels and media channels in every community.",
is selected, and a member sees it only when they are also selected and use a client \
that supports threads. Bots follow the guild selection.",
html! { html! {
form method="post" action={(base) "/instance-config?action=update_channel_threads"} { form method="post" action={(base) "/instance-config?action=update_channel_threads"} {
(csrf_input(csrf_token)) (csrf_input(csrf_token))
div class="space-y-6" { div class="space-y-6" {
(alert_warning("Before enabling", html! {
p class="text-sm" {
"Enable only after every gateway role, the api, the workers and the \
messages service run the gate build and the bit 34-38 overwrite audit \
is clean."
}
}))
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))
@if channel_threads.ever_enabled {
(badge("Ever enabled", BadgeVariant::Warning))
}
span class="text-xs text-neutral-500" {
"Config version " (channel_threads.config_version)
}
}
(checkbox( (checkbox(
"channel_threads_enabled", "channel_threads_everyone",
"true", "true",
"Turn on threads for the selected guilds and users", "Available to everyone",
channel_threads.enabled, everyone,
true, true,
)) ))
p class="text-xs text-neutral-500" { @if channel_threads.enabled && !everyone {
"Off is the safe state and the kill switch. With this unchecked no guild \
has threads, and existing threads and forums stay stored but hidden \
until it is turned back on."
}
h3 class="text-sm font-semibold text-neutral-900" { "Guilds" }
(number_field(
"channel_threads_guild_basis_points",
"Guild rollout (basis points)",
&channel_threads.guild_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of guilds bucketed into the experiment, in basis points: 0 is nobody, 100 is 1%, 10000 is every guild."),
))
div class="flex flex-col gap-2" {
(text_input(
"channel_threads_guild_salt",
"Guild rollout salt",
&channel_threads.guild_salt,
CHANNEL_THREADS_DEFAULT_GUILD_SALT,
))
p class="text-xs text-neutral-500" { p class="text-xs text-neutral-500" {
"Seeds the guild bucketing hash. Printable ASCII only. Changing it \ "Currently on for part of this instance. Saving applies the setting above to everyone."
reshuffles which guilds fall inside the percentage above."
} }
} }
div class="flex flex-col gap-2" {
(textarea_input(
"channel_threads_enabled_guild_ids",
"Always-on Guild IDs",
"1600000000000000001\n1600000000000000002",
&enabled_guild_ids,
4,
false,
))
(entry_count_hint(
channel_threads.enabled_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These guilds 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(
"channel_threads_disabled_guild_ids",
"Never-on Guild IDs",
"1600000000000000003\n1600000000000000004",
&disabled_guild_ids,
4,
false,
))
(entry_count_hint(
channel_threads.disabled_guild_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-guild kill switch."
}
}
h3 class="text-sm font-semibold text-neutral-900" { "Users" }
(number_field(
"channel_threads_user_basis_points",
"User rollout (basis points)",
&channel_threads.user_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the experiment, in basis points. Set 10000 before enrolling any guild outside staff, so every member of that guild, moderators included, sees its threads."),
))
div class="flex flex-col gap-2" {
(text_input(
"channel_threads_user_salt",
"User rollout salt",
&channel_threads.user_salt,
CHANNEL_THREADS_DEFAULT_USER_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the user bucketing hash. Printable ASCII only. Changing it \
reshuffles which users fall inside the percentage above."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"channel_threads_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
channel_threads.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format as the guild lists. These users are targeted regardless \
of the percentage above."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"channel_threads_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
channel_threads.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, and it also applies to bots."
}
}
(alert_warning("Excluded bots", html! {
p class="text-sm" {
"Excluded bots are blind to threads, including moderation bots."
}
}))
(form_actions(html! { (form_actions(html! {
(submit_button("Save channel threads configuration")) (submit_button("Save"))
})) }))
} }
} }
@@ -2414,49 +2275,35 @@ mod tests {
} }
#[test] #[test]
fn channel_threads_section_shows_both_dimensions_and_the_warnings() { fn channel_threads_section_is_a_single_everyone_toggle() {
let channel_threads = ChannelThreadsConfigResponse { let everyone = ChannelThreadsConfigResponse {
enabled: true, enabled: true,
config_version: 7, guild_basis_points: 10_000,
ever_enabled: true, user_basis_points: 10_000,
guild_basis_points: 25,
enabled_guild_ids: vec!["1600000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
..ChannelThreadsConfigResponse::default() ..ChannelThreadsConfigResponse::default()
}; };
let markup = channel_threads_section("/admin", "csrf", &channel_threads).into_string(); let on = channel_threads_section("/admin", "csrf", &everyone).into_string();
assert!(markup.contains("action=update_channel_threads")); assert!(on.contains("action=update_channel_threads"));
for name in [ assert!(on.contains("name=\"channel_threads_everyone\""));
"channel_threads_enabled", assert!(on.contains("checked"));
"channel_threads_guild_basis_points", assert!(!on.contains("basis_points"));
"channel_threads_guild_salt", assert!(!on.contains("part of this instance"));
"channel_threads_enabled_guild_ids",
"channel_threads_disabled_guild_ids", let partial = ChannelThreadsConfigResponse {
"channel_threads_user_basis_points", enabled: true,
"channel_threads_user_salt", enabled_guild_ids: vec!["1600000000000000001".to_owned()],
"channel_threads_included_user_ids", user_basis_points: 10_000,
"channel_threads_excluded_user_ids", ..ChannelThreadsConfigResponse::default()
] { };
assert!(markup.contains(&format!("name=\"{name}\"")), "{name}"); let partial = channel_threads_section("/admin", "csrf", &partial).into_string();
} assert!(!partial.contains("checked"));
assert!(markup.contains("value=\"25\"")); assert!(partial.contains("part of this instance"));
assert!(markup.contains("Config version 7"));
assert!(markup.contains("Ever enabled"));
assert!(markup.contains("bit 34-38 overwrite audit is clean"));
assert!(markup.contains("Excluded bots are blind to threads, including moderation bots."));
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
let off = let off =
channel_threads_section("/admin", "csrf", &ChannelThreadsConfigResponse::default()) channel_threads_section("/admin", "csrf", &ChannelThreadsConfigResponse::default())
.into_string(); .into_string();
assert!(off.contains("Inert")); assert!(!off.contains("checked"));
assert!(!off.contains("Ever enabled")); assert!(!off.contains("part of this instance"));
assert!(off.contains(CHANNEL_THREADS_DEFAULT_GUILD_SALT));
assert!(off.contains(CHANNEL_THREADS_DEFAULT_USER_SALT));
} }
#[test] #[test]
+11 -18
View File
@@ -491,13 +491,14 @@ async fn channel_threads_section_renders_and_saves_through_htmx_toasts() {
let app = setup().await; let app = setup().await;
let (headers, body) = get_with_headers(&app, "/instance-config", &[]).await; let (headers, body) = get_with_headers(&app, "/instance-config", &[]).await;
assert_full_layout(&body); assert_full_layout(&body);
assert!(body.contains("Channel threads"), "{body}"); assert!(body.contains("Threads and forums"), "{body}");
assert!(body.contains("Config version 3"), "{body}"); assert!(body.contains("Available to everyone"), "{body}");
assert!(body.contains("Ever enabled"), "{body}");
assert!(body.contains("1600000000000000001"), "{body}");
assert!(body.contains("1500000000000000009"), "{body}");
assert!( assert!(
body.contains("Excluded bots are blind to threads, including moderation bots."), body.contains(r#"name="channel_threads_everyone""#),
"{body}"
);
assert!(
!body.contains("channel_threads_guild_basis_points"),
"{body}" "{body}"
); );
let csrf_token = csrf_cookie(&headers) let csrf_token = csrf_cookie(&headers)
@@ -509,17 +510,9 @@ async fn channel_threads_section_renders_and_saves_through_htmx_toasts() {
("Cookie", cookie.as_str()), ("Cookie", cookie.as_str()),
]; ];
for (form, expected) in [ for form in [
( format!("_csrf={csrf_token}&channel_threads_everyone=true"),
format!( format!("_csrf={csrf_token}"),
"_csrf={csrf_token}&channel_threads_enabled=true&channel_threads_guild_basis_points=0&channel_threads_enabled_guild_ids=1600000000000000001&channel_threads_user_basis_points=10000"
),
"Instance config updated",
),
(
format!("_csrf={csrf_token}&channel_threads_enabled_guild_ids=not-a-guild"),
"Enabled guild IDs entry 1 must contain 1 to 20 decimal digits",
),
] { ] {
let (status, response_headers, response_body) = post_form_with_headers( let (status, response_headers, response_body) = post_form_with_headers(
&app, &app,
@@ -533,7 +526,7 @@ async fn channel_threads_section_renders_and_saves_through_htmx_toasts() {
.get("X-Fluxer-Admin-Toast") .get("X-Fluxer-Admin-Toast")
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.unwrap_or_else(|| panic!("missing toast header\n{response_body}")); .unwrap_or_else(|| panic!("missing toast header\n{response_body}"));
assert!(toast.contains(expected), "{toast}"); assert!(toast.contains("Instance config updated"), "{toast}");
} }
} }