From 002502a7fa381fd36801830e2c7c591ba693c298 Mon Sep 17 00:00:00 2001 From: Hampus Date: Wed, 7 Oct 2026 21:32:45 +0200 Subject: [PATCH] feat(admin): one threads toggle, hide hosted-only rollouts (#3257) --- fluxer_admin/src/routes/system_actions.rs | 177 +++---------- .../src/templates/pages/instance_config.rs | 247 ++++-------------- fluxer_admin/tests/htmx_acceptance.rs | 29 +- 3 files changed, 94 insertions(+), 359 deletions(-) diff --git a/fluxer_admin/src/routes/system_actions.rs b/fluxer_admin/src/routes/system_actions.rs index 1cf8e4ae9..e8fb4d9bb 100644 --- a/fluxer_admin/src/routes/system_actions.rs +++ b/fluxer_admin/src/routes/system_actions.rs @@ -229,10 +229,11 @@ pub async fn instance_config_post( Ok(update) => instance_config_result(client.update_instance_config(&update).await), Err(message) => FlashData::error(message), }, - "update_channel_threads" => match build_channel_threads_update(&form) { - Ok(update) => instance_config_result(client.update_instance_config(&update).await), - Err(message) => FlashData::error(message), - }, + "update_channel_threads" => instance_config_result( + client + .update_instance_config(&build_channel_threads_update(&form)) + .await, + ), "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), @@ -676,51 +677,26 @@ fn build_captcha_update(form: &MultiValueForm) -> Result Result { - Ok(InstanceConfigUpdateRequest { - channel_threads: Some(ChannelThreadsConfigUpdateRequest { - enabled: Some(form.bool_value("channel_threads_enabled")), - guild_basis_points: parse_form_number( - form, - "channel_threads_guild_basis_points", - "Guild rollout basis points", - 0, - EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX, - )?, - guild_salt: parse_experiment_rollout_salt(form, "channel_threads_guild_salt")?, - enabled_guild_ids: Some(parse_experiment_user_ids( - form.first("channel_threads_enabled_guild_ids") - .unwrap_or_default(), - "Enabled guild IDs", - )?), - 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", - )?), - }), +fn build_channel_threads_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest { + let channel_threads = if form.bool_value("channel_threads_everyone") { + ChannelThreadsConfigUpdateRequest { + enabled: Some(true), + guild_basis_points: Some(EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX), + user_basis_points: Some(EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX), + disabled_guild_ids: Some(Vec::new()), + excluded_user_ids: Some(Vec::new()), + ..Default::default() + } + } else { + ChannelThreadsConfigUpdateRequest { + enabled: Some(false), + ..Default::default() + } + }; + InstanceConfigUpdateRequest { + channel_threads: Some(channel_threads), ..Default::default() - }) + } } fn build_experiment_delivery_update( @@ -1489,108 +1465,27 @@ mod tests { } #[test] - fn build_channel_threads_update_reads_both_rollout_dimensions() { - let form = MultiValueForm::parse( - 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)); + fn build_channel_threads_update_turns_threads_on_for_everyone() { + let form = MultiValueForm::parse(b"_csrf=token&channel_threads_everyone=true"); assert_eq!( - update.guild_salt, - 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::to_value(build_channel_threads_update(&form)).expect("serializable update"), serde_json::json!({"channel_threads": { - "enabled": false, - "enabled_guild_ids": [], + "enabled": true, + "guild_basis_points": 10000, + "user_basis_points": 10000, "disabled_guild_ids": [], - "included_user_ids": [], "excluded_user_ids": [], }}) ); } #[test] - fn build_channel_threads_update_rejects_invalid_targeting() { - let too_many_guilds = (0..=EXPERIMENT_MAX_TARGETED_USERS) - .map(|index| index.to_string()) - .collect::>() - .join("%2C"); - 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 - ); - } + fn build_channel_threads_update_only_turns_threads_off_when_unchecked() { + let form = MultiValueForm::parse(b"_csrf=token"); + assert_eq!( + serde_json::to_value(build_channel_threads_update(&form)).expect("serializable update"), + serde_json::json!({"channel_threads": {"enabled": false}}) + ); } #[test] diff --git a/fluxer_admin/src/templates/pages/instance_config.rs b/fluxer_admin/src/templates/pages/instance_config.rs index 75d7a87c3..4d67ff7ed 100644 --- a/fluxer_admin/src/templates/pages/instance_config.rs +++ b/fluxer_admin/src/templates/pages/instance_config.rs @@ -3,9 +3,8 @@ use crate::{ api::types::{ AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse, - CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CHANNEL_THREADS_DEFAULT_GUILD_SALT, - CHANNEL_THREADS_DEFAULT_USER_SALT, CaptchaConfigResponse, ChannelThreadsConfigResponse, - DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse, + CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse, + ChannelThreadsConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse, EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse, @@ -17,7 +16,6 @@ use crate::{ middleware::auth::AuthContext, templates::{ components::{ - alert::alert_warning, badge::{BadgeVariant, badge}, form::{ 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.", html! { (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)) - (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)) @if let Some(limit_config) = 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( base: &str, csrf_token: &str, channel_threads: &ChannelThreadsConfigResponse, ) -> Markup { - let status = if channel_threads.enabled { - ("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"); + let everyone = channel_threads_available_to_everyone(channel_threads); section_card_with_description( - "Channel threads", - "Threads, forum channels and media channels. A guild gets the feature only when the guild \ - 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.", + "Threads and forums", + "Threads, forum channels and media channels in every community.", html! { form method="post" action={(base) "/instance-config?action=update_channel_threads"} { (csrf_input(csrf_token)) 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( - "channel_threads_enabled", + "channel_threads_everyone", "true", - "Turn on threads for the selected guilds and users", - channel_threads.enabled, + "Available to everyone", + everyone, true, )) - p class="text-xs text-neutral-500" { - "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, - )) + @if channel_threads.enabled && !everyone { p class="text-xs text-neutral-500" { - "Seeds the guild bucketing hash. Printable ASCII only. Changing it \ - reshuffles which guilds fall inside the percentage above." + "Currently on for part of this instance. Saving applies the setting above to everyone." } } - 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! { - (submit_button("Save channel threads configuration")) + (submit_button("Save")) })) } } @@ -2414,49 +2275,35 @@ mod tests { } #[test] - fn channel_threads_section_shows_both_dimensions_and_the_warnings() { - let channel_threads = ChannelThreadsConfigResponse { + fn channel_threads_section_is_a_single_everyone_toggle() { + let everyone = ChannelThreadsConfigResponse { enabled: true, - config_version: 7, - ever_enabled: true, - guild_basis_points: 25, - enabled_guild_ids: vec!["1600000000000000001".to_owned()], - excluded_user_ids: vec![ - "1500000000000000002".to_owned(), - "1500000000000000003".to_owned(), - ], + guild_basis_points: 10_000, + user_basis_points: 10_000, ..ChannelThreadsConfigResponse::default() }; - let markup = channel_threads_section("/admin", "csrf", &channel_threads).into_string(); - assert!(markup.contains("action=update_channel_threads")); - for name in [ - "channel_threads_enabled", - "channel_threads_guild_basis_points", - "channel_threads_guild_salt", - "channel_threads_enabled_guild_ids", - "channel_threads_disabled_guild_ids", - "channel_threads_user_basis_points", - "channel_threads_user_salt", - "channel_threads_included_user_ids", - "channel_threads_excluded_user_ids", - ] { - assert!(markup.contains(&format!("name=\"{name}\"")), "{name}"); - } - assert!(markup.contains("value=\"25\"")); - 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 on = channel_threads_section("/admin", "csrf", &everyone).into_string(); + assert!(on.contains("action=update_channel_threads")); + assert!(on.contains("name=\"channel_threads_everyone\"")); + assert!(on.contains("checked")); + assert!(!on.contains("basis_points")); + assert!(!on.contains("part of this instance")); + + let partial = ChannelThreadsConfigResponse { + enabled: true, + enabled_guild_ids: vec!["1600000000000000001".to_owned()], + user_basis_points: 10_000, + ..ChannelThreadsConfigResponse::default() + }; + let partial = channel_threads_section("/admin", "csrf", &partial).into_string(); + assert!(!partial.contains("checked")); + assert!(partial.contains("part of this instance")); let off = channel_threads_section("/admin", "csrf", &ChannelThreadsConfigResponse::default()) .into_string(); - assert!(off.contains("Inert")); - assert!(!off.contains("Ever enabled")); - assert!(off.contains(CHANNEL_THREADS_DEFAULT_GUILD_SALT)); - assert!(off.contains(CHANNEL_THREADS_DEFAULT_USER_SALT)); + assert!(!off.contains("checked")); + assert!(!off.contains("part of this instance")); } #[test] diff --git a/fluxer_admin/tests/htmx_acceptance.rs b/fluxer_admin/tests/htmx_acceptance.rs index b5db73976..3c2be9c57 100644 --- a/fluxer_admin/tests/htmx_acceptance.rs +++ b/fluxer_admin/tests/htmx_acceptance.rs @@ -491,13 +491,14 @@ async fn channel_threads_section_renders_and_saves_through_htmx_toasts() { let app = setup().await; let (headers, body) = get_with_headers(&app, "/instance-config", &[]).await; assert_full_layout(&body); - assert!(body.contains("Channel threads"), "{body}"); - assert!(body.contains("Config version 3"), "{body}"); - assert!(body.contains("Ever enabled"), "{body}"); - assert!(body.contains("1600000000000000001"), "{body}"); - assert!(body.contains("1500000000000000009"), "{body}"); + assert!(body.contains("Threads and forums"), "{body}"); + assert!(body.contains("Available to everyone"), "{body}"); 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}" ); 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()), ]; - for (form, expected) in [ - ( - format!( - "_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", - ), + for form in [ + format!("_csrf={csrf_token}&channel_threads_everyone=true"), + format!("_csrf={csrf_token}"), ] { let (status, response_headers, response_body) = post_form_with_headers( &app, @@ -533,7 +526,7 @@ async fn channel_threads_section_renders_and_saves_through_htmx_toasts() { .get("X-Fluxer-Admin-Toast") .and_then(|value| value.to_str().ok()) .unwrap_or_else(|| panic!("missing toast header\n{response_body}")); - assert!(toast.contains(expected), "{toast}"); + assert!(toast.contains("Instance config updated"), "{toast}"); } }