mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(app): gate a collapsing guild header behind an experiment (#2779)
This commit is contained in:
@@ -10570,6 +10570,7 @@
|
||||
"message_keyboard_focus": {"$ref": "#/components/schemas/MessageKeyboardFocusConfigResponse"},
|
||||
"blocked_message_groups": {"$ref": "#/components/schemas/BlockedMessageGroupsConfigResponse"},
|
||||
"expression_info_card": {"$ref": "#/components/schemas/ExpressionInfoCardConfigResponse"},
|
||||
"guild_header_collapse": {"$ref": "#/components/schemas/GuildHeaderCollapseConfigResponse"},
|
||||
"registration": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -10978,6 +10979,7 @@
|
||||
"message_keyboard_focus",
|
||||
"blocked_message_groups",
|
||||
"expression_info_card",
|
||||
"guild_header_collapse",
|
||||
"registration",
|
||||
"self_hosted",
|
||||
"app_public",
|
||||
@@ -11135,6 +11137,10 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ExpressionInfoCardConfigUpdateRequest"}]
|
||||
},
|
||||
"guild_header_collapse": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/GuildHeaderCollapseConfigUpdateRequest"}]
|
||||
},
|
||||
"registration": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
@@ -15201,6 +15207,24 @@
|
||||
"enum": ["open", "approval", "closed"],
|
||||
"type": "string"
|
||||
},
|
||||
"GuildHeaderCollapseConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExpressionInfoCardConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15355,6 +15379,36 @@
|
||||
"type": "string",
|
||||
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"]
|
||||
},
|
||||
"GuildHeaderCollapseConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"default": "guild-header-collapse-v1", "type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"excluded_user_ids"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ExpressionInfoCardConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -34,6 +34,8 @@ pub struct InstanceConfigResponse {
|
||||
pub guild_activity_log_presentation: GuildActivityLogPresentationConfigResponse,
|
||||
#[serde(default)]
|
||||
pub expression_info_card: ExpressionInfoCardConfigResponse,
|
||||
#[serde(default)]
|
||||
pub guild_header_collapse: GuildHeaderCollapseConfigResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -737,6 +739,44 @@ pub struct ExpressionInfoCardConfigUpdateRequest {
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct GuildHeaderCollapseConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for GuildHeaderCollapseConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "guild-header-collapse-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct GuildHeaderCollapseConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ExperimentDeliveryConfigResponse {
|
||||
@@ -864,6 +904,8 @@ pub struct InstanceConfigUpdateRequest {
|
||||
pub guild_activity_log_presentation: Option<GuildActivityLogPresentationConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expression_info_card: Option<ExpressionInfoCardConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guild_header_collapse: Option<GuildHeaderCollapseConfigUpdateRequest>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -1209,6 +1251,8 @@ mod tests {
|
||||
.expect("default guild activity log presentation config");
|
||||
let expression = serde_json::from_value::<ExpressionInfoCardConfigResponse>(json!({}))
|
||||
.expect("default expression info card config");
|
||||
let collapse = serde_json::from_value::<GuildHeaderCollapseConfigResponse>(json!({}))
|
||||
.expect("default guild header collapse config");
|
||||
let noise = serde_json::to_value(noise).expect("serializable noise config");
|
||||
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
|
||||
let hover =
|
||||
@@ -1221,6 +1265,8 @@ mod tests {
|
||||
.expect("serializable guild activity log presentation config");
|
||||
let expression =
|
||||
serde_json::to_value(expression).expect("serializable expression info card config");
|
||||
let collapse =
|
||||
serde_json::to_value(collapse).expect("serializable guild header collapse config");
|
||||
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
|
||||
serde_json::from_value(noise.clone()).expect("generated noise config contract");
|
||||
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||
@@ -1240,6 +1286,9 @@ mod tests {
|
||||
let generated_expression: generated_types::ExpressionInfoCardConfigResponse =
|
||||
serde_json::from_value(expression.clone())
|
||||
.expect("generated expression info card config contract");
|
||||
let generated_collapse: generated_types::GuildHeaderCollapseConfigResponse =
|
||||
serde_json::from_value(collapse.clone())
|
||||
.expect("generated guild header collapse config contract");
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
|
||||
noise
|
||||
@@ -1274,6 +1323,11 @@ mod tests {
|
||||
.expect("serializable generated expression info card config"),
|
||||
expression
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_collapse)
|
||||
.expect("serializable generated guild header collapse config"),
|
||||
collapse
|
||||
);
|
||||
for (name, value) in [
|
||||
("VoiceNoiseSuppressionConfigResponse", noise),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
@@ -1282,6 +1336,7 @@ mod tests {
|
||||
("BlockedMessageGroupsConfigResponse", blocked),
|
||||
("GuildActivityLogPresentationConfigResponse", activity_log),
|
||||
("ExpressionInfoCardConfigResponse", expression),
|
||||
("GuildHeaderCollapseConfigResponse", collapse),
|
||||
] {
|
||||
for (field, value) in value.as_object().expect("config object") {
|
||||
assert_eq!(
|
||||
@@ -1345,6 +1400,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guild_header_collapse_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = GuildHeaderCollapseConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::GuildHeaderCollapseConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(GuildHeaderCollapseConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guild_header_collapse_response_defaults_to_the_guild_header_collapse_v1_salt() {
|
||||
let config = GuildHeaderCollapseConfigResponse::default();
|
||||
assert!(!config.enabled);
|
||||
assert_eq!(config.config_version, 0);
|
||||
assert_eq!(config.rollout_basis_points, 0);
|
||||
assert_eq!(config.rollout_salt, "guild-header-collapse-v1");
|
||||
assert!(config.included_user_ids.is_empty());
|
||||
assert!(config.excluded_user_ids.is_empty());
|
||||
assert_eq!(
|
||||
serde_json::from_value::<GuildHeaderCollapseConfigResponse>(json!({}))
|
||||
.expect("default guild header collapse config")
|
||||
.rollout_salt,
|
||||
"guild-header-collapse-v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_hover_tracking_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = MessageHoverTrackingConfigUpdateRequest {
|
||||
|
||||
@@ -10,7 +10,8 @@ use crate::{
|
||||
CreateRegistrationUrlRequest, DeferredPhoneGateUpdateRequest,
|
||||
ExperimentDeliveryConfigUpdateRequest, ExpressionInfoCardConfigUpdateRequest,
|
||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
GuildActivityLogPresentationConfigUpdateRequest, InstanceAttachmentDecayUpdateRequest,
|
||||
GuildActivityLogPresentationConfigUpdateRequest,
|
||||
GuildHeaderCollapseConfigUpdateRequest, InstanceAttachmentDecayUpdateRequest,
|
||||
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
|
||||
InstanceCaptchaIntegrationUpdateRequest, InstanceConfigUpdateRequest,
|
||||
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
|
||||
@@ -231,6 +232,10 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_guild_header_collapse" => match build_guild_header_collapse_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),
|
||||
@@ -805,6 +810,38 @@ fn build_expression_info_card_update(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_guild_header_collapse_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
guild_header_collapse: Some(GuildHeaderCollapseConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("guild_header_collapse_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"guild_header_collapse_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(
|
||||
form,
|
||||
"guild_header_collapse_rollout_salt",
|
||||
)?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("guild_header_collapse_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("guild_header_collapse_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
@@ -1726,6 +1763,79 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_guild_header_collapse_update_reads_the_whole_form() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"guild_header_collapse_enabled=true&guild_header_collapse_rollout_basis_points=2500&guild_header_collapse_rollout_salt=%20guild-header-collapse-v2%20&guild_header_collapse_included_user_ids=1500000000000000001%0A1500000000000000001&guild_header_collapse_excluded_user_ids=1500000000000000002%2C%201500000000000000003",
|
||||
);
|
||||
let update = build_guild_header_collapse_update(&form)
|
||||
.expect("valid form")
|
||||
.guild_header_collapse
|
||||
.expect("guild header collapse update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(2_500));
|
||||
assert_eq!(
|
||||
update.rollout_salt,
|
||||
Some("guild-header-collapse-v2".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000002".to_owned(),
|
||||
"1500000000000000003".to_owned()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_guild_header_collapse_update_leaves_the_rollout_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_guild_header_collapse_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"guild_header_collapse": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_guild_header_collapse_update_rejects_a_rollout_above_the_maximum() {
|
||||
let form = MultiValueForm::parse(b"guild_header_collapse_rollout_basis_points=10001");
|
||||
assert_eq!(
|
||||
build_guild_header_collapse_update(&form).expect_err("invalid rollout"),
|
||||
"Rollout basis points must be a whole number between 0 and 10000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_guild_header_collapse_update_reads_only_its_own_prefix() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"guild_header_collapse_enabled=true&guild_header_collapse_rollout_basis_points=2500&guild_header_collapse_rollout_salt=guild-header-collapse-v2&guild_header_collapse_included_user_ids=1500000000000000001&expression_card_rollout_basis_points=750&expression_card_rollout_salt=expression-info-card-v2&expression_card_excluded_user_ids=1500000000000000009",
|
||||
);
|
||||
let update = build_guild_header_collapse_update(&form)
|
||||
.expect("valid form")
|
||||
.guild_header_collapse
|
||||
.expect("guild header collapse update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(2_500));
|
||||
assert_eq!(
|
||||
update.rollout_salt,
|
||||
Some("guild-header-collapse-v2".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(update.excluded_user_ids, Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
|
||||
@@ -5,12 +5,12 @@ use crate::{
|
||||
AppPublicConfigResponse, BlockedMessageGroupsConfigResponse,
|
||||
ExperimentDeliveryConfigResponse, ExpressionInfoCardConfigResponse,
|
||||
GatewayRolloutConfigResponse, GuildActivityLogPresentationConfigResponse,
|
||||
InstanceConfigResponse, InstanceIntegrationsResponse, InstanceMediaResponse,
|
||||
InstancePolicyResponse, InstanceRegistrationResponse, LimitConfigResponse,
|
||||
MessageHoverTrackingConfigResponse, MessageKeyboardFocusConfigResponse,
|
||||
NoiseSuppressionBackend, PendingRegistrationResponse, RegistrationUrlResponse,
|
||||
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
|
||||
VoiceNoiseSuppressionConfigResponse,
|
||||
GuildHeaderCollapseConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
|
||||
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
|
||||
LimitConfigResponse, MessageHoverTrackingConfigResponse,
|
||||
MessageKeyboardFocusConfigResponse, NoiseSuppressionBackend, PendingRegistrationResponse,
|
||||
RegistrationUrlResponse, SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES,
|
||||
VOICE_NS_MAX_TARGETED_USERS, VoiceNoiseSuppressionConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -156,6 +156,7 @@ pub fn instance_config_page(
|
||||
(blocked_message_groups_section(base, csrf_token, &instance_config.blocked_message_groups))
|
||||
(guild_activity_log_presentation_section(base, csrf_token, &instance_config.guild_activity_log_presentation))
|
||||
(expression_info_card_section(base, csrf_token, &instance_config.expression_info_card))
|
||||
(guild_header_collapse_section(base, csrf_token, &instance_config.guild_header_collapse))
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
@@ -1702,6 +1703,110 @@ fn expression_info_card_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn guild_header_collapse_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
guild_header_collapse: &GuildHeaderCollapseConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if guild_header_collapse.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = guild_header_collapse.included_user_ids.join("\n");
|
||||
let excluded_user_ids = guild_header_collapse.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Guild Header Collapse",
|
||||
"Picks how a targeted client draws the guild banner above the channel list. A targeted \
|
||||
client reduces that banner to the height of the header as the channel list scrolls down \
|
||||
and returns it to full height as the list scrolls back up. While the master switch below \
|
||||
is off every client keeps the fixed banner height it ships with, whatever the rest of \
|
||||
these fields say.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_guild_header_collapse"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (guild_header_collapse.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"guild_header_collapse_enabled",
|
||||
"true",
|
||||
"Serve guild header collapse assignments to clients",
|
||||
guild_header_collapse.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state. With this unchecked every client is told the \
|
||||
rollout is inert and keeps its current banner height, so the rollout \
|
||||
and targeting fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"guild_header_collapse_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&guild_header_collapse.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"guild_header_collapse_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&guild_header_collapse.rollout_salt,
|
||||
"guild-header-collapse-v1",
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above. Leave it alone to keep the current \
|
||||
cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"guild_header_collapse_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save. Blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"guild_header_collapse_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage, so this is the per-user kill switch."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Guild Header Collapse Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn experiment_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
@@ -2357,4 +2462,24 @@ mod tests {
|
||||
assert!(markup.contains("1000 of 1000 stored"));
|
||||
assert!(markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guild_header_collapse_section_posts_its_own_action_and_fields() {
|
||||
let guild_header_collapse = GuildHeaderCollapseConfigResponse {
|
||||
included_user_ids: vec!["1500000000000000001".to_owned()],
|
||||
excluded_user_ids: vec!["1500000000000000002".to_owned()],
|
||||
..GuildHeaderCollapseConfigResponse::default()
|
||||
};
|
||||
let markup =
|
||||
guild_header_collapse_section("/admin", "csrf", &guild_header_collapse).into_string();
|
||||
assert!(markup.contains("/instance-config?action=update_guild_header_collapse"));
|
||||
assert!(markup.contains("Guild Header Collapse"));
|
||||
assert!(markup.contains("guild-header-collapse-v1"));
|
||||
assert!(markup.contains("guild_header_collapse_enabled"));
|
||||
assert!(markup.contains("guild_header_collapse_rollout_basis_points"));
|
||||
assert!(markup.contains("guild_header_collapse_rollout_salt"));
|
||||
assert!(markup.contains("guild_header_collapse_included_user_ids"));
|
||||
assert!(markup.contains("guild_header_collapse_excluded_user_ids"));
|
||||
assert!(!markup.contains("expression_card_"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,6 +449,14 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"future_object_knob": {"nested": true},
|
||||
"future_list_knob": ["a", "b"]
|
||||
},
|
||||
"guild_header_collapse": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "guild-header-collapse-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"registration": {
|
||||
"mode": "open",
|
||||
"admin_registration_urls_enabled": false,
|
||||
@@ -592,6 +600,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
assert!(!resp.message_hover_tracking.enabled);
|
||||
assert!(!resp.message_keyboard_focus.enabled);
|
||||
assert!(!resp.blocked_message_groups.enabled);
|
||||
assert!(!resp.guild_header_collapse.enabled);
|
||||
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
|
||||
assert!(resp.policy.single_community_guild_id.is_none());
|
||||
assert_eq!(resp.policy.services.gif_enabled, Some(true));
|
||||
|
||||
@@ -432,6 +432,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_voice_noise_suppression",
|
||||
"/instance-config?action=update_guild_header_collapse",
|
||||
"/instance-config?action=update_experiment_delivery",
|
||||
][..],
|
||||
),
|
||||
@@ -1097,6 +1098,14 @@ fn instance_config() -> Value {
|
||||
"max_concurrent_guild_starts": 16,
|
||||
"voice_e2ee_scope": "guild_feature_only"
|
||||
},
|
||||
"guild_header_collapse": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "guild-header-collapse-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"voice_noise_suppression": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
|
||||
@@ -35,6 +35,7 @@ import {BlockedMessageGroupsConfigSchema} from '@fluxer/schema/src/domains/exper
|
||||
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {ExpressionInfoCardConfigSchema} from '@fluxer/schema/src/domains/experiment/ExpressionInfoCardSchemas';
|
||||
import {GuildActivityLogPresentationConfigSchema} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
|
||||
import {GuildHeaderCollapseConfigSchema} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
|
||||
import {MessageHoverTrackingConfigSchema} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas';
|
||||
import {MessageKeyboardFocusConfigSchema} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas';
|
||||
import type {InstanceBranding} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||
@@ -68,6 +69,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
messageKeyboardFocus,
|
||||
blockedMessageGroups,
|
||||
expressionInfoCard,
|
||||
guildHeaderCollapse,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
pendingRegistrations,
|
||||
@@ -81,6 +83,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getMessageKeyboardFocusConfig(),
|
||||
instanceConfigRepository.getBlockedMessageGroupsConfig(),
|
||||
instanceConfigRepository.getExpressionInfoCardConfig(),
|
||||
instanceConfigRepository.getGuildHeaderCollapseConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
instanceConfigRepository.getPendingRegistrations(),
|
||||
@@ -117,6 +120,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
message_keyboard_focus: messageKeyboardFocus,
|
||||
blocked_message_groups: blockedMessageGroups,
|
||||
expression_info_card: expressionInfoCard,
|
||||
guild_header_collapse: guildHeaderCollapse,
|
||||
registration: {
|
||||
...registrationConfig,
|
||||
urls: registrationUrls,
|
||||
@@ -321,6 +325,18 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
await instanceConfigRepository.setExpressionInfoCardConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.guild_header_collapse) {
|
||||
const patch = omitUndefinedFields(data.guild_header_collapse);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const currentGuildHeaderCollapse = await instanceConfigRepository.getGuildHeaderCollapseConfig();
|
||||
const validated = GuildHeaderCollapseConfigSchema.parse({
|
||||
...currentGuildHeaderCollapse,
|
||||
...patch,
|
||||
config_version: currentGuildHeaderCollapse.config_version + 1,
|
||||
});
|
||||
await instanceConfigRepository.setGuildHeaderCollapseConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
const currentExperimentDelivery = await instanceConfigRepository.getExperimentDeliveryConfig();
|
||||
const validated = ExperimentDeliveryConfigSchema.parse({
|
||||
|
||||
@@ -13,6 +13,7 @@ import {resolveBlockedMessageGroupsAssignment} from '@fluxer/schema/src/domains/
|
||||
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {resolveExpressionInfoCardAssignment} from '@fluxer/schema/src/domains/experiment/ExpressionInfoCardSchemas';
|
||||
import {resolveGuildActivityLogPresentationAssignment} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
|
||||
import {resolveGuildHeaderCollapseAssignment} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
|
||||
import {resolveMessageHoverTrackingAssignment} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas';
|
||||
import {resolveMessageKeyboardFocusAssignment} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas';
|
||||
|
||||
@@ -41,6 +42,7 @@ export function ExperimentController(app: HonoApp) {
|
||||
blockedMessageGroupsConfig,
|
||||
guildActivityLogPresentationConfig,
|
||||
expressionInfoCardConfig,
|
||||
guildHeaderCollapseConfig,
|
||||
] = await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
@@ -49,6 +51,7 @@ export function ExperimentController(app: HonoApp) {
|
||||
instanceConfigRepository.getBlockedMessageGroupsConfig(),
|
||||
instanceConfigRepository.getGuildActivityLogPresentationConfig(),
|
||||
instanceConfigRepository.getExpressionInfoCardConfig(),
|
||||
instanceConfigRepository.getGuildHeaderCollapseConfig(),
|
||||
]);
|
||||
const userId = ctx.get('user').id.toString();
|
||||
const body: ExperimentAssignmentsResponse = {
|
||||
@@ -64,6 +67,7 @@ export function ExperimentController(app: HonoApp) {
|
||||
userId,
|
||||
),
|
||||
expression_info_card: resolveExpressionInfoCardAssignment(expressionInfoCardConfig, userId),
|
||||
guild_header_collapse: resolveGuildHeaderCollapseAssignment(guildHeaderCollapseConfig, userId),
|
||||
},
|
||||
};
|
||||
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
readBlockedMessageGroupsAssignment,
|
||||
readExpressionInfoCardAssignment,
|
||||
readGuildActivityLogPresentationAssignment,
|
||||
readGuildHeaderCollapseAssignment,
|
||||
readMessageHoverTrackingAssignment,
|
||||
readMessageKeyboardFocusAssignment,
|
||||
readVoiceNoiseSuppressionAssignment,
|
||||
@@ -34,6 +35,10 @@ import {
|
||||
DEFAULT_GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG,
|
||||
INERT_GUILD_ACTIVITY_LOG_PRESENTATION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
|
||||
import {
|
||||
DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG,
|
||||
INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
|
||||
import {
|
||||
DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG,
|
||||
INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT,
|
||||
@@ -81,6 +86,7 @@ describe('GET /experiments', () => {
|
||||
blocked_message_groups: INERT_BLOCKED_MESSAGE_GROUPS_ASSIGNMENT,
|
||||
guild_activity_log_presentation: INERT_GUILD_ACTIVITY_LOG_PRESENTATION_ASSIGNMENT,
|
||||
expression_info_card: INERT_EXPRESSION_INFO_CARD_ASSIGNMENT,
|
||||
guild_header_collapse: INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -364,7 +370,53 @@ describe('GET /experiments', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves all six experiments independently', async () => {
|
||||
it('populates the guild header collapse key even when the rollout is disabled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(Object.hasOwn(body.assignments, 'guild_header_collapse')).toBe(true);
|
||||
expect(readGuildHeaderCollapseAssignment(body)).toEqual(INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT);
|
||||
});
|
||||
|
||||
it('targets an allowlisted account for guild header collapse', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setGuildHeaderCollapseConfig({
|
||||
...DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
included_user_ids: [account.userId],
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readGuildHeaderCollapseAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
user_targeted: true,
|
||||
source: 'user_rule',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves an account outside a zero-width guild header collapse rollout', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setGuildHeaderCollapseConfig({
|
||||
...DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readGuildHeaderCollapseAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
user_targeted: false,
|
||||
source: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves all seven experiments independently', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setMessageHoverTrackingConfig({
|
||||
...DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG,
|
||||
@@ -391,6 +443,11 @@ describe('GET /experiments', () => {
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
await getInstanceConfigRepository().setGuildHeaderCollapseConfig({
|
||||
...DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
@@ -399,6 +456,7 @@ describe('GET /experiments', () => {
|
||||
expect(readBlockedMessageGroupsAssignment(body).user_targeted).toBe(true);
|
||||
expect(readGuildActivityLogPresentationAssignment(body).user_targeted).toBe(true);
|
||||
expect(readExpressionInfoCardAssignment(body).user_targeted).toBe(true);
|
||||
expect(readGuildHeaderCollapseAssignment(body).user_targeted).toBe(true);
|
||||
expect(readVoiceNoiseSuppressionAssignment(body).enabled).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
DEFAULT_GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG,
|
||||
type GuildActivityLogPresentationConfig,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
|
||||
import {
|
||||
DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG,
|
||||
type GuildHeaderCollapseConfig,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
|
||||
import {
|
||||
DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG,
|
||||
type MessageHoverTrackingConfig,
|
||||
@@ -45,6 +49,7 @@ const MESSAGE_KEYBOARD_FOCUS_CONFIG_KEY = 'message_keyboard_focus_config';
|
||||
const BLOCKED_MESSAGE_GROUPS_CONFIG_KEY = 'blocked_message_groups_config';
|
||||
const GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG_KEY = 'guild_activity_log_presentation_config';
|
||||
const EXPRESSION_INFO_CARD_CONFIG_KEY = 'expression_info_card_config';
|
||||
const GUILD_HEADER_COLLAPSE_CONFIG_KEY = 'guild_header_collapse_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
|
||||
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
|
||||
@@ -566,6 +571,70 @@ describe('InstanceConfigRepository', () => {
|
||||
await expect(repository.getExpressionInfoCardConfig()).resolves.toEqual(config);
|
||||
});
|
||||
|
||||
it('returns the default guild header collapse config when the key is absent', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await expect(repository.getGuildHeaderCollapseConfig()).resolves.toEqual(DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{name: 'unparseable text', stored: 'not-json'},
|
||||
{name: 'a json array', stored: '[]'},
|
||||
{name: 'out-of-range values', stored: '{"rollout_basis_points":99999}'},
|
||||
{name: 'a target that is not a snowflake', stored: '{"included_user_ids":["nope"]}'},
|
||||
])('falls back to the default guild header collapse config for $name', async ({stored}) => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(GUILD_HEADER_COLLAPSE_CONFIG_KEY, stored);
|
||||
|
||||
await expect(repository.getGuildHeaderCollapseConfig()).resolves.toEqual(DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG);
|
||||
});
|
||||
|
||||
it('round-trips a stored guild header collapse config', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
const config: GuildHeaderCollapseConfig = {
|
||||
...DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 5,
|
||||
rollout_basis_points: 1500,
|
||||
rollout_salt: 'guild-header-collapse-v2',
|
||||
included_user_ids: ['1400000000000000001'],
|
||||
excluded_user_ids: ['1400000000000000002'],
|
||||
};
|
||||
await repository.setGuildHeaderCollapseConfig(config);
|
||||
|
||||
await expect(repository.getGuildHeaderCollapseConfig()).resolves.toEqual(config);
|
||||
});
|
||||
|
||||
it('fills newly added guild header collapse fields from the schema defaults', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(
|
||||
GUILD_HEADER_COLLAPSE_CONFIG_KEY,
|
||||
JSON.stringify({enabled: true, config_version: 2, rollout_basis_points: 1000}),
|
||||
);
|
||||
|
||||
await expect(repository.getGuildHeaderCollapseConfig()).resolves.toEqual({
|
||||
...DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
rollout_basis_points: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('fills newly added voice noise suppression fields from the schema defaults', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
|
||||
@@ -48,6 +48,10 @@ import {
|
||||
type GuildActivityLogPresentationConfig,
|
||||
GuildActivityLogPresentationConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
|
||||
import {
|
||||
type GuildHeaderCollapseConfig,
|
||||
GuildHeaderCollapseConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
|
||||
import {
|
||||
type MessageHoverTrackingConfig,
|
||||
MessageHoverTrackingConfigSchema,
|
||||
@@ -80,6 +84,7 @@ const MESSAGE_HOVER_TRACKING_CONFIG_KEY = 'message_hover_tracking_config';
|
||||
const MESSAGE_KEYBOARD_FOCUS_CONFIG_KEY = 'message_keyboard_focus_config';
|
||||
const BLOCKED_MESSAGE_GROUPS_CONFIG_KEY = 'blocked_message_groups_config';
|
||||
const EXPRESSION_INFO_CARD_CONFIG_KEY = 'expression_info_card_config';
|
||||
const GUILD_HEADER_COLLAPSE_CONFIG_KEY = 'guild_header_collapse_config';
|
||||
const REGISTRATION_CONFIG_KEY = 'registration_config';
|
||||
const REGISTRATION_URLS_KEY = 'registration_urls';
|
||||
const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals';
|
||||
@@ -368,6 +373,7 @@ type StoredConfigSection =
|
||||
| 'message keyboard focus'
|
||||
| 'blocked message groups'
|
||||
| 'expression info card'
|
||||
| 'guild header collapse'
|
||||
| 'instance policy'
|
||||
| 'integrations'
|
||||
| 'media'
|
||||
@@ -529,6 +535,10 @@ function parseStoredExpressionInfoCardConfig(raw: string | null): ExpressionInfo
|
||||
return parseStoredConfigOrDefault(ExpressionInfoCardConfigSchema, raw, 'expression info card');
|
||||
}
|
||||
|
||||
function parseStoredGuildHeaderCollapseConfig(raw: string | null): GuildHeaderCollapseConfig {
|
||||
return parseStoredConfigOrDefault(GuildHeaderCollapseConfigSchema, raw, 'guild header collapse');
|
||||
}
|
||||
|
||||
function validateStoredCollection<T>(schema: z.ZodType<T>, value: unknown, section: StoredConfigSection): Array<T> {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`Stored ${section} configuration must be an array`);
|
||||
@@ -1053,6 +1063,7 @@ export class InstanceConfigRepository {
|
||||
parseStoredMessageKeyboardFocusConfig(snapshot.get(MESSAGE_KEYBOARD_FOCUS_CONFIG_KEY) ?? null);
|
||||
parseStoredBlockedMessageGroupsConfig(snapshot.get(BLOCKED_MESSAGE_GROUPS_CONFIG_KEY) ?? null);
|
||||
parseStoredExpressionInfoCardConfig(snapshot.get(EXPRESSION_INFO_CARD_CONFIG_KEY) ?? null);
|
||||
parseStoredGuildHeaderCollapseConfig(snapshot.get(GUILD_HEADER_COLLAPSE_CONFIG_KEY) ?? null);
|
||||
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
|
||||
checkStoredConfig('registration', () =>
|
||||
parseStoredRegistrationConfig(snapshot.get(REGISTRATION_CONFIG_KEY) ?? null),
|
||||
@@ -1192,6 +1203,16 @@ export class InstanceConfigRepository {
|
||||
await this.setConfig(EXPRESSION_INFO_CARD_CONFIG_KEY, JSON.stringify(validated));
|
||||
}
|
||||
|
||||
async getGuildHeaderCollapseConfig(): Promise<GuildHeaderCollapseConfig> {
|
||||
const raw = await this.getConfig(GUILD_HEADER_COLLAPSE_CONFIG_KEY);
|
||||
return parseStoredGuildHeaderCollapseConfig(raw);
|
||||
}
|
||||
|
||||
async setGuildHeaderCollapseConfig(config: GuildHeaderCollapseConfig): Promise<void> {
|
||||
const validated = validateStoredConfig(GuildHeaderCollapseConfigSchema, config, 'guild header collapse');
|
||||
await this.setConfig(GUILD_HEADER_COLLAPSE_CONFIG_KEY, JSON.stringify(validated));
|
||||
}
|
||||
|
||||
async setExperimentDeliveryConfig(config: ExperimentDeliveryConfig): Promise<void> {
|
||||
const validated = validateStoredConfig(ExperimentDeliveryConfigSchema, config, 'experiment delivery');
|
||||
await this.setConfig(EXPERIMENT_DELIVERY_CONFIG_KEY, JSON.stringify(validated));
|
||||
|
||||
@@ -28319,7 +28319,8 @@
|
||||
"guild_activity_log_presentation": {
|
||||
"$ref": "#/components/schemas/GuildActivityLogPresentationAssignmentResponse"
|
||||
},
|
||||
"expression_info_card": {"$ref": "#/components/schemas/ExpressionInfoCardAssignmentResponse"}
|
||||
"expression_info_card": {"$ref": "#/components/schemas/ExpressionInfoCardAssignmentResponse"},
|
||||
"guild_header_collapse": {"$ref": "#/components/schemas/GuildHeaderCollapseAssignmentResponse"}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -32074,6 +32075,17 @@
|
||||
"additionalProperties": false,
|
||||
"description": "Public presentation of the source guild of an expression"
|
||||
},
|
||||
"GuildHeaderCollapseAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"config_version": {"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991},
|
||||
"user_targeted": {"type": "boolean"},
|
||||
"source": {"anyOf": [{"type": "string", "enum": ["user_rule", "canary"]}, {"type": "null"}]}
|
||||
},
|
||||
"required": ["enabled", "config_version", "user_targeted", "source"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ExpressionInfoCardAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.channelListScrollerOverBanner {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.bannerSpacer {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.channelListScrollerWrapper {
|
||||
--floating-unread-indicator-bottom-inset: calc(
|
||||
var(--layout-user-area-overlay-height, var(--layout-user-area-reserved-height, 0px)) +
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type RememberedSkeletonGuildChannelRow,
|
||||
reportSkeletonGuildChannelList,
|
||||
} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
|
||||
import type {GuildBannerPresentation} from '@app/features/app/hooks/useGuildBannerPresentation';
|
||||
import {useRovingFocusList} from '@app/features/app/hooks/useRovingFocusList';
|
||||
import {
|
||||
measureSkeletonTextWidthPx,
|
||||
@@ -72,7 +73,7 @@ import {UsersIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type {MotionValue} from 'motion';
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
|
||||
import {useDragLayer} from 'react-dnd';
|
||||
|
||||
const CATEGORY_FULL_DESCRIPTOR = msg({
|
||||
@@ -164,7 +165,12 @@ function createRememberedChannelGroups(
|
||||
};
|
||||
});
|
||||
}
|
||||
export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scrollY: MotionValue<number>}) => {
|
||||
interface ChannelListContentProps {
|
||||
readonly guild: Guild;
|
||||
readonly scrollY: MotionValue<number>;
|
||||
readonly banner: GuildBannerPresentation;
|
||||
}
|
||||
export const ChannelListContent = observer(({guild, scrollY, banner}: ChannelListContentProps) => {
|
||||
const {i18n} = useLingui();
|
||||
const channels = Channels.getGuildChannels(guild.id);
|
||||
const location = useLocation();
|
||||
@@ -180,6 +186,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
});
|
||||
const [activeDragItem, setActiveDragItem] = useState<DragItem | null>(null);
|
||||
const scrollerRef = useRef<ScrollerHandle>(null);
|
||||
const showIntegratedBanner = banner.imageUrl != null && banner.collapseDistance > 0;
|
||||
const channelGroupsContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const stickToBottomRef = useRef(false);
|
||||
const pendingScrollTopRef = useRef<number | null>(null);
|
||||
@@ -313,7 +320,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
scrollerRef.current.jumpToEndEdge({animate: false});
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
const guildDimensions = Dimension.guildDimensionsFor(guild.id);
|
||||
if (guildDimensions.scrollTo) {
|
||||
const element = document.querySelector(`[data-channel-id="${guildDimensions.scrollTo}"]`);
|
||||
@@ -324,7 +331,8 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
} else if (guildDimensions.scrollTop && guildDimensions.scrollTop > 0 && scrollerRef.current) {
|
||||
scrollerRef.current.scrollTo({to: guildDimensions.scrollTop, animate: false});
|
||||
}
|
||||
}, [guild.id]);
|
||||
scrollY.set(scrollerRef.current?.getViewportElement()?.scrollTop ?? 0);
|
||||
}, [guild.id, scrollY]);
|
||||
const handleContextMenu = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
ContextMenuCommands.openFromEvent(event, ({onClose}) => (
|
||||
@@ -517,7 +525,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
>
|
||||
<Scroller
|
||||
ref={scrollerRef}
|
||||
className={styles.channelListScroller}
|
||||
className={clsx(styles.channelListScroller, showIntegratedBanner && styles.channelListScrollerOverBanner)}
|
||||
onScroll={handleScroll}
|
||||
onResize={handleResize}
|
||||
key={guild.id}
|
||||
@@ -531,6 +539,14 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
ref={channelListNavigationRef}
|
||||
data-flx="app.channel-list-content.navigation-container.context-menu"
|
||||
>
|
||||
{showIntegratedBanner && (
|
||||
<div
|
||||
ref={banner.hoverRef}
|
||||
className={styles.bannerSpacer}
|
||||
style={{height: banner.collapseDistance}}
|
||||
data-flx="app.channel-list-content.banner-spacer"
|
||||
/>
|
||||
)}
|
||||
<GuildDetachedBanner guild={guild} data-flx="app.channel-list-content.guild-detached-banner" />
|
||||
<div className={styles.topDropZone} data-flx="app.channel-list-content.top-drop-zone">
|
||||
<NullSpaceDropIndicator
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
GUILD_BANNER_HEADER_GLASS_DISTANCE,
|
||||
GUILD_BANNER_HEADER_GLASS_MAX_OPACITY,
|
||||
isGuildBannerHeaderFrosted,
|
||||
resolveGuildBannerCollapseProgress,
|
||||
resolveGuildBannerGeometry,
|
||||
resolveGuildBannerHeaderGlassOpacity,
|
||||
} from '@app/features/app/components/layout/GuildBannerCollapse';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('resolveGuildBannerGeometry', () => {
|
||||
it('sizes the banner from the sidebar width and the banner aspect ratio', () => {
|
||||
const geometry = resolveGuildBannerGeometry({
|
||||
containerWidth: 320,
|
||||
viewportHeight: 1000,
|
||||
headerHeight: 56,
|
||||
aspectRatio: 16 / 9,
|
||||
});
|
||||
expect(geometry.bannerHeight).toBeCloseTo(180);
|
||||
expect(geometry.collapseDistance).toBeCloseTo(124);
|
||||
expect(geometry.heightCapped).toBe(false);
|
||||
});
|
||||
|
||||
it('caps the banner at a fraction of the viewport height', () => {
|
||||
const geometry = resolveGuildBannerGeometry({
|
||||
containerWidth: 480,
|
||||
viewportHeight: 500,
|
||||
headerHeight: 56,
|
||||
aspectRatio: 1,
|
||||
});
|
||||
expect(geometry.bannerHeight).toBeCloseTo(150);
|
||||
expect(geometry.collapseDistance).toBeCloseTo(94);
|
||||
expect(geometry.heightCapped).toBe(true);
|
||||
});
|
||||
|
||||
it('never collapses below the header height', () => {
|
||||
const geometry = resolveGuildBannerGeometry({
|
||||
containerWidth: 200,
|
||||
viewportHeight: 120,
|
||||
headerHeight: 56,
|
||||
aspectRatio: 21 / 9,
|
||||
});
|
||||
expect(geometry.bannerHeight).toBe(56);
|
||||
expect(geometry.collapseDistance).toBe(0);
|
||||
});
|
||||
|
||||
it('reports no banner height before the header is measured', () => {
|
||||
const geometry = resolveGuildBannerGeometry({
|
||||
containerWidth: 0,
|
||||
viewportHeight: 0,
|
||||
headerHeight: 56,
|
||||
aspectRatio: 16 / 9,
|
||||
});
|
||||
expect(geometry.bannerHeight).toBe(56);
|
||||
expect(geometry.collapseDistance).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGuildBannerCollapseProgress', () => {
|
||||
it('tracks scroll position across the collapse distance', () => {
|
||||
expect(resolveGuildBannerCollapseProgress(0, 80)).toBe(0);
|
||||
expect(resolveGuildBannerCollapseProgress(40, 80)).toBe(0.5);
|
||||
expect(resolveGuildBannerCollapseProgress(80, 80)).toBe(1);
|
||||
});
|
||||
|
||||
it('clamps overscroll and bounce past both ends', () => {
|
||||
expect(resolveGuildBannerCollapseProgress(-30, 80)).toBe(0);
|
||||
expect(resolveGuildBannerCollapseProgress(4000, 80)).toBe(1);
|
||||
});
|
||||
|
||||
it('stays expanded when there is nothing to collapse', () => {
|
||||
expect(resolveGuildBannerCollapseProgress(200, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGuildBannerHeaderGlassOpacity', () => {
|
||||
it('ramps in over a fixed scroll distance regardless of banner height', () => {
|
||||
expect(resolveGuildBannerHeaderGlassOpacity(0)).toBe(0);
|
||||
expect(resolveGuildBannerHeaderGlassOpacity(GUILD_BANNER_HEADER_GLASS_DISTANCE / 2)).toBeCloseTo(
|
||||
GUILD_BANNER_HEADER_GLASS_MAX_OPACITY / 2,
|
||||
);
|
||||
expect(resolveGuildBannerHeaderGlassOpacity(GUILD_BANNER_HEADER_GLASS_DISTANCE)).toBeCloseTo(
|
||||
GUILD_BANNER_HEADER_GLASS_MAX_OPACITY,
|
||||
);
|
||||
});
|
||||
|
||||
it('never exceeds the opaque header background', () => {
|
||||
expect(resolveGuildBannerHeaderGlassOpacity(5000)).toBe(GUILD_BANNER_HEADER_GLASS_MAX_OPACITY);
|
||||
});
|
||||
|
||||
it('frosts the header once the glass is fully ramped in', () => {
|
||||
expect(isGuildBannerHeaderFrosted(0)).toBe(false);
|
||||
expect(isGuildBannerHeaderFrosted(GUILD_BANNER_HEADER_GLASS_DISTANCE - 1)).toBe(false);
|
||||
expect(isGuildBannerHeaderFrosted(GUILD_BANNER_HEADER_GLASS_DISTANCE)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const GUILD_BANNER_DEFAULT_ASPECT_RATIO = 16 / 9;
|
||||
export const GUILD_BANNER_MAX_VIEWPORT_HEIGHT_FRACTION = 0.3;
|
||||
export const GUILD_BANNER_HEADER_GLASS_MAX_OPACITY = 0.72;
|
||||
export const GUILD_BANNER_HEADER_GLASS_DISTANCE = 16;
|
||||
|
||||
export interface GuildBannerMetrics {
|
||||
readonly containerWidth: number;
|
||||
readonly viewportHeight: number;
|
||||
readonly headerHeight: number;
|
||||
readonly aspectRatio: number;
|
||||
}
|
||||
|
||||
export interface GuildBannerGeometry {
|
||||
readonly bannerHeight: number;
|
||||
readonly collapseDistance: number;
|
||||
readonly heightCapped: boolean;
|
||||
}
|
||||
|
||||
export function resolveGuildBannerGeometry({
|
||||
containerWidth,
|
||||
viewportHeight,
|
||||
headerHeight,
|
||||
aspectRatio,
|
||||
}: GuildBannerMetrics): GuildBannerGeometry {
|
||||
if (containerWidth <= 0 || aspectRatio <= 0) {
|
||||
return {bannerHeight: headerHeight, collapseDistance: 0, heightCapped: false};
|
||||
}
|
||||
const idealHeight = containerWidth / aspectRatio;
|
||||
const viewportCap =
|
||||
viewportHeight > 0 ? viewportHeight * GUILD_BANNER_MAX_VIEWPORT_HEIGHT_FRACTION : Number.POSITIVE_INFINITY;
|
||||
const bannerHeight = Math.max(headerHeight, Math.min(idealHeight, viewportCap));
|
||||
return {
|
||||
bannerHeight,
|
||||
collapseDistance: Math.max(bannerHeight - headerHeight, 0),
|
||||
heightCapped: idealHeight > viewportCap,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGuildBannerCollapseProgress(scrollTop: number, collapseDistance: number): number {
|
||||
if (collapseDistance <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(1, Math.max(0, scrollTop / collapseDistance));
|
||||
}
|
||||
|
||||
export function resolveGuildBannerHeaderGlassOpacity(scrollTop: number): number {
|
||||
if (scrollTop <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const ramp = (scrollTop / GUILD_BANNER_HEADER_GLASS_DISTANCE) * GUILD_BANNER_HEADER_GLASS_MAX_OPACITY;
|
||||
return Math.min(GUILD_BANNER_HEADER_GLASS_MAX_OPACITY, ramp);
|
||||
}
|
||||
|
||||
export function isGuildBannerHeaderFrosted(scrollTop: number): boolean {
|
||||
return resolveGuildBannerHeaderGlassOpacity(scrollTop) >= GUILD_BANNER_HEADER_GLASS_MAX_OPACITY;
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.headerWrapper {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headerContainer {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
overflow: hidden;
|
||||
height: var(--layout-header-height);
|
||||
min-height: var(--layout-header-height);
|
||||
min-width: 0;
|
||||
border-bottom: 0.0625rem solid var(--user-area-divider-color);
|
||||
@@ -24,25 +27,54 @@
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.headerContainerBanner {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.headerContainerCollapsing {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
.headerContainerNoBanner:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.bannerBackground {
|
||||
.bannerClip {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bannerImage {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-size: cover;
|
||||
background-position: top center;
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--background-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bannerBackgroundCentered {
|
||||
.bannerImageCentered {
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.headerGlass {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--background-secondary);
|
||||
border-bottom: 0.0625rem solid var(--user-area-divider-color);
|
||||
backdrop-filter: blur(1.25rem);
|
||||
-webkit-backdrop-filter: blur(1.25rem);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bannerGradient {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -78,6 +110,9 @@
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
color var(--transition-normal),
|
||||
filter var(--transition-normal);
|
||||
}
|
||||
|
||||
.verifiedIconDefault {
|
||||
@@ -92,6 +127,9 @@
|
||||
}
|
||||
|
||||
.guildName {
|
||||
transition:
|
||||
color var(--transition-normal),
|
||||
text-shadow var(--transition-normal);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
@@ -118,7 +156,10 @@
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-fast);
|
||||
transition:
|
||||
transform var(--transition-fast),
|
||||
color var(--transition-normal),
|
||||
filter var(--transition-normal);
|
||||
}
|
||||
|
||||
.caretIconDefault {
|
||||
@@ -141,6 +182,9 @@
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
color var(--transition-normal),
|
||||
filter var(--transition-normal);
|
||||
}
|
||||
|
||||
.dotsIconDefault {
|
||||
|
||||
@@ -7,24 +7,19 @@ import {
|
||||
reportSkeletonGuildPresentation,
|
||||
SkeletonGuildBannerPlacement,
|
||||
} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
|
||||
import {useAnimatedImageUrl} from '@app/features/app/hooks/useAnimatedImageUrl';
|
||||
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
|
||||
import type {GuildBannerPresentation} from '@app/features/app/hooks/useGuildBannerPresentation';
|
||||
import {
|
||||
measureSkeletonTextWidthPx,
|
||||
useSkeletonLayoutReport,
|
||||
} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture';
|
||||
import {clampWideAssetAspectRatio} from '@app/features/expressions/utils/AssetImageGeometry';
|
||||
import {GuildHeaderBottomSheet} from '@app/features/guild/components/bottomsheets/GuildHeaderBottomSheet';
|
||||
import {GuildBadge} from '@app/features/guild/components/GuildBadge';
|
||||
import {GuildHeaderPopout} from '@app/features/guild/components/popouts/GuildHeaderPopout';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {GuildContextMenu} from '@app/features/ui/action_menu/GuildContextMenu';
|
||||
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import Popout from '@app/features/ui/state/Popout';
|
||||
import {SIDEBAR_WIDTH_DEFAULT} from '@app/features/ui/state/SidebarWidth';
|
||||
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
@@ -33,15 +28,12 @@ import {clsx} from 'clsx';
|
||||
import {motion} from 'framer-motion';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useLayoutEffect, useMemo, useRef, useState} from 'react';
|
||||
import {useCallback, useRef} from 'react';
|
||||
|
||||
const OPEN_COMMUNITY_MENU_FOR_DESCRIPTOR = msg({
|
||||
message: 'Open community menu for {guildName}',
|
||||
comment: 'Short label in the app layout guild header. Preserve {guildName}; it is inserted by code.',
|
||||
});
|
||||
const HEADER_MIN_HEIGHT = 56;
|
||||
const DEFAULT_BANNER_ASPECT_RATIO = 16 / 9;
|
||||
const MAX_VIEWPORT_HEIGHT_FRACTION = 0.3;
|
||||
const GUILD_BADGE_FEATURES: ReadonlyArray<string> = [
|
||||
GuildFeatures.VERIFIED,
|
||||
GuildFeatures.PARTNERED,
|
||||
@@ -61,71 +53,14 @@ function resolveSkeletonBannerPlacement(hasBanner: boolean, isDetachedBanner: bo
|
||||
}
|
||||
return SkeletonGuildBannerPlacement.INTEGRATED;
|
||||
}
|
||||
export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
export const GuildHeader = observer(({guild, banner}: {guild: Guild; banner: GuildBannerPresentation}) => {
|
||||
const {i18n} = useLingui();
|
||||
const {popouts} = Popout;
|
||||
const isOpen = 'guild-header' in popouts;
|
||||
const isMobile = MobileLayout.isMobileLayout();
|
||||
const isDetachedBanner = guild.features.has(GuildFeatures.DETACHED_BANNER);
|
||||
const staticBannerURL = useMemo(
|
||||
() => AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, false) || null,
|
||||
[guild.banner, guild.id],
|
||||
);
|
||||
const animatedBannerURL = useMemo(
|
||||
() => (isDetachedBanner ? null : AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, true) || null),
|
||||
[guild.banner, guild.id, isDetachedBanner],
|
||||
);
|
||||
const {hoverRef: bannerHoverRef, imageUrl: bannerURL} = useAnimatedImageUrl({
|
||||
staticUrl: staticBannerURL,
|
||||
animatedUrl: animatedBannerURL,
|
||||
kind: 'gif',
|
||||
});
|
||||
const showIntegratedBanner = Boolean(bannerURL && !isDetachedBanner);
|
||||
const bannerAspectRatio =
|
||||
guild.bannerWidth && guild.bannerHeight
|
||||
? (clampWideAssetAspectRatio(guild.bannerWidth / guild.bannerHeight) ?? DEFAULT_BANNER_ASPECT_RATIO)
|
||||
: DEFAULT_BANNER_ASPECT_RATIO;
|
||||
const headerContainerRef = useRef<HTMLElement | null>(null);
|
||||
const mergedHeaderContainerRef = useMergeRefs([headerContainerRef, bannerHoverRef]);
|
||||
const [containerWidth, setContainerWidth] = useState<number>(() =>
|
||||
isMobile && typeof window !== 'undefined' ? window.innerWidth : SIDEBAR_WIDTH_DEFAULT,
|
||||
);
|
||||
const [viewportHeight, setViewportHeight] = useState<number>(() =>
|
||||
typeof window !== 'undefined' ? window.innerHeight : 0,
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
const measure = () => {
|
||||
const width = headerContainerRef.current?.clientWidth;
|
||||
if (width) setContainerWidth((prev) => (prev === width ? prev : width));
|
||||
setViewportHeight((prev) => (prev === window.innerHeight ? prev : window.innerHeight));
|
||||
};
|
||||
measure();
|
||||
window.addEventListener('resize', measure);
|
||||
const visualViewport = typeof window !== 'undefined' ? window.visualViewport : null;
|
||||
visualViewport?.addEventListener('resize', measure);
|
||||
const container = headerContainerRef.current;
|
||||
const resizeObserver = container && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null;
|
||||
if (container && resizeObserver) {
|
||||
resizeObserver.observe(container);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('resize', measure);
|
||||
visualViewport?.removeEventListener('resize', measure);
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, []);
|
||||
const {bannerMaxHeight, centerCrop} = (() => {
|
||||
if (!showIntegratedBanner || !bannerURL || !containerWidth) {
|
||||
return {bannerMaxHeight: HEADER_MIN_HEIGHT, centerCrop: false};
|
||||
}
|
||||
const idealHeight = containerWidth / bannerAspectRatio;
|
||||
const viewportCap = viewportHeight * MAX_VIEWPORT_HEIGHT_FRACTION;
|
||||
const isCapped = idealHeight > viewportCap;
|
||||
return {
|
||||
bannerMaxHeight: Math.max(HEADER_MIN_HEIGHT, Math.min(idealHeight, viewportCap)),
|
||||
centerCrop: isMobile && isCapped,
|
||||
};
|
||||
})();
|
||||
const showIntegratedBanner = banner.imageUrl != null;
|
||||
const collapsesOnScroll = showIntegratedBanner && banner.collapseEnabled;
|
||||
const onBanner = showIntegratedBanner && !banner.frosted;
|
||||
const handleContextMenu = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
ContextMenuCommands.openFromEvent(event, ({onClose}) => (
|
||||
@@ -141,37 +76,61 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
const headerButtonRef = useRef<HTMLDivElement | null>(null);
|
||||
const guildNameRef = useRef<HTMLSpanElement | null>(null);
|
||||
const badgeVisible = resolveGuildBadgeVisible(guild.features);
|
||||
const bannerPlacement = resolveSkeletonBannerPlacement(Boolean(bannerURL), isDetachedBanner);
|
||||
const bannerPlacement = resolveSkeletonBannerPlacement(banner.hasBanner, banner.isDetached);
|
||||
useSkeletonLayoutReport(() => {
|
||||
reportSkeletonGuildPresentation(guild.id, {
|
||||
headerNameWidthPx: measureSkeletonTextWidthPx(guildNameRef.current),
|
||||
badgeVisible,
|
||||
bannerPlacement,
|
||||
bannerAspectRatio,
|
||||
bannerAspectRatio: banner.aspectRatio,
|
||||
});
|
||||
}, `${guild.id}:${guild.name}:${badgeVisible}:${bannerPlacement}:${bannerAspectRatio}:${isMobile}`);
|
||||
}, `${guild.id}:${guild.name}:${badgeVisible}:${bannerPlacement}:${banner.aspectRatio}:${isMobile}`);
|
||||
return (
|
||||
<div className={styles.headerWrapper} data-flx="app.guild-header.header-wrapper">
|
||||
{showIntegratedBanner && (
|
||||
<motion.div
|
||||
className={styles.bannerClip}
|
||||
style={{height: banner.bannerHeight, y: banner.clipShift, opacity: banner.imageOpacity}}
|
||||
aria-hidden
|
||||
data-flx="app.guild-header.banner-clip"
|
||||
>
|
||||
<motion.div
|
||||
className={clsx(styles.bannerImage, banner.centerCrop && styles.bannerImageCentered)}
|
||||
style={{
|
||||
height: banner.bannerHeight,
|
||||
y: banner.imageShift,
|
||||
backgroundImage: `url(${banner.imageUrl})`,
|
||||
}}
|
||||
data-flx="app.guild-header.banner-image"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
<NativeDragRegion
|
||||
as={motion.div}
|
||||
ref={mergedHeaderContainerRef}
|
||||
ref={banner.headerRef}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={clsx(
|
||||
styles.headerContainer,
|
||||
!showIntegratedBanner && styles.headerContainerNoBanner,
|
||||
showIntegratedBanner ? styles.headerContainerBanner : styles.headerContainerNoBanner,
|
||||
collapsesOnScroll && styles.headerContainerCollapsing,
|
||||
!showIntegratedBanner && isOpen && styles.headerContainerActive,
|
||||
)}
|
||||
style={{height: showIntegratedBanner ? bannerMaxHeight : remFromPx(HEADER_MIN_HEIGHT)}}
|
||||
style={showIntegratedBanner && !collapsesOnScroll ? {height: banner.bannerHeight} : undefined}
|
||||
data-flx="app.guild-header.header-container.context-menu"
|
||||
>
|
||||
{showIntegratedBanner && (
|
||||
<>
|
||||
<div
|
||||
className={clsx(styles.bannerBackground, centerCrop && styles.bannerBackgroundCentered)}
|
||||
style={{backgroundImage: `url(${bannerURL})`}}
|
||||
data-flx="app.guild-header.banner-background"
|
||||
{collapsesOnScroll && (
|
||||
<motion.div
|
||||
className={styles.headerGlass}
|
||||
style={{opacity: banner.glassOpacity}}
|
||||
data-flx="app.guild-header.header-glass"
|
||||
/>
|
||||
)}
|
||||
<motion.div
|
||||
className={styles.bannerGradient}
|
||||
style={{opacity: banner.scrimOpacity}}
|
||||
data-flx="app.guild-header.banner-gradient"
|
||||
/>
|
||||
<div className={styles.bannerGradient} data-flx="app.guild-header.banner-gradient" />
|
||||
</>
|
||||
)}
|
||||
<GuildHeaderShell
|
||||
@@ -195,13 +154,13 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
<>
|
||||
<GuildBadge
|
||||
features={guild.features}
|
||||
variant={showIntegratedBanner ? 'banner' : 'default'}
|
||||
variant={onBanner ? 'banner' : 'default'}
|
||||
tooltipPosition="bottom"
|
||||
data-flx="app.guild-header.guild-badge"
|
||||
/>
|
||||
<span
|
||||
ref={guildNameRef}
|
||||
className={showIntegratedBanner ? styles.guildNameWithBanner : styles.guildNameDefault}
|
||||
className={onBanner ? styles.guildNameWithBanner : styles.guildNameDefault}
|
||||
data-flx="app.guild-header.guild-name"
|
||||
>
|
||||
{guild.name}
|
||||
@@ -209,14 +168,14 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
{isMobile ? (
|
||||
<DotsThreeIcon
|
||||
weight="bold"
|
||||
className={showIntegratedBanner ? styles.dotsIconWithBanner : styles.dotsIconDefault}
|
||||
className={onBanner ? styles.dotsIconWithBanner : styles.dotsIconDefault}
|
||||
data-flx="app.guild-header.dots-icon"
|
||||
/>
|
||||
) : (
|
||||
<CaretDownIcon
|
||||
weight="bold"
|
||||
className={clsx(
|
||||
showIntegratedBanner ? styles.caretIconWithBanner : styles.caretIconDefault,
|
||||
onBanner ? styles.caretIconWithBanner : styles.caretIconDefault,
|
||||
isOpen && styles.caretIconOpen,
|
||||
)}
|
||||
data-flx="app.guild-header.caret-icon"
|
||||
|
||||
@@ -4,6 +4,7 @@ import {TopNagbarContext} from '@app/features/app/components/layout/app_layout/T
|
||||
import {ChannelListContent} from '@app/features/app/components/layout/ChannelListContent';
|
||||
import {GuildHeader} from '@app/features/app/components/layout/GuildHeader';
|
||||
import {GuildSidebar} from '@app/features/app/components/layout/GuildSidebar';
|
||||
import {useGuildBannerPresentation} from '@app/features/app/hooks/useGuildBannerPresentation';
|
||||
import {useNativePlatform} from '@app/features/app/hooks/useNativePlatform';
|
||||
import KeybindManager from '@app/features/app/keybindings/KeybindManager';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
@@ -12,18 +13,16 @@ import * as UserGuildSettingsCommands from '@app/features/user/commands/UserGuil
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {useMotionValue} from 'framer-motion';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useContext, useEffect, useMemo} from 'react';
|
||||
import {useContext, useMemo} from 'react';
|
||||
import {useHotkeys} from 'react-hotkeys-hook';
|
||||
|
||||
export const GuildNavbar = observer(({guild}: {guild: Guild}) => {
|
||||
const scrollY = useMotionValue(0);
|
||||
const banner = useGuildBannerPresentation({guild, scrollY});
|
||||
const {isNative, isWindows, isLinux} = useNativePlatform();
|
||||
const topNagbarCount = useContext(TopNagbarContext);
|
||||
const hasTopNagbar = topNagbarCount > 0;
|
||||
const shouldRoundTopLeft = isNative && (isWindows || isLinux) && !hasTopNagbar;
|
||||
useEffect(() => {
|
||||
scrollY.set(0);
|
||||
}, [guild.id, scrollY]);
|
||||
const channels = Channels.getGuildChannels(guild.id);
|
||||
const categoryIds = useMemo(() => {
|
||||
return channels.filter((ch) => ch.type === ChannelTypes.GUILD_CATEGORY).map((ch) => ch.id);
|
||||
@@ -46,8 +45,15 @@ export const GuildNavbar = observer(({guild}: {guild: Guild}) => {
|
||||
return (
|
||||
<GuildSidebar
|
||||
roundTopLeft={shouldRoundTopLeft}
|
||||
header={<GuildHeader guild={guild} data-flx="app.guild-navbar.guild-header" />}
|
||||
content={<ChannelListContent guild={guild} scrollY={scrollY} data-flx="app.guild-navbar.channel-list-content" />}
|
||||
header={<GuildHeader guild={guild} banner={banner} data-flx="app.guild-navbar.guild-header" />}
|
||||
content={
|
||||
<ChannelListContent
|
||||
guild={guild}
|
||||
scrollY={scrollY}
|
||||
banner={banner}
|
||||
data-flx="app.guild-navbar.channel-list-content"
|
||||
/>
|
||||
}
|
||||
data-flx="app.guild-navbar.guild-sidebar"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
GUILD_BANNER_DEFAULT_ASPECT_RATIO,
|
||||
GUILD_BANNER_HEADER_GLASS_MAX_OPACITY,
|
||||
isGuildBannerHeaderFrosted,
|
||||
resolveGuildBannerCollapseProgress,
|
||||
resolveGuildBannerGeometry,
|
||||
resolveGuildBannerHeaderGlassOpacity,
|
||||
} from '@app/features/app/components/layout/GuildBannerCollapse';
|
||||
import {useAnimatedImageUrl} from '@app/features/app/hooks/useAnimatedImageUrl';
|
||||
import GuildHeaderCollapseRollout from '@app/features/app/state/GuildHeaderCollapseRollout';
|
||||
import {clampWideAssetAspectRatio} from '@app/features/expressions/utils/AssetImageGeometry';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {SIDEBAR_WIDTH_DEFAULT} from '@app/features/ui/state/SidebarWidth';
|
||||
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {type MotionValue, useMotionValue, useMotionValueEvent, useTransform} from 'framer-motion';
|
||||
import type React from 'react';
|
||||
import {useCallback, useLayoutEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
const HEADER_FALLBACK_HEIGHT = 56;
|
||||
|
||||
interface GuildBannerMeasurements {
|
||||
readonly containerWidth: number;
|
||||
readonly headerHeight: number;
|
||||
readonly viewportHeight: number;
|
||||
}
|
||||
|
||||
export interface GuildBannerPresentation {
|
||||
readonly headerRef: React.RefCallback<HTMLElement>;
|
||||
readonly hoverRef: React.RefCallback<HTMLElement>;
|
||||
readonly imageUrl: string | null;
|
||||
readonly hasBanner: boolean;
|
||||
readonly isDetached: boolean;
|
||||
readonly aspectRatio: number;
|
||||
readonly bannerHeight: number;
|
||||
readonly collapseDistance: number;
|
||||
readonly collapseEnabled: boolean;
|
||||
readonly centerCrop: boolean;
|
||||
readonly frosted: boolean;
|
||||
readonly collapsed: boolean;
|
||||
readonly clipShift: MotionValue<number>;
|
||||
readonly imageShift: MotionValue<number>;
|
||||
readonly imageOpacity: MotionValue<number>;
|
||||
readonly glassOpacity: MotionValue<number>;
|
||||
readonly scrimOpacity: MotionValue<number>;
|
||||
}
|
||||
|
||||
export function useGuildBannerPresentation({
|
||||
guild,
|
||||
scrollY,
|
||||
}: {
|
||||
guild: Guild;
|
||||
scrollY: MotionValue<number>;
|
||||
}): GuildBannerPresentation {
|
||||
const isMobile = MobileLayout.isMobileLayout();
|
||||
const isDetached = guild.features.has(GuildFeatures.DETACHED_BANNER);
|
||||
const [headerNode, setHeaderNode] = useState<HTMLElement | null>(null);
|
||||
const headerRef = useCallback((node: HTMLElement | null) => {
|
||||
setHeaderNode(node);
|
||||
}, []);
|
||||
const [measurements, setMeasurements] = useState<GuildBannerMeasurements>(() => ({
|
||||
containerWidth: isMobile && typeof window !== 'undefined' ? window.innerWidth : SIDEBAR_WIDTH_DEFAULT,
|
||||
headerHeight: HEADER_FALLBACK_HEIGHT,
|
||||
viewportHeight: typeof window !== 'undefined' ? window.innerHeight : 0,
|
||||
}));
|
||||
useLayoutEffect(() => {
|
||||
if (headerNode == null) return;
|
||||
const measure = () => {
|
||||
setMeasurements((previous) => {
|
||||
const next: GuildBannerMeasurements = {
|
||||
containerWidth: headerNode.offsetWidth || previous.containerWidth,
|
||||
headerHeight: headerNode.offsetHeight || previous.headerHeight,
|
||||
viewportHeight: window.innerHeight,
|
||||
};
|
||||
if (
|
||||
next.containerWidth === previous.containerWidth &&
|
||||
next.headerHeight === previous.headerHeight &&
|
||||
next.viewportHeight === previous.viewportHeight
|
||||
) {
|
||||
return previous;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
measure();
|
||||
window.addEventListener('resize', measure);
|
||||
const visualViewport = window.visualViewport;
|
||||
visualViewport?.addEventListener('resize', measure);
|
||||
const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null;
|
||||
resizeObserver?.observe(headerNode);
|
||||
return () => {
|
||||
window.removeEventListener('resize', measure);
|
||||
visualViewport?.removeEventListener('resize', measure);
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, [headerNode]);
|
||||
const aspectRatio = useMemo(() => {
|
||||
if (!guild.bannerWidth || !guild.bannerHeight) return GUILD_BANNER_DEFAULT_ASPECT_RATIO;
|
||||
return clampWideAssetAspectRatio(guild.bannerWidth / guild.bannerHeight) ?? GUILD_BANNER_DEFAULT_ASPECT_RATIO;
|
||||
}, [guild.bannerHeight, guild.bannerWidth]);
|
||||
const staticBannerUrl = useMemo(
|
||||
() => AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, false) || null,
|
||||
[guild.banner, guild.id],
|
||||
);
|
||||
const hasBanner = staticBannerUrl != null;
|
||||
const showsIntegratedBanner = hasBanner && !isDetached;
|
||||
const geometry = useMemo(() => {
|
||||
if (!showsIntegratedBanner) {
|
||||
return {bannerHeight: measurements.headerHeight, collapseDistance: 0, heightCapped: false};
|
||||
}
|
||||
return resolveGuildBannerGeometry({
|
||||
containerWidth: measurements.containerWidth,
|
||||
viewportHeight: measurements.viewportHeight,
|
||||
headerHeight: measurements.headerHeight,
|
||||
aspectRatio,
|
||||
});
|
||||
}, [aspectRatio, measurements, showsIntegratedBanner]);
|
||||
const collapseEnabled = showsIntegratedBanner && GuildHeaderCollapseRollout.collapsesOnScroll;
|
||||
const collapseDistance = collapseEnabled ? geometry.collapseDistance : 0;
|
||||
const progress = useMotionValue(0);
|
||||
const clipShift = useMotionValue(0);
|
||||
const glassOpacity = useMotionValue(0);
|
||||
const imageShift = useTransform(clipShift, (value) => -value);
|
||||
const imageOpacity = useTransform(progress, [0, 1], [1, 0]);
|
||||
const scrimOpacity = useTransform(glassOpacity, (value) => 1 - value / GUILD_BANNER_HEADER_GLASS_MAX_OPACITY);
|
||||
const [frosted, setFrosted] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const frostedRef = useRef(false);
|
||||
const collapsedRef = useRef(false);
|
||||
const syncToScroll = useCallback(() => {
|
||||
const scrollTop = scrollY.get();
|
||||
const nextProgress = resolveGuildBannerCollapseProgress(scrollTop, collapseDistance);
|
||||
progress.set(nextProgress);
|
||||
clipShift.set(-nextProgress * collapseDistance);
|
||||
glassOpacity.set(collapseDistance > 0 ? resolveGuildBannerHeaderGlassOpacity(scrollTop) : 0);
|
||||
const nextFrosted = collapseDistance > 0 && isGuildBannerHeaderFrosted(scrollTop);
|
||||
if (frostedRef.current !== nextFrosted) {
|
||||
frostedRef.current = nextFrosted;
|
||||
setFrosted(nextFrosted);
|
||||
}
|
||||
const nextCollapsed = collapseDistance > 0 && nextProgress >= 1;
|
||||
if (collapsedRef.current !== nextCollapsed) {
|
||||
collapsedRef.current = nextCollapsed;
|
||||
setCollapsed(nextCollapsed);
|
||||
}
|
||||
}, [clipShift, collapseDistance, glassOpacity, progress, scrollY]);
|
||||
useMotionValueEvent(scrollY, 'change', syncToScroll);
|
||||
useLayoutEffect(() => {
|
||||
syncToScroll();
|
||||
}, [syncToScroll]);
|
||||
const animatedBannerUrl = useMemo(() => {
|
||||
if (!showsIntegratedBanner || collapsed) return null;
|
||||
return AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, true) || null;
|
||||
}, [collapsed, guild.banner, guild.id, showsIntegratedBanner]);
|
||||
const {hoverRef, imageUrl} = useAnimatedImageUrl({
|
||||
staticUrl: showsIntegratedBanner ? staticBannerUrl : null,
|
||||
animatedUrl: animatedBannerUrl,
|
||||
kind: 'gif',
|
||||
});
|
||||
return {
|
||||
headerRef,
|
||||
hoverRef,
|
||||
imageUrl,
|
||||
hasBanner,
|
||||
isDetached,
|
||||
aspectRatio,
|
||||
bannerHeight: geometry.bannerHeight,
|
||||
collapseDistance,
|
||||
collapseEnabled,
|
||||
centerCrop: isMobile && geometry.heightCapped,
|
||||
frosted,
|
||||
collapsed,
|
||||
clipShift,
|
||||
imageShift,
|
||||
imageOpacity,
|
||||
glassOpacity,
|
||||
scrimOpacity,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
type ExperimentAssignmentsResponse,
|
||||
INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {
|
||||
type GuildHeaderCollapseAssignmentResponse,
|
||||
INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
|
||||
import {runInAction} from 'mobx';
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@app/features/platform/utils/AppLogger', () => ({
|
||||
Logger: class {
|
||||
debug = vi.fn();
|
||||
info = vi.fn();
|
||||
warn = vi.fn();
|
||||
error = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/platform/transport/RestTransport', () => ({
|
||||
http: {get: vi.fn(), post: vi.fn()},
|
||||
}));
|
||||
|
||||
const {ExperimentAssignments} = await import('@app/features/experiment/state/ExperimentAssignments');
|
||||
const {GuildHeaderCollapseRollout} = await import('@app/features/app/state/GuildHeaderCollapseRollout');
|
||||
|
||||
const TARGETED_ASSIGNMENT: GuildHeaderCollapseAssignmentResponse = {
|
||||
enabled: true,
|
||||
config_version: 3,
|
||||
user_targeted: true,
|
||||
source: 'canary',
|
||||
};
|
||||
|
||||
function publish(assignment: GuildHeaderCollapseAssignmentResponse | undefined): void {
|
||||
const response: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: 300,
|
||||
poll_jitter_percent: 15,
|
||||
assignments: assignment === undefined ? {} : {guild_header_collapse: assignment},
|
||||
};
|
||||
runInAction(() => {
|
||||
ExperimentAssignments.response = response;
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
runInAction(() => {
|
||||
ExperimentAssignments.response = INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE;
|
||||
});
|
||||
});
|
||||
|
||||
describe('GuildHeaderCollapseRollout', () => {
|
||||
it('reads the inert assignment before any response has landed', () => {
|
||||
expect(GuildHeaderCollapseRollout.assignment).toEqual(INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT);
|
||||
expect(GuildHeaderCollapseRollout.collapsesOnScroll).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a missing experiment key as off', () => {
|
||||
publish(undefined);
|
||||
|
||||
expect(GuildHeaderCollapseRollout.assignment).toEqual(INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT);
|
||||
expect(GuildHeaderCollapseRollout.collapsesOnScroll).toBe(false);
|
||||
});
|
||||
|
||||
it('collapses on scroll for a targeted account', () => {
|
||||
publish(TARGETED_ASSIGNMENT);
|
||||
|
||||
expect(GuildHeaderCollapseRollout.assignment).toEqual(TARGETED_ASSIGNMENT);
|
||||
expect(GuildHeaderCollapseRollout.collapsesOnScroll).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the old header for an account outside the rollout', () => {
|
||||
publish({...TARGETED_ASSIGNMENT, user_targeted: false, source: null});
|
||||
|
||||
expect(GuildHeaderCollapseRollout.collapsesOnScroll).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the old header while the rollout is disabled instance-wide', () => {
|
||||
publish({...TARGETED_ASSIGNMENT, enabled: false});
|
||||
|
||||
expect(GuildHeaderCollapseRollout.collapsesOnScroll).toBe(false);
|
||||
});
|
||||
|
||||
it('follows a later poll that turns the rollout off', () => {
|
||||
publish(TARGETED_ASSIGNMENT);
|
||||
expect(GuildHeaderCollapseRollout.collapsesOnScroll).toBe(true);
|
||||
|
||||
publish({...TARGETED_ASSIGNMENT, enabled: false, user_targeted: false, source: null});
|
||||
|
||||
expect(GuildHeaderCollapseRollout.collapsesOnScroll).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
|
||||
import {readGuildHeaderCollapseAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {
|
||||
type GuildHeaderCollapseAssignmentResponse,
|
||||
isGuildHeaderCollapseTargeted,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
|
||||
|
||||
class GuildHeaderCollapseRolloutSelector {
|
||||
get assignment(): GuildHeaderCollapseAssignmentResponse {
|
||||
return readGuildHeaderCollapseAssignment(ExperimentAssignments.response);
|
||||
}
|
||||
|
||||
get collapsesOnScroll(): boolean {
|
||||
return isGuildHeaderCollapseTargeted(this.assignment);
|
||||
}
|
||||
}
|
||||
|
||||
export const GuildHeaderCollapseRollout = new GuildHeaderCollapseRolloutSelector();
|
||||
|
||||
export default GuildHeaderCollapseRollout;
|
||||
@@ -41,6 +41,7 @@ Missing settings use the defaults documented below. Invalid stored configuration
|
||||
| message_keyboard_focus | [message keyboard focus configuration](#message-keyboard-focus-configuration-object) object | Message list keyboard navigation rollout |
|
||||
| blocked_message_groups | [blocked message groups configuration](#blocked-message-groups-configuration-object) object | Blocked message block rendering rollout |
|
||||
| expression_info_card | [expression info card configuration](#expression-info-card-configuration-object) object | Emoji and sticker info card rollout |
|
||||
| guild_header_collapse | [guild header collapse configuration](#guild-header-collapse-configuration-object) object | Collapsing guild header rollout |
|
||||
| registration | [registration configuration](#registration-configuration-object) object | Registration policy, issued URLs, and pending registrations |
|
||||
| self_hosted | boolean | Whether the deployment runs in self-hosted mode |
|
||||
| app_public | [public application configuration](#public-application-configuration-object) object | Branding, legal, setup, and registration field policy |
|
||||
@@ -246,6 +247,27 @@ Every field is present on read. An absent document or missing field uses the def
|
||||
|
||||
How often a client revalidates this rollout is set once for every experiment in the [experiment delivery configuration](#experiment-delivery-configuration-object) above.
|
||||
|
||||
## Guild header collapse configuration object
|
||||
|
||||
The instance rollout of how a client draws the guild banner above the channel list. [Experiments](/http-api/experiments/) defines what a client resolves from it.
|
||||
|
||||
### Structure
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| enabled | boolean | Whether the rollout runs at all (default false) |
|
||||
| config_version | integer | Revision counter, raised by Fluxer and never accepted from a request |
|
||||
| rollout_basis_points | integer | Share of accounts drawn, in basis points (0-10000, default 0) |
|
||||
| rollout_salt | string | Salt of the sampling hash (1-64 characters, default `guild-header-collapse-v1`) |
|
||||
| included_user_ids | array[snowflake] | Accounts always drawn, up to 1000 entries (default empty) |
|
||||
| excluded_user_ids | array[snowflake] | Accounts never drawn, up to 1000 entries (default empty) |
|
||||
|
||||
Every field is present on read. An absent document or missing field uses the defaults above.
|
||||
|
||||
`excluded_user_ids` is applied before `included_user_ids`, so an account in both is never drawn. A drawn client reduces the banner to the height of the header as the channel list scrolls down, and a client that is not drawn keeps the fixed banner height it ships with. Neither arm changes any response this API produces.
|
||||
|
||||
How often a client revalidates this rollout is set once for every experiment in the [experiment delivery configuration](#experiment-delivery-configuration-object) above.
|
||||
|
||||
## Registration configuration object
|
||||
|
||||
Registration policy in force, plus every issued registration URL and every account awaiting a decision.
|
||||
@@ -641,6 +663,7 @@ The body has one optional object for each section. Fluxer leaves an absent secti
|
||||
| message_keyboard_focus? | object | Any subset of the [message keyboard focus](#message-keyboard-focus-configuration-object) fields |
|
||||
| blocked_message_groups? | object | Any subset of the [blocked message groups](#blocked-message-groups-configuration-object) fields |
|
||||
| expression_info_card? | object | Any subset of the [expression info card](#expression-info-card-configuration-object) fields |
|
||||
| guild_header_collapse? | object | Any subset of the [guild header collapse](#guild-header-collapse-configuration-object) fields |
|
||||
| registration? | object | `mode` and `admin_registration_urls_enabled` |
|
||||
| app_public?<sup>2</sup> | object | `branding`, `setup`, `legal`, and `registration` sub-objects, each merged field by field |
|
||||
| integrations?<sup>3</sup> | object | `gif`, `youtube`, `captcha`, `email`, and `bluesky` sub-objects, the last of which also has the `keys` array |
|
||||
@@ -659,6 +682,7 @@ The body has one optional object for each section. Fluxer leaves an absent secti
|
||||
`message_keyboard_focus` takes every [message keyboard focus configuration](#message-keyboard-focus-configuration-object) field except `config_version`, each bound as documented there. Fluxer raises that section's own `config_version` by one on each request that supplies at least one of them, independently of the noise suppression revision. A section that is absent, or present with no field set, writes nothing and leaves `config_version` alone.
|
||||
`blocked_message_groups` takes every [blocked message groups configuration](#blocked-message-groups-configuration-object) field except `config_version`, each bound as documented there. Fluxer raises that section's own `config_version` by one on each request that supplies at least one of them, independently of the noise suppression revision. A section that is absent, or present with no field set, writes nothing and leaves `config_version` alone.
|
||||
`expression_info_card` takes every [expression info card configuration](#expression-info-card-configuration-object) field except `config_version`, each bound as documented there. Fluxer raises that section's own `config_version` by one on each request that supplies at least one of them, independently of the noise suppression revision. A section that is absent, or present with no field set, writes nothing and leaves `config_version` alone.
|
||||
`guild_header_collapse` takes every [guild header collapse configuration](#guild-header-collapse-configuration-object) field except `config_version`, each bound as documented there. Fluxer raises that section's own `config_version` by one on each request that supplies at least one of them, independently of the noise suppression revision. A section that is absent, or present with no field set, writes nothing and leaves `config_version` alone.
|
||||
|
||||
`experiment_delivery` takes both [experiment delivery configuration](#experiment-delivery-configuration-object) fields, each bound as documented there. It is a section of its own, so a write to it changes no `config_version` and changes no assignment, only the cadence on which clients ask for one.
|
||||
|
||||
@@ -696,7 +720,7 @@ Fluxer skips URL validation while the merged configuration leaves single sign-on
|
||||
| 400 | [error response](/admin-api/#error-response) | A policy transition is refused, returned as `INSTANCE_POLICY_TRANSITION_NOT_ALLOWED` |
|
||||
|
||||
:::caution[Sections are applied one after another]
|
||||
The order is `gateway_rollout`, `voice_noise_suppression`, `guild_activity_log_presentation`, `message_hover_tracking`, `message_keyboard_focus`, `blocked_message_groups`, `expression_info_card`, `experiment_delivery`, `sso`, `registration`, `app_public` branding, legal, and registration fields, `integrations`, `media`, `policy`, and finally `app_public.setup`. A failure part way through leaves the earlier sections written.
|
||||
The order is `gateway_rollout`, `voice_noise_suppression`, `guild_activity_log_presentation`, `message_hover_tracking`, `message_keyboard_focus`, `blocked_message_groups`, `expression_info_card`, `guild_header_collapse`, `experiment_delivery`, `sso`, `registration`, `app_public` branding, legal, and registration fields, `integrations`, `media`, `policy`, and finally `app_public.setup`. A failure part way through leaves the earlier sections written.
|
||||
:::
|
||||
|
||||
### Side effects
|
||||
|
||||
@@ -6,7 +6,7 @@ description: The experiment assignments envelope, the revalidation and polling c
|
||||
|
||||
import RouteHeader from '@/components/RouteHeader.astro';
|
||||
|
||||
An experiment is an instance-wide rollout that an operator configures. For each account, Fluxer works out from that configuration whether the account is in the rollout and which settings the account receives. The single route on this page resolves every experiment the server defines and returns them in one envelope, together with the polling cadence they share. This server defines `voice_noise_suppression`, whose placement protocol [Voice](/voice/) defines, `message_hover_tracking`, which selects one of two client implementations of the message hover state, `message_keyboard_focus`, which selects one of two client implementations of keyboard navigation in the message list, `blocked_message_groups`, which selects how a client renders a revealed block of blocked messages, `guild_activity_log_presentation`, which selects one of two client renderings of the community activity log, and `expression_info_card`, which selects what a client shows for an emoji or a sticker in a message.
|
||||
An experiment is an instance-wide rollout that an operator configures. For each account, Fluxer works out from that configuration whether the account is in the rollout and which settings the account receives. The single route on this page resolves every experiment the server defines and returns them in one envelope, together with the polling cadence they share. This server defines `voice_noise_suppression`, whose placement protocol [Voice](/voice/) defines, `message_hover_tracking`, which selects one of two client implementations of the message hover state, `message_keyboard_focus`, which selects one of two client implementations of keyboard navigation in the message list, `blocked_message_groups`, which selects how a client renders a revealed block of blocked messages, `guild_activity_log_presentation`, which selects one of two client renderings of the community activity log, `expression_info_card`, which selects what a client shows for an emoji or a sticker in a message, and `guild_header_collapse`, which selects how a client draws the guild banner above the channel list.
|
||||
|
||||
Every assignment is advice. A client that ignores one behaves as it does with the rollout off, and no route and no Gateway event reports what a client actually ran.
|
||||
|
||||
@@ -38,10 +38,11 @@ One entry per experiment. The envelope reports this object even when it is empty
|
||||
| blocked_message_groups? | [blocked message groups assignment](#blocked-message-groups-assignment-object) object | The caller's blocked message groups assignment |
|
||||
| guild_activity_log_presentation? | [guild activity log presentation assignment](#guild-activity-log-presentation-assignment-object) object | The caller's guild activity log presentation assignment |
|
||||
| expression_info_card? | [expression info card assignment](#expression-info-card-assignment-object) object | The caller's expression info card assignment |
|
||||
| guild_header_collapse? | [guild header collapse assignment](#guild-header-collapse-assignment-object) object | The caller's guild header collapse assignment |
|
||||
|
||||
Ignore unknown experiments and treat a missing experiment as off.
|
||||
|
||||
This server version writes `voice_noise_suppression`, `message_hover_tracking`, `message_keyboard_focus`, `blocked_message_groups`, `guild_activity_log_presentation`, and `expression_info_card` on every response, including while a rollout is disabled. The disabled value is the first [resolution outcome](#resolution-outcomes) below, which reports `enabled` false and the stored `config_version`, so a client that compares `config_version` with the value from its previous response can see that an operator saved the configuration, even while the rollout stays disabled, and needs no second request for it.
|
||||
This server version writes `voice_noise_suppression`, `message_hover_tracking`, `message_keyboard_focus`, `blocked_message_groups`, `guild_activity_log_presentation`, `expression_info_card`, and `guild_header_collapse` on every response, including while a rollout is disabled. The disabled value is the first [resolution outcome](#resolution-outcomes) below, which reports `enabled` false and the stored `config_version`, so a client that compares `config_version` with the value from its previous response can see that an operator saved the configuration, even while the rollout stays disabled, and needs no second request for it.
|
||||
|
||||
## Noise suppression backends
|
||||
|
||||
@@ -242,6 +243,33 @@ A caller is drawn either by the operator's allowlist, which sets `source` to `us
|
||||
|
||||
`config_version` reports the stored revision in all outcomes, the rollout being off included. A client branches on `user_targeted` alone.
|
||||
|
||||
## Guild header collapse assignment object
|
||||
|
||||
One resolution of the instance guild header collapse rollout against one account. Every field is present whenever the key is written.
|
||||
|
||||
The rollout selects how a client draws the guild banner above the channel list. A drawn client reduces that banner to the height of the header as the channel list scrolls down and returns it to full height as the list scrolls back up. A client that is not drawn keeps the fixed banner height it ships with. A guild without a banner has the same header in either arm. Neither arm changes what the API returns, and no route reports which arm a client ran.
|
||||
|
||||
### Structure
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| enabled | boolean | Whether the rollout is running on this instance |
|
||||
| config_version | integer | The revision of the instance configuration this assignment was resolved from |
|
||||
| user_targeted | boolean | Whether the caller is inside the rollout |
|
||||
| source | ?string | Which rule targeted the caller, one of `user_rule` or `canary`, and null where the caller is not targeted |
|
||||
|
||||
### Guild header collapse resolution outcomes
|
||||
|
||||
The outcomes below set `user_targeted` to false, and they differ in what else they report.
|
||||
|
||||
1. The rollout is off. `enabled` is false and `source` is null.
|
||||
2. The operator has excluded the caller. `enabled` is true and `source` is null.
|
||||
3. The caller was not drawn. `enabled` is true and `source` is null.
|
||||
|
||||
A caller is drawn either by the operator's allowlist, which sets `source` to `user_rule`, or by the sampled share of the account population, which sets `source` to `canary`. The blocklist is read before the allowlist, so an account named in both is not drawn.
|
||||
|
||||
`config_version` reports the stored revision in all outcomes, the rollout being off included. A client branches on `user_targeted` alone.
|
||||
|
||||
## Get experiment assignments
|
||||
|
||||
<RouteHeader method="GET" path="/v1/experiments" bot />
|
||||
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
GuildActivityLogPresentationConfigResponse,
|
||||
GuildActivityLogPresentationConfigUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
|
||||
import {
|
||||
GuildHeaderCollapseConfigResponse,
|
||||
GuildHeaderCollapseConfigUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
|
||||
import {
|
||||
MessageHoverTrackingConfigResponse,
|
||||
MessageHoverTrackingConfigUpdateRequest,
|
||||
@@ -655,6 +659,7 @@ export const InstanceConfigResponse = z.object({
|
||||
message_keyboard_focus: MessageKeyboardFocusConfigResponse,
|
||||
blocked_message_groups: BlockedMessageGroupsConfigResponse,
|
||||
expression_info_card: ExpressionInfoCardConfigResponse,
|
||||
guild_header_collapse: GuildHeaderCollapseConfigResponse,
|
||||
registration: InstanceRegistrationResponse,
|
||||
self_hosted: z.boolean(),
|
||||
app_public: AppPublicConfigResponse,
|
||||
@@ -696,6 +701,7 @@ export const InstanceConfigUpdateRequest = z.object({
|
||||
message_keyboard_focus: MessageKeyboardFocusConfigUpdateRequest.nullish(),
|
||||
blocked_message_groups: BlockedMessageGroupsConfigUpdateRequest.nullish(),
|
||||
expression_info_card: ExpressionInfoCardConfigUpdateRequest.nullish(),
|
||||
guild_header_collapse: GuildHeaderCollapseConfigUpdateRequest.nullish(),
|
||||
registration: z
|
||||
.object({
|
||||
mode: InstanceRegistrationModeSchema.optional(),
|
||||
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
GuildActivityLogPresentationAssignmentResponse,
|
||||
INERT_GUILD_ACTIVITY_LOG_PRESENTATION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
|
||||
import {
|
||||
GuildHeaderCollapseAssignmentResponse,
|
||||
INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
|
||||
import {
|
||||
INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT,
|
||||
MessageHoverTrackingAssignmentResponse,
|
||||
@@ -67,6 +71,7 @@ const ExperimentAssignmentsSchema = z.object({
|
||||
blocked_message_groups: BlockedMessageGroupsAssignmentResponse.optional(),
|
||||
guild_activity_log_presentation: GuildActivityLogPresentationAssignmentResponse.optional(),
|
||||
expression_info_card: ExpressionInfoCardAssignmentResponse.optional(),
|
||||
guild_header_collapse: GuildHeaderCollapseAssignmentResponse.optional(),
|
||||
});
|
||||
|
||||
export const ExperimentAssignmentsResponse = z.object({
|
||||
@@ -118,3 +123,9 @@ export function readExpressionInfoCardAssignment(
|
||||
): ExpressionInfoCardAssignmentResponse {
|
||||
return response.assignments.expression_info_card ?? INERT_EXPRESSION_INFO_CARD_ASSIGNMENT;
|
||||
}
|
||||
|
||||
export function readGuildHeaderCollapseAssignment(
|
||||
response: ExperimentAssignmentsResponse,
|
||||
): GuildHeaderCollapseAssignmentResponse {
|
||||
return response.assignments.guild_header_collapse ?? INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createClientBehaviorExperiment} from '@fluxer/schema/src/domains/experiment/ClientBehaviorExperiment';
|
||||
import type {z} from 'zod';
|
||||
|
||||
const guildHeaderCollapse = createClientBehaviorExperiment({defaultRolloutSalt: 'guild-header-collapse-v1'});
|
||||
|
||||
export const GuildHeaderCollapseConfigSchema = guildHeaderCollapse.ConfigSchema;
|
||||
|
||||
export type GuildHeaderCollapseConfig = z.infer<typeof GuildHeaderCollapseConfigSchema>;
|
||||
|
||||
export const DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG: GuildHeaderCollapseConfig = guildHeaderCollapse.DEFAULT_CONFIG;
|
||||
|
||||
export const GuildHeaderCollapseConfigUpdateRequest = guildHeaderCollapse.ConfigUpdateRequest;
|
||||
|
||||
export type GuildHeaderCollapseConfigUpdateRequest = z.infer<typeof GuildHeaderCollapseConfigUpdateRequest>;
|
||||
|
||||
export const GuildHeaderCollapseConfigResponse = guildHeaderCollapse.ConfigSchema;
|
||||
|
||||
export type GuildHeaderCollapseConfigResponse = GuildHeaderCollapseConfig;
|
||||
|
||||
export const GuildHeaderCollapseAssignmentResponse = guildHeaderCollapse.AssignmentResponse;
|
||||
|
||||
export type GuildHeaderCollapseAssignmentResponse = z.infer<typeof GuildHeaderCollapseAssignmentResponse>;
|
||||
|
||||
export const INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT: GuildHeaderCollapseAssignmentResponse =
|
||||
guildHeaderCollapse.INERT_ASSIGNMENT;
|
||||
|
||||
export function resolveGuildHeaderCollapseAssignment(
|
||||
config: GuildHeaderCollapseConfig,
|
||||
userId: string,
|
||||
): GuildHeaderCollapseAssignmentResponse {
|
||||
return guildHeaderCollapse.resolveAssignment(config, userId);
|
||||
}
|
||||
|
||||
export function isGuildHeaderCollapseTargeted(assignment: GuildHeaderCollapseAssignmentResponse): boolean {
|
||||
return assignment.enabled && assignment.user_targeted;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createClientBehaviorExperiment} from '@fluxer/schema/src/domains/experiment/ClientBehaviorExperiment';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const TARGETED_USER_CAP = 1000;
|
||||
|
||||
function userIds(count: number): Array<string> {
|
||||
return Array.from({length: count}, (_, index) => String(index + 1));
|
||||
}
|
||||
|
||||
const rollout = createClientBehaviorExperiment({defaultRolloutSalt: 'client-behaviour-cap-test'});
|
||||
|
||||
describe('createClientBehaviorExperiment targeted user lists', () => {
|
||||
it('accepts an included list at the thousand entry cap', () => {
|
||||
const config = rollout.ConfigSchema.parse({included_user_ids: userIds(TARGETED_USER_CAP)});
|
||||
|
||||
expect(config.included_user_ids).toHaveLength(TARGETED_USER_CAP);
|
||||
});
|
||||
|
||||
it('rejects an included list above the thousand entry cap', () => {
|
||||
expect(() => rollout.ConfigSchema.parse({included_user_ids: userIds(TARGETED_USER_CAP + 1)})).toThrow();
|
||||
});
|
||||
|
||||
it('accepts an excluded list at the thousand entry cap', () => {
|
||||
const config = rollout.ConfigSchema.parse({excluded_user_ids: userIds(TARGETED_USER_CAP)});
|
||||
|
||||
expect(config.excluded_user_ids).toHaveLength(TARGETED_USER_CAP);
|
||||
});
|
||||
|
||||
it('rejects an excluded list above the thousand entry cap', () => {
|
||||
expect(() => rollout.ConfigSchema.parse({excluded_user_ids: userIds(TARGETED_USER_CAP + 1)})).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
import {
|
||||
DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG,
|
||||
type GuildHeaderCollapseConfig,
|
||||
GuildHeaderCollapseConfigSchema,
|
||||
INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT,
|
||||
isGuildHeaderCollapseTargeted,
|
||||
resolveGuildHeaderCollapseAssignment,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const USER_ID = '1485064866382176262';
|
||||
const OTHER_USER_ID = '1485064866382176263';
|
||||
|
||||
function config(overrides: Partial<GuildHeaderCollapseConfig> = {}): GuildHeaderCollapseConfig {
|
||||
return {...DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG, ...overrides};
|
||||
}
|
||||
|
||||
describe('GuildHeaderCollapseConfigSchema', () => {
|
||||
it('defaults to a disabled rollout that targets nobody', () => {
|
||||
expect(DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG).toEqual({
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: 'guild-header-collapse-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a rollout share above the bucket resolution', () => {
|
||||
expect(() => GuildHeaderCollapseConfigSchema.parse({rollout_basis_points: 10001})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects a target that is not a snowflake', () => {
|
||||
expect(() => GuildHeaderCollapseConfigSchema.parse({included_user_ids: ['not-a-snowflake']})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGuildHeaderCollapseAssignment', () => {
|
||||
it('reports the inert assignment with the stored revision while the rollout is off', () => {
|
||||
const assignment = resolveGuildHeaderCollapseAssignment(
|
||||
config({enabled: false, config_version: 4, rollout_basis_points: 10000}),
|
||||
USER_ID,
|
||||
);
|
||||
|
||||
expect(assignment).toEqual({...INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT, config_version: 4});
|
||||
expect(isGuildHeaderCollapseTargeted(assignment)).toBe(false);
|
||||
});
|
||||
|
||||
it('targets every account at the full rollout share', () => {
|
||||
const assignment = resolveGuildHeaderCollapseAssignment(
|
||||
config({enabled: true, config_version: 2, rollout_basis_points: 10000}),
|
||||
USER_ID,
|
||||
);
|
||||
|
||||
expect(assignment).toEqual({enabled: true, config_version: 2, user_targeted: true, source: 'canary'});
|
||||
expect(isGuildHeaderCollapseTargeted(assignment)).toBe(true);
|
||||
});
|
||||
|
||||
it('targets no account at a zero rollout share', () => {
|
||||
const assignment = resolveGuildHeaderCollapseAssignment(config({enabled: true}), USER_ID);
|
||||
|
||||
expect(assignment).toEqual({enabled: true, config_version: 0, user_targeted: false, source: null});
|
||||
expect(isGuildHeaderCollapseTargeted(assignment)).toBe(false);
|
||||
});
|
||||
|
||||
it('targets an allowlisted account outside the sampled share', () => {
|
||||
const assignment = resolveGuildHeaderCollapseAssignment(
|
||||
config({enabled: true, included_user_ids: [USER_ID]}),
|
||||
USER_ID,
|
||||
);
|
||||
|
||||
expect(assignment).toEqual({enabled: true, config_version: 0, user_targeted: true, source: 'user_rule'});
|
||||
});
|
||||
|
||||
it('excludes an account even when it is allowlisted and inside the share', () => {
|
||||
const assignment = resolveGuildHeaderCollapseAssignment(
|
||||
config({
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
included_user_ids: [USER_ID],
|
||||
excluded_user_ids: [USER_ID],
|
||||
}),
|
||||
USER_ID,
|
||||
);
|
||||
|
||||
expect(assignment).toEqual({enabled: true, config_version: 0, user_targeted: false, source: null});
|
||||
});
|
||||
|
||||
it('resolves the same account the same way on every call', () => {
|
||||
const rollout = config({enabled: true, rollout_basis_points: 5000});
|
||||
const first = resolveGuildHeaderCollapseAssignment(rollout, USER_ID);
|
||||
const second = resolveGuildHeaderCollapseAssignment(rollout, USER_ID);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
});
|
||||
|
||||
it('draws a caller inside the share and not one exactly at the boundary', () => {
|
||||
const bucket = experimentBucket(USER_ID, DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG.rollout_salt);
|
||||
const inside = resolveGuildHeaderCollapseAssignment(
|
||||
config({enabled: true, rollout_basis_points: bucket + 1}),
|
||||
USER_ID,
|
||||
);
|
||||
const atBoundary = resolveGuildHeaderCollapseAssignment(
|
||||
config({enabled: true, rollout_basis_points: bucket}),
|
||||
USER_ID,
|
||||
);
|
||||
|
||||
expect([inside.user_targeted, inside.source]).toEqual([true, 'canary']);
|
||||
expect([atBoundary.user_targeted, atBoundary.source]).toEqual([false, null]);
|
||||
});
|
||||
|
||||
it('excludes only the named account', () => {
|
||||
const rollout = config({enabled: true, rollout_basis_points: 10000, excluded_user_ids: [USER_ID]});
|
||||
|
||||
expect(resolveGuildHeaderCollapseAssignment(rollout, USER_ID).user_targeted).toBe(false);
|
||||
expect(resolveGuildHeaderCollapseAssignment(rollout, OTHER_USER_ID).user_targeted).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user