mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(threads): thread and forum API, admin, schemas and docs (#3251)
This commit is contained in:
1 parent
5f4295e399
commit
3f4160b138
495 files changed
+39232
-1215
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user