feat(threads): thread and forum API, admin, schemas and docs (#3251)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-07 15:25:12 +02:00
1 parent 5f4295e399
commit 3f4160b138
495 files changed
+39232 -1215

No files matched your search

File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::snowflake;
use super::client::{AdminApiClient, ApiResult};
use super::types::ListGuildThreadsResponse;
impl AdminApiClient {
pub async fn list_guild_threads(&self, guild_id: &str) -> ApiResult<ListGuildThreadsResponse> {
let response = self
.generated()
.list_admin_guild_threads(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn delete_thread_channel(&self, channel_id: &str) -> ApiResult<()> {
self.generated()
.delete_admin_thread_channel(&snowflake(channel_id))
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
}
+1
View File
@@ -13,6 +13,7 @@ pub mod client;
pub mod codes;
pub mod discovery;
pub mod guild_assets;
pub mod guild_threads;
pub mod guilds;
pub mod instance_config;
pub mod jobs;
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct GuildThreadMetadata {
pub archived: bool,
pub locked: bool,
pub auto_archive_duration: i32,
pub archive_timestamp: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct GuildThreadItem {
pub id: String,
#[serde(rename = "type")]
pub channel_type: i32,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub parent_id: Option<String>,
#[serde(default)]
pub owner_id: Option<String>,
#[serde(default)]
pub member_count: Option<i32>,
#[serde(default)]
pub message_count: Option<i32>,
#[serde(default)]
pub thread_metadata: Option<GuildThreadMetadata>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ListGuildThreadsResponse {
pub threads: Vec<GuildThreadItem>,
}
@@ -31,6 +31,8 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub captcha: CaptchaConfigResponse,
#[serde(default)]
pub channel_threads: ChannelThreadsConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
#[serde(default)]
pub billing: InstanceBillingResponse,
@@ -510,6 +512,8 @@ pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
pub const PLUTONIUM_PAGE_DEFAULT_SALT: &str = "plutonium-page-v1";
pub const CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=20_000;
pub const CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=20_000;
pub const CHANNEL_THREADS_DEFAULT_GUILD_SALT: &str = "channel-threads-guild-v1";
pub const CHANNEL_THREADS_DEFAULT_USER_SALT: &str = "channel-threads-user-v1";
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
@@ -653,6 +657,62 @@ pub struct CaptchaConfigUpdateRequest {
pub max_counter: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ChannelThreadsConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub ever_enabled: bool,
pub guild_basis_points: u32,
pub guild_salt: String,
pub enabled_guild_ids: Vec<String>,
pub disabled_guild_ids: Vec<String>,
pub user_basis_points: u32,
pub user_salt: String,
pub included_user_ids: Vec<String>,
pub excluded_user_ids: Vec<String>,
}
impl Default for ChannelThreadsConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
ever_enabled: false,
guild_basis_points: 0,
guild_salt: CHANNEL_THREADS_DEFAULT_GUILD_SALT.to_owned(),
enabled_guild_ids: Vec::new(),
disabled_guild_ids: Vec::new(),
user_basis_points: 0,
user_salt: CHANNEL_THREADS_DEFAULT_USER_SALT.to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ChannelThreadsConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_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 {
@@ -775,6 +835,8 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub captcha: Option<CaptchaConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub channel_threads: Option<ChannelThreadsConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub billing: Option<InstanceBillingUpdateRequest>,
+2
View File
@@ -9,6 +9,7 @@ mod codes;
mod common;
mod discovery;
mod guild_assets;
mod guild_threads;
mod instance_billing;
mod instance_config;
mod jobs;
@@ -29,6 +30,7 @@ pub use codes::*;
pub use common::*;
pub use discovery::*;
pub use guild_assets::*;
pub use guild_threads::*;
pub use instance_billing::*;
pub use instance_config::*;
pub use jobs::*;
+27
View File
@@ -96,6 +96,33 @@ pub async fn render(
config, &guild, &stickers, csrf_token,
))
}
"threads" => {
if !acl::has_permission(admin_acls, acl::GUILD_LOOKUP) {
return None;
}
let threads = client
.list_guild_threads(guild_id)
.await
.map(|response| response.threads)
.map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild threads"))
.unwrap_or_default();
let threads_enabled = client
.get_instance_config()
.await
.map(|instance| instance.channel_threads.enabled)
.map_err(
|error| tracing::warn!(%error, "admin API request failed: get instance config"),
)
.unwrap_or(false);
Some(tabs::threads::threads_tab(
config,
&guild,
&threads,
acl::has_permission(admin_acls, acl::MESSAGE_DELETE_ALL),
threads_enabled,
csrf_token,
))
}
"audit_log" | "audit-log" => {
if !acl::has_permission(admin_acls, acl::GUILD_AUDIT_LOG_VIEW) {
return None;
+10
View File
@@ -433,6 +433,16 @@ async fn dispatch_guild_action(
"Failed to delete sticker",
)
}
"delete_thread" => {
let Some(thread_id) = get("thread_id") else {
return FlashData::error("Thread ID is required");
};
action_result(
client.delete_thread_channel(&thread_id).await,
"Thread deleted",
"Failed to delete thread",
)
}
"trigger_archive" => {
let inc = form.bool_value("include_attachments");
action_result(
+170 -13
View File
@@ -7,19 +7,20 @@ use crate::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
CaptchaConfigUpdateRequest, CreateRegistrationUrlRequest,
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
LimitRuleFilters, PlutoniumPageConfigUpdateRequest, PremiumMode,
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope,
CaptchaConfigUpdateRequest, ChannelThreadsConfigUpdateRequest,
CreateRegistrationUrlRequest, DomainMigrationConfigUpdateRequest,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceConfigUpdateRequest,
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters,
PlutoniumPageConfigUpdateRequest, PremiumMode, PushRelayConfigUpdateRequest,
RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope,
},
},
config::AdminConfig,
@@ -228,6 +229,10 @@ 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_experiment_delivery" => match build_experiment_delivery_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
@@ -671,6 +676,53 @@ fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateReq
})
}
fn build_channel_threads_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
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",
)?),
}),
..Default::default()
})
}
fn build_experiment_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
@@ -1436,6 +1488,111 @@ 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));
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::json!({"channel_threads": {
"enabled": false,
"enabled_guild_ids": [],
"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::<Vec<_>>()
.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
);
}
}
#[test]
fn build_push_relay_update_reads_the_consent_checkbox() {
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
@@ -26,6 +26,7 @@ pub const GUILD_TABS: &[(&str, &str)] = &[
("archives", "Archives"),
("emojis", "Emojis"),
("stickers", "Stickers"),
("threads", "Threads"),
("audit_logs", "Admin Audit Logs"),
("audit_log", "Guild Audit Log"),
("reports", "Reports"),
@@ -208,6 +209,7 @@ fn guild_tab_visible(_config: &AdminConfig, tab_id: &str, admin_acls: &[String])
"overview" | "members" | "settings" | "features" | "moderation" => true,
"reports" => acl::has_permission(admin_acls, acl::REPORT_VIEW),
"emojis" | "stickers" => acl::has_permission(admin_acls, acl::ASSET_PURGE),
"threads" => acl::has_permission(admin_acls, acl::GUILD_LOOKUP),
"audit_logs" => acl::has_permission(admin_acls, acl::AUDIT_LOG_VIEW),
"audit_log" => acl::has_permission(admin_acls, acl::GUILD_AUDIT_LOG_VIEW),
"archives" => acl::has_any_permission(
@@ -11,6 +11,7 @@ pub mod overview;
pub mod reports;
pub mod settings;
pub mod stickers;
pub mod threads;
use crate::{api::types::GuildDetailInfo, utils::user_tag::user_tag};
@@ -33,7 +33,11 @@ fn channel_type_label(channel_type: i32) -> &'static str {
2 => "Voice",
4 => "Category",
5 => "Announcement",
13 => "Link",
11 => "Public thread",
12 => "Private thread",
15 => "Forum",
16 => "Media",
998 => "Link",
_ => "Unknown",
}
}
@@ -179,7 +183,7 @@ pub fn overview_tab(config: &AdminConfig, guild: &GuildDetailInfo, csrf_token: &
} @else {
div class="flex flex-col gap-2" {
@for channel in &sorted_channels {
@let is_link = channel.channel_type == 13;
@let is_link = channel.channel_type == 998;
@let parent = channel.parent_id.as_deref()
.and_then(|pid| channels_by_id.get(pid));
@let parent_nsfw_override = parent
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::{GuildInfo, GuildThreadItem},
config::AdminConfig,
templates::components::{
badge::{BadgeVariant, badge},
form::{csrf_input, danger_button, form_actions, submit_button},
page_container::card_with_header,
table::{data_table, table_cell, table_row},
},
};
use maud::{Markup, html};
pub fn threads_tab(
config: &AdminConfig,
guild: &GuildInfo,
threads: &[GuildThreadItem],
can_delete: bool,
can_reindex: bool,
csrf_token: &str,
) -> Markup {
let base = &config.base_path;
html! {
@if can_reindex {
(card_with_header("Thread search index", html! {
form method="post"
action={(base) "/guilds/" (guild.id) "?tab=threads&action=refresh_search_index"}
class="w-full" {
(csrf_input(csrf_token))
input type="hidden" name="index_type" value="threads";
(form_actions(html! {
(submit_button("Refresh threads"))
}))
}
}))
}
(card_with_header(
&format!("Threads ({})", threads.len()),
html! {
@if threads.is_empty() {
p class="text-sm text-neutral-500" { "No threads found for this guild." }
} @else {
(data_table(
&["Thread", "Parent", "State", "Members", "Messages", ""],
html! {
@for thread in threads {
(thread_row(base, &guild.id, thread, can_delete, csrf_token))
}
},
))
}
},
))
}
}
fn thread_kind(channel_type: i32) -> &'static str {
match channel_type {
10 => "Announcement",
12 => "Private",
_ => "Public",
}
}
fn thread_state(thread: &GuildThreadItem) -> Markup {
let metadata = thread.thread_metadata.as_ref();
let archived = metadata.is_some_and(|metadata| metadata.archived);
let locked = metadata.is_some_and(|metadata| metadata.locked);
html! {
div class="flex flex-wrap gap-1" {
(badge(thread_kind(thread.channel_type), BadgeVariant::Default))
@if archived { (badge("Archived", BadgeVariant::Default)) }
@if locked { (badge("Locked", BadgeVariant::Default)) }
}
}
}
fn thread_row(
base: &str,
guild_id: &str,
thread: &GuildThreadItem,
can_delete: bool,
csrf_token: &str,
) -> Markup {
table_row(html! {
(table_cell(false, html! {
div class="font-medium" { (thread.name.as_deref().unwrap_or("")) }
div class="text-xs text-neutral-500" { "ID: " (thread.id) }
}))
(table_cell(true, html! { (thread.parent_id.as_deref().unwrap_or("")) }))
(table_cell(false, thread_state(thread)))
(table_cell(true, html! { (thread.member_count.unwrap_or(0)) }))
(table_cell(true, html! { (thread.message_count.unwrap_or(0)) }))
(table_cell(false, html! {
@if can_delete {
form method="post"
action={(base) "/guilds/" (guild_id) "?tab=threads&action=delete_thread"} {
(csrf_input(csrf_token))
input type="hidden" name="thread_id" value=(thread.id);
(danger_button("Delete thread"))
}
}
}))
})
}
@@ -3,7 +3,8 @@
use crate::{
api::types::{
AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse,
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse,
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CHANNEL_THREADS_DEFAULT_GUILD_SALT,
CHANNEL_THREADS_DEFAULT_USER_SALT, CaptchaConfigResponse, ChannelThreadsConfigResponse,
DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
@@ -16,6 +17,7 @@ use crate::{
middleware::auth::AuthContext,
templates::{
components::{
alert::alert_warning,
badge::{BadgeVariant, badge},
form::{
FORM_INPUT_CLASS, checkbox, csrf_input, danger_button, form_actions,
@@ -191,6 +193,7 @@ pub fn instance_config_page(
html! {
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
(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))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
@@ -1484,6 +1487,189 @@ fn captcha_section(base: &str, csrf_token: &str, captcha: &CaptchaConfigResponse
)
}
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");
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.",
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",
"true",
"Turn on threads for the selected guilds and users",
channel_threads.enabled,
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,
))
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."
}
}
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"))
}))
}
}
},
)
}
fn experiment_delivery_section(
base: &str,
csrf_token: &str,
@@ -2227,6 +2413,52 @@ mod tests {
assert!(!markup.contains("at the cap"));
}
#[test]
fn channel_threads_section_shows_both_dimensions_and_the_warnings() {
let channel_threads = 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(),
],
..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 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));
}
#[test]
fn plutonium_page_section_shows_the_rollout_and_list_counts() {
let plutonium_page = PlutoniumPageConfigResponse {
+118
View File
@@ -433,6 +433,19 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"max_counter": 1000,
"future_captcha_knob": 1
},
"channel_threads": {
"enabled": true,
"config_version": 3,
"ever_enabled": true,
"guild_basis_points": 0,
"guild_salt": "channel-threads-guild-v1",
"enabled_guild_ids": ["1600000000000000001"],
"disabled_guild_ids": [],
"user_basis_points": 10000,
"user_salt": "channel-threads-user-v1",
"included_user_ids": [],
"excluded_user_ids": []
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"registration": {
"mode": "open",
@@ -595,6 +608,9 @@ fn deserialize_instance_config_response_with_unknown_keys() {
assert!(resp.push_relay.relay_consent_accepted);
assert!(resp.captcha.enabled);
assert_eq!(resp.captcha.max_counter, 1000);
assert!(resp.channel_threads.enabled);
assert_eq!(resp.channel_threads.config_version, 3);
assert_eq!(resp.channel_threads.enabled_guild_ids.len(), 1);
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));
@@ -662,6 +678,63 @@ fn deserialize_instance_config_response_with_unknown_keys() {
);
}
#[test]
fn deserialize_channel_threads_config() {
let config: types::ChannelThreadsConfigResponse = serde_json::from_str(
r#"{
"enabled": true,
"config_version": 12,
"ever_enabled": true,
"guild_basis_points": 50,
"guild_salt": "channel-threads-guild-v2",
"enabled_guild_ids": ["1600000000000000001"],
"disabled_guild_ids": ["1600000000000000002", "1600000000000000003"],
"user_basis_points": 10000,
"user_salt": "channel-threads-user-v1",
"included_user_ids": [],
"excluded_user_ids": ["1500000000000000001"],
"future_threads_knob": 1
}"#,
)
.expect("a channel threads config must deserialize");
assert!(config.enabled);
assert!(config.ever_enabled);
assert_eq!(config.config_version, 12);
assert_eq!(config.guild_basis_points, 50);
assert_eq!(config.guild_salt, "channel-threads-guild-v2");
assert_eq!(config.enabled_guild_ids, vec!["1600000000000000001"]);
assert_eq!(config.disabled_guild_ids.len(), 2);
assert_eq!(config.user_basis_points, 10000);
assert_eq!(config.excluded_user_ids, vec!["1500000000000000001"]);
let absent: types::ChannelThreadsConfigResponse =
serde_json::from_str("{}").expect("an api without the experiment still deserializes");
assert!(!absent.enabled);
assert!(!absent.ever_enabled);
assert_eq!(absent.guild_salt, types::CHANNEL_THREADS_DEFAULT_GUILD_SALT);
assert_eq!(absent.user_salt, types::CHANNEL_THREADS_DEFAULT_USER_SALT);
}
#[test]
fn serialize_channel_threads_update_never_sends_server_owned_fields() {
let update = types::InstanceConfigUpdateRequest {
channel_threads: Some(types::ChannelThreadsConfigUpdateRequest {
enabled: Some(true),
enabled_guild_ids: Some(vec!["1600000000000000001".to_owned()]),
..Default::default()
}),
..Default::default()
};
assert_eq!(
serde_json::to_value(&update).unwrap(),
serde_json::json!({"channel_threads": {
"enabled": true,
"enabled_guild_ids": ["1600000000000000001"],
}})
);
}
#[test]
fn deserialize_push_relay_config() {
let accepted: types::PushRelayConfigResponse = serde_json::from_str(
@@ -1084,3 +1157,48 @@ fn account_identity_lock_is_unknown_when_the_api_omits_it() {
assert_eq!(identity.mode, types::AccountIdentityMode::Email);
assert_eq!(identity.locked, None);
}
#[test]
fn deserialize_guild_threads_response() {
let json = r#"{
"threads": [
{
"id": "1600000000000000010",
"type": 12,
"guild_id": "1600000000000000001",
"parent_id": "1600000000000000002",
"owner_id": "1500000000000000001",
"name": "secret plans",
"last_message_id": null,
"last_pin_timestamp": null,
"rate_limit_per_user": 0,
"flags": 0,
"thread_metadata": {
"archived": true,
"auto_archive_duration": 4320,
"archive_timestamp": "2026-09-27T12:00:00.000Z",
"locked": false,
"invitable": false,
"create_timestamp": "2026-09-26T12:00:00.000Z"
},
"message_count": 3,
"total_message_sent": 4,
"member_count": 2
}
]
}"#;
let generated: generated_types::ListGuildThreadsResponse =
serde_json::from_str(json).expect("the generated client must accept the thread list");
assert_eq!(generated.threads.len(), 1);
let resp: types::ListGuildThreadsResponse = serde_json::from_str(json).unwrap();
let thread = &resp.threads[0];
assert_eq!(thread.channel_type, 12);
assert_eq!(thread.name.as_deref(), Some("secret plans"));
assert_eq!(thread.member_count, Some(2));
assert!(
thread
.thread_metadata
.as_ref()
.is_some_and(|m| m.archived && !m.locked)
);
}
+65
View File
@@ -469,6 +469,7 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
"/instance-config?action=update_gateway_rollout",
"/instance-config?action=update_sso",
"/instance-config?action=update_domain_migration",
"/instance-config?action=update_channel_threads",
"/instance-config?action=update_plutonium_page",
"/instance-config?action=update_experiment_delivery",
][..],
@@ -485,6 +486,57 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
}
}
#[tokio::test]
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("Excluded bots are blind to threads, including moderation bots."),
"{body}"
);
let csrf_token = csrf_cookie(&headers)
.unwrap_or_else(|| panic!("instance config page did not set csrf_token cookie\n{body}"));
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
let htmx_headers = [
("HX-Request", "true"),
("HX-Target", "flash-container"),
("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",
),
] {
let (status, response_headers, response_body) = post_form_with_headers(
&app,
"/instance-config?action=update_channel_threads",
&htmx_headers,
&form,
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "{response_body}");
let toast = response_headers
.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}");
}
}
#[tokio::test]
async fn instance_config_registration_tables_show_copyable_urls_and_compact_pending_actions() {
let app = setup().await;
@@ -1193,6 +1245,19 @@ fn instance_config() -> Value {
"anonymous_rollout_basis_points": 0,
"standalone_forwarding": false
},
"channel_threads": {
"enabled": false,
"config_version": 3,
"ever_enabled": true,
"guild_basis_points": 0,
"guild_salt": "channel-threads-guild-v1",
"enabled_guild_ids": ["1600000000000000001"],
"disabled_guild_ids": [],
"user_basis_points": 10000,
"user_salt": "channel-threads-user-v1",
"included_user_ids": [],
"excluded_user_ids": ["1500000000000000009"]
},
"plutonium_page": {
"enabled": false,
"config_version": 0,